/
Теги: языки программирования программирование
ISBN: 978-5-4461-4390-0
Текст
100 C++ Mistakes
and How to Avoid Them
R ICH YONTS
100 ошибок C++
и как их избежать
Р ИЧ ЙОНТС
2026
Рич Йонтс
100 ошибок C++ и как их избежать
Серия «Библиотека программиста»
Перевела с английского Н. Григорьева
Научный редактор Е. Войнов
ББК 32.973.2-018.1
УДК 004.43
Йонтс Рич
И75 100 ошибок C++ и как их избежать. — СПб.: Питер, 2026. — 416 с.: ил. — (Серия «Библиотека программиста»).
ISBN 978-5-4461-4390-0
Более десяти миллиардов строк кода C++ в настоящее время используется в реальных проектах,
и 98 % разработчиков ежедневно находят и исправляют в них ошибки. Даже в критически важных приложениях встречаются баги, проблемы с производительностью и читаемостью. Эта книга поможет выявить
их в коде, который вы поддерживаете, и избежать — в коде, который вы пишете.
Познакомьтесь с практическими методами улучшения кода на языке C++: от устаревших приложений
до современных проектов, использующих C++11 и более поздние версии. Автор Рич Йонтс приводит
конкретные примеры для иллюстрации каждой проблемы и пошаговые инструкции по улучшению
читаемости, эффективности и производительности. Узнайте также, как заменить устаревшие шаблоны
и идиомы C++ современными.
16+ (В соответствии с Федеральным законом от 29 декабря 2010 г. № 436-ФЗ.)
ISBN 978-1633436893 англ.
ISBN 978-5-4461-4390-0
Authorized translation of the English edition © 2025 Manning Publications.
This translation is published and sold by permission of Manning Publications,
the owner of all rights to publish and sell the same.
© Перевод на русский язык ООО «Прогресс книга», 2026
© Издание на русском языке, оформление ООО «Прогресс книга», 2026
© Серия «Библиотека программиста», 2026
Права на издание получены по соглашению с Manning Publications. Все права защищены. Никакая часть
данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения
владельцев авторских прав.
Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может
гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные ошибки, связанные с использованием книги. В книге возможны упоминания организаций, деятельность
которых запрещена на территории Российской Федерации, таких как Meta Platforms Inc., Facebook, Instagram
и др. Издательство не несет ответственности за доступность материалов, ссылки на которые вы можете найти
в этой книге. На момент подготовки книги к изданию все ссылки на интернет-ресурсы были действующими.
Изготовлено в России. Изготовитель: ООО «Прогресс книга». Место нахождения и фактический адрес:
194044, Россия, г. Санкт-Петербург, Б. Сампсониевский пр., д. 29А, пом. 52. Тел.: +78127037373.
Дата изготовления: 06.2026. Наименование: книжная продукция. Срок годности: не ограничен.
Налоговая льгота — общероссийский классификатор продукции ОК 034-2014, 58.11.12 — Книги печатные
профессиональные, технические и научные.
Импортер в Беларусь: ООО «ПИТЕР М», 220020, РБ, г. Минск, ул. Тимирязева, д. 121/3, к. 214, тел./факс: 208 80 01.
Подписано в печать 24.04.26. Формат 70×100/16. Бумага офсетная. Усл. п. л. 33,540. Тираж 700. Заказ 0000.
Краткое содержание
Глава 1. C++: с большой силой приходит большая ответственность.................. 25
Часть 1
Современный C++
Глава 2. Современный C++: классы и типы данных................................................. 36
Глава 3. Современный C++: общие принципы программирования.................... 65
Глава 4. Современный C++: дополнительные вопросы........................................... 85
Часть 2
Переходный вариант C++
Глава 5. Идиомы языка C................................................................................................. 116
Глава 6. Лучшее из предыдущих версий C++........................................................... 149
Часть 3
Классический (досовременный) C++
Глава 7. Установление инварианта класса.................................................................. 174
Глава 8. Соблюдение инварианта класса..................................................................... 217
Глава 9. Операции класса................................................................................................. 266
Глава 10. Исключения и ресурсы................................................................................... 316
Глава 11. Функции и написание кода........................................................................... 359
Глава 12. Обобщенные принципы написания кода................................................. 387
Оглавление
От издательства................................................................................................................ 14
О научном редакторе русского издания.......................................................... 14
Предисловие......................................................................................... 15
Благодарности...................................................................................... 17
Об этой книге.......................................................................................................................... 19
Для кого эта книга........................................................................................................... 20
Структура книги.............................................................................................................. 20
О коде в книге................................................................................................................... 21
Форум liveBook................................................................................................................ 22
Об авторе.............................................................................................. 23
Иллюстрация на обложке.................................................................................................. 24
Глава 1. C++: с большой силой приходит большая ответственность.................. 25
1.1. Ошибки........................................................................................................................ 26
1.1.1. Простой пример............................................................................................ 26
1.2. Анатомия ошибки.................................................................................................... 27
1.2.1. Корректность................................................................................................. 28
1.2.2. Читаемость..................................................................................................... 28
Оглавление
7
1.2.3. Эффективность............................................................................................. 29
1.2.4. Производительность................................................................................... 29
1.3. Учимся на своих ошибках..................................................................................... 29
1.3.1. Как распознать ошибку.............................................................................. 30
1.3.2. Суть ошибок................................................................................................... 30
1.3.3. Исправление ошибок.................................................................................. 30
1.3.4. Учимся на ошибках..................................................................................... 31
1.4. Где мы находим ошибки........................................................................................ 31
1.4.1. Проектирование классов........................................................................... 31
1.4.2. Реализация программных решений....................................................... 32
1.4.3. Проблемы библиотек.................................................................................. 32
1.4.4. Современный C++....................................................................................... 32
1.4.5. Старые стандарты и способы их применения.................................... 32
1.4.6. Утраченный опыт и искаженное обучение.......................................... 33
1.5. Несколько слов о структуре книги.................................................................... 33
Итоги.................................................................................................................................... 34
Часть 1
Современный C++
Глава 2. Современный C++: классы и типы данных................................................. 36
2.1. Ошибка #1: неумение правильно использовать семантику
перемещения............................................................................................................. 38
2.2. Ошибка #2: использование пустых спецификаций исключений.......... 41
2.3. Ошибка #3: неиспользование ключевого слова override
в производных виртуальных функциях.......................................................... 44
2.4. Ошибка #4: стремление к простоте, или Сокрытие нежелательных
членов класса, предоставляемых компилятором......................................... 47
2.5. Ошибка #5: неиспользование инициализации членов класса
при объявлении ...................................................................................................... 49
2.6. Ошибка #6: чрезмерное использование циклов на основе индексов... 51
2.7. Ошибка #7: отказ от использования ключевого слова nullptr................ 54
8
Оглавление
2.8. Ошибка #8: неиспользование умного указателя unique_ptr
для эксклюзивного владения.............................................................................. 56
2.9. Ошибка #9: неиспользование умного указателя shared_ptr
для совместного владения.................................................................................... 59
Глава 3. Современный C++: общие принципы программирования.................... 65
3.1. Ошибка #10: неумение использовать if с инициализацией..................... 66
3.2. Ошибка #11: неиспользование вывода типа для переменных................ 68
3.3. Ошибка #12: использование typedef................................................................ 71
3.4. Ошибка #13: написание распространенных алгоритмов.......................... 74
3.5. Ошибка #14: неиспользование универсальной инициализации........... 76
3.6. Ошибка #15: неиспользование emplacement-операций............................ 77
3.7. Ошибка #16: неверное использование кортежей........................................ 80
3.8. Ошибка #17: неиспользование структурной привязки............................. 83
Глава 4. Современный C++: дополнительные вопросы........................................... 85
4.1. Ошибка #18: неиспользование вариативных шаблонов........................... 86
4.2. Ошибка #19: использование перечислений enum глобального
пространства имен.................................................................................................. 89
4.3. Ошибка #20: неиспользование новых функций форматирования....... 93
4.4. Ошибка #21: неиспользование диапазонов вместе с контейнерами..... 96
4.5. Ошибка #22: написание непереносимого кода для работы
с файловой системой............................................................................................ 100
4.6. Ошибка #23: написание избыточного количества отдельных
функций.................................................................................................................... 104
4.7. Ошибка #24: использование неудобных констант.................................... 109
4.8. Ошибка #25: написание кода для сопоставления с паттерном............. 111
Часть 2
Переходный вариант C++
Глава 5. Идиомы языка C................................................................................................. 116
5.1. Ошибка #26: всегда объявлять переменные в начале функции........... 117
Оглавление
9
5.2. Ошибка #27: зависимость от макросов......................................................... 120
5.3. Ошибка #28: неправильное понимание макроса NULL.......................... 124
5.4. Ошибка #29: доступ к файлам на диске с помощью FILE..................... 126
5.5. Ошибка #30: использование целых чисел в качестве булевых
значений................................................................................................................... 129
5.6. Ошибка #31: использование приведения типов в стиле C..................... 131
5.7. Ошибка #32: преобразование текста с помощью функции atoi........... 135
5.8. Ошибка #33: использование строк в стиле C.............................................. 138
5.9. Ошибка #34: вызов функции exit.................................................................... 141
5.10. Ошибка #35: отдавать предпочтение массивам, а не векторам........... 145
Глава 6. Лучшее из предыдущих версий C++........................................................... 149
6.1. Ошибка #36: ввод и вывод с использованием функций scanf
и printf....................................................................................................................... 150
6.2. Ошибка #37: чрезмерное использование endl............................................ 153
6.3. Ошибка #38: динамическое выделение памяти с помощью
функций malloc и free.......................................................................................... 155
6.4. Ошибка #39: использование объединений (union)
для преобразования типов................................................................................. 158
6.5. Ошибка #40: использование varargs для списков с переменным
количеством параметров.................................................................................... 160
6.6. Ошибка #41: неправильный порядок инициализации класса.............. 163
6.7. Ошибка #42: добавление в контейнеры типов, не являющихся
значениями.............................................................................................................. 166
6.8. Ошибка #43: предпочтение индексов итераторам.................................... 169
Часть 3
Классический (досовременный) C++
Глава 7. Установление инварианта класса.................................................................. 174
7.1. Инварианты классов обеспечивают корректное проектирование....... 175
7.1.1. Интерпретация............................................................................................ 175
7.1.2. Набор операций.......................................................................................... 176
10
Оглавление
7.1.3. Диапазон значений.................................................................................... 176
7.1.4. Механизмы использования памяти..................................................... 177
7.1.5. Влияние на производительность.......................................................... 178
7.2. Ошибки в дизайне классов................................................................................ 178
7.2.1. Инвариант класса....................................................................................... 179
7.2.2. Определение инварианта класса.......................................................... 180
7.3. Ошибка #44: несоблюдение инварианта класса......................................... 181
7.4. Ошибка #45: не рассматривать классы как типы данных....................... 186
7.5. Ошибка #46: не создавать базис для методов............................................. 192
7.6. Ошибка #47: неспособность реализовать «большую тройку».............. 196
7.7. Ошибка #48: применение наследования только для повторного
использования кода.............................................................................................. 201
7.8. Ошибка #49: чрезмерное использование конструкторов
по умолчанию......................................................................................................... 208
7.9. Ошибка #50: несоблюдение отношения «is-a»........................................... 212
Глава 8. Соблюдение инварианта класса..................................................................... 217
8.1. Соблюдение инварианта класса....................................................................... 218
8.2. Ошибка #51: написание несущественных аксессоров............................. 219
8.3. Ошибка #52: предоставление простейших мутаторов............................. 222
8.4. Ошибка #53: чрезмерное использование защищенных
переменных экземпляра..................................................................................... 226
8.5. Ошибка #54: ошибочное понимание семантики operator=
и конструктора копирования............................................................................ 230
8.6. Ошибка #55: непонимание семантики поверхностного
и глубокого копирования................................................................................... 236
8.7. Ошибка #56: неиспользование операторов базового класса................. 240
8.8. Ошибка #57: неиспользование виртуальных деструкторов
в полиморфных базовых классах.................................................................... 244
8.9. Ошибка #58: вызов виртуальных функций в конструкторах
и деструкторах........................................................................................................ 248
Оглавление
11
8.10. Ошибка #59: попытка использовать полиморфные элементы
массива.................................................................................................................... 254
8.11. Ошибка #60: неинициализация всех переменных экземпляра........... 261
Глава 9. Операции класса................................................................................................. 266
9.1. Ошибка #61: неправильное понимание затенения переменных.......... 267
9.2. Ошибка #62: допущение дублирования уникальных объектов........... 273
9.3. Ошибка #63: отсутствие оптимизации возврата значения.................... 278
9.4. Ошибка #64: отсутствие возвращения ссылки из оператора
присваивания копированием............................................................................ 282
9.5. Ошибка #65: пренебрежение обработкой самоприсваивания.............. 285
9.6. Ошибка #66: непонимание различий между префиксной
и постфиксной формами записи...................................................................... 288
9.7. Ошибка #67: вводящие в заблуждение операторы неявного
преобразования...................................................................................................... 293
9.8. Ошибка #68: чрезмерное использование конструкторов неявного
преобразования...................................................................................................... 296
9.9. Ошибка #69: слишком много внимания автономным операторам..... 299
9.10. Ошибка #70: не помечать неизменяемые методы как константные....302
9.11. Ошибка #71: неправильная пометка методов класса как static.......... 305
9.12. Ошибка #72: некорректный выбор между методами и внешними
функциями............................................................................................................. 309
9.13. Ошибка #73: некорректный возврат строк из аксессоров..................... 313
Глава 10. Исключения и ресурсы................................................................................... 316
10.1. Использование исключений............................................................................ 318
10.1.1. Утверждение: смешение путей управления
и восстановления.................................................................................... 319
10.1.2. Возражение: путаница между обработкой исключений
и обычной обработкой ошибок.......................................................... 320
10.1.3. Возражение: сложность добавления обработки
исключений в существующий код................................................... 320
10.1.4. Утверждение: неоднозначные значения......................................... 321
12
Оглавление
10.1.5. Утверждение: неоднозначные типы данных................................. 321
10.1.6. Утверждение: принудительная обработка ошибок.................... 321
10.1.7. Возражение: предоставление обработчиков
восстановления....................................................................................... 322
10.1.8. Утверждение: преобразование типов сбоев.................................. 322
10.1.9. Утверждение: разделение задач......................................................... 323
10.1.10. Возражение: побуждение к предварительному
проектированию..................................................................................... 323
10.2. Ошибка #74: отсутствие генерации исключений
в конструкторах.................................................................................................... 323
10.3. Ошибка #75: генерация исключений из деструкторов........................... 327
10.4. Ошибка #76: допущение утечки ресурсов при использовании
исключений............................................................................................................ 331
10.5. Ошибка #77: неиспользование идиомы RAII............................................ 335
10.6. Ошибка #78: использование сырых указателей на ресурсы................. 341
10.7. Ошибка #79: смешение форм операторов new и delete.......................... 347
10.8. Ошибка #80: доверие к спецификациям исключений............................ 350
10.9. Ошибка #81: неумение генерировать исключения по значению
и перехватывать их по ссылке......................................................................... 354
Глава 11. Функции и написание кода........................................................................... 359
11.1. Особенности проектирования......................................................................... 360
11.2. Ошибка #82: использование перегруженных функций вместо
параметров по умолчанию................................................................................ 361
11.3. Ошибка #83: неиспользование утверждений............................................ 363
11.4. Ошибка #84: возвращение указателей или ссылок на локальные
объекты.................................................................................................................... 365
11.5. Ошибка #85: использование выходных параметров............................... 368
11.6. Ошибка #86: неправильное использование типов параметров........... 370
11.7. Ошибка #87: зависимость от порядка вычисления параметров......... 373
11.8. Ошибка #88: передача избыточного количества параметров.............. 376
Оглавление
13
11.9. Ошибка #89: слишком длинные функции с несколькими типами
поведения................................................................................................................ 379
11.10. Ошибка #90: функции с чрезмерной ответственностью..................... 382
Глава 12. Обобщенные принципы написания кода................................................. 387
12.1. Ошибка #91: неправильная обработка деления на ноль...................... 388
12.2. Ошибка #92: неправильное использование ключевого
слова continue в циклах.................................................................................... 390
12.3. Ошибка #93: неприсваивание удаленным указателям
значения NULL................................................................................................... 393
12.4. Ошибка #94: неиспользование возврата булевых значений
напрямую после вычисления......................................................................... 395
12.5. Ошибка #95: недостаточное использование выражений..................... 397
12.6. Ошибка #96: использование лишних ключевых слов else................... 399
12.7. Ошибка #97: неиспользование вспомогательных функций................ 401
12.8. Ошибка #98: неправильное сравнение чисел
с плавающей точкой........................................................................................... 404
12.9. Ошибка #99: присваивание целому числу значения
с плавающей точкой........................................................................................... 406
12.10. Ошибка #100: игнорирование предупреждений компилятора......... 411
От издательства
Мы выражаем огромную благодарность клубу рецензентов ИТ-литературы
ReadIT Club за помощь в работе над русскоязычным изданием книги и вклад
в повышение качества переводной литературы.
Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com
(издательство «Питер», компьютерная редакция).
Мы будем рады узнать ваше мнение!
На веб-сайте издательства www.piter.com вы найдете подробную информацию
о наших книгах.
О научном редакторе русского издания
Евгений Войнов — тимлид Java-разработчиков в «Яндекс Маркете». Ранее
работал в «Яндекс Такси», где создавал платформенные решения для повышения стабильности 800+ микросервисов, написанных на С++. Был ментором
разработчиков и будущих руководителей, преподавал в учебных программах
для студентов и школьников. Более 17 лет опыта разработки систем для государственного и коммерческого сектора.
Предисловие
Изучение C++ в основном заключается в описании применения его языковых
возможностей в конкретной среде для решения определенных задач. Преподавание C++ в учебных заведениях отличается от обучения молодого разработчика
в рабочей среде, но язык остается тем же. Представляйте себе C++ как «общий
язык», на котором общаются разработчики на самом низком уровне. Шаблоны
проектирования, общепринятое использование, специфика предметной области
и корпоративные процессы — это уже более высокий уровень коммуникации.
Эти уровни — самые важные. Алан Тьюринг (Alan Turing) продемонстрировал,
что любую вычислимую задачу можно решить более чем на одном компьютере;
различие будет только в используемых подходах и затраченном времени. Точно
так же любую вычислимую задачу, решаемую на C++, можно решить и на любом
другом языке программирования.
Эта мысль никоим образом не является критикой C++ (или любого другого
языка); это просто утверждение, что для компании сам по себе факт использования C++ не определяет направление ее бизнеса и решаемых ею проблем.
Ваше стремление стать опытным разработчиком гораздо важнее, чем знание
конкретного языка программирования.
С учетом всего вышесказанного, зачем же все-таки нужна эта книга о языке
C++? Ранее мы определили, что вы познакомитесь со средой, в которой используется C++, и сможете применить свои знания и умения для решения
задач, важных для вашей компании. Вам предстоит использовать C++ для повседневной работы, и способность распознавать некоторые типичные ошибки
в C++ поможет вам не только свести их к минимуму, но и, что еще важнее, не
повторять их. Необходимость работать с существующей кодовой базой может
ограничивать ваши возможности использовать C++ наиболее выразительным
и правильным образом. Например, во многих проектах для обработки строк до
сих пор используют идиомы языка C, такие как strcat, strstr и strcpy. Если это
общепринятая практика, вы можете захотеть — и быть вынуждены — следовать
16
Предисловие
ей, хотя этих функций следует избегать, отдавая предпочтение современным
средствам C++.
C++ — очень гибкий язык, позволяющий программисту делать все, что возможно
сделать на его компьютере. Многие более современные языки абстрагируются от
значительной части тонкостей и сложностей конкретной машины. Заметными
исключениями являются Go и Rust. C++ открыто заявляет, что предоставляет
самый низкий уровень детализации для управления характеристиками машины.
Такой подход делает его привлекательным языком для работы с низкоуровневыми деталями.
Однако эти гибкость и детализация имеют свою цену. На C++ нельзя программировать с той же свободой, что и на Python или Java. Например, C++ не предоставляет стандартного сборщика мусора для управления памятью и ресурсами.
Разработчик должен сам разбираться с этими элементами. Многочисленные
ошибки, связанные с управлением ресурсами, могут существенно повлиять
на производительность и корректность программы. Здесь уместно привести
цитату из фильма «Человек-паук» (2002), в котором дядя Бен предупреждает:
«С большой силой приходит большая ответственность». Эта цитата должна
всегда ассоциироваться с C++.
Мое знакомство с C++ началось с CFront — транспайлера, который превращал
C++ в C и был создан доктором Бьёрном Страуструпом (Dr. Bjarne Stroustrup).
Я уже изучил и применял C и изо всех сил пытался попасть в проект, в котором
использовался бы C++. Наконец, такие проекты появились в конце 1980-х годов, когда произошла революция в области рабочих станций. Затем, примерно
в 1995 году, появился Java, который внезапно положил конец моему путешествию
в C++. Позже, в начале 2020-х годов, я снова взялся за проекты с использованием C++. Параллельно я преподавал программирование в университете и имел
возможность вести множество занятий по C++.
В обоих случаях я заметил, что слабой стороной является преобладание в изложении классических задач и идиом C++, а современному C++ уделяется
слишком мало внимания; во многих учебниках его новые возможности едва
упоминаются. Поэтому я решил поделиться своими промахами и открытиями
в этой области в форме книги. Я надеюсь, что мне удастся воодушевить студентов
и практикующих специалистов работать с существующим кодом, вооружившись
знаниями о том, как он появился — со всеми его недостатками — и как его можно
усовершенствовать. Если они смогут обойти его подводные камни, то будут намного более продвинутыми, чем мы, когда только начинали свою работу.
Благодарности
В произведении английского поэта Джона Донна (John Donne), написанном
в прозе в 1624 году, говорится, что «ни один человек не является островом». Мы
все взаимосвязаны — так или иначе. Написание книги раскрывает эту взаимозависимость как ничто другое.
Я хочу выразить признательность редакторам и сотрудникам службы поддержки
издательства Manning, особенно Дугу Раддеру (Doug Rudder) и Майклу Стивенсу (Michael Stephens). Эти джентльмены позволили мне создать и воплотить
в жизнь мою концепцию, помогали мне не сбиться с пути, разгребали хаос в моей
работе и были по-настоящему приятными в общении. Несколько других членов
команды так хорошо выполняли свою работу, что на них почти не обращали
внимания. Это не умаляет их вклада, а лишь подчеркивает их профессионализм.
Спасибо вам, незаметные герои.
Мой технический редактор, Мэтт Годболт (Matt Godbolt), оказал мне огромную
поддержку и был очень внимателен. Мэтт Годболт — разработчик на языке C++,
увлеченный поиском баланса между читаемым, легко поддерживаемым кодом
и высокопроизводительными решениями. Его зоркий взгляд помог обнаружить
несколько ошибок, несоответствий и возможностей для улучшения. Все оставшиеся ошибки — моя вина.
Благодарю также всех рецензентов: Эйба Таху (Abe Taha), Абеля Сена (Abel
Sen), Абира Касема (Abir Qasem), Альдо Биондо (Aldo Biondo), Андре Вайнера (Andre Weiner), Дмитрия Нестерука (Dmitri Nesteruk), Дойла Тернера
(Doyle Turner), Фрэнсис Буонтемпо (Frances Buontempo), Франческо Базиле
(Francesco Basile), Ганеша Харке (Ganesh Harke), Грега Вагнера (Greg Wagner),
Ивана Чукича (Ivan Čukić), Цзялина Цзяо (Jialin Jiao), Джона Донохью (John
Donoghue), Джона Вилларосу (John Villarosa), Хуана Хосе Дурилло Баррионуево
(Juan José Durillo Barrionuevo), Хуана Руфеса (Juan Rufes), Кита Кима (Keith
Kim), Леона Пфлетшингера (Leon Pfletschinger), Люка Купку (Luke Kupka),
Маркуса Гезелле (Marcus Geselle), Майкла Уолла (Michael Wall), Нарендру
18
Благодарности
Мерлу (Narendra Merla), Офека Шилона (Ofek Shilon), Петра Ястржебски
(Piotr Jastrzebski), Раджата Канта Гоэля (Rajat Kant Goel), Ричарда Мейнсена
(Richard Meinsen), Роналдо Скарпате (Ronaldo Scarpate), Руда Гейсена (Ruud
Gijsen), Сабоктакина Хаяти (Saboktakin Hayati), Сачина Хандикара (Sachin
Handiekar), Шантану Кумара (Shantanu Kumar), Шона Смита (Shawn Smith),
Симону Сгуацца (Simone Sguazza), Шриниваса Вамси Парасу (Srinivas Vamsi
Parasa), Шрирама Мачарлу (Sriram Macharla), Стэнли Анози (Stanley Anozie),
Стефана Туральски (Stefan Turalski), Тимоти Япа ван Дёрзена (Timothy Jaap van
Deurzen), Троя Эйслера (Troi Eisler) и Уолта Стоунбернера (Walt Stoneburner).
Ваши предложения помогли сделать эту книгу лучше.
Моя семья поддерживала меня на протяжении всего процесса. Моя жена Ким
(Kim) помогала мне не отвлекаться от работы, подбадривая меня словами поддержки. Мои дети всегда были в восторге от того, что их отец пишет книгу, и поддерживали меня, помогая сохранять позитивный настрой в работе (и не только).
Наконец, мои таланты, образование, опыт работы и многое другое были даны
мне милостивым Господом Богом. Такой долг невозможно отплатить; можно
только наслаждаться им, быть благодарным и стараться передать его другим
(Послание Иакова, стих 1: 17).
Об этой книге
C++ — это мультипарадигменный язык программирования, который существует
на протяжении большей части компьютерной эры. Он начинался как усовершенствованный C (то есть язык C с классами) и стал основанным на международных
стандартах языком, который активно развивается. На момент написания этой
книги был опубликован стандарт C++20, стандарт C++23 был представлен для
финального рассмотрения, а стандарт C++26 находился в стадии формирования.
C++ используется в проектах — от самых маленьких до самых крупных — как
начинающими, так и опытными разработчиками и применяется в большинстве
сфер разработки программного обеспечения.
По некоторым оценкам, в настоящее время в промышленных средах работают
десятки миллиардов строк кода на C++. Многочисленные команды и миллионы разработчиков, имеющих различный опыт, писали этот код на протяжении
десятилетий. Каждый разработчик имеет собственное представление о том, как
должны выглядеть правильные модели программирования, что такое хороший
код на C++ и какие методологии разработки следует использовать.
Этот объем кода, созданного разными разработчиками, значительно различается по корректности, читаемости, эффективности и производительности.
Большая часть кода написана в парадигме императивного программирования,
часть — в объектно-ориентированной, и небольшая часть — в функциональной.
Некоторая часть кода была написана новичками, гораздо большая часть — экспертами, хотя и не обязательно экспертами в области проектирования и реализации на C++. Значительная часть этого кода появилась до первой попытки
стандартизации языка в 1998 году. Код, написанный после этого, может соответствовать одному из стандартов, но часто выглядит так, будто был создан до
стандартизации.
В этой книге рассматривается несколько проблем, возникающих при написании программ на C++. Считается, что современный C++ начался со стандарта
20
Об этой книге
C++11. Обширная база установленного на текущий момент досовременного
(или классического) кода C++ делает понимание этих проблем актуальным,
а их решение — критически важным. После прочтения книги программисты
будут лучше подготовлены к выявлению и исправлению обсуждаемых проблем.
Кроме того, даже ошибки, которые мы не будем обсуждать подробно, станет
легче находить и исправлять. Обдумывание предлагаемых задач поможет
любому разработчику лучше понимать некоторые нюансы языка и процесса
разработки.
Для кого эта книга
Независимо от происхождения кода, стандарта, парадигмы и уровня квалификации, кто-то должен его активно поддерживать. Необходимо добавлять новые
функции, изменять или улучшать существующие, исправлять ошибки и повышать эффективность. В кодовой базе ошибки встречаются часто, но программы
работают и дают осмысленные результаты.
Предполагается, что читатель относительно недавно вошел в мир разработки на
C++, вероятно, имея за плечами опыт самообучения, университетское образование или навык работы с другими языками программирования. Большинство
разработчиков редко имеют возможность создавать совершенно новые проекты
с нуля, поэтому вы, скорее всего, будете работать преимущественно с уна
следованным кодом. Ваша задача — разрабатывать новые функции и решать
существующие проблемы в кодовой базе, состоящей из тысяч или миллионов
строк старого кода.
Ваша миссия — научиться писать код на C++ в такой среде. C++ редко разрабатывается в «открытой» среде, где разработчик принимает все решения
самостоятельно. Эта среда отличается от академического обучения: компания
или команда устанавливает различные правила, руководства по стилю, соглашения об именовании и другие параметры. Кроме того, кодовая база определяет
приемлемые архитектурные шаблоны, соглашения об именовании, правила
использования и решения для распространенных задач. Если большая часть
этих обязанностей кажется вам чем угодно, но только не реальным программированием на C++, — ваша интуиция вас не подводит.
Структура книги
Ошибки, которые мы рассмотрим в первой части книги «100 ошибок C++ и как
их избежать», связаны с неправильным использованием языка, когда разработчик
может улучшить код и подходы к программированию, применяя современные
возможности C++ и стандартную библиотеку шаблонов. Глава 2 посвящена
О коде в книге
21
классам и типам данных — с акцентом на проектирование классов и использование умных указателей. В главе 3 познакомимся с подводными камнями программирования в целом, а также с функциями языка, которые, возможно, стоит
применять чаще. Глава 4 описывает недавние изменения в языке C++ и методы,
облегчающие решение довольно распространенных задач.
Во второй части книги обсудим ошибки, унаследованные современным C++ от
языка C, с учетом его исторических корней. Многие решения, рассмотренные
в этой части, можно успешно адаптировать к современным условиям. Глава 5
посвящена привычкам и практикам, которые по инерции переходят из C в C++,
несмотря на наличие более эффективных подходов. Глава 6 описывает устаревшие практики, популярные на заре программирования на C++, которые до сих
пор сохраняются в некоторых современных кодовых базах.
Ошибки, приведенные в третьей части, отражают проблемы, часто встречающиеся в унаследованных кодовых базах, где разработчик не может применить
современные методы. Некоторые из этих проблем можно решить, а качество
кода — повысить. Глава 7 расскажет о правильном проектировании классов
и связанных с этим проблемах устойчивости классов. Глава 8 рассматривает
дополнительные аспекты проектирования классов и ошибки, которые могут
неожиданно привести к архитектурным проблемам. Глава 9 описывает операции
классов, которые при неверной реализации могут вызвать трудности. Глава 10
посвящена обработке системных ресурсов и проблемам, возникающим при неправильном управлении ими. Глава 11 анализирует сложности, возникающие
при использовании функций и параметров. В главе 12 обсудим проблемы,
характерные для классического C++, которые по-прежнему встречаются и в современных кодовых базах.
О коде в книге
Эта книга содержит множество примеров исходного кода — как в нумерованных
листингах, так и в обычном тексте. В обоих случаях исходный код форматируется моноширинным шрифтом, чтобы его было легко отличить от основного текста.
В некоторых примерах используется жирный шрифт, чтобы подчеркнуть новые
или измененные участки — например, когда добавляется функциональность
в уже существующую строку кода.
Во многих случаях оригинальный код переформатируется: добавляются разрывы строк или измененные отступы, чтобы он помещался на странице. В редких
случаях даже этого оказывается недостаточно, и в листингах используются
маркеры продолжения строки (➥). Кроме того, из исходного кода в листингах
часто удаляются комментарии, если их смысл уже объяснялся в тексте. Многие
листинги снабжены аннотациями, выделяющими ключевые моменты.
22
Об этой книге
Исполняемые фрагменты кода можно получить из онлайн-версии этой книги
(liveBook) по адресу https://livebook.manning.com/book/100-c-plus-plus-mistakes-andhow-to-avoid-them. Полный набор исходников доступен на сайте издательства
Manning по адресу https://www.manning.com/books/100-c-plus-plus-mistakes-andhow-to-avoid-them.
Форум liveBook
Приобретая книгу «100 ошибок C++ и как их избежать», вы также получаете бесплатный доступ к платформе для онлайн-чтения liveBook издательства Manning
(на английском языке). Эксклюзивные возможности liveBook позволяют
оставлять комментарии как к книге в целом, так и к отдельным ее разделам или
абзацам. Можно легко делать заметки для себя, задавать технические вопросы
и отвечать на них, а также получать помощь от авторов и других пользователей.
Чтобы получить доступ к форуму, посетите страницу https://livebook.manning.
com/book/100-c-plus-plus-mistakes-and-how-to-avoid-them/discussion. Подробнее
о форумах Manning и правилах поведения на них читайте здесь: https://livebook.
manning.com/discussion.
В рамках своих обязательств перед читателями издательство Manning предоставляет ресурс для содержательного общения читателей и авторов. Эти обязательства не подразумевают конкретную степень участия автора, которое остается добровольным (и неоплачиваемым). Задавайте авторам хорошие вопросы,
чтобы им было интересно участвовать в диалоге! Форум и архивы обсуждений
доступны на сайте Manning, пока книга продолжает издаваться.
Об авторе
Рич Йонц (Rich Yonts) — старший инженер-программист
компании Teradata и опытный разработчик, долгое время
работающий с C++, Java и Python. В течение 10 лет работы
он занимал различные технические и руководящие должности в компании IBM. Рич имеет большой опыт работы
с крупными кодовыми базами и считает себя одновременно
учеником и учителем языка C++.
Иллюстрация на обложке
Иллюстрация на обложке книги «100 ошибок C++ и как их избежать», озаглавленная L’Infirmier («Медсестра»), взята из книги Луи Кёрмера (Louis Curmer),
опубликованной в 1841 году. Каждая иллюстрация в этом издании была тщательно прорисована и раскрашена вручную.
В те времена по одежде человека можно было легко определить, где он живет
и какова его профессия или какое положение он занимает в обществе. Издательство Manning отдает дань изобретательности и инициативности мира
технологий, создавая обложки, вдохновленные культурным разнообразием
регионов прошлых веков, — иллюстрации из старинных коллекций возвращают
это богатство к жизни.
1
C++: с большой силой
приходит большая
ответственность
В этой главе
3 Суть происхождения ошибок при программировании на C++
3 Четыре аналитические характеристики качественного кода
3 Подход к обнаружению и исправлению ошибок программирования
3 Мотивация, которая помогает взяться за непростую задачу
исправления ошибок
Перед каждым разработчиком C++ стоит сложная задача: писать и поддерживать тысячи, а иногда и миллионы строк кода. К счастью, сложное не означает
невыполнимое! Учитесь на собственных ошибках и, что еще важнее, на ошибках
предшественников — и вы сможете справляться практически с любыми трудностями, которые встретятся вам в будущем. Сопровождение проектов на C++ —
это длительный процесс, и вы не освоите язык или большую кодовую базу за
несколько месяцев. Разочарование — постоянная проблема разработчиков на
любом языке: часто оно возникает из-за предыдущих решений, оказавшихся сомнительными или неудачными. Ищите хорошие, умные и элегантные решения
в чужом коде — и вскоре вы научитесь находить такие решения самостоятельно.
В этой книге мы разберем 100 ошибок, которые вы скорее всего обнаружите (или
допустите сами) в современном и унаследованном C++-коде. Мы постарались
26
Глава 1. C++: с большой силой приходит большая ответственность
превратить эти ошибки в практические знания, полезные для создания и поддержки более чистой и эффективной кодовой базы. Понимание причин и способов исправления каждой ошибки поможет вам находить и устранять аналогичные
проблемы в будущем — или, что еще лучше, не допускать их вообще. Давайте
приступим!
1.1. Ошибки
Ошибки — обычное явление в любой кодовой базе, и причины их возникновения могут быть самыми разными. За годы работы C++-разработчики изучили
бесчисленное множество приложений, пытаясь понять природу ошибок. Среди
них — неправильные или устаревшие приемы программирования, несовершенство самого языка, слабый дизайн приложений и неэффективные инструменты. И для всего этого уже были найдены методы решения! Были разработаны
методологии, которые помогают устранить сбои в процессе разработки, а для
компенсации изъянов языка были добавлены новые функции.
Типичные кодовые базы на C++ почти всегда можно улучшить. Код, написанный
год или десять лет назад, использует методы и языковые возможности, которые
были популярны и доступны в то время. С течением времени появились новые
подходы, и несоответствия между стилями и архитектурными решениями стали
очевидны. Этот прогресс в знаниях и инструментах не всегда отражается в существующем коде: технический долг накапливается, а несоответствия множатся.
Эти проблемы необходимо решать, осуществляя поддержку, модификацию,
обновление и отладку кода.
Не все разработчики готовы перепроектировать или переписывать большие
объемы кода. Бизнес-цели редко предполагают улучшение существующего
кода, когда можно просто создать новый код или новые функции. Но технический долг, вызванный старыми практиками и неудачными решениями, влияет
и на новые проекты. Он проявляется в хрупкости (ненадежности) кода — тот
становится трудным для изменения и интеграции. Вместо подхода «или/или»,
при котором старый код либо исправляют, либо игнорируют, разумнее искать
и устранять повторяющиеся ошибки и шаблоны.
1.1.1. Простой пример
В разделе «Ошибка #57» мы рассмотрим широкий спектр предупреждений,
полученных при выполнении полной сборки продукта и анализе результатов.
При изучении вывода компиляции некоторые предупреждения будут появляться
неоднократно. Один из подходов — сосредоточиться на конкретном предупре
ждении компилятора и исправить ошибку во всей или большей части кодовой
1.2. Анатомия ошибки
27
базы. Следующий пример демонстрирует минимальный тестовый сценарий,
в котором предупреждение об адресе -Waddress появляется тремя способами.
Код в листинге 1.1 некорректно пытается:
вызвать функцию без синтаксически необходимых круглых скобок;
проверить возвращаемое значение функции;
сравнить два значения объектов.
Листинг 1.1. Пример кода с предупреждениями компилятора
int cleanup() {
return 0;
}
int main() {
cleanup;
}
Попытка вызова функции без
круглых скобок — это просто
обращение к адресу, и никакой
код не выполняется; исправляется
добавлением круглых скобок
if (!(cleanup)) {
…
}
const char* message = "Hello, world";
if (message == "Hello, world") {
…
}
return 0;
Попытка использовать возвращаемое
значение. Используется адрес функции,
который всегда истинный (ненулевой);
при отрицании он становится всегда
ложным (нулевым), и тело кода никогда
не выполняется; исправляется добавлением
круглых скобок
Сравнение одного константного указателя
с другим константным указателем всегда
ложно (адреса не совпадают); исправляется
использованием функции типа strcmp
или строк C++ для сравнения значений,
на которые они указывают
В кодовой базе, где были обнаружены эти предупреждения, проблемы впоследствии устранили в ходе другой работы. Если выбран такой подход, сообщите
остальным разработчикам и ревьюерам, что вы намерены улучшать код постепенно. Лучше заручиться поддержкой команды: некоторые исправления могут
оказаться нетривиальными, хотя на первый взгляд выглядят простыми. Действуйте с осторожностью и проводите тщательное тестирование. Не пытайтесь
решить слишком много задач одновременно (несмотря на соблазн) — лучше
двигаться в устойчивом темпе, решая управляемое количество проблем.
Другой вариант — устранить несколько ошибок в пределах одного файла. Если вы
уже работаете над модификацией кода, то легко выборочно исправить и другие
проблемы. Как и прежде, следует действовать предусмотрительно.
1.2. Анатомия ошибки
Каждая ошибка, обсуждаемая в этой книге, рассматривается отдельно. В реальных условиях некоторые ошибки влияют на другой код, и такая взаимо
связь усложняет диагностику и решение проблемы. В книге мы сознательно
28
Глава 1. C++: с большой силой приходит большая ответственность
игнорируем эти зависимости, чтобы сосредоточиться на сути конкретных проблем. Это, конечно, создает риск упрощения и неполноты картины, но позволяет
глубже рассмотреть природу отдельных ошибок. В большинстве глав мы будем
обсуждать обобщенную концепцию ошибок, включенных в раздел. Некоторые
ошибки повторяются в разных главах, поскольку затрагивают несколько аспектов
языка. Это свидетельствует о перекрестном характере многих функций языка
C++: функция или ошибка редко ограничивается узким контекстом. Каждая
ошибка анализируется с точки зрения четырех ключевых характеристик, которые
затрагивают разработку и выполнение.
1.2.1. Корректность
Одна из важнейших характеристик кода — корректность, то есть способность
решать поставленную задачу без ошибок. В конце концов, код может быть полной
катастрофой по другим меркам (стиль, эффективность), но, если он корректен,
мы можем с уверенностью сказать, что задача решена. Даже самый элегантный,
академически сложный и выглядящий привлекательно код прежде всего должен
быть правильным. Возьмите себе за правило писать код так, чтобы он точно решал
поставленную задачу и при этом не вызывал побочных эффектов. В реальности
достичь этого удается не всегда, но именно к этому нужно стремиться.
ПРИМЕЧАНИЕ В некоторых случаях некорректный код может привести к неопределенному поведению (undefined behavior, UB). Относитесь к UB как к крайне оскорбительной вульгарности, которую нужно избегать любой ценой (включите здесь тему из
фильма Shaft1). UB — это действия программы, не определенные стандартом языка.
Они могут вызвать непредсказуемые результаты, сбои или уязвимости безопасности.
Компилятор вправе обработать UB любым способом, включая оптимизацию, при
которой соответствующий код полностью удаляется.
1.2.2. Читаемость
Вторая характеристика — читаемость, которая определяет то, насколько ясно
разработчик доносит идеи своего кода до других. У каждого программиста есть
свой стиль, но его следует подкорректировать, если он препятствует пониманию
кода. Старайтесь следовать общепринятым или командным правилам оформления. Практика показывает, что писать код следует так, чтобы он был удобен
другим разработчикам (компилятору это безразлично). Формулируйте свои
мысли таким образом, чтобы любой коллега понял ход ваших мыслей однозначно и быстро, и соблюдайте все обязательные правила стиля. Более опытные
1
Shaft — «Шафт»; культовый американский боевик 1971 года. Фраза используется как
шутка, «нагнетание пафоса». — Примеч. ред.
1.3. Учимся на своих ошибках
29
разработчики стремятся к элегантности, и это естественно, но иногда чрезмерная
«элегантность» делает код трудным для восприятия менее опытными коллегами.
1.2.3. Эффективность
Эффективность отражает то, насколько полно программист использует возможности языка для ускорения и упрощения разработки. В ее основе лежат
компактность выражения и умелое использование определенных функций.
Время, необходимое для решения задачи, напрямую влияет на продуктивность
разработчика, а косвенно — на корректность кода. Общее правило простое:
используйте правильные (корректные) функции и конструкции по прямому
назначению.
1.2.4. Производительность
Наконец, производительность — это выбор наилучшего подхода, алгоритма,
структуры данных или способа решения задачи, обеспечивающих оптимальную
работу программы на конкретном оборудовании. Этот аспект сложнее всего
правильно реализовать, поскольку интуиция не всегда верно подсказывает,
что именно делает код наиболее производительным. Для выявления реальных
узких мест и «горячих точек»1 необходимо использовать инструменты профилирования.
Эта характеристика — наиболее академически строгая из всех четырех перечисленных. Выбор правильного алгоритма обычно является первым шагом
к решению задачи. Для этого требуется определенное понимание принципов
computer science — оценки вычислительных затрат и выбора оптимального
способа решения.
1.3. Учимся на своих ошибках
Любой автор надеется, что он правильно определил потребности аудитории
и восполнил пробелы в существующей литературе. Прочитав эту книгу, вы
сможете распознавать описанные и прочие ошибки, поймете, почему они являются проблемой, узнаете, как их решить. Это поможет вам избегать подобных
ошибок в будущем.
1
Хот-спот (hotspot), или «горячая точка», — участок программы, на который приходится бˆольшая часть выполняемых процессором инструкций или выполнение которого
занимает значительное время (одни инструкции исполняются быстрее, а другие —
медленнее). — Примеч. пер.
30
Глава 1. C++: с большой силой приходит большая ответственность
1.3.1. Как распознать ошибку
Осведомленность — это первый шаг к лучшему программированию. Иногда мои
студенты смотрят на меня в полной растерянности, когда я показываю им примеры. Они стараются понять концепцию, но «прорыв» пока не наступил. Чтобы
облегчить их страдания, я часто говорю, что не жду от них самостоятельного
понимания. Я показываю и объясняю примеры, чтобы привлечь внимание к конкретным проблемам, научить распознавать их, а затем — понять, как их решить.
Мои собственные знания и опыт — результат работы тех, кто учил меня и делился
примерами. Хотя я кое-что знаю и, может быть, кажусь умным, я (как и все) должен постоянно учиться. Распознавание типовых ошибок — важнейший элемент
профессионального роста: как только мы замечаем знакомый паттерн, у нас сразу
появляется инструмент для его решения. Эта книга поможет вам распознавать
и устранять ошибки в существующем коде, а не множить их.
1.3.2. Суть ошибок
Распознать ошибку — это важный шаг; но чтобы понять ее природу, мы должны
разобраться, почему она вообще является ошибкой. Некоторые практики, которые когда-то считались лучшими, сегодня уже устарели. Программирование развивается по научной модели: мы выдвигаем гипотезы, проверяем их, оцениваем
их эффективность. Иногда мы оказываемся правы, а иногда — нет.
Никого не должно удивлять такое развитие событий — это естественный процесс обучения. Совершая ошибки, мы выявляем пробелы в своих знаниях (или
убеждаемся, что их у нас нет). Заполнение этих пробелов затрагивает всё новые
и новые области, расширяя наш кругозор и понимание взаимосвязей между элементами программирования. Я хочу донести до вас, что каждая ошибка может
стать интеллектуальной и эмоциональной возможностью для роста. Настоящие
неудачи случаются только тогда, когда мы сдаемся или отказываемся учиться.
Эта книга поможет вам понять, почему тот или иной подход к написанию кода
является ошибкой.
1.3.3. Исправление ошибок
Исправление ошибок — вот истинная причина появления этой книги. Хотя
устранение проблем очень важно, оно является лишь одной частью более сложного процесса. Без понимания того, почему ошибка является проблемой, мы
становимся механическими устройствами, модифицирующими исходный код
(гипотетически этому можно обучить и обезьяну).
Мы хотим исправлять ошибки, потому что понимаем, в чем заключается их
суть. Такое понимание особенно важно при работе в команде. Мы все совершаем
1.4. Где мы находим ошибки
31
ошибки, но, если удается найти и устранить их до того, как они попадут в продакшен, это принесет пользу разработчикам, пользователям и компании. Эта
книга поможет вам научиться не только самостоятельно исправлять, но и предотвращать ошибки при написании кода.
1.3.4. Учимся на ошибках
Обнаружение, понимание и исправление ошибок необходимо для получения
хорошего кода, но из этого процесса можно извлечь еще больше пользы. По мере
накопления опыта вам будет легче анализировать проблемный программный код.
Распознавание и понимание определенных паттернов помогут выявить и другие
ошибки. Это можно сравнить с тренировками в спортзале, где тело развивают
для различных видов активности, не сосредоточиваясь на чем-то одном.
Знание этих ошибок сделает вас ценным источником информации для других —
как во время разработки, так и на этапе проверки кода. Делясь своими знаниями,
вы помогаете коллегам расти и развивать те же навыки.
Эта книга подготовит вас к тому, чтобы делиться опытом и способствовать развитию других.
1.4. Где мы находим ошибки
Любой опытный разработчик C++ скажет, что существует гораздо больше
100 типов ошибок, которые можно допустить при работе с этим языком. Я выбрал только те, с которыми сталкивался сам, которые изучал или которые оказали на меня влияние. Считайте этот список моим личным «топ-100» — другой
автор, скорее всего, составил бы другой перечень. Ошибки условно разделены
на несколько категорий.
1.4.1. Проектирование классов
C++ был разработан, чтобы привнести объектно-ориентированную парадигму
в популярный язык C. Разработка классов — это процесс определения новых
типов данных, которые компилятор воспринимает и обрабатывает так же, как
встроенные типы. Разработчик полностью отвечает за то, что представляют собой новые классы, как они работают и какой смысл в них заложен.
Компилятор обеспечивает соблюдение синтаксиса и правил языка, но накладывает минимум ограничений на вновь создаваемые типы данных. Такая свобода
становится отличной почвой для ошибок. Чтобы избежать их, разработчикам
просто необходимо понимать и минимизировать эти проблемы, а не полагаться
на ограничения языка.
32
Глава 1. C++: с большой силой приходит большая ответственность
1.4.2. Реализация программных решений
В программировании, как правило, есть несколько областей, где неудачное
проектирование или реализация могут вызвать проблемы. C++ — гибкий язык,
поэтому необходимо осознавать наличие таких зон риска, чтобы предотвращать
ошибки. Некоторые из них напрямую связаны с проектированием классов, но,
поскольку эти проблемы являются более общими, они отнесены к этому разделу.
Корректное применение языковых возможностей имеет решающее значение для
создания качественного программного обеспечения.
1.4.3. Проблемы библиотек
Код отдельного разработчика часто составляет лишь крошечную часть всей
программы. Большинство приложений активно используют библиотеки и их
функции. Кроме того, сам язык обладает инструментами для обобщенного
решения проблем — например, шаблонами, позволяющими описывать целые
семейства решений, оставляя компилятору генерацию конкретного кода.
Библиотечные функции предоставляют обширную функциональность, самостоятельное написание которой было бы утомительным, трудоемким и чреватым
ошибками. Правильное использование этих функций позволяет разработчику
писать более чистый, надежный код, сосредоточившись на прикладных задачах.
Однако неправильное понимание принципов работы библиотек может привести
к серьезным проблемам.
1.4.4. Современный C++
Одной из целей C++ всегда было обеспечение полной обратной совместимости
по мере развития языка. Это позволяет современным компиляторам успешно
собирать унаследованный код. Но совместимость не означает, что старый код
написан хорошо. С развитием языка часть прежних проблем была устранена,
а более удачные решения стали частью стандарта.
Многие нововведения позволяют программисту создавать более корректный,
устойчивый и простой код, выполняющий те же задачи, что и раньше. Часто эти
улучшения затрагивают один или несколько аспектов ошибок: корректность,
читаемость, эффективность и производительность. Цена, которую мы платим за
эти усовершенствования, — необходимость изучать новые возможности языка
и понимать, как они могут положительно влиять на наше мышление и процесс
программирования.
1.4.5. Старые стандарты и способы их применения
Код, написанный до появления новых возможностей языка, очевидно, не сможет
воспользоваться их преимуществами. А код, созданный уже после добавления
1.5. Несколько слов о структуре книги
33
этих возможностей, может просто их не использовать. В результате программа
становится функционально устаревшей, менее выразительной и потенциально более подверженной ошибкам. Например, строки в стиле C++ устраняют
множество проблем, связанных со строками в стиле C. Но чтобы получить эти
преимущества и избежать типичных ошибок, разработчик должен начать применять именно строки C++.
1.4.6. Утраченный опыт и искаженное обучение
Опытных разработчиков всегда будут сменять молодые специалисты. Этим
программистам приходится разбираться со старым кодом — часто сложным, громоздким и неочевидным. Ситуацию усугубляет то, что во многих академических
курсах по-прежнему недостаточно внимания уделяется современному C++. Есть
немало современных учебников по C++, которые все еще содержат устаревшие
примеры и рекомендации, не учитывающие лучшие практики разработки.
Некоторые ошибки возникают из-за применения древних подходов, другие — изза плохого преподавания, а третьи — из-за того, что бывшие когда-то передовыми
методы больше не работают. Независимо от природы этих ошибок, знакомство
с ними и их последствиями, а также с возможными способами их устранения
поможет каждому разработчику C++ создавать корректный, читаемый, эффективный и производительный код.
1.5. Несколько слов о структуре книги
Книга построена в обратном хронологическом порядке. Сначала рассматриваются современные проблемы C++. Затем — переходные темы, затрагивающие
как современные (C++11 и более поздние версии), так и предыдущие стандарты
(C++98 и C++03). Наконец, внимание уделяется более ранним версиям C++,
где многие унаследованные кодовые базы ограничены в использовании современных методологий.
Код в этой книге в основном базируется на версиях, принятых до появления
стандарта C++11 (приятная смесь C++98 и C++03). Для компиляции кода использовался набор инструментов GNU C++ toolset (версия 11.3.0), запущенный
в операционной системе Ubuntu (22.04 LTS под Windows Subsystem for Linux
[WSL] 2). Эта версия компилятора была выбрана за простоту и стабильность
работы на указанной платформе. Более поздние версии уже есть и продолжают
выходить. Любая из них может обнаружить проблемы и недостатки кода, о которых говорится в этой книге. Использование более старых версий компилятора
может привести к иным результатам.
Бизнес-ограничения, вероятно, заставят некоторых читателей собирать код
в режиме C++98, и тем самым они упустят все замечательные возможности
34
Глава 1. C++: с большой силой приходит большая ответственность
современных функций языка. Печально, но факт: значительная часть моей
кодовой базы ограничена C++03, поскольку работа требует стабильности. Заставить команду разработчиков операционной системы и инструментальных
средств проверить более поздние версии оказалось не так просто.
Хотя многие из рассматриваемых далее ошибок встречаются в классическом C++
(до C++11), первые главы посвящены современному C++. Эти темы особенно
полезны практикующему программисту, поэтому они размещены в начале.
Далее в книге решения и примеры приводятся с использованием только классического C++. Некоторые из этих проблем встречаются и в более современном
коде, но новые методы следует использовать там, где это возможно. Позже мы
обсудим ситуации, когда программист не может использовать современные
компиляторы — увы, но такое случается. Знание того, как решать эти задачи
в условиях ограниченных технологий, по-прежнему важно. Обратите внимание,
что раздел «Смотри также» в конце каждого пункта «Ошибка» может содержать
перекрестные ссылки как на соответствующие современные, так и на классические ошибки и решения, если это необходимо.
Итоги
Эта книга поможет вам:
понять причины распространенных ошибок программирования на C++;
выявлять типичные ошибки, встречающиеся во многих существующих кодовых базах C++;
анализировать код на предмет корректности, читаемости, эффективности
и производительности;
определять, где и как вносить изменения, чтобы устранить эти ошибки;
писать более качественный код, избегая этих ошибок и используя современные подходы, когда это возможно;
делиться своими знаниями с другими разработчиками, помогая им распо
знавать и устранять аналогичные проблемы.
Часть 1
Современный C++
Ключевой аспект современного C++ — строгий подход к проектированию надежных классов и управлению типами данных. Классы в C++ давно вышли за
рамки простых контейнеров и стали фундаментальными компонентами надежной
программной инфраструктуры. Эффективное управление динамической памятью с помощью умных указателей позволяет минимизировать утечки и избежать
появления «висячих» указателей. Применяя передовые методы проектирования
классов и используя новые возможности с типами, разработчики могут создавать отказоустойчивые и адаптируемые структуры, прокладывая путь к более
надежным приложениям.
Современный C++ предлагает целый набор инструментов, которые делают код
лаконичным и точным: лямбда-выражения, цикл по диапазону и контекстные
ключевые слова. Распознавание и интеграция этих пока еще недостаточно используемых функций в повседневную практику может значительно сократить
количество ошибок и повысить эффективность кода. Переход от традиционных
методов к современным позволяет разработчикам с большей уверенностью
и креативностью справляться с задачами разработки, создавая корректный
и устойчивый к будущим изменениям код.
2
Современный C++:
классы и типы данных
В этой главе
3 Семантика перемещения (move semantics)
3 Автоматически предоставляемые члены класса
3 Циклы for по диапазону
3 Умные указатели
С появлением стандарта C++11 язык претерпел значительные изменения, направленные на расширение возможностей, улучшение стандартной библиотеки
шаблонов (Standard Template Library, STL), повышение производительности
и упрощение синтаксиса и выразительности. Хотя в книге это не рассматривается подробно, стандарт C++11 также внес значительные усовершенствования
в область параллелизма — поддержку потоков и задач, а также в систему обработки ошибок, работу со временем и датами и механизмы вычислений во
время компиляции.
Функции языка, предоставляемые C++11, дают программистам существенные
преимущества. Ключевое слово auto ввело вывод типа, что упростило код и повысило его читаемость и эффективность. Циклы for по диапазону упростили
обход коллекций, сделав синтаксис интуитивнее и уменьшив риск ошибок.
Современный C++: классы и типы данных
37
Литерал nullptr повысил безопасность, позволив отличать нулевые указатели
от целочисленного нуля.
Усовершенствования STL, внесенные в C++11, также открывают широкие возможности. Добавление умных указателей облегчило безопасное управление
памятью и сократило утечки памяти. Семантика перемещения оптимизировала
обработку ресурсов, повысив производительность за счет сокращения лишнего
копирования. Новые контейнеры расширили выбор структур данных, улучшив
гибкость и эффективность при работе с информацией. Лямбда-выражения
позволили создавать компактный и выразительный код, увеличив читаемость
и эффективность.
Полученный выигрыш в производительности повышает эффективность программ и рациональность использования ресурсов. Rvalue-ссылки сделали обработку временных объектов более быстрой и экономичной, сведя к минимуму
затраты вычислительных ресурсов. Все эти дополнительные возможности приводят к ускорению выполнения программ и более эффективному управлению
ресурсами.
Упрощенный синтаксис и повышение выразительности в C++11 способствуют
созданию кода, который легче читать и поддерживать. Лямбда-выражения
позволяют определять функцию непосредственно в том месте кода, где она
требуется, повышая ясность кода и уменьшая потребность во вспомогательных
функциях. Шаблоны с переменным числом аргументов обеспечивают гибкость,
упрощая создание обобщенного и повторно используемого кода. Циклы for по
диапазону дают интуитивно понятный способ для итерации по контейнерам.
Благодаря этим улучшениям разработчики могут писать более выразительный,
эффективный и гибкий код, что в конечном счете повышает его качество.
Большинство перечисленных улучшений мы обсудим в этой и двух последующих главах. Некоторые опции, не вошедшие в обсуждение, подробно описаны
в других книгах, учебных курсах и онлайн-материалах. Изучение нововведений
стандарта C++11 заслуживает отдельного внимания, так как именно они являются основой для стандартов C++17 и C++20, где добавлены новые возможности,
проведены уточнения, а также удалены устаревшие элементы. Язык C++ далеко
не мертв и продолжает совершенствоваться с течением времени.
Современный C++ не не защищает от ошибок, но предоставляет инструменты,
которые снижают вероятность их совершения. Например, циклы for по диапазону устраняют ошибки смещения на единицу (off-by-one errors). Преимущества, отмеченные в рассматриваемых далее ошибках, — это новые функции,
направленные на исправление классических ошибок. Хотя было добавлено всего
несколько функций, использование даже небольшой части этих средств способно заметно облегчить разработку. В этой главе мы разберем лишь некоторые из
наиболее важных инструментов, устраняющих ограничения классического C++.
38
Глава 2. Современный C++: классы и типы данных
2.1. Ошибка #1: неумение правильно использовать
семантику перемещения
Эта ошибка связана с производительностью. Передача данных из одной области в другую может оказаться довольно затратной, поскольку данные обычно
копируются, и в результате создаются две (или более) их копии.
Проблема
Часто данные должны быть созданы или собраны в одном месте, а проанализированы или обработаны — в другом. Копирование данных между этими частями
может быть неэффективным, так как при этом информация дублируется. Копирование небольших объемов кажется безобидным, но при работе с большими
объемами, особенно при частом копировании, это становится проблемой.
Если нескольким сущностям требуется доступ к данным, то используется совместное владение. В таких случаях применяют передачу копий указателей,
что позволяет различным фрагментам кода получать доступ к данным, не
копируя их. Довольно часто данные принадлежат только одному владельцу.
Передача указателей в такой ситуации может быть опасной, поскольку указатели по своей сути являются механизмом совместного использования. Современный C++ предоставляет умные указатели с эксклюзивным владением
для таких случаев.
Нередко встречаются данные, которые принадлежат только одному владельцу
и управляются по значению, а не через указатель. Рассмотрим код в листинге 2.1,
где объект TextSection создается и инициализируется в одном экземпляре,
а затем передается другому. Объект p1 эксклюзивно владеет TextSection, и владение (а вместе с ним и данные) «передается» объекту p2 — по крайней мере,
так предполагается. В результате единственная копия данных оказывается у p2,
но такой подход слишком затратен. Обратите внимание: данные и заголовки
исходного объекта уничтожаются, что сохраняет требование эксклюзивного
владения, но нарушает обычную практику передачи источника по const-ссылке
(константной ссылке).
Листинг 2.1. О
ператор присваивания, сохраняющий уникальность,
но требующий больших затрат
class TextSection {
// полезная реализация
};
class Page {
private:
TextSection* headers;
TextSection* body;
public:
Page(TextSection* h) : headers(h), body(new TextSection()) {}
Page(Page& o);
~Page() { if (body) delete body; }
};
Page::Page(Page& o) {
class TextSection {
// полезная реализация
};
class Page {
2.1. Ошибка #1
private:
TextSection* headers;
TextSection* body;
public:
Page(TextSection* h) : headers(h), body(new TextSection()) {}
Page(Page& o);
~Page() { if (body) delete body; }
};
39
Page::Page(Page& o) {
if (this == &o)
return;
body = new TextSection(*o.body);
delete o.body;
o.body = 0;
headers = o.headers;
o.headers = 0;
}
int main() {
Page p1(new TextSection());
Page p2 = p1;
return 0;
}
Операция присваивания с непредвиденным
поведением
Анализ
Копией данных владеет объект p2, однако это не исходные данные. Дублирование приводит к лишним затратам памяти и времени. Если объем данных велик,
время копирования может оказаться значительным. Конструктор копирования
уничтожает данные исходного объекта, сокращая количество копий до одной,
но он не может уменьшить стоимость копирования. Попытка передать владение
заслуживает похвалы, но ее можно реализовать более эффективно. Необходимо
найти средство передачи данных без копирования.
Решение
Современный C++ предоставляет новый тип ссылок — rvalue-ссылки (я называю
их «правыми ссылками»). Они ссылаются на значения, находящиеся в правой
части оператора. Rvalue — это источник для операций присваивания, но не его
цель. Это означает, что правую часть можно изменить, несмотря на то что она не
является объектом присваивания. Временные объекты — также rvalue-объекты,
поэтому изменять их не имеет смысла, но «заимствовать» их значения — вполне
допустимо. В новом синтаксисе двойной амперсанд (&&) обозначает семантику
перемещения.
В языке предусмотрено добавление конструктора перемещения и оператора присваивания с перемещением. Эти новые функции предназначены для сохранения
эксклюзивного владения при передаче ресурса из источника целевому объекту
и позволяют перемещать данные вместо их копирования. Поскольку после перемещения целевой объект становится владельцем переданных данных, ресурсы
40
Глава 2. Современный C++: классы и типы данных
в исходном объекте следует объявить недействительными (аннулировать), чтобы
предотвратить некорректное обращение к ним, — как показано в листинге 2.2.
Листинг 2.2. О
ператор присваивания, сохраняющий уникальность
при минимальных затратах
class TextSection {
// полезная реализация
};
class Page {
private:
TextSection* headers;
TextSection* body;
public:
Page(TextSection* h) : headers(h), body(new TextSection())
Page(const Page& o) : headers(o.headers), body(new
TextSection(*o.body)) {}
Page(Page&& o) : headers(o.headers), body(o.body) {
o.headers = nullptr;
Ресурсы исходного объекта
o.body = nullptr;
объявляются недействительными
}
~Page() { delete body; }
};
int main() {
Page p1(new TextSection());
Page p2 = std::move(p1);
return 0;
}
{}
Конструктор
копирования
с возможностью
перемещения
Ресурсы исходного
объекта
переназначаются
целевому
Это выглядит немного странно, но имеет
правильную семантику перемещения
Перемещение означает, что исходный объект передает свое содержимое целевому
объекту. В некоторых реализациях для перемещения данных от объекта-источника в целевой объект и обратно используется функция обмена (swap). Поскольку
происходит присваивание, исходный объект теряет свои первоначальные данные, но приобретает данные целевого — что обычно допустимо. Если операция
обмена не используется, лучше всего убедиться, что все перемещенные данные
в исходном объекте обнулены и объявлены недействительными.
В случаях, когда исходный объект не уничтожается, его прежние данные не
должны быть доступны ни при каких обстоятельствах. Все мы знаем, что всё, что
может пойти не так, обязательно пойдет не так — и часто это происходит в самый
неподходящий момент. Поэтому необходимо «обезвредить» данные в исходном
объекте, чтобы предотвратить их случайное использование после перемещения.
Обычно этого можно добиться, установив указатели на nullptr (или 0).
Еще одной приятной особенностью является то, что, если в классе определены
конструктор перемещения и оператор присваивания с перемещением, компилятор будет автоматически использовать их везде, где это возможно. Разработчику
2.2. Ошибка #2
41
не нужно вручную определять необходимость их применения — стандартные
операции копирования и оператор присваивания копированием будут использоваться, только если источником является lvalue (присваиваемое значение
в левой части оператора).
Шаблон функции std::move имеет решающее значение для корректного перемещения. Как шутят разработчики: «Перемещает не перемещая» (“Move doesn’t
move”). Почему же он все-таки так важен? Функция перемещения (move) преобразует lvalues в rvalues, делая объект пригодным для перемещения. Если этого
не сделать, произойдет стандартное присваивание, а не присваивание с перемещением. Используя операторы копирования (copy) и перемещения (move),
можно реализовать как совместное, так и эксклюзивное владение ресурсами,
что делает семантику класса понятнее.
Рекомендации
Добавьте в класс конструктор перемещения и оператор присваивания с перемещением для эксклюзивной передачи владения ресурсами.
Расширьте «большую тройку» (конструктор копирования, оператор присваивания, деструктор) до «большой пятерки», добавив конструктор перемещения и оператор присваивания с перемещением.
Смотри также
«Ошибка #47», чтобы найти обсуждение «большой тройки» (деструктор,
конструктор копирования и оператор присваивания).
«Ошибка #54», где обсуждается сохранение семантики.
«Ошибка #62»: рассматривается проблема эксклюзивного владения и ее
решение с помощью unique_ptr.
«Ошибка #8», чтобы разобраться с самим unique_ptr.
2.2. Ошибка #2: использование пустых спецификаций
исключений
Эта ошибка касается эффективности, производительности и отчасти читаемости.
Спецификации исключений сообщают компилятору, какие исключения может
выбрасывать функция.
Проблема
Спецификации исключений устарели с 2011 года: их эффективность и влияние
на производительность так и не оправдали ожиданий, что жизнь разработчиков
станет проще. Механизм оказался сложным и запутывал ситуацию, поэтому
42
Глава 2. Современный C++: классы и типы данных
современный C++ пометил его устаревшим в спецификации C++11 и полностью
удалил в стандарте C++17. Внимательно ознакомьтесь с документом P0003R5
(https://mng.bz/N1mE), чтобы понять всю историю этого вопроса. Тем не менее
одна идея, выросшая из спецификаций, по-прежнему актуальна и имеет смысл.
Исключения — это прекрасный способ отделить нормальный функциональный
поток от обработки ошибок. Как и следовало ожидать, такая гибкость имеет свою
цену. Чрезмерное использование исключений так же нежелательно, как и полный отказ от них. Вместо этого необходимо найти разумный баланс, который
обеспечит и явную обработку ошибок, и хорошую производительность. Многие
функции не выбрасывают исключений, поэтому их не следует обременять проверками обработки любых потенциальных исключений во время выполнения.
В прежнем механизме был случай пустой спецификации throw(), означавший, что
функция не выбрасывает никаких исключений. Целью такой записи была оптимизация. После удаления спецификаций этот подход тоже перестал существовать.
Предложение об удалении спецификации исключений признает ценность оператора throw() и предлагает не удалять эту функциональность, а преобразовать
ее. В стандарте C++11 был добавлен новый спецификатор noexcept, который
фактически эквивалентен пустой спецификации throw(). Он был введен для
тех же целей — позволить компилятору выполнять оптимизацию и устранять
проверки на исключения во время выполнения. Следующий код демонстрирует
досовременный подход в C++, показывающий, как можно было убедить компилятор убрать ненужную проверку исключений.
Листинг 2.3. Ф
ункции, которые в предыдущей версии сообщали,
что исключений не будет
const double pi = 3.1415927;
struct Circle {
double radius;
Circle(double r) : radius(r) {}
double perimeter() const throw() { return 2 * pi * radius; }
double area() const throw() { return pi * radius * radius; }
};
Ресурсы исходного объекта
объявляются недействительными
int main() {
Circle c(3);
std::cout << "perimeter " << c.perimeter() << ", area " << c.area() << '\n';
return 0;
}
Анализ
В листинге 2.3 показан подход прежней версии С++. Однако в современном
C++ такая запись вызовет предупреждение: в предложении по удалению спецификаций исключений утверждается, что пустой оператор throw() эквивалентен
спецификации noexcept(true). Выражение в скобках определяет, уверен ли
2.2. Ошибка #2
43
компилятор, что функция не выбросит ни одного исключения. Убедитесь, что
ваш код не «обманывает» компилятор.
Важно: если функция, помеченная как noexcept, все же выбросит исключение,
будет вызвана печально известная функция terminate, которая немедленно завершит программу, и никакой раскрутки стека (деструкторы вызваны не будут!)
не произойдет. Разработчик обязан «говорить правду», иначе компилятор будет
наказывать за неопределенное поведение (непредвиденные исключения).
Решение
Листинг 2.4 представляет собой обновленную — с учетом современного подхода — версию предыдущего примера: пустая спецификация throw() заменена
новым языковым спецификатором noexcept. Чтобы правильно передать намерение функции не выдавать исключений, требуется совсем немного изменений,
поэтому обновление кода будет простым. Помните, что это работает только для
пустых спецификаций исключений!
Листинг 2.4. Ф
ункции, которые в современной версии сообщают о том,
что исключений не будет
struct Circle {
double radius;
explicit Circle(double r) : radius(r) {}
double perimeter() const noexcept { return 2 * std::numbers::pi * radius; }
double area() const noexcept { return std::numbers::pi * radius * radius; }
};
Более корректная попытка оптимизации,
поскольку никакие исключения не выбрасываются
int main() {
Circle c(3);
std::cout << "perimeter " << c.perimeter() << ", area " << c.area() << '\n';
return 0;
}
Конструктор, принимающий один параметр, называется конструктором преобразования. Код в листинге 2.4 может неявно преобразовать значения с типом
double к значению типа Circle. Когда компилятор ищет такие преобразования,
он определяет, доступен ли конструктор или операция преобразования, и выбирает наиболее подходящий вариант, если найдено более одного. Однако такое
поведение требуется не всегда.
ПРИМЕЧАНИЕ В современном C++ предусмотрено ключевое слово explicit для
предотвращения использования конструкторов с одним параметром в неявных преобразованиях. Иногда неявное преобразование уместно, но обычно число неконтролируемых конструкторов преобразования следует ограничивать. Добавление explicit
к конструктору запрещает компилятору применять его для автоматических преобразований. Хорошо подумайте над этим и обратитесь к разделу «Смотри также» для
получения дополнительной информации.
44
Глава 2. Современный C++: классы и типы данных
Рекомендации
Замените throw() на noexcept, чтобы модернизировать код.
Убедитесь, что функции, которые говорят, что не будут выбрасывать исключения, действительно этого не делают.
Смотри также
«Ошибка #68», где мы рассмотрим конструкторы преобразования: когда
и зачем использовать ключевое слово explicit.
«Ошибка #80», где мы обсудим проблемы старого механизма спецификаций
исключений.
2.3. Ошибка #3: неиспользование ключевого слова
override в производных виртуальных функциях
Эта ошибка влияет на эффективность и, хоть и немного, на читаемость. Реализация виртуальных функций в производных классах позволяет настраивать
унаследованное поведение. Неправильно названные производные виртуальные
функции — настолько серьезная проблема, что комитет по стандартизации С++
добавил средства, помогающие компилятору ее предотвращать.
Проблема
Классы с виртуальными функциями предполагают наследование — ключевое слово virtual намекает на это. Виртуальные функции, реализованные
в производных классах, позволяют модифицировать (или полностью заменять) поведение, определенное в базовом классе. Этот подход лежит в основе
полиморфизма.
В больших кодовых базах базовые и производные классы могут располагаться
далеко друг от друга. Определить, является ли метод виртуальным, может быть
непросто, если дистанция велика. Производные классы не обязаны дублировать
ключевое слово virtual в объявлениях своих методов: компилятор и так это понимает. Однако такое повторение — допустимое средство документирования того,
что метод виртуальный. Но оно не указывает, в каком именно базовом классе
определен исходный виртуальный метод, оставляя разработчику необходимость
просматривать иерархию, чтобы разобраться.
В листинге 2.5 показана быстрая и удачная попытка добавить функциональность в пятницу поздно вечером. Код компилируется, разработчик счастлив,
поставка уходит клиенту. К сожалению, на следующей неделе клиент жалуется:
объекты Square работают некорректно. Разработчик удивлен, потому что все
«выглядело хорошо».
2.3. Ошибка 3
45
Листинг 2.5. К
од, который выглядит хорошо и компилируется
без ошибок
struct Shape {
std::string type;
explicit Shape(const std::string& t) : type(t) {}
virtual double area() const { return 0; }
};
struct Square : public Shape {
double side;
Square(double s) : Shape("square"), side(s) {}
double aria() const { return side*side; }
};
В код легко могут
закрасться опечатки
const static double pi = 3.1415927;
struct Circle : public Shape {
double radius;
Circle(double r) : Shape("circle"), radius(r) {}
double area() const { return pi * radius * radius; }
};
int main() {
Square s(2);
Circle c(2);
std::vector<Shape*> shapes;
shapes.push_back(&s);
shapes.push_back(&c);
for (int i = 0; i < shapes.size(); ++i)
std::cout << shapes[i]->area() << '\n';
return 0;
}
Анализ
Проблема проста: разработчик допустил опечатку в имени метода. Компилятор
без возражений добавил новую функцию в класс, поскольку синтаксических
ошибок допущено не было. Успешная компиляция не подразумевает корректного
переопределения виртуального метода.
Неправильно названные виртуальные функции производного класса не являются синтаксической ошибкой — по крайней мере, не по причине неправильного
имени! Компилятор просто видит и компилирует новый метод как обычный
невиртуальный, добавленный в класс. Тот факт, что разработчик хотел переопределить виртуальную функцию, его не волнует.
Решение
В стандарте C++11 появилось новое ключевое слово — override (переопределение). Проблема неверно названных виртуальных функций встречалась столь
46
Глава 2. Современный C++: классы и типы данных
часто, что требовалось средство предотвращения. override добавляется после
списка параметров метода, который должен переопределять виртуальный метод.
Если компилятор встречает метод с override, который не совпадает ни с одной
виртуальной функцией в каком-либо базовом классе, он выдает ошибку. Это ключевое слово не является обязательным, но с его помощью достигаются два важных
результата. Во-первых, оно документирует намерение класса переопределить
виртуальный метод, что улучшает читаемость. Во-вторых, компилятор проверяет, что метод действительно переопределяет известную виртуальную функцию,
то есть опечатки становятся ошибками компиляции, а не «новыми методами».
Код в листинге 2.5 не скомпилировался бы, если бы к методу aria() был добавлен override. Компилятор бы «споткнулся», а разработчик — увидел ошибку
и быстро исправил ее после нескольких нажатий клавиш. Пятничное пиво еще
никогда не было таким вкусным.
Небольшой нюанс: технически override — это контекстное ключевое слово.
Оно считается ключевым только в определенных контекстах (например, после
списка параметров метода). В других контекстах это допустимый (но не рекомендуемый!) идентификатор. Так сделали потому, что override уже встречался
в качестве идентификатора во многих существующих кодовых базах. Это было
очень любезно со стороны комитета по стандартизации (который, вероятно,
поддерживает часть из этих кодовых баз).
Листинг 2.6. И
спользование override для проверки корректности
переопределения
struct Shape {
std::string type;
Shape(const std::string& t) : type(t) {}
virtual double area() const { return 0; }
};
struct Square : public Shape {
double side;
Square(double s) : Shape("square"), side(s) {}
double area() const override { return side*side; }
};
struct Circle : public Shape {
double radius;
Circle(double r) : Shape("circle"), radius(r) {}
double area() const override { return
std::numbers::pi * radius * radius; }
};
int main() {
Square s(2);
Circle c(2);
std::vector<Shape*> shapes;
shapes.push_back(&s);
shapes.push_back(&c);
for (auto& shape : shapes)
std::cout << shape->area() << '\n';
return 0;
}
Добавлено override, чтобы
заставить компилятор
проверять имена
виртуальных функций
Добавлено override, чтобы
заставить компилятор проверять
имена виртуальных функций
int main() {
Square s(2);
Circle c(2);
std::vector<Shape*> shapes;
shapes.push_back(&s);
shapes.push_back(&c);
for (auto& shape : shapes)
std::cout << shape->area() << '\n';
return 0;
}
Добавлено override, чтобы
заставить компилятор проверять
имена виртуальных
функций
2.4. Ошибка
4
47
};
double radius;
Circle(double r) : Shape("circle"), radius(r) {}
double area() const override { return
std::numbers::pi * radius * radius; }
Рекомендации
Используйте override, чтобы явно зафиксировать намерение переопределить
унаследованный виртуальный метод.
Применяйте override, чтобы компилятор проверял корректность переопределения и соответствие имени метода его объявлению в базовом классе.
Помните: override — это контекстное ключевое слово; по возможности
избегайте использовать его в иных ролях, чтобы не усложнить понимание
кода.
2.4. Ошибка #4: стремление к простоте,
или Сокрытие нежелательных членов класса,
предоставляемых компилятором
Эта ошибка влияет на эффективность и читаемость. Когда класс определен,
некоторые его члены будут автоматически созданы компилятором, если разработчик не определил их самостоятельно.
Проблема
Компилятор легко справляется с простым классом, в котором отсутствуют
пользовательские реализации конструкторов, операторов копирования и присваивания. То, что не предоставил разработчик, компилятор сгенерирует
автоматически. Обычно это полезно, и предоставленные версии делают все
необходимое. Однако разработчики должны писать собственные реализации,
если автоматически предоставляемые методы некорректны или недостаточны.
Компилятор не сгенерирует конструктор по умолчанию, если разработчиком
определен хотя бы один. Если реализованы конструктор копирования, конструктор перемещения или деструктор — ни один из этих методов не будет
сгенерирован автоматически. Кроме того, если некоторые члены не должны
предоставляться, распространенный подход — объявить их приватными без
реализации.
Оборотная сторона проблемы в том, что некоторые разработчики вручную реализуют слишком много таких автоматически предоставляемых методов, когда
48
Глава 2. Современный C++: классы и типы данных
этого явно не требуется. Далее приведен простой класс Container, обеспечивающий некоторую базовую функциональность. Было решено, что копирование
допустимо, а присваивание — нет. Поэтому следующий код явно задает конструктор по умолчанию (обязательный) и конструктор копирования, а оператор
присваивания — скрывает.
Листинг 2.7. П
ростой класс с явно определенным конструктором
по умолчанию
class Container {
private:
std::vector<int> values;
Container& operator=(const Container& o);
Спрятано, чтобы никто не мог
public:
вызвать оператор присваивания
Container() {}
Container(const Container& o) : values(o.values) {}
void add(int n) { values.push_back(n); }
};
int main() {
Container c1;
c1.add(42);
Container c2(c1);
// c2 = c1;
return 0;
}
Вызывается конструктор
копирования
Присваивание вызовет ошибку
Анализ
Код работает, но писать и читать его сложнее, чем хотелось бы. Конструкторы
и операторы присваивания обычно являются публичными (public) членами,
поэтому сокрытие их в приватной (private) секции класса не совсем правильно. Кроме того, такой подход не передает намерение разработчика: чтобы
понять смысл, нужно быть знакомым с паттерном. Если класс станет чуть
сложнее, определение конструктора по умолчанию потребует еще больше
усилий.
Решение
Более удачный подход, который хорошо отражает замысел, — использование
ключевых слов =delete и =default. Ключевое слово =delete означает, что член
намеренно опущен, поэтому его нельзя использовать. Ключевое слово =default
указывает компилятору, что соответствующий член должен быть сгенерирован
со стандартным поведением. Преимущество такого метода в том, что класс может изменить свою реализацию, не затрагивая клиентский код, при условии что
изменения понятны компилятору. Главное достоинство — разработчик четко
обозначает назначение таких членов.
2.5. Ошибка #5
49
Листинг 2.8. Простой класс с членами по умолчанию и удаленными членами
class Container {
private:
Компилятор знает, что
std::vector<int> values;
делает, — пусть он сам создаст
public:
конструктор по умолчанию
Container() = default;
Container(const Container& o) : values(o.values) {}
Container& operator=(const Container& o) = delete;
void add(int n) { values.push_back(n); }
};
int main() {
Container c1;
c1.add(42);
Container c2(c1);
// c2 = c1;
return 0;
}
Нет необходимости
прятать оператор —
компилятор сам запретит
его использование
Присваивание является ошибкой
Рекомендации
Используйте =delete, чтобы исключить ненужные члены.
Используйте =default, чтобы позволить компилятору сгенерировать член —
это экономит время и устраняет источник потенциальных ошибок.
Смотри также
«Ошибка #62», где мы рассмотрим старый подход к методам сокрытия.
2.5. Ошибка #5: неиспользование инициализации
членов класса при объявлении
Устранение этой ошибки в основном направлено на улучшение читаемости,
а также оно может немного повысить эффективность. Многие языки позволяют
инициализировать переменные экземпляра непосредственно в определении
класса; C++ не позволял этого до стандарта C++11.
Проблема
Большинству разработчиков удобно писать конструкторы со значениями по
умолчанию. Эти значения часто очевидны, поэтому единственная причина определять их — если значения по умолчанию неверны для конкретного экземпляра.
Обычно разработчик указывает значения по умолчанию в списке параметров
конструктора, как показано в листинге 2.9. Этот способ хорошо работает, но ухудшает читаемость. Предположим, что в классе имеется несколько конструкторов
и множество переменных экземпляра. Некоторые из этих конструкторов будут
50
Глава 2. Современный C++: классы и типы данных
задавать редко меняющиеся значения по умолчанию, но читателю все равно понадобится искать, какое значение присвоено конкретной переменной. Код в листинге 2.9 читается легко, но представьте, как он будет выглядеть в классе с четырьмя,
пятью или более параметрами. Например, скажите, насколько удобно воспринимать следующий конструктор с несколькими параметрами по умолчанию:
Demo(int n=0, double d=1.0, double e=0.0, double f=10.5, bool b=false) : n(n),
d(d), e(e), f(f), b(b) {}
Значения по умолчанию должны использоваться в пределах простого и понятного списка. Чрезмерное количество параметров может говорить о том, что класс
имеет слишком много обязанностей и следует провести рефакторинг — разделить
на несколько типов данных. Следующий код такую ошибку не допускает, но тем
не менее его можно сделать более удобным для чтения.
Листинг 2.9. И
спользование значений по умолчанию в списке
параметров
class Complex {
private:
double real;
double imag;
public:
Complex(double r=0, double i=0) : real(r), imag(i) {}
double getReal() const { return real; }
double getImag() const { return imag; }
};
Использование
параметров
по умолчанию
int main() {
Complex c1;
Complex c2(3);
Complex c3(-2, -2);
return 0;
}
Анализ
Хотя с этим кодом все в порядке, при наличии нескольких конструкторов читаемость ухудшается. Кроме того, чем больше кода написано, тем выше вероятность
появления ошибки. Если параметры со значениями по умолчанию различаются
в нескольких конструкторах, читателю будет сложно определить фактическое
значение переменной экземпляра.
Решение
Современный C++ предлагает альтернативу, которая не зависит от параметров по умолчанию. Разработчики учли бˆольшую определенность, которую
некоторые другие языки предоставляют значениям по умолчанию, и в C++
2.6. Ошибка #6
51
добавили инициализаторы полей класса (in-class initializers). Каждой переменной экземпляра можно присвоить значение по умолчанию либо с помощью
оператора присваивания, либо через фигурные скобки — форма, называемая
универсальной инициализацией (uniform initialization). Этот вариант обеспечивает единообразие, позволяет избежать неоднозначности и не допускает автоматических сужающих преобразований. Для инициализации рекомендуется
использовать именно ее.
Листинг 2.10. И
спользование инициализации полей класса
для улучшения читаемости
class Complex {
Использует инициализацию полей
private:
класса через оператор присваивания
double real = 0;
double imag{0};
Использует инициализацию полей
public:
класса через фигурные скобки
Complex() {}
Complex(double r, double i) : real(r), imag(i) {}
double getImag() const { return imag; }
};
Использует значения по умолчанию
int main() {
Complex c1;
Это не сработает; непонятно, какой член
// Complex c2(3);
следует инициализировать этим значением
Complex c3(-2, -2);
return 0;
}
Если конструктору передано значение для переменной, инициализируемой
внутри класса, то переданное значение используется для инициализации. Если
значение не указано, то применяется значение, заданное в определении поля.
Рекомендации
Используйте инициализаторы для полей класса (in-class initialization), чтобы
локализовать значения по умолчанию для переменной экземпляра.
Используйте значения по умолчанию в списке параметров конструктора
только при работе с классическим C++.
2.6. Ошибка #6: чрезмерное использование циклов
на основе индексов
Эта ошибка влияет на эффективность и читаемость, а также может негативно
сказаться на производительности. Индексация по контейнеру с использованием
циклов — это распространенная идиома программирования. Однако этот традиционный подход сложнее, чем нужно.
52
Глава 2. Современный C++: классы и типы данных
Проблема
Разработчикам часто приходится перебирать элементы контейнеров, чтобы
получить доступ к каждому из них. Традиционно это делалось путем создания
управляющей переменной цикла, начинающейся с первого индекса и монотонно
увеличивающейся (на единицу) при каждой итерации. Последний индекс все
гда равен длине контейнера минус один. В листинге 2.11 продемонстрирована
обобщенная функция sum, которая принимает вектор значений и возвращает их
сумму. Предположим, что это результаты тестов, а среднее значение (mean value)
будет вычислено позже. В этом цикле нет ничего особенного — это типичное
решение для обхода контейнера по индексам. Что может быть проще?
Листинг 2.11. И
спользование значений индексов для итерации
по контейнеру
template <typename T>
T sum(const std::vector<T>& values) {
T sum = (T)0;
for (int i = 0; i < values.size(); ++i)
sum += values[i];
return sum;
}
int main() {
std::vector<int> values;
for (int i = 0; i < 10; ++i)
values.push_back(10*i + i);
std::cout << sum(values) << '\n';
return 0;
}
Традиционный подход
на основе индексов
Анализ
Код в листинге 2.11 работает и характерен для большинства циклов, встречающихся в существующих кодовых базах. Для его реализации требуется немного
времени, но при этом внимание сосредоточено на реализации механики цикла,
а не на задаче. Даже если человек хорошо знаком с этим подходом, выглядит он
несколько неуклюже. Любое дополнительное нажатие клавиши приводит к тому,
что возрастает вероятность допустить ошибку. Кроме того, те, кто не знаком
с этой идиомой, нередко пишут проверку на продолжение в виде:
i<=values.size()
Мы знаем, что это неверно. Интуитивно кажется правильным, но на самом деле
это классическая ошибка «на единицу».
Решение
Современный C++ предоставляет форму цикла for по диапазону, чтобы
упростить этот привычный паттерн. Вместо обычного оператора for форма
2.6. Ошибка #6
53
range-based удаляет секции продолжения и обновления и изменяет часть инициализации. Читайте двоеточие как «в», например: «Для (каждого) элемента
в values (значениях) выполнить нечто», где «нечто» — это тело цикла.
Механика проста: объявляется переменная, которой по очереди присваивается
значение каждого элемента контейнера — начиная с нулевого индекса и далее
до последнего, как и в индексированном подходе. Каждая итерация цикла копирует элемент в переменную. Значение переменной можно менять внутри цикла,
но так как это копия, изменение не влияет на исходный элемент контейнера.
Рассматривайте этот цикл прежде всего как средство улучшения читаемости.
Если вам нужно изменять значения элементов, используйте ссылочный тип
переменной.
Поскольку каждый элемент копируется, если тип элемента — класс, то конструктор копирования будет вызываться на каждой итерации, что, скорее всего,
гораздо ресурсозатратнее, чем необходимо. Для оптимизации лучше сделать
переменную ссылкой на элемент. В этом случае копируется только адрес объекта, что минимизирует объем данных. Используйте ключевое слово const, если
изменение элемента не требуется. Для числовых типов, как показано в листинге 2.12, ссылка, вероятно, будет избыточна — это демонстрационный код, так
что можно воспринимать это условно. Когда тип элемента представляет собой
класс, такой подход дает значительный прирост производительности. Иногда
можно использовать указатель, но ссылка предпочтительнее, так как такая форма
избавляет от синтаксиса работы с указателями, улучшая читаемость.
Листинг 2.12. И
спользование цикла for по диапазону при итерации
по контейнеру
template <typename T>
T sum(const std::vector<T>& values) {
T sum {};
for (const T& value : values)
sum += value;
return sum;
}
int main() {
std::vector<int> values;
for (int i = 0; i < 10; ++i)
values.push_back(10*i + i);
std::cout << sum(values) << '\n';
return 0;
}
Используется инициализатор для получения
корректного нулевого значения типа
Цикл for по диапазону
прост в использовании
Цикл for на основе индексов
выглядит неуклюже
Рекомендации
Используйте цикл for по диапазону, чтобы исключить утомительное написание традиционных индексационных циклов.
54
Глава 2. Современный C++: классы и типы данных
Помните, что значение элемента копируется в переменную, поэтому такой
цикл по умолчанию работает только на чтение.
Если копирование элемента требует больших затрат ресурсов, используйте
ссылочную переменную, чтобы минимизировать передачу данных.
2.7. Ошибка #7: отказ от использования ключевого
слова nullptr
Эта ошибка касается читаемости и, в меньшей степени, эффективности. Макрос
NULL был унаследован от языка C, однако в ранних версиях C++ лучшим спосо-
бом было инициализировать указатель значением ноль.
Проблема
В листинге 2.13 показана простая модель класса Student, содержащая несколько
переменных экземпляра. Одна из них инициализируется нулевым значением.
Далее, поскольку коду нужны указатели на экземпляры, мы видим их инициализацию с присвоением целочисленного нуля. Использование литерального нуля
может запутать, так как это значение применяется во множестве контекстов.
Листинг 2.13. Мешанина из нулевых значений
struct Student {
std::string name;
int id;
double gpa;
Student(const std::string& name, int id, double gpa) : name(name),
id(id), gpa(0) {
if (gpa < 0)
throw std::invalid_argument("gpa is negative");
}
};
int main() {
Лучше, чем макрос NULL, но не так очевидно
Student* sammy = 0;
Student* ginny = 0;
Student* gene = 0;
sammy = new Student("Samuel", 0, 3.75);
ginny = new Student("Virginia", 1, 3.8);
gene = new Student("Eugene", 2, 0);
return 0;
}
Анализ
Инициализация указателя нулевым значением — это лучший подход, и в старой
версии C++ необходимо обозначать указатель, не указывающий на реальный
2.7. Ошибка #7
55
объект. Однако литеральный ноль сам по себе не является указателем, что может
вызвать некоторую путаницу. Хотя многие разработчики знакомы с таким применением, читается оно хуже, чем использование спорного макроса NULL. По
крайней мере, макрос визуально ассоциируется с понятием указателя (хотя чаще
всего он, вероятно, таковым не является). Компилятор достаточно умен, чтобы
принять целочисленный литерал и корректно преобразовать его к типу указателя.
Решение
В современном C++ специально для указателей предусмотрено ключевое слово
nullptr — литеральное значение, представляющее null. Его самое значительное
преимущество — ясная семантика; его значение легко понять и интерпретировать. Нулевой литерал не обладает такой простотой использования.
Еще одно явное преимущество nullptr в том, что компилятор не может спутать
его с параметром функции типа int. Ранее мы уже сталкивались с подобной проблемой — за дальнейшими разъяснениями обратитесь к разделу «Смотри также».
Листинг 2.14. И
спользование ключевого слова nullptr для повышения
ясности
struct Student {
std::string name;
int id;
double gpa;
Student(const std::string& name, int id, double gpa) : name(name),
id(id), gpa(0) {
if (gpa < 0)
throw std::invalid_argument("gpa is negative");
}
};
Это лучше, чем макрос NULL, и более
очевидно, чем нулевой литерал. Что тут
int main() {
может не понравиться?
Student* sammy = nullptr;
Student* ginny = nullptr;
Student* gene = nullptr;
sammy = new Student("Samuel", 0, 3.75);
ginny = new Student("Virginia", 1, 3.8);
gene = new Student("Eugene", 2, 0);
return 0;
}
Рекомендации
Используйте nullptr для указателей, которые не инициализируются сразу
допустимым объектом.
Присваивайте указателю nullptr сразу после освобождения памяти, чтобы
предотвратить ошибки его повторного использования (use-after-free errors).
56
Глава 2. Современный C++: классы и типы данных
Смотри также
«Ошибка #76», там мы обсудим утечки памяти, где nullptr может эффективно применяться.
«Ошибка #77», где дано общее решение проблемы удаления динамических
ресурсов.
«Ошибка #28»: более подробно обсудим макрос NULL и ошибки его использования.
«Ошибка #8» покажет отличную возможность использования nullptr.
«Ошибка #9»: рассмотрим еще одну возможность использования nullptr.
2.8. Ошибка #8: неиспользование умного указателя
unique_ptr для эксклюзивного владения
Эта ошибка влияет на корректность и читаемость. Идиома RAII (Resource
Acquisition Is Initialization — получение ресурса есть инициализация) должна
управлять динамическими ресурсами, но может потребовать создания отдельных
управляющих классов. В ранних версиях C++ существовал вариант auto_ptr,
но он был признан устаревшим из-за склонности к ошибкам, а позже — удален.
Проблема
Эксклюзивное владение динамическими ресурсами необходимо для отслеживания корректного управления уникальными ресурсами. Такие ресурсы нельзя
копировать, поскольку исходный и целевой указатели будут ссылаться на один
и тот же объект. Ссылка, которая владеет и управляет таким ресурсом, не определена явно. Более того, остается вероятность того, что оба «владельца» могут
попытаться управлять объектом, изменяя или удаляя его; второе может привести
к ошибке двойного удаления. Первое удаление сработает, но второе перенесет
программу в фантастическую страну неопределенного поведения, где может
произойти все что угодно, — остается молиться, чтобы компилятор дал сбой.
В следующем примере показан простой случай управления уникальным динамическим ресурсом. Поскольку он создается в одном месте, а обрабатывается
в другом, его указатель приходится передавать. Хотя код написан последовательно, читается он так, словно между строк был вставлен другой код, отвлекаю
щий внимание настолько, что общий смысл становится неочевидным — как во
многих реальных проектах!
Листинг 2.15. Неудачная попытка управления эксклюзивным ресурсом
struct Buffer {
private:
int* data;
public:
Buffer() : data(new int[10]) {}
~Buffer() { delete[] data; }
};
int main() {
Упс! Эксклюзивные ресурсы нельзя
2.8. Ошибка 8
int main() {
Buffer* b1
Buffer* b2
if (b1)
delete
if (b2)
delete
return 0;
}
= new Buffer();
= b1;
b1;
b2;
struct Buffer {
private:
int* data;
public:
Buffer() : data(new int[10]) {}
~Buffer() { delete[] data; }
};
57
Упс! Эксклюзивные ресурсы нельзя
копировать или присваивать
Это кажется разумным; это был
первоначальный владелец
Это кажется неразумным; это был второй владелец
Анализ
Проблема в том, что разработчик хотел создать динамический ресурс в одном
месте, а затем передать его другому владельцу. Однако, как и в случае с другими
хорошими идеями, реализация оказалась некорректной. Поскольку указатель
копируется, он становится разделяемым, а не эксклюзивным. Удаление ресурса
владельцем b1 кажется разумным, но игнорирует намерение передать владение.
Попытка удаления через b2 могла бы иметь смысл, если бы передача владения
была выполнена правильно. Тем не менее, поскольку ресурс уже был удален,
попытка его повторного удаления вызывает неопределенное поведение, переводя программу в «сумеречную зону»1 функционирования.
Структура Buffer спроектирована разумно и должна корректно управлять своим
динамическим ресурсом, реализуя RAII. Однако, как будет показано ниже, ее
можно улучшить.
Решение
Основная проблема использования сырых указателей (raw pointers) для динамических ресурсов состоит в том, что с ними трудно обеспечить безопасность
работы как в обычных условиях, так и при возникновении исключений. Идиома
RAII решает эту задачу, однако требует новой реализации для каждого динамического ресурса. Опция auto_ptr распознавала эту проблему и пыталась предоставить общее решение, но такая попытка оказалась неудачной: это приводило
к значительным проблемам и появлению странного кода.
Современный C++ предоставляет умный указатель std::unique_ptr для реализации эксклюзивного владения. Этот шаблон хорошо спроектирован и работает ожидаемо при любых условиях. Код в листинге 2.16 демонстрирует его
1
Культовый американский телевизионный сериал-антология, созданный Родом Серлингом. Это одно из самых влиятельных произведений в жанре научной фантастики,
фэнтези и мистики. — Примеч. науч. ред.
58
Глава 2. Современный C++: классы и типы данных
использование с указателем и массивом (в отличие от auto_ptr, который не
мог обрабатывать массивы). Внутренние данные класса Buffer представлены
массивом, и unique_ptr управляет этим динамическим ресурсом. Благодаря
этому класс не нуждается в пользовательском деструкторе.
Шаблонная функция std::make_unique (см. листинг 2.16) предпочтительнее
прямого вызова new: она проще, безопаснее при исключениях, избегает сырых
указателей и обладает большей производительностью — иногда можно получить все сразу!
Для передачи владения используем функцию std::move. Хотя может показаться,
что она «перемещает» объект от одного владельца к другому, на самом деле это
не так — другими словами, «перемещение не перемещает». Функция std::move
лишь приводит lvalue -выражение к rvalue . Rvalue -ссылка часто является
временным значением, которое не имеет адресации, хранит некоторое значение и копируется в принимающую сущность, после чего (если оно временное)
может быть уничтожено. Операция перемещения делает исходную сущность
с точки зрения владения фактически «удаляемой». Компилятор определяет, что
используется присваивание (или копирование) rvalue-сущности, и вызывает
семантику перемещения. Значение перемещается от источника к назначению,
оставляя источник (концептуально) пустым и лишенным значения. Некоторые
реализации используют обмен (swap) для передачи данных, оставляя источник со
значениями, которые ранее принадлежали цели. В любом случае, исходную сущность не следует использовать до тех пор, пока ее снова не инициализируют. На
мой взгляд, проще всего считать перемещенный объект недоступным и непригодным для дальнейшего использования. Такой подход гораздо проще обосновать.
Листинг 2.16. Реализация эксклюзивного владения с помощью unique_ptr
struct Buffer {
private:
Может работать с массивом
std::unique_ptr<int[]> data;
public:
Инициализация массива
Buffer() : data(std::make_unique<int[]>(10)) {}
};
Указатель и использование
предпочтительного шаблона
int main() {
функции make_unique
auto b1 = std::make_unique<Buffer>();
auto b2 = std::move(b1);
Передача (а не копирование или присваивание)
return 0;
владения с помощью шаблона функции move
}
Решение, предложенное в листинге 2.16, значительно чище, хотя для его освоения может потребоваться немного практики. Ценность этого подхода в том, что
он эффективнее и аккуратнее всего того, что использовалось ранее. Обратите
внимание на ключевое слово auto для определения типа переменных b1 и b2.
2.9. Ошибка 9
59
Поскольку компилятор может однозначно вывести их тип, это предпочтительнее,
чем указывать его самостоятельно.
Рекомендации
Откажитесь от использования сырых указателей для любых динамических
ресурсов; как правило, применяйте unique_ptr для любых указателей, кроме
тех, которые участвуют в совместном владении.
Используйте unique_ptr для реализации эксклюзивного владения. При
присваивании ресурс передается от источника к цели, оставляя исходного
владельца непригодным для использования.
Отдавайте предпочтение unique_ptr ради простоты и эффективности; использование shared_ptr требует больше ресурсов.
Смотри также
«Ошибка #76» о мотивации применения идиомы RAII с динамическими
ресурсами.
«Ошибка #77», чтобы изучить использование этого паттерна управления
ресурсами.
«Ошибка #78», где объясняется, почему сырые указатели — плохой выбор
для динамических ресурсов, а auto_ptr — неудачное решение.
«Ошибка #1», чтобы лучше понять преимущества семантики перемещения.
«Ошибка #9», где обсудим совместное владение динамическими ресурсами.
2.9. Ошибка #9: неиспользование умного указателя
shared_ptr для совместного владения
Эта ошибка касается в первую очередь корректности и эффективности. Разделяе
мые указатели (shared pointers) требуют бˆольших затрат ресурсов, чем сырые,
но после понимания механики обычно улучшают читаемость.
Проблема
Некоторые структуры данных требуют работы с динамически выделяемыми
сущностями, организованными по-разному. Наилучший способ управлять такими ресурсами — использовать RAII. Однако если на одну и ту же сущность
должны ссылаться несколько объектов, сложно определить, когда и кто обязан
удалять ресурс.
Листинг 2.17 типичен для разработчика, который учился применению указателей в университете. Реализация двусторонней очереди (deque) получилась бы
60
Глава 2. Современный C++: классы и типы данных
похожей на учебный пример. Код работает достаточно хорошо, но сырые указатели не рекомендуются для управления ресурсами сущностей. Если допустить
любую ошибку, утечки ресурсов будут неизбежны. Кроме того, необходимость
в тщательном управлении возрастает в приложениях, где используются исключения.
Листинг 2.17. Deque, использующий сырые указатели
class Node {
public:
Node(int value) : data(value), prev(nullptr), next(nullptr) {}
int data;
Node* prev;
Node* next;
};
class Deque {
private:
Node* front;
Node* back;
public:
Deque() : front(nullptr), back(nullptr) {}
~Deque() {
{
~Deque()
Обязательно
Обязательно наличие
наличие деструктора
деструктора для
для удаления
удаления
while
while (front
(front !=
!= nullptr)
nullptr) {
{
оставшихся
динамических
сущностей
оставшихся
динамических
сущностей
Node* temp
temp =
= front;
front;
Node*
front
front =
= front->next;
front->next;
delete
delete temp;
temp;
}
}
}
}
void push_front(int
push_front(int value)
value) {
{
void
Node*
Node* newNode
newNode =
= new
new Node(value);
Node(value);
if
if (front
(front ==
== nullptr)
nullptr)
front =
= back
back =
= newNode;
newNode;
front
else
else {
{
newNode->next =
= front;
front;
newNode->next
front->prev
front->prev =
= newNode;
newNode;
front
front =
= newNode;
newNode;
}
}
}
}
void push_back(int
push_back(int value)
value) {
{
void
Node*
Node* newNode
newNode =
= new
new Node(value);
Node(value);
if
if (back
(back ==
== nullptr)
nullptr)
front =
= back
back =
= newNode;
newNode;
front
else
else {
{
newNode->prev =
= back;
back;
newNode->prev
back->next
back->next =
= newNode;
newNode;
back
back =
= newNode;
newNode;
}
}
}
}
void pop_front()
pop_front() {
{
void
if
if (front
(front !=
!= nullptr)
nullptr) {
{
Node*
Node* temp
temp =
= front;
front;
front =
= front->next;
front->next;
front
if
if (front
(front !=
!= nullptr)
nullptr)
front->prev =
= nullptr;
nullptr;
front->prev
else
Две
else
Две ссылки
ссылки
back
на один
один узел
узел
back =
= nullptr;
nullptr;
на
delete temp;
temp;
delete
}
front = back = newNode;
else {
newNode->prev = back;
back->next = newNode;
back = newNode;
}
};
2.9. Ошибка #9
}
void pop_front() {
if (front != nullptr) {
Node* temp = front;
front = front->next;
if (front != nullptr)
front->prev = nullptr;
else
back = nullptr;
delete temp;
}
}
void pop_back() {
if (back != nullptr) {
Node* temp = back;
back = back->prev;
if (back != nullptr)
back->next = nullptr;
else
front = nullptr;
delete temp;
}
}
61
Две ссылки
на один узел
Удаляет
освобождаемые
объекты
int main() {
Deque deque;
deque.push_front(3);
deque.pop_front();
return 0;
}
Анализ
Чтобы поместить узел в дек (очередь) или удалить его из дека, требуется как
минимум два указателя, ссылающихся на этот узел. На один узел также могут
ссылаться и другие указатели (например, в индексированной очереди). Сырые
указатели предоставляют простой способ для нескольких указателей ссылаться
на один узел, но такое совместное использование усложняет управление его
временем жизни.
Существует несколько мест, где требуется удаление узлов. Каждое такое место
должно быть правильно реализовано, иначе велика вероятность утечки ресурсов.
Деструктор и эти отдельные точки удаления создают когнитивную нагрузку, поскольку разработчику приходится решать две задачи: во-первых, обеспечивать
правильное проектирование и программироване дека; во-вторых, осуществлять
управление ресурсами. Такой подход слишком сложен и отвлекает от решения
основной задачи.
Решение
Отсутствие необходимости управлять динамическими ресурсами решает
одну из двух задач, оставляя время и силы для решения другой. Современный
62
Глава 2. Современный C++: классы и типы данных
подход C++ заключается в использовании умного указателя std::shared_ptr
для управления ресурсами. По возможности используйте std::make_shared
вместо new . Разделяемый указатель реализует RAII и предназначен для
случаев, когда на один ресурс требуются две или более ссылок. Указатель
std::shared_ptr управляет ресурсом, отслеживая количество ссылок: счетчик
увеличивается при каждом копировании или присваивании и уменьшается при
выходе shared_ptr за пределы области видимости. Когда счетчик достигает
нуля, ресурс удаляется. Таким образом, пользовательский код управления не
требуется. Очень удобно!
Однако у этого подхода есть и обратная сторона. Указатель std::unique_ptr
менее затратен и должен использоваться для любого ресурса, который не нужно
разделять. Умный указатель std::shared_ptr ресурсоемкий и должен использоваться только тогда, когда совместное владение действительно необходимо.
Во-первых, разделяемые указатели (см. листинг 2.18) занимают больше памяти,
так как хранят счетчик ссылок. Во-вторых, обслуживание счетчика требует создания дополнительных циклов. Поэтому используйте shared_ptr только в случае
крайней нужды, предпочитая unique_ptr там, где это возможно.
Листинг 2.18. Deque с использованием указателей с подсчетом ссылок
class Node {
public:
Node(int value) : data(value) {}
int data;
std::shared_ptr<Node> prev;
Shared_ptr неявно инициализируется
std::shared_ptr<Node> next;
};
class Deque {
private:
std::shared_ptr<Node> front;
std::shared_ptr<Node> back;
public:
Деструктор и удаления не нужны
Deque() : front(nullptr), back(nullptr) {}
void push_front(int value) {
std::shared_ptr<Node> newNode = std::make_shared<Node>(value);
if (front == nullptr)
else
front = back = newNode;
else { front = nullptr;
Деструктор и удаления не нужны
}
newNode->next = front;
}
front->prev = newNode;
};
front = newNode;
}
}
void push_back(int value) {
std::shared_ptr<Node> newNode = std::make_shared<Node>(value);
if (back == nullptr)
2.9. Ошибка #9
63
front = back = newNode;
else {
newNode->prev = back;
back->next = newNode;
back = newNode;
}
}
void pop_front() {
std::shared_ptr<Node>
prev;
if (front != nullptr)
{
Shared_ptr неявно инициализируется
std::shared_ptr<Node>
next;
front = front->next;
};
if (front != nullptr)
front->prev = nullptr;
class Deque {
else
private:
back = nullptr;
std::shared_ptr<Node>
front;
}
std::shared_ptr<Node>
back;
}
public:
void pop_back() {
Деструктор и удаления не нужны
Deque()
: front(nullptr),
if (back
!= nullptr) {back(nullptr) {}
back = back->prev;
if (back != nullptr)
back->next = nullptr;
else
front = nullptr;
Деструктор и удаления не нужны
}
}
};
int main() {
Deque deque;
deque.push_front(3);
deque.pop_front();
return 0;
}
Еще одна приятная особенность std::shared_ptr заключается в том, что он не
нуждается в явной инициализации; при создании он имеет значение nullptr,
что избавляет разработчика от лишней работы. Проблема возникает при использовании умных указателей с подсчетом ссылок — возможны циклические
ссылки, когда одна сущность ссылается на другую через shared_ptr, и наоборот.
Разделяемый указатель хранит счетчик ссылок на общий объект: он увеличивается при каждом создании shared_ptr, ссылающегося на ресурс, и уменьшается
при каждом уничтожении такого указателя. Если счетчик достигает нуля, ресурс
удаляется; однако в случае циклической зависимости он может никогда не стать
нулевым, что приведет к напрасной трате ресурсов.
Код в листинге 2.18 может быть легко преобразован в двусвязный список, подверженный проблемам циклических ссылок. Если вы столкнулись с подобной
ситуацией, рассмотрите возможность использования std::weak_ptr для разрыва
цикличности. Слабый указатель работает как общий, за исключением того, что
64
Глава 2. Современный C++: классы и типы данных
не разделяет владение ресурсом. Перед разыменованием его необходимо преобразовать в std::shared_ptr. Если преобразование успешно — создается валидный
указатель; если нет — возвращается разделяемый указатель, равный nullptr.
Хотя этот пример демонстрирует дек, стандартная библиотека (STL) предлагает собственную качественную реализацию — std::deque. Используйте ее
и не пишите свою, если только вы не хотите проиллюстрировать применение
разделяемых указателей!
Рекомендации
Помните, что использование умных указателей избавляет от необходимости
вручную управлять динамическими ресурсами и позволяет сосредоточиться
на решаемой задаче.
Применяйте std::shared_ptr, когда нужно иметь несколько ссылок на динамический ресурс; не забывайте разрывать циклические ссылки с помощью
std::weak_ptr.
Перед разыменованием обязательно преобразуйте s t d : : w e a k _ p t r
в std::shared_ptr.
В большинстве случаев следует отдавать предпочтение использованию
std::unique_ptr.
Смотри также
«Ошибка #78», чтобы узнать, почему лучше использовать умные указатели.
«Ошибка #8», где обсуждаются умные указатели.
3
Современный C++: общие
принципы программирования
В этой главе
3 Универсальная инициализация
3 Усовершенствованные операторы if
3 Кортежи
3 Структурная привязка
В динамичном мире программирования на C++ практика написания кода часто
формируется под влиянием глубоко укоренившихся привычек, которые влияют
на наши подходы к решению задач. Однако по мере того, как мы разбираемся
в тонкостях традиционных циклов, операторов выбора и методах инициализации
переменных, становится очевидно, что следование традициям далеко не всегда
гарантирует читаемость или эффективность. Хотя эти привычные подходы
сначала могут казаться интуитивно понятными, за ними нередко скрываются
проблемы корректности, читаемости и общей эффективности кода.
C++ опирается на фундаментальные принципы своего предшественника, языка C, но в нем появились новые методы инициализации и агрегации данных.
Эта эволюция порождает собственные сложности. Множество способов инициализации способно привести к когнитивной перегрузке и несогласованности
66
Глава 3. Современный C++: общие принципы программирования
исходных файлов в кодовой базе. Аналогично опора на структуры или классы
для агрегации разнородных данных нередко оказывается громоздкой — особенно когда простота критична. Шаблоны стандартной библиотеки предлагают
альтернативы, но вместе с тем создают новые проблемы, потенциально снижая
читаемость и эффективность кода.
В этой главе мы рассмотрим несколько типичных ошибок, которые помогают
выявить распространенные подводные камни при программировании на C++,
прояснить ключевые проблемы и предложить более удачные подходы. Разбирая
эти ошибки, вы сможете усовершенствовать навыки программирования и глубже
понять особенности языка C++ и средств STL. Цель, как всегда, — сделать свой
код лучше, повышая квалификацию и внедряя более эффективные и удобные
методы написания кода.
3.1. Ошибка #10: неумение использовать if
с инициализацией
Эта практика в целом положительно влияет на эффективность, хотя в некоторых случаях может отрицательно сказаться на читаемости. В цикле for давно
используется инициализация в области видимости оператора, но исторически
оператор if этого не позволял.
Проблема
Необходимо найти ключевое значение в контейнере. Традиционно делалось
так: создавали цикл и проходили по элементам, пока ключ не находился или
контейнер не заканчивался. Сегодня лучше использовать алгоритмы, поставляе
мые в стандартной библиотеке. Типичный подход показан в следующем коде.
Предполагается написать или использовать функцию поиска, возвращающую
булево значение, указывающее, было ли искомое найдено; это можно сделать
с помощью условного оператора. Оператор if использует возвращаемое значение функции, чтобы выбрать одну из ветвей выполнения. В этом случае
возвращается не булево значение, а итератор. Его необходимо проверить на
валидность.
Листинг 3.1. Типичный поиск ключа индексным способом
int main() {
std::vector<int> values;
for (int i = 0; i < 10; ++i)
values.push_back(i*2);
int key = 4;
std::vector<int>::iterator it
std::find(values.begin(),
if (it != values.end())
std::cout << key << " was
else
std::cout << key << " was
return 0;
}
=
values.end(), key);
Определяет, был
ли найден ключ
found\n";
Проверяет итератор,
чтобы выбрать
дальнейшие действия
not found\n";
3.1. Ошибка #10
int main() {
std::vector<int> values;
for (int i = 0; i < 10; ++i)
values.push_back(i*2);
int key = 4;
std::vector<int>::iterator it
std::find(values.begin(),
if (it != values.end())
std::cout << key << " was
else
std::cout << key << " was
return 0;
}
67
=
values.end(), key);
Определяет, был
ли найден ключ
found\n";
Проверяет итератор,
чтобы выбрать
дальнейшие действия
not found\n";
Анализ
Код, необходимый для подготовки поиска, самого поиска и обработки результата, выходит слишком многословным и перегруженным синтаксисом. Все это
допустимо, но могло бы быть намного проще. Это напоминает стиль C (за исключением удобства std::vector!).
Решение
Современный C++ усовершенствовал оператор if, разрешив инициализатор,
ограничивающий область видимости переменной рамками оператора (подобно
for). Эта конструкция называется оператором if с инициализатором. Она очень
важна, поскольку занимает меньше строк кода и не вводит новую переменную
с длинной областью видимости. Кроме того, логика остается в условии, что
улучшает локальную читаемость.
Следующий код демонстрирует поиск ключа с использованием инициализатора.
Он также показывает применение алгоритма стандартной библиотеки для поиска.
Переменная-итератор объявляется и инициализируется до точки с запятой — это
секция инициализатора. Код условия, следующий за точкой с запятой, проверяет,
не равен ли итератор последнему значению (если равен — ключ не был найден).
Условие определяет, по какому пути будет продолжено выполнение оператора.
Использование оператора if с инициализатором делает код компактнее и выразительнее, чем традиционный if. Кроме того, такая форма служит удобным переходом для тех, кто привык к подобным конструкциям в функциональных языках.
Листинг 3.2. И
спользование оператора if с инициализатором
для поиска ключа
int main() {
std::vector<int> values;
for (int i = 0; i < 10; ++i)
values.push_back(i*2);
int key = 4;
if (auto it = std::find(values.begin(),
values.end(), key); it != values.end())
std::cout << key << " was found\n";
else
std::cout << key << " was not found\n";
return 0;
}
Инициализирует
итератор и определяет,
был ли найден ключ
68
Глава 3. Современный C++: общие принципы программирования
Рекомендации
Используйте if с инициализатором, чтобы сократить количество строк, необходимых для получения результата.
Помните, что переменные должны жить ровно столько, сколько используются; главное преимущество if с инициализатором — локализация области
видимости временной переменной, что всегда выгодно.
Смотри также
«Ошибка #13», чтобы разобраться в том, почему не стоит заново писать распространенные алгоритмы, если в STL уже есть хорошие решения.
3.2. Ошибка #11: неиспользование вывода типа
для переменных
Эта ошибка влияет на эффективность. Читаемость также нередко страдает,
поскольку точный тип данных скрыт — его приходится выяснять, когда он
действительно нужен.
Проблема
Старые привычки трудно искоренить. Код в следующем листинге демонстрирует типичный подход: цикл по индексам и оператор выбора, проверяющий
результат поиска. Традиционное использование этих конструкций записалось
на подкорку так прочно, что стало второй натурой. Однако традиционное не
всегда означает правильное.
Листинг 3.3. Типичный цикл по индексам и проверка переменной
int main() {
std::vector<int> values;
for (int i = 0; i < 10; ++i)
values.push_back(i*2);
int key = 4;
std::vector<int>::iterator it
std::find(values.begin(),
if (it != values.end())
std::cout << key << " was
else
std::cout << key << " was
return 0;
}
Типичный цикл по индексам
с управляющей переменной
=
values.end(), key);
found\n";
Тип данных правильный,
но является ли он
интуитивно понятным?
not found\n";
Анализ
Многие традиционные приемы решения повседневных задач — например, выбор
типа переменной или написание циклов — отражают раннюю эволюцию языков
3.2. Ошибка #11
69
программирования. Раньше компиляторы были менее искусны во взаимодействии с разработчиком и зависели от явных обновлений типов: компилятор либо
подтверждал, либо отклонял выбор разработчика. Цикл по индексам настолько
привычен, что просто перестает замечаться, однако его управляющая переменная
технически неверна.
Индексы элементов контейнера всегда начинаются с нуля и увеличиваются
до размера контейнера, но не включают его. Отрицательные значения в этом
контексте недопустимы, однако переменная int способна представлять положительные, нулевые и отрицательные значения. Педант скажет: int не
может быть корректным типом данных для счетчика индекса. Переменная,
используемая для проверки результата работы алгоритма find, требует от нас
знания как типа контейнера и его элементов, так и типа его итератора — все
эти три аспекта необходимо держать в голове. Что еще хуже, они не относятся
напрямую к решаемой задаче, представляя собой лишь побочные сведения.
Это негативно сказывается на эффективности и влияет на выразительность
кода.
Решение
Современный C++, следуя примеру более новых языков, наделяет компилятор
большей интеллектуальностью и гибкостью. Поскольку именно компилятор
в конечном счете знает, корректен ли тип данных, он способен сам определить
идеально подходящий тип и подставить его в код. Листинг 3.4 решает обе упомянутые ранее проблемы.
Во-первых, компилятор определяет допустимый тип для переменной управления циклом — в идеале это должен быть беззнаковый тип. Тем не менее, так как
ее инициализатор равен нулю, будет выведен int. Беззнаковый тип не только
обеспечивает вдвое больший диапазон значений, но и исключает отрицательные
индексы. С математической точки зрения он корректен; фактически — нужен
тип size_t, часто реализуемый как unsigned long или unsigned long long. Он
гарантированно способен представить размер любого объекта, поэтому его
можно смело использовать. Однако все-таки лучше применять auto и позволить
компилятору вывести конкретный тип.
Во-вторых, сложность типа может отвлекать от сути задачи и приводить к скрытым ошибкам. Способность компилятора определять нужный тип данных снимает эту сложность и предотвращает появление странностей. Ключевое слово auto
использует механизм вывода типов и, что еще лучше, автоматически вставляет
эту информацию в код. Разработчик фокусируется на решаемой задаче, а язык
берет на себя рутинные дела, что дает синергетический эффект — каждый занимается тем, что умеет лучше всего.
70
Глава 3. Современный C++: общие принципы программирования
Листинг 3.4. И
спользование auto, чтобы позволить компилятору
самостоятельно определить правильный тип
int main() {
std::vector<int> values;
for (auto i = 0; i < 10; ++i)
Компилятор всегда выбирает правильный тип
values.push_back(i*2);
данных для переменной управления циклом
int key = 4;
if (auto it = std::find(values.begin(),
values.end(), key); it != values.end())
Позволяет компилятору
std::cout << key << " was found\n";
определить правильный тип,
else
и нам при этом приходится
std::cout << key << " was not found\n";
держать в голове меньшее
return 0;
количество информации
}
У ключевого слова auto есть один нюанс: циклы с обратным отсчетом могут быть
довольно коварными. В следующем коде показан простой подход к отображению
элементов в обратном порядке.
Листинг 3.5. Трудноуловимое неправильное использование auto
int main() {
std::vector<int> values;
for (int i = 0; i < 10; ++i)
values.push_back(i*2);
for (auto i = values.size() - 1; i >= 0; --i)
std::cout << values[i] << '\n';
return 0;
}
Реализует цикл в обратном
порядке
Когда этот код запускается в моей системе, он аварийно завершает работу изза ошибки сегментации. Почему? Вспомните, что size_t — выведенный тип
для переменной управления циклом — является беззнаковым. Существует ли
какое-нибудь беззнаковое значение, которое не равно или меньше нуля? Нет.
Поэтому, когда i становится нулем, его следующее значение станет максимальным числом для этого типа данных — почти наверняка выходящим далеко за
границы контейнера. Эту проблему проще всего решить, сделав тип данных int
вместо auto. В этом случае size_t использовать неправильно; это одна из тех
трудноуловимых ошибок, которые могут возникнуть при автоматическом выводе типа. Ни один стандарт не изменит этого, поскольку поведение связано со
стабильностью и обратной совместимостью двоичного (бинарного) интерфейса
приложений (ABI, Application Binary Interface). Ну и бог с ним.
Еще одна неочевидная проблема, влияющая на производительность, возникает, когда auto используется для выведения типа значения в случае, подобном
этому:
auto name = student->getName();
3.3. Ошибка #12
71
Переменная name — это копия имени студента, но лучше было бы использовать
ссылку. Ссылка — это псевдоним объекта std::string, представляющий имя
студента, а не его копия, что позволяет избежать вызова конструктора копирования. Код будет выглядеть следующим образом:
auto& name = student->getName();
Аналогичное различие возникает при использовании auto для вывода типа
управляющей переменной в цикле for по диапазону: такая переменная всегда
является копией элемента. Хотя это позволяет использовать характеристики элементов, предназначенные только для чтения, подход может быть неэффективен,
если размер элемента больше, чем размер указателя. Этот код демонстрирует
предпочтительный способ итерации по списку студентов:
for (auto& student : students) ...
Рекомендации
Используйте auto в большинстве случаев объявления переменных.
Шаблоны могут значительно выиграть от использования ключевого слова auto.
Широко применяйте ссылки с auto, когда размер типа данных больше, чем
размер указателя, чтобы предотвратить копирование.
Разработчику редко нужно знать точный тип — когда это необходимо, его
следует указывать явно.
Смотри также
«Ошибка #10», где мы разбираем, как лучше управлять областью видимости
временных переменных.
3.3. Ошибка #12: использование typedef
Эта ошибка влияет как на читаемость, так и на эффективность. Псевдонимы
типов обычно используются для определения нового типа данных на основе
существующего. Новый тип позволяет более выразительно передать назначение
данных, тем самым улучшает читаемость.
Проблема
Код в листинге 3.6 использует typedef для определения нового типа, лучше
выражающего некоторую абстракцию. Эта абстракция — функция, которая принимает два целых числа и возвращает одно. Тип MathFunction — это указатель на
функцию, так что подходящие функции можно назначать и вызывать как любые
другие. Неочевидно лишь то, что имя псевдонима встроено внутрь объявления,
что делает запись малочитаемой и неинтуитивной.
72
Глава 3. Современный C++: общие принципы программирования
Листинг 3.6. Использование typedef для создания псевдонима типа
typedef int (*MathFunction)(int, int);
int add(int a, int b) {
return a + b;
}
int sub(int a, int b) {
return a - b;
}
Определяет новый тип
на основе функции
Присваивает переменной
нового типа
int main() {
MathFunction f = add;
std::cout << "Result of addition: " << f(5, 3) << std::endl;
f = sub;
std::cout << "Result of subtraction: " << f(5, 3) << std::endl;
return 0;
}
Анализ
Неуклюжесть определения псевдонима унаследована от языка C. C++ попрежнему поддерживает эту технику и сохраняет ее необычный синтаксис.
Если новый тип основан на встроенном типе (например, int), запись все равно
читается странно, поскольку исходный тип ставится перед именем псевдонима — противоположно интуитивному порядку. Например, создать псевдоним
age как беззнаковый целый можно так:
typedef unsigned int age;
Решение
В современном C++ появилось ключевое слово using, которое значительно упрощает объявление псевдонимов типов. Оно следует более интуитивному подходу:
имя псевдонима находится слева, а его определение — справа. Такой синтаксис
читается проще, потому что визуально вызывает ассоциацию с присваиванием.
Кроме того, написание кода становится более эффективным.
Следующий код практически идентичен листингу 3.6; единственное отличие —
способ определения псевдонима. Это сходство говорит о двух вещах: во-первых,
использование псевдонима ничем не отличается от варианта с typedef; во-вторых,
нет причин продолжать применять typedef в новых проектах.
Листинг 3.7. Использование using для создания псевдонима типа
using MathFunction = int (*)(int, int);
int add(int a, int b) {
return a + b;
}
int sub(int a, int b) {
return a - b;
}
int main() {
Имя типа отделено
от его определения
Использование остается
прежним
int add(int a, int b) {
return a + b;
}
int sub(int a, int b) {
return a - b;
}
Имя типа отделено
от его определения
3.3. Ошибка #12
using MathFunction = int (*)(int, int);
73
Использование остается
прежним
int main() {
MathFunction f = add;
std::cout << "Result of addition: " << f(5, 3) << std::endl;
f = sub;
std::cout << "Result of subtraction: " << f(5, 3) << std::endl;
return 0;
}
Если псевдоним основан на встроенном типе (например, int), читаемость
улучшается, поскольку такой код выглядит естественно. Например, можно
определить псевдоним age как беззнаковое целое число следующим образом:
using age = unsigned int;
А теперь рекомендация от профессионалов: не используйте глобальное включение
using namespace std;
в вашем коде (в верхней части исходного файла). Большинство учебников
и многие онлайн-преподаватели игнорируют этот совет, используя это глобальное включение, по-видимому, чтобы избежать необходимости постоянно
писать что-то типа
std::cout << ...
Однако пространство имен std (стандартное) довольно велико и включает
в себя множество идентификаторов. Если применять глобальное включение, то
велика вероятность конфликта имен. Угадайте, какой из идентификаторов будет
использовать компилятор? Я тоже не знаю. Потенциал конфликтов и неоднозначности очень велик и может привести к неопределенному поведению — а кому
это нужно? Отладка таких ситуаций — непростая задача; вы заслуживаете того,
чтобы спать спокойно.
Рекомендации
Всегда используйте using вместо typedef; нет ни одного случая, когда это
было бы невыгодно.
Заменяйте typedef на using в любом коде, где они используются.
Помните, что читаемость кода стоит того, чтобы приложить некоторые
усилия.
Избегайте включения глобального using namespace std;.
74
Глава 3. Современный C++: общие принципы программирования
3.4. Ошибка #13: написание распространенных
алгоритмов
Эта ошибка касается эффективности и читаемости, но повышает корректность. Написание нескольких алгоритмов в каждом проекте, над которым мы
работаем, — стандартная практика, однако она препятствует повторному использованию кода.
Проблема
Когда разработчики становятся опытными в написании нескольких стандартных алгоритмов, они склонны реализовывать их заново для каждого проекта,
дублируя код почти не задумываясь. Многие алгоритмы достаточно просты, поэтому на их написание уходит совсем мало времени — в таких случаях помогает
мышечная память. Приведенный ниже код демонстрирует пару часто встречающихся алгоритмов. Ни один из них не является сложным или трудозатратным,
в них нет ничего оригинального. Они работают и удовлетворяют требованиям
программы. Что тут еще сказать?
Листинг 3.8. Два типичных алгоритма, которые быстро реализуются
template <typename T>
T sum(const std::vector<T>& values) {
T total = (T)0;
for (auto val : values)
total += val;
return total;
}
Хорошая обобщенная функция суммы (sum),
которую можно быстро написать
template <typename T>
bool find(const std::vector<T> values, T key) {
for (auto val : values)
if (val == key)
return true;
return false;
}
Хорошая, обобщенная функция
поиска (find), которую можно
быстро написать
int main() {
std::vector<int> v { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
std::cout << sum(v) << '\n';
if (find(v, 4))
std::cout << "the key 4 was found\n";
else
std::cout << "the key 4 was not found\n";
}
Анализ
Большинство разработчиков привыкают каждый раз делать нечто определенным
образом, формируя устойчивые привычки. Многие из них полезны, но здесь они
3.4. Ошибка #13
75
снижают эффективность. Каждому алгоритму требуется около семи строк кода,
чтобы быть обобщенным и аккуратно оформленным. Однако нередко проще использовать готовые решения, что позволяет разработчику сосредоточиться только
на основной задаче, а не на служебных подзадачах, таких как суммирование и поиск.
Решение
Часто лучший код с наименьшим количеством ошибок — это тот, который вы не
пишете. Меньше строк кода — меньше возможностей ошибиться. Неудивительно,
что разработчики работают эффективнее, когда не тратят время на служебный
код и могут сосредоточиться на своей основной задаче. В листинге 3.9 показано
использование стандартных функций из <algorithm> и <numeric>. Изучение
использования этих средств требует некоторого времени, но в итоге они оказываются крайне удобными.
Дополнительным плюсом является универсальная инициализация, которая
упрощает создание простых векторов (обратите внимание на отсутствие вызовов push_back). Дополнительную информацию по этому вопросу можно найти
в разделе «Смотри также».
Листинг 3.9. Две библиотечные функции, которые не нужно писать
int main() {
std::vector<int> v { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
std::cout << std::accumulate(v.begin(), v.end(), 0) << '\n';
Хорошая обобщенная
auto it = std::find(v.begin(), v.end(), 4);
функция суммы (sum),
if (it != v.end())
std::cout << "the key 4 was found\n";
которую не нужно писать
else
Отличная обобщенная функция поиска
std::cout << "the key 4 was not found\n";
(find), которую не нужно писать
}
Стандартные алгоритмы уже написаны и отлажены так, чтобы удовлетворять
нужды разработчика. Используйте их, когда вам нужны корректность и высокая производительность. Кроме того, это упрощает код и ускоряет разработку.
Рекомендации
Используйте алгоритмы стандартной библиотеки шаблонов для решения
большинства задач — они чрезвычайно гибкие и эффективные.
Сосредоточивайтесь на решаемой задаче, а не на вспомогательных алгоритмах. Это повысит удовлетворенность работой.
Смотри также
«Ошибка #11», там проведен разбор ключевого слова auto.
«Ошибка #14», где можно узнать больше об использовании универсальной
инициализации.
76
Глава 3. Современный C++: общие принципы программирования
3.5. Ошибка #14: неиспользование универсальной
инициализации
Эта ошибка снижает корректность, читаемость и эффективность; в некоторых
случаях она также влияет на производительность. В C++ существует множество
способов инициализации переменных, каждый из которых имеет свои преимущества и недостатки. Однако эти подходы должны быть более последовательными.
Проблема
C++ унаследовал методы инициализации переменных от языка C. Однако в некоторых случаях C++ выходит за рамки C, добавляя собственные формы инициа
лизации. Со временем было введено несколько различных подходов, но ни один
из них не повторяет остальные. Из-за этого в C++ появилось несколько форм
инициализации, и программисту приходится держать в голове, какую в каком
контексте применять. Помните, что разработчик должен сосредоточиться на решае
мой задаче, а не следить за тонкостями синтаксиса. Отслеживание деталей языка
создает ненужную нагрузку, а его непоследовательность снижает эффективность.
Следующий код представляет собой простой пример инициализации вектора
целых чисел. В предыдущей версии C++ не было других способов сделать это,
и приходилось писать больше кода, чем нужно. Ужас!
Листинг 3.10. Т
ипичная инициализация контейнера и простой
переменной
int main() {
std::vector<int> v;
Для последовательных вычислений значений
for (int i = 0; i < 10; ++i)
можно использовать цикл
v.push_back(i);
Непоследовательные значения
v.push_back(33);
приходится добавлять отдельно
v.push_back(42);
int count = v.size();
Инициализация через
std::cout << "the vector has " << count << " elements\n";
присваивание
return 0;
}
Анализ
Если все, что у вас есть, — это функция push_back, то, как можно догадаться, задачи инициализации сводятся к вызовам push_back. Эта ситуация демонстрирует,
что C++ нужны дополнительные способы, чтобы задать начальные значения:
такой код неуклюж и требует больших усилий для выполнения простой задачи.
Есть множество других примеров, но этого вполне достаточно, чтобы понять суть.
Решение
Есть несколько причин использовать универсальную инициализацию, и самая
важная из них — ее последовательность: одна форма записи работает одинаково во
3.6. Ошибка #15
77
всех контекстах. Для контейнеров это особенно полезно. Код в листинге 3.11 демонстрирует, как задать начальные значения для вектора и для обычной переменной.
Значения помещают внутрь фигурных скобок, такой подход быстро становится
предпочтительным, хотя, чтобы к нему привыкнуть, требуется немного времени.
При универсальной инициализации значения заключаются в фигурные скобки.
Так работают и вектор, и переменная count. Единый подход значительно упрощает работу по мере роста опыта: отказаться от других методов бывает непросто,
но результат того стоит.
Листинг 3.11. У
ниверсальная инициализация контейнера
и простой переменной
int main() {
std::vector<int> v {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 33, 42};
auto count {v.size()};
std::cout << "the vector has " << count << " elements\n";
Универсальная инициализация переменной
return 0;
}
(простая переменная)
Универсальная инициализация
контейнера (составная переменная)
Универсальная инициализация использует единую форму для инициализации
любых переменных. Такой подход делает код проще для чтения и написания.
Есть и другие преимущества.
Единообразие между различными типами переменных.
Исключение сужающих преобразований — например, double не сможет неявно инициализировать int.
Сложные структуры данных легко инициализируются (подробнее об этом
см. в материалах о назначенной инициализации (designated initialization)).
Поддержка списка инициализаторов в конструкторах и контейнерах.
Рекомендации
Используйте универсальную инициализацию для любых переменных, включая сложные.
Как только вы освоите этот подход, он окажется логичным и простым.
3.6. Ошибка #15: неиспользование
emplacement‑операций
Эта ошибка связана с производительностью. Добавление в контейнеры крупных
или сложных объектов может обходиться слишком дорого.
78
Глава 3. Современный C++: общие принципы программирования
Проблема
Контейнеры (например, вектор) обычно используют при работе с наборами объектов. Типичный подход — создать объект и затем скопировать его в контейнер,
как показано в листинге 3.12. Создание объекта приводит к вызову конструктора, а помещение его в элемент контейнера вызывает еще один копирующий
конструктор. Если вместо него используется оператор копирующего присваивания, его стоимость примерно сопоставима с вызовом конструктора. Временные
объекты могут обходиться слишком дорого, но с этим ничего не поделаешь.
Использование стандартных шаблонных контейнеров требует аналогичного
двухшагового процесса.
Размещение (emplacement) создает объект непосредственно в нужной области
памяти. Локальные переменные размещаются на стеке; динамически созданные — в куче. Программист не влияет на расположение этих переменных, он
только создает объект. С другой стороны, размещение позволяет разработчику
создать объект прямо там, где он должен находиться.
Следующий код показывает распространенный прием: создание сущности
и последующее добавление его в вектор. Как правило, в момент создания вызывается конструктор. Добавление в вектор приводит к копированию объекта
в его внутренний буфер — снова вызывается конструктор.
Листинг 3.12. Создание и добавление элементов в контейнер
class Resource {
private:
std::string name;
int instance;
static int handle;
public:
Resource(const std::string& n) : name(n), instance(++handle) {}
int id() const { return instance; }
};
int Resource::handle = 0;
int main() {
std::vector<Resource> resources;
resources.push_back(Resource("resource 1"));
resources.push_back(Resource("resource 2"));
for (int i = 0; i < resources.size(); ++i)
std::cout << resources[i].id() << '\n';
return 0;
}
Создает временный объект,
а затем копирует или перемещает
его в элемент вектора
Анализ
Создание объекта Resource требует вызова конструктора. Чтобы добавить сущность в вектор, контейнер копирует содержимое источника в элемент контейнера.
3.6. Ошибка #15
79
Такой подход — единственный способ добиться нужного поведения. И все же
двойная стоимость создания объекта кажется несправедливой. Увы, такова жизнь.
Решение
Современный C++ немного изменил правила, чтобы оспорить распространенное
мнение, что двухшаговая вставка объектов в контейнер бывает слишком дорогой
(а в некоторых случаях это действительно так). Как показано в листинге 3.12,
экземпляр создается только для того, чтобы быть вставленным в контейнер; сам
по себе он больше не нужен. В этом случае объект можно сразу сконструировать
в элементе контейнера, а не создавать отдельно и затем копировать, как продемонстрировано в листинге 3.13. Размещение (emplacement) вызывает только
один конструктор — объект создается непосредственно в ячейке вектора. Эта
техника позволяет исключить лишний вызов конструктора или присваивания,
повышая производительность. Также стоит отметить, что наличие конструктора
перемещения и оператора присваивания с перемещением позволяет помещать
в контейнер некопируемые объекты.
Листинг 3.13. Создание элементов непосредственно в контейнере
class Resource {
private:
std::string name;
int instance;
static int handle;
public:
Resource(const std::string& n) : name(n), instance(++handle) {}
int id() const { return instance; }
};
int Resource::handle = 0;
int main() {
std::vector<Resource> resources;
resources.emplace_back("resource 1");
resources.emplace_back("resource 2");
for (int i = 0; i < resources.size(); ++i)
std::cout << resources[i].id() << '\n';
return 0;
}
Создает объект в элементе
вектора
Не каждый класс подходит для этого приема. Если объект нельзя копировать
или присваивать, компилятор будет громко жаловаться во время сборки. Зато
он будет мурлыкать, если у объекта есть семантика перемещения. Во время
работы контейнера может потребоваться переместить или скопировать его
элементы. Например, если вектор должен увеличиваться, элементы старого
массива переносят в новый. Поэтому размещение применимо только для некоторых типов данных.
80
Глава 3. Современный C++: общие принципы программирования
Рекомендации
Применяйте emplacement, когда объект не используется отдельно от контейнера.
Помните, что контейнеры указателей (желательно умных!) могут оказаться
предпочтительнее контейнеров значений.
Смотри также
Комментарии об использовании только значений или только указателей
в качестве элементов контейнера в разделе «Ошибка #42».
3.7. Ошибка #16: неверное использование кортежей
Эта ошибка влияет на эффективность. Создание структур или классов для
хранения разнородных или нескольких значений отвлекает разработчика от
решения основной задачи.
Проблема
Типичная ситуация — нужно объединить несколько (часто разнородных) типов
данных в одну сущность, чтобы удобнее с ней работать. Как правило, для этого
используют struct или class, что требует дополнительного кода для определения
агрегата. Такой подход работал годами, но для простых структур данных (plain
old data, POD) он чаще всего избыточен, особенно если структура используется
только в нескольких местах.
В листинге 3.14 разработчику необходимо смоделировать три атрибута студента:
имя, возраст и средний балл. Подобным образом потребуется определить еще
несколько классов для других групп данных. Это увеличивает число определений и порождает дублирование. Одно из очевидных преимуществ такого подхода — доступ к членам POD осуществляется по имени поля (об этом подробнее
поговорим позже).
Листинг 3.14. Определение простых POD с помощью структуры
struct Student {
Избыточный код при
std::string name;
определении простого POD
int age;
double gpa;
Student(const std::string& n, int a, double g) : name(n), age(a), gpa(g) {}
};
// другие корректно определенные POD
struct Employee {};
struct Teacher {};
struct Admin {};
int main() {
Инициализация POD
Student s("Susan", 23, 3.85);
std::cout << "student " << s.name << ", " << s.age << " years old, carries a "
<< s.gpa << '\n';
Использование POD
return 0;
}
Student(const std::string& n, int a, double g) : name(n), age(a), gpa(g) {}
// другие корректно определенные POD
struct Employee {};
struct Teacher {};
struct Admin {};
3.7. Ошибка #16
};
81
int main() {
Инициализация POD
Student s("Susan", 23, 3.85);
std::cout << "student " << s.name << ", " << s.age << " years old, carries a "
<< s.gpa << '\n';
Использование POD
return 0;
}
Анализ
Каждый такой агрегат определяет новый тип данных, и каждое поле нужно описать явно. Проще всего использовать конструктор для инициализации экземпляра.
Доступ к переменным осуществляется по имени поля, что является естественным
и эффективным способом работы. Однако приходится дублировать похожий код
и определять каждый новый POD вручную — процесс утомительный и чреватый
ошибками. Кроме того, понимание типов данных ухудшается по мере роста кода:
определения оказываются далеко друг от друга, повторяются похожие фрагменты.
Решение
Как показано в листинге 3.15, ключевое слово using используется для упрощения
объявления имени типа. Это современный аналог typedef из C. В разделе «Смотри также» указана другая ошибка, в которой обсуждается применение using.
Ключевым моментом в этом коде является кортеж. Шаблон std::tuple позволяет
скомпоновать несколько разнородных значений в единый объект, достаточно
просто указать их типы; у полей нет имен, предоставленных пользователем. Такое различие упрощает процесс: больше не нужно придумывать названия полей
и писать лишний код. Инициализация экземпляра выглядит точно так же, как
и в случае со структурой, но без необходимости определять конструктор — еще
одно средство экономии времени и устранения беспорядка.
Последнее отличие, являющееся недостатком, — доступ к элементам осуществляется не по имени, а по индексу. Индексация соответствует порядку объявления
и начинается с нуля. Шаблон функции std::get позволяет получить поле по
индексу. Однако в разделе «Смотри также» упомянут более удобный способ
доступа — структурная привязка (structured binding).
Листинг 3.15. Определение простых POD с помощью кортежей
using Student = std::tuple<std::string, int, double>;
//
//
//
//
другие корректно определенные POD
using Employee = std::tuple<...>;
using Teacher = std::tuple<...>;
using Admin = std::tuple<...>;
Экономичный способ
определить POD
int main() {
Инициализация POD
Student s("Susan", 23, 3.85);
std::cout << "student " << std::get<0>(s) << ", " << std::get<1>(s)
<< " years old, carries a " << std::get<2>(s) << '\n';
Использование
return 0;
POD
}
82
Глава 3. Современный C++: общие принципы программирования
Шаблон std::tuple предоставляет компактный и удобный способ описывать
агрегаты данных, когда полная семантика класса не требуется. Такой подход
особенно подходит для POD. Шаблоны можно использовать для дальнейшего
обобщения кортежей. Например, так можно определить и использовать Student:
template <typename T1, typename T2, typename T3>
using Student = std::tuple<T1, T2, T3>;
И его инициализация будет выглядеть следующим образом:
Student<std::string, int, double> s("Susan", 23, 3.85);
Доступ к полям остается прежним.
Здесь есть одна существенная оговорка: если Student и Employee определены
одинаково, это фактически один и тот же тип. Любая функция или контейнер,
принимающие такой Student, также примет и Employee — это не пример принципа подстановки Барбары Лисков (Liskov Substitution Principle)1, а серьезный
недостаток дизайна. Продумывайте использование таких типов заранее.
Еще одно применение кортежей — возвращение нескольких значений из функции. В таких случаях шаблон std::tuple — удачное решение. Это также повод
использовать шаблоны с ключевым словом using для именования подобных
типов. Выходные параметры следует заменить кортежами для повышения
читаемости.
Рекомендации
Для простых структур (обычно POD) используйте шаблон std::tuple, чтобы
упростить код и улучшить читаемость.
Рассмотрите возможность обобщения кортежей с помощью шаблонов.
Возвращайте несколько значений с помощью кортежа.
Смотри также
«Ошибка #85», где мы обсудим, почему лучше возвращать структуру, а не
использовать выходные параметры.
«Ошибка #12», чтобы понять, как эффективно использовать using . Это
упрощает объявление новых типов данных, если применять метод так же,
как показано в разделе«Ошибка #16».
1
Принцип подстановки Барбары Лисков (Liskov Substitution Principle, LSP) — это один
из пяти основополагающих принципов объектно-ориентированного проектирования
SOLID, сформулированный Барбарой Лисков в 1987 году. Заключается в том, что
функции, которые используют базовый тип, должны иметь возможность применять
подтипы базового типа, не зная об этом. — Примеч. ред.
3.8. Ошибка #17
83
«Ошибка #17», чтобы узнать об элегантном способе доступа к значениям
кортежа.
3.8. Ошибка #17: неиспользование
структурной привязки
Эта ошибка влияет на читаемость и эффективность. Кортежи позволяют передавать или возвращать несколько значений с меньшими усилиями, чем структуры или классы; однако обращаться к их элементам менее удобно — такой код
сложнее писать и читать.
Проблема
Пример из «Ошибки #16» показал, что код в листинге 3.16 позволяет отказаться
от написания структур или классов и опираться на шаблон std::tuple. Простота
такого подхода — его главный плюс. Но для доступа к значениям приходится
применять шаблонную функцию std::get. Код выглядит громоздким и неудобным, что ставит под сомнение его практическую пользу. Метод ценен благодаря
своей простоте, но он делает код невыразительным.
Листинг 3.16. Извлечение значений кортежа с помощью шаблона функции
using Student = std::tuple<std::string, int, double>;
int main() {
Student s("Susan", 23, 3.85);
std::cout << "student " << std::get<0>(s) << ", " << std::get<1>(s) <<
" years old, carries a " << std::get<2>(s) << '\n';
Используется
return 0;
шаблонная функция
}
std::get с индексацией
Анализ
Кортеж Student легко создать. Его инициализация выглядит как вызов конструктора пользовательского класса. Это создает ожидание, что дальнейшая работа
окажется такой же простой, однако доступ к полям доказывает обратное. Поля
кортежа располагаются в порядке их объявления. Поскольку у них нет имен,
доступ возможен только по индексу через get. Такой способ работает, но несоответствие между ожидаемым «именем» поля в голове разработчика и индексом
вызывает дискомфорт.
Решение
Гибкость шаблона std::tuple ограничивается неудобством доступа к его полям.
Очень легко ошибиться, указав неправильный индекс, или изменить порядок
полей, забыв при этом обновить соответствующие вызовы get.
84
Глава 3. Современный C++: общие принципы программирования
Структурная привязка разлагает кортеж или другой объект с публичными
(public) полями на отдельные значения. В некоторых языках это называется
деструктуризацией (destructuring). Синтаксис прост: используется ключевое
слово auto, затем в квадратных скобках перечисляются переменные для каждого
поля. После этого кортеж присваивается этой конструкции. Следующий пример
демонстрирует простоту использования структурной привязки (листинг 3.17).
Она может потребовать больше нажатий клавиш, чем вызовы get, но наличие
именованных переменных компенсирует это неудобство (если оно возникнет).
Листинг 3.17. И
спользование структурной привязки для извлечения
значений кортежа
using Student = std::tuple<std::string, int, double>;
Использует структурную привязку для разложения кортежа
int main() {
Student s("Susan", 23, 3.85); auto [name, age, gpa] = s;
std::cout << "student " << name << ", " << age <<
" years old, carries a " << gpa << '\n';
Использует имена
return 0;
переменных вместо индексов
}
Структурная привязка может привести к проблемам с корректностью и читае
мостью, если порядок полей в структуре меняется. Будучи переставленными,
имена переменных в листинге 3.17 перестанут соответствовать значениям.
Например, если в структуре поменять местами age и gpa, переменные будут содержать неправильные значения. Если типы совместимы, код в листинге 3.17
продолжит работать (без ошибок во время сборки и выполнения), но значения
будут неверными. Поэтому важно следить, чтобы порядок полей соответствовал
порядку переменных структурной привязки.
Рекомендации
Используйте структурную привязку для декомпозиции POD и простых
структур или классов.
Помните, что для работы структурной привязки объект должен иметь пуб
личные (public) поля.
Смотри также
«Ошибка #85», где мы обсудим, почему std::pair предпочтительнее выходных параметров.
«Ошибка #16», чтобы понять, почему std::tuple может быть предпочтительнее std::pair.
4
Современный C++:
дополнительные вопросы
В этой главе
3 Усовершенствование форматирования текста
3 Регулярные выражения
3 Лямбда-выражения
3 Вариативные шаблоны (шаблоны с переменным числом
аргументов)
3 Переносимый код для работы с файловой системой
Современный C++ расширил диапазон и глубину возможностей, доступных разработчикам. Многие области были усовершенствованы, а хорошо продуманные
и четко сформулированные рекомендации вошли в стандарт. Эта глава представляет собой довольно скромную выборку из множества функций, которые
делают повседневное программирование проще, надежнее и удобнее для чтения.
Практически любой разработчик C++ извлечет пользу из этих дополнительных
разделов, хотя, возможно, заметит, что некоторые важные темы остались без
внимания. Это справедливо: в языке слишком много интересных возможностей,
чтобы упомянуть их все. Тем не менее считайте этот материал отправной точкой
для освоения некоторых полезных улучшений.
86
Глава 4. Современный C++: дополнительные вопросы
Работа с текстом стала проще благодаря новому заголовочному файлу <format>
(пока еще не поддержан всеми производителями компилятора). Парсинг текста
вручную часто приводит к сложностям и ошибкам, но регулярные выражения
облегчают задачу.
Стремление включить элементы функционального программирования побудило
Комитет по стандартизации (Standard Committee) принять диапазоны и улучшить взаимодействие с функциями, определяемыми по месту их использования, — лямбда-выражениями. О лямбда-выражениях написано множество книг,
но даже краткое знакомство с их возможностями, надеюсь, пробудит интерес
к их изучению и использованию. Диапазоны реализуют концепцию фильтров,
знакомую по Linux (обычно используются с оператором конвейера: |), результат
одной операции становится входными данными для следующей, образуя очень
мощные конвейеры обработки данных.
Код для работы с файловой системой традиционно разрабатывается под конкретную платформу. Для кросс-платформенной поддержки ранее применялся механизм условной компиляции. Современный C++ предлагает абстрагироваться
от этих различий, что позволяет использовать единый код на любой платформе.
Наконец, благодаря новым возможностям: предопределенным математическим
константам (например, число пи), разделителям тысяч в числовых литералах
и пользовательским литералам, облегчающим восприятие и полезным при преобразовании единиц измерения, — значительно улучшилась читаемость.
4.1. Ошибка #18: неиспользование
вариативных шаблонов
Эта ошибка отрицательно сказывается на эффективности и читаемости. Списки
параметров переменной длины естественны для многих задач, но их обработка
увеличивает сложность и накладные расходы.
Проблема
Вариативные списки аргументов (возможность вызвать функцию с переменным числом аргументов) встречаются достаточно часто, поэтому в языке C
уже давно предусмотрены средства для их обработки. C++ унаследовал этот
подход (см. раздел «Смотри также»), но он оказался слишком проблематичным.
Современный C++ предлагает более чистое решение, хотя и не без сложностей
и излишних затрат. В листинге 4.1 параметры собираются в контейнер и передаются функции sum (для этого лучше использовать алгоритм std::accumulate).
При этом не требуется специальных вызовов функций вроде va_list, и работа
с контейнером остается простой. Такое решение шаблонизировано, что позволяет
работать с любыми типами, поддерживающими оператор сложения. Однако
за это удобство приходится платить: разработчик должен создать контейнер,
4.1. Ошибка #18
87
упаковать в него аргументы и передать в функцию. Все эти действия не имеют
отношения к решению основной задачи — лишь служебная работа по упаковке
данных. Чтение такого кода требует разделять внимание между логикой решения
и вспомогательными действиями.
Листинг 4.1. И
спользование вектора для реализации списка
с переменным числом аргументов
template <typename T>
T sum(T initial, const std::vector<T>& vals) {
T sum = initial;
for (int i = 0; i < vals.size(); ++i)
sum += vals[i];
return sum;
}
Выбирает шаблонную форму
для большей гибкости
int main() {
Создает контейнер
std::vector<int> intvalues;
Заполняет контейнер
for (int i = 1; i < 10; ++i)
intvalues.push_back(i);
std::cout << sum(0, intvalues) << '\n';
Наконец, решает задачу
std::vector<double> doublevalues;
for (int i = 1; i < 4; ++i)
doublevalues.push_back(i);
std::cout << sum(5.0, doublevalues) << '\n';
return 0;
}
Анализ
Решение из листинга 4.1 рекомендовано в предыдущем разделе (см. раздел
«Смотри также»). Однако оно создает дополнительную нагрузку и снижает
эффективность, хотя и открывает новые возможности для улучшения. Программисту приходится вручную формировать структуру со списком значений.
Это чистые накладные расходы: действие не решает задачу, но необходимо для
объединения значений в пригодную для использования форму. Чем больше
кода, не ориентированного непосредственно на решение основной задачи, тем
ниже эффективность разработчика и выше вероятность ошибок. Кроме того,
это просто скучно.
Решение
Современный C++ предоставляет отличный способ использовать рекурсивный
подход для решения этой задачи. Идея в том, чтобы передать набор аргументов
переменной длины в общую шаблонную функцию. Она рекурсивно разбирает
(парсит) вариативный список аргументов на пары: первый аргумент и оставшиеся. Рекурсия продолжается, пока не останется единственный аргумент.
Для него создается отдельная (базовая) версия шаблона, принимающая один
88
Глава 4. Современный C++: дополнительные вопросы
параметр, — здесь происходит перегрузка общего шаблона. Существенное преимущество шаблонов в том, что их вычисление выполняется на этапе компиляции, что экономит ресурсы времени выполнения.
В листинге 4.2 показаны эти две шаблонные функции: специализированная
(базовая) и рекурсивная (общая). Первая принимает один параметр и просто возвращает его. Вторая тоже проста: принимает крайний левый параметр
и «упаковку» (pack) оставшихся. При каждом вызове текущий шаблон (концептуально) помещается в стек, а оставшиеся параметры передаются в следующий
вызов. Этот вызов шаблона разделяет список параметров на первый аргумент
и оставшиеся. Специализированная версия вызывается, когда остается ровно
один параметр. Затем все (концептуально) помещенные в стек вызовы последовательно извлекаются, каждый со своим единственным аргументом, и из них
складывается итоговый вызов функции.
Чтобы построить такой вариативный шаблон, сначала напишите базовый случай,
а затем — рекурсивный. Вот и все! Когда компилятор парсит вызовы, он ищет
функцию суммирования, способную работать с несколькими параметрами. Он
находит пару шаблонов с одним параметром и вариативным пакетом. Компилятор продолжает вызывать вариативную версию, пока удается создавать пары
параметров (крайний левый и оставшиеся). Когда остается один параметр,
вариативную версию применить нельзя, и выбирается базовый шаблон с одним аргументом. Без этой базовой функции прием не работал бы. Но с ней он
работает — и работает великолепно.
Листинг 4.2. И
спользование шаблонной функции с вариативным списком
параметров
template <typename T> // базовый случай
T sum(T t) { return t; }
template <typename T, typename... Pack> // общий (рекурсивный) случай
T sum(T t, Pack... remaining) { return t + sum(remaining...); }
int main() {
std::cout << sum(3, 4) << '\n';
std::cout << sum(3, 4, 5) << '\n';
std::cout << sum(3, 4, 6, 7) << '\n';
return 0;
}
Решает задачу
естественным образом
Для дальнейшего развития можно использовать выражения свертки (C++17
и более поздние версии). Шаблоны из листинга 4.2 можно упростить так:
template <typename T, typename... Pack>
T sum(T t, Pack... remaining) {
return (t + ... + remaining);
}
4.2. Ошибка #19
89
Рекомендации
Используйте шаблоны с вариативными пакетами для чистой обработки
списков параметров переменной длины.
Избегайте устаревших приемов из ранних версий C++ при работе с такими
списками.
Смотри также
«Ошибка #40», где мы обсудим списки параметров переменной длины.
4.2. Ошибка #19: использование перечислений enum
глобального пространства имен
Эта ошибка влияет на эффективность и читаемость, а также может оказать едва
заметное, но существенное влияние на корректность. Перечисления позволяют разработчикам присваивать имена литеральным значениям, что повышает
символическую читаемость, однако они должны быть реализованы корректно
и чисто.
Проблема
Переменные и константы можно использовать для именования абстрактных
понятий — например, цветов. Так, целочисленной константе можно присвоить
значение, соответствующее красному или синему цвету. Если разработчик не
проявляет осторожность, такие сопоставления могут быть настолько произвольными, что читателю потребуются дополнительные пояснения — особенно
если в коде встречается несколько подобных сущностей. Язык C++ упростил
эту задачу, введя константы перечисления enum. Каждая именованная сущность
в перечислении представляет одно из возможных значений в заданном наборе.
Константы перечисления (enumerated constants) реализуются как целые числа, поэтому их легко преобразовывать в целые числа и обратно. Это может
показаться полезным, но зачастую не имеет особого смысла. В листинге 4.3
целочисленной переменной, представляющей цвет, присвоено значение 2. Что
это значит?
Наш разработчик работает над задачей, связанной с автомобилями и их взаимодействием. У автомобилей и светофоров есть цвета — поэтому имеет смысл
использовать перечисления, чтобы оперировать символическими именами.
Компилятор автоматически присваивает значения элементам enum, а изменения
(то есть добавление, удаление или перестановка) учитываются без участия программиста. Однако в приведенном примере разработчик столкнулся с рядом
проблем. Следующий код демонстрирует (жалкую) попытку смоделировать
атрибут цвета для автомобилей и светофоров.
90
Глава 4. Современный C++: дополнительные вопросы
Листинг 4.3. Проблемные перечисления в глобальном пространстве имен
enum TrafficColor {
Green,
Yellow,
Red
};
enum PaintColor {
Gray,
White,
Black,
Paint_Red,
Blue,
Paint_Yellow,
Paint_Green
};
int main() {
int color = 2;
switch (color) {
case Red:
std::cout <<
break;
case Blue:
std::cout <<
break;
case Yellow:
std::cout <<
break;
default:
std::cout <<
break;
// case White:
};
return 0;
}
Красный сигнал светофора введен
в глобальное пространство имен
В обоих перечислениях индекс
по умолчанию равен 1
Нельзя повторно использовать одно и то же имя
в перечислении — его необходимо изменить
Присвоение и использование int для
хранения значения перечисления
"we have a red one\n";
"we have a blue one\n";
"we have a yellow one\n";
"we have a white one\n";
Дублирующееся значение индекса в перечислении —
ошибка компиляции
Анализ
Константы enum находятся в глобальном пространстве имен — наряду со всеми
символами, не помещенными в отдельное пространство имен. При использовании
подхода, показанного в листинге 4.3, разработчик обнаружил несколько проблем.
Во-первых, как уже отмечалось, все константы находятся в одном пространстве
имен, что вызывает ошибку при дублировании значений. Попытка переименовать цвет краски показывает, что разработчик вынужден искусственно изменять
имена, чтобы обойти эту проблему.
Во-вторых, поскольку целые числа служат основой для констант, любой код, который использует несколько различных enum, рискует столкнуться с конфликтами
4.2. Ошибка #19
91
значений. Например, в операторе switch не может быть двух кейсов с одиновыми
числовыми значениями — такими как Yellow в TrafficColor и White в PaintColor
(каждому из них присвоено одно и то же значение; в данном случае это 1). В результате логика программы может стать неоднозначной.
В-третьих, переменной, представляющей цвет, можно присвоить произвольное
целочисленное значение. В примере ей присваивается значение 2, и разработчику придется потрудиться, чтобы четко объяснить, что означает это присвоение
и к какой константе перечисления оно относится.
Наконец, лежащая в основе целочисленная переменная не может определить,
выходит ли значение за допустимые границы. Ей можно присвоить отрицательное число или значение, превышающее максимально допустимое. Ни читатель,
ни программа не поймут, что имелось в виду. Хуже того, код может продолжать
работать, но при этом вести себя непредсказуемо (опять эта история с неопределенным поведением).
Решение
Современный C++ приходит на помощь! Теперь перечисления можно инкапсулировать в пространстве имен класса. Это расчищает глобальное пространство
имен, позволяя использовать одинаковые имена констант в разных контекстах.
Хотя тип базовой переменной остается целочисленным, присвоить ей произвольное целое значение нельзя — оно должно принадлежать множеству перечисляемых констант класса. Любая попытка выйти за эти пределы пресекается
автоматически, поскольку за их границами просто не существует допустимых
констант, а значит, невозможно присвоить недопустимое значение.
В следующем коде демонстрируется использование класса enum, изолирующего
все определенные значения внутри одного класса, тем самым устраняя конфликты с другими. Константы одного класса (и его пространства имен) нельзя
присвоить переменной другого класса, что предотвращает некоторые из ранее
описанных проблем.
Листинг 4.4. Перечисления с отдельными пространствами имен
enum class Traffic {
Green,
Yellow,
Red
};
enum class Paint {
Gray,
Black,
White,
Red,
Blue,
Yellow,
Green
};
Перечисление, ограниченное
пространством имен
Перечисление, ограниченное другим
пространством имен
int main() {
auto color = Traffic::Yellow;
Используется auto для вывода типа данных
из инициализирующего значения
Литерал перечисления отличается
Yellow,
Red
};
enum class Paint {
Перечисление, ограниченное другим
Gray,
имен
92 Black,
Глава 4. Современныйпространством
C++: дополнительные
вопросы
White,
Red,
Blue,
Yellow,
Green
};
Используется auto для вывода типа данных
из инициализирующего значения
int main() {
auto color = Traffic::Yellow;
Литерал перечисления отличается
switch (color) {
от одноименного литерала в другом классе
case Traffic::Red:
std::cout << "we have a red light\n";
break;
case Traffic::Yellow:
std::cout << "we have a yellow light\n";
break;
case Traffic::Green:
default:
std::cout << "we have a green light\n";
break;
};
}
Paint can = Paint::Blue;
switch (can) {
case Paint::Red:
std::cout << "we have a red cube\n";
break;
case Paint::Yellow:
std::cout << "we have a blue cube\n";
break;
case Paint::White:
Устраняет предупреждения о преднамеренном
[[fallthrough]];
переходе к следующему кейсу (fall-through)
default:
std::cout << "we have a white cube\n";
break;
};
return 0;
Аннотация [[fallthrough]] документирует случаи преднамеренного перехода
к следующему кейсу в операторе switch, когда ключевое слово break намеренно
пропускается. Оператор switch часто становится источником ошибок, поэтому
стоит использовать все доступные средства для их предотвращения. Не забывайте ставить завершающую точку с запятой (;) после последней закрывающей
скобки (}).
Классы перечислений изолируют значения в собственных пространствах имен
и не позволяют присваивать переменным произвольные значения. С помощью
такой важной функциональности решаются проблемы классического enum
и устраняются неоднозначности. Если необходимо указать базовый тип хранения
для класса enum, это можно сделать путем расширения основного типа:
4.3. Ошибка #20
93
enum class Traffic : uint8_t {
...
};
Рекомендации
Замените глобальные перечисления enum на классы enum, чтобы устранить
описанные проблемы.
4.3. Ошибка #20: неиспользование новых функций
форматирования
Эта ошибка влияет на эффективность и читаемость, а в некоторых случаях —
и на корректность. Правильное форматирование вывода — это целое искусство.
Тот редкий случай, когда форматирование в стиле С имеет несколько явных
преимуществ перед неуклюжим подходом C++.
Проблема
Язык C предлагал полезное, хотя и рискованное решение для детального форматирования выводимого текста. Семейство функций printf предоставляло специ
фикатор формата, включающий произвольный текст и встроенные инструкции по
форматированию переменных. В спецификаторе использовались символы, указывающие, где будет вставлена переменная, за которыми следовал список переменных или выражений. Однако проблемы возникали, когда заполнители переменных в спецификаторе формата не соответствовали типу переменной. Компилятор
не проверял ни соответствие типов спецификаторам, ни то, чтобы количество
переменных совпадало с количеством заполнителей. Другими словами, трудно
достичь правильного поведения, а неопределенное — всегда непредсказуемо.
Предположим, мы создаем модель школы. Один из основных типов данных
в этом примере — Student. Естественно, как только у нас появляется студент, мы
хотим знать его имя, возраст и средний балл. Чтобы вывести эти данные, специ
фикатор формата должен включать три заполнителя — по одному для каждого
свойства. Код должен гарантировать, что заполнители соответствуют типам
данных свойств, а аргументы указаны в правильном порядке. Следующий код
демонстрирует корректный пример без излишеств. Большинство разработчиков
хорошо знакомы с этим подходом.
Листинг 4.5. Использование функции sprintf для точного форматирования
struct Student {
std::string name;
int age;
double gpa;
Student(const std::string& n, int a, double g) : name(n), age(a), gpa(g) {}
};
int main() {
Student dinah("Dinah", 26, 3.3);
char buffer[256];
sprintf(buffer, "%s is a student aged %d carrying a gpa of %.2f",
dinah.name.c_str(), dinah.age, dinah.gpa);
struct Student {
94 std::string
Глава 4. Современный
C++: дополнительные вопросы
name;
int age;
double gpa;
Student(const std::string& n, int a, double g) : name(n), age(a), gpa(g) {}
};
int main() {
Student dinah("Dinah", 26, 3.3);
char buffer[256];
sprintf(buffer, "%s is a student aged %d carrying a gpa of %.2f",
dinah.name.c_str(), dinah.age, dinah.gpa);
Понятное форматирование
std::cout << buffer << '\n';
(но со всеми рисками,
return 0;
присущими sprintf)
}
Анализ
Хотя этот код написан корректно, стоит лишь поменять местами несколько
спецификаторов формата или переменных во время правок — и вы получите
неожиданное (и снова неопределенное) поведение. Почти любое изменение
будет успешно компилироваться, что является тревожным сигналом. Одна из
больших проблем заключается в том, что спецификатор %s требует char*, а не
std::string. Это легко исправить, но есть еще один момент, который отвлекает
от решения основной задачи.
Классический C++ предложил нам шаблон std::stringstream, который работает с оператором operator<< и обеспечивает аккуратное преобразование
большинства данных, в первую очередь числовых значений, в строки. Другая
ошибка рекомендует это решение во многих случаях; однако строковые потоки (string streams) становятся слишком неудобными, когда форматирование
усложняется.
Следующий код предлагает способ решения этой проблемы, но вряд ли он
легче читается — скорее наоборот. Его самое главное преимущество в том, что
невозможно получить неопределенное поведение из-за ошибочного указания
переменных: базовый поток символов корректно отформатирует любой используемый тип данных.
Давайте добавим новое требование: средний балл учащегося должен быть отформатирован таким образом, чтобы он всегда имел одну цифру перед десятичной
точкой и две после нее — ведь никто не хвастается, говоря: «У меня средний балл
3,8», когда на самом деле он 3,85. Кроме того, для отчетов средний балл должен
иметь ровно две цифры после запятой для общей согласованности и удобства
выравнивания столбцов. Администрация школы непреклонна: они не хотят вручную выравнивать средний балл 4 и 3,85 — им нужны 4,00 и 3,85. Таким образом,
к значению 4 необходимо добавить два нуля, а к 3,5 — один, чтобы результат
выглядел как 3,50. Как показывает следующий код, реализовать это требование
сложнее, чем описать словами. Заставить переменную gpa корректно добавлять
нули в конце строки — непростая задача, и для ее решения пришлось немного
повозиться. Это не так очевидно, как может показаться.
4.3. Ошибка #20
95
Листинг 4.6. Использование stringstream для точного форматирования
struct Student {
std::string name;
int age;
double gpa;
Student(const std::string& n, int a, double g) : name(n), age(a), gpa(g) {}
};
int main() {
Student dinah("Dinah", 26, 3.3);
std::stringstream str;
str << dinah.gpa;
std::string gpa_str(str.str());
str.str("");
str << dinah.name << " is a student aged " << dinah.age << " carrying a
gpa of "
<< gpa_str << std::setw(4 - gpa_str.size()) << std::setfill('0') << "";
std::cout << str.str() << '\n';
Сложное форматирование ради
return 0;
незначительного результата
}
Решение
Современный C++ предлагает изящное решение, которое сочетает в себе простоту спецификации функции семейства printf и безопасность типов строкового
потока. Листинг 4.7 демонстрирует тот же принцип, что и printf, но с использованием {} в качестве заполнителей и списка переменных или выражений
после спецификатора формата. Преимущество использования такого подхода
заключается в том, что вывод остается интуитивным и наглядным, а размещение переменных в тексте — очевидным. Но еще лучше то, что компилятор сам
определяет тип данных и выполняет преобразование в строку автоматически,
с соблюдением безопасности типов. Возможность тонкой настройки вывода
переменных напоминает стиль языка C, но выглядит чище и единообразнее.
Конечно, нужно немного поэкспериментировать, чтобы разобраться в этих
символах и их взаимодействии, но это несложно. В результате форматирование
вывода остается простым и легкочитаемым, без каких-либо проблем, связанных
с несоответствием типов. Теперь требование о наличии двух цифр в конце реализуется простым способом: ширина поля (4) и символ заполнения (0) задают
формат так, что средний балл 4 выводится как 4,00; средний балл 3,5 — как 3,50;
а 3,85 — как 3,85. Во всех случаях столбцы выстраиваются последовательно
и заполняются по мере необходимости.
ПРИМЕЧАНИЕ Часть функциональности std::format, входящей в заголовок format,
доступна в ограниченном числе компиляторов. Стандарт C++20 определяет эту возможность, но некоторые компиляторы все еще не поддерживают ее. Мне, например,
пришлось установить Ubuntu 23.10 и использовать компилятор GCC (версии 13+),
чтобы получить эту функциональность. В Clang и MSVC она уже реализована, так
что, возможно, вам повезет больше.
96
Глава 4. Современный C++: дополнительные вопросы
Листинг 4.7. Использование std::format для точного форматирования
struct Student {
std::string name;
int age;
double gpa;
Student(const std::string& n, int a, double g) : name(n), age(a), gpa(g) {}
};
int main() {
Student dinah("Dinah", 26, 3.3);
std::cout << std::format("{} is a student aged {} carrying a gpa of
➥{:0^4}", dinah.name, dinah.age, dinah.gpa) << '\n';
функциональность, подобreturn 0;
ная sprintf, но с типовой
}
безопасностью
Рекомендации
Используйте std::format, чтобы получить преимущества функций семейства
printf без скрытых проблем, которые они создают.
Строковые потоки обеспечивают более строгую типовую безопасность, чем
функции в стиле printf, однако с их помощью сложнее выполнять тонкое
форматирование.
Некоторые компиляторы еще не реализовали функциональность std::format;
проверьте, поддерживается ли она в вашем.
C++23 предлагает std::print и std::println, упрощающие вывод и согласующиеся с подходом, используемым в таких языках, как Java и Python.
Смотри также
«Ошибка #36», где мы обсуждаем проблемы, связанные с использованием
семейства функций printf.
4.4. Ошибка #21: неиспользование диапазонов
вместе с контейнерами
Эта ошибка существенно влияет на эффективность. Код для отображения, фильтрации или агрегации данных часто получается перегруженным и утомительным,
а бˆольшая часть кода сосредоточена на рутинных задачах.
Проблема
Контейнеры данных представляют собой эффективное и компактное средство
для работы с последовательностями значений. Векторы являются отличным
выбором, поскольку они просты в использовании, гибки и обладают широкими возможностями. Однако код, необходимый для их использования, по сути,
4.4. Ошибка #21
97
требует выполнения рутинных действий, которые отвлекают разработчика от
решения основной задачи. Циклы for по диапазону — значительный шаг в сторону более функционального программирования. Они намекают на возможность
использования потоков данных.
Нашему разработчику необходимо обработать последовательность значений:
преобразовать все отрицательные в их абсолютные значения, исключить нечетные и просуммировать оставшиеся. Листинг 4.8 представляет собой простой подход к выполнению этой задачи и является общим подходом к работе
с последовательностями. Первые два цикла можно объединить, но операции
станут менее понятными. Выполнение одного функционального аспекта за раз
более понятно, но менее производительно. Как правило, в большинстве случаев
я предпочитаю читаемость производительности.
Листинг 4.8. Н
есколько независимых функций, выполняющих операции
над последовательностью значений
int main() {
std::vector<int> vals { 42, 3, 7, -9, 0, 22, 23, -7, 22 };
for (auto v : vals)
if (v < 0)
v = -v;
std::vector<int> evens;
for (const auto v : vals)
if (v % 2 == 0)
evens.push_back(v);
int sum = 0;
for (const auto v : evens)
sum += v;
}
Преобразование отрицательных значений
в их положительные аналоги
Для простоты создается
новый контейнер
Отфильтровывает отрицательные значения
Суммирует все значения
во втором контейнере
std::cout << sum << '\n';
return 0;
Анализ
Код выполняет ту функциональность, которая требовалась от разработчика. Он
прост и очевиден — понятно, что происходит, как он должен работать и каким
будет результат. Однако дублирование циклов наводит на мысль, что необходимо
использовать нечто получше. К сожалению, вариантов немного. Объединить
первые два цикла возможно, но это отрицательно скажется на читаемости. Использование второго контейнера также кажется не совсем удобным. Корректно
изменить вектор на месте довольно сложно, поэтому здесь для сохранения
чистоты и корректности используется второй вектор. Это также влияет на производительность.
98
Глава 4. Современный C++: дополнительные вопросы
Решение
Преимущество последовательностей данных состоит в том, что их можно обрабатывать как конвейер. Объекты cin и cout используют именно этот подход.
Файловый ввод и вывод также следуют модели потока данных. Эта модель
привлекательна, и такой подход стоит использовать везде, где это возможно.
Издержки на их применение сокращаются, а код, необходимый для манипуляции с ними, сводится к минимуму. В C++ появился заголовочный файл ranges
с диапазонами и представлениями для работы с потоками значений. Они сокращают код до минимума, особенно при использовании лямбда-выражений.
Отличительная особенность диапазонов — функции можно объединять в композиции. Это означает, что они вызываются последовательно одна за другой.
Простая аналогия — программы-фильтры Linux, используемые в командной
строке или скриптах. Выходные данные первой функции-фильтра подаются
на вход следующей, ее выход — на вход следующей и т. д. Часто такую композицию фильтров называют конвейером, что является подходящим наглядным
термином.
В следующем коде представлены три основные функциональные формы для
работы с потоками данных:
преобразование (Mapping);
фильтрация (Filtering);
агрегация (Reduction).
Важно понимать, что здесь слово фильтр используется в особом значении.
В примере с Linux оно употребляется в традиционном смысле, а в контексте
диапазонов — обозначает тип функциональности. Эти два понятия связаны, но
не эквивалентны. Код в листинге 4.9 демонстрирует композицию двух функций — взятия абсолютного значения и предиката четности.
Преобразование (Mapping) — это функция, которая принимает каждый элемент
входного потока, выполняет над ним операцию и создает новый элемент, помещаемый в выходной поток. Эта функция работает по принципу «один к одному» — каждое входное значение создает ровно одно выходное значение. Адаптер
диапазона std::views::transform принимает функцию, которая сопоставляет
или преобразует входное значение в выходное. Следующий код вызывает
функцию abs для преобразования отрицательных значений в положительные.
Положительные значения не изменяются, то есть преобразование может при
необходимости пропускать значения без изменений. Каждое значение преобразуется в его абсолютное значение.
Фильтрация (Filtering) — это функция, которая принимает каждый элемент
входного потока и решает, следует ли передавать его в выходной поток. Она
4.4. Ошибка #21
99
принимает предикат — функцию, возвращающую булево значение. Если результат равен true, элемент сохраняется; если false — отклоняется (исключается).
Код в листинге 4.9 отфильтровывает нечетные значения, сохраняя только четные.
Эта функция работает по принципу «один к одному» или «один в никуда»; сохраняется либо значение, либо ничего. Адаптер диапазона std::views::filter
принимает функцию-предикат, определяющую, следует включить или исключить элемент. Для изучения лямбда-выражений обратитесь к разделу «Смотри
также».
Наконец, агрегация (Reduction) — это функция, которая принимает каждый
элемент входного потока и выполняет над ним операцию, способствующую
получению одного результирующего значения. Функция использует каждый
элемент из входного потока для получения конечного значения. Типичный
пример — суммирование. Код в листинге 4.9 складывает отфильтрованные значения, чтобы получить их сумму. Эта функция работает по принципу «многие
к одному» — каждый элемент входного потока вносит свой вклад в конечный
результат. Шаблон функции std::accumulate принимает итераторы для первого
и последнего элементов и начальное значение (ноль для суммирования). Все
входные значения сводятся к их сумме.
Диапазоны и адаптеры диапазонов C++ — мощный инструмент, но пока они
не достигли уровня производительности, предлагаемого другими языками.
Функцию accumulate нельзя напрямую объединить с результатами функций
преобразования и фильтрации, поскольку разработчик должен указать начальный и конечный итераторы. Поэтому необходимо создать второй контейнер для
хранения результатов конвейерной обработки потоковых значений. В будущем,
возможно, появится адаптер диапазона, аналогичный accumulate. Некоторые
функциональные языки уже предоставляют множество составных функций,
с которыми очень приятно работать.
Листинг 4.9. Композиция функций для последовательности значений
Создает поток, преобразует значения
в абсолютные, отфильint main() {
std::vector<int> vals { 42, 3, 7 -9, 0, 22, 23, -7, 22 }; тровывает нечетные
и сохраняет результат
в новом контейнере
auto evens = vals
| std::views::transform([](int x) { return abs(x); })
| std::views::filter([](int x){ return x % 2 == 0; });
auto sum = std::accumulate(evens.begin(), evens.end(), 0);
}
std::cout << sum << '\n';
return 0;
Суммирует значения
во втором контейнере
100
Глава 4. Современный C++: дополнительные вопросы
Рекомендации
Диапазоны могут значительно снизить издержки традиционного кода для
обработки контейнеров.
По возможности обрабатывайте контейнеры с помощью циклов for по диапазону и адаптеров диапазонов, чтобы облегчить написание и чтение кода.
Помните, что не все функции, ориентированные на диапазоны, можно объединять в конвейеры.
Функциональное программирование — это мощный инструмент, и конвейеры
помогают перейти к этой парадигме.
Смотри также
«Ошибка #6», где мы обсудили отказ от использования традиционного подхода циклов по индексам.
«Ошибка #13», где мы рассмотрели применение алгоритмов стандартной
библиотеки шаблонов вместо их переписывания.
«Ошибка #23», в котором описано использование лямбда-выражений.
4.5. Ошибка #22: написание непереносимого кода
для работы с файловой системой
Эта ошибка влияет на читаемость и эффективность. Файловые системы различаются на разных платформах, и разработка кода для взаимодействия с ними
может быть сложной, запутанной и непереносимой.
Проблема
Многие программы должны получать доступ к файловой системе для ввода
и вывода данных — для настройки, обработки, документирования результатов
или других целей. Со временем код, необходимый для реализации этих задач,
становился все сложнее и функциональнее. Существенная проблема такого
прогресса заключается в том, что различные платформы эволюционировали
в разных, часто несовместимых направлениях.
Написание программы, которая проверяет наличие файла и создает его, если
тот отсутствует, само по себе несложно — если не учитывать особенности файловой системы конкретной платформы. Разработчику нужно написать простую
программу, которая будет проверять, существует ли файл, создавать его при необходимости и выводить строку текста. Программа должна корректно работать
как под Windows, так и под Linux. Реализовать такую функциональность для
одной из систем несложно, но сделать это для обеих одновременно — достаточно
4.5. Ошибка #22
101
трудно. Возможны два подхода: использовать директивы препроцессора и отдельные исходные файлы. Первый вариант мы не будем рассматривать, хотя
он может встречаться в реальных проектах. Код в листингах 4.10 и 4.11 демонстрирует второй подход — использование отдельных файлов. Работа с файлами в Windows зависит от ряда функций и констант, специфичных для этой
платформы.
Листинг 4.10. Создание и заполнение файла: Windows
#include <windows.h>
Заголовочный файл, специфичный для Windows
void createFileIfNotExists(const std::string& filename) {
std::wstring wideFilename = std::wstring(filename.begin(), filename.end());
if (GetFileAttributesW(wideFilename.c_str()) == INVALID_FILE_ATTRIBUTES) {
HANDLE hFile = CreateFileW(wideFilename.c_str(), GENERIC_WRITE, 0,
NULL, CREATE_NEW,
Константы, специфичные для Windows
FILE_ATTRIBUTE_NORMAL, NULL);
Использование, специфичное для Windows
if (hFile != INVALID_HANDLE_VALUE) {
std::wcout << L"File created: " << wideFilename << std::endl;
const wchar_t* content = L"Hello, world!\r\n";
DWORD bytesWritten;
WriteFile(hFile, content, wcslen(content) * sizeof(wchar_t),
&bytesWritten, NULL);
CloseHandle(hFile);
} else
std::wcerr << "Error creating file: " << wideFilename << std::endl;
} else
std::wcout << L"File already exists: " << wideFilename << std::endl;
}
Используем wcerr для потоков
int main() {
createFileIfNotExists("example_file.txt");
return 0;
}
расширенных символов
В Linux есть свои функции и константы, отличающиеся от тех, что используются
в Windows. Именно эти различия затрудняют кросс-платформенную реализацию
и делают ее подверженной ошибкам. Программист, привыкший к одной платформе, наверняка отпустит едкое замечание о «гениальности» разработчиков другой.
Листинг 4.11. Создание и заполнение файла: Linux
#include <unistd.h>
#include <fcntl.h>
Заголовочные файлы, специфичные для Linux
Константы, специфичные
void createFileIfNotExists(const std::string& filename) {
для Linux
if (access(filename.c_str(), F_OK) == -1) {
int fd = open(filename.c_str(), O_CREAT | O_WRONLY, S_IRUSR
| S_IWUSR | S_IRGRP | S_IROTH);
Использование, специфичное для Linux
if (fd != -1) {
std::cout << "File created: " << filename << std::endl;
const char* content = "Hello, world!\n";
write(fd, content, strlen(content));
close(fd);
} else
std::cerr << "Error creating file: " << filename << std::endl;
} else
#include <unistd.h>
#include <fcntl.h>
Заголовочные файлы, специфичные для Linux
Константы, специфичные
void createFileIfNotExists(const std::string& filename) {
102if (access(filename.c_str(),
Глава 4. Современный C++: дополнительные вопросы
для Linux
F_OK) == -1) {
int fd = open(filename.c_str(), O_CREAT | O_WRONLY, S_IRUSR
| S_IWUSR | S_IRGRP | S_IROTH);
Использование, специфичное для Linux
if (fd != -1) {
std::cout << "File created: " << filename << std::endl;
const char* content = "Hello, world!\n";
write(fd, content, strlen(content));
close(fd);
} else
std::cerr << "Error creating file: " << filename << std::endl;
} else
std::cout << "File already exists: " << filename << std::endl;
}
int main() {
createFileIfNotExists("example_file.txt");
return 0;
}
Анализ
Распространенный подход — использование директив препроцессора, которые
опционально включают или исключают код в зависимости от платформы. Весь
код содержится в одном исходном файле, а вариации разделены по платформам
при помощи #ifdef. Преимущество такого подхода заключается в упрощении
сборки: один исходный файл, единый процесс. Однако дублирование кода
с условным включением часто ухудшает его читаемость.
Второй подход, продемонстрированный здесь, использует отдельные файлы
и определяет, какой из них включается в сборку, при помощи специфичных
для этой платформы макросов или других методов. Такой способ упрощает
чтение — весь код для одной платформы сосредоточен в одном файле, — но
делает процесс сложнее.
Основная проблема — отсутствие единых кросс-платформенных инструментов
для таких операций, как работа с файловой системой. И снова наш разработчик
вынужден тратить время на устранение мелких ошибок, которые не решают задачу напрямую, но обычно доставляют много хлопот.
Решение
Современный C++17 предоставляет стандартный набор функций для работы
с файловыми системами на любых поддерживаемых платформах. Унификация
подхода позволяет легко писать код, выполняющий операции с файловой системой, единым образом. Код в листинге 4.12 объединяет предыдущие примеры
в одну общую реализацию, работающую и под Windows, и под Linux. Это избавляет от необходимости условной компиляции с использованием директив
препроцессора или создания множества исходных файлов для изоляции кода,
специфичного для конкретной платформы. Также добавлены проверки на наличие ошибок, так как файловые системы, как известно, ведут себя непредсказуемо
в условиях нагрузки (пусть и редко во время разработки).
4.5. Ошибка #22
103
Листинг 4.12. С
оздание и заполнение файла: кросс-платформенный
вариант
namespace fs = std::filesystem;
Удобный способ создать псевдоним пространства
имен для упрощения использования; fs в коде —
это псевдоним для std::filesystem
Универсальный код, работающий как под Windows,
так и под Linux
void createFileIfNotExists(const
std::string& filename) {
fs::path filePath = filename;
if (fs::exists(filePath)) {
std::cout << "File already exists: " << filePath << std::endl;
return;
Момент проверки
}
std::ofstream file(filePath);
if (!file) {
std::cerr << "Error creating file: " << filePath << std::endl;
return;
}
if (!file.is_open()) {
std::cerr << "File cannot be opened: " << filePath << std::endl;
return;
}
std::cout << "File created: " << filePath << std::endl;
file << "Hello, world!\n";
Момент использования
}
int main() {
createFileIfNotExists("example_file.txt");
return 0;
}
Вынесение функциональности работы с файловой системой в стандартный
заголовок позволяет разработчикам сосредоточиться на решении основной
задачи с помощью стандартных операций с файловой системой. Упрощенная
абстракция файловой системы предоставляет всю необходимую функциональность в независимой от ОС форме. Директива namespace fs = std::filesystem
задает псевдоним; эта форма может использоваться в различных контекстах
(например, в шаблонах). Она упрощает обращение к элементам пространства
имен — например, fs::path и fs::exists. Замечательно!
Код из листинга 4.12 имеет важное ограничение: пример предназначен только
для однопоточной среды. Параллельное выполнение добавляет дополнительную
сложность. Проблема заключается в том, что другой поток может удалить, обновить или иным образом повлиять на файл между моментом проверки (TOC,
Time of checking) и моментом использования (TOU, Time of use). Естественно,
эта ситуация известна как TOCTOU (Time of Check to Time of Use) — это класс
программных ошибок, вызванных состоянием гонки данных (race condition),
которое может поставить в тупик неопытного разработчика.
104
Глава 4. Современный C++: дополнительные вопросы
Рекомендации
Используйте новые возможности std::filesystem для работы с файлами
и каталогами.
По возможности обновляйте существующий код, чтобы воспользоваться
этими средствами.
Не забывайте проверять наличие ошибок при работе с файловой системой:
хотя код стало проще писать, ошибки по-прежнему возможны.
4.6. Ошибка #23: написание избыточного количества
отдельных функций
Эта ошибка влияет как на читаемость, так и на эффективность: однако чтобы
разобраться в ней, может потребоваться небольшой, но довольно интенсивный
период обучения. Одноразовые и редко вызываемые функции, как правило,
засоряют исходный код, а их поиск отвлекает от основной логики программы.
Проблема
Предположим, что разработчик пишет код для управления типичной школой
в Соединенных Штатах. В школе обучается несколько учеников, и каждого нужно внести в базу данных. Как и в любой организации, состоящей из более чем
одного человека, требуется выполнить ранжирование или сортировку. Нашему
разработчику нужно упорядочить учеников по имени, возрасту и среднему баллу.
Список учеников хранится в контейнере для удобства работы. Следовательно,
нужно перебрать его элементы и определить требуемый порядок. Ради простоты
работы допускается менять порядок внутри контейнера.
Разработчик пишет три автономные функции для реализации сортировки по
заданным критериям. Кодовая база становится намного больше, чем можно
было предположить. Эти новые функции нужно где-то разместить, поэтому они
определяются в одном из файлов среди множества других, а для выполнения
своей задачи они вызываются совсем в других местах. Листинг 4.13 иллюстрирует такую ситуацию.
Листинг 4.13. Несколько автономных функций
class Student {
private:
std::string name;
int age;
double gpa;
public:
Student(const std::string& n, int a, double g) : name(n), age(a), gpa(g) {}
4.6. Ошибка #23
};
105
std::string getName() const { return name; }
int getAge() const { return age; }
double getGpa() const { return gpa; }
// предположим, что здесь много кода
bool by_name(const Student& s1, const Student& s2)
{ return s1.getName() < s2.getName(); }
bool by_age(const Student& s1, const Student& s2)
{ return s1.getAge() < s2.getAge(); }
bool by_gpa(const Student& s1, const Student& s2)
{ return s1.getGpa() < s2.getGpa(); }
Несколько функций,
запрятанных в коде
и используемых только
один раз
void output_by_student_name(const std::vector<Student>& s) {
for (int i = 0; i < s.size(); ++i)
std::cout << s[i].getName() << '\n';
std::cout << '\n';
}
// предположим, что здесь еще некоторое количество кода
int main() {
std::vector<Student> s;
s.push_back(Student("Susan", 23, 3.85));
s.push_back(Student("James", 24, 3.35));
s.push_back(Student("Annette", 25, 3.75));
s.push_back(Student("Wilson", 26, 3.8));
std::sort(s.begin(), s.end(), by_name);
output_by_student_name(s);
std::sort(s.begin(), s.end(), by_age);
output_by_student_name(s);
}
Функции вызываются, но нет
подсказки, какие ожидаются
параметры или правила их
применения
std::sort(s.begin(), s.end(), by_gpa);
output_by_student_name(s);
return 0;
Анализ
Именование функций позволяет создать легко запоминающиеся отсылки,
описывающие их поведение. Однако когда такие функции перемешаны с другим кодом, отслеживать их реализацию сложно, и они становятся еще одной
деталью, которую нужно все время держать в голове. Поскольку место определения функции и место ее вызова находятся далеко друг от друга, разработчик
должен помнить детали ее реализации, чтобы полностью понимать ожидаемое
поведение в точке вызова.
Теперь сделаем одно допущение: представим, что наш разработчик недавно
начал изучать функциональное программирование. Он приходит к мысли, что
106
Глава 4. Современный C++: дополнительные вопросы
существующие функции можно переработать под более функциональный стиль.
Появляется новое требование, и разработчик решает, что это прекрасная возможность попробовать создать функтор.
Функтор (функциональный объект) — это структура (или класс), реализующая
оператор operator(). У этого оператора есть разные названия, но мы будем
называть его оператором применения (apply operator), следуя терминологии
функционального программирования. Оператор применения — это обычная
функция, которая выполняет некоторое действие, в данном случае определяет
максимальный элемент. Для этого создается структура Maximum, название которой
отражает ее назначение. Ее оператор применения реализован для поиска максимального значения в векторе. Термин применение здесь следует воспринимать
буквально: функция применяется к данным. Возможно, это может показаться
немного устаревшим способом мышления, но таков функциональный подход.
В традиционном императивном программировании основное внимание уделяется передаче данных в функцию и выполнению ее поведения; функциональное
программирование думает о самой функции и ее применении к данным — это
более ориентированная на данные модель.
Код в листинге 4.14 демонстрирует две реализации поиска максимального значения. Первая — это то, к чему пришел разработчик после создания функтора.
Инстанцирование (создание экземпляра) функтора порождает функциональный
объект f. Его применение к данным выполняется обычным образом: за функциональным объектом следует список параметров, после чего возвращается
результат (если таковой есть). Руководитель команды разработчиков предложил
альтернативу, показанную ниже кода разработчика. Он рекомендовал использовать лямбда-выражение, чтобы упростить код, избавиться от именованного
функтора и необходимости его создавать, а также для того, чтобы поведение
было видно прямо в точке вызова.
Листинг 4.14. Демонстрация определения функции в месте вызова
template <typename T>
struct Maximum {
T operator()(const std::vector<T>& vals) {
T max = vals[0];
for (const auto& v : vals)
if (v > max)
max = v;
return max;
}
};
int main() {
std::vector<int> v {3, 9, 6, 2, -1, 0, 8};
Maximum<int> f;
std::cout << f(v) << '\n';
}
Определяет класс функционального
объекта (функтор)
Определяет оператор применения
Создает экземпляр класса
функционального объекта,
формируя функциональный объект
Применяет функциональный объект к данным; вызывает оператор применения
auto max = std::accumulate(v.begin(), v.end(), v[0],
[](auto a, auto b) { return std::max(a, b); });
std::cout << max << '\n';
return 0;
Использует числовой
алгоритм с лямбдафункцией нахождения
локального максимума
return max;
int main() {
std::vector<int> v {3, 9, 6, 2, -1, 0, 8};
Maximum<int> f;
std::cout << f(v) << '\n';
}
Создает экземпляр класса
функционального объекта,
формируя функциональный объект
Ошибка #23объект107
Применяет4.6.
функциональный
к данным; вызывает оператор применения
auto max = std::accumulate(v.begin(), v.end(), v[0],
[](auto a, auto b) { return std::max(a, b); });
std::cout << max << '\n';
return 0;
};
}
Использует числовой
алгоритм с лямбдафункцией нахождения
локального максимума
Решение
Руководитель команды демонстрирует одну из сильных сторон лямбда-
выражений — их способность определять поведение ровно там, где нужно. Такой
подход избавляет от необходимости прокручивать файл вверх и искать определения функций, а также от неудобств, связанных с определением функторов.
Код в листинге 4.15 показывает три исходных варианта сортировки студентов:
по имени, возрасту или среднему баллу. В этом примере логика определяется
прямо в месте вызова, так читатель сразу понимает, что делает функция. Конечно, первые несколько раз такой стиль может потребовать больше времени
на написание и понимание, но после того, как вы освоите лямбда-выражения,
они станут естественной и привычной частью кода.
Поскольку лямбда-выражения являются функциональными объектами, их
можно присвоить переменной, а затем передать эту переменную в точке вызова.
Такой подход удобно продемонстрировать на примере сортировки учащихся по
среднему баллу.
Листинг 4.15. Определение функций в месте вызова (лямбда-выражения)
class Student {
private:
std::string name;
int age;
double gpa;
public:
Student(const std::string& n, int a, double g) : name(n), age(a), gpa(g) {}
std::string getName() const { return name; }
int getAge() const { return age; }
double getGpa() const { return gpa; }
};
// предположим, что здесь много кода
void output_by_student_name(const std::vector<Student>& s) {
for (int i = 0; i < s.size(); ++i)
std::cout << s[i].getName() << '\n';
std::cout << '\n';
}
int main() {
std::vector<Student> s;
s.push_back(Student("Susan", 23, 3.85));
108
Глава 4. Современный C++: дополнительные вопросы
s.push_back(Student("James", 24, 3.35));
s.push_back(Student("Annette", 25, 3.75));
s.push_back(Student("Wilson", 26, 3.8));
std::sort(s.begin(), s.end(),
[](const Student& s1, const Student& s2){
return s1.getName() < s2.getName(); });
output_by_student_name(s);
std::sort(s.begin(), s.end(),
[](const Student& s1, const Student& s2){
return s1.getAge() < s2.getAge(); });
output_by_student_name(s);
}
Определяет функцию
прямо в месте
использования
Определяет функцию
непосредственно перед
ее использованием
auto f = [](const Student& s1, const Student& s2) {
return s1.getGpa() < s2.getGpa(); };
std::ranges::sort(s, f);
Использует диапазоны и передает
output_by_student_name(s);
функциональный объект в качестве параметра
return 0;
Сортировка по среднему баллу была выполнена немного иначе, чем по имени
и возрасту. Здесь показан альтернативный вариант с использованием API диапазонов. Он более компактный, легче читается и проще пишется. Так зачем же
медлить с переходом на диапазоны?
Лямбда-выражения находят широкое применение. Они особенно полезны, когда
одноразовые и редко вызываемые функции трудно найти и запомнить в контексте кода, который пишется или изучается. Лямбда-выражения позволяют
определить функцию именно там, где она применяется, избавляя разработчика
и поддерживающего код программиста от необходимости искать правила ее
реализации в другом месте.
Обычно лямбда-выражения лучше всего подходят, когда их функциональность
используется один раз. Если же одно и то же выражение применяется несколько
раз, имеет смысл присвоить его переменной. Конечно, преимущества определения функции по месту использования при этом теряются. Как и во многих
других случаях, здесь необходимо учитывать компромисс между удобством
и лаконичностью.
Рекомендации
Используйте лямбда-выражения для простых, редко вызываемых функций;
документирование логики в точке вызова значительно улучшает понимание,
не перегружая оперативную память разработчика.
4.7. Ошибка #24
109
Рассмотрите возможность добавления лямбда-выражений там, где существующий код использует простые функции.
Помните, что в стандартной библиотеке уже есть множество полезных функций (например, std::max_element и std::less, std::sort), которые можно
применять вместо написания собственных реализаций.
Для упрощения написания кода и облегчения его чтения рассмотрите возможность использования API диапазонов. Программисту не нужно указывать итераторы начала и конца контейнера — диапазон делает это сам;
достаточно лишь указать контейнер и функцию.
4.7. Ошибка #24: использование неудобных констант
Эта ошибка влияет на читаемость, эффективность и в некоторой степени на
корректность. Константы необходимы, но способ их определения часто приводит к трудночитаемому коду, неверно записанным значениям и негибкому
использованию.
Проблема
Во многих программах необходимо определять константы или использовать
функции, преобразующие величины на некоторый фиксированный коэффициент. Традиционно это решалось с помощью #define или const, позволяющих
задать имя и значение константы либо определить функцию для преобразования
единиц измерения.
В следующем коде показаны три примера использования таких констант. Первая — количество секунд в году (для простоты — в невисокосном). Однако здесь
допущена ошибка: значение в 10 раз больше, чем нужно.
Листинг 4.16. Традиционное использование константных значений
const int SEC_PER_YEAR = 315360000;
Это правильно, или значение
в 10 раз больше, чем нужно?
const double pi = 3.1415927;
struct Circle {
Недостаточная аппроксимация
double radius;
Circle(double r) : radius(r) {}
double perimeter() throw() { return 2 * pi * radius; }
double area() throw() { return pi * radius * radius; }
};
int main() {
Circle c(3);
std::cout << "perimeter " << c.perimeter() << ", area " << c.area() << '\n';
double rads = 90 * pi / 180;
std::cout << "90 degrees is " << rads << " radians\n";
}
Негибкий подход, пригодный только для 90 градусов
std::cout << "There are " << SEC_PER_YEAR << " seconds in a year\n";
return 0;
};
Circle(double r) : radius(r) {}
double perimeter() throw() { return 2 * pi * radius; }
double area() throw() { return pi * radius * radius; }
int main() {
110CircleГлава
4. Современный C++: дополнительные вопросы
c(3);
std::cout << "perimeter " << c.perimeter() << ", area " << c.area() << '\n';
double rads = 90 * pi / 180;
std::cout << "90 degrees is " << rads << " radians\n";
}
Негибкий подход, пригодный только для 90 градусов
std::cout << "There are " << SEC_PER_YEAR << " seconds in a year\n";
return 0;
Анализ
При определении первой константы легко допустить ошибку — разработчик
запутался в количестве нулей и по ошибке добавил один лишний. Вторая
константа — усеченное значение числа пи. Разработчик написал разумное приближение, но оно куда менее точное, чем позволяет используемый тип данных.
Наконец, преобразование градусов в радианы негибкое — выражение работает
только для 90 градусов. Здесь была бы уместна функция.
Решение
Современный C++ позволяет решить все три проблемы. Сначала мы рассмотрим
значение константы pi. Разработчику не нужно вручную подбирать приближение, заголовок numbers уже определяет это число и ряд других констант. При
выборе этих значений учитывается архитектура системы, поэтому используется
наилучшее представление; больше нет необходимости писать такие константы
самостоятельно.
Определение секунд в году улучшено с помощью разделителя тысяч. Апостроф
разбивает число на тысячи примерно так же, как это делается в книгах или статьях. Запятые уже определены как операторы, поэтому был выбран похожий
символ. Его использование упрощает подсчет количества нулей (в данном
случае) и делает ошибки гораздо более заметными.
Наконец, при преобразовании градусов в радианы используются определяемые
пользователем литералы (UDL, user-defined literal). Оператор operator"" — это
функция, принимающая значение параметра и выполняющая над ним преобразование. В результате получается наглядный и простой в использовании вызов,
который корректно обрабатывает любое допустимое значение.
Листинг 4.17. Улучшенное использование константных значений
constexpr double operator"" _deg_in_rad(long double d) {
return d * std::numbers::pi / 180;
}
constexpr int SEC_PER_YEAR = 31'536'000;
Пользовательский
литерал, выполняющий
преобразования
Хорошо разделенные цифры,
которые легко читать
struct Circle {
double radius;
Circle(double r) : radius(r) {}
Максимально точное приближение,
double perimeter() noexcept { return 2 *
которое позволяет архитектура
std::numbers::pi * radius; }
double area() noexcept { return std::numbers::pi * radius * radius; }
};
int main() {
Circle c(3);
constexpr double operator"" _deg_in_rad(long double d) {
return d * std::numbers::pi / 180;
}
Хорошо разделенные
цифры,
4.8. Ошибка
#25
которые легко читать
constexpr int SEC_PER_YEAR = 31'536'000;
Пользовательский
литерал, выполняющий
преобразования
111
struct Circle {
double radius;
Circle(double r) : radius(r) {}
Максимально точное приближение,
double perimeter() noexcept { return 2 *
которое позволяет архитектура
std::numbers::pi * radius; }
double area() noexcept { return std::numbers::pi * radius * radius; }
};
int main() {
Circle c(3);
std::cout << "perimeter " << c.perimeter() << ", area " << c.area()
<< '\n';
std::cout << "90 degrees is " << 90.0_deg_in_rad << " radians\n";
}
std::cout << "There are " << SEC_PER_YEAR << " seconds in a year\n";
return 0;
Определение констант — часто необходимость. Современные средства C++
делают код проще и заметно удобнее для чтения.
Рекомендации
Используйте разделитель тысяч для разбиения длинных последовательностей цифр.
Используйте operator"", чтобы легко преобразовать единицы измерения;
литерал можно создать для любого типа данных. Обратите внимание на
std::chrono, в котором есть строго типизированные минуты, часы и многое
другое, — там эта концепция прекрасно демонстрируется.
Используйте константы из заголовка numbers, чтобы упростить использование и получить наилучшие приближения констант для вашей системы.
4.8. Ошибка #25: написание кода для сопоставления
с паттерном
Эта ошибка влияет на эффективность и корректность, а также на читаемость
кода. Сопоставление с паттерном (pattern matching) для строкового типа данных — типичная операция; код, написанный для сопоставления с паттерном,
трудоемок и сложен в реализации.
Проблема
Частая проблема — парсинг текстовых данных. Ввод часто осуществляется в виде
строк, а информация, считываемая из файла, тоже нередко является текстовой.
Когда требуемый тип данных — строка, это удобно, но во многих случаях текст
нужно проверить или разбить на составные части. Код, который анализирует
112
Глава 4. Современный C++: дополнительные вопросы
данные на корректность формата, содержимого или чего-то подобного, оказывается сложным во всех случаях, кроме самых простых. В листинге 4.18 показана проверка адреса электронной почты. Правило такое: одно «слово» (имя
пользователя) отделяется знаком «собачка» (@), за которым следует еще одно
«слово» (домен), затем точка, после которой идет еще одно «слово» (домен верхнего уровня). Код проверяет наличие этих двух разделителей и трех «слов», где
«слово» — это одна или несколько букв (не включая «собачку» и точку). Если
что-то не так, адрес электронной почты считается неверным.
Листинг 4.18. С
амостоятельно написанная функция проверки
электронной почты (с ошибками)
bool isValidEmail(const std::string& e) {
Проверка наличия разделителя
size_t pos_at = e.find('@');
if (pos_at == std::string::npos) // Отсутствует значок '@'
return false;
if (pos_at == 0) // Отсутствует первое "слово"
Проверка наличия
return false;
разделителя
size_t pos_period = e.find('.', pos_at);
if (pos_period == std::string::npos) // Отсутствует точка '.'
return false;
if (pos_at + 1 >= pos_period) // Отсутствует среднее "слово"
return false;
if (pos_period == e.length() - 1) // Отсутствует последнее "слово"
return false;
return true;
}
Проверка наличия
«слова»
int main() {
std::cout << isValidEmail("prof@nu.edu") << '\n';
return 0;
}
Анализ
Код лишь проверяет наличие в строке символов (любых, даже недопустимых),
образующих имя пользователя, домен и домен верхнего уровня, и что они разделены «собачкой» и точкой. В этом сопоставлении есть неточность: функция
проверяет только форму, но никак не валидирует содержимое. Вредоносный
или ошибочный адрес может спокойно обойти эту проверку. Представьте: этот
код нужно доработать так, чтобы он проверял, что в имени пользователя используются только допустимые буквы, цифры и знаки препинания, а в обеих
частях домена — только буквы и цифры, без посторонних символов. Как уже
говорилось ранее, написать собственный парсер (синтаксический анализатор)
очень сложно. Кроме того, чтобы понять такой код, его пришлось бы щедро
снабдить комментариями. Комментарии же имеют отвратительное свойство
устаревать или становиться неправильными уже после первого изменения кода.
4.8. Ошибка #25
113
Решение
В современном C++ появилась возможность сопоставлять текстовые паттерны
благодаря заголовочному файлу regex, который добавляет поддержку регулярных выражений. Регулярные выражения — это один из простейших вычислительных механизмов. Обычно они реализуются в виде конечного автомата, и каждая
буква (символ) вызывает переход из одного состояния в другое. Если паттерн
совпадает, он считается принятым. Листинг 4.19 демонстрирует такое поведение
в действии. Мы видим пять основных частей: двигаемся слева направо.
1. Имя пользователя, состоящее из одной или нескольких букв, цифр и ограниченного количества знаков препинания.
2. Разделитель — значок «собачка».
3. Домен, состоящий из одной или нескольких букв или цифр.
4. Точка (обратите внимание на экранирующий обратный слеш перед ней).
5. Домен верхнего уровня, состоящий из двух или более букв.
Этот шаблон парсера более точен, чем код в листинге 4.18, поскольку он гарантирует, что домен верхнего уровня содержит не менее двух букв (технически домен
верхнего уровня может состоять из одного символа, но на момент написания ни
один такой вариант не зарегистрирован; см. текущий список доменов на сайте
http://data.iana.org/TLD/tlds-alpha-by-domain.txt). Кроме того, имя пользователя
и домены проверяются на наличие только допустимых символов. Такая точность
существенно влияет на корректность.
Листинг 4.19. И
спользование регулярного выражения для проверки
электронной почты
bool isValidEmail(const std::string& e) {
std::regex pattern(
R"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})";
return std::regex_search(e, pattern);
Регулярное выражение
}
из пяти сегментов
int main() {
std::cout << isValidEmail("prof@nu.edu") << '\n';
return 0;
}
для проверки адреса
электронной почты
Еще одна показанная здесь возможность — это так называемая сырая строка (raw
string). Сырая строка начинается с префикса R"( и завершается символами )".
Ее назначение — дать возможность записать (как правило, шаблон регулярного выражения) без необходимости экранирования специальных символов
C++. Внимательный читатель заметит, что в регулярном выражении есть один
114
Глава 4. Современный C++: дополнительные вопросы
экранированный символ: это экранирование является неотъемлемой частью
самого паттерна. Обычно точка в регулярном выражении соответствует любому
одиночному символу (кроме новой строки). Поэтому ее нужно экранировать,
если мы хотим сопоставить именно символ точки.
Когда требуется экранирование (в обычных строках, не в сырых), регулярное
выражение становится еще более сложным для чтения, поскольку паттерн должен отличаться от служебных символов языка C++. Простой пример наглядно
демонстрирует преимущества. Предположим, что необходимо сопоставить
с паттерном двойной обратный слеш, тогда с экранирующими символами паттерн будет выглядеть так:
std::string pattern = "\\\\";
А с использованием сырой строки — так:
std::string pattern = R"(\\)";
Последний вариант точно показывает, что именно сопоставляется, а в первом —
все скрыто в символах экранирования. Убедились?
Регулярные выражения предназначены для сопоставления текстовых паттернов. Их язык сложен и требует усилий для изучения, но разработчики считают,
что оно того стоит. Стоит в них немного разобраться, и паттерны будет легко
создавать, просто тестировать и удобно использовать. Кроме того, регулярные
выражения дают гораздо больше возможностей: можно извлечь сопоставленные
с паттерном части строки (например, имя пользователя или все три компонента
адреса) или выполнить замену текста на основе паттерна.
Рекомендации
Не пожалейте времени на изучение и использование регулярных выражений,
поскольку обеспечить корректность самостоятельно разработанного кода для
сопоставления с текстовым паттерном очень сложно.
Регулярные выражения очень выразительны, но их трудно читать и писать;
тщательно тестируйте их на всех вариантах входных данных, которые только
можно себе представить.
Часть 2
Переходный вариант C++
Важным аспектом переходного C++ является сохранение идиом, связанных
с языком С. Хотя когда-то эти привычки были практически незаменимы, внедрение современных функций C++ превратило их в устаревшие. Многие из этих
привычек до сих пор переходят в современный код, привнося потенциальную
неэффективность и подводные камни. Выявляя и заменяя эти устаревшие
практики современными альтернативами, разработчики смогут очистить свои
кодовые базы и использовать все возможности C++, повысив как ясность кода,
так и его эффективность.
Хотя наследие программирования на C и ранних версиях C++ все еще сохраняется во многих кодовых базах, эти варианты и парадигмы проектирования больше
не соответствуют лучшим практикам. Эти некогда передовые подходы требуют
переоценки с учетом новых методов и возможностей языка. Переход от C и раннего C++ к современным практикам предполагает проведение рефакторинга
кода в соответствии с текущими стандартами, что улучшает сопровождаемость,
читаемость и эффективность. Такая модернизация позволяет обеспечить соответствие приложений актуальным требованиям и стандартам производительности, а также сохраняет их адаптируемость к будущим изменениям.
5
Идиомы языка C
В этой главе
3 Обзор макросов
3 Использование определенных макросов
3 Строки в стиле С
3 Неправильные типы данных для булевых значений
Язык C был основным предшественником C++, и его влияние ощущается
в огромном количестве проектов. Многие ранние последователи C++ были программистами на C, которые ожидали получить «более совершенный C». C++
обеспечивал совместимость с C за некоторыми исключениями. Эти исключения
были необходимы для улучшенной проверки типов, повышения корректности
и большей согласованности языка.
Однако есть много областей, где стиль программирования на С так и не изменился, даже после появления C++. Многие программисты освоили приемы и методы
языка C и перенесли их в C++. Компилятор редко жаловался на какие-либо
из этих подходов. Код компилировался и выполнялся — чего еще можно было
ожидать? Однако такой переход не всегда означал, что разработчики отказались
5.1. Ошибка #26
117
от идиом C и приняли подход C++. Довольно много кода писалось без учета
новых средств выражения тех же намерений.
За прошедшие годы C++ и C стали расходиться все больше и больше. Стандарт
C99 прояснил и стандартизировал язык C, одновременно переняв некоторые
элементы из C++; в свою очередь, C++ принял несколько идей C. Эти два языка
по-прежнему существуют отдельно, но каждый из них привнес нечто полезное
в другой.
Расхождение языков привело к ряду проблем и ошибок, характерных для
C-ориентированного подхода в C++-коде. Эти ошибки в основном безобидны,
потому что код работает, но стиль становится труднее читать, сложнее писать,
а сопровождение стиля C требует бˆольших усилий, чем следовало бы.
В этой главе мы рассмотрим несколько проблем, которые сохраняются в кодовых базах, написанных на C++ разработчиками, продолжающими пользоваться
идиомами С. Хотя есть более удачные подходы, разработчик должен намеренно
перейти к ним, чтобы избежать описанных проблем. Для улучшения качества
кода на C++ важно понимать влияние на него языка C и способы исправления
типичных ошибок, связанных с его обратной совместимостью. Следующая глава
продолжает тему, но фокусируется на неправильном применении конструкций
C++, часто написанных под влиянием C.
5.1. Ошибка #26: всегда объявлять переменные
в начале функции
Эта ошибка влияет на корректность, читаемость и эффективность соответственно. В языке C все переменные требовалось объявлять в начале функции;
разработчики обычно там их и инициализируют.
Проблема
Многие языки требуют, чтобы все переменные были объявлены, а иногда
и определены в начале функции. Это было сделано в угоду компилятору, а не
разработчику, и с такой практикой связано несколько проблем. Во-первых, когда
все переменные объявлены в начале функции, читателю и разработчику постоянно приходится возвращаться в начало, чтобы вспомнить их имена, значения
и места использования. Такой подход создает излишнюю когнитивную нагрузку.
Во-вторых, разработчик должен определить инициализацию переменной до ее
использования; ради удобства он, скорее всего, присвоит ей простое значение
(часто нулевое), которое может не соответствовать тому значению, на которое
рассчитывает последующий код. Однако если программист потратит свое время
на корректную инициализацию, читатель поймет ее мотив только гораздо позже.
118
Глава 5. Идиомы языка C
Поддерживающим программистам впоследствии может понадобиться изменить
это значение, что приведет к ошибкам. Наконец, такие инициализирующие
значения часто выглядят как магические числа: почему вообще нужно инициа
лизировать нулем, единицей или (по-видимому) произвольным значением?
Листинг 5.1 демонстрирует некоторые из этих проблем. Разработчику нужно
было инициализировать переменную max, но из-за рефлекторно поставленного значения 0 он получает неправильный результат. Кроме того, переменная
pos используется до цикла и сохраняет некоторое значение. В исходном коде
предполагалось, что pos будет инициализирована (где-нибудь) и корректно
использована в цикле. Более поздний разработчик не обратил внимания на
это недокументированное предположение и задействовал «вполне хорошую»
переменную для другой цели.
Листинг 5.1. Объявление переменных в начале функции
int maximum(const std::vector<int>& values) {
int max = 0;
int pos;
Плохой выбор для инициализации
переменной
Переменная не инициализирована; предполагается, что это будет сделано позже
}
// предположим, что здесь код, который использует переменную pos...
pos = 1;
Значение, оставшееся после вычисления,
// предположим, что здесь еще код...
добавленного в код позже
for (; pos < values.size(); ++pos)
Переменная pos используется так,
if (values[pos] > max)
будто ее текущее значение корректно
max = values[pos];
return max;
int main() {
std::vector<int> values;
values.push_back(1);
values.push_back(-2);
values.push_back(-3);
std::cout << maximum(values) << '\n';
return 0;
}
Анализ
Разрыв между объявлением переменной и ее использованием позволяет происходить нежелательным побочным эффектам. Переменная pos была хорошим
выбором для промежуточных вычислений, но после этого оказалась в неподходящем состоянии для использования в цикле. При инициализации переменной max использовалось значение, которое часто бывает корректным, но не
в каждой задаче может использоваться такое начальное значение. Поскольку
все элементы, начиная со второго (из-за ошибки начального значения pos) вектора, были отрицательными, ни один из них не оказался больше ошибочного
5.1. Ошибка #26
119
нулевого значения, что привело к возвращению результата, которого вовсе
не было в контейнере.
Решение
Объявляйте переменную непосредственно перед ее использованием — минимизируйте расстояние между объявлением, определением и применением.
В конструкциях (например, for) переменную можно объявить прямо внутри
структуры цикла (современный C++ допускает то же самое и в if).
Инициализация переменных — интересное упражнение. Многие студенты по
привычке инициализируют переменные нулем. Во многих случаях это уместно,
но далеко не всегда. Подмножество задач, в которых нулевое значение неверно,
приведет к неочевидным ошибкам. Функция maximum в листинге 5.2 демонстрирует это, выполняя поиск максимума среди отрицательных значений. По какойто причине слишком многие разработчики при написании кода принимают во
внимание только положительные значения. Целые и вещественные числа имеют
отвратительную привычку быть отрицательными — игнорировать это нельзя.
Перед тем как просматривать коллекцию значений, используйте простой способ инициализации: скопируйте первый элемент в переменную и сравнивайте
с ней остальные. Такой подход гарантирует, что переменная получает реальное
значение из набора данных, а не число, придуманное разработчиком. Как уже
говорилось ранее, инициализация произвольным значением может привести
к использованию величины, которой в данных попросту нет, — это вызовет
ошибку корректности.
Листинг 5.2. Объявление переменных в нужном месте
int maximum(const std::vector<int>& values) {
// предположим, что здесь код...
int max = values[0];
for (int pos = 1; pos < values.size(); ++pos)
if (values[pos] > max)
max = values[pos];
return max;
}
Инициализирует переменную
значением из коллекции
Ограничивает область
видимости управляющей
переменной рамками цикла
int main() {
std::vector<int> values;
values.push_back(1);
values.push_back(-2);
values.push_back(-3);
std::cout << maximum(values) << '\n';
return 0;
}
Помнить роли и значения переменных очень важно для хорошей читаемости
и понимания кода. Объявление переменных именно там, где они нужны, и их
120
Глава 5. Идиомы языка C
инициализация непосредственно перед использованием снижают когнитивную
нагрузку на читателя.
Рекомендации
Ограничивайте область видимости каждой переменной; при объявлении
сразу же инициализируйте ее осмысленным значением.
Используйте возможность объявлять переменные в области видимости цикла
for и любых других конструкций, допускающих такой подход (современный
C++ расширил число таких случаев).
Смотри также
«Ошибка #10», где представлен современный ответ C++ на вопрос об области
видимости переменных.
«Ошибка #60» для обсуждения этой проблемы в контексте классов.
5.2. Ошибка #27: зависимость от макросов
Эта ошибка влияет на корректность, читаемость, эффективность и иногда на
производительность. Макросы добавляют в исходный код программы текст,
который компилятор напрямую не проверяет. Их развертка происходит во время
препроцессинга, до компиляции. Это означает, что некоторые ошибки могут быть
неочевидны, их трудно отлаживать и, что хуже всего, код с макросами почти
всегда работает, за исключением самых критических ситуаций.
Проблема
Код в листинге 5.3 демонстрирует несколько трудностей, связанных с макросами C++, но на самом деле их гораздо больше. Часто макросы используют для
определения литеральных значений: им присваивают осмысленные имена, чтобы
объяснить их назначение в программе.
Другой способ применения — это имитация структур, похожих на функции,
которые документируют их использование. Как и в случае с большинством
функций, такие макросы могут требовать параметры. Макросы могут прекрасно
работать с параметрами, поэтому ими легко злоупотребить.
Кроме того, макросы кажутся удобным способом сократить повторяющийся
код, заменяя его одним символом. В следующем коде предпринята попытка
минимизировать усилия, необходимые для написания цикла for, объединив
инициализацию, условие и обновление в один символ. Учитывая все эти (очевидные) преимущества, рекомендация как можно реже использовать их — а тем
более и вовсе от них отказаться — может показаться чрезмерной.
5.2. Ошибка #27
121
Листинг 5.3. И
спользование макросов для определения, сокращения
и добавления типов
#define
#define
#define
#define
#define
PI 3.1415927
SQUARE(n) n * n
FOR(a, b) for (i = a; i < b; ++i);
FALSE 0
TRUE !FALSE
int main() {
int n = 3;
std::cout << SQUARE(n) << '\n';
std::cout << SQUARE(n + (n - 1)) << '\n';
std::cout << SQUARE(++n) << '\n';
Вводит литеральное значение
без определенного типа
Конструкция, похожая на функцию,
для простоты вычислений
Сокращает усилия по написанию
кода, определяя большую часть
структуры, но содержит баг
Еще одно нетипизированное значение
int i;
FOR(0, 10)
std::cout << i << '\n';
int truth = FALSE;
++truth;
++truth;
}
Разработчик пытается
переключить булевы значения
if (truth == FALSE)
std::cout << "smooth\n";
else
std::cout << "dismay\n";
return 0;
Анализ
Самая очевидная проблема макросов в том, что они подставляются в код еще до
компиляции: препроцессор выполняет текстовую подстановку в том месте, где
они найдены, и вставляет туда буквальное определение макроса. Если в макросе допущена ошибка, компилятор, скорее всего, обнаружит ее позже и выдаст
странное сообщение, которое трудно связать с исходным кодом. В итоге скомпилированный код отличается от того, что написал разработчик. Такая ситуация
создает дополнительные трудности при отладке.
Код в листинге 5.3 демонстрирует несколько типичных проблем, связанных с использованием макросов. Во-первых, константа PI не имеет определенного типа.
Обычно она интерпретируется как double, что, скорее всего, правильно; однако
в редких случаях это может быть ошибкой, и компилятор может неправильно
определить, что имеется в виду.
Во-вторых, макрос SQUARE кажется вполне нормальным, но скрывает проблему.
В первом вызове макроса SQUARE значение возводится в квадрат, и это правильно.
Во втором — выражение внутри вызова дважды подставляется в расширение макроса. То есть ожидаемое 5 × 5 превращается в 3 + 2 × 3 + 2, поскольку аргументы
122
Глава 5. Идиомы языка C
не вычисляются, а просто подставляются. Третий вызов приводит к выражению
++n * ++n, где n становится равным 5 после первого инкремента.
В-третьих, макрос FOR содержит ошибку: заголовок цикла for заканчивается
точкой с запятой, что означает отсутствие тела цикла (а на самом деле у него
нулевое тело цикла). Таким образом, следующий фрагмент кода является
автономным и выполняется только один раз, а не 10, как ожидается. Кроме
того, обратите внимание, что переменная управления циклом должна быть
объявлена заранее, до применения макроса FOR, — это важно, чтобы избежать
недоразумений.
В-четвертых, попытка определить булев тип через TRUE и FALSE работает только
частично. Разработчик ожидает переключения между ними. К сожалению, поскольку используется тип int, инкремент просто увеличивает значение, и переключение не происходит. Ожидаемое и фактическое поведение не совпадают.
Использование макроса создает иллюзию, заставляя сознание «видеть» булевы
ограничения, о которых компилятор даже не подозревает.
Решение
Во-первых, эти ошибки устраняются средствами языка C++, которые гарантируют, что компилятор будет задействован на каждом этапе и выдаст ошибку при
неправильном использовании. Литеральное значение для PI определяется как
значение с двойной точностью (double). В большинстве случаев этот тип является правильным, но разработчик должен убедиться в этом. Если компилятор
обнаружит несоответствие, он выдаст ошибку, и разработчик будет иметь возможность пересмотреть свои предположения. Современный C++ предоставляет
форму constexpr, которая снижает риск подобных проблем. Эти выражения обрабатываются во время компиляции и гарантированно имеют правильный тип.
Во-вторых, создана новая функция с именем square. Она создана как шаблон,
поскольку неизвестно, какой тип данных нужен. Компилятор выведет подходящий тип аргумента и выберет соответствующий тип результата. Такой подход
гарантирует использование правильного кода без неявных преобразований
типов, которые могут повлиять на точность и производительность.
В-третьих, макрос FOR упраздняется, и используется цикл for . Макрос не
сильно упрощает написание кода, но вводит странную область видимости для
переменной управления циклом. Определение переменной управления цикла
в заголовке ограничивает область ее видимости рамками этого блока, что обес
печивает лучшую читаемость и бˆольшую корректность.
В-четвертых, странно реализованное булево значение заменяется на истинностный тип bool, предоставляемый C++. Ошибки при переключении переменной
обнаруживаются, и компилятор выдает сообщение, требующее от разработчика
5.2. Ошибка #27
123
устранить баг. Тип должен использоваться в соответствии с определением,
а предположения — соответствовать фактическому поведению.
Листинг 5.4. Использование конструкций C++ вместо макросов
constexpr double PI = 3.1415927;
template <typename T>
T square(T n) {
return n * n;
}
Использование именованных констант
хорошо документирует код
Шаблоны позволяют вывести тип параметра
и тип возвращаемого значения
int main() {
int n = 3;
std::cout << square(n) << '\n';
std::cout << square(n + (n - 1)) << '\n';
std::cout << square(++n) << '\n';
for(int i = 0; i < 10; ++i)
std::cout << i << '\n';
bool truth = false;
truth = !truth;
truth = !truth;
}
Выражения для аргументов
вычисляются перед вызовом
Область видимости переменной
ограничена циклом
Переключение булева значения
ограничено типом данных
if (truth == false)
std::cout << "smooth\n";
else
std::cout << "dismay\n";
return 0;
Можно было бы продемонстрировать и другие проблемы, но этих достаточно,
чтобы понять, что макросы скорее опасны, чем полезны. В интернете можно
найти множество сообщений, утверждающих, что макросы вполне можно использовать в C++, и в рамках ограниченного подхода это действительно так.
Однако на практике макросы не следует считать универсально безопасными —
они приемлемы только в некоторых случаях.
Единственное место, где я считаю использование макросов оправданным, —
это уже существующие (к сожалению) кодовые базы с требованиями условной
компиляции. В этом случае код, меняющийся между условными секциями, следует вынести в функции в заголовочном файле, а затем использовать условную
компиляцию для выбора подходящей функции.
Рекомендации
Ограничьте или исключите применение макросов, чтобы создавать безопасный и предсказуемый код.
124
Глава 5. Идиомы языка C
Используйте функции C++, которые предоставляют ожидаемые от макроса
преимущества; компилятор имеет возможность проверять их на ошибки
и выдать осмысленные сообщения.
5.3. Ошибка #28: неправильное понимание макроса NULL
Эта ошибка влияет на читаемость и иногда на эффективность. Неправильное
понимание макроса NULL может привести к проблемам с корректностью.
Проблема
Общий совет — по возможности избегайте макросов. Он особенно важен, поскольку значение и тип макроса NULL не определены однозначно. Этот код был
написан на 64-битной архитектуре, где размер указателя составляет 64 бита,
что также является размером типа long. На разных системах размеры могут
отличаться, как и определение NULL. Например, в моей системе NULL определен
в файле unicode/utypes.h следующим образом:
#define NULL ((void *)0)
Рассмотрим листинг 5.5, где NULL используется двумя разными способами,
причем ни один из них не является очевидным. В первом случае — это перегруженная функция compute, принимающая значение типа long, а во втором —
указатель на long. В первом вызове compute передается переменная типа long,
инициализированная как NULL. Очевидный выбор в этом случае — вызов должен
соответствовать первой функции. При втором вызове передается значение NULL,
которое должно соответствовать второй функции compute.
Листинг 5.5. Использование NULL двумя способами
long compute(long n) { // увеличивается значение
return ++n;
}
long compute(long* p) { // корректно увеличивает значение разыменованного
указателя
if (p)
return ++*p;
return 0;
Использует NULL в качестве
}
целочисленного нулевого
int main() {
long x = NULL;
long n = compute(x);
std::cout << n << '\n';
n = compute(NULL);
std::cout << n << '\n';
return 0;
}
значения — дурной тон
Значение long должно соответствовать
первой функции
Указатель NULL должен соответствовать
второй функции
5.3. Ошибка #28
125
Анализ
Кажется, что код обращается сначала к первой, а затем ко второй функции;
однако это не так. Первый вызов действительно соответствует перегрузке с параметром типа long (первая функция), однако второй тоже выбирает эту же
функцию. Разве это не очевидно? Макрос NULL читается как «указатель, который не ссылается ни на один допустимый объект памяти». Код в листинге 5.5
использует определение NULL, которое представляет собой целое значение long,
равное нулю; это не указатель. Но это лишь часть проблемы, связанной с этим
макросом; он выглядит как указатель и применяется как указатель, хотя им не
является. Читателя таким образом очень легко ввести в заблуждение.
Кроме того, разные компиляторы и архитектуры могут по-разному определять
NULL. Поэтому любое «допустимое» использование в одной системе не обязательно будет таким же в другой. Если разработчики используют NULL в своей
среде, они могут ожидать аналогичного поведения на другой платформе. Они
ошибаются. У NULL нет четкого определения, что создает проблемы для его
единообразного использования. Его естественная семантика нарушается в системах, которые не трактуют его как указатель (в C он определен как указатель).
Некоторые реализации определяют NULL как целое нулевое значение, размер
которого совпадает с размером указателя. Но можно ли быть уверенным в этом
без дополнительной проверки?
В самой первой строке функции main NULL используется как нулевое значение.
Это может показаться искусственным примером, пригодным исключительно для
учебных целей, но будьте уверены, такое случается достаточно часто, и некоторые
компиляторы (возможно, большинство) обнаруживают и предупреждают о таком неправильном использовании. Я видел подобное в кодовой базе реального
проекта: NULL встречался и как инициализатор, и как значение параметра там,
где требовался ноль, — ужас!
Решение
Во-первых, не используйте макрос NULL в качестве нулевого значения. Во-вторых,
помните, что NULL не является указателем и не может быть использован в качестве указателя. Идеальный способ описать указатель, который не ссылается
ни на один допустимый объект памяти, в предыдущих версиях C++ — это использовать нулевое значение. Если разработчик захотел применить NULL для
имитации указателя, его нужно привести к указателю соответствующего размера.
Единственное преимущество NULL в том, что он передает семантику указателя
лучше, чем обычный ноль.
Листинг 5.6. Использование NULL более правильным способом
long compute(long n) { // увеличивается значение
return ++n;
}
long compute(long* p) { // увеличивается значение
if (p)
return ++*p;
return 0;
}
long compute(long n) { // увеличивается значение
126returnГлава
++n;5. Идиомы языка C
}
long compute(long* p) { // увеличивается значение
if (p)
return ++*p;
return 0;
}
Использует ноль там, где
int main() {
long m = 0;
long n = compute(m);
std::cout << n << '\n';
n = compute((long*)NULL);
std::cout << n << '\n';
return 0;
}
необходимо нулевое значение
Соответствует первой функции, сохраняя
семантику значения
Соответствует второй функции, сохраняя
семантику указателя
Макрос NULL четко отражает намерение присвоить указателю значение, не указывающее ни на один объект. Однако лучше использовать ноль, чтобы избежать
особенностей, возникающих при преобразовании макроса.
Современный C++ предоставляет ключевое слово, которое избавляет от недостатков макроса NULL или нулевого значения. В следующем фрагменте показано,
как правильно присвоить переменной-указателю нулевое значение. Ключевое
слово nullptr — это литерал указателя, поэтому не происходит ни двусмысленных, ни скрытых преобразований:
long* p = new long(42);
…
delete p;
p = nullptr;
Рекомендации
Используйте нулевое значение для объявления указателя, который не ссылается ни на один допустимый объект памяти.
Избегайте использования NULL в качестве нулевого значения где бы то ни было.
Избегайте использования NULL в качестве указателя; он таковым не является.
Смотри также
«Ошибка #7», где дается подробное обсуждение использования ключевого
слова nullptr.
5.4. Ошибка #29: доступ к файлам на диске
с помощью FILE
Эта ошибка отрицательно сказывается на эффективности и читаемости. Разработчики уже много лет используют объект FILE языка C, но зачастую он
оказывается сложнее, чем хотелось бы.
5.4. Ошибка #29
127
Проблема
Довольно часто разработчикам требуется открывать файлы и читать строки
текста, и объект FILE языка C широко используется в C++ для этих целей. Код
в листинге демонстрирует распространенный подход: программа пытается открыть файл, проверяет, удалось ли это, и, если да, читает его построчно.
Листинг 5.7. Чтение строк текста с помощью FILE
const int SIZE = 100;
Будем надеяться, что каждая строка
int main() {
состоит не более чем из 100 символов
FILE* file;
file = fopen("data.txt", "r");
if (!file) {
Первое использование
std::cerr << "Error opening file\n";
отрицательной логики
return 1;
}
char buffer[SIZE];
while (!feof(file)) {
if (!fgets(buffer, SIZE, file))
Отрицательная логика и, надеемся,
break;
чтение всей строки
std::cout << buffer;
}
return 0;
}
Анализ
Пример из листинга 5.7 работает хорошо и достигает цели, ради которой он
был написан. Но понятен ли он? Код использует отрицательную логику в трех
местах: проверка успешного открытия файла, определение условия конца строки
и проверка того, была ли строка прочитана. Такой подход всегда несет большую
когнитивную нагрузку по сравнению с положительной логикой. Она возрастает,
когда мы имеем дело с вложенной отрицательной логикой, как в этом примере.
Кроме того, такие конструкции сложнее писать: то, что кажется естественным
ходом, приходится инвертировать, а получившуюся цепочку хранить в памяти.
Еще одна проблема с этим кодом — длина строки. В коде предполагается, что ни
одна строка не будет длиннее 99 символов. Является ли это разумным? Независимо
от того, какое значение используется, существует вероятность, что оно будет либо
слишком маленьким, что приведет к нескольким операциям чтения на одну строку,
либо слишком большим, что повлечет за собой неоправданный расход памяти.
Хотя технически это не ошибка, буфер должен быть на один символ больше,
чтобы предусмотреть завершающий символ строки. Если строка текста состоит
ровно из SIZE символов, то конец строки будет считан только на следующей
итерации цикла. Такой подход немного снижает производительность, поскольку
каждая строка должна обрабатываться за одну операцию чтения. Тем не менее
хороших определений для SIZE не существует; есть только работоспособные.
128
Глава 5. Идиомы языка C
Наконец, объект FILE остается открытым. Программа, длительно работающая
с таким багом, может негативно повлиять на системные ресурсы и потенциально
привести к их исчерпанию.
Решение
Эти проблемы можно решить с помощью объекта std::ifstream языка C++.
В листинге 5.8 нет отрицательной логики, что упрощает написание кода и его
чтение. Отсутствие такой конструкции означает, что код читается прямолинейно. Если входной файл не открыт, то проверка ошибки выглядит естественно.
В этом решении нет буфера; вместо него используется объект std::string. Длина
line всегда соответствует длине фактически прочитанной строки. Кроме того,
при каждом новом чтении переменная не содержит прежних данных. Буферы
же всегда были уязвимы к появлению лишних данных при частичном чтении.
Объект std::ifstream выходит из области видимости в конце функции main,
что гарантирует закрытие файла. Это отличный пример идиомы RAII, когда
пара «конструктор — деструктор» управляет динамическим ресурсом. Можно
закрыть поток вручную, но в этом нет необходимости. Такой подход упрощает
запись и чтение. Однако разработчик должен убедиться, что автоматическое
уничтожение задокументировано; в противном случае остается лишь догадываться о происходящем. Стандартная библиотека шаблонов в этом отношении
превосходна.
Листинг 5.8. Чтение строк текста с помощью ifstream
int main() {
std::ifstream file("data.txt");
if (file.fail()) {
std::cerr << "Error opening file\n";
return 1;
}
std::string line;
while (std::getline(file, line))
std::cout << line << '\n';
return 0;
}
Положительная логика облегчает
чтение и написание кода
Снова положительная логика —
и никакой перезаписи старых данных
Подход с использованием std::ifstream — это простой способ чтения текстовых
данных и проверки потока на наличие ошибок, влияющих на его работу. Такой
вариант гораздо лучше метода с FILE*, унаследованного от языка C.
Рекомендации
Используйте объекты std::ifstream и std::ofstream для упрощения чтения
и записи данных в файлы.
По возможности используйте положительную (нормальную) логику.
5.5. Ошибка #30
129
Смотри также
«Ошибка #77», где мы обсудим идиому RAII.
5.5. Ошибка #30: использование целых чисел
в качестве булевых значений
Эта ошибка влияет на корректность и читаемость. В языке C не было встроенного
типа, представляющего булевы значения; в C++ он появился позже. В ранних
версиях C++ булеву семантику обычно моделировали через целые числа.
Проблема
Использовать целое число для определения FALSE и TRUE имеет смысл; 0 означает FALSE, а 1 — TRUE (на самом деле, любое ненулевое значение считается
TRUE). В листинге 5.9 реализована эта идея и показан фрагмент кода, где она
применяется. Сделать определения константными очень важно, чтобы значения
истинности нельзя было бы изменить (Джордж Оруэлл, возможно, поспорил
бы, защищая свою концепцию из книги «1984»). Естественно, поскольку булево
значение представлено в виде целого числа, переменная позволяет любые операции, допустимые для int. Это приводит к странному коду.
Листинг 5.9. Моделирование булевых значений с помощью целых чисел
const int TRUE = 1;
const int FALSE = 0;
int main() {
int truth = FALSE;
std::cout << "truth is " << (truth ? "real"
++truth;
std::cout << "truth is " << (truth ? "real"
truth = 42;
std::cout << "truth is " << (truth ? "real"
return 0;
}
Что будет означать инкрементирование
булевой переменной?
: "illusory") << "\n";
: "illusory") << "\n";
: "illusory") << "\n";
Что означает присваивание значения,
отличного от FALSE или TRUE?
Многие ранние программисты быстро поняли, что суть булевых значений проще
передать с помощью перечисления. Такой подход ограничивает набор допустимых операций над переменной, представляющей булево значение. Следующий
листинг демонстрирует этот вариант.
Листинг 5.10. М
оделирование булевых значений с помощью
перечисления
enum BOOL { FALSE, TRUE };
Присваивание ограничено
int main() {
значениями FALSE и TRUE
BOOL truth = FALSE;
std::cout << "truth is " << (truth ? "real" : "illusory") << "\n";
truth = TRUE;
std::cout << "truth is " << (truth ? "real" : "illusory") << "\n";
return 0;
}
enum BOOL { FALSE, TRUE };
Присваивание ограничено
int main() {
значениями FALSE и TRUE
130BOOL Глава
truth 5.
= Идиомы
FALSE; языка C
std::cout << "truth is " << (truth ? "real" : "illusory") << "\n";
truth = TRUE;
std::cout << "truth is " << (truth ? "real" : "illusory") << "\n";
return 0;
}
Анализ
Основная проблема представления булевых значений в виде любого типа данных, отличного от булевого, — в искажении исходного смысла. Целочисленное
определение допускает операции, которые не имеют смысла, но являются
допустимыми. Решение на основе перечисления намного лучше и предлагает
гораздо более ограниченный набор операций. Его главная трудность в том, что
он не является стандартным и не все разработчики реализуют этот подход одинаково. Отсутствие общего понимания делает это отличное во всем остальном
решение не совсем идеальным.
Решение
Разработчики языка C++ осознали недостатки обоих способов и добавили
тип данных bool. Он представляет собой булево значение, которое должно
сделать намерение программиста более явным. Идея была правильной, но реализация могла бы быть более удачной: bool — это отдельный тип данных, но
компиляторы реализуют его в виде целых чисел, что позволяет выполнять те
же некорректные операции, что приведены в листинге 5.9. Стандарт языка не
определяет поведение для значений, отличных от 0 и 1, поэтому такие случаи
считаются неопределенными. Однако это не предотвращает неправильное использование. Тот факт, что есть отдельный тип данных, хорошо документирует
цель и назначение переменной, но не обеспечивает строгую семантику при ее
неправильном использовании. Эту ситуацию остается просто принять такой,
какая она есть.
Начиная с C++17 возможность инкрементировать переменную bool была удалена, что улучшает корректность разрешенного набора операций над этим типом.
Следующий код показывает работу переменной bool, которая должна принимать
только значения false и true. Не присваивайте ей ничего другого, иначе ваши
читатели подумают о вас плохо — это стыдно!
Листинг 5.11. Использование типа данных bool
int main() {
bool truth =
std::cout <<
++truth;
std::cout <<
truth = 42;
std::cout <<
return 0;
}
false;
"truth is " << (truth ? "real" : "illusory") << "\n";
"truth is " << (truth ? "real" : "illusory") << "\n";
"truth is " << (truth ? "real" : "illusory") << "\n";
5.6. Ошибка #31
131
Тип данных bool должен использоваться в рамках предусмотренного набора
операций и представлять только булевы значения. Любого другого использования следует избегать.
Рекомендации
Используйте тип данных bool для выражения истиннности; он хорошо работает и ясно документирует свое назначение.
Не присваивайте переменной bool никаких других значений, кроме false
и true.
По возможности, используйте стандарты C++17 или выше, где семантика
bool более строгая.
5.6. Ошибка #31: использование приведения типов
в стиле C
Эта ошибка влияет на корректность и читаемость, а также немного снижает
эффективность. Однако преимущества приведения типов стоят того, чтобы
лишний раз понажимать клавиши. Отрицательное влияние на эффективность — предсказуемое последствие: приведения типов в стиле C++ крупные,
уродливые и сложные в написании, поэтому их следует использовать с умом
и осторожностью.
Проблема
Существуют четыре основные ситуации, когда приведение типов необходимо:
обход ограничений констант;
переинтерпретация данных как другого типа;
преобразование одного совместимого типа в другой;
нисходящее приведение типа указателя от базового класса к производ
ному.
С помощью приведения типов в стиле C можно выполнить все четыре операции, не вызывая проблем с компиляцией. Однако успешная компиляция еще
не означает корректную или осмысленную. Рассмотрим код в листинге 5.12.
В первом фрагменте приведение типов используется, чтобы обойти константность (const) массива символов и изменить данные. Во втором — целое число
трактуется как последовательность символов. В третьем — символьный массив
маскируется под двойной указатель, что не имеет смысла (кроме специфических
случаев маршалинга/демаршалинга, когда других вариантов действительно нет).
В четвертом — объект базового класса приводится к производному.
132
Глава 5. Идиомы языка C
Листинг 5.12. Ш
ирокое и опасное использование приведения типов
в стиле C
struct B {
virtual int compute() { return 0; }
};
struct D : public B {
int n;
D() : n(42) {}
int compute() { return n; }
};
int main() {
const char msg[] = "Hello, world";
char* p = (char*)msg;
*(p+1) = 'a';
std::cout << msg << '\n';
int n = 0x2A2A2A2A;
char* c = (char*)(&n);
for (int i = 0; i < sizeof(int); ++i)
std::cout << *(c+i);
std::cout << '\n';
std::cout << *((double*)msg) << '\n';
B* b = new B();
D* d = (D*)b;
std::cout << d->compute() << '\n';
}
Обходит константность массива
символов
Рассматривает целое число
как набор байтов
Это не имеет смысла, но все равно
компилируется
D является B, но B может и не быть D
return 0;
Анализ
Первый пример нарушает семантику типа данных и должен применяться крайне редко. Компилятор по умолчанию не считает это ошибкой, хотя его можно
настроить так, чтобы он ее выявлял. Такая «свобода» позволяет программисту
делать с данными все, что он пожелает.
Второй пример выглядит хитро, но, как правило, с большой натяжкой: хотя он
и работает, для таких задач требуются более подходящие типы и конструкции.
Третий фрагмент — откровенно проблемный: семантика полностью нарушена — два типа несовместимы и непереводимы, но при этом код компилируется,
что приводит к неопределенному поведению. Наконец, четвертый пример
может (изредка) встречаться на практике, но в таком виде крайне опасен.
Виртуальная функция производного класса вызывается некорректно, а если
бы и вызывалась, то обращалась бы к несуществующему полю — снова неопределенное поведение!
5.6. Ошибка #31
133
Решение
Каждая ситуация с приведением типов требует более осмысленного подхода,
чем простое приведение в стиле C. С этим связаны две основные проблемы: вопервых, необходимо передать замысел программиста, а во-вторых, прояснить
выполняемые преобразования. Приведения типов в стиле C++ намеренно длинные и выглядят неуклюже, но хорошо документируют намерения и заставляют
разработчика дважды подумать, прежде чем их применять. Обычно более продуманный код позволяет вовсе обойтись без кастов, но в тех немногих случаях,
когда они действительно нужны (целесообразны), используйте предлагаемые
далее версии.
Каст const_cast намеренно снимает (или добавляет, если сущность еще не
константна) константность. Он часто нужен при смешивании кода на C и C++ —
используйте его ответственно.
Каст reinterpret_cast следует применять очень редко. Он говорит компилятору
игнорировать фактический тип данных и делать вид, что объект имеет новый
тип. Компилятор выполняет желания программиста, но не несет никакой ответственности за результат. Пример уместного использования — запись и чтение
двоичных файлов с числовыми данными. В таких случаях этот каст хорошо
документирует намерения.
Каст static_cast преобразует один совместимый тип данных в другой. Компилятор проверяет совместимость типов; при несовместимости сообщает об ошибке.
Из всех кастов этот используется чаще всего. Например, усечение значения
double до int успешно достигается с помощью static_cast.
Наконец, dynamic_cast преобразует указатель или ссылку на базовый класс
в указатель или ссылку на производный. Если вы используете этот механизм,
в классе должен быть хотя бы один виртуальный метод (не наследуйте без него!);
в противном случае невозможно корректно хранить информацию о типе для
нисходящего преобразования. Результат проверяется во время выполнения,
что позволяет успешно собрать программу, но все же получить сбой во время
выполнения. Совместимый каст возвращает валидный указатель, а несовместимый — нулевой; однако к такому подходу стоит прибегать крайне редко. Если
же неудачно преобразовать ссылку, возникает исключение. C++ предоставляет
для этого более надежные механизмы, прежде всего — наследование с полиморфизмом.
Листинг 5.13. Б
олее аккуратное и ответственное использование
приведения типов в стиле C++
struct B {
virtual int compute() { return 0; }
};
134
Глава 5. Идиомы языка C
struct D : public B {
int n;
D() : n(42) {}
int compute() { return n; }
};
int main() {
const char msg[] = "Hello, world";
char* p = const_cast<char*>(msg);
*(p+1) = 'a';
std::cout << msg << '\n';
Предположим, что это правильно и задумка
разработчика явно задокументирована
int n = 0x2A2A2A2A;
char* c = reinterpret_cast<char*>(&n);
for (int i = 0; i < sizeof(int); ++i)
std::cout << *(c+i);
std::cout << '\n';
Еще один странный случай с явно
документированным намерением
// std::cout << static_cast<double*>(msg) << '\n';
std::cout << static_cast<double>(n) << '\n';
B* b = new B();
D* d = dynamic_cast<D*>(b);
if (d)
std::cout << d->compute() << '\n';
else
std::cout << "incompatible downcast\n";
}
Компилятор
определяет, что типы
несовместимы
Разумное преобразование
типов
Если типы совместимы, указатель будет
валидным, иначе — NULL (nullptr)
return 0;
Приведение типов в стиле C и использование reinterpret_cast могут стать
источником неопределенного поведения, если их неправильно использовать.
Если вы чувствуете необходимость в приведении типов, лучше подумайте
о возможности перепроектирования кода. Если же без приведения не обойтись, используйте его только тогда, когда убеждены, что преобразование
безопасно.
Рекомендации
По возможности избегайте приведения типов.
Используйте const_cast при сопряжении кода C++ с функциями C.
Проверяйте результат dynamic_cast, чтобы убедиться в совместимости указателей.
Хорошо подумайте, прежде чем использовать reinterpret_cast; разные архитектуры представляют данные по-разному, после такого преобразования вы
можете получить сильно различающиеся результаты; при этом легко попасть
в ловушку неопределенного поведения.
5.7. Ошибка #32
135
5.7. Ошибка #32: преобразование текста
с помощью функции atoi
Эта ошибка влияет в первую очередь на корректность, а также немного затрагивает читаемость и эффективность. Ввод с клавиатуры всегда осуществляется
в виде потока символов; однако эти символы не имеют никакого изначально
присущего им значения. Другими словами, нет контекста, по которому можно
было бы определить их тип данных. Функции преобразования принудительно
задают этот контекст.
В языке C предусмотрены функции преобразования для типов данных, которые
компилятор не переводит напрямую. Функция atoi используется для преобразования строк в стиле C в целочисленные значения. Семейство функций sprintf
осуществляет работу в противоположном направлении, принимая числовые
данные и создавая соответствующие строки в стиле C.
Проблема
Функция atoi анализирует строку в стиле C и пропускает все пробельные
символы, стоящие в начале. Она ищет знаки и цифры, соответствующие целочисленному значению, начиная с первого непробельного символа. К допустимым
относятся начальный плюс или минус и десятичные цифры. Первый найденный
символ, не соответствующий этому шаблону, считается разделителем, и обработка строки завершается.
Если входные данные представляют собой пустую строку или не найдено ни
одного допустимого символа, функция спокойно возвращает значение 0. Рассмотрим следующий код, в котором вводится возраст.
Листинг 5.14. Безобидный ввод текста, представляющего возраст
int main() {
std::cout << "Enter your age: ";
std::string input;
std::cin >> input;
}
Преобразует недопустимые входные данные
и нулевое значение в целое число «ноль»
int age = atoi(input.c_str());
std::cout << "On your next birthday, you will be " << age + 1 << " years
old\n";
return 0;
Все работает хорошо, если вводятся допустимые цифры. Однако если приходит неверный ввод, функция преобразования дает сбой и возвращает 0 .
Если пользователь действительно ввел 0, результат тот же. То есть отсутствует
136
Глава 5. Идиомы языка C
важная информация о том, является ли полученный 0 допустимым (например,
была введена цифра 0) или недопустимым значением (например, была введена
строка cat).
Анализ
Фундаментальная проблема семейства функций atoi состоит в том, что возвращаемое значение 0 нуждается в интерпретации. Если пользователь кода из
листинга 5.14 действительно младенец, выходной результат будет правильным.
Но если он случайно ткнет в клавиатуру и введет что-либо, кроме цифр от 0 до 9,
итоговый вывод окажется ошибочным.
Добавление проверки, позволяющей определить, является ли полученный 0
корректным, вынуждает просматривать каждый символ строки и убеждаться,
что он принадлежит ожидаемому диапазону. Даже если добавить такую функцию проверки, результат станет громоздким и трудночитаемым. Поскольку
C++ предоставляет средства, позволяющие более разумно обрабатывать ввод,
используйте их для проверки данных. Ваши пользователи, скорее всего, не скажут
вам спасибо — хорошо, если хотя бы воздержатся от проклятий.
Решение
Хотя существует не один подход к решению этой проблемы, простейший вариант — использовать класс stringstream для синтаксического анализа строковых
данных с помощью привычного потока с оператором извлечения. Преимущество
этого метода — возможность проверить входной поток на наличие сбоев, чего
не позволяют делать функции типа atoi.
Листинг 5.15. Проверка ввода возраста
int main() {
std::cout << "Enter your age: ";
std::string input;
std::cin >> input;
}
std::istringstream is(input);
int age;
Недопустимые входные данные можно
is >> age;
обнаружить и обработать корректно
if (is.fail()) {
std::cout << "Invalid input\n";
return 1;
}
std::cout << "On your next birthday, you will be " << age + 1 << " years
old\n";
return 0;
5.7. Ошибка #32
137
Если не проверять результат функции fail, то поведение по умолчанию аналогично atoi — для недопустимых данных возвращается нулевое значение. Обязательно проверяйте поток на наличие ошибок: объекты достаточно умны, не упускайте
возможность использовать эти знания. Это гарантирует более корректную работу
программы. Использование оператора извлечения позволяет легко проверить,
удалось ли преобразовать текст в ожидаемый тип. Всегда проверяйте результат
преобразования. В C++11 есть функция std::stoi, которая преобразует строки
в стиле C или C++ в числовые значения и выбрасывает исключение при ошибке.
C++17 предоставляет функцию std::from_chars, обеспечивающую эффективное
преобразование текста в числа и позволяющую корректно обрабатывать ошибки.
Чтобы увидеть эти опции в действии, рассмотрим следующий листинг.
Листинг 5.16. С
овременный вариант ввода возраста с возможностью
проверки
int main() {
std::cout << "Enter your age: ";
std::string input;
std::cin >> input;
int age;
// C++11
try {
age = std::stoi(input);
} catch (const std::invalid_argument& e) {
std::cout << "stoi: Invalid input\n";
return 1;
}
std::cout << "On your next birthday, you will be " << age + 1 << " years
old\n";
}
// C++17
auto result = std::from_chars(input.data(), input.data() + input.size(),
age);
if (result.ec != std::errc()) {
std::cout << "from_chars: Invalid input\n";
return 1;
}
std::cout << "On your next birthday, you will be " << age + 1 << " years
old\n";
return 0;
Рекомендации
Используйте потоки ввода, чтобы проверять данные на ошибки.
По возможности используйте функции, которые выбрасывают исключения;
их легко проверить.
138
Глава 5. Идиомы языка C
По возможности заменяйте вызовы функции atoi на другие, более умные
решения, предоставляемые C++.
Если вам доступна современная версия языка, рассмотрите функции
std::stoi или std::from_chars.
5.8. Ошибка #33: использование строк в стиле C
Эта ошибка влияет на читаемость и эффективность; в некоторых случаях страдает и производительность. Строки в стиле C — это массивы символов, завершающий элемент которых имеет нулевое значение: это контрольный символ,
обозначающий конец строки.
Проблема
В языке С не было понятия класса или того, что можно назвать «умными»
объектами. Вместо этого процедурный код, как правило, был наполнен специ
фическими командами для управления «глупыми» объектами. Разработчики
часто работали со строками в стиле C во множестве мест, нередко дублируя код
и воспроизводя одну и ту же функциональность (часто путем копирования).
Код в листинге 5.17 — лишь один из множества возможных примеров. В старых
кодовых базах строки в стиле C обычно применяются повсеместно по причине
их привычности. Многие функции принимают параметры char*, что поощряет
их дальнейшее использование.
Рассмотрим следующий код, в котором разработчик подсчитывает количество
появлений символа в строке C-стиля. Код вполне безобиден и делает именно
то, что от него требуется. Он выглядит производительным и решает задачу за
O(n). Кому такое может не понравиться?
Листинг 5.17. Простые алгоритмы поиска и добавления
int freq(const char* s, char k) {
int count = 0;
for (int i = 0; i < strlen(s); ++i)
if (s[i] == k)
++count;
return count;
}
char* concat(const
char* buffer =
strcpy(buffer,
strcat(buffer,
return buffer;
}
Линейный поиск по строке
char* lhs, const char* rhs) {
new char[strlen(lhs) + strlen(rhs)];
lhs);
rhs);
Динамическое
выделение памяти —
правильно ли это?
int main() {
const char* msg = "Hello, world";
char letter = 'l';
std::cout << letter << " occurs " << freq(msg, letter) << " times\n";
const char* msg2 = ", come on in!";
std::cout << concat(msg, msg2) << '\n';
}
}
char* lhs, const char* rhs) {
new char[strlen(lhs) + strlen(rhs)];
lhs);
rhs);
Динамическое
выделение памяти —
правильно
ли это? 139
5.8. Ошибка
#33
char* concat(const
char* buffer =
strcpy(buffer,
strcat(buffer,
return buffer;
}
int main() {
const char* msg = "Hello, world";
char letter = 'l';
std::cout << letter << " occurs " << freq(msg, letter) << " times\n";
const char* msg2 = ", come on in!";
std::cout << concat(msg, msg2) << '\n';
}
Анализ
Для относительно коротких строк этот подход работает хорошо — код писать
легко. Однако для длинных строк его производительность может оказаться
неприемлемой. Используйте профилировщик, чтобы понять, где требуется
более эффективное решение. На первый взгляд, цикл осуществляет линейный
поиск, и это действительно так: он является линейным O(n). Но весь алгоритм
оказывается квадратичным, O(n2). Почему? Строки в стиле C «глупые»: код не
может просто запросить у строки ее длину — ее приходится вычислять каждый
раз заново. В цикле длина строки пересчитывается на каждой итерации; то есть
каждая операция strlen сама по себе линейная O(n), а вызывается она n раз, что
приводит к квадратичному решению. К счастью, большинство компиляторов
обнаруживают эту ситуацию и выносят вызов strlen за пределы цикла. Однако не стоит полагаться на то, что компилятор исправит это во всех возможных
случаях. Это как раз пример того, когда знание проблемы приводит к простому
решению. Тем не менее подход остается неидеальным, поскольку по-прежнему
опирается на строки в стиле C. Код в листинге 5.18 улучшает производительность, но не устраняет недочеты самого подхода.
Любой код вне области видимости цикла все равно будет пересчитывать длину
строки. Если обработка строк раскидана по большой кодовой базе, то вычисление
длины в одном месте никак не помогает в других.
Управление массивами символов также создает трудности. Нужно помнить различие между strcpy и strcat и учитывать обработку завершающего нулевого
символа. Необходимо убедиться, что буферы назначения длиннее строки как
минимум на один символ. Проблема в том, что разработчику приходится держать в голове множество мелких деталей, хотя обычно все работает правильно.
Однако в тех редких случаях, когда все идет наперекосяк, возникает неопределенное поведение. Часто, если целевая строка слишком мала, копирование или
добавление кажется корректным, но фактически повреждает данные. Хорошие
системы при этом дают сбой (но не все системы хорошие).
Вычисление длины строки в функции freq не дает преимущества аналогичному
вычислению в функции concat. Кроме того, динамическая память, выделенная
в concat, не была освобождена впоследствии. Этот факт может показаться безобидным, но подобные утечки напрямую влияют на корректность.
140
Глава 5. Идиомы языка C
Листинг 5.18. Усовершенствованные алгоритмы поиска и добавления
int freq(const char* s, char k) {
int count = 0;
int len = strlen(s);
for (int i = 0; i < len; ++i)
if (s[i] == k)
++count;
return count;
}
Вычисляет длину строки один
раз для этой функции
char* concat(const char* lhs, const char* rhs) {
char* buffer = new char[strlen(lhs) +
strlen(rhs) + 1];
Обеспечивает место для завершающего
strcpy(buffer, lhs);
нулевого символа
strcat(buffer, rhs);
return buffer;
}
int main() {
const char* msg = "Hello, world";
char letter = 'l';
std::cout << letter << " occurs " << freq(msg, letter) << " times\n";
const char* msg2 = ", come on in!";
std::cout << concat(msg, msg2) << '\n';
delete [] msg;
}
Решение
C++ предлагает значительные улучшения по сравнению со строками в стиле C.
Проблемы с ними были настолько существенны, что одним из первых серьезных
пользовательских классов, появившихся в первые дни существования C++, стал
класс string. Строка в стиле C++ — это «умный» объект: разработчик может задавать ей вопросы и получать ответы. В следующем коде этот факт используется
для получения длины строки string.
Листинг 5.19. И
спользование строк C++ для решения выявленных
проблем
int freq(const std::string& s, char k) {
int count = 0;
for (int i = 0; i < s.length(); ++i)
if (s[i] == k)
++count;
return count;
}
Константное время получения длины
std::string concat(const std::string& lhs, const std::string& rhs) {
return lhs + rhs;
Разработчику не нужно заботиться
}
о длине при перемещении данных
int main() {
std::string msg = "Hello, world";
char letter = 'l';
std::cout << letter << " occurs " << freq(msg, letter) << " times\n";
std::string msg2 = ", come on in!";
std::cout << concat(msg, msg2) << '\n';
Память, выделенная под временные
}
}
if (s[i] == k)
++count;
return count;
std::string concat(const std::string& lhs, const std::string& rhs) {
5.9. Ошибка #34
return lhs + rhs;
Разработчику не нужно заботиться
}
141
о длине при перемещении данных
int main() {
std::string msg = "Hello, world";
char letter = 'l';
std::cout << letter << " occurs " << freq(msg, letter) << " times\n";
std::string msg2 = ", come on in!";
std::cout << concat(msg, msg2) << '\n';
Память, выделенная под временные
}
объекты, обрабатывается автоматически
Решение могло бы быть и лучше, но оно сохраняет структуру предыдущего
кода, одновременно улучшая его. Локальная переменная, инициализированная
длиной строки, была бы более подходящим вариантом, но текущий подход
демонстрирует преимущества «умных» объектов. Оставив запрос длины строки внутри цикла, мы видим, что, хотя функция вызывается n раз, суммарная
вычислительная стоимость цикла остается O(n). Это возможно, потому что
запрос длины не приводит к вычислению длины строки. Она определяется
при создании объекта и сохраняется во внутреннем поле, поэтому операция
выполняется за O(1).
Конкатенация двух строк решает проблему управления длиной результирующей
строки, позволяя объекту самому определять необходимый объем памяти. Разработчику не нужно знать, как именно обрабатываются данные — используется
завершающий символ (нулевой терминатор) или нет. К тому же освобождать
динамическую память вручную не требуется: объект строки управляет ею сам
с помощью идиомы RAII.
Рекомендации
Научитесь использовать строки в стиле C++ везде, где вам нужны последовательности символов; класс string хорошо оптимизирован и прост в применении.
По возможности откажитесь от строк в стиле C, заменяя их строками C++.
Строки автоматически управляют динамической памятью: когда объект выходит из области видимости, вызывается его деструктор.
Смотри также
«Ошибка #43», где показан улучшенный способ индексации строки. Этот
подход работает с любым контейнером.
5.9. Ошибка #34: вызов функции exit
Эта ошибка влияет на корректность. Использование функции exit может привести к утечкам при работе с ограниченными или динамическими ресурсами,
что ухудшит стабильность системы.
142
Глава 5. Идиомы языка C
Проблема
Предположим, что наш разработчик вынужден следовать некоторым сомнительным требованиям. При обнаружении уволенного сотрудника программа должна
аварийно завершиться во время обработки платежной ведомости. Разработчик
понял это требование и вспомнил, что в прежних программах на языке C для
немедленного завершения работы сразу вызывали функцию exit; поэтому он
решил использовать тот же прием и в программе на C++. Позднее, после появления странных сбоев, системный программист спросил разработчика, почему
эта программа нарушает стабильность системы.
Листинг 5.20. Завершение программы с помощью функции exit
class Connection {
private:
int conn;
public:
Connection(const std::string& name) : conn(0) {
conn = 1; // предположим: устанавливает соединение с ресурсом
// подключения к базе данных
}
~Connection() {
if (conn)
conn = 0; // предположим: разрывает соединение с ресурсом
// подключения к базе
std::cerr << "Connection destroyed\n";
}
};
struct Employee {
bool isTerminated() { return true; }
double computePay() { return 42.0; }
};
int main() {
Connection c("payroll");
Employee emp;
if (emp.isTerminated())
exit(-1);
std::cout << emp.computePay() << '\n';
return 0;
}
В этой точке программа
должна завершиться
Анализ
Программа устанавливает соединение с базой данных, обрабатывает сотрудников и аварийно завершает работу, когда обнаруживает уволенного сотрудника.
Ее перезапускают после корректировки базы, и, предположительно, она падает еще несколько раз. В конце обработки база данных вычищена, программа
5.9. Ошибка #34
143
выполняется до самого конца, и все получат зарплату — ура-а! Но почему возникают проблемы со стабильностью системы? Проблема заключается в классе
Connection. Его конструктор получает доступ к ограниченному ресурсу, который
деструктор должен освободить. В обычных обстоятельствах этот код работает
корректно. Но, как это бывает, проблемы проявляются именно в особых случаях. Вызов exit принуждает программу немедленно завершить работу. Объекты
Connection не будут очищены, поскольку их деструкторы не смогут выполниться.
Динамический ресурс был выделен, но так и не будет освобожден — классическая утечка ресурсов.
Решение
Решение этой проблемы состоит из двух шагов. Во-первых, не используйте
вызов функции exit; вместо этого выбрасывайте исключение. Во-вторых, оберните внешний код в общий блок try/catch, который затем повторно пробросит
исключение. Разумеется, выражение catch может быть более конкретным, но
в контексте этой проблемы «интеллект» кода ограничен.
Такой подход предотвращает утечку ресурсов, поскольку деструкторам дается
возможность выполниться. Когда объект Connection выходит из области видимости, его деструктор возвращает соединение к базе данных в пул, не допуская
ситуации, когда ресурс выделен, но не освобожден.
Надо признать, что код выглядит неуклюже, но разработчики часто сталкиваются с необходимостью улучшить общую структуру или реализацию. В таких
случаях требуются «дешевые» решения, даже если архитектура оставляет желать
лучшего. Редко когда у программистов есть возможность выделить достаточно
времени и денежных средств на модернизацию старого кода, поэтому приходится учитывать реальные ограничения и улучшать то, что возможно. Реальность
иногда кусается.
Листинг 5.21. Завершение программы с использованием исключения
class Connection {
private:
int conn;
public:
Connection(const std::string& name) : conn(0) {
conn = 1; // предположим: ресурс подключения к базе данных выделен
}
~Connection() {
if (conn)
conn = 0; // предположим: возвращает подключение к базе данных в пул
std::cerr << "Connection destroyed\n";
}
};
144
Глава 5. Идиомы языка C
struct Employee {
bool isTerminated() { return true; }
double computePay() { return 42.0; }
};
struct TerminatedEmployee {
std::string message;
TerminatedEmployee(const std::string& msg) : message(msg) {}
};
int main() {
try {
Connection c("payroll");
Employee emp;
if (emp.isTerminated())
throw TerminatedEmployee("Employee was terminated");
std::cout << emp.computePay() << '\n';
} catch (...) {
throw;
В этой точке происходит
}
завершение программы
return 0;
}
В некоторых случаях действительно требуется досрочно завершить работу программы. Когда возникает такая необходимость, важно обеспечить корректный
выход: деструкторы должны быть вызываны до завершения. Программа должна
завершаться без очистки ресурсов только в тех ситуациях, когда раскрутка стека нежелательна (например, при отладке). В таких случаях будьте осторожны,
чтобы не допустить утечки системных ресурсов — сокетов, соединений с базами
данных и подобных ограниченных сущностей.
Рекомендации
Избегайте вызова функции exit.
Выбрасывайте исключение, перехватывайте его в общем (или конкретном)
обработчике и пробрасывайте повторно, чтобы дать деструкторам возможность отработать.
Улучшайте все, что можете, и там, где можете; однако, скорее всего, у вас не
будет шанса значительно улучшить большие объемы кода.
Смотри также
«Ошибка #74», где мы обсудим динамические ресурсы.
«Ошибка #75», чтобы узнать об опасностях, связанных с аварийным завершением работы программы без очистки динамических ресурсов.
«Ошибка #76»: там речь идет о других случаях утечки динамических ресурсов.
«Ошибка #77» — об идеальной идиоме для предотвращения утечки ресурсов.
5.10. Ошибка #35
145
5.10. Ошибка #35: отдавать предпочтение
массивам, а не векторам
Эта ошибка влияет на эффективность и читаемость; в некоторых случаях может
повлиять и на корректность, что приведет к неблагоприятным последствиям.
Язык C предоставлял только один встроенный контейнер данных, который
широко применялся. Массив — это последовательность элементов, к которым
обращаются по одному имени и различают по индексу.
Проблема
Многие задачи программирования требуют создания коллекций сущностей —
как встроенных, так и написанных пользователем. Если пользовательский тип
хранится в массиве, у него должен быть конструктор по умолчанию. Иногда
это оправданно, но часто — нет. Бессмысленные конструкторы по умолчанию
указывают на то, что проект нуждается в пересмотре. Однако при использовании
массивов у вас нет выбора.
При создании статического массива количество элементов должно быть известно на этапе компиляции. Во многих задачах точное число элементов заранее неизвестно, поэтому выбранный размер оказывается произвольным. Если
разработчик завышает оценку — место тратится впустую; если занижает — возникают сбои или что-нибудь похуже. Отличным выбором здесь становится использование динамических массивов, поскольку код может определить нужный
размер до выделения памяти. Но разработчик должен помнить о необходимости
управлять ресурсами, и динамические массивы, к сожалению, все равно требуют
конструктора по умолчанию.
Независимо от того, сколько элементов содержит массив, всегда можно добавить еще один. Использование динамических массивов позволяет разработчику
управляться со слишком коротким массивом путем выделения большего массива
и копирования в него значений. Если на массив ссылаются другие сущности,
разработчиков часто ждет неприятный сюрприз: они могут не знать, что выделенная память уже освобождена.
Наконец, попробуйте удалить элемент массива, который больше не нужен или
недействителен. Весь код, использующий массив, должен знать, как определяется
недействительный (или удаленный) элемент. Эта проблема распространяется на
все функции, использующие массив, и дублирует усилия. Поскольку массивы —
«глупые» объекты, нет способа спросить у них, какие элементы являются действительными. Разработчик должен определить схему пометок, и читателю может
быть неочевидно, что означает этот код или почему он возится с определенными
элементами. Следующий код демонстрирует простой случай статического массива,
в котором класс Person вынужден иметь фиктивный конструктор по умолчанию.
146
Глава 5. Идиомы языка C
Листинг 5.22. С
татические массивы принудительно используют
конструктор по умолчанию
struct Person {
std::string name;
int age;
Person(const std::string& n, int a) : name(n), age(a) {}
Person() : name("", 0) {}
};
int main() {
Person people[3];
Person suzy("Susan", 25);
people[0] = suzy;
Person anna("Annette", 32);
people[2] = anna;
std::cout << people[1].name << '\n';
}
Каждый элемент
инициализируется путем
вызова конструктора
по умолчанию
int count = 5; // предположим, что это вычислено
Person* others = new Person[count];
return 0;
Анализ
Первый массив является статическим; количество элементов должно быть известно во время компиляции. Этот пример показывает, что выбранное количество
оказалось слишком маленьким. Был опробован альтернативный вариант —
выделение динамического массива после определения количества элементов.
В обоих случаях требуется конструктор по умолчанию, но разумные значения
для переменных экземпляра отсутствуют. Какое-то значение так или иначе
должно быть выбрано независимо от его смысла. Если четыре из пяти элементов
будут инициализированы осмысленно, то недостающий элемент все равно будет
«легитимным» объектом класса Person; однако его данные будут недопустимы,
поскольку ни один реальный человек ими не описывается.
Решение
Замена массива вектором — почти всегда правильный выбор. Доктор Бьёрн
Страуструп (Dr. Stroustrup), создатель C++, рекомендует именно такой подход, и его авторитет сложно переоценить. Но даже если бы он этого не говорил,
оснований использовать вектор более чем достаточно.
Во-первых, векторы — динамические объекты, поэтому выбрать слишком мало
элементов для них невозможно. Использование вектора во многом похоже на
использование массива: массив лежит в основе вектора, называемого внутренним массивом (backing array). Волшебство происходит, когда этот внутренний
массив заполняется и в нем не остается свободного места. При добавлении
5.10. Ошибка #35
147
нового элемента сбоя не происходит: вектор выделяет новый, более крупный внутренний массив, копирует туда элементы из предыдущего массива и добавляет
новый элемент в первый свободный индекс. Разработчик просто наслаждается
этим удобством — без необходимости вручную управлять памятью. См. пункт
«в-третьих» — там приводится предупреждение об опасности слишком частого
расширения внутреннего массива. Пожалуйста, не думайте, что векторы можно
использовать неправильно; некорректный код может вызвать исключение — как
и должно быть!
Во-вторых, элементы можно помещать в вектор без привязки к конкретному
индексу. Достаточно, чтобы индекс был действителен, тогда элемент может
быть добавлен. Удаление элемента тоже выполняется корректно: нет никаких
неудобных флагов, которые нужно установить, чтобы указать на недействительность элемента.
В-третьих, если внутренний массив перераспределяется много раз, производительность может пострадать из-за многократного копирования элементов.
Лучше предварительно оценить количество необходимых элементов и вызвать функцию reserve. Она выделяет достаточно памяти под указанное число
элементов. Если значение выбрано удачно, не будет ни перераспределений, ни
неиспользованного пространства. Даже если ваше предположение окажется неверным, вектор будет вести себя правильно: при слишком маленьком значении
произойдет перераспределение, при слишком большом — часть памяти останется
неиспользованной. Однако разработчику не придется управлять памятью или
беспокоиться о принципах действия вектора.
Следующий код вносит улучшения в реализацию массива и делает написание
кода более гладким и безошибочным. Разработчики никогда не получают ничего даром, но при разумном использовании векторы приближают нас к этой
несбыточной мечте.
Листинг 5.23. Использование вектора для замены неудобных массивов
struct Person {
std::string name;
int age;
Person(const std::string& n, int a) : name(n), age(a) {}
};
int main() {
std::vector<Person> people;
Person suzy("Susan", 25);
people.push_back(suzy);
Person anna("Annette", 32);
people.push_back(anna);
std::cout << people[people.size()-1].name << '\n';
148
}
Глава 5. Идиомы языка C
int count = 5; // предположим, что это вычислено
std::vector<Person> others;
others.reserve(count);
return 0;
Рекомендации
Заменяйте массивы векторами во всех возможных случаях.
Внимательно ознакомьтесь с последствиями использования векторов с точки
зрения экономии пространства и времени; во многих случаях они не вызывают проблем и ведут себя отлично.
Векторы имеют множество доступных методов — изучите их, чтобы понять
их мощный потенциал и возможности.
Смотри также
«Ошибка #49», где обсуждаются аргументы против конструкторов по умолчанию в целом и особенно в контексте массивов.
6
Лучшее из предыдущих
версий C++
В этой главе
3 Ввод и вывод
3 Выделение памяти
3 Списки параметров переменной длины
3 Итераторы
В процессе эволюции от C к C++ наследие прежних практик программирования
продолжает формировать ландшафт разработки программного обеспечения.
Переходя от идиом языка С, рассмотренных в главе 5, мы сосредоточимся на
устойчивом влиянии укоренившихся привычек раннего C++. В этой главе мы
рассмотрим почти незаметное, но существенное воздействие исторической преемственности на структуру и функциональность кодовых баз C++.
Несмотря на усовершенствования, появившиеся в С++, разработчики нередко
оставались привержены привычным конструкциям и С-ориентированному
стилю мышления. Эта склонность к существующим подходам распространялась
и на выбор библиотечных функций, иногда препятствуя использованию новых
возможностей C++. Даже среди тех, кто принял новейшие средства языка, сохранялась неопределенность в отношении наиболее эффективного использования
некоторых из его расширенных возможностей.
150
Глава 6. Лучшее из предыдущих версий C++
Влияние этих шаблонов проявилось в ряде проблем, скрытых в кодовых базах.
В этой главе обсуждаются такие ситуации и предлагаются идеи и решения,
которые помогают избежать типичных подводных камней и заблуждений. Обнаруживая эти проблемы и понимая способы их устранения, мы сможем лучше
ориентироваться в некоторых сложных аспектах программирования на C++.
6.1. Ошибка #36: ввод и вывод с использованием
функций scanf и printf
В этой ошибке основное внимание уделяется эффективности, за которой следует
и читаемость. Когда функции типа scanf и printf — это все, что у вас есть, приходится их использовать. Однако часто бывает сложно делать это правильно,
и в коде они всегда выглядят неуклюже.
Проблема
Многие задачи требуют ввода текстовых данных с клавиатуры или из файла и их
преобразования в различные типы данных. Функции scanf и printf используют
спецификатор формата для определения типа. Обычно он представляет собой
один символ, обозначающий тип. Если используется неправильный формат, это
приводит к неопределенному поведению. В некоторых случаях ошибка будет
допустима, в других — произойдет сбой или что-то похуже. Скорее всего, данные
окажутся неверными, даже если неправильный спецификатор не вызовет сбоя.
Корректно прочитать строку формата возможно только тогда, когда разработчик
знает ее возможности. В листинге 6.1 используется сложный спецификатор
формата для функции sscanf (сканирование строк). Иногда он оказывается
труднее для чтения, чем регулярное выражение, из-за чего читателю приходится останавливаться и мысленно разбирать его части. Если какой-либо формат
неизвестен, разработчикам приходится искать его значение и верные способы
применения в интернете или на других ресурсах. Это касается многих аспектов
программирования, а не только спецификаторов формата. Суть в том, что все,
что заставляет нас прервать чтение и что-то уточнять, указывает на возможность
для улучшения. Спецификаторы формата — один из таких случаев.
Листинг 6.1. И
спользование сложных спецификаторов формата
для определения преобразований
int main() {
const char* str = "3.14159 042 boxes .3";
double pi;
int cats;
int mice;
char buffer[5];
Все спецификации входных
данных применяются в одной
операции; правильны ли они?
Программист должен
определить правильное
int count = sscanf(str, "%lf%*c%i%s%d", &pi, &cats,
число
buffer, &mice);
if (count != 4)
std::cout << "error reading value " << count+1 << '\n';
printf("%f being eaten by %d cats in %s along with %d mice\n",
pi, cats, mice, mice);
Текст, спецификация и переменные неуклюже
return 0;
перемешаны — в этом содержится ошибка
const char* str = "3.14159 042 boxes .3";
double pi;
int cats;
int mice;
char buffer[5];
Все спецификации входных
данных применяются в одной
операции; правильны ли они?
Программист должен
#36
151
определить
правильное
int count = sscanf(str, "%lf%*c%i%s%d", &pi, &cats, 6.1. Ошибка
число
buffer, &mice);
if (count != 4)
std::cout << "error reading value " << count+1 << '\n';
}
printf("%f being eaten by %d cats in %s along with %d mice\n",
pi, cats, mice, mice);
Текст, спецификация и переменные неуклюже
return 0;
перемешаны — в этом содержится ошибка
Анализ
В спецификаторе формата есть несколько странностей, которые неочевидны.
Переменная типа double могла бы интуитивно считываться и преобразовываться
с помощью символа d, но это привело бы к ошибке. Символ d здесь означает
decimal (десятичный), а не double (с двойной точностью). Символ f представляет значение с плавающей точкой, что логично воспринимается как тип float.
Чтобы считать текст в переменную типа double, требуется формат типа long
с плавающей точкой — lf. Можно было бы ожидать чего-то более простого.
Другая странность в том, что первая целочисленная переменная использует
спецификатор i (для integer?), а вторая — d. На выходе первая переменная
дает 34, а не 42. Это объясняется тем, что d означает десятичное целое число,
а i — целое число с основанием, которое определяется по первому символу
ввода (0 — для восьмеричных, 0x — для шестнадцатеричных, иначе — для
десятичных). В данном случае ноль в начале означает, что текст представляет собой восьмеричное значение. Неудивительно, что такие детали легко
забываются.
Переменная buffer представляет собой строку в стиле C и использует строку
формата s — наконец-то спецификатор с интуитивно понятным значением! Поскольку разработчик знал, что строка должна состоять из пяти символов, ровно
столько элементов было выделено для массива. Красиво, чисто — и неправильно.
Функции семейства scanf передают символы в буфер и добавляют завершающий нулевой символ. Это могло быть очевидно; однако если бы использовался
спецификатор c, то нулевой символ не добавлялся бы. Эти различия нужно либо
запомнить, либо каждый раз искать в справочнике.
Вывод printf тоже имеет свои проблемы. Его спецификаторы в некоторых
случаях отличаются от форматов scanf. В этом примере для переменной типа
double используется шаблон f, а не lf, как в scanf, — об этом следует помнить.
Смешивание выходного текста и спецификаторов в одной строке понятно, но
вносит некоторый когнитивный хаос, поскольку переменные перечисляются
после строки формата. При чтении слева направо приходится перескакивать
от строки к переменным и обратно (и их порядок должен строго соблюдаться).
Наконец, что может произойти, если возникнет несоответствие между специ
фикатором и типом переменной? В примере из листинга 6.1 переменная mice
указана неверно дважды; в первом случае она вообще должна поменяться местами
с переменной buffer. Что произойдет, если целочисленная переменная будет
152
Глава 6. Лучшее из предыдущих версий C++
использована в качестве источника для строки в стиле C? Ничего хорошего.
Правильный ответ — страшное неопределенное поведение. Моя система выдает
ошибку сегментации, которая мягко напоминает мне о том, что что-то сломалось,
не обманывая меня продолжением выполнения.
Эти семейства функций не являются типобезопасными, что делает их использование рискованным. Слишком легко перепутать предполагаемый спецификатор
формата и реальный тип данных. Учитывая ограниченные возможности обнаружения ошибок, осмысленный код обработки багов является слишком сложным.
Решение
Язык C предоставил нам функции scanf и printf, однако мы пишем на C++,
а он предлагает более удобные и безопасные возможности. Каждый раз, когда
символьные данные перемещаются в программу или из нее, следует использовать
поток. Потоки предоставляют очень полезные операторы вставки и извлечения
(<< и >> соответственно). Эти операторы определяют, как преобразовать текст
в нужный тип данных (если входные данные корректны), и избавляют разработчика от необходимости указывать спецификатор типа.
Неправильно написанный код в листинге 6.2, где переменная mice выводится
дважды, не приводит ни к сбою, ни к повреждению данных — просто выходные
данные оказываются неверными. Но, в отличие от листинга 6.1, здесь нет ошибочного преобразования одного типа. Извлечение текстовых данных в различные
переменные работает корректно, если данные согласованы, но не работает, если
формат нарушен. Проблема scanf заключалась в том, что ввод и преобразование
выполнялись за один шаг; при возникновении ошибки разработчик должен был
определить, какое из преобразований провалилось, по возвращаемому значению.
Логика проверки в листинге 6.1 проще, чем в листинге 6.2, но, по крайней мере,
вторая версия дает разработчику более точный контроль над обнаружением
ошибок.
Листинг 6.2. Использование типов для определения преобразований
int main() {
std::istringstream str("3.14159 042 boxes .3");
double pi;
int cats;
int mice;
Ввод и преобразование работают
std::string buffer;
str >> pi;
if (str.fail())
std::cout <<
str >> cats;
if (str.fail())
std::cout <<
str >> buffer;
if (str.fail())
std::cout <<
str >> mice;
if (str.fail())
std::cout <<
корректно или устанавливают легко
проверяемый флаг fail()
"error reading value 1\n";
"error reading value 2\n";
"error reading value 3\n";
"error reading value 4\n";
int mice;
std::string buffer;
}
"error reading value 1\n";
6.2. Ошибка #37
str >> pi;
if (str.fail())
std::cout <<
str >> cats;
if (str.fail())
std::cout <<
str >> buffer;
if (str.fail())
std::cout <<
str >> mice;
if (str.fail())
std::cout <<
Ввод и преобразование работают
корректно или устанавливают легко
проверяемый флаг fail()
153
"error reading value 2\n";
"error reading value 3\n";
"error reading value 4\n";
std::cout << pi << " being eaten by " << cats << " cats in " << mice
<< " along with " << mice << " mice\n";
Преобразование зависит только
return 0;
от фактических данных
Целочисленное значение 042 правильно преобразуется в 42; ноль в начале не
влияет на результат. Если данные действительно восьмеричные, входной поток
можно настроить на основание 8. Например, следующий код вводит и преобразует переменную cats как восьмеричное число:
str >> std::setbase(8) >> cats;
Рекомендации
По возможности заменяйте вызовы scanf и printf на более эффективные
потоковые операторы извлечения и вставки.
Избегайте сложных строк формата ввода и вывода; их трудно правильно
написать и еще труднее читать.
6.2. Ошибка #37: чрезмерное использование endl
Эта ошибка связана с производительностью. Операционные системы обычно
буферизируют входные и выходные данные, чтобы минимизировать количество
операций ввода/вывода (input/output, I/O). Неправильное понимание процесса
буферизации может существенно ухудшить производительность.
Проблема
Код в следующем листинге предельно прост: цикл выполняется тысячу раз
и каждый раз выводит значение переменной управления циклом. Наконец, выводится сообщение о завершении — ничего сложного, не так ли?
Листинг 6.3. Использование std::endl где попало
int main() {
for (int i = 0; i < 1000; ++i)
std::cout << i << std::endl;
std::cout << "finished!" << std::endl;
return 0;
}
Выводит данные в буфер std::cout
и сбрасывает на устройство
154
Глава 6. Лучшее из предыдущих версий C++
Анализ
Здесь нет никаких хитростей! Код работает так, как и ожидается, — за исключением того, что его производительность хуже, чем хотелось бы. Проблема в том,
что std::endl не только выводит символ конца строки, но и очищает выходной
буфер. Меньшее количество операций ввода-вывода равнозначно большей
пропускной способности. Поток std::cout — буферизированный; он выполняет меньше операций ввода-вывода. Его буфер накапливает выходные данные
и символы новой строки до тех пор, пока не заполнится, после чего сбрасывает
накопленное одним крупным блоком. Использование std::endl нарушает эту
оптимизацию буферизацией, поскольку всегда принудительно очищает буфер
независимо от объема содержащихся в нем данных. На некоторых системах
различные оптимизации частично компенсируют эту очевидную разницу при
выводе на терминал, но не при выводе в файл, поэтому рассмотрим нюансы
этого анализа более подробно.
Решение
Если вы замените std::endl на '\n' , буферизация будет работать так, как
и должна. Помните, что в некоторых случаях частично заполненные буферы
все же нужно сбрасывать, чтобы завершить операцию ввода-вывода. Код в листинге 6.4 позволяет буферу заполниться, пока выводятся все значения и новые
строки. Затем сообщение о завершении операции очищает буфер, гарантируя,
что все накопленные данные будут записаны. В этом случае программа завершается сразу после сброса, поэтому он не обязателен, но в целом такой подход
считается хорошей практикой.
Листинг 6.4. Разумное использование std::endl
int main() {
Выводит данные в буфер std::cout
for (int i = 0; i < 1000; ++i)
std::cout << i << '\n';
std::cout << "finished!" << std::endl;
Выводит данные и сбрасывает буфер
return 0;
на устройство
}
Буферизованный вывод стоит использовать практически всегда — он взаи
модействует с операционной системой и обеспечивает наилучшую производительность. Если требуется вывод без буферизации, используйте поток
std::cerr.
Рекомендации
Замените std::endl на \n везде, где это возможно, чтобы предотвратить
лишнюю очистку буфера.
6.3. Ошибка #38
155
Если необходимо явно сбросить выходной буфер, используйте std::endl.
При выводе большого объема данных используйте \n для всех строк, кроме
последней; для последней — std::endl.
Для небуферизованного вывода используйте std::cerr.
6.3. Ошибка #38: динамическое выделение памяти
с помощью функций malloc и free
Эта ошибка влияет на корректность и, в меньшей степени, на эффективность.
Выделение памяти претерпело значительные изменения со времен C, но это не
значит, что все изменения были приняты одинаково хорошо.
Проблема
C предоставляет пару функций выделения и освобождения памяти — malloc
и free. Как и в случае с динамическим выделением ресурсов в C++ с использованием операторов new и delete, все, что получено с помощью malloc, должно
быть освобождено через free. Звучит просто — и во многих случаях так оно
и есть. Однако смешение кода на C и C++ может усложнить этот «простой»
процесс, особенно когда владение ресурсами меняется по мере передачи объекта
между функциями.
В листинге 6.5 показаны две распространенные проблемы, возникающие при
использовании malloc:
неправильное вычисление размера;
невозможность инициализировать полученную память.
Листинг 6.5. Хаотичное использование malloc и free
struct Buffer {
char* str;
Buffer(int size) : str(new char[size+1]) { str[0] = '\0'; }
~Buffer() { free(str); }
};
Опечатка, пропущенная
замыленным взглядом
Пространство под динамический объект выделено, но не
инициализировано
int main() {
double* val = (double*)malloc(sizeof(int));
std::cout << val << '\n';
Buffer* buf = (Buffer*)malloc(sizeof(Buffer));
std::cout << buf->str << ", size " << strlen(buf->str)
free(buf);
return 0;
}
<< '\n';
156
Глава 6. Лучшее из предыдущих версий C++
Анализ
Помимо двух проблем с malloc, отмеченных ранее, в коде есть еще как минимум
три дополнительных бага. Во-первых, выделяется блок динамической памяти
для хранения значений типа double. Из-за усталости и падения уровня кофеина разработчик по ошибке запросил память только под целое число (обычно
вдвое меньшее, чем размер double). Компилятор промолчал об этом несоответствии — классический баг безопасности типов. Любое обращение к данным
в этом слишком коротком блоке приведет к выходу за его пределы. Ненавижу
запах неопределенного поведения по утрам.
Вторая проблема проявляется при создании объекта Buffer. На этот раз размер выделенной памяти корректный, но переменная char* не инициализируется. Конструктор должен получить блок динамической памяти для хранения
символьных данных и гарантировать, что завершающий символ будет записан
в первую позицию. Однако этого никогда не происходит. При вызове malloc
конструктор не выполняется и инициализация не производится.
В-третьих, нет соответствующего освобождения памяти для переменной val. Динамически выделенный блок утечет при выходе из функции main: ни деструктор,
ни другой управляющий механизм не следит за памятью. В долго выполняющейся программе несколько таких утечек могут привести к серьезным проблемам.
Четвертая проблема: хотя объект buf правильно освобождается, динамическая
память, которую он мог бы выделить внутри себя, — нет. Конечно, в текущем коде
она никогда и не выделялась, но в реальном варианте использования она была
бы выделена только для того, чтобы затем произошла утечка при освобождении
вмещающей сущности. Вызов функции free не запускает деструктор, оставляя
все динамические ресурсы внутри объекта неосвобожденными.
Наконец, имеется несоответствие между выделением динамической памяти
для объекта Buffer с помощью new в конструкторе и попыткой деструктора
освободить ее через free. Поведение при таком несоответствии не определено,
что оставляет программу (и программиста!) на милость компилятора. Более надежная реализация семантики копирования и присваивания (и перемещения)
в классе Buffer помогла бы избежать значительной части этих проблем.
Решение
Операторы new и delete были разработаны не просто для того, чтобы быть
противоположными. Недостатки malloc и free были достаточно существенными,
чтобы оправдать появление нового подхода.
Как показано в листинге 6.6, первая проблема решается с помощью оператора
new, который гарантирует, что выделенная сущность имеет корректный тип для
принимающей переменной — никакие баги безопасности типов не проскользнут.
6.3. Ошибка #38
157
Вторая проблема устраняется тем, что при каждом выделении памяти вызывается конструктор. Правильно спроектированный код будет использовать идио
му RAII, в результате выделение памяти будет сопровождаться правильным
освобождением. Третья проблема состоит в том, что можно по-прежнему забыть
удалить объект, выделенный через new; разработчик должен обеспечить их появление в паре с delete. Четвертая проблема решается при вызове деструктора,
если он правильно спроектирован (опять же RAII). Пятая и последняя проблема:
несоответствие функций выделения и освобождения не может возникнуть, если
используются только new и delete. Смешивать new/delete с malloc/free — не
только дурной тон, но и прямой путь к серьезным проблемам: еще более неопределенному поведению.
Листинг 6.6. Грамотное использование new и delete
struct Buffer {
char* str;
Buffer(int size) : str(new char[size+1]) { str[0] = '\0'; }
~Buffer() { delete[] str; }
};
Так больше нельзя
int main() {
Безопасность типов
//
double* val = new int;
гарантирована
double* val = new double;
std::cout << val << '\n';
Вызывается конструктор
Buffer* buf = new Buffer(25);
std::cout << buf->str << ", size " << strlen(buf->str) << '\n';
delete buf;
Вызывается деструктор;
return 0;
не забывайте про удаление!
}
Класс Buffer может удалить или скрыть конструктор копирования и оператор
присваивания, чтобы сделать свою реализацию более надежной. Это здесь
не продемонстрировано, но всегда должно учитываться при использовании
динамических ресурсов. В разделе «Смотри также» указаны номера ошибок
с обсуждением этих тем.
Рекомендации
Заменяйте вызовы malloc на new, а free — на delete.
Помните, что каждому new должен соответствовать свой delete, а каждому
new[] — соответствующий delete[].
Смотри также
«Ошибка #77», где обсуждаются идиомы RAII.
«Ошибка #78», чтобы изучить утечки ресурсов.
158
Глава 6. Лучшее из предыдущих версий C++
6.4. Ошибка #39: использование объединений (union)
для преобразования типов
Эта проблема влияет на корректность и читаемость. Компилятор понимает типы
переменных в C++, а попытки программиста преобразовать их могут поставить
в тупик механизмы проверки типов.
Проблема
Система проверки типов в компиляторе призвана предотвратить несоответствие
одного типа другому. Человек может не видеть особой разницы между некоторыми типами, но компилятор знает лучше и блокирует многие преобразования — если только программист не настаивает на приведении и не приказывает
компилятору «заткнуться». Такой подход работает как по волшебству — компилятор умолкает, — но почти наверняка приводит к ошибкам, которые трудно
обнаружить и сложно отлаживать.
Код в листинге 6.7 работает в некоторых системах. В нем используется union
(объединение) для описания массива из четырех символов и целого числа. Программист хочет превратить целое число в набор символов и вывести его в двоичный файл. Объединение mixer упрощает преобразование между небольшим
массивом символов и целым числом: значение записывается в экземпляр объединения, а при вызове функции write рассматривается как массив символов.
В моей системе это работает хорошо.
Листинг 6.7. Использование union для преобразования типов
static const int bytes_per_int = 4;
union mixer {
char ch[bytes_per_int];
int n;
};
Всегда ли целое число
занимает четыре байта?
Здесь это предполагается
int main() {
mixer converter;
converter.n = 42;
std::ofstream out("data.txt", std::ios::binary);
out.write(converter.ch, bytes_per_int);
return 0;
}
Анализ
Объединение mixer резервирует четыре байта под массив символов и накладывает на него целое число. Это работает в системах, где число занимает четыре
байта, но будет ли это работать везде? Будет ли это работать при изменении
6.4. Ошибка #39
159
версии компилятора? Возможно, на оба вопроса можно ответить «да», но не стоит
рассчитывать, что такой код заработает на любой чужой системе. Нет никакой
гарантии, что целое число всегда займет четыре байта. Конечно, программист
может проверить это значение и жестко зашить его в код; однако такое решение
является непереносимым. Объединение union не позволяет эффективно использовать динамическую память, чтобы автоматически подгонять размер массива
символов под размер целого типа.
Решение
Фундаментальная проблема с преобразованиями типов, которые не являются
повышениями (promotions), заключается в том, что разработчик делает предположения о базовой структуре размеров, битов и байтов. Эти предположения
делают код, по сути, жестким и непереносимым. Осуществляемые программистом преобразования проблематичны из-за этих предположений, но если их
все же необходимо реализовать в коде, к делу, по возможности, должен быть
привлечен компилятор.
Оператор reinterpret_cast с использованием определяемого компилятором
размера целого числа, показанный в листинге 6.8, — более удачный подход.
Здесь нет жестко запрограммированного размера, который разработчик должен
выявить и проверить: компилятор определяет его для конкретной архитектуры
во время сборки.
Одно из немногих мест, где двоичные потоки действительно имеют смысл, —
ситуации, когда уместны и преобразования типов, определяемых пользователем.
Данные, преобразованные в символы (точнее, байты), никогда не используются
в таком виде. Обратное преобразование указателя с помощью reinterpret_cast —
из символов обратно в исходный тип — должно быть строго симметричным,
чтобы избежать странностей и ошибок.
Листинг 6.8. Использование reinterpret_cast в разумных пределах
int main() {
int n = 42;
std::ofstream out("data.txt", std::ios::binary);
out.write(reinterpret_cast<const char*>(&n),
sizeof(n));
Компилятор определяет правильный размер;
return 0;
никаких предположений не делается
}
Учитывая все вышесказанное, гораздо лучше проектировать программу так,
чтобы в ней вообще отсутствовало пользовательское преобразование типов.
Доверяйте базовым типам данных и используйте классы для агрегирования
разных типов данных. По возможности перекладывайте детали реализации на
компилятор и работайте на более высоком уровне абстракции.
160
Глава 6. Лучшее из предыдущих версий C++
Еще два места, где могут потребоваться преобразования типов, определяемые
пользователем, — это границы интерфейса API и интеграция с модулями на
другом языке. В таких случаях используйте компилятор, чтобы упростить процесс преобразования. Кроме того, изолируйте код преобразования в хорошо документированные, небольшие, одноцелевые функции, которые выполняют эти
операции. Проверяйте допущения этих функций каждый раз, когда меняются
компилятор, системный уровень или архитектура.
Рекомендации
По возможности избегайте определяемых пользователем и зависящих от
реализации преобразований типов.
Там, где они все-таки необходимы, по возможности избегайте жестко запрограммированных предположений; используйте компилятор в качестве
помощника.
Тщательно документируйте каждое место, где появляется такой код преобразования; сделайте все предположения явными, чтобы программисты
сопровождения могли корректировать их по мере необходимости при неизбежных изменениях.
Смотри также
«Ошибка #31», где ведется обсуждение оператора reinterpret_cast и некоторых его ограничений.
6.5. Ошибка #40: использование varargs для списков
с переменным количеством параметров
Эта ошибка в большей степени затрагивает корректность, а также влияет на
эффективность и читаемость. Списки с переменным количеством аргументов
(variable argument lists) используются, когда разработчик не знает, сколько параметров потребуется для вызова функции сверх нескольких первых.
Проблема
Вместо того чтобы писать несколько перегруженных функций, которые принимают всё большее количество параметров, можно использовать одну функцию,
работающую с переменным числом аргументов (varargs), — для любого количества
параметров. Как правило, функция имеет один или несколько именованных параметров, какой-то из них обычно задает длину списка. Последний параметр — это
многоточие (...), которое указывает компилятору, что далее следует переменное
количество аргументов. Технически правильнее было бы сказать, что за ними следует неизвестное количество значений, но не будем придираться к терминологии.
6.5. Ошибка 40
161
Рассмотрим код в листинге 6.9. Разработчик хотел иметь одну функцию, которая
бы выполняла суммирование списка значений. Для гибкости добавляется начальное значение — на случай, если к сумме нужно прибавить результат предыдущего
вычисления (обычно это ноль). Количество значений необходимо передавать
явно, потому что функция должна знать, сколько раз нужно прочитать список
значений: компилятор не определяет это автоматически. Что может пойти не так?
Функция начинает с объявления списка аргументов типа va_list. Далее процесс инициализируется вызовом va_start; затем происходит проход по каждому аргументу с помощью va_arg; и, наконец, выполняется завершение работы
с помощью va_end.
Этот код демонстрирует ошибку, которая была замечена в коде реального программного продукта. Макросу va_start нужно знать две вещи: во-первых, где
хранится список аргументов; во-вторых, какой параметр является последним
именованным перед переменными аргументами. В этом случае следовало бы
использовать initial, а не len. Хотя так делать определенно не стоит, такой
странный подход, похоже, работает (выяснение причин этого оставим любопытному читателю).
Листинг 6.9. И
спользование varargs для списка значений переменной
длины
int sum(int len, int initial, ...) {
int sum = initial;
va_list args;
va_start(args, len);
for (int i = 0; i < len; ++i)
sum += va_arg(args, int);
va_end(args);
return sum;
}
Упс, ошибка! Но это уже почти правильно
Обращается к каждому переданному значению
int main() {
std::cout << sum(9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9) << '\n';
std::cout << sum(3, 5, 1, 2, 3) << '\n';
return 0;
}
Анализ
Ожидаемая сумма двух вызовов корректно выводится в одних системах, но
в других могут возникнуть проблемы. Но подумайте, что может произойти,
если одним из значений переменного аргумента окажется число типа double
или объект Person. Компилятор не проверяет тип аргументов, поэтому это не
вызовет ни ошибки, ни предупреждения. По сути, использование varargs из
языка C отключает любую проверку, перекладывая ответственность за результат
162
Глава 6. Лучшее из предыдущих версий C++
на разработчика. Как и следовало ожидать, несовпадение типов приводит к неопределенному поведению. Хуже того, функция sum может вернуть значение, не
раскрывающее того, что внутри все сломано и программа упорно отказывается
аварийно завершаться.
ПРИМЕЧАНИЕ То, что система отработала «как ожидалось», не означает, что код
корректен или ведет себя правильно. Это лишь говорит о том, что не произошло
очевидного сбоя. Преимущество такого примера в том, что неправильно написанная
функция может выглядеть рабочей, скрывая потенциально фатальную проблему.
Не игнорируйте предупреждения, сомнительные приемы и прочие признаки беды
только потому, что «вроде все работает». Приведенный код некорректен. Это как раз
тот случай, когда все может внезапно привести к сбою, стоит чему угодно в системе
(компилятор, платформа, вспышки на Солнце и т. д.) измениться. Пользователь, будь
бдителен! (Caveat emptor!)
Решение
Проще говоря, использование varargs — плохая идея. Это не значит, что они
никогда не работают, но означает, что разработчик не защищен от ошибок.
В языке C практически не было других способов добиться подобной функциональности, но и возможностей для возникновения проблем, связанных с типами, было меньше. В C++ в этом случае довольно легко все испортить. Одно из
возможных решений — передать массив значений и полностью убрать varargs.
Базовая функциональность sum сохранится и после удаления макросов va_*, но
существует решение и получше — с использованием векторов. Преимущество
обоих подходов в том, что они не позволяют добавлять значения типов, отличных от типа элемента. Таким образом, компилятор может достичь безопасности
типов. Рассмотрим код в листинге 6.10, где используется вектор и отпадает необходимость передавать длину.
В следующем коде используется шаблон для повышения гибкости в выборе
типа элемента. Это решение позволяет работать с произвольным количеством
значений одного и того же типа — прекрасный подход.
Листинг 6.10. Использование векторов для списков переменной длины
template <typename T>
T sum(T initial, const std::vector<T>& vals) {
T sum = initial;
for (int i = 0; i < vals.size(); ++i)
sum += vals[i];
return sum;
}
Выбирает шаблонную форму
для большей гибкости
int main() {
std::vector<int> intvalues;
Выбирает контейнер для большей гибкости
for (int i = 1; i < 10; ++i)
intvalues.push_back(i);
std::cout << sum(0, intvalues) << '\n';
std::vector<double> doublevalues;
for (int i = 1; i < 4; ++i)
doublevalues.push_back(i);
std::cout << sum(5.0, doublevalues) << '\n';
return 0;
}
T sum = initial;
for (int i = 0; i < vals.size(); ++i)
sum += vals[i];
return sum;
для большей гибкости
6.6. Ошибка #41
int main() {
std::vector<int> intvalues;
Выбирает контейнер для большей гибкости
for (int i = 1; i < 10; ++i)
intvalues.push_back(i);
std::cout << sum(0, intvalues) << '\n';
std::vector<double> doublevalues;
for (int i = 1; i < 4; ++i)
doublevalues.push_back(i);
std::cout << sum(5.0, doublevalues) << '\n';
return 0;
}
163
Рекомендации
По возможности избегайте использования varargs; удалите их и замените
более надежным решением.
Используйте массивы или, еще лучше, векторы для достижения тех же результатов, но с безопасностью типов.
Смотри также
«Ошибка #18», где объясняется современный подход в языке C++, который
заменяет концепцию с varargs, сохраняет безопасность типов и к тому же
может быть даже забавным в использовании.
6.6. Ошибка #41: неправильный порядок
инициализации класса
Эта ошибка влияет на производительность и эффективность. Списки инициализации повышают скорость инициализации переменных экземпляра.
Проблема
Код в листинге 6.11 демонстрирует попытку построить экземпляр из предоставленных параметров. Полное имя представляет собой композицию из переменных title, first, middle и last (обращение (титул, звание), имя, отчество
и фамилия). Инициализация полного имени из этих переменных в конструкторе
выглядит разумной, поскольку все остальные переменные инициализируются
из переданных значений. В этом случае построение полного имени можно было
бы выполнить в аксессоре (методе доступа). Тем не менее попытка сэкономить
небольшую долю производительности — при условии, что к полному имени никогда не обращались, — дает мало выигрыша и вынуждает разработчика писать
больше кода. Такой подход снижает читаемость и не приносит заметной пользы.
При запуске кода в моей системе выбрасывается исключение std::bad_alloc.
Любой программист исправил бы эту ошибку. Такой результат — одно из возможных последствий этой проблемы; ваша система может отреагировать по-другому.
Как бы то ни было, в коде есть проблема, приводящая к неопределенному поведению. Если по случайности этот код будет запущен, скорее всего, результат
окажется странным, а отладка — затруднительной.
164
Глава 6. Лучшее из предыдущих версий C++
Листинг 6.11. Неправильный порядок инициализации
struct Person {
std::string full;
std::string first;
std::string middle;
std::string last;
std::string title;
Person(std::string f, std::string m, std::string l, std::string t) :
first(f), middle(m), last(l), title(t),
full(title + ' ' + first + ' ' + ' ' + middle + ' ' + last ) {}
};
int main() {
Person judge("Hank", "M.", "Hye", "Hon.");
std::cout << judge.full << '\n';
return 0;
}
Собирает полное имя
из отдельных частей
Анализ
По большей части этот код корректен, но обратите внимание на порядок объявления переменных экземпляра и порядок их инициализации. Сначала объявляется
переменная full, а затем все остальные. Программист немного поразмыслил
и решил инициализировать эту переменную последней, поскольку отдельные
поля (вроде бы) уже были инициализированы.
Разработчик ошибся в этом предположении: хотя переменная экземпляра full
в списке инициализации стоит последней, компилятор генерирует код инициализации полей в порядке объявления. Поэтому full инициализируется первой,
раньше всех остальных. Конкатенация этих неинициализированных строк приводит либо к выбрасыванию исключения, либо к другому неприятному поведению.
Поняв, что порядок инициализации задается порядком объявления, автор программы решил взять процесс под свой контроль, поместив код в тело конструктора. Результат показан в листинге 6.12.
Листинг 6.12. Перемещение инициализации в тело конструктора
struct Person {
std::string full;
std::string first;
Помещает всю инициализацию в тело конструктора
std::string middle;
(хуже по производительности)
std::string last;
std::string title;
Person(std::string f, std::string m, std::string l, std::string t) {
first = f; middle = m; last = l; title = t;
full = title + ' ' + first + ' ' + ' ' + middle + ' ' + last;
}
};
int main() {
6.6. Ошибка #41
}
165
Person judge("Hank", "M.", "Hye", "Hon.");
std::cout << judge.full << '\n';
return 0;
Это решение работает, но требует больше ресурсов. Это может быть неочевидно,
но все переменные экземпляра по-прежнему инициализируются с использованием формы списка инициализаторов, как если бы код выглядел так:
Person(std::string f, std::string m, std::string l, std::string t) :
full(), first(), middle(), last(), title() {}
Компилятор гарантирует, что все поля класса инициализируются через конструкторы по умолчанию. То есть сначала каждое поле инициализируется значением
по умолчанию в списке инициализаторов. Затем выполняется тело конструктора,
и значения параметров присваиваются уже существующим объектам. В итоге
затраты на каждую операцию примерно удваиваются (не будем придираться
к разнице в производительности между конструктором копирования и оператором присваивания). Такой подход нельзя назвать оптимальным.
Решение
Поскольку компилятор использует список инициализации для каждой переменной экземпляра класса (кроме примитивов) в объявлении этого класса,
если порядок важен, идеальным подходом будет расположить переменные в том
порядке, в котором они должны инициализироваться. В этом примере порядок
следования имеет значение; по-возможности избегайте таких зависимостей. Если
порядок не важен, выбирайте то, что логично (алфавитный порядок, группировка
по типу и т. д.). Следующий код учитывает порядок следования и гарантирует,
что full строится только после полной инициализации всех ее составных частей.
Листинг 6.13. Грамотное использование порядка инициализации
struct Person {
std::string first;
std::string middle;
std::string last;
std::string title;
std::string full;
Person(std::string f, std::string m, std::string l,
std::string t) :
first(f), middle(m), last(l), title(t),
full(title + ' ' + first + ' ' + ' ' + middle + ' ' + last ) {}
};
Переупорядочивает переменные, чтобы в первую
очередь обеспечить инициализацию компонентов
int main() {
Person judge("Hank", "M.", "Hye", "Hon.");
std::cout << judge.full << '\n';
return 0;
}
166
Глава 6. Лучшее из предыдущих версий C++
Рекомендации
По возможности избегайте составных переменных экземпляра; если без них
не обойтись, располагайте их так, чтобы все компоненты инициализировались первыми.
Используйте списки инициализации для всех переменных экземпляра; даже
если вы их не пишете явно, компилятор сгенерирует их в любом случае.
Убедитесь, что предупреждения компилятора включены; это одна из самых
ранних и наиболее широко распространенных проблем (рассмотрите возможность всегда использовать -Wall при компиляции).
6.7. Ошибка #42: добавление в контейнеры типов,
не являющихся значениями
Эта проблема влияет на корректность. В большинстве случаев контейнеры —
предпочтительная альтернатива массивам. Однако этот факт не избавляет от
проблем при использовании контейнеров.
Проблема
Часто в задачах программирования приходится иметь дело с группами данных, которые обрабатываются или используются совместно. Массивы — это классическое
решение для хранения и управления отдельными элементами данных как единым
целым; однако их не следует применять часто. Стандартная библиотека шаблонов
предлагает несколько контейнеров, которые гораздо умнее массивов и предоставляют функциональность, делающую их использование целесообразным.
Предположим, наш разработчик хочет вывести список людей на мероприятии.
В списке будут присутствовать и Person, и Employee, но все они должны быть
перечислены. Код в листинге 6.14 показывает его попытку. Из-за особенностей
владения объектами программисту порекомендовали использовать автоматические указатели (auto_ptr). Смело продвигаясь вперед, он написал и протестировал код. Результаты могли бы быть и более обнадеживающими.
Листинг 6.14. Н
еправильное использование контейнера
для полиморфных элементов
struct Person {
std::string name;
Person(const std::string& n) : name(n) {}
virtual std::string toString() { return name; }
};
struct Employee : public Person {
double salary;
Employee(const std::string& n, double s) : Person(n), salary(s) {}
6.7. Ошибка #42
};
167
std::string toString() {
std::stringstream ss;
ss << Person::name << " gets paid " << salary;
return ss.str();
}
int main() {
Person p("Sue");
Employee e("Jane", 123.45);
std::vector<Person> people;
people.push_back(p);
people.push_back(e);
for (int i = 0; i < people.size(); ++i)
std::cout << people[i].toString() << '\n';
Полиморфное поведение
не работает
std::vector<std::auto_ptr<Person> > persons;
// persons.push_back(p); persons.push_back(e);
Это не скомпилируется:
элементы имеют разные типы
std::vector<Person> peeps;
peeps.push_back(p);
for (int i = 0; i < peeps.size(); ++i)
std::cout << peeps[i].toString() << '\n';
}
Отдельные векторы
решили проблему
std::vector<Employee> emps;
emps.push_back(e);
for (int i = 0; i < emps.size(); ++i)
std::cout << emps[i].toString() << '\n';
return 0;
Анализ
Попытка использовать вектор auto_ptr не компилируется. Разработчик закомментировал этот код, рассчитывая вернуться к нему позже. Поскольку ему очень
хотелось заставить код работать, он создал отдельные контейнеры для хранения
каждого типа данных. Этот подход работает, но нуждается в оптимизации.
Проблема с одним вектором в том, что в нем хранятся объекты базового типа. Для
каждого элемента выделяется место, достаточное только для объекта базового
класса, данные производного класса при этом отбрасываются. Та же проблема
возникает и при использовании массивов для решения подобной задачи. Вот
вам и полиморфное поведение!
Вторая попытка заключалась в использовании новомодного auto_ptr, который
эксклюзивно владеет объектами внутри контейнера. Идея выглядит разумной,
но упирается в жесткое ограничение: в контейнеры можно помещать только те
типы данных, для которых определены конструктор копирования и оператор
присваивания. Тип auto_ptr этим требованиям не соответствует. Поскольку
168
Глава 6. Лучшее из предыдущих версий C++
он реализует семантику эксклюзивного владения, экземпляры не могут быть
добавлены в вектор — вот вам и новомодные идеи. (В современном C++ есть
подходы куда лучше, чем auto_ptr; этот тип по уважительной причине был
объявлен устаревшим в C++11.) Последняя попытка разработчика решает проблему, однако лишает подход гибкости единого контейнера — его определенно
можно сделать лучше.
Решение
Контейнеры должны содержать типы значений — примитивы или классы, не
смешанные с экземплярами производных объектов, — либо указатели (в этом
случае сырые указатели). В современном C++ проблема сырых указателей решена, поэтому этот совет актуален в основном для более ранних версий C++.
Сырые указатели выбираются в качестве типа элемента контейнера, чтобы сохранить гибкость, задуманную разработчиком, устранить проблему обрезания
данных и обеспечить работоспособность конструктора копирования и оператора
присваивания. В следующем коде приведено решение, демонстрирующее все
предпочтительные действия программиста.
Листинг 6.15. П
равильное использование контейнера для полиморфных
элементов
struct Person {
std::string name;
Person(const std::string& n) : name(n) {}
virtual std::string toString() const { return name; }
virtual ~Person() {}
};
struct Employee : public Person {
double salary;
Employee(const std::string& n, double s) : Person(n), salary(s) {}
std::string toString() const {
std::stringstream ss;
ss << Person::name << " gets paid " << salary;
return ss.str();
}
};
int main() {
Person p("Sue");
Employee e("Jane", 123.45);
}
std::vector<Person*> people;
people.push_back(&p);
Каждый из них может быть добавлен;
people.push_back(&e);
они относятся к одному типу элементов
for (int i = 0; i < people.size(); ++i)
std::cout << people[i]->toString() << '\n';
Полиморфное поведение работает
return 0;
6.8. Ошибка 43
169
Контейнеры полезны при работе с полиморфными элементами, но ими нужно
правильно управлять. Убедитесь, что базовые классы имеют виртуальный деструктор, поскольку контейнеры копируют и удаляют элементы при создании
и уничтожении. Безопасный код — залог стабильной работы!
Рекомендации
Убедитесь, что все, что добавляется в контейнер, можно копировать и присваивать; классы, которые запрещают одно или другое, не могут быть элементами контейнера.
Контейнеры должны содержать либо тип значения, либо тип указателя, чтобы
избежать обрезания данных или других проблем.
По возможности используйте современные средства C++.
Смотри также
«Ошибка #8», где мы обсудили указатели с эксклюзивным владением.
«Ошибка #9», чтобы изучить указатели с совместным владением.
«Ошибка #35», где мы разобрали преимущества использования контейнеров
стандартной библиотеки шаблонов.
«Ошибка #57», где мы рассмотрим виртуальные деструкторы базовых классов.
«Ошибка #59», чтобы узнать подводные камни, связанные с обрезанием
элементов в контейнере.
«Ошибка #6», где мы обсудили циклы for, основанные на диапазоне.
6.8. Ошибка 43: предпочтение индексов итераторам
Эта ошибка влияет на эффективность и на производительность. Многие программисты привыкли перебирать массивы по индексам и применяют этот подход к контейнерам.
Проблема
Разработка кода с использованием массивов направляет мысли разработчика
в сторону одного, привычного способа итерации. Использовать индексы в цик
ле — обычное дело и подход, применимый ко многим задачам; однако такой
прием может излишне ограничивать мышление и приводить к проблемам при
работе с другими контейнерами.
Массивы редко являются лучшим выбором для роли контейнера. Стандартная
библиотека шаблонов предоставляет несколько более специализированных
контейнеров, которые зачастую оказываются полезнее массивов. Пренебрежение
170
Глава 6. Лучшее из предыдущих версий C++
ими искусственно ограничивает разработчика и заставляет его проектировать
и переписывать код без необходимости. Предпочтение индексации итераторам
может повлиять на производительность. Часто тело цикла достаточно велико,
чтобы этим эффектом можно было пренебречь, но не всегда.
В листинге 6.16 показано использование индексации для итерации по нескольким контейнерам. В пример включены лишь некоторые полезные контейнеры;
остальные работают аналогично (например, deque, list и map). Их отсутствие — не
следствие лени: часть таких контейнеров просто не поддерживает индексацию,
из-за чего работать с ними приходится совсем не так, как учили на языке C или
в школе. Передача массивов в функции имеет один недостаток — приходится
отдельно передавать количество элементов. Легко ошибиться с этим значением,
что приведет к пропуску данных или выходу за пределы массива. Кроме того,
вызывающий компонент становится ответственным за обработку деталей, которые должна выполнять сама функция. В целом массивы, как правило, являются
плохим выбором для контейнеров.
Листинг 6.16. Индексирование массива в функции
double sum(const double* values, int size) {
double sum = 0.0;
for (int i = 0; i < size; ++i)
sum += values[i];
return sum;
}
int main() {
double vals[] = { 3.14, 2.78, 3.45, 7.77 };
std::cout << sum(vals, 3) << '\n';
Упс! Использован последний индекс,
return 0;
а не общее количество элементов
}
Анализ
Программирование на языке C не предлагало альтернатив индексации, поэтому
было допущено множество ошибок. Хотя ошибки не являются характерной
особенностью индексации, в C риск ошибиться на единицу был колоссален.
Основная причина заключалась в том, что разработчику приходилось отслеживать и сам список значений, и его размер, а затем передавать их в функции
или использовать в цикле.
Тема использования массивов и их ограничений хорошо задокументирована, ей
посвящено множество учебников. Как только этот подход закрепляется на уровне
мышечной памяти, итераторы начинают казаться неудобными и «чужими». То,
что мы узнаем первым, часто воспринимается как правильное, естественное и эффективное. Индексация производит как раз такое впечатление и в большинстве
случаев оказывается плохим выбором.
6.8. Ошибка 43
171
Решение
Использование итераторов идеально в большинстве случаев. Во-первых, все
контейнеры стандартной библиотеки шаблонов (STL) работают с ними. Вовторых, итераторы оптимизированы и часто более производительны, чем индексы. В-третьих, они не допускают ошибок подсчета элементов типа «ошибка на
единицу» (off-by-one). В листинге 6.17 показаны итераторы с использованием
различных контейнеров. Согласованность, которую они обеспечивают, очень
важна: если вы научитесь выполнять итерацию в одном случае, в других все
будет устроено аналогично. Этот подход должен со временем стать настолько
привычным, чтобы использоваться на уровне мышечной памяти.
В отличие от массивов, контейнеры STL являются умными; они знают свой
размер и могут взаимодействовать с итераторами, чтобы начать с первого
элемента и выполнить обход до конца. Подход не меняется в зависимости от
контейнера.
Листинг 6.17. Использование итератора с вектором
double sum(const std::vector<double>& values) {
double sum = 0.0;
for (std::vector<double>::const_iterator
it = values.begin(); it != values.end(); ++it)
sum += *it;
return sum;
}
int main() {
std::vector<double> vals;
vals.push_back(3.14); vals.push_back(2.78);
vals.push_back(3.45); vals.push_back(7.77);
std::cout << sum(vals) << '\n';
return 0;
}
Кто-то может возразить, что тип итератора в заголовке цикла сложный. Это
справедливое замечание: современный C++ предлагает возможность делать
вывод типов с помощью ключевого слова auto, что устраняет эту сложность,
перекладывая вывод правильного типа на компилятор. Это реальная экономия
времени!
Для доказательства утверждения, что другие контейнеры работают аналогично,
рассмотрим листинг 6.18, в котором используется контейнер типа set. Единственные отличия: тип контейнера — set, а не vector, и способ добавления элементов
в набор — insert вместо push_back. Третий набор вставок (insert) добавлен,
чтобы показать, что set исключает дубликаты значений.
172
Глава 6. Лучшее из предыдущих версий C++
Листинг 6.18. Использование итератора с набором
double sum(const std::set<double>& values) {
double sum = 0.0;
for (std::set<double>::const_iterator it = values.begin();
it != values.end(); ++it)
sum += *it;
return sum;
}
int main() {
std::set<double>
vals; vals.insert(3.14); vals.insert(2.78);
vals.insert(3.45); vals.insert(7.77);
vals.insert(3.45); vals.insert(7.77);
std::cout << sum(vals) << '\n';
return 0;
}
Дублирующиеся значения в контейнере
типа set игнорируются
Со временем этот способ обхода контейнеров становится все более привычным
и помогает разработчику стандартизировать подход к итерации по любому
контейнеру. Надеюсь, этого стимула достаточно, чтобы отказаться от массивов!
В современном C++ появились три функции, которые упрощают или заменяют
написание циклов. Чтобы узнать о них больше, загляните в раздел «Смотри
также».
Рекомендации
Используйте контейнеры стандартной библиотеки шаблонов везде, где это
возможно.
Используйте итераторы по этим контейнерам, чтобы исключить ошибки типа
«ошибка на единицу»; пусть тип данных определяет начальную и конечную
точки.
По возможности откажитесь от использования массивов.
Смотри также
«Ошибка #6», где мы обсудили циклы for, основанные на диапазоне.
«Ошибка #11», где разобрали использование ключевого слова auto для
упрощения определения типов переменных.
«Ошибка #13», чтобы изучить применение алгоритмов стандартной библиотеки шаблонов для исключения написания некоторых циклов for.
«Ошибка #35», где мы рассмотрели убедительный аргумент против использования массивов.
Часть 3
Классический
(досовременный) C++
При разработке программного обеспечения борьба с унаследованным кодом — постоянная проблема, особенно в программировании на C++. Классическая эпоха
(или эпоха досовременного C++) — это практики, сформировавшиеся до появления
современных возможностей языка и часто лишенные той безопасности, эффективности и простоты, которые предоставляет современный язык. В этой части мы
разберем классические проблемы написания кода и рассмотрим идеи и стратегии
для повышения качества и надежности программ в рамках устаревших систем.
Важнейшая область, требующая особого внимания, — разработка и соблюдение
инвариантов классов, одного из ключевых факторов создания надежных и стабильных приложений. Без инвариантов трудно добиться стабильного и предсказуемого
поведения, однако поддерживать их с течением времени, особенно по мере развития кодовой базы, непросто. В этой части мы рассмотрим типичные прежние
ошибки и изучим рекомендации по созданию и поддержанию целостности классов
с помощью продуманных решений, что поможет разработчикам избежать постепенного снижения качества дизайна и необходимости постоянно улучшать код.
Кроме того, в этой части мы обратим внимание на вопросы, связанные с операция
ми классов, управлением ресурсами и использованием функций. Продуманная
реализация операций классов, таких как конструкторы, деструкторы и операторы
присваивания, имеет решающее значение для минимизации ошибок и оптимизации производительности. Корректная обработка исключений и управление
системными ресурсами крайне важны для предотвращения утечек и обеспечения
надежности приложения. Переработка дизайна функций и параметров необходима для того, чтобы избежать неэффективности, унаследованной от досовременных методов разработки на C++. Наконец, всесторонний обзор общих практик
программирования на классическом C++ дает разработчикам методическую
основу для повышения качества и устойчивости унаследованного кода, помогая
ему оставаться жизнеспособным и актуальным для будущих требований.
7
Установление
инварианта класса
В этой главе
3 Инвариант класса как основное представление о том, что такое
класс и что он делает
3 Построение поведения класса из минимального набора ключевых
функций
3 Отношение к классам как к новым типам данных
3 Корректный дизайн наследования, рассматриваемый через
призму инварианта класса
3 Обеспечение правильной работы классов и их взаимодействия
с компилятором
Классы представляют собой типы данных. Комитет по стандартизации C++
никогда не смог бы предугадать все необходимые типы, поэтому язык был
спроектирован расширяемым. Если требуется новый тип данных, разработчик
может написать его, создав новый класс.
Хотя написать класс относительно просто, создать корректный, хорошо спроектированный класс гораздо сложнее. За прошедшие годы появилось множество
7.1. Инварианты классов обеспечивают корректное проектирование
175
советов и выявилось немало проблем. Их изучение помогает программистам находить эти и похожие ошибки в существующем коде. Устраняя такие проблемы,
разработчик учится более качественно проектировать классы.
7.1. Инварианты классов обеспечивают
корректное проектирование
Класс должен обладать как минимум четырьмя основными характеристиками.
Некоторые специалисты могут предложить и другие, но эти четыре я считаю
основополагающими:
интерпретация;
набор операций;
диапазон значений;
механизмы использования памяти.
7.1.1. Интерпретация
Основная характеристика инвариантов класса — интерпретация его данных.
Класс — это совокупность данных и функций. Данные задают состояние,
а функции — поведение экземпляра класса. В этом контексте под экземпляром понимается специально созданное в программе проявление класса, а под
объектом — его значения, хранящиеся в памяти. То, что означают различные
компоненты по отдельности (каждое поле) и в совокупности (объект), зависит
от дизайна класса. Каждое значение имеет определенный тип, а он несет свой
смысл и вносит вклад в общее понимание экземпляра.
Разработчик класса должен определить, что именно означают данные помимо их
типа. Например, в классе Student строка std::string может хранить имя студента. Типом данных является std::string со всем заложенным в него смыслом, но
в контексте объекта Student конкретное значение отражает нечто специфическое
для этого студента и может иметь определенную форму. Допустим, формат выглядит так: фамилия, запятая, пробел, имя. Экземпляр std::string не может ни
знать, ни обеспечивать соблюдение этой схемы; поэтому поддерживать ее должен
класс. Такой подход неудачен: лучше хранить несколько строк, представляющих
отдельные части данных. Этот пример показывает, что хороший дизайн класса
может быть непростым.
Интерпретация std::string становится такой: фамилия студента, некоторая
разделяющая пунктуация и имя студента. В совокупности другие поля будут
дополнять смысл экземпляра Student. В итоге этот экземпляр должен достаточно полно представлять конкретного студента в некотором учебном заведении.
176
Глава 7. Установление инварианта класса
Ожидается, что все данные, необходимые для решения задачи, смоделированы
в классе и отражены в его экземпляре.
7.1.2. Набор операций
Манипуляции с данными экземпляра должны быть ограничены самим классом.
Если внешний (клиентский) код может напрямую работать с данными экземпляра, это быстро приводит к ошибкам или уязвимостям. Ошибки неизбежны,
поэтому доступ к данным и их изменение должны контролироваться. Понимание
того, какой смысл имеют хранимые значения (интерпретация), какие из них
допустимы (диапазон значений) и какие манипуляции можно над ними проводить, должно контролироваться, чтобы предотвратить баги.
Публичный интерфейс (public interface) — это набор операций (конструкторов
и функций), которые может использовать клиент. Если сделать общедоступными
только некоторые члены класса, это ограничит доступ к экземпляру и поможет
обеспечить корректную работу с ним. Могут существовать и другие члены, не
входящие в публичный интерфейс (protected и private функции и деструкторы). В совокупности эти функциональные члены образуют набор операций.
Представьте себе целочисленную переменную. Типичные операции включают
в себя сложение, вычитание, умножение и деление — все, что можно сделать
с целым числом. Аналогично публичный интерфейс — это то, что можно делать
с экземпляром класса.
Продумывание правильного набора операций предполагает рассмотрение нескольких сценариев использования: как клиенты используют экземпляр и взаи
модействуют с ним? По умолчанию класс будет иметь несколько доступных
операций, но обычно их недостаточно для выполнения чего-либо значимого.
Поскольку класс создается для представления концепции или сущности, разработчик должен предоставить осмысленное поведение и способы взаимодействия
с его экземплярами.
Разработчики классов определяют этот набор операций. При проектировании
интерфейса программист должен учитывать абсолютный минимальный набор
(базис) операций, которые могут быть выполнены. Другие небазисные операции
можно реализовать поверх этого базиса. Хороший дизайн сокращает количество
функций, выполняющих вычисления или манипуляции, устраняет дублирование
кода, минимизирует усилия по сопровождению и упрощает класс. Поиск базиса
для класса требует определенных усилий, но они окупаются.
7.1.3. Диапазон значений
Диапазон значений связан с идеей интерпретации. Допустимые значения часто ограничены с одного или двух концов диапазона. Рассмотрим переменную
7.1. Инварианты классов обеспечивают корректное проектирование
177
unsigned char: с одной стороны она не может быть меньше 0, а с другой — боль-
ше 255. Эти границы продиктованы природой байта. Другие типы данных на
практике обычно используют поддиапазон всех возможных значений переменной. Целое число на моей машине — это 32-битная сущность с границами
плюс-минус 2,14 миллиарда, но в большинстве задач такой разброс не нужен.
Например, если моделируется возраст человека, то диапазон стоит ограничить
неотрицательными числами с возможным максимумом 150 (точное верхнее
значение назвать трудно).
Если предположить, что в классе Student есть целочисленная переменная age,
то возможный диапазон значений будет ограничен; лучше сделать это поле беззнаковым. Во-первых, возраст должен быть неотрицательным — отрицательный
вариант просто не будет иметь смысла. Во-вторых, максимальное значение
сложно определить, и его можно формально задать как заведомо завышенное
значение 200. Возможно, верхний предел лучше вообще не фиксировать. Важно,
что ни один возраст не должен выходить за рамки допустимого диапазона; он
включает все корректные значения и отсеивает некорректные. Иногда такие
рамки очевидны, но в некоторых случаях они могут оказаться расплывчатыми.
В хорошо спроектированном классе для каждого поля задается допустимый
диапазон значений. Необходимо продумать каждое поле и их сочетание. Каждое
поле несет важную информацию об экземпляре, все они должны гармонично
взаимодействовать и иметь общий смысл. Например, рассмотрим класс Movie,
в котором можно хранить два числа: дату первого доступного показа и дату
последнего просмотра. Каждая из них должна иметь осмысленное значение
(не 30 февраля) и правильно соотноситься с другой. Будет неправильно, если
дата последнего просмотра будет раньше даты первого, даже если обе даты по
отдельности сформированы корректно.
7.1.4. Механизмы использования памяти
Выделение и использование памяти становится проблемой для многих классов
только при работе с динамическими ресурсами. Обычно порядок расположения переменных экземпляра следует из предпочтений разработчика; их могут
сгрупировать по типу данных или в алфавитном порядке. Тем не менее C++
позволяет задавать, где и как выделяется память, если это необходимо. Возможность указывать аллокаторы с пользовательским поведением — мощный
нетривиальный инструмент. Структура памяти зависит от размеров полей
разных типов и требований выравнивания. Некоторые типы выравниваются по
4-байтовой границе, другие — по 2- или 8-байтовой. Если поля подобраны неудачно, некоторые переменные будут иметь пробелы неиспользуемой памяти. На
современных машинах с большим объемом памяти это редко является проблемой
по соображениям экономии места, но может сказаться на производительности.
178
Глава 7. Установление инварианта класса
В зависимости от размера объект может занимать две и более строк кэша, и его
перемещение окажется неэффективным, поскольку часть байтов будет потрачена впустую. В идеале в объект не следует вводить практически никаких
дополнительных байтов.
7.1.5. Влияние на производительность
Хотя производительность и не относится напрямую к характеристикам типов
данных, она зависит от проектирования и реализации этих типов. Оптимизация
производительности должна быть одним из последних шагов в процессе разработки программы — не потому, что она не важна, а потому, что преждевременная
оптимизация может негативно сказаться на корректности и эффективности.
Гораздо лучше разрабатывать рациональные алгоритмы, структуры данных
и компактные по размеру экземпляры с хорошей компоновкой. Оптимизация
производительности должна выявлять узкие места и проблемные зоны и стремиться к их устранению. Следуя распространенным рекомендациям, производительность должна достигаться с помощью соответствующих инструментов,
а не интуитивных догадок. При необходимости C++ предоставляет механизмы
для оптимизации выделения памяти и размещения объектов. При своевременном
и умеренном использовании эти возможности могут оказаться весьма полезными.
7.2. Ошибки в дизайне классов
Довольно часто проектирование класса начинается с относительно простой
идеи, которую удается хорошо реализовать, но затем начинается неизбежное —
изменения. Изменения обычно требуются потому, что со временем начинает
казаться, что класс получился правильным, но недостаточно продуманным.
Затем требуются дополнительные правки, дизайн оказывается под угрозой,
целесообразность берет верх над точностью, и начинается так называемый отток
кода (code churn)1. Класс разлагается, но по-прежнему служит своей цели. Запахи кода (code smells) — неидеальные методы или реализации, часто вызванные
нехваткой времени или недостаточным пониманием предметной области или
влияния кода, — накапливаются, но сроки и бюджет диктуют другие приоритеты.
Выявить эти проблемы зачастую не так сложно, как найти решение. Распространенные ошибки обычно можно исправить с минимальным влиянием на другие
части кодовой базы. Однако эти ошибки представляют собой лишь небольшое
1
Code churn, или показатель оттока, — это процентное соотношение строчек кода,
которые были удалены или значительно обновлены меньше чем через две недели после написания. Churn — нежелательное явление, поскольку это изменения в коде без
пользы для проекта. — Примеч. пер.
7.2. Ошибки в дизайне классов
179
подмножество существующих проблем. Об ошибках проектирования классов
можно написать целые тома.
Правильный дизайн класса — основа для понимания и соблюдения инварианта
класса. Класс представляет собой тип данных, который должен поддерживать
согласованность и корректность, чтобы его экземпляры имели смысл. Пренебрежение любым аспектом инварианта подвергает поле риску несогласованности
и вероятному сбою. Клиенты ожидают корректности, и наш долг как разработчиков — эти ожидания оправдать.
7.2.1. Инвариант класса
Инвариант класса — это условие, которое всегда должно выполняться для объекта класса после его создания и между любой последовательностью вызовов
методов этого объекта. Он описывает согласованное, допустимое состояние
объекта и обычно обеспечивается конструкторами, деструкторами и методами.
Инварианты класса поддерживают целостность и корректность данных объекта
на протяжении всего его жизненного цикла.
Каждый класс должен представлять собой концепцию или сущность; класс —
это новый тип данных. Он осмысленно описывает концепцию или сущность,
агрегируя другие типы данных — от различных классов до примитивных типов.
Хороший дизайн класса и понимание его инварианта — две стороны одной
медали. Принцип абстракции означает, во-первых, что в классе все детали концепции или сущности сведены до минимума, достаточного для определения
класса. Во-вторых, абстракция означает, что экземпляр можно обрабатывать как
единое целое. Предполагается, что такая абстракция сущности или концепции
будет вести себя осмысленно в клиентском коде. Класс не должен удивлять
клиентский код неожиданным поведением.
Поскольку класс представляет концепцию или сущность, он должен ограничивать свои внутренние состояния набором значений, которые имеют смысл
для его представления. Как уже говорилось ранее, Student должен иметь неотрицательный параметр age. Класс служит средоточием знаний о представляемой сущности или концепции. Он не должен полагаться на другие классы или
внешние данные для передачи этих знаний. Принцип инкапсуляции, в частности,
означает, что все, что знает и делает класс, должно полностью содержаться в нем
самом. Клиентский код должен иметь возможность взаимодействовать с экземпляром или получать из него любые необходимые данные без необходимости
манипулировать другими объектами.
Эти характеристики накладывают ответственность. Класс должен гарантировать, что все его поля, как по отдельности, так и в совокупности, остаются
допустимыми и всегда имеют смысл, а взаимодействие с ними предсказуемо
180
Глава 7. Установление инварианта класса
и не содержит неожиданностей. Кроме того, он должен гарантировать, что эта
осмысленность и предсказуемость никогда не будут нарушены во время выполнения программы. Ответственность класса называется инвариантом класса.
Инвариант — это свойство экземпляра, которое всегда истинно, независимо от
того, как этот экземпляр инициализируется или изменяется. Класс никогда не
должен создавать экземпляры, нарушающие инвариант, и обязан защищать
существующие экземпляры от изменений, которые его ломают.
Характеристика корректности наиболее важна, для того чтобы программа вела
себя правильно. Если код некорректен, ничего нельзя гарантировать; никакая
производительность не компенсирует эту ошибку, а результаты вызовут сомнение. Соблюдение инварианта класса — важнейшее средство обеспечения
корректности. Когда класс ведет себя контролируемым, предсказуемым образом,
клиенты могут уверенно его использовать и результаты его работы имеют смысл.
В унаследованном коде часто встречаются классы, спроектированные без опоры на инвариант. Здесь скрыт огромный потенциал для наведения порядка во
множестве существующих классах. К сожалению, правки — даже те, что делают
класс более предсказуемым и надежным, — могут привести к неожиданному
поведению. Как всегда, идеалы и теории должны учитывать реальность. Цель —
улучшение, но путь может быть несколько (или даже слишком) ухабистым.
7.2.2. Определение инварианта класса
Класс представляет собой согласованную, связную концепцию или сущность.
Требования к классу должны задавать границы для каждой переменной экземпляра — как в отдельности, так и в совокупности. Эта совокупность представляет
собой состояние экземпляра. Важная задача — обеспечить согласованность
этого состояния. Продолжая пример с Person, если значение age отрицательно,
смысл Person оказывается под вопросом: что вообще представляет собой отрицательный возраст?
Определение инварианта класса — задача конструктора или конструкторов; соблюдение инварианта — это ответственность мутаторов или других функций,
которые изменяют состояние. Каждое поле экземпляра должно инициализироваться и сохранять осмысленное и корректное значение. Интерпретация экземпляра зависит от того, насколько строго он соответствует своему инварианту.
Диапазон допустимых значений для типа переменной, скорее всего, будет шире
диапазона инварианта, поэтому конструкторы и мутаторы должны следить,
чтобы ни одно значение не выходило за пределы этого диапазона. Мне встречался код, где переменные экземпляра инициализируются или устанавливаются
в недопустимые значения, а аксессоры лишь проверяют диапазон и возвращают
только осмысленные значения. Если какое-либо поле экземпляра хранит недопустимое значение, следует считать, что инвариант класса нарушен.
7.3. Ошибка #44
181
Ошибки, рассматриваемые в этой главе, касаются формирования инварианта
класса, поэтому основное внимание уделяется конструкторам. Мутаторы рассматриваются отдельно в следующей главе, где они отвечают за поддержание
инварианта. Эти две главы различны, но должны слаженно работать на один
результат — обеспечение корректности инварианта класса.
7.3. Ошибка #44: несоблюдение инварианта класса
Эта ошибка касается корректности и эффективности в рамках дизайна и разработки класса. Переменные экземпляра должны быть инициализированы
значением в пределах, определенных классом. Это гарантирует, что объект еще
до его использования будет корректным и допустимым.
Мутаторы изменяют состояние полей объекта. Чтобы сохранить инвариант
класса, они должны проверять, попадают ли входные значения в допустимый
диапазон для этой переменной. Кроме того, любой код в других методах, который
влияет на состояние какой-либо переменной экземпляра, должен гарантировать,
что он не ставит под угрозу инвариант. Очень важно, чтобы конструкторы, мутаторы и вспомогательный код обеспечивали соблюдение инварианта класса.
Каждый конструктор отвечает за инициализацию всех полей. Если какое-либо
из значений задано по умолчанию, оно должно, как правило, иметь смысл и не
делать экземпляр недействительным, даже если значение по умолчанию впоследствии не будет изменено. Каждый мутатор должен сперва убедиться, что
значение его входного параметра находится в диапазоне допустимых, и изменять
переменную экземпляра только в этом случае. Предположим, что значение параметра конструктора или мутатора недопустимо. Тогда поведение класса должно
гарантировать, что состояние экземпляра по-прежнему соответствует инварианту.
Во многих случаях ввод недопустимого параметра следует обрабатывать путем
выбрасывания исключения; в других случаях поле не должно изменяться. Этот
последний подход может вызвать проблемы с конструктором. Дополнительные
осложнения могут возникнуть при использовании паттернов проектирования
«строитель» (Builder) или «фабрика» (Factory). В любом случае поля должны
быть допустимыми еще до начала использования экземпляра.
Проблема
Правильно написанный конструктор (или набор конструкторов) либо инициа
лизирует каждую переменную экземпляра осмысленным значением, либо не
позволяет инстанцирование (создание экземпляра) объекта (то есть выбрасывает
исключение). Соблюдение инварианта класса требует выполнения этого условия, и никак не меньше. Каждый мутатор должен проверять входной параметр
и гарантировать, что поле не будет изменено на недопустимое значение или
182
Глава 7. Установление инварианта класса
будет выброшено исключение. Все остальные методы, влияющие на состояние
объекта, должны обеспечить соблюдение инварианта класса. Сложности возникают, когда эти условия не выполняются, как в листинге 7.1.
Во многих классах есть конструктор по умолчанию, или конструктор без аргументов, написанный компилятором или разработчиком. Предположим, что
программист не определил ни одного конструктора для класса. В этом случае
компилятор сгенерирует конструктор по умолчанию, при вызове которого
каждая переменная экземпляра будет инициализирована нулевым значением
для своего типа. Условная часть этого оператора показывает, что вызов конструктора по умолчанию зависит от того, как именно объявлена или присвоена
переменная.
Листинг 7.1. Типичная структура из учебника
struct Person {
int age;
std::string name;
};
int main() {
Person p1;
std::cout << p1.age << " '" << p1.name << "'\n";
Person p2 = Person();
std::cout << p2.age << " '" << p2.name << "'\n";
}
Переменная age
выводит бессмысленные
(неопределенные) значения,
а name — пустую строку
Это выводит: 0 ''
В первом случае компилятор предоставляет конструктор без аргументов, но не
вызывает его. Во втором случае явно вызывается сгенерированный конструктор, который демонстрирует инициализацию примитивных (или встроенных)
переменных нулевым значением для этого типа. В обоих случаях у объектов
вызывается конструктор по умолчанию.
Результат выполнения кода из листинга 7.1 непредсказуем — неопределенное
поведение для переменной age. Каждый байт памяти хранит какое-то значение;
на выходе мы увидим то, что получится при интерпретации этих байтов как age
(то есть бессмыслицу!). Такому коду трудно доверять.
Анализ
Поскольку конструктор отвечает за инициализацию каждого поля, первый
подход сводит все усилия на нет. Если конструктор не справляется, он не
может поддерживать инвариант класса. Хуже того, компилятор никогда не жалуется; переменные экземпляра принимают любые неопределенные значения,
уже лежащие в памяти, и внешне все вроде бы работает. Но использование
таких значений приводит к неопределенному поведению. То, как вызывается
7.3. Ошибка #44
183
написанный компилятором конструктор по умолчанию, зависит от того, как
создается объект. Если же разработчик напишет конструктор по умолчанию, он
будет вызван в любом случае. Поэтому, если это необходимо для соблюдения
инварианта класса, конструктор по умолчанию придется писать явно. Например, возможен такой вариант:
Person() : name("Joey"), age(21) {}
Но будет ли это действительно хорошим, осмысленным конструктором по
умолчанию? Написать его легко, но он должен содержать корректные имя и возраст по умолчанию. В большинстве случаев применение такого конструктора
приводит к тому, что инвариант класса не выполняется, потому что экземпляр
инициализируется бессмысленными данными — не недопустимыми с точки
зрения ограничения типов, а просто бессмысленными. Хотя конструктор по
умолчанию присвоил объекту Person имя Joey и возраст 21 год, но что это дает?
Клиентский код может позже изменить эти значения на что-то подходящее под
свою задачу, но что произойдет, если хотя бы одно поле в одном объекте будет
упущено из виду? Гарантии соблюдения инварианта уже нет.
Разработчик должен писать конструкторы, принимающие больше нуля параметров. Клиентский код тогда создает экземпляр Person и передает в конструктор
с двумя аргументами осмысленные имя и возраст.
Например, для структуры Person:
Person(const std::string& n, int a) : name(n), age(a) {}
Компилятор не будет генерировать конструктор по умолчанию, если в классе уже
определен хотя бы один конструктор. Поэтому, если конструктор без параметров
тоже необходим, разработчик должен явно включить его. Помните, что на него
ложится ответственность за то, чтобы значения по умолчанию действительно
подходили к задаче, без всяких предположений (или надежды на то), что клиент
изменит их позже. Конструктор по умолчанию нарушит ограничение, если подобрать корректные значения по умолчанию невозможно.
Мутаторы также могут нарушить инвариант класса, если пользоваться ими неаккуратно. Рассмотрим следующий мутатор для поля age:
void setAge(int a) { age = a; }
Возраст может быть установлен на отрицательное или неоправданно большое
значение, что не имеет смысла. Ограничение снизу неотрицательными значениями снимает проблему только с одной стороны диапазона; верхнюю границу
задать действительно сложно. Какой максимальный возраст будет наиболее
разумным?
184
Глава 7. Установление инварианта класса
Решение
В идеале конструкторы и мутаторы работают согласованно. Конструктор отвечает за определение инварианта класса, но не обязательно напрямую инициа
лизирует каждое поле. Если и конструктор, и мутатор инициализируют или
изменяют одно и ту же переменную экземпляра, они, скорее всего, дублируют
информацию. Эта информация должна быть инкапсулирована в мутаторе. Конструктор может вызывать соответствующие мутаторы, передавая им значение
параметров инициализации. Мутатор проверяет аргумент и обрабатывает его
соответствующим образом. Задача конструктора — обеспечить вызов мутатора
для инициализации, а не обязательно самостоятельно присваивать значение
полю.
В этом случае конструктор по умолчанию, предоставленный разработчиком или
компилятором, будет недопустим — для переменных name или age нельзя задать
осмысленное, правильное значение по умолчанию. Как бы ни было заманчиво
иметь конструктор по умолчанию, по возможности избегайте его и определяйте
хотя бы один конструктор, требующий аргумент для каждого поля.
Отметим, что отсутствие конструктора по умолчанию несет негативные последствия при использовании массивов. Определение массива экземпляров класса
означает, что конструктор по умолчанию должен быть вызван для инициализации каждого элемента; в этом случае отсутствие конструктора по умолчанию
является ошибкой. Подробнее об этом читайте в разделе «Смотри также».
В листинге 7.2 продемонстрировано, как конструктор и мутатор совместно
работают для инициализации экземпляра с частичной проверкой диапазона
возрастов. Предупреждаем, что в этом немного запутанном коде age задается
дважды, но цель — продемонстрировать ограничение допустимого диапазона
значений (преподаватели могут иногда делать подобные штуки!). Более удачным
решением будет вынести проверку диапазона в приватную функцию и вызывать
ее и из списка инициализаторов, и из функции setAge.
Листинг 7.2. З
апрос значения инициализации для каждой переменной
экземпляра
struct Person {
int age;
std::string name;
Person(std::string n, int a) : name(n),
age(setAge(a)) {}
Процесс вызова метода во время работы
int setAge(int);
конструктора может привести к неопределенному
};
поведению. Сделайте все максимально просто или
int Person::setAge(int a) {
выберите другой подход
if (a < 0)
throw std::out_of_range("age must be non-negative");
age = a;
return age;
}
int main() {
Person annie("Annette", 25);
Person floyd("Floyd", -1);
}
Выбрасывает исключение out_of_range
(вне диапазона)
std::string name;
Person(std::string n, int a) : name(n),
age(setAge(a)) {}
Процесс вызова метода во время работы
int setAge(int);
конструктора может привести к неопределенному
};
поведению. Сделайте все максимально просто или
int Person::setAge(int a) {
выберите другой подход
7.3. Ошибка #44
185
if (a < 0)
throw std::out_of_range("age must be non-negative");
age = a;
return age;
}
int main() {
Person annie("Annette", 25);
Person floyd("Floyd", -1);
}
Выбрасывает исключение out_of_range
(вне диапазона)
Когда функция или конструктор не могут осмысленно выполнить то, для чего
предназначены, лучшим решением будет выбросить исключение. Этот подход
отличается от попытки вернуть код ошибки. Код ошибки подразумевает, что
что-то сделать не удалось из-за некорректного условия, но объект или функция
все еще остаются в хорошем состоянии. В случае конструктора недопустимое
значение не может быть использовано для инициализации переменной экземпляра. Объект не должен быть инстанцирован, если нет разумного значения по
умолчанию и поле нельзя корректно проинициализировать. Следовательно,
лучше всего выбросить исключение: оно заставляет вызывающий код явно
учитывать возможность того, что объекта не существует. Если возвращается
неверный код ошибки, значит, объект был инстанцирован, а инвариант класса
соблюден. Для некорректных данных ни тот ни другой вывод недопустим.
ПРИМЕЧАНИЕ В нескольких решениях, описанных в книге, используется подход,
при котором метод вызывается в списке инициализаторов. В некоторых случаях это
может привести к неопределенному поведению. Наглядный пример см. на сайте
https://compiler-explorer.com/z/PPes7vPYd. В книге я использую этот прием только для
простых переменных. Если метод зависит от состояния каких-либо других полей,
все может быстро пойти наперекосяк; поэтому пользуйтесь этим приемом с осторожностью.
Рекомендации
Убедитесь, что конструктор правильно инициализирует каждую переменную
экземпляра.
По возможности используйте мутаторы из конструктора для инициализации
полей, чтобы не дублировать код.
Мутатор должен проверять значение входного аргумента до присваивания
переменной экземпляра; если значение неправильное, следует выбросить
исключение.
Выбрасывайте исключение, если экземпляр не может быть полностью
и правильно инициализирован — не создавайте частично или неправильно
сконструированные объекты.
В большинстве случаев избегайте использования конструкторов по умолчанию; если они необходимы, напишите их самостоятельно.
186
Глава 7. Установление инварианта класса
Смотри также
«Ошибка #35», где мы разбираем, как решить эту проблему с помощью контейнеров, а не массивов.
«Ошибка #49»: там ведется более подробное обсуждение использования
конструкторов по умолчанию.
«Ошибка #70» позволит глубже погрузиться в использование исключений
в конструкторах.
7.4. Ошибка #45: не рассматривать классы
как типы данных
Встроенные типы данных C++, такие как int и double, интуитивно понятны,
синтаксически просты и производительны. Когда разработчик проектирует
класс, эти характеристики следует считать существенными. Дизайн класса — это
проектирование типа; все, что делает тип корректным и полезным, должно быть
учтено. Немногие другие языки предоставляют разработчику классов такую
гибкость, как C++, но при этом необходимо учитывать несколько потенциальных подводных камней. К ним относятся выделение и освобождение памяти;
инстанцирование, инициализация и уничтожение объектов; перегруженные
и дружественные функции; и особенно инвариант класса.
Типы определяют набор операций, выполняемых экземпляром класса. Часто
возникает необходимость в подтипах, поэтому важно продумать корректное
использование наследования. Если класс или тип должен быть базовым классом, он обязан определять интерфейс, который имеет смысл для всех подтипов.
Необходимо следить за тем, чтобы подтипы не нарушали инвариант базового
класса.
Правильное и осмысленное проектирование типов обеспечивает читателю
и разработчику простоту использования и четкое понимание смысла. Корректность должна иметь первостепенное значение для дизайна класса, но при этом
особое внимание следует уделить читаемости того, как применяются его экземпляры. Хороший дизайн также приводит к эффективному применению типа,
и программисту не придется компенсировать отсутствующую или неудобную
функциональность. Наконец, при проектировании типов важно учитывать, как
экземпляр может включаться в коллекции или большие наборы; это влияет на
производительность. Как всегда, продумайте все нюансы по времени выполнения
и потреблению, чтобы минимизировать проблемы, связанные с неправильным
выбором алгоритма.
Четыре основные характеристики класса влияют на то, как он спроектирован,
реализован и применяется. Их необходимо тщательно продумать, чтобы все
7.4. Ошибка #45
187
участники могли эффективно использовать класс. Это обычно сводится к набору операций, которые предоставляет класс. Разработайте этот набор так, чтобы
программист мог пользоваться им интуитивно (насколько это возможно).
Проблема
Листинг 7.3 представляет собой вымышленный пример, демонстрирующий несколько проблем с плохо продуманным типом данных, реализованным в виде
класса. Программист думал, что, разработав простой класс Rational, можно
легко формализовать и использовать понятие рационального числа. На первый
взгляд код выглядит разумным, но он содержит несколько замаскированных
багов. Класс создан для того, чтобы экземпляры Rational можно было складывать, умножать и выводить результаты. Однако добавление его в код приводит
к появлению неуклюжего кода.
Идея заключалась в том, чтобы можно было выполнять вычисления с рациональными числами, а затем получать результаты. Клиент, работающий с таким
кодом, быстро увидел бы, что обычные, ожидаемые операции не предусмотрены (например, метод plus вместо оператора +) и использование становится
громоздким и непонятным. Такой плохо читаемый код просто попусту тратит
время разработчиков, делая эту попытку настолько же примитивной, насколько
и когнитивно затратной.
Листинг 7.3. Примитивная реализация класса Rational
class Rational {
private:
double num;
double den;
public:
Rational() : num(0), den(1) {}
Rational(double n, double d) : num(n), den(d) { reduce(); }
void setNumerator(double n) { num = n; }
void setDenominator(double d) { den = d; }
double getNumerator() { return num; }
double getDenominator() { return den; }
static int gcd(int a, int b) { return a == 0 ? b : gcd(b % a, a); }
void reduce() {
int div = gcd(num, den);
num = (den > 0 ? 1 : -1) * num / div;
den = abs(den) / div;
}
Rational plus(const Rational& o) const {
Неудачно названный оператор
int n = num * o.den + den * o.num;
сложения
int d = den * o.den;
return Rational(n, d);
}
Rational times(const Rational& o) const {
Неудачно названный оператор
int n = num * o.num;
умножения
int d = den * o.den;
return Rational(n, d);
}
void print() { std::cout << num << '/' << den; }
Неудачно названный
};
оператор вывода
Rational plus(const Rational& o) const {
n = num * o.den + den * o.num;
188 int
Глава
Установление
int
d =7.den
* o.den; инварианта класса
return Rational(n, d);
}
Rational times(const Rational& o) const {
int n = num * o.num;
int d = den * o.den;
return Rational(n, d);
}
void print() { std::cout << num << '/' << den; }
};
Неудачно названный оператор
сложения
Неудачно названный оператор
умножения
Неудачно названный
оператор вывода
int main() {
Rational r1(1, 3);
Rational r2(2, 4);
Rational r3 = r1.plus(r2);
r3.print();
std::cout << '\n';
Rational(1, 0);
return 0;
}
Анализ
Первое, что бросается в глаза в Rational, — это неверное понимание природы
рациональных чисел. По сути рациональные числа представляют собой отношения (дроби) (отсюда и название ratio-nal, рацио-нальные — не в смысле
«разумное» число, как я сначала думал). Теория чисел утверждает, что числитель
и знаменатель (в отношении) должны быть целыми, а не числами двойной точности. Следовать здравому математическому смыслу здесь очень важно. Далее
рассмотрим последнюю строку кода в функции main непосредственно перед
return. Это неопределенная ситуация, поскольку знаменатель представляет
собой задачу деления на ноль.
Возникает важный вопрос: меняется ли вообще рациональное число со временем?
Я предпочитаю неизменяемые данные; этот пример хорошо это иллюстрирует.
Для изменяемой версии необходимо использовать другие соображения. Числа
сами по себе не изменяются; поэтому рациональное число, однажды созданное,
в общем случае не должно менять свое значение (но это зависит от решаемой
задачи). Уберите мутаторы везде, где это возможно. Подумайте, имеет ли смысл
получать числитель отдельно от знаменателя — в данном случае, скорее всего,
нет. Такие аксессоры тоже стоит убрать.
Определите, должен ли клиент иметь возможность напрямую вызывать gcd или
reduce. Эти функции являются вспомогательными для класса Rational и не
предназначены для публичного интерфейса — пометьте их как private. Наконец, определите, должны ли они быть встроенными. Как правило, рекурсивные
функции не встроены, но фактические результаты зависят от компилятора.
Определяя их внутри класса, вы их неявно встраиваете, а дальше компилятор
выберет подходящий вариант. Для gcd еще можно привести аргументы в пользу
7.4. Ошибка #45
189
такого решения: функция простая и локальная. Однако reduce вряд ли даст заметный выигрыш по времени при встраивании.
Внимательно проанализируйте, что означают функции plus и times. Разработчик
подразумевал сложение и умножение, но это должно быть четко обозначено.
Кроме того, неясно, изменяют ли эти функции вызывающий объект (r1 в случае
с plus) или создают новый, инициализированный результатом вычислений. Ответ можно найти только в исходном коде, а необходимость лезть внутрь класса
серьезно ухудшает читаемость.
Функция print задумана с благими намерениями, но у нее неудобный интерфейс.
Вероятно, предполагалось, что std::cout — это естественный и, следовательно,
единственный поток для вывода Rational; это глупое и корявое решение. Хороший дизайн — общий по своей форме, а здесь поведение жестко привязано
к конкретному потоку. Эти три плохо спроектированные функции значительно
снижают эффективность, поскольку они неинтуитивны и не опираются ни на
какие привычные для языка паттерны.
Решение
Этот код представляет собой переработанную версию, которая учитывает
математический смысл рационального числа и отражает его в реализации.
Методы plus и times заменены на стандартные арифметические операторы +
и *. Клиентский код теперь использует ожидаемые вычислительные символы.
Метод print заменен на перегруженный оператор operator<< для ostream, так
что можно применять стандартный оператор вставки для вывода значения —
точно так же, как и для всех встроенных типов данных. Такая согласованность
делает использование класса естественным и эффективным.
Листинг 7.4. Р
еализация, обеспечивающая интуитивно понятное
использование
class Rational {
private:
int num;
int den;
static int gcd(int a, int b) { return a == 0 ? b : gcd(b % a, a); }
void reduce();
int validate(int v) {
return v != 0 ? v : throw
std::invalid_argument("zero denominator");
}
public:
Rational(int n, int d=1) : num(n), den(validate(d)) { reduce(); }
Rational operator+(const Rational& o) const;
Rational operator*(const Rational& o) const;
friend std::ostream& operator<<(std::ostream&, const Rational&);
};
190
Глава 7. Установление инварианта класса
void Rational::reduce() {
int div = gcd(num, den);
num = (den > 0 ? 1 : -1) * num / div;
den = abs(den) / div;
}
Rational Rational::operator+(const Rational& o) const {
return Rational(num * o.den + den * o.num, den * o.den);
}
Rational Rational::operator*(const Rational& o) const {
return Rational(num * o.num, den * o.den);
}
Привычный
оператор
сложения
Привычный
оператор
умножения
std::ostream& operator<<(std::ostream& out, const Rational& r) {
out << r.num << '/' << r.den;
Привычный оператор вставки
return out;
}
int main() {
Rational r1(1, 3);
Rational r2(2, 4);
std::cout << r1 + r2 << '\n';
//Rational(1, 0);
return 0;
}
Значение рационального числа сохраняется с помощью превращения переменных экземпляра в целые числа. Вспомогательные функции аккуратно спрятаны,
чтобы только класс мог их использовать. Деление на ноль явно учитывается
и корректно обрабатывается. Операторы интуитивно понятны, и пользователь
знает, что сложение и умножение не влияют на вызывающий (или стоящий
слева) операнд. Оператор вставки работает как любой другой, что улучшает
читаемость и эффективность.
Сравнивая встраиваемые и невстраиваемые (внешние (out-of-line)) функции
с точки зрения целесообразности их использования, полезно помнить о нескольких моментах.
Производительность. Встраиваемые функции могут уменьшить потребление
вычислительных ресурсов на вызов небольшой, часто вызываемой функции.
Однако чрезмерное использование встраивания может увеличить размер кода
и ухудшить производительность кэша. Невстраиваемые функции обычно
имеют повышенное ресурсопотребление на вызов, но они не увеличивают
размер кода в месте использования, что может помочь поддерживать лучшую
локальность кэша инструкций.
7.4. Ошибка #45
191
Размер кода. Встраиваемые функции при многократном использовании,
особенно для крупных функций, могут привести к раздуванию кода (code
bloat). Невстраиваемые функции помогают сохранять небольшой размер исполняемого файла, поскольку код функции многократно переиспользуется,
а не дублируется.
Отладка и поддержка. Встраиваемые функции могут усложнить отладку, поскольку код реплицируется в нескольких местах, что затрудняет трассировку
стека и поиск ошибок. Невстраиваемые функции централизуют реализацию,
что упрощает их сопровождение и отладку.
Зависимости при компиляции. Встраиваемые функции часто определяются
в заголовочных файлах, что повышает риск перекомпиляции нескольких единиц трансляции при изменении такой функции. Невстраиваемые функции
обычно определяются в исходных файлах, что позволяет минимизировать
повторные компиляции, поскольку изменения этих функций не затрагивают
заголовки и зависящие от них файлы.
Инкапсуляция. Встраиваемые функции часто определяются в заголовочных
файлах; поэтому детали реализации открыты, что может быть нежелательно
с точки зрения инкапсуляции. Невстраиваемые функции могут скрывать
реализацию в исходных файлах, улучшая инкапсуляцию и разделение интерфейса и реализации.
Сложность и читаемость. Встраиваемые функции лучше всего подходят для
простых случаев, когда объявление их как встраиваемых может улучшить
читаемость, оставляя взаимосвязанный код в одном месте. Невстраиваемые
функции лучше всего подходят для сложных функций, где вынос реализации
помогает сохранить определение класса чистым и легкочитаемым.
Рекомендации
Помните, что реализация класса — это, по сути, разработка нового типа данных. Использование таких типов должно быть интуитивно понятным для
любого, кто имеет базовое представление об их назначении.
Используйте операторы на основе символов, если они точно передают смысл
операции в контексте типа данных. Никогда не используйте их просто потому, что они «выглядят круто».
Сохраняйте единый дизайн потоков данных и кода; по возможности не привязывайте пользователя к одному конкретному пути. Лучше всего разработать тип данных, который ведет себя максимально близко к естественным
операциям встроенных типов.
Используйте встраивание осмотрительно, оценивая компромиссы между
встраиваемыми и внешними методами.
192
Глава 7. Установление инварианта класса
7.5. Ошибка #46: не создавать базис для методов
Эта ошибка касается корректности и эффективности. Множество конструкторов
зачастую реализуются как отдельные операции, хотя они дублируют большую
часть кода. Кроме того, вычислительные методы часто повторяют один и тот же
фрагмент логики. Такое дублирование должно привести к рассмотрению вопроса
об определении базиса. Не всякий повтор кода означает, что базис обязательно
нужен, но это хороший признак, что его можно найти. Базис — это математическая концепция, определяющая минимальный набор функций, каждая из
которых необходима и не перекрывает другие. На основе этого набора могут
быть реализованы другие, небазисные функции. Принцип «не повторяйся»
(DRY, don’t repeat yourself) абсолютно уместен в этом обсуждении, но он должен
служить более глубокой цели, чем обычно предполагается, — но об этом позже.
Различия в реализации нескольких конструкторов или функций могут привести к некорректному поведению. Сосредоточение необходимого кода в одном
месте приводит к более эффективному использованию времени программиста
и снижает когнитивную нагрузку.
В этом разделе мы проанализируем конструкторы, но те же идеи применимы
и к методам, и к отдельным функциям. Основная мысль — сократить число реа
лизаций одного и того же поведения, чтобы предотвратить расхождение кода
с течением времени. Когда функциональность дублируется и поддерживается
по-разному, очень быстро все может пойти не так.
Часто классы имеют несколько конструкторов с разным количеством параметров.
Если при создании экземпляра какая-то информация не передается, подставляются значения по умолчанию. В других случаях пробелы данных заполняются
поведением по умолчанию.
Проблема
Чтобы лучше понять смысл базисной функции, можно привести небольшой пример. Рассмотрим класс integer, который оборачивает встроенный тип int. Четыре
основные математические операции, вероятно, достаточно сильно отличаются
друг от друга, чтобы все четыре были реализованы независимо. Однако тщательный анализ показывает, что это не так. Задача вычитания a – b математически
эквивалентна a + -b. Вычитание может быть реализовано через сложение и отрицание. Поэтому вычитание не обязательно должно быть отдельной операцией.
Умножение — это повторяющееся сложение, а деление — это повторяющееся вычитание, или, что еще лучше, повторяющееся сложение отрицательного значения.
Таким образом, для реализации четырех арифметических функций достаточно
двух базисных функций — сложения двух целых и отрицания целочисленного
значения. Остальные три операции могут быть определены в терминах этих двух.
7.5. Ошибка #46
193
Идея о том, что операции или функции могут реализовываться через другие
операции или функции, устанавливает правило, как отличить базисные функции,
и непосредственно приводит к способу их реализации. Корректность обеспечивается тем, что вычислительный код размещен в минимальном количестве мест.
Другие методы, реализованные через базисные, будут работать корректно, не
дублируя код, который может стать несовместимым при внесении изменений.
Эффективность поддерживается путем написания основного кода один раз для
каждой базисной функции и многократного переиспользования в небазисных.
Пример с Cylinder (листинг 7.5) демонстрирует типичный подход, когда про
базисные функции не думают. Его конструкторы и операции пишутся отдельно
и независимо. На ранних этапах разработки класса легко предположить, что
в будущем код останется таким же. Слишком часто это оказывается неверным.
Реализация конструкторов и методов быстро начинает расходиться из-за добавления одного или двух новых требований. Любое расхождение бьет по корректности, читаемости и эффективности.
Листинг 7.5. Дублирование информации в классе
class Cylinder {
private:
double radius;
double height;
double area;
double volume;
public:
const double PI = 3.1415927;
Cylinder() {
radius = 1;
height = 1;
area = PI;
volume = PI;
}
Cylinder(double h) {
radius = 1;
height = h;
area = PI;
volume = PI * h;
}
Cylinder(double r, double h) {
radius = r;
Вычисляет площадь
height = h;
по стандартной формуле
area = PI * r * r;
volume = PI * r * r * h;
Объем равен площади, умноженной на
}
высоту (area * height); площадь
double getBaseArea() const { return area; }
вычисляется заново
double getVolume() const { return volume; }
};
194
Глава 7. Установление инварианта класса
Анализ
Реализации этих конструкторов и функций дублируют информацию. Принцип
DRY как раз призван предотвратить такую ситуацию. На практике его часто
трактуют как «не повторяй код», хотя гораздо полезнее понимать как «не повторяй информацию». Со временем к функциональному классу появляются новые
требования, код дополняется и меняется. Эти изменения неизбежны, и риск
дублирования информации только возрастает. Расхождение возникает, когда
скопированная информация меняется в одном месте, но не меняется в другом.
Через некоторое время становится непонятно, какую версию считать правильной.
Каждый конструктор дублирует инициализацию переменных экземпляра, иногда со значениями по умолчанию, а иногда с параметрами. Такое дублирование
прямо подсказывает, что нужен более удачный способ. В коде показан типичный
паттерн, который можно свести к одной вспомогательной функции. Преимущество этого решения в том, что все конструкторы опираются на единый код
и не размножают одну и ту же информацию. При появлении новых требований
изменения вносятся только во вспомогательную функцию, что предотвращает
расхождения в коде.
Решение
Лучший способ подойти к решению этой проблемы — понять, какие функции
можно реализовать через другие, а затем сузить набор зависимостей до минимума. Конструкторы должны вычленять стандартный код и поместить его
в приватный вспомогательный метод. Эта функция становится базисом для
конструкторов, которые уже строятся поверх нее.
Функция getBaseArea вычисляет площадь круглого основания цилиндра. Метод
volume вычисляет площадь круглого основания цилиндра, а затем умножает ее на
его высоту. Возможность выделить общую часть вычислений и считать площадь
один раз указывает на необходимый базис. В этом случае volume должен быть
реализован в терминах метода basearea, а не в виде вспомогательной функции.
Многократное использование методов также возникает при наследовании
классов, когда производный класс наследует функциональность методов базового класса и расширяет ее. Во многих случаях переопределяющий метод
производного класса может вызывать метод базового класса как часть своих
вычислений и модифицировать результат по мере необходимости. Такой подход
предотвращает дублирование функциональности и позволяет производному
классу максимально использовать базовый. Адекватно спроектированные методы
базового класса становятся частью базиса производного класса.
Такие шаги по рефакторингу были предприняты в листинге 7.6. Был создан
вспомогательный метод init, чтобы убрать предыдущее дублирование в конструкторах.
7.5. Ошибка #46
195
Метод volume теперь использует basearea для вычисления площади цилиндра
и умножает это значение на высоту. Объем реализован в терминах площади
основания, что позволяет избежать расхождений в будущем, когда произойдут
неизбежные изменения. Этот пример довольно упрощенный, но он типичен
для проблемы, которой посвящена ошибка. В простых случаях дублирование
информации может не казаться серьезной проблемой. Однако тот же подход
легко перенести на более сложные классы, где цена такого дублирования уже
гораздо выше. Не позволяйте простым вещам обмануть вас, думая, что здесь не
может быть ошибок.
Листинг 7.6. Минимальный набор функций
class Cylinder {
private:
double radius;
double height;
void init(double r, double h) {
radius = r;
height = h;
}
public:
const double PI = 3.1415927;
Cylinder() { init(1, 1); }
Cylinder(double h) { init(1, h); }
Cylinder(double r, double h) { init(r, h); }
double basearea() { return PI * radius * radius; }
double volume() { return basearea() * height; }
};
Вычисляет площадь
Вычисляет объем
в терминах площади
Рекомендации
Вынесите общий код конструктора во вспомогательную функцию, которая
станет частью базисного набора для класса.
Подумайте, как реализовать функции через другие базисные функции, чтобы упростить написание кода, предотвратить расхождения и поддерживать
единый источник истины для общей логики.
Минимизируйте набор базисных функций, убедившись, что их обязанности не пересекаются. При добавлении новых функций опирайтесь на уже
существующий базис и снова оценивайте необходимость введения новой
базисной операции.
Если такой подход становится громоздким или слишком неудобным, откажитесь от него. Помните, что принципы помогают минимизировать технический долг и ускорить разработку, но их неразумное применение может
только навредить.
Ищите возможности выделить переопределяемые методы в базисную функцию или набор.
196
Глава 7. Установление инварианта класса
Смотри также
«Ошибка #82», чтобы узнать, как сочетать передаваемые значения и значения
по умолчанию в конструкторах и функциях.
7.6. Ошибка #47: неспособность реализовать
«большую тройку»
Основная характеристика, на которую влияет управление ресурсами, — корректность. Некорректное поведение сказывается на программе и может негативно
повлиять не только на нее, но и на систему в целом. Большинство других проблем с корректностью ограничены по масштабу, но эта ошибка может стать
всеобъемлющей, затрагивая другие программы и систему.
«Большая тройка»: конструктор копирования, оператор присваивания копированием и деструктор — присутствует во всех версиях C++ и при использовании
динамических ресурсов должна быть полностью реализована. Отсутствие одного
или нескольких компонентов может негативно сказаться на программе. Многие
классы имеют одну или несколько переменных экземпляра, основанных на значениях. Например, целые числа или числа с двойной точностью представляют
числовые значения. Некоторые классы используют std::string или, возможно,
std::vector. Эти переменные инициализируются при создании экземпляра, какоето время используются в ходе выполнения программы, а затем уничтожаются,
когда экземпляр выходит из области видимости. Управление такими переменными
автоматизировано и не создает никаких негативных последствий для разработчика.
Однако более сложные классы используют динамические или ограниченные
ресурсы и имеют переменные для их представления. Типичный пример — переменные на основе указателей (pointer-based variables); они часто представляют
динамические ресурсы памяти. Аналогично ограниченные ресурсы нередко
инкапсулируются в переменных на основе значений (value-based variables).
В этом случае ресурсом выступает экземпляр некоторого ограниченного системного ресурса, которым необходимо правильно управлять. Это может быть как
соединение с базой данных, так и сокет или дескриптор файлов. Переменные
экземпляра уничтожаются автоматически, когда объект выходит из области
видимости, ресурсы, на которые они ссылаются, автоматически не освобождаются. Отсутствие такого управления открывает широкие возможности для некорректного поведения. В таких ситуациях программист должен быть особенно
бдительным и правильно управлять ресурсами.
Проблема
Когда класс использует динамические ресурсы, он должен корректно ими
управлять. Считайте это управление частью инварианта класса. Класс обязан
создавать, использовать и освобождать ресурсы в рамках своего жизненного
7.6. Ошибка #47
197
цикла. Пример с динамической памятью демонстрирует правильные шаги по
ее выделению и освобождению.
Некий клиентский код вызывает конструктор. В конструкторе выполняется операция new, выделяющая динамическую память, — это часть инварианта. Другие
методы экземпляра используют память, не нарушая инвариант. Наконец, когда
экземпляр выходит из области видимости (или явно удаляется), вызывается
деструктор для очищения памяти. Если этот подход реализован правильно, не
происходит ни утечек памяти, ни двойного освобождения памяти, ни освобождения недействительных указателей. Стабильность программы и системы в этом
отношении гарантирована.
Еще одна проблема возникает с динамическими ресурсами, не имеющими отношения к операциям конструктора и деструктора. Оператор присваивания
копированием тоже имеет подводные камни, которые могут повлиять на корректность, и требует внимания. Давайте разберемся, чем отличаются конструктор
копирования и оператор присваивания копированием. Конструктор вызывается,
когда при создании экземпляра объекту выделяется блок сырой памяти (raw
memory). Эта сырая память может содержать что угодно; ее состояние не определено. Конструктор должен инициализировать эту память осмысленными
данными, которые задают инвариант класса.
С другой стороны, оператор присваивания копированием, напротив, не инициа
лизирует объект с нуля, а изменяет уже существующий экземпляр, копируя
в него значения из другого, уже инициализированного экземпляра. Необходимо
позаботиться о корректной обработке самоприсваивания (оператор должен просто возвращать значение). Конструктор копирования обязан выделить новый
динамический ресурс и инициализировать указатель на него или соответствую
щие поля. Оператор присваивания копированием должен делать то же самое,
но с дополнительным шагом: если целевой объект уже владеет динамическим
ресурсом, его во многих случаях необходимо корректно уничтожить, чтобы
предотвратить утечку памяти или ресурсов. В некоторых случаях можно скопировать значения из исходного экземпляра, но во многих других этот подход
необходимо пересмотреть.
Значительная часть кода конструктора копирования и оператора присваивания копированием обычно совпадает. Это создает возможность рефакторинга
общего кода в приватную вспомогательную функцию, которую могли бы вызывать оба оператора. При этом необходимо уделить внимание порядку вызова
деструкторов и обеспечить корректную обработку любого дублирующегося кода
освобождения ресурсов. Идиома RAII демонстрирует такую парную схему выделения и освобождения памяти.
Предположим, что был написан проект для работы с вики-страницами. Компания-заказчик требует определенного набора заголовков на каждой странице.
Было выдвинуто новое требование — реализовать операцию копирования, чтобы
198
Глава 7. Установление инварианта класса
клонировать существующую страницу в качестве основы для новой. Конструктор
копирования — идеальный выбор для такой операции.
Код конструктора копирования был добавлен, чтобы включить новую функциональность. К сожалению, разработчик не вспомнил о необходимости реализовать
оператор присваивания копированием.
Листинг 7.7. К
онструктор копирования, который совместно использует
уникальные ресурсы
class TextSection {
// предположим, что реализация разумна
};
class Page {
private:
Динамический ресурс
TextSection* headers;
TextSection* body;
public:
Page(TextSection* h) : headers(h), body(new TextSection()) {}
Page(const Page& o) : headers(o.headers), body(o.body) {}
Page& operator=(const Page&);
Копирование указателя на
};
int main() {
Page p1(new TextSection());
Page p2 = p1;
return 0;
}
динамический ресурс; два объекта
используют один и тот же ресурс
Анализ
Разработчику необходимо обратить внимание на то, что конструктор копирования использует семантику совместного использования. Заголовки могут быть
общими, поскольку они обязательны для компании и не подлежат изменению.
Однако совместное использование тела страницы является проблемой. В ходе
обычной работы основной текст клонированной страницы будет изменяться.
Поскольку он используется совместно, любые изменения затронут и исходную
страницу. Кроме того, когда объект уничтожается, выходя из области видимости,
динамически выделенный TextSection останется в куче изолированным и недоступным, что приведет к утечке памяти.
Корректный конструктор копирования должен учитывать, что поверхностное
копирование указателя реализует семантику совместного использования, что
нежелательно для тела страницы. Клонированная страница должна выделить
новый TextSection, инициализированный данными исходного объекта. Поскольку текст тела страницы — динамический ресурс, обрабатываемый указателем, деструктор должен гарантировать, что при уничтожении экземпляра
соответствующий ресурс будет освобожден до того. Добавление деструктора
7.6. Ошибка #47
199
обеспечивает надлежащее освобождение. Наконец, так как конструктор копирования и деструктор уже добавлены, очень важно убедиться, что присутствует
и оператор присваивания копированием.
Конструкторы копирования предполагают, что новый экземпляр использует
сырую память (заполненную бессмысленными (мусорными) значениями).
Оператор присваивания копированием, напротив, работает с уже корректно
сконструированным экземпляром, чьи поля содержат допустимые значения.
На примере класса Page мы видим, что копирование указателя на исходное тело
(поверхностное копирование) приводит к появлению бага — это продемонстрировано в плохо спроектированном конструкторе копирования. Оператор
присваивания копированием должен обеспечивать использование семантики
глубокого копирования для динамических ресурсов. Он также обязан гарантировать, что любой существующий динамический ресурс будет корректно
освобожден или иным образом обработан до того, как произойдет глубокое
копирование. Оператор присваивания копированием должен добавить код для
освобождения существующего ресурса перед созданием нового тела.
Решение
В листинге 7.8 показаны эти улучшения. Конструктор копирования предполагает
отсутствие существующих динамических ресурсов, поэтому он выделяет новый
объект TextSection. Аналогичным образом оператор присваивания копированием
выполняет такое же выделение, но сначала освобождает существующие ресурсы,
чтобы избежать их утечки. Наконец, деструктор обеспечивает освобождение
всех динамических ресурсов.
Такая координация в «большой тройке» предотвращает утечки ресурсов, часто
в виде утечек памяти. Она сохраняет уникальность конкретных объектов, обес
печивая их корректность в процессе работы программы.
Листинг 7.8. К
онструктор копирования, сохраняющий уникальность
объектов
class TextSection {
// предположим, что реализация разумна
};
class Page {
private:
Новый динамический ресурс
TextSection* headers;
создается и инициализируется
Динамический ресурс
TextSection* body;
из существующей копии
public:
Page(TextSection* h) : headers(h), body(new TextSection()) {}
Page(const Page& o) : headers(o.headers), body(new TextSection(*(o.body))) {}
Page& operator=(const Page&);
~Page() { delete body; }
};
200
Глава 7. Установление инварианта класса
Page& Page::operator=(const Page& o) {
if (this == &o)
return *this;
headers = o.headers;
delete body;
body = new TextSection(*(o.body));
return *this;
}
int main() {
Page p1(new TextSection());
Page p2 = p1;
return 0;
}
В современном C++ термин «большая тройка» (big 3) заменяется на «большая
пятерка» (big 5). Дополнительные два члена — это конструктор перемещения
и оператор присваивания с перемещением. Рекомендации для «большой тройки»
остаются прежними, но добавляется дополнительная семантика перемещения. В лис
тинге 7.9 показан код из листинга 7.8, дополненный современной семантикой перемещения и умными указателями. Обратите внимание на использование =default.
Листинг 7.9. Д
обавлены сохранение уникальности, конструктор
перемещения и присваивания
class TextSection {
// предположим, что реализация разумна
};
class Page {
private:
TextSection* headers;
std::unique_ptr<TextSection> body;
public:
Page(TextSection* h) : headers(h),
body(std::make_unique<TextSection>()) {}
Page(const Page& o) : headers(o.headers),
body(std::make_unique<TextSection>(*o.body)) {}
Page& operator=(const Page& o) {
headers = o.headers;
body = std::make_unique<TextSection>(*o.body);
return *this;
}
Современный конструктор перемещения
Page(Page&&) = default;
Page& operator=(Page&&) = default;
Современный оператор
};
присваивания с перемещением
int main() {
Page p1(new TextSection());
Page p2 = p1;
return 0;
}
7.7. Ошибка 48
201
Рекомендации
Если в классе используются какие-либо динамические ресурсы, всегда следует реализовывать «большую тройку» — деструктор, конструктор копирования
и оператор присваивания копированием.
Помните, что конструкторы предназначены для инициализации сырой
памяти, а операторы присваивания — для обновления существующего, уже
созданного экземпляра. Убедитесь, что вы правильно обращаетесь с существующими ресурсами.
Убедитесь, что объекты после перемещения нейтрализованы так, чтобы их
нельзя было повторно использовать, пока они не будут правильно инициализированы.
Смотри также
«Ошибка #1», где мы более подробно обсуждаем две дополнительные составляющие «большой пятерки».
«Ошибка #46», где мы рассматриваем минимизацию количества методов
и их реализацию.
«Ошибка #55», чтобы узнать дополнительные подробности.
«Ошибка #77», где обсуждаются идиомы RAII, которым мы здесь следовали.
7.7. Ошибка #48: применение наследования только
для повторного использования кода
Правильное применение наследования необходимо с точки зрения корректности,
читаемости и эффективности. При неправильном использовании наследование
приводит к аномалиям, код становится трудно читать и понимать, и требуются
дополнительные усилия, чтобы разобраться в деталях.
На заре объектно ориентированного программирования наследование преподносилось как «серебряная пуля» (панацея), которая решит все проблемы
разработки программного обеспечения. Принцип DRY («не повторяйся») был
быстро принят в качестве подтверждения правомерности его использования.
Учебники восхваляли наследование и убеждали, что повторное применение
кода вполне достижимо.
Основная идея заключалась в том, чтобы написать код один раз в базовом классе
(суперклассе, если вы пользуетесь терминологией других языков) и повторно
использовать его в производных классах. Это было очень заманчиво, и многие
из нас клюнули на эту наживку. Позже мы обнаружили, что наши классы могли бы работать лучше. Нам приходилось писать странный код, который делал
202
Глава 7. Установление инварианта класса
исключения для одного класса, но не для другого. Нам нужно было понять
концепцию отношения «is-a» («является»).
Во всех сказках есть намек, но даже самые фантастические детали, кажется,
тускнеют в свете реального мира. Сказка о повторном использовании кода значительно потускнела за прошедшие годы. Учебники по-прежнему упоминают
повторное использование кода в контексте наследования. Это утверждение —
худшая из возможных причин применять наследование. Повторное использование кода — это его преимущество, но оно никогда не служит основанием для
использования наследования.
Проблема
Наследование — это отличная идея и очень мощная техника при правильном
применении. Производный класс по отношению к базовому находится в отношении «is-a». Именно его доктор Барбара Лисков описала в своем знаменитом
принципе подстановки Лисков (Liskov Substitution Principle, LSP). Она заявила,
что там, где нужен экземпляр базового типа, можно подставить экземпляр производного типа. Мы исходим из представления, что классы — это типы данных.
ПРИМЕЧАНИЕ Эта ошибка касается только публичного наследования (public
inheritance). Защищенное (protected), приватное (private) и виртуальное (virtual)
наследование — это совсем другое, и их не следует использовать для отношения «is-a».
В книге они не рассматриваются.
Что, возможно, должно быть очевидным для LSP, так это то, что при работе
с коллекцией объектов — некоторыми экземплярами базовых классов и различными производными — мы обращаемся с ними определенным образом.
Каждый экземпляр рассматривается так, как если бы он был базовым классом.
Запомните эту мантру: «Все, что знает и делает базовый класс, знает и делает
производный класс». Конечно, это не означает, что все, что знает и делает
базовый класс, напрямую доступно производному классу (приватные члены,
например, недоступны). В этом контексте было бы лучше сказать, что «все неприватное, что знает и делает базовый класс, непосредственно знает и делает
производный класс».
Одно из следствий этого таково: при работе с коллекцией объектов и подобъектов можно использовать только интерфейс базового класса — если поведение
не определено в базовом классе, его нельзя вызвать в производном классе. Второе следствие заключается в том, что коллекция зачастую должна иметь дело
с указателями на объекты, а не с самими объектами. Многие контейнеры хранят
объекты по значению фиксированного размера. Попытка втиснуть производный
объект (скорее всего, более крупный) в пространство с размером, определенным
в базовом классе, обречена на провал — копирование спокойно сработает, но
7.7. Ошибка 48
203
часть объекта в производном классе будет срезана, и останется только то, что
имеет размер, определенный в базовом классе.
Рассмотрим случай, когда класс Student — производный от класса Person, как
показано в следующем листинге. Все, что знает (и делает) Person, знает (и делает) Student.
Листинг 7.10. Простая иерархия наследования
class Person {
std::string name;
int age;
};
class Student : public Person {
double gpa;
};
Чтобы понять, почему производный класс можно подставить вместо базового,
изучите рис. 7.1. Слева мы видим объект класса Person, у которого есть две
переменные экземпляра: name (имя) и age (возраст). Объект Student справа —
производный от Person с дополнительным полем gpa (средний балл). Верхняя
часть Student выглядит как Person именно потому, что между Student и Person
отношение «is-a». Нижняя часть Student — это дополнительная переменная
экземпляра. Поскольку Student может рассматриваться как Person или как
Student, в зависимости от контекста обращения, он способен вести себя полиморфно, и так и так.
Джози — это Person, и Салли тоже. Если у нас есть ссылка на Салли как на Person,
то все, что касается Person, будет доступно. Нижняя часть доступна только
тогда, когда мы рассматриваем Салли как Student, потому что у студентов есть
значения gpa. Рисунок 7.1 также демонстрирует, почему при хранении Салли
в массиве элементов Person есть место только для части базового класса; часть
Student отрезается, и Салли внезапно становится только Person — объект теряет
свою принадлежность к Student.
Джози
name
Салли
name
23
age
21
age
3.79
gpa
Рис. 7.1. Объекты Person и Student, показывающие переменные экземпляра
и их расположение
204
Глава 7. Установление инварианта класса
Анализ
Полиморфизм — основная причина для организации классов в иерархии. Еще
одной причиной может быть повторное использование кода, позволяющее
сэкономить пару нажатий клавиш. Однако эта причина на самом деле скорее
антипричина: она создает больше проблем и неудобств, чем экономит усилия.
В следующем листинге базовый класс обладает функциональностью, которую
производный класс хочет повторно использовать. Однако производный класс не
может быть связан с базовым классом отношением «is-a». Этот пример призван
показать определенные недостатки, которые становятся очевидными — обычно
слишком поздно — при попытке сэкономить несколько нажатий клавиш за счет
повторного использования методов, когда классы не тесно связаны.
Листинг 7.11. У
становление отношений между классами по внешнему
виду
class Square {
private:
double side;
double offset;
public:
Square(double side) : side(side), offset(0) {}
virtual void move(int n) { offset += n*side; }
double getOffset() { return offset; }
double area() { return side*side; }
};
class Horse : public Square {
public:
Horse(double height) : Square(height) {}
};
Квадрат может двигаться
в графическом пространстве
Лошадь может двигаться,
но совсем по-другому
int main() {
std::vector<Square*> squares;
squares.push_back(new Square(1));
Приведение типов — это,
squares.push_back(new Horse(15));
скорее всего, плохой признак
for (int i = 0; i < squares.size(); ++i) {
(«запах кода»)
squares[i]->move(2);
Horse* h = dynamic_cast<Horse*>(squares[i]);
Это дурацкий беспорядок,
if (h) {
который будет только
std::cout << "Horse moved " << h->getOffset()
усложняться по мере добав<<" meters ahead\n";
ления новых классов
} else {
std::cout << "Square moved to (" << squares[i]->getOffset()
<< ", " << squares[i]->getOffset() << ")\n";
std::cout << "Square area is " << squares[i]->area() << '\n';
}
Динамические объекты создают
}
утечки; их следует удалять
return 0;
}
7.7. Ошибка 48
205
Во-первых, при итерации по контейнеру этих классов, которые не являются
родственными, необходимо добавлять специальный код обработки, чтобы гарантировать, что экземпляры Horse не вызывают теперь уже бессмысленный метод
area. Дополнительная нагрузка заставляет программистов обрабатывать каждый
производный объект как особый случай, разрушая простой поток контейнеров родственных классов. Кроме того, эту обработку можно добавить только в новый код.
Во-вторых, метод move означает перемещение квадрата в некоторую точку на n
единиц вверх и на n единиц вправо (или наоборот, если используются отрицательные значения). Однако getOffset для лошади означает нечто совершенно
иное, и название метода не отражает его назначения.
В-третьих, объект Horse нельзя осмысленно заменить объектом Square. Экземпляр Horse будет иметь оба поля данных и наследовать три метода; объект Horse
не может иметь допустимую область и не перемещается, как Square. Хотя этот
пример весьма надуман, приведенные замечания справедливы и для более «ра
зумного» кода, где наследование применяют ради повторного использования.
Решение
Есть два варианта решения этой проблемы. Первый — отказаться от такого
способа написания кода. Используйте наследование только в том случае, если
производный класс корректно связан со своим базовым классом и они действительно взаимозаменяемы во всех случаях. Отправная точка для проверки
такого отношения — ответ на вопрос, можно ли производный класс всегда подставить там, где требуется экземпляр базового. Взаимозаменяемость означает,
что каждое поле данных и поведение имеют смысл в контексте как базового, так
и производного класса.
Код в листинге 7.12 лучше выражает эту мысль, разрывая вертикальную иерар
хию наследования и распространяя ее горизонтально. Программе могут понадобиться объекты Horse и Square, но не в непосредственной связи. Каждый
класс определяет собственный набор переменных экземпляра (то, что он знает)
и методов (то, что он делает). У обоих есть метод move, но это просто совпадение.
Более подходящие названия для этих методов — getOffset и getPosition: они
лучше передают смысл каждого метода. Наконец, коллекции объектов разделены
по разным контейнерам, что более интуитивно понятно и лучше соответствует
реальности.
Листинг 7.12. Разделение несвязанных классов
class Square {
private:
double side;
double offset;
public:
Square(double side) : side(side), offset(0) {}
void move(int n) { this->offset += n*side; }
double getOffset() { return offset; }
double area() { return side*side; }
};
class Horse {
Квадрат может
двигаться по-своему
class Square {
private:
double side;
206doubleГлава
7. Установление инварианта класса
offset;
public:
Square(double side) : side(side), offset(0) {}
void move(int n) { this->offset += n*side; }
double getOffset() { return offset; }
double area() { return side*side; }
};
Квадрат может
двигаться по-своему
class Horse {
double height;
double position;
public:
Horse(double height) : height(height), position(0) {}
void move(int offset) { position += offset; }
Лошадь может двигаться
double getPosition() { return position; }
по-своему
};
int main() {
std::vector<Square*> squares;
Держит квадраты вместе
squares.push_back(new Square(1));
for (int i = 0; i < squares.size(); ++i) {
squares[i]->move(2);
std::cout << "Square moved to (" << squares[i]->getOffset()
<< ", " << squares[i]->getOffset() << ")\n";
std::cout << "Square area is " << squares[i]->area() << '\n';
}
std::vector<Horse*> horses;
Держит лошадей вместе
horses.push_back(new Horse(15));
for (int i = 0; i < horses.size(); ++i) {
horses[i]->move(10);
std::cout << "Horse moved " << horses[i]->getPosition()
<< " meters ahead\n";
}
return 0;
}
Во втором подходе используется базовый класс, который определяет только
чисто виртуальные функции. Такие языки, как Java, предоставляют структуры,
подобные классам и называемые интерфейсами, без деталей реализации (в Java 8
и последующих версиях этот вопрос был решен). Идея заключается в объявлении поведения без реализации (абстрактные функции) в интерфейсе. Каждый
конкретный производный класс реализует интерфейс и должен предоставить
тело кода для каждого абстрактного метода. Наиболее существенным преимуществом использования такого типа классов является то, что ранее не связанные
классы могут быть связаны между собой на более абстрактном уровне. Однако
мы должны быть очень осторожны, чтобы действовать таким образом только
тогда, когда эта общность действительно оправданна.
В текущем примере, если задача в основном заключалась в перемещении объектов (предполагается, что это происходит в программе рисования графики),
возможно, имеет смысл связать эти два класса, каждый из которых реализует
типичный абстрактный базовый класс с одним объявленным методом move.
7.7. Ошибка 48
207
Будьте осторожны и не усматривайте паттерны там, где их нет; не «связывайте»
классы между собой из-за нескольких одинаковых методов или имен переменных. Сосредоточьтесь на семантике (смысле), а не на грамматике (синтаксисе
и именовании).
Например, классы Square и Horse можно связать, используя концепцию Moveable.
Она отличается от технической языковой функции, появившейся в C++20. Объект Moveable подразумевает, что он реализует метод move, а контейнер объектов
Moveable подразумевает, что каждый элемент может перемещаться (но не имеет
других общих черт). Следующий код демонстрирует эту концепцию.
Листинг 7.13. Использование класса, похожего на интерфейс
class Moveable {
public:
virtual void move(int) = 0;
virtual double getPosition() = 0;
};
Класс-примесь1, который объявляет поведение,
создавая абстрактные методы
class Square : public Moveable {
private:
double side;
double offset;
public:
Square(double side) : side(side), offset(0) {}
void move(int n) { this->offset += n*side; }
double getPosition() { return offset; }
double area() { return side*side; }
};
Квадраты могут
двигаться по-своему
class Horse : public Moveable {
private:
double height;
double position;
public:
Horse(double height) : height(height), position(0) {}
void move(int offset) { position += offset; }
Лошади могут двигаться
double getPosition() { return position; }
по-своему
};
int main() {
std::vector<Moveable*> movers;
Коллекция объектов Moveable; их не следует
movers.push_back(new Square(1));
считать именно квадратами и лошадьми
movers.push_back(new Horse(15));
for (int i = 0; i < movers.size(); ++i) {
movers[i]->move(2);
std::cout << "Mover moved " << movers[i]->getPosition() << " units \n";
1
}
В объектно
ориентированных языках программирования миксин (или примесь) — это
return
0;
класс,
который
содержит методы для использования другими классами, но при этом
}
не является их родительским классом. — Примеч. пер.
};
double getPosition() { return position; }
по-своему
int main() {
std::vector<Moveable*> movers;
Коллекция объектов Moveable; их не следует
movers.push_back(new Square(1));
208movers.push_back(new
Глава 7. Установление
инварианта
класса
считать именно квадратами и лошадьми
Horse(15));
for (int i = 0; i < movers.size(); ++i) {
movers[i]->move(2);
std::cout << "Mover moved " << movers[i]->getPosition() << " units \n";
}
return 0;
}
При использовании класса, похожего на интерфейс, убедитесь, что каждый метод имеет смысл в рамках идеи, которую отражает класс. Принцип подстановки
Лисков применяется здесь так же строго, как и раньше: везде, где требуется
экземпляр базового класса, может быть корректно и осмысленно использован
экземпляр производного класса.
Рекомендации
Полиморфизм должен быть основной мотивацией для применения наследования.
Без полиморфизма цель наследования сводится к повторному использованию кода.
Повторное использование кода приведет к неудобствам при написании
и чтении кода.
Отделите классы, использующие наследование для переиспользования кода,
и перепишите методы соответствующим образом.
Смотри также
«Ошибка #45», где мы рассматриваем классы как типы данных с сопутствую
щим этому набором операций и инвариантом класса.
«Ошибка #50», где представлен классический пример борьбы с организацией
иерархии наследования.
«Ошибка #59», где мы обсудим то, как реализация полиморфного поведения
для элементов массива с треском проваливается.
7.8. Ошибка #49: чрезмерное использование
конструкторов по умолчанию
Эта ошибка сильно влияет на корректность, поскольку она связана с обеспечением соблюдения инварианта класса. На читаемости это сказывается тем,
что в коде, использующем конструктор по умолчанию, без дополнительного
изучения нельзя понять, какими значениями инициализируется объект.
Обеспечение инстанцирования объекта с сохранением инварианта класса — основная ответственность конструктора. Многим классам требуется специальная
инициализация переменных экземпляра, чтобы они находились в осмысленном
7.8. Ошибка #49
209
состоянии. Конструкторы по умолчанию предназначены для придания переменным допустимого (но не обязательно осмысленного) значения, когда параметры инициализации не переданы. Если корректное значение по умолчанию
определить невозможно, конструктор по умолчанию оставляет экземпляр в несогласованном состоянии. Предположим, что программист позже заполнит эти
поля осмысленными значениями. Проблема возникнет, если один или несколько
мутаторов так и не будут вызваны.
Проблема
Конструкторы по умолчанию могут казаться более эффективными, но зачастую
это не так. Значения по умолчанию обычно должны быть изменены позже, чтобы добавить осмысленные данные, что ухудшает эффективность и читаемость.
При условии что инициализированные значения изменяются до использования
экземпляра, производительность страдает лишь незначительно.
Экземпляр никогда не должен содержать в своем состоянии недопустимые
или бессмысленные данные. Если используются конструкторы по умолчанию,
то класс должен быть спроектирован так, чтобы иметь разумные значения по
умолчанию для «типичного» объекта, пригодного к использованию без дополнительной модификации.
Код в листинге 7.14 в некотором смысле типичен, но совершенно бесполезен.
Поскольку конструктор по умолчанию не задан программистом, компилятор
предоставляет свою версию, которая инициализирует члены класса значениями
по умолчанию. Однако встроенные переменные не инициализируются вовсе.
Поэтому name будет пустой строкой (странное имя), а age — неопределенным
(мусорное значение). Осмысленное использование такого объекта возможно
только после вызова сеттеров, которые легко забыть вызвать. Такие сеттеры
опасны без проверки, так как переменным экземпляра могут быть присвоены
недопустимые значения.
Листинг 7.14. К
онструктор по умолчанию, который приводит
к бессмысленным результатам
class Person {
private:
std::string name;
int age;
public:
std::string getName() { return name; }
int getAge() { return age; }
};
Не предусмотрено ни одного конструктора
по умолчанию, написанного программистом;
поэтому его пишет компилятор
Экземпляр класса будет создан с вызовом
конструктора по умолчанию
Встроенная переменная не будет инициализирована (она будет содержать мусорные значения)
int main() {
Person p1;
std::cout << p1.getName() << ' ' << p1.getAge() << '\n';
}
Будет вызван конструктор по умолчанию,
созданный компилятором: name будет
инициализировано, а age — нет
private:
по умолчанию, написанного программистом;
std::string name;
поэтому его пишет компилятор
int age;
Экземпляр класса будет создан с вызовом
public:
конструктора по умолчанию
std::string getName() { return name; }
int getAge() { return age; }
Встроенная переменная не будет инициализиро210
Глава 7. Установление инварианта класса
};
вана (она будет содержать мусорные значения)
int main() {
Person p1;
std::cout << p1.getName() << ' ' << p1.getAge() << '\n';
}
Будет вызван конструктор по умолчанию,
созданный компилятором: name будет
инициализировано, а age — нет
Добавление явного конструктора по умолчанию ничем не лучше:
Person() : name(""), age(0) {}
Однако переменная экземпляра age будет инициализирована, что предотвращает
неопределенное поведение.
Анализ
Ни в одной бизнес-задаче, скорее всего, не будет человека без имени и с нулевым
возрастом; однако если конструктор по умолчанию необходим, следует учесть
возможность возникновения такой опасной ситуации. Значения по умолчанию
можно поменять на Lakshmi и 21, но тогда каждый объект без параметров получит эти данные. Такой подход ничего не дает и, что гораздо хуже, еще сильнее
маскирует проблему.
Единственный осмысленный конструктор в данном случае — это следующий:
Person(const std::string& name, int age) : name(name), age(age) {}
Здесь для создания объекта Person требуются два параметра; поля инициализируются этими значениями, что (скорее всего) предотвращает появление
бессмысленного значения по умолчанию и устраняет проблему, возникающую
при пропуске вызова мутатора.
Инвариант класса требует, чтобы каждое поле содержало валидные данные.
Конструктор по умолчанию часто нарушает этот принцип. Кроме того, его наличие может стать поводом выбрать неудачную структуру данных.
Один из наиболее известных случаев, когда конструктор по умолчанию действительно нужен, — создание массивов объектов. В этом случае создается массив,
и каждый элемент инициализируется вызовом конструктора по умолчанию. За
исключением очень редких ситуаций, это приводит к нарушению инварианта
для каждого элемента.
Решение
Как правило, инициализация всех переменных экземпляра при создании объекта
необходима для поддержания корректности. В небольшом количестве случаев
разумным подходом может стать использование конструкторов по умолчанию.
Поскольку данные для инициализации элемента еще не определены, конструктор
7.8. Ошибка #49
211
по умолчанию приходится вызвать, чтобы задать значения переменным экземпляра. Использование массивов имеет смысл в некоторых случаях, но чаще
оно вызывает проблемы. Компромисс заключается в небольшом повышении
эффективности в ущерб корректности. Массивы обычно размещаются в стеке
(если только они не создаются с помощью ключевого слова new). Кроме того,
в учебниках массивы рассматриваются как что-то само собой разумеющееся,
и это придает им важность в сознании учащегося. То, что изучается раньше,
часто используется первым, когда есть выбор. Поэтому векторы следует изучать
в первую очередь и применять их вместо массивов почти во всех случаях.
Векторы используют один уровень косвенности (указатель) для доступа к данным, поэтому операции чтения массивов немного быстрее. Однако разработчик
должен обосновать, что этот небольшой выигрыш оправдывает снижение надежности. Массивы привычны и часто используются, но их недостатки могут быть
существенными. Лучше выбрать контейнер, который не требует конструктора
по умолчанию.
В листинге 7.15 показан простой пример массива и эквивалентного ему вектора.
Массив вызывает конструктор по умолчанию, что приводит к некорректному
состоянию элементов. Вектор же требует конструктора с двумя аргументами для
каждого элемента, что гарантирует правильную (не по умолчанию) инициализацию; кроме того, элементы должны быть копируемыми. Современный C++
позволяет сгладить проблему при помощи использования списков инициализации при создании массива, но в предыдущих версиях C++ этого сделать нельзя.
Листинг 7.15. Использование вектора вместо массива
class Person {
private:
std::string name;
int age;
public:
Person() : name(""), age(0) {}
Person(const std::string& name, int age) : name(name), age(age) {}
std::string& getName() { return name; }
int getAge() { return age; }
};
Вызывается конструктор по умолчанию,
int main() {
Person people[2];
в результате чего каждый элемент
инициализируется неправильно
Количество элементов может измениться,
но это значение может быть пропущено
for (int i = 0; i < 2; ++i)
std::cout << people[i].getName() << " is " << people[i].getAge()
<< '\n';
std::vector<Person> peeps;
peeps.push_back(Person("Susan", 21));
peeps.push_back(Person("Jason", 25));
}
Добавляет в вектор любое количество
элементов и следит за их числом
for (int i = 0; i < peeps.size(); ++i)
std::cout << peeps[i].getName() << " is " << peeps[i].getAge()
<< '\n';
При добавлении или удалении элементов этот
цикл всегда работает корректно
но это значение может быть пропущено
for (int i = 0; i < 2; ++i)
std::cout << people[i].getName() << " is " << people[i].getAge()
<< '\n';
std::vector<Person> peeps;
212peeps.push_back(Person("Susan",
Глава 7. Установление инварианта
21)); класса
peeps.push_back(Person("Jason", 25));
}
Добавляет в вектор любое количество
элементов и следит за их числом
for (int i = 0; i < peeps.size(); ++i)
std::cout << peeps[i].getName() << " is " << peeps[i].getAge()
<< '\n';
При добавлении или удалении элементов этот
цикл всегда работает корректно
Рекомендации
Не поддавайтесь искушению добавлять конструкторы по умолчанию только
потому, что они хорошо знакомы или распространены; это может быть небезопасно.
Подумайте, стоит ли вообще включать конструктор по умолчанию. Убедитесь,
что каждое поле будет находиться в корректном состоянии, иначе объект
будет ошибочным или недопустимым.
Применяйте векторы или другие контейнеры вместо массивов, поскольку
массивы требуют использования конструкторов по умолчанию для инициа
лизации каждого элемента.
Помните об инварианте класса и значении членов данных по умолчанию:
если объекты действительно должны создаваться без параметров — это приемлемо; если нет — конструктора по умолчанию следует избегать.
Смотри также
«Ошибка #35», где мы обсуждаем, почему стоит выбирать вектор в качестве
контейнера по умолчанию.
7.9. Ошибка #50: несоблюдение отношения «is-a»
Читаемость (или ее отсутствие) является основной мотивацией для этого обсуждения. Однако плохо реализованные отношения наследования могут также
негативно сказаться на корректности и повлиять на эффективность.
Публичное наследование (public inheritance) позволяет создавать новые классы
на основе существующих. Эти новые производные классы должны быть связаны с базовым через отношение «is-a». Защищенное (protected) и приватное
(private) наследование принципиально отличаются от публичного и здесь не
рассматриваются. Если функция получает параметр в виде экземпляра базового
класса (или указатель на экземпляр, или ссылку на экземпляр), объект производного заменит его и будет работать как положено. Это свойство — принцип
подстановки Лисков. Наследование сообщает важную информацию читателю,
но, чтобы передавать правильные и точные данные, экземпляр производного
класса, унаследованный через public, должен быть заменяем и вести себя как
экземпляр базового класса во всех случаях.
7.9. Ошибка #50
213
Проблема
Как уже упоминалось, в некоторых учебниках до сих пор утверждается, что наследование — это изящный способ повторного использования кода, позволяющий
сэкономить время разработчика. Такой подход выглядит эффективным: меньше
кода — больше выгоды. Однако этот совет легко загоняет неопытного разработчика в тупик (а нарисованный под ногами пол внезапно становится лавой).
Публичное наследование означает, что базовый класс — это общая идея или концепция, которая поддается специализации несколькими способами. Например,
рассмотрим класс Shape, который описывает двумерный многоугольник. Далее
предположим, что программе необходимо работать с объектами Circle (Круг),
Square (Квадрат) и Triangle (Треугольник). Поскольку все они являются объектами типа Shape и обладают общим поведением (например, area (площадь)
и perimeter (периметр)), логично смоделировать их как производные классы от
Shape. Перед этим необходимо проверить: вдруг существует случай, когда объекты Circle, Square или Triangle не удовлетворяют отношению «is-a». В текущем
примере ответ отрицательный, но некоторые случаи не так очевидны и не так
четко спроектированы.
В противоположность этому примеру рассмотрим ситуацию с классом Bird
(Птица) и производным от него классом Ostrich (Страус), поскольку страусы
определенно являются птицами. Такое наследование, казалось бы, удовлетворяет
свойству «is-a», однако здесь скрыта проблема. Мы интуитивно считаем птиц
летающими животными, но страусы не летают. Интуиция может привести нас
к ошибкам. Правильные отношения между классами диктует решаемая задача;
иногда естественные связи следует минимизировать или игнорировать.
Возвращаясь к примеру с Shape, можно сказать, что один из самых убедительных
аргументов в пользу наследования, особенно публичного, заключается в том,
что программам часто требуется обрабатывать коллекцию связанных объектов
одинаковым образом. Функции интерфейса базового класса определяют общее
поведение — именно поэтому он является базовым. Производные классы могут
переопределять эти функции, предоставляя специализированное поведение.
Если задача не предполагает работу с коллекциями связанных объектов, то,
скорее всего, нет необходимости в иерархии наследования.
Анализ
Разработчик пытается связать геометрические фигуры через наследование,
поскольку кажется разумным обрабатывать разные классы одинаковым образом. Поэтому, по его мнению, будет использоваться базовый класс с общими
функциями, а производные классы могут уточнить поведение в соответствии
с фактическим типом экземпляра. Поскольку прямоугольник и квадрат очень
214
Глава 7. Установление инварианта класса
похожи, кажется логичным вывести квадрат из прямоугольника и обеспечить
более строгое соотношение между его высотой и шириной.
Класс Square был создан для совместного использования кода из Rectangle, что
выглядит как способ повысить эффективность в обмен на небольшое снижение
читаемости. Однако желание повторно использовать код (читай — сэкономить
несколько нажатий клавиш) быстро разбивается о твердые скалы реальности.
Объект Square кажется частным случаем Rectangle, пока не осознаешь, что прямоугольник, высота и ширина которого не могут меняться независимо, вообще
не является прямоугольником — это просто фигура с двумя числами, которые
не задают корректный прямоугольник. Хотя он может иметь одинаковую высоту
и ширину, этот редкий случай не оправдывает проектирование Square как частного
случая Rectangle. Прямоугольники должны поддерживать независимые свойства
высоты и ширины. Эта независимость является частью инварианта класса Rectangle
для каждого экземпляра (спорьте с геометрами, если не хотите соглашаться).
Листинг 7.16. Н
еправильное наследование, которое кажется экономией
усилий
class Rectangle {
private:
double height;
double width;
public:
Rectangle(double h, double w) : height(h), width(w) {}
double getHeight() { return height; }
void setHeight(double h) { height = h; }
double getWidth() { return width; }
void setWidth(double w) { width = w; }
virtual void validate() { assert(height >= 0 && width >= 0); }
};
Попытка сэкономить несколько нажатий клавиш
приводит к созданию этого производного класса
class Square : public Rectangle {
public:
Square(double s) : Rectangle(s, s) {}
void validate() override {
Rectangle::validate();
assert(getHeight() == getWidth());
}
};
int main() {
std::vector<Rectangle*> shapes { new Rectangle(3, 4),
new Square(2) };
for (auto shape : shapes) {
// гарантирует разные длины
shape->setHeight(shape->getWidth() + 1);
Экземпляр Square будет иметь
shape->validate();
различающиеся значения высоты
}
и ширины!
return 0;
}
7.9. Ошибка #50
215
Хотя при нормальной работе кода не возникает никаких проблем, функция
validate сразу их раскрывает. Экземпляр Square должен иметь одинаковые высоту и ширину (по определению), а Rectangle обязан допускать разные размеры.
Когда проверяется инвариант класса, проблема раскрывается во всей своей красе:
Square не является Rectangle (и наоборот), хотя интуитивно кажется обратное.
Решение
Листинг 7.17 демонстрирует правильное использование наследования. Любой
производный класс при необходимости может вести себя как Shape (цикл в основной функции main). Производные классы связаны через наследование, но
каждый класс представляет собой отдельную идею: может проверять соблюдение
собственного инварианта, и ни один из них не нарушает интерфейса базового
класса. Доктор Барбара Лисков могла бы гордиться.
Подход не начинается с Rectangle, а строится вокруг абстрактного базового
класса (в идеале с чистыми1 виртуальными функциями; по крайней мере одна
должна быть такой), который определяет поведение всех производных классов.
Затем, с учетом того, что между прямоугольником и квадратом нет отношения
«is-a», производные классы выводятся непосредственно из базового. В этом
случае Rectangle и Square связаны между собой как равнозначные потомки, а не
как родитель и потомок. Это принципиально важно. Каждый класс может накладывать свои ограничения на длины сторон, не влияя на другой. Представьте
себе, что в эту смесь нужно добавить и класс Triangle, который будет иметь еще
более разнородные ограничения, — упорядоченная иерархия позволит сделать
это без обходных трюков и головной боли.
Листинг 7.17. Абстракция основных элементов в базовый класс
class Shape
public:
virtual
virtual
virtual
};
{
double area() = 0;
double perimeter() = 0;
void validate() = 0;
Абстрактный базовый класс
определяет только поведение
Этот класс реализует поведение
особым образом
class Rectangle : public Shape {
private:
double height;
double width;
public:
Rectangle(double h, double w) : height(h), width(w) {}
double area() override { return height * width; }
double perimeter() override { return (height + width) * 2; }
void validate() override { assert(height >= 0 && width >= 0); }
};
class Square : public Shape {
класс реализует
поведение
1
Чистые функции (pure functions) — это Этот
функции,
которые
при вызове не изменяют
private:
идиоматическим способом,
состояние
объектов и не имеют побочных эффектов. — Примеч. пер.
double side;
отличным от других классов
public:
Square(double s) : side(s) {}
double area() override { return side * side; }
double perimeter() override { return side * 4; }
void validate() override { assert(side >= 0); }
double height;
double width;
public:
Rectangle(double h, double w) : height(h), width(w) {}
double area() override { return height * width; }
perimeter() override { return (height + width) * 2; }
216double
Глава 7. Установление инварианта класса
void validate() override { assert(height >= 0 && width >= 0); }
};
class Square : public Shape {
Этот класс реализует поведение
private:
идиоматическим способом,
double side;
отличным от других классов
public:
Square(double s) : side(s) {}
double area() override { return side * side; }
double perimeter() override { return side * 4; }
void validate() override { assert(side >= 0); }
};
int main() {
std::vector<Shape*> shapes { new Square(2), new Rectangle(3, 4) };
for (auto shape : shapes)
shape->validate();
return 0;
}
Сэкономить несколько нажатий клавиш при совместном использовании кода —
опасное искушение: оно кажется многообещающим, но приводит к катастрофе.
Только когда производный класс действительно является вариантом базового и,
следовательно, корректно заменяет его в каждом случае, можно надлежащим образом поддерживать отношение публичного наследования. Во многих реальных
иерархиях часть кода действительно может быть совместной для производного
и базового классов, что приводит к его повторному использованию, повышающему эффективность. Помните, что многократное использование кода (включая
экономию нажатия клавиш) — это преимущество наследования, а не причина
для его использования.
Рекомендации
Совместное использование кода (повторное использование) — это преимущество публичного наследования, но никак не причина для его использования.
Используйте публичное наследование, только если производный класс
корректно заменяет экземпляр базового класса во всех случаях и имеет осмысленные реализации всех его функций.
Если поведение не обязательно моделировать в базовом классе, не поддавайтесь соблазну добавлять его, даже если интуиция подсказывает обратное.
Удалите все, что не нужно, прямо сейчас — это принцип YAGNI (You ain't
gonna need it, «Вам это не понадобится») в действии.
Классы должны быть связаны наследованием, потому что они являются связанными сущностями и используются в коллекциях, где каждый экземпляр
действует как объект базового класса, возможно, с некоторыми особенностями своего поведения.
8
Соблюдение инварианта
класса
В этой главе
3 Как обеспечить соблюдение инварианта класса при
проектировании программ
3 Трудности, возникающие из-за непродуманного следования
устаревшим советам по объектно-ориентированному
проектированию
3 Различия между конструкторами копирования и оператором
присваивания копированием
3 Широко распространенная проблема отсутствия инициализации
переменных встроенных типов
После тяжелой работы по определению инварианта класса, описанной в главе 7, разработчики должны тщательно следить за его соблюдением. Некоторые
аспекты разработки программ создают риск нарушения этого требования. Важно
знать об этом и быть начеку, чтобы его предотвратить.
Существует вероятность несоблюдения инварианта класса всякий раз, когда
данные используются для инициализации или изменения переменной состояния.
218
Глава 8. Соблюдение инварианта класса
Гибкость и высокая степень детализации конструкций C++ предоставляют
широкие возможности воздействия на данные, иногда непредсказуемым образом. Наиболее очевидное место, где может произойти нарушение ограничения
состояния, — это изменение любых данных состояния. Однако это очевидно
только в некоторых случаях. Создание и удаление объектов — отчасти скрытый
процесс, однако он играет важную роль в определении содержания — а значит,
и инварианта объекта.
Наследование открывает дополнительные возможности для неправильного
использования данных и воздействия на требование класса. В C++ существуют строгие правила создания объектов, порядка построения каждой их части
и того, какие данные состояния могут быть использованы в тот или иной момент
конструирования (или уничтожения). Ошибки в этих местах могут быть катастрофическими. Функции копирования данных также предоставляют несколько
способов испортить состояние объекта. Неправильное использование иногда
сложно обнаружить, но последствия могут быть колоссальными. При копировании существуют два варианта — поверхностный и глубокий; и неправильный
выбор метода может угрожать инварианту класса.
Работа с массивами и другими контейнерами зависит от семантики копирования.
Корректная реализация гарантирует сохранность объектов и их данных. Правильное использование виртуальных функций необходимо при полиморфизме,
они также критичны и для корректного формирования объектов. Ошибки в их
применении различны, но каждая из них может негативно повлиять на корректность операций, состояние или на то и другое одновременно.
8.1. Соблюдение инварианта класса
Соблюдение инварианта требует внимания к деталям и знания некоторых тонкостей взаимодействия конструкторов и виртуальных функций. Гибкость языка
C++ предоставляет разработчикам большую свободу в создании специфического
поведения, но необходимо иметь четкое понимание этой гибкости и правильно
ее использовать. Всякий раз при инициализации или изменении переменных
состояния существует вероятность нарушить согласованное состояние, и для
предотвращения таких ошибок требуется пристальное внимание к некоторым
наиболее неясным или уязвимым областям.
Технически конструирование объекта относится к этапу установления инварианта класса. Наследование — это мощный метод построения иерархии родственных
классов, которые разделяют стандартное поведение, определенное публичным
интерфейсом. Конструирование производных объектов может повлиять на уже
определенные состояния из-за некорректного применения виртуальных функций
или ошибочного размещения производных объектов в массивах. Эти ошибки
8.2. Ошибка #51
219
касаются неправильного и, возможно, непредусмотренного использования
объектно-ориентированных методов, которые при таком применении делают
инвариант класса недействительным.
8.2. Ошибка #51: написание несущественных
аксессоров
Эта ошибка влияет на корректность, читаемость и эффективность. Несущественные аксессоры разрушают информационный барьер между классом и клиентским
кодом. Такие методы требуют много времени на написание, но не на поддержку;
они создают дополнительную когнитивную нагрузку при минимальной или
нулевой пользе.
В оптимистическую эпоху объектно ориентированного программирования
многие учебники и авторы продвигали идею писать аксессоры и мутаторы для
каждой приватной переменной экземпляра. Популярным утверждением было
то, что каждый клиент должен иметь доступ ко всему состоянию объекта. Такой
код разбавляет более важные методы и усложняет чтение и запоминание класса.
Код, написанный по этому совету, насыщен аксессорами, которые возвращают
копию значения переменной экземпляра. Такие методы нарушают границу инкапсуляции между публичной и приватной частями реализации и чрезмерно
раскрывают ее детали.
Проблема
Такой подход вызывает семантические проблемы в нескольких аспектах. Если
для переменной имеются несущественные аксессоры, реализация переменной
и класса может быть опасно раскрыта клиенту. По сути, не остается разницы
между публичными и приватными переменными, к которым предоставлены
простые аксессоры и мутаторы. Раскрытие деталей реализации переменных
состояния предполагает, что клиентский код должен понимать их и управлять
ими. Этот недостаток — прямое нарушение инварианта класса. При изменении
реализации корректность и эффективность оказываются под угрозой, поскольку
клиентский код, вероятно, зависит от определенного поведения, которое было
раскрыто.
Еще одна плохая практика для аксессоров — использование переменных экземпляра для хранения состояний объекта, вычисляемых на основе других
переменных. Предположим, что класс Circle вычисляет свою площадь, как
показано в листинге 8.1. В некоторых примерах унаследованного кода встречается вещественная переменная area , инициализируемая в конструкторе.
Затем пишется простейший аксессор, возвращающий вычисленное значение
этой переменной.
220
Глава 8. Соблюдение инварианта класса
Листинг 8.1. К
ласс с вычисляемыми значениями и простейшими
аксессорами
class Circle {
private:
const static double PI;
double radius;
double area;
public:
Circle() : radius(1), area(PI) {}
Circle(double radius) : radius(radius),
area(radius*radius*PI) {}
double getArea() const { return area; }
double getRadius() const { return radius; }
};
const double Circle::PI = 3.1415927;
Значения вычисляемые, независимо
от того, используются они или нет
Простейший аксессор, который
позволяет получить доступ только
к вычисляемому значению
int main() {
Circle c1(3);
std::cout << c1.getArea() << '\n';
return 0;
}
Несущественные аксессоры раздувают код. После инициализации объекта его
конструктором, вероятнее всего, окажется, что доступ клиента к некоторым или
большинству переменных экземпляра не требуется. Как уже упоминалось, одно
из последствий таких аксессоров состоит в том, что клиентский код начинает
зависеть от деталей реализации переменной, что затруднит процесс изменения
реализации. Другое — что клиенты могут взять на себя ответственность за понимание и поддержку состояния экземпляра. Такая ситуация нарушает инвариант
класса и распространяет детали реализации за пределы класса.
Анализ
Правильно спроектированные классы раскрывают только необходимую информацию о состоянии и не обязательно в том виде, в котором она хранится
внутри класса. Публичный интерфейс определяет, как клиент может получить
доступ к этой информации, но не то, как она реализована, кэшируется ли она
или вычисляется напрямую, а также любые другие детали реализации. Клиентский код не должен зависеть от этих деталей, и ответственность за это лежит на
разработчике класса.
Класс Circle создает экземпляр, используя переданное значение радиуса.
Аксессор предоставляет пользователям неограниченный доступ к значению
radius и его реализации. Метод area зависит от предварительно вычисленной
переменной экземпляра; его аксессор возвращает это значение.
Не следует без необходимости добавлять аксессор для radius. Если клиенту
действительно нужна эта информация, ее стоит предоставить, но сначала важно
8.2. Ошибка #51
221
выяснить, требуется ли она клиентскому коду. Интерес, по-видимому, представляет только площадь круга — критическим является именно вычисление
площади, а не радиус. Тем не менее он остается доступным во всей своей предполагаемой полноте. Прежде чем добавлять аксессор к радиусу, следует уточнить
требования. Клиент уже указал радиус; если он необходим, клиентский код
может сохранить его самостоятельно.
Вторая проблема — автоматическое вычисление площади. В этом случае клиенту,
скорее всего, понадобится это значение, поэтому предварительный расчет —
хорошая идея. Однако, как правило, вычисление лучше отложить до первого
(или каждого) фактического использования. Если бы класс был сложнее, было
бы неясно, понадобится ли площадь вообще. Компьютер называется вычислителем (computer), потому что он вычисляет — разработчики классов должны
использовать компьютер по прямому назначению и позволить ему выполнять
свою работу.
Решение
Несущественные аксессоры должны быть удалены в соответствии с политикой.
Клиенты могут нуждаться в этих значениях, но не следует заранее предполагать,
что они им действительно нужны. Проверка кодовой базы может показать, используется ли аксессор в существующем коде. Если нет — удалите его. Если
используется — определите, не превышает ли клиентский код свои полномочия,
обращаясь к нему. В примере радиус был бы необходим, если бы клиент представлял собой графическую программу, в которой нужно рисовать и располагать
круги. Если же клиент вычисляет вес круглых камней для мощения, радиус не
имеет значения — потребуется только площадь.
Листинг 8.2. К
ласс с аксессором, выполняющим вычисление
при обращении
class Circle {
private:
const static double PI;
double radius;
public:
Circle() : radius(1) {}
Circle(double radius) : radius(radius) {}
double getArea() const { return radius*radius*PI; }
};
const double Circle::PI = 3.1415927;
Вычисляет значения
только при обращении
Разработчики должны применять сбалансированный подход, учитывая вычислительные затраты при написании вычисляющих аксессоров. Часто вычисление бывает недорогим и может выполняться по запросу. Предположим,
что операция ресурсоемкая (например, использует большие объемы данных,
222
Глава 8. Соблюдение инварианта класса
обращается к базе или сети), ее результат должен вычисляться как можно реже.
В таком случае лучший подход — выполнять расчет только при первом доступе,
а затем кэшировать результат. Последующие вызовы будут обращаться к кэшу,
что снизит вычислительные затраты.
Поскольку при применении большинства аксессоров никакие переменные
экземпляра не меняются, лучше всего пометить их как const . Компилятор
проконтролирует неизменяемость и дополнительно документирует намерение
разработчика.
Рекомендации
Не пишите аксессор, пока не будет доказано, что он действительно нужен
клиенту; доступны должны быть только критически важные аксессоры.
Не храните переменную экземпляра, значение которой может быть вычислено, кроме случаев, когда вычисление дорогостоящее.
Результаты ресурсоемких вычислений должны кэшироваться по соображениям производительности.
Отмечайте аксессоры как const, чтобы показать их неизменяемость.
Смотри также
«Ошибка #52», где мы более подробно обсуждаем проектирование, написание
и использование простейших мутаторов.
8.3. Ошибка #52: предоставление простейших
мутаторов
Эта ошибка в значительной степени влияет на корректность и в меньшей — на
эффективность. Многие учебники по программированию продолжают повторять старый, хорошо знакомый антипаттерн. Объясняя, как построить класс,
авторы часто рекомендуют писать аксессор и мутатор для каждой переменной
экземпляра. На заре объектно ориентированного программирования это считалось хорошей практикой, и современные авторы нередко переносят этот подход
в свои книги. Однако он часто приводит к нарушению инварианта класса; нам
нужен более эффективный подход.
Проблема
Рассмотрим класс Circle с единственной переменной экземпляра radius, как
показано в следующем листинге. Конструктор инициализирует эту переменную,
аксессор возвращает ее значение, а мутатор изменяет его.
8.3. Ошибка #52
223
Листинг 8.3. Класс с простейшим мутатором без проверки
class Circle {
private:
Неограниченный
double radius;
public:
диапазон значений из-за
Circle() : radius(1) {}
отсутствия проверки
Circle(double radius) : radius(radius) {}
допустимости
double getRadius() { return radius; }
void setRadius(double radius) { this->radius = radius; }
};
int main() {
Circle c1(2);
std::cout << c1.getRadius() << '\n';
c1.setRadius(-1);
std::cout << c1.getRadius() << '\n';
}
Авторы классического учебника сочли бы такой дизайн правильным, однако
есть три существенные проблемы:
непроверенные входные значения;
требование, чтобы все переменные экземпляра были изменяемыми;
дублирование информации между мутаторами и конструкторами.
Унаследованный код изобилует такими шаблонами. Осознание перечисленных
проблем помогает определить, когда и где следует улучшить ваш код.
Простейшие мутаторы в значительной степени влияют на корректность, допуская
неограниченное изменение переменных экземпляра. Клиенты не должны нести
ответственность за знание правильного диапазона значений переменной — класс
обязан поддерживать эту часть инварианта и предупреждать клиента о некорректных значениях. Читаемость тоже страдает: вспомогательные методы
загромождают интерфейс и мешают сосредоточиться на ключевом поведении.
Корректность может быть нарушена, если переменные экземпляра делают изменяемыми без необходимости — некоторые классы не должны менять свое
состояние после создания.
Эффективность падает из-за того, что разработчик вынужден писать лишние
методы и вводить код, нарушающий инвариант класса. Информация о допустимом диапазоне значений часто дублируется одновременно в конструкторе
и мутаторе, что создает риск расхождения логики. Эффективная реализация
стремится исключить такое дублирование информации.
224
Глава 8. Соблюдение инварианта класса
Анализ
Хотя простейшие аксессоры отвлекают внимание, но относительно безвредны,
тривиальные мутаторы могут быть опасны. Мутатор отвечает за соблюдение
инварианта класса, заданного конструктором. Любое неправильное значение,
переданное в класс, сделает экземпляр несогласованным и, возможно, приведет
к неопределенному поведению. Поэтому мутаторы должны проверять корректность любого возможного входного значения.
Кроме того, некоторые переменные экземпляра, возможно, и не потребуется изменять. Хотя наша интуиция подсказывает, что любая переменная экземпляра
должна быть изменяемой, этот путь более изощренный, чем может показаться.
Перед написанием мутатора необходимо выполнить два важных шага. Во-первых,
определить, должно ли поле быть изменяемым. Во-вторых, установить диапазон
допустимых значений.
Во многих случаях ответ на первый вопрос отрицательный. Если это так, нет
смысла писать мутатор; он был бы опасным и лишним. Разработчик должен
определить, можно ли корректно менять радиус в классе Circle . Здесь это
маловероятно: если требуется круг с другим радиусом, клиент должен создать
новый экземпляр.
Для всех остальных мутаторов тщательно определите правильный диапазон
значений. Напишите код проверки, чтобы убедиться, что эти границы соблюдаются, прежде чем присваивать параметр переменной экземпляра. Подумайте,
как обрабатывать значения параметров, которые выходят за пределы диапазона.
В идеале — выбрасывать исключения, когда значение недопустимо. Это часто
лучший подход, если только руководство проекта не решило, что исключения
использовать нельзя. Другой вариант — игнорировать недопустимое значение
и ничего не делать; однако это не работает, когда конструктор вызывает мутатор
для установки начального значения.
Решение
Следующий код демонстрирует более правильный, но не идеальный подход.
Он решает проблему проверки, гарантируя, что конструктор и мутатор выбрасывают исключение, когда клиент передает недопустимое значение. Однако он
не решает проблему изменяемости или дублирования.
Листинг 8.4. Класс с проверкой, но с дублированием информации
class Circle {
private:
double radius;
public:
Circle() : radius(1) {}
Circle(double radius) {
if (radius < 0)
throw std::invalid_argument("radius is negative");
this->radius = radius;
}
double getRadius() const { return radius; }
void setRadius(double radius) {
if (radius < 0)
Дублирование
информации
о допустимых
значениях
class Circle {
private:
double radius;
8.3. Ошибка #52
public:
Circle() : radius(1) {}
Circle(double radius) {
if (radius < 0)
throw std::invalid_argument("radius is negative");
Дублирование
this->radius = radius;
информации
}
о допустимых
double getRadius() const { return radius; }
значениях
void setRadius(double radius) {
if (radius < 0)
throw std::invalid_argument("radius is negative");
this->radius = radius;
}
};
225
int main() {
Circle c1(2);
std::cout << c1.getRadius() << '\n';
c1.setRadius(-1);
std::cout << c1.getRadius() << '\n';
}
Часто конструкторы и мутаторы имеют одинаковый код. Конструктор отвечает за определение инварианта класса, что включает инициализацию значения
переменной экземпляра (в настоящее время неинициализированной). Мутатор отвечает за изменение (теперь уже инициализированного) значения. Это
дублирование можно устранить, заставив конструктор вызывать мутатор. Поскольку в обоих местах должен соблюдаться инвариант класса, дублирование
информации неизбежно. Неправильное толкование принципа «не повторяйся»
(DRY) может привести к тому, что внимание будет сосредоточено на повторе
кода; вместо этого разработчикам следует сфокусироваться именно на дублировании информации. Код проверки диапазона должен быть помещен в мутатор,
а конструктор — его вызывать.
Следующий код решает три ранее рассмотренные проблемы (непроверяемые
значения параметров, неизбирательная изменяемость и дублирование информации), устраняя каждую из них с минимальными затратами усилий.
Листинг 8.5. Класс с проверкой и единственным источником информации
class Circle {
private:
Единственный источник
double radius;
проверяемой информации
static double validateRadius(double radius) {
if (radius < 0)
throw std::invalid_argument("radius is negative");
return radius;
}
public:
Circle() : radius(1) {}
Код зависит от проверенного
Circle(double radius) : radius(validateRadius(
источника
radius)) {}
double getRadius() const { return radius; }
void setRadius(double r) { radius =
validateRadius(r); }
Код зависит от проверенного источника; удалите
};
мутатор, если переменная должна быть неизменяемой
int main() {
Circle c1(2);
std::cout << c1.getRadius() << '\n';
При таком значении выбрасывается
исключение, предупреждая разработчика
проверяемой информации
static double validateRadius(double radius) {
if (radius < 0)
throw std::invalid_argument("radius is negative");
return radius;
}
public:
226Circle()
Глава
Соблюдение
: 8.
radius(1)
{} инварианта класса
Код зависит от проверенного
Circle(double radius) : radius(validateRadius(
источника
radius)) {}
double getRadius() const { return radius; }
void setRadius(double r) { radius =
validateRadius(r); }
Код зависит от проверенного источника; удалите
};
мутатор, если переменная должна быть неизменяемой
int main() {
Circle c1(2);
std::cout << c1.getRadius() << '\n';
Circle c2(-1);
std::cout << c2.getRadius() << '\n';
}
При таком значении выбрасывается
исключение, предупреждая разработчика
о проблеме
Приватный метод validateRadius изолирует информацию о допустимых значениях радиуса; конструктор инициализирует переменную экземпляра через этот
метод; окружности с разными радиусами создаются отдельно. Все неизменяемые
переменные экземпляра не должны иметь мутаторов — удалите все существую
щие. Выполнение этого шага — это упражнение по освобождению вашего ума
от хлама, а вашего кода — от ненужных точек обслуживания.
Рекомендации
Сделайте как можно больше переменных экземпляра неизменяемыми и удалите их мутаторы.
Проверяйте каждый входной параметр, чтобы убедиться, что его значение
находится в диапазоне, определенном инвариантом класса.
По возможности выбрасывайте исключения для недопустимых значений;
в противном случае игнорируйте недопустимые значения.
Конструктор и мутатор должны вызывать общий метод проверки, чтобы
изолировать эту информацию в одном месте.
Убедитесь, что каждая переменная экземпляра инициализирована осмысленным значением.
Смотри также
«Ошибка #51» для получения информации о написании несущественных
аксессоров, сопутствующих этой ошибке.
8.4. Ошибка #53: чрезмерное использование
защищенных переменных экземпляра
Эта ошибка в первую очередь влияет на корректность. Базовый класс определяет
и обеспечивает соблюдение инварианта, но он уязвим к изменениям, которые
вносит производный. Производный класс обязан соблюдать инвариант базового.
8.4. Ошибка 53
227
Вряд ли получится значительно улучшить читаемость, исправив эту ошибку, но
эффективность можно повысить.
В качестве наилучшей практики все переменные экземпляра должны быть приватными. Такой подход гарантирует, что никакой внешний код не сможет получить к ним доступ или изменить их без строгого контроля со стороны класса.
При введении наследования производный класс может обнаружить, что нужные
данные базового класса недоступны. Как и клиентский код, производный должен
использовать аксессоры и мутаторы базового класса. Такое ограничение может
показаться чрезмерно строгим. C++ предоставляет ключевое слово protected
(защищенный), позволяя производным классам обращаться напрямую к защищенным переменным экземпляра, тогда как внешнему коду это по-прежнему
запрещено. Каким бы приятным ни казалось это смягчение, оно создает возможность (и вероятность) нарушения инварианта класса.
Проблема
Базовый класс сохраняет свое состояние, строго контролируя диапазон значений для каждого члена данных. Конструктор и мутаторы должны быть разработаны таким образом, чтобы инициализировать инвариант класса и следить,
чтобы значения оставались в пределах допустимых границ. Производные
классы имеют прямой доступ к этим переменным, если базовый сделал их
защищенными.
Производный класс должен поддерживать собственный инвариант и не нарушать инвариант базового. К сожалению, компилятор не может обеспечить
соблюдение этих правил — это должен сделать программист. Кроме того, чтобы
производный класс мог правильно поддерживать ограничения базового, разработчик должен знать детали этих требований. Эта информация уже существует
в базовом классе и не должна дублироваться в производном.
Листинг 8.6. Уязвимость защищенной переменной экземпляра
class Person {
protected:
Уязвимая переменная
std::string name;
экземпляра
int age;
public:
Person(const std::string& name, int age) : name(name)
if (age < 0)
throw std::invalid_argument("negative age");
this->age = age;
}
std::string getName() const { return name; }
int getAge() const { return age; }
};
Надлежащим образом
проверена и соблюдает
инвариант класса
class Student : public Person {
private:
double gpa;
public:
Student(const std::string& name, int age, double gpa) : Person
(name, age), gpa(gpa) {}
double getGpa() const { return gpa; }
void setAge(int age) { this->age = age; }
инвариант класса
Person(const std::string& name, int age) : name(name)
if (age < 0)
throw std::invalid_argument("negative age");
this->age = age;
}
std::string getName() const { return name; }
228int getAge()
Глава 8. Соблюдение
инварианта
const { return
age; } класса
};
class Student : public Person {
private:
double gpa;
public:
Student(const std::string& name, int age, double gpa) : Person
(name, age), gpa(gpa) {}
double getGpa() const { return gpa; }
void setAge(int age) { this->age = age; }
Удобство, которое ставит под угрозу
};
инвариант базового класса
int main() {
Student jane("Jane", 26, 3.85); jane.setAge(-26);
std::cout << "Jane is " << jane.getAge() << " years old\n";
return 0;
}
Анализ
Если производный класс не понимает или не поддерживает инвариант базового
класса, его защищенные переменные можно легко вывести за пределы диапазона без предупреждения. Отклонение недопустимых значений становится
невозможным. Метод setAge класса Student не выполняет проверку и напрямую
устанавливает переменную age; это происходит потому, что она защищенная,
а не приватная.
Разработчик класса Student счел удобным использовать защищенные переменные Person. Программист, использующий класс Student, решил добавить метод
setAge, учитывая, что некоторые новые школьные правила зависят от возраста
и что у ученика может наступить день рождения в течение учебного года.
Класс Person правильно проверяет входной возраст и отклоняет недопустимые
значения. Производный класс должен был понимать последствия добавления
метода setAge, но не сделал этого. Поскольку конструктор базового класса проверяет значение age, сначала все кажется в порядке. Однако базовый класс не
обновляет поле age после создания объекта; поэтому необходим короткий путь
для прямого изменения переменной. Чтобы правильно реализовать метод setAge
в классе Student, производный класс должен дублировать информацию о проверках, относящихся к переменной age базового класса. Такое дублирование
нарушает принцип DRY и затрудняет инкапсуляцию переменных.
Решение
Базовый класс Person отвечает за соблюдение инварианта своего класса. Часть
этой ответственности реализуется через запрет другим классам напрямую обращаться к его переменным экземпляра без логики проверки, как показано
в следующем листинге.
8.4. Ошибка 53
229
Листинг 8.7. П
овышение безопасности за счет того, что переменные
экземпляра становятся приватными
class Person {
private:
std::string name;
int age;
public:
Person(std::string name, int age) : name(name) { setAge(age); }
std::string getName() const { return name; }
Неуязвим к изменениям
int getAge() const { return age; }
в производных класса
void setAge(int age) {
if (age < 0)
Правильно проверяется
throw std::invalid_argument("negative age");
и поддерживает инвариант
this->age = age;
класса
}
};
class Student : public Person {
private:
double gpa;
public:
Student(std::string name, int age, double gpa) :
Person(name, age), gpa(gpa) {}
Должен использовать метод базового
double getGpa() const { return gpa; }
класса с корректной проверкой
};
int main() {
Student jane("Jane", 26, 3.85);
jane.setAge(-26);
std::cout << "Jane is " << jane.getAge() << " years old\n";
return 0;
}
Базовый класс правильно проверяет значение возраста, и эта проверка вызывается из конструктора, производного класса и клиентского кода. Производный
класс не должен знать, какой возраст считается допустимым, не говоря уже
о том, чтобы обеспечивать его правильность. Он делегирует эту ответственность своему базовому классу — единственному месту, где такая информация
должна быть.
Это обсуждение не означает, что защищенные переменные нельзя использовать
вообще; есть случаи, когда они уже присутствуют в существующей иерархии,
и вы не можете изменить этот факт. Однако вы можете — и должны — уважать инвариант базового класса и использовать его мутаторы. Если базовый
класс изменить нельзя и у него нет мутаторов с проверкой, реализуйте такой
мутатор в производном классе и обязательно прокомментируйте причину.
Помните о принципе подстановки Лисков (LSP); требование подстановки
не выполняется, если мутатор базового класса не выбрасывает исключение,
230
Глава 8. Соблюдение инварианта класса
а производный — выбрасывает. Если базовый класс не желает предоставлять
некоторые свои переменные экземпляра клиентскому коду, он может предоставить защищенные методы для производных классов. Эти методы позволяют
изменять состояние под контролем базового класса, но при этом предоставляют
интерфейс для производных классов, позволяющий изменять состояние способами, недоступными для клиентского кода.
Рекомендации
Устраните как можно больше защищенных переменных.
Всегда заставляйте мутаторы проверять параметры.
Поймите, что небольшое неудобство производных классов, использующих
аксессоры и мутаторы, полностью компенсируется тем, что базовый класс
сохраняет свой инвариант.
Рассмотрите возможность использования защищенных аксессоров и мутаторов для предоставления такого доступа, который клиентский код не
должен получать.
Не делайте мутаторы виртуальными; они должны быть реализованы только
в классе-владельце.
8.5. Ошибка #54: ошибочное понимание семантики
operator= и конструктора копирования
Эта ошибка влияет на корректность и читаемость. Неправильно запрограммированное, любое из этих поведений может негативно повлиять на инвариант
класса и пригодность кода.
Копирование информации из одного экземпляра класса в другой происходит
регулярно. Обычно для этой операции используется оператор присваивания
копированием, но важно учитывать и конструктор копирования. Если семантика
любого из них неправильно понята, легко предположить, что оба делают одно
и то же и что один из них избыточен. Однако, хотя их действие действительно
похоже, они различаются по смыслу и назначению.
Проблема
При создании класса необходимо учитывать семантику присваивания и копирования из существующего объекта. Если программист не пишет ни конструктор
копирования, ни оператор присваивания копированием, компилятор предоставляет оба по умолчанию. Если определен один из них, второй генерируется
автоматически. См. обсуждение в конце раздела «Решение», где мы разберем,
как все это происходит в современном C++.
8.5. Ошибка 54
231
Реализация по умолчанию у обоих (конструктора и оператора) — поэлементное
копирование каждой переменной экземпляра из исходного объекта в целевой.
В ряде случаев это достаточно и корректно. Однако разработчик все равно должен продумать последствия. Игнорировать их и бездумно полагаться на работу
компилятора — не слишком удачный подход.
Если в коде используются динамические ресурсы — память, дескрипторы, соединения, сокеты и другие, — требуется подход, отличный от поведения компилятора по умолчанию. Разработчик должен обеспечить правильную обработку
таких ресурсов при копировании.
Конструктор копирования вызывается при создании нового объекта, который
еще не инициализирован, — это сырая память, будь то стек или куча. Оператор
присваивания копированием вызывается, когда уже инициализированный целевой объект перезаписывается значениями исходного. Разница в том, инициализирован целевой объект или нет — семантика в этих случаях немного различается.
Код в листинге 8.8 использует динамические ресурсы для чтения файлов (большая часть кода опущена) и берет существующие считыватели (ридеры) как источники инициализации. Поскольку разработчик не реализовал ни конструктор
копирования, ни оператор присваивания копированием, компилятор щедро
предоставляет обе версии по умолчанию. Каждая из них использует семантику
поверхностного копирования, то есть копирует значение из исходной переменной экземпляра в целевую переменную экземпляра.
Листинг 8.8. Р
есурсы, обрабатываемые конструктором копирования
по умолчанию и оператором присваивания копированием
class InputReader {
Это намеренное использование плохого подхода для
private:
простоты; STL предоставляет гораздо лучшие решения
std::string* filenames;
int count;
public:
InputReader() : filenames(new std::string[5]), count(0) {}
~InputReader() { delete[] filenames; }
void addFileName(const std::string& n) {
if (count == 5)
throw std::runtime_error("too many files specified");
filenames[count++] = n;
}
int getCount() const { return count; }
const std::string& operator[](int index) {
return filenames[index];
}
};
int main() {
InputReader ir1;
ir1.addFileName("statistics.txt");
InputReader ir2(ir1);
ir2.addFileName("numbers.txt");
InputReader ir3 = ir2;
ir3.addFileName("categories.txt");
ir1.addFileName("questions.txt");
Конструктор копирования по умолчанию,
предоставляемый компилятором, вызывается
с семантикой поверхностного копирования
for (int i = 0; i < ir1.getCount(); ++i)
Оператор присваивания копированием
по умолчанию, предоставляемый
компилятором, вызывается с семантикой
поверхностного копирования
};
const std::string& operator[](int index) {
return filenames[index];
}
int main() {
Конструктор копирования по умолчанию,
232InputReader
Глава 8.ir1;
Соблюдение инварианта класса
предоставляемый компилятором, вызывается
ir1.addFileName("statistics.txt");
с семантикой поверхностного копирования
InputReader ir2(ir1);
ir2.addFileName("numbers.txt");
InputReader ir3 = ir2;
Оператор присваивания копированием
ir3.addFileName("categories.txt");
по умолчанию, предоставляемый
ir1.addFileName("questions.txt");
}
for (int i = 0; i < ir1.getCount(); ++i)
std::cout << ir1[i] << '\n';
for (int i = 0; i < ir2.getCount(); ++i)
std::cout << ir2[i] << '\n';
for (int i = 0; i < ir3.getCount(); ++i)
std::cout << ir3[i] << '\n';
return 0;
компилятором, вызывается с семантикой
поверхностного копирования
Анализ
Первый ридер добавляет файл statistics.txt, а затем используется для инициализации второго. Тот, в свою очередь, добавляет файл numbers.txt и служит источником для инициализации третьего. Третий ридер добавляет файл categories.
txt, и, как ожидается, он должен иметь три файла для чтения. Затем первый ридер
добавляет новый файл с именем questions.txt. Вывод программы показывает, что
поведение отличается от ожидаемого (для наглядности добавлены пустые строки):
statistics.txt
questions.txt
statistics.txt
questions.txt
statistics.txt
questions.txt
categories.txt
Segmentation fault
Первый ридер должен быть единственным, кто имеет доступ к файлу questions.
txt; однако вывод демонстрирует, что второй и третий считыватели тоже видят
этот файл. Это происходит, поскольку копирование и присваивание просто
копируют значение указателя, и все экземпляры начинают ссылаться на одни
и те же — общие — данные. Ридер ir2 потерял доступ к файлу numbers.txt: когда
ir1 добавляет новый файл, он считает, что в массиве только один допустимый
элемент. Поэтому при добавлении questions.txt он записывается во второй
элемент общего массива, перезаписывая numbers.txt.
Конструктор копирования по умолчанию и оператор присваивания копированием создали поверхностную копию указателя filenames, обеспечив общий
доступ к одному и тому же массиву. Каждый ридер обновляет следующий
элемент независимо от остальных, что приводит к повреждению данных. Это
вызывает непредсказуемое поведение и серьезные ошибки, включая двойное
освобождение памяти.
8.5. Ошибка 54
233
Решение
Чтобы этот пример работал правильно, необходимо запрограммировать конструктор копирования и оператор присваивания копированием так, чтобы они
корректно обрабатывали динамический массив имен файлов. Конструктор
копирования должен выделить новый массив, скопировать все существующие
элементы и установить значение переменной count (счетчик).
В случае с конструктором копирования новый экземпляр не инициализирован,
поэтому динамический массив создается в списке инициализации. Копирование
существующих значений выполняется в теле конструктора. После этого изменения код работает правильно и так, как ожидается. Оператор присваивания
копированием должен использовать иной подход, хотя общая логика похожа.
Поскольку целевой объект уже инициализирован и имеет свой неразделяемый
динамический массив, выделять новый массив не нужно. Необходимо только
скопировать элементы и обновить значение счетчика, как показано в следующем
листинге.
Листинг 8.9. П
равильно реализованные конструктор копирования
и оператор присваивания копированием
class InputReader {
private:
std::string* filenames;
int count;
public:
InputReader() : filenames(new std::string[5]), count(0) {}
~InputReader() { delete[] filenames; }
InputReader(const InputReader& r) : filenames(new std::string[5]),
count(r.count) {
Выделяет новый массив и копирует
for (int i = 0; i < r.count; ++i)
элементы, а не указатель
filenames[i] = r.filenames[i];
}
InputReader& operator=(const InputReader& r) {
Копирует элементы,
for (int i = 0; i < r.count; ++i)
а
не указатель
filenames[i] = r.filenames[i];
count = r.count;
return *this;
}
void addFileName(const std::string& n) {
if (count == 5)
throw std::runtime_error("too many files specified");
filenames[count++] = n;
}
int getCount() const { return count; }
const std::string& operator[](int index) {
return filenames[index];
}
};
int main() {
InputReader ir1;
ir1.addFileName("statistics.txt");
InputReader ir2(ir1);
ir2.addFileName("numbers.txt");
InputReader ir3 = ir2;
ir3.addFileName("categories.txt");
Вызывает пользовательский
конструктор копирования
Вызывает пользовательский оператор
throw std::runtime_error("too many files specified");
filenames[count++] = n;
}
int getCount() const { return count; }
const std::string& operator[](int index) {
return filenames[index];
234} Глава 8. Соблюдение инварианта класса
};
int main() {
InputReader ir1;
ir1.addFileName("statistics.txt");
InputReader ir2(ir1);
ir2.addFileName("numbers.txt");
InputReader ir3 = ir2;
ir3.addFileName("categories.txt");
ir1.addFileName("questions.txt");
}
Вызывает пользовательский
конструктор копирования
Вызывает пользовательский оператор
присваивания копированием
for (int i = 0; i < ir1.getCount(); ++i)
std::cout << ir1[i] << '\n';
for (int i = 0; i < ir2.getCount(); ++i)
std::cout << ir2[i] << '\n';
for (int i = 0; i < ir3.getCount(); ++i)
std::cout << ir3[i] << '\n';
return 0;
Благодаря этим изменениям поведение скорректировано. Вывод теперь соответствует ожиданиям (для наглядности добавлены пустые строки):
statistics.txt
questions.txt
statistics.txt
numbers.txt
statistics.txt
numbers.txt
categories.txt
Этот подход с оператором присваивания копированием может потребовать модификации для некоторых классов. В листинге 8.9 предполагается, что размер
массива одинаков для всех экземпляров. Однако предположим, что есть класс,
в котором размер различается. Конструктор копирования будет работать так же,
поскольку он создает новый массив исходного размера. А вот целевой объект при
выполнении оператора присваивания копированием будет иметь массив другого
размера. В этом случае существующий массив необходимо удалить и создать новый — на основе размера массива источника, — после чего скопировать каждый
элемент. При этом нужно внимательно следить за корректным уничтожением
динамических элементов. Если элементы сами являются экземплярами класса,
их, скорее всего, тоже нужно будет уничтожить. Хотя это, вероятно, произойдет
автоматически, в редких случаях может потребоваться явно вызвать их деструктор. Обязательно вникните в эту ситуацию и разберитесь.
Код в листинге 8.10 демонстрирует вариант решения этой проблемы, где предполагается, что в класс добавлена переменная экземпляра capacity (емкость),
представляющая максимальный размер массива (что гораздо лучше, чем жестко
задавать размер в коде). В этом примере скрыта проблема: если выполняется
8.5. Ошибка 54
235
самоприсваивание, такой код не сработает. Обратитесь к разделу «Смотри также», где мы обсудим этот вопрос.
Листинг 8.10. С
лучай, когда исходный и целевой массивы имеют
разный размер
InputReader& operator=(const InputReader& r) {
if (capacity != r.capacity) {
delete[] filenames;
filenames = new std::string[r.capacity];
capacity = r.capacity;
}
for (int i = 0; i < r.capacity; ++i)
filenames[i] = r.filenames[i];
count = r.count;
return *this;
}
В современном C++ поменялись правила генерации членов по умолчанию. Если
определен только конструктор перемещения, компилятор не будет автоматически генерировать конструктор копирования, оператор присваивания копированием или оператор присваивания с перемещением. Если же определен только
оператор присваивания с перемещением, компилятор не станет автоматически
генерировать конструктор копирования, оператор присваивания копированием или конструктор перемещения. Обязательно учитывайте эти ограничения,
когда ожидаете, что компилятор восполнит отсутствующие члены. Следующий
фрагмент (дополнение к коду из листинга 8.9) демонстрирует и конструктор
перемещения, и оператор присваивания с перемещением:
InputReader(InputReader&& r) : filenames(r.filenames), count(r.count) {
r.filenames = nullptr;
r.count = 0;
}
InputReader& operator=(InputReader&& r) {
if (this != &r) {
delete[] filenames;
filenames = r.filenames;
count = r.count;
r.filenames = nullptr;
r.count = 0;
}
return *this;
}
Рекомендации
Всегда тщательно продумывайте механику работы оператора присваивания
копированием и конструктора копирования и решайте, достаточно ли поэлементного копирования членов.
236
Глава 8. Соблюдение инварианта класса
Не забывайте обрабатывать самоприсваивание; если его игнорировать, возможно неопределенное поведение.
Помните, что конструктор копирования работает с неинициализированной
памятью, а оператор присваивания копированием — с уже инициализированным объектом.
Тщательно продумайте, как правильно работать с динамическими ресурсами
во время инициализации.
Смотри также
«Ошибка #47», чтобы подробнее разобраться в том, что написание деструктора неизбежно влечет за собой написание конструктора копирования и оператора присваивания копированием.
«Ошибка #55», чтобы узнать, как правильно обращаться с динамическими
ресурсами.
«Ошибка #65» для получения рекомендаций, как в этом случае использовать
динамические ресурсы.
8.6. Ошибка #55: непонимание семантики
поверхностного и глубокого копирования
Эта ошибка влияет только на корректность; другие характеристики не затрагиваются. Типичная операция присваивания значения одной переменной другой
или копирования одного объекта в другой объект того же типа выглядит просто,
но некоторые нюансы могут привести к некорректному поведению, если их не
учитывать.
Когда объект копируется или присваивается другому, каждый его член получает новое значение, перезаписывая старое. Конструкторы копирования по
умолчанию и операторы присваивания копированием реализуют поэлементное
копирование из источника в целевой объект. Если среди членов есть указатель,
то исходный и целевой объекты будут указывать на одну и ту же память, таким
образом, разделяя ее. Хотя совместное использование иногда и требуется, зачастую оно все же нежелательно и приводит к серьезным проблемам.
Проблема
Перед копированием или присваиванием инвариант класса был установлен как
в исходном, так и в целевом объекте. После операции оба объекта должны попрежнему удовлетворять инварианту. Однако это может оказаться неверным,
если требуется семантика глубокого копирования.
8.6. Ошибка 55
237
Здесь следует рассмотреть два случая: указатели и ресурсы, находящиеся в эксклюзивном владении. В «Ошибке #54» мы рассмотрели первый вариант и дали
рекомендации, гарантирующие корректное сохранение инварианта. В этом разделе освещается случай эксклюзивных ресурсов.
Рассмотрим ситуацию, когда для передачи сообщений используются каналы
связи. Каждый канал подключен к собственной конечной точке — например,
подписчику платной услуги. Разработчик понял, что указатели на сообщения
требуют аккуратного обращения: динамические данные не должны использоваться совместно. Существующие сообщения обрабатываются правильно, но
необходимо решить вопрос использования уникальных ресурсов.
Уникальные ресурсы часто используются как значения из ограниченного набора сущностей. Например, в офисе может быть три сетевых принтера; каждый
такой принтер — уникальный ресурс, доступный только одному пользователю
в один момент времени. Не динамически выделенные ресурсы могут показаться
обычными значениями. По умолчанию они копируются без ограничений — но на
практике ими нужно управлять строго. Когда уникальный ресурс копируется из
одного экземпляра в другой, источник не должен больше владеть ресурсом — он
передается в целевой объект.
В качестве другого примера предположим, что разработчик хочет перенести
существующие сообщения из одного канала (например, в случае некоторого сбоя
конечного узла) в другой и изменить подключенную конечную точку. По сути,
это операция перемещения; поэтому он реализует оператор присваивания копированием и аккуратно обрабатывает динамические сообщения. Однако, когда
канал (Channel) пытается обработать сообщения (Messages), он терпит неудачу:
конечная точка окажется некорректной, как показано в следующем листинге.
Листинг 8.11. Динамические ресурсы обработаны, а уникальные — нет
int createSocket() {
// предположим, что такая реализация допустима; это только пример!
static int sock_num = 0;
return ++sock_num;
}
class Message {
private:
std::string payload;
public:
Message(const std::string& msg) : payload(msg) {}
const std::string& getMessage() const { return payload; }
};
class Channel {
private:
std::queue<Message*> messages;
int socket;
238
Глава 8. Соблюдение инварианта класса
public:
Channel(int sock) : socket(sock) {}
void setMessage(std::string msg) { messages.push(new Message(msg)); }
int getSocket() const { return socket; }
std::string getMessage() {
std::string msg = messages.front()->getMessage();
messages.pop();
return msg;
}
Channel& operator=(Channel& o) {
for (int i = 0; i < messages.size(); ++i)
messages.pop();
for (int i = 0; i < o.messages.size(); ++i) {
messages.push(o.messages.front());
o.messages.pop();
}
socket = o.socket;
Сокет теперь совместно используется
return *this;
источником и целевым объектом
}
};
int main() {
Channel one(createSocket());
Channel two(createSocket());
two = one;
std::cout << one.getSocket() << ' ' << two.getSocket() << '\n';
}
Анализ
Указатели в очереди исходного канала используются для создания новых объектов Message (не слишком удачный дизайн, но это просто пример). Прямая
передача указателя привела бы к проблеме: вызов pop удалит элемент из очереди
и вызовет его деструктор — указатель будет ссылаться на освобожденную память.
Ресурс сокета копируется как есть, в результате чего два канала совместно используют один и тот же ресурс (но ни один из них не владеет им). Поскольку
сокет не является указателем, легко упустить из виду этот момент и предположить, что достаточно простого копирования.
Оператор копирования копирует значение сокета из источника в цель, но исходный объект не должен больше использовать этот ресурс. Его случайное
использование может привести к неопределенному, некорректному или неожиданному поведению в системе; однако предполагается, что сокет представляет
собой неисправную конечную точку. В этом случае копирование, скорее всего,
оставит цель с нерабочим сокетом, что только усугубит проблему, которую присваивание должно было решить.
Эксклюзивные ресурсы требуют понимания принципов владения. Тот факт, что
ресурс является эксклюзивным, означает, что одновременно им может владеть
8.6. Ошибка 55
239
только один объект. Передача ресурса от одного объекта другому должна лишать
исходного доступа к нему. В таком случае уникальный ресурс, возможно, придется создать заново. Важно осознавать используемую стратегию и корректно
ее реализовать.
Решение
Самый простой подход — гарантировать, что значение эксклюзивного ресурса
в исходном объекте устанавливается в недействительное состояние (или состояние по умолчанию), которое соответствует инварианту класса. Указатель должен
быть обнулен (конкретный метод зависит от вашей версии C++), а переменные
значимых типов должны иметь четкое и однозначно определяемое нулевое
значение, означающее, что ресурс не выделяется. Изменение небольшое, но его
эффект значителен.
Листинг 8.12. Передача динамических и уникальных ресурсов
int createSocket() {
// предположим, что такая реализация допустима; это только пример!
static int sock_num = 0;
return ++sock_num;
}
class Message {
private:
std::string payload;
public:
Message(const std::string& msg) : payload(msg) {}
const std::string& getMessage() const { return payload; }
};
class Channel {
private:
std::queue<Message*> messages;
int socket;
public:
Channel(int sock) : socket(sock) {}
void setMessage(std::string msg) { messages.push(new Message(msg)); }
int getSocket() const { return socket; }
std::string getMessage() {
std::string msg = messages.front()->getMessage();
messages.pop();
return msg;
}
Channel& operator=(Channel& o) {
for (int i = 0; i < o.messages.size(); ++i) {
messages.push(new Message(o.messages.front()->getMessage()));
o.messages.pop();
}
Уникальная версия конечной точки
socket = createSocket();
o.socket = 0;
Исходная конечная точка недействительна,
return *this;
не может быть разделена и очищается,
}
если находится в недопустимом состоянии
};
int main() {
Channel one(createSocket());
240
};
}
Глава 8. Соблюдение инварианта класса
}
socket = createSocket();
o.socket = 0;
return *this;
Уникальная версия конечной точки
Исходная конечная точка недействительна,
не может быть разделена и очищается,
если находится в недопустимом состоянии
int main() {
Channel one(createSocket());
Channel two(createSocket());
two = one;
std::cout << one.getSocket() << ' ' << two.getSocket() << '\n';
return 0;
}
Рекомендации
Убедитесь, что динамические ресурсы обрабатываются надлежащим образом
и не используются совместно.
Убедитесь, что уникальные ресурсы передаются от источника в целевой
объект и что исходный объект приводится в нулевое значение. NULL — это
распространенная идиома языка C, но между его определением и типом
ресурса могут возникнуть несоответствия по размеру. В современном
C++ есть ключевое слово nullptr для присвоения указателям нулевого
значения.
Убедитесь, что нулевое значение, присвоенное полю класса, не нарушает
инвариант; если ноль — допустимое значение, выберите что-то другое для
представления недействительного состояния.
Смотри также
«Ошибка #54», где обсуждаются проблемы, связанные с запутанной семантикой инициализации.
8.7. Ошибка #56: неиспользование операторов
базового класса
Эта ошибка влияет в основном на корректность. Другие характеристики практически не затронуты.
Наследование предполагает создание новых классов на основе существующих,
что позволяет обрабатывать коллекции связанных объектов единым образом.
Каждый класс в иерархии добавляет свою часть данных в состав производного
класса. Объекты производного класса представляют собой комбинацию всех
этих частей. Полиморфизм — это способность обрабатывать производные классы
как базовые во время выполнения программы, но при этом сохраняя поведение
производного класса. Соответствующие конструкторы, деструкторы и операторы
8.7. Ошибка #56
241
должны правильно управлять своей частью данных в иерархии. Корректную
работу оператора присваивания копированием легко упустить из виду.
Проблема
Каждая часть составного объекта (экземпляр производного класса) должна
правильно реализовывать соответствующий оператор. В этом контексте часто
упоминаются конструкторы и деструкторы, поскольку обычно невозможно
написать конструктор без ссылки на конструктор базового класса. Правильная
работа операторов в этом контексте должна упоминаться и обсуждаться чаще.
Рассмотрим следующую иерархию наследования между классом Person (Человек) и классом Student (Студент). Student наследует Person, потому что
студент — это человек, но при этом студент — «больше», чем человек. Разработчик класса Student аккуратно реализует код, который обращается к специа
лизированной информации этого класса, но может упустить необходимость
корректно обрабатывать данные базового класса. Закрытые члены базового
класса недоступны производному, поэтому производный класс не может правильно скопировать их.
В листинге 8.13 показана операция копирования из одного экземпляра в другой.
Этот пример несколько надуман, но будьте уверены, что подобная операция часто
происходит с неименованными переменными, такими как элементы вектора или
массива, — и протекает без явных предупреждающих признаков, как, например,
при копировании Тельмы (Thelma) в Луизу (Louise) в следующем листинге.
Листинг 8.13. П
роизводный класс копирует свои данные, но игнорирует
базовый класс
class Person {
private:
std::string name;
public:
Person(const std::string& name) : name(name) {}
const std::string& getName() { return name; }
};
class Student : public Person {
private:
double gpa;
public:
Student(const std::string& name, double gpa) : Person(name), gpa(gpa) {}
Student& operator=(const Student& o) {
if (this == &o)
return *this;
gpa = o.gpa;
Переменные экземпляра Student копируются
return *this;
правильно, а переменные Person — нет
}
242
};
Глава 8. Соблюдение инварианта класса
double getGpa() const { return gpa; }
int main() {
Student thelma("Thelma", 3.85);
Student louise("Louise", 3.75);
}
louise = thelma;
std::cout << thelma.getName() << ' ' << thelma.getGpa() << '\n';
std::cout << louise.getName() << ' ' << louise.getGpa() << '\n';
Эта проблема становится очевидной при выводе результатов:
Thelma 3.85
Louise 3.85
Анализ
Когда мы завершаем создание объекта louise, ему присваивается значение объекта thelma. Теперь у нас есть два значения thelma, каждое из которых связано
с разными именами переменных из-за операции присваивания экземпляру
louise. Присваивание не вызвало ошибок компиляции или времени выполнения, поэтому внешне код сработал как положено. Однако обратите внимание:
объект louise по-прежнему сохраняет свое имя Louise (Луиза), но при этом
получает gpa Тельмы — для студента это может оказаться лучшим исходом из
всех возможных вариантов. А с точки зрения корректности нашего подхода
это не так.
Производный класс определил оператор присваивания копированием, чтобы
обеспечить копирование значения gpa из параметра в текущий объект; этот
код работает правильно. Однако поле name в базовом классе также должно
быть корректно скопировано. Оно объявлено приватным в Person, и поэтому
Student не может скопировать его напрямую — этот момент легко упустить из
виду. Операция копирования работает как ожидалось, только если определение
оператора присваивания копированием удалить из кода. Значит, существует
разница между оператором присваивания копированием по умолчанию и нашей
пользовательской реализацией.
Оператор присваивания копированием по умолчанию, генерируемый компилятором, обеспечивает вызов оператора присваивания копированием базового
класса до выполнения поэлементного копирования полей данных производного
класса. Этот небольшой, но критически важный факт имеет решающее значение
для правильной работы. Поскольку производный объект представляет собой
композицию всех базовых классов и собственных данных, при копировании или
присваивании любой части этого составного объекта необходимо убедиться, что
каждая составляющая получила свою операцию копирования или присваивания.
8.7. Ошибка #56
243
Решение
Эта проблема легко устраняется, если в ней разобраться. Обновленный код
в листинге 8.14 обеспечивает корректную работу операции присваивания. Оператор присваивания копированием сначала вызывает версию базового класса,
а затем выполняет собственное копирование. Он передает параметр оператору
присваивания базового класса, тем самым гарантируя правильную обработку
всех уровней иерархии. Снова прослеживается тема, связанная с порядком создания объектов: базовый класс всегда создается, копируется или присваивается
первым, а затем та же операция выполняется в производном классе.
Листинг 8.14. П
роизводный класс, корректно учитывающий свой
базовый класс
class Person {
private:
std::string name;
public:
Person(const std::string& name) : name(name) {}
const std::string& getName() { return name; }
};
class Student : public Person {
private:
double gpa;
public:
Student(const std::string& name, double gpa) : Person(name), gpa(gpa) {}
Student& operator=(const Student& o) {
if (this == &o)
Сначала вызывается оператор присваивания
return *this;
копированием базового класса
Person::operator=(o);
gpa = o.gpa;
Затем копируются поля производного класса
return *this;
}
double getGpa() const { return gpa; }
};
int main() {
Student thelma("Thelma", 3.85);
Student louise("Louise", 3.75);
}
louise = thelma;
std::cout << thelma.getName() << ' ' << thelma.getGpa() << '\n';
std::cout << louise.getName() << ' ' << louise.getGpa() << '\n';
Рекомендации
Если в производном классе определен какой-либо оператор, убедитесь, что
его реализация включает вызов соответствующего оператора базового класса
до выполнения любых операций над собственными данными.
244
Глава 8. Соблюдение инварианта класса
Помните, что порядок вызова конструкторов и операторов строго фиксирован: сначала выполняются операции базового класса, затем производного,
а деструкторы работают в обратном порядке.
Смотри также
«Ошибка #57», где мы обсуждаем игнорирование деструктора в иерархиях
наследования.
8.8. Ошибка #57: неиспользование виртуальных
деструкторов в полиморфных базовых классах
Эта ошибка влияет на корректность. Разница между правильной и неправильной
реализацией чаще всего никак не отражается на читаемости, эффективности
или производительности (а если отражается, то это зависит от реализации
компилятора).
Конструкторы должны быть тщательно продуманы, поскольку корректная
инициализация переменных экземпляра необходима для поддержания инварианта класса. Точно такое же внимание следует уделять и деструкторам. C++
дает разработчику как возможности, так и ответственность за проектирование
классов. При разработке класса необходимо учитывать, будет он использовать
наследование, служить базовым классом или останется самостоятельным типом
данных.
Проблема
Обособленные классы проще всего планировать. Это не означает, что их дизайн
обязательно прост, — только то, что без наследования структура прямолинейна
и находится под большим контролем. Дизайн базового класса влияет на проектирование производного. Ошибки, допущенные выше по иерархии наследования,
могут вызвать трудности у разработчиков производных классов. Вы мало что
можете с этим поделать, кроме как программировать, думая о других членах
коллектива и учитывая исключительные случаи. Однако, если вы пишете базовый класс, нужно думать не только о типе, но и обо всех подтипах — поведение
вашего базового класса будет влиять на каждый из них.
Если класс спроектирован как базовый, он должен соответствовать некоторым
правилам. Одно из них заключается в том, что базовый класс обязан предоставлять виртуальный деструктор, поскольку в нем должен быть по крайней
мере один виртуальный метод. И наоборот: не следует объявлять виртуальные
деструкторы, не имея никаких виртуальных функций. Класс не должен содержать виртуальные методы, до тех пор пока он не предназначен быть базовым;
поэтому каждый базовый класс должен предоставлять виртуальный деструктор.
8.8. Ошибка 57
245
C++ преподносит нам сюрпризы при удалении производных объектов через
указатель или ссылку на базовый класс. Полиморфное поведение возможно
только через указатели или ссылки, поэтому такой подход обязателен. Указатели
и ссылки не подразумевают объекты из кучи, но это очень распространенный
сценарий. Так в чем же подвох? Попробуйте удалить производный класс, используя указатель или ссылку на базовый класс. Кажется, все должно быть в порядке, но результат непредсказуем; хуже того, это неопределенное поведение.
Код в листинге 8.15 содержит базовый и производный классы, которые используют динамические ресурсы. Деструктор базового класса не объявлен как
виртуальный. Основная функция создает динамический объект Square и присваивает его указателю Shape. После использования объекта-наследника он
удаляется.
Листинг 8.15. Базовый класс без виртуального деструктора
class Point {
private:
double x;
double y;
public:
Point(double x, double y) : x(x), y(y) {}
};
class Shape {
private:
Невиртуальный деструктор, который
Point* location;
только кажется правильным
public:
Shape(double x, double y) : location(new Point(x, y)) {}
~Shape() { delete location; std::cout << "~Shape\n"; }
virtual double area() const = 0;
Виртуальный метод, предназначенный
};
для полиморфного поведения
class Square : public Shape {
private:
double* side;
public:
Square(double side, double x, double y) : Shape(x, y),
side(new double(side)) {}
~Square() { delete side; std::cout << "~Square\n"; }
double area() const { return *side * *side; } // выглядит странно
};
int main() {
Shape* s = new Square(2.5, 0, 0); // размещение по указателю на базовый
// класс
std::cout << s->area() << '\n';
delete s;
Удаляет часть объекта, относящуюся к базовому классу, но не затрагивает
return 0;
часть, относящуюся к производным классам
}
246
Глава 8. Соблюдение инварианта класса
Результат выполнения этого кода:
6.25
~Shape
Это показывает, что деструктор базового класса был вызван, но произошла утечка
динамической памяти — ваши результаты могут отличаться.
Анализ
Результат вывода функции main показывает, что был вызван только деструктор
Shape. Несмотря на неуклюжий дизайн обоих классов, тот факт, что деструктор
Square не был вызван, демонстрирует утечку памяти.
Базовый класс должен иметь по крайней мере одну виртуальную функцию,
поскольку только они могут участвовать в полиморфном поведении. В противном случае наследование, вероятно, используется лишь для повторного использования кода. Базовый класс без виртуального деструктора может казаться
правильно спроектированным, ведь программа работает. При работе с объектом
производного класса через указатель на базовый все работает как надо — до
момента уничтожения объекта. В этот момент поведение становится нестабильным, хотя заметить это бывает трудно. Конкретное поведение зависит от того,
как компилятор реализует эту функцию; отладочные и релизные версии также
могут вести себя по-разному.
Уничтожение производного класса через указатель базового, скорее всего, удалит
часть объекта, относящуюся к базовому классу. Будет вызван деструктор базового
класса, который выполнит свою работу. Базовый класс не знает (и не должен
знать), что часть объекта, относящаяся к производным классам, должна быть
удалена. Итог, как правило, заключается в том, что производная часть объекта
станет утечкой памяти. Это, несомненно, проблема, но в программах с коротким временем выполнения существенных последствий может и не быть — что
делает поведение особенно обманчивым. Однако ситуация становится куда
серьезнее, когда производный класс имеет динамические ресурсы — такие как
подключения к базе данных, дескрипторы файлов или другие уникальные либо
ограниченные ресурсы.
Решение
Избежать этой ошибки просто: всегда объявляйте виртуальный деструктор
в базовых классах, содержащих одну или несколько виртуальных функций.
Кроме того, не следует создавать базовые классы без виртуальных методов,
а также классы с виртуальными методами, которые не предназначены быть
базовыми. Порядок построения всегда идет от самого базового класса вниз
по иерархии наследования к самому производному. Порядок уничтожения
идет от самого производного класса вверх к базовому — в противоположность
8.8. Ошибка 57
247
построению. Базовый класс должен быть создан первым, потому что производные классы могут зависеть от его данных. Это означает, что самый производный класс должен быть уничтожен первым. Без ключевого слова virtual
это невозможно.
Листинг 8.16. Базовый класс с виртуальным деструктором
class Point {
private:
double x;
double y;
public:
Point(double x, double y) : x(x), y(y) {}
};
class Shape {
private:
Point* location;
public:
Shape(double x, double y) : location(new Point(x, y)) {}
virtual ~Shape() { delete location;
Добавлено ключевое слово virtual;
std::cout << "~Shape\n"; }
теперь деструктор производного класса
virtual double area() const = 0;
вызывается первым
};
class Square : public Shape {
private:
double* side;
public:
Square(double side, double x, double y) : Shape(x, y),
side(new double(side)) {}
~Square() { delete side; std::cout << "~Square\n"; }
double area() const { return *side * *side; } // все еще выглядит странно
};
int main() {
Shape* s = new Square(2.5, 0, 0); // поместим в начало координат
std::cout << s->area() << '\n';
delete s;
return 0;
}
Вывод результата выполнения показывает ожидаемое уничтожение сначала
производного класса, затем базового:
6.25
~Square
~Shape
К деструктору базового класса было добавлено ключевое слово virtual, это
привело к тому, что оба деструктора были вызваны. Этот приятный факт
248
Глава 8. Соблюдение инварианта класса
свидетельствует о том, что динамически выделенная память была правильно
освобождена в обоих классах. Убедитесь, что базовый и производный классы
имеют деструкторы, если они используют динамические ресурсы — такие как
память в куче, системные дескрипторы и другие уникальные или ограниченные
ресурсы.
Рекомендации
Полиморфное поведение требует применения указателей или ссылок и виртуальных функций; при передаче объектов по значению (то есть элементов
базового класса) происходит срез производной части.
Если класс имеет виртуальную функцию, он должен быть базовым классом;
всегда следуйте этому принципу.
Не поддавайтесь соблазну создать класс из уже существующего класса,
у которого нет виртуального деструктора (или исправьте этот класс перед
использованием в иерархии).
Если у класса есть какие-либо виртуальные функции, всегда определяйте
виртуальный деструктор, чтобы предотвратить уничтожение только базовой
части, что приведет к утечкам памяти.
Не объявляйте виртуальный деструктор в классе без виртуальных функций.
Это согласуется с принципом «не создавать классы с виртуальными методами, если они не предназначены для наследования».
Смотри также
«Ошибка #58», где мы обсуждаем использование виртуальных функций
в деструкторах.
«Ошибка #74», где мы разбираем применение исключений в деструкторах.
8.9. Ошибка #58: вызов виртуальных функций
в конструкторах и деструкторах
Эта ошибка влиет на корректность. Неправильное использование конструкторов
и деструкторов в виртуальных функциях затрагивает и другие характеристики.
Одно из значительных преимуществ объектно ориентированного программирования — возможность создания иерархии родственных классов. Классы наследуют поведение базового, расширяя его специфической функциональностью.
Эффективность этого подхода заключается в обработке родственных классов
через интерфейс, определяющий конкретное поведение. Производные классы
модифицируют функции интерфейса, обеспечивая результаты для своего конкретного типа.
8.9. Ошибка #58
249
Такое специализированное поведение позволяет точно управлять реализацией
общих функций. Например, базовый класс Shape может иметь производный
класс Circle. В общем случае все фигуры должны иметь метод area, но способ
вычисления площади полностью зависит от типа обрабатываемой фигуры. Эта
идея — определение общего поведения (в базовом классе) и переопределение
его с помощью конкретных действий (в производном классе) — называется
динамическим полиморфизмом (runtime polymorphism). Полиморфизм — один из
трех общепринятых столпов объектно ориентированного программирования (но
я тоже выступаю за абстракцию!1). Знание того, как конкретный компилятор
реализует полиморфизм, бесполезно; гораздо важнее понимать концептуально,
почему вызов виртуальных функций в конструкторе недопустим.
Рассмотрим наследование как нисходящий поток информации: то, что знает
базовый класс (состояние, поля) и что он делает (методы), передается вниз
производному классу. Полиморфное поведение — это восходящий поиск конкретной функции: он начинается с производного класса и поднимается вверх
по иерархии, пока не будет найдено первое определение.
Компилятор знает, когда требуется адрес невиртуального метода экземпляра,
и вставляет его непосредственно в код. Адрес сохраняется в исполняемом коде,
где экземпляр вызывает один из этих методов. Во время выполнения этот адрес
используется для нахождения кода метода. Однако для виртуальных функций
такого подхода недостаточно.
Наличие виртуальной функции заставляет компилятор построить для класса
новую таблицу, которая содержит адрес каждой объявленной в нем функции.
Эти адреса не вставляются непосредственно в код во время компиляции или
компоновки, как в случае с невиртуальными методами. Когда создается экземпляр, указатель на таблицу виртуальных функций используется для обращения
к нужной виртуальной функции. Фактические детали сложнее, но следующая
концептуальная модель дает общее представление. Конструктор определяет,
какая таблица виртуальных функций должна использоваться, и настраивает
указатель на нее.
Порядок построения строго определен. Предположим, что имеется трехуровневая
иерархия: базовый класс — A, промежуточный — B и самый производный — C.
При создании экземпляра C вызывается его конструктор. Первое, что делает
этот конструктор, — вызывает соответствующий конструктор базового класса B.
Конструктор B начинает выполнение с вызова конструктора своего базового
1
В ИТ-сообществе есть разные мнения, сколько на самом деле столпов ООП: многие
считают, что их 4, где 4-й как раз «абстракция». Кто-то и 7 насчитывает. Поэтому тут
автор начинает с общепринятой версии (3 столпа), но не упускает возможности высказать и свое мнение. — Примеч. науч. ред.
250
Глава 8. Соблюдение инварианта класса
класса для инициализации части A. После того как конструктор A завершает
инициализацию части A, конструктор B продолжает выполнение и завершает
инициализацию части B. Только после возврата из конструктора B конструктор C
возобновляет выполнение и инициализирует часть C объекта. На рис. 8.1 показан
этот порядок конструирования.
Для классов с виртуальными функциями полезно думать о конструкторе как об
ответственном за настройку указателя на правильную таблицу класса; технически
это не совсем так, но объясняет, почему последнее слово остается за самым производным классом. Поскольку конструктор A выполняется первым и завершает
работу, таблица будет инициализирована таблицей класса A, определяющей
виртуальную функцию. Если B также определяет эту виртуальную функцию,
указатель на таблицу класса A перезаписывается местоположением таблицы
класса B. То же самое происходит и для класса C. Поэтому, когда экземпляр C
обрабатывается через указатель на базовый класс, таблица, используемая для
поиска нужной виртуальной функции, будет самой последней скорректированной. Таким образом, даже если экземпляр C обрабатывается как общий объект A,
последняя записанная в экземпляр таблица и будет использована при вызове
виртуальной функции. Следовательно, общий указатель или ссылка на A могут
вести себя как экземпляр C при вызове виртуальной функции.
A
A(...) ...
B(...)
B
C
C(...)
...
...
Вызовы
конструкторов
Завершение
конструктора
и возврат
Код: new C(...)
Рис. 8.1. Трехуровневая иерархия, показывающая порядок построения
Следствием такого порядка построения является то, что реальный объект (в данном случае экземпляр C) получит корректно настроенный указатель на свою
таблицу класса лишь на определенном этапе своего создания. В любой момент
8.9. Ошибка #58
251
до этой корректировки вызов виртуальной функции приведет к вызову неверной
и непредсказуемой версии. Когда выполняется конструктор или деструктор,
объект находится либо в процессе построения, либо уничтожения. Поскольку
объект еще не полностью создан, его нельзя считать готовым и соответствие
инварианту класса не гарантируется.
Проблема
Предположим, что определена иерархия наследования, в которой разные
люди описываются разными формами уважительного обращения (почетными
титулами). В этом учебном заведении аспиранты пользуются большим почетом, студенты — некоторым, а обычные люди — минимальным. Поскольку эта
иерархия должна действовать обобщенно, экземпляры GradStudent и Student
при необходимости подставляются вместо Person, как это и положено. Однако
в этом случае подставляемые экземпляры должны вести себя специфически,
определяя корректную форму обращения.
Пример кода в листинге 8.17 демонстрирует случай, когда ожидалось, что будет
вызван виртуальный метод getHonorific фактического типа и использован для
вывода правильного обращения. Однако этого не происходит из-за рассматриваемой ошибки.
В примере используется трехуровневая иерархия. Каждый конструктор инициа
лизирует свои переменные экземпляра, а затем вызывает метод print. В процессе его выполнения вызывается виртуальный метод getHonorific. Вывод
результатов показывает, что каждый вызов print выдает разные сообщения.
Виртуальный метод getHonorific добавляет свой указатель функции в таблицу
во время конструирования каждой части, и именно эта конкретная функция
вызывается методом print.
Листинг 8.17. Вызов виртуальных функций из конструктора
class Person {
private:
std::string name;
public:
Person(const std::string& name) :
name(name) { print(); }
void print() const { std::cout << getHonorific() << name << '\n'; }
const std::string getName() const { return getHonorific() + name; }
virtual std::string getHonorific() const { return ""; }
};
class Student : public Person {
private:
std::string year;
public:
252
};
Глава 8. Соблюдение инварианта класса
Student(const std::string& name, const std::string& year) : Person(name),
year(year) { print(); }
std::string getHonorific() const { return year + " "; }
class GradStudent : public Student {
private:
bool candidate;
public:
GradStudent(const std::string& name, const std::string& year,
bool candidate) : Student(name, year), candidate(candidate)
{ print(); }
Вызов виртуального метода в конструкторе;
версия функции, скорее всего, неверна
};
std::string getHonorific() const {
return candidate ? "candidate " : ""; }
int main() {
GradStudent aimee("Aimee", "second year", true);
return 0;
}
Анализ
Результат выполнения кода показывает, что после создания каждой части
объекта вызывается ее собственная версия getHonorific, а не версия фактического типа, поскольку экземпляр еще не был до конца создан. Поэтому версия
и результат виртуальной функции полностью зависят от того, какая часть объекта вызывает метод. Следующий вывод демонстрирует различные формы
обращения по мере создания объекта — по одной строке на каждый тип класса:
Aimee
second year Aimee
candidate Aimee
Другая проблема заключается в том, что виртуальные методы могут зависеть
от данных состояния рассматриваемой части. Если конструктор этой части
еще не завершил работу, нет гарантии, что данные находятся в корректном состоянии — скорее всего, это не так. Если виртуальная функция будет вызвана
и при этом будет опираться на эти неполные данные, результат может оказаться
неопределенным.
Решение
Решение состоит в том, чтобы вызывать виртуальные функции только после
того, как объект полностью сконструирован. Только тогда таблица виртуальных
функций будет инициализирована надлежащим образом и поведение будет
корректно полиморфным. Следующий код демонстрирует это исправление.
8.9. Ошибка #58
253
Листинг 8.18. Конструкторы без вызовов виртуальных функций
class Person {
private:
std::string name;
public:
Person(const std::string& name) : name(name) {}
void print() const { std::cout << getHonorific() << name << '\n'; }
std::string getName() const { return getHonorific() + name; }
virtual std::string getHonorific() const { return ""; }
};
class Student : public Person {
private:
std::string year;
public:
Student(const std::string& name, const std::string& year) : Person(name),
year(year) {}
std::string getHonorific() const { return year + " "; }
};
class GradStudent : public Student {
private:
bool candidate;
public:
GradStudent(const std::string& name, const std::string& year,
bool candidate) :
Student(name, year), candidate(candidate) {}
std::string getHonorific() const { return candidate ? "candidate " : ""; }
};
int main() {
GradStudent aimee("Aimee", "second year", true);
aimee.print();
return 0;
}
Виртуальная функция
вызывается после того, как
объект сконструирован
Для правильно спроектированных классов деструкторы работают в порядке,
обратном порядку конструкторов. Когда деструктор вызывается по указателю
или ссылке, сначала вызывается деструктор самого производного класса. В конце
своего выполнения он вызывает деструктор своего базового класса. Так цепочка
поднимается вверх по иерархии, обеспечивая уничтожение объектов в порядке,
обратном построению.
Если во время уничтожения вызывается виртуальный метод, состояние в самом
производном классе может оказаться некорректным, в зависимости от поведения
его деструктора. Однако виртуальные методы предполагают, что эти данные будут в допустимом состоянии. Поскольку это невозможно гарантировать, следует
всегда избегать виртуальных методов в деструкторе.
254
Глава 8. Соблюдение инварианта класса
Рекомендации
Убедитесь, что ни одна виртуальная функция класса не вызывается до того,
как экземпляр будет полностью построен; никогда не вызывайте виртуальную
функцию класса из его конструктора.
Помните, что порядок построения влияет на достоверность информации
о состоянии и на то, какая версия виртуальной функции будет вызвана;
дождитесь завершения построения, прежде чем вызывать какие-либо виртуальные функции.
Помните, что порядок уничтожения влияет на достоверность информации
о состоянии, доступной виртуальной функции: часть из нее, скорее всего,
уже уничтожена или недействительна; после начала уничтожения никогда
не вызывайте виртуальные функции.
Смотри также
«Ошибка #57», где мы обсуждаем необходимость написания виртуального
деструктора, если в классе определен виртуальный метод.
8.10. Ошибка #59: попытка использовать
полиморфные элементы массива
Эта ошибка влияет на корректность. Функции, которые принимают массивы
(или контейнеры) с элементами базового класса в качестве параметра, на первый взгляд, допускают полиморфное поведение. В такую функцию может быть
передан массив элементов производного типа — при этом не возникает ошибок
компиляции. Это повышает эффективность, однако скрытая опасность может
подвести неосторожного пользователя.
Контейнеры объектов — это естественный способ собирать связанные объекты
и обрабатывать их по единому образцу в соответствии с публичным интерфейсом базового класса — в этом суть полиморфизма. Однако их использование
может быть проблематичным, когда в функции передаются массивы производ
ных объектов. Массивы — это встроенные контейнеры, которые легко писать
и использовать, что делает их применение простым и интуитивно понятным.
Проблема
Функция, которая принимает указатель на объект базового класса, также может
получать указатель или ссылку на производный класс. Здесь работает принцип
LSP: производный класс может подставляться туда, где ожидается базовый.
Это мощный прием для написания обобщенного кода, который корректно обрабатывает любой переданный ему объект.
8.10. Ошибка #59
255
Несложно догадаться, что если указатель на объект работает, то должен работать и указатель на массив объектов. Однако в C++ это не означает, что массив
производных элементов можно подставить вместо массива элементов базового
класса и ожидать корректной работы.
Код C++ получает доступ к элементам массива с помощью арифметики указателей (pointer arithmetic). Предположим, что имеется массив целых чисел
с именем arr, содержащий несколько элементов; третий элемент индексируется
как arr[2]. Такая запись — «синтаксический сахар» (syntactic sugar)1, поскольку
фактически доступ осуществляется через *(arr+2). К константному указателю
const на первый элемент arr добавляется целое число 2, чтобы пропустить
два элемента и указать на третий. Количество пропускаемых байтов равно
2*sizeof(element_type). Именно эта арифметика указателей и вызывает проблемы при обобщенной передаче массивов.
Рассмотрим код в листинге 8.19, где выводится массив элементов производного
класса. Класс D наследуется от базового класса B и использует метод getN, определенный в B, когда его элементы необходимо вывести. В классе B переменная
экземпляра по умолчанию инициализируется как 0 , а в D переменная m по
умолчанию имеет значение 1. Эти хорошо различимые значения делают анализ
вывода результатов более очевидным.
Листинг 8.19. Передача массива с производными элементами
const int SIZE = 4;
class B {
private:
Здесь намеренно не сделан
int n;
виртуальный метод, чтобы
public:
лучше продемонстрировать всю
B(int n=0) : n(n) {}
проблему. Обычно так не делают!
~B() { std::cout << "destroying B\n"; }
int getN() const { return n; }
Обобщенная функция доступа
};
к элементам массива
class D : public B {
int m;
public:
D() : B(0), m(1) {}
~D() { std::cout << "destroying D\n"; }
};
Использование обобщенной
void printArray(const B a[]) {
функции доступа при выводе
for (int i = 0; i < SIZE; ++i)
значений
std::cout << a[i].getN() << '\n';
}
void deleteArray(const B a[]) {
Использование обобщенной
1
Синтаксический
delete [] a; сахар (syntactic sugar) — это дополнительные конструкции языка,
функции удаления
} которые делают код более удобным для написания и чтения, но при этом не меняют
его функциональности. — Примеч. пер.
int m;
public:
D() : B(0), m(1) {}
~D() { std::cout << "destroying D\n"; }
};
256
Глава 8. Соблюдение
класса
void printArray(const
B a[]) инварианта
{
for (int i = 0; i < SIZE; ++i)
std::cout << a[i].getN() << '\n';
}
void deleteArray(const B a[]) {
delete [] a;
}
Использование обобщенной
функции доступа при выводе
значений
Использование обобщенной
функции удаления
int main() {
B* bs = new B[SIZE];
printArray(bs);
deleteArray(bs);
}
D* ds = new D[SIZE];
printArray(ds);
deleteArray(ds);
return 0;
Когда этот код выполняется, массив элементов типа B работает как ожидается:
вывод значений n дает только 0, а при уничтожении вызываются деструкторы B.
Однако вывод для массива элементов типа D мог бы быть и получше. Функция
getN чередует вывод части n и m элементов D. Поскольку части n и m — целые
числа одинакового размера, вывод результатов показывает, что индексация
значений увеличивает размер элемента B, а не нужного элемента D. Кроме того,
при уничтожении элементов вызывается только деструктор B, это означает, что
блок памяти, возвращаемый операционной системе, может не соответствовать
фактическому размеру массива. Это приведет к утечке памяти. Хотя ожидается,
что на разных системах результаты могут отличаться, в любом случае это — неопределенное поведение. Следующий вывод результатов демонстрирует, что
использование производных элементов не работает как следует. Во-первых,
вызываются только версии деструктора базового класса. Во-вторых, результат
getN различается для переменных экземпляра n и m:
0
0
0
0
destroying
destroying
destroying
destroying
0
1
0
1
destroying
destroying
destroying
destroying
B
B
B
B
B
B
B
B
8.10. Ошибка #59
257
Анализ
Функция printArray будет проходить по элементам массива и вызывать функцию getN. Первый вызов функции передает массив из элементов B. Индексация
в функции переходит от первого элемента к следующему путем добавления
размера элемента B. Этот процесс продолжается до тех пор, пока все элементы
не будут пройдены.
Во втором случае массив содержит элементы типа D. Поскольку D — производный
от B, передача массива проходит без возражений со стороны компилятора — здесь
работает принцип LSP. Функция printArray получает указатель const на массив в качестве начальной точки; первый элемент начинается в этой точке. При
индексации массива и вызове функции getN доступ начинается с этого элемента
и используется часть B, из которой извлекается значение; пока что все хорошо.
Она получает значение 1 и выводит его.
Внимательно посмотрите на вторую часть вывода результатов: это значение
переменной m. В этом случае элемент B занимает пространство одного целого
числа (обычно четыре байта), а элемент D — вдвое больше. Когда цикл обновляет
индекс, он увеличивает указатель на размер элемента B (одного целого числа),
но фактическое значение — это первая переменная m, которая находится ровно
на расстоянии sizeof(B) от указателя (то есть *(ptr+sizeof(B)). Элементы D
в два раза больше элемента B, поэтому индексация ссылается на них попеременно. Следовательно, указатель для индекса 1 указывает на часть объекта D
первого элемента, а не на часть B второго. Если это звучит неправильно, то это
потому, что так оно и есть.
Арифметика указателей работает правильно для массивов элементов B, но не
для производных классов, таких как D. Компилятор не выдает никаких преду
преждений, и во время выполнения не было замечено ошибок сегментации (доступ к недопустимой области памяти). Эта ошибка противоположна выходу за
пределы массива: она заключается в недостаточном доступе к памяти!
ПРИМЕЧАНИЕ Объяснение работы и результатов выполнения кода основано на
неоптимизированной компиляции и выполнении в одной среде. Оптимизация или
изменения компилятора могут вызвать иное поведение. Еще более важно, что это —
неопределенное поведение. Поэтому не следует делать поспешных окончательных
выводов о конкретных результатах.
Теперь рассмотрим удаление массивов. Этот код работает правильно, когда
deleteArray вызывается с массивом элементов B. Неочевидно то, что оператор
delete также будет индексировать массив и вызывать версию delete класса B для
каждого элемента — отсюда в выводе результатов появляется destroying B («уничтожение B»). Такой подход работает правильно, когда массив содержит элементы B.
258
Глава 8. Соблюдение инварианта класса
Когда эта функция вызывается с элементами D, цикл вызовет деструкторы для
части B элемента и будет индексировать в соответствии с его размером. Второй
delete относится к части D первого элемента. О том, что в этом случае произойдет,
остается только догадываться. Очевидно, что уничтожаются только элементы
с размером B. Виртуальный деструктор здесь не решит проблему, потому что ее
корень — ошибка арифметики указателей.
Теперь обсудим ситуацию, когда производные объекты сложнее, чем целое число.
Если элементы D содержали ссылки std::string или указатели на другие типы
данных, неясно, что именно — если вообще что-то — окажется очищено. Тот
факт, что это остается непонятным, вызывает серьезное беспокойство: ни один
язык программирования не должен предоставлять операции, которые могут
работать или не работать в зависимости от факторов, которые вы не можете
контролировать.
В исходной задаче, когда массив элементов D удалялся — или код пытался его
удалить, — фактически уничтожались только первые четыре элемента размером B. Если массив находится в стеке, со временем система сама разберется
с путаницей, но если память основана на куче, то опять же неясно, как именно
деаллокаторы (освобождающие память механизмы) обработают ее и сколько
памяти будет возвращено.
Если выполнить этот код с использованием valgrind — фантастического инструмента для обнаружения различных проблем с памятью, особенно утечек, — результат будет чистым, потому что количество выделений памяти соответствует
количеству освобождений. Детектор ошибок при работе с адресацией (Address
sanitizer) может обнаружить часть проблем; пример можно найти на веб-сайте
Matt Godbolt’s Compiler Explorer (https://compiler-explorer.com/z/6d73hscoE). Эта
ошибка очень тонкая, и даже продвинутые инструменты, похоже, не в состоянии
полностью обнаружить ее. Программист обязан никогда не использовать массивы базового класса как общий прием для экономии кода и нажатий клавиш.
Решение
Правильный подход — отказаться от передачи указателей на массивы базовых
и производных объектов в функции. Сделайте так, чтобы функции обрабатывали
только массивы с фактическим типом элементов. Простое средство решения
этой проблемы — дублирование таких функций. Идеальный способ обеспечить
такое дублирование — это применять техники обобщенного программирования:
добавление шаблона функции. Такой подход одновременно хорошо читаемый
и эффективный. Другой вариант — передавать массивы указателей на базовый
класс. Недостатком этого подхода будет то, что коду, возможно, придется обрабатывать указатели на указатели, что делает вызов и обработку несколько
грязнее, но если вы готовы к этому — действуйте!
8.10. Ошибка #59
259
Современный C++ предоставляет класс std::array, который также решает
эту проблему. Если у вас есть возможность — используйте его, чтобы обойти
описанную ситуацию.
В текущем случае повторное использование кода — плохая идея, и чрезмерное
следование этому принципу приводит к трудностям. Следующий код исправляет
ранее отмеченные проблемы, делая функции печати намеренно неполиморфными, в соответствии с предложенным шаблоном.
Листинг 8.20. Функции без полиморфной передачи массива
const int SIZE = 4;
class B {
private:
int n;
public:
B(int n=0) : n(n) {}
~B() { std::cout << "destroying B\n"; }
int getN() const { return n; }
};
class D : public B {
int m;
public:
D() : B(1), m(2) {}
~D() { std::cout << "destroying D\n"; }
};
template <typename T>
void printArray(T a[]) {
for (int i = 0; i < SIZE; ++i)
std::cout << a[i].getN() << '\n';
}
template <typename T>
void deleteArray(T a[]) {
delete [] a;
}
int main() {
B* bs = new B[SIZE];
printArray(bs);
deleteArray(bs);
D* ds = new D[SIZE];
printArray(ds);
deleteArray(ds);
}
return 0;
Общая обработка различных
типов элементов
Шаблоны функций
обрабатывают необходимое
дублирование кода
260
Глава 8. Соблюдение инварианта класса
Следующий фрагмент кода показывает результаты работы исправленного кода:
0
0
0
0
destroying
destroying
destroying
destroying
1
1
1
1
destroying
destroying
destroying
destroying
destroying
destroying
destroying
destroying
B
B
B
B
D
B
D
B
D
B
D
B
Элементы отображаются и удаляются правильно, и память не теряется. Что
очень важно — вывод правильный как для элементов B, так и для элементов D,
и каждая часть объекта D уничтожается. Уничтожение элементов происходит
с использованием корректного типа, поэтому не остается неочищенной памяти
и не возникает странных несоответствий между выделением и освобождением
памяти в операционной системе.
Рекомендации
Пишите отдельные функции для обработки массивов базовых и производ
ных классов, если их нужно передавать в качестве аргументов в функцию;
подобные функции предотвратят странное поведение. Рассмотрите возможность использовать шаблоны функций, чтобы компилятор генерировал
дубликаты кода.
Отсутствие ошибок во время выполнения не означает, что выбранный
метод программирования корректен или что в нем нет скрытых проблем
с памятью.
Поймите, что даже продвинутые инструменты не смогут обнаружить все
проблемы; valgrind — отличный инструмент, но он не предназначен для выявления этого необычного случая.
Возьмите на себя ответственность за корректное написание кода с полным
пониманием потенциальных проблем при попытке срезать углы.
8.11. Ошибка #60
261
Смотри также
«Ошибка #49», где мы разбираем, почему нежелательно чрезмерное использование конструкторов по умолчанию.
«Ошибка #57», где обсуждаем, почему необходимо делать деструкторы базовых классов виртуальными.
8.11. Ошибка #60: неинициализация
всех переменных экземпляра
Эта ошибка влияет в основном на корректность и эффективность, а также немного на производительность. Пока идиома не освоена, разработчик может считать,
что она негативно сказывается на читаемости. Однако после того, как подход
становится понятным, большинство из них предпочтет именно его. Эта ошибка
настолько очевидна, что, казалось бы, нет необходимости о ней говорить — но,
увы, она может возникнуть даже при самых благих намерениях.
У класса есть два вида переменных-членов: примитивные (встроенные) и экземпляры классов. Многие современные языки принудительно инициализируют
все переменные, независимо от их расположения и использования. C++ не
всегда обеспечивает такую инициализацию. В некоторых случаях переменныечлены объявлены, но не имеют определенного начального значения. Каждый
байт памяти хранит некоторое состояние, поэтому такие переменные содержат
неопределенное значение. Следовательно, чтение этих переменных приводит
к неопределенному поведению; все будет нормально, если сначала сделать
присвоение, а только затем чтение. Цель инициализации — гарантировать, что
состояние объекта было определено программой.
Обычно используются три способа инициализации примитивных типов:
присваивание переменной литерального значения; ввод значения, которое
затем присваивается переменной; и присваивание значения из другой, уже
инициализированной переменной. Эти методы гарантируют, что переменная
одновременно объявлена и инициализирована. Разработчик должен убедиться, что это выполняется для каждой локальной переменной перед ее первым
использованием.
В случае классов за инициализацию отвечает конструктор; его основная задача —
инициализировать переменные, устанавливая инвариант класса. Конструктор
должен получать аргументы для всех параметров и использовать их для инициализации переменных-членов либо предоставлять корректное значение по
умолчанию. Если этого не сделать хотя бы в одном случае, может возникнуть
неопределенное поведение.
262
Глава 8. Соблюдение инварианта класса
Проблема
Многие проблемы так или иначе связаны с человеческим фактором. Предположим, нам нужно смоделировать класс Person, содержащий только name (имя)
и age (возраст). Следующая реализация компилируется и, кажется, работает
нормально, но использует неопределенные данные. Ошибка, которую она делает,
неочевидна, пока возраст человека не проверить вручную.
Листинг 8.21. К
ласс с неинициализированной встроенной
переменной‑членом
Переменная-член, являющаяся
class Person {
экземпляром класса
private:
Переменная-член
Казалось бы,
std::string name;
примитивного типа
int age;
безобидный
public:
конструктор
Person(const std::string& name) { this->name = name; }
void setAge(int age) { this->age = age; }
friend std::ostream& operator<<(std::ostream&, const Person&);
};
std::ostream& operator<<(std::ostream& o, const Person& p) {
o << p.name << " is " << p.age << " years old";
return o;
}
int main() {
Person joey("Joey");
std::cout << joey << '\n';
return 0;
}
Разработчику следовало настоять на том, чтобы возраст был указан в конструкторе (игнорирование переменных — дурной тон). Предполагалось, что клиентский код вызовет setAge, чтобы инициализировать возраст и сделать значение
корректным. Когда же программисту приходится помнить, что это нужно сделать вручную, проблемы, скорее всего, неизбежны. В этом случае компилятор
не может предупредить о проблеме неинициализации, поэтому вам придется
разбираться с ошибкой самостоятельно — что, вероятнее всего, произойдет уже
после того, как код будет некоторое время использоваться.
Отдельные компиляторы инициализируют переменные при запуске в режиме
отладки, но не в режиме релиза. Это, конечно, означает, что «на моей машине
все работает», но клиентов это, мягко говоря, не впечатляет.
Анализ
Две переменные экземпляра в классе относятся к разным категориям. Объект
std::string является экземпляром класса, и компилятор гарантирует, что его
8.11. Ошибка #60
263
конструктор по умолчанию будет вызван, если в конструктор не передано значение. Такая инициализация по умолчанию делает строку пустой — это корректный
объект, но он не содержит никакого значения, полезного для решаемой задачи.
Но подождите, как же имя может быть пустой строкой? Тело конструктора
инициализирует его значением параметра, что вряд ли является пустой строкой.
Совершенно верно, но это опережает реальный порядок выполнения.
Код в теле конструктора — это присваивание, а не инициализация. Термин инициализация употреблялся в вводной части этой ошибки несколько расплывчато,
так как там присваивания фактически использовались как инициализация.
Конструктор обеспечивает инициализацию всех переменных-членов класса
до вызова тела конструктора. Это означает, что конструктор по умолчанию
std::string вызывается перед входом в тело конструктора Person. Затем, уже
в теле конструктора, операция присваивания копирует значение параметра
в переменную-член. Это неэффективно: переменная сначала инициализируется,
затем присваивается. Выполняются две операции, когда необходима только одна.
Хуже того, если параметр не ссылается на аргумент вызова, может быть выполнена дополнительная копия для инициализации самого параметра. Вот это да!
Переменная экземпляра age — это встроенный тип. Что делает с ней конструктор
до входа в тело? Абсолютно ничего. (Спасибо тебе, язык C, твое наследие здесь
«очень полезно».) Переменная age будет содержать случайный набор битов,
который в текущий момент находится в выделенной области памяти. Доступ
к этому значению разрешен, но его содержимое неизвестно — его значение
и использование не определены. Такая ситуация может привести к долгому
и увлекательному сеансу отладки.
Решение
Правильный способ написать конструктор — это обеспечить явную инициализацию каждой переменной-члена. Передайте значение параметра для каждой из
них или укажите осмысленное значение по умолчанию в списке инициализации.
Соблюдение этого подхода гарантирует инициализацию всех переменных-членов
при каждом создании объекта.
Решение проблемы избыточной инициализации переменных экземпляра
классов в том, чтобы использовать значения параметров для инициализации
полей в форме списка инициализации. Использование тела конструктора для
присваивания переменных-членов должно быть очень редким исключением.
Листинг 8.22. Класс, который требует аргументы для каждого поля
class Person {
private:
std::string name;
264
Глава 8. Соблюдение инварианта класса
int age;
public:
Каждая переменная-член должна
Person(const std::string& name, int age) :
быть инициализирована
name(name), age(age) {}
friend std::ostream& operator<<(std::ostream&, const Person&);
};
std::ostream& operator<<(std::ostream& o, const Person& p) {
o << p.name << " is " << p.age << " years old";
return o;
}
int main() {
Person joey("Joey", 27);
std::cout << joey << '\n';
return 0;
}
Каждый параметр конструктора
должен получать аргумент
Если требуется проверка, например, переменных, напишите приватный метод
валидации, который возвращает проверенное значение параметра или выбрасывает исключение. Вызовите метод проверки в списке инициализации. Код
в следующем листинге дополнен приватным методом проверки для переменной
экземпляра age, который вызывается в списке инициализации конструктора.
Листинг 8.23. П
роверка параметра с помощью приватного валидатора
в списке инициализации
class Person {
private:
Приватный метод проверки, который
std::string name;
возвращает допустимые значения
int age;
или выбрасывает исключение
static int validateAge(int age) {
if (age < 0)
throw std::invalid_argument("negative age");
return age;
Метод проверки вызывается
}
в списке инициализации;
public:
никаких дополнительных
Person(const std::string& name, int age) :
присваиваний не требуется
name(name), age(validateAge(age)) {}
friend std::ostream& operator<<(std::ostream&, const Person&);
};
std::ostream& operator<<(std::ostream& o, const Person& p) {
o << p.name << " is " << p.age << " years old";
return o;
}
int main() {
Person joey("Joey", 27);
std::cout << joey << '\n';
return 0;
}
Безопасное создание экземпляра
благодаря валидации
8.11. Ошибка #60
265
Рекомендации
Убедитесь, что каждая переменная экземпляра инициализируется в каждом
конструкторе — либо значением параметра, либо значением по умолчанию.
Помните, что встроенные типы не инициализируются автоматически вне
тела конструктора.
Не полагайтесь на то, что программисты вспомнят, что нужно вручную
проинициализировать переменные-члены; передавайте параметры в конструкторе, чтобы компилятор напоминал им о необходимости предоставить
каждый нужный аргумент.
Вызывайте код проверки параметров, чтобы убедиться, что инвариант класса
установлен правильно; сделайте метод проверки приватным, локализуйте
в нем информацию о переменной экземпляра и сделайте так, чтобы он возвращал допустимые значения или выбрасывал исключение.
9
Операции класса
В этой главе
3 Выбор между операторами и методами
3 Обработка самоприсваивания и оптимизация возвращаемого
значения
3 Взаимодействие с компилятором при работе с членами класса
3 Выбор между явными и неявными преобразованиями
3 Префиксные и постфиксные версии операторов инкремента
и декремента
Эта глава продолжает обсуждение того, как правильно работать с экземплярами
класса. Инвариант класса остается в поле зрения, но эти ошибки требуют иного
подхода, чем в предыдущих главах. Это, конечно, не означает, что они менее
важны — просто акцент делается на более широком круге вопросов, затрагиваю
щих области, которые не обязательно напрямую влияют на состояние объекта.
Некоторые рассматриваемые здесь ошибки часто касаются категории производительности, особенно в тех местах, где речь идет об устранении ненужных
временных объектов. Такие временные объекты возникают, когда частичный
9.1. Ошибка #61
267
результат вычисления выражения требует хранения в промежуточном объекте,
который затем используется в дальнейшем вычислении. Информация о том,
когда создаются эти временные объекты и как спроектировать класс, чтобы избежать их появления, оказывает значительное влияние на количество вызовов
конструкторов и деструкторов.
Другие ошибки связаны с неправильным использованием операторов, что влияет
на типичные сценарии работы или производительность. C++ предоставляет разработчику значительную гибкость, и эту гибкость необходимо уважать, чтобы
избежать неправильного использования возможностей языка.
9.1. Ошибка #61: неправильное понимание
затенения переменных
Эта ошибка в первую очередь влияет на читаемость. Однако при неверном понимании области видимости и смысла переменной могут пострадать корректность и эффективность.
Именование переменных — сложная задача. Разработчики должны правильно
называть объекты, чтобы избежать путаницы и ошибок. По мере того как эволюционирует понимание кода, поиск удачных имен может занять много времени.
Существует множество областей видимости, в которых может быть объявлена
переменная. К этим областям относятся оператор, локальная область, класс,
пространство имен, глобальная область и файл. Понимание того, какая область
видимости актуальна в текущий момент, критически важно для корректного
использования соответствующей переменной.
Переменная объявляется, когда записываются ее тип и имя. Ее область видимости начинается с момента объявления и ограничивается концом файла, блока
или оператора, в котором она объявлена. Как правило, область видимости заканчивается концом файла, закрывающей фигурной скобкой или точкой с запятой (в случае оператора). Переменная затеняется (shadowed), когда более
поздняя внутренняя область видимости определяет другую переменную с тем
же именем (не обязательно с тем же типом). Код во внутренней области не видит одноименную переменную из внешней области. В некоторых случаях для
доступа к затененной переменной, находящейся во внешней области, можно
использовать оператор разрешения области видимости operator::.
В больших программах часто встречаются переменные с одинаковыми именами.
Поскольку эти функции не имеют общей области видимости, затенения не происходит, так как области видимости не пересекаются. Однако, если во внутренней
области объявлена переменная с тем же именем, что и во внешней, программист
обязан использовать правильную переменную при присваивании или вычислении.
268
Глава 9. Операции класса
Проблема
Затенение происходит, когда разные области видимости определяют переменные
с одинаковыми именами. Чем шире область, тем больше вероятность возникновения конфликтов. Глобальные переменные видны всем функциям в единице
компиляции от точки их объявления до конца исходного файла. Использование глобальных переменных, как правило, не рекомендуется, но оно широко
распространено. Корректность и читаемость — первые жертвы чрезмерного
применения глобальных переменных. Если они все же нужны, их необходимо
тщательно именовать. Выбор простых, коротких имен для глобальных переменных допустим только в том случае, если эти короткие имена обозначают хорошо
известные понятия, например PI или E. Для менее очевидных значений следует
использовать схемы именования — например, все заглавные буквы, разделенные
подчеркиваниями (или подобным символом), чтобы четко отличать глобальные
переменные от неглобальных. Для предотвращения простых ошибок затенения
лучше использовать пространства имен.
Зачастую хорошие практики именования соблюдаются недостаточно. Например,
представьте глобальную переменную с именем I — понять ее значение можно
только после прочтения ее определения (и, надеюсь, полезного комментария).
Чем дальше объявление или определение находится от кода, который использует
эту переменную, тем длиннее и выразительнее должно быть ее имя. У большинства компаний или проектов есть некоторые рекомендации; строго следуйте
им — речь идет о читаемости и взаимодействии в команде.
Во многих случаях короткие имена переменных используются в нескольких
областях видимости. Такая ситуация затрудняет понимание того, какая именно
переменная применяется. Тем не менее короткие имена широко распространены
и очень удобны. Чтобы знать, когда они уместны, необходимо понимать как
конкретный контент, так и общий замысел.
Ошибка затенения по содержанию возникает, когда допущена ошибка при
использовании имени переменной; область видимости нужной переменной —
внутренняя, а ожидаемая находится во внешней. Код в листинге 9.1 демонстрирует, что глобальная переменная имеет такое же имя, как и переменная-член.
Для наглядности функция sum некорректно использует область видимости
переменной.
Функция sum ошибочно использует поле класса дважды, полагая, что она обращается и к внутренней, и к внешней переменной. Для устранения неоднозначности имен переменных следует использовать оператор разрешения области
видимости. Это ошибочное затенение по содержанию — синтаксическая ошибка,
вероятно, просто опечатка. Такие моменты могут привести в замешательство, но
их относительно легко исправить, правильно назвав переменную.
9.1. Ошибка #61
269
Листинг 9.1. И
дентичные имена переменных в разных областях
видимости
Глобальная переменная с очень коротким
именем, из-за чего ее смысл не совсем ясен
double rad = 1.0;
class Circle {
private:
Переменная-член с коротким именем
double rad;
public:
Параметр с таким же именем, как у переменной-члена,
Circle(double rad) : rad(rad) {}
для которого необходимо устранить неоднозначность
void setRadius(double rad) {
if (rad < 0)
throw std::invalid_argument("negative radius");
rad = rad;
}
Это две переменные с одинаковым именем; используется
double sum() const {
наиболее локальная переменная, маскирующая глобальную
return rad + rad;
}
double getRadius() { return rad; }
Используется переменная экземпляра;
};
это единственная переменная
в области видимости
int main() {
Circle c(3);
std::cout << "radius is " << c.getRadius() << '\n';
std::cout << "enlarged radius is " << c.sum() << '\n';
return 0;
}
Замысел состоит в том, чтобы передать массив случайных значений
в SearchAnalyzer, который определит, сколько значений появляется в массиве.
Поскольку этот код должен изменить массив для эффективного поиска, он
клонирует его и модифицирует значения путем сортировки. Другим классам,
производным от Analyzer, не нужно было клонировать данные; следовательно,
было сформировано представление о совместном использовании одного и того
же массива. При вызове метода analyze ожидается строка текста, объясняющая,
сколько случайных значений поиска было найдено в данных. Студент был
удивлен, обнаружив, что не было найдено ни одного. Следующим шагом было
отправить профессору электронное письмо с просьбой о помощи!
Листинг 9.2. Разные имена переменных, перепутанные по смыслу
class Analyzer {
protected:
int* cloneValues(int* a, int size){
int* arr = new int[size];
for (int i = 0; i < size;i++)
arr[i] = a[i];
return arr;
}
270
Глава 9. Операции класса
int* array;
Защищенный указатель на массив
int size;
public:
Analyzer(int* values, int size) : array(values), size(size) {}
virtual std::string analyze() = 0;
virtual ~Analyzer() { delete[] array; }
};
class SearchAnalyzer : public Analyzer {
Вызов метода cloneValues
public:
SearchAnalyzer(int* values, int size) : Analyzer(cloneValues(values,
size), size) {
selection_sort(values, size);
Неправильное использование переменной
}
values в вызове сортировки
std::string analyze() {
int count = 0;
for (int i = 0; i < 100; ++i)
if (binary_search(array, rand() % SIZE, size))
++count;
std::stringstream ss;
ss << "There were " << count << " random values found.";
return ss.str();
Поиск в массиве array;
}
он не отсортирован!
};
int main() {
int* numbers = createArray(SIZE);
SearchAnalyzer searcher(numbers, SIZE);
std::cout << searcher.analyze() << '\n';
return 0;
}
Проблема проста, но возникает чрезвычайно легко. Параметр values понимается
как список случайных значений для поиска; однако этот список необходимо
было клонировать и отсортировать, создав новую переменную, представляющую
нужные данные. Легко сосредоточиться на входной переменной, забыв о незаметных клонированных значениях — особенно, когда остальной код не имеет
своих независимых данных. Исправление простое: замените values на array
в вызове selection_sort, и все заработает как надо.
Это не совсем затенение, а распространенная ошибка, возникающая, когда для
одного и того же контента используются похожие имена переменных. Этот
случай даже более опасен, чем фактическое затенение: в листинге 9.2 происходит затенение по намерению, когда переменные с одинаковыми именами не
приводят к возникновению проблемы; поэтому простое устранение затенения
по содержанию здесь не применимо. Вместо этого две переменные с похожим
смыслом ошибочно принимаются за одну — это семантическая ошибка. Код взят
из проекта студентов, который они не смогли завершить.
9.1. Ошибка #61
271
Анализ
Глобальные переменные часто используются, но редко оформляются так, чтобы
их можно было отличить от классов или локальных переменных. Глобальная переменная в листинге 9.1 имеет такое же имя, как и переменная класса, и параметр
в коде. В этом случае легко увидеть, что параметр rad метода setRadius должен
инициализировать одноименную переменную-член с таким же именем. Тем не
менее более обширный или запутанный код значительно затруднит понимание
их очевидного смысла и способа использования. Разработчики склонны давать
глобальным переменным короткие имена внутри класса или функции, поскольку
на тот момент они кажутся «очевидными». При достаточно четком разграничении
смысла и назначения переменной ее использование становится однозначным.
Рассмотрим метод setRadius: код должен ссылаться на глобальную переменную
rad, но что это значит и где она находится? Если в классе и методе нет такого
имени, будет выполнен поиск, чтобы найти переменную во внешней области
видимости; в таком случае используется глобальная переменная. Такой подход
требует много времени и усилий для понимания.
Когда параметр мутатора имеет такое же имя, как и переменная экземпляра,
ключевое слово this должно отличать переменную-член от параметра. Без этого
различия параметр будет присвоен самому себе, как в листинге 9.1. Некоторые
компиляторы не выдают предупреждений о такой ситуации, а просто компилируют и генерируют код, который не делает того, что нужно. Я учу студентов
использовать этот паттерн как проверенный временем прием. Параметр с таким
же именем, что и поле класса, которое он инициализирует, служит отличной
документацией; использование указателя this необходимо для устранения
неоднозначности. Если этот подход принят, следует сделать его повседневной
привычкой — иначе периодические пропуски this неизбежны и приведут к неприятным сеансам отладки.
Решение
Для того чтобы код воспринимался легко, читатель должен понимать значение
каждой части списка инициализации. Переменная вне скобок — переменнаячлен (часть this->radius в листинге 9.3), а переменная внутри скобок — это
параметр (double radius в этом примере). Эффективность немного повышается,
поскольку разработчики могут дублировать имена, не храня ни одной буквы или
аббревиатуры в своей оперативной памяти.
Листинг 9.3. И
дентичные имена переменных, различаемые с помощью
пространства имен
namespace global {
Пространство имен используется для того,
double radius = 1.0;
чтобы избежать затенения
};
class Circle {
private:
double radius;
public:
Circle(double radius) : radius(radius) {}
void setRadius(double radius) {
if (radius < 0)
272
9. Операции
класса
namespaceГлава
global
{
Пространство имен используется для того,
double radius = 1.0;
чтобы избежать затенения
};
class Circle {
private:
double radius;
public:
Circle(double radius) : radius(radius) {}
void setRadius(double radius) {
if (radius < 0)
throw std::invalid_argument("negative radius");
this->radius = radius;
Не забудьте сделать привычкой использование
}
этого паттерна или не используйте его вообще
double sum() const {
return radius + global::radius;
Однозначное использование имени переменной
}
double getRadius() const { return radius; }
};
int main() {
Circle c(3);
std::cout << "radius is " << c.getRadius() << '\n';
std::cout << "enlarged radius is " << c.sum() << '\n';
return 0;
}
Затененные имена — это полезный способ документирования параметра, используемого для инициализации или присваивания переменной-члена: имена
параметров конструктора или мутатора и имена переменных экземпляра идентичны. Некоторые разработчики рекомендуют писать параметр в виде одного
символа (первого символа переменной экземпляра) или в виде сокращенной
версии имени, которая ясно передает смысл. Вот совет получше — добавьте
к переменной-члену префикс или постфикс (например, подчеркивание), чтобы
показать, что переменная является приватной (что может быть только для переменных экземпляра).
Глобальные переменные должны иметь такие имена, которые ясно показывают,
что они являются глобальными. Такой подход предотвращает непреднамеренное
использование или путаницу, когда ожидается появление другой переменной.
Есть два полезных приема, которые помогут все сделать понятнее. Во-первых,
можно стандартизировать имена глобальных переменных. Например, добавьте
к каждой переменной префикс GLOB_ или другой подобный маркер. Альтернативный вариант — это добавить суффикс _g или _global к имени переменной.
Помните, что чем дальше переменная находится от места ее использования, тем
длиннее и описательнее должно быть ее имя, чтобы передать ее назначение; эти
два варианта хорошо согласуются с таким принципом. Во-вторых, можно использовать пространство имен для хранения всех глобальных переменных. Такой
подход значительно упрощает понимание того, когда используется глобальная
переменная. Пространство имен гарантирует, что глобальные переменные не будут разбросаны по исходному коду и будут иметь очевидный стиль именования.
9.2. Ошибка #62
273
Кроме того, включение глобальных переменных в перечисление (enum) или класс
представляет некую альтернативу с теми же преимуществами. Этот фрагмент
кода демонстрирует использование пространства имен при вычислении непрерывных сложных процентов:
namespace Constants {
const double e = 2.7182828283;
}
…
amount = principal * std::pow(Constants::e, rate*time);
Рекомендации
Чем дальше имя переменной от места ее использования, тем длиннее и более
описательным оно должно быть и тем более явно должен быть задан способ
ее использования (например, пространство имен).
По возможности избегайте глобальных переменных, чтобы предотвратить
проблемы как с дизайном, так и с затенением.
Помните, что дублирование имен переменных может привести к проблемам
с пониманием смысла кода и с его правильным использованием.
Будьте внимательны к затенению по содержанию (простой случай) и затенению по намерению (более сложный случай); первое — синтаксическая ошибка, а второе — семантическая, из-за чего заметить ее становится еще труднее,
особенно когда в похожем коде используются другие имена переменных.
9.2. Ошибка #62: допущение дублирования
уникальных объектов
Эта ошибка в основном влияет на корректность. Она также немного снижает
читаемость, если пользователь заранее не поймет суть задачи и ее решение.
Многие типы данных представляют концепции, которые допускают дублирование объектов. Например, std::string может содержать название продукта:
марку и модель класса Automobile . При моделировании парковки можно
ожидать, что будет несколько автомобилей с одинаковой маркой и моделью;
наличие нескольких экземпляров одного и того же транспортного средства —
это нормально.
Однако некоторые типы данных представляют собой уникальные концепции.
Идея их копирования нарушает инвариант класса. В классическом C++ нет
специфических языковых средств для предотвращения дублирования уникальных объектов, поэтому программист должен самостоятельно следить за
сохранением этого свойства.
274
Глава 9. Операции класса
Проблема
Давайте рассмотрим моделирование произведений искусства в системе — например, той, которая создает каталог для потенциальных покупателей. Каждое
произведение искусства уникально и должно обрабатываться именно в таком
ключе. В коде из листинга 9.4 не определено ни одного способа дублирования
объекта, что, на первый взгляд, сохраняет инвариантное свойство уникальности. Изначально класс имел публичный оператор присваивания копированием, который необходимо было исправить. Разработчик (ошибочно) полагал,
что перемещение его в приватную секцию исключит возможность создания
дубликата.
Листинг 9.4. Н
екорректная попытка запретить копирование уникальных
объектов
class ArtPiece {
private:
Существующий оператор
int id;
присваивания копированием
std::string description;
перенесен в приватную секцию
ArtPiece& operator=(const ArtPiece& o) {
this->id = o.id;
this->description = o.description;
return *this;
Ни публичный конструктор копирования,
}
ни оператор присваивания копированием
не написаны
public:
ArtPiece(int id, std::string d) : id(id), description(d) {}
std::string getDesc() { return description; }
};
int main() {
ArtPiece ml(333, "Mona Lisa");
ArtPiece ts(444, "The Scream");
std::cout << ml.getDesc() << '\n';
}
ArtPiece dup(ml);
std::cout << dup.getDesc() << '\n';
// dup = ts;
return 0;
Кажется, что это не должно работать
Приватный оператор присваивания
копированием не позволяет этому
коду работать
Разработчик думал, что с перемещением оператора присваивания копированием возможность дублирования объектов будет устранена: в конце концов,
оператор не может быть вызван — но он забыл о конструкторе копирования
по умолчанию! Программист оценил последствия дублирования уникального
объекта и выбрал сокрытие членов в качестве средства для его предотвращения.
В результате получилось:
9.2. Ошибка #62
275
Mona Lisa
Mona Lisa
И мир искусства в руинах!
Анализ
Компилятор генерирует конструктор копирования по умолчанию и оператор
присваивания копированием, если программист не указывает ни того ни другого. Поскольку конструктор копирования не определен, версия default создает
объект dup и порождает копию Моны Лизы. Вместо того чтобы завершиться
сбоем, произошло копирование, и нет никаких признаков подделки (нет явного
нарушения свойства уникальности инварианта класса). Это существенная проблема такого (небрежного) подхода.
Код для создания dup компилируется и выполняется без проблем, поскольку
компилятор предоставляет версию конструктора копирования по умолчанию,
обеспечивая выполнение поверхностного копирования для каждого поля. Однако в этом случае поведение по умолчанию приводит к созданию поддельной
копии без возможности отличить копию от оригинала.
Примитивный подход программиста не учитывал, что, если класс не имеет
определенного программистом конструктора копирования, компилятор с радостью предоставит версию по умолчанию бесплатно (в данном случае бесплатно
в смысле свободного падения). Класс должен быть спроектирован так, чтобы
предотвратить компиляцию обеих операций. Любой другой вид нежелательной
функциональности можно запретить, просто не написав код для функции; но
эти две к таким не относятся. Программист должен явно предотвратить их использование.
Один из подходов, применяемый достаточно успешно, заключается в явном определении конструктора копирования и оператора присваивания копированием,
но делая их приватными. В большинстве случаев это решает проблему. Однако
если код в классе случайно делает что-то непредвиденное — копирование или
присваивание, — компилятор с радостью выполнит это, и произойдет нарушение
инварианта (возникнет подделка).
Решение
Упомянутый подход содержит часть правильной идеи, но в нем отсутствует еще
один важный момент. Недостающий аспект в том, что приватное определение
каждого оператора, содержащее тело функции, все равно создает исполняемый
код. Поэтому тело метода нельзя указать. Решение простое: объявить, но не
определять две операции. Код в следующем листинге решает обе проблемы
одним махом.
276
Глава 9. Операции класса
Листинг 9.5. П
редотвращение дублирования путем объявления
операций по умолчанию
class ArtPiece {
private:
int id;
std::string description;
ArtPiece(const ArtPiece&);
Приватные и нереализованные
ArtPiece& operator=(const ArtPiece&);
public:
ArtPiece(int id, std::string d) : id(id), description(d) {}
std::string getDesc() { return description; }
void badMethod() { ArtPiece a = *this; }
Присваивание внутри класса
};
компилируется без сообщения
об ошибке
int main() {
ArtPiece ml(333, "Mona Lisa");
ArtPiece ts(444, "The Scream");
Не скомпилируется, так как операции
// ArtPiece dup(ml);
являются приватными
// ts = ml;
std::cout << ml.getDesc() << '\n';
ts.badMethod();
Здесь не будет ошибки компиляции
return 0;
}
Две дублирующие функции объявлены как приватные и не определены. Любая
попытка кода клиента вызвать одну из этих операций завершится ошибкой,
как и ожидалось, — инвариант сохраняется, поскольку ни ориентированный на
клиента оператор присваивания копированием, ни конструктор копирования
не будут компилироваться. Компилятор видит, что обе операции являются
приватными, и не позволяет клиенту их использовать. Но не все так хорошо,
как кажется.
Обратите внимание на введение метода badMethod в листинге 9.5. Его тело
выполняет присваивание, которое компилятор не может предотвратить, как
это было бы в клиентоориентированном коде: синтаксис допустим, но его
семантика вызывает сомнения. У компилятора нет проблем с кодом, и он
успешно компилируется — ошибок нет, никаких сообщений не выводится.
Это утверждение может показаться неправильным, но это так. Проблема проявляется в компоновщике (линкере). Компоновщик пытается найти определение оператора присваивания копированием во внешнем коде, но не может.
Поэтому он выдает ошибку неопределенной ссылки. Она может быть менее
очевидной в проектах, где компиляция выполняется отдельно от компоновки.
Поведение может отличаться от ожидаемого, поэтому лучше вовсе не допускать
подобных ситуаций.
Современные компиляторы C++ могут обнаружить такую проблему, если
сделать конструктор копирования и оператор присваивания копированием
9.2. Ошибка #62
277
публичными и пометить их как =delete. Пример этого показан в следующем
листинге. Обратите внимание на закомментированный код; эти ошибки теперь
явно недопустимы.
Листинг 9.6. Предотвращение дублирования с помощью =delete
class ArtPiece {
private:
int id;
std::string description;
public:
ArtPiece(int id, std::string d) : id(id), description(d) {}
ArtPiece(const ArtPiece&) = delete;
ArtPiece& operator=(const ArtPiece&) = delete;
const std::string& getDesc() const { return description; }
//void badMethod() { ArtPiece a = *this; }
};
int main() {
ArtPiece ml(333, "Mona Lisa");
ArtPiece ts(444, "The Scream");
// ArtPiece dup(ml);
// ts = ml;
std::cout << ml.getDesc() << '\n';
// ts.badMethod();
return 0;
}
Современный C++ предотвращает
эти ошибки. Молодец!
Рекомендации
Помните, что неопределенные конструкторы копирования и операторы присваивания копированием задаются по умолчанию компилятором, что может
привести к неожиданному или нежелательному поведению.
Убедитесь, что вы знаете, какие объекты являются уникальными и не должны
копироваться; передача значений допустима, если исходный объект остается
пустым.
Используйте =delete в современном C++ для члена класса, чтобы удалить
его специально, так как это вызывает ошибку компиляции, а не ошибку
компоновки.
Смотри также
«Ошибка #20», где мы обсуждаем, как современный C++ предотвращает
рассматриваемую здесь проблему, обеспечивая очистку исходного объекта —
состояние источника передается целевой сущности.
278
Глава 9. Операции класса
9.3. Ошибка #63: отсутствие оптимизации
возврата значения
Эта ошибка влияет в основном на производительность и, в некоторой степени, на эффективность. Классы, использующие операторы, склонны создавать
временные объекты чаще, чем это необходимо. В ранних компиляторах C++
стоимость выполнения некоторых арифметических операций была выше из-за
быстрого увеличения количества временных объектов, создаваемых в процессе
вычисления выражения. Суть проблемы — в числе вызовов конструкторов, необходимых для вычисления такого выражения.
Были испробованы различные схемы для уменьшения количества временных
значений. Два таких подхода заключались в возвращении указателей и ссылок.
Однако полученный код становился трудночитаемым и порой откровенно опасным. Другие варианты требовали использования громоздкого синтаксиса. Все
эти решения не давали возможности сократить количество временных значений.
Проблема
Рассмотрим ситуацию, когда неким инженерам требуется написать сложный
класс. Листинг 9.7 представляет собой типичную реализацию, которая сможет
удовлетворить их потребности. Код выглядит чистым и эффективным, поэтому
инженеры довольны. Однако со временем они начали жаловаться на слишком
низкую производительность. Этот код, возможно, не имеет никаких проблем,
поэтому разработчики пытаются найти причину в другом месте. (Всегда проверяйте свои предположения и проводите измерения производительности!)
Следующий листинг показывает код, который использовали для создания
и сложения комплексных чисел.
Листинг 9.7. Типичный подход к сложению комплексных чисел
class Complex {
private:
double real;
double imag;
public:
Complex(double r, double i) : real(r), imag(i) {std::cout<<"x\n";}
Complex(const Complex& o) : real(o.real), imag(o.imag)
{ std::cout<<"y\n"; }
friend const Complex operator+(const Complex&, const Complex&);
};
const Complex operator+(const Complex& lhs, const Complex& rhs) {
Complex sum(lhs.real+rhs.real, lhs.imag+rhs.imag);
Третий вызов
return sum;
Четвертый вызов конструктора —
конструктора
}
конструктор копирования
int main() {
Complex c1(-2, 3.3);
Complex c2(3, -1);
Complex c3 = c1 + c2;
}
Первый вызов конструктора
Второй вызов конструктора
Пятый вызов конструктора —
конструктор копирования
{ std::cout<<"y\n"; }
friend const Complex operator+(const Complex&, const Complex&);
};
const Complex operator+(const Complex& lhs, const Complex& rhs) {
Complex sum(lhs.real+rhs.real, lhs.imag+rhs.imag);
Третий вызов
return sum;
9.3. Ошибка
#63
Четвертый вызов конструктора —
конструктора
}
конструктор копирования
int main() {
Complex c1(-2, 3.3);
Complex c2(3, -1);
Complex c3 = c1 + c2;
}
279
Первый вызов конструктора
Второй вызов конструктора
Пятый вызов конструктора —
конструктор копирования
В этом коде пять вызовов конструктора при отсутствии оптимизации возврата
значения (return value optimization, RVO). Первые два — создание объектов c1
и c2. Третий — создание объекта sum в функции operator+. При возврате sum
создается временный объект и инициализируется его значениями с помощью
конструктора копирования — это четвертый вызов. Наконец, объект c3 создается
из временного объекта, что приводит к пятому вызову конструктора.
ПРИМЕЧАНИЕ Из-за распространенной проблемы отсутствия RVO современные
компиляторы автоматически реализуют эту оптимизацию. Поэтому найти компилятор, который не выполняет RVO, сложно, следовательно, эта ошибка будет появляться
только в старых компиляторах. Однако рекомендации по написанию кода для RVO
по-прежнему актуальны, поскольку код получается чище. Компилятор GNU g++
предоставляет флаг -fno-elide-constructors для отключения RVO в экспериментальных целях (только для этого!).
Анализ
Было предпринято множество попыток сократить число вызовов конструктора
и тем самым уменьшить стоимость временных объектов. Попытка создавать объект и возвращать его указатель делала синтаксис вызова чудовищно неудобным.
Такой подход с указателем можно реализовать следующим образом:
Листинг 9.8. М
инимизация вызовов конструктора путем возвращения
указателя
class Complex {
private:
double real;
double imag;
public:
Complex(double r, double i) : real(r), imag(i) { std::cout << "x\n"; }
Complex(const Complex& o) : real(o.real), imag(o.imag) {
std::cout<<"y\n"; }
friend const Complex* operator+(const Complex&, const Complex&);
};
const Complex* operator+(const Complex& lhs, const Complex& rhs) {
Complex* cpx = new Complex(lhs.real+rhs.real, lhs.imag+rhs.imag);
return cpx;
Исключает один вызов
}
конструктора
int main() {
Исключает
вызов
конструктора,
делая
его
Complex c1(-2, 3.3);
использование неловким и неестественным,
Complex c2(3, -1);
но это работает — однако кто удаляет объект?
Complex c3 = *(c1 + c2);
}
280
Глава 9. Операции класса
Этот код исключает один вызов конструктора, но заставляет клиентский код
выглядеть неестественно и неинтуитивно. Хуже того, он выделяет динамическую
память в куче, что всегда дороже, чем автоматическая (стековая) память. Код при
таком подходе трудно читать, а время программиста используется неэффективно.
Другой заманчивый вариант — создавать объекты внутри тела оператора и возвращать ссылку на него. Обычно это работает (если игнорировать чрезвычайно
полезное предупреждающее сообщение). Но стоит остерегаться ловушек, показанных в следующем листинге, — дьявол кроется в деталях!
Листинг 9.9. М
инимизация вызовов конструктора путем возвращения
ссылки
class Complex {
private:
double real;
double imag;
public:
Complex(double r, double i) : real(r), imag(i) { std::cout << "x\n"; }
Complex(const Complex& o) : real(o.real), imag(o.imag) {
std::cout<<"y\n"; }
friend const Complex& operator+(const Complex&, const Complex&);
};
const Complex& operator+(const Complex& lhs, const Complex& rhs) {
Complex cpx(lhs.real+rhs.real, lhs.imag+rhs.imag);
return cpx;
Исключает один вызов
}
конструктора
int main() {
Complex c1(-2, 3.3);
Complex c2(3, -1);
Complex c3 = c1 + c2;
}
Здесь используется простой и естественный
синтаксис, но это неправильно!
Объект cpx, созданный в функции оператора, существует только до ее конца,
после чего уничтожается. Объект создается в кадре стека, и этот стековый кадр
становится недействительным. Возвращение ссылки на теперь уже уничтоженный объект означает, что это ссылка на недействительный объект. Использование такой ссылки приводит к неопределенному поведению, которое будет
различаться в зависимости от множества факторов. Опасный сюрприз в том,
что кадр стека в большинстве случаев не перезаписывается сразу и программа
работает. Но однажды она внезапно и необъяснимо упадет, причем именно тогда,
когда будет так необходима. Карау-у-л!
Решение
Вместо того чтобы пытаться создать объект и вернуть либо указатель (неуклюже!), либо ссылку (неправильно!), напишите конструктор так, чтобы компилятор
9.3. Ошибка #63
281
мог применить RVO для устранения ненужных временных объектов. Многие
учебники демонстрируют паттерн создание объекта и его возврат вместо более
элегантного и эффективного варианта возврат создания объекта.
Листинг 9.10 демонстрирует более удачный подход: оператор создает новый
объект непосредственно в return. RVO устраняет два вызова конструктора
копирования — при возврате значения и при присваивании в клиентском коде.
Была проделана большая работа по повышению эффективности этой процедуры
в компиляторах, но, как всегда, не все могут воспользоваться преимуществами
новейших технологий.
Листинг 9.10. Код с учетом преимуществ RVO
class Complex {
private:
Код написан так, чтобы использовать
double real;
преимущества RVO — исключает один
double imag;
вызов конструктора копирования
public:
Complex(double r, double i) : real(r), imag(i) {std::cout<<"x\n";}
Complex(const Complex& o) : real(o.real), imag(o.imag)
{ std::cout<<"y\n"; }
friend const Complex operator+(const Complex&, const Complex&);
};
const Complex operator+(const Complex& lhs, const Complex& rhs) {
return Complex(lhs.real+rhs.real, lhs.imag+rhs.imag);
}
int main() {
Complex c1(-2, 3.3);
Complex c2(3, -1);
Complex c3 = c1 + c2;
}
RVO устраняет второй вызов
конструктора копирования
Объект Complex создается за счет одного вызова конструктора — и это неизбежно
при любом подходе. Цель состоит не в том, чтобы предотвратить использование
конструктора, а в том, чтобы устранить временные объекты. С помощью RVO
сумма c1 и c2 может быть создана сразу в памяти, отведенной под c3, что устраняет все вызовы конструктора копирования. Объект должен быть создан в теле
оператора; сосредоточьтесь на исключении стоимости копирований, написав код
таким образом, чтобы сработал механизм RVO компилятора.
Рекомендации
Многие современные компиляторы реализуют RVO; внимательно изучите
документацию, чтобы узнать, поддерживает ли ваш компилятор эту функцию.
Возвращайте результат вызова конструктора, а не объект, созданный в теле
оператора, — так ваш код станет идеальной целью для RVO.
282
Глава 9. Операции класса
Операторы могут возвращать указатели, но синтаксис вызова в этом случае
будет запутанным и кто-то должен отвечать за удаление объектов.
Никогда не возвращайте ссылки на локальные объекты из операторов —
к моменту обращения вызывающего кода объект уже будет уничтожен.
Смотри также
«Ошибка #84», где мы более подробно обсуждаем проблемы, связанные
с обращением к локальным объектам за пределами области их видимости.
9.4. Ошибка #64: отсутствие возвращения ссылки
из оператора присваивания копированием
Эта ошибка касается производительности. При разработке класса могут создаваться лишние временные объекты. Понимание того, почему они создаются,
позволяет разработчику избегать появления ненужных объектов. Неправильное
написание оператора присваивания копированием — одна из областей, где легко
возникает избыток таких временных объектов.
Проблема
Предположим, что разработчик пишет пакет для инженерных расчетов. Один
из типов данных, необходимых клиенту, — это комплексное число. C++ предоставляет шаблон complex в заголовочном файле complex, но для иллюстрации
этой проблемы разработчики напишут свою версию класса.
Этот код должен поддерживать цепочки присваиваний, соблюдая приоритет
и ассоциативность оператора присваивания копированием. Он компилируется
и работает, но создает несколько проблем с временными объектами.
Листинг 9.11. К
ласс, возвращающий временный объект из оператора
присваивания копированием
class Complex {
private:
double real;
double imag;
public:
Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) {}
Complex operator=(const Complex& o) {
Complex cpx(o.real, o.imag);
Возможно, это написано в расчете
real = cpx.real;
на оптимизацию возвращаемого
imag = cpx.imag;
значения?
return cpx;
}
};
int main() {
Complex c1, c2, c3(1, 1);
c1 = c2 = c3;
return 0;
}
Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) {}
Complex operator=(const Complex& o) {
Complex cpx(o.real, o.imag);
Возможно, это написано в расчете
real = cpx.real;
на оптимизацию возвращаемого
imag = cpx.imag;
значения?
return cpx;
9.4. Ошибка #64
}
};
283
int main() {
Complex c1, c2, c3(1, 1);
c1 = c2 = c3;
return 0;
}
Анализ
Оператор присваивания копированием должен изменять состояние экземпляра,
что и делает этот код; однако разработчик написал его таким образом, что создается временный объект. Целевой объект присваивания должен обновляться
непосредственно из параметра; здесь же присваивание происходит из временной
копии. Создается и возвращается новый объект, инициализированный значениями исходного (возможно, в результате чрезмерного применения RVO). Его
преимущество в предотвращении создания еще одного временного объекта из
возвращаемого значения. В этом случае подход оказывается затратным: конструктор вызывается для создания временного объекта, который затем немедленно
выбрасывается после использования.
В листинге 9.11 конструктор Complex вызывается пять раз. Кроме того, инициализация целевого объекта происходит не за счет объекта, стоящего справа,
а путем передачи данных из его избыточной копии.
Решение
Следующий код демонстрирует более правильный подход. Вместо возвращения
копии объекта следует получать ссылку на модифицированный объект, чтобы
в операции присваивания участвовал именно он, а не временная копия.
Листинг 9.12. В
озвращение ссылки из оператора присваивания
копированием
class Complex {
private:
double real;
Написано для изменения
double imag;
целевого объекта
public:
Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) {}
Complex& operator=(const Complex& cpx) {
real = cpx.real;
imag = cpx.imag;
return *this;
Возвращает ссылку на измененный
}
целевой объект
};
int main() {
Complex c1, c2, c3(1, 1);
c1 = c2 = c3;
return 0;
}
284
Глава 9. Операции класса
Критически важный фактор — оператор присваивания копированием возвращает ссылку на объект, а не его копию. Такой подход исключает вызов
конструктора, используемого для создания временного возвращаемого значения. Возвращается ссылка на недавно обновленный экземпляр, что устраняет
необходимость копирования объекта. Семантика присваивания сохраняется,
а временный объект не создается. Фактический объект используется в цепочке
присваиваний.
Этот метод следует применять ко всем операторам присваивания, а не только
к обычному operator=. В приведенном далее примере показан лишь оператор
operator+=, который следует тому же паттерну. Все остальные составные операторы присваивания тоже должны придерживаться этой схемы. Например,
составной оператор сложения был бы реализован, как показано в следующем
листинге.
Листинг 9.13. Возвращение ссылки из составного оператора
class Complex {
private:
double real;
double imag;
public:
Complex(double r = 0.0, double i = 0.0) :
real(r), imag(i) {std::cout<<"x\n";}
Complex& operator+=(const Complex& cpx) {
real += cpx.real;
imag += cpx.imag;
return *this;
}
};
Повторяет заданный паттерн
int main() {
Complex c1, c2, c3(1, 1);
c2 += c3;
Использует паттерн
c1 += c2;
return 0;
}
Рекомендации
Разберитесь со стандартными паттернами использования операторов и обес
печивайте аналогичное поведение в своих классах.
Помните о приоритете и ассоциативности каждого оператора — реализация
должна им следовать.
Убедитесь, что классы используют тот же подход к операторам, что и встроенные типы данных; не удивляйте клиента экзотическим, недоступным для
интуитивного понимания поведением.
9.5. Ошибка #65
285
9.5. Ошибка #65: пренебрежение обработкой
самоприсваивания
Эта ошибка влияет в первую очередь на корректность и, в некоторой степени,
на производительность. Она особенно актуальна, когда в классе используются
динамические ресурсы. Пренебрежение обработкой самоприсваивания может
привести к серьезным ошибкам в работе с такими ресурсами.
Присваивание одного экземпляра другому происходит часто. Для этого вызывается оператор присваивания копированием. Правильно написанные операторы
должны корректно обрабатывать динамические ресурсы, чтобы предотвратить
нарушение инварианта класса, не сломать уникальность объектов и не допустить
утечки ресурсов. Однако, когда объект присваивается сам себе, разработчики
могут столкнуться с неочевидной проблемой. Может показаться, что это маловероятно, но при работе с указателями фактический объект, на который ссылаются
переменные, гораздо менее очевиден. Если самоприсваивание обрабатывается
некорректно, данные могут быть потеряны, программа может аварийно завершиться или поведение станет неопределенным.
Проблема
Рассмотрим код в листинге 9.14, который при присваивании из другого объекта
Auto создает новый объект Engine. Паттерн обработки динамических ресурсов
правильный, но в коде скрывается серьезная проблема. Предполагается, что удаление существующего Engine необходимо (и это так). Поэтому код удаляет его до
копирования. Поскольку исходный и целевой объект присваивания — один и тот же
элемент, попытка получить VIN (идентификационный номер транспортного средства) представляет собой обращение к удаленным данным. Будем надеяться, что
код аварийно завершит работу без непредсказуемого поведения или повреждения
данных; в противном случае начнется сущий кошмар. Использование инструментов
вроде valgrind и детектора ошибок при работе с памятью (memory sanitizer) — действительно хорошая практика, что отлично иллюстрирует следующий пример.
Листинг 9.14. Присваивание объекта, таящее в себе скрытую опасность
class Engine {
private:
std::string vin;
public:
Engine(std::string vin) : vin(vin) {}
std::string getVin() { return vin; }
};
class Auto {
private:
Engine* engine;
public:
Auto(Engine* engine) : engine(engine) {}
Auto& operator=(const Auto& car) {
Удаляет существующий engine
if (engine)
delete engine;
engine = new Engine(car.engine->getVin());
Обращение к удаленным
return *this;
данным engine
std::string vin;
public:
Engine(std::string vin) : vin(vin) {}
std::string getVin() { return vin; }
};
286
class
Глава
Auto
{ 9. Операции класса
private:
Engine* engine;
public:
Auto(Engine* engine) : engine(engine) {}
Auto& operator=(const Auto& car) {
Удаляет существующий engine
if (engine)
delete engine;
engine = new Engine(car.engine->getVin());
Обращение к удаленным
return *this;
данным engine
}
};
int main() {
Engine* e1 = new Engine("123456789");
Auto mustang(e1);
mustang = mustang;
}
Анализ
В некоторых случаях такая обработка ресурсов может оказаться корректной, но
при использовании оператора присваивания копированием происходит ошибка.
Код присваивания сначала удаляет существующий объект Engine. Затем он создает новый объект Engine на основе VIN, предоставленного исходным объектом.
На первый взгляд, все выглядит нормально, но удаление — серьезная проблема.
Удаление объекта Engine гарантирует, что существующий Engine будет удален
для предотвращения утечек ресурсов. Присваивание нового Engine на основе
VIN из параметра предназначено для создания нового Engine только из этих
данных. Однако эти данные поступают из объекта, который только что был
удален. В большинстве случаев эти два объекта не будут идентичны, поэтому
код работает без проблем. Но в этом примере источник и цель совпадают. Это
ситуация самоприсваивания, и такой код приводит к проблемам. То есть код,
который раньше работал хорошо, теперь вылетает (что еще хорошая новость)
или демонстрирует неопределенное поведение (очень плохая новость).
Мне повезло с системой — попытка обращения к VIN-номеру удаленного двигателя приводит к появлению сообщения об ошибке двойного освобождения или
повреждения данных. Такая ошибка указывает на то, что все серьезно сломано
и мне придется во всем разобраться (позор на мою седую голову!). Возможно,
другие системы не выдадут ошибку. И вот это будет настоящей бедой!
Решение
Проблема заключается в том, что, когда объект и параметр являются одной и той
же сущностью, удаление объекта Engine вызывает проблему. В других случаях —
когда это разные объекты — проблем не возникает. Необходим код, который
проверяет, идентичны ли принимающий объект и объект-параметр. В такой
ситуации удаление выполнять нельзя. И ничего страшного не произойдет.
9.5. Ошибка #65
287
Код в следующем листинге показывает лучший подход, который обрабатывает
самоприсваивание и предотвращает неправильное обращение с динамическими ресурсами. Простая проверка на совпадение исходного и целевого объекта
предотвращает неправильное поведение.
Листинг 9.15. О
бработка самоприсваивания в операторе присваивания
копированием
class Engine {
private:
std::string vin;
public:
Engine(std::string vin) : vin(vin) {}
std::string getVin() { return vin; }
};
class Auto {
private:
Engine* engine;
public:
Auto(Engine* engine) : engine(engine) {}
Auto& operator=(const Auto& car) {
Проверка на самоприсваивание
if (this == &car)
return *this;
Удаление производится, только если исходный
if (engine) {
и целевой объект не совпадают
delete engine;
По возможности используйте nullptr
engine = 0;
}
engine = new Engine(car.engine->getVin());
Безопасный доступ
return *this;
к корректным данным
}
};
int main() {
Engine* e1 = new Engine("123456789");
Auto mustang(e1);
mustang = mustang;
}
Присваивание в предыдущем неудачном примере кажется маловероятным; никто ведь не стал бы настолько безответственно поступать, верно? Однако если
указатели хранятся в контейнере, визуально обнаружить присваивание объекта
самому себе практически невозможно. Такое выражение вполне может вызвать
самоприсваивание, которое будет совершенно неочевидным:
autos[i] = autos[j];
Кроме того, указатели могут вызвать неожиданное самоприсваивание, как показано в этом случае:
*pcar1 = *pcar2;
288
Глава 9. Операции класса
Необходимо проводить проверку на предмет наличия самоприсваивания; если
такой случай обнаружен, следует быстро выйти из оператора присваивания
копированием и не удалять никаких ресурсов.
Рекомендации
Всегда проверяйте на самоприсваивание в каждом операторе копирующего
и составного присваивания — это важно для корректности и производительности.
Убедитесь, что указатели обнулены после удаления динамического ресурса.
Если целевой объект совпадает с исходным объектом, немедленно выйдите
из оператора.
Будьте осторожны с самоприсваиванием через указатели — как напрямую,
так и с использованием элементов контейнера.
9.6. Ошибка #66: непонимание различий между
префиксной и постфиксной формами записи
Эта ошибка в большей степени влияет на производительность и в меньшей — на
корректность. C++ позволяет разработчикам перегружать операторы для поддержания единого подхода к написанию кода. Примером служат префиксные
и постфиксные операторы инкремента и декремента. Подход к написанию
пользовательского оператора декремента похож на его инкрементный аналог, поэтому в текущем обсуждении можно рассмотреть только инкрементную версию.
Оператор инкремента существует в двух формах: префиксной и постфиксной.
Может показаться, что эти формы идентичны по реализации и имеют одинаковую производительность. Однако это интуитивное представление обманчиво.
Отличается не только их реализация, но и тип возвращаемого значения. Эти
различия означают, что эти формы следует использовать по-разному и их производительность также может быть разной.
Проблема
Предположим, что тип данных, представляющий комплексное число, увеличивает (или уменьшает) действительную часть при использовании оператора
инкремента (декремента). Самым простым подходом было бы создать функцию
increment, которая добавляет единицу к действительной части комплексного
числа. Однако это не является идиоматичным C++ и не передает намерение так
же хорошо, как использование оператора инкремента.
Поэтому программист может реализовать оператор operator++, чтобы быть более
идиоматичным. Выбор возвращаемого типа оператора требует внимательности.
9.6. Ошибка 66
289
Для корректной работы префиксная и постфиксная версии должны возвращать
разные типы, что необходимо учитывать.
Определение в листинге 9.16 реализует обе версии оператора одинаково, предполагая, что их реализация идентична. Язык определяет префиксную версию
с пустым списком параметров, а постфиксную — с параметром int. Этот параметр различает только форму и не используется; у него не должно быть имени.
Проблема возникает, когда некий «умный» программист решает выполнить
двойной инкремент переменной. Такой код не только компилируется, хотя не
должен, но и работает не так, как ожидается. В этой ситуации компилятор делает
то, что от него требуется, но не то, что ожидается. Отладка подобной проблемы
может оказаться непростой задачей — что-то здесь явно не так.
Листинг 9.16. И
дентичная реализация префиксной и постфиксной
версии оператора
class Complex {
private:
double real;
double imag;
public:
Complex(double real = 0.0, double imag = 0.0) : real(real), imag(imag) {}
void increment() { real += 1; }
Complex operator++();
Complex operator++(int);
friend std::ostream& operator<<(std::ostream&, const Complex&);
};
Complex Complex::operator++() {
Префиксный оператор, который
real += 1;
возвращает сам объект
return *this;
}
Complex Complex::operator++(int) {
Постфиксный оператор, который
real += 1;
возвращает сам объект
return *this;
}
std::ostream& operator<<(std::ostream& out, const Complex& c) {
out << '(' << c.real << ", " << c.imag << ')';
return out;
}
int main() {
Complex cpx(2.2, -1);
cpx++;
cpx++++;
++cpx;
++++cpx;
std::cout << cpx << '\n';
return 0;
}
Двойной инкремент допустим, но работает
неправильно и семантически сомнителен
290
Глава 9. Операции класса
Анализ
Этот пример демонстрирует несколько проблем. Во-первых, реализованная логика инкремента не соответствует идиоматичному стилю C++. Хотя, возможно,
это и имело бы смысл, но код упускает возможность предоставить согласованный
интерфейс для числовых (и итерационных) типов. Для повышения читаемости
и эффективности следует использовать операторы, они гораздо лучше передают
намерение, чем функции.
Во-вторых, возвращаемое значение или объект для префиксной и постфиксной
форм должны различаться, поскольку их использование и семантика различны.
Префиксная форма должна возвращать значение или объект (о чем мы поговорим
позже) после изменения — это реализовано верно. Постфиксная форма должна
возвращать значение или объект (опять же, обсудим позже) до изменения — код
этого не делает, и сделать это напрямую нельзя.
В-третьих, в постфиксной версии возвращаемое значение не имеет квалификатора const, что позволяет его изменить. «Умный» программист может ошибочно
подумать, что таким образом он может получить двойное инкрементирование
(увеличение). Первое увеличение возвращает копию значения или объекта, а второе — измененную копию копии. Второе инкрементирование не влияет на исходный
объект, который увеличивается только один раз. Таким образом, ожидания и реальность не совпадают. Вместо того чтобы пытаться обучить этого программиста,
компилятор, надеюсь, предотвратит эту ошибку при сборке. Если же компилятор
не обнаружит эту проблему, «разумность» программиста приведет к появлению
неожиданного поведения и, вероятно, к длительному и необычному сеансу отладки.
В-четвертых, операторы реализованы изолированно, а не через другие операторы, что с течением времени почти гарантированно приводит к возникновению
различных трактовок по мере изменения кода. Такая несогласованность может
начать нарастать как снежный ком и привести к поведению, зависящему от
порядка использования операторов: изменение порядка будет давать разные
результаты. Реализация префиксных и постфиксных операторов инкремента
может полностью опираться на operator+=. Информация о том, как модифицировать данные экземпляра, должна быть максимально изолирована. В таком
случае этим трем операторам не нужно будет дублировать описанную логику.
Решение
Правильно реализованный оператор operator++ упрощает решение этих проблем,
поскольку опирается на хорошо устоявшуюся идиому C и C++. Использование
оператора вместо функции эффективно и способствует улучшению читаемости.
Реализация operator++ (и других связанных с ним операторов) должна поддерживать семантику встроенных типов. При правильном подходе детали реализации остаются неизменными с течением времени, что предотвращает проблемы,
9.6. Ошибка 66
291
вызванные дублированием информации в классе. Обе формы реализуются
с помощью operator+=: более специфичные операторы (инкремент) зависят
от более общих. Такое разделение задач позволяет сохранить изолированность
и согласованность информации в операторах.
Необходимо учитывать два аспекта этих операторов: детали их реализации
и характеристики производительности. Порядок записи оператора инкремента
и объекта, с которым он работает, показывает семантическое различие между
ними. Префиксная версия обновляет значение до вычисления выражения — это
так называемый подход «обновить-затем-вычислить»: результат соответствует
обновленному значению. Вызывающий код видит фактическое значение объекта.
Постфиксную версию следует рассматривать как реализацию подхода «вычислитьзатем-обновить»: сначала выполняется вычисление, при котором сохраняется
текущее значение объекта, после чего происходит обновление и возвращается
сохраненное значение. После вычисления отражается не текущее значение объекта,
а значение до изменения. Вызывающий код видит то, что было, а не то, что есть.
Между этими двумя значениями есть временная разница. Поскольку постфиксный
инкремент использует временный объект, который отражает значение объекта до
операции инкрементирования, необходимо возвращать значение, а не ссылку —
мы не хотим возвращать ссылку на теперь уже уничтоженный локальный объект!
Тип возвращаемого значения в обеих версиях должен различаться. Префиксный
оператор возвращает ссылку на измененный объект. «Умный» программист все
еще может попытаться выполнить двойное инкрементирование (прощай, читаемость!) — но теперь он будет применен к реальному объекту, а не к копии. Постфиксный вариант не может возвращать ссылку на себя и обязан вернуть копию
предыдущего состояния. Поэтому он должен возвращать значение, а не ссылку.
Чтобы предотвратить проблему двойного инкрементирования, следует объявить
возвращаемое значение const (константным), что запретит его модификацию —
как показывает следующий листинг, это также значительно улучшит читаемость.
Листинг 9.17. Р
еализация семантически корректных операторов
инкремента
class Complex {
private:
double real;
Префиксная версия возвращает
double imag;
ссылку на измененный объект
public:
Complex(double real = 0.0, double imag = 0.0) : real(real), imag(imag) {}
Complex& operator++();
const Complex operator++(int);
Complex& operator+=(int);
friend std::ostream& operator<<(std::ostream&, const Complex&);
};
Complex& Complex::operator++() {
return *this += 1; // реализовано в терминах оператора +=
}
const Complex Complex::operator++(int) {
Постфиксная версия возвращает
Complex temp(*this);
копию неизмененного объекта
*this += 1; // реализовано в терминах оператора +=
return temp;
}
double imag;
ссылку на измененный объект
public:
Complex(double real = 0.0, double imag = 0.0) : real(real), imag(imag) {}
Complex& operator++();
const Complex operator++(int);
Complex& operator+=(int);
292friend
Глава
9. Операции
класса
std::ostream&
operator<<(std::ostream&,
const Complex&);
};
Complex& Complex::operator++() {
return *this += 1; // реализовано в терминах оператора +=
}
const Complex Complex::operator++(int) {
Постфиксная версия возвращает
Complex temp(*this);
копию неизмененного объекта
*this += 1; // реализовано в терминах оператора +=
return temp;
}
Complex& Complex::operator+=(int n) {
real += n;
return *this;
}
std::ostream& operator<<(std::ostream& out, const Complex& c) {
out << '(' << c.real << ", " << c.imag << ')';
return out;
}
int main() {
Complex cpx(2.2, -1);
cpx++;
//cpx++++;
++cpx;
++++cpx;
std::cout << cpx << '\n';
return 0;
}
Двойной постфиксный инкремент больше
не является синтаксически допустимым, что
полностью соответствует семантике
Постфиксный оператор в теле цикла или в выражении обновления менее эффективен, чем префиксный, — и это невозможно изменить. Каждый раз, когда для
объекта вызывается постфиксный оператор инкремента, он вынужден создавать
копию: выделяется память, вызывается конструктор копирования, затем возвращаемое значение так никогда и не используется (какая расточительность),
а после выхода анонимного объекта из области видимости вызывается деструктор. Гораздо экономнее использовать префиксную версию, которая возвращает
ссылку, — и на этом все.
Рекомендации
Реализуйте постфиксный инкремент (и декремент) через префиксный, а префиксный — через оператор operator+= (operator-=), чтобы локализовать
информацию об изменении значений в одном месте.
По возможности используйте префиксную форму записи, особенно в теле
циклов и выражениях обновления, что минимизирует количество копий
объекта.
Смотри также
«Ошибка #46», где мы обсуждаем проектирование операторов на основе
минимального набора базовых операций, предотвращая дублирование информации в классе.
9.7. Ошибка 67
293
«Ошибка #84», там идет дальнейшее обсуждение этой зловредной ошибки, которая обычно кажется рабочим вариантом, но всегда оказывается только ошибкой.
9.7. Ошибка 67: вводящие в заблуждение операторы
неявного преобразования
Эта ошибка влияет в первую очередь на читаемость и, в меньшей степени, на
корректность. Код должен быть понятен сам по себе; неявные преобразования
скрывают некоторые детали.
C++ предоставляет сложный набор правил для преобразования одного типа
данных в другой, когда функция получает параметры неожиданного типа (типов). Используются две формы неявного преобразования, в зависимости от того,
определены ли функция и конструктор преобразования. В рамках этой ошибки
рассматривается именно функция преобразования.
Проблема
Оператор преобразования определяется с помощью ключевого слова operator, за
которым следуют тип, в который преобразуются данные, и круглые скобки. Например, класс Rational в листинге 9.18 может возвращать значение типа double,
представляющее приблизительное значение дроби. Оператор double() — это
метод класса Rational, который преобразует значение Rational в его приближенное значение типа double.
Листинг 9.18. Класс Rational с функцией преобразования
class Rational {
private:
int num;
int den;
public:
Rational(int num, int den = 1) : num(num), den(den) {}
operator double() { return (double)num/den; }
friend std::ostream& operator<<(std::ostream&, const Rational&);
};
std::ostream& operator<<(std::ostream& out, const Rational& r) {
out << r.num << '/' << r.den;
return out;
}
int main() {
Явное преобразование
Rational r1(3);
очень легко читается
std::cout << r1 << ' ' << (double)r1 << '\n';
if (r1 == 3)
Неявное преобразование вводит в заблуждение:
std::cout << "equal\n";
кажется, что оно сравнивает целые числа
else
std::cout << "not equal\n";
return 0;
}
294
Глава 9. Операции класса
Код в листинге 9.18 работает как надо, потому что определен дружественный
оператор operator<< 1. Вывод результатов работы кода следующий:
3/1 3
equal
Код по-прежнему будет компилироваться и выполняться, даже если дружественный оператор не определен, но результаты окажутся неверными, а его
использование — вводящим в заблуждение. В этом случае вывод уже не будет
выглядеть как рациональное число из-за отсутствия перегруженного оператора
operator<<. Однако произойдет следующее: компилятор, обнаружив, что он не
может вызвать operator<< для объекта Rational, преобразует его в какой-нибудь
тип, который он умеет отправлять в поток вывода. Будет выбран оператор приведения к double для неявной конвертации значения Rational в double — тип,
уже поддерживаемый std::ostream. Следовательно, между двумя выходными
значениями нет разницы — они оба представляют собой результат преобразования объекта Rational в double.
Условное выражение тоже работает, но вводит читателя в заблуждение. Судя
по коду, объект Rational якобы сравнивается с целочисленным значением 3.
Однако это предположение неверно и, следовательно, дезориентирует читателя.
Поскольку в этом классе Rational не определен operator==, объект на самом деле
не сравнивается напрямую с целочисленным значением. Опять же, компилятор
обнаруживает, что не может выполнить то, что явно указано в коде, поэтому
он пробует несколько вариантов неявного преобразования. Он замечает, что
объект Rational можно преобразовать к double, а целое значение тоже можно
привести к double, — и тогда можно выполнить осмысленное сравнение. Оно
имеет смысл только в том случае, если значение типа double достаточно точно
представляет значение объекта Rational, что здесь верно лишь потому, что у него
нет дробной части. Такое сравнение вряд ли будет работать для рациональных
значений, у которых преобразование к double имеет дробную часть. Рассмотрим,
например, следующий код:
Rational r(1, 3);
if (r.toDouble() == 0.333) …
Невозможно точно предсказать, с каким именно значением нужно сравнивать
(тем самым 0,333). Мои студенты часто слышат от меня: «Числа с плавающей
1
Дружественный оператор (friend operator) — это функция-оператор, которая не является
членом класса, но имеет доступ к приватным и защищенным членам этого класса, как
если бы она была его членом. Дружественные операторы объявляются внутри класса
с помощью ключевого слова friend, но их определение находится за пределами класса. — Примеч. пер.
9.7. Ошибка 67
295
точкой — это приближения, которые редко бывают точными». Такой подход
к сравнению значений с плавающей точкой является неправильным. Для сравнений всегда следует использовать метод эпсилон-дельта (ε-δ) — см. раздел
«Смотри также», где указана ссылка на ошибку, связанную с этой техникой.
Решение
Если требуется преобразование, лучше использовать функцию, а не оператор.
Функция не будет задействована, когда компилятор попытается неявно привести
значения из одного типа к другому. В случаях, когда преобразование выполнить
невозможно, компилятор выдаст ошибку, тем самым предупреждая разработчика о том, что необходимых функций нет. Разработчик может осознать, что
написанный код не имеет смысла, и выбрать другой подход.
Следующий код предотвращает обе проблемы: добавляет функцию преобразования и исключает неявное приведение. Кроме того, теперь читателю очевидно,
что происходит явное преобразование.
Листинг 9.19. К
ласс Rational, использующий явную функцию
преобразования
class Rational {
private:
int num;
int den;
public:
Rational(int num, int den = 1) : num(num), den(den) {}
double toDouble() { return (double)num/den; }
friend std::ostream& operator<<(std::ostream&, const Rational&);
};
std::ostream& operator<<(std::ostream& out, const Rational& r) {
out << r.num << '/' << r.den;
return out;
}
int main() {
Rational r1(3);
std::cout << r1 << ' ' << r1.toDouble() << '\n';
if (r1.toDouble() == 3)
std::cout << "equal\n";
else
std::cout << "not equal\n";
return 0;
}
Удаление оператора double и добавление функции toDouble делают неявное
преобразование явным. Теперь сравнение прозрачно для читателя, и несоответствие типов будет бросаться в глаза. Любая неточность, возникающая из-за
296
Глава 9. Операции класса
осуществления операции сравнения значений double вместо рационального
числа, становится яснее для того, кто читает код. Сравнение double с помощью
operator== почти всегда оказывается неверным выбором.
Рекомендации
Сведите к минимуму использование операторов неявного преобразования
типов, если только их применение не является очевидным для читателя —
что бывает редко.
Пишите явные функции преобразования типов, чтобы улучшить читаемость
и предотвратить непредсказуемые преобразования и ошибочные предположения.
Смотри также
«Ошибка #99», где мы обсуждаем, почему присваивание значения с плавающей точкой целому числу — это плохой выбор.
9.8. Ошибка #68: чрезмерное использование
конструкторов неявного преобразования
Эта ошибка влияет на производительность; читаемость и эффективность
также могут слегка пострадать, поэтому предварительно расставьте свои
приоритеты.
Компиляторы C++ значительно упрощают использование смешанных вычислений и вызовов функций. Такая ситуация часто возникает, когда вызов функции
или арифметическая операция выполняются над значениями разных типов.
Если компилятор может найти способ преобразовать число из одного типа
в другой, что обеспечит успешный вызов, он сделает это молча и (как правило)
с пользой. Однако такое преобразование, скорее всего, приведет к снижению
производительности.
Проблема
Неявные преобразования кажутся удобными и приятными, когда они работают гладко и корректно. Код в листинге 9.20 показывает вполне незатейливый
класс Complex, рассчитанный на то, что пользователь может захотеть прибавить
значение с типом double к вещественной части. Совсем неочевидно, что прямой
возможности для осуществления этого простого сложения здесь нет. Определенный оператор operator+ принимает два параметра Complex; очевидно, что
double — это не Complex, и на помощь подспудно приходит, казалось бы, полезный
конструктор преобразования типов!
9.8. Ошибка 68
297
Листинг 9.20. М
олчаливый и дорогой конструктор неявного
преобразования
class Complex {
private:
double real;
double imag;
public:
Complex(double real, double imag=0) : real(real), imag(imag) {}
double getReal() const { return real; }
double getImag() const { return imag; }
};
const Complex operator+(const Complex& lhs, const Complex& rhs) {
return Complex(lhs.getReal()+rhs.getReal(), lhs.getImag()+rhs.getImag());
}
int main() {
Complex c1(2.2);
Complex c2 = c1 + 3.14159;
Complex c3 = 2.71828 + c1;
Complex c4 = 2.71828 + 3.14159;
}
Два вызова
конструктора
Один вызов
конструктора
Как и ожидалось, конструктор Complex вызывается для создания объекта c1.
Он также дважды вызывается для объектов c2 и c3. Первый вызов — это вызов
преобразования, при котором значение double приводится к эквивалентному
представлению Complex. Значение double инициализирует действительную часть,
а мнимая по умолчанию принимает значение ноль. Наконец, объект c4 создается
всего одним вызовом конструктора. В четырех строках кода содержится шесть
вызовов конструктора. Если бы это был более сложный тип данных, то было
бы выполнено и шесть вызовов деструктора. В этом случае, когда речь идет
о простом типе, содержащем только данные, большинство компиляторов (если
не все) оптимизируют вызовы деструктора.
Анализ
Создание объектов c1 и c4 типа Complex очевидно: для каждого вызывается один
конструктор. Это поведение невозможно устранить. Объекты c2 и c3 должны
сначала преобразовать значение double в объект Complex, а затем выполнить
сложение, поскольку оператор operator+ принимает только объекты Complex.
Преобразование требует одного вызова конструктора; результат сложения приводит ко второму вызову конструктора. Присваивание возвращаемого объекта
объявленной переменной не требует дополнительного вызова конструктора,
поскольку RVO копирует значение на прежнее место.
Все в этой операции правильно, но дополнительные вызовы конструктора
(и деструктора) могут стать проблемой, если важна производительность. Даже
если производительность — не критичный фактор, это все равно остается
298
Глава 9. Операции класса
неэффективным подходом, который можно улучшить. Необходим способ смешанного преобразования, не требующий дополнительных вызовов конструктора.
Решение
В случаях, когда производительность не имеет значения, предыдущий подход
работает приемлемо и с минимальным количеством кода. Это слегка повышает
читаемость и эффективность. Однако, если дополнительные вызовы конструктора (и деструктора) не приветствуются, должен быть способ их минимизировать.
Цена этого решения — написание еще нескольких строк кода (что снижает
эффективность). Суть метода заключается в перегрузке оператора operator+
с разными списками параметров, принимающими любую комбинацию Complex
и double. Случай, когда оба параметра имеют тип double, не включен — и не может
быть включен. C++ требует, чтобы в перегруженном операторе по крайней мере
один параметр был пользовательского типа. Если бы компилятор допускал этот
случай, сложение двух значений double было бы переопределено, что привело
бы к несогласованному поведению со встроенными правилами их стандартного
сложения. Тут все продумано!
Решение проблемы производительности простое: написать перегруженный
оператор operator+ для каждой комбинации, которая, как ожидается, будет использоваться. Следующий код показывает более правильный вариант для класса
Complex. Добавление еще двух операторов завершает набор возможных списков
параметров (Complex/Complex, double/Complex и Complex/double).
Листинг 9.21. М
инимизация вызовов конструкторов преобразования
путем перегрузки операторов
class Complex {
private:
double real;
double imag;
public:
Complex(double real, double imag=0) : real(real), imag(imag) {}
double getReal() const { return real; }
double getImag() const { return imag; }
};
const Complex operator+(const Complex& lhs, const Complex& rhs) {
return Complex(lhs.getReal()+rhs.getReal(), lhs.getImag()+rhs.getImag());
}
const Complex operator+(const Complex& lhs, double rhs) {
return Complex(lhs.getReal()+rhs, lhs.getImag());
}
const Complex operator+(double lhs, const Complex& rhs) {
return Complex(lhs+rhs.getReal(), rhs.getImag());
}
Перегруженные определения операторов устраняют необходимость
в конструкторах преобразования при смешанных операциях
9.9. Ошибка #69
299
int main() {
Complex c1(2.2);
Complex c2 = c1 + 3.14159;
Complex c3 = 2.71828 + c1;
Complex c4 = 2.71828 + 3.14159;
}
Наличие всех необходимых перегрузок оператора минимизирует влияние
временных объектов. Количество вызовов конструктора (и деструктора) было
сокращено до минимума — до четырех. Этот код демонстрирует, что при реализации смешанных вычислений, вызовов функций и конструкторов преобразования типов производительность может существенно пострадать. Однако
не стоит увлекаться — перегружайте только необходимые операторы, а не все
подряд. Что-то смущает? Это баланс, и мы просто стараемся сделать все, что
в наших силах.
Рекомендации
Если производительность важна, перегружайте все ожидаемые варианты
смешанных вызовов функций или операторов; если производительность не
имеет значения, такой подход все равно повышает аккуратность и полноту
реализации.
Помните, что операторы должны иметь по крайней мере один определяемый
пользователем тип параметра, чтобы сохранить семантическую согласованность и предотвратить переопределение существующих правил.
9.9. Ошибка #69: слишком много внимания
автономным операторам
Эта ошибка влияет на производительность, но не на читаемость или эффективность. Арифметические операции часто реализуются на основе их алгебраи
ческих форм. Этот подход по умолчанию оправдан во многих случаях, и нет
необходимости беспокоиться о его влиянии на производительность. Однако
в других ситуациях мы можем выиграть, если избежать создания и уничтожения
временных объектов. Арифметические выражения — это одна из областей, где
нетривиальная форма записи может минимизировать количество временных
объектов и увеличить скорость выполнения.
Проблема
Рассмотрим код, в котором создается несколько объектов Complex, а затем они
суммируются для инициализации другого объекта. Этот подход является распространенным и интуитивно понятным способом создания арифметических
выражений с использованием объектов класса. В этом коде выполняются пять
300
Глава 9. Операции класса
вызовов конструктора: три — для первых трех объектов c1, c2 и c3, которые
нельзя сократить, и два — для временных объектов, созданных в выражении
суммирования. Исключение этих временных объектов повысило бы производительность. Многие современные компиляторы сами попытаются их устранить,
но все же стоит разобраться в этой проблеме.
Листинг 9.22. К
ласс с избыточным числом временных объектов
при вычислении выражения
class Complex {
private:
double real;
double imag;
public:
Complex(double real=0, double imag=0) : real(real), imag(imag) {}
double getReal() const { return real; }
double getImag() const { return imag; }
};
const Complex operator+(const Complex& lhs, const Complex& rhs) {
return Complex(lhs.getReal()+rhs.getReal(), lhs.getImag()+rhs.getImag());
}
int main() {
Complex c1(2, 2);
Создание каждого объекта требует
Complex c2(0, -1);
одного вызова конструктора
Complex c3(-2.2, 4.2);
Для вычисления необходимы
Complex c4 = c1 + c2 + c3;
}
два временных объекта
Анализ
Первые три вызова конструктора необходимы: они должны быть для создания
объектов Complex. Мы не можем волшебным образом получить объекты без
вызовов конструктора; однако суммирование создает два временных объекта.
Если рассматривать производительность, то каждый раз, когда создается временный объект, появляется потенциальная возможность избежать его создания.
Помните, что у каждого конструктора нетривиального типа данных неизбежно
будет соответствующий вызов деструктора, что удваивает затраты ресурсов на
использование временных объектов. Большинство компиляторов могут оптимизировать вызовы деструктора для простых объектов.
Первый временный объект содержит сумму c1 и c2, а второй — результат сложения первого временного объекта и c3. Затем вызывается сгенерированный оператор по умолчанию operator=, чтобы инициализировать объект c4 значениями
из второго временного объекта. Вопрос в том, можно ли сократить количество
вызовов конструктора, сохранив при этом ту же функциональность.
9.9. Ошибка #69
301
Решение
Число временных объектов можно уменьшить, используя оператор составного
присваивания напрямую или вместе с обособленным оператором operator+.
Если доступны как обособленная, так и составная версии, первая должна быть
реализована в терминах составного присваивания.
Кроме того, устраняется вероятность дублирования кода, поскольку основная
функция инкапсулируется только в версии с составным присваиванием. Обособленный оператор лишь вызывает ее и не содержит никакой собственной
логики. Следующий код демонстрирует эти улучшения: обособленный оператор
отделяется через версию с составным присваиванием, но продолжает использоваться в вычислении.
Листинг 9.23. К
ласс с минимальным количеством временных объектов
при вычислении выражения
class Complex {
private:
double real;
double imag;
public:
Complex(double real=0, double imag=0) : real(real), imag(imag) {}
Complex& operator+=(const Complex&);
double getReal() const { return real; }
double getImag() const { return imag; }
};
Complex& Complex::operator+=(const Complex& o) {
Логика оператора заключена
real += o.real;
в составном присваивании
imag += o.imag;
return *this;
}
const Complex operator+(const Complex& lhs, const
Complex& rhs) {
Обособленный оператор реализован в терминах
return Complex(lhs) += rhs;
версии с составным присваиванием
}
int main() {
Complex c1(2, 2);
Complex c2(0, -1);
Complex c3(-2.2, 4.2);
Complex c4 = c1 + c2 + c3;
}
Необходимость создания временных объектов устраняется, когда обособленный
оператор реализован через версию с составным присваиванием. Реализация
в листинге 9.23 выполняет три вызова конструктора, важных для построения
c1, c2 и c3. Вычисление операторов суммирования не приводит к созданию
302
Глава 9. Операции класса
каких-либо временных объектов, что повышает производительность без ущерба
для корректности вычисления выражения.
Код в листинге 9.24 показывает альтернативный способ выполнения последовательности вызовов операторов. Обособленная версия сама по себе создала бы по
одному временному объекту на каждый вызов, тогда как эта форма обновляет его
на месте без создания временных значений. Определение класса Complex такое
же, как и в листинге 9.23, за исключением того, что поменялась функция main.
Листинг 9.24. А
льтернативная форма для нескольких вызовов
операторов
int main() {
Complex c1(2, 2);
Complex c2(0, -1);
Осуществляется вызов
Complex c3(-2.2, 4.2);
конструктора копирования
Complex c4(c1);
c4 += c2;
Последовательное суммирование, не
c4 += c3;
требующее создания временных объектов
}
Преимущество этого подхода в том, что составные операторы присваивания
более эффективны, поскольку они обновляют значения на месте. Предыдущая
обособленная версия показывает, что ей приходится возвращать новый объект,
а значит — создавать временный вариант с помощью вызова конструктора.
Рекомендации
Чтобы предотвратить создание временных объектов, рассмотрите возможность реализации составных версий арифметических операторов и вызова
их из обособленных версий.
Многократное применение оператора может выполняться последовательно
без создания временных объектов.
Смотри также
«Ошибка #46», где мы обсуждаем необходимость реализации минимального
набора функций и построения остальных поверх них.
9.10. Ошибка #70: не помечать неизменяемые
методы как константные
Эта ошибка влияет на корректность и немного — на эффективность. При выполнении метода экземпляра класса его тело может обращаться к переменным
экземпляра и, при необходимости, изменять их. Это нормальное и правильное
поведение, но не всем методам требуется что-либо изменять. Константный
9.10. Ошибка #70
303
метод никогда не изменяет поля; он лишь напрямую или косвенно возвращает
состояние объекта. Прямой метод доступа называется аксессором, а косвенный —
вычисляющим аксессором.
Проблема
Предположим, что класс довольно большой, а его методы слишком объемны,
чтобы их было легко читать и понимать — практически в любой кодовой базе
таких полно! Учтите также, что в некоторых методах-аксессорах содержится
дополнительная логика. Такие методы могут легко — и, возможно, ошибочно — изменять переменные состояния. Компилятор не может предотвратить
непреднамеренные изменения состояния. В этом случае объект может нарушить
инвариант класса или просто оказаться в некорректном состоянии. Следующий
пример — сильно упрощенная иллюстрация такой проблемы: весь затрудняющий
понимание код убран, оставлено только самое существенное.
Листинг 9.25. С
ложный метод доступа (аксессор), который
непреднамеренно изменяет состояние
class Person {
private:
std::string name;
int age;
public:
Person(const std::string& name, int age) : name(name), age(age) {}
const std::string& getName() { return name; }
int getAge() {
Возможно, этот метод действительно
++age; // Упс, непреднамеренно
должен изменять состояние
return age;
}
};
int main() {
Person amy("Aimee", 26);
std::cout << amy.getName() << " is " << amy.getAge() << " years old\n";
return 0;
}
Анализ
Хотя код в листинге 9.25 очень прост, а ошибка очевидна, в более сложных
фрагментах изменение состояния может легко затеряться среди множества
строк, в некоторых из них будет трудно разобраться. В этом запутанном коде
легко совершить непреднамеренное изменение состояния. Как уже отмечалось,
компилятор не может выдать предупреждение, поскольку не знает, является ли
такое изменение корректным или ошибочным, — он обязан предполагать, что
изменение сделано сознательно. Необходимо найти способ предотвратить непреднамеренное изменение переменных состояния.
304
Глава 9. Операции класса
Решение
Использование ключевого слова const значительно снижает риск непреднамеренного изменения переменных состояния. Хотя ни одно ключевое слово
не может гарантировать полностью корректный код, const по крайней мере
обеспечивает, что в определенных методах переменные экземпляра не будут
изменяться. Когда метод помечен как const, компилятор гарантирует, что он
не изменяет ни одно поле.
Рекомендуется помечать методы как const везде, где это возможно. Ошибочное обозначение практически безвредно — единственным последствием станет
ошибка компиляции в месте корректного изменения состояния. При проверке
можно определить, что метод действительно должен изменять объект, — и просто убрать ключевое слово. Для всех остальных методов, которые намеренно
изменяют состояние, const не требуется (фактически это было бы ошибкой)
и не будет использоваться.
Компилятор не может определить, нужно ли выполнять изменение, но он может
гарантировать, что изменений не произойдет, если метод помечен как const.
Код в листинге 9.26 изменяет метод getAge и выдает сообщение об ошибке из-за
непреднамеренного изменения кода. После анализа было установлено, что изменение было ошибкой (возможно, опечаткой), и ее устранили. Поскольку метод
не должен изменять состояние, ключевое слово гарантирует, что компилятор не
позволит изменить данные экземпляра.
Листинг 9.26. П
омечаем метод как const, чтобы предотвратить
изменение состояния
class Person {
private:
std::string name;
int age;
public:
Person(const std::string& name, int age) : name(name), age(age) {}
const std::string& getName() const { return name; }
int getAge() const {
Ключевое слово const предотвращает
return age;
изменение любой переменной экземпляра
}
};
int main() {
Person amy("Aimee", 26);
std::cout << amy.getName() << " is " << amy.getAge() << " years old\n";
return 0;
}
9.11. Ошибка #71
305
Рекомендации
Помечайте все методы, которые не изменяют состояние объекта, как const,
чтобы исключить непреднамеренные модификации; больших усилий это не
потребует, но безопасность, которую вы этим гарантируете, того стоит.
9.11. Ошибка #71: неправильная пометка методов
класса как static
Эта ошибка влияет на читаемость и эффективность. Классы представляют собой
состояние (переменных экземпляра) и поведение (методов). Существуют два
варианта объявления методов: привязывая к экземпляру или к классу. В первом
случае функция имеет доступ ко всем переменным экземпляра и класса (объявленным с ключевым словом static). Во втором — только к переменным класса,
то есть доступа к значениям конкретных объектов нет. Возникает вопрос: зачем
отказываться от доступа к данным экземпляра и в чем преимущество такого
подхода?
Я поощряю своих студентов задавать вопросы, а не слепо следовать моим наставлениям, когда они обнаруживают, что некоторые вещи, которым их учат,
либо не согласуются друг с другом, либо не имеют конкретного смысла. Эта
ошибка — один из тех случаев, когда можно поднять руку и попросить разъяснений.
Одно из преимуществ метода класса — для выполнения кода не требуется создавать объект. Это позволяет определять вспомогательные методы, связанные
с классом, но работающие обособленно. Рассмотрим, например, класс Math
в языке Java. Было бы очень неудобно создавать экземпляр этого класса, инициализировать его с определенным значением, а затем вызывать метод sqrt.
Гораздо лучше просто вызвать sqrt, передавая значение параметром. Язык C++
предоставляет свободные функции для ясности и удобства использования; кроме
того, он позволяет реализовать функциональность, аналогичную Java, путем
объявления методов внутри класса.
Проблема
Предположим, что мы пишем класс Rational, в котором команда математиков
хочет работать с точными значениями, а не с приближениями, которые предлагают числа с плавающей точкой. Например, рациональное число 10/3 точное,
но не может быть идеально представлено в десятичной или двоичной системе
счисления. Код в листинге 9.27 демонстрирует первую попытку реализации
подмножества Rational. Методы reduce и gcd используются каждый раз, когда
состояние объекта изменяется. Обращения к ним будут частыми.
306
Глава 9. Операции класса
Листинг 9.27. Первая попытка создания класса Rational
class Rational {
private:
int num;
int den;
reduce и gcd вызываются
public:
в конструкторе
Rational(int num, int den = 1) : num(num), den(den)
{ reduce(); }
friend std::ostream& operator<<(std::ostream&, const Rational&);
Rational& operator*=(const Rational&);
int gcd(int a, int b) {
if (b == 0)
return a;
return gcd(b, a % b);
}
void reduce() {
int div = gcd(num, den);
num /= div;
den /= div;
}
};
std::ostream& operator<<(std::ostream& out, const Rational& r) {
out << r.num << '/' << r.den;
return out;
}
Rational& Rational::operator*=(const Rational& o) {
num *= o.num;
den *= o.den;
reduce();
reduce и gcd вызываются в operator*=
return *this;
}
int main() {
Rational r1(3, 9);
std::cout << r1 << '\n';
return 0;
}
Анализ
Здесь reduce и gcd — публичные методы экземпляра. С точки зрения корректности
в этой реализации нет ничего неправильного, но читаемость при этом страдает.
Код подразумевает, что эти функции обязательно должны быть привязаны к объекту. То есть вызывать их можно только при наличии экземпляра, и каждая должна
иметь доступ как минимум к одному полю. Метод reduce обращается как к num,
так и к den, как следует из кода, но gcd работает только со своими параметрами.
В функциональном программировании есть термин, описывающий реализацию
gcd: чистая функция. В этом случае чистая означает, что метод не обращается
к состоянию за пределами своей области видимости и не влияет на него; иными
9.11. Ошибка #71
307
словами — не имеет побочных эффектов. Он вычисляет свой результат исключительно на основе данных, переданных через параметры, — одни и те же входные данные всегда дают один и тот же результат. Поскольку метод gcd чистый, нет причин
настаивать на том, что ему нужен объект для работы. Следовательно, это кандидат
на превращение метода экземпляра в метод класса через ключевое слово static.
Метод reduce использует поля экземпляра, поэтому он должен быть привязан
к экземпляру. Или нет? Его можно легко переписать, чтобы устранить это требование и сделать его чистым. Следует ли это делать? Предположим, да.
Решение
Код в листинге 9.28 — это переработанная первая попытка создания класса
Rational. Здесь gcd сразу помечен как статический. Такое объявление означает,
что метод общий для всех экземпляров и не влияет на переменные экземпляра.
Добавление ключевого слова static делает его назначение и область видимости
более понятными. Метод gcd является чистым методом (он не изменяет состояние за пределами самого себя). К сожалению, метод reduce чистым быть не может,
так как он изменяет переменные экземпляра. Поскольку оба метода теперь объявлены как методы класса, их семантика изменилась по сравнению с предыдущей
реализацией и теперь несет иной смысл. Обычно лучше сделать такие методы
приватными, ведь они предназначены только для использования внутри класса.
Листинг 9.28. Переработанный Rational — добавлено два метода класса
class Rational {
private:
int num;
int den;
static int gcd(int a, int b) {
if (b == 0)
return a;
Теперь приватный,
return gcd(b, a % b);
чистый метод
}
static void reduce(int& num, int& den) {
int div = gcd(num, den);
num /= div;
den /= div;
}
public:
Rational(int num, int den = 1) : num(num), den(den) {
reduce(this->num, this->den); }
friend std::ostream& operator<<(std::ostream&, const Rational&);
Rational& operator*=(const Rational&);
};
std::ostream& operator<<(std::ostream& out, const Rational& r) {
out << r.num << '/' << r.den;
return out;
308
Глава 9. Операции класса
}
Rational& Rational::operator*=(const Rational& o) {
num *= o.num;
den *= o.den;
reduce(num, den);
return *this;
}
int main() {
Rational r1(3, 9);
std::cout << r1 << '\n';
return 0;
}
Чтобы все выглядело более аккуратно, оба метода были сделаны приватными;
однако действительно ли это лучший подход к определению такой семантической
характеристики? Это сомнение дает нам шанс поразмыслить над внесенными
изменениями. Глядя на метод reduce, можно задаться вопросом, действительно
ли он корректно передает свое назначение. В конце концов, reduce предназначен
для взаимодействия с переменными экземпляра. Передача ссылок, конечно, позволяет сделать его чистым, но теперь он почти противоречит своему истинному
назначению, и ссылки понадобились лишь для того, чтобы можно было изменить
две переменные. Было бы лучше вернуть reduce в разряд методов экземпляра,
чем усложнять код неочевидными ссылками. Сами по себе ссылки очень удобны,
но здесь они только затрудняют понимание. Возврат к реализации как метода
экземпляра выглядит более разумно. Листинг 9.29 показывает, что изменения,
внесенные в этой цепочке рассуждений, принесли свои плоды.
Листинг 9.29. R
ational снова переработан, чтобы более четко
обозначить метод класса
class Rational {
private:
int num;
Приватный, поскольку этот метод предназначен
int den;
только для использования экземплярами класса
void reduce();
public:
Rational(int num, int den = 1) : num(num), den(den) { reduce(); }
friend std::ostream& operator<<(std::ostream&, const Rational&);
Rational& operator*=(const Rational&);
};
std::ostream& operator<<(std::ostream& out, const Rational& r) {
out << r.num << '/' << r.den;
return out;
}
Rational& Rational::operator*=(const Rational& o) {
num *= o.num;
den *= o.den;
reduce();
return *this;
}
static int gcd(int a, int b) {
Публичный, поскольку клиентский код
if (b == 0)
может захотеть воспользоваться этой
return a;
функциональностью
return gcd(b, a % b);
}
out << r.num << '/' << r.den;
return out;
Rational& o) {
9.12. Ошибка #72
}
Rational& Rational::operator*=(const
num *= o.num;
den *= o.den;
reduce();
return *this;
}
static int gcd(int a, int b) {
if (b == 0)
return a;
return gcd(b, a % b);
}
void Rational::reduce() {
int div = gcd(num, den);
num /= div;
den /= div;
}
309
Публичный, поскольку клиентский код
может захотеть воспользоваться этой
функциональностью
int main() {
Rational r1(3, 9);
std::cout << r1 << '\n';
return 0;
}
В листинге 9.29 был учтен еще один аспект. Команда математиков сообщила,
что они хотели бы сделать gcd доступным для внешнего использования. Одна из
основных характеристик ключевого слова static — возможность совместного
использования. Статический метод позволяет классу делиться частью своей
логики с внешним миром.
Рекомендации
Тщательно обдумайте, можно ли сделать метод чистым; если да, пометьте
его как static, что лучше будет отражать его назначение, поскольку он не
влияет ни на одно поле класса.
Подумайте, следует ли сделать статические методы публичными или приватными; публичные версии могут быть доступны клиентам для общего использования, а приватные должны быть доступны только для использования
внутри класса.
Если приватный метод должен использоваться в производных классах, рассмотрите возможность сделать его защищенным; тогда он будет доступен
наследникам, но скрыт от клиентского кода.
9.12. Ошибка #72: некорректный выбор между
методами и внешними функциями
Эта ошибка касается эффективности и семантической корректности (то есть
корректности смысла, а не результата). Изначально кажется, что эффективность ухудшается, но при изменении требований к реализации выигрыш станет
очевидным. Изменения должны затрагивать как можно меньше кода.
Функции бывают трех типов: методы, внешние функции и дружественные функции, не являющиеся методами самого класса (далее — функция-друг). Объектно
310
Глава 9. Операции класса
ориентированное программирование основано на трех столпах: инкапсуляции,
наследовании и полиморфизме. Инкапсуляция — это идея сокрытия деталей
реализации (полей и тел методов) от клиентского кода, чтобы обеспечить использование только публичного интерфейса класса.
Преимущество хорошей инкапсуляции в том, что класс можно переопределить
для улучшения одной или нескольких категорий. Примером может служить
концепция класса Date: следует ли реализовать его в терминах года, месяца,
дня, или в виде количества секунд, прошедших с начала эпохи компьютеризации, или каким-то другим способом? Ни один вариант не будет универсально
правильным, но в определенных обстоятельствах один из них, как правило,
предпочтительнее. Инкапсуляция позволяет переопределять класс, используя
новые технологии, методы, требования или знания, не ограничивая разработчика
конкретным подходом; при этом клиент никогда не должен замечать никаких
изменений (особенно в интерфейсе).
Проблема
Рассмотрим разработку класса Date и добавление методов форматирования для
вывода. Код в листинге 9.30 демонстрирует выбранный подход. Обоснование
таких методов простое: инкапсуляция. Каждый из них обращается к переменным экземпляра и изменяет их, поэтому функции должны быть частью класса.
Перегрузка оператора operator<< обычно осуществляется через дружественную
функцию, имеющую прямой доступ к переменным экземпляра. Для других
локалей могут быть определены свои методы форматирования.
Функция-друг может напрямую обращаться к приватным данным; для этого
анализа она рассматривается как метод. Лучше всего, чтобы дружественные
функции никогда не изменяли данные. Посмотрите на методы класса и подсчитайте, сколько из них напрямую обращаются к переменным экземпляра:
таковых пять. Запомните это.
Листинг 9.30. Класс с методами экземпляра
class Date {
private:
int year;
int month;
Пять методов напрямую обращаются
int day;
к переменным экземпляра
public:
Date(int year, int month, int day) : year(year), month(month), day(day) {}
std::string formatUS();
friend std::ostream& operator<<(std::ostream&, const Date&);
int getYear() { return year; }
int getMonth() { return month; }
int getDay() { return day; }
};
std::ostream& operator<<(std::ostream& o, const Date& d) {
Дружественная
o << d.year << '/' << d.month << '/' << d.day;
функция имеет
return o;
прямой доступ
}
к приватным
std::string Date::formatUS() {
Методы также имеют
переменным
std::stringstream ss;
прямой доступ к
экземпляра
ss << month << '/' << day << '/' << year;
приватным переменным
int day;
к переменным экземпляра
public:
Date(int year, int month, int day) : year(year), month(month), day(day) {}
std::string formatUS();
friend std::ostream& operator<<(std::ostream&, const Date&);
int getYear() { return year; }
9.12. Ошибка #72
311
int getMonth() { return month; }
int getDay() { return day; }
};
std::ostream& operator<<(std::ostream& o, const Date& d) {
Дружественная
o << d.year << '/' << d.month << '/' << d.day;
функция имеет
return o;
прямой доступ
}
к приватным
std::string Date::formatUS() {
Методы также имеют
переменным
std::stringstream ss;
прямой доступ к
экземпляра
ss << month << '/' << day << '/' << year;
приватным переменным
return ss.str();
экземпляра
}
int main() {
Date birthday(1970, 1, 1); // умная ИИ-система поймет, о чем речь
std::cout << birthday << '\n';
std::cout << birthday.formatUS() << '\n';
return 0;
}
Теперь требования внезапно изменились. В условиях сжатых сроков класс необходимо модифицировать так, чтобы он использовал реализацию даты в виде
количества секунд, прошедших с 1970/01/01 (или 01.01.1970), то есть в формате
«эпохи».
ПРИМЕЧАНИЕ Класс Date, как известно, сложно реализовать правильно, поэтому
настоятельно рекомендуем использовать куда более качественную реализацию, чем
в этом примере, — пожалуйста!
Анализ
Класс Date реализован вполне хорошо (разумеется, только в качестве примера!)
и работает так, как ожидает клиент. Однако изменившиеся требования диктуют
необходимость модифицировать приватные переменные экземпляра и, что еще
хуже, пять методов экземпляра. Инкапсуляция предполагала, что такой подход
оптимален, но пересмотр архитектуры окажется полезным. Еще лучше будет
использовать другую метрику — подсчитать количество методов, которые напрямую обращаются к полям класса, — и определить, можно ли сократить их
количество.
Три метода-аксессора все равно придется изменить, чтобы они работали с количеством секунд, прошедших с даты начала эпохи; с этим фактом ничего не
поделаешь. Однако если переработать operator<< и formatUS так, чтобы они
полностью зависели от аксессоров, то эти два метода не придется модифицировать. В классах с еще бˆольшим количеством методов эта проблема усугубляется.
Поэтому лучшая метрика для измерения инкапсуляции — подсчет количества
методов, обращающихся к переменным экземпляра. Логика проста: если метод
не работает с переменными экземпляра, он не может подвергнуть изменению
инкапсулированные данные. Чем меньше таких методов, тем лучше скрыты данные и тем меньше изменение деталей реализации повлияет на остальные методы.
312
Глава 9. Операции класса
Решение
Чтобы минимизировать количество методов экземпляра, сделайте как можно
больше внешних функций. Остальные следует реализовать через небольшое
число оставшихся методов объекта — так можно сократить объем модификаций,
когда появится необходимость внесения изменений. В листинге 9.31 показан
(нереалистичный) код, в котором вычисляющие аксессоры используются в качестве основы для внешних функций operator<< и formatUS.
Листинг 9.31. Минимизация методов и реализация внешних функций
class Date {
private:
static const long sec_in_year = 31536000;
static const long sec_in_mon = 2592000;
static const long sec_in_day = 86400;
long seconds;
public:
Date(int year, int month, int day) {
seconds = (year-1970) * sec_in_year;
seconds += (month-1) * sec_in_mon;
seconds += (day-1) * sec_in_day;
}
int getYear() const {
return seconds/sec_in_year + 1970; }
int getMonth() const {
int sec = seconds % sec_in_year;
return sec/sec_in_mon + 1;
}
int getDay() const {
int sec = seconds/sec_in_year/sec_in_mon;
return sec/sec_in_day + 1;
}
};
std::ostream& operator<<(
std::ostream& o, const Date& d) {
o << d.getYear() << '/' << d.getMonth()
<< '/' << d.getDay();
return o;
}
std::string formatUS(const Date& d) {
std::stringstream ss;
ss << d.getMonth() << '/' << d.getDay() << '/'
return ss.str();
}
Вычисляющие
аксессоры, которые
должны отражать
изменения реализации
Не подвержены
изменениям при
смене реализации
<< d.getYear();
int main() {
Date birthday(1970, 1, 1); // Умная ИИ-система это поймет
std::cout << birthday << '\n';
std::cout << formatUS(birthday) << '\n';
return 0;
}
9.13. Ошибка #73
313
Минимальный набор методов, обращающихся к переменным экземпляра, помогает
разработчику определить, что именно должно составлять базовый интерфейс.
Остальные методы следует реализовывать поверх основных. Такой подход минимизирует количество функций, которые влияют на поля класса, обеспечивая
максимальную инкапсуляцию. Важность этого свойства имеет большое значение
для объектно ориентированного программирования и всегда должна учитываться.
Рекомендации
Минимизируйте количество методов, напрямую обращающихся к переменным экземпляра, путем сокращения числа методов и дружественных
функций.
Функции, не являющиеся методами объекта, которые используют минимальное количество методов объекта, — лучший способ сохранить инкапсуляцию:
чем меньше методов напрямую работают с переменными экземпляра, тем
лучше инкапсуляция.
Функции-друзья привлекательны, но опасны: они могут непреднамеренно
изменить приватные данные и нарушить инкапсуляцию.
Смотри также
«Ошибка #46», где мы обсуждаем сокращение количества необходимых
методов и реализации остальных поверх них.
9.13. Ошибка #73: некорректный возврат строк
из аксессоров
Эта ошибка влияет на производительность и корректность. Она достаточно
специфична, но встречается часто.
Класс std::string очень полезен и предоставляет множество возможностей для
работы с текстом. Современный C++ значительно расширил функциональность,
и каждый новый стандарт, похоже, добавляет еще больше полезных методов.
Важно правильно использовать такой класс, поскольку многие пользовательские
варианты имеют по крайней мере одно поле строкового типа. Язык C++ — один
из немногих, где строки не являются неизменяемыми, что создает неочевидные
проблемы корректности.
Проблема
Мы разрабатываем систему учета для школы, и нам нужно смоделировать людей.
Мы решили начать с класса Person, от которого в конечном счете будем получать
другие классы (и, конечно, разработаем хорошую схему наследования). Пока
что мы работаем с небольшим набором функциональности.
314
Глава 9. Операции класса
Разработчик, которому было поручено это задание, недавно узнал о преимуществах ссылок и решил возвращать ее на строковую переменную name, чтобы
повысить производительность. Ссылки действительно быстрее, чем копии, но
у них есть некоторые недостатки. Следующий код демонстрирует первую попытку реализации и непредвиденное поведение — каким-то образом переменная
экземпляра name изменяется.
Листинг 9.32. М
оделирование класса Person и попытка улучшить
производительность
class Person {
private:
std::string name;
int age;
public:
Person(const std::string& name, int age) : name(name), age(age) {}
std::string& getName() { return name; }
Возвращает ссылку на переменную
int getAge() { return age; }
экземпляра для повышения
};
производительности
int main() {
Клиентский код не должен иметь
Person sam("Samantha", 26);
возможности напрямую изменять
std::string& name = sam.getName();
приватные данные
name += "x";
std::cout << sam.getName() << " is " << sam.getAge() << " years old\n";
return 0;
}
Разработчик хотел изменить копию данных, а не саму переменную экземпляра.
Ничего подозрительного замечено не было, код был протестирован и принят.
Анализ
Разработчик попытался создать производительный код. Он должен был заметить, что аксессор getName возвращает ссылку на переменную экземпляра — это
не копия данных. Ссылка — это просто другое имя для того же самого объекта
(сущности), то есть псевдоним. Если изменить объект через псевдоним, изменится и сама сущность. Стремление программиста к эффективности заслонило
тот факт, что реальная переменная экземпляра становится доступной.
Решение
Возвращение ссылки на переменную экземпляра типа std::string предотвращает копирование переменной, а значит, позволяет избежать лишнего выделения памяти и копирования данных. Однако если бы возвращалась копия,
поле объекта осталось бы недоступным для изменений со стороны клиента.
Намерение разработчика похвально, но его реализация — нет. Это касается не
только std::string; любой класс, который возвращает член по ссылке (или
указателю!), страдает от той же проблемы.
9.13. Ошибка #73
315
Чтобы добиться производительности ссылки и защищенности приватной переменной экземпляра, необходимо возвращать константную ссылку (const), как
показано в листинге 9.33. Если эта ссылка хранится в какой-либо локальной
переменной, компилятор гарантирует, что она тоже будет константной. Следовательно, попытка изменить данные через ссылку приведет к ошибке компиляции.
Этот код исправляет подход чрезмерно старательного разработчика, сохраняя
при этом целостность данных.
С другой стороны, сама попытка клиентского кода изменить данные, полученные с помощью аксессора, вызывает подозрения. Если локальная копия данных
изменяется, она перестает соответствовать значению объекта; следовательно,
меняется семантика. В некоторых случаях это делается намеренно, но здесь
нужно быть очень осторожным. Лучше создавать копии данных экземпляра
или константные ссылки на них, что сохраняет семантику объекта. Код всегда
может получить доступ к значению переменной экземпляра, если это необходимо,
и оно будет согласовано с объектом. Локальное сохранение данных позволяет
изменять их и придавать иной смысл.
Листинг 9.33. И
спользование констант для предотвращения изменений
и повышения производительности
class Person {
private:
std::string name;
int age;
public:
Person(const std::string& name, int age) : name(name), age(age) {}
const std::string& getName() const { return name; }
int getAge() const { return age; }
Возвращает константную ссылку на переменную
};
экземпляра типа std::string
int main() {
Person sam("Samantha", 26);
const std::string& name = sam.getName();
// name += "x";
std::cout << name << " is " << sam.getAge() <<
return 0;
}
Делает локальную переменную
неизменяемой для корректной
компиляции и сохранения семантики
" years old\n";
Рекомендации
Для повышения эффективности возвращайте поля объекта типа std::string
по ссылке; для обеспечения корректности убедитесь, что ссылка является
константой, чтобы предотвратить непреднамеренное изменение переменной
экземпляра.
Помните, что ссылки — это псевдонимы; они представляют фактические
данные, а не являются их копией.
10
Исключения и ресурсы
В этой главе
3 Недостатки использования исключений
3 Преимущества грамотного использования исключений
3 Связь между обработкой ресурсов и исключениями
3 Построение иерархии исключений для более эффективного
управления ошибками
Тема исключений имеет сторонников и противников во всем безграничном спектре мнений, причем разработчики выступают как за то, чтобы использовать их
редко, если вообще использовать, так и за частое применение. На протяжении
многих лет некоторые авторы публиковали статьи, в которых отстаивали диаметрально противоположные позиции в этом спектре. Многие из этих аргументов
значимы, но они плохо согласуются с другими точками зрения. Эта ситуация
ставит нас в затруднительное положение при описании использования исключений. Многие положения верны в рамках определенных областей, но спорны
в других. Существует множество ситуаций, в которых определенная политика
имеет смысл, но она же оказывается бесполезной в иных обстоятельствах.
Исключения необходимо рассматривать в контексте всей программы. Политика,
определяющая поведение одной функции, может оказаться неэффективной при
Исключения и ресурсы
317
распространении ее на более крупные модули. Многие современные программы, работающие в продакшене, лишены роскоши перепроектирования, чтобы
реализовать единую стратегию обработки исключений. В этих бесчисленных
случаях практически любой подход лучше, чем его отсутствие. Однако интеграция локализованных стратегий в более крупные модули может оказаться
сложной задачей, подверженной ошибкам.
Более правильный подход в этих многочисленных ситуациях — глубже разобраться с исключениями и развивать интуицию по их применению в конкретной кодовой базе. Поэтому мы начнем с общего обзора исключений и причин
их существования. Исходя из этого, мы сможем лучше понять, как правильно
использовать их в критических случаях, таких как управление ресурсами. Исключения и ресурсы тесно связаны друг с другом (наряду с конструкторами
и деструкторами), обеспечивая комплексный паттерн управления, особенно
когда что-то неожиданно случается. Идиоматический шаблон получение ресурса
есть инициализация (Resource Acquisition Is Initialization, RAII), изобретенный
Бьёрном Страуструпом (Bjarne Stroustrup), будет представлен как способ координации управления ресурсами в C++.
Важнейший аспект использования исключений — понимание того, что является
ошибкой, а что — нет. Функция — это фрагмент именованного кода, который
выполняет какое-либо действие. Каждая функция имеет три характеристики,
которые необходимо поддерживать, — нарушение любой из них следует считать
ошибкой:
нарушение предусловия;
нарушение инварианта;
нарушение постусловия.
Другие проблемы, обнаруженные внутри функции, следует устранять локально.
Если они являются или становятся нарушениями трех перечисленных характеристик, необходимо выбросить исключение. Четко разграничить ошибки (выраженные через исключения) и проблемы, требующие локальной обработки,
может оказаться совсем непросто. Чтобы пояснить возможные варианты, ниже
приведены небольшие примеры для каждого типа.
Нарушение предусловия — в функцию передается указатель на связный
список для поиска ключа, но он равен NULL (вероятно, равен нулю). Поскольку функция не может продолжить выполнение, она должна сообщить
об ошибке. Если функция изначально предназначена для обработки пустых
списков (указателя NULL), это не ошибка и должно возвращаться значение,
указывающее, что ключ не найден.
Нарушение инварианта — экземпляр класса хранит состояние, представляющее собой дату. Конструктор проверил диапазоны с помощью предваритель-
318
Глава 10. Исключения и ресурсы
ных условий. Объект еще существует, значит, инвариант класса действителен
и не нарушен. Однако некоторые методы изменили состояние даты так, что
день превысил 31. Другой метод пытается преобразовать эту дату, но обнаруживает ошибку посредством проверки инварианта. Поскольку обнаружено
нарушение, о нем следует сообщить. Так как класс сам внес недопустимое
значение, это яркий пример неправильного программирования! К счастью,
проверка инварианта позволяет выявить проблему.
Нарушение постусловия — функция получает указатель на буфер, содержащий текст для преобразования в число, но текст не соответствует числовому
формату. Функция не может вернуть осмысленное числовое значение, поэтому она должна сообщить об ошибке. Если же она изначально предназначена
для возвращения числового представления отсутствующего значения (например, NaN с плавающей точкой), то это значение возвращается и ошибка
не выдается.
Другие случаи следует рассматривать, используя эти три характеристики последовательно и предсказуемо. Должно быть ясно, что при разработке функции
важно учитывать эти характеристики и тщательно продумывать, что является
ошибкой, а что нет. Избегайте ситуативных стратегий обработки ошибок — сочувствую всем, кто имеет дело с унаследованным беспорядком в коде и вынужден
его расчищать.
10.1. Использование исключений
Основная цель исключений — зафиксировать, что произошла ошибка и что
вызванный код не может успешно выполнить запрос. Она проявляется таким
образом, что разработчик не может ее игнорировать. Классическая обработка
ошибок часто представляла собой установку статуса, указывающего на возникновение ошибки, и проверку на наличие этого статуса в вызывающем коде;
однако не существовало средств для принудительной проверки.
Исключения привлекают внимание разработчика. Если оно выброшено, а разработчик его игнорирует, программа в итоге завершится сбоем. Такое поведение
нежелательно, и программист обязан обработать исключение, чтобы предотвратить сбой, — свойство невозможности игнорирования вынуждает заняться
этой проблемой. Надеемся, это побудит разработчика тщательно обдумать, что
означает эта ошибка и что может повлечь за собой ее устранение. Другими словами, исключения дают возможность проектировать программное обеспечение
с высокой отказоустойчивостью.
Существуют три пространственно-временных аспекта исключений, которые
необходимо рассматривать в следующем порядке.
10.1. Использование исключений
319
1. Точка вызова функционального фрагмента кода.
2. Точка обнаружения ошибки.
3. Точка восстановления.
Точка вызова возникает первой: вызывается функциональный фрагмент кода,
и вызывающая сторона ожидает результата (здесь мы рассматриваем только
синхронное выполнение). Второй идет точка обнаружения ошибки — место, где
операция непосредственно обнаруживает ситуацию ошибки. С кода, следую
щего за точкой вызова, начинается точка восстановления, которая может быть
опциональной. В случае классических кодов возврата это была бы проверка
возвращаемого значения; при использовании исключений — соответствующий
блок catch.
Эти три точки разделены в пространстве (разные функции). Ошибка обнаруживается там, где вызываемый код понимает, что не может осмысленно продолжить
выполнение. Вызывающая сторона запрашивает выполнение определенного
действия, но вызываемый код оказывается не в состоянии это сделать. Во многих
случаях такого рода никакое возвращаемое значение не будет иметь смысла;
поэтому ошибка должна быть передана по альтернативному пути управления.
Наконец, код восстановления является третьей точкой в пространстве и времени:
этот опциональный код пытается восстановить работу программы после ошибки.
Если это возможно, программа продолжает работу; если нет — она должна быть
корректно завершена.
Ниже приведены несколько утверждений и возражений по поводу исключений.
Каждое возражение детально рассматривается, и приводятся доводы в пользу
использования исключений. Вкратце, большинство возражений против исключений связано не с корректным применением механизма, а с неправильным. Как
и с любой технологией, некорректное использование создает проблемы, но это
не повод отказываться от ее правильного применения.
10.1.1. Утверждение: смешение путей управления
и восстановления
Классическая обработка ошибок перемешивает пути управления и восстановления; исключения разделяют их. Нормальный путь выполнения программы
без всяких проблем (так называемый счастливый путь — happy path) часто
хорошо спроектирован, поскольку большинство программистов пишут код для
реализации определенного поведения. Слишком часто бывает так: только после
того, как «счастливый путь» написан и функционирует, начинается работа над
обнаружением и обработкой ошибок — и иногда этой работе уделяется лишь
поверхностное внимание. Честно говоря, код обнаружения и обработки ошибок
320
Глава 10. Исключения и ресурсы
нередко делает основной путь управления менее очевидным, затрудняя его понимание и обоснование.
Такое усложнение кода может побудить некоторых разработчиков уделять
недостаточно внимания обнаружению и обработке ошибок. Проще надеяться,
что все будет работать правильно (по крайней мере, в большинстве случаев)
и что ошибкам можно уделить меньше времени. К этому следует добавить тот
факт, что такая обработка часто сводится к гаданию на кофейной гуще, когда
вызываемый код не до конца понятен или когда вызывающий код скрыт внутри
цепочки вызовов, происходящих в условиях, которые также непонятны. Неспособность четко представлять последствия ошибки может существенно снизить
эффективность обработки ошибок.
10.1.2. Возражение: путаница между обработкой исключений
и обычной обработкой ошибок
Исключения нередко чрезмерно используются при обработке ошибок. Здесь
важно различать точку вызова кода и точку обнаружения ошибки. Код, который выполняет обнаружение, не должен напрямую выбрасывать исключение.
Вместо этого в точке обнаружения необходимо решить, может ли ошибка быть
обработана локально. Если может, необходимости выбрасывать исключение
нет. Когда исключение генерируется, читатель может справедливо утверждать,
что код в точке обнаружения должен был решить проблему самостоятельно.
Однако, если он не может обработать ошибку, он обязан выбросить исключение. Ожидается, что код в точке обнаружения будет выполнять определенные
действия. Если это невозможно, он должен сообщить о своей неспособности
с помощью какого-либо механизма. Ранее об этом сигнализировал код возврата ошибки. Но опять же, ничто не заставляет вызывающий код проверить
возвращаемый код.
Использование исключений для сигнализации о том, что функция не может
выполнить свою задачу для вызывающей стороны, является обоснованной причиной их применения. Использование исключений для того, чтобы переложить
на вызывающий код исправление проблемы, которую должен был решать вызываемый код, неприемлемо и не должно практиковаться.
10.1.3. Возражение: сложность добавления обработки
исключений в существующий код
Добавление обработки исключений в большую часть существующего кода —
слишком сложная задача. Независимо от того, возражаете вы против доработки старого кода ради внедрения обработки исключений или нет, это,
скорее всего, плохая идея. При этом необходимо будет добавить код обработки
10.1. Использование исключений
321
исключений в логику, что приведет к несогласованности, неэффективности
и затруднениям при чтении и понимании кода. Существующий вариант, скорее
всего, не предусматривает расходов на доработку, но в некоторых случаях это
возможно. Разработка последовательной стратегии обработки исключений
в новом коде сама по себе выступает сложной задачей, но в существующем
коде — еще более сложной. Пожалуйста, сделайте все, что в ваших силах, но
не переусердствуйте.
10.1.4. Утверждение: неоднозначные значения
Исключения разделяют нормальные и ошибочные значения. Коды возврата
зависят от определенного типа данных, конкретного значения этого типа и от
кода, который выполняет проверку значения. Функция возвращает значение
своего объявленного типа. Коды возврата являются контрольными значениями.
Контрольное значение соответствует типу данных, но не соответствует задаче.
Предположим, что функция возвращает целочисленное число. Определить
целочисленное значение, не соответствующее задаче, можно легко, но, скорее
всего, это будет не совсем правильно. Необходимо определить одно значение
или набор, которые будут представлять корректный результат, и другое значение
или набор, которые будут обозначать ошибку. Какое из них чему соответствует?
Кто может запомнить все это? И можем ли мы гарантировать, что комментарии,
которые все объясняют, будут соответствовать коду?
Выбрасывание исключения однозначно и не зависит от типа возвращаемого
значения функции. Тип исключения сам по себе несет информацию. Более того,
исключение, скорее всего, будет содержать дополнительный текст, описывающий
особенности ошибки.
10.1.5. Утверждение: неоднозначные типы данных
Исключения гарантируют, что тип данных значения четко различается между
нормальными и ошибочными условиями. Классический подход с использованием кода возврата или установки глобальной переменной все равно должен
придерживаться типа возвращаемого значения, который одновременно используется и для нормальной обработки. Исключение может выбросить совершенно другой тип, не имеющий отношения к типу возвращаемого значения.
Тип исключения однозначно определяет, что означает возвращаемое значение,
устраняя двусмысленность.
10.1.6. Утверждение: принудительная обработка ошибок
Использование исключений гарантирует обработку ошибки. Сами исключения не делают обработку: они лишь обеспечивают, что каждое исключение
322
Глава 10. Исключения и ресурсы
перехватывается или приводит к завершению программы. Таким образом,
предоставляется возможность осмысленной обработки ошибки — и разработчик
должен эффективно этим воспользоваться. Существенным преимуществом
исключения является то, что оно упрощает процесс просачивания ошибок на
более высокие уровни. На этих уровнях зачастую легче справиться с проблемой.
Использование кодов возврата усложняет и затрудняет тестирование такого
распространения на каждом уровне.
Если на первом уровне ошибка не может быть обработана, то не составит большого труда повторно выбросить исключение или преобразовать его в другой
тип и выбросить уже его. Единственное требование — на каждом уровне должен быть блок catch, способный перехватить соответствующее исключение.
Альтернативный путь управления в случае исключения упрощает код и делает
его более ясным.
10.1.7. Возражение: предоставление обработчиков
восстановления
Блоки catch не обязательно должны выполнять осмысленное восстановление.
Это вряд ли является существенным возражением, поскольку плохо написать
код восстановления можно всегда — будь то исключения или классическая
обработка ошибок. Первый блок, который перехватывает исключение, может
определить, что пошло не так и можно ли это исправить. Восстановление может
означать, что все состояния, измененные вызванной функцией, либо откатываются назад, либо инициализируются до определенной точки. Несомненно,
можно игнорировать изменения и двигаться дальше. Однако разработчик должен
тщательно обдумать последствия такого решения. Инварианты классов должны
соблюдаться и восстанавливаться при необходимости. Код восстановления,
следующий за вызывающим кодом, обычно лучше понимает, какие состояния
могли быть изменены и что требуется очистить. Код, локальный по отношению
к точке обнаружения, обладает недостаточным контекстом для осмысленного
восстановления.
10.1.8. Утверждение: преобразование типов сбоев
Использование отдельных путей управления для нормальных и ошибочных состояний позволяет разработчику преобразовывать типы исключений в процессе
работы. Если код в точке обнаружения понимает ошибку одним способом, то
в точке восстановления эта семантика может преобразоваться в другое значение.
Например, предположим, что запись считывается из файла на диске. Процедура
проверки входных данных обнаруживает недопустимые для определенного поля
данные при преобразовании текста в целое число. Она выбрасывает исключение,
10.2. Ошибка #74
323
указывая на то, что были обнаружены некорректные данные и преобразование
выполнить невозможно. Если код в точке восстановления решает не предпринимать попытку восстановления, он может передать ошибку на другой уровень,
добавив метаданные или дополнительную диагностическую информацию.
Более высокий уровень должен работать с более общим представлением о сбое,
чем низкоуровневые детали (например, ошибки несоответствия входного
формата). Поэтому первая точка восстановления может изменить тип исключения на более подходящий для следующего уровня — например, на ошибку
недопустимой записи. Для фиксирования деталей, которые могут оказаться
неподходящими для фактического восстановления, можно использовать логирование (запись логов).
10.1.9. Утверждение: разделение задач
Возможность разделения задач обнаружения и обработки ошибок позволяет
нескольким разработчикам работать параллельно, не мешая друг другу. Перемешивание нормального пути управления и пути обработки ошибок приводит
к смешению задач. Код, который обрабатывает «счастливый путь», работает
принципиально иначе, чем код, обрабатывающий ошибки. Поэтому разделение
этих путей на две отдельные области позволяет изолировать их друг от друга.
Это мощный принцип программной инженерии, и везде, где он применим, его
следует использовать максимально широко.
10.1.10. Возражение: побуждение к предварительному
проектированию
Код должен разрабатываться с учетом обработки исключений с самого начала,
что требует дополнительных затрат времени и сил. Это возражение, вероятно,
служит оправданием для написания кода до проведения должного проектирования — или вообще без него. Оно не имеет отношения непосредственно
к исключениям. Новый код и как можно бˆoльшая часть унаследованного кода
должны быть спроектированы надлежащим образом, чтобы обеспечить разделение нормального пути выполнения программы и обработки ошибок.
10.2. Ошибка #74: отсутствие генерации
исключений в конструкторах
Эта ошибка влияет на корректность и эффективность. Поскольку сохранение
инварианта класса имеет важное значение, выбрасывание исключений из конструкторов, которые завершаются с ошибкой, является лучшим способом предотвратить использование частично инициализированных объектов. Исключения
324
Глава 10. Исключения и ресурсы
делают код, создающий объекты, чище, но, что еще более важно, любые сбои
конструктора явно подсвечиваются и должны быть обработаны. Легко забыть
проверить коды возврата и флаги ошибок или последовательно реализовать
другие схемы проверки корректности объектов. В итоге разработчики тратят
меньше времени, используя исключения.
Проблема
Если код не был специально спроектирован должным образом, в большинстве
существующих проектов исключения редко применяются правильно. Одна
из примечательных в этом смысле областей — создание объектов. Конструкторы по умолчанию могут применяться слишком часто, что приводит к некорректной инициализации некоторых переменных экземпляра. Ошибочно
предполагать, что создание объекта с отсутствующими инициализирующими
данными допустимо только потому, что остальные данные будут предоставлены (позже!).
В этом некорректном подходе часто используются конструкторы по умолчанию
или конструкторы, которые принимают лишь часть необходимых данных. Когда
информация для инициализации отсутствует, бит валидности (valid bit) сбрасывается, чтобы указать, что объект неполный или инициализирован частично.
Когда отсутствующие данные поступают, бит валидности устанавливается в соответствующее состояние, указывая на полную инициализацию. Этот подход
работает, если он применяется последовательно, но, скорее всего, свидетельствует
о неудачных решениях и плохом дизайне. Следующий код демонстрирует это
предположение на практике.
Листинг 10.1. Частично инициализированный класс
class Person {
private:
std::string name;
int age;
public:
Person(const std::string& name) : name(name) {}
Person(const std::string& name, int age) : name(name),
int getAge() { return age; }
const std::string& getName() const { return name; }
bool isValid() const { return age > -1; }
};
Конструктор с частичной
инициализацией, который
не инициализирует одну
переменную экземпляра
age(age) {}
Используется конструктор
с частичными значениями;
int main() {
переменная age не инициализируется
Person sally("Sally");
std::cout << sally.getName() << " is " << sally.getAge()
<< " years old\n";
В состояние экземпляра вносятся
некорректные данные
Person brian("Brian", -1);
std::cout << brian.getName();
if (brian.isValid())
std::cout << " is " << brian.getAge() << " years old\n";
else
std::cout << " is invalid\n";
std::cout << brian.getName() << " is " << brian.getAge() <<
" years old\n";
Разработчик забыл проверить
return 0;
бит валидности
Разработчик не
забыл проверить
бит валидности
};
bool isValid() const { return age > -1; }
Используется конструктор
с частичными значениями;
int main() {
переменная age не инициализируется
Person sally("Sally");
std::cout << sally.getName() << " is " << sally.getAge()
10.2. Ошибка #74
325
<< " years old\n";
В состояние экземпляра вносятся
}
некорректные данные
Person brian("Brian", -1);
std::cout << brian.getName();
if (brian.isValid())
std::cout << " is " << brian.getAge() << " years old\n";
else
std::cout << " is invalid\n";
std::cout << brian.getName() << " is " << brian.getAge() <<
" years old\n";
Разработчик забыл проверить
return 0;
Разработчик не
забыл проверить
бит валидности
бит валидности
Объект sally был частично инициализирован: переменная экземпляра age
осталась на произвол случайного значения, находящегося в этой ячейке
памяти, что означает неопределенное поведение. Объект brian полностью
инициализирован, но вносит неверные данные в поле. В обоих случаях эти
объекты необходимо проверить на допустимость. Если предположить, что
случайное значение возраста (age) объекта sally окажется положительным,
объект будет выглядеть допустимым. А объект brian не пройдет проверку на
допустимость, если она будет выполнена; однако нет способа обеспечить проведение проверки.
Анализ
Код, использующий эти объекты, не имеет гарантированного способа определить, являются ли они допустимыми и соблюден ли инвариант класса. Хотя,
чтобы обеспечить такую гарантию, проверка правильности и была введена, она
может не вызываться — это еще одна интересная мысль, которая была не до
конца продумана.
Решение
Первая линия защиты — создавать объект только тогда, когда известны все необходимые данные для инициализации. Это еще один аргумент против частого
использования конструкторов по умолчанию. Инвариант класса зависит от двух
параметров, оба из которых обязательны. Более того, переменная экземпляра age
имеет ограниченный диапазон. До тех пор пока эти проверки не будут сделаны,
состояние объекта неизвестно.
Класс должен проверить значения обоих параметров, чтобы гарантировать
корректное состояние объекта, и, если что-то не так, выбрасывать исключение,
чтобы четко указать, что объект не может быть создан в указанном состоянии.
Вызывающий код всегда должен однозначно указывать на валидность объекта;
за обеспечение и соблюдение этого отвечает класс. В следующем коде представлены улучшения, которые обеспечивают соблюдение инварианта класса
и сообщают о любых ошибках при его определении.
326
Глава 10. Исключения и ресурсы
Листинг 10.2. Генерация исключений при сбоях в конструкторе
class Person {
private:
Метод валидации, используемый
std::string name;
для проверки данных параметров
int age;
static const std::string& validateName(const std::string& name) {
if (name.empty())
throw std::invalid_argument("name must not be empty");
return name;
}
static int validateAge(int age) {
if (age < 0)
throw std::out_of_range("age must be non-negative");
return age;
}
Конструктор отвечает за
public:
установление
инварианта класса
Person(const std::string& name, int age) :
name(validateName(name)), age(validateAge(age)) {}
int getAge() const { return age; }
const std::string& getName() const { return name; }
bool isValid() const { return age > -1; }
};
Неполные аргументы не будут
int main() {
скомпилированы
// Person sally("Sally");
Person sally("Sally", 27);
std::cout << sally.getName() << " is " << sally.getAge()
<< " years old\n";
Исключение выбрасывается во время работы
конструктора; не допускается никакой неоднозначности
}
Person brian("Brian", -1);
std::cout << brian.getName() << " is " << brian.getAge()
<< " years old\n";
return 0;
Поскольку этот конструктор был удален, объект sally больше нельзя создать
с частичной инициализацией — теперь необходимо предоставлять оба значения
аргументов. Объект brian предоставляет оба аргумента, но один из них недопустим. Метод validateAge гарантирует, что для инициализации поля age не будет
использовано отрицательное значение. Однако в случае brian предоставленная
информация приводит к исключению. Нет никакой неясности относительно
допустимости этого объекта. Вызывающий код должен предоставить точку
восстановления для обработки такой ситуации.
Рекомендации
Никогда не создавайте объект, пока не будет известна вся необходимая информация; без нее невозможно установить инвариант класса.
Выбрасывайте исключения, если данные о создании объекта недопустимы или
другие ошибки препятствуют корректному завершению работы конструктора.
10.3. Ошибка #75
327
Старайтесь предусмотреть все возможные сбои в работе конструктора; планируйте и обрабатывайте их соответствующим образом.
Смотри также
«Ошибка #44», чтобы понять суть инвариантов класса и их значение.
«Ошибка #49», чтобы узнать, почему не рекомендуется использовать конструкторы по умолчанию в большинстве случаев.
«Ошибка #60», где мы обсуждаем необходимость инициализации всех переменных экземпляра.
10.3. Ошибка #75: генерация исключений
из деструкторов
Эта ошибка касается корректности, которая влияет на эффективность; однако
это цена, которую приходится платить за корректную работу программы. Исключения — полезный механизм для выявления и обработки ошибок. Существует
мнение, что конструкторы должны быстро выбрасывать исключения при передаче недопустимых параметров. Частично созданные объекты представляют
собой скрытую, но реальную угрозу для корректности; поэтому такой принцип
должен применяться и к деструкторам. В конце концов, если деструктор обнаруживает ошибку, что может быть лучше, чем просто выбросить исключение?
Такая интуиция понятна, но ее реализация далека от идеала.
Проблема
Предположим, что усердный программист освоил некоторые тонкости работы
с исключениями. В своем рвении он распространяет их на деструкторы. Код
в листинге 10.3 демонстрирует результат попытки разработчика применить
эти знания для создания надежной и последовательной стратегии обработки
ошибок. Предполагается, что объект Paragraph будет добавлен после создания
Page . При уничтожении класс спроектирован для удаления динамического
объекта Paragraph. Если объект Page удален без корректного Paragraph, это считается ошибкой. Такая ситуация кажется идеальным моментом для генерации
исключения.
Листинг 10.3. Слишком частое использование исключений
struct Paragraph {};
class Page {
private:
std::string title;
Paragraph* pgph;
328
Глава 10. Исключения и ресурсы
public:
Page(const std::string& title) : title(title), pgph(0) {}
~Page() {
if (pgph == 0)
throw std::string("destructor");
Генерация исключения из деструктора
delete pgph;
}
};
int main() {
Нормальное уничтожение
try {
объекта
Page p("Catching Up");
} catch (const std::string& ex) {
std::cout << "Exception caught: " << ex << '\n';
}
Ошибочное уничтожение
try {
объекта
Page p("Trouble Ahead");
throw std::string("try block");
} catch (const std::string& ex) {
std::cout << "Exception caught: " << ex << '\n';
}
return 0;
}
В результате выполнения программа выдает следующий результат (конкретный
текст может отличаться в зависимости от компилятора и системы):
Exception caught: destructor
➥terminate called after throwing an instance of
➥'std:: cxx11::basic_string<
➥char, std::char_traits<char>, std::allocator<char> >'
Aborted
Программа была аварийно завершена во втором блоке try "Trouble Ahead".
Первый блок try сработал, или, точнее, не вызвал сбоя, но это скорее счастливая случайность, временно скрывающая что-то куда более зловещее. Другие
компиляторы и системы могут повести себя иначе.
Анализ
Первый блок try работает корректно только потому, что в этот момент нет выполняющегося исключения. Соответствующий блок catch может перехватить
исключение и обработать его как положено. Эта, казалось бы, правильная операция вводит в заблуждение; она преподнесет разработчику неприятный сюрприз,
когда стратегия перестанет работать.
Второй блок try и есть этот самый отложенный сюрприз. Объект Page создается внутри блока try, но исключение выбрасывается до того, как объект будет
уничтожен при нормальном выходе из него. Далее блок catch должен был бы
обработать это исключение, однако сначала необходимо уничтожить объект
10.3. Ошибка #75
329
Page. Вызывается его деструктор, и возникает ошибка. Поэтому он генерирует
исключение, чтобы сообщить об отсутствии элемента Paragraph.
Когда одно исключение находится в процессе выполнения, выброс второго приводит к вызову функции terminate. По умолчанию std::terminate немедленно
прерывает выполнение без раскрутки стека, вызывая std::abort. Поведение
функции abort корректное и ожидаемое: оно гарантирует, что отладчики (например, dbg) могут увидеть точное состояние программы в точке завершения.
Если бы стек раскручивался и выполнялись другие функции очистки, отладка
потеряла бы значительную часть своего контекста.
Неожиданным здесь является сам факт того, что программа ведет себя именно
так. Программист не знал об опасности выбрасывания исключения, когда другое
уже находится в процессе выполнения. Деструкторы особенно уязвимы к такой
опасности, поскольку они вызываются в двух случаях: при нормальном завершении и при завершении с ошибкой. Нежелательное поведение проявляется
именно тогда, когда деструктор, выбрасывающий исключение, вызывается при
обработке другого исключения.
Решение
Более правильный подход — избегать генерации исключений из деструктора;
это полностью предотвращает проблему, поскольку вероятность того, что другое
исключение уже находится в процессе обработки, достаточно велика, чтобы
привести к проблеме. Однако в некоторых случаях выбрасывание исключения
внутри деструктора необходимо. Ключевым моментом является разница между
выбрасыванием исключения в деструкторе и из деструктора. Если деструктор
выполняет код, который генерирует исключение, он не может контролировать
это поведение и может пасть жертвой вызова функции завершения программы.
Необходимый подход для кода деструктора, который потенциально (или
фактически) вызывает исключение, заключается в том, чтобы обернуть такой
фрагмент в блок try. Листинг 10.4 демонстрирует подобный подход: он перехватывает любое исключение и, предположительно, регистрирует проблему, но
не выбрасывает это исключение повторно и не генерирует новое. Универсальный
обработчик (catch-all) перехватывает любое исключение; если для определенной
обработки требуется более конкретная спецификация, добавьте ее перед общим
catch. Главное — ни одно исключение не должно покинуть деструктор.
Листинг 10.4. Генерация исключения в деструкторе, но не из него
struct Paragraph {};
class Page {
private:
std::string title;
Paragraph* pgph;
public:
Page(const std::string& title) : title(title), pgph(0) {}
~Page() {
Исключение все еще
try {
генерируется в деструкторе
if (pgph == 0)
Перехват любого исключения
throw std::string("destructor");
в деструкторе, предотвращающий
struct Paragraph {};
330 PageГлава
class
{ 10. Исключения и ресурсы
private:
std::string title;
Paragraph* pgph;
public:
Page(const std::string& title) : title(title), pgph(0) {}
~Page() {
Исключение все еще
try {
генерируется в деструкторе
if (pgph == 0)
Перехват любого исключения
throw std::string("destructor");
в деструкторе, предотвращающий
delete pgph;
его выход из тела деструктора
} catch (...) {
std::cout << "ERROR: exception captured in destructor\n";
}
}
};
int main() {
Нормальное уничтожение
try {
объекта
Page p("Catching Up");
} catch (const std::string& ex) {
std::cout << "Exception caught: " << ex << '\n';
}
try {
Ошибочное уничтожение
Page p("Trouble Ahead");
объекта
throw std::string("try block");
} catch (const std::string& ex) {
std::cout << "Exception caught: " << ex << '\n';
}
return 0;
}
Рекомендации
Не выбрасывайте исключения из деструктора; слишком велика вероятность
того, что другое исключение уже находится в процессе обработки, что приведет к немедленному завершению программы.
Если код в деструкторе может генерировать исключение, оберните его в блок
try, чтобы перехватить его и преобразовать в логи (или нечто аналогичное);
не выбрасывайте исключение повторно.
Смотри также
«Ошибка #74», чтобы понять, почему неполная инициализация — важная
причина для генерации исключений.
«Ошибка #76», где мы разбираем стратегию предотвращения утечки динамических ресурсов во время обработки исключений.
«Ошибка #77», где мы обсуждаем общее решение по работе с динамическими
ресурсами в условиях исключений.
10.4. Ошибка #76
331
10.4. Ошибка #76: допущение утечки ресурсов
при использовании исключений
Эта ошибка влияет на корректность. Проблема возникает тогда, когда конструкторы, выделившие динамические ресурсы, выбрасывают исключения — в результате ресурсы не освобождаются, а система остается в нарушенном состоянии.
Корректность — это не только правильные вычисления в программе, эта характеристика также должна учитывать влияние на систему в целом.
Динамические ресурсы — это простой способ доступа к различным сущностям,
которые не очень хорошо работают в качестве объектов-значений. Простейший
пример — массив неизвестной длины, размер которого определяется во время
выполнения, а память выделяется динамически. При наличии исключений обработка таких ресурсов становится сложнее.
Проблема
Предположим, что разработчик создает сущность Page, которой нужны верхний
и нижний колонтитулы. Текст для каждой из них находится в файле на жестком
диске. Системные ресурсы — источник непредсказуемого поведения: файлы
могут существовать или отсутствовать; права доступа к ним могут быть предоставлены или нет; могут возникнуть ошибки чтения или записи, некорректные
данные и ряд других проблем.
Код в листинге 10.5 демонстрирует несколько наивную попытку обработки
текста верхнего и нижнего колонтитулов. Во-первых, следует использовать
std::string, а не C-строки, но не у всех есть такая возможность. Во-вторых,
ожидаемые ошибки (например при чтении текстовых файлов) следует обрабатывать локально; исключение стоит выбрасывать только в том случае, если
отсутствует приемлемое решение проблемы. Хотя у разных групп разработчиков
могут быть различные мнения по этому вопросу, в этой главе используется подход, выбранный автором. В-третьих, жестко заданная длина входного буфера
чревата потенциальными проблемами. Злоумышленники любят такие ошибки,
поскольку они дают им возможность проникнуть в код, который иначе был бы
вполне безопасным.
Листинг 10.5. Попытка обработки динамических ресурсов в деструкторе
class OneLiner {
private:
std::string filename;
char* text;
public:
OneLiner(const std::string& filename) : filename(filename), text(0) {
332
Глава 10. Исключения и ресурсы
text = new char[64];
std::cout << filename << " allocated\n";
std::ifstream file(filename.c_str());
if (file.fail())
throw std::string("file " + filename + " inaccessible");
// чтение данных в text
};
}
~OneLiner() { std::cout << filename <<
" ~deallocated\n"; delete [] text; }
char* getText() const { return text; }
Освобождает динамическую
память при вызове
class Page {
private:
std::string title;
char* header;
char* footer;
public:
Page(const std::string& title, const std::string& headerfile, const
std::string& footerfile) :
title(title), header(0), footer(0) {
try {
OneLiner head(headerfile);
Динамическая память
header = head.getText();
очищается корректно
OneLiner foot(footerfile);
Происходит утечка динамической
footer = foot.getText();
памяти, а не ее очистка
} catch(const std::string& ex) {
std::cout << ex << '\n';
}
}
};
int main() {
Page chapter("Introduction to C++", "header.txt", "footer.txt");
return 0;
}
Деструктор класса OneLiner корректно освобождает выделенный динамический
ресурс при нормальном выполнении. Однако при ошибке все кардинально меняется в худшую сторону. Объект head уничтожается, когда исключение прерывает
выполнение блока try. Объект foot, казалось бы, должен быть уничтожен аналогичным образом, но это впечатление обманчиво. Следующий вывод показывает,
что происходит на самом деле, — обратите внимание, что освобождение памяти
происходит только один раз:
header.txt allocated
footer.txt allocated
header.txt ~deallocated
file footer.txt inaccessible
10.4. Ошибка #76
333
Анализ
При создании объекта head выделяется динамическая память, file успешно открывается, и может быть выполнена операция чтения. Конструктор завершает
ее, и объект head оказывается полностью созданным.
Динамическая память также выделяется при создании объекта foot, однако
попытка открыть file завершается ошибкой. Это приводит к выбрасыванию исключения, поскольку конструктор не может устранить проблему. Он корректно
определяет, что невозможно ничего сделать, поэтому генерирует исключение.
Выполнение конструктора прерывается — и здесь начинаются проблемы.
Когда основная функция выполняет блок try, она успешно создает объект head.
При попытке создания экземпляра foot генерируется исключение. Блок try
немедленно завершается, и выполняется блок catch. Однако перед его выполнением необходимо осуществить раскрутку стека, чтобы очистить все ресурсы,
созданные в области видимости try. Объект head уничтожается, освобождая
динамическую память. Экземпляр foot так и не был полностью построен, поэтому он не очищается. Вызов деструктора для частично созданного объекта
привел бы к многочисленным проблемам, поскольку было бы непонятно, какие
части объекта были правильно инициализированы. Поэтому деструктор не
вызывается, и память утекает. Таким образом, объект foot приводит к утечке
динамически выделенной памяти.
Решение
Решение простое: перед тем как выбросить исключение из конструктора,
вручную освободите все выделенные динамические ресурсы — звучит легко!
На практике такой подход может привести к дублированию кода очистки, появлению странной или запутанной логики освобождения и, возможно, к потере
некоторых ресурсов — в зависимости от сложности конструкции. Следующий
код освобождает динамическую память, перед тем как генерировать исключение,
предотвращая ее утечку.
Листинг 10.6. О
чистка динамических ресурсов перед генерацией
исключения
class OneLiner {
private:
std::string filename;
char* text;
public:
OneLiner(const std::string& filename) : filename(filename), text(0) {
text = new char[64];
std::cout << filename << " allocated\n";
std::ifstream file(filename.c_str());
if (file.fail()) {
334
Глава 10. Исключения и ресурсы
std::cout << filename << " deallocated dynamic memory\n";
delete [] text;
throw std::string("file " + filename + " inaccessible");
}
// чтение данных в text
};
}
~OneLiner() {
std::cout << filename << " ~deallocated\n";
delete [] text;
}
char* getText() const { return text; }
Освобождает динамический
ресурс, перед тем как
выбросить исключение
class Page {
private:
std::string title;
char* header;
char* footer;
public:
Page(const std::string& title, const std::string& headerfile, const
std::string& footerfile) :
title(title), header(0), footer(0) {
Обычный вызов деструктора
try {
для освобождения памяти
OneLiner head(headerfile);
header = head.getText();
OneLiner foot(footerfile);
Ошибка приводит к освобождению
footer = foot.getText();
памяти прямо в конструкторе
} catch(const std::string& ex) {
std::cout << ex << '\n';
}
}
};
int main() {
Page chapter("Introduction to C++", "header.txt", "footer.txt");
return 0;
}
Следующий вывод показывает результат освобождения ресурсов из конструктора. Все соответствует ожиданиям:
header.txt allocated
footer.txt allocated
footer.txt deallocated dynamic memory
header.txt ~deallocated
file footer.txt inaccessible
Несмотря на всю привлекательность такого подхода, используйте его только
в тех случаях, когда невозможно сделать что-то более простое и элегантное. Следующая ошибка рекомендует идиому, разработанную создателем C++, — и если
10.5. Ошибка #77
335
Бьёрн (Bjarne) говорит, что его нужно использовать, давайте будем его использовать!
Основная сложность в том, что класс OneLiner выполняет сразу множество
задач: выделяет память, открывает файл и считывает данные. Жизнь стала бы
проще, если бы существовал способ минимизировать его обязанности, переложив рутинные задачи выделения и освобождения памяти на другой класс.
Идиома RAII решает эту задачу. Поэтому, если есть возможность, используйте
ее; прибегайте к ручной очистке в конструкторах только тогда, когда другого
выхода нет.
Современный C++ предоставляет умные указатели для более лаконичного решения этой проблемы. Указатели std::unique_ptr и std::shared_ptr реализуют
RAII, избавляя вас от необходимости выполнять тяжелую работу.
Рекомендации
Убедитесь, что все выделенные динамические ресурсы корректно освобо
ждаются перед выходом из конструктора с помощью исключения.
Убедитесь, что деструктор обрабатывает все динамические ресурсы.
Помните, что деструктор будет вызван только для полностью созданного
объекта.
Старайтесь использовать идиому RAII.
Смотри также
«Ошибка #8», где мы обсуждаем реализацию RAII для ресурсов с эксклюзивными указателями из стандартной библиотеки шаблонов.
«Ошибка #9», где мы разбираем реализацию RAII для ресурсов с общими
(разделяемыми) указателями из стандартной библиотеки шаблонов.
«Ошибка #77», чтобы узнать гораздо более эффективный подход к управлению динамическими ресурсами.
10.5. Ошибка #77: неиспользование идиомы RAII
Эта ошибка влияет на корректность и читаемость. Читаемость улучшается,
когда важная семантическая идея выражена через простую и понятную идиому.
Корректность поддерживается путем удаления динамических или ограниченных
ресурсов при любых условиях.
Динамические и ограниченные ресурсы накладывают требования на владение,
и управлять ими непросто, поскольку компилятор не может помочь в обнаружении проблем. Поэтому разработчикам необходимо сопоставлять каждое
336
Глава 10. Исключения и ресурсы
выделение памяти с соответствующим освобождением. Это становится особенно
сложным при наличии исключений. Правильный дизайн должен учитывать как
нормальный путь исполнения, так и путь с обработкой ошибок. В «Ошибке #76»
эта ситуация подробно обсуждалась, и было предложено решение. Еще лучше
найти общий вариант, который освобождает разработчика от необходимости
управления кодом и перекладывает обработку динамических ресурсов на код.
Такое решение — создание классов управления ресурсами, которые следуют
идиоме RAII.
Проблема
Рассмотрим случай, когда для хранения набора результатов тестов требуется
динамический массив, размер которого заранее неизвестен. Клиентский код
определяет количество элементов, выделяет память, вносит значения в массив
и передает эту информацию конструктору. Конструктор (хотя в этом нет необходимости) выделяет свой динамический массив, копирует в него входные
значения и вычисляет среднее. Разработчик знает, что отрицательные значения
недопустимы, поэтому проверяет их и выбрасывает исключение при обнаружении ошибки.
Следующий код пытается сделать все правильно, но необходимо уточнить, как
данные, созданные в одной функции, должны передаваться в другую. Эта попытка частично решает проблему, но делает это неуклюже.
Листинг 10.7. Утечка ресурсов при возникновении исключения
class Grades {
private:
int size;
double* grades;
double average;
public:
Grades(int size, const double* grades) : size(size), grades(0),
average(0) {
if (size == 0)
return;
Выделение динамического ресурса
this->grades = new double[size];
Ресурс не освобождается, перед
for (int i = 0; i < size; ++i) {
тем как выбросить исключение
if (grades[i] < 0)
throw std::invalid_argument("negative score");
this->grades[i] = grades[i];
}
double sum = 0.0;
for (int i = 0; i < size; ++i)
sum += this->grades[i];
average = sum/size;
}
~Grades() { delete [] grades; }
10.5. Ошибка #77
};
337
double getAverage() const { return average; }
int main() {
int count;
std::cout << "Enter number of items to average: ";
std::cin >> count;
double* scores = new double[count];
for (int i = 0; i < count; ++i) {
std::cout << "Enter score: ";
std::cin >> scores[i];
}
}
try {
Grades g(count, scores);
std::cout << "Average score is " << g.getAverage() << '\n';
} catch (const std::invalid_argument& ex) {
std::cout << ex.what() << '\n';
}
delete [] scores;
return 0;
Анализ
При обнаружении отрицательного значения конструктор генерирует исключение. Ресурс grades не освобождается, что приводит к утечке памяти. В коде
есть как минимум три ошибки. Во-первых, конструктор выделяет динамический
массив, а затем проверяет данные и при недопустимом значении выбрасывает
исключение без учета освобождения ресурса. Во-вторых, конструктор вычисляет
среднее значение, хотя это не его ответственность. Здесь происходит слишком
много действий, которые размывают смысл кода и затрудняют обнаружение
проблемы с ресурсами. В-третьих, нет необходимости выделять динамический
массив: копирование массива для корректного доступа к данным о результатах
тестов не требуется; существуют более эффективные способы получить владение.
Наш отважный разработчик прочитал статью о RAII и решил, что код следует
обновить в соответствии с ее рекомендациями. Поэтому он передает входной
массив значений классу Grades, реализующему идиому RAII, чтобы экземпляр
мог самостоятельно управлять данными. Первая попытка показана в листинге 10.8, где конструктор больше не выделяет динамический ресурс, а принимает
владение на себя.
Несколько статей действительно описывают такой подход и утверждают, что
конструктор не обязан создавать динамические ресурсы, но может принимать
их владение. Это хороший и правильный совет; однако проблема совместного
использования остается, и создатель ресурса должен передать полное владение
классу RAII. Следующий код иллюстрирует эту путаницу.
338
Глава 10. Исключения и ресурсы
Листинг 10.8. Идиома RAII с общим ресурсом
class Grades {
private:
int size;
double* grades;
public:
Принимает ресурс во владение
Grades(int size, double* grades) : size(size),
Освобождает полученный
grades(grades) {}
ресурс при любых условиях
~Grades() { delete [] grades; }
double getAverage() const {
if (size == 0)
return 0.0;
double sum = 0.0;
for (int i = 0; i < size; ++i) {
if (grades[i] < 0)
throw std::invalid_argument("negative score");
sum += grades[i];
}
return sum/size;
}
};
int main() {
int count;
std::cout << "Enter number of items to average: ";
std::cin >> count;
double* scores = new double[count];
Создает исходный ресурс
for (int i = 0; i < count; ++i) {
std::cout << "Enter score: ";
std::cin >> scores[i];
}
}
try {
Grades g(count, scores);
std::cout << "Average score is " << g.getAverage() << '\n';
} catch (const std::invalid_argument& ex) {
std::cout << ex.what() << '\n';
delete [] scores;
Освобождает исходный ресурс
}
при возникновении ошибки
return 0;
Эта попытка заслуживает похвалы, но необходимо соблюдать семантику уникальных ресурсов. Если вызывающий код желает передать владение классу RAII,
он не должен впоследствии управлять этим ресурсом или обращаться к нему — он
должен опираться исключительно на интерфейс, предоставляемый этим классом. Выполнение кода показывает, что при возникновении ошибки происходит
двойное освобождение памяти. Такое случается, когда указатель удаляется
дважды, что приводит к неопределенному поведению. Когда это происходит,
моя система аварийно завершает работу, что указывает на реальную проблему.
10.5. Ошибка #77
339
Деструктор, следуя идиоме RAII, корректно удаляет ресурс; однако разработчик
ошибочно решил, что исходный владелец также должен выполнить явное удаление — отсюда и двойное освобождение. Если исключение не генерируется, класс
RAII обрабатывает все корректно. В такой ситуации настоятельно рекомендуется проводить тестирование для обеспечения корректности как в нормальном
режиме, так и в режиме ошибки.
Решение
Основная идея RAII заключается в том, что конструктор должен выделять
динамические ресурсы. Во многих случаях оптимально сделать это единственной ответственностью конструктора (помимо всего, что указано в его списке
инициализации). В некоторых ситуациях это может быть непрактично, поэтому
подход следует изменить так, чтобы получение динамического ресурса было последней операцией конструктора. Таким образом, фаза инициализации (то есть
инициализация) завершается только в том случае, если ресурс был успешно
получен (получение ресурса), что предотвращает утечки при возникновении
исключения. Если конструктор завершился успешно, деструктор будет вызван
как при нормальной работе, так и в режиме ошибки. Если получить ресурс не
удалось, деструктор не будет вызван — это корректно, поскольку освобождать
нечего. Такой подход «все или ничего» (all-or-nothing) лежит в основе идиомы
RAII.
В приведенном ниже коде есть один существенный недостаток: класс RAII является неполным, поскольку не реализует конструктор копирования и оператор
присваивания копированием. Кроме того, полный RAII-класс, скорее всего, будет
предоставлять операторы доступа к указателю. Все такие операции должны быть
запрограммированы так, чтобы избежать ошибок. Для полной реализации RAII
обычно используют операторы по умолчанию.
Листинг 10.9. RAII с эксклюзивным владением
class Grades {
private:
int size;
int next;
double* grades;
public:
Инициализация сводится
Grades(int size) : size(size), next(0), grades(0) {
только к выделению ресурса
this->grades = new double[size];
}
~Grades() { delete [] grades; }
Вызывается только
void addGrade(double grade) { grades[next++] = grade; }
в случае успешного
double getAverage() const {
выполнения
if (size == 0)
конструктора
return 0.0;
double sum = 0.0;
for (int i = 0; i < next; ++i) {
if (grades[i] < 0)
throw std::invalid_argument("negative score");
sum += grades[i];
}
return sum/size;
}
Grades(int size) : size(size), next(0), grades(0) {
только к выделению ресурса
this->grades = new double[size];
}
~Grades() { delete [] grades; }
Вызывается только
void addGrade(double grade) { grades[next++] = grade; }
в случае успешного
double getAverage() const {
выполнения
340 ifГлава
10.==Исключения
и ресурсы
(size
0)
конструктора
return 0.0;
double sum = 0.0;
for (int i = 0; i < next; ++i) {
if (grades[i] < 0)
throw std::invalid_argument("negative score");
sum += grades[i];
}
return sum/size;
}
};
int main() {
int count;
std::cout << "Enter number of items to average: ";
std::cin >> count;
Grades g(count);
Класс RAII приобретает
for (int i = 0; i < count; ++i) {
динамический ресурс
std::cout << "Enter score: ";
double grade;
std::cin >> grade;
g.addGrade(grade);
}
Деструктор вызывается как в обычном
}
режиме, так и в режиме управления ошибками
try {
std::cout << "Average score is " << g.getAverage() << '\n';
} catch (const std::invalid_argument& ex) {
std::cout << ex.what() << '\n';
}
return 0;
Динамические и ограниченные ресурсы (например, дескриптор соединения
с базой данных) встречаются практически во всех программах, кроме самых
маленьких. Программисты тщательно следят за тем, чтобы их код работал корректно, но легко упустить из виду проблему влияния программы на систему.
Это взаимодействие необходимо учитывать для обеспечения корректности.
Управление динамическими и ограниченными ресурсами может быть довольно
сложным, но идиома RAII предлагает способ перенести механизмы управления
в классы, владеющие ресурсами. По возможности, ответственность за управление
следует передавать в класс, предназначенный исключительно для управления
ресурсами. Идиома RAII гарантирует, что все успешно полученные ресурсы
будут автоматически удалены в нужный момент без добавления какой-либо
логики вручную.
Мне всегда было трудно полностью осмыслить фразу получение ресурса есть
инициализация, хотя реализация этой концепции достаточно проста. Некоторые
предлагают использовать более семантически точный термин: управление ресурсами с привязкой к области видимости (Scope-Bound Resource Management,
SBRM). Я изменил эту фразу, добавив пояснительный текст, чтобы сделать
ее более понятной: получение ресурса есть цель инициализации этого объекта.
10.6. Ошибка #78
341
Я слышал, что кто-то на StackOverflow предложил выражение уничтожение —
это освобождение ресурса, которое очень хорошо отражает суть вопроса. Фил
Карлтон (Phil Karlton) говорил, что в computer science есть только две сложные
вещи: инвалидация кэша и именование. Вот и все; этого и придерживайтесь.
Рекомендации
Используйте идиому RAII для управления динамическими и ограниченными ресурсами: паттерн гарантирует, что все полученные ресурсы будут
автоматически освобождены.
Ограничьте обязанности конструктора только выделением ресурса, если
это возможно; в противном случае сделайте получение ресурса последней
операцией.
Если конструктору передается ресурс, убедитесь, что вызывающий код
больше никогда не будет обращаться к нему, — это ресурс, находящийся
в эксклюзивном владении класса RAII.
Смотри также
«Ошибка #54», где мы обсуждаем поведение при неправильном копировании.
«Ошибка #62», где мы разбираем проблемы, связанные с уникальным владением объектами.
«Ошибка #76», чтобы найти обоснование использования идиомы RAII.
«Ошибка #79», чтобы увидеть рекомендации по правильному использованию
ключевых слов.
10.6. Ошибка #78: использование сырых указателей
на ресурсы
Эта ошибка влияет на корректность и эффективность. Пока суть не будет ясна,
неопытные читатели могут столкнуться с негативными последствиями при
работе с этой идиомой.
Управление динамическими и ограниченными ресурсами становится довольно
затруднительным, когда разработчик использует указатели и должен контролировать их как по всем нормальным путям выполнения программы, так и по
путям обработки ошибок. Идиома RAII, реализованная в классах, которые автоматически управляют ресурсами, является огромным шагом вперед в решении
подобных задач. Однако каждый раз, когда требуется обработка очередного
ресурса, приходится разрабатывать новый класс.
342
Глава 10. Исключения и ресурсы
Использование указателей для управления динамическими ресурсами — паттерн,
который применяется уже много лет, начиная с языка C, и до сих пор встречается
в коде на C++. Сложность управления ресурсами с помощью сырых указателей
привела к появлению решения под названием auto_ptr. Этот тип указателя
был предназначен для решения проблем, связанных с сырыми указателями, но
в одном из редких серьезных просчетов он вызвал значительные затруднения
и выглядел, мягко говоря, странно.
Проблема
Во многих унаследованных кодовых базах можно встретить несколько сырых
указателей на ресурсы, разбросанных по всему проекту. Ранее уже обсуждались
некоторые подводные камни их использования для управления ресурсами. Была
предложена идиома RAII, но для каждого типа ресурса необходимо разрабатывать новый класс. Учитывая накладные расходы, связанные со следованием
этому шаблону, отказ от сырых указателей может показаться пугающе сложным.
Код в листинге 10.10 — это упрощенное представление части ресурсов, управляемых с помощью сырых указателей. Предполагается, что внесение изменений
в такой код затруднительно и, как правило, не приветствуется техническими
специалистами высшего звена и руководством. У разработчика остается несколько вариантов, кроме изучения кода и моделирования нормальных путей
и путей обработки ошибок. Код корректен, но программист тратит слишком
много времени на обдумывание и написание кода для управления экземплярами класса Student. Это время бесценно, но, к сожалению, оно потрачено не на
решение проблемы, а на нечто совсем иное.
Листинг 10.10. И
спользование сырых указателей для управления
ресурсами
struct Student {
std::string name;
double gpa;
Student(const std::string& name, double gpa) : name(name), gpa(0) {
if (gpa < 0)
throw std::invalid_argument("gpa is negative");
}
};
Указатель должен быть доступен при нормальном
int main() {
выполнении программы и в случае обработки ошибок
Student* sammy = NULL;
Student* ginny = NULL;
Student* gene = NULL;
Создает экземпляры
try {
и назначает их указателям
sammy = new Student("Samuel", 3.75);
ginny = new Student("Virginia", 3.8);
gene = new Student("Eugene", -1);
} catch (const std::invalid_argument& ex) {
Дублированная очистка
std::cout << "exception caught\n";
в ошибочном пути
if (sammy)
delete sammy;
if (ginny)
delete ginny;
if (gene)
Указатель должен быть доступен при нормальном
int main() {
выполнении программы и в случае обработки ошибок
Student* sammy = NULL;
Student* ginny = NULL;
Student* gene = NULL;
Создает экземпляры
try {
10.6. Ошибка
#78
и назначает
их указателям
sammy = new Student("Samuel", 3.75);
ginny = new Student("Virginia", 3.8);
gene = new Student("Eugene", -1);
} catch (const std::invalid_argument& ex) {
Дублированная очистка
std::cout << "exception caught\n";
в ошибочном пути
if (sammy)
delete sammy;
if (ginny)
delete ginny;
if (gene)
delete gene;
std::exit(1);
}
if (sammy)
Дублированная очистка нормального
delete sammy;
пути работы программы
if (ginny)
delete ginny;
if (gene)
delete gene;
return 0;
}
343
Анализ
Хотя код работает правильно, как и было задумано, значительная его часть занимается исключительно управлением ресурсами, основанными на указателях. Сырые
указатели встречаются в большей части кода, но управление ими можно минимизировать, используя идиому RAII. Этот код разработан так, чтобы программа
завершала работу при возникновении исключения; поэтому нормальный путь
и путь обработки ошибок не могут использовать общий код очистки. Вследствие
этого требуется дублирование логики очистки. Можно вынести ее в отдельную
функцию и вызывать из двух мест, но это принципиально не изменит ситуацию.
Классический C++ предоставляет обобщенный класс, который реализует RAII
и может использоваться для ресурсов, управляемых через указатели. Шаблон
auto_ptr реализует идиому RAII и управляет указателями на ресурсы. Динамический ресурс создается вне auto_ptr, а не в его конструкторе. Конструктор
auto_ptr принимает указатель в эксклюзивное владение, чтобы предотвратить
проблему, описанную ранее.
Код в листинге 10.11 предлагает чистое решение проблемы с помощью класса
auto_ptr; однако у него есть несколько серьезных недостатков. Возникающие
трудности можно разделить как минимум на три значимые группы:
при копировании такого указателя исходный указатель обнуляется;
такие указатели некорректно работают в контейнерах стандартной библио
теки шаблонов;
массивы этих указателей можно создать, но невозможно корректно удалить.
Во-первых, при копировании экземпляра auto_ptr изменяются указатели как
целевого объекта, так и источника: после копирования источнику присваивается
344
Глава 10. Исключения и ресурсы
значение NULL. Это необычная семантика копирования, предназначенная для
предотвращения совместного доступа к ресурсам; однако такое поведение никак
не отражено в названии класса.
Во-вторых, контейнеры STL требуют, чтобы элементы можно было копировать
и присваивать. Если auto_ptr помещается в контейнер, тот получает ресурс
в эксклюзивное владение, а исходный указатель обнуляется (как последствие
предыдущего пункта). Таким образом, управляемые указатели оказываются
фактически некопируемыми и неприсваиваемыми (или, скорее, «передаваемыми»).
В-третьих, массив — единственный вариант, поскольку контейнеры STL не подходят для коллекций умных (управляемых) указателей. Этот подход не будет
работать корректно, поскольку деструктор auto_ptr использует оператор delete,
а не delete[]. Хотя такой массив можно создать, его невозможно корректно
удалить, что приводит к утечке ресурсов.
В листинге 10.11 показан простой подход с использованием auto_ptr, но его
ограничения настолько существенны, что, как правило, их не следует применять.
Стандарт C++11 объявил auto_ptr устаревшим, а из C++17 он был полностью
удален. Такое быстрое объявление шаблона устаревшим говорит о вредоносном
характере этих управляемых указателей и отношении пользователей к ним.
Листинг 10.11. Использование auto_ptr для управления ресурсами
struct Student {
std::string name;
double gpa;
Student(const std::string& name, double gpa) : name(name), gpa(0) {
if (gpa < 0)
throw std::invalid_argument("gpa is negative");
}
};
int main() {
try {
std::auto_ptr<Student> sammy(new Student("Samuel", 3.75));
std::auto_ptr<Student> ginny(new Student("Virginia", 3.8));
std::auto_ptr<Student> gene(
new Student("Eugene", -1));
Нормальный путь и путь обработки
} catch (const std::invalid_argument& ex) {
ошибок запускают деструкторы при
std::cout << "exception caught\n";
выходе из области видимости
}
return 0;
}
Этот простой подход работает так, как ожидалось, но сам по себе он ничем не
примечателен. Преимущество заключается исключительно в автоматическом
10.6. Ошибка #78
345
управлении указателями, когда они выходят за пределы области видимости —
как по умолчанию, так и вследствие генерации исключения. Не отчаивайтесь:
есть способ лучше.
Решение
В идеале ресурсы, основанные на указателях, должны управляться с использованием идиомы RAII. Такой подход усложняет жизнь разработчика, поскольку
приходится писать несколько специальных классов для работы с этими ресурсами. Гораздо удобнее иметь класс, специально предназначенный для решения
подобных ситуаций. В современном C++ для этой цели был предложен класс
auto_ptr. Однако это предложение оказалось серьезной ошибкой, было признано
устаревшим и удалено, что лишь подтверждает его проблемность. Современный
C++ предоставляет надежные и осмысленные умные указатели для тех, кому
посчастливилось иметь возможность их использовать.
Тем не менее разработчику не обязательно писать собственные классы RAII
для управления указателями. Некоторые целеустремленные программисты
проекта Boost выполнили эту работу (https://www.boost.org/). Использование
библиотек Boost — пусть даже только для умных указателей — стоит того,
если у вас нет возможности применять современные стандарты C++. Код в листинге 10.12 демонстрирует использование boost::scoped_ptr, обладающего
семантикой эксклюзивного владения; для совместного пользования применяйте boost::shared_ptr. Эти указатели Boost работают ожидаемым образом
и устраняют недостатки устаревшего std::auto_ptr. Более того, они легли
в основу указателей современного C++ std::unique_ptr и std::shared_ptr. Не
задумываясь используйте их!
Листинг 10.12. И
спользование boost::scoped_ptr для управления
ресурсами
#include <boost/scoped_ptr.hpp>
struct Student {
std::string name;
double gpa;
Student(const std::string& name, double gpa) : name(name), gpa(0) {
if (gpa < 0)
throw std::invalid_argument("gpa is negative");
}
};
int main() {
try {
boost::scoped_ptr<Student> sammy(new Student("Samuel", 3.75));
boost::scoped_ptr<Student> ginny(new Student("Virginia", 3.8));
boost::scoped_ptr<Student> gene(
346
}
Глава 10. Исключения и ресурсы
new Student( "Eugene", -1));
} catch (const std::invalid_argument& ex) {
std::cout << "exception caught\n";
}
return 0;
Нормальный путь и путь обработки
ошибок вызывают деструкторы при
выходе из области видимости
Теперь разработчик тратит минимум времени и сил на создание кода управления. Остальная работа сосредоточена непосредственно на решении задачи.
Минимизированный код проще писать (то есть он более эффективный) и проще
понимать (то есть он более читаемый). Экземпляры boost::scoped_ptr — это
экземпляры шаблона, который управляет указателями на объекты Student. Конструктор принимает указатель и тем самым получает эксклюзивное владение
ресурсом. Присваивание запрещено; если оно необходимо, используйте версию
с общим указателем. Всякий раз, когда указатель выходит за пределы области
видимости, динамический ресурс освобождается. Обратите внимание, что это
только предполагаемое поведение.
Современный C++ позволяет передавать экземпляры std::unique_ptr с помощью функции std::move. В этом случае исходный экземпляр становится
перемещенным, и владение передается целевому объекту с помощью оператора
присваивания копированием. Например:
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
…
std::unique_ptr<int> ptr2 = std::move(ptr1);
Рекомендации
По возможности заменяйте сырые указатели на современные умные указатели C++ (или Boost, если современный вариант недоступен).
По возможности заменяйте auto_ptr как минимум на умные указатели Boost;
однако гораздо лучше использовать современные версии C++.
Не используйте auto_ptr в контейнерах STL — их семантика копирования
непредсказуема.
Не используйте auto_ptr для массивов — их деструктор приведет к неопределенному поведению.
По возможности рассматривайте unique_ptr и shared_ptr современного
языка C++ в качестве предпочтительной альтернативы.
Смотри также
«Ошибка #8», где мы обсуждаем реализацию RAII для ресурсов с эксклюзивными указателями из стандартной библиотеки шаблонов.
10.7. Ошибка #79
347
«Ошибка #9», где мы разбираем реализацию RAII для ресурсов с общими
(разделяемыми) указателями из стандартной библиотеки шаблонов.
«Ошибка #28», чтобы узнать, почему NULL является неудачным выбором
для указателя.
«Ошибка #76», чтобы понять сложности управления динамическими ресурсами.
«Ошибка #77» для получения информации о создании классов RAII для
автоматического управления динамическими и ограниченными ресурсами.
10.7. Ошибка #79: смешение форм
операторов new и delete
Эта ошибка влияет на корректность. Неправильное использование форм операторов new и delete приводит к неопределенному поведению, оставляя программу
и систему в неизвестном состоянии.
Динамические ресурсы используются по-разному, например, когда размеры сущностей неизвестны на этапе компиляции. Возможность получения динамической
памяти, размер которой вычисляется во время выполнения, крайне важна для
безопасного и корректного использования. В C++ предусмотрены операторы
new и delete для выделения и освобождения памяти. Каждый из них имеет две
формы: для отдельной сущности и для массива.
Проблема
Начинающие студенты-программисты часто увлекаются массивами — они предлагают одно имя переменной для нескольких значений, различая элементы по
вычисляемому значению индекса. Огромные возможности массивов — весомый
стимул для их использования, и это заставляет многих новичков (и профессионалов) злоупотреблять ими. В большинстве случаев std::vector оказывается более
удачным выбором; однако массивы никуда не денутся, как и следовало ожидать.
Мы разрабатываем простую программу, которая поможет учителям вычислять
средний балл. У каждого студента есть несколько тестов и дополнительные
баллы, которые необходимо усреднить для получения итоговой оценки. Поскольку дополнительные задания необязательны (дополнительные баллы), их
количество неизвестно и варьируется в зависимости от студента. Простейшим
способом будет создание массива, размер которого значительно превышает
предполагаемое максимальное число элементов. Сразу возникают две проблемы:
во-первых, что, если это количество окажется меньше фактического максимального значения и, во-вторых, зачем тратить место на ненужные элементы? Первая
проблема связана с корректностью работы программы, и ее нельзя упускать из
348
Глава 10. Исключения и ресурсы
виду. Вторая проблема — стилистическая (прямых проблем с корректностью
нет), но более опытным разработчикам такая ситуация кажется неправильной.
Кроме того, могут возникнуть вопросы к производительности, поэтому такой
подход действительно проблематичен.
Листинг 10.13. П
ростой код вычисления среднего с несовпадающими
формами new/delete
int main() {
int count;
std::cout << "Enter number of items to average: ";
std::cin >> count;
double* scores = new double[count];
Выделяет вычисленный
for (int i = 0; i < count; ++i) {
объем памяти
std::cout << "Enter score: ";
std::cin >> scores[i];
}
double sum = 0.0;
for (int i = 0; i < count; ++i)
sum += scores[i];
delete scores;
}
Освобождает ли это
выделенную память?
if (count > 0)
std::cout << "Average score is " << sum/count << '\n';
else
std::cout << "No items to average\n";
return 0;
Анализ
Код выглядит разумно и работает как ожидается; вычисленные средние значения
верны. Однако существует серьезная проблема с удалением выделенной памяти.
Оператор new получает фиксированный объем памяти, зависящий от размера
сущности. Если выделение производится для одной сущности, необходимый
объем памяти точно соответствует ее размеру. В этом случае используется
обычная форма new без нотации [].
Форма записи new [] применяется тогда, когда выделяемая память предназначена для нескольких сущностей; используется нотация []. Размер элемента
умножается на количество элементов, и выделяется соответствующий объем
памяти. Однако это еще не все.
Компиляторы могут реализовывать управление памятью по своему усмотрению,
но во многих случаях дополнительная информация о размере массива хранится непосредственно в выделенной памяти. Обычно компиляторы помещают
число элементов прямо перед началом возвращаемого блока; поэтому первый
10.7. Ошибка #79
349
элемент следует за этим значением, при этом код доступа к массиву ничего не
знает о размере.
В листинге 10.13 массив динамически выделяется, и программа вычисляет
сумму баллов. Затем указатель scores удаляется. Что произойдет в этом случае,
остается только гадать; возможно, освобождается некоторое количество байтов,
отведенных для типа элемента указателя. Поскольку число элементов хранится в начале, оно — а возможно, и часть первого элемента — может попасть под
освобождение или же удален будет только первый элемент. Кто знает? Форма
оператора delete (без нотации массива) освобождает ровно один элемент. Что
происходит с остальной частью массива — не определено, но, возможно, она
прекрасным образом утекает. Это не вызывает каких-либо видимых проблем,
но такое поведение некорректно.
Что произойдет, если после обычного new последует оператор delete [], предсказать еще сложнее (хотя здесь и так мало что понятно). Каким, по мнению
оператора delete [], будет количество элементов? О, это выглядит совсем нехорошо! Неопределенное поведение на то и неопределенное: программа может
делать все, что захочет, и почти наверняка не так, как ожидает разработчик.
Решение
Рассматривайте операторы new и delete как пару: для каждого new должен быть
соответствующий delete, а каждому new [] должен соответствовать оператор
delete []. Никогда не перемешивайте эти пары. Их смешение приводит к неопределенному поведению, что всегда плохо, и даже если кажется, что программа
работает, она все равно некорректна. В следующем коде показано небольшое,
но существенное изменение, внесенное для правильного сочетания оператора
delete [] с соответствующим ему оператором new [].
Листинг 10.14. Правильное сочетание операторов new и delete
int main() {
int count;
std::cout << "Enter number of items to average: ";
std::cin >> count;
double* scores = new double[count];
Выделяет массив
for (int i = 0; i < count; ++i) {
вычисленного размера
std::cout << "Enter score: ";
std::cin >> scores[i];
}
double sum = 0.0;
for (int i = 0; i < count; ++i)
sum += scores[i];
delete [] scores;
Удаляет массив, сопоставляя его
с выделенной памятью
350
}
Глава 10. Исключения и ресурсы
if (count > 0)
std::cout << "Average score is " << sum/count << '\n';
else
std::cout << "No items to average\n";
return 0;
Рекомендации
Убедитесь, что используется правильная форма оператора delete, соответствующая форме оператора new; их смешение приводит к неопределенному
поведению.
Внимательно изучите код при использовании операторов new и delete, чтобы
убедиться в правильности их сочетания; если в одной функции содержится
несколько операторов new и new [], пары new и delete легко перепутать.
Минимизируйте размер функций, чтобы избежать смешения форм.
10.8. Ошибка #80: доверие к спецификациям
исключений
Эта ошибка в первую очередь влияет на корректность и читаемость, что может
привести к снижению производительности. Проблема с корректностью заключается в том, что код может работать не так, как ожидается, а с читаемостью —
в том, что код кажется интуитивно понятным, но на самом деле это не так.
Исключения предоставляют чистый способ сообщать об ошибках через альтернативный путь выполнения, значительно упрощая код. Документирование
ожидаемых исключений осуществляется путем чтения всех соответствующих
блоков catch; однако такой подход оставляет желать лучшего. Хорошей идеей
казалось документировать исключения, которые выбрасывает функция, указывая их в списке спецификаций. Один взгляд на список должен давать полное
представление о ситуации.
Проблема
Обработка исключений упрощается, когда разработчик пишет весь затрагиваемый код. Однако возможность увидеть и понять различные ошибки значительно
снижается при использовании библиотек или других модулей. Код в листинге 10.15 вычисляет стандартное отклонение для набора значений в векторе.
Разработчик позаботился о том, чтобы слишком малое количество значений
приводило к генерации исключения с целью предотвращения получения неправильного результата в функции std_dev. Будучи осмотрительным программистом, он добавил спецификацию исключений, чтобы задокументировать, что
10.8. Ошибка #80
351
функция может сгенерировать std::invalid_argument, если элементов слишком
мало. Он также продусмотрел, чтобы вызывающий код мог восстановиться после
исключения, поскольку завершение программы нежелательно.
В нормальных условиях этот код работает корректно, но ситуация принимает
неприятный оборот, когда значения не переданы. Ожидается, что будет сгенерировано исключение при слишком малом числе значений. Однако программист
упустил из виду, что функция arith_mean вызывается до проверки размера
вектора. Именно она обнаружила проблему отсутствия данных и выбросила
исключение. Тем не менее, несмотря на всю проявленную разработчиком осторожность, программа внезапно падает.
Листинг 10.15. Список исключений, которые могут быть сгенерированы
double arith_mean(const std::vector<double>& values) {
if (values.size() == 0)
throw std::domain_error("missing values");
double sum = 0.0;
for (int i = 0; i < values.size(); ++i)
sum += values[i];
return sum/values.size();
}
Выбрасывает исключение
std::domain_error, если
элементов слишком мало
Предусматривает возможность
возникновения исключения
double std_dev(const std::vector<double>& values)
std::invalid_argument
throw (std::invalid_argument) {
double mean = arith_mean(values);
if (values.size() < 2)
throw std::invalid_argument("too few values");
Выдает исключение
double sum = 0.0;
std::invalid_
for (int i = 0; i < values.size(); ++i)
argument, если
sum += std::pow(values[i] - mean, 2);
элементов
слишком
return std::sqrt(sum / (values.size() - 1));
мало
}
int main() {
std::vector<double> values;
values.push_back(3.14159);
values.push_back(2.71828);
try {
std::cout << "standard deviation is " << std_dev(values) << '\n';
} catch (...) {
std::cout << "exception caught, but we did not crash\n";
}
return 0;
}
Разработчик был удивлен поведением программы в исключительной ситуации
и недоумевал, почему блок catch функции main не смог спасти ее.
352
Глава 10. Исключения и ресурсы
Анализ
Попытка разработчика контролировать поведение программы путем указания
исключений была разумной, но неполной. Если вы досконально не понимаете
свой код, указание исключений в списке спецификаций превращается в гадание
на кофейной гуще. Если вы угадаете правильно, программа будет вести себя как
ожидается; если ошибетесь — она аварийно завершит работу.
Компилятору приходится прилагать определенные усилия для обработки
спецификации исключений. В программе, которая не использует такие специ
фикации, любое сгенерированное исключение поднимается вверх по стеку во
время его раскрутки. Как только добавляется спецификация, поведение программы меняется непостижимым образом. Компилятор вставляет код для проверки выброшенных исключений и сверяет его со списком. Если исключение
соответствует, оно передается дальше обычным способом; если нет — механизм
генерирует исключение std::unexpected, стандартное поведение которого — вызвать terminate (который, в свою очередь, вызывает abort). Этот дополнительный
код также влияет на производительность.
Если разработчик желает преобразовать непредвиденное исключение во что-то
более управляемое, он может написать функцию, которая генерирует известное
исключение (стандартное или пользовательское), и передать ее в set_unexpected.
Вкратце код будет выглядеть следующим образом:
class MyException {};
void transformException() { throw MyException(); }
set_unexpected(transformException);
При использовании этого подхода результирующее исключение MyException
должно быть перехвачено в каком-либо блоке catch. Существенный недостаток
этого способа в том, что вся программа преобразует исключение unexpected
в MyException, что значительно затрудняет понимание его контекста. Такое решение рабочее, но из-за своей чрезмерной обобщенности — вредное. Попытки
документировать исключения таким образом зачастую только скрывают проблемы, которые необходимо решать более эффективно.
Решение
Ввиду неопределенности относительно того, можно ли предвидеть и перечислить все возможные исключения в спецификации, наиболее радикальным и при
этом эффективным подходом будет полный отказ от спецификаций. Их удаление устраняет возможность непредвиденного (и нежелательного) поведения,
которое выходит за рамки интуитивного понимания механизма исключений.
Значительная часть вызываемого библиотечного кода не может быть адекватно проанализирована на предмет всех потенциальных исключений, поэтому
10.8. Ошибка #80
353
использование списка спецификаций лишь повышает вероятность столкнуться
с непредвиденным поведением.
Спецификации исключений были экспериментом, и в современном C++ они
удалены. Этот факт свидетельствует о том, что эксперты не доверяли такому
механизму. Мы можем воспринимать это как весомый намек: стоит поступить
так же и удалить все спецификации исключений.
В следующем коде продемонстрирована такая корректировка. В качестве альтернативы закомментированный заголовок функции указывает оба возможных
исключения; однако в реальной системе, скорее всего, невозможно будет указать
полный список потенциальных вариантов. Если руководство или технические
специалисты не требуют обратного, избегайте подобных решений.
Листинг 10.16. Удаление спецификации исключений
double arith_mean(const std::vector<double>& values) {
if (values.size() == 0)
throw std::domain_error("missing values");
double sum = 0.0;
for (int i = 0; i < values.size(); ++i)
sum += values[i];
return sum/values.size();
}
// double std_dev(const std::vector<double>& values)
➥throw (std::invalid_argument, std::domain_error) {
double std_dev(const std::vector<double>& values) {
if (values.size() < 2)
throw std::invalid_argument("too few values");
double mean = arith_mean(values);
double sum = 0.0;
for (int i = 0; i < values.size(); ++i)
sum += std::pow(values[i] - mean, 2);
return std::sqrt(sum / (values.size() - 1));
}
Как вариант: добавьте все
обнаруженные исключения
(будет ли этот список полным?)
Полностью удалите спецификацию исключений
Убедитесь, что локальная
проверка выполняется до
вызова любого другого кода
int main() {
std::vector<double> values;
values.push_back(3.14159);
values.push_back(2.71828);
try {
std::cout << "standard deviation is " << std_dev(values) << '\n';
} catch (...) {
std::cout << "exception caught, but we did not crash\n";
}
return 0;
}
354
Глава 10. Исключения и ресурсы
Хотя списки спецификаций исключений были (теоретически) хорошей идеей,
непредвиденное поведение, дополнительные затраты на реализацию и невозможность выполнить то, что они, казалось бы, обещают, делают лучший подход
очевидным — просто не использовать их.
Рекомендации
По возможности удаляйте списки спецификаций исключений.
Старайтесь разобраться, к какому непредвиденному поведению приводит
генерация исключения, не включенного в список.
Помните о влиянии спецификаций исключений на производительность.
10.9. Ошибка #81: неумение генерировать исключения
по значению и перехватывать их по ссылке
Эта ошибка касается корректности, эффективности и производительности. Исключения могут генерироваться по указателю, значению или ссылке и перехватываться таким же образом. Однако у каждого подхода есть свои преимущества
и недостатки.
Исключения предоставляют альтернативный механизм управления для обработки ошибок. При правильной реализации их использование позволяет создавать отказоустойчивый код и дает разработчику возможность восстанавливать
выполнение после ошибки, которая в противном случае могла бы привести
к завершению программы. Исключения более затратны, чем обычные функции,
а их поведение менее интуитивно. Поэтому понимание некоторых нюансов необходимо для правильного применения такого механизма.
Проблема
Предположим, что наш разработчик изучил различные способы генерации
и перехвата исключений, но не до конца понимает, какой из них оптимальный.
Он проводит несколько экспериментов, чтобы протестировать различные комбинации и увидеть различие.
Разработчик правильно понял, что генерация исключения означает передачу
части данных из точки генерации в точку перехвата. Его интуиция подсказывает,
что оптимальным будет решение, при котором переносится минимальный объем
данных. Поэтому сначала он пытается выбрасывать исключение по указателю.
Не удовлетворенный этим подходом, он решил попробовать разные варианты
генерации по указателю и сравнить их с генерацией по значению.
Код в листинге 10.17 демонстрирует три такие попытки. Первая — генерация
исключения по указателю на локальный объект; вторая — по указателю на
10.9. Ошибка 81
355
объект, размещенный в динамической области памяти; и, для сравнения, третья — генерация исключения по значению. Программист оснастил конструктор
DerivedException и его конструктор копирования инструментами для подсчета
количества вызовов каждого из них (код для этих инструментов не показан).
Листинг 10.17. Перехват исключений по указателю и по значению
struct DerivedException : public std::exception {
std::string message;
DerivedException() : message("DerivedException") {}
~DerivedException() throw() {}
const char* what() const throw() { return message.c_str(); }
};
Переопределяет виртуальный
void throw_by_local_pointer() {
DerivedException de;
throw &de;
}
метод std::exception
void throw_by_heap_pointer() {
DerivedException* de = new DerivedException();
throw de;
}
void throw_by_value() {
DerivedException de;
throw de;
}
int main() {
try {
throw_by_local_pointer();
} catch(std::exception* ex) {
std::cout << ex->what() << '\n';
}
try {
throw_by_heap_pointer();
} catch(std::exception* ex) {
std::cout << ex->what() << '\n';
}
try {
throw_by_value();
} catch(std::exception ex) {
std::cout << ex.what() << '\n';
}
return 0;
}
Разработчик запустил код и заметил, что во всех трех случаях конструктор вызывался лишь один раз, а конструктор копирования — ни разу. Из этих результатов он сделал вывод, что между способами генерации и перехвата исключений
нет различий.
356
Глава 10. Исключения и ресурсы
Анализ
Мы можем похвалить научный подход разработчика, но остаются подозрения,
что в его выводах было упущено нечто важное. Следует подчеркнуть, что при
генерации исключения (по объекту или указателю) локальный экземпляр выходит за пределы области видимости в точке генерации. Поэтому C++ требует,
чтобы было выполнено копирование того, что выбрасывается, а временный
объект (или указатель) был передан в точку перехвата. Таким образом, независимо от выбранного метода генерации исключения, всегда создается копия,
что увеличивает затраты ресурсов.
Функция throw_by_local_pointer (выбрасывание исключения по локальному
указателю) должна вызывать протест у любого программиста. Локальный объект
создается внутри функции, и его адрес попадает в выбрасываемое исключение.
Этот подход минимизирует объем данных, передаваемых из точки генерации
исключения в точку его перехвата, поскольку размер копируемых данных равен
размеру указателя. Однако после выполнения момента выбрасывания функция
завершается, уничтожая локальные переменные. Блок catch получает копию
указателя, но он уже указывает на недействительный объект DerivedException.
Вывод метода what приводит к неопределенному поведению. Это решение действительно быстрое, но оно совершенно неправильное.
Функция throw_by_heap_pointer (выбрасывание исключения по указателю
на объект в динамической памяти) работает так же быстро, как и предыдущая
попытка, но и устраняет проблему недействительного объекта. Однако здесь
возникает менее очевидный вопрос: кто удаляет объект, созданный в куче?
Если точка перехвата — последнее место, где он будет использоваться, то
удаление можно выполнить прямо там. Но исключение может быть переброшено дальше, поскольку восстановить работу программы не удалось. Тогда
кто удалит объект? Этот подход порождает вопрос о зоне ответственности
за освобождение памяти, на который трудно — а порой невозможно — дать
корректный ответ.
Наконец, анализируя случай генерации исключения по значению, разработчик
не видит разницы в количестве вызовов конструктора и ошибочно заключает,
что единственное отличие — это разница между размером указателя и размером
объекта исключения. Возникают две неочевидные проблемы; первую разработчик просто упустил, а вторая вообще осталась скрытой от него.
Вывод what оказывается некорректным для исключения, перехваченного по
значению. Он выводит сообщение из std::exception (буквально std::exception,
ничего больше — неужели нельзя было придумать ничего более умного?) —
полиморфный метод what класса DerivedException не выполняется. Причина
в том, что копия выброшенного исключения используется для инициализации
10.9. Ошибка 81
357
параметра catch. Параметр имеет тип std::exception, поэтому происходит срез
данных: копируется только часть, относящаяся к базовому классу. Переопределенный метод отбрасывается, и выполняется только версия базового класса. Эту
проблему легко обнаружить.
Проблема, которую сложно обнаружить, вытекает из предыдущей — они образуют пару. Очевидная плата за генерацию исключения по значению — это
создание одной копии, как (ошибочно) определил разработчик. Но поскольку
исключение перехватывается его базовым классом, вызывается не конструктор копирования производного класса, а версия базового. Программист не
мог снабдить соответствующими инструментами конструктор копирования
std::exception, поскольку это код стандартной библиотеки; иначе он увидел
бы скрытую копию. При генерации исключения по значению создается копия
временного объекта, а затем она используется для инициализации параметра
catch, итого — две копии.
Решение
В конце концов, наш разработчик стал выбрасывать исключения по значению,
чтобы избежать проблем с удалением указателей. Узнав, что такой способ приводит к двойному копированию, он начал перехватывать исключения по ссылке.
Этот случай — один из немногих, если не единственный, где ссылка на временный объект допустима (подождем появления современного C++). Временный
объект, созданный в точке генерации, передается в параметр catch по ссылке;
следовательно, вторая копия не создается.
Кроме того, перехват по ссылке сохраняет полиморфное поведение, и вызывается версия метода what из DerivedException. Следующий код показывает,
что для минимизации ресурсов на копирование и для сохранения поведения
переопределенного метода требуется небольшая корректировка. Всего лишь
добавьте амперсанд — и получите отличный результат!
Листинг 10.18. Перехватывание исключения по ссылке
struct DerivedException : public std::exception {
std::string message;
DerivedException() : message("DerivedException") {}
~DerivedException() throw() {}
const char* what() const throw() { return message.c_str(); }
};
void throw_by_value_catch_by_reference() {
DerivedException de;
throw de;
Выбрасывайте исключения по значению, поскольку
}
копирование должно произойти в любом случае
int main() {
try {
throw_by_value_catch_by_reference();
} catch(const std::exception& ex) {
std::cout << ex.what() << '\n';
}
return 0;
}
Перехватывание исключения по
ссылке для предотвращения двойного
копирования и сохранения полиморфизма
DerivedException de;
358throwГлава
de; 10. Исключения и ресурсы
}
Выбрасывайте исключения по значению, поскольку
копирование должно произойти в любом случае
int main() {
try {
throw_by_value_catch_by_reference();
} catch(const std::exception& ex) {
std::cout << ex.what() << '\n';
}
return 0;
}
Перехватывание исключения по
ссылке для предотвращения двойного
копирования и сохранения полиморфизма
Разработчик также усвоил важный урок: нельзя полагаться на то, что использование простых инструментов будет достаточным для получения полной картины
поведения программы.
Рекомендации
Выбрасывайте исключения по значению, чтобы избежать проблем, связанных
с генерацией исключений по указателю.
Перехватывайте исключения по ссылке, чтобы минимизировать копирование
и обеспечить полиморфное поведение.
Минимизируйте размер производных классов исключений, чтобы уменьшить
затраты ресурсов на копирование.
Никогда не возвращайте указатель (или ссылку) на локальный объект.
Смотри также
«Ошибка #84», где мы обсуждаем, почему возвращение указателей или ссылок на локальные объекты — плохая практика.
«Ошибка #86», где мы разбираем затрачиваемые ресурсы на передачу указателей, значений и ссылок.
11
Функции и написание кода
В этой главе
3 Выбор правильного типа параметра
3 Возврат нескольких значений из функции
3 Сохранение краткости и лаконичности функций
3 Обеспечение выполнения предусловий и постусловий
для функций
Функции являются основой модульного программирования. Функция — это
именованный фрагмент кода, который можно вызывать множество раз из разных мест программы. Проектирование функций имеет решающее значение для
создания многократно используемого кода, который прост в использовании, обладает предсказуемым поведением и не содержит ошибок. С функциями связано
несколько характерных проблем, главным образом касающихся корректного
определения и применения параметров. Существуют и другие распространенные ошибки, о которых разработчику следует знать, чтобы избегать их в своей
практике.
360
Глава 11. Функции и написание кода
11.1. Особенности проектирования
Функции состоят из имени, списка параметров, возвращаемого значения и тела.
Компилятор требует наличия всех четырех частей, но при этом предоставляет
возможность сделать список параметров и возвращаемое значение кажущимися
необязательными. В некоторых книгах конструкторы (и, по сути, деструкторы)
называются функциями; технически они таковыми не являются, но стандарт
C++ называет их «специальными методами». У конструктора и деструктора нет
возвращаемого значения, поэтому формально они не попадают под определение
функции. Список параметров кажется необязательным, поскольку он может
быть пустым, но он все равно должен быть указан — с помощью пустых круглых
скобок. Возвращаемое значение также кажется опциональным, поскольку его тип
может быть void (отсутствие любого типа данных). Однако это все равно тип;
просто такой, который не имеет значений, или его значением является пустое
множество (для поклонников теории множеств).
Имя — самая сложная часть в определении функции. Хотя можно выбрать любое
именование, часто бывает трудно найти лаконичное и информативное. Оно должно интуитивно понятно объяснять назначение функции, делая ее использование
очевидным. Слишком длинные функции или функции с большим количеством
ответственностей делают выбор имени практически непосильным делом.
В идеале функция должна быть чистой. Чистая функция не обращается ни
к каким переменным или данным за пределами своей области видимости и выполняет работу исключительно на основе данных, переданных через список
параметров. Существуют функции, которые по определению должны влиять на
систему за пределами своей области видимости. Хорошие примеры — операторы
operator<< и operator>>. Поскольку такие функции должны существовать, цель
состоит в том, чтобы разделить их на две категории: чистые функции и функции,
выполняющие только побочные эффекты.
Функции, работающие (только) с побочными эффектами, не должны заниматься
вычислениями, должны иметь минимальную логику и влиять на внешние объекты. Чистые функции, наоборот, должны выполнять вычисления, содержать
всю необходимую логику и возвращать результаты через возвращаемое значение. Некоторые функции должны возвращать несколько значений, что невозможно сделать лишь через return. В таких случаях можно вернуть экземпляр
структуры (или класса) либо использовать выходные параметры. Выходные
параметры сложны для быстрого понимания и влияют на читаемость. Возвращение экземпляра — подход более сложный, но он лучше изолирует входные
данные от выходных.
Параметры функции в виде значения, указателя или ссылки могут быть использованы для передачи входных данных в функцию. В этой книге значения в точке
11.2. Ошибка 82
361
вызова называются аргументами, а переменные в списке параметров — параметрами. (Некоторые авторы и преподаватели используют более сложную для понимания пару терминов: параметры и формальные параметры соответственно.)
Локальные переменные внутри функции инициализируются явным программируемым действием, обычно через оператор присваивания. Параметры — это
тоже локальные переменные, но программист не инициализирует их явно:
компилятор генерирует код для копирования значения аргумента в параметр.
В остальном параметр ничем не отличается от обычной локальной переменной. Важное следствие: локальные переменные уничтожаются при выходе из
функции — как при обычном завершении, так и путем генерации исключения.
К сожалению, это не означает, что уничтоженные переменные обязательно
становятся полностью недоступными. Некоторые системы разрешают доступ
к ним после уничтожения (через указатели или ссылки), что приводит к трудно
диагностируемым ошибкам; даже самые совершенные системы в таких случаях
выходят из строя.
Ошибки, рассмотренные далее, — это попытка систематизировать наиболее распространенные проблемы, связанные с проектированием и использованием функций. Таких ошибок существует много, но некоторые встречаются особенно часто.
11.2. Ошибка #82: использование перегруженных
функций вместо параметров по умолчанию
Эта ошибка влияет на читаемость и эффективность. Написание нескольких перегруженных функций позволяет обеспечить единообразный способ обработки
различных списков параметров, но может оказаться утомительным и привести
к дублированию кода.
Проблема
Часто для решения задачи требуется несколько функций, которые выполняют
одну и ту же работу, но принимают разное количество параметров. Язык C требует, чтобы эти функции имели разные имена; однако C++ позволяет называть
их одинаково при единственном ограничении: списки параметров должны быть
уникальными (будь то типы параметров, их количество или порядок следования
типов). Перегруженные функции используют одно имя, что сохраняет семантику операции.
В следующем коде представлены три перегруженные функции, выполняющие
одинаковую операцию над разными наборами параметров. Такой подход утомителен и бесконечен: когда именно стоит остановиться и считать имеющийся
набор функций достаточным?
362
Глава 11. Функции и написание кода
Листинг 11.1. П
ерегруженные функции, обрабатывающие разное
количество параметров
int sum(int a, int b) { return a + b; }
int sum(int a, int b, int c) { return a + b + c; }
int sum(int a, int b, int c, int d) { return a + b + c + d; }
int main() {
std::cout << sum(3, 4) << '\n';
std::cout << sum(3, 4, 5) << '\n';
std::cout << sum(3, 4, 6, 7) << '\n';
return 0;
}
Анализ
Эти функции выполняют ровно то, что от них ожидается, однако приводят
к дублированию кода и быстро становятся утомительными для написания уже
после первых нескольких вариантов. Хотя это и маловероятно, опечатки в теле
одной функции могут привести к различиям в поведении между перегруженными версиями.
Решение
Использование параметров по умолчанию — гораздо более удачный подход,
который максимально повышает эффективность написания кода, облегчает его
понимание при чтении и, возможно, улучшает корректность. Некоторые функции плохо подходят для этого метода, поскольку для них просто не существует
разумных значений по умолчанию.
Листинг 11.2. И
спользование значений по умолчанию для нескольких
параметров
int sum(int a, int b, int c = 0, int d = 0) { return a + b + c + d; }
int main() {
std::cout << sum(3, 4) << '\n';
std::cout << sum(3, 4, 5) << '\n';
std::cout << sum(3, 4, 6, 7) << '\n';
return 0;
}
Функция sum устраняет дублирование кода, что снижает риск ошибок. Во многих
случаях можно использовать осмысленные значения по умолчанию, но важно
не «поскользнуться на банановой кожуре», создавая слишком универсальные
функции, чье поведение существенно меняется в зависимости от параметров.
Например, если булев параметр при false выполняет некоторую функцию,
а при true — другую (несколько иную) функцию — это нехорошо. Пожалуйста,
11.3. Ошибка #83
363
стремитесь к простоте. Я однажды так «поскользнулся», что это едва не привело
к катастрофе, — это реальная опасность!
Рекомендации
Придерживайтесь простоты функций, чтобы можно было использовать
параметры по умолчанию.
Не используйте параметры по умолчанию, если они существенно меняют
поведение функции.
Смотри также
«Ошибка #18», чтобы узнать о современном подходе к использованию шаблонов вместо параметров по умолчанию.
11.3. Ошибка #83: неиспользование утверждений
Эта ошибка влияет на эффективность и корректность. Существуют два временных этапа при разработке программы: компиляция и выполнение — и два
этапа жизненного цикла программного обеспечения: разработка и продакшен.
Эта ошибка в основном касается этапа разработки.
Проблема
Во время разработки программы тестируются новые идеи, предлагается новый
код и проверяются дополнительные взаимодействия между компонентами. Все
это создает питательную среду для появления и размножения багов — и они
обязательно появятся.
Убедиться в том, что код работает как положено, можно с помощью модульного
и интеграционного тестирования, но перед этим обязательно надо проверить,
выполняются ли предусловия функций. Кроме того, разработчикам крайне
важно обеспечить выполнение постусловий и инвариантов. Однако писать код,
который проверяет и контролирует эти условия, всегда кажется... чем-то, что
можно отложить на потом, — «всегда можно написать это завтра».
Откладывать проверки предусловий, постусловий и инвариантов — заманчиво,
но опасно. Без достаточного тестирования баги будут пролезать и множиться.
Следующий код — именно такой случай: хотя он выглядит безобидным, в нем
таится реальная опасность.
Листинг 11.3. Непредвиденная проблема деления на ноль
// b нацело делится на a
bool divides(int a, int b) {
return b % a == 0;
}
Это редко приводит к ошибкам, однако…
int main() {
int x = 0;
int y = 42;
if (divides(x, y))
std::cout << x << " divides " << y << '\n';
// b нацело делится на a
bool divides(int a, int b) {
364returnГлава
и написание кода
b % 11.
a ==Функции
0;
Это редко приводит к ошибкам, однако…
}
int main() {
int x = 0;
int y = 42;
if (divides(x, y))
std::cout << x << " divides " << y << '\n';
}
return 0;
В большинстве случаев этот код действительно будет работать, и уверенность
разработчика в его полезности будет только расти. Однако однажды — когда
пройдет много времени с момента написания этого фрагмента и внимание
будет сосредоточено на другом, более свежем коде — этот фрагмент перестанет
работать. Но почему? Ведь раньше он всегда выполнялся.
Анализ
Причиной является деление на ноль: некорректные входные данные приводят
к появлению ошибки. Этот баг существовал все это время, просто ни разу не
проявлялся. Его должны были выявить модульные тесты, но дополнительная
работа — еще одна «причина» отложить написание кода проверки.
Решение
Один из лучших способов выявить такие баги во время разработки — это написать проверки соблюдения предусловий и постусловий. Эти небольшие тесты
весьма жесткие: программа сразу же вылетает, если условие нарушено. С другой
стороны, это здорово, поскольку баг выявляется, а отладчик без раскрутки стека
устанавливает точное место проблемы.
Утверждение (assert) — это простой тест, который проверяет истинность некоторого выражения. Они быстро пишутся, не требуют сложной логики и очень
эффективны. Скрытый баг из листинга 11.3 легко устраняется в следующем коде.
Листинг 11.4. Использование утверждения для проверки предусловия
// отключайте утверждения с помощью установки флага NDEBUG
// b нацело делится на a
bool divides(int a, int b) {
assert(a!=0);
Проверяет, что делитель
return b % a == 0;
не равен нулю
}
int main() {
int x = 0;
int y = 42;
if (divides(x, y))
std::cout << x << " divides " << y << '\n';
}
return 0;
Передает компилятору флаг NDEBUG
для отключения
утверждений в коде
готового программного продукта
11.4. Ошибка #84
365
Если код готового программного продукта не должен содержать утверждений,
не используйте условную компиляцию для их удаления. Достаточно определить
макрос NDEBUG при компиляции — и связанный с ним код исчезнет без влияния
на время выполнения.
Ознакомьтесь с другими ресурсами по программной инженерии ПО: тестовыми
фреймворками, инструментами и подходами. Это выходит далеко за рамки обсуждаемой здесь темы, но служит стимулом изучить и применять такие инструменты. Утверждения особенно полезны при отладке, поэтому стоит научиться
использовать их грамотно.
Рекомендации
Используйте фреймворки для модульного тестирования, чтобы дополнительно проверять код. Не считайте тесты нефункциональным кодом — воспринимайте их как метакод, демонстрирующий корректность основной
логики.
Никогда не пишите утверждения, которые имеют побочные эффекты; если
такое утверждение будет отключено, поведение кода может непредсказуемо
измениться.
Смотри также
«Ошибка #91», где мы обсуждаем случай, когда исключение — не исключение, и разбираемся в различных контекстах.
11.4. Ошибка #84: возвращение указателей
или ссылок на локальные объекты
Эта ошибка влияет на корректность и читаемость, а также она немного снижает
производительность. Возможность создавать значения или объекты в одном
месте и передавать их в другие области кода — это мощный прием, который
позволяет сделать код модульным.
Проблема
Создание объектов в области видимости функции помогает изолировать код,
делая его более понятным и управляемым. Пример в листинге 11.5 должен
объединить два объекта std::string в третий, более крупный. Разработчик посчитал, что вынесение этой логики в функцию будет выглядеть логичнее в контексте вывода в поток, чем создание локального объекта с помощью operator+=
и последующее обращение к этой переменной. Разработчик также считал, что
такой подход минимизирует копирование (вызов конструктора и, возможно,
деструктора), тем самым повышая производительность.
366
Глава 11. Функции и написание кода
Листинг 11.5. Возвращение локального указателя или ссылки
const std::string& catenate(const std::string& a, const std::string& b) {
std::string combined(a);
combined += b;
return combined;
}
// const std::string* catenate(const std::string& a,
const std::string& b) {
Пример возвращения объекта
//
std::string combined(a);
по указателю
//
combined += b;
//
return &combined;
// }
int main() {
std::string msg1 = "Hello, ";
std::string msg2 = "world!";
std::cout << catenate(msg1, msg2) << '\n';
return 0;
}
Лучше, чем создание, обновление
и обращение к переменной
Анализ
Стремление изолировать код в функциях, особенно когда они более органично
используются в определенных контекстах, — хорошая идея. Код в листинге 11.5
читается легко. Кроме того, такой подход устраняет необходимость создания
и обновления локальной переменной.
Функция конкатенации (catenate) была создана именно для того, чтобы вынести создание и обновление локальной переменной, улучшив читаемость.
Вызывающий код использует эту функцию в выражении вывода, и на первый
взгляд все должно работать. Однако ее поведение неопределенно: при возврате
из функции локальная переменная, содержащая результат, выходит за рамки
области видимости.
Функция catenate создает локальный объект std::string, объединяет два параметра и возвращает указатель (или ссылку) на эту локальную переменную.
Вызывающая сторона пытается получить доступ к данным. Если разработчику
повезет, попытка доступа приведет к ошибке сегментации (или аналогичной)
и вызовет сбой программы. Невезучий разработчик не заметит никаких проблем.
Хуже того, этот неудачник вообще увидит правильные результаты (что, скорее
всего, и произойдет).
При создании локальной переменной в стеке выделяется область памяти для ее
представления. После того как функция завершит выполнение, она возвращает
адрес этой переменной — однако сама функция при этом совершает невидимое,
но ключевое действие: ее стековый кадр становится недействительным и доступным для использования другим кодом. Указатель по-прежнему ссылается
11.4. Ошибка #84
367
на прежний адрес, и вызывающий код видит результат, если он не был перезаписан или обращение к нему не приводит к сбою.
Решение
Решение очень простое: нужно возвращать копию локального значения, как показано в следующем листинге. Такой подход вызывает конструктор копирования,
что приводит к дополнительному расходу (иногда и к вызову деструктора). Однако скопированный результат корректен и лишен неприятного неопределенного
поведения. В большинстве случаев оптимизация возвращаемого значения (return
value optimization, RVO) позволяет устранить часть этих накладных расходов.
Листинг 11.6. Возвращение значения
std::string catenate(const std::string& a, const std::string& b) {
std::string combined(a);
combined += b;
return combined;
}
int main() {
std::string msg1 = "Hello, ";
std::string msg2 = "world!";
std::cout << catenate(msg1, msg2) << '\n';
return 0;
}
Стремление к созданию производительного и читаемого кода похвально, но для
обеспечения корректности необходимо соблюдать определенные правила. При
возвращении локально вычисляемых значений возвращение ссылки или указателя небезопасно. Придется смириться с наличием копирования. Современные
компиляторы C++ часто минимизируют или устраняют этот эффект.
Альтернативный подход — создавать локальный объект в куче и возвращать
указатель вызывающему коду. В этом случае не возникает неопределенного поведения, но вопрос очистки динамической памяти становится новой проблемой,
которая ухудшает читаемость и, если ее не решить, приводит к утечке ресурсов.
Рекомендации
Никогда не возвращайте указатели или ссылки на локально созданные объекты.
Разберитесь, как вызов функции и возврат из нее влияют на память.
Смотри также
«Ошибка #63», где мы обсуждаем RVO.
368
Глава 11. Функции и написание кода
11.5. Ошибка #85: использование выходных
параметров
Эта ошибка влияет на читаемость, скрывая истинное назначение переменных,
и может иметь некоторые незначительные последствия для эффективности. Возвращение нескольких значений оказывается довольно проблематичным, и для
этого применяются определенные методики. Эти подходы часто трудночитаемы,
сложны в написании — или и то и другое одновременно.
Проблема
Иногда функции необходимо возвращать два или более значений. Очевидный
подход — создать переменные в вызывающем коде, которые будут изменены
вызываемой функцией. Передача указателя или использование ссылочного
параметра для каждой такой переменной будет работать; однако возникающая
сложность может затруднить их применение и понимание.
Код в листинге 11.7 выполняет целочисленное деление и возвращает частное
и остаток. Разработчик использовал выходной параметр, поскольку функция
может возвращать только одно значение. Параметр remainder (остаток) — ссылочная переменная, псевдоним для значения в вызывающем коде. Функция
изменяет эту переменную, и все в порядке; однако читатель может не заметить
этого и остаться в недоумении на какое-то (достаточно долгое) время.
Листинг 11.7. И
спользование ссылочных переменных для возвращения
значения
int divide(int value, int divisor, int& remainder) {
remainder = value % divisor;
return value/divisor;
}
int main() {
int x = 42;
int y = 4;
Неочевидное возвращаемое значение
int r;
int q = divide(x, y, r);
std::cout << x << " divided by " << y << " is " << q
<< " with remainder " << r << '\n';
return 0;
}
Анализ
Читателю может потребоваться некоторое время, чтобы понять, что локальная
переменная r в вызывающем коде не инициализирована, поскольку ее значение задается внутри вызываемой функции. Это не соответствует ожидаемому
11.5. Ошибка #85
369
шаблону, однако читается немного проще, чем вариант с фиктивной инициализацией. Как правило, программисты не ожидают, что функции будут изменять
переданные аргументы; напротив, предполагается, что результаты вычислений
возвращаются в форме, пригодной для присваивания переменной.
Использование указателей на локальные переменные вызывающего кода не
меняет ситуацию — различается лишь способ реализации. Синтаксис становится
более запутанным, а код функции — немного менее удобным для чтения.
Решение
Стандартная библиотека шаблонов предоставляет контейнер с двумя типизированными значениями, реализованный в виде шаблона класса. Этот шаблон
std::pair позволяет функции возвращать два значения, что гораздо лучше соответствует ожидаемому поведению. Более того, нет необходимости определять
структуру или класс для хранения этих значений — это уже сделано в стандартной библиотеке шаблонов.
Листинг 11.8. Использование std::pair для возвращения двух значений
std::pair<int, int> divide(int value, int divisor) {
return std::make_pair(value / divisor, value % divisor);
}
int main() {
int x = 42;
Возвращаются оба значения, что лучше
int y = 4;
с точки зрения читаемости
std::pair<int, int> res = divide(x, y);
std::cout << x << " divided by " << y << " is " << res.first
<<" with remainder " << res.second << '\n';
return 0;
}
Другой подход — вернуть массив, из которого вызывающий код сможет извлечь
значения; однако такой способ может быть не столь понятен и ухудшить читае
мость. Он лишь приблизительно соответствует использованию std::pair, но
при этом менее гибкий: элементы массива должны иметь одинаковый тип, в то
время как std::pair допускает использование разных типов.
Последний вариант — это возвращать структуру и заставить вызывающий код
извлекать значения из ее полей. Этот подход более читаемый, но требует написания новой структуры, которая, вероятно, будет использоваться только один раз.
Рекомендации
Минимизируйте использование выходных переменных; по возможности
исключите их.
370
Глава 11. Функции и написание кода
Предпочитайте средства, представленные стандартной библиотекой шаблонов, когда это возможно.
Смотри также
«Ошибка #16», где мы обсуждаем способ возвращать более двух значений
из функции в современном C++.
«Ошибка #88», где мы разбираем ограничение количества параметров, передаваемых в функцию.
11.6. Ошибка #86: неправильное использование
типов параметров
Эта ошибка негативно влияет на производительность и может сказаться на
читаемости. C++ допускает передачу параметров по значению, указателю или
ссылке. Важно сделать правильный выбор. Три ключевых аспекта правильного использования параметров — это их размер, назначение и безопасность.
В дальнейшем обсуждении мы сосредоточимся на параметрах типа std::string,
с разовыми ссылками на встроенные типы. Размер параметра зависит от его
типа. Параметры-значения копируются из точки вызова. Встроенные типы,
как правило, имеют небольшой размер, поэтому передавать их как копии
эффективно; однако пользовательские типы данных могут быть большими.
Размер объекта std::string зависит от реализации стандартной библиотеки,
но будем считать, что он равен 32 байтам. Следовательно, если строка передается по значению, стек должен выделить аналогичное пространство под
копию и выполнение кода копирования. Это влияет на производительность
и расход стековой памяти.
Если параметр передается с использованием указателя, то стеку достаточно
выделить размер указателя в этой архитектуре — для удобства обсуждения
предположим, что он равен 8 байтам. Передача строки по указателю, вероятно,
более эффективна с точки зрения размера и скорости — как и в случае с большинством утверждений о производительности, для проверки необходимо провести измерения.
Наконец, если параметр передается по ссылке, фактически действует тот же
механизм, что и для указателей: размер передаваемых данных составляет 8 байт.
Но синтаксис ссылки проще и удобнее.
Таким образом, именно передача по значению имеет наибольший разброс по
размерам. Если тип данных небольшой (8 байт или меньше (на 64-битной архитектуре)), то передача по значению будет использовать тот же объем ресурсов,
что и передача по указателю или ссылке. Однако если тип крупнее или имеет
11.6. Ошибка #86
371
нетривиальный конструктор копирования, возникает дополнительный расход
ресурсов.
Использование параметра — это его семантика. Определение того, как параметр
будет применяться, имеет ключевое значение для выбора корректного способа
передачи. Предположим, что параметр является входным значением; тогда подходит любой из способов. При выборе наилучшего варианта следует учитывать
его читаемость и производительность. Однако если параметр используется для
вывода результата из функции, передача по значению невозможна — он должен
быть передан либо по указателю, либо по ссылке. Отдельная ошибка посвящена
тому, почему использования выходных параметров лучше избегать.
Передача параметров по указателю или по ссылке не ограничивается простыми
выходными параметрами; это единственный способ изменить исходные данные в области видимости вызывающего кода. Например, если вызывающей
стороне требуется сортировать контейнер, оптимальным будет передать его
по указателю или ссылке, поскольку это предотвращает копирование содержимого контейнера. При выборе типа передачи необходимо четко понимать
назначение параметра.
Наконец, безопасность данных имеет первостепенное значение. Поскольку
параметры-указатели и параметры-ссылки обычно эффективнее передачи по
значению, возникает соблазн использовать их всегда. Прежде чем согласиться на
такую эффективность, хорошенько подумайте о читаемости. Кроме того (и это
крайне важно), определите, должна ли функция изменять данные. Если нет,
то необходимо соответствующим образом использовать ключевое слово const.
Учитывая удобство работы со ссылками и функциями, которые могут изменять
данные вызывающего объекта, будьте очень либеральны в использовании const
для ссылок. Поскольку синтаксис указателей сложнее для чтения и написания,
предпочтительнее применять ссылки. Кроме того, они не могут быть нулевыми,
поэтому проверка на корректность не требуется.
Проблема
Передача строки по значению занимает гораздо больше места, чем по указателю
или по ссылке. Ее преимущество в том, что параметр является копией исходных
данных, и функция не может изменять оригинал. Плюс передачи по ссылке —
простота ее синтаксиса. Одно из преимуществ передачи по указателю — ее
историческая распространенность. Кроме того, модификация данных через
указатель очевидна в коде; тогда как изменение данных через ссылку выглядит
как обычное присваивание, из-за чего легко упустить, какие именно данные
изменяются. В следующем коде показаны все три варианта передачи, но без
попытки определить, когда каждый тип параметра уместен.
372
Глава 11. Функции и написание кода
Листинг 11.9. Неправильное использование типов параметров
const std::string catenate(std::string a, std::string& b, std::string* c) {
std::string combined(a);
b[0] = '-';
Непреднамеренная опечатка, влияющая
combined += b;
на исходные данные; синтаксически
c[0] = 'z';
корректна, но является багом
combined += *c;
return combined;
Еще одна случайная опечатка,
}
влияющая на исходные данные;
компилируется, но глючит
int main() {
std::string msg1 = "Hello";
std::string msg2 = ", ";
std::string msg3 = "world!";
std::cout << catenate(msg1, msg2, &msg3) << '\n';
return 0;
}
Анализ
Первый параметр неэффективен: передача типа данных, размер которого превышает размер указателя, должна быть тщательно продумана. Кроме того, при
использовании такого подхода необходимо учитывать стоимость вызовов конструктора и деструктора. Несмотря на простоту и встроенную безопасность для
данных вызывающего объекта, передачу по значению разумно применять только
для небольших данных и встроенных типов. Второй параметр эффективен, но
небезопасен: его объявление не дает однозначно понять, используется ли он
только как входной или предполагает изменение данных. Третий параметр во
многом похож на второй, но более сложен в связи с использованием синтаксиса
указателей. Хотя он может быть хорошо знаком, его интерпретация потребует
дополнительных умственных усилий. Разработчику следует учитывать влияние
размера, способа использования и безопасности для каждого элемента в списке
параметров функции.
Решение
В листинге 11.10 учитываются эти три характеристики и используется наиболее
осмысленный подход для каждого параметра. Поскольку строковые аргументы
применяются только для чтения, каждый параметр объявлен как константная
ссылка. Теперь им явно запрещено изменять данные вызывающего объекта, что
исключает возможность их использования в качестве выходных параметров.
Передача ссылок обеспечивает минимальный объем данных, перемещаемых
из точки вызова в функцию, что положительно влияет на производительность.
Кроме того, компилятор блокирует любые случайные попытки модификации
аргументов благодаря константности.
11.7. Ошибка #87
373
Листинг 11.10. Правильное использование типов параметров
const std::string catenate(const
std::string& c) {
std::string combined(a);
// b[0] = '-';
combined += b;
// c[0] = 'z';
combined += c;
return combined;
}
std::string& a, const std::string& b, const
Теперь это ошибка; параметры
объявлены константными, что исключает
возможность их случайного изменения
int main() {
std::string msg1 = "Hello";
std::string msg2 = ", ";
std::string msg3 = "world!";
std::cout << catenate(msg1, msg2, msg3) << '\n';
return 0;
}
Рекомендации
Оцените эффективность передачи параметров с точки зрения размера. Общее
правило: чем больше байтов нужно скопировать из точки вызова в функцию,
тем дороже передача. Если сомневаетесь — измеряйте.
Оцените использование параметра: является ли он только входным, входным/выходным или только выходным и должна ли функция изменять данные вызывающего объекта.
Оцените безопасность данных: разрешайте изменение аргументов только
в том случае, если это явно необходимо; в противном случае используйте
ключевое слово const.
Смотри также
«Ошибка #85», где мы рассматриваем аргументы против использования
параметров для вывода результатов.
11.7. Ошибка #87: зависимость от порядка
вычисления параметров
Эта ошибка влияет на корректность и читаемость. Во многих случаях порядок
вычисления определен стандартом, и разработчик быстро вырабатывает интуитивное понимание того, как писать код, рассчитывая на этот порядок. Например,
в более сложном цикле for существует четкий порядок вычисления каждой
секции. Вычисление параметров кажется простым, потому что они представляют собой копию какого-либо значения, указатель или ссылку на переменную.
374
Глава 11. Функции и написание кода
ПРИМЕЧАНИЕ Термин параметр в данном обсуждении используется в довольно
широком смысле. Существуют два общепринятых подхода к терминологии. Первый:
значения, используемые в точке вызова, называются параметрами, а переменные
в списке параметров функции — формальными параметрами. Второй: значения в точке
вызова называются аргументами, а переменные в списке параметров функции — параметрами. Хотя я предпочитаю второй вариант, здесь я буду использовать первый,
чтобы сохранить согласованность с большой частью литературы.
Проблема
Предположим, что наш разработчик стремится сократить количество нажатий
клавиш при вызове функции и решает внести побочный эффект при передаче
параметров. По идее, это уменьшит количество строк кода, поскольку обновление
параметров является частью вызова функции. Следующий код демонстрирует простой вызов функции с двумя значениями. Функция определяет, увеличиваются
следующие друг за другом значения или нет. Увеличение означает, что левое значение
меньше или равно правому. Это настолько просто, что вызов функции в цикле вызывает соблазн использовать хитроумные приемы, экономящие нажатия клавиш.
Листинг 11.11. Вызов функции с побочными эффектами
bool isIncreasing(int a, int b) {
return a <= b;
}
int main() {
std::vector<int> values;
for (int i = 0; i < 10; ++i)
values.push_back(i+1);
int loc = 0;
while (loc < 9) {
if (!isIncreasing(values[loc], values[++loc]))
break;
}
if (loc == 9)
std::cout << "success\n";
else
std::cout << "failure\n";
return 0;
}
Вычисление параметров,
включающее побочные
эффекты
Анализ
Код самой функции корректен, но вызывающий код содержит существенную
ошибку. Разработчик предполагал, что сначала будет вычисляться самый левый
параметр: считается значение по индексу loc, а затем — правый параметр после инкремента loc. Самый правый параметр получит новое значение индекса
и обновит его. Все должно работать как положено, но цикл завершается сбоем
11.7. Ошибка #87
375
уже на первой итерации. C++ не гарантирует порядок вычисления параметров.
В моей конфигурации первым вычисляется самый правый параметр: сначала
увеличивая значение loc, а затем определяя его значение. После этого значение
на позиции loc фиксируется и передается в функцию в качестве первого параметра. В результате в цикле каждый раз сравнивается одно и то же значение, а не
соседние элементы. Более того, первый элемент вообще никогда не проверяется.
Побочный эффект приводит к сбою программы, хотя это и неочевидно, и сама
функция корректна. Что же там сравнивается, остается тайной.
Решение
Как бы ни был велик соблазн использовать побочные эффекты при вычислении
выражений, это почти всегда плохая идея. Интуитивно кажется, что параметры
будут вычисляться слева направо, но C++, вслед за C, не определяет никакого
порядка. Следовательно, никакой порядок нельзя считать гарантированным.
Чтобы решить эту проблему, убедитесь, что параметры не содержат выражений.
Вычисление выражений до вызова функции потребует нескольких дополнительных нажатий клавиш, но корректность здесь имеет первостепенное значение.
Следующий код более длинный, но и более корректный.
Листинг 11.12. В
ычисление выражений перед их использованием
в качестве параметров
bool isIncreasing(int a, int b) {
return a <= b;
}
int main() {
std::vector<int> values;
for (int i = 0; i < 10; ++i)
values.push_back(i+1);
int loc = 0;
while (loc < 9) {
int x = values[loc];
int y = values[++loc];
if (!isIncreasing(x, y))
break;
}
if (loc == 9)
std::cout << "success\n";
else
std::cout << "failure\n";
return 0;
}
Вычисляет выражения
перед вызовом функции
Код в листинге 11.12 сравнивает соседние значения, начиная с первого значения.
Момент срабатывания побочного эффекта префиксного оператора инкремента
376
Глава 11. Функции и написание кода
хорошо изолирован, и он не приподнесет сюрпризов. Хотя код для получения
двух значений можно было бы написать изящнее, здесь продемонстрирована
именно изоляция кода с побочными эффектами.
Рекомендации
Никогда не используйте побочные эффекты в аргументах, поскольку порядок
вычисления параметров не определен.
Сведите к минимуму (или исключите!) хитроумное написание кода, которое
экономит нажатия клавиш; лучше потратить немного больше усилий, чтобы
четко передать замысел и гарантировать корректность.
11.8. Ошибка #88: передача избыточного
количества параметров
Эта ошибка влияет на читаемость и в некоторой степени на эффективность.
Преимущество именованных параметров было продемонстрировано в таких
языках, как Python и JavaScript. В настоящее время C++ не имеет подобных
мощных возможностей, поэтому необходимо строго придерживаться порядка
параметров. Иногда его хотелось бы сделать более интуитивно понятным, но
длинные списки затрудняют это.
Проблема
Некоторые функции имеют длинные списки параметров, и их порядок может
нуждаться в разъяснении. Следующий код демонстрирует класс, в котором несколько параметров имели (по-видимому!) некоторый определенный, понятный
разработчику порядок. Однако пользователям класса могут потребоваться объяснения, что повышает вероятность неправильного использования.
Листинг 11.13. В
ызов конструктора с неочевидным порядком
параметров
class Person {
private:
std::string first;
std::string middle;
std::string last;
int year;
int month;
Неудачные названия и слишком
int day;
много параметров
public:
Person(const std::string& f, const std::string& l, const std::string& m,
int y, int d, int mo) :
first(f), middle(m), last(l), year(y), month(mo), day(d) {}
const std::string& getFirst() const { return first; }
int getYear() const { return year; }
};
Сложно отследить, какой
параметр какой и сколько
int main() {
именно их нужно включить
Person p("Ann", "Konda", "A.", 2000, 14, 3);
std::cout << p.getFirst() << " is " << 2024 - p.getYear() << " years old\n";
return 0;
std::string last;
int year;
int month;
Неудачные названия и слишком
int day;
много параметров
public:
Person(const std::string& f, const std::string& l, const std::string& m,
11.8. Ошибка #88
377
int y, int d, int mo) :
first(f), middle(m), last(l), year(y), month(mo), day(d) {}
const std::string& getFirst() const { return first; }
int getYear() const { return year; }
};
Сложно отследить, какой
параметр какой и сколько
int main() {
именно их нужно включить
Person p("Ann", "Konda", "A.", 2000, 14, 3);
std::cout << p.getFirst() << " is " << 2024 - p.getYear() << " years old\n";
return 0;
}
Анализ
Для создания экземпляра класса Person вызывающему коду требуется передать
шесть параметров: три для имени и три для даты рождения. Конструктор мог
бы быть более понятным, поскольку он задает параметрам крайне короткие
имена — одиночные буквы. Разработчик четко понимал их значение, но это
знание следовало бы донести до пользователей класса с помощью более выразительных названий.
Поскольку все шесть параметров — обязательные, вызывающий код должен
предоставить каждый из них в правильном порядке. Легко ошибиться и указать
значение в интуитивной последовательности «имя, отчество, фамилия». Тем не
менее разработчик расположил их как «имя, фамилия, отчество». Сам по себе
такой порядок не является ошибочным, но его необходимо прояснять.
Нужен способ передавать параметры в более очевидном и понятном для вызывающего кода порядке — при этом не нарушая корректность конструктора.
Решение
Именованные параметры сделали бы этот подход гораздо более управляемым.
Без соответствующей поддержки в C++ альтернативой является использование
структуры POD (plain old data, простая структура данных); некоторые называют ее объектом параметров. POD — это передаточный слой: он позволяет
пользователям задавать значения через именованные поля в любом понятном
им порядке, а затем переносит эти данные в конструктор. Конструктор, в свою
очередь, извлекает их из POD в том порядке, который требуется классу. Такой
слой позволяет варьировать порядок указания параметров без потери корректности — фактически он гарантирует корректность, позволяя интуитивно понятное применение без строгого соблюдения определенного порядка. Кроме того,
использование POD избавляет от громоздких вызовов, поскольку несколько
значений передаются через один аргумент.
Сомнительно, что можно провести четкую границу оптимального количества
параметров. Кратковременная память и интуитивно понятное использование
предполагают наличие не более трех. Программисты должны помнить, что их
код будут использовать другие люди и то, что кажется очевидным и простым
378
Глава 11. Функции и написание кода
одному, может быть совершенно непонятно другому. Старайтесь использовать
небольшое количество параметров.
Здесь необходимо сделать оговорку: POD не гарантирует инициализацию всех
значений. Для предотвращения потери данных крайне важно правильно спроектировать конструктор.
Листинг 11.14. И
спользование POD как техники имитации именованных
переменных
struct personPOD {
std::string first;
std::string middle;
std::string last;
int year;
int month;
int day;
};
POD, содержащий все параметры
class Person {
private:
std::string first;
std::string middle;
std::string last;
int year;
int month;
int day;
public:
Person(const std::string& f, const std::string& l, const std::string& m,
int y, int d, int mo) :
first(f), middle(m), last(l), year(y), month(mo), day(d) {}
Person(const personPOD& pp) : first(pp.first), middle(pp.middle),
last(pp.last), year(pp.year), month(pp.month),
day(pp.day) {}
Конструктор,
const std::string& getFirst() const { return first; }
принимающий POD
int getYear() const { return year; }
};
int main() {
personPOD pp;
pp.month = 3;
pp.day = 14;
pp.year = 2000;
pp.last = "Konda";
pp.first = "Ann";
pp.middle = "A.";
}
Инициализация полей POD в удобном
порядке с использованием
именованных полей
Person p(pp);
std::cout << p.getFirst() << " is " << 2024 - p.getYear() << " years
old\n";
return 0;
11.9. Ошибка #89
379
Рекомендации
Установите ограничение на количество параметров и используйте POD для
передачи большего количества. Для обеспечения согласованности и связности полезно иметь отдельные POD для разных концепций.
Убедитесь, что каждое поле инициализировано, чтобы не нарушить инвариант класса.
Применяйте структуру POD в тех случаях, когда именованные переменные
были бы уместны, но их невозможно использовать.
Смотри также
«Ошибка #49», чтобы увидеть предостережение чрезмерного использования
значений по умолчанию для переменных экземпляра.
«Ошибка #60», где мы обсуждаем, как обеспечить предоставление значений
для каждой переменной экземпляра.
11.9. Ошибка #89: слишком длинные функции
с несколькими типами поведения
Наиболее существенное влияние эта ошибка оказывает на читаемость. Размещение небольшого фрагмента кода в отдельных функциях может негативно
повлиять на эффективность. Тем не менее в долгосрочной перспективе разработчик становится продуктивнее, если пишет короткие функции.
Проблема
Многие функции в унаследованном коде длинные, а некоторые — даже слишком.
Нередко в проектах встречаются варианты длиной более 1000 строк. Однажды
я работал с функцией, содержащей 1200 строк кода, — и так и не понял большую
часть ее функциональности. Часто функция начинается как небольшая, но со
временем обрастает новым кодом, добавляемым для удовлетворения дополнительных требований и изменений. Код в листинге 11.15 требует переработки,
потому что со временем в него добавлялось все больше и больше логики. Разработчик написал весь этот код и (ошибочно) предположил, что раз все это
работает вместе, то и располагаться должно вместе.
Мы предполагаем, что код стандартного отклонения будет работать только с неотрицательными значениями — нужна же хоть какая-то причина для очистки
данных! Поэтому перед обработкой информацию необходимо очистить: все отрицательные значения должны быть удалены. После этого вычисляется среднее
арифметическое, поскольку оно требуется для расчета стандартного отклонения.
Наконец, вычисляется стандартное отклонение.
380
Глава 11. Функции и написание кода
Код логически выстроен сверху вниз, однако функцию можно было бы сделать попроще, чтобы ее можно было понять с первого взгляда. Хуже того, имя
std_dev никак не указывает на то, что внутри происходит очистка данных. Это
неправильное название легко вводит читателя в заблуждение, заставляя его
думать, что вычисляется только стандартное отклонение. Подбирать имена
для функций — сложная задача: каждое имя должно кратко и ясно передавать
ее назначение. Для такой расширенной функции невозможно придумать короткое и понятное имя, которое бы точно передавало ее поведение. Например,
compute_standard_deviation_after_removing_negative_values хорошо передает
смысл, но сложно в написании; честно говоря, его и читать-то неудобно.
Листинг 11.15. С
лишком длинная функция с тремя логическими
секциями
double std_dev(const std::vector<double>& values) {
std::vector<double> new_values;
for (int i = 0; i < values.size(); ++i)
if (values[i] >= 0)
new_values.push_back(values[i]);
double sum = 0.0;
for (int i = 0; i < new_values.size(); ++i)
sum += new_values[i];
double mean = sum/new_values.size();
}
sum = 0.0;
for (int i = 0; i < new_values.size(); ++i)
sum += std::pow(new_values[i] - mean, 2);
return std::sqrt(sum / (new_values.size() - 1));
Название предполагает,
что вычисляется только
стандартное отклонение
Очистка данных — это сюрприз;
она не задокументирована
Только этот код соответствует
тому, что подразумевает
название функции
int main() {
std::vector<double> values;
values.push_back(3.14159);
values.push_back(-1.23456);
values.push_back(2.71828);
std::cout << "standard deviation is " << std_dev(values) << '\n';
return 0;
}
Анализ
Эта функция включает в себя три различных варианта поведения. (См. раздел
«Смотри также», где описана другая ошибка, в которой смежная проблема рассматривается под похожим углом.) Эти действия логически вытекают друг из
друга: каждое из них необходимо выполнить, чтобы получить результат. Легко
предположить, что, поскольку эти шаги обязательны, их можно — или даже
нужно — писать одно за другим.
11.9. Ошибка #89
381
Хотя в этой книге не рассматривается вопрос тестируемости, он все же остается
весомым аргументом в пользу написания коротких и понятных функций. Этот
код сложно понять с первого взгляда. По мере появления новых требований
в функцию может добавляться все новая функциональность, что только усугубит проблему.
Решение
Функции должны быть короткими и иметь одно четко определенное поведение.
В случаях, когда требуется последовательность действий — то есть цепочка вызовов функций — существуют два подхода: неправильный и правильный. Неправильный вариант, — это разбить std_dev на три функции и заставить клиента
(функцию main) вызывать их в нужном порядке.
Мои студенты постоянно слышат, что функция main должна рассматриваться
как «код босса». Босс отвечает за управление операциями, но не за обработку
деталей — нам не нужны микроменеджеры. Поэтому функции, управляемые
таким образом, должны обрабатывать детали самостоятельно. Код босса должен
передавать только достаточные для решения задачи данные, но не более того.
Перекладывать на него ответственность за правильный порядок вызовов для
вычисления стандартного отклонения — значит снимать эту ответственность
с разработчика.
Правильное решение — написать три функции, реализующие необходимую
логику, и добавить четвертую, которая будет ими управлять. Следующий код
демонстрирует этот подход. Код босса вызывает std_dev, как и раньше, не зная
деталей, необходимых для вычисления результата. Предполагается, что это имя
уже устоялось; если нет — его следует расписать более подробно. Иногда приходится мириться с тем, что неизбежно.
Листинг 11.16. Разбиение длинной функции и добавление оркестратора
std::vector<double> cleanse(const std::vector<double>& values) {
std::vector<double> good;
for (int i = 0; i < values.size(); ++i)
if (values[i] >= 0)
good.push_back(values[i]);
return good;
}
double arith_mean(const std::vector<double>& values) {
double sum = 0.0;
for (int i = 0; i < values.size(); ++i)
sum += values[i];
return sum/values.size();
}
382
Глава 11. Функции и написание кода
double std_deviation(const std::vector<double>& values, double mean) {
double sum = 0.0;
for (int i = 0; i < values.size(); ++i)
sum += std::pow(values[i] - mean, 2);
return std::sqrt(sum / (values.size() - 1));
}
double std_dev(const std::vector<double>& values) {
std::vector<double> new_values = cleanse(values);
double mean = arith_mean(new_values);
return std_deviation(new_values, mean);
}
int main() {
std::vector<double> values;
values.push_back(3.14159);
values.push_back(-1.23456);
values.push_back(2.71828);
std::cout << "standard deviation is " <<
std_dev(values) << '\n';
return 0;
}
Функция-оркестратор,
управляющая деталями
Клиентский код (код босса)
не меняется
Рекомендации
Разбивайте длинные функции на небольшие, понятные фрагменты.
Добавляйте функции-оркестраторы для координации новых небольших
функций.
Давайте функциям имена, которые четко описывают их поведение.
Если у вас есть доступ к автоматизированным инструментам (например,
плагинам IDE) для рефакторинга кода, научитесь ими пользоваться — некоторые из них преобразуют несколько параметров в объект параметров,
это классно!
Смотри также
«Ошибка #90», где мы обсуждаем, как сделать так, чтобы каждая функция
была сфокусирована только на одной зоне ответственности.
11.10. Ошибка #90: функции с чрезмерной
ответственностью
Эта ошибка влияет на читаемость и эффективность, а также может негативно
сказываться на корректности. Функции — это основа написания многократно
используемого, читаемого кода, но если они плохо запрограммированы, то могут
стать слишком тяжелыми.
11.10. Ошибка #90
383
Проблема
Аналогично предыдущей ошибке, функции с чрезмерной ответственностью
содержат слишком много функциональности, что препятствует их ясному пониманию. Наш отважный программист выбрал упрощенный путь, что привело
к следующему примеру. Необходимость очистки данных переросла в два требования: фильтрация значений ниже нуля и фильтрация значений выше нуля.
Поскольку эти действия похожи, была написана одна функция, выполняющая
обе задачи. Для вызова необходимо передать вектор значений и булев флаг,
определяющий, какие значения следует исключить.
Листинг 11.17. Фильтрация значений выше и ниже нуля в одной функции
std::vector<double> cleanse(const std::vector<double>& values,
bool less_than) {
std::vector<double> new_values;
for (int i = 0; i < values.size(); ++i)
if (less_than) {
Фильтры на основе булева флага
if (values[i] < 0)
new_values.push_back(values[i]);
} else
if (values[i] > 0)
new_values.push_back(values[i]);
return new_values;
}
int main() {
std::vector<double> values;
values.push_back(3.14159);
values.push_back(-1.23456);
values.push_back(2.71828);
values.push_back(-3.14159);
std::vector<double> above = cleanse(values, false);
for (int i = 0; i < above.size(); ++i)
std::cout << above[i] << ' ';
std::cout << '\n';
}
std::vector<double> below = cleanse(values, true);
for (int i = 0; i < below.size(); ++i)
std::cout << below[i] << ' ';
std::cout << '\n';
return 0;
Устанавливает булев
флаг для фильтрации
значений выше или
ниже нуля
Анализ
Функция cleanse перебирает значения в векторе, определяет, следует ли
фильтровать элементы выше или ниже нуля, проверяет знак величины и, если
значение попадает в желаемый диапазон, копирует его в вектор результатов.
384
Глава 11. Функции и написание кода
Несмотря на простоту операции, очевидно, что функция реализует два различных действия, переключаемых флагом. Разработчик может помнить тонкости
этого поведения, но новичку придется потратить время, чтобы разобраться, что
же означает вызов вида cleanse(values, false). Ему придется найти исходный
код функции, прочитать его и — при отсуствии содержательных комментариев (как показано в примере) — просмотреть код, пока не станет ясно, как он
работает.
Такой стиль программирования требует дополнительных усилий, чтобы разобраться (эффективность) и понять, что написано (читаемость). Это пример
неудачного подхода, пытающегося сэкономить усилия и (о ужас!) избежать
дублирования. Существующие кодовые базы полны подобных примеров.
Решение
Дублирование кода в такого рода функциях неизбежно, и попытки полностью
его устранить могут привести к более серьезным проблемам. Цель состоит в том,
чтобы минимизировать или исключить распространение логики между несколькими функциями или методами. В этом случае фрагменты кода, выполняющие
фильтрацию значений меньше и больше нуля, не используют никакой общей
информации, хотя дублирование логики между ними значительное.
Проблема часто возникает, когда поток управления (структура управления)
между фильтрами идентичен. Это заставляет разработчиков объединять различные части (логику фильтрации) в общую структуру, зачастую с дублирующимися фрагментами. (Ой! Мы же старались исключить это дублирование.)
Добавление управляющих веток, переключаемых булевым флагом, — явный
признак попытки устранить дублирование управляющих структур в отдельных
функциях путем их объединения. Это не только тратит время на этапе разработки
впустую и затрудняет тестирование, но и не решает ту проблему, ради которой
все и затевалось.
Несколько коротких функций будут идеальным вариантом, при этом они не
содержат ужасного дублирования. Короткие функции проще именовать, что
делает вызовы более интуитивно понятными. Их легко тестировать и проверять, а также просто читать и писать. Ах да — они обычно дублируют логику
потока управления. Как говорится в одной песне американского рок-певца
Мита Лоуфа (Meat Loaf): «Два из трех — это уже неплохо» (“Two out of three
ain’t bad”).
В следующем коде логика фильтрации разделена между двумя функциями,
которым присвоены соответствующие имена. Стремление исключить структурный код отвергается, и назначение каждой функции — ее логика — выделяется
в отдельный блок.
11.10. Ошибка #90
385
Листинг 11.18. Использование отдельных функций для фильтрации
std::vector<double> filter_above(const
std::vector<double>& values) {
std::vector<double> new_values;
for (int i = 0; i < values.size(); ++i)
if (values[i] > 0)
new_values.push_back(values[i]);
return new_values;
}
std::vector<double> filter_below(const
std::vector<double>& values) {
std::vector<double> new_values;
for (int i = 0; i < values.size(); ++i)
if (values[i] < 0)
new_values.push_back(values[i]);
return new_values;
}
Функция с одной четкой задачей
Другая функция с четкой задачей
int main() {
std::vector<double> values;
values.push_back(3.14159);
values.push_back(-1.23456);
values.push_back(2.71828);
values.push_back(-3.14159);
std::vector<double> above = filter_above(values);
for (int i = 0; i < above.size(); ++i)
std::cout << above[i] << ' ';
std::cout << '\n';
}
Однозначное
использование без
спорных аргументов
std::vector<double> below = filter_below(values);
for (int i = 0; i < below.size(); ++i)
std::cout << below[i] << ' ';
std::cout << '\n';
return 0;
В качестве задания попробуйте разобраться, как использовать шаблоны функций для реализации функциональности сравнения. Напишите одну функцию
фильтрации, которая принимает вектор и параметр шаблона, — функцию
сравнения. Рассмотрите возможность использования std::greater<double>
и std::less<double> в данном примере. Подсказку можно найти на веб-странице
справочного руководства по C++: https://mng.bz/gaan.
Рекомендации
Делайте каждую функцию самостоятельной, с одной четко выраженной ответственностью.
386
Глава 11. Функции и написание кода
Не бойтесь дублировать структуры управления в функциях — если что-то
работает в одной, то будет работать и в другой.
Не особо беспокойтесь о дублировании кода в функциях: изолируйте информацию в одной функции или в связанном наборе функций (вероятнее
всего, в классе).
Смотри также
«Ошибка #23», где дается пример использования лямбда-выражений в современном C++.
«Ошибка #89», где приведены аргументы против длинных функций, чтобы
функции оставались более читаемыми и тестируемыми.
12
Обобщенные принципы
написания кода
В этой главе
3 Исключения, не являющиеся исключениями C++
3 Правильная организация и выполнение циклов
3 Излишне подробное программирование и чрезмерное
использование некоторых ключевых слов
3 Применение удаленных указателей
Некорректное проектирование и реализация бывают не только в C++. Многие из
следующих ошибок носят общий характер и встречаются во многих языках. Эта
универсальность не означает, что перечисленные проблемы неважны — скорее,
что они могут проявиться в любой кодовой базе. Язык нельзя полностью отделить
от архитектуры и вычислительной машины, на которой он выполняется, хотя
большинство языков стремятся абстрагировать как можно больше таких деталей.
Подход Java устраняет многие машинные особенности, но все же демонстрирует
определенные ограничения. Например, разрядность типов данных задает допустимый диапазон значений — и, похоже, что нет способа обойти такого рода
машинные тонкости, несмотря на все попытки абстрагироваться.
Язык C++ ближе к вычислительной машине, чем большинство языков. Поэтому
неудивительно, что машинно-специфичные тонкости и проблемы проявляются
388
Глава 12. Обобщенные принципы написания кода
здесь довольно часто. Следующие ошибки касаются нескольких случаев, когда
тонкости языка или платформы влияют на корректную реализацию программ.
12.1. Ошибка #91: неправильная обработка деления
на ноль
Эта ошибка влияет на читаемость и эффективность. Обработка исключений — это
мощный инструмент для решения проблем в тех случаях, когда восстановление
возможно и осуществимо.
Некоторые студенты были удивлены тем фактом, что компьютеры не могут работать со значительным набором чисел, известных как целые (или вещественные,
представляющие действительные числа). Компьютеры быстры, точны и аккуратны, но ограниченны, особенно в представлении диапазонов числовых значений.
Вычислительные задачи, содержащие этапы деления, имеют особую проблему,
требующую внимания. Математики раздражаются, когда мы пытаемся разделить
целые числа на ноль.
Проблема
Многие вычисления включают в себя деление. К ним относятся оператор деления
(/) и менее очевидный оператор остатка от деления (%). Опытный программист,
скорее всего, на собственном горьком опыте убедится, что деление целого числа
на ноль приводит к аварийному завершению программы с исключением в операции с плавающей точкой (FPE, floating-point exception). Многие начинающие
программисты пока не понимают этого и редко защищают свой код проверкой
делителя перед выполнением вычисления. Следующий код, написанный более
осмотрительным программистом, учитывает возможность такой математической ошибки.
Листинг 12.1. Непроверенное деление целых чисел
bool divides(int b, int a) {
return a % b == 0;
}
Потенциальный источник исключения
в операции с числами с плавающей точкой
int main() {
int x = 0;
int y = 42;
try {
if (divides(x, y))
std::cout << x << " divides " << y << '\n';
} catch(...) {
std::cout << "probably divide by zero issue\n";
}
return 0;
}
Универсальный блок catch
12.1. Ошибка #91
389
Однако при запуске этого кода программист с удивлением обнаруживает, что
программа по-прежнему вылетает с ошибкой FPE. Несмотря на все благие намерения, проблема не решена и восстановление невозможно. Некоторые системы
не завершаются аварийно, но это не означает, что код отработал правильно.
Анализ
Запуск кода из листинга 12.1 на моей системе приводит к следующему неожиданному сообщению:
Floating point exception
На других системах и при использовании других компиляторов сообщение
может отличаться.
Поначалу удивляет то, что обработчик исключений типа catch-all не реагирует
на это исключение. Казалось бы, задача блока catch-all — перехватывать любое
исключение, которое не было обработано ранее. Это универсальная ветка catch,
но она не справляется со своей задачей.
Решение
Вот загадка: когда же исключение не является исключением? Тогда, когда это не
исключение языка C++. Программист сталкивается со смешением терминов стандарта IEEE 754 (который описывает операции с числами с плавающей точкой)
и версии C++. Термин исключение в операциях над числами с плавающей точкой
сбивает с толку многих разработчиков, изучавших языки программирования.
Процессор определяет, что выполняется операция деления на ноль, и прекращает ее выполнение. В C++ это вызывает сигнал SIGFPE (SIGnal Floating Point
Exception), который запускает обработчик сигнала, отвечающий за исключения
в операциях над числами с плавающей точкой. Однако этот сигнал не является
исключением языка C++, поэтому универсальный блок catch не выполняется.
Обработчик SIGFPE находится вне кода разработчика и является частью кода
среды выполнения C++. Этот альтернативный поток управления полностью
обходит блок catch.
Один из вариантов для программиста — это определить собственную функцию
для обработки деления на ноль и перенастроить обработчик SIGFPE на вызов
этой функции. Однако возврат управления в некую осмысленную точку после
ее выполнения сложен или вообще невозможен.
Лучше сразу понять двойственный смысл термина исключение и самостоятельно
предотвратить проблему деления на ноль. В следующем коде ошибка перехватывается заранее — генерируется исключение. Поскольку это исключение языка
C++, все стандартные механизмы обработки исключений работают как следует.
390
Глава 12. Обобщенные принципы написания кода
Листинг 12.2. П
редотвращение деления на ноль и генерация
исключения языка C++
bool divides(int b, int a) {
if (b == 0)
throw std::invalid_argument("divisor is zero");
return a % b == 0;
}
int main() {
int x = 0;
int y = 42;
try {
if (divides(x, y))
std::cout << x << " divides " << y << '\n';
} catch(std::invalid_argument ex) {
std::cout << ex.what() << '\n';
}
return 0;
}
Предотвращает
проблему деления
на ноль
Генерирует исключение
C++ при ее возникновении
Корректно обрабатывает ситуацию
Рекомендации
Внимательно относитесь к терминологии и не рассчитывайте на то, что слово
всегда означает то, что определено в языке C++.
Ознакомьтесь со стандартом IEEE 754, если хотите узнать больше о представлении, ограничениях и использовании чисел с плавающей точкой
в компьютере.
Помните, что вычисления ограниченны: типы данных имеют конечные
диапазоны значений — это 32-разрядные знаковые целые числа, лежащие
в диапазоне от –2 147 483 648 до 2 147 483 647, и все, что выходит за пределы этого диапазона, является недействительным. Переполнение и выход за
границы диапазона реальны, поэтому будьте осторожны при приближении
к этим пределам.
Смотри также
«Ошибка #83», чтобы увидеть более агрессивный подход к обработке недопустимых входных значений.
12.2. Ошибка #92: неправильное использование
ключевого слова continue в циклах
Эта ошибка влияет на корректность и эффективность. Циклы зачастую пишутся
по принципу мышечной памяти. В таких случаях невнимательность может привести к проблемам, которые способны долго оставаться незаметными, прежде
чем проявятся.
12.2. Ошибка #92
391
Проблема
Некоторые циклы имеют дополнительную логику обработки итераций. В языке
C++ предусмотрены ключевые слова break и continue для изменения управления циклом.
Каждый цикл состоит из четырех секций, некоторые из них могут быть пустыми.
Понимание назначения каждой необходимо для проектирования корректного
цикла. Первая — это секция инициализации, где задаются переменная управления циклом или другие значения, то есть предварительные условия цикла.
Вторая — это проверка на продолжение, которая определяет, следует выполнять
тело цикла или нет; в большинстве случаев она проверяет управляющую переменную. Третья — это тело, основной смысл цикла, то есть код, выполняемый
при каждой итерации. Последняя — это секция обновления, где переменная
управления циклом изменяется, чтобы приблизить проверку на продолжение
к моменту завершения. Такой порядок определен для цикла while (включая
и цикл for). В цикле do проверка на продолжение перемещается на четвертую
позицию, а остальные секции сохраняют порядок.
Многие программисты склонны использовать циклы for, поскольку они более
лаконичны, переменная управления циклом ограничена областью видимости
и их быстрее писать. Эта привязанность к for и альтернативным потокам
управления (ключевые слова break и continue) может создать проблемы для
неосторожного разработчика при переходе к использованию while или do. Когда
в цикле for выполняется continue, управление возвращается к «началу» цикла
(см. следующий листинг) — или к секции обновления, которая находится перед
условием продолжения.
Листинг 12.3. Цикл while с ключевым словом continue
int main() {
int x = 10;
while (x > 0) {
if (x % 3 != 0)
continue;
std::cout << x << " is divisible by 3\n";
--x;
}
return 0;
}
Управление переходит
к началу цикла —
к ключевому слову while
Этот код представляет собой бесконечный цикл: он будет выполняться до тех
пор, пока разочарованный пользователь или операционная система не остановят
его по истечении определенного времени.
392
Глава 12. Обобщенные принципы написания кода
Анализ
В цикле while или do ключевое слово continue переводит управление к началу
цикла — либо к проверке на продолжение, либо к ключевому слову do. Это небольшое различие имеет решающее значение, поскольку отличает поведение
таких циклов от поведения цикла for.
Решение
Небольшое различие между циклом for и циклами do и while заключается в том,
что цикл for объединяет секции инициализации, проверки условия продолжения
и обновления в «начале» цикла; вне этого блока остается только тело. В двух
других циклах эти секции расположены иначе: верхней точкой оказывается либо
только ключевое слово do, либо ключевое слово while с проверкой на продолжение. Цикл for вызывает секцию обновления сразу после выполнения ключевого
слова continue. Циклы while и do никогда не выполняют секцию обновления
при срабатывании continue. Это различие критически важно для правильного
понимания того, как писать эти циклы. В следующем коде секция обновления
дублируется непосредственно перед ключевым словом continue.
Листинг 12.4. П
равильное выполнение секции обновления
перед ключевым словом continue
int main() {
int x = 10;
Выполнение секции
while (x > 0) {
обновления перед ключевым
if (x % 3 != 0) {
словом continue
--x;
continue;
}
std::cout << x << " is divisible by 3\n";
--x;
}
return 0;
}
Это необходимо для продолжения продвижения переменной управления циклом
к условию его завершения. Без этой дублирующей секции переменная в текущей
итерации не изменится, что приведет к бесконечному циклу.
Рекомендации
В циклах while и do всегда выполняйте секцию обновления перед оператором
continue.
Отдавайте предпочтение циклам for, поскольку в них эта проблема отсутствует; появление дублирующейся секции обновления в цикле while или
do — это, скорее всего, признак плохого кода («запах кода»).
12.3. Ошибка #93
393
12.3. Ошибка #93: неприсваивание удаленным
указателям значения NULL
Эта ошибка влияет на корректность кода. Указатели часто используются для
управления динамическими ресурсами; их удаление критически важно для правильной работы программы. Доступ к уже освобожденному ресурсу через указатель
нередко остается технически возможен, но его поведение при этом не определено.
Проблема
Разработчики сталкиваются со множеством ситуаций, когда динамические ресурсы управляются через сырые указатели. Большая часть существующего кода
полна подобных примеров. Современный C++ решает проблему управления
памятью с помощью умных указателей. Но при их отсутствии разработчику
приходится вручную и очень внимательно управлять динамическими ресурсами.
В большинстве случаев это делается правильно, и неопределенное поведение не
проявляется. Однако бывают ситуации, когда доступ к ресурсу осуществляется
уже после его удаления: код сложен, и факт освобождения памяти легко упустить из виду. Это особенно часто происходит, когда код восстановления после
исключений неправильно обрабатывает ресурсы.
Лично я работал над проектом, в котором эта проблема встречалась 12 раз. Статический анализатор кода обнаружил эти случаи, хотя они не были очевидны
ни разработчику, ни специалистам по поддержке. Следующий код представляет
собой очень упрощенный пример, иллюстрирующий эту проблему.
Листинг 12.5. О
бращение к динамическому ресурсу после удаления
указателя
class Person {
private:
std::string name;
int age;
public:
Person(const std::string& name, int age) : name(name), age(age) {}
const std::string& getName() { return name; }
int getAge() { return age; }
};
Допустимая проверка,
int main() {
подтверждающая, что
Person* anne = new Person("Annette", 28);
объект был создан
if (anne)
std::cout << anne->getName() << " is " << anne->getAge()
<< " years old\n";
delete anne;
if (anne)
std::cout << anne->getName() << " is " << anne->getAge()
<< " years old\n";
Казалось бы, корректная проверка: объект уничтожен,
return 0;
но через указатель к нему все еще можно обратиться
}
394
Глава 12. Обобщенные принципы написания кода
Анализ
Динамический ресурс создается, проверяется на корректность, обрабатывается
и удаляется. Проблема возникает после удаления, когда указатель снова проверяется на корректность и затем ошибочно используется для доступа к ресурсу.
Различные компиляторы и системы по-разному реагируют на такой код, но во
всех случаях любая попытка обратиться к ресурсу через удаленный указатель
требует исправления. В лучшем случае программа аварийно завершится, предотвращая дальнейший доступ. Это случай неопределенного поведения, и иногда
он может выглядеть работоспособным — что делает ошибку еще более опасной.
Решение
Самый простой способ решить эту проблему — обнулять указатель сразу после его удаления. Если удаление происходит в конце области видимости, это
можно не делать, но в целом такая практика полезна. Доступ через нулевой
указатель — вариант гораздо более предпочтительный, чем «успешный» доступ
к уже удаленному ресурсу. Сбой при обращении к нулевому указателю — явный
признак ошибки в программе. Следующий код демонстрирует это незначительное
изменение, которое приводит к весьма значимому эффекту.
Листинг 12.6. О
бнуление указателя для предотвращения обращения
после удаления
class Person {
private:
std::string name;
int age;
public:
Person(const std::string& name, int age) : name(name), age(age) {}
const std::string& getName() { return name; }
int getAge() { return age; }
};
int main() {
Person* anne = new Person("Annette", 28);
if (anne)
std::cout << anne->getName() << " is " << anne->getAge()
<< " years old\n";
delete anne;
Обнуление удаленного указателя
anne = NULL;
if (anne)
std::cout << anne->getName() << " is " << anne->getAge()
<< " years old\n";
Проверка, предотвращающая доступ
return 0;
через удаленный указатель
}
Рекомендации
Всегда обнуляйте указатели на удаленные ресурсы; используйте 0 в классическом C++ или nullptr в современном C++.
12.4. Ошибка 94
395
Смотри также
«Ошибка #7», где мы рассматриваем наилучший способ обнуления указателей в современном C++.
«Ошибка #76» для получения дополнительных советов по утечкам ресурсов.
«Ошибка #78», где мы обсуждаем проблемы, связанные с использованием
сырых указателей.
12.4. Ошибка #94: неиспользование возврата
булевых значений напрямую после вычисления
Эта ошибка влияет на читаемость и эффективность. Программисты часто пишут
функции, определяющие истинность или ложность некоторого условия. Такая
функция называется предикатом — она возвращает булево значение true или false.
Проблема
Многие предикаты принимают некоторые значения в качестве параметров, выполняют вычисления на их основе, чтобы определить результат, а затем возвращают этот результат. При этом часто используется локальная переменная, которая
инициализируется определенным состоянием, выполняется вычисление, затем
в нее записывается результат, и только после этого переменная возвращается.
Однако за таким замысловатым подходом скрывается серьезная проблема.
Рассмотрим следующий код, который определяет, является число четным или
нечетным.
Листинг 12.7. Вычисление булева значения и его возврат
bool isEven(int n) {
bool even = false;
if (n % 2 == 0)
even = true;
else
even = false;
return even;
}
Объявляет переменную
Вычисляет ее значение
Возвращает значение
int main() {
int n = 42;
if (isEven(n))
std::cout << n << " is even\n";
else
std::cout << n << " is odd\n";
++n;
if (isEven(n))
std::cout << n << " is even\n";
396
Глава 12. Обобщенные принципы написания кода
else
}
std::cout << n << " is odd\n";
return 0;
Функция isEven написана корректно, но слишком многословно. Скрытая проблема заключается в том, что разработчик должен видеть: результат операции
взятия остатка уже однозначно определяет возвращаемое значение. Условный
оператор if лишь определяет значение вычисления, получает такой же результат
и присваивает его переменной. В переменной оказывается именно то, что было
вычислено изначально — оператор if ничего не дает.
Анализ
Часто необходимо использовать локальную переменную для хранения промежуточного значения вычисления. Однако в тех случаях, когда вычисляемое
выражение само по себе является итоговым результатом, сохранять его в переменной нет никакой нужды.
Решение
В функции isEven следует убрать проверку if, чтобы она просто возвращала
результат вычисления. Такой подход делает код более читаемым, упрощает написание и лучше отражает связь между проверяемым условием и возвращаемым
значением.
Эта проблема не ограничивается булевыми функциями: множество других
функций (не только предикатов) вычисляют возвращаемое значение без дополнительных изменений. Этот прием одинаково хорошо подходит и для таких
случаев. Старайтесь, чтобы функции были максимально короткими и понятными.
Листинг 12.8. Непосредственный возврат вычисленного булева значения
bool isEven(int n) {
return n % 2 == 0;
}
int main() {
int n = 42;
if (isEven(n))
std::cout <<
else
std::cout <<
++n;
if (isEven(n))
std::cout <<
else
std::cout <<
return 0;
}
Непосредственный возврат результата вычисления
n << " is even\n";
n << " is odd\n";
n << " is even\n";
n << " is odd\n";
12.5. Ошибка #95
397
Рекомендации
Функции должны быть короткими и лаконичными.
По возможности возвращайте вычисляемые значения напрямую.
Смотри также
«Ошибка #95», где приведены советы по минимизации использования конструкций if/else.
«Ошибка #96», где мы рассматриваем излишне подробное написание конструкций if/else.
12.5. Ошибка #95: недостаточное
использование выражений
Эта ошибка касается эффективности и читаемости. Многие разработчики
предпочитают использовать операторы if, игнорируя мощные возможности
тернарного оператора.
Проблема
В учебниках обычно подробно разбирают условную конструкцию if/else ,
и именно она используется в большинстве примеров. Тернарный оператор
встречается довольно редко, зачастую лишь эпизодически. Поэтому я считаю,
что необходимо подробно обсудить различия между if/else и тернарными
выражениями, а также область их применения. Например, операторы не возвращают значение, в то время как выражения всегда возвращают результат.
Рассмотрим следующий код, в котором предикат isEven определяет, по какому
пути следовать после проверки if. Если проверка дает результат true, выполняется оператор, следующий за ключевым словом if; если false — оператор,
следующий за ключевым словом else, если он указан.
Листинг 12.9. Излишне подробный код с условными конструкциями
bool isEven(int n) {
return n % 2 == 0;
}
int main() {
int n = 42;
if (isEven(n))
std::cout << n << " is even\n";
else
std::cout << n << " is odd\n";
++n;
Оператор if/else слишком перегружен
398
}
Глава 12. Обобщенные принципы написания кода
if (isEven(n))
std::cout << n << " is even\n";
else
std::cout << n << " is odd\n";
return 0;
Анализ
В коде определяется четность значения, после чего выводится результат. Поскольку для проверки используется оператор if, вывод происходит либо по
ветке true, либо по ветке false. Или соответствующий путь должен присвоить
локальной переменной определенное значение, которое затем применяется при
выводе. Если выбран второй вариант, то при выводе результата используется
содержимое локальной переменной. Конструкция if/else не может возвращать
значение — она может только вычислить его. Код вычисления нередко дублируется для путей true и false, что делает его громоздким и немного усложняет
чтение.
Решение
Конструкция if/else не может возвращать значение, поскольку является оператором. Тернарный оператор — по сути тот же if/else, но он представляет собой
выражение, а значит, способен производить значение. Используя его как выражение, можно формировать значения напрямую, без присваивания локальной
переменной или дублирования кода.
В листинге 12.10 продемонстрировано использование оператора для возврата
значения. Выражение должно быть заключено в скобки, чтобы компилятор не
запутался. Не менее важно и то, что скобки отделяют выражение от окружающего
его кода, что облегчает чтение.
Это не означает, что все конструкции if/else должны быть принудительно преобразованы в выражения. Однако существует множество случаев, когда такой
метод хорошо подходит. Особенно полезно применять тернарный оператор
в выражениях вывода на экран.
Листинг 12.10. К
од с минимизированным количеством условных
конструкций
bool isEven(int n) {
return n % 2 == 0;
}
Минимизация логики if/else
int main() {
с помощью тернарного оператора
int n = 42;
std::cout << n << " is " << (isEven(n) ? "even" : "odd") << '\n';
12.6. Ошибка #96
}
399
++n;
std::cout << n << " is " << (isEven(n) ? "even" : "odd") << '\n';
return 0;
Рекомендации
Используйте тернарный оператор для прямого вычисления значения, исключая локальные переменные и дублирующийся код; приведенный пример
неидеален — лучше было бы вынести логику в отдельную функцию.
Обращайте внимание на выражения вывода, которые могли бы выиграть от
использования тернарной операции.
Смотри также
«Ошибка #94», где приведены рекомендации по использованию конструкций if/else для вычисления булевых значений.
«Ошибка #96», где мы рассматриваем излишне подробное написание конструкций if/else.
12.6. Ошибка #96: использование лишних
ключевых слов else
Эта ошибка касается эффективности и читаемости. Во многих учебниках обычно
показаны полные конструкции if/else, без описания тех случаев, когда они
затрудняют понимание смысла кода.
Проблема
Когда разработчик изучает новые языковые приемы, стиль, в котором он обучается, часто отражается на рабочем коде. Следующий пример — простой случай
вычисления среднего арифметического набора значений. В C++ можно выбрать
один из нескольких вариантов обработки (в данном случае трех) с помощью
конструкции if/else. Первый вариант — отсутствие значений; второй — одно
значение; третий — несколько значений. Каждый случай должен обрабатываться
по-разному, поэтому использование подобной структуры кажется естественным.
Однако читателю придется определять, какие из вариантов будут выполняться
по упрощенной схеме, а какие — станут основными вычислениями.
Короткие пути — это те, в которых возвращаемое значение вычисляется напрямую (это базовые случаи в рекурсивной функции). Когда такие варианты
смешаны с основной логикой, их роль становится менее очевидной, чем показано
в следующем коде.
400
Глава 12. Обобщенные принципы написания кода
Листинг 12.11. Использование цепочки if/else
double mean(const std::vector<double>& values) {
unsigned int size = values.size();
if (size == 0)
throw std::invalid_argument("no values to average");
else if (size == 1)
return values[0];
else {
double sum = 0.0;
for (int i = 0; i < size; ++i)
sum += values[i];
return sum/size;
}
}
Цепочка if/else,
скрывающая условия
возвращения результата
вычислений
int main() {
std::vector<double> values;
values.push_back(3.14159);
values.push_back(2.71828);
std::cout << mean(values) << '\n';
return 0;
}
Анализ
Два варианта, в которых результат возвращается напрямую (хотя генерация
исключения — формально не вычисление, но сейчас это неважно), оказываются перемешаны с веткой, где выполняется основное вычисление. Логика идет
по пути: «ни одного», «одно» и «множество» значений. Нет необходимости
придерживаться именно такой последовательности, и когда код становится
объемнее, вычисления могут быть полностью погребены среди этих вариантов.
Предпочтительнее подход, который четко разделяет эти случаи и делает их
более понятными.
Решение
В листинге 12.12 предыдущий код слегка изменен, но благодаря этому результат
стал гораздо понятнее. Два случая, которые можно обработать по упрощенной
схеме (short-circuit), рассматриваются сразу: либо они true, и тогда выполняется
throw или return, либо они не true — выполнение идет дальше. Если оба случая
false, выполняется основная вычислительная часть.
Между двумя примерами появляется визуальное различие. В первом случае
используется больший отступ, что всегда создает нагрузку на кратковременную
память. Во втором случае отступ отсутствует, и становится гораздо очевиднее:
если первые два условия не выполняются, про них можно быстро забыть. Вычисление находится на том же уровне отступа, что и ключевые слова if, что
ясно показывает: именно этот код выполнится, если поток управления дойдет
12.7. Ошибка #97
401
до него. Уменьшение количества ключевых слов else позволяет читателю быстрее отбросить случаи «по упрощенной схеме» и сосредоточиться на основной
вычислительной задаче.
Листинг 12.12. Использование логики вычисления по упрощенной схеме
double mean(const std::vector<double>& values) {
unsigned int size = values.size();
if (size == 0)
throw std::invalid_argument("no values to average");
if (size == 1)
return values[0];
double sum = 0.0;
for (int i = 0; i < size; ++i)
sum += values[i];
return sum/size;
}
Короткая схема
обработки условий
с немедленным
возвратом
int main() {
std::vector<double> values;
values.push_back(3.14159);
values.push_back(2.71828);
std::cout << mean(values) << '\n';
return 0;
}
Рекомендации
Проверяйте ошибочные или некорректные случаи в начале функции; если
эти проверки проходят, то основные вычисления разместите после них.
По возможности минимизируйте количество уровней отступов.
Используйте упрощенное вычисление, когда это имеет смысл; оно напрямую
вычисляет результат или генерирует исключения, кроме того, оно быстро
забывается и не мешает пониманию основного кода.
Смотри также
«Ошибка #94», где приведены рекомендации по использованию конструкций
if/else для вычисления булевых значений.
«Ошибка #95», чтобы увидеть советы по минимизации использования конструкций if/else.
12.7. Ошибка #97: неиспользование
вспомогательных функций
Эта ошибка влияет на эффективность и читаемость, а в некоторых случаях может негативно сказаться и на корректности. Клиентский код никогда не должен
402
Глава 12. Обобщенные принципы написания кода
знать конкретных деталей работы алгоритма или функции; напротив, он должен
иметь возможность интуитивно вызывать нужную функцию, предоставляя
только необходимые для решения задачи данные.
Проблема
Рассмотрим случай, когда бинарный (двоичный) поиск реализован рекурсивно.
Для минимизации проблем с производительностью коллекция данных передается по ссылке. Начальные и конечные значения индекса корректируются при
каждом последующем рекурсивном вызове.
Клиентский код вызывает рекурсивную функцию и предоставляет начальные
и конечные значения индекса. В подобных случаях клиент неизбежно начинает
жестко указывать значения индексов, поскольку они должны определять механизм вызова функции и ее связь с контейнером данных. Однако такие значения
становятся ненадежными: изменение размера контейнера без соответствующей
корректировки индексов приводит к неправильному поведению.
Остается надеяться, что вызывающая сторона всегда задаст корректные начальные и конечные значения индекса. Однако, скорее всего, клиент будет либо
догадываться, либо полагаться на свой минимальный опыт, если что-то понимает
неверно. Такой подход опасен.
Листинг 12.13. Рекурсивная функция, раскрывающая детали реализации
bool bin_search(const std::vector<int>& values, int key, int start, int end)
{
int mid = start + (end-start) / 2;
if (values[mid] == key) // вспомните предыдущую проблему с 'else'
return true;
else if (values[mid] < key)
return bin_search(values, key, mid+1, end);
else
return bin_search(values, key, start, mid-1);
}
int main() {
std::vector<int> values;
for (int i = 0; i < 100; ++i)
values.push_back(i);
std::cout << bin_search(values, 55, 0, 99) << '\n';
}
Клиентский код должен
знать начальное
и конечное значения
индекса. Слишком много
информации!
Анализ
Разработчик, написавший основную функцию, правильно указал значения
индекса. Тем не менее неясно, понимал ли он, что конечный индекс должен соответствовать размеру контейнера (100) или максимально допустимому индексу
12.7. Ошибка #97
403
(99). При несколько иных обстоятельствах, скорее всего, будет использован
конечный индекс 100. Чтобы скорректировать значение индекса, разработчику
необходимо понимать, как рекурсивный поиск использует этот параметр. Такая
информационная нагрузка негативно влияет на эффективность.
В идеале разработчик функции поиска должен реализовать ее так, чтобы вызывающей стороне не нужно было ничего знать о значениях индексов или механизме
ее работы. В объектно ориентированном программировании инкапсуляция
скрывает такие детали и предоставляет минимальный, желательно интуитивно
понятный, интерфейс. То же самое следует делать и с функциями.
Решение
Инкапсуляция функциональности рекурсивной функции может быть достигнута
путем создания вспомогательной функции. Такая функция сохраняет имя поисковой функции, но не требует от клиента информации о значениях индекса или
других деталях реализации. Перегрузка функций позволяет оставить имя без
изменений. Нет необходимости редактировать имя рекурсивной версии, чтобы
подчеркнуть ее рекурсивность (если только это не представляет ценность для
документации). Вспомогательная функция, которая, вероятно, была написана
разработчиком рекурсивной функции, самостоятельно определяет детали начального и конечного значений индекса.
Еще одно преимущество вспомогательной функции — конечный индекс вычисляется на основе размера контейнера, что исключает соблазн жестко прописать
это значение. Детали реализации вызова рекурсивной функции абстрагированы,
а интерфейс для клиента сведен к минимуму и содержит только необходимые
данные — контейнер значений и ключ.
В листинге 12.14 представлена вспомогательная функция между вызывающим
кодом и рекурсивной функцией, во многом похожая на публичные методы
класса. Идея заключается в том, чтобы минимизировать когнитивную нагрузку
на разработчика клиентской программы и предоставить возможность позднее
переопределить функцию, если это необходимо.
Листинг 12.14. Р
екурсивная функция, вызываемая из абстрагированной
вспомогательной функции
bool bin_search(const std::vector<int>& values, int key, int start, int end) {
int mid = start + (end-start) / 2;
if (values[mid] == key)
return true;
if (values[mid] < key)
return bin_search(values, key, mid+1, end);
return bin_search(values, key, start, mid-1);
}
404
Глава 12. Обобщенные принципы написания кода
bool bin_search(const std::vector<int>& values, int key) {
return bin_search(values, key, 0, values.size()-1);
Является связующим
}
звеном между клиентом
int main() {
std::vector<int> values;
for (int i = 0; i < 100; ++i)
values.push_back(i);
std::cout << bin_search(values, 55) << '\n';
}
и вызовом рекурсивной
функции
Делает вызов
максимально простым
Рекомендации
Минимизируйте объем данных, которые должен передавать код, вызывающий функцию; требуйте предоставления только самой необходимой
информации.
Обращайте внимание на случаи, когда вспомогательные функции могут
минимизировать интерфейс и упростить вызов функций.
12.8. Ошибка #98: неправильное сравнение чисел
с плавающей точкой
Эта ошибка влияет на корректность, а также немного ухудшает читаемость
и эффективность. Сравнение чисел кажется простым делом, но значения с плавающей точкой следует считать приблизительными, а не точными.
Проблема
Наша математическая интуиция подсказывает, что, если число разделить на
делитель, а затем полученный результат умножить обратно на этот делитель,
должно получиться исходное число. Алгебра подтверждает эту истину, и большинство из нас принимают ее без колебаний. Однако такая интуиция может
ввести нас в заблуждение, когда мы приступаем к работе с числами с плавающей точкой.
Рассмотрим код в листинге 12.15, где простая операция деления дает неожиданный результат. В этом примере сравнения мы полагаемся на интуицию
и ожидаем равенства результатов. Результат деления действительно очень, очень
близок, но неточен. Разработчики привыкли использовать оператор равенства
для проверки эквивалентности значений, поэтому эта привычка приводит их
к неправильному использованию оператора.
Наш отважный разработчик ожидал, что сравнение вернет значение true, но
был удивлен, обнаружив false.
12.8. Ошибка #98
405
Листинг 12.15. Сравнение с использованием оператора равенства
int main() {
double amount = 100.0 / 3;
std::cout << (amount == 33.3333333333 ? "true" : "false") << '\n';
return 0;
Ожидаемый результат должен
}
быть true, но это не так
Анализ
Компьютеры — это машины с ограниченными возможностями. Одно из их ограничений состоит в том, что для представления чисел с плавающей точкой отводится
лишь небольшое количество битов. Значения с плавающей точкой — аппроксимация минимального набора действительных чисел. Однако при переходе от
алгебры к программированию возникает неприятный сюрприз: число с плаваю
щей точкой не может точно представить большинство действительных чисел.
Если бы наш разработчик умножил результат деления на 3, сравнение с числом
100 прошло бы успешно — но по неверной причине. Фактическое значение результата деления не заканчивается цифрой 3; последняя цифра, скорее всего,
будет 4. Стандарт IEEE 754 определяет такое поведение; и все соответствующие
компиляторы будут ему следовать. На моем компьютере при установке точности вывода в 18 знаков последние цифры результата — … 57; при большем
увеличении точности представление становится еще более неожиданным. Эти
цифры, отличные от 3, — не то, что мы ожидаем увидеть, и совсем не то, что
подсказывает нам интуиция. Если кто-то возразит, что увеличение количества
знаков после запятой решит эту проблему, то возникает вопрос: насколько
именно нужно увеличивать точность? Ответ: никакое количество разрядов не
позволит представить значение абсолютно точно. Ключевая мысль: числа с плавающей точкой — это всегда приближения. Они редко бывают правильными, но
обычно достаточно точны для большинства приложений. Эта проблема касается
не только компьютеров: точное значение одной трети невозможно представить
в десятичной системе счисления (то есть с основанием 10) — а что насчет двенадцатеричной системы счисления (с основанием 12)?
Решение
Поскольку числа с плавающей точкой приближенные, их нельзя сравнивать с помощью оператора operator==. Для сравнения необходимо определять, насколько
схожи два приблизительных значения. Если разница между ними достаточно
мала, их следует считать эквивалентными. Код в листинге 12.16 демонстрирует
метод сравнения дельта-эпсилон. Для учеников я называю его функцией типа
«достаточно близко».
Дельта — это греческая буква, которой математики обозначают разность между
величинами; представьте себе, что это вычитание одного значения из другого.
406
Глава 12. Обобщенные принципы написания кода
Эпсилон — это греческая буква, обозначающая очень маленькое значение (для
строковых переменных она представляет собой пустую строку). Идея состоит
в том, чтобы найти разницу между двумя значениями и посмотреть, меньше
ли она заранее заданного очень малого порога. Если да — подразумевается, что
числа достаточно близки и должны считаться эквивалентными, даже если их
последние цифры различаются.
Чтобы разность была корректной, необходимо вычесть меньшую величину из
большей. Поскольку заранее неизвестно, какое число больше, вычисляется абсолютное значение разницы, что гарантирует, что результат будет положительным или равным нулю. Эпсилон часто определяется как 10–14 — это примерно
минимальная величина, которую можно достоверно определить для типичного
64-разрядного double. Однако убедитесь, что ваша система может эффективно
работать с таким значением; для чисел меньшей разрядности потребуется большее значение эпсилон. Кроме того, если желаемую точность нужно уменьшить,
возьмите большее значение эпсилон. В следующем коде используется точность
10–10, поскольку для этой задачи ее достаточно.
Листинг 12.16. Сравнение с использованием метода дельта-эпсилон
bool close_enough(double value, double
target, double epsilon=1e-14) {
return fabs(value-target) < epsilon;
}
По умолчанию принимается разумное,
очень маленькое значение
Определяет, достаточно ли
мала разница, чтобы считать
числа равными
int main() {
double amount = 100.0 / 3;
std::cout << (close_enough(amount, 33.3333333333, 1e-10) ? "true" :
"false") << '\n';
}
return 0;
Рекомендации
Помните, что алгебра и программирование связаны, но не эквивалентны.
Сравнивайте значения с плавающей точкой посредством сравнения их разницы с произвольным малым значением.
12.9. Ошибка #99: присваивание целому числу
значения с плавающей точкой
Эта ошибка влияет на корректность. Вопрос преобразования значения с плаваю
щей точкой в целое число сложнее, чем кажется.
12.9. Ошибка #99
407
Проблема
Многим студентам рассказывают о функции round и тем самым учат тому, что
всегда следует использовать преобразование между значениями с плавающей
точкой и целыми числами. Им также преподают усечение с помощью приведения типов. Эти два подхода, безусловно, преобразуют число с плавающей
точкой в целое, но не охватывают все возможные варианты и далеко не всегда
оказываются лучшими.
Следующий код демонстрирует базовое понимание, но при этом не учитывает
два важных преобразующих метода.
Листинг 12.17. П
реобразование числа с плавающей точкой в целое
с помощью усечения и округления
int main() {
std::vector<double> values;
values.push_back(3.14); values.push_back(2.71); values.push_back(1.5);
values.push_back(-1.5); values.push_back(-2.71);
values.push_back(-3.14);
std::cout << "value trunc round\n";
for (int i = 0; i < values.size(); ++i) {
double v = values[i];
std::cout << std::setw(5) << v
<< std::setw(6) << static_cast<int>(v)
<< std::setw(6) << round(v)
<< '\n';
}
return 0;
}
Вывод программы показывает разницу между этими двумя функциями преобразования:
value trunc round
3.14
3
3
2.71
2
3
1.5
1
2
-1.5
-1
-2
-2.71
-2
-3
-3.14
-3
-3
Анализ
Предыдущий код показывает результаты работы двух часто используемых
функций. Однако этих результатов недостаточно для решения всего спектра
задач разработчика. Например, если нам нужно вычислить количество коробок, необходимых для перевозки 3,14 единицы товара, то эти функции преобразования не вернут правильное значение — 4. Если рассмотреть ситуацию
408
Глава 12. Обобщенные принципы написания кода
с отрицательным значением, например, баланс составляет –3,14 доллара (то
есть это задолженность), — какие действия необходимо выполнить по счету?
Доступные функции покажут, что нужно выполнить операцию –$3 (снятие
3 долларов), тогда как правильным ответом была бы операция –$4 (после чего
остаток сдачи вернулся бы обратно). Такие расхождения указывают на то, что
для получения корректного результата необходимы дополнительные функции.
Другими словами, если использовать только имеющиеся варианты, для решения
двух вышеупомянутых задач потребуется написание дополнительного кода,
чтобы вручную определить правильное поведение, — иногда это увеличивает
риск ошибки. Лучше было бы иметь функцию, которая подходит для преобразования без дополнительной, возможно сложной, логики.
Решение
Помимо типичного усечения и округления, при преобразовании значений с плавающей точкой в целые числа необходимо учитывать еще две дополнительные
функции. Все четыре будут проанализированы и описаны с примерами для закрепления материала. Во многих разъяснениях знак числа упускается из виду, что
неверно. Корректная интерпретация должна учитывать весь диапазон значений.
Усечение следует понимать как усечение в сторону нуля. Это означает, что при
удалении дробной части значение смещается в определенном направлении.
Каким бы ни было исходное число, после отсечения результат становится ближе к нулю. Визуальное представление такого смещения показано на рис. 12.1.
− бесконечность
0
+ бесконечность
Усечение в сторону нуля
Округление в большую сторону
Округление в меньшую сторону
Округление < 0,5
Округление ≥ 0,5
Рис. 12.1. Направления преобразования для различных функций
Примером усечения может служить возраст человека (хотя если ребенок очень
маленький, кажется, все уделяют внимание дробным значениям возраста).
12.9. Ошибка #99
409
В течение года между днями рождения возрастом считается целое число лет —
точное значение с плавающей точкой без дробной части. То есть усечение
сдвигает значение в сторону нуля, пока оно не достигнет целого числа. Ребенок
в возрасте 3,14 года считается трехлетним, пока ему не исполнится 4.
При изучении преобразований необходимо рассмотреть функцию std::floor.
В отличие от усечения, она смещает значение в сторону отрицательной бесконечности. Как видно из рис. 12.1, для положительных чисел это выглядит так
же, как усечение, но для отрицательных работает совсем по-другому.
Примером функции округления в меньшую сторону может служить какая-нибудь
фирма-производитель. Рассмотрим компанию, которая производит мороженое
в виде сэндвичей. Если коробка сэндвичей вмещает 12 штук, а компания произвела 150 штук, она может собрать только 12 коробок. Никто не захочет купить
коробку и обнаружить, что в ней всего шесть сэндвичей (хотя, если бы они продавали ее как средство для похудения, это могло бы сработать).
Функция округления работает примерно так же, как мы изучали на уроках
арифметики: если дробная часть меньше половины, мы берем меньшее значение; а если превышает — переходим к большему; а при дробной части, равной
половине, ничья разрешается переходом к большему значению. На рис. 12.1
показано, в каком направлении происходит переход при применении такой
функции. Округление сложно описать как переход, поскольку оно зависит и от
значения числа, и от его знака.
В качестве простого примера можно привести оценку за тест: если студенты
набирают 89,6 балла, они считают, что заслуживают 90. Я всегда стараюсь округ
лять в пользу студентов, но не могу оправдать присвоение 90 баллов тому, кто
набрал всего 89,4.
Наконец, следует также рассмотреть функцию std::ceil. Ее смещение противоположно смещению функции floor. Она всегда переходит в направлении
положительной бесконечности. Любая дробная часть позволяет ей перейти
к следующему большему целому значению. На рис. 12.1 наглядно показано
смещение значения при использовании этой функции.
Примером std::ceil может быть покупка галлонов краски для ремонта. Предположим, что площадь стены 165 квадратных футов, а галлон краски покрывает
144 квадратных фута. К сожалению, в магазинах товаров для дома не продают
дробные галлоны — ладно, в этом примере мы игнорируем кварты! Таким образом, нам нужно купить 2 галлона, чтобы покрасить эту стену, в результате чего
значительная часть второго галлона останется.
Код в листинге 12.18 добавляет дополнительные функции std::floor и std::ceil
к уже упомянутым. Характер задачи определяет, какой из четырех вариантов
использовать. Обработка дробной части имеет важное значение для реальных
задач и не может быть проигнорирована без негативных последствий.
410
Глава 12. Обобщенные принципы написания кода
Листинг 12.18. Преобразование числа с плавающей точкой в целое
int main() {
std::vector<double> values;
values.push_back(3.14); values.push_back(2.71); values.push_back(1.5);
values.push_back(-1.5); values.push_back(-2.71);
values.push_back(-3.14);
std::cout << "value trunc floor round ceil\n";
for (int i = 0; i < values.size(); ++i) {
double v = values[i];
std::cout << std::setw(5) << v
<< std::setw(6) << static_cast<int>(v)
<< std::setw(6) << std::floor(v)
<< std::setw(6) << round(v)
<< std::setw(5) << std::ceil(v)
<< '\n';
}
return 0;
}
Результат выполнения приведенного выше кода следующий:
value trunc floor round ceil
3.14
3
3
3
4
2.71
2
2
3
3
1.5
1
1
2
2
-1.5
-1
-2
-2
-1
-2.71
-2
-3
-3
-2
-3.14
-3
-4
-3
-3
Вернемся к проблемам, отмеченным в разделе «Анализ». Краткий обзор показывает преимущество выбора подходящей функции преобразования, причем
без добавления какой-либо дополнительной логики. Задача с упаковочной
коробкой — это случай, когда существует ненулевая дробная часть. Она требует
целой коробки, независимо от величины дробной части; правильный выбор —
использовать функцию ceiling. В задаче со счетом, где баланс составляет –3,14
доллара, требуется отрицательное действие по счету (опять же снятие) на сумму
4 доллара, чтобы покрыть списание. В этой задаче применяется функция floor,
чтобы снять нужную сумму.
К счастью, банки и другие финансовые учреждения не используют числа
с плавающей точкой для представления денежных единиц. Они на собственном горьком опыте убедились, что хитрые разработчики могут «нажиться» на
этом. Эта техника называется «нарезка салями» (“salami slicing”) и тщательно
скрывается — какое финансовое учреждение захочет признаться во внутреннем
мошенничестве?
Для правильного выбора подходящего способа преобразования требуется
более широкий набор функций, позволяющий выполнить конвертацию без
12.10. Ошибка #100
411
определения граничных условий и добавления потенциально сложной логики.
Когда задачу можно решить с помощью одной функции, ее намерение выражается лучше (повышается читаемость), и код гораздо проще написать корректно
(улучшается эффективность).
Рекомендации
Разберитесь в сути решаемой задачи и в том, что подразумевается под дробной частью.
Выберите правильную функцию для преобразования значения с плавающей
точкой в целое число.
Поймите, в какую сторону должны смещаться результаты: к отрицательной
бесконечности, положительной бесконечности или к нулю.
12.10. Ошибка #100: игнорирование
предупреждений компилятора
Эта ошибка в значительной степени влияет на корректность и в меньшей — на
читаемость и эффективность. Предупреждения компилятора существуют не
просто так; игнорировать их неразумно, поскольку некоторые из них приводят к неопределенному поведению. Чаще всего предупреждения не влияют
на рабочие характеристики программы, но иногда все же могут; оставлять их
без внимания опасно. Что еще более важно, эти проблемы могут сказаться на
будущей разработке и застать врасплох неосторожных программистов, занимаю
щихся сопровождением.
Проблема
Код в листинге 12.19 компилируется без проблем и выдает только одно преду
преждение (в моей системе; в вашей системе все может быть иначе). При компиляции не включены никакие дополнительные проверки. Такое предупреждение
относительно безобидно, и возникает соблазн продолжить разработку. Если
достаточно долго игнорировать такие, казалось бы, безвредные предупреждения, они накапливаются, и в конце концов процесс становится неуправляемым.
Листинг 12.19. Простая программа с несколькими проблемами
int compute(int* x, int y) {
int* pos = x;
if (*x > NULL)
return *x + *x;
return 0;
}
NULL используется неправильно
int main() {
x имеет неопределенное значение
int* x; //
int res = compute(x, 0);
Это неопределенное значение
std::cout << "hello, world\n";
передается в функцию
return 0;
}
int compute(int* x, int y) {
int* pos = x;
NULL используется неправильно
if (*x > NULL)
return *x + *x;
412returnГлава
12. Обобщенные принципы написания кода
0;
}
int main() {
x имеет неопределенное значение
int* x; //
int res = compute(x, 0);
Это неопределенное значение
std::cout << "hello, world\n";
передается в функцию
return 0;
}
При включенной проверке предупреждений (использовались -Wall и -Wextra)
вывод внезапно начинает выглядеть ужасно. Результат компиляции с этой проверкой предупреждений следующий:
Компилятор сигнализирует
о неправильном использовании
warning-bad.cpp: In function 'int compute(int*, int)':
warning-bad.cpp:6:14: warning: NULL used in arithmetic [-Wpointer-arith]
6 |
if (*x > NULL)
|
^~~~
warning-bad.cpp:5:10: warning: unused variable 'pos' [-Wunused-variable]
5 |
int* pos = x;
|
^~~
warning-bad.cpp:4:25: warning: unused parameter 'y' [-Wunused-parameter]
4 | int compute(int* x, int y) {
|
~~~~^
warning-bad.cpp: In function 'int main()':
warning-bad.cpp:13:9: warning: unused variable 'res' [-Wunused-variable]
13 |
int res = compute(x, 0);
|
^~~
warning-bad.cpp:14:5: warning: label 'std' defined but not used [-Wunusedlabel]
14 |
std:cout << "hello, world\n";
|
^~~
warning-bad.cpp:13:22: warning: 'x' is used uninitialized [-Wuninitialized]
13 |
int res = compute(x, 0);
Компилятор задается вопросом, почему
|
~~~~~~~^~~~~~
Компилятор знает, что использование
без инициализации создаст проблему
переменная не используется; может
быть, чего-то не хватает
Это безобразие говорит о том, к чему следует внимательно прислушаться.
Две дополнительные опции предупреждений могут иметь разные названия в зависимости от используемого инструмента. Они относятся непосредственно к g++
и добавляются в командную строку; MSVC и прочие компиляторы используют
другие опции и способы их включения. Выберите максимально возможный
высокий уровень предупреждений, чтобы выявить как можно больше ошибок,
которые компилятор может обнаружить.
Анализ
Безобидные на первый взгляд предупреждения могут указывать на несколько
проблем. Во-первых, неиспользуемые параметры или переменные могут быть
12.10. Ошибка #100
413
свидетельством рефакторинга, проведенного для упрощения кодовой базы. Если
это так, каждый вызов функции вынужден передавать этот лишний, бесполезный
параметр, что запутывает его назначение и повышает сложность. Во-вторых,
неинициализированные переменные почти всегда требуют исправления; можно
даже утверждать, что такие случаи должны считаться ошибками. В-третьих, разработчик, вероятно, допустил ошибку, и ему может потребоваться более глубоко
разобраться в механизмах работы кода. В-четвертых, всегда есть вероятность
непреднамеренной опечатки. Существует множество других причин. Обнаруженные предупреждения будут зависеть от кодовой базы и опыта программиста.
Лучшая практика — проводить компиляцию с включением максимально возможного количества предупреждений.
ПРИМЕЧАНИЕ Однажды я работал над проектом, в котором возникли 21 283 преду
преждения компилятора по 48 категориям. Одна из категорий — неиспользуемые переменные — содержала 5176 случаев. Я начал систематически сокращать число преду
преждений, занимаясь конкретными категориями по очереди. Через 5 месяцев меня
перевели на новый проект, и я так и не закончил работу — сомневаюсь, что это было
связано с моими усилиями! Но кто знает? Возможно, код просто не хотел улучшаться.
Решение
Включение максимального уровня предупреждений, скорее всего, выявит множество проблем. Обратитесь к документации вашего компилятора, чтобы узнать,
как это сделать. Вы можете ускорить этот процесс, выполнив поиск в интернете.
Каждый компилятор имеет свой набор дополнительных предупреждений.
Все предупреждения следует рассматривать как проблему, которую необходимо
решить. В случае опечаток исправление текста программы обычно не вызывает
затруднений. Подумайте, действительно ли неиспользуемые переменные или
параметры нужны или же при рефакторинге следует выполнить очистку кода.
Когда обнаруживаются неинициализированные переменные, исправляйте их
немедленно! Неисправленные предупреждения быстро превращаются в технический долг, который может стать настолько большим, что захочется избежать
его устранения. Не допускайте такого психологического барьера и исправляйте
предупреждения по мере их обнаружения. Если же вам предстоит действительно
сложная задача, узнайте, как настроить компилятор (если это возможно) так,
чтобы преобразовывать предупреждения в ошибки. Это предотвращает сборку
кода с предупреждениями и стимулирует разработчика к устранению всех обнаруженных проблем. Лучше всего делать это после очистки, чтобы избежать появления новых ошибок. При менее строгом подходе включайте дополнительные
предупреждения по очереди и постепенно устраняйте проблемы.
Если доступны другие инструменты статического анализа, рассмотрите возможность их использования для дополнительной проверки кодовой базы на наличие
414
Глава 12. Обобщенные принципы написания кода
ошибок, которые не обнаруживает компилятор. Хорошим вариантом для C++
является инструмент с открытым исходным кодом Cppcheck (https://cppcheck.
sourceforge.io/). Существуют и коммерческие инструменты, и ваша компания,
возможно, уже располагает одним или несколькими из них. Если нет — попросите приобрести один такой инструмент для разработки.
Листинг 12.20. Простая программа после очистки
int compute(int* x, int y) {
int* pos = x;
if (*x > 0)
return *x + y;
return *pos;
}
int main() {
int n = 42;
int* x = &n;
int res = compute(x, 0);
std::cout << res << " hello, world\n";
return 0;
}
Компиляция этого кода с включенными предупреждениями не выдает ошибок.
Это не доказывает корректность программы, но устраняет потенциальные и реальные проблемы по сравнению с листингом 12.19.
Рекомендации
Включите максимально возможный уровень предупреждений, с которым
ваша команда способна работать над проектом; если он будет слишком низким, ошибки будут пропущены, а если слишком высоким — может возникнуть
«усталость от оповещений» (alert fatigue). Вы можете утонуть в избыточной
детализации!
Не игнорируйте предупреждения в коде, когда это возможно; чем раньше
исправлена ошибка, тем меньше расходы на нее — руководству придется
определить уровень практичности, и он всегда будет отличаться от определенного разработчиком.
Предупреждения в коде внешних библиотек или проектов могут потребовать отключения определенных предупреждений только для этого кода;
рассмотрите возможность обернуть такие файлы во включаемый файл с отключенными конкретными предупреждениями.
По возможности используйте инструменты статического анализа, чтобы
выявлять ошибки; некоторые из них бесплатны, большая часть — платные,
но зачастую их полезность быстро окупается.
Комьюнити рецензентов
и переводчиков ИТ-литературы
Миссия участников клуба — обеспечить
высокое качество профессиональной
переводной литературы в России.
«Книжные дебагеры» проверяют
корректность терминологии и подписей
на схемах и иллюстрациях, чтобы сделать
книги более понятными русскоязычному
читателю. Стать участником Read IT Club
может любой ИТ-специалист, готовый
поделиться опытом с сообществом.
присоединиться к нам
ВОСПОЛЬЗУЙТЕСЬ
В ОЗ М ОЖ Н ОСТЬЮ
ПРИОБРЕСТИ КНИГИ
НА САЙТЕ ИЗДАТЕЛЬСТВА
piter.com
со скидкой по промокоду
20%
PITER