/
Author: Мейерс С.
Tags: компьютерные технологии программирование информационные технологии языки программирования язык программирования c++
ISBN: 5-93700-006-4
Year: 2000
Text
Скотт Мейерс
Эффективное ▼
C++
использование
М ранамелдацлй ее улучаюяи*
0 Объектно -
ориентированный
Addison
Wesley
Скотт Мейерс
Эффективное использование i
50 рекомендаций по улучшению
ваших программ и проектов
Effective C++
Second Edition
50 Specific Ways
to Improve Your Programs
and Designs
Scott Meyers
Addison-Wesley
An imprint of Addison Wesley Longman, Inc.
Серия «Для программистов»
Эффективное
использование
-1- I
V/ ’ »
50 рекомендаций
по улучшению ваших
программ и проектов
Скотт Мейерс
Москва, 2000
ББК 32.973.26-018.1
М46
Мейерс С.
М46 Эффективное использование C++. 50 рекомендаций по улучшению ва-
ших программ и проектов: Пер. с англ. - М.: ДМК, 2000. - 240 с,: ил.
(Серия «Для программистов»),
ISBN 5-93700-006-4
В книге приводятся практические рекомендации по проектированию
и программированию на языке C++. Изложены правила, позволяющие
программисту сделать выбор между различными методами реализации
программы - наследованием и шаблонами, шаблонами и указателями на ба-
зовые классы, открытым и закрытым наследованием, закрытым наследо-
ванием и вложенными классами, виртуальными и невиртуальными функ-
циями и т.п. Для иллюстрации всех принципов используются новейшие
языковые средства из стандарта ISO/ANSI C++ - внутриклассовая ини-
циализация констант, пространства имен и шаблоны-члены класса. Рас-
сматривается стандартная библиотека шаблонов и классы, подобные string
и vector.
ББК 32.973.26-018.1
The author and publisher have taken care in the preparation of this book, but make no express or
implied warranty of any kind and assume no responsibility for errors or omissions. No liability is
assumed for incidental or consequental damages in connection with or arising out of the use of the
information or programs contained herein.
All rights reserved. No part of this publication may be reprod used, stored in a retrieval system, or
transmitted, in any form, or by any means, electronic, mechanical, photocopying, recording, or
otherwise, without the prior consent of the publisher.
Права на издание книги были получены по соглашению с Pearson Education USA
и Литературным агентством Мэтлок.
Все права защищены. Любая часть этой книги не может быть воспроизведена в какой бы то
ни было форме и какими бы то пи было средствами без письменного разрешения владельцев
авторских прав.
Материал, изложенный в данной книге, многократно проверен. Но, поскольку вероятность
технических ошибок все равно существует, издательство не может гарантировать абсолютную
точность и правильность приводимых сведений. В связи с этим издательство не несет ответ-
ственности за возможные ошибки, связанные с использованием книги.
ISBN 0-201-92488-9 (англ.) Copyright © 1998 by Addison Wesley Longman, Inc.
ISBN 5-93700-006-4 (pyc.) © Перевод на русский язык, оформление ДМК, 2000
Отзывы на первое издание книги
«Эффективное использование C++»
Книга Мейерса действительно заслуживает похвал. Она содержит превосход-
ное изложение основ управления памятью и отличное объяснение значений раз-
личных типов наследования C++.
New York Computerist
Книгу, безусловно, необходимо прочитать, прежде чем вы приступите к пер-
вому реальному проекту на C++, и перечитать, когда вы приобретете некоторый
опыт.
comp.lang.c++
Книга имеет подзаголовок «50 рекомендаций по улучшению ваших программ
и проектов». Автор не только предлагает четкие правила, которым необходимо
следовать при написании кода на C++, но и приводит обоснования и примеры,
иллюстрирующие их применение.
Sun Expert
Можно рекомендовать книгу «Эффективное использование C++» всем, кто
стремится овладеть C++ на среднем или более высоком уровне.
The С User’s Journal
Это одна из лучших книг для программистов среднего уровня, которые
я знаю. Опа структурирована, как серия «эссе» по конкретным проблемам, возни-
кающими перед программистами на C++. Это одна из тех редких книг по програм-
мированию, которые одновременно и занимательны, и полезны.
comp.lang.c++
В результате получилась небольшая книга, аналогичная по охвату и духу дру-
гой книге, «Элементы стиля» (The Elements of Style), написанной Уильямом Стран-
ком и Э. Б. Уайтом (William Strunk, Е. В. White). По крайней мере, на моей полке
оба издания стоят рядом... Эта скромная небольшая книга формулирует ясные
цели и способствует их осуществлению.
C++ Report
Эта книга содержит практические советы по использованию C++.
DEC Professional
Любой программист на C++ должен не только приобрести, но изучить и при-
менять эту книгу. Она удобна в пользовании, так как снабжена перекрестными
ссылками и предметным указателем.
Computer Language
Содержание
Предисловие.......................................................9
Введение.........................................................16
Глава 1. Переход от С к C++......................................26
Правило 1. Предпочитайте const и inline использованию #define .26
Правило 2. Предпочитайте <iostream> использованию <stdio.h>.29
Правило 3. Предпочитайте new и delete использованию malloc и free... 31
Правило 4. Предпочитайте комментарии в стиле C++...............33
Глава 2. Управление памятью......................................34
Правило 5. Используйте одинаковые формы new и delete...........34
Правило 6. Используйте delete в деструкторах
для указателей членов...................................36
Правило 7. Будьте готовы к нехватке памяти.....................37
Правило 8. При написании операторов new и delete
придерживайтесь ряда простых правил.....................43
Правило 9. Старайтесь не скрывать «нормальную» форму new....46
Правило 10. Если вы написали оператор new,
напишите и оператор delete...............................48
Глава 3. Конструкторы, деструкторы
и операторы присваивания......................................55
Правило 11. Для классов с динамическим выделением памяти
объявляйте копирующий конструктор
и оператор присваивания .................................55
Правило 12. Предпочитайте инициализацию
присваиванию в конструкторах.............................58
Правило 13. Перечисляйте члены в списке инициализации
в порядке их объявления..................................62
Правило 14. Убедитесь, что базовые классы
имеют виртуальные деструкторы............................63
Правило 15. operator= должен возвращать ссылку на *this.....68
Правило 16. В operator= присваивайте значения
всем элементам данных....................................71
Правило 17. В operator- осуществляйте проверку на присваивание
самому себе..............................................74
Глава 4. Классы и функции:
проектирование и объявление......................................79
Правило 18. Стремитесь к таким интерфейсам классов,
которые будут полными и минимальными ....................80
Содержание
Правило 19. Проводите различие между функциями-членами,
функциями, не являющимися членами класса,
и дружественными функциями.................................84
Правило 20. Избегайте данных в открытом интерфейсе.........89
Правило 21. Везде, где только можно, используйте const.....90
Правило 22. Предпочитайте передачу параметров по ссылке
передаче по значению.......................................96
Правило 23. Не пытайтесь вернуть ссылку,
когда вы должны вернуть объект.............................99
Правило 24. Тщательно обдумывайте выбор между
перегрузкой функции и аргументами по умолчанию............ 103
Правило 25. Избегайте перегрузки по указателю и численному типу . 107
Правило 26. Примите меры предосторожности
против потенциальной неоднозначности...................... 110
Правило 27. Явно запрещайте использование нежелательных
функций-членов, создаваемых компилятором
по умолчанию............................................. 112
Правило 28. Расчленяйте глобальное пространство имен..... 113
Глава 5. Классы и функции: реализация.................119
Правило 29. Избегайте возврата «дескрипторов»
внутренних данных........................................ 119
Правило 30. Не используйте функции-члены, возвращающие
неконстантные указатели или ссылки на члены класса
с более ограниченным доступом............................ 124
Правило 31. Никогда не возвращайте ссылку
на локальный объект или разыменованный указатель,
инициализированный внутри функции посредством new.... 126
Правило 32. Откладывайте определение переменных
до последнего момента.................................... 129
Правило 33. Тщательно обдумывайте использование
встраиваемых функций..................................... 131
Правило 34. Уменьшайте зависимости файлов при компиляции.. 136
Глава 6. Наследование
и объектно-ориентированное проектирование...................145
Правило 35. Используйте открытое наследование
для моделирования отношения «есть разновидность» ... 146
Правило 36. Различайте наследование интерфейса
и наследование реализации................................ 151
Правило 37. Никогда не переопределяйте
наследуемые невиртуальные функции ....................... 158
Правило 38. Никогда не переопределяйте наследуемое значение
аргумента по умолчанию................................... 160
лива Содержание
Правило 39. Избегайте приведения типов
вниз по иерархии наследования........................ 162
Правило 40. Моделируйте отношения «содержит»
и «реализуется посредством» с помощью вложения . 169
Правило 41. Различайте наследование и шаблоны............. 172
Правило 42. Продумывайте подход
к использованию закрытого наследования................ 176
Правило 43. Продумывайте подход
к использованию множественного наследования............ 181
Правило 44. Говорите то, что думаете, понимайте то, что говорите ... 193
Глава 7. Другие принципы......................................195
Правило 45. Необходимо знать, какие функции
неявно создает и вызывает C++......................... 195
Правило 46. Предпочитайте ошибки во время компиляции
ошибкам во время выполнения........................... 198
Правило 47. Обеспечьте инициализацию
нелокальных статических объектов до их использования ..201
Правило 48. Уделяйте внимание предупреждениям компилятора. 205
Правило 49. Ознакомьтесь со стандартной библиотекой....... 206
Правило 50. Старайтесь понимать цели C++.................. 213
Послесловие.................................................. 218
Алфавитный указатель..........................................220
It И IMMI ииия
Посвящается Нэнси,
без которой ничто
не представляло бы большой ценности.
Мудрость и красота
образуют очень редкое сочетание.
Петроиий Арбитр, Сатирикон, XCIV
Предисловие
Эта книга - результат осмысления моего опыта преподавания языка C++ профес-
сиональным программистам. Я обнаружил, что большинство слушателей после
недели интенсивной подготовки очень уверенно обращаются с основными кон-
струкциями языка, но испытывают затруднения, пытаясь эффективно использо-
вать их в процессе программирования. Это наблюдение положило начало моим
попыткам сформулировать короткие, конкретные и легко запоминающиеся руко-
водящие принципы для эффективной работы в C++, то есть написать краткий
обзор того, что опытные программисты делают или избегают делать.
Первоначально меня интересовали правила, которые могла бы «навязать»
программа, похожая на lint. С этой целью я провел исследование по созданию
средств проверки кода C++ на предмет соответствия критериям, определенным
пользователем1. К сожалению, исследование закончилось раньше, чем был разра-
ботан первый прототип. Но, к счастью, сейчас уже имеется несколько коммерчес-
ких продуктов проверки программ на C++.
Хотя первоначально меня интересовали правила программирования, соблюдение
которых можно было бы отследить автоматически, я скоро осознал ограниченность
такого подхода. Большинство правил, используемых хорошими программистами,
слишком трудно формализовать или из них существует чересчур много важных
исключений, для того чтобы такие правила было просто поддерживать программны-
ми средствами. Таким образом, у меня возникла идея создания чего-то более гибко-
го, чем компьютерная программа, но в то же время более конкретного, чем обычный
учебник по C++. Результат перед вами: книга содержит 50 конкретных рекоменда-
ций о том, как улучшить ваши программы и проекты на C++.
В этом руководстве вы найдете советы: что и почему нужно делать, програм-
мируя на C++; чего и почему делать не следует. По существу, конечно, вопрос «по-
чему» важнее вопроса «что», но намного удобнее ссылаться на список правил, чем
заучивать наизусть всю книгу.
В отличие от большинства справочников по C++ структура предлагаемого
руководства базируется не на принципе последовательного изучения конкретных
конструкций языка. Здесь не говорится в одной главе о конструкторах, в другой -
о виртуальных функциях, в третьей - о наследовании и т.д. Напротив, каждый раз-
дел посвящен рассмотрению определенного правила, а описание конкретных ин-
струментов языка может проходить через всю книгу.
1 Вы можете найти обзор исследования на Web-сайте http://www.awl .сот/ср/сс++.html.
10
Предисловие
Преимущество этого подхода состоит в том, что он в большей степени соот-
ветствует сложности программных систем, для которых предназначен C++, - сис-
тем, при работе с которыми освоения индивидуальных инструментов языка не-
достаточно. Например, опытные разработчики C++ знают, что понимание сути
встраиваемых функций и виртуальных деструкторов не всегда означает знание
встраиваемых виртуальных деструкторов. Такие «закаленные в боях» программи-
сты сознают, что постижение взаимодействий между инструментами C++ имеет
первостепенную важность для эффективного использования языка. Структура
книги отражает это фундаментальное свойство языка C++.
Недостатком подобной стратегии является необходимость разыскивать по всей
книге полную информацию о той или иной конструкции C++. Для удобства чита-
теля книга содержит множество перекрестных ссылок,, а в конце имеется подроб-
ный предметный указатель.
При подготовке второго издания мое стремление к переработке книги сдер-
живали некоторые опасения. Десятки тысяч программистов воспользовались пер-
вым изданием «Effective C++», и я не хотел потерять то, что их привлекало. Одна-
ко за шесть лет, прошедших с момента написания книги, C++ и его библиотека
претерпели заметные изменения (см. правило 49). Кроме того, изменилось мое по-
нимание языка и общепринятая практика его применения. Чтобы отразить столь
существенные поправки, было важно пересмотреть многие технические детали
«Эффективного использования C++». Я делал все возможное, внося отдельные
правки в первое издание, но книги, как и программное обеспечение, увы, облада-
ют общим свойством: с некоторого момента небольших изменений становится
недостаточно, и возникает необходимость в полной переработке всего материала.
Эта книга - результат подобного обновления. Перед вами, по существу, «Эффек-
тивное использование C++», версия 2.0.
Тем, кто знаком с первым изданием, будет интересно узнать, что каждое пра-
вило книги было переработано. Тем не менее я полагаю, что структура в общем
осталась прежней, хотя в отдельных местах и претерпела изменения. Из 50 пра-
вил первого издания я сохранил 48, хотя и подправил на скорую руку некоторые
названия (в дополнение к пересмотру изложенного материала). Еще два правила,
содержащие совершенно новые сведения, вошли в книгу под номерами 32 и 49;
при этом значительная часть информации, ранее приводимой в правиле 32, теперь
находится в правиле 1. Потребовалось поменять местами правила 41 и 42, по-
скольку это облегчило изложение материала. И наконец, я изменил направление
стрелок наследования на рисунках. Теперь они в соответствии с общепринятым
соглашением указывают от производных к базовым классам. Этого же соглаше-
ния я придерживался и в книге 1996 года «More Effective C++», обзор которой
можно найти в послесловии.
Предлагаемый в данной книге набор рекомендаций далеко не всеобъемлющ,
но сформулировать достаточно хорошие правила, применимые всегда и во всех
приложениях, гораздо труднее, чем это может показаться. Возможно, вы знаете
0 других правилах и методах эффективного программирования на C++. Я был бы
вам очень признателен, если бы вы поделились ими.
С другой стороны, вы можете решить, что некоторые правила вам не под-
ходят, поскольку есть лучшие методы решения рассматриваемых задач, или что
- -
<>»'<* • - I IBM
11
Благодарности
обсуждение того или иного вопроса неясно, неполно, даже ошибочно. В таком
случае мне также было бы интересно узнать об этом.
Дональд Кнут (Donald Knuth) имеет богатый опыт по части предоставления
небольших поощрений читателям, сообщающим ему об ошибках в его книгах.
Стремление к совершенству похвально в любом случае, а то обстоятельство, что
рынок наводнило большое количество книг по C++, изобилующих ошибками, еще
больше укрепляет желание последовать примеру Кнута. Поэтому в благодарность
за каждое сообщение об ошибке, содержащейся в этом руководстве, я в последу-
ющих изданиях с радостью назову тех, кто первым довел информацию о неточно-
стях до моего сведения.
Посылайте предлагаемые вами правила, комментарии, критические замечания
и сообщения об ошибках по адресу:
Scott Meyers
с/о Publisher, Corporate and Professional Publishing
Addison Wesley Longman, Inc.
1 Jacob Way
Reading, MAO 1867
U. S. A.
Вы также можете воспользоваться электронной почтой и послать сообщение
по адресу: ec++@awl.com.
Я веду список изменений, вносимых в книгу с момента последнего издания,
включая исправления ошибок, уточнения и обновления. Этот список можно найти
на Web-сайте «Эффективное использование C++» по адресу: http://www.awl.com/
cp/ec++.html. Если вы хотите получить копию списка, но не имеете доступа
к Internet, пожалуйста, направьте запрос по вышеуказанному электронному адре-
су, и вам вышлют требуемые материалы.
Скотт Дуглас Мейерс
Стаффорд, Орегон, июль 1997
Благодарности
Уже минуло около трех десятков лет с тех пор, как Кэти Рид (Kathy Reed) учи-
ла меня тому, что такое компьютер и как на нем программировать, поэтому мне
кажется, что начало всему положила она. В 1989 году Дональд Френч (Donald
French) попросил меня подготовить учебные материалы по C++ для Института
высшего профессионального образования, а значит, он также должен взять на себя
часть ответственности за появление этой книги. Студенты Stratus Computer, с ко-
торыми я работал в 1991 году, были не первыми, кто советовал мне доверить бу-
маге ту сомнительную мудрость, коей я их щедро оделял. Но именно они в конеч-
ном счете убедили меня сделать это, поэтому частично тоже отвечают за результат.
Я им очень благодарен.
Многие правила и примеры из книги обобщают прежде всего мой собствен-
ный опыт использования и преподавания C++, опыт моих коллег, а также мнения
членов групп новостей Usenet по C++. Большинство примеров, ставших теперь
классическими при изучении C++ (в особенности строки), ведет свою историю
[^SQZ£ I i Предисловие
от первого издания книги Бьорна Страуструпа «Язык программирования C++»
(Bjarne Stroustrup. The C++ Programming Language. Addison-Wesley, 1986). Некото-
рые правила моей книги базируются на идеях этой основополагающей работы.
Правило 8 воплощает идею, изложенную в статье Стива Клэмиджа «Реализа-
ция new и delete» (Steve Clamage. Implementing new and delete), которая была
опубликована в майском выпуске журнала C+ + Report в 1993 году. Основой для
написания правила 9 послужили материалы «Справочного руководства по языку
C++ с комментариями» (см. правило 50), а основные соображения, изложенные
в правилах 10 и 13, были подсказаны Джоном Шучаком (John Shewchuck). Реали-
зации operator new из правил 10 и 13 основаны на соображениях, изложенных
во втором издании «Языка программирования C++» Страуструпа (Addison-
Wesley, 1991) и «C++ на высоком уровне: идиомы и стили программирования»
Джима Коплиена (Jim Coplien. Advanced C++: Programming Styles and Idioms.
Addison-Wesley, 1992). Даймар Кюль (Deimar Kuehl) указал на неопределенное по-
ведение, описанное в правиле 14. Идея использования 0L для NULL, изложенная
в правиле 25, была заимствована из статьи Джека Ривза «Борьба с исключениями»
(Jack Reeves. Coping with Exceptions), опубликованной в мартовском номере
C+ + Report за 1996 год. Несколько участников различных групп новостей Usenet
по C++ помогли уточнить реализацию класса, используемого для преобразования
NULL-указателей с помощью шаблонов-членов. Сообщение в группу новостей,
посланное Стивом Клэмиджем, умерило мой энтузиазм по поводу ссылок на функ-
ции, что нашло отражение в правиле 28. Правило 33 включает в себя наблюдения,
приведенные в книгах Тома Каргилла «Стиль программирования на C++» (Тот
Cargill. C++ Programming Style. Addison-Wesley, 1992), Мартина Кэрролла
и Маргарет Эллис «Проектирование и программирование кода на C++ для по-
вторного использования» (Martin Carroll, Margaret Ellis. Designing and Coding
Reusable C++. Addison-Wesley, 1995), «Руководство по проектированию про-
грамм» фирмы Taligent (Guide to Designing Programs. Addison-Wesley, 1994), Роба
Мюррея «Стратегии и тактики в языке C++» (Rob Murray. C++ Strategies and
Tactics. Addison-Wesley, 1993), а также информацию из публикаций и сообщений
в группы новостей Стива Клэмиджа. Изложение материала в правиле 34 явно вы-
играло от моих бесед с Джоном Лакосом (John Lakos) и чтения его книги «Созда-
ние крупных проектов на C++» (Large-Scale C++ Software Design. Addison-Wesley,
1996). Терминология «письмо/конверт» в этом правиле восходит к труду Джима
Коплиена «C++ на высоком уровне: идиомы и стили программирования», а Джон
Кэролап (John Carolan) придумал восхитительный термин «Класс Чеширский
Кот». Пример с прямоугольником и квадратом в правиле 35 взят из колонки
Роберта Мартина (Robert Martin) «Принцип подстановки Дисков» (The Liskov
Substitution Principle) в мартовском выпуске C++ Report за 1996 год. Давнее со-
общение Марка Линтона (Mark Linton) в группу comp.lang.c++ помогло сформу-
лировать мои мысли по поводу кузнечиков и сверчков, приведенные в правиле 43.
Пример со свойствами из правила 49 взят из статьи Натана Майерса (Nathan
Myers) в июньском номере C+ + Reported. 1995 год, называвшейся «Новый полез-
ный прием программирования на C++: свойства» (A New and Useful Template Tech-
nique: Traits), и колонки Пита Беккера (Pete Becker) «C/C++: вопросы и ответы»
13
Благодарности
(C/C++ Q&A) в C/C++ User’sJournal. Мой обзор поддержки интернационализации
основан на неопубликованной рукописи книги Клауса Крефта и Анджелики Лан-
гер (Klaus Kreft, Angelika Langer). Наконец, пример С "Hello world" взят из книги
«Язык программирования С» Брайана Кернигана и Денниса Ричи (Brian Kernighan,
Dennis Ritchie. The C Programming Language. Prentice-Hall, 1978).
Многие читатели первого издания прислали предложения, которые я не мог
включить в первую книгу, но так или иначе учел в этом издании. Другие восполь-
зовались группами новостей Usenet по C++ для того, чтобы прислать мне важные
замечания по поводу материала книги. Я выражаю благодарность всем этим лю-
дям и указываю, где я воспользовался их идеями: Майк Кэлблинг (Mike Kaclbling)
и Хулио Куплински (Julio Kuplinsky) (Введение); человек, который значится
в моей записной книжке как «парень из Клэрис» (правило 5); Джоэл Реген (Joel
Regen) и Крис Трейчел (Chris Treichel) (правило 7); Том Каргилл (Tom Cargill),
Ларри Гаждос (Larry Gajdos), Дуг Морган (Doug Morgan) и Уве Стайнмюллер
(Uwe Steinmueller) (правило 10); Роджер Скотт (Roger Scott) и Стив Беркетт
(Steve Burkett) (правило 12); Дэвид Папюрт (David Papurt) (правило 13); Алек-
сандр Гутман (Alexander Gootman) (правило 14); Дэвид Берн (David Bern) (пра-
вило 16); Том Каргилл, Том Чеппелл (Tom Chappell), Дэн Франклин (Dan
Franklin) и Джерри Либельсон (Jerry Liebelson) (правило 17); Джон Элджи Лав-
Дженсен (John «Eljay» Love-Jensen) (правило 19); Эрик Нэглер (EricNagler) (пра-
вило 22); Роджер Истман (Roger Eastman), Дуг Мур (Doug Moore) и Аарон Най-
ман (Aaron Naiman) (правило 23); Дат Тук Нгуен (Dat Thue Nguyen) (правило 25);
Тони Хансен (Tony Hansen), Натрадж Кини (Natraj Kini) и Роджер Скотт (прави-
ло 33); Джон Харрингтон (John Harrington), Рид Флеминг (Read Fleming) и Дэйв
Смоллберг (Dave Smallberg) (правило 34); Йохан Бенгтссон (Johan Bengtsson)
(правило 36); Рене Родони (Rene Rodoni) (правило 39); Пол Бланкенбейкер (Paul
Blankenbaker) и Марк Сомер (Mark Somer) (правило 40); Том Каргилл и Джон Ла-
кос (правило 41); Фридер Кнаусс (Frieder Knauss) и Роджер Скотт (правило 42);
Дэвид Браунэгг (David Braunegg), Стив Клэмидж (Steve Clamage) и Доун Коф-
фман (Dawn Koffman) (правило 45); Том Каргилл (правило 46); Уэсли Мансил
(Wesley Munsil) (правило 47); Рэнди Мангоба (Randy Mangoba) (большинство
определений классов); Джон Элджи Лав-Дженсен (многие фрагменты, где я ис-
пользую тип double).
Некоторые части или всю черновую рукопись, подготовленную для первого
издания, рецензировали Том Каргилл, Гленн Кэрролл (Glenn Carroll), Тони Дэвис
(Tony Davis), Брайан Керниган (Brian Kernighan), Як Кирман (Jak Kirman), Дуг
Ли (Doug Lea), Муазе Лежте (Moises Lejter), Юджин Сантос младший (Eugene
Santos, Jr.), Джон Шучак (John Shewchuck), Джон Стаско (John Stasko), Бьерн
Страуструп (Bjarne Stroustrup), Барбара Тилли (Barbara Tilly) и Нэнси Л. Урбано
(Nancy L. Urbano). Я получил предложения по доработке книги, которые смог
учесть в данном издании, от следующих читателей (привожу их имена в порядке
поступления замечаний): Нэнси Л. Урбано (Nancy L. Urbano), Крис Трейчел
(Chris Treichel), Дэвид Корбин (David Corbin), Пол Гибсон (Paul Gibson), Стив
Виноски (Steve Vinoski), Том Каргилл (Tom Cargill), Нейл Роудс (Neil Rhodes),
Дэвид Берн (David Bern), Расс Уильямс (Russ Williams), Роберт Брэзайл (Robert
14
«и Предисловие
Brazile), Дуг Морган (Doug Morgan), Уве Стайнмюллер (Uwe Steinmueller), Марк
Сомер (Mark Somer), Дуг Мур (Doug Moore), Дэйв Смоллберг (Dave Smallberg),
Сет Мельцер (Seth Meltzer), Олег Штейнбук (Oleg Shteynbuk), Дэвид Папюрт
(David Papurt), Тони Хансен (Tony Hansen), Питер Маккласки (Peter McCluskey),
Стефан Кулине (Stefan Kuhlins), Дэвид Браунэгг (David Brounegg), Пол Чизхолм
(Paul Chisholm), Адам Цейл (Adam Zeil), Кловис Тондо (Clovis Tondo), Майк Кэл-
блинг (Mike Kaelbling), Натрадж Кини (Natraj Kini), Ларс Найман (Lars Nyman),
Грег Лутц (Greg Lutz), Тим Джонсон (Tim Johnson), Джон Лакос (John Lakos),
Роджер Скотт (Roger Scott), Скотт Фромаи (Scott Frohman), Алан Рукс (Alan
Rooks), Роберт Пур (Robert Poor), Эрик Нэглер (EricNagler), Антуан Тру (Antoine
Trux), Каде Ру (Cade Roux), Чандрика Гокул (Chandrika Gokul), Рэнди Мангоба
(Randy Mangoba) и Гленн Тейтельбаум (Glenn Teitelbaum). Каждый из этих лю-
дей впес свой вклад в книгу, которую вы держите в руках.
Черновики второго издания рецензировали Дерек Бош (Derek Bosch), Тим
Джонсон (Tim Johnson), Брайан Керниган (Brian Kernighan), Дзюничи Кимура
(Junichi Kimura), Скотт Левандовски (Scott Lewandowski), Лаура Майклс (Laura
Michaels), Дэйв Смоллберг, Кловис Тондо, Крис Ван Вик (Chris Van Wyk) и Олег
Заблуда (Oleg Zabluda). Я благодарен им всем, и особенно Тиму Джонсону, кото-
рый провел тщательную вычитку оригинала и оказал значительное влияние на
окончательный вариант рукописи. Я также благодарю Джилл Хучитал (Jill
Huchital) и Стива Райсса (Steve Reiss) за их помощь в подборе хороших рецензен-
тов - это чрезвычайно важная и трудная задача. Доун Коффман и Дэйв Смоллберг
предложили ряд дополнений к материалу моих учебников по C++; многие из этих
идей нашли отражение в предлагаемом издании. И наконец, я получил коммента-
рии от читателей предыдущих изданий данной книги и в настоящем издании учел
их замечания. Вот имена этих людей: Дэниел Стейнберг (Daniel Steinberg), Арун-
прасад Марате- (Arunprasad Marathe), Дуг Стапп (Doug Stapp), Роберт Холл
(Robert Hall), Шерил Фергюсон (Cheryl Ferguson), Гари Бартлетт (Gary Bartlett),
Майкл Тамм (Michael Tamm), Кендалл Биман (Kendall Beaman), Эрик Нэглер
(Eric Nagler), Макс Хайлперин (Max Hailperin), Джо Готтман (Joe Gottman), Ричард
Уикс (Richard Weeks), Валентин Боннар (Valentin Bonnard), Джун Хэ Qun Не), Тим
Кинг (Tim King), Дон Майер (Don Maier), Тэд Хилл (Ted Hill), Марк Харрисон
(Mark Harrison), Майкл Рубенштейн (Michael Rubenstein), Марк Роджерс (Mark
Rodgers), Дэвид Го (David Goh), Брентон Купер (Brenton Cooper) и Энди Томас-
Крамер (Andy Thomas-Cramer).
Эви Немет (Evi Nemeth) (при содействии Addison-Wesley, Ассоциации USE-
NIX и The Internet Engineering Task Force) согласилась проследить, чтобы экзем-
пляры первого издания были доставлены на факультеты вычислительной техни-
ки университетов Восточной Европы; этим университетам сложно приобрести
подобную литературу другим способом. Эви также предоставляет подобные услуги
некоторым авторам и издателям, и я счастлив оказать посильную помощь. Если
вы желаете получить дополнительную информацию об этой программе, свяжитесь
с Эви по адресу: evi@cs.colorado.edu.
Иногда кажется, что игроки на издательском рынке меняются так же часто, как
и тенденции в программировании, поэтому я рад, что мой издатель Джон Уэйт
Благодарности
(John Wait), заведующая отделом сбыта Ким Доли (Kim Dawley) и руководитель
производственного отдела Марти Рабиновиц (Marty Rabinowitz) продолжают тру-
диться на той же пиве, что и в период, когда я взялся за написание книги (1991 год).
При работе над книгой Сара Уивер (Sarah Weaver) была руководителем моего про-
екта; рекомендации Розмари Симпсон (Rosemary Simpson) помогли мне при со-
ставлении предметного указателя, а Лана Ланглуа (Lana Langlois) выступала по-
средником и координатором в Addison-Wesley до тех пор, пока не избрала иную
сферу деятельности. Я благодарен этим людям и их коллегам за помощь в преодо-
лении тысяч преград, отделяющих простое написание книги от ее издания.
Кэти Райт (Kathy Wright) никакого участия в создании этой книги не прини-
мала, но хотела, чтобы я упомянул здесь и ее имя.
Я хочу поблагодарить мою жену, Нэнси Л. Урбано, за энтузиазм и неослабева-
ющую поддержку, оказанную при подготовке первого издания.
Вот уже целых шесть лет я занимаюсь писательской работой, - все это время
Нэнси продолжает терпеть мое длительное отсутствие и мирится с моей техно-
кратической болтовней, всячески поддерживая мои литературные занятия. Она
также обладает умением при необходимости найти подходящее слово, которое
я никак не могу вспомнить. Без Нэнси моя жизнь не имела бы смысла.
Наша собака, Персефона, никогда не дает мне забыть о делах первостепенной
важности. Сроки сроками, а главное - вовремя погулять...
шма вм м «
Введение
Одно дело - изучать фундаментальные основы языка, и совсем другое - учиться
проектировать и реализовывать эффективные программы. Это особенно справед-
ливо для языка C++, известного необычайно широкими возможностями и гибко-
стью. Основанный на традиционном языке программирования (С), он предлагает
широкий диапазон объектно-ориентированных возможностей, а также поддерж-
ку шаблонов и исключений.
Работа на C++ при правильном его использовании способна доставить удоволь-
ствие. Самые разные проекты - объектно-ориентированные и обычные - могут по-
лучить непосредственное выражение и эффективную реализацию. Вы можете
определять новые типы данных, совершенно неотличимые от встроенных типов, но
значительно более гибкие. Тщательно выбранный и грамотно реализованный на-
бор классов, который берет на себя автоматическое управление памятью, использо-
вание альтернативных имен, инициализацию и высвобождение ресурсов, преобра-
зование типов и другие проблемы, представляющие собой сущее проклятие для
программистов, может сделать программирование приложений легким, эффектив-
ным и практически свободным от ошибок. При наличии определенных навыков на-
писание эффективных программ на C++ - совсем не трудное дело.
При неразумном использовании C++ может давать практически нечитаемый,
сложный в эксплуатации и просто неправильный код.
Все, что необходимо, - выделить те аспекты C++, которы могут стать камнем
преткновения, и научиться их избегать. Это и есть цель настоящей книги. Я пред-
полагаю, что вы уже знаете язык C++ и обладаете некоторым опытом его приме-
нения. Моя задача - дать общие указания, как использовать язык эффективно:
чтобы ваше программное обеспечение легко читалось, легко расширялось, было
простым в эксплуатации и работало согласно вашему замыслу.
Предлагаемые советы можно разделить на две категории: общая стратегия про-
ектирования и практическое использование отдельных языковых конструкций.
Обсуждение вопросов проектирования сфокусировано на выборе между раз-
личными методами реализации программы на C++. Как сделать выбор между на-
следованием и шаблонами? Между шаблонами и указателями на базовые классы?
Между открытым и закрытым наследованием? Между закрытым наследованием
и вложенными классами? Между перегрузкой функции и введением аргумента
со значением по умолчанию? Между виртуальными и невиртуальными функция-
ми? Между передачей аргумента по значению или по ссылке? Важно с самого на-
чала правильно ответить на эти вопросы, поскольку ошибочность выбора может
стать очевидной лишь намного позднее, в ходе разработки проекта, когда исправ-
ление ошибки оказывается трудоемким, деморализующим и дорогостоящим.
17
Введение
Даже когда вы точно знаете, что нужно делать, добиться желаемых результа-
тов бывает нелегко. Какой тип должен возвращать оператор присваивания? Как
должен себя вести оператор new, когда он не может найти достаточного количе-
ства памяти? Когда деструктор должен быть виртуальным? Как следует писать
список инициализации? Исключительно важно проработать подобные детали, по-
скольку, не сделав этого, вы почти неизбежно столкнетесь с неожиданным и даже
необъяснимым поведением программы. И, что более существенно, оно зачастую
проявляется не сразу. В итоге код может пройти проверку в отделе тестирования,
хотя оп все еще содержит множество необнаруженных ошибок - «мин замедлен-
ного действия», ждущих своего часа.
Эту книгу необязательно читать от корки до корки. Необязательно даже чи-
тать ее, продвигаясь от начала к концу. Материал разбит на 50 правил, каждое из
которых более-менее независимо и самодостаточно. Впрочем, нередко в них со-
держатся ссылки на другие правила, так что один из способов работы с книгой -
начать чтение с правила, вызвавшего интерес, и затем следовать по ссылкам туда,
куда они вас выведут.
Правила сгруппированы вокруг общих тем, поэтому если вы интересуетесь
определенным вопросом, например управлением памятью или объектно-ориен-
тированпым проектированием, то можете начать с соответствующего раздела -
либо прочитать его целиком, либо обратиться к разделам, на которые указывают
ссылки. Вы, однако, обнаружите, что весь материал книги очень важен для эф-
фективною программирования на C++, и практически все в ней так или иначе
взаимосвязано.
Это руководство не является ни справочником по C++, ни пособием для изу-
чения C++ с нуля. Например, мне очень хотелось бы рассказать вам о тонкостях
написания ваших собственных операторов new (см. правила 7-10), но я предпо-
лагаю, что вы узнаете из других источников, что эта функция должна возвращать
void * и ее первый аргумент должен иметь тип size_t. Изложение вопросов,
подобных этим, содержится в различных книгах по C++ для начинающих.
Цель данной книги - уделить особое внимание тем аспектам программирова-
ния на C++, которые обычно излагаются поверхностно (если излагаются вообще).
Другие источники описывают различные языковые конструкции. Настоящая кни-
га рассказывает вам, как объединить эти элементы, с тем чтобы в конечном счете
получить эффективную программу. Другие источники научат вас, как добиться
того, чтобы ваши программы компилировались. Эта книга подскажет, как избе-
жать проблем, о которых компилятор сообщить не может.
Подобно большинству других языков C++ связан с обширным фольклором,
образчики которого обычно передаются от программиста к программисту. В этой
книге я попытался зафиксировать часть накопленной в виде «устного народного
творчества» мудрости в более доступной форме.
В то же время материал, изложенный на страницах руководства, не выходит
за рамки стандартного переносимого C++. Здесь реализованы только те возмож-
ности, которые вошли в стандарт ISO/ANSI. В данной книге переносимость -
предмет особой заботы, так что, если вас интересуют зависящие от реализаций
хитрости и уловки, вам лучше поискать их где-нибудь в другом месте.
18
Эффективное использование C++
К сожалению, C++, описываемый в стандарте, может до некоторой степени от-
личаться от C++, поддерживаемого вашими компиляторами. Поэтому там, где
упоминаются сравнительно новые языковые средства, я также показываю, как соз-
давать эффективное программное обеспечение при их отсутствии. В конце кон-
цов было бы глупо работать, не имея представления о том, что появится в буду-
щем, но вместе с тем мы не можем ждать всю жизнь, пока станут доступными
самые последние, самые совершенные и универсальные компиляторы C++. При-
ходится работать с теми инструментами, которые имеются в наличии, и данная
книга призвана помочь в этом.
Обратите внимание на то, что я использую термин компиляторы во множест-
венном числе. Различные компиляторы реализуют разные приближения к стан-
дарту, поэтому советую вам разрабатывать код с использованием как минимум
двух компиляторов. Поступая подобным образом, вы избегаете непреднамеренной
привязки к оригинальным разработкам, расширениям или неправильной интер-
претации языка. Помимо этого вы не рискуете оказаться «на переднем крае» раз-
вития технологии компилирования, то есть ограничиться новыми возможностя-
ми, поддерживаемыми только одним поставщиком. Они зачастую плохо
реализованы (с ошибками или неэффективно, а иногда и то и другое), и в среде
программистов C++ еще не накоплен опыт, который мог бы помочь корректному
использованию этих новшеств в приложениях. Яркие эксперименты увлекатель-
ны, но если ваша цель - создание надежного кода, то, как правило, лучше усту-
пить другим право прокладывать новые дороги.
Эта книга не является панацеей от всех программистских бед и не указывает
единственно верного пути к идеальному программному обеспечению. Каждое
из 50 правил представляет собой руководство по созданию более профессиональ-
ных проектов, показывающее, как избежать обычных ошибок и как добиться боль-
шей эффективности; но ни одно из них не универсально. Проектирование
и реализация ПО - сложная задача, неизбежно сопряженная с ограничениями ап-
паратного обеспечения, операционной системы и приложения, поэтому лучшее,
что я могу сделать, - дать общие принципы создания более эффективных программ.
Если вы всегда будете опираться на правила, изложенные в этой книге, то
уменьшите вероятность попадания во всяческие «ловушки», поджидающие вас
в C++, но общие положения по самой своей природе порождают исключения. Вот
почему каждое правило содержит объяснения. Объяснения - это наиболее важ-
ная часть текста. Только поняв логику, стоящую за каждой рекомендацией, вы смо-
жете определить, применима ли она к разрабатываемым вами программам при тех
ограничениях, с которыми приходится иметь дело.
Наибольшая польза, которую можно извлечь из настоящей книги, - четко усво-
ить, как работает C++, почему он работает именно таким образом и как использо-
вать его возможности с максимальной эффективностью.
В книге, подобной этой, нет смысла задерживаться на терминологии - это за-
нятие лучше оставить лингвистам. Однако есть небольшой словарь терминов C++,
которые необходимо изучить каждому. Нижеследующие понятия встречаются
часто, и имеет смысл договориться о том, что они означают.
Объявления «говорят» компилятору об имени и типе объекта, функции, клас-
са или шаблона, но детали в них опускаются. Вот пример объявления:
19
Введение
extern int X;
int numDigits(int number);
class Clock;
templatecclass T>
class SmartPointer;
// Объявление объекта.
// Объявление функции.
// Объявление класса.
// Объявление шаблона.
Определения обеспечивают компилятор деталями. Для объекта определение -
это место, где компилятор выделяет объекту память. Для функции или шаблона
функции определение - это код тела функции. Для класса или шаблона класса
определение содержит список членов класса или шаблона:
int х; / / Определение объекта.
int numDigits(int number) // Определение функции.
{ // (Эта функция возвращает
int digitsSoFar = 1; // количество цифр своего параметра.)
if (number < 0) {
number = -number;
}
while (number /- 10) ++digitsSoFar;
return digitsSoFar;
}
class Clock { // Определение класса.
public:
Clock();
"Clock();
int hour() const;
int minute () const;
int second() const;
};
templatecclass T>
class SmartPointer {
public:
SmartPointer(T *p = 0) ;
// Определение шаблона.
-SmartPointer();
T * Operator->() const;
T& operator* () const;
};
Таким образом, мы подходим к понятию конструктора по умолчанию - это
конструктор, вызываемый без аргументов. У него либо нет аргументов, либо для
каждого аргумента имеется значение по умолчанию. В основном конструктор
по умолчанию необходим при определении массивов объектов:
class А {
public:
А() ;
};
А аггауА[10] ;
class В (
// Конструктор по умолчанию.
// Конструктор вызывается 10 раз.
20
III
Эффективное использование С++
public:
В (int х = 0) ; / / Конструктор по умолчанию.
};
В аггауВ[10];// Конструктор вызывается 10 раз со значением аргумента 0.
class С {
public:
С (int х) ; // Конструктор не по умолчанию.
};
С аггауС[10]; // Ошибка!
Вы можете обнаружить, что ваш компилятор отвергает массивы объектов, ког-
да конструктор класса по умолчанию имеет аргументы со значениями по умолча-
нию. Например, некоторые компиляторы отказываются понимать определение
аггауВ, данное выше, несмотря на то что такое определение разрешено стандар-
том C++. Это пример одного из расхождений, существующих между стандартом
C++ и конкретной реализацией языка. Каждый из известных мне компиляторов
имеет несколько подобных недостатков. До тех пор пока поставщики компилято-
ров не приведут их в соответствие со стандартом, будьте готовы проявлять гиб-
кость и утешайтесь мыслью, что в недалеком будущем C++, описанный в стандар-
те, будет соответствовать языку, реализуемому компиляторами C++.
Заметим, что, если вы хотите создать массив объектов, не имеющих конструктора
по умолчанию, другая стандартная уловка - определение массива указателей. Тогда
вы можете инициализировать каждый указатель отдельно, используя оператор new:
С *ptrArray[10]; // Конструкторы не вызываются.
ptrArray[O] = new С(22); // Разместить и создать один объект класса С.
ptrArray[l] = new С(4); // Разместить и создать один объект класса С.
Вернемся к терминологии. Конструктор копирования используется для того,
чтобы инициализировать объект другим объектом того же типа:
class String {
public:
String(); // Конструктор по умолчанию.
String(const Strings rhs); // Конструктор копирования.
private:
char *data;
};
String si;
String s2(si);
String s3 = s2;
// Вызов конструктора по умолчанию
// Вызов конструктора копирования.
// Вызов конструктора копирования.
Возможно, наиболее важное назначение конструктора копирования - опре-
деление того, что означает передача и возврат объекта по значению. В качестве
примера рассмотрим следующий (неэффективный) способ написания функции
конкатенации двух объектов класса String:
const String operator+(string si. String s2)
{
String temp;
Введение
LU
delete [] temp.data;
temp.data =
new char[strlen(si.data) + strlen(s2.data) + 1];
strcpy(temp.data, si.data);
strcat(temp.data, s2.data);
return temp;
)
String a ("Hello") ;
String b (" world");
String c = a + b; lie- String ("Hello world")
Этот оператор сложения берет в качестве аргументов два объекта String,
а возвращает один. Как аргументы, так и результат передаются по значению, по-
этому конструктор копирования будет вызываться для инициализации s 1 значе-
нием а, для инициализации s2 значением Ь и для инициализации с значением
temp. В действительности, если компилятор решит создавать временные объек-
ты, чего ему делать не возбраняется, то в данном случае возможны и дополнитель-
ные вызовы конструктора копирования. Важно то, что передача по значению яв-
ляется вызовом конструктором копирования.
Кстати, в подобной реализации operator+ для String необходимости нет.
Возврат объекта const String вполне корректен (см. правила 21 и 23), по аргу-
менты лучше было бы передать по ссылке (см. правило 22).
Если этого можно избежать (а такая возможность предоставляется практи-
чески всегда), operator+ для String лучше вообще не определять. Дело в том,
что стандартная библиотека C++ (см. правило 49) содержит тип строки, называ-
емый string, a operator+ объектов string делает практически то же самое,
что и operator+ выше. В этой книге я использую как объекты String, так
и объекты string, но для разных целей. (Заметьте, что первое название пишется
с большой буквы, а второе - с маленькой.) Если мне просто нужно использовать
строку, а ее реализация не важна, то я использую тип string, являющийся час-
тью стандартной библиотеки C++. Этого правила следует придерживаться и вам.
Однако зач’астую мне необходимо показать, как работает C++; в таких случаях
требуется некоторый код реализации. Тогда я использую нестандартный класс
String. Как программист, вы должны всегда, когда вам необходим объект-стро-
ка, использовать стандартный тип string; время создания своих собственных
классов строк, прежде являвшегося неотъемлемой частью ритуала посвящения
в C++, уже миновало. Тем не менее необходимо разбираться в вопросах, связан-
ных с созданием классов, подобных string. Для этой (и только для этой) цели
Удобно использовать класс String. Что же касается простых строк типа char*,
то без очень веских причин нет необходимости в использовании подобных «ата-
визмов». Хорошо реализованный тип string сегодня предпочтительнее типа
char* практически с любой точки зрения, в том числе из соображений эффек-
тивности (см. правило 49).
Следующие два термина, которые нам необходимо освоить, - это инициализа-
ция и присваивание. Инициализация объекта происходит в тот момент, когда он
получает значение в самый первый раз. Для объектов классов или структур с кон-
структорами инициализация всегда происходит посредством вызова конструктора,
22
Ki
Эффективное использование C++
что прямо противоположно тому случаю, когда уже инициализированному объек-
ту присваивается новое значение:
string si;
string s2("Hello");
string s3 = s2;
si - s3;
// Инициализация.
// Инициализация.
// Инициализация.
// Присваивание.
Разница между инициализацией и присваиванием, с чисто функциональной
точки зрения, заключается в том, что в первом случае используется конструктор,
во втором - operators Другими словами, эти два случая соответствуют различ-
ным функциональным вызовам.
Причина, по которой мы проводим это различие, состоит в том, что при на-
писании обоих типов функций необходимо уделять внимание разным вещам.
Конструкторы обычно должны проводить проверку допустимости значений сво-
их аргументов, в то время как большинство операторов присваивания может
принимать допустимость значений своих аргументов как должное (поскольку
объект уже был сконструирован). С другой стороны, объекту, которому присва-
ивается значение, в отличие от конструируемого объекта, уже могли быть выде-
лены ресурсы. Зачастую один из таких ресурсов - память. Прежде чем оператор
присваивания сможет предоставить память для нового значения, он должен вы-
свободить ее для старого значения.
Ниже приведен пример возможной реализации конструктора и оператора при-
сваивания для String:
// Возможный конструктор для String.
String::String(const char *value)
(
if (value) { // Если указатель value ненулевой,
data = new char[strlen(value) + 1] ;
strepy(data,value) ;
)
else { // Обработка нулевого указателя1,
data = new char[l] ;
*data = '\0' ; // Нулевой символ в конце.
}
}
// Возможный оператор присваивания для String.
Strings String::operator=(const Strings rhs)
{
if (this == Srhs)
return *this; // См. правило 17.
delete [] data; // Освободить старую память.
data = // Разместить новую память.
' .new char [strlen (rhs . data) + 1] ;
'Мой конструктор класса String, принимая аргумент типа const char*, корректно обрабатыва-
ет передачу нулевого указателя, но стандартный тип string не обязан быть столь же «терпимым».
Попытка создать string из нулевого указателя дает неопределенный результат. Однако создание
объекта string из пустой строки типа char* (то есть из "") вполне безопасно.
Введение
23
strcpyfdata, rhs.data);
return *this; // См. правило 15.
}
Обратите внимание на то, как конструктор должен проводить проверку допу-
стимости своих аргументов, как обеспечить правильную инициализацию члена
класса data, который был инициализирован терминированным нулем указателем
типа char*. С другой стороны, оператор присваивания принимает допустимость
передаваемых аргументов как должное. Вместо этого все сконцентрировано на выяв-
лении такой патологии, как присваивание самому себе (см. правило 17), и на высво-
бождении выделенной памяти перед выводом новой. Различия между этими функ-
циями - иллюстрация типичных различий между инициализацией и присваиванием
объекта. Заметим, что, если для вас внове использованное вместе с delete обозначе-
ние [ ], правило 5 призвано развеять все сомнения на этот счет.
И наконец, последний термин, заслуживающий обсуждения, - пользова-
тель. Пользователь - это программист, использующий написанный вами код. Го-
воря о пользователях в этой книге, я имею в виду тех, кто просматривает ваш код
и пытается понять, что он делает; людей, читающих ваши определения классов
и стремящихся установить, стоит ли использовать наследование от этих классов;
людей, изучающих ваш проект и желающих до мелочей разобраться в его логике.
Возможно, вы не привыкли думать о пользователях, но я потрачу немало вре-
мени, пытаясь убедить вас облегчить их жизнь настолько, насколько это возможно.
В конце концов, вы тоже являетесь пользователем программного обеспечения,
создаваемого другими людьми. Разве вам не хотелось бы, чтобы разработчики
помогали решать ваши задачи? Кроме того, однажды вы сами можете оказаться
в затруднительном положении: вам будет необходимо применить ваш собствен-
ный код, и в таком случае придется столкнуться с пользовательскими проблема-
ми на личном опыте!
В этой книге я использую две конструкции, которые могут быть вам незна-
комы. Обе являются сравнительно недавними дополнениями к C++. Первая - это
булев тип bool, который принимает значения true и false. Он сейчас возвра-
щается встроенными операторами сравнения (например, <, >, == и т.п.) и про-
веряется в условной части операторов if, for, while и do. Если ваш компилятор
не реализует тип bool, его легко аппроксимировать, применяя для создания true
и false оператор typedef:
typedef int bool;
const bool false = 0;
const bool true = 1;
Это совместимо с традиционной семантикой С и C++. Поведение программ,
Использующих аппроксимацию, не изменится при переходе к компиляторам, под-
держивающим тип bool.
Вторая новая конструкция - точнее говоря, четыре конструкции - это формы
Приведения (преобразования) типов static_cast, const_cast, dynamic_cast
и reinterpret_cast. Стандартное приведение типов в стиле С выглядит так:
(тип) выражение // Привести выражение к типу тип.
Эффективное использование C++
Новое приведение типов выглядит так:
static_cast<rwn> (выражение) // Привести выражение к типу тип.
const_cast<rwn>(выражение)
dynamic_cast<rwn>(выражение)
reinterpret_cast<Twn>(выражение)
Различные формы приведения типов служат разным целям:
□ const_cast предназначается для нейтрализации действия модификатора
const - эра тема обсуждается в правиле 21;
□ dynamic_cast используется для выполнения безопасного преобразования
типов - см. правило 39;
□ reinterpret_cast служит для преобразований типов, результаты кото-
рых зависят от реализации, например для преобразования между указате-
лями на функции. (Скорее всего, частое использование reinterpret.__cast
вам не потребуется. В этой книге я его вообще нигде не использую);
□ stat ic_cast является своего рода вместилищем самых разных преобразо-
ваний типов. Его нужно использовать, когда не подходят никакие другие
преобразования типов. По своему смыслу данная форма ближе всего к обыч-
ному преобразованию типов в стиле С.
Обычные преобразования типов по-прежнему остаются вполне допустимыми,
но новые формы более предпочтительны. Их намного проще идентифицировать
в коде (как для человека, так и для инструментов, подобных grep), а узкоспециа-
лизированная форма каждого преобразования позволяет компилятору диагнос-
тировать ошибки использования. Например, только приведение const_cast
может быть применено для отмены действия модификатора const. Если вы по-
пытаетесь сделать это, используя какую-либо иную новую форму приведения, то
выражение компилироваться не будет.
Для получения подробной информации о новых преобразованиях типов обра-
титесь к современным учебникам по введению в C++ или к правилу 2 моей книги
«Более эффективное использование C++» (ее обзор приводится в послесловии).
В рассматриваемых примерах я старался выбирать осмысленные имена объек-
тов, классов, функций и т.д. Многие источники при выборе идентификаторов при-
держиваются проверенной истины: краткость - сестра таланта. Моей целью, од-
нако, была не столько изящность стиля, сколько доступность изложения. Поэтому
была предпринята попытка сломать традицию использования замысловатых
идентификаторов при написании книг по языкам программирования. Тем не ме-
нее временами я не мог устоять против искушения применить два моих излюб-
ленных обозначения, а их смысл может быть неочевиден, особенно если вам не
доводилось тесно общаться с разработчиками компиляторов.
Ihs и rhs означают соответственно «слева» и «справа». Я использую их в ка-
честве аргументов функций - бинарных операторов, особенно для operator==,
и арифметических операторов, подобных operator*. Например, если объекты
а и Ь - рациональные числа, то, следовательно, их можно перемножить посред-
ством функции operator*, не являющейся членом класса, и выражение
а * b
Введение
будет эквивалентно функциональному вызову
operator*(а, Ь)
В правиле 23 я объявляю оператор умножения следующим образом:
const Rational operator*(const Rational& Ihs, const Rational& rhs);
Как вы видите, левосторонний операнд а внутри функции обозначается через
Ihs, а операнд справа (Ъ) обозначается как rhs.
Кроме того, я формирую сокращенные обозначения для указателей, следуя
правилу: указатель на объект типа Т часто обозначается как рТ, «указатель на Т».
Вот несколько подобных примеров:
string *ps;
class Airplane;
Airplane *pa;
class BankAccount;
BankAccount *pba;
// ps = указатель на string.
// pa = указатель на Airplane.
// pba = указатель на BankAccount.
Подобного соглашения я придерживаюсь и для ссылок на объекты. То есть rs
может быть ссылкой на строку, а га ссылкой на Airplane.
Для функций-членов классов я иногда использую обозначение mf.
На всякий случай, во избежание путаницы, говоря в этой книге о языке про-
граммирования С, я неизменно подразумеваю под ним ISO/ANSI стандарт С, а не
старый «классический» вариант языка с менее строгим контролем типов.
Глава 1. Переход от С к
Для того чтобы освоиться с C++, необходимо некоторое время. Однако опытным
программистам, привыкшим к стандартным конструкциям языка С, этот процесс
может показаться особенно неприятным. Поскольку С является, по существу,
подмножеством C++, все его старые «трюки» остаются в силе, но многие из них
теряют свою значимость. Так, например, для программистов на C++ выражение
указатель на указатель звучит немного забавно. Почему вместо указателя, недо-
умеваем мы, не была использована ссылка?
С - достаточно простой язык. Макросы, указатели, структуры, массивы и функ-
ции - это все, что он в действительности предлагает. Каким бы сложным ни ока-
зался алгоритм, его всегда можно реализовать, используя перечисленный набор
средств. В C++ дело обстоит несколько иначе: наравне с макросами, указателями,
структурами, массивами и функциями используются закрытые и защищенные
члены классов, перегрузка функций, аргументы по умолчанию, конструкторы и дес-
трукторы, операции, определяемые пользователем, встроенные функции, ссылки,
дружественные классы и функции, шаблоны, исключения, пространства имен и т. д.
Очевидно, что более богатые средства проектирования предоставляют самые ши-
рокие возможности, а это, в свою очередь, требует существенно иной культуры
программирования.
Столкнувшись с таким разнообразием выбора, многие программисты на С те-
ряются, продолжая крепко держаться за то, к чему они привыкли. По большей час-
ти в этом нет особого греха, но некоторые «привычки» идут вразрез с духом C++.
От них просто необходимо избавиться!
Правило 1. Предпочитайте const и inline
использованию #define
Этот правило лучше было бы назвать «Компилятор предпочтительнее препро-
цессора», поскольку #def ine зачастую вообще не относят к языку C++. В этом
и заключается одна из проблем. Рассмотрим простой пример; попробуйте напи-
сать что-нибудь вроде:
ttdefine ASPECT_RATIO 1.653
Символическое обозначение может так и остаться неизвестным компилятору
или быть удалено препроцессором, прежде чем код попадет в компилятор. Если
это произойдет, то обозначение ASPECT_RATIO не окажется в таблице символов.
Поэтому в ходе компиляции вы получите ошибку, связанную с использованием
константы (в сообщении об ошибке будет сказано 1.653, а не ASPECT__ratio).
Правило 1
Это вызовет путаницу. Если файл заголовков писали нс вы, а кто-либо другой,
у вас нс будет никакого представления о том, откуда взялось значение 1.653, и на
поиски ответа вы потеряете много времени. Та же проблема может возникнуть
и при отладке, поскольку обозначение, выбранное вами, будет отсутствовать в таб-
лице символов.
Указанная задача решается просто и быстро. Вместо использования макроса
препроцессора определите константу:
const double ASP’ECT_RATIO = 1.653;
Однако есть два специальных случая, заслуживающих упоминания.
Во-первых, при определении константных указателей могут возникнуть неко-
торые осложнения. Поскольку определения констант обычно выносятся в заголовоч-
ные файлы (где к ним получает доступ множество различных исходных файлов), важ-
но, чтобы сам указатель был объявлен с const, в дополнение к объявлению const
того, на что он указывает. Например, для определения в файле заголовков констант-
ной строки char* следует писать const дважды:
const char * const authorName = "Scott Meyers";
Сущность и примеры использования const, особенно в связке с указателями,
рассматриваются в правиле 21.
Во-вторых, иногда удобно определять некоторые константы как относящиеся
к конкретным классам, а это требует другого подхода. Для того чтобы ограничить
область действия константы конкретным классом, необходимо сделать ее членом
этого класса, а чтобы гарантировать, что существует только одна копия констан-
ты, требуется сделать ее статическим (static) членом класса:
class GamePlayer {
private:
static const int NUM_TURNS =5; // Объявление константы.
int scores[NUM_TURNS]; // Использование константы.
};
Остается еще одна небольшая проблема, поскольку все то, что вы видите выше -
это объявление, а не определение NUM_TURNS. Если вам необходимо определить ста-
тические члены класса в файле реализации, то напишите следующее:
const int GamePlayer: :NUM_TURNS; // Обязательное объявление
// находится в файле реализации.
Впрочем, терять сон из-за подобных пустяков не стоит. Если об определении
забудете вы, то напомнит компоновщик.
Старые компиляторы могут не поддерживать принятый здесь синтаксис, так
как в более ранних версиях языка было запрещено задавать значения статических
Членов класса во время их объявления. Более того, инициализация в классе до-
пускалась только для целых типов (таких как int, bool, char и пр.) и для кон-
стант. Если вышеприведенный синтаксис не работает, то начальное значение сле-
дует задавать в определении:
28
II
Переход от С к C++
class Engineeringconstants { // Это находится в файле заголовка класса,
private:
static const double FUDGE_FACTOR;
};
// Это находится в файле реализации класса.
const double Engineeringconstants::FUDGE_FACTOR = 1.35;
Единственное исключение обнаруживается тогда, когда для компиляции класса
необходима константа. Например, при объявлении массива GamePlayer: : scores
в листинге, приведенном выше, в момент компиляции может потребоваться задание
его размера. Для того чтобы работать с компилятором, ошибочно запрещающим ини-
циализировать целые константы внутри класса, следует применять технику, которая
шутливо называется «трюком с перечислением». Она основана на том, что перемен-
ные перечисляемого типа можно использовать там, где ожидаются целые числа, по-
этому GamePlayer определяют следующим образом:
class GamePlayer {
private:
enum { NUM_TURNS = 5 }; 11 "Трюк с перечислением" - делает
/ / из NUM_TURNS символ co значением 5.
int scores[NUM_TURNS]; // Нормально.
};
Если вы имеете дело не с примитивным компилятором, написанным до 1995
года и представляющим собой только исторический интерес, то считайте, что вам
повезло: необходимость использовать этот трюк отпадет сама собой. Тем не менее
его нужно знать, поскольку для многих из нас устаревший компилятор - «тяже-
лое наследство», доставшееся от прошлых, не столь изысканных времен.
Вернемся к препроцессору. Другой частый случай неправильного использова-
ния директивы #def ine - создание макросов, которые выглядят как функции,
но не обременены накладными расходами функционального вызова. Каноничес-
кий пример - вычисление максимума двух значений:
ttdefine max(a,b) ((а) > (b) ? (а) : (Ь))
В этой небольшой строчке содержится так много недостатков, что даже не со-
всем понятно, с какого проще начать.
Всякий раз, когда вы пишете макрос подобный этому, необходимо помнить,
что все аргументы следует заключать в скобки. В противном случае у Пользовате-
лей будут возникать серьезные проблемы с применением таких макросов в выра-
жениях. Но, даже если вы все сделаете верно, посмотрите, какие странные вещи
могут при этом произойти:
int а = 5, b = 0 ;
тах(++а, Ь) ; // а увеличивается дважды.
тах(++а, Ь+10); // а увеличивается один раз.
Происходящее внутри max зависит от того, что с чем сравнивается!
Правило 2
+ !!!
29
К счастью, вам пет нужды мириться с поведением, так сильно противореча-
щим привычной логике. Существует метод, позволяющий добиться такой же эф-
фективности, как при использовании макросов. В таком случае обеспечиваются
как предсказуемость поведения, так и контроль типов аргументов (что характер-
но для обычных функций). Этот результат достигается применением встраивае-
мых функций (см. правило 33):
inline int max(int a, int b) { return a > b ? a : b; }
Новая версия max несколько отличается от предыдущей, поскольку она мо-
жет работать только с целыми аргументами. Возникшую проблему удачно ре-
шает шаблон:
templatecclass Т>
inline const Т& max (const Т& a, const Т& b)
{ return а > b ? а : b; }
Он генерирует целое семейство функций, каждая из которых берет два приво-
димых к одному типу объекта и возвращает ссылку (с модификатором const) на
больший из двух объектов. Поскольку вам неизвестно, каким будет тип Т, для эф-
фективности передача и возврат значения происходят по ссылке (см. правило 22).
Кстати говоря, прежде чем вы решите писать шаблон для какой-либо функ-
ции, подобной max, узнайте, не присутствует ли она уже в стандартной библиоте-
ке (см. правило 49). В случае с max вы можете воспользоваться плодами чужих
усилий: max является частью стандартной библиотеки C++.
Возможность использования const и inline уменьшает необходимость
в препроцессоре, но не устраняет ее полностью. Еще далек тот день, когда вы смо-
жете обходиться без #include, между тем как #ifdef /ttifndef продолжают
играть важную роль в контроле над компиляцией. Пока рано списывать со счетов
препроцессор, но, без сомнения, уже сейчас стоит задуматься над тем, как освобо-
диться от него в дальнейшем.
Правило 2. Предпочитайте <iostream>
использованию <stdio.h>
Да, они переносимы. Да, они эффективны. Да, вы уже знаете, как их использо-
вать. Но какой бы благоговейный восторг они ни вызывали, факт остается фактом:
операторы scanf и printf и им подобные далеки от совершенства. В частности,
они не осуществляют контроль типа и к тому же нерасширяемы. Поскольку кон-
троль типов и расширяемость - краеугольные камни идеологии C++, то лучше
всего с самого начала во всем опираться на них. Кроме того, семейство функций
Printf /scanf отделяет переменные, которые необходимо прочитать или запи-
вать, от форматирующей информации, управляющей записью и чтением, в точ-
ности так же, как это делает FORTRAN. Настало время распрощаться с пятидеся-
тыми.
Неудивительно, что эти слабости функции print f /scanf - сила операторов
» и <<.
30
Переход от С к C++
int i;
Rational г; //г является рациональным числом.
cin » i >> г;
cout « i « г;
Если этот код предназначен для компиляции, должны быть в наличии функ-
ции operator» и operatorcc, которые могли бы работать с объектом типа
Rational. Отсутствие данных функций является ошибкой. (Для int имеются
стандартные версии.) Более того, компилятор берет на себя заботу о том, какие
версии операторов вызывать для разных переменных; таким образом, вам нет не-
обходимости беспокоиться о том, что первый читаемый или записываемый объект
имеет тип int, а второй - Rational.
Кроме того, считывание объектов происходит с использованием той же син-
таксической формы, что и при записи. Поэтому нет необходимости помнить о том,
что, если вы работаете не с указателем, важно не забыть взять адрес, а если имеете
дело с указателем, следует убедиться, что вы не берете адрес. Пусть о таких дета-
лях заботится компилятор C++. Это его дело, у вас же есть задачи посерьезнее.
И наконец, заметьте, что встроенные типы, подобные int, читаются и записывают-
ся совершенно аналогично типам, определенным пользователями, таким, напри-
мер, как Rational. Попробуйте сделать то же самое, используя scant и print f!
Ниже приводится пример того, как можно написать функцию для вывода
класса рациональных чисел:
class Rational {
public:
Rational (int numerator = 0, int denominator = 1) ;
private:
int n, d; // Числитель и знаменатель.
friend ostream& operator«(ostream& s, const Rational& r) ;
};
ostream& operator<<(ostream& s, const Rational& r)
{
s « r.n « ' г « r. d;
return s;
}
Эта версия operator« демонстрирует некоторые тонкости (притом весьма
важные!), обсуждаемые в других разделах книги. Например, она не является
функцией-членом (правило 19 объясняет, почему), а объект Rational передает-
ся operator« по ссылке const, а не как объект (см. правило 22). Соответствую-
щая функция ввода, operator», объявляется и реализуется аналогичным образом.
Как ни обидно мне это признавать, в ряде случаев имеет смысл вернуться
к старому и проверенному способу. Во-первых, некоторые реализации операций
потоков ввода/вывода менее эффективны, чем соответствующие операции С,
и возможно (хотя маловероятно), что в отдельных приложениях это может ока-
заться существенным. Помните, однако: это относится не к потокам ввода/выво-
да вообще, а только к той или иной реализации. Во-вторых, библиотека потоков
Правило 3
131
ввода/вывода в ходе своей стандартизации претерпела ряд кардинальных изме-
нений (см. правило 49); следовательно, приложения, требующие максимальной
переносимости, могут столкнуться с тем фактом, что различные поставщики под-
держивают различные приближения стандарта. И наконец, поскольку классы
библиотеки потоков ввода/вывода имеют конструкторы, а функции <stdio. h> -
нет, в редких случаях существенным будет порядок инициализации статических
объектов (см. правило 47), и стандартная библиотека С окажется более удобной
просто потому, что вы можете ею пользоваться без опасений.
Контроль типов и расширяемость, предлагаемые классами и функциями биб-
лиотеки потоков ввода/вывода, являются более важными, чем это может пока-
заться, - не стоит отвергать их только из-за того, что вы привыкли к <stdio. h>.
В конце концов, никто не посягает на ваши воспоминания.
Между прочим, это не опечатка - в названии данного правила действитель-
но фигурирует <iostream>, а не ciostream .h>. Строго говоря, такого заголов-
ка, как ciostream. h>, не существует: Комитет по стандартам отказался от него
в пользу названия <iostream> при сокращении имен стандартных файлов
заголовков, отсутствующих в библиотеке языка С. Причина объясняется в пра-
виле 49, но на самом деле важно уяснить лишь следующее: если (что весьма ве-
роятно) ваш компилятор поддерживает как файл заголовков <iostream>, так
и ciostream. h>, необходимо иметь в виду, что они слегка отличаются друг от
друга. В частности, если вы включаете ciostream>, элементы библиотеки пото-
ков ввода/вывода весьма удобно расположены в пространстве имен std (см. пра-
вило 28); включая ciostream.h>, вы получаете те же элементы, но в глобаль-
ном пространстве имен. Их определение в нем может вести к конфликтам,
предотвращению которых и должно было послужить введение понятия простран-
ства имен. Кроме того, ciostream> короче,чем ciostream.h>. Для многих это
оказывается достаточным аргументом в пользу нового названия.
Правило 3. Предпочитайте new и delete
использованию malloc и free
Проблема, связанная с malloc, free, а также их вариациями, очень проста:
эти функции ничего не «знают» о конструкторах и деструкторах.
Рассмотрим следующие два способа выделить память для десяти объектов
string: сначала с использованием malloc, а затем - new:
string *stringArrayl static_cast<string*>(malloc(10 * sizeof(string)));
string *stringArray2 = new string[10];
Здесь stringArrayl является указателем на область памяти, достаточную для
размещения десяти объектов string, но ни один объект в этой памяти не размещен.
Более того, у вас нет никакого способа инициализировать объект, не прибегая к неко-
торым весьма изощренным уловкам. Другими словами, stringArrayl практически
бесполезен. Б противоположность ему stringArray2 - это указатель на массив из
Десяти полностью сконструированных объектов string, каждый из которых можно
использовать в операциях с этим типом.
32
Переход от С к C++
И все-таки давайте предположим, что вам магическим образом удалось ини-
циализировать объекты массива stringArrayl. Тогда далее в программе необ-
ходимо сделать следующее:
free(stringArrayl);
delete [] stringArray2; //См. правило 5, для чего требуются “ []".
Вызов free высвобождает память, на которую ссылается stringArrayl, но
никаких деструкторов для размещенных в памяти объектов string при этом не
вызывается. Если объекты string сами выделяют память (что они обычно и де-
лают), то вся выделенная ими память будет потеряна. С другой стороны, если для
stringArray2 вызывается delete, то прежде чем происходит высвобождение
какой-либо памяти, для каждого объекта массива вызывается деструктор.
Поскольку new и delete должным образом взаимодействуют с конструкто-
рами и деструкторами, очевидно, что их выбор более предпочтителен.
Одновременное использование new и delete с malloc и free нельзя назвать
удачной идеей. Когда вы пытаетесь вызвать free для указателя, полученного по-
средством new, или вызываете delete для указателя, появившегося при помощи
malloc, результат окажется неопределенным, а мы знаем, что означает «неопре-
деленный»: программа будет функционировать в процессе разработки и в ходе те-
стирования, а затем произойдет сбой на глазах у самого важного клиента.
Несовместимость new/delete и malloc/ free может вести к некоторым ин-
тересным осложнениям. Например, функция strdup, обычно содержащаяся
в <string.h>, берет строку char* и возвращает ее копию:
char * strdup (const char *ps); / / Вернуть копию того, на что указывает ps.
Некоторые разработчики стандартных библиотек реализуют одинаковые вер-
сии strdup и для С, и для C++, поэтому память внутри функции выделяется по-
средством malloc. В результате неискушенные программисты C++ могут упус-
тить из виду тот факт, что для указателя, возвращаемого strdup, они должны
использовать free. Но подождите! Предвидя подобные осложнения, некоторые
разработчики могут прийти к решению переписать strdup для C++ и внутри
этой функции вызывать new, обуславливая, таким образом, в дальнейшем вызов
delete. Как нетрудно представить, при возникновении потребности в переноси-
мости между платформами, на которых функции strdup реализованы по-раз-
ному, это может превратиться в настоящий кошмар.
Тем не менее программисты на C++ так же заинтересованы в повторном ис-
пользовании кода, как и программисты на С, а факт остается фактом: существует
огромное количество полезных библиотек С, содержащих вызовы malloc и free.
При использовании подобных библиотек весьма вероятно, что на вас ляжет от-
ветственность за применение free для высвобождения памяти, выделенной
malloc внутри библиотеки, или/и за выделение с помощью malloc памяти, кото-
рую библиотека сама потом освободит функцией free. Это нормально. В вызове
malloc и free внутри программы на C++ нет ничего плохого, пока вы следите
за тем, чтобы указатели, полученные при помощи malloc, всегда высвобожда-
лись посредством free, а к указателям, полученным с помощью new, применялся
33
Правило 4
delete. Проблемы начинаются тогда, когда вы проявляете небрежность и ис-
пользуете одновременно new с free и inalloc с delete. При .этом вы, к сожале-
нию, сами напрашиваетесь на неприятности.
Учитывая, что malloc и free не взаимодействуют с конструкторами и де-
структорами, а последствия одновременного использования malloc/free с new/
delete гораздо менее предсказуемы, чем финал студенческой вечеринки, лучше
всего по возможности применять new и delete.
Правило 4. Предпочитайте комментарии в стиле С++
Старый добрый стиль комментариев С работает и в C++, но новый синтаксис
комментариев C++ до конца строки имеет свои отчетливые преимущества. Па-
пример, рассмотрим следующую ситуацию:
if ( а > Ь ) {
// int temp = а; // Поменять а и Ь.
// а = Ъ;
// b = temp;
}
У нас есть блок с кодом, закомментированным по той или иной причине. Яв-
ляя достойный подражания пример, программист, написавший изначальный код,
в дополнение к этому включил в него комментарий, который подсказывает, что же
здесь происходит. Когда комментарий к блоку пишется в форме, принятой в C++,
вложенный комментарий не составляет проблемы, но если бы оба комментария
были написаны в стиле С, у нас могли бы возникнуть трудности:
if ( а > Ь ) {
/* int temp - а; /* поменять а и Ь */
а = Ь;
Ь = temp;
*/
}
Обратите внимание на то, что вложенный комментарий непреднамеренно раз-
рывает комментарий, который должен был относиться к блоку кода.
Комментарии в стиле С по-прежнему играют важную роль. Например, они
Неоценимы в файлах заголовков, обрабатываемых компиляторами С и C++. Од-
нако если вы можете использовать комментарии в стиле C++, то лучше приме-
нять именно их.
Стоит отметить, что старые препроцессоры, легко обрабатывающие С, «не
Знают», как быть с комментариями в стиле C++, поэтому иногда использование
таковых может вызывать неожиданные эффекты:
^define LIGHT_SPEED Зе8 // м/сек (в вакууме).
Если препроцессор «не знает» C++, комментарий в конце строчки становит-
ся 'частью макроса} Тем более, как уже говорилось в правиле 1, использовать пре-
ППоцессоп для определения констант не следует.
2~- 1682
Глава 2. Управление памятью
Вопросы управления памятью в C++ распадаются на две основные труппы: как
делать это правильно, с одной стороны, и эффективно, с другой. Хорошие про-
граммисты понимают, что данные проблемы следует решать именно в таком по-
рядке, поскольку исключительно быстрая и удивительно маленькая программа
никому не нужна, если она не выполняет того, что от нес ожидается. Для большин-
ства программистов правильное управление памятью - это наличие корректного
вызова функций выделения и высвобождения памяти. Эффективность же озна-
чает написание своих собственных функций выделения и высвобождения памяти.
Следует признать, что C++ наследует от С наиболее существенную проблему,
характерную для данного языка, - потенциальную возможность утечек памяти.
Даже виртуальная память, каким бы замечательным изобретением она ни была,
ограничена и к тому же не всем доступна.
В С утечка памяти происходит всякий раз, когда память, выделенная посред-
ством malloc, не возвращается при помощи free. В C++действуют new и delete,
но в таком случае происходит примерно то же самое. В какой-то степени, однако,
ситуацию облегчают деструкторы, предоставляя место для вызовов delete, ко-
торые необходимо выполнить при уничтожении объекта. В то же время у вас по-
является больше проблем, поскольку new влечет неявный вызов конструктора,
a delete - вызовы деструкторов. Более того, существует еще одно осложнение:
вы можете определять свои собственные версии операторов new и delete как для
классов, так и вне них. Таким образом, не исключена возможность ошибиться. После-
дующие правила должны помочь вам избежать наиболее распространенных ошибок.
Правило 5. Используйте одинаковые формы
new и delete
Что неправильно в этом примере?
string *stringArray = new string[100] ;
delete stringArray;
На первый взгляд все в полном порядке - использованию new соответствует
применение delete, но что-то здесь совершенно неверно: поведение программы
непредсказуемо. По крайней мере 99 из 100 объектов string, на которые указыва-
ет stringArray, скорее всего, не будут надлежащим образом удалены, поскольку
их деструкторы, вероятно, так и не вызваны.
При использовании new происходят два события. Во-первых, выделяется па-
мять (посредством оператора new, о чем я еще буду говорить в правилах 7-10).
Во-вторых, для этой памяти вызывается один или несколько конструкторов. При
вызове delete вызывается один или несколько деструкторов, а затем посредством
функции operator delete высвобождается память (см. правило 8). Большой
вопрос, возникающий в связи с использованием delete, заключается в следующем:
сколько объектов следует удалить из памяти? Ответ зависит от того, сколько де-
структоров должно быть вызвано.
В действительности вопрос гораздо проще: является ли удаляемый указатель
указателем па один объект или на массив объектов? Если, применяя delete,
вы не используете квадратных скобок, он предполагает, что это указатель на оди-
ночный объект. В противном случае это указатель на массив:
string *stringPtrl = new string;
string *stringPtr2 = new string[100];
delete stringPtrl; // Удаляется объект.
delete [] stringPtr2; // Удаляется массив объектов.
Что произойдет, если использовать форму с [ ] для stringPtrl? Результат
не определен. Что случится, если вы не используете [ ] для stringPtr2? Неиз-
вестно. Более того, это не определено даже для встроенных типов, подобных int,
несмотря на то, что у таких типов нет деструкторов. Правило выглядит просто:
если вы применяете [ ] при вызове new, то должны использовать [ ] при вызове
delete. Если вы не применяете [ ] при вызове new, не используйте [ ] при вызо-
ве delete.
Это правило особенно важно помнить при написании классов, содержащих
указатели на конструкторы разного рода, поскольку вам необходимо соблюдать
осторожность, используя для всех конструкторов во время инициализации указа-
телей-членов класса одну и ту же форму new. Если этого не делать, то как узнать,
какую форму delete использовать в деструкторе? Для дальнейшего ознакомле-
ния с этим вопросом обратитесь к правилу И.
Данное правило также важно для тех, кто часто прибегает к использованию
typedef, поскольку из него следует, что автор typedef должен документировать,
какую форму delete следует применять к объектам типа typedef, возникаю-
щим при использовании new:
typedef string AddressLines[4];// Адрес лица содержит четыре строки,
// каждая представлена типом string.
Поскольку AddressLines - это массив, использованию new
string *pal = new AddressLines; // Заметьте, что new AddressLines
// возвращает string*,
// как возвратил бы new string[4] .
должна соответствовать форма delete, принятая для массивов:
delete pal; //Не определено.
delete [] pal; // Нормально.
Во избежание такой путаницы лучше воздерживаться от использования
typedef для создания типов с массивами. Это, однако, не должно вызывать
— 2*
36
паи i aii Управление памятью
особых затруднений, поскольку библиотека C++ (см. правило 49) включает в себя
шаблоны string и vector, практически сводящие к нулю необходимость исполь-
зования встроенных массивов. В нашем случае, например, тип AddressLines мог
бы быть определен как вектор строк. Иными словами, AddressLines мог иметь
тип vector<string>.
Правило 6. Используйте delete в деструкторах
для указателей членов
В большинстве случаев классы, применяющие динамическое выделение па-
мяти, будут использовать в конструкторах new для выделения памяти, а затем -
delete в деструкторах для ее высвобождения. Нетрудно сделать это правильно
при условии, что вы не забудете использовать delete для всех членов класса,
которым когда-либо могла быть выделена память в любом из конструкторов.
По мере эксплуатации и усовершенствования классов ситуация усложняется,
так как вносить изменения в классы могут те программисты, которые первона-
чально не создали их. При этом легко забыть, что добавление указателя - члена
класса практически всегда требует выполнения следующих действий:
□ инициализации указателя во всех конструкторах. Если в данном конструк-
торе для указателя выделять память не требуется, то указатель должен быть
инициализирован как нулевой;
□ удаления имеющейся памяти и выделения новой в операторе присваивания
(см. также правило 17);
□ удаления указателя в деструкторе.
Если вы забудете инициализировать указатель в конструкторе или обработать
его внутри оператора присваивания, проблема, вероятнее всего, станет очевидной
очень быстро, поэтому на практике подобные ошибки не доставляют серьезных
неприятностей. Если, однако, не удалить указатель в деструкторе, то можно вообще
не заметить никаких симптомов. Просто начнут происходить небольшие утечки
памяти, и получится нечто, напоминающее медленно растущую раковую опухоль,
которая в конечном итоге «пожрет» имеющуюся память и приведет к преждевре-
менной кончине вашей программы. Важно помнить об этой проблеме всякий раз,
когда вы добавляете в объявление класса член, являющийся указателем.
Заметьте, между прочим, что удаление нулевого указателя всегда безопасно
(при этом ничего не происходит). Таким образом, если конструкторы, операторы
присваивания и другие члены-функции написаны так, что каждый указатель-член
класса всегда либо указывает на память необходимого типа, либо равен нулю,
вы можете, ни о чем не беспокоясь, удалять их посредством delete, независимо
от того, применяли ли вы для них new или нет.
Не стоит, впрочем, впадать в крайности. Например, не нужно использовать
delete для указателей, не инициализированных посредством new, и практически
никогда не следует удалять указатель, который был вам ранее передан. Другими
словами, ваш деструктор класса обычно не должен использовать delete, если
в ваших членах класса вы не применяли new.
37
Правило 6. Правило 7
Правило 7. Будьте готовы к нехватке памяти
Когда оператор new не может выделить запрошенную память, он генерирует
исключение. (Раньше он возвращал 0, и некоторые старые компиляторы до сих пор
работают таким образом. При необходимости можно заставить компилятор делать
это снова, ио я воздержусь от обсуждения указанного момента в рамках данного
правила.) В глубине души вы прекрасно знаете, что обработка исключений не-
хватки памяти - единственно правильный способ действий. Вместе с тем не при-
ходится сомневаться, что это добавит вам много неприятной работы. В итоге вы
станете время от времени, а возможно даже всегда, опускать подобную обработку.
При этом на душе у вас будет неспокойно. А что, если new действительно даст
исключение?
Вы можете решить, что разумным способом решения данной проблемы будет
возврат к давно минувшим дням, то есть к использованию препроцессора. Напри-
мер, распространенный прием С - определение макроса, который не зависит
от передаваемого типа, гарантирующего успех выделения памяти. В C++ такой
макрос мог бы выглядеть следующим образом:
#define NEW(PTR, TYPE) \
try { (PTR) = new TYPE; } \
catch (std::bad_alloc&) { assert(0); }
«Подождите-ка! Что означает std: : bad_alloc?» - спросите вы. bad_alloc -
это тип исключения, генерируемый оператором new, когда он не может удовлетво-
рить запрос на выделение памяти, a std - название пространства имен (см. пра-
вило 28), где определяется bad_alloc. «Хорошо, а как же тогда быть с операто-
ром assert?» Если вы заглянете в стандартный заголовочный файл <assert. h>
(или в его эквивалент из C++, понимающий пространства имен, <cassert> -
см. правило 49), то обнаружите, что assert - макрос. Он проверяет, является ли
переданное ему выражение ненулевым, и, если это не так, вызывает abort. Заме-
тим, что он делает это только тогда, когда не установлено стандартное макро-
определение NDEBUG, то есть в режиме отладки. При окончательной компиляции,
когда NDEBUG определено, assert расширяется ни во что, то есть в void. Таким
ооразом, assert работает только при отладке.
В предлагаемом макросе NEW допущена распространенная ошибка: исполь-
зование assert для тестирования состояний, возникающих в ходе эксплуатации
(в конце концов, проблема нехватки памяти может проявиться в любое время).
Кроме того, у assert есть характерный для C++ недостаток: он не учитывает
огромное число способов, с помощью которых можно применять new. Существу-
ют три стандартные формы получения новых объектов типа Т, и для каждой из
них вам необходимо принять во внимание возможность исключений:
new Т;
new Т(аргументы конструктора) ;
new Т [размер] ;
Данное представление, однако, является упрощенным, так как пользователь
°пределяет свои собственные (перегруженные) формы оператора new, - следова-
тельно, программа может содержать произвольное количество различных синтак-
сических форм применения new.
38
Управление памятью
Как же тогда быть? Один из возможных вариантов - выбрать простейшую страте-
гию обработки ошибок: сделать так, чтобы, сел и запрос на выделение памяти нс может
быть удовлетворен, вызывалась определенная вами функция-обработчик ошибок. Эта
стратегия основана на договоренности о том, что, когда opera t or new ле может удов-
летворить запрос, перед генерацией исключения он вызывает определенную пользо-
вателем функцию обработки ошибок, часто называемую обработчиком new. (В дей-
ствительности new делает все немного сложнее - подробнее см. правило 8.)
Для того чтобы задать функцию, обрабатывающую нехватку памяти, пользо-
ватель вызывает функцию set_new_handler, определенную в файле <new>,
приблизительно следующим образом:
typedef void (*new_handler) () ;
new_handler set_new_handler(new_handler p) throw]);
Ясно видно, что new_handler - это определяемый typedef указатель на
функцию, которую operator new должен вызывать, если он нс может выделить
требуемую память. Значение, возвращаемое set_new_handler, - это указатель на
функцию, отвечавшую за обработку исключения до вызова set_new_handler.
Используйте set_new_handler следующим образом:
// Вызвать эту функцию, если new не сможет выделить память,
void noMoreMemory()
{
cerr « "Unable to satisfy request for memoryXn" ;
abort() ;
}
int main ()
{
set_new__handler (noMoreMemory) ;
int *pBigDataArray = new int[100000000];
}
Если operator new не может выделить память для 100000000 целых (что
представляется весьма вероятным), то будет вызвана функция noMoreMemory,
и после выдачи сообщения об ошибке программа прервет выполнение. Это лишь
немногим лучше, чем завершить выполнение системным сообщением. (Кстати,
подумайте, что произойдет, если для записи сообщения об ошибке в cerr требу-
ется динамическое выделение памяти...)
Когда оператор new не может удовлетворить запрос памяти, он вызывает на-
значенную функцию-обработчик непрерывно, пока не сможет найти необходимое
количество памяти. Код этих последовательных вызовов рассматривается в пра-
виле 8, но и такого высокоуровневого описания достаточно, чтобы сделать вывод
о том, что хорошо спроектированная функция-обработчик new должна выполнить
одно из следующих действий:
□ сделать доступным дополнительное количество памяти. Это может позволить
оператору new успешно выполнить операцию выделения памяти во время
следующей попытки. Один из способов реализации этой стратегии - выде-
ление большого блока памяти при запуске программы и его высвобождение
Правило 7
39
при первом вызове обработчика new. Оно часто сопровождается предупреж-
дением пользователю о том, что памяти остается мало и последующие за-
просы могут потерпеть неудачу, если каким-либо образом не будет выделе-
но нужное количество памяти;
□ установить другой обработчик new. Если текущий обработчик не может вы-
делить большее количество памяти, скорее всего, он установит другой обработ-
чик (вызывая set_new_handler), который проявит больше «изобретательно-
сти». В следующий раз, когда operator new вызовет функцию-обработчик
new, произойдет вызов последней установленной функции. (Один из спосо-
бов достичь этого - модификация обработчиком статических или глобаль-
ных данных, влияющих па поведение обработчика new);
□ переустановить обработчик new, то есть передать set_new_handler нулевой
указатель. Если обработчик new пе установлен, то оператор new при неудачной
попытке выделения памяти генерирует исключение типа std: :bad_alloc;
□ генерировать исключение типа std: : bad_alloc или некоторого производ-
ного от std: : bad_alloc типа. Такие исключения не перехватываются опе-
ратором new, а передаются в место, вызвавшее появление запроса памяти.
(Генерация исключений другого типа нарушит спецификацию исключений
оператора new. При этом по умолчанию вызывается abort, так что если ваш
обработчик new собирается генерировать исключение, то вам определенно
стоит убедиться, принадлежит ли оно иерархии std: :bad_alloc);
□ не возвращаться, вызывая abort или exit из стандартной библиотеки С
(входящие и в стандартную библиотеку C++, см. правило 49).
Этот набор средств позволяет проявлять достаточную гибкость при реализа-
ции функций-обработчиков new.
В некоторых случаях необходимо обрабатывать неудачные попытки выделе-
ния памяти другим способом, зависящим от класса объекта:
class X {
public:
static void outOfMemory () ;
};
class Y {
public:
static void outOfMemory () ;
};
X* pl = new X; // Если не удается выделить память, вызвать X: :outOfMemory () .
Y * р2 = new Y; // Если не удается выделить память, вызвать Y: :outOfMemory () .
C++ не поддерживает специфичные для классов обработчики new, но в этом
и нет необходимости. Вы можете реализовать такое поведение самостоятельно. Просто
Каждый класс должен обеспечивать свою собственную версию set_new_handler
и оператора new. Функция set_new_handler позволит задать для класса глобаль-
ный обработчик new (подобно тому, как стандартная функция set_new_handler
Дает возможность указать глобальный обработчик new). Определение для класса
40
Управление памятью
оператора new гарантирует, что при выделении памяти для объектов класса вме-
сто глобального будет использоваться специфичный обработчик new.
Рассмотрим класс X, для которого вы хотите обрабатывать неудачные попытки
выделения памяти. Вам необходимо отслеживать, какую функцию следует вызывать,
когда оператор new не может выделить достаточное количество памяти для объекта
типа X, поэтому требуется объявить статический член типа new_handler, указыва-
ющий на функцию-обработчик new этого класса. Ваш класс X будет выглядеть так:
class X {
public:
static new_handler set_new_handler(new_handler p) ;
static void * operator new(size_t size);
private:
static new_handler currentHandler;
};
Статические члены класса должны быть указаны вне определения класса. По-
скольку статические объекты предпочтительнее инициализировать, нулем, ука-
жите X: : currentHandler, не инициализируя его.
new_handler X: :currentHandler; // Устанавливает currentHandler
// в 0 (null) по умолчанию.
Функция set_new_handler из класса X будет сохранять передаваемый ей
указатель и возвращать указатель, имевшийся до вызова. Это в точности то, что
делает стандартная версия set_new_handler:
new_handler X::set_new_handler(new_handler p)
{
new_handler oldHandler = currentHandler;
currentHandler = p;
return oldHandler;
}
И наконец, оператор new для X начнет выполнять следующие действия:
1. Вызовет стандартную функцию set_new_handler с функцией-обработчи-
ком X. Это инсталлирует обработчик new класса X в качестве глобального
обработчика new. В коде, приводимом ниже, обратите внимание на то, как
явным образом, с использованием : :, задается область видимости - std
(где размещается стандартная функция set_new_handler).
2. Вызовет глобальный оператор new для того, чтобы выделить запрашиваемую
память. Если первоначальная попытка выделения памяти закончится не-
удачно, глобальный оператор new вызовет обработчик new класса X, так как
эта функция была только что установлена в качестве глобального обработ-
чика new. Когда в конечном итоге глобальный оператор new не сможет най-
ти способ для выделения запрашиваемой памяти, он сгенерирует исключение
std: : bad_alloc, которое будет перехвачено оператором new класса X. Да-
лее оператор new класса X восстановит глобальный обработчик new, установ-
ленный в самом начале, и возвратит управление, передав исключение.
3. Если глобальный оператор new смог выделить достаточное количество па-
мяти для объекта типа X, оператор new класса X снова вызовет стандартную
Р41
функцию set_new_handler и восстановит глобальную функцию обработ-
ки ошибок исходного состояния. Затем он вернет указатель на выделенную
память.
Теперь посмотрите, как все это будет выглядеть па C++:
void * X: :operator new(size_t size)
{
new_handler globalHandler = // Установить обработчик для X.
std::set_new_handler(currentHandler);
void *memory;
try { // Попытаться выделить память,
memory = ::operator new(size);
)
catch (std::bad_alloc&) { // Восстановить обработчик.
std::set_new_handler(globalHandler) ;
throw; // Сгенерировать исключение снова.
)
std: : set_new__handler (globalHandler) ; // Восстановить обработчик,
return memory;
)
Пользователи класса X применяют возможности обработки new следующим
образом:
void noMoreMemory () ; // Объявить функцию, которую требуется вызвать, если
//не удается выделить память для объектов класса X.
X: : set_new_handler (noMoreMemory) ; // Установить noMoreMemory
//в качестве обработчика new для X.
X *pxl - new X; // При нехватке памяти вызвать noMoreMemory.
string *ps = new string; // При нехватке памяти вызвать глобальный
// обработчик new (если есть) .
X: :set_new_handler(O) ; // Установить обработчик-new для X в О
// (то есть отменить обработку) .
X *рх2 = new X; // При нехватке памяти сразу же сгенерировать исключение.
// (Обработчик new для класса X отсутствует.)
Как вы могли заметить, код (независимо от класса), реализующий эту схему,
остается одинаковым, поэтому возникает желание использовать его повторно. Как
объясняется в правиле 41, для создания повторно используемого кода можно при-
менять как наследование, так и шаблоны. Однако в этом случае то, что нам необ-
ходимо, получается в результате комбинации обоих методов.
Нам нужно создать базовый класс-примесь, то есть базовый класс, спроекти-
рованный так, чтобы производные классы наследовали одно специфическое свой-
ство - в данном случае способность устанавливать для класса свой обработчик new.
Затем вы превратите базовый класс в шаблон. Часть схемы, относящаяся к насле-
дованию, позволяет производным классам наследовать необходимые им функции
Set_new_handler и оператор new, а шаблонная часть схемы гарантирует, что каж-
дый класс-наследник получает различные члены currentHandler. Все это зву-
чит немного туманно, но, как нетрудно заметить, код выглядит обнадеживающе.
Единственная разница заключается в том, что он теперь может быть использован
В любом классе, который в этом нуждается:
42
IM Hi 91 I’ Управление памятью
templatecclass T> // Базовый класс-примесь для поддержки
clas NewHandlerSupport { //set_riew_handler на уровне класса.
public:
static ncw__handler set__new_handler (new_handler p) ;
static void * operator hew(size_t size) ;
private:
static newjaandler currentHandler;
J;
templatecclass T>
new_handler NewHandlerSupport<T>: :set_new_handler (new_handler p)
{
new_handler oldHandler = currentHandler;
currentHandler = p;
return oldHandler;
}
templatecclass T>
void * NewHandlerSupport<T>: :operator new(size_t size)
{
new_handler globalHandler = std: : set__new_Jiandler (currentHandler) ;
void *memory;
try {
memory = ::operator new (size);
}
catch (std::bad_alloc&) {
set_new_handler(globalHandler);
throw;
}
std::set_new_handler(globalHandler);
return memory;
}
// Устанавливаем каждый currentHandler в ноль.
tempLatecclass T>
new__handler NewHandlerSupport<T>: :currentHandler;
С использованием этого шаблона класса добавление для класса X поддержки
set_new_handler выглядит просто - Х“наследует от newHandlerSupport<X>:
// Обратите внимание на наследование от шаблона-примеси.
class X: public NewHandlerSupport<Х> {
...II Как ранее, но без объявлений set_new_handler и operator new.
};
Пользователь X остается в неведении относительно «кухни»; старый код про-
должает функционировать. И это хорошо, поскольку единственная вещь, на кото-
рую вы можете положиться, - это неведение пользователя.
Применение set_new_handler - удобный и легкий способ обрабатывать со-
стояние нехватки памяти, более эффективный, чем обрамление каждого new бло-
ком try. Кроме того, шаблоны, подобные NewHandl er Support, облегчают Добав-
ление отдельных обработчиков new для классов, требующих этого. Наследование
примесей, однако, неизбежно подводит нас к теме множественного наследования,
поэтому, прежде чем ступить на этот тернистый путь, прочитайте правило 43.
43
Правило 8
До 1993 года язык C++ требовал, чтобы оператор new, если он не может вы-
полнить запрос на выделение памяти, возвращал 0. Сейчас new должен генери-
ровать исключение std: :bad_alloc, по большая часть кода C++ была написа-
на до пересмотра спецификации. Комитет по стандартизации C++ не хотел
отказываться от уже созданного кода, проверяющего на 0, поэтому предложил
альтернативную форму оператора new (и оператора new [ ] - см. правило 8), ко-
торая продолжает при неудаче возвращать 0. Эти формы называются nothrow,
поскольку они никогда не используют throw и применяют объекты nothrow,
определенные в стандартном заголовочном файле <new>:
class Widget { ... );
Widget *pwl = new Widget ;
if (pwl == 0) ...
Widget *pw2 = new (nothrow) Widget;
if (pw2 == 0) ...
Независимо от того, используете ли вы нормальную (то есть генерирующую
исключение) или версию nothrow new, важно, чтобы вы были готовы к обработ-
ке ошибок недостатка памяти. Самый легкий способ сделать это - воспользовать-
ся функцией set__new_handler, которая работает с обеими формами.
11 Генерирует std: :bad_alloc при нехватке памяти.
11 Это условие не должно выполняться.
// Возвращает 0 при нехватке памяти.
// Это условие может выполниться.
Правило 8. При написании операторов new и delete
придерживайтесь ряда простых правил
Когда вы беретесь за написание оператора new (правило 10 объясняет, зачем
это может понадобиться), важно, чтобы поведение функций, написанных вами,
соответствовало действиям оператора new по умолчанию. На практике это озна-
чает правильное возвращаемое значение, вызов функции обработки ошибки при
недостаточном количестве памяти (см. правило 7) и готовность к обработке за-
проса на нулевой размер памяти. Вам также необходимо избегать неумышленно-
го сокрытия «нормальной» формы new, по это уже тема правила 9.
Что же касается возвращаемого значения, здесь все просто. Если вы можете вы-
делить запрошенную память, то просто возвращаете указатель на нее. Если не може-
те - следуйте правилу 7 и генерируете исключение типа std: :bad_alloc.
Однако на самом деле оператор new пытается выделить память более одного
Раза, после каждой неудачной попытки вызывая функцию обработки ошибки
и предполагая яри этом, что функция-обработчик может сделать что-нибудь для
высвобождения некоторого количества памяти. Только когда указатель функции
обработки ошибки равен пулю, оператор new генерирует исключение.
Псевдокод оператора new, не являющегося членом класса, выглядит следую-
щим образом:
void * operator new(size__t size) // у вашего оператора new могут
//• быть дополнительные аргументы.
{
if (size == 0) { // Обрабатываем запросы на 0 байт,
size - 1; // как если бы был запрошен 1 байт памяти.
}
44
Управление памятью
while (true) {
пытаемся выделить size байтов памяти
if (выделение прошло успешно)
return (указатель на память) ;
// Попытка разместить память прошла неудачно;
// найдем текущую функцию обработки ошибок (см. правило 7) .
new_handler globalHandler = set_new_handler (0) ;
set_new_handler(globalHandler);
if (globalHandler) (*globalHandler)();
else throw std::bad_alloc();
}
}
Трюк с обработкой запросов на 0 байт выглядит странным, как если бы это
были запросы на 1 байт, но этот метод прост, законен и... работает.
Вас также может смутить то место в псевдокоде, где функция обработки ошиб-
ки установлена в ноль, а затем быстренько переустановлена в свое исходное со-
стояние. К сожалению, не существует прямого способа непосредственного полу-
чения указателя на обработчик new, поэтому, чтобы получить его, вам необходимо
вызвать set_new_handler. Грубо, конечно, но зато эффективно.
В правиле 7 уже было сказано, что operator new содержит бесконечный цикл,
и в приведенном выше коде этот цикл налицо: while (true) бесконечен так, что
дальше некуда. Выбраться из него мы можем тогда, когда память будет успешно
выделена или функция-обработчик new выполнит одно из действий, описанных
в правиле 7: сделает доступным большее количество памяти, установит другой
обработчик new, деинсталлирует обработчик new, сгенерирует исключение типа
std:: bad_al loc или не вернет управление, завершив программу. Теперь вам долж-
но быть понятно, почему обработчик new должен выполнить одно из этих дейст-
вий. Если он этого не сделает, то цикл внутри оператора new никогда не закончится.
У оператора new есть свойство, которое многие упускают из виду: он наследует-
ся производными классами. Это может приводить к некоторым осложнениям. Обра-
тите внимание, что в псевдокоде оператора new, приведенном выше, функция пыта-
ется выделить size байтов (если s i zе не равно 0). Такое поведение вполне разумно,
поскольку именно этот аргумент передан функции. Однако большинство версий опе-
раторов new для классов (включая ту, которую вы обнаружите в правиле 10) спроек-
тировано только для конкретного класса, а не для класса со всеми его подклассами.
То есть при наличии у класса определения функции operator new ее поведение
практически всегда настраивается для работы с объектами размера sizeof (X) -
не больше и не меньше. Из-за наследования, однако, оператор new базового класса
будет вызван для выделения памяти объекту производного класса:
class Base {
public:
static void * operator new(size_t size);
};
class Derived: public Base
{ - . - } ;
Derived *p = new Derived;
// Derived не объявляет operator new.
// Вызов Base::operator new!
Правило 8
ПД5
Если оператор new для класса Base не был спроектирован для того, чтобы
справляться с подобной ситуацией (вероятнее всего, так и есть), лучше всего пе-
ренести обязанность выделения «неправильного» количества памяти па стандарт-
ный оператор new следующим образом:
void * Base::operator new(size_t size)
{
if (size '= sizeof (Base) ) // Если размер "не тот",
return ::operator new(size); // пусть запрос обработает
// стандартный оператор new.
... //В противном случае обрабатываем запрос здесь.
}
«Постойте-ка! - скажете вы. - Вы забыли проверить не вполне типичный
случай, когда size равен нулю!» На самом деле я об этом не забыл. Такая про-
верка здесь содержится, и встроена она в сравнение size с sizeof (Base). Пути
стандарта C++ неисповедимы, и одно из подтверждений тому - соглашение, что
все самостоятельные1 классы имеют ненулевой размер. По определению значе-
ние sizeof (Base) никогда не может быть равно нулю (даже если класс не со-
держит членов), поэтому, если size равен 0, запрос будет передан функции
: : operator new и она станет отвечать за адекватную обработку этого запроса.
Если вы хотите управлять выделением памяти массивам для отдельных классов,
вам потребуется реализовать родственную оператору new функцию - operator
new[]. (Она обычно называется new для массивов, поскольку трудно понять, как
следует произносить operator new [ ].) Если вы решите написать свой operator
new [ ], помните, что вы отвечаете только за выделение неструктурированной па-
мяти. С несуществующими объектами массива вы ничего делать не можете. В дей-
ствительности вы даже не можете рассчитать, сколько объектов будет в массиве,
так как вам неизвестен размер каждого объекта. В конце концов, operator new [ ],
объявленный в базовом классе, может быть вызван с целью выделения памяти для
массивов объектов производных классов, но такие объекты обычно больше по
размерам, чем объекты базовых классов. Поэтому внутри Base: : operator new [ ]
нельзя предполагать, что размер каждого объекта массива равен sizeof (Base),
а количество объектов в массиве - значению выражения {запрошенное количество
байтов)/sizeof (Base).
Вот и все правила, которым необходимо следовать, когда вы определяете
operator new (и operator new [ ] ). Для оператора delete и его аналога для мас-
сивов - оператора delete [] все намного проще. C++ гарантирует безопасность
Удаления нулевого объекта, поэтому вам также следует соблюдать эту гарантию.
Приведем псевдокод для оператора delete:
void operator delete(void *rawMemory)
{
if (rawMemory -= 0) return; // Ничего не делаем при удалении
// нулевого указателя.
удаляем память, на которую указывает rawMemory
1 Имеются в виду невложенные классы. - Прим, научного редактора.
С
Управление памятью
return;
}
Версия этой функции, определенной как член класса, тоже проста; единствен-
ный нюанс заключается в том, чтобы проверять размер удаляемого объекта. Если
operator new передает запросы памяти «неправильного» размера функции
: : operator new, то запросы на удаление объектов «неправильного» размера сле-
дует передавать функции : : operator delete:
class Base { // Тот же класс, только добавлен оператор delete.
public:
static void * operator new(size_t size) ;
static void operator delete(void *rawMemory, size_t size);
};
void Base::operator delete(void *rawMemory, size_t size)
{
if (rawMemory == 0) return; // Проверить нулевой указатель.
if (size 1= sizeof (Base) ) { // Если размер "не тот", пусть запрос
: : operator delete (rawMemory); // обработает стандартный
return; // оператор delete.
}
удаляем память, на которую указывает rawMemory
return;
Правила для операторов new и delete (и их аналогов для массивов) не осо-
бенно обременительны, но придерживаться их следует. Если написанные вами
функции выделения памяти поддерживают функции-обработчики new и коррект-
но обрабатывают запросы нулевого размера, то это практически все, что требует-
ся, а если эти функции высвобождения памяти умеют работать с нулевыми указа-
телями, то вам осталось совсем немного - добавить поддержку наследования для
функций-членов.
Правило 9. Старайтесь не скрывать
«нормальную» форму new
Определение имени во внутренней области видимости скрывает это имя во
всех внешних областях, так что если функция f определена и в глобальной обла-
сти видимости, и в области видимости класса, то функция-член скроет глобаль-
ную функцию:
void f () ; // Глобальная функция.
class X {
public:
void f(); // Функция-член.
};
X х;
f(); // Вызывает глобальную f.
х.f(); II Вызывает X: :f.
Правило 9
Такое поведение в нормальной ситуации не приведет к путанице, поскольку
глобальные функции и функции-члены обычно вызываются с использованием
разных синтаксических форм. Однако, если добавить к классу оператор new, тре-
бующий дополнительных аргументов, результат может вас неприятно удивить:
class X {
public:
void f() ;
// Оператор new, которому необходимо указать функцию-обработчик.
static void * operator new(size_t size, new_handler p) ;
};
void. specialErrorHandler () ; // Определение в другом месте.
X *pxl - new (specialErrorHandler) X; // Вызывает X: :operator new.
X *px2 = new X; // Ошибка!
Определив функцию по имени operator new внутри этого класса, вы, сами
того не заметив, заблокируете доступ к «нормальной» форме оператора new. По-
чему так происходит, обсуждается в правиле 50. Здесь же нас больше интересует,
как можно этого избежать.
Одно из решений проблемы - написать для класса оператор new, поддержи-
вающий вызов «нормальной» функции. Если он делает то же самое, что и глобаль-
ная функция, это может быть эффективно и изящно реализовано с помощью встра-
иваемой функции:
class X {
public:
void f() ;
static void * operator new(size_t size, new_handler p) ;
static void * operator new(size_t size)
{ return ::operator new(size); }
};
X *pxl = new (specialErrorHandler) X; // Вызывает X: :operator
// new(size_t, new_handler ) .
X* px2 = new X; // Вызывает X: :operator new(size_t) .
В качестве альтернативы для каждого дополнительного аргумента оператора
new можно задать значение по умолчанию (см. правило 24):
class X {
public:
void f () ;
static
void * operator new(size_t size, new_handler p = 0) ;
// Заметьте: у p есть значение по умолчанию.
};
X *pxl = new (specialErrorHandler) X; 11 Нормально.
X* px2 = new X; //И это тоже.
В любом случае, если вы позднее решите модифицировать поведение «нормаль-
ной» функции new, все, что вам необходимо будет сделать, — это переписать ее. После
Перекомпоновки вы сразу добьетесь желаемого поведения в месте вызова функции new.
48
Управление памятью
Правило 10. Если вы написали оператор new,
напишите и оператор delete
Давайте ненадолго вернемся к основам. Почему иногда возникает желание
написать свою собственную версию operator new или operator delete?
Чаще всего побудительной причиной является стремление повысить эффек-
тивность. Версии operator new или operator delete по умолчанию идеаль-
но подходят для использования в общих целях, но их гибкость неизбежно приво-
дит к мысли об улучшении функциональности в частных случаях. Это особенно
верно для приложений, которые динамически размещают большое количество
маленьких объектов.
В качестве примера давайте рассмотрим класс, призванный описывать само-
леты, причем класс Airplane содержит.только указатели на объекты, действи-
тельно описывающие самолеты (прием, обсуждаемый в правиле 34):
class AirplaneRep {. . . }; // Реализация объекта "самолет".
class Airplane {
public:
private:
AirplaneRep *rep; // Указатель на реализацию.
};
Объект Airplane не очень большой и содержит только один указатель. (Как
объясняется в правиле 14, если в классе объявлены виртуальные функции, он
может неявно содержать еще один указатель.) При выделении памяти объекту
Airplane посредством вызова new вы, весьма вероятно, получите больше памя-
ти, чем нужно для хранения этого указателя (или пары указателей). Причина та-
кого поведения заключается в необходимости поддержания соответствия между
операторами new и delete.
Поскольку версия new по умолчанию - это функция выделения памяти, пред-
назначенная для самых общих случаев, она должна быть готова к обработке бло-
ков любого размера. Аналогичным образом версия оператора delete по умолча-
нию должна быть готова к высвобождению блоков любых размеров, выделенных
оператором new. Для того чтобы оператор delete мог определить необходимый
объем памяти, он прежде всего должен определить, сколько памяти было выделе-
но оператором new. Стандартный способ передачи информации о выделенной па-
мяти от оператора new оператору delete - добавление перед выделяемой памя-
тью некоторого дополнительного блока данных, указывающих на то, как много
памяти было выделено. Соответственно, когда вы пишете:
Airplane *ра = new Airplane;
то вряд ли получаете блок памяти, выглядящий следующим образом:
ра----------------------------------тт-
Memory for
Airplane object
49
Правило 10 | £ВЯНИ1:
Чаще всего получаемый блок памяти выглядит примерно так:
Data on size of memory block
pci' ► Memory for
♦ Airplane object
Для небольших объектов, подобных классу Airplane, эта дополнительная бух-
галтерия может более чем вдвое увеличить количество памяти для каждого дина-
мически создаваемого объекта (особенно если класс имеет виртуальные функции).
Если вы разрабатываете программное обеспечение для тех случаев, когда память
является ценным ресурсом, то, вероятно, не сможете позволить себе подобную
расточительность. При написании вашего собственного оператора new для класса
Airplane воспользуйтесь тем обстоятельством, что объекты Airplane имеют
одинаковый размер. Поэтому нет никакой необходимости сопровождать каждый
выделенный блок дополнительной информацией об использовании ресурсов.
Один из способов реализации ваших собственных операторов new - запрашивать
у оператора new по умолчанию большие блоки неструктурированной памяти, имею-
щие размеры, достаточные для значительного количества объектов Airplane. Пор-
ции памяти для самих объектов Airplane мы будем брать из этих больших блоков.
Не используемые в текущий момент порции будут организовываться в связный
список свободных блоков, доступных для Airplane. Может показаться, что вам
нужно будет выделять дополнительную память для указателей next каждого объ-
екта (для поддержки списка), но это не так. Память для поля гер, необходимого
только для блоков памяти, используемых в качестве объектов Airplane, будет
служить и как место хранения указателя next, поскольку он нужен только для тех
блоков памяти, которые не применяются объектами Airplane. Вы можете обес-
печить эту многофункциональность стандартным образом, используя объединение.
Для того чтобы реализовать данный проект, вам необходимо модифицировать
Airplane для поддержки управления памятью. Эта операция осуществляется так:
class Airplane' { // Измененный класс поддерживает
public: // собственное управление памятью.
static void * operator new(size_t size);
private:
union {
AirplaneRep *rep; // Для используемых объектов.
Airplane *next; // Для списка свободных блоков.
};
// Эта определенная в классе константа (см. правило 1)
// указывает, сколько объектов Airplane помещается
//в большом блоке памяти; она инициализируется ниже,
static const int BLOCK_SIZE;
static Airplane *headOfFreeList;
50
II. Управление памятью
Здесь мы добавили объявление оператора new, объединение, которое позво-
ляет полям гер и next занимать одну и ту же память, константу, указывающую
размер каждого выделяемого блока, и статический указатель, который должен от-
слеживать заголовок списка свободных блоков. Важно использовать именно ста-
тический член класса, так как существует общий список свободных блоков для
всего класса, а не для каждого объекта Airplane.
Далее следует написать оператор new:
void * Airplane::operator new(size_t size)
{
11 Перенаправляем запросы "неправильного" размера
// стандартному : :operator new (подробнее см. правило 8) .'
if (size !- sizeof(Airplane))
return ::operator new(size);
Airplane *p = headOfFreeList;
// Теперь p указывает на заголовок списка свободных блоков.
// Если р имеет допустимое значение, передвигаем
// заголовок списка свободных блоков на следующий элемент.
if (р)
headOfFreeList = p->next;
else {
// Список свободных блоков пуст. Размещаем блок памяти,
// достаточный для BLOCK__SIZE объектов Airplane.
Airplane *newBlock = static_cast<Airplane*>
(::operator new(BLOCK_SIZE * sizeof(Airplane)));
// Формируем новый список, связывая вместе блоки памяти,
// пропускаем нулевой элемент, поскольку он станет
// возвращаемым значением для нашего оператора new.
for (int i = 1; i < BLOCK_SIZE-1; + + i)
newBlock[i] .next = &newBlock[i + l] ;
// Завершаем связный список нулевым указателем.
newBlock[BLOCK_SIZE-l].next = 0;
// Устанавливаем р на начало списка,
//a headOfFreeList - на следующий элемент.
р = newBlock;
headOfFreeList = &newBlock[l];
)
return p;
}
Если вы читали правило 8, то должны помнить, что, когда operator new не мо-
жет удовлетворить запрос на выделение памяти, он должен выполнить ряд действий,
включающих в себя вызов функции обработчика new и генерацию исключений. Выше
все это отсутствует. Дело в том, что operator new получает всю память от глобаль-
ной функции : : operator new. Это означает, что operator new может потерпеть
неудачу, только если терпит неудачу : : operator new. Но в таком случае : : operator
new должен сам выполнить все нужные действия (в конечном итоге, возможно, гене-
рируя исключение), поэтому оператору new из класса Airplane нет необходимости
самому проделывать это. Другими словами, вызов обработчика new здесь уже имеет-
ся; просто вы его не видите, так как он спрятан внутри функции : : operator new.
51
Правило 10
Если у вас есть в наличии такой operator new, единственное, что вам остает-
ся сделать, - определить статические члены класса:
Airplane *Airplane: :headOfFreeList; // Эти определения находятся в файле
const int Airplane::BL0CK_SIZE = 512;// реализации, а не заголовка.
Нет необходимости явно устанавливать headOfFreeList в ноль, поскольку
до умолчанию статические члены инициализируются нулем. Величина BLOCK_SIZE
определяет размер каждого блока памяти, получаемого от : : operator new.
Эта версия функции operator new - именно то, что требуется. Она не толь-
ко использует для объектов Airplane намного меньше памяти, чем оператор new
по умолчанию, но, надо полагать, станет исполняться быстрее - возможно, даже
на два порядка быстрее. Впрочем, это и неудивительно. В итоге общая версия
operator new должна уметь работать с запросами различных размеров, уделять
внимание внутренней и внешней фрагментации и т.д., в то время как наша вер-
сия operator new всего лишь манипулирует парой указателей в связном списке.
Когда не нужно заботиться о гибкости, достичь быстродействия нетрудно.
Теперь, наконец, мы готовы к обсуждению оператора delete. Вы о нем еще помни-
те? Это правило посвящено именно ему. Пока что мы определили в классе Airplane
только operator new, но не определили operator delete. Теперь посмотрим, что
получится, если написать следующий, вполне естественный код:
Airplane *ра = new Airplane; // Вызывает Airplane::operator new.
delete pa; // Вызывает ::operator delete.
При прочтении этого кода вы сможете услышать грохот разбивающегося
и горящего самолета и вопли программистов, имевших к этому отношение. Про-
блема заключается в том, что operator new (написанный для Airplane) воз-
вращает указатель на память без какой-либо информации в заголовке, a operator
delete (глобальный, принятый по умолчанию) предполагает, что передаваемая
ему память содержит заголовочную информацию.
Этот пример иллюстрирует общее правило: операторы new и delete должны
быть согласованными и исходить из одинаковых предпосылок. Если вы собирае-
тесь разрабатывать свою собственную функцию выделения памяти, не забывайте
и о функции высвобождения памяти.
Вот как можно решить проблему для класса Airplane:
class Airplane { // Тот же, что и выше, но теперь
public: //с собъявлением оператора delete.
static void operator delete (void *deadObject, size_t size) ;
};
// Оператору delete передается блок памяти, который, если подходит
//по размеру, добавляется к началу списка свободных блоков памяти,
void Airplane::operator delete(void *deadObject, size_t size)
{
if (deadobject -= 0) return; // См. правило 8.
if (size !- sizeof (Airplane) ) { // См. правило 8.
::operator delete(deadObject);
return;
52
Управление памятью
}
Airplane *carcasg = static_cast<Airplane*>(deadobject);
carcass->next = headOfFreeList;
headOfFreeList = carcass;
}
Проявив осмотрительность при отправке запросов «неправильного» размера
глобальному оператору new в нашем операторе new (см. правило 8), следует поза-
ботиться о том, чтобы объекты с такими «неправильными» размерами передава-
лись глобальному оператору delete.
Интересно, что значение size_t, передаваемое в C++ оператору delete, мо-
жет быть неправильным, если удаляемый объект - производный от базового класса,
в котором не определен виртуальный деструктор. Это уже само по себе служит по-
водом для того, чтобы базовые классы содержали виртуальные деструкторы, но
в правиле 14 приводится и вторая, может быть, более существенная причина. Пока же
просто обратите внимание, что если вы опускаете виртуальные деструкторы в базо-
вом классе, то функции operator delete могут не работать надлежащим образом.
Все это прекрасно, однако нас должны беспокоить возможные утечки памя-
ти. Вы не могли не заметить, что operator new из класса Airplane вызывает
: : operator new для выделения больших блоков памяти, a operator delete
эти блоки не высвобождает1. Утечка памяти! Утечка памяти! Мне кажется, я поч-
ти слышу звуки сигнальной сирены в вашей голове.
Обратите внимание: никакой утечки памяти нет.
Утечка возникает тогда, когда память выделяется, а затем все указатели на нее
теряются. В таком случае она не может быть восстановлена иначе, как чисткой па-
мяти или другим внеязыковым механизмом. Но в данном случае утечка памяти
отсутствует, поскольку все указатели на память не теряются. Каждый большой
блок памяти вначале разбивается на порции размером объекта Airplane, а затем
эти порции помещаются в список свободных блоков. Когда пользователь вызы-
вает Airplane: : operator new, блоки удаляются из списка, и пользователь по-
лучает указатели на них. Когда пользователь вызывает operator delete, бло-
ки опять помещаются в список свободных блоков. При этом все блоки памяти
либо используются объектами Airplane (в этом случае за предотвращение
утечки памяти отвечает пользователь), либо находятся в списке свободных бло-
ков (в этом случае на блок памяти имеется указатель). Никакой утечки нет.
Однако блоки памяти, возвращаемые функцией : : operator new, никогда не
высвобождаются функцией Airplane: : operator delete, и это явление долж-
но быть названо как-то особо. Такое название действительно существует: пул па-
мяти. Если хотите, можете считать это семантической эквилибристикой, но меж-
ду пулом и утечкой памяти существует важное различие. Утечка может расти до
бесконечности, даже если пользователи ведут себя правильно, а пул никогда не
превышает максимального количество памяти, запрошенного пользователем.
Было бы нетрудно модифицировать управление памятью объектов Airplane
так, чтобы блоки памяти, которые возвращает : :operator new, автоматически
’ Я намеренно завел об этом разговор, поскольку в первом издании книги обошел стороной обсуж-
дение данного вопроса, и многие читатели укоряли меня за это упущение.
Правило 10
высвобождались, когда они более не используются, но существуют две причины,
во которым делать этого не стоит.
Первая причина непосредственно связана с мотивацией нашего решения запять-
ся управлением памятью. Оснований для этого достаточно много, но наиболее распро-
страненное из пих - оператор new и оператор delete по умолчанию используют че-
ресчур много памяти л ибо чересчур медленно работают (или и то, и другое). Притом
каждый дополнительный байт и каждая дополнительная строчка, предназначенные
для отслеживания и высвобождения этих больших блоков памяти, непосредственно
сказывают ся на вашем замысле: программное обеспечение работает медленнее и ис-
пользует больше памяти, чем если бы вы придерживались стратегии с пулом памяти.
Для библиотек и приложений, в которых производительность является первоочеред-
ной задачей, а ожидаемый размер пула должен оставаться в разумных пределах, ис-
пользование пула может быть наилучшей стратегией.
Вторая причина связана с различными экстремальными случаями. Допустим,
функции управления памятью класса Airplane модифицированы таким образом,
чтобы оператор delete из этого класса высвобождал любые большие блоки памя-
ти, не содержащие активных объектов. Теперь рассмотрите следующую программу:
int main()
{
Airplane *ра = new Airplane; // Первое размещение: выделить большой
// блок, организовать список и т.д.
delete ра; // Теперь блок пуст, освобождаем его.
pa = new Airplane;// Снова выделяем блок, создаем список и т.д.
delete ра; // Ну вот, блок снова пуст, поэтому освобождаем его.
... / / Надеюсь, вы понимаете. . .
return 0;
Эта «противная» программка будет работать медленнее и использовать боль-
ше памяти, чем даже с применением операторов new и delete по умолчанию, не
говоря уже о версии этих функций, использующих пул!
Конечно, существуют способы бороться с такими отклонениями, но чем больше
кода вы напишете для различных специальных случаев, тем ближе подойдете к ис-
ходным функциям управления памятью, принятым по умолчанию. Пул памяти не
решает всех вопросов управления, но во многих случаях это вполне разумный выбор.
Поскольку использование пулов памяти - часто самое рациональное реше-
ние, у вас может возникнуть потребность облегчить его реализацию для различ-
ных классов. «Несомненно, - скажете вы, - должен существовать способ, облег-
чающий использование стратегии выделения памяти фиксированного размера в
Различных классах». Он в самом деле существует, но это правило уже и так отня-
ло У нас много драгоценного времени, поэтому выяснение деталей оставлю чита-
телю в качестве занимательного упражнения.
Вместо этого я просто покажу минимальный интерфейс (см. правило 18) клас-
са Pool, где каждый объект типа Pool предназначен для выделения памяти объек-
там фиксированного размера, который задан в конструкторе класса Pool:
class Pool {
public:
54
Управление памятью
Pool(size_t n) ; // Создайте распределитель памяти
// для объектов размером п байт.
void * alloc(size_t n);// Разместите память для одного объекта, следуя
// правилам для оператора new из правила 8.
void free (void *р, size_t n) ; Il Верните память, на которую ссылается р,
// в пул, следуя правилам для оператора
// delete из правила 8.
-Pool () ; // Освободите всю память в пуле.
};
Данный класс позволяет создавать и удалять объекты Pool и выполнять опе-
рации выделения и высвобождения памяти. Когда объект Pool удаляется, он вы-
свобождает выделенную память. Следовательно, у вас есть способ избежать си-
туации, похожей на утечку памяти, что характерно для Airplane. Однако это
также означает, что если деструктор будет вызван слитком быстро (до того как все
объекты, использующие его память, будут разрушены), то вы удалите память, вы-
деленную некоторым объектам, прежде, чем они закончат ее использовать. В та-
ком случае сказать, что поведение программы не определено, - значит слишком
смягчить реальную ситуацию.
С помощью класса Pool даже программист на Java сможет добавить функ-
ции управления памятью в класс Airplane:
class Airplane {
public:
... // Обычные функции класса Airplane.
static void * operator new(size_t size) ;
static void operator delete (void *p, size_t size) ;
private:
AirplaneRep *rep; // Указатель на реализацию.
static Pool memPool; // Пул памяти для объектов класса.
};
inline void * Airplane: .-operator new(size_t size)
{ return memPool.alloc(size); }
inline void Airplane: : operator delete (void *p, size_t size)
{ memPool.free(p, size); }
// Создаем новый пул для объектов класса Airplane,-
// этот код находится в файле реализации.
Pool Airplane::memPool(sizeof(Airplane));
Это намного более простая конструкция по сравнению с вышеприведенной,
поскольку класс Airplane не загружен подробностями, ие относящимися к са-
молетам. Здесь уже пет объединения, указателя списка свободных блоков, констан-
ты, определяющей величину каждого блока неструктурированной памяти, и т.д.
Все это спрятано внутри Pool, что вполне логично. Пусть автор класса Pool за-
ботится о таких мелочах управления памятью. Ваше дело - заставить работать
надлежащим образом класс Airplane.
Было бы интересно посмотреть, как специальные функции управления памя-
тью могут улучшать производительность программ; заслуживает также внимания
вопрос, как эти функции могут быть инкапсулированы внутри классов, подобных
Pool, однако не будем терять из виду главное. А главное заключается в том, что
operator new и operator delete должны работать вместе, поэтому если вы
пишете operator new, не забывайте также написать и operator delete.
Глава 3. Конструкторы, деструкторы
и операторы присваивания
Практически любой написанный вами класс будет содержать один или более кон-
структоров, деструктор и оператор присваивания. Ничего удивительного в этом
нет. Перечисленные функции - самые важные: они контролируют основные опе-
рации создания нового объекта и его инициализации, удаления объекта и высво-
бождения его ресурсов, задания нового значения объекта. Ошибки в этих функ-
циях могут вызвать чрезвычайно серьезные последствия, поэтому важно сделать
все правильно. В этом разделе изложены руководящие принципы создания вы-
шеназванных функций, являющихся основой хорошо написанных классов.
Правило 11 Для классов с динамическим
выделением памяти объявляйте копирующий
конструктор и оператор присваивания
Рассмотрим класс, представляющий объекты String:
// Плохо спроектированный класс String.
class String {
public:
String(const char *value);
-String();
... // Без конструктора копирования и оператора присваивания,
private:
char *data;
};
String::String(const char *value)
{
if (value) (
data = new char [strlen (value) + 1] ;
strcpy(data, value);
}
else {
data = new char[l] ;
*data.= ' \0 ' ;
}
)
inline String::-String() { delete [] data; }
Обратите внимание на то, что данный класс не объявляет ни оператор присваи-
вания, ни конструктор копирования. Как вы увидите, это влечет за собой некоторые
Нежелательные последствия.
56
Конструкторы и операторы
Если вы дадите следующее определение объектов:
String а("Hello") ;
String b("World");
то получите вот что:
Внутри объекта а располагается указатель на память, содержащую символь-
ную строку "Hello". Отдельно расположен объект Ь с указателем на символь-
ную строку "World". Если теперь вы выполните присваивание:
b - а,-
то, поскольку вы не определили operator^, C++ сгенерирует и вызовет вместо
него оператор присваивания, определенный по умолчанию (см. правило 45). Этот
глобальный оператор присваивания производит почленное присваивание членов
класса из а в Ь, что для указателей (а. data и b. data) означает простое побито-
вое копирование. Результат показан ниже:
При таком положении дел существует по меньшей мере две проблемы. Во-
первых, память, на которую указывал объект Ь, никогда не будет удалена; она те-
ряется навсегда. Это классический пример, демонстрирующий, как возникают
утечки памяти. Во-вторых, и а, и Ь теперь содержат указатели на одну и ту же сим-
вольную строку. Когда один из этих объектов удаляется, его деструктор удаляет
память, на которую по-прежнему указывает другой. Например:
String а ("Hello"); // Определяем и создаем а.
{
String b("World"); // Открываем область видимости,
... // определяем и создаем Ь.
b - а; // Вызываем operator^, теряем память объекта Ь.
} // Закрываем область видимости, вызываем деструктор Ь.
String с = а; // с.data не определен! a.data уже удален.
Правило 11
Ji к
57
Последняя строчка этого примера - вызов конструктора копирования, кото-
рый также не определен внутри класса, а следовательно, генерируется C++ ана-
логично оператору присваивания (см. правило 45) и будет обладать сходными
свойствами: побитово копировать указатели. Это порождает все те же проблемы -
нет лишь необходимости беспокоиться об утечках памяти, инициализируемый объ-
ект еще не указывает ни на какую память. Например, в случае с кодом, приведен-
ным выше, когда с . data инициализируется значением а . data, никаких утечек
нет, поскольку с. data пока ни на что не указывает. Однако после того, как с ини-
циализируется а, и с. data, и а. data указывают на одну и ту же память, поэтому
она будет удалена дважды: один раз при удалении с, другой - при удалении а.
Случай с копирующим конструктором не слишком сильно, но все-таки отли-
чается от случая с оператором присваивания. Разница в том, каким образом он мо-
жет создать вам проблемы: передачей по значению. Конечно, в правиле 22 объ-
яснено, что вы лишь в редких случаях должны использовать передачу объектов
по значению; тем не менее рассмотрите следующий пример:
void doNothing(String localstring) {}
String s = "The Truth Is Out There" ;
doNothing(s);
Все выглядит достаточно безобидно, но, поскольку localstring передается
по значению, она должна быть инициализирована из s посредством копирующе-
го конструктора (по умолчанию). Следовательно, localstring содержит копию
указателя, находящегося внутри s. Когда doNothing заканчивает выполнение,
localstring оказывается за пределами видимости и для нее вызывается де-
структор. Конечный результат вам знаком: s содержит указатель на память, кото-
рая уже была удалена localstring.
Между прочим, результат использования delete для удаленного указателя
не определен, поэтому даже если s больше не используется, неприятности могут
возникнуть, когда s выйдет за пределы видимости.
Решение подобных проблем совмещения указателей заключается в написании
Для классов, внутри которых имеются какие-либо указатели, своих собственных вер-
сий конструкторов копирования и операторов присваивания. Внутри этих функций
вы можете либо копировать члены классов, либо реализовывать некоторую схему
Подсчета, отслеживающую, сколько объектов в настоящее время указывают на кон-
кретную структуру данных. Подход с подсчетом указателей намного более сложен;
°н также требует дополнительной работы внутри конструкторов и деструкторов, но
в некоторых (далеко не во всех) приложениях может дать значительный вышрыш
Памяти и значительно увеличить скорость.
В некоторых случаях создание конструкторов и операторов присваивания ско-
рее вызывает дополнительные проблемы, чем облегчает ваши задачи, особенно если
У вас есть основания предполагать, что пользователь не будет делать копии или при-
менять операцию присваивания. Вышеприведенные примеры показывают, что пре-
небрежение соответствующими функциями-членами свидетельствует о плохом
Качестве программирования, по что вам делать, если их написание также нерацио-
нально? Все просто: следуйте совету этого правила. Объявите функции private,
58
№
Конструкторы и операторы
но не определяйте их, то есть не создавайте реализацию. Это не дает клиенту воз-
можности вызывать их и одновременно предотвращает их генерирование компи-
лятором. Подробное описание этого остроумного приема приводится в правиле 27.
И еще одно замечание относительно класса String, упомянутого в данном
правиле. В теле конструктора предусмотрительно использованы скобки [ ] для
обоих вызовов new, хотя в одном месте я хотел создать только один объект. Как
было описано в правиле 5, применяя new и delete, важно использовать одну и ту
же форму. Поэтому, делая ставку на new, я проявляю последовательность, о чем
и вам не следует забывать. Всегда используйте [ ] с delete, если вы применяли
[ ] при использовании new!
Правило 12. Предпочитайте инициализацию
присваиванию в конструкторах
Рассмотрим применение шаблонов для генерирования классов, позволяющих
ассоциировать идентификатор паше с указателем на объект некоторого типа Т:
templatecclass Т>
class NamedPtr {
public:
NamedPtr(const strings initName, T *initPtr);
private:
string name;
T *ptr;
};
(В свете проблем, которые могут возникать при присваивании и создании пу-
тем копирования объектов с указателями - см. правило 11 - возникает вопрос,
нужно ли реализовывать эти функции. Подсказка: да, нужно - см. правило 27.)
При написании конструктора для NamedPtr возникает необходимость пере-
давать значения параметров соответствующим членам класса. Существует два
способа сделать это. Во-первых, используется список инициализируемых членов:
templatecclass Т>
NamedPtr<T>::NamedPtr(const strings initName, T *initptr)
: name(initName), ptr(initPtr)
{}
Во-вторых, присваивания можно сделать в теле конструктора:
templatecclass Т>
NamedPtrcT>::NamedPtr(const strings initName, T *initPtr)
{
name = initName;
ptr = initPtr;
}
Между этими двумя подходами существует важное отличие.
Если придерживаться чисто практической точки зрения, следует отметить, что
встречаются случаи, когда необходимо использовать список инициализации. Так,
Правило 12
члены const и ссылки можно только инициализировать, а нс присваивать. Поэтому,
если вы решили, что объект NamedPtr<T> не может изменить свой идентификатор
или указатель, вы должны следовать совету в правиле 21 и объявлять члены с const:
templatecclass Т>
class NamedPtr {
public:
NamedPtr(const strings initName, T *initPtr);
private:
const string name;
T * const ptr;
};
Это определение класса требует, чтобы вы использовали список инициализа-
ции членов, поскольку члены const не могут быть инициализированы путем при-
сваивания.
Если же вы решите, что объект NamedPtr<T> должен содержать ссылку на
существующий идентификатор, то получите совершенно иную картину. И все рав-
но в этом случае вам следует инициализировать ссылку в списке инициализации
вашего конструктора. Конечно, вы можете скомбинировать оба подхода, получая
объекты NamedPtr<T> с доступом только для чтения к идентификаторам, кото-
рые могут быть модифицированы вне класса:
templatecclass Т>
class NamedPtr {
public:
NamedPtr(const strings initName, T *initPtr);
private:
const strings name;
T * const ptr;
// Требует инициализации в списке инициализации.
// Требует инициализации в списке инициализации.
Исходный шаблон класса не содержит, однако, членов const или ссылок.
Но даже и в этом случае использование списка инициализации более предпочти-
тельно, чем выполнение присваивания внутри конструктора. На этот раз причина
кроется в большей эффективности первого подхода. Когда используется список
инициализации, вызывается только одна функция-член класса string. При при-
сваивании внутри конструктора вызываются две функции. Для того чтобы понять
Почему, рассмотрим, что происходит, когда вы объявляете объект NamedPtr<T>.
Создание объектов подразумевает два этапа:
1. Инициализация членов класса (см. также правило 13).
2. Выполнение тела вызываемого конструктора.
(Для объектов базовых классов инициализация и выполнение кода внутри
Конструктора происходит до инициализации производных классов.)
В отношении классов NamePtr это означает, что конструктор для идентифи-
катора типа string всегда вызывается прежде, чем вы войдете в тело конструктора
Name Pt г. Встает единственный вопрос: какой конструктор string будет вызван?
60
Конструкторы и операторы
Это зависит от списка инициализации класса NamePtr. Если для name вы не
укажете инициализирующий аргумент, для string будет вызван конструктор по
умолчанию. Когда позднее для name будете выполняться присваивание внутри
конструкторов NamePtr, будет вызван operator= . Это приведет к тому, что про-
изойдут два вызова функций-членов string: один раз - конструктора по умолча-
нию, а другой раз - оператора присваивания.
С другой стороны, если вы используете список инициализации членов, чтобы
указать, что name следует инициализировать значением in it Name, то name бу-
дет инициализировано только с использованием копирующего конструктора, по-
средством одного-единственного функционального вызова.
Даже в случае простого типа string стоимость лишнего вызова функции мо-
жет быть значительной, и по мере того как классы становятся больше и сложнее,
усложняются их конструкторы и увеличивается стоимость их вызова. Если вы
возьмете за привычку везде, где это возможно, использовать список инициализа-
ции членов, не ограничиваясь только членами с const и ссылками, для которых
это является обязательным, вы также уменьшите шансы неэффективной инициа-
лизации членов класса.
Другими словами, инициализация посредством списка инициализации всегда
корректна и не менее, а часто более эффективна, чем присваивание внутри тела
конструктора. Кроме того, она упрощает дальнейшую поддержку класса, посколь-
ку, если тип члена класса позднее изменится так, что потребуется использование
списка инициализации данных, вам ничего не придется менять.
Тем не менее может возникнуть ситуация, когда будет целесообразно исполь-
зовать присваивание, а не инициализацию членов класса. Это случай, когда у вас
имеется большое количество членов класса встроенных типов, и вы хотите, чтобы
в каждом конструкторе все они инициализировались одинаковым образом. Вот
пример класса, который можно отнести к данной категории:
class ManyDataMbrs { ,
public:
// Конструктор по умолчанию.
ManyDataMbrs();
// Конструктор копирования.
ManyDataMbrs (const ManyDataMbrs& х) ;
private:
int a, b, c, d, e, f, g, h;
double i, j , k, 1, m;
};
Предположим, что вы хотите инициализировать все переменные типа int зна-
чением 1, а все double - 0, даже если используется конструктор копирования. Ис-
пользуя список инициализации членов, вы должны были бы написать следующее:
ManyDataMbrs::ManyDataMbrs()
: а(1) , b(l), с(1), d(l), е(1), f(1), g(l), h(l) , i(0) ,
j (0) , k (0), 1(0), m(0)
{ . . . }
Правило 12
ManyDataMbrs::ManyDataMbrs(const ManyDataMbrs& x)
: a(l), b(l), c(l), d(l) , e(l), f(l), g(l), h(l), i(0),
j(0) , к (0) , 1(0) , m(0)
{ ... }
Это не просто неприятная и нудная работа. В краткосрочном плане такой код
увеличивает вероятность ошибки, а в долгосрочном - затрудняет поддержку
Вы, однако, можете воспользоваться тем фактом, что между инициализацией
и присваиванием объектов встроенного типа (если они не константные и не ссыл-
ки) нет никакого функционального отличия, поэтому вполне допустимо заме-
нить инициализационный список вызовом общей инициализирующей функции:
class ManyDataMbrs {
public:
/ / Конструктор по умолчанию.
ManyDataMbrs() ;
// Конструктор копирования.
ManyDataMbrs (const ManyDataMbrs& х) ;
private:
int a, b, c, d, e, f, g, h;
double i, j , k, 1, m;
void init() ; // Используется для инициализации членов-данных.
};
void ManyDataMbrs: : init ()
{
a = b = c = d = e = f = g = h= l;
i = j = k = l = m= 0;
}
ManyDataMbrs::ManyDataMbrs()
{
init();
}
ManyDataMbrs::ManyDataMbrs(const ManyDataMbrs& x)
{
init();
}
Поскольку инициализирующая функция - это внутреннее дело реализации
Класса, не забудьте сделать эту функцию private.
Обратите внимание на то, что статические члены класса никогда не должны
Инициализироваться в конструкторе. Они инициализируются только однажды
в Ходе выполнения программы, поэтому нет смысла пытаться инициализировать
Их каждый раз при создании объекта данного типа. В самом лучшем случае это
°Удет неэффективным: зачем несколько раз платить за инициализацию объекта?
Кроме того, инициализация статических членов класса отличается от инициали-
зации их нестатических эквивалентов, и этой теме посвящено правило 47.
6ZJ
fl
Конструкторы и операторы
Правило 13. Перечисляйте члены в списке
инициализации в порядке их объявления
Программисты на Pascal и Ada часто страдают от невозможности объявлять
массивы с произвольными границами, то есть от 10 до 20 (вместо 0...10). Тот, кто
давно имеет дело с С, считает, что любой уважающий себя программист всегда на-
чинает отсчет с 0. Умиротворить приверженцев begin/end совсем не сложно. Все,
что для этого необходимо, - определить свой шаблон класса массива:
templatecclass Т>
class Array {
public:
Array(int lowBound, int highBound);
private:
vector<T> data; // Данные массива хранятся в векторе;
// о шаблоне vector см. правило 49.
size_t size; // Число элементов массива.
int IBound, hBound; // Нижняя и верхняя границы.
};
templatecclass Т>
Array<T>::Array(int lowBound, int highBound) : size(highBound - lowBound + 1),
IBound(lowBound), hBound(highBound), data(size)
{}
Любой конструктор программного продукта промышленного уровня выпол-
нял бы проверку допустимости параметров, чтобы убедиться, что highBound по
крайней мере не меньше lowBound, но здесь кроется и гораздо более неприятная
ошибка: даже при идеальных граничных значениях никак нельзя сказать, сколько
элементов содержит data.
«Как это может быть? - слышу я. - Мы аккуратно инициализировали size, прежде
чем передавать его конструктору vector!» К сожалению, вы заблуждаетесь: вы лишь
пытались это сделать. Здесь действует следующее правило: члены списка инициали-
зации класса инициализируются в том же порядке, в котором они объявляются в классе',
порядок их следования в списке инициализации не имеет ни малейшего значения.
В классах, генерируемых из нашего шаблона Array, вначале будет инициализиро-
ван data, затем size, IBound, и, наконец, hBound. Так будет происходить всегда.
Хотя это может показаться дикостью, для упомянутого правила есть причина.
Рассмотрите следующий сценарий:
class Wacko {
public:
Wacko (const char *s) : sl(s), s2(0) {}
Wacko (const WackoS rhs) .- s2(rhs.sl), sl(0) (}
private:
string si, s2 ;
};
Wacko wl = "Hello world! " ;
Wacko w2 = wl;
63
Правило 13
Если бы члены класса инициализировались в порядке их следования в списке
инициализации, порядок создания элементов данных wl и w2 был бы различным.
Вспомните, что деструкторы элементов данных объекта всегда вызываются в поряд-
ке, обратном порядку вызова их конструкторов. Таким образом, если бы порядок
инициализации определялся порядком следования в списке инициализации, для
того чтобы гарантировать, что деструкторы будут вызваны в правильной последова-
тельности, компилятору необходимо было бы отслеживать порядок инициализа-
ции каждого объекта. Занятие, согласитесь, весьма дорогостоящее... Дабы избежать
лишних затрат ресурсов, порядок создания и уничтожения для объектов данного
типа всегда один и тот же, а порядок в списке инициализации игнорируется.
Если уж вдаваться в детали, следует оговориться, что согласно этому правилу
инициализируются только пестатическис члены класса. Статические члены класса
подобны глобальным объектам и объектам, определенным в пространстве имен,
и поэтому инициализируются только один раз; подробнее см. правило 47. Более того,
члены базовых классов инициализируются прежде членов производных классов, по-
этому если вы используете наследование, то должны инициализировать члены базо-
вых классов в самом начале списка. (Если используется множественное наследование,
созданные вами базовые классы будут инициализированы в порядке наследования;
порядок, в котором они перечисляются в списке инициализации, снова окажется про-
игнорирован. Однако при использовании множественного наследования у вас по-
явятся более серьезные причины для беспокойства. Правило 43 подскажет вам, ка-
кие аспекты множественного наследования заслуживают пристального внимания.)
Подытожим все вышесказанное: если вы действительно хотите понимать, что
происходит при инициализации объектов, не забывайте вносить их в список ини-
циализации в том порядке, в котором они объявляются в классе.
Правило 14. Убедитесь, что базовые классы
имеют виртуальные деструкторы
Иногда бывает удобно отслеживать, сколько всего объектов данного класса су-
ществует в вашей программе. Наиболее простой способ достичь этого - создать ста-
тический член класса для подсчета объектов. Этот член инициализируется нулем,
инкрементируется в конструкторах класса и декрементируется в деструкторах.
Вы можете представить себе некоторое военное приложение, в котором класс,
представляющий собой вражеские цели, мог бы выглядеть приблизительно следу-
ющим образом:
class EnemyTarget {
public:
EnemyTarget() { ++numTargets; }
EnemyTarget(const EnemyTarget^) { ++numTargets; }
-EnemyTarget() { --numTargets; }
static size_t numberOfTargets()
{ return numTargets; }
virtual bool destroy(); // Удачна ли была попытка уничтожить
// объект EnemyTarget?
64
Конструкторы и операторы
private:
static size_t numTargets; // Счетчик объектов.
};
// Статистические данные должны быть определены вне класса;
// переменная инициализируется 0 по умолчанию.
size_t EnemyTarget::numTargets;
Этот класс вряд ли поможет вам подписать контракт с министерством оборо-
ны, но он вполне подойдет для наших целей. По крайней мере, я на это надеюсь.
Давайте предположим, что один из типов вражеских целей - это танк, кото-
рый вполне логично моделировать (см. правило 35) классом, открыто наследую-
щим от EnemyTarget. Поскольку наряду с суммарным количеством вражеских
целей для вас представляет интерес суммарное количество танков, вы проделыва-
ете для него то же самое, что и для базового класса:
class EnemyTank: public EnemyTarget {
public:
EnemyTank() { ++numTanks; }
EnemyTank(const EnemyTankS rhs)
: EnemyTarget(rhs)
{ ++numTanks; }
-EnemyTank() { —numTanks; }
static size_t numberOfTanks()
{ return numTanks; }
virtual bool destroy();
private:
static size_t numTanks; // Счетчик объектов для танков.
};
И наконец, давайте предположим, что где-то в вашем приложении вы, исполь-
зуя new, динамически создаете объект EnemyTank, а затем удаляете его, исполь-
зуя delete:
EnemyTarget *targetPtr = new EnemyTank;
delete targetPtr;
Все, что вы до сих пор предпринимали, кажется вполне разумным. Оба класса
аннулируют сделанное в конструкторе, а в приложении, аккуратно удаляющем
объекты, созданные ранее при помощи new, естественно, нет ничего плохого. Тем
не менее, что-то здесь вызывает сильное беспокойство. Поведение вашей програм-
мы является неопределенным, - вы не можете предсказать, что с ней случится.
Стандарт языка C++ в этом вопросе необычайно четок. Когда вы пытаетесь
удалить объект производного класса посредством указателя на базовый класс, ко-
торый содержит невиртуальный деструктор (как, например, EnemyTank), резуль-
тат не определен. Это означает, что компилятор может создать код, который спо-
собен делать что угодно: форматировать диск, посылать вашему начальнику
непотребные письма, отправлять факсы с кодом ваших программ конкурирующим
фирмам и т.п. Чаще всего при выполнении программы нигде не вызывается де-
структор производного класса. В нашем примере это говорит о том, что счетчик
65
Правило 14
в gnemyTank не будет уменьшаться при удалении targetPtr. Таким образом,
счетчик вражеских танков попросту неверен (достаточно неприятная перспектива
ДЛЯ бойцов, жизнь которых зависит от точной информации с поля боя).
Единственное, что необходимо предпринять для устранения подобных про-
блем,- сделать деструктор EnemyTank виртуальным. Объявление виртуального
деструктора гарантирует, что все совершится по вашему замыслу, перед высвобож-
дением памяти будут вызываться как деструкторы EnemyTank, так и EnemyTarget.
Теперь класс EnemyTank будет содержать виртуальную функцию, что в общем
характерно для базовых классов. В конце концов, задача виртуальных функций -
дать возможность изменять поведение производных классов (см. правило 36), по-
этому практически все базовые классы содержат такие функции.
Если класс не содержит виртуальных функций, это зачастую свидетельствует
о том, что его не предполагается использовать в качестве базового класса. В таком
случае определение в нем виртуального деструктора обычно не оправдано. Рас-
смотрим пример, идея которого взята из ARM (Annotated C++ Reference Manual),
книги Маргарет Эллис и Бьерна Страуструпа (см. правило 50):
// Класс для представления точек на плоскости.
class Point {
public:
Point(short int xCoord, short int yCoord);
-Point();
private:
short int x, y;
};
Если short int занимает 16 бит, объект Point может поместиться в 32-би-
товом регистре. Более того, объект Point может быть передан функции, написан-
ной на другом языке, например С или Fortran, как 32-битовое значение. Если
же деструктор класса Point объявлен виртуальным, ситуация изменяется.
Реализация виртуальных функций требует, чтобы объект содержал дополни-
тельную информацию, которую можно было бы использовать в момент выполнения
программы, дабы определить, чья виртуальная функция должна быть вызвана для
объекта. В большинстве компиляторов эта дополнительная информация вводится
Б форме указателя, называемого vptr (указатель па виртуальную таблицу), vptr ука-
зывает на массив указателей функций, именуемый vtbl (виртуальная таблица); каж-
дый класс, содержащий виртуальные функции, имеет связанную с ним таблицу vtbl.
Подробности, касающиеся того, как реализуются виртуальные функции, не
ВаЖны. По-настоящему важно то, что, если класс Point содержит виртуальную
Функцию, объекты этого типа автоматически увеличат свой размер от 16-битового
short до двух 16 битовых short плюс 32-битовый указатель vptr. Объекты
Point не будут более помещаться в 32-битовый регистр. Более того, объекты
Point в C++ уже не выглядят так, как аналогичные структуры в других языках,
подобных С, поскольку структуры из других языков не содержат vptr. В результа-
те отсутствует возможность передавать Point в функции и из функций, написан-
ных на других языках, если вы явным образом не учтете vptr, который является
элементом реализации, и, следовательно, программа будет плохо, переносима.
Конструкторы и операторы
В итоге можно сделать вывод, что объявление всех деструкторов виртуальны-
ми так же неправильно, как и полный отказ от объявления виртуальных деструк-
торов. Можно сформулировать это таким образом: объявляйте виртуальный де-
структор для класса тогда и только тогда, когда этот класс содержит по крайней
мере одну виртуальную функцию.
Это хорошее правило - одно из тех, которые верны для большинства случаев,
но, к сожалению, проблема отсутствия виртуальных деструкторов может возникать
даже при отсутствии виртуальных функций. Например, в правиле 13 содержится
шаблон класса для реализации массивов с задаваемыми пользователем пределами.
Предположим, что вы решили написать шаблон для получения производных клас-
сов, представляющих именованные массивы, то есть таких классов, где каждый
массив имеет название:
templatecclass Т> // Шаблон базового класса (из правила 13) .
class Array (
public:
Array(int lowBound, int highBound);
-Array();
private:
vector<T> data;
size_t size;
int IBound, hBound;
};
templatecclass T>
class NamedArray: public Array<T> {
public:
NamedArray(int lowBound, int highBound, const strings name) ;
private:
string arrayName;
};
Если где-либо в приложении вы преобразуете указатель на NamedArray в ука-
затель на Array и затем используете delete для указателя на Array, то мгновен-
но окажетесь в области неопределенного поведения:
NamedArray<int> *pna = new NamedArray<int> (10, 20, "Impending Doom") ;
Array<int> *pa;
pa = pna; // NamedArray<int> -> Array<int>
delete pa; // He определено! Скорее всего, pa->ArrayName
//не будет уничтожена, что вызовет утечку памяти.
С таким положением дел приходится сталкиваться чаще, чем вы можете себе
представить, поскольку ситуация, когда берут класс, выполняющий что-либо, в дан-
ном случае Array, и получают из него производный класс, делающий еще больше,
Правило 14
L67
ilHM
не столь уж редка. NamedArray нс переопределяет поведение класса Array - он
наследует его функции без изменений и просто добавляет некоторые дополнитель-
ные возможности. Тем не менее проблема невиртуального деструктора остается в силе.
И наконец, следует отметить, что в некоторых классах бывает удобно объяв-
лять чисто виртуальные деструкторы. Вспомните, что чисто виртуальные функции
дают абстрактные классы, которые не могут быть инстанцированы (то есть вы не
можете создавать объекты этого типа). Иногда, однако, встречаются классы, кото-
рые бы хотелось сделать абстрактными, но для этого в вашем распоряжении
не оказывается чисто виртуальных функций. Что делать в этом случае? Поскольку
абстрактный класс предназначается для использования в качестве базового класса,
который должен содержать виртуальный деструктор, а кроме того, чисто виртуаль-
ная функция дает абстрактный класс, решение просто: объявите в классе, который
должен быть абстрактным, чисто виртуальный деструктор.
Ниже приведен пример:
class AWOV { // Абстрактный класс без виртуальных функций,
public:
virtual -AWOV() =0; // Объявляем чисто виртуальный деструктор.
};
Этот класс включает в себя чисто виртуальную функцию, поэтому он абстрак-
тный. Он содержит виртуальный деструктор, а стало быть, вы можете жить спо-
койно, зная, что проблема с деструктором вам не грозит. Однако вы должны дать
определение чисто виртуального деструктора:
AWOV::-AWOV() {} // Определение чисто виртуального деструктора.
Оно необходимо, поскольку виртуальный деструктор работает таким образом,
что вначале вызывается деструктор самого верхнего производного класса, а за-
тем - деструкторы каждого базового класса. Это означает, что компилятор будет
генерировать вызов -AWOW, даже когда класс является абстрактным, поэтому тело
Функции надо определять обязательно. Если этого не сделать, компоновщик вы-
даст ошибку отсутствия символа, и вам придется вернуться и дать определение.
В этой функции можно делать что угодно, но, как и в примере выше, зачастую
в ней не делается ничего. Разумеется, у вас возникнет искушение избежать на-
кладных расходов вызова пустой функции посредством объявления встраиваемо-
Го Деструктора. Это вполне разумная стратегия, но здесь есть один момент, кото-
рый нужно принимать во внимание.
Поскольку деструктор виртуален, его адрес содержится в таблице vtbl.
Чо встраиваемые функции не предназначены для автономного существования
КЧто, собственно, и подразумевает inline), поэтому для получения адреса требу-
ется принять специальные меры. В правиле 33 можно найти более подробное объ-
яснение, но общий вывод таков: если вы объявляете виртуальный деструктор как
lriline, то, возможно, избежите накладных расходов при вызове функции, одна-
ко Компилятор все равно должен будет где-то генерировать отдельную не встраи-
ваемую функцию.
з*
68
Конструкторы и операторы
Правило 15. operator= должен возвращать
ссылку на *this
Бьерн Страуструп, создатель C++, особо заботился о том, чтобы определяемые
пользователями типы были неотличимы от встроенных типов настолько, насколько
это вообще возможно. Поэтому вы можете перегружать операторы, писать функции
преобразования типов, контролировать операции копирования и присваивания и т.д.
Благодаря предусмотрительности Страуструпа вам остается только следить, что-
бы все шло по накатанной дорожке.
Наиболее часто используемой операцией является присваивание. Для встро-
енных типов можно использовать последовательные присваивания, например:
int w, х, у, z;
W = X = y = Z = 0;
Необходимо, чтобы и для типов, определяемых вами, можно было использо-
вать последовательные присваивания:
string w, х, у, z; // Тип string определен "пользователем"
//в стандартной библиотеке C++ (см. правило 49) .
w=x=y=z= "Hello";
Оператор присваивания ассоциативен, поэтому цепочка присваиваний интер-
претируется следующим образом:
w = (х = (у - (z = "Hello" ) ) ) ;
Имеет смысл записать это выражение в эквивалентной функциональной фор-
ме. Если только вы не «кабинетный» программист на языке LISP, нижеследую-
щий пример заставит вас оценить операторы:
w.operator=(x.operator=(у.operator=(z.operator^("Hello"))));
Данная форма приведена для иллюстрации, поскольку она показывает, что
аргументом для w. operator3, х. operator3 и у. operator3 является значе-
ние, возвращаемое предыдущим вызовом функции operator3. В результате тип,
возвращаемый функцией operator3, должен быть приемлемым в качестве аргу-
мента этой же функции. В случае версии по умолчанию operator3 для класса С
общий вид функции выглядит следующим образом (см. правило 45):
С& С::operator=(const С&);
Как правило, соглашение о том, что operator3 принимает и возвращает ссылку
на объект класса, выполняется, хотя иногда можно перегрузить operator3 так, что-
бы он принимал аргументы различных типов. Например, стандартный тип string
поддерживает две различных версии оператора присваивания:
strings
operator^(const strings rhs) ; // Присвоить string объекту типа string-
strings
operator^(const char.*rhs) ; // Присвоить char * объекту типа string.
Правило 15
69
Заметьте, однако, что даже при перегрузке возвращаемый тип является ссыл-
кой на объект класса.
Типичная среди начинающих программистов C++ ошибка — возвращать для
operator= тип void. Данное решение будет казаться разумным до тех пор, пока
рЫ не осознаете, что оно препятствует последовательным присваиваниям. Вот по-
чему этого делать не следует.
Другая распространенная ошибка - возвращать ссылку на объект const, как
показано ниже:
class Widget {
public:
... / / Заметьте: возвращается значение с const.
const Widget& operator=(const Widgets rhs) ;
};
Обычно аргументация сводится к тому, что надо уберечь пользователя от ис-
кушения наделать глупостей, скажем, такого рода:
Widget wl, w2, w3;
(wl = w2) - w3; // Присвоить wl значение w2, а затем результату
// присвоить w3!(Если бы operator= класса Widget
// возвращал константную ссылку, этот код не прошел
/ / бы компиляцию.)
Как ни глупо это выглядит, но такое присваивание не запрещено для встроен-
ных типов:
int il, i2, i3;
(il = i2) = i3; // Допустимо! Присвоить il значение i2, а затем
// присвоить il значение i3.
Я не слышал о практическом использовании вещей подобного рода, но если
это приемлемо для int, то подходит и для моих классов. Это должно пригодиться
и вам. Зачем создавать себе лишние проблемы из-за несовместимости с соглаше-
ниями, принятыми для встроенных типов?
Для оператора присваивания, следующего общему соглашению, есть два кан-
дидата на роль возвращаемого объекта: объект с левой стороны знака присваива-
ния, указываемый при помощи this, и объект с правой стороны, содержащийся
в списке аргументов. Какой из них будет правильным?
Ниже перечислены возможности, которыми мы располагаем при работе с клас-
сом String (класс, для которого, как свидетельствует правило 11, в обязательном
Порядке требуется оператор присваивания):
Strings String::operator=(const Strings rhs)
{
return *this; // Возвращаем ссылку на объект слева.
70
Конструкторы и операторы
}
Strings string::operator=(const Strings rhs)
{
return rhs; // Возвращаем ссылку на объект справа.
}
Может показаться, что указанные возможности практически идентичны, од-
нако между ними имеется существенное различие.
Во-первых, версия, возвращающая rsh, не будет компилироваться: данное
обстоятельство объясняется тем, что rsh - это ссылка на const String, а опе-
ратор = возвращает ссылку на String. Пока для объекта, объявленного с const,
вы будете пытаться вернуть неконстантную ссылку, компилятор не даст вам
покоя. Может показаться, что этого достаточно легко избежать, - просто нужно
переопределить operator= следующим образом:
Strings String::operator=(Strings rhs) {... }
Увы, теперь не будет компилироваться код пользователя! Давайте опять
взглянем на последнюю часть цепочки равенств:
х = "Hello"; // То же, что и x.operator= ("Hello")
Поскольку аргумент справа от знака равенства имеет неправильный тип - это
массив char, а не String, - для того чтобы вызов функции прошел успешно, ком-
пилятору придется создавать временный объект String (посредством конструк-
тора String). Иными словами, будет сгенерирован код, приблизительно эквива-
лентный следующему:
const String temp("Hello") ; // Создание временного объекта.
х = temp; // Передача его оператору = .
Компилятор пытается создать такие временные объекты (если необходимый
конструктор не объявлен как explicit, см. правило 19), однако обратите вни-
мание на то, что временный объект объявлен с const. Это важно, поскольку
предотвращает случайную передачу временных объектов в функции, которые
модифицируют свои аргументы. Если бы такая передача была возможна, про-
граммисты с удивлением обнаружили бы, что модифицируется только сгенери-
рованный компилятором временный объект, а не сам аргумент, переданный ими
в функцию. (Мы знаем это точно, поскольку ранние версии C++ разрешали по-
добного рода генерацию, объекты передавались функции и модифицировались,
а в результате только возрастало число недоумевающих программистов.)
Теперь становится ясным, почему код пользователя не будет компилировать-
ся, если operator= для String объявляется с аргументом без модификатора
const: неверно передавать объект с const функции, в которой соответствующий
аргумент объявлен без const. Это элементарные правила корректности при ис-
пользовании const.
Таким образом, вы оказываетесь в ситуации, когда нет иного выбора, как возвра-
щать ссылку на левосторонний аргумент *this. Если вы пойдете иным путем, то
разорвете цепочку знаков равенства, или создадите препятствие на пути неявных
Правило 16
1 71
правил преобразования типов при вызове функций, или сделаете и то и друюе
одновременно.
Правило 16. В operator= присваивайте значения
всем элементам данных
Как показано в правиле 45, если вы не объявите оператор присваивания, C++
сделает это самостоятельно, а в правиле 11 объясняется, почему в ряде случаев
генерируемый оператор не удовлетворяет запросам программиста. Вам, навер-
ное, интересно, можно ли позволить C++ генерировать оператор присваивания
По умолчанию и при этом выборочно переписывать те фрагменты, которые вы
сочли неудачными. К сожалению, такой возможности у вас нет. Если желаете
взять на себя управление любой частью процесса присваивания, вы должны сде-
лать все сами.
На практике это означает, что при написании оператора (операторов) присва-
ивания вам необходимо присваивать каждый элемент созданного вами объекта:
templatecclass Т> // Шаблон для классов, связывающих указатели и имена
class NamedPtr { // (из правила 12) .
public:
NamedPtr(const strings initName, T *initPtr);
NamedPtrS operator^(const NamedPtrS rhs) ;
private:
string name;
T *ptr;
};
templatecclass T>
NamedPtr<T>& NamedPtr<T>::operator=(const NamedPtr<T>& rhs)
{
if (this == &rhs)
return *this; // См. правило 17.
name = rhs . name; / / Присваиваем имя.
*ptr = *rhs.ptr; // Для указателя присваиваем указываемый объект,
// а не значение самого указателя.
return *this; //См. правило 15.
}
Этого правила легко придерживаться при первоначальном написании клас-
Са> но не менее важно помнить о том, что обновлять операторы присваивания
Необходимо и при добавлении новых членов класса. Например, если вы реши-
Те Усовершенствовать шаблон NamedPtr так, чтобы он включал в себя маркер
Последнего времени изменения, вам придется добавить новые члены класса,
Что потребует обновления конструктора (конструкторов), а также операторов
Присваивания. В спешке и суете усовершенствования класса, добавления но-
вЫх функций-членов и т. д. необходимость подобных изменений может с лег-
костью ускользнуть от вашего внимания.
По-настоящему «весело» становится, когда сюда подключается наследование,
Поскольку оператор (операторы) присваивания производных классов должны
Конструкторы и операторы
обрабатывать операцию присваивания своих базовых классов. Рассмотрим сле-
дующий пример:
class Base {
public:
Base(int initialvalue = 0) : x(initialvalue) {}
private:
int x;
};
class Derived: public Base {
public:
Derived(int initialvalue) : Base(initialvalue), у(initialvalue) {}
Derived^ operator^(const Derived^ rhs) ;
private:
int y;
};
Логичный способ написания оператора присваивания для Derived был бы
представлен следующим образом:
//
Derived^ Derived::operator=(const Derived^ rhs)
{
if (this -- &rhs) return *this; // См. правило 17.
у = rhs.у; // Присвоить член класса, определенный в Derived,
return *this; //См. правило 15.
}
К сожалению, этот вариант некорректен, поскольку элемент данных х базово-
го класса Base объекта Derived при использовании этого оператора присваива-
ния остается неизменным. Рассмотрим, например, следующий фрагмент кода:
void assignmentTester()
{
Derived dl (0) ; 11 dl. x - 0, dl. у = 0
Derived d2 (1); // d2.x = 1, d2. у = 1
dl = d2; // dl.x = 0, dl.y = 1!
}
Заметьте, что Base-составляющая dl в ходе присваивания остается неизменной-
Непосредственным способом решения проблемы было бы присваивание я
в Derived: : operators К сожалению, оно невозможно, поскольку х является
закрытым членом класса Base. Вместо этого необходимо внутри оператора прИ'
сваивания Derived явным образом записать присваивание Вазе-составляющей
данного класса.
Это можно сделать следующим образом:
// Правильный оператор присваивания.
Derived& Derived::operator=(const Derived^ rhs)
{
if (this •== &rhs) return *this;
Base::operator=(rhs); // Вызов this->Base::operator=.
Правило 16
73
у = rhs.y;
return *this;
}
Здесь вы явно вызываете Base: : operators Этот вызов, подобно любому
вызову функции-члена из другой функции-члена, использует в качестве неявного
левостороннего объекта *this. В результате Base: : operator= сделает все не-
обходимое с Base частью *this - в точности то, что нам и требуется.
К сожалению, некоторые компиляторы ошибочно не допускают подобного
рода вызовов операторов присваивания, если таковые генерируются компилято-
ром (см. правило 45). Имея дело с такими компиляторами, Derived: : operator^
необходимо реализовать следующим образом:
Derived^ Derived::operator=(const Derived^ rhs)
{
if (this == &rhs) return *this;
static_cast<Base&>(*this) = rhs; // Вызов operator^ для
// Base-составляющей *this.
у = rhs.y;
return *this;
}
Этот «монстр» приводит *this к ссылке на Base, а затем для результата
преобразования осуществляет присваивание. Таким образом, мы выполняем при-
сваивание только для Base-части объекта Derive. Теперь внимание! Важно осу-
ществлять преобразование к ссылке на объект Base, а не к объекту Base как тако-
вому. Если произвести преобразование к объекту Base, это закончится вызовом
конструктора копирования Base, а объектом присваивания будет вновь создавае-
мый вами объект; *this же останется неизменным, между тем как мы хотели до-
биться иного результата.
Независимо от того, какой из этих подходов применяется, как только вы при-
своили Base часть объекта Derive, вы переходите к оператору присваивания
класса Derive, делая присваивания всем членам класса Derived.
Подобные проблемы, связанные с наследованием, часто возникают и при реа-
лизации конструкторов копирования класса. Рассмотрим следующий пример, ана-
лог предыдущего примера конструктора копирования:
class Base {
public:
Base(int initialvalue = 0) : x(initialvalue) {}
Base(const Bases rhs): x(rhs.x) {}
private:
int X;
};
class Derived: public Base {
public:
Derivedfint initialvalue) : Base(initialvalue), у(initialvalue) {}
Derived(const DerivedS rhs) // Неправильный конструктор копирования.
: у(rhs.y) { }
74
Конструкторы и операторы
*
private:
int у;
};
Класс Derive иллюстрирует одну из самых неприятных ошибок в C++: ког-
да объект Derived создается операцией копирования, ему не удается получить
копию базового класса. Конечно, Base-составляющая такого объекта Derived
создается, но с использованием конструктора Base по умолчанию. При этом х ини-
циализируется нулем (аргумент по умолчанию конструктора по умолчанию) не-
зависимо от значения х в копируемом объекте.
Во избежание подобных проблем конструктор копирования класса должен
следить за тем, чтобы вместо конструктора по умолчанию вызывался конструк-
тор копирования класса Base. Добиться этого легко. В списке инициализации кон-
структора копирования Derived необходимо задать для Base инициализирую-
щее значение:
class Derived: public Base {
public:
Derived(const Derived^ rhs): Base(rhs), y(rhs.y) {}
};
Теперь, когда пользователь создает Derived копированием существующего
объекта этого типа, его Base-составляющая также будет скопирована.
Правило 17. В operator= осуществляйте проверку
на присваивание самому себе
Присваивание самому себе возникает примерно в такой ситуации:
class X { . . . } ;
X а;
а = а; // а присвоена самой себе.
Код выглядит достаточно нелепо, однако он совершенно корректен, и в том,
что программисты на такое способны, вы можете не сомневаться ни секунды. Впро-
чем, присваивание самому себе может принять более разумную форму:
а = Ь;
где b - другое обозначение для а (например, ссылка, инициализированная а)
и присваивание самому себе, хотя внешне этого и не видно: один и тот же объект
имеет два или более имени. Как будет показано в конце этого правила, совмещение
имен может появиться в результате самых разных действий, поэтому при написа-
нии функции всякий раз необходимо принимать во внимание такую возможность.
Существуют веские причины проявлять особую осторожность относительно
возможного совмещения имен в операторах присваивания. Наименее важная при-
чина - эффективность. Если в самом начале оператора присваивания вы обна-
руживаете присваивание самому себе, то сразу можете выйти из него, возможно
75
Правило 17
Предупреждая большую работу, которую в противном случае необходимо было бы
проделать. Например, в правиле 16 указано, что правильный оператор присваива-
ния в производном классе должен вызывать операторы присваивания для всех
своих базовых классов, а эти классы могут сами быть производными классами,
поэтому, проскочив код оператора присваивания в производном классе, мы можем
избежать целого ряда функциональных вызовов.
Более важная причина для проверки па присваивание самому себе - это про-
блема гарантии корректности. Помните, что оператор присваивания перед выде-
лением новых ресурсов должен высвобождать ресурсы, выделенные объекту ра-
нее (то есть избавляться от своих старых значений). При присваивании самому
себе высвобождение ресурсов может быть просто гибельным, поскольку старые
ресурсы могут понадобиться в процессе создания новых.
Рассмотрим присваивание объектов String, когда оператор присваивания не
делает проверки на присваивание самому себе:
class String {
public:
String(const char *value) ; // Определение функции - см. правило 11.
-StringO ; // Определение функции - см. правило 11.
Strings operator^(const Strings rhs) ;
private:
char *data;
};
// Оператор присваивания, He проверяющий присваивание объекта самому себе.
Strings String::operator=(const Strings rhs)
{
delete [] data; // Удаляем старую память.
// Выделяем новую память и копируй в нее значение rhs
data = new char[strlen(rhs.data) + 1];
strcpy(data, rhs.data);
return *this; //См. правило 15.
}
Рассмотрим, что получится в этом случае:
String а = "Hello";
а = а; // То же, что и а.operator^ (а) .
Внутри оператора присваивания *this и rhs на первый взгляд кажутся раз-
ными объектами, но в данном случае они являются различными идентификатора-
ми одного и того же объекта. Вы можете представить себе это следующим образом:
Z "iHlell II 1о|\0]
Д datajY -----/гВВаПХ,
*this
rhs
Конструкторы и операторы
Первое, что делает оператор присваивания, - вызывает delete для указателя
datadelete, в результате чего мы приходим к следующему положению вещей:
Теперь результат применения strlen к rhs .data в операторе присваивания
не определен. Это объясняется тем, что при удалении data мы удалили rhs. data,
поскольку data, this->data и rhs .data - один и тот же указатель! Далее не-
приятности только множатся.
Решение проблемы - проверка на присваивание самому себе и немедленный
выход в этом случае. К сожалению, о такой проверке гораздо легче говорить, чем ее
сделать, ибо сразу возникает вопрос, какие объекты считать «одинаковыми».
Проблема, с которой мы сталкиваемся, известная как тождественность объек-
тов, - популярная тема объектного программирования. В задачу автора не входит
написание трактата о тождественности объектов; тем не менее есть смысл упомя-
нуть два основных подхода к данному вопросу.
Один подход заключается в том, чтобы считать два объекта одинаковыми
(тождественными), если они имеют одинаковое значение. Например, два объекта
String будут одинаковыми, если содержат идентичный набор символов:
String а = "Hello";
String b - "World";
String c = "Hello";
Здесь а и с имеют одно и то же значение, поэтому их можно считать тожде-
ственными, а b отлично от них обоих. Если в нашем классе String использовать
это определение тождественности, оператор присваивания мог бы выглядеть сле-
дующим образом:
Strings String::operator=(const Strings rhs)
{
if (stremp(data, rhs.data) == 0) return *this;
}
Равенство значений обычно определяет operator—, поэтому общая форма
для оператора присваивания класса С, реализующего тождественность в смысле
равенства значений объектов, будет следующей:
CS С::operator=(const CS rhs)
{
/ / Проверка на присваивание себе.
if (*this == rhs) // Предполагается, что operator== определен.
Правило 17
return *this;
}
Обратите внимание, что эта функция сравнивает объекты (посредством
operator==), а не указатели. При использовании тождественности по значе-
нию не важно, занимают ли оба объекта одну и ту же память: единственное, что
принимается во внимание, - это содержащиеся в ней значения.
Другая возможность - определять тождественность объектов по их адре-
сам в памяти. При использовании этого определения равенства объектов два
объекта тождественны тогда и только тогда, когда они имеют один адрес. Это
определение более распространено в программах на C++, возможно потому,
что его легко реализовать, и необходимые вычисления выполняются быстро.
Эти два преимущества не всегда достижимы, если тождественность определя-
ется по значениям. При использовании эквивалентности адресов общий вид
оператора присваивания имеет следующую форму:
С& С::operator=(const С& rhs)
{
// Проверка на присваивание себе,
if (this == &rhs) return *this;
}
Для большинства программ это вполне приемлемо.
Если вам необходим более изощренный механизм определения эквивалент-
ности объектов, вам придется реализовывать его самим. Наиболее часто исполь-
зуемый подход основан на использовании функции-члена, возвращающей неко-
торый идентификатор объекта:
class С {
public:
ObjectID identityO const; // См. также правило 36.
};
В этом случае, имея два указателя на объекты а и Ь, мы считаем объекты, на ко-
торые они указывают, тождественными только в том случае, если a->identity ()
==z b->ident i ty (). При этом, разумеется, в вашу задачу входит написание функ-
ции operator— для ObjectID.
Проблема совмещения имен и тождественности объектов не ограничивается
оператором присваивания. Просто это функция, для которой возникновение дан-
ной проблемы наиболее вероятно. При наличии ссылок и указателей любые два
объекта совместимого типа могут в действительности ссылаться на один и тот же
объект. Вот еще несколько ситуаций, в которых совмещение имен может создать
сУЩественные трудности:
class Base {
void mfl (Base& rb) ; // rb и *this могут совпадать.
78
Конструкторы и операторы
};
void fl(Bases rbl,Bases rb2);
class Derived: public Base {
void mf2(Bases rb) ;
// rbl и rb2 могут совпадать.
// rb и *this могут совпадать.
};
int f2(DerivedS rd, Bases rb) ; // rd и rb могут совпадать.
В этих примерах используются ссылки, но вполне могли бы подойти и ука-
затели.
Как видите, совмещение может принимать множество форм, так что не стоит
забывать о нем, надеясь, что вы никогда с ним не столкнетесь. Впрочем, может
быть, лично вам это удастся, но большинству программистов - нет. Образно выра-
жаясь, это именно тот случай, когда предосторожность ценится на вес золота. Вся-
кий раз, когда вы создаете функцию, в которой возможно совмещение имен, вам
следует учитывать такую возможность при написании кода.
Глава 4. Классы и функции:
проектирование и объявление
Объявление в программе нового класса создает новый тип: создание класса экви-
валентно созданию типа. Весьма вероятно, что у вас нет большого опыта создания
типов, поскольку большинство языков не предоставляет нам возможности по-
упражняться в этом. В C++ создание типов имеет фундаментальное значение, и не
только потому, что эта операция в принципе возможна, но и потому, что незави-
симо от вашего желания вы производите ее всякий раз при объявлении классов.
Создание хороших классов - это сложная задача, поскольку создание типов
требует усилий. Хороший тип имеет естественный синтаксис, интуитивную се-
мантику и одну или несколько эффективных реализаций. В C++ плохо проду-
манное определение класса означает, что вы не добьетесь таких характеристик.
Даже производительность зависит как от определения, так и от объявления функ-
ций-членов.
Как в таком случае решить задачу проектирования эффективных классов?
Прежде всего вы должны разобраться с ограничениями, которые возникают при
создании практически любого класса;
□ как следует создавать и удалять объекты? От этого сильно зависит вид
ваших конструкторов и деструкторов, равно как и ваших версий operator
new, operator new [ ], operator delete и operator delete [ ], если вы
их реализуете;
□ отличается ли инициализация объекта от его присваивания? Ответ на этот
вопрос определяет поведение конструкторов и операторов присваивания,
а также различия между ними;
□ что означает передача объекта нового типа по значению? Помните, именно
конструктором копирования определяется, что означает передача по зна-
чению;
□ каковы ограничения на значения нового типа? Эти ограничения определяют
тип контроля ошибок, который необходимо осуществлять внутри функций-
членов, особенно конструкторов и операторов присваивания. Это также мо-
жет повлиять на генерируемые функциями исключения, и, при их исполь-
зовании, на спецификацию исключений;
□ каково место нового типа в иерархии классов? Если вы наследуете от суще-
ствующих классов, неизбежно возникают определенные ограничения; от них
зависит, например, виртуальны ли наследуемые вами функции. Если вы хо-
тите, чтобы класс использовался как базовый, ваше решение повлияет на то,
будут ли объявляемые функции виртуальными.
80 I I III
Классы и функции
□ каковы допустимые преобразования типов? Если вы хотите разрешить неяв-
ное преобразование объектов типа А в объекты типа В, необходимо либо напи-
сать функцию преобразования типов в классе А, либо конструктор в классе В,
который не объявлен как explicit и может вызываться с одним аргумен-
том. Если вы хотите разрешить только явные преобразования типов, необхо-
димо написать функции, выполняющие преобразования, избегая при этом
операторов преобразования типов или не объявленных explicit конструк-
торов с одним аргументом;
□ какие операторы и функции имеют смысл для нового типа? От ответа на этот
вопрос зависит, какие функции вы определите для интерфейса класса;
□ какие стандартные операторы и функции явно следует сделать недоступ-
ными? Их необходимо объявить закрытыми;
□ кто должен иметь доступ к членам создаваемого типа? Этот вопрос помо-
жет вам определить, какие члены должны быть открытыми, какие защищен-
ными, а какие - закрытыми. Это также поможет вам установить, какие клас-
сы и/или функции должны быть дружественными, а также есть ли смысл
вкладывать один класс в другой;
□ насколько новый тип будет общим? Возможно, вы в действительности соз-
даете не новый тип, а определяете целое семейство типов. Если это действи-
тельно так, вам нужно определить не новый класс, а шаблон класса.
Ответить на перечисленные вопросы нелегко, и поэтому создание эффектив-
ных классов - далеко не простое дело. Однако если взяться за него должным об-
разом, определяемые пользователем классы C++ дают типы, которые почти не-
отличимы от встроенных, и это оправдывает все затраченные усилия.
Обсуждению деталей каждого из приведенных вопросов можно было бы по-
святить отдельную книгу, поэтому нижеизложенные руководящие принципы не
претендуют на всесторонность. Тем не менее они высвечивают наиболее важные
вопросы проектирования, предостерегают против самых распространенных оши-
бок и предлагают решение проблем, часто встречающихся при проектировании
классов. Многие из советов также применимы и для функций, не являющихся чле-
нами классов, так что в этом разделе помимо всего прочего рассматривается и про-
ектирование, и объявления глобальных функций, и объявления функций в про-
странствах имен.
Правило 18. Стремитесь к таким
интерфейсам классов,
которые будут полными и минимальными
Пользовательский интерфейс класса - это интерфейс, доступный программи-
стам, использующим данный класс. Обычно в таком интерфейсе присутствуют
только функции, поскольку наличие в нем элементов данных имеет целый ряд не-
достатков (см. правило 20).
Попытки понять, какие функции должны присутствовать в пользовательском
интерфейсе, могут просто свести с ума. Вы разрываетесь между двумя совершенно
Правило 18
81
противоположными направлениями. С одной стороны, вам бы хотелось создать
классы, которые будут просты для понимания, использования и реализации.
Обычно это означает, что требуется достаточно небольшое количество функций,
каждая из которых будет выполнять весьма узкую задачу. С другой стороны, хо-
телось бы, чтобы класс был разносторонним и удобным в применении, что обыч-
Но требует добавления функций для поддержки часто выполняемых задач. Как
определить, какие функции должны войти в класс, а какие нет?
Попробуйте подойти к этому так: поставьте перед собой задачу построения
класса, который был бы полным и минимальным.
Полный интерфейс дает возможность пользователю в некоторых пределах де-
лать все, что ему хочется. То есть для любой разумной задачи, которую пользова-
тель желает решить, должен существовать реальный способ достижения постав-
ленной цели, хотя он может и не быть столь удобным, как хотелось бы. С другой
стороны, минимальный интерфейс - это интерфейс с минимально возможным ко-
личеством функций, таким, что никакие две функции интерфейса не обладают пе-
рекрывающейся функциональностью. Если вы предлагаете полный минимальный
интерфейс, пользователи могут делать все, что им заблагорассудится, но интер-
фейс класса не должен быть более сложным, чем это в принципе необходимо.
Стремление к полному интерфейсу кажется достаточно разумным, но зачем
нужна минимальность? Почему просто не дать пользователю все, что он хочет,
совершенствуя функциональность до тех пор, пока все не будут удовлетворены?
Помимо морального аспекта (насколько это правильно - баловать пользовате-
лей?) у интерфейса класса, перегруженного функциями, существуют и технические
недостатки. Во-первых, чем больше функций в интерфейсе, тем труднее его понять
потенциальному клиенту, и, соответственно, с тем большей неохотой он будет обу-
чаться использованию такого интерфейса. Класс с десятью функциями кажется по-
сильным большинству пользователей; класс со ста функциями у большинства про-
граммистов, как правило, вызывает желание никогда больше его не видеть. Расширяя
Функциональность так, чтобы сделать класс как можно более привлекательным, вы
в действительности можете добиться того, что отобьете охоту к его изучению.
Перегруженный интерфейс может даже привести к путанице. Предположим, что
вы создаете класс, который поддерживает мышление для системы искусственного
интеллекта. Одна из функций-членов называется think (думать), но позднее вы
обнаруживаете, что некоторым хотелось бы иметь функцию, называемую ponder
(раздумывать), другие же предпочитают название ruminate (размышлять). Пыта-
ясь понравиться всем, вы предлагаете все три функции, хотя они делают одно и то же.
Подумайте, в каком состоянии оказывается пользователь, столкнувшийся с тремя
Различными функциями, которые должны играть одну роль. Он наверняка задума-
ется, на самом ли деле это так? Нет ли между тремя функциями некоторых малоза-
метных различий, возможно в эффективности или надежности? Если нет, то почему
их три? Вместо того чтобы оценить вашу гибкость, потенциальный пользователь
будет теряться в догадках, о чем вы думали (или раздумывали, или размышляли),
Поступая таким образом.
Второй недостаток перегруженных интерфейсов классов — трудности в эксплуа-
тации. Действительно, класс со многими функциями намного труднее поддерживать
к ________
Классы и функции
и совершенствовать, чем класс, содержащий лишь несколько функций. Намного
сложнее избегать дублирования кода (и сопутствующего дублирования ошибок),
равно как и оставаться последовательным в интерфейсе. Такой класс также го-
раздо труднее документировать.
И наконец, перегруженные определения классов приводят к использованию
длинных файлов заголовков. Поскольку файлы заголовков обычно необходимо
читать всякий раз при компиляции программы (см. правило 34), определения
классов, раздутые сверх меры, вызывают значительное увеличение времени ком-
пиляции.
Короче говоря, непродуманное добавление функций к интерфейсу обходится
дорого, поэтому следует хорошо подумать, оправдано ли дополнительное удоб-
ство, обеспечиваемое несколькими функциями (если интерфейс полон, новые
функции добавляются только ради удобства).
Нередко создание более чем минимального набора функций вполне оправданно.
Если часто выполняемая задача может быть реализована намного более эффективно
как функция-член, это вполне достойный повод для добавлений к интерфейсу. Если
добавление функции-члена делает класс значительно более удобным в использова-
нии, этого может быть достаточно для включения в класс. И, если добавление функ-
ции может помочь предотвратить ошибки пользователя, это также должно служить
весомым аргументом в пользу ее включения в интерфейс класса.
Давайте рассмотрим конкретный пример: шаблон класса, реализующий мас-
сив с задаваемой пользователем верхней и нижней границей и предлагающий фа-
культативную проверку на выход за границы индекса. Первые шаги к созданию
такого шаблона массива выглядят так:
template<class Т>
class Array {
public:
enum BoundsCheckingStatus {NO_CHECK_BOUNDS - 0, CHECKJBOUNDS = 1} ;
Array(int lowBound, int highBound,
BoundsCheckingStatus check = NO_CHECK_BOUNDS);
Array (const Arrays rhs) ;
-Array();
Arrays operator=(const Arrays rhs) ;
private:
int IBound, hBound; // Нижняя и верхняя границы.
vector<T> data; // Содержимое массива; информацию
// о vector см. в правиле 49.
BoundsCheckingStatus checkingBounds;
};
Функции-члены, объявленные до сих пор, практически не вызывают вопро-
сов. У вас имеется конструктор, позволяющий пользователю задавать границы
массива, конструктор копирования, оператор присваивания и деструктор. В дан-
ном случае вы объявляете функции как невиртуальные, подразумевая, что класс
нс будет использоваться в качестве базового (см. правило 14).
В действительности объявление оператора присваивания - гораздо менее оче-
видное решение, чем это может показаться. В конце концов, встроенные массивы
Правило 18
C++ не допускают присваивания, поэтому, вероятно, вам также захочется запре-
тить эту возможность (см. правило 27). С другой стороны, подобный массиву шаб-
лон vector (из стандартной библиотеки, см. правило 49) допускает операцию
присваивания. В этом примере мы будем действовать по образцу vector,
и это решение, как вы увидите в дальнейшем, окажет влияние на другие составля-
ющие интерфейса класса.
Вид этого интерфейса заставит поежиться приверженцев старого С. Где под-
держка для объявления массива определенного размера? Для этого достаточно
было бы просто добавить другой конструктор,
Array(int size, BoundsCheckingStatus check = NO_CHECK_BOUNDS);
но это уже не минимальный интерфейс, поскольку для достижения той же цели
могут быть применены конструкторы, использующие верхнюю и нижнюю грани-
цу. Однако было бы неплохо ублажить этих «старичков», возможно под девизом
совместимости с базовым языком.
Какие еще функции могут нам понадобиться? Несомненно, что индексация
массива - тоже часть полного интерфейса:
// Вернуть элемент для чтения/записи.
Т& operator[](int index);
// Вернуть элемент только для чтения.
const Т& operatorf](int index) const;
Объявляя одну и ту же функцию дважды, один раз с const и один раз без
const, вы обеспечиваете поддержку как const-, так и не с on st-объектов Array.
Как объясняется в правиле 21, разница в типах имеет большое значение.
Теперь шаблон Array поддерживает создание, удаление и передачу по значе-
нию, присваивание и индексацию, и может показаться, что процесс создания ин-
терфейса завершен. Но приглядитесь внимательнее. Предположим, пользователь
хочет пройтись по массиву целых чисел, распечатывая каждый элемент, напри-
мер так:
Array<int> а(10, 20) ; // Границы а равны 10 и 20.
for (int i = нижняя граница a; i <- верхняя граница a; ++i)
cout « "a[" « i « " ] = " « a[i] « " \n" ;
Как пользователь может получить границы для а? Ответ зависит от того, что
наблюдается в ходе присваивания объектов Array, то есть от того, что происхо-
дит внутри Array: : operators В частности, если присваивание может менять
границы объекта Array, необходимо создать функцию-член, возвращающую те-
кущие границы, поскольку априори пользователь не имеет средств определения
границ в данном участке программы. В вышеприведенном примере, если с момен-
та создания массива и до его использования в цикле а присваивались новые зна-
чения, пользователь не может определить текущие границы а.
С другой стороны, если границы объекта Array в результате присваивания
Меняться не могут, то они фиксируются в момент определения и их отслеживание
Пользователем вполне возможно (хотя и трудоемко). И в этом случае, однако, было
84
'laHi
Классы и функции
бы удобно использовать функции, возвращающие текущие границы, - функции,
которые в действительности не являются частью минимального интерфейса.
Если исходить из того, что присваивание может модифицировать границы объ-
екта, функции, возвращающие границы, могли бы выглядеть следующим образом:
int lowBound() const;
int highBoundO const;
Поскольку эти функции не изменяют объект, для которого они вызваны, и так
как вы, очевидно, предпочитаете использовать const всегда, когда это возможно
(см. правило 21), обе эти функции-члена объявлены с модификатором const.
С помощью данных функций приведенный выше цикл может быть записан сле-
дующим образом:
for (int i = а. lowBound О ; i <= a.highBoundf); ++i)
cout « "a[" « i « "] = " « a[i] « '\n';
Нет необходимости говорить, что для того, чтобы такой цикл работал и в от-
ношении объектов типа Т, для указанного типа должна быть определена функция
operator<<. (Это не совсем верно. В действительности operator<< должен
быть определен либо для т, либо для некоторого другого типа, к которому Т мо-
жет быть неявно преобразован. Но я думаю, что суть идеи вы уловили.)
Некоторые разработчики будут утверждать, что класс Array должен также
содержать функцию, возвращающую количество элементов массива объектов.
Количество элементов - это просто highBound () - lowBound () +1, поэтому та-
кая функция не является действительно необходимой, однако было бы неплохо
добавить ее ввиду распространенности ошибки на единицу при подсчете.
Другие функции, которые могут оказаться полезными для рассматриваемого
класса, включают в себя ввод/вывод, а также различные операторы сравнения
(например, <, > и т.п.). Ни одна из этих функций не является при этом частью
минимального интерфейса, поскольку все они могут быть реализованы в рамках
циклов, содержащих вызовы operator [ ].
Наконец, рассмотрим функции operator«, operator», операторы срав-
нения и им подобные. В правиле 19 обсуждается причина, по которой их часто
реализуют как дружественные функции, а не как члены класса. При этом не забы-
вайте, что с практической точки зрения дружественные функции являются час-
тью интерфейса класса. Это означает, что они вносят свой вклад в полноту интер-
фейса класса и обеспечивают его минимальность.
Правило 19. Проводите различие
между функциями-членами,
функциями, не являющимися членами класса,
и дружественными функциями
Самое существенное различие между функциями-членами и не членами класса
заключается в том, что функции-члены могут быть виртуальными, а не входящие
Правило 19
'I
85
Б класс - пет. Б результате, если вам нужна функция, для которой будет осуществ-
иться динамическое связывание (см. правило 38), то необходимо использовать
виртуальную функцию, которая должна быть членом некоторого класса Если в
виртуальной функции необходимости нет, все оказывается несколько сложнее.
Рассмотрим класс, представляющий рациональные числа:
class Rational (
public:
Rational (int numerator = 0, int denominator = 1) ;
int numerator () const;
int denominator() const;
private:
};
В таком виде, в каком он представлен сейчас, это достаточно бесполезный класс
(или, если использовать терминологию правила 18, его интерфейс несомненно ми-
нимален, но далек от полноты). Вы знаете, что было бы желательно поддерживать
арифметические операции, такие как добавление, вычитание, умножение и т.п., но
не уверены, следует ли реализовывать их посредством функций-членов, глобаль-
ных или, может быть, дружественных функций.
В сомнительных случаях придерживайтесь объектно-ориентированного под-
хода. Скажем, вы знаете, что умножение рациональных чисел имеет отношение
к классу Rational, поэтому попытайтесь связать операцию с классом, делая ее
функцией-членом:
class Rational {
public:
const Rational operator*(const Rational& rhs) const;
};
(Если вы не знаете, почему эта функция объявляется как возвращающая ре-
зультат по значению с модификатором const и требующая ссылку на const
в качестве аргумента, обратитесь к правилам 21-23.)
Теперь вы с легкостью можете перемножать рациональные числа:
Rational oneEighth(l, 8);
Rational oneHalfd, 2);
Rational result = oneHalf * oneEighth; // Правильно.
result - result * oneEighth; // Правильно.
Но на этом вы не останавливаетесь. Вам бы хотелось поддерживать сме-
шанные операции так, чтобы Rational можно было умножать на int. Одна-
ко при попытке выполнить такую операцию обнаруживается, что она действен-
на только наполовину:
result = oneHalf * 2; // Правильно.
result = 2 * oneHalf; // Ошибка!
86
; 'г Классы и функции
Это плохое предзнаменование. Умножение должно быть коммутативным,
не правда ли?
Источник проблемы становится очевидным, когда вы переписываете послед-
ние два примера в эквивалентной функциональной форме:
result - oneHalf.operator*(2) ; // Правильно.
result = 2.operator*(oneHalf) ; // Ошибка!
Объект oneHalf является примером класса, содержащего operator *, и ком-
пилятор вызывает эту функцию. Однако целое число 2 не относится к какому-
либо классу, и, следовательно, не имеет функции-члена operator*. Ваш ком-
пилятор будет искать функцию operator*, не являющуюся членом класса (то
есть глобальную функцию или функцию в видимом пространстве имен), кото-
рую можно было бы вызвать следующим образом:
result = operator*(2, oneHalf); // Ошибка!
но не являющаяся членом функция operator*, принимающая аргументы int
и Rational, отсутствует, поэтому поиск заканчивается безрезультатно.
Снова взглянем на успешный вызов функции. Вы видите, что второй аргу-
мент - целое число 2, a Rational: : operator* требует в качестве аргумента
объект Rational. Что здесь происходит? Почему в одном случае все в поряд-
ке, а в другом - нет?
Все дело в неявном преобразовании типов. Ваш компилятор знает, что вы пере-
даете int, а функция требует Rational, но он также знает, что может создать не-
обходимый объект Rational, вызывая конструктор Rational с передаваемым
вами целым числом, что и делает. Другими словами, компилятор рассматривает
вызов так, как будто бы он был написан приблизительно следующим образом:
const Rational temp(2) ; // Создаем временный объект класса
// Rational из числа 2.
result - oneHalf * temp; // То же, что и oneHalf .operator* (temp) ;
Конечно, компилятор делает это только тогда, когда нет явных конструкторов
с explicit, поскольку конструктор, объявленный explicit, не может быть ис-
пользован для неявных преобразований, что, собственно, и означает ключевое
слово explicit. Если бы Rational был определен следующим образом:
class Rational {
public:
explicit Rational (int numerator =0, // Этот конструктор теперь
int denominator = 1) ; // объявлен как explicit.
const Rational operator*(const RationalS rhs) const;
};
ни одно из нижеследующих предложений не было бы скомпилировано:
result = oneHalf * 2;
result = 2 * oneHalf;
/ / Ошибка!
// Ошибка!
Правило 19
87
Это вряд ли можно было бы квалифицировать как поддержку арифметики сме-
шанных типов, но, по крайней мере, поведение компилятора считалось бы последо-
вательным.
Рассмотренный нами класс Rational спроектирован так, чтобы допускать не-
явные преобразования из встроенных типов в Rational, - причина, по которой
конструктор для Rational не объявлен с explicit. В этом случае компилятор
будет выполнять неявное преобразование, необходимое для того, чтобы компили-
ровалась первая строчка с result. В действительности ваш компилятор будет при
необходимости выполнять неявное преобразование типа для каждого аргумента
при каждом вызове функции. Но он делает это только для параметров из списка
аргументов, а не для объекта, вызывающего функцию, то есть объекта, соответству-
ющего *this внутри функции-члена. Вот почему этот вызов станет работать:
result = oneHalf.operator*(2); // Преобразует int в Rational.
а этот - нет:
result = 2.operator*(oneHalf); // Не преобразует int в Rational.
В первом случае параметр дан в объявлении функции, а во втором - нет.
Несмотря на это не исключено, что вам все равно понадобится поддерживать сме-
шанную арифметику, и способ, которым можно воспользоваться теперь, пожалуй, до-
статочно очевиден: сделайте operator* функцией, не являющейся членом класса,
позволяя компилятору выполнять неявное преобразование типов всех аргументов:
class Rational {
... //Не содержит operator*.
};
// Это объявление либо глобально, либо в пространстве имен.
const Rational operator*(const Rationale Ihs, const Rational& rhs)
{
return Rational(Ihs.numerator() * rhs.numerator(),
Ihs.denominator() * rhs.denominator());
}
Rational oneFourth(l, 4) ;
Rational result;
result = oneFourth * 2; // Хорошо.
result = 2 * oneFourth; // Ура, заработало!
Можно считать, что история подошла к «счастливому концу», но остается не-
которое беспокойство. Следует ли делать operator* дружественным классу
Rational?
В данном случае ответ будет отрицательный, поскольку operator* может
быть полностью реализован в рамках открытого интерфейса класса. Предложен-
ный код демонстрирует один из способов достижения этого результата. Если у вас
Имеется возможность избежать использования дружественных функций, ею все-
гда нужно пользоваться, потому что здесь, как бывает и в реальной жизни, дру-
зья порой приносят больше вреда, чем пользы.
Однако часто функции, не являющиеся членами класса, но концептуально
Входящие в его интерфейс, нуждаются в доступе к неоткрытым членам класса.
88
Классы и функции
Для примера давайте вновь обратимся к «коньку» этой книги - классу String.
Если вы попытаетесь перегрузить operator>> и operator<< для чтения и за-
писи объектов String, то быстро обнаружите, что они не должны быть членами
класса. Иначе вам пришлось бы при вызове этих функций использовать слева от
них объект String:
// Класс, некорректно объявляющий operator»
//и operator« функциями-членами.
class String {
public:
String(const char *value);
istreamS operator»(istreams input);
ostreamS operator« (ostreamS output) ;
private:
char Mata;
};
String s;
s >>'cin; // Допустимо, но противоречит принятым соглашениям.
s « cout; // Аналогично.
Это сбило бы с толку кого угодно. Итак, данные функции не должны быть
членами класса. Заметьте, что рассматриваемый случай отличается от обсуж-
давшегося нами выше. Здесь целью является естественный синтаксис, а ранее
мы были озабочены неявным преобразованием типов.
Если бы вы стали проектировать эти функции, то изобрели бы что-нибудь
в таком роде:
istreamS operator»(istreamS input, Strings string)
{
delete [] string.data;
читаем из входного потока в некоторую область памяти
и устанавливаем на нее указатель string.data
return input;
}
ostreamS operator« (ostreamS output, const Strings string)
{
return output « string.data;
}
Обратите внимание на то, что обеим функциям необходим доступ к полю
data класса String, - полю, являющемуся закрытым. Однако вы уже знаете, что
эти функции не должны быть членами класса. У вас нет выбора: функция, не яв-
ляющаяся членом класса, но имеющая доступ к его закрытым членам, должна
быть дружественной функцией этого класса.
Основные идеи этого правила обобщены ниже (будем считать, что f - функ-
ция, которую вы пытаетесь объявить надлежащим образом, а С - класс, с которым
она концептуально связана):
Правило 20
89
□ виртуальные функции должны быть членами класса. Если f должна быть
виртуальной, ее следует сделать функцией-членом С;
□ operator» и operator<< никогда не являются членами класса. Если f —
это operator» или operatorcc, объявляйте f вне класса. Если, кроме
того, f необходим доступ к закрытым членам С, объявляйте f дружествен-
ной для С;
□ только функции, не являющиеся членами класса, производят преобразование
типа для. аргумента слева. Если f необходимо преобразование типа для ар-
гумента слева, определите f вне класса. Когда, кроме того, f необходим до-
ступ к закрытым членам С, сделайте f дружественной для С;
□ в остальных случаях функции должны быть членами класса. Если ни одна из
вышеприведенных ситуаций не имеет места, сделайте f функцией-членом С.
Правило 20. Избегайте данных
в открытом интерфейсе
Во-первых, давайте рассмотрим вопрос об открытом интерфейсе с точки зре-
ния последовательности нашего подхода. Если в открытом интерфейсе имеются
только функции, пользователям вашего класса не придется напрягаться, стараясь
вспомнить, следует ли при доступе к члену вашего класса использовать скобки.
Они просто будут их использовать, поскольку все члены интерфейса - функции.
Это избавит вас от множества проблем.
Вас не убедили аргументы в пользу последовательности? А тот факт, что ис-
пользование функций дает вам намного более точный контроль над доступнос-
тью членов класса? Если вы делаете член класса открытым, в этом случае каждый
имеет к нему доступ чтения-записи; если же вы используете его для получения
и установки значений функции, то можете запретить доступ и открывать его толь-
ко для чтения или для чтения-записи. При желании вы можете даже реализовать
Доступ только для записи:
class AccessLevels {
public:
int getReadOnly() const{ return readonly; }
void setReadWrite(int value) { readWrite = value; }
int getReadWrite() const { return readWrite; }
void setWriteOnly(int value) { writeOnly = value; }
Private:
int noAccess; // Доступа к этому целому нет.
int readonly; // А здесь доступ только для чтения.
int readWrite; // Доступ для чтения-записи.
int writeOnly; // Доступ только для записи.
};
Все еще сомневаетесь? Тогда настало время привести самый весомый аргу-
мент — функциональную абстракцию. Если вы реализуете доступ посредством
Функции, то позднее можете заменить член класса вычислением, и никто из
Пользователей вашего класса об этом не догадается.
Ml Классы и функции
Например, предположим, что вы пишете приложение, в котором некоторое
автоматическое оборудование отслеживает скорость проходящих мимо машин.
Когда мимо проезжает машина, тут же вычисляется ее скорость, а затем эта вели-
чина добавляется к данным о скорости, собранным до этого момента:
class SpeedDataCollection {
public-.
void addValue(int speed) ; // Добавляем новые данные.
double averageSoFar() const; // Возвращаем среднюю скорость.
};
Теперь давайте рассмотрим возможные варианты реализации функции-члена
averageSoFar. Один из способов - добавить член класса, который будет содер-
жать среднюю величину всех собранных данных о скорости. При вызове функции
averageSoFar она просто вернет значение этого члена класса. Другой подход -
вычислять значение каждый раз при вызове функции, что можно сделать, распо-
лагая значениями всех элементов множества.
Первый подход - получение текущего среднего - увеличивает каждый объ-
ект SpeedDataCollection, поскольку вы должны выделить место для эле-
мента данных, хранящего текущую среднюю величину. Зато можно очень эф-
фективно реализовать averageSoFar: это просто встраиваемая функция (см.
правило 33), которая возвращает значение члена класса. И наоборот, вычисле-
ние среднего при каждом вызове замедляет исполнение averageSoFar, но
каждый объект SpeedDataCollection будет меньше.
Кто возьмется решить, что лучше? Что касается машин с ограниченным объ-
емом памяти, если средние величины необходимы лишь изредка, то их вычисле-
ние при каждом вызове - наилучшее решение. В приложении, где средние значе-
ния нужны достаточно часто, где важна скорость, а не память, предпочтительно
сохранение текущего среднего. Существенно то, что посредством доступа к сред-
нему при помощи функции-члена вы можете использовать любую реализацию -
а значит, получаете простор для действий, которого лишитесь, если решите вклю-
чить текущее среднее в открытый интерфейс.
Подведем итог: включая данные в открытый интерфейс класса, вы сами на-
прашиваетесь на неприятности, поэтому обезопасьте себя, пряча все элементы
данных за стеной функциональной абстракции. Сделайте это, и получите в награ-
ду последовательный подход и регулируемое управление доступом!
Правило 21. Везде, где только можно,
используйте const
Замечательное свойство модификатора const состоит в том, что он наклады-
вает определенное семантическое ограничение: данный объект не должен моди-
фицироваться, - и компилятор будет проводить это ограничение в жизнь, const
позволяет указать компилятору и программистам, что определенная величина
должна оставаться инвариантной. Во всех подобных случаях вы должны обозна-
чить это явным образом, призывая себе на помощь компилятор и гарантируя, что
ограничение не будет нарушено.
Правило 21 11
Ключевое слово const удивительно разносторонне. Вне классов вы може-
е использовать его для глобальных констант или констант пространств имен
(сМ- правила 1 и 47), а также для статических объектов (внутри файла или бло-
ка)- Внутри классов допустимо применять его для статических либо нестати-
ческих данных (см. также правило 12).
Для указателей вы можете объявить, является ли сам указатель const, одно-
временно являются ли данные, на которые он указывает, const, или и то, и другое:
char *р = "Hello"; // Неконстантный указатель, неконстантные данные1,
const char *р = "Hello"; // Неконстантный указатель, константные данные,
char * const р = "Hello"; // Константный указатель, неконстантные данные,
const char * const р = "Hello"; // Константный указатель, константные данные.
Этот синтаксис не так страшен, как кажется. Вы проводите в воображении
вертикальную линию через звездочку объявления первого указателя, и если сло-
во const располагается слева от линии, то, на что указывается, - константа; если
const располагается справа от линии, сам указатель - константа; если const распо-
лагается по обе стороны, константа - и то, и другое.
Когда то, на что указывается, - константа, некоторые программисты ставят
const перед идентификатором типа. Другие ставят const после идентификато-
ра типа, но до звездочки. В результате следующие функции требуют аргументов
одинакового типа:
class Widget {... };
void fl(const Widget *pw); // fl требует указателя
//на константный объект Widget.
void f2(Widget const *pw); // Аналогично ведет себя и f2 .
Поскольку в реальном коде встречаются обе формы, следует привыкать к ним
обеим.
Некоторые из наиболее важных применений const связаны с их использова-
нием в объявлениях функций. Внутри объявления функции const может отно-
ситься к типу, возвращаемому функцией, к индивидуальным аргументам и (для
функций-членов) к функциям в целом.
Делая значение, возвращаемое функцией, константным, можно, не поступа-
ясь безопасностью или эффективностью, уменьшить вероятность ошибки пользо-
вателя. В действительности, как показано в правиле 29, использование const
в качестве возвращаемой величины увеличивает безопасность и эффективность
Функций в тех случаях, когда добиться этого другим способом невозможно.
Например, рассмотрим объявление функции operator* для рациональных
Чисел, введенное в правиле 19:
const Rational operator*(const Rational& Ihs, const Rationale rhs) ;
Согласно стандарту C++ тип "Hello” - const char [ ], который практически всегда эквивален-
тен const char*. Поэтому при инициализации переменных char* строками, подобными "Hello", мы
Могли бы ожидать ошибки нарушения условия const. Эта практика, однако настолько распростране-
на в С, что стандарт вводит специальное допущение для подобных инициализаций. Тем не менее вам
Следует их избегать, поскольку их использование не одобряется.
92 1
nil
Классы и функции
Многие программисты удивятся, впервые увиден такое объявление. Почему
результат функции operator* является объектом const? Потому, что в против-
ном случае пользователь получил бы возможность делать вещи, которые иначе как
надругательством над здравым смыслом не назовешь:
Rational а, Ь, с;
(а * b) = с; II Присваивание произведению а*Ь!
Я не знаю, с какой стати программисту пришло бы в голову присваивать зна-
чение произведению двух чисел, но могу сказать точно, что для а, b и с встроенных
типов это было бы однозначно некорректной операцией. Один из критериев хоро-
шо определенного пользовательского типа - отсутствие необоснованной несовмес-
тимости с поведением встроенных типов, а возможность присваивания значения
произведению двух чисел мне кажется совершенно необоснованной. Объявление
возвращаемого типа функции operator* как const препятствует этому, поэтому
оно вполне целесообразно.
В отношении аргументов с модификатором const невозможно прибавить
ничего нового; они просто аналогичны локальным объектам с const. Функции-
члены с const - совсем другое дело.
Цель функций-членов с const безусловно состоит в том, чтобы определить,
какие функции-члены могжно вызвать для константных объектов const. Многие
упускают из виду, что функции, отличающиеся только объявлением const, могут
быть перегружены. Это, однако, очень важное свойство C++. Давайте еще раз рас-
смотрим класс String:
class String {
public:
// operator[] для неконстантных объектов.
char& operator[](int position)
{ return data [posit ion]; }
// operator!] для константных объектов.
const char& operator!] (int position) const
{ return data[position] ; }
private:
char *data;
};
String si = "Hello";
cout « sl[0]; // Вызывает неконстантный String::operator!].
const String s2 = "World" ;
cout <x s2[0]; // Вызывает константный String.-:operator!] .
Перегружая operator [ ] и создавая различные версии с разными возвраща-
емыми типами, вы можете по-разному обрабатывать константные и неконстант-
ные объекты String:
String s = "Hello"; // Неконстантный объект String.
cout « s[0] ; // Правильно - считываем неконстантный объект String-
s[0] = 'х'; //Правильно - записываем неконстантный объект String-
93
Правило 21
const String cs = "World" ; // Константный объект String.
cout « cs[0] ; // Правильно - считываем константный объект String.
cs[0] = 'х'; // Ошибка! Запись в константный объект String.
Заметьте, между прочим, что ошибка здесь генерируется только значением, воз-
вращаемым функцией operator [ ]; сами вызовы operator [ ] вполне коррект-
ны. Ошибки возникают из-за попытки присвоить значение ссылке на констант-
ный символ const char&, то есть возвращаемому типу const-версии функции
operator[]const.
Обратите также внимание на то, что тип, возвращаемый operator [ ] без const,
должен быть ссылкой на char - просто char не работает. Если бы operator [ ] дей-
ствительно возвращал просто char, не компилировались бы предложения, подобные
следующему:
S СО] = 'X';
Это объясняется тем, что возвращаемое функцией значение встроенного типа
модифицировать некорректно. И даже если бы это было допустимо, тот факт, что
C++ возвращает объекты по величине (см. правило 22), означал бы следующее:
модифицировалась копия s. data [0 ], а не само значение s. data [0 ].
Давайте немного передохнем и пофилософствуем. Какое значение на деле име-
ет то, что функция-член определена с const? Существуют два широко распро-
страненных понятия: побитовая константность и концептуальная константность.
Сторонники побитовой константности полагают, что функция-член констант-
на, только если она не модифицирует элементы данных объекта (за исключением
статических), то есть никакие биты внутри объекта. Определение побитовой кон-
стантности хорошо тем, что ее нарушение легко обнаружить: компилятор просто
ищет присваивания членам класса. В действительности побитовая константность -
это константность, определенная в C++: функции-члены с const не могут моди-
фицировать члены класса вызвавшего их объекта.
К сожалению, многие функции-члены, которые ведут себя далеко не кон-
стантно, проходят побитовый тест. В частности, функции-члены, модифициру-
ющие то, на что указывает указатель, часто не ведут себя константно. Но если
объекту принадлежит только указатель, функция побитово константна, и ком-
пилятор не выдаст сообщения об ошибке. Может получиться результат, проти-
воречащий здравому смыслу:
class String {
public:
/ / Конструктор устанавливает указатель data
//на копию тех данных, на которые указывает value.
String(const char *value);
operator char *() const { return data; }
private:
char *data;
};
const String s = "Hello"; // Объявление константного объекта.
Классы и функции
char *nasty = s;
*nasty = 'M' ;
cout << S;
// Вызов operator char* () const.
// Модифицирует s.data[0].
// Выводит "Mello".
Несомненно, есть нечто некорректное в том, что вы создаете константный объ-
ект с определенным значением, вызываете для него только функции-члены с const
и тем не менее изменяете его значение! (Более детальное обсуждение этого при-
мера см. в правиле 29.)
Данная проблема приводит к понятию концептуальной константности. Адепты
этой философии утверждают, что функции-члены с const могут модифицировать
некоторые биты вызвавшего их объекта, но только так, чтобы пользователь этого
не обнаружил. Например, ваш класс String мог бы при каждом запросе кэширо-
вать размер объекта:
class String {
public:
//Конструктор, устанавливающий указатель data
//на то, на что указывает value.
String(const char *value): lengthlsValid(false) {... }
size_t length() const;
private:
char *data;
size_t dataLength; // Последнее значение, полученное для длины строки.
bool lengthlsValid; // Является ли текущее значение длины правильным?
};
size_t String: .-length() const
{
if ((lengthlsValid) {
dataLength = strlen(data); // Ошибка!
lengthlsValid = true; // Ошибка!
}
return dataLength;
}
Приведенная реализация length, конечно, не побитово константна; как
dataLength, так и lengthlsValid могут быть модифицированы. Тем не менее,
на первый взгляд, это вполне допустимо для объектов const String. Как вы
обнаружите, компиляторы с данным утверждением не согласятся; они настаива-
ют на побитовой константности. Что делать?
Решение просто: воспользуйтесь связанным с const средством, позволяющим
обойти данную проблему, - Комитет по стандартизации C++ создал его именно
для таких случаев. Этот обходной маневр воплощен в форме ключевого слова
mutable. Будучи примененным к нестатическим членам класса, mutable осво-
бождает эти члены от ограничения побитовой константности:
class String {
public:
... / / То же, что и выше.
private:
Правило 21
95
char *data;
mutable size_t dataLength;
mutable bool lengthlsValid;
size_t String::length() const
{
if (!lengthlsValid) {
dataLength = strlen(data);
lengthlsValid = true;
}
return dataLength;
// Эти члены данных объявлены
// теперь с mutable; их можно
// модифицировать где угодно, даже
// внутри константной функции-члена.
// Теперь нормально.
// Тоже нормально.
Использование mutable - это замечательное решение проблемы, когда побито-
вая константность вас не вполне устраивает, но оно было добавлено к C++ на срав-
нительно позднем этапе стандартизации, поэтому ваш компилятор может его пока не
поддерживать. Если это так, вам придется заглянуть в «темные закоулки» C++, где
константность можно отбросить, используя преобразование типа.
Внутри класса С указатель this ведет себя так, как будто он был объявлен
следующим образом:
С * const this;
const С * const this;
// Для неконстантных функций-членов.
// Для константных функций-членов.
При этом все, что необходимо, чтобы сделать проблемную версию String:: length
(то есть ту, которую вы могли бы поправить с помощью mutable, если бы ваш
компилятор поддерживал это ключевое слово) применимой как для константных,
так и для неконстантных объектов, - изменить тип this с const С * на С * const.
Вам не удастся сделать это непосредственно, но вы можете добиться требуемого
результата, инициализируя локальный указатель так, чтобы он указывал на тот
же объект, что и this. Тогда вы получаете доступ к членам, которые хотите моди-
фицировать, через локальный указатель:
size_t String::length() const
(
// Создаем локальную версию this, не являющуюся
/ / указателем на константный объект.
String * const localThis = const_cast<String * const>(this);
if (!lengthlsValid) {
localThis->dataLength = strlen(data);
localThis->length!sValid = true;
}
return dataLength;
Результат, конечно, непригляден, но иногда программисту приходится посту-
пать подобным образом.
Никто, впрочем, не гарантирует, что это будет работать, и иногда старый трюк
с отбрасыванием константности не удается. В частности, если объект, на который
96
L- ..
Классы и функции
указывает this, действительно const, то есть был объявлен как const при опреде-
лении, результат отбрасывания константности не определен. Если в одной из функ-
ций-членов вы хотите отбросить константность, вам лучше заранее убедиться, что
объект, для которого вы применяете приведение типа, не был определен с const.
Существует еще один случай, когда отбрасывание константности может быть
одновременно и безопасным, и полезным. Это случай, когда у вас имеется объект
const, который вы хотите передать функции, принимающей аргумент без const,
и знаете, что внутри функции аргумент не будет модифицирован. Второе условие
очень важно, поскольку безопасно отбрасывать лишь константность объекта, ко-
торый будет только читаться, но не изменяться.
Например, некоторые библиотеки, как известно, неправильно объявляют функ-
цию strlen следующим образом:
size_t strlen(char *s) ;
Несомненно, strlen не собирается модифицировать то, на что указывает s, -
по крайней мере тот strlen, на котором я учился программированию. Из-за этого
определения, однако, недопустимо вызывать strlen для указателей типа const
char *. Чтобы избежать проблемы, вполне возможно отбросить константность
указателя при его передаче strlen:
const char *klingonGreeting = "nuqneH"; II "nuqneH" - это "привет"
// по-клингонски.
size_t length - strlen(const_cast<char*>(klingonGreeting));
He будьте, однако, чрезмерно самоуверенны. Вас ждет удача, только если вы-
зываемая функция, в данном случае strlen, действительно не пытается модифи-
цировать данные, на которые указывает аргумент.
Правило 22. Предпочитайте передачу
параметров по ссылке передаче по значению
В С все передается по значению, и C++ уважает эту традицию, по умолчанию
принимая соглашение о передаче по значению. Если не указано обратное, аргу-
менты функции инициализируются копиями реальных аргументов, а вызов функ-
ции возвращает копию возвращаемой функцией величины.
Как я указывал во введении к данной книге, что именно означает передача
объекта по значению, определяется конструктором копирования класса, к кото-
рому принадлежит объект. В связи с этим передача по значению может стать чрез-
вычайно проблематичной. Рассмотрим, например, следующую (достаточно искус-
ственную) иерархию классов:
class Person {
public:
Person О ; // Параметры для простоты опускаем.
-Person();
private:
string name, address;
Правило 22
97
};
class Student: public Person {
public:
Student () ; // Параметры для простоты опускаем.
-Student();
private:
string schoolName, schoolAddress;
};
Теперь рассмотрим простую функцию returnstudent, которая принимает
аргумент типа Student (по значению) и сразу возвращает его (опять-таки по зна-
чению), а также ее вызов:
Student returnstudent(Student s) { return s; }
Student plato; // Платон учился у Сократа.
returnstudent(plato); // Вызов returnstudent.
Что произойдет в ходе выполнения вызова этой функции, так безобидно вы-
глядящей?
Коротко говоря, случится следующее: конструктор копирования Student бу-
дет вызван для инициализации s значением plato. Далее конструктор копирова-
ния Student вызовется снова для инициализации объекта, возвращаемого функ-
цией значением s. Затем будет вызван деструктор для s. И наконец, для объекта,
возвращаемого returnstudent, вызывается деструктор. Таким образом, эта «ни-
чего не делающая» функция осуществляет два вызова конструкторов копирования
Student и два вызова деструктора Student.
Но подождите! Объект Student содержит в себе два объекта string, поэто-
му всякий раз при создании объекта Student вы должны также создавать два
объекта string. Объект Student тоже наследует от объекта Person, поэтому
всякий раз при создании объекта Student вы должны конструировать и объект
Person. Объект Person имеет внутри еще два дополнительных объекта string,
поэтому каждое создание объекта Person влечет за собой два вызова конструкто-
ра string. Конечный результат выглядит следующим образом: передача объекта
Student по значению приводит к одному вызову конструктора копирования
Student, одному вызову конструктора копирования Person и четырем вызовам
Конструктора копирования string. Если копия объекта Student удаляется, каж-
дому вызову конструктора соответствует вызов деструктора, поэтому «суммарная
ст°имость» передачи объекта Student по значению - шесть конструкторов
11 Шесть деструкторов. Поскольку функция returnstudent использует переда-
чу по значению дважды (один раз для аргумента и один раз для возвращаемого
значения), «суммарная стоимость» вызова функции - двенадцать конструкторов
11 Двенадцать деструкторов!
К чести разработчиков компиляторов C++, это наихудший сценарий. Компи-
ляторам разрешается эмулировать некоторые из таких вызовов конструкторов ко-
дирования (стандарт C++ четко формулирует условия, при которых подобная опе-
рация возможна - см. правило 50). Встречаются компиляторы, пользующиеся
этой возможностью оптимизации. По до тех пор, пока они не распространятся
4~~ 1682
98
Классы и функции
повсеместно, вам не стоит забывать о возможных издержках передачи объектов
по значению.
Для того чтобы избежать непомерных «накладных расходов», следует пользо-
ваться передачей по ссылке, а не по значению:
const Studentb returnstudent(const Students s)
{ return s; }
Это намного более эффективно: поскольку никаких новых объектов не созда-
ется, не вызываются никакие конструкторы.
Передача аргументов по ссылке имеет и другое преимущество: мы избегаем
того, что иногда называется «проблемой усечения». Когда объект производного
класса передается как объект базового, все особые свойства, благодаря которым
он ведет себя как объект производного класса, «отсекаются», и остается просто
объект базового класса. Предположим, например, что мы работаем с набором клас-
сов для реализации системы графических окон:
class Window {
public:
string name() const; // Возвращаем название окна,
virtual void display() const; // рисуем окно и его содержимое.
};
class WindowWithScrollBars: public Window {
public:
virtual void display() const;
};
Всем объектам класса Window присуще имя, которое допустимо получить по-
средством функции name, и все окна можно вывести на экран, что достигается вы-
зовом функции display. Виртуальность функции display говорит о том, что
способ, которым простые базовые объекты класса Window выводятся на экран, мо-
жет отличаться от способа вывода изощренных, «дорогостоящих» объектов клас-
са WindowWithScrollBars (см. правила 36 и 37).
Теперь предположим, что вы захотели написать функцию, печатающую назва-
ние окна и выводящую его на экран. Следующий вариант написания функции -
неправильный:
// Функция, для которой актуальна проблема усечения объектов.
void printNameAndDisplay(Window w)
{
cout « w.name() ;
w.display();
}
Посмотрим, что получится, если вы вызовете эту функцию для объекта
WindowWithScrollBars:
WindowWithScrollBars wwsb;
printNameAndDisplay (wwsb) ,-
I I 99
Правило 23 Л:
Аргумент w (помните, что он передается по значению?) будет сконструирован как
объект Window, и вся специальная информация, благодаря которой wwsb действовал
как объект WindowWithScrollBars, отссчется. Внутри printNameAndDisplay
w будет всегда вести себя, как объект класса Window (поскольку это и есть объект
класса Window), независимо от типа объекта, передаваемого функции. В частности,
вызов display внугри printNameAndDisplay всегда вызывает Window: : display
и никогда - WindowWithScrollBars::display.
Способ, которым можно обойти проблему усечения - передача w по ссылке:
// Функция, для которой не актуальна проблема усечения.
void printNameAndDisplay (const Windows w)
{
cout « w.name () ;
w.display();
}
Теперь w действует в соответствии с тем, какой тип окна передан функции. Для
того чтобы подчеркнуть, что w в этой функции не модифицируется, хотя и переда-
ется по ссылке, мы воспользовались советом из правила 21 и осмотрительно объ-
явили его с const. Какие мы молодцы!
Передача по ссылке - замечательная вещь, но она влечет за собой некоторые
осложнения, наиболее известное из которых - совмещение имен, обсуждавшееся
в правиле 17. Кроме того, важно понимать, что иногда вы не можете передавать
аргумент по ссылке (см. правило 23). Наконец, жестокая реальность свидетель-
ствует, что ссылки практически всегда реализуются как указатели, поэтому пере-
дача по ссылке обычно в действительности почти всегда означает передачу указа-
теля. В результате, если ваш объект мал (например, это int), возможно, что его
более эффективно передавать по значению.
Правило 23. Не пытайтесь вернуть ссылку,
когда вы должны вернуть объект
Говорят, что Альберт Эйнштейн однажды дал такой совет: делайте все настоль-
ко просто, насколько возможно, но не проще. Программистам на C++ можно посо-
ветовать делать все настолько эффективно, насколько возможно, но нс более того.
Как только программисты осознают проблемы эффективности, связанные
с передачей объектов по значению (см. правило 22), они, подобно крестоносцам,
^Реисполняются решимости искоренить зло - передачу по значению - везде, где
Ь1 оно ни пряталось. Непреклонные в своем «святом» порыве, они с неизбежно-
стыо совершают фатальную ошибку: начинают передавать по ссылке значения не-
существующих объектов. А это неправильно.
Рассмотрим класс рациональных чисел, включающий в себя дружественную
Функцию (см. правило 19) для перемножения двух рациональных чисел:
class Rational {
Public:
4*
100
Классы и функции
Rational(int numerator = 0, int denominator = 1) ;
private:
int n, d; // Числитель и знаменатель.
friend
const Rational // Почему возвращаемое значение константно,
11 см. в правиле 21.
operator*(const Rational& Ihs, const Rational& rhs) ;
};
inline const Rational operator*(const Rational& Ihs, const Rational& rhs)
{
return Rational(Ihs.n * rhs.n, Ihs.d * rhs.d);
}
Ясно, что эта версия operator* возвращает результирующий объект по ве-
личине, и вы обнаружили бы непрофессиональный подход, если бы не уделили
внимание вопросу о затратах на создание и удаление объекта. Кроме того, очевид-
но, что количество имеющихся у вас ресурсов ограничено, и если есть возмож-
ность избежать затрат на создание промежуточного объекта, ее надо реализовать.
Стало быть, вопрос заключен в следующем: реальна ли такая возможность?
Вообще-то вы устраните данную проблему, если вернете ссылку. Но ссылка -
это просто идентификатор какого-то уже существующего объекта. Всякий раз,
сталкиваясь с объявлением ссылки, требуется отдавать себе отчет, для чего пред-
назначен этот идентификатор, поскольку он должен идентифицировать нечто
конкретное. В случае с operator*, если функции надлежит возвращать ссылку,
она должна вернуть ссылку на некий уже существующий объект Rational, со-
держащий произведение двух объектов, которые следовало перемножить.
Очевидно, нет никаких оснований полагать, что такой объект существовал
до вызова operator*. Например, если у вас есть
Rational а(1, 2); // а = 1/2.
Rational Ь(3, 5); //Ь = 3/5.
Rational с - а * Ь; //с должно равняться 3/10.
кажется неразумным ожидать, что уже существует рациональное число со значени-
ем одна десятая. Если operator* будет возвращать такое число, он должен создать
его самостоятельно.
Функция может создать новый объект только двумя способами: в стеке или
в куче. Создание в стеке совершается посредством определения локальной пере-
менной. Используя эту стратегию, вы пытаетесь записать operator* следую-
щим образом:
// Первый способ написать эту функцию неправильно.
inline const Rational& operator*(const Rational& Ihs, const Rational&
rhs)
{
Rational result(lhs.n * rhs.n, Ihs.d * rhs.d);
return result;
)
Правило 23
101
Этот подход можно отвергнуть сразу, поскольку вашей целью было избежать
рызова конструктора, a result должен быть создан подобно любому другому объек-
ту. Кроме того, эта функция порождает и более серьезные проблемы, поскольку воз-
вращает локальный объект - ошибка, более подробно рассмотренная в правиле 31.
Таким образом, вам предоставлена лишь возможность создания объекта в куче
И возвращения его ссылки. Объекты в куче создаются с использованием new. Вот
как мог бы выглядеть operator* в этом случае:
/ / Второй способ написать эту функцию неправильно.
inline const Rational& operator*(const Rational& Ihs, const Rational& rhs)
{
Rational *result = new Rational(Ihs,n * rhs.n, Ihs.d * rhs.d);
return *result;
}
Да, вам все же придется расплачиваться за вызов конструктора, поскольку па-
мять, выделяемая new, инициализируется вызовом соответствующего конструктора
(см. правило 5), но теперь встает другая проблема: как будет вызван delete для
объекта, созданного вами с использованием new?
По правде говоря, это гарантирует утечку памяти. Даже если пользователя,
вызвавшего operator*, можно было бы убедить взять адрес возвращаемого функ-
цией результата и использовать для него delete (такая вероятность чрезвычайно
мала; правило 31 показывает, как должен был бы выглядеть этот код), сложные
выражения приводили бы к созданию безымянных временных объектов, никак
не доступных программисту Например, в
Rational w, х, у, z;
w - х * у * z;
оба вызова operator* создают безымянные временные объекты, которые не видны
программисту, а следовательно, не могут быть удалены (и снова см. правило 31).
Но предположим, что вы умнее среднего программиста. Возможно, вы обрати-
ли внимание, что недостатком обоих подходов, как со стеком, так и с кучей, являет-
ся необходимость вызова конструктора для каждого результата, возвращаемого
Функцией operator*. Возможно, вы еще не забыли о своем первоначальном
делании избежать таких вызовов. Вероятно, вы знаете способ избежать всех вы-
зовов конструктора, кроме одного. Вполне допустимо, что вы придумали следую-
щую реализацию функции operator*, возвращающую ссылку на статический
объект Rational, определенный внутри функции:
/ / Третий способ написать эту функцию неправильно.
inline const Rational& operator*(const Rational& Ihs, const Rational&
rhs)
{
static Rational result; // Статический объект,
//на который возвращается ссылка.
Каким-либо образом перемножьте Ish и rsh
и поместите результат в переменную result
return result;
102
Классы и функции
Это выглядит многообещающе, хотя, попытавшись реализовать на C++ псев-
докод инициализации, показанный выше, вы обнаружите, что практически невоз-
можно придать необходимое значение result, нс вызывая конструктор Rational,
а стремление избежать такого вызова, собственно, и послужило поводом для всей
этой деятельности. Более того, давайте предположим, что ваш маневр будет иметь
успех - и все же никакой острый ум нс сможет в конечном счете спасти эту про-
грамму, родившуюся под несчастливой звездой.
Для того чтобы вскрыть причину, давайте рассмотрим следующий совершен-
но разумный код:
// operator— для объектов Rational.
bool operator—(const Rational& Ihs, const Rational& rhs) ;
Rational a, b, c, d;
if ((a * b) == (c * d) ) {
действия, необходимые в случае, если произведения равны
} else {
действия, необходимые в противном случае
}
Теперь обратите внимание, что выражение ( (a*b)==(c*d) ) всегда равняет-
ся true, независимо от значений а, Ь, с, и d!
Легче всего найти объяснение такому неприятному поведению, переписав
проверку на равенство в эквивалентной функциональной форме:
if (operator==(operator*(a, b) , operator*(с, d) ) )
Заметьте, что, когда вызывается operator==, всегда уже присутствуют два
активных вызова функции operator*, каждый из которых будет возвращать
operator* ссылку на статический объект Rational. Таким образом, operator==
будет сравнивать статический объект Rational, определенный в функции ope-
rator*, со значением статического объекта Rational внутри operator* той
же функции. Стоило бы удивиться, если бы при сравнении они не были равны
всегда.
При некотором везении этого может быть достаточно, чтобы убедить вас, что
возвращать ссылку из функции, подобной operator*, - пустая потеря времени,
по я не настолько наивен, чтобы полагаться на везение. Кое-кто из вас в настоя-
щий момент думает: «Хорошо, - если не достаточно одного статического объекта,
может, для этого подойдет статический массив...»
Подождите, разве мы уже не достаточно намучились?
Я не снизойду до того, чтобы посвятить такой программе отдельный пример, но
вкратце могу пояснить, почему даже возникновение такой идеи должно повергать
вас в стыд. Во-первых, вы должны выбрать п - размер массива. Если п слишком
мало, у вас может закончиться место для хранения, и вы ничего не выиграете по срав-
нению с вышеописанной программой с одним статическим объектом. Если же п че-
ресчур велико, вы уменьшаете производительность вашей программы, поскольку
каждый объект в массиве конструируется при первом вызове функции. Это будет
стоить вам п конструкторов и п деструкторов, даже если данная функция вызывается
Правило 23
103
только один раз. Если процесс улучшения производительности программного обес-
печения называется оптимизацией, тогда самое верное название происходяще-
му - «пессимизация». Наконец, подумайте о том, как заносить необходимые вам
значения в массив объектов и во что это обойдется. Наиболее прямой способ пе-
редачи объектов - операция присваивания, но с чем она связана? В общем случае
это вызов деструктора (для удаления старого значения) плюс вызов конструктора
(для копирования нового значения). А ваша цель - избежать вызова конструк-
тора и деструктора! Так что затея весьма неудачна.
Правильный способ написания функции заключается в том, что функция дол-
жна возвращать новый объект. В применении к operator* для класса Rational
это означает либо следующий код (который мы видели в самом начале страницы
100), либо что-пибудь вроде:
inline const Rational operator*(const Rationale Ihs, const Rational&
rhs)
{
return Rational (Ihs.n * rhs.n, Ihs.d * rhs.d);
}
Несомненно, вам придется смириться с издержками вызова конструктора и де-
структора для объекта, возвращаемого функцией operator*, но в глобальном мас-
штабе это небольшая цена за корректное поведение. Притом, вероятно, не все так уж
страшно. Подобно всем языкам программирования, C++ позволяет разработчикам
компиляторов для улучшения производительности генерируемого кода применять
определенную оптимизацию, и, как оказывается, в некоторых случаях коду не ну-
жен результат вызова функции operator*. Когда компилятор пользуется этим
фактом (а современные компиляторы очень часто так и поступают), ваша про-
грамма продолжает делать то, что вы от нее хотите, даже быстрее, чем ожидалось.
Подведем итог: когда вы выбираете между возвратом ссылки и возвратом
объекта, ваша задача заключается в том, чтобы в результате все работало правиль-
но. О том, как сделать этот выбор наименее накладным, должен позаботиться раз-
работчик компилятора.
Правило 24. Тщательно обдумывайте выбор
между перегрузкой функции
и аргументами по умолчанию
Проблема выбора между перегрузкой функции и заданием для аргумента зна-
чения по умолчанию связана с тем, что оба варианта позволяют осуществлять
вЬ1зов одного имени функции несколькими способами:
void f(); // f перегружена.
void f(int x);
f(); // Вызов f() .
f(10); // Вызов f(int).
void g(int x = 0) ; // Для g задано значение аргумента по умолчанию.
g(); // Вызов g(0).
Q (10);// Вызов д(Ю)-
104
Классы и функции
Что и когда следует использовать?
Все зависит от ответа на два других вопроса. Во-первых, существует ли зна-
чение, которое вы можете использовать по умолчанию? Во-вторых, как много ал-
горитмов вам надлежит применить? В целом, если вы достаточно разумно выбе-
рете аргумент со значением по умолчанию и намерены использовать только один
алгоритм, вам следует прибегнуть к аргументу по умолчанию (см. правило 38).
В противном случае вы используете перегрузку функций.
Рассмотрим функцию для вычисления максимума из пяти чисел int. Она ис-
пользует в качестве значения аргумента по умолчанию - вдохните поглубже -
std: : numeric_limits<int>: :min (). Я еще вернусь к этому, по прежде взгля-
ните па код:
int max(int а,
int b = std::numeric_limits<int>::min(),
int c = std::numeric_limits<int>::min(),
int d = std::numeric_limits<int>::min(),
int e = std::numeric_limits<int>::min())
{
int temp = a > b ? a : b;
temp = temp > c ? temp : c;
temp = temp > d ? temp : d;
return temp > e ? temp : e;
}
Расслабьтесь. Использование std: :numeric_limits<int>: :min() - всего
лишь новомодный способ реализовать в стандартной библиотеке C++ то, что С
делает посредством макроса INT_MIN из <limits .h>: это минимальное значе-
ние int для того компилятора C++, с которым вы работаете. Да, здесь сказыва-
ется отход от лаконичности, которой славится С, но за всеми этими двоеточиями
и другими синтаксическими хитростями стоит определенный подход.
Предположим, ваша задача - написать шаблон функции, требующий в каче-
стве параметра любой встроенный тип чисел, и вам хотелось бы, чтобы функции,
генерируемые шаблоном, распечатывали минимальное значение, возможное для
данного типа:
templatecclass Т>
void printMinimumValue()
{
cout « минимальное значение, представляемое типом Т;
}
Это достаточно трудно сделать, если все, чем вы располагаете, - <limits .h>
и cfloat . h>. Вам неизвестно, с чем вы имеете дело, поэтому невозможно по-
нять, печатать ли вам INT_MIN или DBL_MIN.
Для того чтобы снять проблему, в стандартной библиотеке C++ (см. прави-
ло 49) в заголовочном файле <limits> определен шаблон numeric_limits,
который сам определяет несколько статических функций-членов. Каждая фун-
кция возвращает информацию о типе, для которого реализуется шаблон. Так,
Правило 24
105
функции в numeric_limits<int> возвращают информацию о типе int, функ-
ции в nuraeric_limits<double> - информацию о типе double и т.д. Среди
функций в numericalimits функция min возвращает минимальное возможное
чцсло этого типа, поэтому numeric_limits<int>: :min() возвращает мини-
мальное целое число.
При наличии numeric_limits (шаблона, который, как почти все шаблоны
в стандартной библиотеке, располагается в пространстве имен std, - см. прави-
ло 28; сам но себе шаблон numeric_limits расположен в заголовочном файле
<limits>) написание printMinimumValue становится настолько легким, на-
сколько это можно себе представить:
templatecclass Т>
void printMinimumValue()
{
cout « std: .•numeric_limits<T>: .-min () ;
}
Этот основанный на numeric_limits подход к созданию типизированных
констант может показаться накладным, но такое впечатление обманчиво. Дело
в том, что запутанность исходного кода не отражается на машинном коде. В дей-
ствительности вызовы функций в numeric_limits совершенно не генерируют
кода. Для того чтобы понять, возможно ли это, рассмотрим следующий очевид-
ный способ реализации numeric_limits<int>: :min:
#include <limits.h>
namespace std {
inline int numeric_limits<int>::min() throw ()
{ return INTJMIN; }
}
Поскольку данная функция определена как встраиваемая, ее вызов заменяется
ее телом (см. правило 33). А это дает INT_MIN, что само по себе является определе-
нием с помощью #def ine - константы, зависящей от реализации. Поэтому, хотя
Функция max в начале этого правила выглядит так, как будто она делает функцио-
нальный вызов для каждого значения аргумента по умолчанию, это просто хитрый
способ доступа к типизированной константе, в данном случае к значению INT_MIN.
Стандартная библиотека C++ изобилует примерами таких курьезов. В связи с этим
ним следует обязательно прочитать правило 49.
Но вернемся к функции max. Принципиально важно отметить, что max ис-
пользует тот же самый (довольно неэффективный) алгоритм вычисления, не за-
висящий от количества аргументов, передаваемых при вызове. Нигде в функ-
ции вы не пытаетесь определить, какие аргументы «настоящие», а какие заданы
Ио умолчанию. Вместо этого вы выбрали значения по умолчанию, которые ни-
как не влияют на вычисления вашего алгоритма. В результате использование ар-
гУМента по умолчанию оказывается жизнеспособным решением.
Для многих функций разумных значений по умолчанию нет. Предположим,
Что вы хотите написать функцию для вычисления средней величины из пяти int.
При этом вы. не можете использовать аргументы, задаваемые по умолчанию,
106
Классы и функции
поскольку функция зависит от количества переданных аргументов: если вам
передается три значения, вы делите их сумму па 3; если передается пять значе-
ний, вы делите сумму па 5. Более того, нет «магического числа», которое бы
указывало на то, что аргумент нс был в действительности передан пользовате-
лем, поскольку все возможные значения int подходят для использования в ка-
честве аргументов. В этом случае у вас нет выбора и вы должны использовать
перегрузку функции:
double avg(int а) ;
double avgdnt a, int b) ;
double avg(int a, int b, int c) ;
double avg(int a, int b, int c, int d) ;
double avg(int a, int b, int c, int d, int- e) ;
Другой случай, когда возникает необходимость использования перегруженных
функций: вы хотите решить определенную задачу, но применяемый вами алго-
ритм зависит от входных данных. Для конструкторов это обычное дело: конструк-
тор по умолчанию создает объект «с нуля», в то время как конструктор копирова-
ния создает его на основании уже существующего объекта:
/ / Класс, представляющий натуральные числа.
class Natural {
public:
Natural dnt initValue) ;
Natural (const Natural& rhs) ;
private:
unsigned int value;
void init(int initValue);
void error(const string& msg);
};
inline
void Natural::init(int initValue) { value = initValue; }
Natural::Natural(int initValue)
{
if(initValue > 0) init(initValue);
else error("illegal initial value");
}
inline Natural::Natural(const Natural& x)
{ init(x.value); }
Конструктор, принимающий int, должен осуществлять контроль наличия
ошибок, а конструктор копирования - нет, поэтому здесь необходимы две функ-
ции; возникает перегрузка. Однако заметьте, что обе функции должны иници-
ализировать начальное значение нового объекта. Это может вести к дублиро-
ванию кода в конструкторах, поэтому допустимо устранить данную проблему
написанием закрытой функции-члена init, содержащей общий код двух кон-
структоров. Эту тактику - использование перегруженных функций, вызываю'
щих общие функции для выполнения некоторой работы, - стоит запомнить,
поскольку она часто оказывается полезной (см., например, правило 12).
Правило 25
107
Правило 25. Избегайте перегрузки
по указателю и численному типу
Для начала зададим себе простой вопрос: что такое ноль? Выражаясь кон-
кретнее, что здесь произойдет?
void f(int х);
void f(string *ps);
f(0); // Вызов f(int) или f(string*)?
Ответ: 0 - это int (если быть точным, литерная целая константа), потому все-
гда будет вызываться f (int). Здесь и кроется проблема, поскольку это требуется
не каждый раз. Описанная ситуация уникальна в мире C++: люди думают, что вы-
зов функции должен быть неоднозначным, а компиляторы так не «считают».
Было бы здорово, если бы мы могли каким-либо способом избавиться от этой
проблемы (скажем, используя для нулевого указателя NULL), но она оказывается
гораздо более сложной, чем можно себе представить.
Вашим первым побуждением может быть объявление константы с идентифика-
тором NULL, но константы имеют типы, а какой тип должен тогда иметь NULL?
Он должен быть совместим с указателями всех типов, но единственный подходящий
тип - это void, а вы не можете передавать указатели void* типизируемым указате-
лям, не делая явного преобразования типов. Это выглядит достаточно непривлека-
тельно, и более того, на первый взгляд ненамного лучше, чем исходная ситуация:
void * const NULL - 0; // Возможное определение NULL.
f (0) ; / / По прежнему вызывает f (int) .
f(static_cast<string*>(NULL)); // Вызывает f(string*).
f(static_cast<string*>(0)); // Вызывает f(string*).
Однако при более внимательном рассмотрении использование NULL - кон-
станты void* - не особенно лучше того, с чего мы начали, поскольку мы избега-
ем неоднозначности, если для нулевых указателей используем NULL:
f(0); // Вызов f(int) .
f(NULL); // Ошибка! Несоответствие типов.
f(static_cast<string*>(NULL)); // Отлично, вызов f(string*).
По крайней мере, мы достигли того, что вместо ошибки в момент выполнения
Программы (вызов «неправильного» f для 0) получаем ошибку в момент компиля-
ции (попытка передачи void* для аргумента string*). Это слегка улучшает дело
(см. правило 46), но преобразование типов - неудовлетворительное решение.
Если вы, хоть и краснея со стыда, прибегнете к помощи препроцессора, то об-
наружите, что он в действительности не может предложить удовлетворительного
выхода, поскольку наиболее очевидным выбором кажется
ttdefine NULL 0
или
ttdefine NULL ((void*) 0),
108
I.
Классы и функции
где первая возможность - это просто литерал 0, то есть по существу целая кон-
станта (как вы помните, это и было нашим исходным затруднением), в то время
как вторая возможность возвращает нас к проблеме передачи указателей void*
для типизированных указателей.
Если вы хорошо знакомы с правилами, отвечающими за преобразования типов,
вам должно быть известно, что с точки зрения C++ преобразование из long int
в int не лучше и не хуже, чем преобразование значения long int 0 в нулевой ука-
затель. Вы можете воспользоваться этим обстоятельством для введения неоднознач-
ности в выборе int/указатель, которая, как вы, возможно, считаете, должна была
присутствовать с самого начала:
#define NULL OL
void f(int x);
void f(string *p) ;
f(NULL);
// NULL - это теперь long int.
// Ошибка! - неопределенность.
Однако это не помогает решению проблемы, когда вы перегружаете функции
по long int и указателю:
#define NULL OL
void f(long int x) ;
void f(string *p);
f(NULL);
// Эта функция f теперь принимает параметр типа
// long.
// Хорошо, вызывает f(long int).
На практике это, вероятно, более безопасно, чем простое определение NULL
типа int, но перед нами скорее способ обойти проблему, чем устранить ее.
Проблема, впрочем, разрешима, но это требует использования совсем недав-
него дополнения к языку: шаблонов функций-членов (часто называемых просто
шаблонами членов). Шаблоны функций-членов подразумевают именно то, что
можно ожидать из названия: шаблоны внутри классов, генерирующие функции-
члены этих классов. В случае с NULL вам необходим объект, который ведет себя
подобно выражению stat ic_cast<T*> (0) для всех типов Т. Подразумевается,
что NULL должен быть объектом класса, содержащего операторы неявного преоб-
разования типа для всех возможных типов указателей. Таким образом, требуется
множество операторов преобразований типов, но благодаря шаблону членов вы
способны заставить C++ генерировать их вместо вас:
// Первый подход к классу, дающему указатели NULL.
class NullClass {
public:
templatecclass T>// Шаблон генерирует operator T* для всех типов T;
operator Т*() const { return 0; } // каждая функция возвращает
}; // нулевой указатель,
const NullClass NULL; // NULL - это объект типа NullClass.
void f (int x) ; // To же, что было в начале,
void f(string *p) ; Il Аналогично,
f (NULL) ; // Хорошо, преобразует NULL в string*,
// затем вызывает f(string*).
Правило 25
109
Для начала это неплохо, но возможен ряд улучшений. Во-первых, на самом деле
]{еТ необходимости использовать более чем один объект Nullclass, поэтому не имеет
смысла давать классу имя; мы просто можем использовать неименованный класс и соз-
дать NULL этого типа. Во-вторых, как только мы сделаем возможным преобразование
j^jULL к любому типу указателя, понадобится также обрабатывать и указатели на чле-
ны классов. Это требует использования второго шаблона членов, преобразующего О
к типу ТС::* (указателя на член типа Т класса с) для всех классов с и всех типов Т.
(Если вы не видите в этом смысла или никогда не слышали об указателях на члены -
и уж тем более не пользовались ими - не переживайте. Указатели на члены доста-
точно редко встречаются и, возможно, вам никогда не придется иметь с ними дело.
Если они все-таки вас интересуют, можете обратиться к правилу 30, в котором ука-
затели на члены классов обсуждаются немного более подробно.) Наконец, мы не
должны давать пользователю возможность пытаться получить адрес для NULL, так
как NULL не предназначен исполнять роль указателя; он существует в качестве зна-
чения указателя, а значение указателя (например, 0х253АВСЮ2) не имеет адреса.
Обновленное определение для NULL выглядит следующим образом:
const // Этот константный объект преобразуется к нулевому
class { // указателю на не член класса любого типа или к нулевому
public: // указателю на член класса любого типа, чей адрес
templatecclass Т> // получить нельзя (см. правило 27) и чье имя - NULL,
operator Т* () const
{ return 0; )
templatecclass C, class T>
operator T С::*() const
{ return 0; }
private:
void operator^() const;
} NULL;
На это действительно стоит полюбоваться, хотя вы можете пойти на некото-
рые уступки и все-таки дать классу имя. В противном случае, весьма вероятно,
что сообщения компилятора об ошибках, связанных с типом NULL, будут совер-
шенно невразумительными.
Что касается попыток создать работающий NULL, важно, что они оказывают-
ся действенными, только если вы вызываете функции. Если же вы разработчик
вызываемых функций, «защита от дурака» в виде NULL ничем вам не поможет,
поскольку заставить пользователя ее применять не в ваших силах. Например,
Даже если вы предложите пользователям вышеописанный высокотехнологичный
null, то все равно не сможете помешать им делать следующее:
f (0); // По-прежнему вызывает f(int), поскольку 0 - это по-прежнему
// int.
Этот код подвержен тем же самым проблемам, которые рассматривались в на-
чале данного правила.
Для разработчика nepei руженных функций итоговый совет будет выглядеть
следующим образом: лучше всего избегать перегрузки по численным типам и ука-
зателям, если у вас имеется такая возможность.
110
Классы и функции
Правило 26. Примите меры предосторожности
против потенциальной неоднозначности
Все мы придерживаемся некоторой философии. Некоторые люди верят в прин-
цип невмешательства в экономику, другие - в переселение душ. Некоторые даже
убеждены, что COBOL является настоящим языком программирования. У C++
тоже есть своя философия: она заключается в том, что потенциальная неоднознач-
ность - это не ошибка.
Ниже дан пример потенциальной неоднозначности:
class В; // Предварительное объявление класса В.
class А {
public:
A (const В&) ; //А можно создать из В.
};
class В {
public:
operator А() const; //В может быть преобразовано в А.
};
В этих объявлениях классов нет ничего неправильного: они без проблем мо-
гут сосуществовать в одной и той же программе. Однако посмотрим, что случит-
ся, если классы столкнутся в функции, которая требует объекта А, но которой
в действительности передается объект В:
void f (const А&) ;
В b;
f(b); // Ошибка! Неоднозначность.
Видя вызов f, компилятор «знает», что он должен каким-либо способом прий-
ти к объекту типа А, даже если «на руках» у него тип В. Существуют два одинако-
во хороших способа добиться этого. С одной стороны, можно вызвать конструк-
тор класса А; при этом новый объект А будет создан с использованием b в качестве
аргумента. С другой стороны, Ь может быть преобразован в объект А посредством
вызова оператора преобразования, определенного пользователем в классе В. По-
скольку оба подхода считаются одинаково хорошими, компилятор отказывается
выбирать между ними.
Конечно, некоторое время вы можете пользоваться этой программой, не по-
дозревая о наличии неоднозначности. Это и составляет коварную сущность по-
тенциальной неоднозначности. В течение долгого времени, никем не замеченная
и ничем себя не обнаруживающая, она может скрываться в коде программы, пока
однажды программист по неведению не совершит чего-нибудь действительно не-
однозначного, и тогда джинн вырвется из бутылки. В результате не исключены
неприятности: можно, ничего не подозревая, создать библиотеку, которую с пол-
ным основанием следует назвать неоднозначной.
Неоднозначность подобного типа возникает и для стандартных преобразова-
ний типов - нет даже необходимости в классах:
Правило 26
111
void f(int);
void f(char);
double d = 6.02;
f(d); // Ошибка! Неоднозначность.
Следует ли преобразовывать d в int
или char? Преобразования одинаково хо-
роши, поэтому компилятор здесь - не су-
дья. К счастью, вы можете обойти эту про-
блему, используя явное приведение типов:
f(static_cast<int>(d));
f(static_cast<char>(d));
// Правильно, вызывает f(int).
// Правильно, вызывает f(char).
Множественное наследование (см. правило 43) является богатым источни-
ком потенциальных неоднозначностей. Наиболее простой случай возникает, ког-
да производный класс наследует одно и то же название в нескольких базовых
классах:
class Basel {
public:
int dolt();
};
class Base2 {
public:
void dolt () ;
};
// Derived не объявляет функции по имени dolt.
class Derived: public Basel, public Base2 {
};
Derived d;
d.dolt();
// Ошибка! Неоднозначность.
Когда класс Derived наследует две функции с одним именем, C++ «не под-
нимает шума»; на данный момент это лишь потенциальная неопределенность.
Однако вызов dolt вынуждает компилятор определиться, и если вы не указыва-
ете, какой базовый класс вам нужен, вызов функции генерирует ошибку:
d.Basel::dolt(); // Правильно, вызывает Basel::dolt.
d.Base2::dolt(); //Правильно, вызывает Base2::dolt.
Большинство программистов такое положение не беспокоит, но то, что огра-
ничения доступа не вписываются в общую схему, подтолкнуло многих «мирных»
•Шодей к явно не миролюбивым действиям:
class Basel {... } ;
class Base2 {
private:
void dolt();
// To же, что и выше.
// Это теперь закрытая функция.
112
III
Классы и функции
class Derived: public Basel, public Base2
/ / To же, что и выше.
Derived d;
int i = d.doItO; // Ошибка! По-прежнему неоднозначность!
Вызов dolt продолжает оставаться неоднозначным, несмотря на то что до-
ступными являются только функции в Basel! Тот факт, что только Basel: : dolt
возвращает значение, которое может быть использовано для инициализации int,
также не принимается во внимание - вызов функции остается неоднозначным.
Если вы хотите вызвать эту функцию, то просто должны указать, dolt какого
класса вам нужна.
Как и в большинстве случаев, когда правила C++ кажутся неразумными, су-
ществует веская причина, по которой для однозначности вызова функций базо-
вых классов не принимаются в расчет ограничения доступа. Здесь все сводится
к следующему: изменение доступности члена класса не должно менять смысла
программы.
Например, предположим, что в предыдущем примере вы принимаете в расчет
ограничения доступа. Выражение d.dolt () тогда будет интерпретировано как
вызов Basel; : dolt, поскольку функция в Base2 недоступна. Теперь предполо-
жим, что Basel изменился таким образом, что его функция dolt вместо откры-
той стала защищенной, a Base2 был модифицирован так, что его функция вместо
закрытой стала открытой.
Весьма неожиданно то же самое выражение - d.dolt () - станет осуществ-
лять совершенно другой функциональный вызов, даже несмотря на то, что ни код
вызова функции, ни вызывающие функции не были изменены! Теперь уже это ка-
жется неразумным, а у компилятора нет никакой возможности выдать даже пре-
дупреждение. Поэтому, рассматривая варианты, вы можете решить, что необхо-
димость явного разрешения неоднозначности при доступе к наследуемым членам
не является настолько неоправданной, как это представлялось вначале.
При том, что способов написания программ и библиотек, изобилующих по-
тенциальными неоднозначностями, существует великое множество, - что остается
делать хорошему разработчику программного обеспечения? В основном нельзя
упускать из виду указанного обстоятельства. Практически невозможно искоре-
нить все потенциальные источники неоднозначности, особенно при комбиниро-
вании независимо написанных библиотек (см. также правило 28), но понимание
ситуаций, часто ведущих к потенциальным неоднозначностям, дает вам хорошие
шансы на уменьшение их количества.
Правило 27. Явно запрещайте использование
нежелательных функций-членов,
создаваемых компилятором по умолчанию
Предположим, вам хочется написать шаблон класса Array, который генериру-
ет классы, ведущие себя подобно встроенным в C++ массивам за одним исключени-
ем: они осуществляют проверку выхода индекса за границы. Одна из возникающих
113
Правило 27
проблем - как запретить присваивание объектов Array, поскольку в C++ присва-
ивание запрещено для массивов:
. double valuesl[10] ;
double values2[10];
valuesl = values2; // Ошибка!
Для большинства функций это не составляет проблемы. Если функция вам не
нужна, вы просто не объявите ее в классе. Однако оператор присваивания отно-
сится к избранным функциям-членам: если вы не пишете их сами, услужливый
помощник C++ всегда напишет их за вас (см. правило 45). Что делать?
Решение заключается в том, чтобы объявить функцию (в данном случае
operator=) закрытой. Объявляя функцию-член явным образом, вы не даете
компилятору генерировать свои собственные версии, а делая функцию закрытой,
вы не позволяете ее вызвать.
Однако это не защищает вас от ее случайного использования: члены класса
и дружественные функции все же могут вызвать вашу закрытую функцию. Ко-
нечно, это справедливо только в том случае, если вы не опустили ее определение.
Тогда при неумышленном вызове функции вы получите ошибку в момент компо-
новки (см. правило 46).
Для Array ваше определение шаблона начиналось бы следующим образом:
templatecclass Т>
class Array {
private:
//Не определяйте эту функцию!
Array& operator=(const Arrays rhs) ;
};
Теперь, если пользователь вознамерится выполнить присваивание объектов
Array, компилятор пресечет эту попытку, а если вы по неосторожности попыта-
етесь сделать это в функции-члене или дружественной функции, начнет роптать
компоновщик.
Не делайте на основании этого примера вывод, что данное правило применимо
только к оператору присваивания. Совсем нет. Оно действует в отношении каж-
дой генерируемой компилятором функции, описанной в правиле 45. На практике
вы обнаружите, что функциональное подобие между оператором присваивания
и конструктором копирования (см. правила 11 и 16) практически всегда означает,
что когда вы хотите запретить использование одного, вам необходимо запретить
И использование другого.
Правило 28. Расчленяйте
глобальное пространство имен
Самая серьезная проблема, касающаяся глобальной области видимости, связана
с гем, что эта область только одна. В написании большого программного продукта
114
9Н
Классы и функции
участвует огромное количество людей, каждый из которых создает имена в одной
и той же области видимости, что с неизбежностью ведет к конфликтам имен. На-
пример, заголовочный файл libraryl. h может определять ряд констант, вклю-
чая следующие:
const double LIB_VERSION = 1.204;
То же самое и для 1 i bra ту 2 . h:
const int LIB_VERSION = 3;
He требуется особенной проницательности, чтобы заметить, что при одновре-
менном включении как libraryl .И, так и library2 .h у вас возникнут неприят-
ности. К сожалению, кроме проклятий сквозь зубы, отправки недоброжелательных
писем разработчикам библиотеки и редактирования файлов заголовков вы мало что
можете сделать для разрешения конфликтов подобного типа.
Все, что в ваших силах, - это милосердно снизойти до тех несчастных, кому
придется использовать написанные вами библиотеки. Вполне вероятно, вы уже
предваряете ваши глобальные символы префиксами, которые, надеюсь, будут уни-
кальными, по, несомненно, следует признать, что получаемые идентификаторы
оказываются более чем непривлекательными на вид.
Использование пространства имен C++ удачно разрешает это недоразумение.
По своей сути пространство имен - это изысканный способ использования люби-
мых вами префиксов без того, чтобы они все время мозолили глаза. Поэтому вме-
сто фрагмента
const double sdmBOOK__VERSION =2.0; //В этой библиотеке каждый символ
class sdmHandle. {...}; // начинается с "sdm", для чего может
sdmHandle& sdmGetHandle(); // потребоваться объявление подобной
// функции - см. правило 47.
вы пишете следующее:
namespace sdm {
const double BOOK_VERSION = 2.0;
class Handle { ... };
Handled getHandle();
}
При этом пользователь получает доступ к символам в созданном вами простран-
стве имен любым из трех стандартных способов: импортируя символы простран-
ства имен в область видимости, импортируя в область видимости индивидуальные
символы или каждый раз явно указывая символ. Вот несколько примеров:
void fl()
{
using namespace sdm; // Сделайте все символы в sdm доступными без
// квалификации в этой области видимости.
cout « BOOK_VERSION; // Правильно, разрешается в sdm::BOOK_VERSION.
Handle h = getHandle(); // Правильно, Handle разрешается в sdm::Handle,
//a getHandle разрешается в sdm::getHandle.
Правило 28
nil
115
}
void f 2 ()
{
using sdm: :BOOK_VERSION; // Без полного указания имени в этой
// области доступно только BOOK_VERSION.
cout « BOOK-VERSION; // Правильно, разрешается в sdm::BOOK_VERSION.
Handle h = getHandleO ; // Ошибка! Ни Handle, ни getHandle в эту область
// видимости импортированы не были.
}
void f 3 ()
{
cout « sdm::BOOK_VERSION;
double d = BOOK_VERSION;
Handle h = getHandleO ;
// Правильно, BO0K_VERSION доступно только
//в этом месте.
// Ошибка! BOOK_VERSION в этой области
// видимости недоступно.
// Ошибка! Ни Handle, ни getHandle
//в эту область видимости
// импортированы не были.
}
Вот наиболее приятное свойство пространств имен: потенциальная неодноз-
начность не является ошибкой (см. правило 26). В результате вы можете импорти-
ровать те же самые символы из нескольких пространств имен и тем не менее вести
вполне беззаботную жизнь (при условии, что вы никогда в действительности не ис-
пользуете эти символы). Например, в дополнение к стандартному sdm вы решили
использовать следующее пространство имен:
namespace AcmeWindowSystem {
typedef int Handle;
}
В этом случае можно, не опасаясь конфликтов, использовать как sdm. так
И AcmeWindowSystem - при условии, что вы нигде не обращаетесь к Handle.
Если же вы к нему обращаетесь, то следует явным образом указать, из какого про-
странства имен вам нужен Handle:
void f()
{
using namespace sdm; // Импортирует символы из sdm.
using namespace AcmeWindowSystem; // Импортирует символы из Acme.
. . . / / Можно свободно обращаться к символам из sdm и Acme, кроме Handle.
Handle h; // Ошибка! Который Handle?
sdm::Handle hl; // Правильно, нет неоднозначности.
AcmeWindowSystem: :Handle h2; // Также никакой неоднозначности.
116 :
а»! Классы и функции
Сравните приведенный пример со стандартным подходом, использующим
заголовочные файлы, где простое включение sdm. h и acme . h вызвало бы ошиб-
ку компилятора - повторное объявление символа Handle.
Пространства имен были введены в C++ достаточно поздно, в процессе стан-
дартизации. Вероятно, поэтому непосвященному читателю кажется, что они не
так уж важны и без них вполне можно прожить. Нет, нельзя. Нельзя, поскольку
практически все в стандартной библиотеке (см. правило 49) располагается внут-
ри пространства имен std. Вы можете счесть эту особенность несущественной,
но на самом деле она непосредственно касается любого программиста. Именно по
этой причине C++ теперь щеголяет забавными названиями заголовков, например:
<iostream>, <string> и т.п. (более подробно см. правило 49).
Поскольку пространства имен были введены сравнительно поздно, ваш ком-
пилятор может их еще не поддерживать. Даже в этом случае нет причин для засо-
рения глобального пространства имен, поскольку можно аппроксимировать про-
странства имен структурами. Вы делаете это посредством создания структуры,
содержащей глобальные идентификаторы, и объявляете их как статические чле-
ны структуры:
// Определение структуры, эмулирующей пространство имен.
struct sdm {
static const double BOOK_VERSION;
class Handle { ... } ;
static Handled getHandle() ;
};
const double sdm::BOOK__VERSION - 2.0; // Обязательное определение
// статических членов данных.
Теперь, если кому-нибудь потребуется доступ к глобальному имени, достаточ-
но просто воспользоваться префиксом названия структуры:
void f()
{
cout « sdm::BOOK_VERSION;
sdm::Handle h = sdm::getHandle();
}
Если конфликты имен на глобальном уровне отсутствуют, то пользователи
вашей библиотеки могут посчитать использование полных имен обременитель-
ным. К счастью, здесь также предусмотрен способ, позволяющий задавать об-
ласть видимости и игнорировать полные имена.
Для названий типов используйте оператор typedef, который бы устранил не-
обходимость в явном задании области видимости. Итак, для типа Т имитирую-
щей пространство имен структуры запишите глобальное определение типа, при
котором Т окажется синонимом S: : Т:
typedef sdm: :Handle Handle;
Правило 28
117
Для каждого статического объекта X новой структуры определите глобальную
ссылку, инициализируемую s: : X:
const doubled BOOK-VERSION = sdm::BOOK-VERSION;
По правде говоря, после того как вы прочтете правило 47, при одной только мыс-
ли об определении нелокальных статических объектов, подобных BOOK__VERSION,
вам будет делаться дурно. У вас появится желание заменить такие объекты функ-
циями, определенными в правиле 47.
функции во многом подобны объектам, и, хотя определение ссылок на функ-
ции вполне корректно, все же предпочтительно вместо ссылок на функции при-
менять указатели на них:
// getHandle - это константный указатель (см. правило 21) на sdm: :getHandle.
sdm::Handlе& (* const getHandle)() = sdm::getHandle;
Обратите внимание на то, что getHandle - это константный указатель const.
Не хотите же вы, в самом деле, чтобы пользователи делали его указателем на что-
нибудь отличное от sdm: : getHandle?
Если вам очень интересно, как определить ссылку на функцию, это освежит
вашу память:
// getHandle - это ссылка на sdm: :getHandle.
sdm::Handled (&getHandle)() - sdm::getHandle;
Лично я думаю, что такой вариант весьма неплох, но по ряду причин вы, воз-
можно, не сталкивались с этим раньше. Кроме разницы в способе инициализа-
ции, ссылки и указатели на функции const практически ничем не отличаются.
При этом преимущество указателей на функции состоит в том, что с ними гораз-
до проще разобраться.
Имея эти определения типов и ссылки, пользователи, не сталкивающиеся
с конфликтами глобальных имен, могут просто применять типы и имена объек-
тов, в то время как пользователи, которым знакома проблема конфликтов имен,
Должны определять имена полностью. Маловероятно, что все захотят пользовать-
ся короткими названиями, поэтому обязательно поместите определения типов
и ссылки в отдельных файлах заголовков, не содержащих структуру, эмулирую-
щую пространство имен.
Структуры - хорошая аппроксимация пространств имен, но им очень дале-
ко до самого оригинала. Структуры обладают целым рядом недостатков, причем
один из самых очевидных - работа с операторами. Коротко говоря, операторы,
определенные как статические функции-члены структуры, могут быть вызваны
только посредством функционального вызова, а не путем синтаксиса двуместных
операций, для поддержки которых они созданы:
// Определяем структуру, эмулирующую пространство имен
//и содержащую типы и функции для Widgets.
// Объекты Widgets поддерживают операцию сложения с помощью operator +.
' struct widgets {
class Widget { . • • 1;
118
Классы и функции
//В правиле 21 объясняется, почему возвращается константа.
static const Widget operator+(const Widgeta Ihs, const Widget& rhs) ;
};
// Попытка определить глобальные (краткие) имена для Widget
//и описанного выше operator +.
typedef widgets::Widget Widget;
// Ошибка! operator+ не может быть именем указателя.
const Widget (* const operator+)(const Widget&, const Widget&);
Widget wl, w2, sum;
sum = wl + w2; / / Ошибка! Нет operator + с аргументом
// типа Widget в этой области видимости.
sum = widgets: :operator+(wl, w2) ; // Допустимый, но вряд ли
// "естественный" синтаксис.
Подобные ограничения должны подтолкнуть вас к применению настоящих
пространств имен, как только компилятор сделает это возможным.
Глава 5. Классы и функции:
реализация
Поскольку C++ сильно типизирован, надлежащее определение классов, шабло-
нов и функций составляет львиную долю всей работы. Если вы сделали это пра-
вильно, ошибиться при реализации шаблонов, классов и функций достаточно
трудно. Тем не менее некоторые программисты все же умудряются так поступить.
Некоторые проблемы возникают из-за непреднамеренного нарушения аб-
стракции (когда детали реализации выходят за границы классов и функций).
Другие порождаются ошибками, связанными со временем жизни объекта. Третьи
объясняются преждевременной оптимизацией, причина которой часто кроется
в притягательности ключевого слова inline. Наконец, определенные стратегии
реализации, хотя и неплохо смотрятся в локальном масштабе, дают такой уро-
вень взаимосвязи между исходными файлами, который может оказаться непри-
емлемо высоким при создании больших систем.
Каков бы ни был источник проблемы, ее можно избежать, если вы знаете, чего
следует опасаться. Нижеследующие правила обрисовывают некоторые ситуации,
в которых вам следует быть особенно бдительными.
Правило 29. Избегайте возврата «дескрипторов»
внутренних данных
Сцена из объектно-ориентированного романа:
Объект А: Дорогая, оставайся такой всегда!
Объект В: Не волнуйся, дорогой, я const.
Тем не менее, как и в реальной жизни, А размышляет: «Можно ли доверять В?»
И, совсем как в реальной жизни, ответ часто зависит от природы В: состава его
Функций-членов.
Предположим, что В - это константный объект String:
class String {
public:
String (const char * value) ; // Возможную реализацию
-String () ; // вы можете найти в правиле 11.
operator char *() const; // Преобразование String в char*.
private:
char *data;
};
const String В ("Hello World") ; // В - это константный объект.
120
S3
Классы и функции: реализация
Поскольку В объявлен как const, было бы лучше всего, если бы значение в
отныне и навсегда оставалось равным "Hello World". Конечно, при этом пред-
полагается, что программисты, работающие с в, будут вести «честную игру». В част-
ности, многое зависит от того, станут ли они посредством «грязных приемов» об-
ходить константность (см. правило 21):
// Делаем alsoB другим именем для В, но без константности.
Strings alsoB = const_cast<String&> (В) ;
Если, впрочем, никто не совершает таких злодеяний, то вполне можно пору-
читься, что В никогда не изменится. Или все-таки нет? Рассмотрим следующую
последовательность событий:
char *str = В; // Вызов В.operator char*() .
strcpy(str, "Hi Mom") ; // Модифицируем то, на что указывает str.
Действительно ли значение В По-прежнему равно "Hello World", или оно
неожиданно превратилось в "Hi Мош"? Ответ полностью зависит от реализации
String::operator char*.
Ниже приведена небрежная реализация, которая ведет себя неправильно. Тем
не менее она работает очень эффективно, поэтому многие программисты попада-
ются в эту ловушку:
// Быстрая, но некорректная реализация.
inline String::operator char*() const
{ return data; }
Проблема с этой функцией заключается в том, что она возвращает дескриптор
(в данном случае указатель) для информации, содержащейся внутри объекта
String, для которого функция вызывается. Дескриптор дает пользователю не-
ограниченный доступ к тому, на что указывает закрытый член data. Другими сло-
вами, после строчки
char *str = В;
ситуация выглядит следующим образом:
Ясно, что любая модификация памяти, на которую указывает str, изменит так-
же и фактическое значение В. Таким образом, несмотря на то, что В объявлен как
const и вызываются только константные функции-члены В, по ходу выполнения
программы он все равно может принять другое значение. В частности, если изме-
нится значение, на которое указывает str, то В также подвергнется изменению.
Правило 29
I 121
В том, как написана функция String: : operator char*, нет ничего плохого.
Плохо то, что она может быть использована для константных объектов. Если бы
функция не была объявлена как const, то проблемы бы не возникло, поскольку
функцию было бы невозможно вызвать для объектов, подобных В.
Тем не менее преобразование объекта String (даже константного) в эквива-
лентный указатель char* кажется вполне разумным, так что вы предпочтете оста-
вить объявление этой функции const. Если вам это необходимо, следует пере-
писать реализацию так, чтобы избежать возврата дескриптора внутренних данных:
// Более медленная, нб и более безопасная реализация.
inline String: :operator char*() const
{
char *copy = new char [strlen(data) + 1] ;
strcpy(copy, data);
return copy;
}
Приведенная в качестве примера реализация безопасна, поскольку она воз-
вращает указатель на память, содержащую копию данных, на которые указывает
указатель объекта String. Изменение значения объекта String посредством
возвращаемого этой функцией указателя невозможно. За безопасность, как все-
гда, приходится платить: данная версия String: :operator char* медленнее,
чем простая версия, приведенная выше. При вызове этой функции нужно исполь-
зовать delete для возвращаемого указателя.
Если вы считаете, что такая версия operator char* чересчур медленна, или
вас нервирует (по понятным причинам) потенциальная возможность утечки па-
мяти, то представляется целесообразным использовать другое исправление - воз-
вращать указатель на const char:
class String {
public:
operator const char *() const;
};
inline String::operator const char*() const
{ return data; }
Эта функция быстра и безопасна, и, хотя ее спецификация отличается от из-
начальной, для большинства приложений она вполне подойдет. Кроме того, такой
Подход по духу близок решению проблемы string/char*, предложенному Коми-
тетом по стандартизации C++: стандартный тип содержит функцию-член c_str,
Возвращающую версию const char* данной строки string. Более подробная
Информация о стандартном типе string приводится в правиле 49.
Указатель - это не единственный способ возврата дескриптора внутренних
Данных. Ссылки предоставляют столь же широкое поле для злоупотреблений. Вот
Наиболее распространенный способ действий, опять подразумевающий исполь-
зование класса String:
Классы и функции: реализация
class String {
public:
char& operator[](int index) const
{ return data[index]; }
private:
char *data,-
};
String s = "I'm not constant";
s[0] = 'x'; // Правильно, s неконстантно.
const String cs = "I'm constant";
cs[0] = 'x'; // Модифицирует константную строку,
// но компилятор этого не замечает.
Заметьте, что String: :operator[ ] возвращает результат по ссылке. Это
означает, что пользователь данной функции получает другой идентификатор того
же внутреннего элемента data [index], и такой идентификатор может быть Ис-
пользован для модификации внутренних данных предположительно констант-
ного объекта. С подобной проблемой мы столкнулись ранее, но на этот раз «об-
виняемый» - ссылка, а не указатель.
Общее решение подобного рода проблем аналогично решению проблемы ука-
зателей: либо сделать функцию неконстантной, либо переписать ее так, чтобы она
не возвращала дескриптора. О том, как написать String: :operator [], чтобы
он работал и с const-, и с не const-объектами, рассказано в правиле 21.
Практика показывает, что программистам приходится отслеживать на пред-
мет возврата дескрипторов не только функции-члены с const. Даже функции без
const должны «примириться» с тем фактом, что действие дескриптора истекает
одновременно с окончанием срока жизни соответствующего объекта. Это может
произойти быстрее, чем ожидает пользователь, особенно если объект временно
создан компилятором.
Например, взгляните на функцию, возвращающую объект класса string:
String someFamousAuthor() { // Случайным образом выбирает //и возвращает имя автора.
switch (rand() % 3) { case 0: // rand() имеется в <stdlib.h> // (и <cstdlib> - см. правило 49)
return "Margaret Mitchell"; case 1: // Написала "Унесенные ветром", // настоящий классик.
return "Stephen King"; case 2: // Его рассказы не давали уснуть // миллионам людей.
return "Scott Meyers"; } // Гм! Этот пункт чем-то / / отличается от двух других. . .
return “"; //Мы никак не можем попасть сюда, но, увы,
// любой путь исполнения в функции, возвращающей
} // значение, должен возвращать значение.
Правило 29
.123
Давайте оставим на время в стороне вашу вполне обоснованную обеспоко-
енность тем, насколько «случайны» величины, возвращаемые rand, и, пожалуй-
ста, будьте снисходительны к моей мании величия - попытке причислить себя
к настоящим писателям. Вместо этого сфокусируемся па том, что значение, воз-
вращаемое someFamousAuthor, - это временный объект String. Время жизни
таких объектов обычно простирается только до конца выражения, содержащего
вызов создающей их функции, в данном случае - до конца выражения, содер-
жащего вызов someFamousAuthor.
Рассмотрим еще один случай использования этой функции. Предполагает-
ся, что String объявляет функцию-член operator const char*, описанную
выше:
const char *рс = someFamousAuthor ()• ;
cout « pc; // Ой. . .
Хотите - верьте, хотите - нет, но предсказать с какой-либо определенностью,
что будет делать этот код, вы не сможете. К тому времени, когда вы попытаетесь
распечатать последовательность символов, на которую указывает рс, она уже бу-
дет не определена. Неопределенность объясняется особой последовательностью
событий, имеющей место при инициализации рс:
1. Создается временный объект String, который должен содержать значение,
возвращаемое функцией someFamousAuthor.
2. Затем он конвертируется в const char* посредством operator const
char* из String, a pc инициализируется результирующим указателем.
3. Временный объект String удаляется, что означает вызов деструктора. В де-
структоре удаляется указатель data (код приведен в правиле 11). Однако
data указывает на ту же память, что и рс, поэтому рс теперь указывает на
память с неопределенным содержимым.
Поскольку рс был инициализирован дескриптором временного объекта, а вре-
менные объекты вскоре после своего создания удаляются, дескриптор становится
недействительным до того, как рс получает возможность с ним что-нибудь делать.
В каком-то смысле рс «мертв еще до своего появления на свет». Вот какую опас-
ность таят в себе дескрипторы временных объектов.
Таким образом, для функций-членов, содержащих const, возврат дескрип-
торов - опрометчивое решение, поскольку оно приводит к разрушению абстрак-
ции. Возврат дескриптора может привести к проблемам даже для функции-чле-
на, не содержащей const, особенно если используются временные объекты.
Дескрипторы, как и указатели, могут «зависать», и, так же как вы избегаете ви-
сящих указателей, следует избегать и висящих дескрипторов.
Однако нет причин быть чрезмерно осторожными. В нетривиальной програм-
ме нельзя устранить все возможности появления висящих указателей и редко ког-
да можно полностью гарантировать отсутствие висящих дескрипторов. И все-
таки, если вы будете избегать возвращения дескрипторов без особых на то причин,
от этого выиграют и ваши программы, и ваша репутация.
124
Классы и функции: реализация
Правило 30. Не используйте функции-члены,
возвращающие неконстантные указатели
или ссылки на члены класса
с более ограниченным доступом
Смысл определения закрытых или защищенных членов класса заключается
в ограничении доступа, не так ли? Бедные компиляторы C++ трудятся в поте лица,
чтобы ваши ограничения нельзя было обойти. Поэтому нет смысла писать функ-
ции, дающие случайным пользователям свободный доступ к членам с ограничен-
ным доступом. Если вы считаете, что. в этом есть смысл, пожалуйста, перечитайте
данный абзац снова и снова, пока не придете к выводу, что ваше мнение неверно.
Вышеуказанное правило легко нарушить. Вот пример:
class Address {...}; // Чье-либо место жительства.
class Person {
public:
Address& personAddress() { return address; }
private:
Address address;
};
Функция-член personAddress возвращает пользователю объект класса
Address, содержащийся в объекте Person, но, возможно из соображений эффек-
тивности, результат возвращается по ссылке, а не по значению (см. правило 22).
К сожалению, наличие этой функции-члена делает бессмысленным определение
Person: : address как закрытого члена класса:
Person scott (...); // Для простоты аргументы опущены.
Address& addr = // Предполагаем, что addr - глобально,
scott.personAddress();
Теперь глобальный объект addr - это другой идентификатор для доступа
к scott .address, который может быть при желании использован для чтения
и записи. С практической точки зрения scott. address уже не является закры-
тым членом. Теперь он - открытый член класса, и причина этого превращения -
функция-член personAddress. Конечно, в уровне доступа private нет ничего
особенного. Если бы Address был защищенным членом, к нему следовало бы
применять ту же самую логику.
Ссылки - не единственный повод для беспокойства. В равной степени опасе-
ния вызывают и указатели. Рассмотрим тот же пример, только па сей раз с исполь-
зованием указателя:
class Person {
public:
Address * personAddress() ( return &address; }
Правило 30
125
private:
Address address;
};
Address *addrPtr = scott .personAddress () ; // Та же проблема, что и выше.
При использовании указателей необходимо обращать внимание не только на
члены классов, но и на функции-члены. Дело в том, что вы можете вернуть указа-
тель и на функцию-член:
class Person; // Предварительное объявление.
// PPMF указатель на функцию-член класса Person.
typedef void (Person::*PPMF)();
class Person {
public:
static PPMF verificationFunction()
{ return &Person::verifyAddress; }
private:
Address address;
void verifyAddress();
};
Если вы до сих пор не сталкивались с указателями на функции-члены, то
объявление Person: : verificationFunction может показаться устрашающим.
Не стоит пугаться. Все, что там написано, - это:
□ verificationFunction - функция-член, не требующая аргументов;
□ возвращаемое значение - указатель на функцию-член класса Person;
□ функция, на которую указывает указатель (то есть значение, возвращаемое
verificationFunction), не требует параметров и ничего не возвращает,
то есть void.
Что же касается слова static, оно означает то же, что и всегда при объявлении
членов классов: для всего класса существует только одна копия члена, и к ней мож-
но получить доступ, не используя объекта. О подробностях справьтесь в своем
учебнике по C++. (Если ваш учебник по C++ не содержит информации о стати-
ческих членах класса, разорвите его и сдайте в макулатуру, а затем одолжите или
купите лучший.)
В данном примере verifyAddress - закрытая функция. Об этой детали реа-
лизации класса должны быть «осведомлены» только члены класса и, конечно дру-
зья. Однако открытая функция-член verificationFunction возвращает ука-
затель на verifyAddress, поэтому пользователи могут сделать что-нибудь
вроде:
PPMF pmf = scott.verificationFunction();
(scott.*pmf)(); // To же самое, что и вызов scott.verifyAddress.
Здесь pmf становится синонимом Person: :verifyAddress с той лишь су-
щественной разницей, что нет никаких ограничений на его использование.
126
II:
Классы и функции: реализация
Иногда из соображений производительности бывает чрезвычайно важно на-
писать функцию-член, возвращающую ссылку или указатель на член класса
с более ограниченным доступом. В то же время жертвовать ограничениями досту-
па, даваемыми private и protected, не всегда желательно. В этих случаях
вы практически стопроцентно можете достигнуть обеих целей, возвращая указа-
тель или ссылку на константный объект const. Подробнее см. правило 21.
Правило 31. Никогда не возвращайте ссылку
на локальный объект или разыменованный
указатель, инициализированный внутри функции
посредством new
Название правила может показаться чересчур многословным, но на самом деле
это не так. Оно просто продиктовано соображениями здравого смысла. В самом
деле. Честное слово. Поверьте мне.
Сначала рассмотрим возвращение ссылки на локальный объект. Проблема здесь
в следующем: локальность объектов означает именно то, что они локальны. Иначе
говоря, они создаются при их определении и разрушаются при выходе из области
видимости. Их область видимости - тело той функции, в которой они определены.
Когда вы возвращаетесь из функции, вы выходите из их области видимости. В ре-
зультате, если возвращается ссылка на локальный объект, он удаляется прежде, чем
пользователь функции успевает с ним что-либо сделать.
Эта неприятность обычно имеет место, когда вы пытаетесь улучшить произ-
водительность функции, возвращая результат по ссылке, а не по значению. При-
ведем пример из правила 23, где подробно рассмотрено, когда можно, а когда не
следует возвращать ссылку:
class Rational { // Класс для рациональных чисел.
public:
Rational (int numerator = 0, int denominator - 1) ;
-Rational();
private:
int n, d; // Числитель и знаменатель.
// Обратите внимание, что operator* ошибочно возвращает ссылку.
friend const Rational& operator*(const Rational& Ihs,
const Rationals rhs);
};
// Некорректная реализация operator*.
inline const Rational& operator* (const Rational& Ihs,
const Rationals rhs)
{
Rational result(Ihs.n * rhs.n, Ihs.d * rhs.d);
return result;
}
Здесь локальный объект result создается при входе в тело operator*.
Однако локальные объекты автоматически удаляются при выходе из области
Правило 31 г № ИНЕЕ)
видимости. Объект result выходит из нее после выполнения оператора
return, поэтому, когда вы пишете
Rational two = 2;
Rational four = two * two; // To же самое, что и operator* (two,two) .
При вызове функции происходит следующее:
1. Создается локальный объект result.
2. Ссылка инициализируется другим идентификатором result и передается
наружу как возвращаемое значение.
3. Локальный объект result удаляется, а место, которое он занимал в стеке,
становится доступным для использования другими частями программы или
даже другими программами.
4. Объект four инициализируется с использованием ссылки из шага 2.
Все идет хорошо, пока на шаге 4 не случается то, что в узких кругах высоких тех-
нологий называется «глобальным сбоем». Ссылка, инициализированная на шаге 2,
перестает на заключительном этапе шага 3 ссылаться на допустимый объект, поэто-
му исход инициализации объекта four совершенно не определен.
Урок очевиден: не возвращайте ссылки на локальные объекты.
«Значит, - скажете вы, - проблема заключается в том, что объект, который я хочу
использовать, выходит из области видимости слишком быстро. Это можно исправить.
Я просто вызову new вместо использования локального объекта». Например:
// Другая некорректная реализация operator* .
inline const Rational^ operator*(const Rationale Ihs,
const Rational& rhs)
{
// Создаем в куче новый объект.
Rational *result = new Rational(Ihs.n * rhs.n, Ihs.d * rhs.d);
// Возвращаем его.
return *result;
}
При таком подходе вы действительно избегаете проблемы, рассмотренной
в предыдущем примере, но на ее месте создаете новую. Для того чтобы предот-
вратить утечки памяти в программном обеспечении, вы должны быть уверены,
что каждому указателю, созданному с помощью new, соответствует вызов
delete. Загвоздка лишь в том, кто будет отслеживать, чтобы каждому вызову
Hew соответствовал вызов delete?
Очевидно, что ответственность за вызов delete лежит на том, кто вызвал
°perator *. На практике же с такого рода ответственностью кое у кого из програм-
мистов дело обстоит совершенно безнадежно. Для пессимизма есть две причины.
Во-первых, хорошо известно, что программисты - большие разгильдяи. Не то
Чтобы вы или я были разгильдяями, но редко кто из нас не встречался по работе
с Людьми, так сказать, немного рассеянными. И стоит ли надеяться, что такие спе-
циалисты (мы-то с вами знаем - они существуют) запомнят, что всякий раз, ког-
Ла они вызывают operator*, им следует получить возвращаемый адрес и затем
128
ШИ'
Классы и функции: реализация
использовать для него delete? Другими словами, что они должны использовать
operator* следующим образом:
const Rationals four = two * two; // Получаем разыменованный
// указатель, сохраняем
// ссылку на него.
delete &four; // Получаем указатель и удаляем его.
Шансы па это близки к нулю. Помните, что даже если один-единственный
пользователь operator* не будет следовать этим правилам, произойдет утечка
памяти.
Образование недоступных указателей - вторая и более серьезная проблема, от
которой не застрахованы даже самые сознательные программисты. Часто резуль-
тат действия функции operator* - временное промежуточное значение, объект,
создаваемый только ради вычисления большего выражения. Например:
Rational one(1), two(2), three(3), four(4);
Rational product;
product = one * two * three * four;
Оценка выражения, присваиваемого product, требует трех раздельных вызо-
вов operator*. Этот факт становится более очевидным, когда вы переписываете
выражение в эквивалентной функциональной форме:
• product = operator*(operator*(operator*(one, two),three), four);
Мы знаем, что каждый вызов operator* возвращает объект, требующий уда-
ления, но у нас нет никакой возможности применить delete, поскольку ни одно-
го идентификатора этих объектов создано не было.
Единственное решение этой задачи - требовать написания кода, подобного
нижеприведенному:
const Rational& tempi = one * two;
const Rational& temp2 = tempi * three;
const Rational& temp3 = temp2 * four;
delete &templ;
delete &temp2;
delete &temp3;
Лучшее, на что можно надеяться, - это то, что вас проигнорируют. Более веро-
ятно, что с вас живьем снимут кожу или приговорят к десяти годам тяжелой рабо-
ты по написанию микрокоманд для вафельниц или тостеров.
Поэтому лучше усвоить для себя следующий урок: написание функций, воз-
вращающих разыменованные указатели, - это путь, ведущий к утечке памяти.
Кстати, если вы думаете, что нашли способ избежать неопределенности пове-
дения, присущей возврату ссылки на локальный объект, а вместе с тем и потенци-
альной утечки памяти, сопутствующей возврату ссылки на объект, размещае-
мый в куче, - вернитесь к правилу 23 и прочитайте, почему возврат ссылки на
локальный статический объект также работает некорректно. Это может уберечь вас
от вредных для здоровья попыток «достать левое ухо через правое плечо».
Правило
Правило 32. Откладывайте определение
переменных до последнего момента
Итак, вас обратили в религию, проповедуемую С: переменные должны опре-
деляться в начале блока. Откажитесь от нее! В C++ это необязательно, неестест-
венно и «дорого».
Помните, что когда вы определяете типизированную переменную с конструкто-
ром или деструктором, то всякий раз при достижении места определения принима-
ете на себя расходы по созданию объекта и по удалению переменной при выходе из
области видимости. Это означает, что появление всякой (в том числе и неиспользу-
емой) переменной влечет за собой некоторые расходы. Поэтому надо стремиться
избегать этого везде, где только возможно.
При вашем опыте и искушенности в программировании вы, вероятно, полага-
ете, что никогда не определяете неиспользуемых переменных, и поэтому совет, из-
ложенный в настоящей главе, к вашему лаконичному и сдержанному стилю про-
граммирования не относится. Возможно, вам есть смысл еще раз задуматься над
этим. Рассмотрим следующую функцию, возвращающую зашифрованную версию
пароля, если его длина превышает минимально допустимую. Если пароль черес-
чур короток, функция генерирует исключение типа logic_error, определенное
в стандартной библиотеке C++ (см. правило 49):
// Эта функция слишком рано определяет переменную encrypted.
string encryptPassword(const strings password)
{
string encrypted;
if (password.length() < MINIMUM_PASSWORD_LENGTH) {
throw logic_error("Password is too short") ;
}
здесь вы делаете то, что необходимо для получения
в encrypted зашифрованного пароля
return encrypted;
}
Объект encrypted назвать совершенно неиспользуемым нельзя, но при гене-
рации исключения он не используется. Следовательно, создание и удаление
encrypted ляжет на вас, даже если encryptPassword сгенерирует исключение.
Поэтому определение encrypted было бы лучше отложить до того момента, ког-
да станет ясно, что это необходимо:
// Данная функция воздерживается от определения encrypted,
/ / пока это не станет действительно необходимо.
string encrypt Pas sword (const strings password)
{
if (password.length() < MINIMUM_PASSWORD_LENGTH) {
throw logic_.error ("Password is too short") ;
}
string encrypted;
здесь вы делаете то, что необходимо
для получения в encrypted зашифрованного пароля
5
1682
Классы и функции: реализация
return encrypted;
}
Написанный выше код все еще не столь компактен, как хотелось бы, поскольку
encrypted объявляется без аргументов инициализации. Это означает, что во вре-
мя его выполнения будет использован конструктор по умолчанию. Во многих слу-
чаях первое, что вы делаете с объектом, - даете ему некоторое значение, часто по-
средством присваивания. В правиле 12 объясняется, почему создание объекта
конструктором по умолчанию и последующее присваивание значения намного ме-
нее эффективно, чем инициализация желаемым значением. Аналогичные рассуж-
дения применимы и здесь. Например, предположим, что основная часть работы
encrypt Pas sword выполняется в функции:
void encrypt (strings s) ; // Соответствующим образом шифрует s.
Тогда функцию encryptPassword можно было бы реализовать следующим
(хотя и не наилучшим) образом:
// Функция воздерживается от определения encrypted,
// пока это не станет необходимо,
//но тем не менее она все еще недостаточно эффективна.
string encryptPassword(const strings password)
{
... / / Проверяем длину, как выше.
string encrypted; // Создаем encrypted конструктором по умолчанию,
encrypted = password; // Присваиваем encrypted значение.
encrypt(encrypted);
return encrypted;
}
Более эффективный подход - инициализировать encrypted значением
password, отказавшись от бессмысленного вызова конструктора по умолчанию:
// Наконец, лучший способ определения и инициализации encrypted.
string encryptPassword(const strings password)
{
... // Проверяем длину,
string encrypted (password) ; // определяем и инициализируем
Нс помощью конструктора копирования,
encrypt(encrypted);
return encrypted;
}
Это проливает свет на фразу «до последнего момента» в заголовке правила 32-
Следует не просто откладывать определение переменной до того, как вам необхо-
димо будет ее использовать; вы должны попытаться отложить определение функ-
ции до тех пор, пока не появятся аргументы инициализации.
Поступая подобным образом, вы не только избегаете создания и удаления не-
нужных объектов, но и отказываетесь от использования бессмысленных конструК'
торов по умолчанию. Более того, инициализируя переменные в контексте, кото-
рый проясняет их значение, вы способствуете документированию переменной-
131
Правило 33 И. .ПЗИИ
[{омните, как после каждого определения переменной в С вам советовали писать
короткие комментарии, объясняющие, для чего она используется? Комбинируйте
подходящие названия переменных (см. правило 28) с понятными из контекста
аргументами инициализации, и вы реализуете мечту любого программиста: у вас
будет убедительная аргументация в пользу исключения отдельных комментариев.
Откладывая определение переменных, вы улучшаете эффективность програм-
мы, делаете ее более попятной и уменьшаете необходимость в документировании
смысла переменных. Похоже, пришла пора распрощаться с длинными списками
определений переменных в начале блоков.
Правило 33. Тщательно обдумывайте
использование встраиваемых функций
Встраиваемые функции... какая замечательная идея! Они выглядят подобно
функциям, они работают подобно функциям, они намного лучше, чем макросы
(см. правило 1). Их можно вызывать, не опасаясь накладных расходов функцио-
нального вызова. Чего еще желать?
В действительности вы получаете больше, чем рассчитывали, поскольку воз-
можность избежать затраты функционального вызова - это только полдела. Оп-
тимизация, выполняемая компилятором, обычно наиболее эффективна на участ-
ках кода, не содержащих вызовов функций. Таким образом вы даете компилятору
возможность выполнять оптимизацию тела встраиваемой функции в зависимос-
ти от контекста, в который она попадает. При использовании «обычного» функ-
ционального вызова такая оптимизация была бы невозможна.
Все же давайте не будем слишком увлекаться. В программировании, как и в ре-
альной жизни, не бывает «бесплатных завтраков», и встраиваемые функции
здесь не исключение. Идея их использования состоит в замене каждого вызова
такой функции ее телом. Не надо быть доктором математических наук, чтобы за-
метить, что это увеличит общий размер вашего объектного кода. Слишком час-
тое применение встраиваемых функций на машинах с ограниченной памятью
приводит к созданию программы, размер которой превосходит доступную па-
мять. Даже при наличии виртуальной памяти раздувание кода, вызванное при-
менением встраиваемых функций, может привести к патологиям (пробуксовкам,
thrashing) в механизме подкачки страниц, тормозящим выполнение программы.
(Это, однако, отличная разминка для контроллера диска.) Злоупотребление
встраиваемыми функциями может также уменьшить коэффициент попадания
кэша команд процессора, что в свою очередь снизит скорость извлечения команд
Из кэша в основную память.
С другой стороны, если тело функции очень короткое, код, генерируемый
Для тела функции, может на самом деле быть короче кода, генерируемого для
вызова функции. В таком случае встраивание функции может в действительнос-
ти вести к уменьшению объектного кода и к более высокому коэффициенту попа-
Дания кэша!
Следует иметь в виду, что директива inline, так же как и register, — это
спеет, а не команда компилятору. В результате всякий раз, когда компилятор сочтет
5*
132
Классы и функции: реализация
необходимым, он может игнорировать вашу директиву встраивания. И вынудить
его к этому достаточно просто. Например, большинство компиляторов отказывает-
ся встраивать сложные функции (в частности, содержащие циклы или рекурсии),
и, за исключением наиболее тривиальных случаев, вызов виртуальной функции от-
меняет встраивание. (В этом пет ничего удивительного: virtual значит «вызов
конкретной функции определяется в момент выполнения», a inline - «в процессе
компиляции замените вызов функции ее кодом». Если компилятор не «знает»,
какой вызов функции осуществлять, его трудно упрекнуть в том, что он отказыва-
ется делать встраивание функции.) Все это в конечном счете сводится к следую-
щему: от реализации используемого компилятора зависит, встраивается ли в дей-
ствительности встраиваемая функция. К счастью, большинство компиляторов
обладает достаточными диагностическими возможностями и выдает предупреж-
дения (см. правило 48) в том случае, если не может выполнить заказанное вами
встраивание.
Предположим, вы написали некоторую функцию f и объявили ее inline. Что
получится, если компилятор по какой-либо причине решит отказаться от встра-
ивания функции? Очевидный ответ заключается в том, что f не будет рассматри-
ваться как встраиваемая функция: ее код сгенерируется так, как если бы это была
нормальная (невстраиваемая) функция, а вызов f будет соответствовать обычно-
му вызову функции.
Теоретически все должно произойти именно так, но мы рассматриваем как раз
тот случай, когда практика может сильно отличаться от теории. Данная ситуа-
ция объясняется тем, что столь тонкое решение проблемы, связанной с не-
встраиваемыми inline-функциями, было предложено в процессе стандартиза-
ции C++ относительно поздно. Более ранние спецификации языка (такие, как
ARM - см. правило 50) предписывали компиляторам другое поведение, и «старо-
режимное» поведение все еще является достаточно распространенным, поэтому
следует понимать, в чем оно заключается.
Немного подумав, вы обнаружите, что определения встраиваемых функций
практически всегда размещаются в заголовочных файлах. Это позволяет включать
одни и те же заголовочные файлы в несколько единиц трансляции (исходных фай-
лов) для использования преимуществ, предоставляемых встраиваемыми функ-
циями. Вот пример, в котором я придерживаюсь соглашения о том, что исходные
файлы оканчиваются на . срр - это, наверное, наиболее распространенное в мире
C++ соглашение об именах файлов:
// Это файл example.h.
inline void f (){... } // Определение f.
// Это файл sourcel.cpp.
ttinclude "example.h" // Включает определение f.
... // Содержит вызовы f.
11 Это файл source2.cpp.
#include "example.h" // Тоже включает определение f.
... // Также вызывает f.
133
Правило 33
В соответствии со старым правилом певстраиваемых inline-функций, если
f не встраивается при компиляции sourcel. срр, то результирующий объектный
файл содержит функцию, называемую f, так, как будто f и не определялась
inline. Аналогично при компиляции source2.cpp генерируемый объектный
файл также будет содержать функцию, называемую f. Когда вы попытаетесь ском-
поновать эти два объектных файла, компоновщик вполне обоснованно выдаст вал?
сообщение: программа содержит два определения f, что является ошибкой.
Для разрешения данной проблемы старые правила требуют, чтобы компиля-
торы рассматривали невстраиваемые inline-функции так, как будто они были
объявлены со static, то есть локально для компилируемого в данный момент
файла. В только что рассмотренном примере компилятор, следуя старым прави-
лам, при компиляции файла sour с el. срр будет рассматривать £ как статичес-
кую функцию так же, как и при компиляции source2 . срр. Такой подход разре-
шает проблему компоновки, но «не бесплатно»: каждая единица трансляции,
содержащая определение (и вызывающая f), содержит свою собственную стати-
ческую копию £. Если в £ входит объявление статических переменных, то каждая
копия также содержит и свою собственную копию переменных, что, несомненно,
удивит программистов, полагающих, что static внутри функции означает «толь-
ко один экземпляр».
Это ведет к удивительному открытию. И в соответствии со старыми, и в соот-
ветствии с новыми правилами, если встраиваемая функция не встраивается, вы
все равно «платите» за функциональный вызов, а в соответствии со старым пра-
вилом можете даже увеличить размер объектного файла, поскольку каждая еди-
ница трансляции, содержащая вызов f, содержит также свою копию кода f и се
статических переменных! (Еще больше положение осложняет то, что каждая ко-
пия £ со своими статическими переменными имеет тенденцию располагаться на
отдельной странице виртуальной памяти, поэтому обращения к различным копиям
£ могут повлечь за собой один или несколько страничных отказов - page fault.)
Более того, иногда вашим бедным компиляторам приходится генерировать
гело встраиваемой функции даже в тех случаях, когда они всей душой хотели бы
ее встроить. В частности, если ваша программа берет адрес встраиваемой функ-
ции, то компилятор должен генерировать ее тело. Как иначе вы можете получить
Указатель функции, когда она не существует?
inline void f() {...}
void (*pf) () = f;
int main()
{
f () ;
pf () ;
// Как выше.
// pf указывает на f.
// Встраиваемый вызов f.
// Невстраиваемый вызов f посредством pf.
}
В данном случае вы оказываетесь в достаточно парадоксальной ситуации, ког-
да вызываемая функция £ является встраиваемой, но в соответствии со старыми
134
£11
Классы и функции: реализация
правилами каждая единица трансляции, требующая адреса f, будет генерировать
статическую копию функции. (В соответствии с новыми правилами независимо
от количества единиц трансляции будет сгенерирована единственная невстраива-
емая копия f.)
Вы можете столкнуться с такими певстраиваемыми функциями inline, даже
если никогда не используете указатели на функции, поскольку не только програм-
мисты могут запросить указатель функции. Иногда это делают и компиляторы.
В частности, иногда они генерируют нсвстраиваемые копии конструкторов и де-
структоров, чтобы можно было получить указатели на эти функции для исполь-
зования при создании и удалении массивов объектов класса.
На самом деле конструкторы и деструкторы, в противоположность тому, что
вы можете подумать о них в первый момент, - зачастую наихудшие кандидаты
для встраивания. Для примера рассмотрим конструктор следующего класса
Derived:
class Base {
public:
private:
string bml, bm2; // Члены базового класса 1 и 2.
};
class Derived: public Base {
public:
Derived() {} // Конструктор Derived пуст.
... // Или все-таки нет?
private:
string dml, dm2, dm3; // Члены производного класса 1-3.
};
Данный конструктор выглядит, как идеальный кандидат для встраивания,
поскольку не содержит кода. Но это впечатление не всегда верно. Хотя внешне ка-
жется, что конструктор пуст, в действительности он может содержать достаточно
большое количество кода.
При создании и удалении объектов C++ дает целый ряд гарантий. Правило 5
описывает, как при использовании new динамически создаваемые объекты автома-
тически инициализируются конструкторами, и как при использовании delete вы-
зываются соответствующие деструкторы. Правило 13 объясняет, что когда вы со-
здаете объект, автоматически создается каждый базовый класс и каждый член
класса, а удаление объектов автоматически влечет обратный процесс. В правилах
5 и 13 изложено, что в таком случае предписывает C++, хотя и ничего не «гово-
рит» о том, как именно это должно произойти. Решение остается за разработчи-
ками компиляторов, и совершенно ясно, что все автоматические действия не мо-
гут происходить сами по себе. В вашей программе должен существовать код,
реализующий такое поведение, - код, написанный разработчиком компилятора
и вставляемый в программу при компиляции; и этот код требуется где-нибудь
разместить. Иногда он попадает в конструкторы и деструкторы, поэтому некото-
рые компиляторы генерируют для якобы пустого конструктора класса Derived
в вышеприведенном примере код, эквивалентный следующему:
Правило 33
// Возможная реализация конструктора Derived.
Derived::Derived()
{
// Если объект должен размещаться в куче, выделяем
// для него там память; дополнительную информацию
//об operator new вы можете найти в правиле 8.
if (этот объект размещается в куче)
this = :zoperator new(sizeof(Derived));
Base: :Base() ; // Инициализация базового класса.
dml.string (); // Создаем dml.
dm2 . string () ; // Создаем dm2 .
dm3 . string () ; // Создаем dm3 .
}
Вы никогда бы не смогли скомпилировать такой код, поскольку он использует
некорректные в C++ выражения - по крайней мере, некорректные для использова-
ния вами. С одной стороны, внутри конструктора у вас нет способа определить, раз-
мещается ли объект в куче. С другой стороны, присваивание значений this запре-
щено. И кроме того, нельзя вызывать конструкторы посредством функциональных
вызовов. Дело здесь не в корректности кода. Суть заключается в том, что в кон-
структор может быть скрытым образом вставлен код, вызывающий (при необхо-
димости) operator new, конструирующий базовые классы и члены класса.
Если это действительно так, то конструкторы увеличиваются в размере, что де-
лает их менее привлекательными для встраивания. Конечно, все вышесказанное
применимо и к конструктору класса Base, а если он встраивается, весь его код бу-
дет встроен в конструктор Derived (через вызов конструктора Base из конструк-
тора Derived). Если конструктор для string также окажется встраиваемым, кон-
структор Derived будет содержать пять копий кода этой функции, ио одной копии
для каждой строки объекта Derived (две он наследует и три объявляет сам). Те-
перь вы, наверное, уже понимаете, что объявление встраиваемого конструктора
Derived - это не такое уж очевидное решение. И, разумеется, аналогичные рассуж-
дения применимы к деструктору Derived, который тем или иным способом дол-
жен проконтролировать, чтобы все объекты, инициализированные конструктором
Derived, надлежащим образом удалялись. Кроме того, при этом может быть необ-
ходимо высвобождение динамически выделенной памяти, ранее занятой только что
Удаленным объектом Derived.
Разработчики библиотек должны проводить оценку целесообразности объяв-
ления функций inline, поскольку встраиваемые функции делают невозможным
Их обновление в бинарной форме. Другими словами, если f - встраиваемая функ-
ция из библиотеки, то пользователи библиотеки компилируют тело f в свои при-
ложения. Если разработчик библиотеки позднее решит изменить f, то все пользо-
ватели, применявшие f, должны перекомпилировать приложения. Зачастую это
Весьма нежелательно (см. также правило 34). С другой стороны, если f - не встра-
иваемая функция, изменения в f требуют только перекомпоновки. Это намного
Менее обременительная операция, чем перекомпиляция, а если библиотека, содер-
жащая функцию, загружается динамически, то изменения могут остаться для
Пользователей незамеченными.
136
Классы и функции: реализация
При разработке программного обеспечения важно иметь в виду все вышепе-
речисленные соображения, по с практической точки зрения при написании кода
один факт доминирует над всеми остальными: у большинства отладчиков возни-
кают проблемы со встраиваемыми функциями.
Это совсем не удивительно. Как установить точки останова в функции, которой
нет? Как трассировать такую функцию? Как перехватывать ее вызовы? Если вы не
проявите чрезвычайную изобретательность, вам не удастся выполнить ничего по-
добного. К счастью, проблемы с отладкой дают нам логически обоснованную стра-
тегию для определения, какую функцию нужно объявлять inline, а какую - нет.
Первоначально никакие функции нс следует делать встраиваемыми, или, по
крайней мере, нужно ограничиться .самыми тривиальными функциями, вроде при-
веденной ниже функции аде:
class Person {
public:
int age() const { return personAge; }
private:
int personAge;
};
Применяя встраиваемые функции с должной аккуратностью, вы не только по-
лучаете возможность пользоваться отладчиком, но и определяете встраиванию
подобающий удел: тонкая оптимизация вручную. Не забывайте об эмпирическом
правиле «80-20», которое утверждает, что программа тратит 80% времени па вы-
полнение 20% кода. Это важное правило, поскольку оно напоминает, что цель раз-
работчика программного обеспечения - идентифицировать те 20% кода, которые
действительно способны увеличить производительность программы. Можно до бес-
конечности оптимизировать и объявлять функции inline, по все это будет про-
стой тратой времени, пока вы не сделаете этого с нужными функциями.
Как только вы идентифицировали наиболее важные функции приложения, -
те, для которых встраивание в самом деле может сыграть определенную роль (на-
бор этих функций зависит от используемой вами архитектуры), - без колебаний
объявляйте их inline. В то же время отслеживайте проблемы, возникающие при
чрезмерном увеличении программного кода, а также следите за предупреждениями
компилятора (см. правило 28), указывающими, что функции не были встроены.
При разумном использовании встраиваемые функции - неоценимый инстру-
мент программиста на C++, ио, как было продемонстрировано выше, они не на-
столько просты и однозначны, сколь могло показаться.
Правило 34. Уменьшайте зависимости файлов
при компиляции
Рассмотрим самую обыкновенную ситуацию. Вы открываете свою программу на
C++ и вносите незначительные изменения в реализацию класса. Заметьте, не в ин-
терфейс, а просто в реализацию - только в закрытые члены. Далее вы начинаете
Правило 34
137
перестраивать программу, рассчитывая, что это займет лишь несколько секунд, по-
скольку был модифицирован только один класс. Вы щелкаете по Rebuild или на-
бираете make (либо какой-то эквивалент) и... удивлены, а затем подавлены, когда
обнаруживаете, что перекомпилируется и заново компонуется весь мир\
Не правда ли, вам это скоро надоест?
Проблема связана с тем, что C++ не проводит сколько-нибудь значительного
различия между интерфейсом и реализацией. В частности, определения классов
включают в себя не только спецификацию интерфейса, но также и целый ряд де-
талей реализации. Например:
class Person {
public:
Person(const string^ name, const Date& birthday,
const Address& addr, const Country& country);
virtual -PersonO ;
...II Для простоты конструктор и оператор присваивания опущены,
string name () const;
string birthDate() const;
string address () const;
string nationality() const;
private:
string name_; // Деталь реализации.
Date birthDate_; // Деталь реализации.
Address address_; // Деталь реализации.
Country citizenship_; // Деталь реализации.
Такой класс вряд ли завоюет вам Нобелевскую премию, хотя и иллюстрирует ин-
тересное соглашение имен, проводящее различие между закрытыми данными и от-
крытыми членами, когда одно и то же название имеет смысл для обоих (первые по-
мечены заключительным символом подчеркивания). В отношении класса Person
важно сказать следующее: он не может быть скомпилирован, если компилятор так-
же не имеет доступа к определениям классов, с помощью которых реализуется
Person, а именно: string, Date, Address и Country. Такие определения обычно
Даются с использованием директивы #include, поэтому весьма вероятно, что в на-
чале файла, определяющего класс Person, вы найдете что-нибудь подобное:
#include <string>
#include "date.h"
#include "address.h"
#include "country.h"
// Для типа string (см. правило 49) .
К сожалению, это устанавливает зависимости между файлом, определяющим
Person, и включаемыми файлами. В результате, если любой из этих вспомогательных
классов изменит свою реализацию, или если изменится реализация любого из классов,
от которых они зависят, то файл, содержащий Person, необходимо будет перекомпили-
ровать, а вместе с ним и все остальные файлы, использующис класс Person. Для пользо-
вателей Person это может быть более чем обременительным и поэтому совершенно
неприемлемым.
138
Классы и функции: реализация
Можно задаться вопросом: почему C++ настаивает тга размещении деталей
реализации в определениях классов? Почему, например, нельзя определить
Person следующим образом:
class string; // "Концептуальное" предварительное объявление
// для типа string (подробнее см. правило 49) .
class Date; // Предварительное объявление.
class Address; // Предварительное объявление.
class Country; // Предварительное объявление.
class Person {
public:
Person(const strings name, const Dates birthday,
const AddressS addr, const CountryS country);
virtual ~Person();
...II Конструктор копирования, operator^.
string name() const;
string birthDate() const;
string address() const;
string nationality () const;
Разве нельзя указать детали реализации класса в другом месте? Если бы это
было возможно, пользователи Person должны были бы его перекомпилировать
только при изменении его интерфейса. Поскольку при работе над большим про-
граммным проектом интерфейс, как правило, четко оформляется прежде, чем реа-
лизация, такое отделение интерфейса от реализации могло бы сэкономить многие
часы перекомпиляции и перекомпоновки.
Увы, этот идеалистичный сценарий не выдерживает столкновения с реальным
миром, что иллюстрирует следующий пример:
int main()
{
int х; // Определяем целое.
Person р(...); // Определяем Person
// (аргументы для простоты опущены) .
}
Когда компилятор обнаруживает определение х, он «понимает», что должен
выделить пространство, достаточное для размещения int. Нет проблем: каждый
компилятор «знает», какова длина int. Встречая определение р, он, безусловно,
«учитывает» тот факт, что нужно выделить место, необходимое для Person, но
откуда ему «знать», сколько именно места потребуется? Единственный спосоо
получить эту информацию - справиться в определении класса, но если бы в опре-
делении классов можно было опускать детали реализации, как компилятор «вы-
яснил» бы, сколько памяти необходимо выделить?
В принципе это пе является непреодолимой трудностью. Языки, подобные
Smalltalk, Eiffel, Java, легко обходят ее. Способ, которым они пользуются, - выде-
ление достаточного пространства для указателей па определяемый объект. Иначе
Правило 34
139
говоря, эти языки интерпретируют вышеприведенный код так, как если бы по-
следний был написан следующим образом:
int main()
{
int х;
Person *р;
11 Определяем целое.
// Определяем указатель на Person.
}
Легко заметить, что это вполне законный код на C++. Оказывается, вы и сами
можете имитировать «сокрытие реализации объекта указателем».
Приведем пример, иллюстрирующий возможность применения рассматрива-
емого приема для отделения интерфейса класса Person от его реализации. Во-
первых, в заголовочном файле Person располагается следующее:
// Компиляторам для конструктора Person по-прежнему
// необходима информация об этих типах.
class string; II В правиле 49 рассказывается, почему это
// неверное объявление для string.
class Date;
class Address;
class Country;
// Класс Personlmpl содержит детали реализации объекта Person;
11 это просто предварительное объявление имени класса.
class Personlmpl;
class Person {
public:
Person(const strings name, const Dates birthday,
const AddressS addr, const CountryS country) ;
virtual -PersonO ;
...II Конструктор копирования, operator^.
string name() const;
string birthDate() const;
string address () const;
string nationality() const;
private:
Personlmpl *impl; // Указатель на реализацию.
};
Теперь пользователи класса Person не имеют никакого понятия об устрой-
стве строк, дат, адресов и национальностей. Эти классы можно необходимым об-
разом модифицировать, оставляя пользователей Person в блаженном неведении
происходящего. Более того, пользователи могут счастливо избежать и перекомпи-
ляции. Далее, поскольку они не видят подробности реализации Person, они вряд
ли напишут код, зависящий от этих деталей. Вот настоящее разделение интерфей-
са и реализации!
Ключом к такому разделению служит замена зависимостей между определения-
ми классов и объявлениями классов. Это все, что вам необходимо знать об уменьше-
нии зависимостей. Каждый раз, когда это целесообразно, делайте заголовочные
140
Классы и функции: реализация
файлы самодостаточными; в противном случае используйте зависимость между
объявлениями, а нс определениями классов. Все остальное вытекает из только что
изложенной стратегии проектирования.
Сформулируем три практических следствия:
□ избегайте использования объектов, если есть шанс обойтись ссылками или
указателями. Вы можете определить ссылки и указатели, имея только объяв-
ление этого типа. Определение объектов требует наличия определения типа;
□ по возможности используйте объявления, а не определения классов. Обратите
внимание, что для объявления функции, использующей некоторый класс,
вам никогда не требуется определение этого класса, даже если функция по-
лучает или возвращает объект класса по значению:
class Date; // Объявление класса.
Date returnADate() // Правильно, необходимость
void takeADate (Date d) ; // в определении отсутствует.
Как правило, передача объекта по значению - не очень хорошая идея (см.
правило 22), но если по той или иной причине вы будете вынуждены ею вос-
пользоваться, это никак не оправдает введение ненужных зависимостей.
Если вы были удивлены, узнав, что такие объявления для returnADate
и takeADate компилируются, не требуя определения Date, вы не одиноки:
в свое время удивился и я. Все, однако, не так уж странно, поскольку опре-
деление Date должно быть доступным, когда кто-либо вызывает данные
функции. Да, я знаю, о чем вы думаете: зачем определять функции, которые
никто не вызывает? Ответ прост. Дело не в том, что никто не вызывает эти
функции, а в том, что их вызывают не все. Например, если у вас имеется биб-
лиотека, содержащая сотни объявлений функций, возможно разнесенных по
пространствам имен (см. правило 28), маловероятно, что каждый пользова-
тель вызовет каждую функцию. Таким образом можно не навязывать пользо-
вателю искусственную зависимость от определений типов, которые в дей-
ствительности ему не требуются; бремя ответственности за определения
классов переносится (посредством директивы #include) с объявлений
функций в файлах заголовков па файлы, содержащие вызовы функций;
□ не включайте в ваш заголовочный файл те файлы, которые не нужны для
его компиляции. Вместо этого вручную объявите необходимые вам классы, и
пусть пользователи сами включают дополнительные файлы, необходимые
для компиляции их кода. Некоторые пользователи могут начать жаловать-
ся на «причиненные неудобства», но будьте уверены: вы гораздо больше
экономите их усилия, чем доставляете им хлопот. В действительности этот
прием считается настолько удачным, что он нашел применение в стандарт-
ной библиотеке C++ (см. правило 49): заголовочный файл <iosfwd> со-
держит объявления (и только объявления) типов библиотеки потоков вво-
да-вывода.
Классы, которые подобно Person содержат только указатели на реализации,
часто называются классами-дескрипторами или конвертами. (В первом случае
класс, на который они указывают, именуется телом, а во втором - письмом.)
Правило 34
141
Иногда вы можете услышать, что такие классы называют Чеширскими Котами
(в честь кота из сказки «Алиса в стране чудес», который при желании мог после
исчезновения оставить только свою улыбку).
Ответ на вопрос, каким образом работают классы-дескрипторы, прост: они
перенаправляют все вызовы функций соответствующим классам-телам, а послед-
ние и производят всю реальную работу. Вот как были бы реализованы функции-
члены класса Person:
^include "Person.h" // Поскольку мы реализуем класс Person, мы должны
// включить определение этого класса.
((include "Personlmpl.h" // Мы должны также включить определение класса
/ / Personlmpl, иначе мы не сможем вызывать
// его функции-члены. Заметьте, что Personlmpl
// имеет в точности те же самые функции-члены,
// что и Person: их интерфейсы идентичны.
Person::Person(const string& name, const Date& birthday,
const Address& addr, const Country& country)
{
impl = new Personlmpl(name, birthday, addr, country);
}
string Person::name() const
{
return impl->name();
}
Обратите внимание на то, как конструктор класса Person вызывает конструк-
тор Personlmpl (неявно используя new, см. правило 5) и как Person: : name вы-
зывает Personlmpl: :name. Это важный момент. Превращение Person в класс-
дескриптор не меняет его поведения, - изменяется только место, в котором это
поведение реализовано.
Альтернатива подходу с использованием класса-дескриптора - сделать Person
абстрактным классом специального типа, называемого классом-протоколом. Класс-
протокол по определению не включает в себя реализацию. Его назначение - задать
интерфейс для производных классов (см. правило 36). В результате он обычно не
содержит ни членов данных, ни конструкторов, а включает в себя виртуальный
Деструктор (см. правило 14) и набор чисто виртуальных функций, определяющих
интерфейс. Класс-протокол ддя. Person мог бы выглядеть следующим образом:
class Person {
public:
virtual -Personf);
virtual string name () const = 0;
virtual string birthDatef) const = 0;
virtual string address () const - 0;
virtual string nationality () const - 0;
};
Пользователи этого класса Person должны программировать в терминах ука-
зателей и ссылок на Person, поскольку инстанцировать класс, содержащий чисто
142
Классы и функции: реализация
виртуальные функции, невозможно. (Однако возможно инстанцировать классы,
производные от Person — об этом см. ниже.) Пользователям классов-протоколов,
как и пользователям классов-дескрипторов, нет нужды проводить перекомпиля-
цию до тех пор, пока не изменяется интерфейс класса-протокола.
Конечно, пользователи класса-протокола должны иметь некоторый способ соз-
дания новых объектов. Обычно они это делают, вызывая функцию, исполняющую
роль конструкюра для спрятанных (производных) классов - тех, которые ин-
станцируются. Такие функции называют по-разному (например, функциями-фаб-
риками или виртуальными конструкторами), но все они действут одинаково: воз-
вращают указатели па динамически размещаемые объекты, поддерживающие
интерфейс классов-протоколов. Такую функцию можно было бы объявить сле-
дующим образом:
// makePerson - это "виртуальный конструктор" (также называемый
// "функция-фабрика") для объектов, поддерживающих интерфейс
// класса Person.
Person*
makePerson(const strings name, // Возвращает указатель
const Dates birthday, // на новый объект Person,
const AddressS addr, // инициализированный
const CountryS country) ; lie данными аргументами.
а использовать так:
string name;
Date dateOfBirth;
Address address;
Country nation;
// Создаем объект, поддерживающий интерфейс Person.
Person *pp = makePerson(name, dateOfBirth, address, nation);
cout << pp->name() // Работаем с объектом, используя интерфейс Person.
« " was born on "
« pp->birthDate()
« " and now lives at "
<< pp->address();
delete pp; // Удаляем объект, когда он больше не нужен.
Поскольку функции, подобные makePerson, тесно связаны с классом-прото-
колом, чей интерфейс поддерживается создаваемыми ими объектами, вы посту-
пите вполне разумно, объявив их статическими членами класса-протокола:
class Person {
public:
... II Как выше.
// Теперь makePerson - член класса.
static Person * makePerson(const strings name,
const Dates birthday,
const AddressS addr.
г 143
Правило 34
const Country^ country};
};
Это поможет избежать загромождения глобального пространства имен - или
любых других пространств имен - множеством функций такого рода (см. также
правило 28).
Конечно, в какой-то момент времени должны быть реализованы конкретные
классы, поддерживающие интерфейс класса-протокола, и вызваны настоящие
конструкторы. Все это скрыто внутри файлов, реализующих виртуальные кон-
структоры. Например, класс-протокол Person мог бы иметь конкретный произ-
водный класс Real Person, обеспечивающий реализацию наследуемых виртуаль-
ных функций:
class RealPerson: public Person {
public:
RealPerson(const strings name, const Dates birthday,
const AddressS addr, const Country& country)
: name_(name), birthday_(birthday),
address_(addr), country_(country)
{}
virtual -RealPerson() {}
string name () const; // Реализация этих
string birthDatef) const; // функций не показана,
string address () const; // но ее нетрудно
string nationality () const; // себе представить.
private:
string name_;
Date birthday^;
Address address_;
Country country_;
Имея класс RealPerson, несложно написать Person: :makePerson:
Person * Person::makePerson(const strings name,
const Dates birthday,
const Addresss addr,
const Countrys country)
{
return new RealPerson(name, birthday, addr, country) ;
)
RealPerson демонстрирует один из двух наиболее распространенных меха-
низмов реализации классов-протоколов: он наследует спецификацию своего ин-
терфейса от класса-протокола (Person), а затем реализует функции этого интер-
фейса. Второй способ реализации класса-протокола использует множественное
Наследование, которое подробно рассматривается в правиле 43.
Итак, классы-дескрипторы и классы-протоколы отделяют интерфейс от реа-
лизации, тем самым уменьшая зависимость между файлами при компиляции.
144
Классы и функции: реализация
Теперь, я уверен, вы ждете примечания мелким шрифтом: «Во сколько обойдет-
ся этот хитрый фокус». Цена вполне обычная в мире программирования: неко-
торое уменьшение скорости выполнения программы плюс дополнительный рас-
ход памяти для каждого из объектов.
Применительно к классам-дескрипторам функции-члены должны использо-
вать указатель на реализацию, чтобы добраться до данных самого объекта. Для
каждого обращения это добавляет один уровень косвенной адресации. Кроме
того, к количеству памяти, требуемому для хранения каждого объекта, вам необ-
ходимо добавить размер указателя. И наконец, указатель на реализацию должен
быть инициализирован (в конструкторе класса-дескриптора), чтобы он указывал
на динамически размещаемый объект реализации; следовательно, вы навлекаете
на себя еще и «накладные расходы», сопровождающие динамическое выделение
памяти и последующее ее высвобождение (см. правило 10).
Для классов-протоколов каждый функциональный вызов виртуален, поэто-
му всякий раз при вызове функции вы платите за косвенный переход (см. пра-
вило 14). Кроме того, классы, производные от класса-протокола, должны содер-
жать указатель на таблицу виртуальных функций (и снова см. правило 14). Этот
указатель может увеличить количество памяти, необходимое для хранения
объекта, в зависимости от того, является ли класс-протокол единственным ис-
точником виртуальных функций объекта.
Наконец, ни классы-дескрипторы, ни классы-протоколы не могут извлечь вы-
году из использования встраиваемых функций. Для практического применения
встраиваемых функций требуется доступ к деталям реализации, а именно его
классы-дескрипторы и классы-протоколы призваны в первую очередь ограничить.
Однако было бы серьезной ошибкой отказываться от классов-дескрипторов
и классов-протоколов просто потому, что их использование связано с «дополни-
тельными расходами». То же самое можно сказать и о виртуальных функциях, но
вы ведь не отказываетесь от их применения. (В противном случае вы читаете не ту
книгу.) Рассмотрите возможность использования предлагаемых приемов в про-
цессе эволюции ваших программ. Применяйте классы-дескрипторы и классы-
протоколы в процессе разработки для того, чтобы уменьшить влияние изменений
в реализации на пользователей. Если вы можете показать, что различие в скоро-
сти и/или размере настолько существенно, что во имя повышения эффективно-
сти оно оправдывает увеличение зависимости между классами, то на конечной
стадии реализации заменяйте классы-дескрипторы и классы-протоколы конкрет-
ными классами. Надеюсь, однажды появятся средства, позволяющие выполнять
этот тип преобразования автоматически.
Умелое использование классов-дескрипторов, классов-протоколов и конкрет-
ных классов позволит вам разрабатывать эффективно выполняемое и легко моди-
фицируемое программное обеспечение, но при этом появляется новый серьезный
недостаток: вы лишаете себя перекуров на время перекомпиляции ваших программ.
Глава 6. Наследование
и объектно-ориентированное
проектирование
Многие полагают, что наследование - это основа объектно-ориентированного
программирования. Действительно ли это так - вопрос спорный, но количество
правил в других разделах данной книги должно убедить вас, что для эффектив-
ного программирования на C++ в вашем распоряжении имеется гораздо больше
средств, чем просто определение того, какие классы от каких наследуют.
Тем не менее проектирование и реализация иерархии классов не имеет анало-
гии в мире С. Несомненно, именно в сфере наследования и объектно-ориентиро-
ванного программирования вы с наибольшей вероятностью можете столкнуться
с необходимостью переосмысления своего подхода к разработке программного
обеспечения. Более того, C++ предоставляет широкий набор объектно-ориенти-
рованных «строительных блоков», включая открытые, защищенные и закрытые
базовые классы, виртуальные и невиртуальные базовые классы, виртуальные и не-
виртуальные функции-члены. Все эти возможности взаимодействуют не только
одна с другой, но также и с другими компонентами языка. В результате, пытаясь
понять, что означает каждый из этих инструментов, когда он может быть исполь-
зован и как взаимодействует с теми аспектами C++, которые не являются объек-
тно-ориентированными, вы можете столкнуться с рядом трудностей.
Ситуация еще больше осложняется ввиду того обстоятельства, что различные
средства языка на первый взгляд служат практически одинаковым целям. Приве-
дем некоторые примеры:
1. Вам нужен набор классов, у которых имеется множество общих характерис-
тик. Следует ли использовать наследование и производить все эти классы
из общего базового класса или требуется применять шаблоны и генериро-
вать их на основе общего скелета кода?
2. Класс А должен быть реализован с помощью класса В. Должен ли А содер-
жать элемент данных типа В, или А надо использовать закрытое наследова-
ние от класса В?
3. Вам необходимо спроектировать типизированный гомогенный контейнер-
ный класс, отсутствующий в стандартной библиотеке. (Список контейнеров,
содержащихся в стандартной библиотеке, приведен в правиле 49.) Следует
ли вам использовать шаблоны, или более целесообразно создать интерфейс
с контролем типов для класса, который сам реализован с использованием не-
типизироваиных (void*) указателей?
146 t
mr
Наследование и ООП
В правилах этого раздела я даю подсказки, как решать задачи такого рода, хотя
и не беру на себя смелость осветить все вопросы объектно-ориентированного про-
граммирования. Вместо этого я сосредоточиваюсь на объяснении того, что озна-
чают различные возможности C++ или что в действительности происходит, когда
вы используете ту или иную возможность. Например, открытое наследование оз-
начает «класс есть разновидность класса» (см. правило 35), и, если вы попытае-
тесь использовать его иначе, вас ожидают проблемы. Аналогично виртуальная
функция означает «интерфейс должен быть наследован», а невиртуальная - «на-
следуется и интерфейс, и реализация». Недопонимание разницы между этими
значениями сослужило плохую службу многим программистам.
Когда вы осознаете смысл разнообразных конструкций C++, ваш подход
к объектно-ориентированному программированию станет совсем другим. Из
упражнения по сравнению языковых конструкций процесс проектирования пре-
вратится в раздумье над тем, что вы хотите сказать о вашей программе. Как толь-
ко это станет ясно, вы без труда сможете перевести свой замысел в соответствую-
щие конструкции C++.
Исключительно важна возможность говорить то, что вы думаете, и понимать
то, что говорите. Нижеследующие правила посвящены подробному исследова-
нию средств эффективного достижения этой цели. Правило 44 обобщает соотно-
шения между объектно-ориентированными конструкциями C++ и их значением.
Оно служит подходящим завершением этого раздела, а также кратким справоч-
ником для дальнейшего использования.
Правило 35. Используйте открытое наследование
для моделирования отношения
«есть разновидность»
В своей книге «Кто-то должен бодрствовать, пока остальные спят» (Some must
watch while some must sleep. W. H. Freeman and Company, 1974) Вильям Демент (Wil-
liam Dement) рассказывает о том, как он пытался донести до студентов наиболее
важные идеи своего курса. Утверждается, говорил он своей группе, что средний анг-
лийский школьник помнит из уроков истории только то, что битва при Гастингсе
произошла в 1066 году. Даже если ученик почти ничего не запомнил из курса исто-
рии, 1066 год остается в его памяти. Демент пытался внушить слушателям несколь-
ко основных идей, в частности ту любопытную истину, что таблетки против бес-
сонницы вызывают бессонницу. Он призывал своих студентов хранить в памяти
ряд ключевых фактов, даже если забудется все, что обсуждалось на протяжении
курса, и в течение семестра неоднократно возвращался к нескольким фундаменталь-
ным заповедям.
Последним на заключительном экзамене был следующий вопрос: «Напишите,
какой факт из тех, которые обсуждались на лекциях, вы запомните на всю жизнь».
Проверяя работы, Демент был ошеломлен. Практически все упомянули 1066 год.
Теперь я с трепетом хочу провозгласить, что единственным наиболее важным
правилом в объектно-ориентированном программировании на C++ является сле-
дующее: открытое наследование означает «класс есть разновидность класса».
Твердо запомните это.
Правило 35
147
Если вы пишете, что класс D («производный») наследует от открытого класса
В («базового»), вы сообщаете компилятору C++ (а заодно и людям, читающим
ваш код), что каждый объект типа D также является объектом типа В, но не наобо-
рот. Вы говорите, что В представляет собой более общую концепцию, чем D, a D -
более конкретную концепцию, чем В. Вы утверждаете, что везде, где может быть
использован объект В, может быть использован и объект D, поскольку объект
типа D является объектом типа В. С другой стороны, если вам необходим объект
типа D, объект В не подойдет: каждый объект класса D «есть разновидность» В,
ио не наоборот.
Такой интерпретации открытого наследования придерживается C++. Рассмот-
рим следующий пример:
class Person { ... };
class Student: public Person { ... };
Здравый смысл и опыт подсказывают нам, что каждый студент - это человек,
по не каждый человек - студент. Именно такую взаимосвязь подразумевает дан-
ная иерархия. Мы ожидаем, что всякое утверждение, справедливое для человека, -
например, что у него есть дата рождения, - справедливо и для студента, по не все,
что верно для студента, - например, что он учится в каком-то определенном ин-
ституте, - верно для человека в общем случае. Понятие человека более масштаб-
ное, чем понятие студента; студент - это особый «тип» человека.
Применительно к C++ это выглядит следующим образом: любая функция,
требующая аргумента типа Person, или указателя на Person, или ссылки на
Person, примет объект класса Student, или указатель на Student, или ссылку
на Student:
void dance(const Persons p) ; void study(const Students s) ; / / Все люди могут танцевать. // Только студенты учатся.
Person p; // р - человек (Person).
Student s; // s - студент (Student).
dance(p); // Нормально, р типа Person.
dance(s); // Нормально, s типа Student, а студент // есть разновидность человека.
study(s); // Хорошо.
study(p); // Ошибка! р не студент.
Это верно только для открытого наследования. C++ будет вести себя так,
как было описано выше, только если student является производным классом
от Person, который использует открытое наследование. Закрытое наследование
означает нечто совершенно иное (см. правило 42), и никто, по-видимому, не зна-
ет, что должно означать защищенное наследование.
Идея тождества открытого наследования и понятия «есть разновидность» ка-
жется достаточно очевидной, по не всегда все так просто. Иногда интуиция может
ввести в заблуждение. Рассмотрим следующий пример: во-первых, пингвин - пти-
ца; во-вторых, птицы умеют летать. 1 Простодушно попытавшись выразить это на
C++, получим:
class Bird {
public:
virtual void fly();
Наследование и ООП
// Птииы умеют летать.
};
class Penguin: public Bird { // Пингвины - птицы.
};
Неожиданно мы столкнулись с затруднением. Утверждается, что пингвин мо-
жет летать, что, как известно, неверно. В чем тут дело?
В данном случае насподводит неточность разговорного языка. Говоря, что пти-
цы умеют летать, мы в действительности утверждаем не то, что все птицы летают,
а только то, что обычно они обладают такой способностью. Если бы мы выбира-
ли формулировки поточнее, то вспомнили бы, что существует несколько видов
птиц, которые не летают, и пришли бы к следующей иерархии, которая значитель-
но лучше моделирует реальность:
class Bird {
...II Функция fly не объявлена.
};
classFlyingBird: public Bird {
public:
virtual void fly();
};
class NonFlyingBird: public Bird {
...II Функция fly не объявлена.
};
class Penguin: public NonFlyingBird {
...II Функция fly не объявлена.
};
Данная иерархия намного больше, чем первоначальная, соответствует дей-
ствительности.
Но и теперь еще не все закончено с «птичьими делами», поскольку для мно-
гих приложений вполне уместно было бы упоминание о том, что пингвины есть
разновидность птиц. Так, если ваше приложение в основном имеет дело с клюва-
ми и крыльями и никак не отражает способность пернатых летать, вполне сойдет
и исходная иерархия. Это наблюдение, собственно, является лишь подтверж-
дением того, что не существует идеального проекта, который подходил бы для всех
видов программных систем. Выбор проекта зависит от того, что должна делать
система как в данный момент, так и в будущем. Если приложение никак не связа-
но с полетами и не предполагается, что оно будет связано с ними в дальнейшем,
сделать класс Penguin производным от класса Bird - вполне разумное решение.
Оно предпочтительнее решения с разделением на летающих и не летающих птиц,
поскольку в моделируемом вами мире эта характеристика отсутствует. Добавле-
ние лишних классов - свидетельство столь же плохого стиля проектирования, как
и использование неправильных отношений наследования между классами.
Существует другая школа, иначе относящаяся к рассматриваемой проблеме.
Она предлагает переопределить для пингвинов функцию fly так, чтобы в момент
выполнения она возвращала ошибку:
Правило 35
ШМВНКЕЗ
void error(const string^ msg); // Определение в другом месте,
class Penguin; public Bird {
pub!ic:
virtual void fly() { error("Penguins can't fly!"); }
};
Интерпретируемые языки, например Smalltalk, часто используют такой
подход, но важно осознавать, что при этом утверждается нечто совершенно от-
личное от того, что может показаться на первый взгляд. Вы не говорите: «пин-
гвины не умеют летать». Вы говорите: «пингвины умеют летать,- но с их сторо-
ны было бы ошибкой пытаться делать это».
В чем различие? Во времени обнаружения ошибки. Утверждение «пингвин
не умеет летать» может быть поддержано па уровне компилятора, а соответствие
утверждения «Попытка полета ошибочна для пингвинов» реальному положению
дел может быть обнаружено только в момент выполнения программы.
Чтобы обозначить ограничение «Пингвины не умеют летать», следует убе-
диться, что для объектов Penguin эта функция не определена:
class Bird {
... // Функция fly не объявлена.
};
class NonFlyingBird: public Bird {
... 11 Функция fly не объявлена.
};
class Penguin: public NonFlyingBird {
... // Функция fly не объявлена.
};
Если вы попытаетесь заставить пингвина летать, компилятор сделает вам вы-
говор за нарушение правил:
Penguin р;
P.flyO ;
// Ошибка!
Это сильно отличается от подхода, принятого в языке Smalltalk, где ком-
пилятор не скажет ни слова.
Философия C++ принципиально отличается от философии Smalltalk, и до тех
пор, пока вы программируете на C++, вам лучше следовать его рецептам Кроме того,
обнаружение ошибок на этапе компиляции, а не в момент исполнения, имеет опре-
деленные технические преимущества (см. правило 46).
Возможно, вы согласитесь с тем, что вам недостает орнитоло-
гической интуиции, но вы вполне можете полагаться на свои по-
знания в элементарной геометрии, не так ли? Тогда ответьте на сле-
дующий простой вопрос: должен ли класс Square (квадрат)
открыто наследовать свойства класса Rectangle (прямоугольник)?
«Конечно! - скажете вы. - Каждый знает, что квадрат - это
Прямоугольник, а обратное утверждение в общем случае невер-
но». Что ж, это правильно, по крайней мере для школы. Но мы
ведь решаем задачи посложнее школьных.
Rectangle
150 I
Наследование и ООП
Рассмотрим следующий код:
class Rectangle {
public:
virtual void setHeight(int newHeight);
virtual void setwidth(int newWidth);
virtual int height() const; // Возвращают
virtual int width!) const; // текущие значения.
void makeBigger (Rectangles r)
int oldHeight = r.height();
r.setwidth(r.width() + 10);
assert(r.height() == oldHeight);
// Функция увеличивает площадь г.
// Увеличить ширину г на 10.
// Убедиться, что высота
//г неизменна.
Ясно, что утверждение assert выполнится всегда. Функция makeBigger из-
меняет только ширину г. Высота остается постоянной.
Теперь приведем следующий код, который посредством открытого наследова-
ния позволяет рассматривать квадраты как частный случай прямоугольников:
class Square: public Rectangle { ... };
Square s;
assert (s. width () == s.heightf)); // Должно быть справедливо
// для всех квадратов.
makeBigger (s) ; // Из-за наследования s есть разновидность Rectangle,
// поэтому мы можем увеличить его площадь.
assert (s.width() == s.heightO); // Все равно должно быть
/ / справедливо для всех квадратов.
Как и в предыдущем примере, здесь ясно, что последнее утверждение будет
выполняться всегда. По определению ширина квадрата равна его высоте.
Но теперь перед нами встает проблема, как следующие утверждения:
□ перед вызовом makeBigger высота s равна ширине;
□ внутри makeBigger ширина s изменяется-, а высота нет;
□ после выхода из makeBigger высота s снова равна ширине. (Заметьте, что s
передается по ссылке, поэтому makeBigger модифицирует s, а не его копию.)
Так что же?
Добро пожаловать в удивительный мир открытого наследования, где интуиция,
приобретенная вами в других сферах науки, включая математику, иногда оказыва-
ется плохим помощником! Основная трудность в данном случае состоит в том, что
некоторые утверждения, справедливые для прямоугольника (его ширина может
быть изменена независимо от высоты), не выполняются для квадрата (его ширина
и высота должны оставаться одинаковыми). Открытое наследование, тем не менее,
предполагает, что все, что применимо к базовому объекту - все! - также примени-
мо и к объектам производных классов. В ситуации с прямоугольниками и квадрата-
ми (а также в аналогичных случаях, включая множества и списки из правила 40),
это условие не выполняется, поэтому использование открытого наследования для
151
Правило 36
моделирования здесь ошибочно. Компилятор, конечно, этого не запрещает, но, как
мы только что видели, не существует гарантий, что такой код будет вести себя
должным образом. Любому программисту хорошо известно (некоторые знают это
лучше других): сам факт компиляции кода не означает, что он будет работать.
Все же не стоит беспокоиться, что приобретенная за многие годы разработки
программного обеспечения интуиция окажется бесполезной в объектно-ориенти-
рованном программировании. Эти знания по-прежнему в цене, но теперь, когда вы
добавили к своему арсеналу наследование, вам придется дополнить свою интуи-
цию новым пониманием, позволяющим создавать приложения с использованием
наследования. Со временем идея наследования Penguin от Bird или Square от
Rectangle будет казаться вам столь же забавной, как функция объемом в несколь-
ко страниц. Такое решение может оказаться правильным, но это маловероятно.
Конечно, отношение «есть разновидность» не единственное, возможное между
классами. Два других, достаточно распространенных отношения - «класс содержит
класс» и «класс реализуется посредством класса». Они рассмотрены в правилах
40 и 42. Очень часто при проектировании на C++ весь проект идет вкривь и вкось
из-за того, что эти взаимосвязи моделируются отношением «есть разновидность»,
поэтому вам следует убедиться, что вы понимаете различия между данными от-
ношениями и знаете, каким образом их лучше всего моделировать в C++.
Правило 36. Различайте наследование интерфейса
и наследование реализации
Внешне простая идея открытого наследования при ближайшем рассмотрении
оказывается состоящей из двух различных частей: наследования интерфейса
функций и наследования реализаций функций. Различие между этими двумя
типами наследования в точности соответствует различию между объявлением
и определением функций, обсуждавшемуся во введении к этой книге.
При разработке классов иногда требуется, чтобы производные классы насле-
довали только интерфейс (объявления) функций-членов; в других случаях необ-
ходимо, чтобы производные классы наследовали и интерфейс, и реализацию функ-
ций, но могли переопределять наследуемую реализацию; а иногда вам может
понадобиться использовать наследование интерфейса и реализации, но без воз-
можности что-либо переопределять.
Чтобы получить лучшее представление о различиях между этими, варианта-
ми, рассмотрим иерархию классов для представления геометрических форм в гра-
фическом приложении:
class Shape {
public:
virtual void draw() const = 0;
virtual void error(const string& msg);
int objectIDO const;
class Rectangle: public Shape { ... };
class Ellipse: public Shape { ...
152
Наследование и ООП
Shape - это абстрактный класс; таковым его делает чисто виртуальная функ-
ция draw. В результате пользователи могут инстанцировать не объекты класса
Shape, а только объекты производных от него классов. Тем не менее Shape ока-
зывает сильное влияние на все производные классы, использующие открытое на-
следование, по следующей причине: интерфейс функций-членов наследуется все-
гда. Как объясняется в правиле 35, открытое наследование означает «есть
разновидность», поэтому все, что верно для базового класса, также верно и для
производных классов. Следовательно, если функция имеет смысл для класса, она
остается применимой и для подклассов.
В классе Shape объявлены три функции. Первая, draw, выводит текущий
объект па дисплей, подразумеваемый по умолчанию. Вторая, error, вызывается
функциями-членами, если необходимо сообщить об ошибке. Третья, objectlD,
возвращает уникальный идентификатор текущего объекта; правило 17 содержит
пример использования подобной функции. Каждая из трех функций объявлена
по-разному: draw - как чисто виртуальная; error - как просто виртуальная;
obj ectID - как невиртуальная функция. Каковы практические последствия этих
различий?
Рассмотрим вначале чисто виртуальную функцию draw. Две наиболее яркие
характеристики чисто виртуальных функций - они должны быть заново объявле-
ны в любом конкретном наследующем их классе, и в абстрактном классе они обыч-
но не определяются. Сопоставьте эти два свойства, и вы придете к пониманию сле-
дующего обстоятельства: цель объявления чисто виртуальной функции состоит
в том, чтобы производные классы наследовали только ее интерфейс.
Это в полной мере относится к функции Shape: : draw, поскольку наиболее
разумное требование ко всем объектам класса Shape заключается в том, что они
должны быть отображены на дисплее, но Shape не может обеспечить разумной
реализации этой функции по умолчанию. Алгоритм создания изображения эллип-
са очень сильно отличается, например, от аналогичного алгоритма построения
прямоугольника. Объявление Shape: : draw можно интерпретировать как следу-
ющее сообщение разработчикам подклассов: «Вы должны обеспечить наличие
функции draw, но у меня нет ни малейшего представления, как вы это собирае-
тесь сделать».
Между прочим, дать определение чисто виртуальной функции возможно.
Иными словами, вы можете создать реализацию для Shape: : draw, и C++ будет
ее компилировать, но единственный способ вызвать ее - квалифицировать имя
функции названием класса:
Shape *ps = new Shape; Shape *psl = new Rectangle; // Ошибка! Shape абстрактный // Хорошо.
psl->draw(); // Вызов Rectangle:-.draw.
Shape *ps2 = new Ellipse; // Хорошо.
ps2->draw(); // Вызов Ellipse::draw.
psl->Shape::draw(); // Вызов Shape::draw.
ps2->Shape::draw(); // Вызов Shape::draw.
Кроме перспективы блеснуть перед друзьями-программистамиво время вече-
ринки, знание этой особенности вряд ли даст вам что-то значительное. Тем не менее,
| 153
Правило 36
как вы увидите в дальнейшем, возможность определения чисто виртуальной функ-
ции может быть использована в качестве механизма обеспечения более безопасной
реализации по умолчанию обычных виртуальных функций.
Иногда бывает полезно объявить класс, не содержащий ничего, кроме чисто
виртуальных функций. Такой класс-протокол может предоставить производным
классам только интерфейс без какой-либо реализации. Классы-протоколы описа-
ны в правиле 34 и еще раз упоминаются в правиле 43.
Ситуация с обычными виртуальными функциями несколько отличается от си-
туации с чисто виртуальными функциями. Как всегда, производные классы насле-
дуют интерфейс функции, но обычные виртуальные функции традиционно обеспе-
чивают реализацию, которую производные классы по своему усмотрению могут
либо переопределять, либо нет. Если вы на минуту задумаетесь над этим, то пойме-
те, что цель объявления обычной виртуальной функции - наследовать в производ-
ных классах как интерфейс функции, так и ее реализацию по умолчанию.
Интерфейс функции Shape: : error говорит о том, что каждый класс должен
поддерживать функцию, которую необходимо вызывать при возникновении
ошибки, но каждый класс волен обрабатывать ошибки наиболее подходящим для
него способом. Если класс не предполагает производить специальные действия,
он может просто прибегнуть к обработке ошибок по умолчанию, обеспечиваемой
классом Shape. То есть объявление Shape: : error говорит разработчикам под-
классов: «Необходимо поддерживать функцию error, но если вы не хотите пи-
сать свою собственную функцию, можете просто использовать версию по умолча-
нию для класса Shape».
Оказывается, иногда может быть опасно использовать обычные виртуальные
функции, которые обеспечивают как объявление функций, так и их реализацию
по умолчанию. Для того чтобы понять, почему имеется такая вероятность, рас-
смотрим иерархию самолетов для компании XYZ Airlines. XYZ располагает
только двумя типами самолетов - модели А и модели В, и оба летают абсолют-
но одинаковым образом. В связи с этим разработчики XYZ проектируют следу-
ющую иерархию:
class Airport {...}; // Представляет аэропорты.
class Airplane {
public:
virtual void fly(const Airports destination);
};
void Airplane::fly(const Airports destination)
{
код no умолчанию для полета самолета
в заданный пункт назначения - destination
}
class ModelA: public Airplane { ... } ;
class ModelB: public Airplane { ... } ;
Чтобы засвидетельствовать, что все самолеты должны поддерживать функцию
fly, и для того, чтобы отразить тот факт, что различные модели могут в принципе
требовать различных реализаций для fly, функция Airplane: : f 1у определена как
154
Наследование и ООП
виртуальная. При этом во избежание написания идентичного кода для классов
ModelA и ModelB в качестве стандартного поведения используется тело функции
Airplane: : fly, которую наследуют как ModelA, так и ModelB.
Это классический пример объектно-ориентированного проектирования. Два
класса имеют общие свойства (способ реализации fly), и, как следствие, общее
свойство реализуется в базовом классе и наследуется обоими классами. Благода-
ря этому проект явным образом выделяет общие свойства, избегает дублирования
кода, благоприятствует проведению будущих модернизаций, облегчает долгосроч-
ную эксплуатацию - иными словами, обеспечивает все то, за что так ценится
объектно-ориентированная технология. Программисты компании XYZ Airlines
могут собой гордиться.
Теперь предположим, что дела XYZ идут в гору, и фирма решает приобрести
новый самолет модели С. Модель С отличается от моделей А и В, например, тем,
что летает по-другому.
Программисты компании XYZ добавляют к иерархии класс для модели с,
но в спешке забывают переопределить функцию fly:
class ModelC: public Airplane {
... // Функция fly не объявлена.
};
Далее они делают что-нибудь в таком роде:
Airport JFK(...); // Аэропорт Джона Кеннеди в Нью-Йорке.
Airplane *ра = new ModelC;
pa->fly(JFK); // Вызов Airplane::fly!
Назревает катастрофа: делается попытка летать на объекте ModelC так, как
если бы это был объект ModelA или ModelB. Такой образ действий вряд ли мо-
жет внушить доверие пассажирам.
Проблема здесь заключается не в том, что Airplane: : f 1у ведет себя опре-
деленным образом по умолчанию, а в том, что такое наследование допускает
неявное применение данной функции для ModelC. К счастью, легко можно пред-
ложить подклассам поведение по умолчанию, но не предоставлять им его, если
они сами об этом не попросят. Весь фокус состоит в том, чтобы разделить интер-
фейс виртуальной функции и ее реализацию по умолчанию. Вот один из спосо-
бов, который позволяет добиться этого:
class Airplane {
public:
virtual void fly(const Airports destination) = 0;
protected:
void defaultFly(const Airports destination);
};
void Airplane::defaultFly(const Airports destination)
{
код no умолчанию для полета самолета
в заданный пункт назначения - destination
}
[ 155
Правило 36 (LB
Обратите внимание, что функция Airplane: : fly была преобразована в чис-
ino виртуальную функцию. Это обеспечивает интерфейс для полета. В классе
Airplane присутствует и реализация по умолчанию, но теперь она представлена
в форме независимой функции, def aultFly. Классы, подобные ModelA и ModelB,
которые хотят использовать поведение по умолчанию, просто применяют встраива-
емый вызов defaultFly внутри fly (см. также информацию о взаимодействии
встраиваемости и виртуальности функций в правиле 33):
class ModelA: public Airplane {
public:
virtual void fly(const Airports destination)
{ defaultFly(destination) ; }
};
class ModelB: public Airplane {
public:
virtual void fly(const Airports destination)
{ defaultFly(destination) ; }
};
Теперь для класса ModelC возможность случайного наследования некорректной
реализации fly исключена, поскольку чисто виртуальная функция в Airplane
вынуждает ModelC создавать свою собственную версию fly.
class ModelC: public Airplane {
public:
virtual void fly(const Airports destination);
};
void ModelC::fly(const Airports destination)
{
код для полета самолета типа ModelC в заданный пункт назначения
}
Эта схема не обеспечивает «защиту от дурака» (программисты все же могут
создать себе проблемы коиированием/вставкой), но она более надежна, чем исход-
ная. Что же касается функции Airplane: :defaultFly, то она защищена, по-
скольку действительно является деталью реализации Airplane и его производ-
ных классов. Пассажиры теперь должны быть обеспокоены только тем, чтобы
Улететь, а не тем, как происходит полет.
Также важно, что Airplane: :defaultFly объявлена как невиртуальная
Функция. Это связано с тем, что никакой подкласс не должен ее перегружать -
обстоятельство, которому посвящено правило 37. Если бы defaultFly была вир-
туальной, перед вами снова встала бы та же самая проблема: что, если некоторые
Подклассы «забудут» переопределить defaultFly должным образом?
Иногда высказываются возражения против идеи разделения функций на обес-
печивающие интерфейс и обеспечивающие реализацию по умолчанию, такие, на-
пример, как fly и defaultFly. Прежде всего, отмечают программисты, это засо-
ряет пространство имен класса близкими названиями функций. Все же они
156
Наследование и ООП
соглашаются с тем, что интерфейс и реализация по умолчанию должны быть разде-
лены. Как разрешить Кажущееся противоречие? Для этого используют тот факт, что
производные классы должны переопределять чисто виртуальные функции, которые
могут иметь и свою собственную реализацию. Вот как допускается использовать
возможность определения чисто виртуальных функций в иерархии Airplane:
class Airplane {
public:
virtual void fly(const Airports destination) = 0;
};
void Airplane: :fly(const Airports destination)
{
код по умолчанию для полета самолета
в заданный пункт назначения - destination
}
class ModelA: public Airplane {
public:
virtual void fly(const Airports destination)
{ Airplane.-.-fly(destination); }
};
class ModelB: public Airplane {
public:
virtual void fly(const Airports destination)
{ Airplane::fly(destination); }
};
class ModelC: public Airplane {
public:
virtual void fly(const Airports destination)
};
void ModelC: : fly (const Airports destination)
{
код для полета самолета типа ModelC в заданный пункт назначения
}
Это практически такой же подход, как и прежде, за исключением того, что тело
чисто виртуальной функции Airplane: : fly играет роль отдельной функции
Airplane: : defaultFly. По существу, fly была разбита на две основные состав-
ляющие. Объявление задает интерфейс (который должен быть использован в про-
изводном классе), а определение задает действия по умолчанию (которые могутп
быть использованы производным классом, но только по явному требованию)-
Однако, производя слияние fly и defaultFly, мы теряем возможность задать
для функций различные уровни доступа: код, который был защищенным (функ-
ция defaultFly), стал открытым (поскольку теперь он находится в f 1у).
И наконец, пришла очередь невиртуальной функции класса Shape - obj ect ID-
Когда функция-член объявлена невиртуальной, не предполагается, что она будет
вести себя иначе в производных классах. В действительности невиртуальные
Правило 36
157
функции-члены выражают инвариант относительно специализации, поскольку они
определяют поведение, которое должно сохраняться вне зависимости от того, как
специализируются производные классы. Справедливо следующее: цель объявления
невиртуальной функции - заставить производные классы наследовать интерфейс
функции, а также ее обязательную реализацию.
Вы можете представлять себе объявление Shape: : object ID как утвержде-
ние: «Каждый объект Shape имеет функцию, которая дает идентификатор объ-
екта, и идентификатор объекта всегда вычисляется одним и тем же образом. Это
поведение задается определением функции Shape: : object ID, и никакой про-
изводный класс не должен изменять поведение». Поскольку невиртуальная функ-
ция определяет инвариант относительно специализации, ее не следует перегру-
жать в производных классах (этот вопрос подробно обсуждается в правиле 37).
Различия в объявлениях чисто виртуальных, просто виртуальных и невирту-
альных функций позволяют точно указать, что, по вашему замыслу, должны на-
следовать производные классы: только интерфейс, интерфейс и реализацию по
умолчанию или интерфейс и обязательную реализацию соответственно. По-
скольку эти типы объявлений означают принципиально разные вещи, следует
осуществлять продуманный выбор между этими вариантами, когда вы объявля-
ете функции-члены класса. При этом вы должны избегать двух ошибок, чаще все-
го совершаемых неопытными разработчиками классов.
Первая ошибка - объявление всех функций невиртуальными. Это не оставля-
ет возможности для маневров в производных классах; при этом больше всего про-
блем вызывают невиртуальные деструкторы (см. правило 14). Конечно, нет ниче-
го плохого в проектировании классов, которые не следует использовать в качестве
базовых. В этом случае вполне уместен набор из одних только невиртуальных
функций-членов. Однако очень часто такие классы объявляются либо из-за незна-
ния различий между виртуальными и невиртуальными функциями, либо в ре-
зультате необоснованного беспокойства по поводу потери* производительности
при использовании виртуальных функций. Факт остается фактом: практически
любой класс, который должен использоваться как базовый, будет содержать вир-
туальные функции (снова см. правило 14).
Если вы обеспокоены тем, во что вам обходится использование виртуальных
Функций, разрешите мне напомнить о так называемой! «правиле 80-20» (см. также
Правило 33), которое утверждает, что в типичной программе 80% времени исполне-
ния затрачивается на 20% кода. Это правило крайне важно, поскольку оно означа-
ет, что в среднем 80% ваших функций могут быть виртуальными, не оказывая ни-
какого ощутимого влияния на общую производительность вашей программы.
Поэтому, прежде чем начать беспокоиться о том, можете ли вы позволить себе ис-
пользование виртуальных функций, сначала убедитесь, что вы имеете дело с теми
Двадцатью процентами программы, для которых ваши решения окажут существен-
ное влияние на производительность.
Другая распространенная ошибка - объявлять все функции-члены виртуаль-
ными. Иногда это вполне правильный подход, о чем свидетельствуют, например,
Классы-протоколы (см. правило 34). Однако данное решение может также навести
На мысль, что у разработчика нет ясного понимания задачи. Некоторые функции
Не должны переопределяться в производных классах, и в таком случае вы должны
158
Наследование и ООП
подтвердить это, делая функции невиртуальными. Не имеет смысла делать вид, что
ваш класс годится на все случаи жизни, стоит лишь переопределить все его функции.
Помните, что если у вас имеется базовый класс В, производный класс D и функция-
член mf, каждый из приведенных вызовов mf должен работать надлежащим образом:
D *pd = new D;
В *pb = pd;
pb->mf () ; // Вызов mf с использованием указателя на базовый класс.
pd->mf () ; // Вызов mf с использованием указателя на производный класс.
Иногда вы должны определить mf как невиртуальную функцию, чтобы гаран-
тировать, что все будет работать так, как и задумывалось (см. правило 37). Если
у вас имеется инвариант относительно специализации, не следует бояться гово-
рить об этом!
Правило 37. Никогда не переопределяйте
наследуемые невиртуальные функции
К вопросу переопределения невиртуальных функций можно подойти двояко:
теоретически и практически. Давайте начнем с прагматиков - в конце концов, тео-
ретики люди терпеливые.
Предположим, я сообщаю вам, что класс D открыто наследует от класса В, и что
в классе В определена открытая функция-член mf. Аргументы и тип, возвращаемый
mf, не важны, поэтому давайте просто предположим, что это void. Другими слова-
ми, я говорю следующее:
class В {
public:
void mf () ;
V
};
class D: public В { ... };
Даже не зная ничего о в, D, или mf, имея объект х типа D,
D х; // х - объект типа D.
вы бы, наверное, удивились, когда код
В *рВ = &х; // Получить указатель на х.
pB->mf () ; // Вызов mf с помощью указателя.
вел себя отлично от следующего кода:
D *pD = &х; // Получить указатель на х.
pD->mf () ; // Вызов mf с помощью указателя.
Ведь в обоих случаях вы вызываете функцию-член mf объекта х. Поскольку
вы имеете дело с одной и той же функцией и одним и тем же объектом, в обоих
случаях она должна вести себя одинаково, не так ли?
Да, так должно быть, но не всегда бывает. В частности, вы получите иной ре-
зультат, если mf невиртуальна, a D определяет свою собственную версию mf:
Правило 37
class D: public В {
public:
void mf () ;
};
pB->mf();
pD->mf();
// Скрывает В::mf, см. правило 50.
11 Вызов b: : mf .
// Вызов В: :Hlf.
Причина такого «двуличного» поведения заключается в том, что невиртуаль-
ные функции, подобные В: : mf и D: : mf, связываются статически (см. правило 38).
Это означает, что, когда рВ объявляется в качестве указателя типа В, невиртуаль-
ные функции, вызываемые посредством рВ, - это всегда функции, определение
которых дано в классе В, даже если рВ, как в данном примере, указывает на класс,
производный от В.
Виртуальные функции, с другой стороны, связываются динамически (снова
см. правило 38), поэтому для них не существует такой проблемы. Если бы функ-
ция mf была виртуальной, то вызов mf посредством рВ или pD привел бы к вызо-
ву D: :mf, поскольку в действительности рВ и pD указывают па объект типа D.
В итоге, если вы пишете класс D и переопределяете невиртуальную функ-
цию mf, наследуемую от класса В, есть вероятность, что объекты D будут вести
себя совершенно непредсказуемо. В частности, любой конкретный объект D бу-
дет вести себя при вызове mf либо подобно В, либо подобно D; при этом фактор,
определяющий поведение, связан отнюдь не с самим объектом, а лишь с типом
указателя на объект.
Это все, что относится к «прагматической» аргументации. Теперь, я уверен, тре-
буется некоторое теоретическое обоснование запрета переопределения невиртуаль-
ных функций. Я с радостью вам его представлю.
Правило 35 объясняет, что открытое наследование означает «класс есть разно-
видность класса», а в правиле 36 говорится, почему объявление невиртуальных
функций класса определяет инвариант относительно специализации класса. Если
вы примените эти положения к классам В и D и невиртуальной функции-члену
В: : mf, то получите следующее:
□ все, что применимо к объектам В, также применимо к объектам D, поскольку
каждый объект D есть разновидность объекта В;
□ подклассы В должны наследовать как интерфейс, так и реализацию mf, по-
скольку mf невиртуально в В.
Теперь, если D определяет mf, возникает противоречие. Если класс D дей-
ствительно должен содержать отличную от В реализацию mf, и если каждый
объект, являющийся разновидностью В, действительно должен использовать реа-
лизацию функции mf класса в, тогда неверно, что каждый объект класса D яв-
ляется разновидностью объекта В. В этом случае D не должен открыто насле-
довать от В. С другой стороны, если класс D действительно должен открыто
наследовать от В, и если класс D действительно должен содержать реализацию
mf, отличную от В, тогда неверно, что mf является инвариантом относительно
специализации В. В этом случае функция mf должна быть виртуальной. Нако-
нец, если каждый экземпляр класса D действительно есть разновидность объекта
160
Наследование и ООП
класса В, и если mf - действительно инвариант относительно специализации В,
тогда, по правде говоря, D не нуждается в переопределении mf и не должен пы-
таться это делать.
Независимо от того, какой аргумент применим в вашем случае, чем-то придет-
ся пожертвовать, но в любых обстоятельствах запрет на перегрузку наследуемых
невиртуальных функций остается неизменным.
Правило 38. Никогда не переопределяйте
наследуемое значение аргумента по умолчанию
Давайте с самого начала упростим обсуждение. Аргумент по умолчанию мо-
жет существовать только как часть функции, а наследовать можно только два типа
функций: виртуальные и невиртуальные. Следовательно, единственный способ
переопределить значение аргумента по умолчанию - переопределение наследуе-
мой функции. Однако переопределять наследуемые невиртуальные функции
в любом случае ошибочно (см. правило 37), поэтому мы вполне можем ограничить
наше обсуждение случаем наследования виртуальной функции со значениями
аргументов по умолчанию.
В этих обстоятельствах мотивировка данного правила становится достаточно
-очевидной: виртуальные функции связываются динамически, а значения аргумен-
тов по умолчанию - статически.
Что это значит? Вы говорите, что не разбираетесь в современном объектно-
ориентированном жаргоне, или, возможно, уже давно позабыли, в чем заключа-
ется разница между динамическим и статическим связыванием? Тогда давайте
освежим вашу память.
Статический тип объекта - это тип, определяемый вами в тексте программы.
Рассмотрим следующую иерархию классов:
enum ShapeColor { RED, GREEN, BLUE } ;
// Класс для представления геометрических фигур.
class Shape {
public:
// Все фигуры должны иметь функцию для их рисования.
virtual void draw(ShapeColor color - RED) const = 0;
};
class Rectangle: public Shape {
public:
// Заметьте, другое значение параметра по умолчанию - плохо,
virtual void draw(ShapeColor color = GREEN) const;
};
class Circle: public Shape {
public:
virtual void draw(ShapeColor color) const;
};
Правило 38
1МШ
161
Графически это можно представить следующим образом:
Теперь рассмотрим следующие указатели:
Shape *ps;
Shape *рс = new Circle;
Shape *pr = new Rectangle;
// Статический тип = Shape*.
I! Статический тип = Shape*.
// Статический тип = Shape*.
В этом примере ps, рс и рг объявляются как указатели на Shape, так что для
них всех он и будет выступать в роли их статического типа. Заметьте, что совер-
шенно безразлично, на что они в действительности указывают, - независимо от
этого они имеют статический тип Shape*.
Динамический тип объекта определяется типом объекта, на который он в дан-
ный момент ссылается. Иными словами, динамический тип определяет поведение
объекта. В приведенном выше примере динамический тип для рс - это Circle*,
а для рг - Rectangle*. Что касается ps, он в действительности не имеет дина-
мического типа, поскольку не ссылается на какой-либо объект (пока).
Динамические типы, как и подразумевает их название, могут влиять на выпол-
нение программы, обычно посредством присваиваний:
ps = рс; // Теперь динамический тип переменной ps = Circle*.
ps - рг; //А теперь динамический тип переменной ps = Rectangle*.
Виртуальные функции связываются Динамически; иными словами, динами-
ческий тип вызывающего объекта определяет, какая конкретная функция вызы-
вается:
pc->draw(RED); // Вызов Circle::draw(RED).
pr->draw(RED); 11 Вызов Rectangle::draw(RED).
Я знаю, что все это давно известно, и вы, несомненно, разбираетесь в вирту-
альных функциях. Самое интересное начинается, когда мы подходим к виртуаль-
ным функциям с аргументами, принимающими значения по умолчанию, посколь-
ку, как я сказал, виртуальные функции связываются динамически, а аргументы по
Умолчанию - статически. Следовательно, вы можете прийти к тому, что будете вы-
аывать виртуальные функции, определенные в производном классе, но использую-
щие аргументы по умолчанию, заданные в базовом классе;
pr->draw(); // Вызов Rectangle::draw(RED)!
В этом случае динамический тип рг - это Rectangle*, поэтому, как вы и ожи-
вили, вызывается виртуальная функция класса Rectangle. Для Rectangle: : draw
аргумент по умолчанию - GREEN. Однако, поскольку статический тип рг - Shape*,
6 — 1682
162
И'""
Наследование и ООП
значения аргументов по умолчанию берутся из класса Shape, а нс Rectangle!
В результате получаем вызов, состоящий из странной, абсолютно непредвиденной
комбинации объявлений draw для классов Shape и Rectangle. Поверьте мне на
слово: ваше программное обеспечение не должно работать подобным образом или
во всяком случае, ваши пользователи уж точно не желают, чтобы оно так работало
Разумеется, в рассматриваемой ситуации не важно, что ps, рс и рг являются
указателями. Будь они ссылками, проблема бы не исчезла. Существенно только
что draw - виртуальная функция и одно из значений ее параметров по умолча-
нию переопределяется в подклассе.
Почему C++ настаивает па таком диковинном поведении? Ответ на этот вопрос
связан с эффективностью исполнения программы. Если бы значения аргументов по
умолчанию связывались динамически, компилятору пришлось бы найти способ
определять значения аргументов по умолчанию в момент выполнения програм-
мы, что медленнее и технически сложнее нынешнего метода определения зна-
чения аргументов при компиляции. Решение было принято в пользу скорости
и простоты реализации; в результате вы можете пользоваться преимуществами
эффективного выполнения кода программы, хотя если не последуете совету, из-
ложенному в этом правиле, поведение вашей программы будет не вполне логично.
Правило 39. Избегайте приведения типов
вниз по иерархии наследования
В наше беспокойное время нелишне быть к курсе финансовых новостей, по-
этому рассмотрим класс-протокол (см. правило 34) для банковских счетов:
class Person { ... };
class BankAccount {
public:
BankAccount (const Person *priniaryOwner,
const Person *jointOwner);
virtual -BankAccount();
virtual void makeDeposit(double amount) = 0;
virtual void makewithdrawal (double amount) = 0;
virtual double balance() const - 0;
};
Многие банки сегодня предлагают широкий выбор типов счетов, но давайте
немного упростим ситуацию и предположим, что имеется только один тип баН'
ковских счетов, а именно сберегательный счет:
class SavingsAccount: public BankAccount {
public:
SavingsAccount(const Person *primaryOwner, const Person *jointOwner);
-SavingsAccount();
void creditinterest () ; // Начислить проценты на счет.
};
I 163
Правило 39
Трудно, конечно назвать его крупным сберегательным счетом, но, сами пони-
маете, время нынче трудное. В любом случае для наших целей этого достаточно.
Банк, вероятно, должен поддерживать список своих счетов, например посред-
ством шаблона класса list из стандартной библиотеки (см. правило 49). Пред-
положим, что этот список изобретательно назван allAccounts:
list<BankAccount*> allAccounts; // Все счета в банке.
Подобно всем стандартным контейнерам, контейнеры list хранят копии по-
мещаемых в них объектов, поэтому во избежание хранения нескольких копий каж-
дого счета BankAccount банк решил, что allAccounts будет содержать указа-
щели, а не сами объекты BankAccount.
Теперь представьте себе, что вам необходимо написать код для итерации по
всем счетам с определением процентов, начисляемых по каждому счету. Вы може-
те испробовать следующий вариант:
// Этот цикл не будет компилироваться (смотрите далее, если вам
// никогда не встречался код с итераторами) .
for (list<BankAccount*>::iterator р = allAccounts.begin();
p != allAccounts.end();
+ +p) {
(*p)->creditlnterest() ; // Ошибка!
}
но ваш компилятор быстро приведет вас в чувство: allAccounts содержит ука-
затели на объекты BankAccount, а не на объекты SavingsAccount, поэтому
в цикле р указывает на BankAccount. Это делает вызов creditinterest не-
допустимым, поскольку creditinterest объявлена только для объектов
SavingsAccount, а не BankAccount.
Если строка list<BankAccount*>::iterator p=allAccounts.begin()
скорее напоминает вам помехи на линии, чем код на C++, вы, очевидно, не имели
Удовольствия познакомиться с шаблонами контейнерных классов из стандартной
библиотеки. Эта часть библиотеки известна как Стандартная библиотека шаблонов
(STL); ее обзор вы найдете в правиле 49. На данный же момент вам лишь необходи-
мо знать, что переменная р выступает как указатель, который проходит в цикле по
всем элементам all Accounts от первого до последнего. Иными словами, р работа-
ет так, как будто его тип - BankAccount **, а список элементов хранится в массиве.
То, что вышеприведенный цикл не компилируется, крайне неприятно. Ко-
нечно, allAccounts определен как содержащий BankAccount*, но вы-то зна-
ете, что на самом деле он содержит указатели SavingsAccount*, - единст-
венный класс, который может быть инстанцирован. Глупый компилятор! И вы
Решаете, что нужно сообщить ему то, что вы считаете очевидным, и о чем у него
«Не хватает мозгов» догадаться самому: allAccounts в действительности со-
держит SavingsAccount.
// Этот цикл откомпилируется, но он все равно неудачен.
for (list<BankAccount*>::iterator р = allAccounts.begin();
p != al1Accounts.end();
++P) {
6*
164
Наследование и ООП
static_cast<SavingsAccount*>(*р)->creditlnterest();
}
Все ваши проблемы решены четко, изящно, кратко, и все с использованием при.
ведения типов. Вы знаете, какой тип указателя в действительности содержит
allAccounts, а ваш «глупый» компилятор - нет, и вы используете приведение ти-
пов, чтобы сообщить ему об этом. Что может быть более логичным?
Хотелось бы привести здесь библейскую аналогию. Преобразование типов для
программиста на C++ - то же самое, что яблоко для Евы.
Приведение типов такого рода - указателей на базовые классы в указатели
на производные классы - называется понижающим приведением (downcast), по-
скольку вы преобразуете типы вниз по иерархии наследования. В только что рас-
смотренном примере понижающее приведение типов сработало, по, как вы увиди-
те, при дальнейшей поддержке кода это приведет к серьезным проблемам.
Однако вернемся к банку. Предположим, что воодушевленный успехом сберега-
тельных счетов, банк решил ввести и текущие счета. При этом на текущие счета, точ-
но так же, как и на сберегательные, начисляются проценты:
class CheckingAccount: public BankAccount {
public:
void creditinterest(); // Начислить проценты на счет.
};
Нет смысла говорить, что allAccounts теперь будет содержать список ука-
зателей как на сберегательные, так и на текущие счета. И неожиданно в цикле на-
числения процентов, написанном вами, возникают серьезные проблемы.
Первая проблема связана с тем, что цикл, как и прежде, будет компилировать-
ся, не требуя изменений, отражающих появление объектов CheckingAccount. Это
произойдет, поскольку компилятор будет по-прежнему верить вам, когда вы сооб-
щаете ему посредством static_cast, что *р в действительности указывает
на SavingsAccount*. (В конце концов, вы босс.) Вот первая проблема, возникаю-
щая при поддержке такого кода. Проблема номер два заключается в том, что для
решения первой у вас появится искушение написать код примерно в таком духе:
for (list<BankAccount*>::iterator р = allAccounts.begin();
p != al1Accounts.end();
++p) {
if (*p указывает на SavingsAccount)
static_cast<SavingsAccount*>(*p)->creditlnterest();
else
static_cast<CheckingAccount*>(*p)->creditlnterest();
}
Создавая код по принципу «если это объект типа Т1, - сделать что-либо; если
же это тип Т2, - сделать что-либо другое», знайте, что вы в корне не правы. Такое
решение противоречит духу C++. Да, подобная стратегия является разумной ДЛЯ
С и Pascal, но не для C++. В C++ для той же цели служат виртуальные функции.
Помните, что при работе с виртуальными функциями компилятор несет от-
ветственность за то, чтобы в зависимости от типа объекта была вызвана нужная
Правило 39 XIIiMKES
функция. Не засоряйте кол условными операторами и операторами выбора; по-
звольте компилятору выполнять такую работу за вас. Вот каким образом это де-
лается:
class BankAccount {...}; // Как выше.
// Новый класс, представляющий счета с начисляемыми процентами.
class InterestBearingAccount: public BankAccount {
public:
virtual void creditinterest () = 0;
};
class SavingsAccount: public InterestBearingAccount {
. . - / / Как выше.
};
class CheckingAccount: public InterestBearingAccount {
... // Как выше.
};
Графически это выглядит так:
Поскольку как на сберегательные, так и на текущие счета начисляются проценты,
желание поместить эти общие функции в общем базовом классе вполне закономер-
но. Однако если допустить, что не на все счета банка будут начисляться проценты
(исходя из моего опыта, это весьма разумное предположение), вы не можете помес-
тить эти счета в класс BankAccount. В результате вы вводите новый подкласс класса
BankAccount, называемый InterestBearingAccount, и делаете так, чтобы
SavingsAccount и CheckingAccount наследовали от него.
То обстоятельство, что проценты начисляются и на сберегательные, и на те-
кущие счета, выражается в следующем: функция creditInterest класса
InterestBearingAccount объявлена как чисто виртуальная, что предполага-
ет ее определение в подклассах SavingsAccount и CheckingAccount.
Эта новая иерархия классов позволяет переписать ваш цикл таким образом:
/ / Уже лучше, хотя еще не идеально.
for (list<BankAccount*>:: iterator р = allAccounts.begin();
p ! = allAccounts.end() ;
166
Наследование и ООП
++р) {
static_cast<InterestBearingAccount*>(*р)->creditlnterest();
}
Хотя этот цикл все еще содержит не слишком приятное приведение типов,
он намного лучше защищен от ошибок, поскольку будет продолжать правильно
работать, даже если к вашему приложению будут добавлены новые подклассы
классаInterestBearingAccount.
Для того чтобы полностью избавиться от приведения типов, вы должны вне-
сти в проект дополнительные изменения. Один из подходов заключается в том,
чтобы сделать спецификацию списка счетов более строгой. Если бы вы могли ис-
пользовать список объектов InterestBearingAccount, а не объектов BankAc-
count, все вышло бы просто замечательно:
// Все счета в банке, на которые начисляются проценты.
list<InterestBearingAccount*> alllBAccounts;
// Цикл компилируется и работает как сейчас, так и в дальнейшем.
for (list<InterestBearingAccount*>::iterator р =
alllBAccounts.begin();
p != alllBAccounts.end();
++p) {
(*p)->creditlnterest();
}
Если замена списка на более специализированный невозможна, тогда, вероят-
но, имеет смысл сказать, что операция creditinterest применима ко всем бан-
ковским счетам, но для беспроцентных счетов она - просто пустая функция. Это
можно выразить следующим образом:
class BankAccount {
public:
virtual void creditinterest () {}
};
class SavingsAccount: public BankAccount { ... };
class CheckingAccount: public BankAccount { ... };
list<BankAccount*> allAccounts;
// Смотрите-ка, нет приведения типа!
for (list<BankAccount*>::iterator p = allAccounts.begin();
p != al1Accounts.end();
+ +P) {
(*p)->creditlnterest();
}
Заметим, что виртуальная функция BankAccount: :creditinterest име-
ет пустую реализацию по умолчанию. Это удачный способ указать, что поведе-
ние данной функции по умолчанию заключается в том, чтобы ничего не делать;
однако такая реализация может вызвать, в свою очередь, непредвиденные ослож-
нения. Подробное рассмотрение проблемы, а также обсуждение способов ее реше-
ния приводятся в правиле 36. Заметим также, что credit Interest неявным об-
разом представляет собой встраиваемую функцию. В этом нет ничего плохого,
Правило 39
I:fo, поскольку опа виртуальна, ес встраивание, вероятно, не будет осуществляться
компилятором. Почему так происходит, объясняется в правиле 33.
Как вы видели, понижающего приведения типов можно избежать несколькими
способами. Наилучший из них - заменить такие преобразования типов вызова-
ми виртуальных функций, при этом по возможности делая каждую виртуальную
функцию пустой в тех классах, к которым она в действительности неприменима,
второй метод - усилить строгость типизации так, чтобы между объявляемым
типом указателя и типом указателя, который, как вы знаете, реально находится
в программе, не возникало неоднозначности. Каких бы усилий ни стоило избав-
ление от понижающих приведений типов, эти усилия будут потрачены не зря, по-
скольку понижающее приведение типов выглядит безобразно, часто является ис-
точником ошибок и дает код, трудный для понимания, разработки и поддержки.
То, что я написал, - правда. Однако это еще не вся правда. Существуют случаи,
когда вам действительно требуется использовать понижающее приведение типов.
Предположим, например, что вы столкнулись с ситуацией, которую мы рас-
сматривали в начале этого правила, то есть All Accounts содержит указатели
BankAccount, функция credit Interest определена только для объектов
SavingsAccount, и вам необходимо написать цикл для начисления процентов
по каждому счету. Далее предположим, что эти проценты вне вашего контроля;
вы не можете изменить определения BankAccount, SavingsAccount или
AllAccounts. (Например, они определены в библиотеке, которая доступна
только для чтения.) В этом случае потребуется использовать понижающее при-
ведение типов, независимо от того, насколько вам неприятна такая идея.
Тем не менее есть лучший способ добиться результата, чем использовать про-
стое приведение типов, как это делалось выше. Более подходящий метод называ-
ется «безопасное понижающее приведение типов» или «динамическое приведение
типов» и реализуется посредством оператора C++ dynamic_cast. При исполь-
зовании dynamic_cast для указателя предпринимается попытка приведения ти-
пов, и если она удается, то есть динамический тип указателя (см. правило 38) со-
вместим с тем типом, для которого происходит вызов функции, - возвращается
Допустимый указатель нового типа. Если же dynamic_cast заканчивается не-
удачно, то возвращается нулевой указатель.
Так выглядит наш банковский пример, переписанный с использованием без-
опасного понижающего приведения типов:
class BankAccount {...}; // Как в начале этого правила.
class SavingsAccount: // Аналогично.
public BankAccount { ... };
class CheckingAccount: 11 Аналогично.
public BankAccount { ... };
list<BankAccount*> allAccounts; // И это тоже должно быть
// знакомо...
void error(const strings msg); // Функция обработки ошибок, см. ниже.
//По крайней мере, здесь приведения типов безопасны.
for (list<BankAccount*>: -.iterator р = allAccounts .begin() ;
p != al 1 Accounts .end () ,-
++p) {
168
Наследование и ООП
// Попытка безопасного понижающего приведения
// *р к SavingsAccount *; см. ниже по поводу определения psa.
it (SavingsAccount *psa = dynamic_cast<SavingsAccount*>(*р)) {
psa->creditlnterest();
}
It Попытка безопасного приведения к CheckingAccount*.
else if (CheckingAccount *pca = dynamic_cast<CheckingAccount*>(*p)) {
pca->creditlnterest();
}
// Увы - неизвестный тип счета!
else {
error ("Unknown account type! ") ;
}
}
Эта схема далека от идеала, ио, по крайней мере, вы можете определить, что пони-
жающее приведение типа завершилось неудачно, а без использования dynamic_cast
у вас не было бы такой возможности. Заметьте, однако, что осторожность требу-
ет, чтобы вы также проводили проверку на тот случай, если неудачей заканчиваются
все попытки приведения типов. В этом и состоит смысл последнего оператора else
в предложенном коде. При использовании виртуальных функций в таком тесте нет
необходимости, поскольку каждый вызов виртуальной функции должен разрешить-
ся некоторой функцией. Однако, когда вы используете приведение типов, никаких га-
рантий у вас уже нет. Например, если в иерархию счетов кто-нибудь добавляет новый
тип, забывая при этом о необходимости обновления вышеприведенного кода, то все
приведения типов окончатся неудачей. Вот почему важно, чтобы вы прорабатывали
и такой вариант. Конечно, маловероятно, что ни одно из приведений типов в этом коде
не завершится успехом, но когда допускается использование понижающих приведений
типов, даже с хорошими программистами начинают происходить нехорошие вещи.
Увидев определения переменных в условных операторах i f, вы сняли очки, что-
бы протереть их? Не беспокойтесь, ваше зрение вас не обманывает. Возможность объ-
явления таких переменных была добавлена в язык одновременно с dynamic_cast.
Это позволяет создавать более «элегантный» код, поскольку на самом деле необяза-
тельно использовать psa или рса, если приведения типов с помощью dynamic_cast
не проходят успешно; а при использовании нового синтаксиса нет необходимости
определять эти переменные вне условных операторов, содержащих приведения типов.
(В правиле 32 объясняется, почему в общем случае следует избегать излишних
объявлений переменных.) Если ваш компилятор еще не поддерживает этот новый
способ определения переменных, вы можете определить их старым способом:
for (list<BankAccount*>::iterator р - allAccounts.begin();
p != al1Accounts.end();
++P) {
SavingsAccount *psa; // Традиционное определение.
CheckingAccount *pca; // Традиционное определение,
if (psa = dynamic_cast<SavingsAccount*>(*p)) {
psa->creditlnterest();
}
Правило 40
169
else if (pea = dynamic_cast<CheckingAccount*>(*p) ) {
pca->creditlnterest();
}
else {
error ("Unknown account type!") ;
}
}
По большому счету, разумеется, не имеет особого значения, где вы размещаете
определения таких переменных, как psa или рса. Важно следующее: програм-
мирование в стиле if-then-el ее, к которому с неизбежностью ведет понижающее
приведение типов, намного менее привлекательно, чем использование вирту-
альных функций, и прибегать к нему следует, только если у вас нет никакой аль-
тернативы. Надо надеяться, вам все же не придется исследовать столь унылые
программные ландшафты.
Правило 40. Моделируйте отношения «содержит»
и «реализуется посредством» с помощью вложения
Вложение - это метод построения одного класса поверх другого, когда один класс
включает в себя объект другого класса в качестве элемента данных. Например:
class Address { ... };
class PhoneNumber { ... };
class Person {
public:
// Чье-либо место жительства.
private:
string name;
Address address;
PhoneNumber voiceNumber;
PhoneNumber faxNumber;
};
// Вложенный объект.
// To же.
II To же.
// To же.
В данном случае о классе Person говорят, что в него вложены классы string,
Address и PhoneNumber, поскольку он содержит переменные этих типов. Тер-
Мин вложение имеет ряд синонимов. Данное понятие тоже обозначается такими
Терминами, как композиция, содержание и включение.
В правиле 35 объясняется, что открытое наследование означает «класс есть
Разновидность класса». В противоположность этому вложение означает либо
«класс содержит класс», либо «класс реализуется посредством класса».
Вышеприведенный класс Person демонстрирует взаимосвязь типа «класс со-
держит класс». Объект Person имеет имя, адрес и номера телефонов для голосо-
вого и факсимильного общения. Нельзя сказать, что человек есть разновидность
Имени, или что человек есть разновидность адреса. Можно сказать, что человек
^меет («содержит») имя и адрес. Большинство людей не испытывает затрудне-
ний в проведении подобных различий, поэтому путаница между «есть разновид-
ность» и «содержит» возникает сравнительно редко.
170
Наследование и ООП
Чуть сложнее провести различие между отношениями «есть разновидность»
и «реализуется посредством». Предположим, например, что вам необходим шаб-
лон для классов, представляющих множества произвольных объектов, то есть кол-
лекции без дубликатов. Поскольку повторное использование - это великолепная
вещь, и вы к тому же предусмотрительно ознакомились с обзором стандартной
библиотеки C++ в правиле 49, ваше первое побуждение - применить библиотеч-
ный шаблон set. В конце концов, зачем писать новый шаблон, когда есть возмож-
ность использовать уже готовый?
Однако, углубившись в документацию по set, вы обнаружите ограничение,
неприемлемое для вашего приложения: set требует, чтобы содержащиеся в нем
элементы были полностью упорядочены, то есть для каждой пары объектов а и b
из множества можно было бы определить, что либо а<Ь, либо Ь<а.
Применительно к некоторым типам удовлетворить этому требованию достаточ-
но легко, а полная упорядоченность объектов позволяет шаблону set дать пользова-
телям достаточно привлекательные гарантии относительно производительности.
(Подробности, касающиеся гарантий производительности стандартной библиотеки,
приводятся в правиле 49.) Однако вам необходимо нечто более общее: класс, подоб-
ный set, объекты которого могут не удовлетворять критерию полного упорядочения,
а единственное требование, предъявляемое к ним, - возможность определения для
объектов аиЬ одного типа, что а==Ь. Это более скромное требование гораздо лучше
подходит для того, чтобы моделировать такие характеристики, как цвет, например.
Меньше ли красный, чем зеленый, или зеленый меньше, чем красный? Для нашего
приложения, по-видимому, придется написать свой собственный шаблон.
Тем не менее повторное использование - это великолепная вещь. Будучи экс-
пертом в области структур данных, вы знаете, что при наличии практически без-
граничного набора возможных реализаций один из самых простых способов -
применение связных списков. Что дальше? Шаблон list (который генерирует
классы связных списков) уже имеется в стандартной библиотеке! Вы принимае-
те решение использовать его повторно.
В частности, вы решаете, что создаваемый вами шаблон Set должен наследо-
вать от list, то есть Set<T> будет наследовать от list<T>. В итоге в вашей реа-
лизации объект Set будет выступать как объект list. Соответственно, вы опре-
деляете Set следующим образом:
// Неправильный способ использовать list для определения Set.
templatecclass Т>
class Set: public list<T> { ... };
До сих пор все вроде бы шло превосходно, но, если присмотреться, в код вкра-
лась ошибка. Как объясняется в правиле 35, если D есть разновидность В, все, что
верно для В, также верно и для D. Однако объект list может содержать дублика-
ты, поэтому, если значение 3051 вставляется в список list<int> дважды, список
будет содержать две копии 3051. Напротив, Set не может содержать дубликатов,
поэтому, если значение 3051 вставляется в Set< int > дважды, множество содержит
только одну копию данного значения. Следовательно, утверждение, что объект
класса Set есть разновидность объекта типа list, является ложным: ведь некото-
рые вещи, которые верны для объектов list, неверны для объектов Set.
Правило 40
171
Из-за того что отношение между данными объектами нс основано на принци-
пе «есть разновидность», открытое наследование - неправильный способ моде-
лирования этой взаимосвязи. Правильный подход обеспечит понимание того, что
объект Set может быть реализован посредством объекта list:
// Правильный способ использовать list для определения Set.
templatecclass Т>
class Set {
public:
bool member(const T& item) const;
void insert(const T& item);
void remove (const T& item) ;
int cardinality () const;
private:
list<T> rep; // Представление множества.
};
Функции-члены класса Set могут опереться на функциональность, предла-
гаемую list и другими частями стандартной библиотеки, поэтому их реализа-
цию нетрудно написать, хотя она и не особенно поражает воображение при чтении:
templatecclass Т>
bool SetcT>::member(const T& item) const
{ return find(rep.begin(), rep.end(), item) !=rep.end(); }
templatecclass T>
void SetcT>:: insert (const T&.item)
{ if (!member(item)) rep.push_back(item); }
templatecclass T>
void SetcT>: : remove (const T& item)
{
listcT>::iterator it =
find(rep.begin(), rep.end(), item);
if (it !=rep.end()) rep.erase(it);
}
templatecclass T>
int SetcT>::cardinality() const
{ return rep.size() ; }
Эти функции достаточно просты для того, чтобы быть использованными в ка-
честве встроенных, хотя перед принятием окончательного решения вам необходи-
мо снова просмотреть правило 33. (В коде, приведенном выше, find, begin, end,
Push__back и т.д. - функции стандартной библиотеки, которые составляют основу
Для работы с контейнерными классами наподобие list. Вы найдете обзор этих
средств в правиле 49.)
Стоит отметить, что интерфейс класса Set не пройдет тест на минимальность
и полноту, предложенный в правиле 18. В том, что касается полноты класса, глав-
ным упущением является отсутствие способа итерации по массиву, который может
Понадобиться многим приложениям (и реализуется во всех классах стандартной
библиотеки, включая set). Недостатком также является то, что Set не следует
соглашениям, принятым контейнерными классами стандартной библиотеки
172
Наследование и ООП
(см. правило 49), а это затрудняет его совместное использование с другими частя-
ми библиотеки.
Недостатки интерфейса класса Set не должны, однако, затенять несомнен-
ного достоинства, которое характеризует его реализацию, а именно отношения
между классами Set и list. Оно не является взаимосвязью типа «есть разновид-
ность» (как могло бы показаться вначале); это «реализация посредством», а ре-
шением использовать для реализации этой взаимосвязи вложение мог бы по пра-
ву гордиться любой разработчик классов.
Между прочим, если для установления отношения между двумя классами
вы используете вложение, это создает зависимость на момент компиляции. По-
чему на это стоит обращать внимание и как разрешить данную проблему, объяс-
няет правило 34.
Правило 41. Различайте наследование и шаблоны
Рассмотрим две задачи проектирования:
1. Прилежно изучая курс информатики, вы хотите создать классы для пред-
ставления стеков. Вам понадобится несколько различных классов, посколь-
ку каждый стек должен быть гомогенным, то есть содержать только объекты
одного типа. Например, у вас может быть один класс для стеков int, дру-
гой - для стеков double, третий - для стеков string и т.д. Вам требуется
поддерживать лишь минимальный интерфейс класса (см. правило 18), поэто-
му необходимо ограничиться операциями создания стека, удаления и встав-
ки объектов в стек, извлечения объектов и проверки на пустоту. Выполняя
данное упражнение, вы игнорируете стандартную библиотеку (включая
stack - см. правило 49), поскольку стремитесь получить опыт самостоя-
тельного написания классов. Повторное использование - это прекрасно, но
когда вы ставите перед собой задачу основательно разобраться в устройстве
чего-либо, лучший способ добиться этого - засучить рукава и начать «раз-
бирать механизм».
2. Будучи страстным любителем кошек, вы хотите спроектировать класс для
их описания. Вам потребуется несколько различных классов, поскольку каж-
дая порода кошек незначительно отличается от остальных. Подобно любым
объектам, «кошек» в программе можно создавать и удалять, но, как извест-
но всякому любителю животных, помимо этого о кошках можно сказать, что
они только едят и спят. Однако каждая порода ест и спит присущим только
ей неподражаемым способом.
Эти две задачи кажутся достаточно близкими, однако требуют совершенно
разных подходов к проектированию. Почему?
Ответ кроется во взаимосвязи между поведением каждого класса и типом обра-
батываемого объекта. Как в случае со стеками, так и с кошками вы имеете дело
с различными типами объектов (стек, содержащий объекты типа Т; кошки породы Т),
но вопрос, который необходимо себе задать, звучит так: влияет ли тип Т на поведе-
ние класса? Если Т не влияет на поведение, вы можете использовать шаблон; если
Правило 41
173
ясе влияет, вам необходимы виртуальные функции, а значит, вы будете использо-
вать наследование.
Вот как мы могли бы определить реализацию класса Stack, использующую
связные списки, предполагая, что объекты, помещаемые в стек, имеют тип Т:
class Stack {
public:
Stack();
-Stack();
void push (const T& object) ;
T pop();
bool empty() const; // Пуст ли стек?
private:
struct StackNode { // Узел связного списка.
T data; // Данные в этом узле.
StackNode *next; 11 Следующий узел в списке.
// Конструктор StackNode инициализирует оба поля.
StackNode(const Т& newData, StackNode *nextNode)
: data(newData), next(nextNode) {}
};
StackNode *top; // Вершина стека.
Stack(const Stacks rhs); // Запретить копирование
Stacks operator=(const Stacks rhs); Il и присваивание -
//см. правило 27.
Объекты stack будут, таким образом, формировать структуры данных, подоб-
ные следующей:
Сам связный список составлен из объектов StackNode, ио это деталь реализа-
ции класса Stack, а потому StackNode был объявлен как закрытый тип класса
Stack. Обратите внимание, что StackNode имеет конструктор, гарантирующий,
что все поля будут инициализированы надлежащим образом. Даже если вы може-
те написать связные списки, будучи разбуженными посреди ночи, не стоит пренеб-
регать достижениями технологии, каковыми являются конструкторы.
Ниже приведена разумная реализация функций-членов класса Stack в пер-
вом приближении. Как бывает во многих прототипах реализации (и, к сожалению,
Даже в коммерческих программах), контроль ошибок здесь отсутствует, посколь-
ку в мире прототипов, как известно, никогда ничего плохого не случается.
muiii Наследование и ООП
Stack::Stack(): top(O) {} // Инициализация вершины значением null,
void Stack::push(const T& object)
{
top = new StackNode(object, top) ; // Добавить новый узел
} / / в начале списка.
Т Stack::рор()
{
StackNode *topOfStack = top; top = top->next; // Запомнить верхний узел.
T data = topOfStack->data; // Запомнить данные узла.
delete topOfStack;
return data;
}
Stack::-Stack () // Удалить все в стеке.
{
while (top) {
StackNode *toDie = top; // Получить указатель на вершину
top - top->next; // Перейти к следующему узлу.
delete toDie; // Удалить предыдущую вершину.
}
}
bool Stack::empty() const
{ return top == 0; }
В этой реализации нет ничего, что могло бы привлечь внимание. Единственное,
что представляет здесь интерес, - это возможность писать функции-члены, по су-
ществу не имея никакой информации о Т. (Вы только допускаете, что можете вызвать
конструктор копирования Т, но, как объясняется в правиле 45, это достаточно ра-
зумное предположение.) Код, написанный вами для создания стека, удаления стека,
вставки/извлечения объектов и определения того, пуст ли стек, остается неизмен-
ным вне зависимости от типа Т. За исключением предположения, что вы можете
вызвать конструктор копирования Т, поведение стека вообще никак не зависит от Т.
Это отличительный признак шаблонного класса: его поведение не связано с типом.
Превращение класса Stack в шаблон настолько элементарно, что это ни для
кого не составит труда:
templatecclass Т> class Stack {
• • //В точности то же, что и выше.
};
Но вернемся к нашим кошкам. Почему шаблоны не подойдут для кошек?
Перечитайте спецификацию и обратите внимание на то, что «каждая порода
кошек ест и спит присущим только ей неподражаемым способом». Это означает,
что вам необходимо реализовать различное поведение для различных типов ко-
шек. Вы не можете просто написать одну-единственную функцию, которая бы ра-
ботала со всеми объектами: все, что можно сделать, - это определить интерфейс
функции, которую должны реализовать все типы кошек. Способ, который позво-
ляет задать только интерфейс функции, заключается в объявлении чисто вирту-
альной функции (см. правило 36):
Правило 41
| 175
class Cat {
public:
virtual -Cat();
virtual void eat () = 0;
virtual void sleep() = 0;
//См. правило 14.
11 Все кошки едят.
// Все кошки спят.
Подклассы класса Cat (скажем, Siamese и Brit ishShortHairedTabby) долж-
ны, конечно, переопределять функции eat и sleep наследуемого интерфейса:
class Siamese: public Cat {
public:
void eat () ;
void sleep () ;
};
class BritishShortHairedTabby: public Cat {
public:
void eat();
void sleep();
Ну вот, теперь вы знаете, почему класс Stack реализован с использованием
шаблонов и почему это не подойдет для класса Cat. Вы также знаете, почему для
класса Cat используется наследование. Остается единственный вопрос: почему
наследование не подходит для класса Stack? Чтобы понять это, попробуйте объя-
вить базовый класс иерархии Stack - один класс, от которого будут наследовать
все другие классы стеков:
class Stack { // Стек чего угодно.
public:
virtual void push (const ??? object) = 0;
virtual ??? pop() = 0;
};
Теперь проблема очерчивается четко. Какой тип вы хотите объявить для чисто вир-
туальных функций push и pop? Помните, что каждый подкласс должен переобъявлять
наследуемые виртуальные функции с аргументами точно такого же типа и с возвраща-
емым типом, совместимым с тем, который объявлен в базовом классе. К сожалению,
Для помещения и извлечения объектов типа int потребуется стек с целыми числами,
в то время как для объектов Cat необходим стек с объектами Cat. Как может класс
Stack объявить свои чисто виртуальные функции таким образом, чтобы пользователи
Могли создавать стеки как с int, так и с Cat? Печальная истина заключается в том,
Что это невозможно, и поэтому для создания стеков наследование не подходит.
Но, предположим, вы достаточно изворотливы. Вероятно, вы думаете, что пере-
хитрите компилятор, используя указатели с типом void*. Как оказывается, указа-
тели на void вам тоже не помогут. Вы просто не сможете проигнорировать тре-
бование о том, чтобы объявления виртуальных функций в производных классах
176
Ill
Наследование и ООП
не противоречили объявлениям в базовых классах. Однако указатели типа void
могут вам пригодиться в другом случае, связанном с эффективностью генерации
шаблонов классов. Более подробно об этом говорится в правиле 42.
Теперь, когда мы разделались со стеками и кошками, можем подытожить из-
влеченные из этого правила уроки следующим образом:
□ шаблоны должны быть использованы для генерации семейств классов, тип
объектов которых не влияет на поведение функций этих классов;
□ наследование следует использовать для создания семейств классов, тип
объектов которых влияет на поведение функций создаваемых классов.
Усвойте эти два положения, и вы сделаете важный шаг к пониманию различия
между наследованием и шаблонами.
Правило 42. Продумывайте подход
к использованию закрытого наследования
В правиле 35 показано, что C++ рассматривает открытое наследование как от-
ношение типа «класс есть разновидность класса». В частности, говорится, что ком-
пиляторы, столкнувшись с иерархией, где класс Student открыто наследует от
класса Person, неявно преобразуют объект класса Student в объект класса
Person, если это требуется для вызова функции Studentstudent. Очевидно,
стоит еще раз привести фрагмент кода, заменив в нем открытое наследование за-
крытым:
class Person { ... } ;
class Student:
private Person { ... } ;
void dance(const Persons p) ;
void study(const Students s) ;
Person p;
Student s;
dance(p);
dance(s) ;
// Теперь мы используем
/ / закрытое наследование.
// Все люди могут танцевать.
/ / Только студенты учатся.
// р - человек (Person).
// s - студент (Student) .
// Нормально, р типа Person.
// Ошибка! Объект Student
//не является объектом Person.
Ясно, что закрытое наследование не означает «есть разновидность». Что же это
тогда означает?
«Стоп! - восклицаете вы. - Прежде чем говорить о значении, давайте погово-
рим о свойствах. Как ведет себя закрытое наследование?» Первое из правил закры-
того наследования вы только что сами имели возможность наблюдать в действии:
в противоположность открытому наследованию, компиляторы в общем случае
не преобразуют объекты производного класса (такие как Student) в объекты базо-
вого класса (такие как Person). Именно поэтому вызов dance для s ошибочен. Вто-
рое правило состоит в том, что члены, наследуемые от закрытого базового класса,
становятся закрытыми, даже если для базового класса они были объявлены как за-
щищенные или открытые. Это то, что касается поведения.
А теперь вернемся к значению. Закрытое наследование означает «класс реа-
лизуется посредством класса». Делая класс D закрытым наследником класса В, вы
Правило 42
; 177
поступаете так потому, что заинтересованы в использовании некоторого кода, уже
написанного для класса В, а не потому, что между объектами типов В и D суще-
ствует некая концептуальная взаимосвязь. Как таковое, закрытое наследование -
это исключительно прием реализации. Используя термины, введенные в правиле
36, можно сказать, что закрытое наследование означает наследование одной толь-
ко реализации, без интерфейса. Если D является закрытым наследником В, это
означает, что объекты D реализуются посредством объектов В, и ничего больше.
Закрытое наследование ничего не означает в ходе проектирования программного
обеспечения и обретает смысл только на этапе реализации.
Утверждение, что закрытое наследование означает «реализуется посредством»,
вероятно, слегка вас озадачит, поскольку в правиле 40 указывалось, что вложение
может означать то же самое. Как же сделать выбор в пользу одного или другого
механизма? Ответ прост: используйте вложение, когда вы можете это сделать, а за-
крытое наследование - когда обязаны так поступить. В каких случаях требуется
использовать закрытое наследование? Тогда, когда на сцене появляются защи-
щенные члены и/или виртуальные функции, - но более подробно об этом будет
рассказано немного позже.
В правиле 41 показано, как написать шаблон Stack, генерирующий классы,
которые содержат объекты различных типов. Стоит сейчас ознакомиться с этим
правилом, если вы не сделали этого раньше. Шаблоны являются одним из наибо-
лее полезных инструментов C++, но как только вы начинаете активно их исполь-
зовать, вы обнаруживаете, что на десять случаев инстанцирования шаблона мож-
но получить десять копий кода шаблона. В случае использования шаблона Stack
код функций-членов Stack<int> будет полностью отделен от кода функций-чле-
нов stack<double>. Иногда это неизбежно, но возникновение такого дублиро-
вания вероятно даже в тех случаях, когда функции шаблона могли бы использо-
вать общий код. Получаемое в результате увеличение общего размера объектного
кода называют раздуванием кода при использовании шаблонов.
При работе с некоторыми типами классов вы можете избежать подобного яв-
ления, используя обобщенные указатели. Такой подход применим к классам, ко-
торые хранят указатели вместо объектов, и реализуются они следующим образом:
1. Создается один класс, который хранит указатели на объекты в виде void*.
2. Создается ряд дополнительных классов, чье единственное предназначение -
обеспечить строгий контроль типов. Эти классы для своей работы исполь-
зуют обобщенный класс, который был определен на шаге 1.
Вот пример класса Stack из правила 41, не являющегося шаблонным, - толь-
ко здесь он хранит обобщенные указатели вместо объектов:
class GenericStack {
public:
GenericStack();
-GenericStack();
void push(void *object);
void * pop () ;
bool empty() const;
private:
struct StackNode {
Наследование и ООП
void *data; // Данные в этом узле.
StackNode *next; // Следующий узел в списке.
StackNode(void *newData, StackNode *nextNode)
: data(newData), next(nextNode) {}
};
StackNode *top; Il Вершина стека.
GenericStack(const GenericStack& rhs); 11 Запретить
GenericStack& // копирование
operator^(const GenericStack& rhs); // и присваивание -
// см. правило 27.
};
Поскольку этот класс храпит указатели, а не объекты, существует вероятность,
что указатель на объект имеется более чем в одном стеке (то есть он был помещен
в несколько стеков). В связи с этим представляется принципиально важным, чтобы
функция pop и деструктор класса не вызывали delete для указателя data из уда-
ляемых объектов StackNode, хотя они по прежнему должны удалять сами объек-
ты StackNode. В конце концов, объекты StackNode размещаются внутри класса
GenericStack, поэтому и высвобождаться они должны внутри него. В результате
реализация для класса Stack из правила 41 вполне подойдет для класса Generic
Stack. Единственное, что необходимо изменить, - вместо Т поставить void*.
Класс GenericStack сам по себе малопригоден - его зачастую можно ис-
пользовать неправильно. Например, пользователь может по ошибке поместить
указатель на объект Cat в стек, предназначенный для хранения только указателей
на int, и компилятор не будет возражать. В конце концов, указатель есть указа-
тель, если речь идет об аргументах типа void*.
Чтобы вернуться к контролю типов, к которому вы уже успели привыкнуть,
для GenericStack необходимо создать классы-интерфейсы, например такие:
class IntStack { // Класс-интерфейс для целых чисел.
public:
voidpush(int *intPtr) { s.push(intPtr) ; }
int *pop() { return static_cast<int*>(s.pop ()); }
bool empty() const { return s.empty(); }
private:
GenericStacks; // Реализация.
};
class Catstack { // Класс-интерфейс для кошек.
public:
voidpush(Cat *catPtr) { s.push(catPtr) ; }
Cat * pop() { return static_cast<Cat*> (s.pop()) ; }
bool empty() const { return s.empty(); }
private:
GenericStack s; // Реализация.
};
Как видите, классы IntStack и Catstack служат исключительно для обеспе-
чения контроля типов. Только указатели па int можно поместить или извлечь из
объекта класса IntStack, и только указатели на Cat можно поместить или извлечь
из экземпляра класса Catstack. Как IntStack, так и Catstack реализуются
Правило 42
179
посредством Genericstack - а такое отношение выражается с помощью вложе-
ния (см. правило 40); и Int Stack, и Cat St ack совместно используют код функ-
ций из класса GenericStack, который фактически реализует их поведение. Бо-
лее того, поскольку все функции-члены классов IntStack и Catstack по
умолчанию определены как inline, использование этих интерфейсных классов
происходит почти «даром».
Но что, если потенциальные пользователи этого не понимают? Что, если они
ошибочно полагают, будто применение GenericStack более эффективно, или по
наивности думают, что только «чайникам» требуются «узы» безопасных типов?
Что можно сделать для того, чтобы удержать таких пользователей от применения
GenericStack в обход классов IntStack и Catstack, напрямую, когда они сво-
бодно смогут совершать те ошибки с типами, для предотвращения которых и был,
собственно, придуман C++?
Ничего. Ничто этому не мешает. А сделать что-нибудь следовало бы.
В начале этого раздела я упоминал, что альтернативным способом выраже-
ния взаимоотношения «реализуется посредством» служит использование закры-
того наследования. В данном случае такая техника дает ряд преимуществ, посколь-
ку позволяет сообщить, что GenericStack слишком небезопасен для обычного
использования, и его следует применять только для реализации других классов.
Вы утверждаете это, делая функции-члены GenericStack защищенными:
class GenericStack {
protected:
GenericStack();
-GenericStack();
void push(void *object);
void * pop () ;
bool empty() const;
private:
... // To же, что и выше.
};
GenericStack s; // Ошибка! Конструктор защищен.
class IntStack: private GenericStack {
public:
voidpush(int *intPtr) { GenericStack: :push(intPtr) ; }
int * pop() { return static_cast<int*>(GenericStack::pop()); }
bool empty () const { return GenericStack: :empty () ; }
};
class Catstack: private GenericStack {
public:
voidpush(Cat *catPtr) { GenericStack: :push(catPtr) ; }
Cat * pop() { return static_cast<Cat*>(GenericStack::pop()); }
bool empty () const { return GenericStack: : empty () ; }
};
IntStack is; // Нормально.
Catstack cs; // Тоже нормально.
Как и при подходе с использованием вложения, реализация, основанная
на закрытом наследовании, позволяет избежать дублирования кода, поскольку
180
Наследование и ООП
классы с интерфейсом контроля типов - это просто встраиваемые вызовы функ-
ций класса GenericStack.
Построение интерфейса с контролем типов поверх GenericStack - достаточно
ловкий маневр, но описывать все эти классы вручную достаточно неудобно. К счас-
тью, в этом нет необходимости. Для их автоматической генерации вы можете исполь-
зовать шаблоны. Вот шаблон, генерирующий интерфейсы классов стеков со встроен-
ным контролем типов и использующий закрытое наследование:
templatecclass Т>
class Stack: private GenericStack {
public:
void push(T *objectPtr) { GenericStack::push(objectPtr); }
T * pop() { return static_cast<T*>(GenericStack::pop()); }
bool empty () const { return GenericStack: : empty () ; }
};
Перед вами удивительный код, хотя вы, возможно, этого еще и не поняли. Ис-
пользуя шаблон, компилятор будет генерировать столько классов интерфейсов,
сколько вам необходимо. Поскольку в эти классы встроен контроль типов, ошиб-
ки пользователя обнаруживаются в ходе компиляции. Из-за того, что функции-
члены класса GenericStack являются защищенными и функции-члены классов-
интерфейсов используют его в качестве закрытого базового класса, пользователи
не могут обойти классы-интерфейсы. Так как все функции-члены классов интер-
фейсов неявно объявляются inline, использование классов с контролем типов
не влечет за собой дополнительных «расходов» в момент выполнения; генерируе-
мый код абсолютно аналогичен коду, который пользователи получили бы, рабо-
тая непосредственно с GenericStack (если компилятор не отклонит требование
встраивания функций - см. правило 33). А поскольку GenericStack использует
указатели типа void*, независимо от того, сколько типов стеков вы применяете
в своей программе, для управления стеками используется только одна копия кода.
Короче говоря, код разработан таким образом, что он является максимально эф-
фективным, а также поддерживает строгий контроль типов. Трудно добиться луч-
ших результатов.
Одна из основных посылок этой книги состоит в том, что различные конструк-
ции языка C++ способны взаимодействовать друг с другом весьма примечатель-
ными способами. Этот пример, я надеюсь, вы найдете достаточно нетривиальным.
Мораль, которую необходимо отсюда вывести, такова: полученных результа-
тов нельзя было добиться, используя вложение. Только наследование дает доступ
к защищенным членам класса, и только оно позволяет переопределять виртуаль-
ные функции. (Пример того, как наличие виртуальных функций может подтолк-
нуть к использованию закрытого наследования, приведен в правиле 43.) Посколь-
ку в C++ имеются виртуальные функции и защищенные члены классов, иногда
почти единственный способ выразить взаимоотношение «реализация посред-
ством» - закрытое наследование. Итак, не надо бояться использовать закрытое
наследование, если это наиболее подходящая техника, имеющаяся в вашем рас-
поряжении. Вместе с тем использование вложения в целом предпочтительней, по-
этому его следует применять всегда, когда это возможно.
| 181
Правило 43. Продумывайте подход
к использованию множественного наследования
В зависимости от того, кто о нем. говорит, множественное наследование пред-
ставляется то результатом божественного вдохновения, то кознями дьявола.
Сторонники множественного наследования провозглашают его неотъемлемой
частью естественного способа моделирования реальных ситуаций, в то время как
критики утверждают, что оно медленно работает, его трудно реализовать, и как ин-
струмент оно не мощнее одиночного наследования. Сбивает с толку то, что в мире
объектно-ориентированных языков также наблюдается двоякое отношение к это-
му вопросу: C++, Eiffel и Common LISP Object System (CLOS) предлагают множе-
ственное наследование, Smalltalk, Objective С и Object Pascal - нет; Java поддержи-
вает его, но только в ограниченной форме. Что же остается бедному программисту?
Прежде чем занимать чью-либо сторону, вам необходимо ознакомиться с фак-
тами. Неоспоримый факт, например, что множественное наследование в C++ от-
крывает ящик Пандоры, преисполненный бед, которые неактуальны при одиноч-
ном наследовании. Наиболее простой пример - неоднозначность (см. правило 26).
Если производный класс наследует функцию-член из более чем одного класса,
любая ссылка на эту функцию неоднозначна; вам необходимо явно указать, ка-
кую функцию вы имеете в виду. Вот код, основанный на материале из ARM (спра-
вочного руководства Annotated C++ Reference Manual, см. правило 50):
class Lottery {
public:
virtual int draw();
};
class GraphicalObject {
public:
virtual int drawO ;
};
class Lotterysimulation: public Lottery, public GraphicalObject {
... //He объявляет draw.
};
Lotterysimulation *pls = new Lotterysimulation;
pls->draw(); // Ошибка! Неоднозначность.
pls->Lottery::draw(); //Нормально
pls->GraphicalObject::draw(); // Нормально.
Код выглядит довольно неуклюже, но, по крайней мере, работает. К сожалению,
Избежать такой неуклюжести достаточно трудно. Даже если бы одна из наследуе-
мых функций draw была закрытой и, следовательно, недоступной, неоднозначность
все равно осталась бы. (Для этого имеются веские причины, по их подробное разъяс-
нение можно найти в правиле 26, поэтому здесь не буду повторяться.)
Явная квалификация членов не только выглядит неуклюже, но еще и влечет
за собой ряд ограничений. Когда вы явным образом вызываете виртуальную функ-
цию, используя название класса, она перестает быть виртуальной. Вместо этого
182
Наследование и ООП
вызывается именно та функция, которая была вами указана, даже для объекта
производного класса:
class SpecialLotterySimulation: public Lotterysimulation (
public:
virtual intclrawO;
};
pls 4 new SpecialLotterySimulation;
pls->draw(); // Ошибка! По-прежнему неоднозначность.
pls->Lottery:-.drawO ; // Вызов Lotterydraw.
pls->GraphicalObject::draw(); // Вызов GraphicalObject::draw.
В данном случае следует обратить внимание на то, что хотя pls указывает на
объект SpecialLotterySimulation, нет способа (исключая приведение ти-
пов - см. правило 39) для вызова функции draw этого класса.
Но подождите - и это еще не все! Функции draw в классах Lottery и Gra-
phical,Object объявлены виртуальными так, чтобы подклассы могли их пере-
определять (см. правило 36), но что если класс LotterySimulatidn захочет пе-
реопределить их обе? Неприятность заключается в том, что этого сделать не удаст-
ся, поскольку класс может иметь только одну функцию с названием draw, не
требующую аргументов. (Из этого закона существует исключение, когда одна
функция объявлена с const, а другая - без const, см. правило 21.)
Одно время эта проблема считалась достаточно серьезной для внесения изме-
нений в стандарт языка. В ARM, руководстве по C++, рассмотрен вопрос, как
добиться того, чтобы наследуемые виртуальные функции можно было «переиме-
новывать», но затем обнаружилось, что данную проблему разрешается обойти до-
бавлением пары дополнительных классов:
class AuxLottery: public Lottery {
public:
virtual int lotteryDraw() = 0;
virtual int draw() { return lottervDraw(); }
};
class AuxGraphicalObject: public GraphicalObject {
public-.
virtual int graphicalObjectDraw() = 0;
virtual int draw!) { return graphicalObj ectDraw () ; }
};
class LotterySimulation: public AuxLottery, public AuxGraphicalObject {
public:
virtual int lotteryDraw();
virtual int graphicalObjectDraw();
};
Каждый из двух новых классов AuxLottery и AuxGraphicalObject, по
существу, объявляет новое имя для наследуемых функций draw. Они принимают
форму чисто виртуальных, в данном случае функций lotteryDraw и graphi"
calobj ectDraw, и поэтому подклассы обязаны их переопределить. Более того,
183
Правило 43
каждый класс переопределяет наследуемую функцию draw, вызывая в ней новую
виртуальную функцию. Суммарный эффект состоит в том, что внутри иерархии
классов неоднозначное название draw фактически расщепляется на два функцио-
нально эквивалентных названия: lotteryDraw и graphicalObj ectDraw.
Lotterysimulation *pls = new Lotterysimulation;
Lottery *pl = pls;
GraphicalObject *pgo = pls;
// Это приводит к вызову Lotterysimulation::lotteryDraw.
pl->draw();
// Это приводит к вызову Lotterysimulation: : graphicalObjectDraw.
pgo->draw();
Подобный образ действий, предусматривающий активное использование про-
думанной комбинации чисто виртуальных, обычных виртуальных и встраиваемых
функций (см. правило 33), следует взять на заметку. Во-первых, это позволяет ре-
шить проблему, с которой вы можете столкнуться в любой момент; во-вторых, мо-
жет служить вам напоминанием о трудностях, возникающих при множественном
наследовании. Да, такая тактика действенна, но действительно ли вы хотите, чтобы
вам приходилось вводить новые классы только для переопределения виртуальных
функций? Наличие классов AuxLotterv и AuxGraphicalObject существенно
для корректного функционирования иерархии, но они не соответствуют абстрак-
ции ни в предметной области, ни в области реализации. Это просто аппарат реали-
зации - и ничего более. Как известно, хорошее программное обеспечение аппарат-
но независимо. Это правило вполне применимо и в данном случае.
Проблема неоднозначности, хотя сама по себе она и интересна, - это только
малая толика тех курьезов, которые возникают при увлечении множественным
наследованием. Другая проблема вырастает из эмпирического наблюдения, что
иерархия наследования, начинающаяся следующим образом:
V class В { ... } ;
class С { ... } ;
class D: public В, public С { ... } ;
имеет неприятное свойство заканчиваться чем-нибудь вроде:
Л class А { ... } ;
class В: virtual public А { ... };
•t < class С: virtual public A { ... };
@ class D: public B, public C { ... };
Такие иерархии наследования не очень дружелюбны. Если вы создаете подоб-
ную иерархию, перед вами немедленно встает вопрос: следует ли делать класс
виртуальным базовым классом, то есть должно ли наследование от А быть вирту-
альным? На практике ответ утвердительный; лишь изредка у вас может возник-
нуть необходимость в том, чтобы объект типа D содержал несколько копий эле-
ментов данных класса А. В силу признания этой закономерности объявленные
Выше классы В и С объявляют класс А виртуальным базовым классом.
Увы, к моменту определения Вис вам могло быть неизвестно, будет ли ка-
кой-либо класс наследовать от них обоих, и в принципе для их корректного
184
Наследование и ООП
определения вам нет необходимости знать это. Как разработчик классов вы оказы-
ваетесь перед весьма неприятной дилеммой. Если вы не объявите А как виртуаль-
ный базовый класс для классов В и С, позднее разработчику D может потребовать-
ся переопределить В и С для их более эффективного использования. Часто это
невозможно постольку, поскольку иногда определения А, В и С могут быть доступ-
ны только для чтения. Например, так и окажется в случае, когда А, В и С находят-
ся в библиотеке, a D написан пользователем.
С другой стороны, если вы объявляете А виртуальным базовым классом для В
и С, то обычно обрекаете пользователей этих классов на дополнительные расходы
памяти и времени исполнения. Это связано с тем, что виртуальные базовые классы
обычно реализуются как указатели, а не как сами объекты. Само собой разумеется,
что схема размещения объектов в памяти зависит от компилятора, но в принципе
схема размещения в памяти объектов типа D с невиртуальным базовым классом -
это, как правило, непрерывная последовательность блоков памяти, в то время как
схема размещения объектов типа D с виртуальным базовым классом А - это зачас-
тую последовательность блоков памяти, два из которых содержат указатели на блок
памяти, в котором присутствуют элементы данных виртуального класса:
A Part
В Part
A Part
С Part
D Part
Даже те компиляторы, которые не придерживаются данной стратегии реали-
зации, все равно вызывают дополнительный расход памяти при использовании
множественного наследования.
В свете этих соображений может показаться, что для эффективной разработ-
ки классов при наличии множественного наследования разработчикам библио-
тек необходимо обладать даром ясновидения. При том, что в наши дни даже
обычное здравомыслие становится дефицитом, вряд ли стоит слишком сильно
полагаться на такие свойства языка программирования, которые не только тре-
буют от разработчика предвидения будущих потребностей, но практически вы-
нуждают его становиться пророком.
Конечно, то же самое можно было бы сказать и относительно выбора между вир-
туальной и невиртуальной функцией, ио здесь существует принципиальное отли-
чие. В правиле 36 объясняется, что виртуальная функция имеет вполне определен-
ную высокоуровневую интерпретацию, отличную от интерпретации невиртуальных
функций, поэтому между ними можно выбирать, основываясь на том, что вы хотите
сообщить разработчикам производных классов. Заметим, что при принятии решения
Правило 43
185
относительно того, следует ли делать базовый класс виртуальным, подобного руко-
водящего принципа у вас нет. Вместо этого решение обычно основывается на струк-
туре всей иерархии наследования, и, следовательно, его нельзя окончательно принять
до тех пор, пока нс станет известна вся иерархия. Если для корректного определения
класса вам необходимо точно знать, как он будет использоваться, проектирование
эффективных классов становится затруднительным.
Как только вы разберетесь с проблемой неоднозначности и решите вопрос
о том, должно ли наследование от вашего базового класса быть виртуальным,
вы столкнетесь с рядом других проблем. Чтобы не тратить времени, я просто упо-
мяну еще два вопроса, о которых вам необходимо помнить;
1. Передача аргументов конструкторам виртуальных базовых классов. При не-
виртуальном наследовании аргументы конструктора базового класса зада-
ются в списке инициализации непосредственного производного класса. По-
скольку, при простом наследовании используются только невиртуальные
базовые классы, аргументы в иерархии наследования передаются весьма
естественным способом: классы уровня п передают аргументы классам
уровня п-1. Аргументы же конструкторов виртуальных базовых классов
задаются в списке инициализации самого старшего производного класса.
В результате класс, инициализирующий виртуальный базовый класс, мо-
жет находиться на произвольном удалении от него в графе иерархии клас-
сов, а тот, который ответствен за инициализацию, может меняться по мере
добавления к иерархии новых классов. (Вы имеете шанс избежать данной
проблемы, устранив необходимость передачи аргументов конструкторам
виртуальных базовых классов. Наиболее легкий способ достижения этой
цели заключается в том, чтобы не определять в таких классах элементы дан-
ных. Таким образом, например, решается проблема в языке Java: «интерфей-
сам» запрещено содержать данные.)
2. Доминирование виртуальных функций. Только вы начинаете думать, что избави-
лись от всех неоднозначностей, как они меняют форму. Вернемся к ромбовидно-
му графу иерархии, включающему в себя классы А, В, С и D. Предположим,
что А определяет виртуальную функцию-член mf, а С ее переопределяет; при
этом В и D не переопределяют mf.
По аналогии со сказанным ранее можно ожи-
дать, что нижеследующий вызов будет неодно-
значен:
D *pd = new D;
pd->mf(); // A: :mf или C:
Какая функция mf должна вызываться для
объекта D: та, которая непосредственно насле-
дуется от С, или та, которая опосредованно (че-
рез В) наследуется от А? Все зависит от того,
каким образом объявлен класс А в списке базовых классов для В и для С. В частно-
сти, если А - невиртуальный базовый класс для В и С, то вызов функции будет
неоднозначным, однако если А - виртуальный базовый класс для В и С, тогда гово-
рят, что переопределение функции mf в классе С доминирует над первоначальным
1
186
Наследование и ООП
определением,в классе А, и вызов mf через pd будет однозначно разрешен как
С: :mf.
Если вы сядете и хорошенько во всем этом разберетесь, то поймете, что имен-
но такого поведения вы стремились добиться; но начинать разбираться прежде,
чем все станет и без того понятным - это сущее наказание.
Наверное, теперь вы согласны, что множественное наследование приводит
к различным осложнениям. Вероятно, вы даже убеждены, что ни один человек,
находящийся в здравом уме, не будет использовать эту возможность. Пожалуй, вы
готовы предложить Международному комитету по стандартизации C++ удалить
из языка множественное наследование или намекнуть своему менеджеру, чтобы
программистам вашей компании строго-настрого запретили его использовать.
Скорее всего, вы немного торопитесь.
Необходимо помнить о том, что разработчики C++ не ставили своей целью
сделать использование множественного наследования как можно более затрудни-
тельным: просто оказалось, что попытка заставить разные части этого механизма
работать вместе более или менее разумным образом неизбежно приводит к опре-
деленному усложнению процесса. В процессе обсуждения данной проблемы вы
могли заметить, что большая часть затруднений связана с использованием вир-
туальных базовых классов. Когда есть возможность избежать их применения -
то есть образования опасного ромбовидного графа наследования - ситуация ста-
новится намного благоприятнее.
Например, в правиле 34 описаны классы-протоколы, служащие исключитель-
но для определения интерфейсов производных классов; они не содержат эле-
ментов данных и конструкторов, по включают в себя виртуальный деструктор
(см. правило 14) и набор чисто виртуальных функций, задающих интерфейс.
Класс-протокол Person мог бы выглядеть следующим образом:
class Person {
public:
virtual -PersonO;
virtual string name() const = 0;
virtual string birthDate () const = 0;
virtual string address () const = 0;
virtual string nationality () const = 0;
};
Поскольку абстрактные классы не могут быть инстанцированы, пользователи
этого класса должны программировать с использованием указателей и ссылок
типа Person.
Для создания объектов, с которыми можно работать как с объектами класса
Person, пользователи применяют функции-фабрики (см. правило 34), тем самым
инстанцируя производные от Person классы:
// Функция-фабрика для создания объекта класса Person
//по уникальному идентификатору из базы данных.
Person * makePerson(DatabaselD personidentifier);
DatabaselD askUserForDatabaselp();
DatabaselD pid - askUserForDatabaselD();
Правило 43
187
Person *рр = makePerson(pid); // Создаем объект,
// поддерживающий интерфейс Person.
... // Манипулируем *рр при помощи функций-членов класса Person,
delete рр; // Удаляем объект, когда он больше не нужен.
Сразу встает вопрос: каким образом makePerson создает объекты, на кото-
рые он возвращает указатели? Ясно, что для этого должен быть отдельный кон-
кретный класс, производный от Person.
Пусть такой класс называется MyPerson. Как конкретный класс, MyPerson
должен обеспечить реализацию для чисто виртуальных функций, наследуемых
от Person. Вы можете написать эти функции «с пуля», но лучше было бы вос-
пользоваться существующими компонентами, которые уже делают все необходи-
мое или, по крайней мере, бдльшую часть. Предположим, например, что уже су-
ществует старый класс Personinfo, работающий с базами данных, и что он
реализует основную функциональность, необходимую для MyPerson:
class Personinfo {
public:
Personinfo(DatabaselD pid);
virtual -Personinfo();
virtual const char * theName() const;
virtual const char * theBirthDate() const;
virtual const char * theAddressO const;
virtual const char * theNationality() const;
virtual const char * valueDelimOpen() const;
virtual const char * valueDelimClose() const;
// Смотрите
// ниже.
};
Понятно, что это старый класс, поскольку функции-члены возвращают const
char*, а не объекты string. Тем не менее, если он работает, то почему бы его не
использовать? Судя по названиям функций этого класса, результат может ока-
заться вполне удовлетворительным.
Вы обнаруживаете, однако, что Personinfo был создан для распечатки по-
лей базы данных в определенном формате, причем начало и конец каждого поля
отделялись специальной строкой. По умолчанию начальными и конечными огра-
ничителями полей служили квадратные скобки, поэтому значение поля «Лемур
с окрашенным разноцветными кольцами хвостом» - Лемур кольцехвостый —
будет представлено следующим образом:
[Лемур кольцехвостый]
Тот факт, что не все пользователи Personinfo обязательно захотят применять
квадратные скобки, находит отражение в виртуальности функций valueDel imOpen
и valueDelimClose, позволяющей производным классам задавать свои собствен-
ные начальные и конечные строки-разделители. Реализации функций Per 7
sonlnfo - theName, theBirthDate, theAddressиtheNationality - вы-
зывают указанные виртуальные функции для того, чтобы добавить к возвраща-
емым значениям соответствующие разделители. Выбрав в качестве примера
Personinfo: :name, мы найдем в коде следующее:
188
Наследование и ООП
const char * Personinfo::valueDelimOpen() const
{
return " // Открывающий разделитель по умолчанию.
}
const char * Personinfo::valueDelimClose() const
{
return // Закрывающий разделитель по умолчанию.
}
const char * Personinfo::theName() const
{
// Зарезервировать буфер для возвращаемого значения. Поскольку
//он объявлен static, то автоматически инициализирован нулями,
static char value[MAX_FORMATTED_FIELD_VALUE_LENGTH] ;
// Записать открывающий разделитель.
strcpy(value, valueDelimOpen());
// Добавить поле name этого объекта к строке value.
// Записать закрывающий разделитель.
strcat(value, valueDelimClose ()) ;
return value;
Можно было бы начать придираться к реализации Personinfo: :theName
(особенно к использованию статического буфера фиксированного размера - см. пра-
вило 23), но давайте пока забудем о придирках, а вместо этого обратим внимание па
следующее: для получения начального разграничителя возвращаемой строки
theName вызывает valueDelimOpen, затем генерирует само значение и, наконец,
вызывает valueDelimClose. Поскольку valueDelimOpenи ValueDelimClose -
это виртуальные функции, результат, возвращаемый theName, зависит не только
от Personinfo, ио также и от классов, производных от Personinfo.
Для разработчика MyPerson это хорошая новость, поскольку, покопавшись
в документации на класс Person, вы обнаружите, что name и аналогичные функции-
члены должны возвращать неформатированные значения, то есть использование раз-
делителей не допускается. Таким образом, для человека из Мадагаскара вызов функ-
ции nationality должен возвращать Мадагаскар, а не [Мадагаскар].
Единственное связующее звено между MyPerson и Personinfo - тот факт, что
Personinfo, как выяснилось, содержит некоторые функции, облегчающие реа-
лизацию MyPerson. Между ними нет никакого отношения типа «есть разновидность»
или «содержит». Таким образом, они связаны отношением «реализован посред-
ством», а мы знаем, что представить его можно двумя способами: с помощью вложе-
ния (см. правило 40) и с помощью закрытого наследования (см. правило 42). В прави-
ле 42 указано, что вложение - это, вообще говоря, более предпочтительный подход,
но если требуется перегрузить виртуальные функции, то необходимо закрытое насле-
дование. В нашем случае классу MyPerson необходимо переопределить valueDe-
limOpen и valueDelimClose, поэтому подойдет не вложение, а закрытое насле-
дование. С его помощью MyPerson и должен быть произведен от Personinfo.
С другой стороны, MyPerson должен так же реализовывать интерфейс клас-
са Person, а это требует открытого наследования. Вот вам и одно из применений
множественного наследования: объединение открытого наследования интерфей-
са и закрытого наследования реализации.
j 189
Правило 43
class Person { // Этот класс задает интерфейс, подлежащий реализации,
public:
virtual -Person();
virtual string name() const = 0;
virtual string birthDate() const = 0;
virtual string address() const = 0;
virtual string nationality() const - 0;
};
class DatabaselD { ... }; // Используется ниже; детали несущественны.
class Personinfo {...}; // Этот класс содержит функции,
public: // полезные для реализации
Personinfo(DatabaselD pid); // интерфейса Person.
virtual -Personinfo();
virtual const char * theName() const;
virtual const char * theBirthDate() const;
virtual const char * theAddressO const;
virtual const char * theNationality() const;
virtual const char * valueDelimOpen() const;
virtual const char * valueDelimClose() const;
};
class MyPerson:public Person, // Обратите внимание
private Personinfo { //на множественное наследование,
public:
MyPerson(DatabaselD pid) : Personinfo(pid) {}
// Переопределение унаследованных функций для разделителей.
const char * valueDelimOpen() const { return ""; }
const char * valueDelimClose () const { return 11"; }
// Определение требуемых интерфейсом Person функций-членов.
string name() const
{ return Personinfo::theName(); }
string birthDate() const
{ return Personinfo: :theBirthDate() ; }
string address() const
{ return Personinfo::theAddress(); }
string nationality () const
{ return Personinfo::theNationality(); }
};
Графически это выглядит так:
Этот пример демонстрирует, что множественное наследование может быть
одновременно и полезным, и понятным, хотя бросающееся в глаза отсутствие
одиозного ромбовидного графа не случайно.
190
Наследование и ООП
Тем нс менее вы должны научиться избегать соблазнов. Иногда можно попасть
в ловушку, используя множественное наследование для быстрого исправления
иерархии наследования там, где для этой цели лучше подошло бы полное пе-
рестраивание иерархии. Предположим, например, что вы работаете с иерархией
персонажей мультфильма. В принципе каждый персонаж может танцевать и петь,
но как он это делает, зависит от его особенностей. При этом поведение по умолча-
нию для функций танца и пения - ничего не делать.
Выразить это на C++ можно следующим образом:
class CartoonCharacter {
public:
virtual void dance() {}
virtual void sing() {}
};
Виртуальные функции естественным образом отражают тот факт, что пение
и танец имеют смысл для всех объектов CartoonCharacter. Поведение по умол-
чанию можно выразить пустыми определениями соответствующих функций клас-
са (см. правило 36).
Допустим, что один из типов персонажей - кузнечик, который танцует и поет
особым образом:
class Grasshopper: public CartoonCharacter {
public:
virtual void dance () ; // Определение в другом месте.
virtual void sing() ; // Определение в другом месте.
};
Теперь предположим, что после реализации класса Grasshopper вам пона-
добился класс для сверчков:
class Cricket: public CartoonCharacter {
public:
virtual void dance();
virtual void sing();
};
Когда вы принимаетесь за реализацию класса Cricket, вы осознаете, что мо-
жете повторно использовать значительную часть класса Grasshopper. Однако
этот код в разных местах необходимо слегка модифицировать, чтобы отразить раз-
личия в пении и танце кузнечиков и сверчков. Вас неожиданно осеняет, каким об-
разом повторно использовать существующий код: вы реализуете класс Cricket по-
средством класса Grasshopper и применяете виртуальные функции для того,
чтобы позволить классу Cricket изменить поведение класса Grasshopper!
Сразу же становится ясно, что одновременное выполнение двух требований -
наличия отношения «реализация посредством» и возможности переопределять
виртуальные функции - означает необходимость закрытого наследования Cricket
от Grasshopper. При этом сверчок, разумеется, - персонаж мультфильма, поэто-
му вы переопределяете класс Cricket так, чтобы он наследовал как от Grass-
hopper, так и от CartoonCharacter:
; 191
Правило 43
class Cricket .-public CartoonCharacter, private Grasshopper {
public:
virtual void dance() ;
virtual void singO ;
};
Затем вы начинаете вносить необходимые изменения в класс Grasshopper.
В частности, требуется объявить ряд новых виртуальных функций, которые мож-
но будет потом переопределять в Cricket:
class Grasshopper: public CartoonCharacter {
public:
virtual void dance() ;
virtual void singO ;
protected:
virtual void danceCustomizationl();
virtual void danceCustomization2();
virtual- void singCustomization() ;
};
Танец кузнечика теперь определяется следующим образом:
void Grasshopper::dance()
{
выполнить общие танцевальные действия
danceCustomizationl ()
выполнить другие общие танцевальные действия
danceCustomization2();
завершить общие танцевальные действия
}
Пение кузнечика определяется аналогичным образом.
Ясно, что класс Cricket должен быть обновлен с учетом новых виртуальных
функций, требующих переопределения:
class Cricket:public CartoonCharacter, private Grasshopper {
public:
virtual void dance() { Grasshopper::dance(); }
virtual void singO { Grasshopper: : singO ; }
protected:
virtual void danceCustomizationl();
virtual void danceCustomization2();
virtual void singCustomization() ;
};
Кажется, что все работает здорово. Когда вы предлагаете объекту Cricket
Танцевать, он выполняет обычный код dance из класса Grasshopper, затем -
специальный код класса Cricket, после чего продолжает выполнение кода из
Grasshopper: : dance и т.д.
Однако данный фрагмент таит в себе существенный недостаток: вы опро-
метчиво попали под бритву Оккама, что, вообще говоря, плохо, какова бы ни
была бритва, а уж тем более плохо, если это бритва Уильяма Оккама. Он учит,
192
Наследование и ООП
что сущности не следует умножать без необходимости, а в нашем случае сущнос-
ти - это отношения наследования. Если вы считаете, чго множесгвенное наследо-
вание сложнее простого (а я подозреваю, что это именно так), то подобная реа-
лизация класса Cricket является чрезмерно сложной.
Суть проблемы состоит в том, что утверждение «класс Cricket реализуется
посредством класса Grasshopper» неверно. На самом деле класс Cricket и класс
Grasshopper используют общий код. В частности, они оба применяют код, опре-
деляющий общие черты в поведении кузнечиков и сверчков при танце и пении.
Выразить некоторую общность двух классов следует не с помощью наследова-
ния одного от другого, а с помощью наследования от общего базового класса. Об-
щий для кузнечика и сверчка код не принадлежит ни классу Cricket, ни классу
Grasshopper. Он относится к новому классу, от которого они оба наследуют, -
скажем, Insect:
class CartoonCharacter { ... } ;
class Insect: public CartoonCharacter {
public:
virtual void dance () ; // Общий код для кузнечиков
virtual void sing(); //и сверчков,
protected:
virtual void danceCustomizationl ()
virtual void danceCustomization2()
virtual void singCustomization() -
};
class Grasshopper: public Insect {
protected:
virtual void danceCustomizationl()
virtual void danceCustomization2()
virtual void singCustomization () ;
};
class Cricket: public Insect {
protected:
virtual void danceCustomizationl () ;
virtual void danceCustomization2();
virtual void singCustomization();
};
Обратите внимание, насколько проще стала конструкция. В ней встречается
только одиночное наследование и, более того, только открытое наследование. Клас-
сы Grasshopper и Cricket определяют исключительно функции, уточняющие
поведение; функции dance и sing, наследуемые от Insect, остаются неизменны-
ми. Уильям Оккам остался бы доволен.
Хотя эта конструкция проще, чем конструкция с использованием множествен-
ного наследования, на первый взгляд она может показаться хуже. В конце концов,
в отличие от подхода, основанного на использовании множественного наследова-
ния, применение простого наследования требует введения совершенно нового клас-
са. Зачем вводить дополнительный класс, если в этом нет необходимости?
Здесь мы неизбежно сталкиваемся с соблазном использования множественно-
го наследования. При поверхностном рассмотрении интерфейс множественного
193
Правило 44 al
наследования кажется более легким. Он не требует добавления новых классов,
и хотя необходимо добавить в класс Grasshopper несколько новых виртуаль-
ных функций, это не составляет проблемы, поскольку такие функции все равно
где-то пришлось бы объявлять.
Представьте себе программиста, поддерживающего большую библиотеку
классов C++, к которой необходимо добавить новый класс, подобно тому как
Cricket должен быть добавлен к существующей иерархии CartoonCharacter/
Grasshopper. Программист знает, что существующей иерархией пользуются
очень и очень многие, поэтому чем больше изменений вносится в библиотеку,
тем больше хлопот доставит это пользователям. Разработчик намерен свести
к минимуму возникающие неудобства. Поразмыслив над имеющимися возмож-
ностями, он осознает, что если добавить дополнительное закрытое наследование
класса Cricket от Grasshopper, то другие изменения в иерархии не потре-
буются. Программист радостно улыбается этой мысли, обрадованный перспек-
тивой большого выигрыша в функциональности за счет небольшого проигрыша
в сложности.
Представьте себя на месте этого программиста и... удержитесь от соблазна.
Правило 44. Говорите то, что думаете,
понимайте то, что говорите
Во введении к этому разделу, посвященному объектно-ориентированному
программированию, я подчеркнул, насколько важно понимать, что означают раз-
личные объектно-ориентированные конструкции C++. Такое понимание во мно-
гом отличается от простого знания правил языка. Например, правила C++ гово-
рят, что если класс D открыто наследует от класса В, то существует стандартное
преобразование указателей D в указатели В; что открытые функции-члены клас-
са В наследуются как открытые функции-члены класса D и т.д. Все это верно, но
практически бесполезно, если вы стараетесь перевести ваш проект на язык C++.
Вместо этого необходимо осознавать, что открытое наследование означает «есть
разновидность», что если D открыто наследует от В, то каждый объект типа D
является разновидностью объекта типа В. Таким образом, если при проектирова-
нии вы имеете в виду отношение «есть разновидность», то вам следует использо-
вать открытое наследование.
Говорить то, что вы имеете в виду, - значит выиграть лишь половину сраже-
ния. Оборотная сторона медали, а именно понимание того, что вы говорите, не
менее важна. Например, было бы безответственно, если не сказать аморально, на-
спех объявить функции класса невиртуальными, не осознавая, что при таком оп-
ределении вы налагаете ограничения на производные классы. Объявляя невирту-
альную функцию-член, вы в действительности утверждаете следующее: данная
функция представляет собой инвариант относительно специализации; если это-
го не учитывать, последствия будут плачевными.
(Эквивалентность открытого наследования и отношения «есть разновидность»,
а также эквивалентность невиртуальных функций-членов и инвариантности
по отношению к специализации - это примеры, иллюстрирующие, насколько
П 1АСЭ
194
«! Наследование и ООП
определенные конструкции C++ соответствуют идеям разработчика. Следующий
список содержит наиболее важные из этих соответствий:
1. Наличие общего базового класса означает наличие общих, свойств. Если класс D1
и класс D2 объявляют класс В своим базовым классом, то D1 и D2 наследуют
общие элементы данных и/или общие функции-члены В (см. правило 43).
2. Открытое наследование означает «есть разновидность». Если класс D откры-
то наследует от класса В, то каждый объект типа D также является объектом
типа В, но не наоборот (см. правило 35).
3. Закрытое наследование означает «реализацию посредством». Если класс D
закрыто наследует от класса В, объекты типа D достаточно просто реализу-
ются с помощью объектов типа В; между объектами типов В и D нет концеп-
туальной взаимосвязи (см. правило 42).
4. Вложение означает «содержит» или «реализуется посредством». Если класс А
содержит элементы данных типа В, то объекты типа А либо имеют компо-
нент типа В, либо реализуются посредством объектов типа В (см. правило 40).
Следующие соответствия применимы только тогда, когда речь идет об откры-
том наследовании:
1. Чистая виртуальность функции означает, что наследуется только интер-
фейс функции. Если класс С объявляет чисто виртуальную функцию-член mf,
то подклассы С должны наследовать интерфейс mf и предоставить для нее
свои собственные реализации (см. правило 36).
2. Обычная виртуальность функции означает, что наследуется интерфейс
плюс реализация по умолчанию. Если класс С объявляет простую виртуаль-
ную функцию mf, то подклассы С должны наследовать интерфейс mf и мо-
гут также по вашему желанию наследовать реализацию по умолчанию (см.
правило 36).
З.Невиртуальность функции означает, что наследуется интерфейс плюс
обязательная реализация функции. Если класс С объявляет невиртуаль-
ную функцию-член mf, то подклассы С должны наследовать как интер-
фейс mf, так и ее реализацию. В действительности mf определяет инва-
риант относительно специализации С (см. правило 36).
Глава 7. Другие принципы
Некоторые принципы эффективного программирования па C++ с трудом подда-
ются классификации. Они-то и собраны в данном разделе. Указанная особенность,
впрочем, не умаляет их значимости. Если ваша цель - написание эффективного
программного обеспечения, необходимо понимать, что за вашей спиной делает
компилятор; как добиться того, чтобы нелокальные статические объекты инициа-
лизировались прежде, чем вы их используете; чего можно ожидать от стандарт-
ной библиотеки; как усвоить философию, лежащую в основе языка. В заключи-
тельном разделе книги я постараюсь дать ответ па эти и некоторые другие
вопросы.
Правило 45. Необходимо знать, какие функции
неявно создает и вызывает С++
Когда пустой класс не является пустым классом? Когда за пего берется C++.
Если этого не сделаете вы, «заботливый» компилятор объявит свою собственную
версию конструктора копирования, оператора присваивания, деструктора и пары
операторов получения адреса. Более того, если вы не определите конструктор, то
компилятор и это сделает за вас. Все указанные функции будут открытыми. Дру-
гими словами, если вы напишете следующее:
class Empty{};
это будет равносильно тому, что вы скажете:
class Empty {
public:
Empty () ; / / Конструктор по умолчанию.
Empty(const Empty& rhs); 11 Конструктор копирования.
-Empty(); // Деструктор - смотрите ниже, виртуальный ли он.
EmptyS/
operator^(const Empty& rhs); // Оператор присваивания.
Empty* operators(); 11 Операторы получения адреса,
const Empty* operators() const;
};
Заметьте, что эти функции генерируются, только если они необходимы; впро-
чем, необходимость в них возникает очень часто. Следующий код приводит
к созданию таких функций:
const Empty el; // Конструктор по умолчанию; деструктор.
Empty e2(el); // Конструктор копирования.
7*
Другие принципы
е2 = el;
Empty *pe2 = &e2 ;
const Empty *pel = ⪙
// Оператор присваивания.
// Оператор получения адреса (не-const) .
// Оператор получения адреса (const) .
Что делает компилятор, когда он пишет за вас функции? Конструкторы и де-
структоры по умолчанию в действительности ничего не делают. Они просто дают вам
возможность создавать и удалять объекты класса. (Это также удобное место, где раз-
работчики компилятора могли разместить код, ответственный за «закулисное» пове-
дение - см. правило 33.) Обратите внимание, что генерируемый деструктор не явля-
ется виртуальным (см. правило 14), если данный класс сам не наследует от базового
класса, объявляющего виртуальный деструктор. Операторы получения адреса
по умолчанию просто возвращают адрес объекта. Бее вышеперечисленные функции
ведут себя так, как если бы они определялись следующим образом:
inline Empty::Empty() {}
inline Empty :-Empty () {}
inline Empty * Empty::operator&() { return this; }
inline const Empty * Empty.- .-operator& () const
{ return this; }
Для конструктора копирования и оператора присваивания формальное прави-
ло таково: конструктор копирования по умолчанию (оператор присваивания) вы-
полняет почленное копирование (присваивание) нестатических членов класса.
Иными словами, если m - неСтатичсский член класса с типа Т и С не объявляет
конструктор копирования (оператор присваивания), то m будет создан копи-
рованием (присваиванием) с использованием конструктора копирования (опера-
тора присваивания), определенного для Т, если таковой имеется. Если же кон-
структор отсутствует, правило рекурсивно применяется к членам класса до тех
пор, пока мы не доходим до конструктора копирования (оператора присваива-
ния) или встроенного типа данных (например, int, double, указателей и т.д.).
По умолчанию объекты встроенных типов создаются побитовым копированием
(присваиванием) исходного объекта в конечный. Для классов, наследующих от
других классов, это правило применимо на каждом уровне иерархии наследо-
вания, так что определенные пользователем конструкторы копирования и опера-
торы присваивания вызываются на тех уровнях, на которых они определяются.
Надеюсь, что здесь все предельно ясно.
Но на тот случай, если что-то осталось непроясненным, изучите следующий
пример. Рассмотрим определение шаблона NamedObject, который генерирует
классы, позволяющие связывать с объектами идентификаторы:
template<class Т>
class NamedObject {
public:
NamedObject(const char *name, const T& value);
NamedObject (const string^ name, const T& value) ;
private:
string namevalue;
T objectvalue;
Правило 45
197
Поскольку класс NamedObj ect определяет по крайней мере один конструктор,
компилятор не будет генерировать конструктор по умолчанию, а так как этот класс
не определяет ни конструктор копирования, пи оператор присваивания, компиля-
тор будет генерировать эти функции (если в них возникнет необходимость).
Рассмотрим следующий вызов конструктора копирования:
NamedObject<int> nol("Smallest Prime Number", 2) ;
NamedObject<int> no2(nol) ; Il Вызов конструктора копирования.
Конструктор копирования, генерируемый вашим компилятором, должен ини-
циализировать no2 . nameValue и no2 . obj ectValue, используя соответственно
nol. nameValue и nol. obj ectValue. Тип nameValue - string, a string имеет
конструктор копирования (в чем вы можете убедиться, исследуя string из стан-
дартной библиотеки - см. правило 49); таким образом, no2 . nameValue будет
инициализирован вызовом конструктора копирования string с аргументом
nol. nameValue. С другой стороны, тип NamedObj ect<int>: : obj ectValue -
int (поскольку T при данной инстанциации шаблона - int), а для типа int кон-
структор копирования не определяется, поэтому no2 .objectValue будет ини-
циализирован побитовым копированием nol.objectvalue.
Генерируемый компилятором оператор присваивания для NamedOb j ect<int>
будет действовать аналогичным образом, но только тогда, когда получаемый код,
во-первых, корректен, а во-вторых, может иметь смысл. Если любое из этих условий
не выполняется, компилятор откажется генерировать для вашего класса operators
и в ходе компиляции вы получите ряд забавных диагностических сообщений.
Например, предположим, что NamedObj ect был определен следующим обра-
зом (здесь nameValue - это ссылка на строку, a obj ectValue имеет тип const Т):
templatecclass Т>
class NamedObject {
public:
// Этот конструктор уже не имеет константного параметра
// name, поскольку nameValue теперь является ссылкой
//на неконстантный объект string. Конструктора, использующего
// char *, больше нет, поскольку теперь есть ссылка на string.
NamedObject (strings name, const T& value) .
...II Как и выше, предположим, что оператора не объявлен.
private:
strings nameValue; // Теперь это ссылка.
const Т objectvalue; // Теперь это const.
};
Давайте посмотрим, что произойдет дальше:
string newDog("Persephone");
string oldDog("Satch");
NamedObject<int> p(newDog, 2); //На момент написания этой книги нашей
// собаке Персефоне чуть меньше двух лет.
NamedObject<int> s(oldDog, 29);// Собаке Сэтч, жившей в нашей семье,
// когда я был ребенком, исполнилось бы
//29 лет, будь она еще жива.
р = s; II Что должно произойти с данными объекта р?
Другие принципы
Перед присваиванием р. nameValue ссылается на некоторый объект string,
так же как и s .nameValue, который ссылается на другую строку string. Как
присваивание отразится на р. nameValue? Будет ли р. nameValue после присва-
ивания ссылаться па ту же строку string, на которую ссылается s . nameValue,
то есть должна ли изменяться сама ссылка? Если должна, то это создаст преце-
дент, поскольку С++ не обеспечивает средств, позволяющих ссылкам ссылаться
на другие объекты. С другой стороны, должен ли модифицироваться объект
string, па который ссылается р.nameValue, что повлияет и на другие указате-
ли и ссылки, указывающие на эту строку, - то есть на объекты, непосредственно
не вовлеченные в операцию присваивания? И как прикажете поступать опера-
тору присваивания, генерируемому компилятором?
Столкнувшись с такими головоломками, C++ отказывается компилировать код.
Если вы хотите поддерживать присваивание для классов, содержащих ссылки, опе-
ратор присваивания вам необходимо определить самостоятельно. Аналогичным об-
разом компиляторы ведут себя с классами, содержащими члены с const (такими, как
objectvalue в рассмотренном выше классе); модифицировать члены с const за-
прещено, поэтому как с ними быть в генерируемой по умолчанию функции присва-
ивания, компилятор не «знает». И наконец, компиляторы отказываются генериро-
вать операторы присваивания производных классов, если базовые классы объявляют
стандартный оператор присваивания закрытым. В конечном счете генерируемые
компилятором операторы присваивания производных классов должны обраба-
тывать и части, относящиеся к базовым классам (см. правило 16), но при этом не
должны вызывать функции, вызывать которые у производного класса нет права.
Приведенный анализ генерируемых компилятором функций ставит вопрос
о том, что необходимо делать, если вы хотите запретить использование этих функ-
ций. А что, если вы преднамеренно не объявляете, например, operators по-
скольку вам вообще нс хочется допускать присваиваний объектов вашего класса?
Решение этой небольшой задачки - тема правила 27. Вопрос о том, какие пробле-
мы (часто упускаемые из виду) связаны с наличием в классе членов-указателей
при создании компиляторами конструкторов копирования и операторов присва-
ивания, обсуждается в правиле И.
Правило 46. Предпочитайте ошибки во время
компиляции ошибкам во время выполнения
Кроме тех редких случаев, когда C++ вынужден генерировать исключения
(например, при нехватке памяти, см. правило 7), понятие ошибок во время выпол-
нения (runtime error) настолько же чуждо языку C++, насколько оно чуждо С.
В обоих языках отсутствуют средства обнаружения ошибок округления, перепол-
нения, деления на ноль; нет проверки выхода за границы массива и т.д. Как толь-
ко программа проходит компиляцию и компоновку, вся ответственность ложит-
ся на вас - здесь нет «ремня безопасности», защищающего от последствий тех или
иных действий. Так же, как и в случае затяжных прыжков с парашютом, одних
людей это привлекает, других приводит в ужас. В основе такой философии, бе-
зусловно, лежит стремление к эффективности: без контроля ошибок в момент вы-
полнения программа получается меньше и быстрее.
Правило 46
199
Существует и другой подход. Языки, подобные Smalltalk и LISP, обычно об-
наруживают меньше ошибок во время компиляции и компоновки, но обладают
мощной системой обнаружения ошибок в момент выполнения программы. В от-
личие от C++ это практически всегда интерпретируемые языки, и за дополни-
тельную гибкость вы расплачиваетесь производительностью.
Никогда не забывайте, что вы программируете на C++. Даже если философия
Smalltalk/LISP кажется вам привлекательной, выбросите ее из головы. О необхо-
димости «придерживаться генеральной линии партии» можно говорить очень
долго; в нашем случае это означает избегать ошибок во время выполнения про-
граммы. Всегда нужно стремиться обнаружить ошибку не в момент выполнения,
а в момент компоновки или, лучше всего, в момент компиляции.
Такой подход оправдывает себя не только в связи с размерами и скоростью вы-
полнения программы, но часто и в отношении надежности. Если ваша программа
проходит через компилятор и компоновщик, не выдавая сообщений об ошибках,
вы можете быть уверены, что ошибок, которые можно обнаружить во время ком-
пиляции или компоновки, в вашей программе заведомо нет. (Существует, конеч-
но, другая вероятность: в вашем компиляторе или компоновщике имеются ошиб-
ки, - но не будем о грустном.)
В случае с ошибками во время выполнения ситуация совершенно иная. То, что
в вашей программе при конкретном запуске не возникает ошибок во время вы-
полнения, не дает вам гарантии, что они не появятся в следующий раз, когда вы бу-
дете выполнять те же действия в другом порядке, введете другие данные или будете
работать в течение большего или меньшего периода времени. Можно тестировать
программу до бесконечности, но вам все равно не удастся охватить все возможнос-
ти. Поэтому попытка обнаруживать ошибки во время выполнения дает менее на-
дежные результаты, чем обнаружение их в момент компиляции или компоновки.
Иногда, внеся в ваш проект относительно малые изменения, вы можете в ходе
компиляции .обнаружить проблему, которая без этих изменений привела бы к ошиб-
ке при выполнении программы. Часто такие изменения связаны с введением новых
типов. Например, предположим, что вы пишете класс для представления дат. Ваша
первая попытка могла бы выглядеть следующим образом:
class Date {
public:
Date (int day, int month, int year) ;
};
Если вам будет необходимо реализовать приведенный конструктор, то одной
из проблем, с которой предстоит столкнуться окажется проверка допустимости
значений дня и месяца. Давайте посмотрим, как можно избавиться от необходи-
мости проверки значения, передаваемого для месяца.
Один из очевидных подходов заключается в том, чтобы применить вместо
целого перечисление:
enum Month { Jan = 1, Feb = 2, ... , Nov = 11, Dec = 12 };
class Date {
public:
200
Другие принципы
Date(int day, Month month, int year) ;
};
К сожалению, это не решает всех ваших проблем, поскольку перечислимые
типы не требуют обязательной инициализации:
Month m;
Date <3(22, m, 1857) ; // m не определено.
В результате конструктор Date все еще нуждается в проверке допустимости
значения аргумента month.
Для достижения той степени безопасности, которая позволит отказаться
от проверок в момент выполнения программы, вам нужно использовать класс,
представляющий месяцы, и вы должны гарантировать, что будут создаваться толь-
ко допустимые значения месяцев:
class Month {
public:
static const Month Jan() { return 1; }
static const Month Feb() { return 2; }
static const Month Dec() { return 12; }
int aslnt() const // Для удобства разрешим преобразование Month в int.
{ return monthNumber; }
private:
Month(int number): monthNumber(number) {}
const int monthNumber;
};
class Date {
public:
Date(int day, const Month& month, int year);
};
Несколько аспектов этого варианта решения в своей совокупности обеспечи-
вают его действенность. Во-первых, конструктор для Month - закрытый. Это не
дает пользователям самостоятельно создавать новые месяцы. Единственно воз-
можные значения возвращаются статическими функциями-членами Month либо
получаются их копированием. Во вторых, каждый объект Month - это объект типа
const, поэтому его нельзя изменять. (Иначе соблазн преобразовать январь в июнь
мог бы однажды оказаться непреодолимым, по крайней мере, в северных широ-
тах.) И наконец, единственный способ получить объект Month - это вызов функ-
ции или копирование существующего объекта Month (посредством неявного
конструктора копирования, см. правило 45). Теперь объекты Month можно ис-
пользовать в любом месте и в любое время; нет нужды беспокоиться о том, чтобы
случайно не использовать объект до того, как он будет инициализирован. (Пра-
вило 47 объясняет, почему в противном случае у вас могут возникнуть проблемы.)
С такими классами пользователь почти не имеет возможности задать недопу-
стимый месяц. Это было бы вообще невозможно, если бы удалось предотвратить
следующую ситуацию:
201
Правило 47
Month *pm; // Определяем неинициализированный указатель.
Date d(1, *pm, 1997); // Бррр! Используем его!
Здесь имеет место обращение к неинициализированному указателю, - опера-
ция, результат которой не определен. (Мои соображения по поводу неопределен-
ного поведения изложены в правиле 3.) К сожалению, я не знаю способа, который
позволил бы свести на нет подобную вероятность. Однако если мы предположим,
что ничего похожего не произойдет, или если нам безразлично, что будет делать
программа в этом случае, мы можем избежать проверки аргумента Month в кон-
структоре Date. С другой стороны, конструктор все еще должен проверять аргу-
мент day - сколько дней в сентябре, апреле, июне, ноябре.
В этом примере с Date проверка во время выполнения программы заменяется
проверкой во время компиляции. Вероятно, для вас остается загадкой, когда мож-
но использовать проверку во время компоновки. На самом деле это позволяется
делать не очень часто. C++ использует компоновщик для того, чтобы гарантиро-
вать, что необходимые функции определяются только один раз (определение не-
обходимости функции - см. правило 45). Он также применяет компоновщик для
того, чтобы статические объекты (см. правило 47) определялись только один раз.
Например, в правиле 27 показано, как проверки, осуществляемые компоновщи-
ком, помогают избежать определения функций, которые вы явно объявляете.
Мой вам совет: не впадайте в крайности. Попытка устранить все проверки
во время выполнения была бы непрактичной. Например, любая программа, до-
пускающая интерактивный ввод, должна, по-видимому, осуществлять проверку
на допустимость вводимых значений. Аналогично класс, реализующий массивы
с проверкой диапазона (см. правило 18), обычно должен проверять индекс масси-
ва всякий раз, когда к нему осуществляется доступ. Тем не менее перенос провер-
ки с момента выполнения на момент компиляции или компоновки - это достой-
ная цель, к которой всегда следует стремиться. Наградой вам будут меньшие
по объему, более быстрые и надежные программы.
Правило 47. Обеспечьте инициализацию
нелокальных статических объектов
до их использования
Вы уже многое узнали, и поэтому пет необходимости объяснять вам, что неле-
по использовать объекты до их инициализации. Собственно, сама постановка во-
проса может показаться вам абсурдной; ведь конструкторы и так гарантируют
Инициализацию объектов в момент их создания, не правда ли?
И да, и нет. В пределах данной единицы трансляции (то есть исходного фай-
ла) все работает хорошо, но ситуация становится намного сложнее, когда инициа-
лизация объекта в одной единице трансляции зависит от значения другого объекта
н другой единице трансляции, а другой объект сам нуждается в инициализации.
Допустим, что вы являетесь автором библиотеки, реализующей абстракцию
файловой системы, которая включает возможность сделать файлы из Internet не-
отличимыми от локальных. Поскольку для вашей библиотеки мир представляет
собой единую файловую систему, вы могли бы создать внутри пространства имен
202
Другие принципы
специальный объект theFileSystem (см. правило 28), который будет использо-
ваться для взаимодействия с абстракцией файловой системы, предлагаемой ва-
шей библиотекой:
class FileSystem {...}; // Этот класс находится в вашей библиотеке.
FileSystem theFileSystem; // Это объект, с которым работают
// пользователи библиотеки.
Поскольку theFileSystem представляет собой нечто весьма сложное, неуди-
вительно, что его создание, во-первых, нетривиально, а во-вторых, существенно
для дальнейшей работы; использование theFileSystem до того, как объект был
создан, дает очень неопределенное поведение.
Теперь предположим, что некоторые пользователи вашей библиотеки создают
класс, описывающий директории файловой системы. Естественно, этот класс ис-
пользует theFileSystem:
class Directory { // Созданный пользователем.
public:
Directory();
};
Directory::Directory()
{
создание объекта Directory с использованием функций-членов
объекта theFileSystem
}
Более того, предположим, что пользователи решили создать отдельный гло-
бальный объект класса Directory для временных файлов:
Directory tempDir; // Директория для временных файлов.
Теперь проблема порядка инициализации theFileSystem становится оче-
видной: если объект theFileSystem не будет инициализирован раньше, чем
tempDir, то конструктор tempDir попытается использовать theFileSystem
до его инициализации. Но theFileSystem и tempDir были созданы различны-
ми людьми в разное время и в разных файлах. Можете ли вы быть уверены, что
объект theFileSystem будет создан раньше, чем tempDir?
Вопросы подобного рода возникают всегда, когда у вас имеются нелокальные
статические объекты, определенные в различных единицах трансляции, коррект-
ное поведение которых зависит от порядка их инициализации. Нелокальные объ-
екты - такие, которые удовлетворяют следующим условиям:
□ определены глобально или в пространстве имен (например, theFileSystem
и tempDir);
□ объявлены как static в классе;
□ определены как static в пределах области видимости файла.
К сожалению, нет более короткого названия для понятия, обозначаемого тер-
мином «нелокальные статические объекты», поэтому вам следует привыкнуть
к этой неуклюжей фразе.
Правило 47
203
Необходимо, чтобы поведение программы не зависело от порядка инициали-
зации нелокальных статических объектов в различных единицах трансляции, по-
скольку изменить этот порядок вы не можете. Повторим еще раз. Вы никак
не можете управлять порядком, в котором инициализируются нелокальные ста-
тические объекты, определенные в различных единицах трансляции.
Естественно, возникает вопрос, почему так происходит.
Причина в том, что очень трудно, практически невозможно определить над-
лежащий порядок инициализации нелокальных статических объектов. В самом
общем случае - при наличии нескольких единиц трансляции и нелокальных ста-
тических объектов, неявно генерированных из шаблонов (которые и сами могут
возникать из-за неявной генерации шаблонов) - не только невозможно опреде-
лить правильный порядок инициализации, но даже и нет смысла искать частные
случаи, для которых было бы реально определить такой порядок.
В теории хаоса существует понятие «эффект бабочки». Суть его в том, что не-
большое возмущение, вызванное взмахом крыльев бабочки в одной части мира,
может привести к серьезным изменениям погоды на большом удалении от этого
места. Или, выражаясь более строго, для некоторых типов систем небольшое из-
менение на входе может вести к радикальным изменениям на выходе.
В отношении разработки программного обеспечения также проявляется «эф-
фект бабочки». Некоторые системы весьма чувствительны к мельчайшим нюан-
сам исходных требований, малые изменения которых могут существенно повли-
ять на сложность реализации системы. Например, правило 29 описывает, как
изменение спецификации преобразования по умолчанию со String в char* на
String в const char* дает возможность заменить медленные и ненадежные
функции быстрыми и безопасными.
Простота решения проблемы инициализации нелокальных статических объ-
ектов перед их использованием также зависит от того, как вы формулируете свои
Цели. Если вместо доступа к нелокальным статическим объектам вы хотите осу-
ществить доступ к объектам, которые во всем функционируют подобно нелокаль-
ным статическим (за исключением неприятностей с инициализацией), то проб-
лема снимается. Вернее, остается задача, которую настолько легко решить, что нет
Даже смысла называть ее проблемой.
Прием, известный как синглетон (Singleton pattern), необычайно прост. Сна-
чала вы перемещаете каждый нелокальный статический объект в отдельную
Функцию, где объявляете его static. Далее необходимо сделать так, чтобы функ-
ция возвращала ссылку на содержащийся в ней объект. Пользователи вместо
ссылки на объект вызывают функцию. Другими словами, нелокальные стати-
ческие объекты заменяются объектами, статическими внутри функций.
В основе этого подхода лежит наблюдение, что C++ довольно точно опреде-
ляет, когда инициализируются статические объекты внутри функции (например,
Локальный статический объект), - в первый раз, когда определение объекта встре-
чается во время вызова данной функции. Таким образом, если вы замените пря-
мой доступ к нелокальным статическим объектам вызовом функций, возвращаю-
щих ссылки на расположенные внутри них локальные статические объекты, то
Можете быть уверены, что ссылки, возвращаемые из функций, будут ссылаться на
204
Mil
Другие принципы
инициализированные объекты. Дополнительное преимущество заключается
в том, что, если вы никогда не вызываете функцию, эмулирующую нелокальный
статический объект, вам не приходится «платить» за создание и удаление объекта,
чего нельзя сказать о настоящих нелокальных статических объектах.
Вот как этот прием применяется для theFil eSy stem и tempDir:
class FileSystem { ... };
FileSystem& theFileSystem()
{
static FileSystem tfs;
return tfs;
}
class Directory { ... };
Directory::Directory()
// Тот же, что и выше.
// Эта функция заменяет
// объект theFileSystem.
// Объявляем и инициализируем локальный
// статический объект (tfs = "the file
// system").
// Возвращаем ссылку на него.
// Тот же, что и выше.
// Тот же код, что и выше, только вместо theFileSystem
// теперь используется theFileSystem() .
}
Directory& tempDir() // Эта функция заменяет объект tempDir.
{
static Directory td; // Объявляем и инициализируем
// локальный статический объект.
return td; // Возвращаем ссылку на него.
}
Пользователи этой модифицированной системы продолжают программи-
ровать так, как привыкли, только теперь они используют theFileSystem ()
и tempDir () вместо theFileSystem и tempDir. Иными словами, применяют-
ся лишь функции, возвращающие эти объекты, и никогда не используются напря-
мую сами объекты.
Функции, которые в соответствии с данной схемой возвращают ссылки, все-
гда просты: определить и инициализировать локальный статический объект
в строчке 1 и вернуть его в строчке 2, ничего более. В связи с этим у вас может
возникнуть искушение объявить их встраиваемыми. В правиле 33 объясняется,
что последние изменения в спецификации языка C++ допускают такую страте-
гию реализации, и, кроме того, показано, почему предварительно стоит убедить-
ся, что ваш компилятор удовлетворяет соответствующей спецификации стандар-
та. Если вы попытаетесь выполнить такую процедуру, используя Компилятор,
который еще не соответствует стандарту в части, касающейся обсуждаемых воз-
можностей, вы рискуете получить несколько копий как функции доступа, так
и статического объекта, определенного в ней. От этого впору зарыдать даже
опытному программисту.
Следует сказать, что рассмотренный прием чудес не творит. Он будет рабо-
тать при условии правильного порядка инициализации объектов. Если вы напи-
шете код, в котором объект А должен будет инициализироваться прежде, чем объ-
ект В, и одновременно сделаете инициализацию А зависимой от инициализации В,
205
Правило 48
то вас ждут проблемы - и поделом! Если, однако, вы будете избегать таких пато-
логических ситуаций, то схема, описанная в данном правиле, сослужит вам доб-
рую службу.
Правило 48. Уделяйте внимание
предупреждениям компилятора
Многие программисты зачастую игнорируют предупреждения компилятора.
В конце концов, если бы проблема была по-настоящему серьезной, то компиля-
тор выдал бы ошибку! Подобные рассуждения могут быть сравнительно безвред-
ными при работе с какими-нибудь другими языками, но в отношении C++ мож-
но поручиться, что создатели компиляторов точнее вас оценивают истинное
положение дел. Например, ниже приведена ошибка, которую рано или поздно со-
вершает каждый из нас:
class В {
public:
virtual void f () const;
};
class D: public В {
public:
virtual void f();
}
Предполагается, что функция D: : f будет переопределять виртуальную функ-
цию В: : f, но ошибка состоит в следующем: в В функция-член f - константная,
а в D она нс объявляется как const. Один из известных мне компиляторов выда-
ет следующее:
warning: D::F() hides virtual
Многие неопытные программисты, получив подобное сообщение, говорят
себе: «Конечно, D: : f скрывает В:; f - так и должно было быть!» Они неправы.
Вот что пытается «сказать» компилятор: f, определенная в В, была не переопре-
делена в D, а полностью спрятана (объяснение причины этого явления содержит-
ся в правиле 50). Если оставить без внимания данное предупреждение компиля-
тора, это наверняка приведет к неопределенному поведению программы, и, чтобы
найти причину, потребуются долгие часы отладки - при том, что компилятор дав-
но уже все обнаружил.
После того как вы приобретете опыт работы с предупреждениями опреде-
ленного компилятора, уже нетрудно будет понимать, что означают различные со-
общения (к сожалению, нередко реальное значение сообщения кардинально от-
личается от предполагаемого'). Потренировавшись, вы впоследствии сможете
спокойно игнорировать целый ряд предупреждений. Это ничуть не опасно при
одном условии: прежде чем отклонить предупреждение, важно убедиться, что вы
точно вникли в его смысл.
Раз уж мы затронули тему предупреждений, стоит заметить, что они по своей
Природе зависимы от реализации, поэтому не следует слишком расслабляться
206 J
Другие принципы
и перекладывать на компилятор обнаружение ваших ошибок. Например, код с со-
крытием функции, приведенный выше, проходит через другой (к сожалению,
широкораспространенный) компилятор без появления каких-либо предупреж-
дений. Компиляторы служат для трансляции C++ в формат исполняемого фай-
ла, а не в качестве «ремней безопасности». Вам требуются персональные ремни
безопасности? Программируйте на языке Ada.
Правило 49. Ознакомьтесь
со стандартной библиотекой
В C++ имеется очень большая стандартная библиотека. Насколько большая?
Я бы сформулировал это следующим образом: спецификация стандарта занима-
ет свыше 300 страниц мелким шрифтом, и все это помимо стандартной библио-
теки С, которая включена в библиотеку C++ «по ссылке» (принято использовать
именно этот термин).
Чем больше функциональности в стандартной библиотеке, тем на большую
функциональность вы можете рассчитывать, разрабатывая приложение. Библио-
тека C++ не универсальна (бросается в глаза отсутствие поддержки параллель-
ных вычислений и графического интерфейса), но чрезвычайно обширна. На нее
может уверенно опереться практически любое приложение.
Прежде чем дать обзор содержания библиотеки, необходимо рассказать о том,
как она организована. Поскольку компонентов очень много, есть шанс, что вы
(или кто-нибудь еще) можете выбрать имя класса или функции, которое совпа-
дает с именем из стандартной библиотеки. Чтобы вам не пришлось столкнуться
с подобными конфликтами имен, практически все в ней помещено в простран-
ство имен std (см. правило 28). Но это ведет к новой проблеме. Миллионы строк
существующего в C++ кода опираются на функциональность псевдостандартной
библиотеки, использовавшейся долгие годы, когда функции, например, объявля-
лись в файлах-заголовках ciostream. h>, ccomplex.h>, climits .h> и т.п. Это
уже существующее программное обеспечение не было рассчитано на использо-
вание пространств имен, и было бы жаль, если бы перенос стандартной библио-
теки в std привел к проблемам с таким кодом. (Авторы существующего кода,
наверное, использовали бы более сильные выражения, нежели «жаль», будучи
лишенными возможности опираться на стандартную библиотеку.)
Не желая вызвать праведный гнев программистов, Комитет по стандартиза-
ции решил создать новые включаемые файлы с элементами, определенными
в пространстве имен std. Алгоритм, избранный для создания новых названий
заголовков файлов, настолько же прост, насколько его результаты вызывают раз-
дражение: в существующих заголовочных файлах C++ всего-навсего опущено
.h. Таким образом, ciostream.h> становится <iostream>, ccomplex.h>^
<complex> и т.д. Применительно к заголовкам С использован тот же принцип,
но дополнительно в начале имени заголовка была добавлена буква с. Следова-
тельно, cstring. h> становится ccstring>, cstdio. h> — <cstdio> и т.д. И по-
следний штрих: старые заголовки C++ официально не рекомендованы (то есть
207
Правило 49 «I
они более не поддерживаются), а по отношению к старым заголовкам С этого
сделано не было ввиду поддержки совместимости с С. В действительности у раз-
работчиков компиляторов нет повода отрекаться от унаследованного ими про-
граммного обеспечения, а потому можно ожидать, что старые заголовочные фай-
лы будут поддерживаться еще в течение очень долгого времени.
Если оценить вышесказанное с практической точки зрения, то ситуация с за-
головками в C++ такова:
□ старые названия заголовков C++, такие как ciostream. h>, скорее всего,
будут по-прежнему поддерживаться, даже несмотря на то, что они не отно-
сятся к официальному стандарту. Содержимое подобных заголовков не вхо-
дит в пространство имен std;
□ новые названия заголовков, такие как <iostream>, наделены в основном
теми же возможностями, что и соответствующие старые заголовки, но со-
держание заголовков размещено в пространстве имен std. (В ходе стандар-
тизации некоторые компоненты библиотеки претерпели незначительные из-
менения, поэтому между отдельными элементами старых и новых
заголовочных файлов нет точного соответствия);
□ по-прежнему поддерживаются стандартные заголовки С, такие как <stdio. h>.
Содержимое подобных заголовков находится вне std;
□ новым заголовкам C++ для функций библиотеки С присвоены имена типа
<cstdio>. Они предлагают то же самое, что и прежние заголовочные фай-
лы С, но их содержимое находится в пространстве имен std.
Ситуация на первый взгляд кажется немного запутанной, но на самом деле
в нее не так уж сложно вникнуть. Наибольшая трудность состоит в том, чтобы
разобраться с заголовками для строк: <string.h> - это старый заголовок для фун-
кций, работающих со строками char*; <string> - действующий в std заголо-
вочный файл новых классов для работы со строками (см. ниже), a <cstring> - это
std-версия старого заголовочного файла С. Если вы сможете в этом разобраться
(лично я уверен, что вы справитесь), освоить все остальное будет довольно просто.
Вам необходимо знать еще об одной особенности стандартной библиотеки:
в ней практически все представлено в виде шаблонов. Давайте посмотрим на по-
токи ввода/вывода. (Если вы не в ладах с ними, обратитесь к правилу 2.) Потоки
ввода/вывода помогают вам работать с потоками символов, но что такое символ?
Это char? Или wchar_t? Символ Unicode? Какой-то другой многобайтовый
символ? Очевидно, здесь не существует единственно правильного ответа, поэтому
библиотека позволяет вам самостоятельно осуществить выбор. Все классы с по-
токами - это в действительности шаблоны классов, и при инстанциации классов
Потоков вы можете выбрать тип символа. Например, стандартная библиотека
определяет тип cout как ©stream, но в действительности ost ream - это typedef
Для basic_ostream<char>.
Аналогичные соображения применимы к большинству других классов стан-
дартной библиотеки. Например, string - это не класс, а шаблон класса: параметр-
тип определяет тип символов в каждом из классов string; complex — не класс,
а шаблон класса: параметр-тип определяет тип реальной и мнимой компоненты
208
Другие принципы
каждого класса complex; vector - это не класс, а шаблон класса. Подобных при-
меров очень много.
Вам не удастся избежать использования шаблонов стандартной библиотеки,
но если вы работаете только с потоками и строками char, то можете их игнори-
ровать. Суть в следующем: библиотека определяет типы с помощью typedef для
специализаций этих компонентов, использующих char в качестве параметра
typedef, что позволяет вам программировать, используя объекты cin, cout,
cerr, типы istream, ©stream, string и не беспокоясь о том, что в действи-
тельности тип cin — basic_istream<char>, a string — basic_string<char>
Многие компоненты библиотеки в гораздо большей степени опираются па
шаблоны, чем можно было бы предположить. Давайте опять рассмотрим простое,
казалось бы, понятие строки. Несомненно, строка может быть параметризована
на основании типа содержащихся в ней символов, но наборы символов иногда
немного различаются, например специальными символами конца файла, наиболее
эффективными способами копирования массивов символов и т.п. Такие характе-
ристики называются в стандарте свойствами (traits) и задаются дополнительны-
ми аргументами шаблонов. В дополнение объекты string должны производить
динамическое выделение и высвобождение памяти, но эту задачу можно решить
множеством различных способов (см. правило 10). Какой из них лучше? У вас
есть выбор: шаблон string требует параметра Allocator, а объекты типа
Allocator выделяют и высвобождают память, используемую объектами string.
Вот вполне законченное объявление шаблона basic_string и использую-
щее его определение typedef; в файле заголовка <string> вы можете увидеть
что-то вроде:
namespace std {
templatecclass charT,
class traits = char_traits<charT>,
class Allocator - allocator<charT> >
class basic_string;
typedef basic_stringcchar> string;
}
Заметьте, что для traits и Allocator в basic_string определены значе-
ния по умолчанию. Для стандартной библиотеки это типичная ситуация. Пользо-
ватели, которые хотят делать «обычные» вещи, могут проигнорировать сложности,
сопутствующие гибкости. Другими словами, если вы хотите получить объекты-
строки, которые ведут себя более или менее аналогично строкам С, то можете ис-
пользовать объекты string и оставаться в счастливом неведении касательно
того, что в действительности применяете объекты типа basic_string<char,
char_traitscchar>, allocatorcchar> >.
Да, в большинстве случаев разрешается так поступать. Иногда, однако, воз-
никает потребность «заглянуть под капот». Например, в правиле 34 рассматри-
ваются преимущества объявления классов без определения, и там же отмечено,
что так нельзя объявить тип string:
class string;// Откомандируется, но так делать нельзя.
Правило 49
!!
Оставим на время вопросы, касающиеся пространств имен; реальная пробле-
ма здесь заключается в том, что string - это не класс, a typedef. Было бы здо-
рово, если бы вы могли выйти из положения следующим образом:
typedef basic_string<char> string;
до компилироваться это не будет. «Что еще за basic_string?!» - будет «недо-
умевать» компилятор (хотя, вероятно, сформулирует вопрос чуть иначе). Нет, для
того чтобы объявить string, вам прежде всего необходимо объявить шаблоны,
от которых он зависит. Если бы вы могли это делать, получилось бы вот что:
template<class charT> struct char_traits;
template<class T> class allocator;
templatecclass charT,
class traits = char_traits<charT>,
class Allocator = allocator<charT> >
class basic_string;
typedef basic_string<char> string;
Однако вы не можете объявить string. По крайней мере, не должны. Дело
в том, что разработчики библиотеки могут объявлять string (и все остальные
имена, относящиеся к пространству std) под другим именем, отличным от ука-
занного в стандарте, если только поведение этих типов соответствует стандарту.
Например, реализация шаблона basic_string могла бы содержать четвертый
аргумент с таким значением по умолчанию, чтобы в результате код работал так,
как это указано в стандарте для basic_string.
Каков окончательный вывод? Не пытайтесь вручную объявить string (или
какой-либо иной элемент стандартной библиотеки). Вместо этого просто исполь-
зуйте соответствующий заголовок, например <string>.
Если вы усвоили вышеприведенную информацию о заголовках и шаблонах,
настало время приступить к обзору основных компонентов стандартной библио-
теки C++:
□ стандартная библиотека С. Она никуда не исчезла, и вы по-прежнему мо-
жете ее использовать. Кое-где в нее были внесены незначительные измене-
ния, но это все та же старая библиотека С, которая служит тем же целям, что
и раньше;
□ потоки ввода и вывода. В сравнении с традиционными потоками ввода-вы-
вода здесь были использованы шаблоны; изменилась иерархия наследова-
ния, в библиотеке появилась возможность генерации исключений. Также
была обновлена поддержка строк (с использованием классов strings tream)
и введена поддержка интернационализации (посредством локален - locale,
см. ниже). Тем не менее большая часть того, что вы привыкли ожидать от
библиотеки потоков ввода и вывода, осталась неизменной. В частности, по-
прежнему поддерживаются буферизация потоков, средства форматирова-
ния, манипуляторы и файлы, а также объекты cin, cout, cerr и clog. Это
означает, что и строки, и файлы позволяется рассматривать как потоки, по-
ведением которых, включая буферизацию и форматирование, можно управ-
лять со значительной степенью гибкости;
8— 1682
Другие принципы
□ строки. Объекты string были разработаны так, чтобы исключить необхо-
димость использования указателей char* для большинства приложений.
Эти объекты поддерживают все операции, которые от них естественно было
бы ожидать (например, конкатенацию, прямой доступ к отдельным симво-
лам посредством operator [ ] и т.п.), могут быть конвертированы в char*
для совместимости со старым кодом и автоматически осуществляют выделе-
ние памяти. Некоторые реализации string применяют подсчет ссылок, что
может улучшить производительность по сравнению со строками типа char*
(как в смысле времени, так и в смысле памяти);
□ контейнеры. Хватит писать свои собственные контейнерные классы! Биб-
лиотека предлагает эффективную реализацию векторов (они работают по-
добно динамически расширяемым массивам), списки (двусвязные), очере-
ди, стеки, двусторонние очереди (деки), таблицы, множества и битовые
множества. Увы, отсутствуют хэш-таблицы (хотя многие разработчики пред-
лагают их в качестве дополнения), по до некоторой степени это компенси-
руется тем фактом, что объекты string являются контейнерами. Данное
обстоятельство важно, поскольку отсюда вытекает следствие: все, что мож-
но делать с контейнером (см. ниже), можно также делать и с типом string.
Хотите знать, откуда мне известно, что реализация библиотеки эффектив-
на? Все просто: библиотека содержит спецификацию каждого интерфейса,
и часть каждой спецификации - это набор требований к характеристикам
производительности. Иными словами, независимо от того, как реализуется
vector, недостаточно предложить просто доступ к его элементам; необхо-
димо гарантировать доступ за постоянное время. Если вы этого не делаете,
то реализация vector неправильна.
Во многих программах на C++ динамическое размещение строк и массивов
является основной причиной использования new и delete, а ошибки, связан-
ные с new/delete, - особенно утечки, вызванные неосвобождением памя-
ти, выделенной с помощью new, - к сожалению, необычайно распростра-
нены. Если вместо char* и указателей на динамически размещаемые
массивы вы применяете объекты string и vector (каждый из которых осу-
ществляет свое собственное управление памятью), то необходимость во мно-
гих операторах new и delete исчезнет, равно как и трудности, с которыми
часто сопряжено их использование (примеры приведены в правилах би И);
□ алгоритмы. Наличие стандартных контейнеров - это здорово, но еще луч-
ше, когда с ними легко работать. Стандартная библиотека предлагает вам
свыше двух десятков способов легкой работы (то есть предопределенных
функций, официально называемых алгоритмами, а в действительности -
шаблонов функций), большинство из которых применимо ко всем контей-
нерам библиотеки, а также к встроенным массивам!
Алгоритмы рассматривают содержимое контейнера как последовательность,
и каждый алгоритм может быть применен либо ко всей последовательности
значений контейнера, либо к некоторой подпоследовательности. Среди стан-
дартных алгоритмов — f or_each (применить функцию к каждому элементу
последовательности), find (найти первый элемент последовательности,
Правило 49
211
содержащий данное значение), count—if (считает количество элементов
последовательности, для которой верно некоторое утверждение), equal
(определяет, содержат ли две последовательности элементы с равными зна-
чениями), search (найти первое вхождение второй последовательности
в первую), сору (копировать одну последовательность в другую), unique
(удалить из последовательности элементы-дубликаты), rotate (сдвигать
элементы последовательности по циклу) и sort (сортировать элементы по-
следовательности). Обратите внимание, что это только некоторые из имею-
щихся алгоритмов; кроме них библиотека содержит множество других.
Как и для операций с контейнерами, для алгоритмов предусмотрены некото-
рые гарантии относительно их производительности. Например, алгоритм
stable_sort должен выполнять не более O(N х logN) сравнений. (Если обо-
значение «О большое», использованное в формуле, вам незнакомо, я сейчас
все объясню. Подразумевается, что алгоритм stable_sort должен обеспе-
чить уровень производительности, предлагаемый наиболее эффективными
алгоритмами сортировки последовательности общего назначения);
□ поддержка интернационализации. Различные национальные культуры само-
бытны и неповторимы. Подобно библиотеке С библиотека C++ предлагает
инструменты, позволяющие создавать программное обеспечение для разных
стран, но подход C++, хотя он концептуально сродни подходу С, значитель-
но от пего отличается. Вас не должно, например, удивлять то, что поддержка
интернационализации в C++ в значительной степени опирается на исполь-
зование шаблонов, а также на применение наследования и виртуальных
функций.
Основные компоненты библиотеки, применяемые для поддержки интерна-
ционализации, - фасеты (facets) и локали (locales). Фасеты описывают, как
следует обрабатывать конкретные национальные символы, включая схемы
упорядочения (то есть как надо сортировать строки, состоящие из нацио-
нальных символов), каким образом работать с датами и временем, как пред-
ставлять денежный формат, каково соответствие между идентификаторами
сообщений и самими сообщениями на данном языке и т.д. Локали группи-
руют воедино множества фасетов. Например, локаль для США будет вклю-
чать в себя фасеты, описывающие, как сортировать строки американского
варианта английского языка, считывать и записывать даты и время, денеж-
ные и численные величины и т.п. согласно стандартам США. А локаль для
Франции будет описывать, как решать эти задачи согласно установкам, при-
нятым во Франции. C++ позволяет в одной программе использовать не-
сколько локалей, так что отдельные части приложения могут придерживать-
ся различных соглашений;
□ поддержка математических вычислений. Возможно, конец эры Фортрана
уже не за горами. Библиотека C++ содержит шаблон для классов комплекс-
ных чисел (точность реальной и мнимой частей может быть float, double
или long double), а также специальные типы массивов, разработанные для
поддержки математических вычислений. Объекты с типом valarray, на-
пример, спроектированы так, что для элементов, которые они содержат,
212
Mil
Другие принципы
не используется совмещение имен, а это позволяет компиляторам применять
более агрессивные стратегии оптимизации, особенно на векторных машинах.
Библиотека также предлагает поддержку двух различных типов срезов
(slice) массивов и обеспечивает алгоритмы для вычисления скалярных про-
изведений, частичных сумм, смежных разностей и т.п.;
□ диагностика. Стандартная библиотека поддерживает три метода сообщений
об ошибках: утверждения (assert) из библиотеки С (см. правило 7), коды
ошибок и исключения. Чтобы придать типам исключений некоторую струк-
туру, библиотека определяет следующую иерархию классов исключений:
Исключения типа logic__error (и его подклассы) представляют ошибки
в логике приложения. Теоретически такие ошибки можно было бы предотвра-
тить посредством более тщательного программирования. Исключения типа
runt ime_erгог (и его производные классы) представляют ошибки, обнаружи-
мые только во время выполнения.
Вы можете использовать эти классы в том виде, в каком они существуют, или
наследовать от них для создания своих собственных классов исключений, или
игнорировать их. Использование таких классов не является обязательным.
В этом обзоре упомянуто далеко не все, что содержится в стандартной био-
лиотеке. Помните, что ее спецификация занимает около 300 страниц. Тем не ме-
нее вы можете составить себе некоторое общее представление.
Та часть библиотеки, которая относится к контейнерам и алгоритмам, обычно
называется Стандартной библиотекой шаблонов (STL). Как правило, выделяют
еще одну составную часть STL, которая здесь не описана, - итераторы. Итерато-
ры - это объекты, похожие на указатели, которые позволяют алгоритмам STL ра-
ботать совместно с контейнерами. Для приведенного выше высокоуровневого опи-
сания стандартной библиотеки знание итераторов необязательно; если они вас
заинтересовали, вы можете найти примеры их использования в правиле 39-
STL - это наиболее революционная часть стандартной библиотеки, причем
не столько из-за предлагаемых ею контейнеров и алгоритмов (хотя они, вне вся-
кого сомнения, полезны), сколько из-за своей архитектуры. Дело в том, что эта
Правило 50
213
архитектура расширяема: вы можете вносить в STL добавления. Конечно, сами
себе компоненты стандартной библиотеки фиксированы, но если вы будете
следовать соглашениям, на которых построена STL, то можете написать свои
собственные контейнеры, алгоритмы и итераторы, которые так же хорошо будут
работать со стандартными компонентами STL, как STL-комиопенты работают
друг с другом. Кроме того, вы вправе воспользоваться преимуществами STL-co-
вместимых контейнеров, алгоритмов и итераторов, написанных другими раз-
работчиками, которые также могут применить на практике ваши достижения.
Революционной библиотеку STL можно назвать потому, что в действительности
это не программное обеспечение, а ряд соглашений. Компоненты STL, входящие
в стандартную библиотеку, - это проявления преимуществ, вытекающих из этих
соглашений.
Используя компоненты стандартной библиотеки, вы в общем случае можете
избавиться от необходимости разработки своих собственных механизмов пото-
ков ввода/вывода, строк, контейнеров (включая итерации и общую обработку),
интернационализации, числовых структур данных, диагностики. Это позволит
вам сохранить много времени и сил для важной составляющей разработки про-
граммного обеспечения: реализации тех аспектов, которые отличают ваши про-
дукты от программ конкурентов.
Правило 50. Старайтесь понимать цели C++
У C++ богатые возможности, к числу которых относятся наследие С, пере-
грузка функций, объектно-ориентированное программирование, шаблоны, ис-
ключения, пространства имен и так далее, и так далее, и так далее! Подчас такое
изобилие просто пугает. Как во всем этом разобраться?
Задача не слишком сложна, если только вы осознаете цели, которые побуди-
ли разработчиков сделать язык C++ таким, каков он сейчас. Наиболее важными
из этих целей являются следующие:
□ совместимость с языком С. Существует немало компиляторов С, на которых
работает множество программистов. C++ пользуется преимуществами С и опи-
рается на них, прибавляя к этой основе новые возможности;
□ эффективность. Бьерн Страуструп, разработчик и первый программист на
C++, с самого начала понимал, что программисты на С, которых он надеял-
ся привлечь на свою сторону, дважды подумают, прежде чем сделать выбор,
если за переход к другому языку им придется заплатить эффективностью.
Поэтому он позаботился о том, чтобы сделать C++ конкурентоспособным по
отношению к С в смысле эффективности - различие между этими языками
лежит в пределах 5%;
□ совместимость с традиционными инструментами и окружением. Время от
времени на той или иной платформе возникают оригинальные инструмен-
ты разработки, ио компиляторы, компоновщики и редакторы могут работать
практически везде. C++ создан для работы в любом окружении - от мини-
компьютеров до мэйнфреймов, поэтому он нетребователен и обходится
скромным «багажом». Вы хотите перенести C++? Вы переносите только
214
Другие принципы
язык и используете уже имеющиеся на платформе инструменты. (Однако
часто можно добиться лучшей реализации, если, например, есть возмож-
ность изменить компоновщик, чтобы было удобнее работать с некоторыми
сложными аспектами встраивания функций и шаблонов);
□ применимость для решения реальных задач. C++ не разрабатывался как ра-
финированный язык, предназначенный для обучения студентов програм-
мированию; он был создан как мощный инструмент для профессионалов,
решающих реальные задачи из разных областей программирования. Наш
мир полон острых углов, поэтому неудивительно, что даже в «отшлифован-
ном» языке для профессионалов встречаются шероховатости.
Перечисленные задачи вскрывают подоплеку многих особенностей языка, ко-
торые без этого объяснения могли бы вызвать лишь раздражение. Почему неявно
генерируемые конструкторы копирования и операторы присваивания ведут себя
именно так, а не иначе, особенно с указателями (см. правила Ии 45)? Ответ
прост: именно таким образом С копирует и присваивает структуры, а совмести-
мость с С является очень важной. Почему деструкторы автоматически не объяв-
ляются виртуальными (см. правило 14) и детали реализации выносятся
в определение класса (см. правило 34)? Потому что, поступив иначе, вы взвали-
ли бы на себя дополнительные «расходы», а производительность является очень
важным аспектом программирования. Почему C++ не может сам обнаружить за-
висимости при инициализации между нелокальными статическими объектами
(см. правило 47)? Дело в том, что C++ поддерживает раздельную трансляцию мо-
дулей (то есть раздельную компиляцию, а затем компоновку нескольких объ-
ектных файлов), опирается на существующие компоновщики и нс требует нали-
чия программных баз данных. В результате компиляторы C++ практически
никогда ничего не знают обо всей программе в целом. Наконец, почему C++ не
освобождает программистов от утомительных обязанностей вроде управления
памятью (см. правила 5-10) и низкоуровневых манипуляций с указателями? Это
происходит потому, что некоторым программистам необходимы данные возмож-
ности, а нужды пользователей имеют первостепенную важность.
Приведенные примеры показывают, как цели, поставленные при разработке
C++, определяют поведение языка. Для того чтобы более-менее подробно расска-
зать об этом, потребовалась бы отдельная книга, - по счастью, такую книгу уже
написал Страуструп. Она называется «Создание и эволюция C++» (Bjarne
Stroustrup. The Design and Evolution of C++. Addison-Wesley, 1994); иногда для
краткости ее называют просто D&E. Ознакомьтесь с этим изданием, и вы узнаете,
какие возможности были добавлены к C++, в каком порядке и почему. Также в
книге приводится информация о том, какие возможности были отвергнуты и по ка-
кой причине. Любопытно будет прочесть о том, как dynamic_cast (см. правило
39) был рассмотрен, отвергнут, затем снова рассмотрен и принят. Если вы еше не
вполне уловили сущность языка C++, книга D&E поможет прояснить многие во-
просы подобного рода.
«Создание и эволюция C++» содержит множество сведений, позволяющих
понять, как C++ стал тем, чем он является сейчас; однако это никоим образом не
есть формальная спецификация языка. Чтобы получить представление о ней, В111
должны обратиться к международному стандарту языка C++ - объемному труДУ’
Правило 50
215
написанному официальным стилем и насчитывающему около 700 страниц. Здесь
вы можете найти, например, такой захватывающий пассаж:
«При вызове виртуальной функции используются аргументы по умолчанию
из объявления виртуальной функции, определяемые статическим типом указателя
или ссылки, обозначающей объект. Переопределяющая функция из производного
класса не получает ар1умептов по умолчанию из переопределяемой ею функции».
Приведенный абзац - основа правила 38 (никогда не переопределяйте насле-
дуемые аргументы по умолчанию), но, смею надеяться, мое изложение темы чуть
более доступно, нежели вышеприведенный текст.
Стандарт вряд ли подойдет для чтения перед сном, но он окажет вам не-
оценимую помощь, если вы с кем-либо еще (скажем, с создателем компилятора
или другого инструмента для обработки кода) не можете прийти к общему мне-
нию по поводу того, что такое C++. Ведь цель стандарта как раз и состоит в том,
чтобы предоставить четкую информацию, устраняющую необходимость в подоб-
ных спорах.
Официальное название стандарта трудно выговорить, по, возможно, вам оно
понадобится в дальнейшем, так что запомните его: International Standard for Infor-
mation Systems - Programming Language C++. Документ опубликован рабочей
группой № 21 Международной организации по стандартам (ISO), полное назва-
ние которой - ISO/IEC JTC1/SC22/WG21. Вы можете заказать копию офици-
ального стандарта в вашей национальной организации по стандартам (в США это
ANSI - Американский национальный институт стандартизации), по последние
варианты данного проекта, которые очень близки (хотя и не идентичны) оконча-
тельному документу, легко найти в Internet. Я рекомендую вам обратиться за этой
информацией по адресу http://www.cygnus.com/misc/wp/. Но, учитывая, что ки-
берпространство постоянно изменяется, не удивляйтесь, если гиперссылка к тому
времени, когда вы попытаетесь ею воспользоваться, окажется устаревшей. Впро-
чем, ваша любимая поисковая машина несомненно обнаружит требуемый адрес.
Как я уже говорил, книга «Создание и эволюция C++» крайне ценна для рас-
крытия секретов работы языка, а стандарт как нельзя лучше подходит для изуче-
ния всякого рода подробностей, но было бы неплохо иметь в распоряжении не-
что среднее между глобальным обзором, представленным в D&E, и детальным
подходом, заявленным в стандарте. Эту нишу должны заполнять учебники, но
они в основном приближаются к стандарту, поэтому в них вопрос «что» освещен
гораздо лучше, чем вопрос «почему».
Возьмите ARM - книгу Маргарет Эллис и Бьерна Страуструпа «Справочное
Руководство по языку программирования C++ с комментариями»1 (Margaret Ellis,
Bjarne Stroustrup. Annotated C++ Reference Manual. Addison-Wesley, 1990). Эта
Публикация стала самым авторитетным источником, описывающим язык C++;
К тому же международный стандарт C++ основывался на ARM (и на сущест-
вУющем стандарте С). В последующие годы язык, описываемый стандартом,
в ряде случаев разошелся с тем, который представлен в ARM, поэтому книга Эл-
Лис и Страуструпа уже не считается официальным справочником, как было ранее.
1 М. Эллис, Б. Страуструп. Справочное руководство по языку программирования C++ с коммента-
риями. - М.: Мир, 1992.
216
Другие принципы
Однако она остается полезным руководством, поскольку большинство сведений,
представленных в ней, по-прежнему верно, а разработчики C++ зачастую придер-
живаются ARM в тех областях спецификации C++, где стандарт был разработан
лишь недавно.
Однако по-настоящему полезным ARM делает не то, что скрывается за аб-
бревиатурой «RM» (Справочное руководство), а то, что обозначено буквой «А»:
аннотации, то есть комментарии. ARM содержит обширные комментарии, объяс-
няющие, почему многие инструменты C++ ведут себя так, а не иначе. Часть этой
информации можно найти и в D&E, но необходимо знать ее во всей полноте. Вот
пример, который обычно доводит до белого каления тех, кто сталкивается с ним
впервые:
class Base {
public:
virtual void f(int x) ;
};
class Derived: public Base {
public:
virtual void f(double *pd);
};
Derived *pd = new Derived;
pd->f(10); //Ошибка!
Проблема состоит в том, что Derived: : f скрывает Base: : f, даже несмотря
на то, что они имеют разные типы аргументов, поэтому компилятор требует, чтобы
вызов f имел аргумент типа double*, каковым литерал 10, конечно, не является.
Такое поведение неудобно, но ARM приводит объяснение этому. Допустим,
вызывая f, вы в действительности хотели вызвать версию из Derived, но слу-
чайно использовали неправильный тип аргумента. Далее предположим, что
Derived находится где-то далеко в конце иерархии наследования, и вы даже не
подозреваете, что Derived косвенно наследует от некоего класса BaseClass,
в котором объявляется функция f, требующая параметр целого типа. В этом слу-
чае вы непреднамеренно вызвали бы BaseClass: : f - функцию, о существова-
нии которой вовсе не знали! Подобные ошибки могут часто возникать там, где
применяются большие иерархии классов, и потому Страуструп решил уничто-
жить зло в самом зародыше, определив, что члены производных классов скрыва-
ют члены базовых классов на основе их имени.
Кстати, обратите внимание на то, что если разработчик Derived хочет разре-
шить пользователям доступ к Base: : f, этого можно легко добиться объявлени-
ем using:
class Derived: public Base {
public:
using Base::f; // Вносим Base::f в область видимости Derived,
virtual void f(double *pd) ;
};
Derived *pd = new Derived;
pd->f(10); // Нормально, вызывает Base::f.
!!
217
Правило 50
Для компиляторов, еще не поддерживающих объявления с using, альтерна-
тивный вариант состоит в применении встраиваемых функций:
class Derived: public Base {
public:
virtual void f(int x) { Base::f(x); }
virtual void f(double *pd) ;
};
Derived *pd = new Derived;
pd->f(10); // Нормально, вызов Derived::f(int),
// который вызывает Base::f(int).
Опираясь на D&E и ARM, вы получите основательное представление о прин-
ципах проектирования и реализации языка C++, что позволит вам оценить глу-
боко продуманную архитектуру, иногда скрывающуюся за «вычурным» фасадом.
Укрепите свои знания, воспользовавшись подробной информацией из стандарта
языка, и у вас появится база для разработки программного обеспечения, которое
будет по-настоящему эффективно использовать возможности C++.
Послесловие
Если, ознакомившись с 50 способами улучшения ваших проектов, вы все еще ощу-
щаете нехватку в рекомендациях по программированию на C++, то вас, возможно,
заинтересует моя вторая книга «Более эффективное использование C++: 35 но-
вых способов усовершенствования ваших программ и проектов» (More Effective
C++: 35 New Ways to Improve Your Programs and Design). Это руководство, подоб-
но «Эффективному использованию C++», содержит материал, существенный для
эффективной разработки программ па C++, но если первая книга в большей сте-
пени сосредоточена па основах, то «Более эффективное использование C++» так-
же уделяет внимание новым инструментам языка и более сложным приемам про-
граммирования.
Подробную информацию о «Более эффективном использовании C++», вклю-
чая полный текст четырех разделов, список литературы, рекомендуемой для чте-
ния, и прочие материалы, вы можете найти на Web-сайте http://www.awl.com/ср/
mec++.html. А сейчас можете изучить содержание этой книги:
Основы
Правило 1. Различайте указатели и ссылки
Правило 2. Предпочитайте приведение типов в стиле C++
Правило 3. Никогда не используйте полиморфизм для массивов
Правило 4. Избегайте автоматических конструкторов по умолчанию
Операторы
Правило 5. Остерегайтесь определенных пользователем операторов преобра-
зования
Правило 6. Различайте префиксную и постфиксную формы операторов ин-
кремента и декремента
Правило 7. Никогда не перегружайте &&, I I и ,
Правило 8. Различайте значения new и delete
Исключения
Правило 9. Во избежание утечки ресурсов используйте деструкторы
Правило 10. Не допускайте утечки ресурсов в конструкторах
Правило И. Не позволяйте исключениям выходить за границы деструкторов
Правило 12. Помните, чем генерация исключения отличается от передачи ар-
гумента или вызова виртуальной функции
Правило 13. Перехватывайте исключения по ссылке
Правило 14. Обдуманно используйте спецификации исключений
Правило 15. Не забывайте, во что обходится обработка исключений
Эффективность
Правило 16. Помните о правиле «80—20»
Правило 17. Рассмотрите возможность использования отложенных вычислений
Правило 18. Демпфируйте предполагаемые вычислительные затраты
Послесловие
219
IIIHM
Правило 19. Изучите причины возникновения временных объектов
Правило 20. Облегчайте процесс оптимизации возвращаемых значений
Правило 21. Во избежание неявных преобразований типов используйте пере-
грузку
Правило 22. Примите во внимание возможность использования ор= вместо
отдельного ор
Правило 23. Рассмотрите возможность использования альтернативных билиотек
Правило 24. Учитывайте затраты, связанные с виртуальными функциями,
множественным наследованием, виртуальными базовыми клас-
сами и RTT1
Приемы
Правило 25. Виртуализация конструкторов и функций-не членов классов
Правило 26. Ограничение количества объектов класса
Правило 27. Требование или запрет размещать объекты в куче
Правило 28. Интеллектуальные указатели
Правило 29. Подсчет ссылок
Правило 30. Классы-представители (proxy)
Правило 31. Создание функций, виртуальных по отношению более чем к одно-
му объекту
Разное
Правило 32. Программируйте «в будущем времени»
Правило 33. Делайте нетерминальные классы абстрактными
Правило 34. Как в одной программе использовать C++ и С
Правило 35. Ознакомьтесь со стандартом языка
Рекомендуемая литература
Реализация auto_ptr
Алфавитный указатель
Абстрактные классы 67, 152, 186
Абстракция
функциональная 89
Автоматически
генерируемые функции 195
Адреса встраиваемых функций 133
Алгоритмы стандартной библиотеки
сору 211
countjf 211
equal 211
find 171, 210
for_each 210
push_back 171
rotate 211
search 211
sort 211
stable_sort 211
unique 211
Аппроксимация
bool 23
пространств имен 116
Аргументы по умолчанию
в сравнении с перегрузкой 103
статическое связывание 161
у оператора new 47
Б
Базовые классы
аргументы конструкторов 185
виртуальные
инициализация 185
и operator= в производных классах 72
наличие общих 194
невиртуальные 185
порядок инициализации 62
Безопасное понижающее приведение
типов 167
Бесконечный цикл в операторе new 44
Библиотека
C++
abort 39
vector 36
vector, шаблон 36
замена массивов 36
потоковввода/вывода 31
в сравнении с традиционной 209
в стандартной библиотеке C++ 207
и stdio 31
и интернационализация 209
и исключения 209
Библиотеки
доступные только для чтения
и понижающее приведение типов 167
и множественное наследование 184
и потенциальные неоднозначности 112
стандартная С 39
Буферизация потоков ввода-вывода 209
В
Виртуальная таблица 65, 67
Виртуальные
базовые классы 183, 185
значение 185
и члены классов 185
инициализация 185
сложности использования 186
стоимость 184
Алфавитный указатель
деструкторы
свойства 65
удаление объектов 63
конструкторы 142, 143
функции 173
вместо условных выражений
или оператора switch 164
динамическое связывание 159
для реализации
разделителей полей 187
доминирование 185
значение отсутствия в классе 65
и dynamic_cast 168
и эффективность 157
и явная квалификация имени 181
как способ
модификации поведения 190
обычные 153
переобъявление 175
переопределение 182
реализация 65
реализация по умолчанию 153
совместимость с другими языками 65
Вложение 169, 194
и закрытое наследование 1 77
значение 169
и зависимости при компиляции 172
Вложенные типы
примеры 200
Возврат по значению 96
и конструктор копирования 20
Возвращаемое значение 122
время жизни 122
Возвращаемый тип
для функции operator[] 93
константный 91, 121
Временные объекты 70, 128
дескрипторы на них 122
Встраиваемые функции
адрес 133
в сравнении с макрокомандами
эффективность 29
дублирование кода 133
и #define 28
и оптимизация компиляторами 131
и отладчики 136
и переполнение памяти 131
как совет компилятору 131
которые не будут
встраиваться 132,204
размер кода 131
рассматриваемые как статические 133
рекурсия 132
стратегия выбора 136
Встраивание 131
зависимость от архитектуры 136
заголовочные файлы 132
и виртуальные функции 132
и динамические библиотеки 135
и классы-дескрипторы 144
и классы-протоколы 144
и конструкторы/деструкторы 134
и наследование 135
и перекомпиляция 135
и перекомпоновка 135
когда не выполняется 132
предупреждения компилятора 136
создание библиотек 135
Выделение памяти
для массивов 45
обработка ошибок 38
Вызов функции
возвращение результата 127
механизм 127
Глобальные функции
и функции-члены 85
д
Дескрипторы
висящие 123
время жизни 122
для недоступных членов класса 122
тело 140
Деструкторы 79
виртуальные
свойства 65
удаление объектов 63
222
Эффективное использование C++
в производных классах 135
и free 32
и встраивание функций 134
и несколько указателей
на один объект 178
их связь с delete 35
невиртуальные 82
удаление объектов 63
чисто виртуальные 67
Динамический тип 161
Динамическое
приведение типов 167
связывание
определение термина 161
функции 159
Директива
#define
в сравнении с const 27
и встраиваемые функции 28
и комментарии 33
и отладчики 27
недостатки 26
#ifdef 29
#ifndef 29
#include 29
и зависимости
при компиляции 137, 140
Доминирование виртуальных функций 185
Доступ, ограничения и наследование 112
Дружественные функции 87
и интерфейс 84
Дублирование кода
как избежать 106, 177
Е
Единица трансляции 132, 201
«Есть разновидность», отношение 146
3
Зависимости времени компиляции
и определения классов 139
минимизация 136
указатели, ссылки и объекты 140
Заголовки
<assert.h> 37
<cassert> 37
<complex.h> 206
<complex> 206
<cstdio> 206
<cstdlib> 122
<cstring> 206
<float.h> 104
<iostream.h> 206
<iostream> 206
<iostream> и <iostream.h> 31,206
<limits.h> 104, 206
<limits> 105
<new> 38
<stdio.h > 206
<sidlib.h> 122
<string.h> 32, 206
Заголовочные файлы
и встраивание функций 132
и конфликты имен 114
и пространства имен 116
размер и скорость компиляции 82
Закрытое наследование 176, 194
и вложение 177
и указатели void* 179
перегрузка виртуальных функций 188
Закрытые
конструкторы 200
функции 61, 113
Замена определения объявлением 139
Запрет
копирования 57
присваивания 57
Засорение пространств имен
как избежать 143
Защищенное наследование 147
Защищенные функции-члены 155, 179
Значение
вложения 169
закрытого наследования 176, 177
классов без виртуальных функций 65
наличия общих базовых классов 194
невиртуальных функций 157, 194
неопределенного поведения 32
обычных виртуальных функций 153
открытого наследования 146
передачи параметров по значению 21
ссылок 100
чисто виртуальных функций 152
Алфавитный указатель
И
223
Импорт пространств имен 114
Инварианты относительно
специализации 157
Инициализация
в сравнении с присваиванием 21, 130
вопросы поддержания кода 61
и виртуальные базовые классы 185
констант, определенных в классе 27
константных статических членов 27
объектов 21
определение термина 21
посредством копирования 196
с аргументами и без них 130
статических объектов
внутри функций 203
статических указателей нулем 51
статических членов 40, 61, 63
Инициализация членов классов 196
объявленных с const 59
порядок 62
ссылок 59
Интернационализация
стандартная библиотека C++ 211
стандартные потоки ввода/выводо 209
Интерфейс
в Java 185
и дружественные функции 84
и реализация 137
минимальный 80, 171
наследование 151
отделение от реализации 154
полный 80, 171
пример проектирования 82
с контролем типов 178
соображения при проектировании 80
Исключения 129
приводящие
к неиспользуемым объектам 129
стандартная иерархия 212
стандартные потоки ввода-вывода 209
Итераторы
в стандартной библиотеке 212
и контейнеры
стандартной библиотеки 171
примеры использования 163-168
К
Классы
дескрипторы 140
интерфейсы 178
эффективность 180
протоколы 141, 162, 186
тела 140
абстрактные 67, 152
и зависимость объекта от типа 172
имеющие общие свойства 192
интерфейсные
для контроля типов 178
эффективность 180
конверты и письма 140
константы в них 27
объявления 18
и определения 139
определения 19
порядок инициализации 63
проектирование 79
реализующие
контейнеры 177
строки 21
сокрытие реализации 139
Чеширские Коты 141
Книги
C++ Strategies and Tactics 12
Designing and Coding
Reusable C++ 12
Large-Scale C++ Software Design 12
Taligents Guide
to Designing Programs 12
The C++ Programming Language 12
The Design and Evolution of C++ 214
The Draft International Standard
for C++ 214
Код
дублирование 106, 177
и невстраиваемые
inline-функции 133
и кажущаяся эффективность 99, 120,
124, 126
Эффективное использование С++
копирование 20, 106
массивы объектов 19
неявно генерируемые 195
передача аргументов 185
по умолчанию 19, 106
Контейнеры
основанные на указателях 177
стандартной библиотеки 163, 210
свойства 163
Контроль доступа
к членам класса 89
к элементам данных 89
Контроль типов 178
Конфликты имен 114
Концептуально константные
функции-члены 94
Корректность присваивания
самому себе 74
Кэш, коэффициент попадания
и встраивание функций 131
Л
Покали
стандартная библиотека C++ 211
Локальные
объекты
создание и удаление 126
статические 203
переменные
определение 129
статические объекты 203
м
Магические числа 106
Макрос assert 37
и NDEBUG 37
Маленькие объекты
передача 99
размещение в памяти 48
Массивы
и new 45
объекты с конструкторами 19
Математика и наследование 150
Минимальное значение
для встроенных типов 104
повторное использование 187
и плохое проектирование 190
раздувание из-за шаблонов 1 77
размер и встраивание функций 131
Комментарии
в стиле С и C++ 33
внутри комментариев 33
и #define 33
как обойтись
без комментариев 131
Компилятор
предупреждения 205
сообщения
для неименованных классов 109
Константность
концептуальная 94
побитовая 93
Константы 27
определение внутри классов 27
Конструктор копирования 106
возврат по значению 20
запрет использования 57
и наследование 74
логика поведения по умолчанию 214
мотивация объявления закрытым 57
неявно сгенерированный 195
определение по умолчанию 196
передача по значению 20, 79, 96
побитовое копирование 57, 196
почленное копирование 196
указатели - члены класса 57
Конструкторы 79
explicit 70, 80, 86
примеры 86
вызов вручную 135
закрытые 200
и malloc 31
и new 35
и operator new 135
и встраивание функций 134
и определения объектов 129
и структуры 173
инициализация статических членов
класса 61, 63
225
Алфавитный указатель
Минимальность интерфейса 171
плюсы и минусы 81
Множественное наследование 181
и библиотеки 184
и доминирование
виртуальных функций 185
и неоднозначность 111, 181, 185
и порядок инициализации классов 63
и ясновидение 184
комбинация из public и private 188
плохое проектирование 190
размещение в памяти 184
сложности 181
споры вокруг 181
Модификация значения,
возвращаемого функцией 93
н
Наследование
в комбинации с шаблонами 41
в сравнении с шаблонами 172
защищенное 147
и интуиция 147
и математика 150
и ограничения доступа 111
и оператор = 71
и оператор new 44
и переопределение
невиртуальных функций 158
интерфейса 151
когда использовать 175
конструкторы копирования 73
общих свойств 154, 192
пингвины и другие птицы 147
преимущества 154
примесей 41
прямоугольники и квадраты 149
реализации 151
случайное, ошибочное 155
Невиртуальные
базовые классы 185
деструкторы 82
и удаление объектов 63
функции 158
значение 194
статическое связывание 159
Неименованные классы
и диагностика компилятора 109
Неиспользуемые объекты
при генерации исключения 129
стоимость 129
Некорректный код и эффективность
99, 120, 124, 126
Нелокальные
статические объекты 117, 202
Ненужные объекты
как избежать 130
Неоднозначность
и библиотеки 110
и множественное
наследование 111, 181, 185
и преобразования типов 110
и ромбы в иерархии наследования 185
потенциальная 110
преднамеренное введение 108
Неопределенное поведение
значение 32
и висящие указатели
на временные объекты 123
и неинициализированные
объекты 200, 201
и неинициализированные
указатели 201
и порядок инициализации
нелокальных статических объектов 202
и удаление производных объектов
посредством указателей
на базовые 64, 66
при возвращении ссылки
на локальные объекты 127
при отбрасывании константности
действительно
константных объектов 96
при работе
с удаленными объектами 76
при удалении удаленных указателей 57
смешивание
new/delete и malloc/free 32
удаление массивов посредством delete
для не-массивов 35
226
Эффективное использование C++
удаление объектов посредством delete
для массивов 35
Несколько указателей на один объект
и деструкторы 178
Неявно генерируемые функции 195
Новые формы приведения типов 23
Ноль
как int 107
как указатель 107
Нулевой указатель
и delete 36
и set_new_handler 39
удаление 45
О
Область видимости 40, 116
Обновление библиотек в бинарном виде
и встраивание функций 135
Обработчик new
деинсталляция 39
определение термина 38
получение 44
Общие
базовые классы
наличие значения 194
свойства 154, 192
и наследование 154, 192
Объектно-ориентированное
проектирование 145
преимущества 154
распространенные ошибки 157
Объекты
время жизни по возвращении
из функций 122
зависимости времени компиляции 139
инициализация 21
копирование, запрет 57
маленькие, передача 99
массивы 19
объявления 18
определения 19, 129
и конструкторы 129
ошибка при подсчете 84
подсчет 63
присваивание 22
равенство 76
размеры, определение 138
схема размещения в памяти 184
тождественность 76
Объявления 18
вместо определений 139
классов 18
объектов 18
функций 18
шаблонов 18
Обычные виртуальные функции 153
Ограничения доступа
при наследовании 111, 112
Оккам, Уильям 192
Оператор switch по типу объекта
в сравнении с виртуальными
функциями 164
Операторы и аппроксимации
пространства имен 117
Определения
замена объявлениями 139
классов 19
зависимости при компиляции 139
и объявления классов 140
размеры объектов 138
неявно генерируемых функций 196
новых типов
для аппроксимации
пространств имен 116
и new/delete 35
объектов 19, 129
переменных 129
в операторе if 168
статических членов класса 40
функций 19
чисто виртуальных функций 152, 156
шаблонов 19
Оптимизация
посредством использования
виртуальных функций 190
при компиляции 97, 103, 131, 212
встраиваемые функции 131
Отделение интерфейса
от реализации 137
227
Алфавитный указатель
Открытое наследование 146, 194
Отладчики
и #define 27
и встраиваемые функции 136
Отношение
«есть разновидность» 146
«реализуется посредством»
169, 171, 176, 179, 180, 192
«содержит» 169
Ошибки
во время выполнения 107, 148, 198
переход к ошибкам
во время компиляции 199
стандартная библиотека C++ 212
во время компиляции 107, 198
преимущества 201
во время компоновки 27, 67, 133,
198, 201
средства библиотеки 212
п
Память, схема размещения объектов 184
Параллельные вычисления 206
Паскаль 164
Перегрузка
const 92
и определение аргументов
по умолчанию 103
Передача
маленьких объектов 99
по значению 96
и конструкторы копирования 96
и эффективность 96
по ссылке 98
и эффективность 98
параметров по значению
и конструкторы копирования 20
Переменные 129
в операторах if 168
и конструкторы 129
Переобъявление
виртуальных функций 1 75
Переопределение
виртуальных функций 1 82
Унаследованных
невиртуальных функций 158
Плата 96
Плохое проектирование
и множественное наследование 190
и повторное использование кода 190
Побитово константные
функции-члены 93
Побитовое копирование
при присваивании 56, 196
при создании копированием 57, 196
Поведение
модификация путем использования
виртуальных функций 190
Повторное использование
разработанной стратегии
управления памятью 53
Поддержка кода
большие интерфейсы классов 81
добавление членов класса 36
и понижающее приведение типов 164
общие базовые классы 154
и множественное наследование 193
список членов инициализации 61
ссылки на функции 117
Подсчет ссылок 57
Полный интерфейс 81, 171
Пользователи
определение термина 23
Понижающее приведение типов
безопасное 167
использование с библиотеками,
доступными только для чтения 167
определение термина 164
Порядок инициализации
в иерархии 63
значимость 202
статических объектов 31
Последовательность подхода
и открытые интерфейсы 89
совместимость
со встроенными типами 69, 80, 83, 92
Потенциальная неоднозначность 110
и пространства имен 115
Почленное
копирование в конструкторе 196
присваивание 196
228
Эффективное использование С++
Пошаговая отладка
и встраивание функций 136
Правило «80-20» 136, 157
Предупреждения компилятора 205
Преобразования типов 87
и закрытое наследование 176
к ссылке 73
обход константности 120
отбрасывание константности 95
Препроцессор 37, 107
Префиксы имен в библиотеках 114
Приведение типов 164, 182
использование с библиотеками,
доступными только для чтения 168
как избежать приведения типов 167
новая форма 24
понижающее приведение 164
преимущества новой формы 23
Примеры новых форм
преобразований типов
const_cast 95, 96
static_cast 31
приведения типов
dynamic_cast 168
Примеси 41
Присваивание
в сравнении с инициализацией 21
запрет 58
и массивы 83
самого себя 74
Проблема усечения объектов 98
Пробуксовка
и встраиваемые функции 131
Проверка на допустимость значения
как избежать 199
Проект международного стандарта 214
Проектирование
объектно-ориентированное 145
приводящее к противоречиям 159
Проектирование классов
и типов 79
классы и функции 79
плохое
и множественное наследование 190
и повторное
использование кода 190
Производные классы
и operator= 71
реализация деструкторов 135
сокрытие имен базовых классов 216
Пространства имен 114
аппроксимация 116
потенциальная неоднозначность 115
Пулы памяти 53
Р
Равенство
адресов 77
значений 76
Разделители полей
реализация
с помощью виртуальных функций 187
Раздельная компиляция
влияние на особенности
языка C++ 211, 214
Размер
классов 45
объектов 138
Размещение объектов в памяти 184
Реализация
классов-протоколов 143
конструкторов и деструкторов
производных классов 134
наследование 151
отделение от интерфейса 154
по умолчанию
operator= 196
виртуальных функций, опасность 153
конструктора копирования 196
сокрытие 139
«Реализуется посредством»,
отношение 169, 176, 179, 192
Рекурсивные функции
и встраивание 132
Решение проблемы
инициализации нелокальных
статических объектов 202
С
Самостоятельное управление памятью 53
Символ подчеркивания
соглашение имен 137
ИМ
I 229
Алфавитный указатель
Синглетон 203
Скалярное произведение 212
Сложности
при множественном наследовании 181
Смежные разности 212
Смешанная арифметика 85, 87
Смешивание
free и delete 32
new и malloc 32
открытого
и закрытого наследования 188
Совместимость
с С как цель при создании C++ 213
с другими языками и vptr 65
Совмещение имен 57, 77, 99
Соглашения
для имен 24, 132, ] 37
«Содержит», отношение 169
Создание
локальных объектов 126
объектов по умолчанию
в сравнении с конструктором 130
как избежать 130
с аргументами и без аргументов 130
этапы 31
Сокрытие реализации класса 139
Спецификация исключений 79
поведение по умолчанию
при нарушении 39
Список
аргументов
и преобразования типов 87
инициализации членов класса
и конструкторы базового класса 185
Срезы массивов в STL 212
Ссылки
внутренняя реализация 99
значение 100
и зависимости при компиляции 140
как аргументы 97
как дескрипторы
недоступных членов классов 122
как тип,
возвращаемый функцией 97, 98
как члены классов 59
на статические объекты
значение,
возвращаемое функцией 101
на указатели
и указатели на указатели 26
на функции 117
обход ограничений доступа 124
приведение к 73
Стандартная библиотека С
31, 206, 207, 209, 211
Стандартная библиотека C++ 206
<iosfwd> 140
basic_ostream, шаблон 207
istream, typedef 208
logic_error 129
max 29
numericjimits 105
ostream, typedef 208
push_back, алгоритм 171
string 21, 207
vector 83
vector, шаблон 62, 66, 83, 208
алгоритмы 210
гарантии эффективности 210, 211
и пространства имен 116
использование
средств выделения памяти 208
итерация по контейнерам 171
контейнерные классы 163, 171, 210
поддержка
диагностики 212
для срезов массивов 212
интернационализации 211
численных вычислений 211
понятие последовательности 210
потоки ввода-вывода 31, 209
свойства контейнеров 163
скалярное произведение 212
смежные разности 212
фасеты 211
хэш-таблицы 210
Стандартная библиотека шаблонов
(STL) 212
Стандартные
имена заголовков 206
контейнеры 163
230
Эффективное использование С++
Статические
объекты
возвращение ссылки 101
типы
определение термина 160
функции,
сгенерированные из встраиваемых 133
члены классов 125
инициализация 40, 61, 63
инициализация в классах 27
использование для аппроксимации
пространств имен 1 16
константные функции-члены 93
Статический массив
возвращение ссылки 102
Статическое связывание
аргументов по умолчанию 161
невиртуальных функций 159
Стоимость
виртуальных базовых классов 184
инициализация и присваивание 60
классов-дескрипторов 144
классов-протоколов 144
неиспользуемых объектов 129
неподставляемых встраиваемых
функций 133
присваивания как удаления
плюс создания 103
уменьшения зависимостей
времени компиляции 144
Строгий контроль типов
реализация 178
Строки С и C++
стандартные заголовочные файлы 207
Структуры
для аппроксимации
пространств имен 116
и конструкторы 173
т
Теория хаоса 203
Терминология, принятая в этой книге 18
Термины (определения)
вложение 169
динамический тип 161
динамическое связывание 161
единица трансляции 201
класс Чеширский Кот 141
классы-дескрипторы 140
классы-конверты 140
классы-протоколы 141
множественное наследование 181
нелокальные статические объекты 202
отношение
«есть разновидность» 146
«реализуется посредством» 171
«содержит» 169
пользователь 23
понижающее приведение 164
статический тип 160
целочисленные типы 27
Тип
this 95
преобразования типов 80, 86
long int и int 108
и глобальные функции 87
и неоднозначность 110
неявные 71, 84, 86, 87
функции-члены 87
проектирование 79
Типы
добавление новых для облегчения
обнаружения ошибок 199
закрытые 173
и использование
операторов switch в сравнении
с виртуальными функциями 64
целочисленные
определение термина 27
Точки останова
и встраиваемые функции 136
У
Удаление объектов
и виртуальные деструкторы 63
Указатели
const 91
в заголовочном файле 27
функции 117
231
Алфавитный указатель
в контейнерах 177
и зависимости
времени компиляции 140
и обход ограничений доступа 124
и побитово константные
функции-члены 93
на виртуальную таблицу 65
на один или несколько объектов
и delete 35, 58
на указатели
в сравнении со ссыпками
на указатели 26
на функции-члены 125
на члены класса 109
наличие нескольких указателей
на один объект
и деструкторы 178
типа void* 107, 175, 177
и закрытое наследование 179
и контроль типов 178
Указатели-члены
и конструкторы копирования 57
и оператор = 56
Уменьшение зависимостей
времени компиляции 136
Управление памятью пользователем
и эффективность 51
Условные операторы
и виртуальные функции 164
определение переменных 168
Утечка памяти 36, 121, 127
в сравнении с пулами памяти 52
примеры 56, 101
причины возникновения 52
Ф
Файлы .срр 132
Фасеты
стандартная библиотека C++ 211
Форматирование
потоки ввода-вывода 209
Формы приведения типов 24
Фортран 29, 65
Функции
фабрики 142, 186
время жизни
возвращаемого объекта 122
глобальные и члены класса 84
и преобразования типов 87
модификация
возвращаемых значений 93
невиртуальные 157
неявно генерируемые 195
обработчики new, свойства 38
объявления I8
обычные виртуальные 194
определения 19
проектирование 79
ссылки 117
статические как результат
объявления встраиваемыми 133
чисто виртуальные 194
Функции-члены
const 91
возвращающие дескрипторы 123
в сравнении
с глобальными функциями 85
закрытые 61, 113
защищенные 155, 179
и дескрипторы 123
и преобразования типов 87
концептуальная константность 94
неявно генерируемые 195
побитовая константность 93
указатели 125
Функциональная абстракция 89
ц
Целочисленные типы 27
Целые статические константы -
члены классов
инициализация 27
ч
Частичные суммы 212
Чисто виртуальные функции 67, 182
значение 152
определение 152
Чисто виртуальный деструктор 67
встраивание 67
определение 67
реализация 67
232 ’
Эффективное использование C++
Члены класса
и виртуальные базовые классы 185
инициализация
и присваивание 58
по умолчанию 196
копирование 196
порядок инициализации 62
присваивание 196
по умолчанию 196
список инициализации
ссылки-члены класса 59
члены с const 59
статические, инициализация 40
статические целые константы
инициализация 27
указатели на члены класса 109, 125
шаблоны 108
для реализации указателей NULL 108
ш
Шаблоны 174
в комбинации
с наследованием 41, 180
в стандартной библиотеке C++ 207
для классов потоков ввода-вывода 207
и наследование 172
и раздувание кода 177
когда использовать шаблоны 174
объявление 18
создание интерфейсов
с контролем типов 180
э
«Эффект бабочки» 203
Эффективность
iostream и stdio 30
numericjimits 105
дополнительный расход памяти
при множественном
наследовании 184
и виртуальные функции 157
и возврат указателей и ссылок
на члены класса 1 26
и кэширование 90
и минимальность
интерфейсов классов 81
и некорректный код 99, 103, 120
и передача
встроенных типов 99
по значению 96
по ссылке 98
и подсчет ссылок 57
и правила языка 213
и присваивание самому себе 74
и проверки во время выполнения 198
и связывание значений аргументов
по умолчанию 162
и управление памятью 51
инициализации статических членов
в конструкторах 61
инициализация
и присваивание 59
с аргументами и без аргументов 130
членов класса, порядок 63
как цель разработки C++ 213
классов-интерфейсов 180
макросов и встраиваемых функций 28
присваивание в сравнении
с созданием и удалением 103
стандартных строк string 210
Я
Явная квалификация имен 40, 152
и виртуальные функции 181
А
abort 37
и нарушение
спецификации исключений 39
Ada 206
Allocator 208
ARM (The Annotated C++ Reference Manual)
12, 65, 132, 181,215
ASPECT_RATIO 26
Алфавитный указатель
В
basic_ostream, шаблон 207
bitset, шаблон 210
bool 23
аппроксимация 23
С
С, язык программирования 25
C++Programming Style 12
cerr 208
char * и класс string 21
cin 208
CLOS 181
COBOL 110
complex, шаблон 207,211
const 90
в классах, инициализация 27
в объявлениях функций 91
в сравнении с #define 26
возвращаемое значение 91, 121
отбрасывание 95
перегрузка функций,
отличающихся объявлением 92
указатели 91
на функции 117
функции-члены 91
возвращающие дескрипторы 123
члены класса 59
const_cast 23
cout 208
D
DBL_MIN 104
delete 79
взаимосвязь с деструкторами 35
и free 32
и new 48
и operator delete 34
и виртуальный деструктор 52
и удаленный указатель 57
нулевой указатель 36
оператор — не член класса
псевдокод 45
свойства 45
эффективность 48
deletef] 45, 79
deque, шаблон 210
dynamic_cast 23, 167
пример использования 168
Е
Eiffel 138, 181
F
false 23
free
и delete 32
и деструкторы 32
FUDGE_FACTOR 28
J
INT_MIN 104
ISO/I EC JTC1 /SC22/WG21215
istream, определение typedef 208
J
Java 54, 138, 181
интерфейсы 185
L
Ihs, аргумент 24
LISP 68, 181, 199
list, шаблон 163,171
logic_error, класс 212
long int в качестве NULL 108
M
malloc
и new 32
и конструкторы 31
max 29
mf как идентификатор 25
mutable 94
N
NDEBUG и макрос assert 37
new 79
234
I
Эффективное использование С++
operator new 34
бесконечный цикл внутри 44
в комбинации с delete 48
в комбинации с malloc 32
возврат 0 43
и std::bad_alloc 43
и запросы неправильного размера 45
и массивы 45
и эффективность 48
наследование 44
не генерирующее исключений 43
пример реализации 41
свойства 43
связь с конструкторами 35
согласованность с delete 51
сокрытие глобального new 46
формы 37
new/delete для vector и string 210
new[] 45, 79
NULL 107
адрес 109
использование
вызываемых функций 109
numericjimits 104
эффективность 105
О
Object Pascal 181
Objective С 181
operator
объявление 88
operator new
и std::bad_alloc 37
нехватка памяти 37
перегрузка 37
спецификация исключений 39
функции-обработчики new 38
operator& 195
operator«
и printf 29
объявление 88
operator= 68, 113
ассоциативность 68
влияние на интерфейс 83
возвращаемый тип 68
возвращающий
константный тип 69
тип void 69
запрет на использование 57
и константные члены класса 198
и члены класса ссылки 197
наследование 71
неявная генерация 196
перегрузка 68
по умолчанию
общий вид 68
побитовое копирование 56, 196
почленное
копирование 196
присваивание 196
реализация по умолчанию 56, 196
указатели-члены 56
operator» и scanf 29
operator[]
возврат дескриптора 122
возвращаемый тип 93
перегрузка 92
пример объявления 83
ostream как typedef 207
Р
Pool, класс 53
printf и operator« 29
R
rand 123
register 131
reinterpret_cast 23, 24
rhs, аргумент 24
runtime_error, класс 212
s
scanf и оператор» 29
set, шаблон 170
set_new_handler 38
для классов, реализация 39
и блоки try 42
sizeof 44
и классы 45
Алфавитный указатель Smalltalk 138, 149, 181, 199 stringstream, шаблон 209
static .cast 23 примеры использования 31, 50 strlen 96
std, пространство имен т
bad_alloc 37 <iostream> и <iostream.h> 31 numericjimits 105 и set_new_handler 40 и стандартная библиотека C++ 116 имена заголовочных файлов 206, 207 stdio и iostreams 31 эффективность 30 STL (Стандартная библиотека шаблонов) this присваивание 135 тип 95 true 23 и union 49
212 расширяемость 213 strdup 32 string, тип 21, 207 typedef 208 и String 21 как стандартный контейнер 210 V valarray, шаблон 21 1 vector, шаблон 36, 62, 66, 83 vptr 65 vtbl 65, 67