Text
                    More effective
C++
35 New Ways
to Improve Your Programs
and Designs
Scott Meyers
Addison-Wesley
An imprint of Addison Wesley Longman, Inc.


Серия «Библиотека программ: Эффективное использование C++ 50 рекомендаций по улучшению ваших программ и проектов Скотт Мейерс Москва, 2006
УДК 681.3.06 ББК 32.973.26-018.1 М46 М46 МейерсС. Эффективное использование C++. 50 рекомендаций по улучшению ва- ваших программ и проектов: Пер. с англ. - М.: ДМК Пресс; Спб.: Питер, 2006. - 240 с: ил. (Серия «Библиотека программиста») ISBN 5-469-01213-1 В книге приводятся практические рекомендации по проектированию и программированию на языке C++. Изложены правила, позволяющие программисту сделать выбор между различными методами реализации программы - наследованием и шаблонами, шаблонами и указателями на ба- базовые классы, открытым и закрытым наследованием, закрытым наследо- наследованием и вложенными классами, виртуальными и невиртуальными функ- функциями и т.п. Для иллюстрации всех принципов используются новейшие языковые средства из стандарта ISO/ANSI C++ - внутриклассовая ини- инициализация констант, пространства имен и шаблоны-члены класса. Рас- Рассматривается стандартная библиотека шаблонов и классы, подобные string и vector. УДК 681.3.06 ББК 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 reprodused, 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-469-01213-1 (рус.) © Обложка. Питер, 2006 © Перевод на русский язык, оформление. ДМК Пресс, 2006
Отзывы на первое издание книги «Эффективное использование 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, E. В. 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. Необходимо знать, какие функции неявно создает и вызываетС++ 195 Правило 46. Предпочитайте ошибки во время компиляции ошибкам во время выполнения 198 Правило 47. Обеспечьте инициализацию нелокальных статических объектов до их использования ..201 Правило 48. Уделяйте внимание предупреждениям компилятора 205 Правило 49. Ознакомьтесь со стандартной библиотекой 206 Правило 50. Старайтесь понимать цели C++ 213 Послесловие 218 Алфавитный указатель 220
Посвящается Нэнси, без которой ничто не представляло бы большой ценности. Мудрость и красота образуют очень редкое сочетание. Петроний Арбитр, Сатирикон, XCIV Предисловие Эта книга - результат осмысления моего опыта преподавания языка C++ профес- профессиональным программистам. Я обнаружил, что большинство слушателей после недели интенсивной подготовки очень уверенно обращаются с основными кон- конструкциями языка, но испытывают затруднения, пытаясь эффективно использо- использовать их в процессе программирования. Это наблюдение положило начало моим попыткам сформулировать короткие, конкретные и легко запоминающиеся руко- руководящие принципы для эффективной работы в C++, то есть написать краткий обзор того, что опытные программисты делают или избегают делать. Первоначально меня интересовали правила, которые могла бы «навязать» программа, похожая на lint. С этой целью я провел исследование по созданию средств проверки кода C++ на предмет соответствия критериям, определенным пользователем1. К сожалению, исследование закончилось раньше, чем был разра- разработан первый прототип. Но, к счастью, сейчас уже имеется несколько коммерчес- коммерческих продуктов проверки программ на C++. Хотя первоначально меня интересовали правила программирования, соблюдение которых можно было бы отследить автоматически, я скоро осознал ограниченность такого подхода. Большинство правил, используемых хорошими программистами, слишком трудно формализовать или из них существует чересчур много важных исключений, для того чтобы такие правила было просто поддерживать программны- программными средствами. Таким образом, у меня возникла идея создания чего-то более гибко- гибкого, чем компьютерная программа, но в то же время более конкретного, чем обычный учебник по C++. Результат перед вами: книга содержит 50 конкретных рекоменда- рекомендаций о том, как улучшить ваши программы и проекты на C++. В этом руководстве вы найдете советы: что и почему нужно делать, програм- программируя на C++; чего и почему делать не следует. По существу, конечно, вопрос «по- «почему» важнее вопроса «что», но намного удобнее ссылаться на список правил, чем заучивать наизусть всю книгу. В отличие от большинства справочников по C++ структура предлагаемого руководства базируется не на принципе последовательного изучения конкретных конструкций языка. Здесь не говорится в одной главе о конструкторах, в другой - о виртуальных функциях, в третьей - о наследовании и т.д. Напротив, каждый раз- раздел посвящен рассмотрению определенного правила, а описание конкретных ин- инструментов языка может проходить через всю книгу. 1 Вы можете найти обзор исследования на Web-сайте http://www.awl.co[n/c[>/ec+-|-.html.
III.' Предисловие Преимущество этого подхода состоит в том, что он в большей степени соот- соответствует сложности программных систем, для которых предназначен C++, - сис- систем, при работе с которыми освоения индивидуальных инструментов языка не- недостаточно. Например, опытные разработчики C++ знают, что понимание сути встраиваемых функций и виртуальных деструкторов не всегда означает знание встраиваемых виртуальных деструкторов. Такие «закаленные в боях» программи- программисты сознают, что постижение взаимодействий между инструментами C++ имеет первостепенную важность для эффективного использования языка. Структура книги отражает это фундаментальное свойство языка C++. Недостатком подобной стратегии является необходимость разыскивать по всей книге полную информацию о той или иной конструкции C++. Для удобства чита- читателя книга содержит множество перекрестных ссылок, а в конце имеется подроб- подробный предметный указатель. При подготовке второго издания мое стремление к переработке книги сдер- сдерживали некоторые опасения. Десятки тысяч программистов воспользовались пер- первым изданием «Effective C++», и я не хотел потерять то, что их привлекало. Одна- Однако за шесть лет, прошедших с момента написания книги, C++ и его библиотека претерпели заметные изменения (см. правило 49). Кроме того, изменилось мое по- понимание языка и общепринятая практика его применения. Чтобы отразить столь существенные поправки, было важно пересмотреть многие технические детали «Эффективного использования C++». Я делал все возможное, внося отдельные правки в первое издание, но книги, как и программное обеспечение, увы, облада- обладают общим свойством: с некоторого момента небольших изменений становится недостаточно, и возникает необходимость в полной переработке всего материала. Эта книга - результат подобного обновления. Перед вами, по существу, «Эффек- «Эффективное использование C++», версия 2.0. Тем, кто знаком с первым изданием, будет интересно узнать, что каждое пра- правило книги было переработано. Тем не менее я полагаю, что структура в общем осталась прежней, хотя в отдельных местах и претерпела изменения. Из 50 пра- правил первого издания я сохранил 48, хотя и подправил на скорую руку некоторые названия (в дополнение к пересмотру изложенного материала). Еще два правила, содержащие совершенно новые сведения, вошли в книгу под номерами 32 и 49; при этом значительная часть информации, ранее приводимой в правиле 32, теперь находится в правиле i. Потребовалось поменять местами правила 41 и 42, по- поскольку это облегчило изложение материала. И наконец, я изменил направление стрелок наследования на рисунках. Теперь они в соответствии с общепринятым соглашением указывают от производных к базовым классам. Этого же соглаше- соглашения я придерживался и в книге 1996 года «More Effective C++», обзор которой можно найти в послесловии. Предлагаемый в данной Книге набор рекомендаций далеко не всеобъемлющ, но сформулировать достаточно хорошие правила, применимые всегда и во всех приложениях, гораздо труднее, чем это может показаться. Возможно, вы знаете о других правилах и методах эффективного программирования на C++. Я был бы вам очень признателен, если бы вы поделились ими. С другой стороны, вы можете решить, что некоторые правила вам не под- подходят, поскольку есть лучшие методы решения рассматриваемых задач, или что
Благодарности обсуждение того или иного вопроса неясно, неполно, даже ошибочно. В таком случае мне также было бы интересно узнать об этом. Дональд Кнут (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++ (в особенности строки), ведет свою историю
Предисловие от первого издания книги Бьерна Страуструпа «Язык программирования 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++ на высоком уровне: идиомы и стили программирования» Джима Коплиена (J'm 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 явно вы- выиграло от моих бесед с Джоном Лакосом (J°hn 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++ Report за 1995 год, называвшейся «Новый полез- полезный прием программирования на C++: свойства» (A New and Useful Template Tech- Technique: Traits), и колонки Пита Беккера (Pete Becker) «C/C++: вопросы и ответы»
Благодарности (C/C++ Q&A) в C/C++ User'sjoumal. Мой обзор поддержки интернационализации основан на неопубликованной рукописи книги Клауса Крефта и Анджелики Лан- гер (Klaus Kreft, Angelika Langer). Наконец, пример с "Hel lo world" взят из книги «Язык программирования С» Брайана Кернигана и Денниса Ричи (Brian Kernighan, Dennis Ritchie. The С Programming Language. Prentice-Hall, 1978). Многие читатели первого издания прислали предложения, которые я не мог включить в первую книгу, но так или иначе учел в этом издании. Другие восполь- воспользовались группами новостей Usenet по C++ для того, чтобы прислать мне важные замечания по поводу материала книги. Я выражаю благодарность всем этим лю- людям и указываю, где я воспользовался их идеями: Майк Кэлблинг (Mike Kaelbling) и Хулио Куплински (Juno 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) и Джерри Либельсон (Jerrv Liebelson) (правило 17); Джон Элджи Лав- Дженсен (John «Eljay» Love-Jensen) (правило 19); Эрик Нэглер (Eric Nagler) (пра- (правило 22); Роджер Истман (Roger Eastman), Дуг Мур (Doug Moore) и Аарон Най- ман (Aaron Naiman) (правило 23); Дат Тук Нгуен (Dat Thuc 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), Pace Уильяме (Russ Williams), Роберт Брэзайл (Robert
Предисловие 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), Джон Лакос O°hn Lakos), Роджер Скотт (Roger Scott), Скотт Фроман (Scott Frohman), Алан Руке (Alan Rooks), Роберт Пур (Robert Poor), Эрик Нэглер (Eric Nagler), Антуан Тру (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), Джун Хэ (Jun He), Тим Кинг (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. Иногда кажется, что игроки на издательском рынке меняются так же часто, как и тенденции в программировании, поэтому я рад, что мой издатель Джон Уэйт
Благодарности JIMHHM (John Wait), заведующая отделом сбыта Ким Доли (Kim Dawley) и руководитель производственного отдела Марти Рабиновиц (Marty Rabinowitz) продолжают тру- трудиться на той же ниве, что и в период, когда я взялся за написание книги A991 год). При работе над книгой Сара Уивер (Sarah Weaver) была руководителем моего про- проекта; рекомендации Розмари Симпсон (Rosemary Simpson) помогли мне при со- составлении предметного указателя, а Лана Ланглуа (Lana Langlois) выступала по- посредником и координатором в Addison-Wesley до тех пор, пока не избрала иную сферу деятельности. Я благодарен этим людям и их коллегам за помощь в преодо- преодолении тысяч преград, отделяющих простое написание книги от ее издания. Кэти Райт (Kathy Wright) никакого участия в создании этой книги не прини- принимала, но хотела, чтобы я упомянул здесь и ее имя. Я хочу поблагодарить мою жену, Нэнси Л. Урбано, за энтузиазм и неослабева- неослабевающую поддержку, оказанную при подготовке первого издания. Вот уже целых шесть лет я занимаюсь писательской работой, - все это время Нэнси продолжает терпеть мое длительное отсутствие и мирится с моей техно- технократической болтовней, всячески поддерживая мои литературные занятия. Она также обладает умением при необходимости найти подходящее слово, которое я никак не могу вспомнить. Без Нэнси моя жизнь не имела бы смысла. Наша собака, Персефона, никогда не дает мне забыть о делах первостепенной важности. Сроки сроками, а главное — вовремя погулять...
Введение Одно дело - изучать фундаментальные основы языка, и совсем другое - учиться проектировать и реализовывать эффективные программы. Это особенно справед- справедливо для языка C++, известного необычайно широкими возможностями и гибко- гибкостью. Основанный на традиционном языке программирования (С), он предлагает широкий диапазон объектно-ориентированных возможностей, а также поддерж- поддержку шаблонов и исключений. Работа на C++ при правильном его использовании способна доставить удоволь- удовольствие. Самые разные проекты - объектно-ориентированные и обычные - могут по- получить непосредственное выражение и эффективную реализацию. Вы можете определять новые типы данных, совершенно неотличимые от встроенных типов, но значительно более гибкие. Тщательно выбранный и грамотно реализованный на- набор классов, который берет на себя автоматическое управление памятью, использо- использование альтернативных имен, инициализацию и высвобождение ресурсов, преобра- преобразование типов и другие проблемы, представляющие собой сущее проклятие для программистов, может сделать программирование приложений легким, эффектив- эффективным и практически свободным от ошибок. При наличии определенных навыков на- написание эффективных программ на C++ - совсем не трудное дело. При неразумном использовании C++ может давать практически нечитаемый, сложный в эксплуатации и просто неправильный код. Все, что необходимо, - выделить те аспекты C++, которы могут стать камнем преткновения, и научиться их избегать. Это и есть цель настоящей книги. Я пред- предполагаю, что вы уже знаете язык C++ и обладаете некоторым опытом его приме- применения. Моя задача - дать общие указания, как использовать язык эффективно: чтобы ваше программное обеспечение легко читалось, легко расширялось, было простым в эксплуатации и работало согласно вашему замыслу. Предлагаемые советы можно разделить на две категории: общая стратегия про- проектирования и практическое использование отдельных языковых конструкций. Обсуждение вопросов проектирования сфокусировано на выборе между раз- различными методами реализации программы на C++. Как сделать выбор между на- наследованием и шаблонами? Между шаблонами и указателями на базовые классы? Между открытым и закрытым наследованием? Между закрытым наследованием и вложенными классами? Между перегрузкой функции и введением аргумента со значением по умолчанию? Между виртуальными и невиртуальными функция- функциями? Между передачей аргумента по значению или по ссылке? Важно с самого на- начала правильно ответить на эти вопросы, поскольку ошибочность выбора может стать очевидной лишь намного позднее, в ходе разработки проекта, когда исправ- исправление ошибки оказывается трудоемким, деморализующим и дорогостоящим.
Введение Даже когда вы точно знаете, что нужно делать, добиться желаемых результа- результатов бывает нелегко. Какой тип должен возвращать оператор присваивания? Как должен себя вести оператор new, когда он не может найти достаточного количе- количества памяти? Когда деструктор должен быть виртуальным? Как следует писать список инициализации? Исключительно важно проработать подобные детали, по- поскольку, не сделав этого, вы почти неизбежно столкнетесь с неожиданным и даже необъяснимым поведением программы. И, что более существенно, оно зачастую проявляется не сразу. В итоге код может пройти проверку в отделе тестирования, хотя он все еще содержит множество необнаруженных ошибок - «мин замедлен- замедленного действия», ждущих своего часа. Эту книгу необязательно читать от корки до корки. Необязательно даже чи- читать ее, продвигаясь от начала к концу. Материал разбит на 50 правил, каждое из которых более-менее независимо и самодостаточно. Впрочем, нередко в них со- содержатся ссылки на другие правила, так что один из способов работы с книгой - начать чтение с правила, вызвавшего интерес, и затем следовать по ссылкам туда, куда они вас выведут. Правила сгруппированы вокруг общих тем, поэтому если вы интересуетесь определенным вопросом, например управлением памятью или объектно-ориен- объектно-ориентированным проектированием, то можете начать с соответствующего раздела - либо прочитать его целиком, либо обратиться к разделам, на которые указывают ссылки. Вы, однако, обнаружите, что весь материал книги очень важен для эф- эффективного программирования на C++, и практически все в ней так или иначе взаимосвязано. Это руководство не является ни справочником по C++, ни пособием для изу- изучения C++ с нуля. Например, мне очень хотелось бы рассказать вам о тонкостях написания ваших собственных операторов new (см. правила 7-10), но я предпо- предполагаю, что вы узнаете из других источников, что эта функция должна возвращать void * и ее первый аргумент должен иметь тип size_t. Изложение вопросов, подобных этим, содержится в различных книгах по C++ для начинающих. Цель данной книги - уделить особое внимание тем аспектам программирова- программирования на C++, которые обычно излагаются поверхностно (если излагаются вообще). Другие источники описывают различные языковые конструкции. Настоящая кни- книга рассказывает вам, как объединить эти элементы, с тем чтобы в конечном счете получить эффективную программу. Другие источники научат вас, как добиться того, чтобы ваши программы компилировались. Эта книга подскажет, как избе- избежать проблем, о которых компилятор сообщить не может. Подобно большинству других языков С-+ связан с обширным фольклором, образчики которого обычно передаются от программиста к программисту. В этой книге я попытался зафиксировать часть накопленной в виде «устного народного творчества» мудрости в более доступной форме. В то же время материал, изложенный на страницах руководства, не выходит за рамки стандартного переносимого C++. Здесь реализованы только те возмож- возможности, которые вошли в стандарт ISO/ANSI. В данной книге переносимость - предмет особой заботы, так что, если вас интересуют зависящие от реализаций хитрости и уловки, вам лучше поискать их где-нибудь в другом месте.
Эффективное использование C++ К сожалению, C++, описываемый в стандарте, может до некоторой степени от- отличаться от C++, поддерживаемого вашими компиляторами. Поэтому там, где упоминаются сравнительно новые языковые средства, я также показываю, как соз- создавать эффективное программное обеспечение при их отсутствии. В конце кон- концов было бы глупо работать, не имея представления о том, что появится в буду- будущем, но вместе с тем мы не можем ждать всю жизнь, пока станут доступными самые последние, самые совершенные и универсальные компиляторы C++. При- Приходится работать с теми инструментами, которые имеются в наличии, и данная книга призвана помочь в этом. Обратите внимание на то, что я использую термин компиляторы во множест- множественном числе. Различные компиляторы реализуют разные приближения к стан- стандарту, поэтому советую вам разрабатывать код с использованием как минимум двух компиляторов. Поступая подобным образом, вы избегаете непреднамеренной привязки к оригинальным разработкам, расширениям или неправильной интер- интерпретации языка. Помимо этого вы не рискуете оказаться «на переднем крае» раз- развития технологии компилирования, то есть ограничиться новыми возможностя- возможностями, поддерживаемыми только одним поставщиком. Они зачастую плохо реализованы (с ошибками или неэффективно, а иногда и то и другое), и в среде программистов C++ еще не накоплен опыт, который мог бы помочь корректному использованию этих новшеств в приложениях. Яркие эксперименты увлекатель- увлекательны, но если ваша цель — создание надежного кода, то, как правило, лучше усту- уступить другим право прокладывать новые дороги. Эта книга не является панацеей от всех программистских бед и не указывает единственно верного пути к идеальному программному обеспечению. Каждое из 50 правил представляет собой руководство по созданию более профессиональ- профессиональных проектов, показывающее, как избежать обычных ошибок и как добиться боль- большей эффективности; но ни одно из них не универсально. Проектирование и реализация ПО - сложная задача, неизбежно сопряженная с ограничениями ап- аппаратного обеспечения, операционной системы и приложения, поэтому лучшее, что я могу сделать, - дать общие принципы создания более эффективных программ. Если вы всегда будете опираться на правила, изложенные в этой книге, то уменьшите вероятность попадания во всяческие «ловушки», поджидающие вас в C++, но общие положения по самой своей природе порождают исключения. Вот почему каждое правило содержит объяснения. Объяснения - это наиболее важ- важная часть текста. Только поняв логику, стоящую за каждой рекомендацией, вы смо- сможете определить, применима ли она к разрабатываемым вами программам при тех ограничениях, с которыми приходится иметь дело. Наибольшая польза, которую можно извлечь из настоящей книги, - четко усво- усвоить, как работает C++, почему он работает именно таким образом и как использо- использовать его возможности с максимальной эффективностью. В книге, подобной этой, нет смысла задерживаться на терминологии - это за- занятие лучше оставить лингвистам. Однако есть небольшой словарь терминов C++, которые необходимо изучить каждому. Нижеследующие понятия встречаются часто, и имеет смысл договориться о том, что они означают. Объявления «говорят» компилятору об имени и типе объекта, функции, клас- класса или шаблона, но детали в них опускаются. Вот пример объявления:
Введение extern int x; // Объявление объекта, int numDigits (int number); // Объявление функции. class Clock; // Объявление класса. template<class T> class SmartPointer; // Объявление шаблона. Определения обеспечивают компилятор деталями. Для объекта определение - это место, где компилятор выделяет объекту память. Для функции или шаблона функции определение - это код тела функции. Для класса или шаблона класса определение содержит список членов класса или шаблона: int x; // Определение объекта. 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; template<class T> class SmartPointer { // Определение шаблона. public: SmartPointer(T *p = 0); -SmartPointer () ,- T * operator->() const; T& operator*)) const; Таким образом, мы подходим к понятию конструктора по умолчанию - это конструктор, вызываемый без аргументов. У него либо нет аргументов, либо для каждого аргумента имеется значение по умолчанию. В основном конструктор по умолчанию необходим при определении массивов объектов: class A { public: А () ; // Конструктор по умолчанию. }; А аггауА[10]; // Конструктор вызывается 10 раз. class В {
ЕЕИНМИНМ Эффективное использование C++ public: B(int x = 0) ; // Конструктор по умолчанию. }; В аггауВ[10];// Конструктор вызывается 10 раз со значением аргумента 0. class С { public: C(int x) ; // Конструктор не по умолчанию. }; С аггауС[10];// Ошибка! Вы можете обнаружить, что ваш компилятор отвергает массивы объектов, ког- когда конструктор класса по умолчанию имеет аргументы со значениями по умолча- умолчанию. Например, некоторые компиляторы отказываются понимать определение аггауВ, данное выше, несмотря на то что такое определение разрешено стандар- стандартом C++. Это пример одного из расхождений, существующих между стандартом C++ и конкретной реализацией языка. Каждый из известных мне компиляторов имеет несколько подобных недостатков. До тех пор пока поставщики компилято- компиляторов не приведут их в соответствие со стандартом, будьте готовы проявлять гиб- гибкость и утешайтесь мыслью, что в недалеком будущем C++, описанный в стандар- стандарте, будет соответствовать языку, реализуемому компиляторами C++. Заметим, что, если вы хотите создать массив объектов, не имеющих конструктора но умолчанию, другая стандартная уловка - определение массива указателей. Тогда вы можете инициализировать каждый указатель отдельно, используя оператор new: С *ptrArray[10]; // Конструкторы не вызываются. ptrArray[0] = new СB2); // Разместить и создать один объект класса С. ptrArray[l] = new СD); // Разместить и создать один объект класса С. Вернемся к терминологии. Конструктор копирования используется для того, чтобы инициализировать объект другим объектом того же типа: class String { public: String() ; // Конструктор по умолчанию. String(const Strings rhs); // Конструктор копирования. private: char *data; }; String si; // Вызов конструктора по умолчанию. String s2(sl); // Вызов конструктора копирования. String s3 = s2; // Вызов конструктора копирования. Возможно, наиболее важное назначение конструктора копирования - опре- определение того, что означает передача и возврат объекта по значению. В качестве примера рассмотрим следующий (неэффективный) способ написания функции конкатенации двух объектов класса String: const String operator+(string si, String s2) { String temp;
Введение delete [] temp.data; temp.data = new char[strlenlsl.data) + strlen(s2.data) + I]; strcpy(temp.data, si.data); strcat(temp.data, s2.data); return temp; String a("Hello"); String b(" world"); String с = a + b; // с = String ("Hello world") Этот оператор сложения берет в качестве аргументов два объекта String, а возвращает один. Как аргументы, так и результат передаются по значению, по- поэтому конструктор копирования будет вызываться для инициализации s 1 значе- значением а, для инициализации s2 значением b и для инициализации с значением 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). Следующие два термина, которые нам необходимо освоить, - это инициализа- инициализация и присваивание. Инициализация объекта происходит в тот момент, когда он получает значение в самый первый раз. Для объектов классов или структур с кон- конструкторами инициализация всегда происходит посредством вызова конструктора,
Эффективное использование 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] ; strcpy(data,value); } else { // Обработка нулевого указателя1, data = new char[l] ; *data = '\0'; // Нулевой символ в конце. // Возможный оператор присваивания для String. Strings String::operator=(const String& rhs) { if (this == &rhs) return *this; // См. правило 17. delete [] data; // Освободить старую память, data = // Разместить новую память, new char[strlen(rhs.data) +1]; 'Мой конструктор класса St r ing, принимая аргумент типа const char *, корректно обрабатыва- обрабатывает передачу нулевого указателя, но стандартный тип string не обязан быть столь же «терпимым». Попытка создать string из нулевого указателя дает неопределенный результат. Однако создание объекта string из пустой строки типа char* (то есть из "") вполне безопасно.
Введение strcpy(data, 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. Стандартное приведение типов в стиле С выглядит так: (тип) выражение II Привести выражение к типу тип.
Эффективное использование C++ Новое приведение типов выглядит так: static_cas\.<тип> (выражение) II Привести выражение к типу тип. const_cast<rnn>[выражение) dynamic_cast<Tnn>[выражение) reinterpret_cast<rnn>[выражение) Различные формы приведения типов служат разным целям: q const_cast предназначается для нейтрализации действия модификатора const - эта тема обсуждается в правиле 21; Q dynamic_cast используется для выполнения безопасного преобразования типов - см. правило 39; a reinterpret_cast служит для преобразований типов, результаты кото- которых зависят от реализации, например для преобразования между указате- указателями на функции. (Скорее всего, частое использование reinterpret_cast вам не потребуется. В этой книге я его вообще нигде не использую); a static_cast является своего рода вместилищем самых разных преобразо- преобразований типов. Его нужно использовать, когда не подходят никакие другие преобразования типов. По своему смыслу данная форма ближе всего к обыч- обычному преобразованию типов в стиле С. Обычные преобразования типов по-прежнему остаются вполне допустимыми, но новые формы более предпочтительны. Их намного проще идентифицировать в коде (как для человека, так и для инструментов, подобных grep), а узкоспециа- узкоспециализированная форма каждого преобразования позволяет компилятору диагнос- диагностировать ошибки использования. Например, только приведение ccnst_cast может быть применено для отмены действия модификатора const. Если вы по- попытаетесь сделать это, используя какую-либо иную новую форму приведения, то выражение компилироваться не будет. Для получения подробной информации о новых преобразованиях типов обра- обратитесь к современным учебникам по введению в C++ или к правилу 2 моей книги «Более эффективное использование C++» (ее обзор приводится в послесловии). В рассматриваемых примерах я старался выбирать осмысленные имена объек- объектов, классов, функций и т.д. Многие источники при выборе идентификаторов при- придерживаются проверенной истины: краткость - сестра таланта. Моей целью, од- однако, была не столько изящность стиля, сколько доступность изложения. Поэтому была предпринята попытка сломать традицию использования замысловатых идентификаторов при написании книг по языкам программирования. Тем не ме- менее временами я не мог устоять против искушения применить два моих излюб- излюбленных обозначения, а их смысл может быть неочевиден, особенно если вам не доводилось тесно общаться с разработчиками компиляторов. Ins и rhs означают соответственно «слева» и «справа». Я использую их в ка- качестве аргументов функций - бинарных операторов, особенно для operator==, и арифметических операторов, подобных operator*. Например, если объекты а и Ь - рациональные числа, то, следовательно, их можно перемножить посред- посредством функции operator*, не являющейся членом класса, и выражение
Введение будет эквивалентно функциональному вызову operator*(a, b) В правиле 23 я объявляю оператор умножения следующим образом: const Rational operator*(const Rational& lhs, const Rational& rhs); Как вы видите, левосторонний операнд а внутри функции обозначается через lhs, а операнд справа (Ь) обозначается как rhs. Кроме того, я формирую сокращенные обозначения для указателей, следуя правилу: указатель на объект типа Т часто обозначается как рТ, «указатель на Т». Вот несколько подобных примеров: string *ps; // ps = указатель на string. class Airplane; Airplane *pa; // pa = указатель на Airplane. class BankAccount; BankAccount *pba; // pba = указатель на BankAccount. Подобного соглашения я придерживаюсь и для ссылок на объекты. То есть rs может быть ссылкой на строку, а га ссылкой на Airplane. Для функций-членов классов я иногда использую обозначение mf. На всякий случай, во избежание путаницы, говоря в этой книге о языке про- программирования С, я неизменно подразумеваю под ним ISO/ANSI стандарт С, а не старый «классический» вариант языка с менее строгим контролем типов.
Глава 1. Переход от С к C++ Для того чтобы освоиться с C++, необходимо некоторое время. Однако опытным программистам, привыкшим к стандартным конструкциям языка С, этот процесс может показаться особенно неприятным. Поскольку С является, по существу, подмножеством C++, все его старые «трюки» остаются в силе, но многие из них теряют свою значимость. Так, например, для программистов на C++ выражение уксиатель на указатель звучит немного забавно. Почему вместо указателя, недо- недоумеваем мы, не была использована ссылка? С - достаточно простой язык. Макросы,указатели, структуры,массивы и функ- функции - это все, что он в действительности предлагает. Каким бы сложным ни ока- оказался алгоритм, его всегда можно реализовать, используя перечисленный набор средств. В C++ дело обстоит несколько иначе: наравне с макросами, указателями, структурами, массивами и функциями используются закрытые и защищенные члены классов, перегрузка функций, аргументы по умолчанию, конструкторы и дес- деструкторы, операции, определяемые пользователем, встроенные функции, ссылки, дружественные классы и функции, шаблоны, исключения, пространства имен и т. д. Очевидно, что более богатые средства проектирования предоставляют самые ши- широкие возможности, а это, в свою очередь, требует существенно иной культуры программирования. Столкнувшись с таким разнообразием выбора, многие программисты на С те- теряются, продолжая крепко держаться за то, к чему они привыкли. По большей час- части в этом нет особого греха, но некоторые «привычки» идут вразрез с духом C++. От них просто необходимо избавиться! Правило 1. Предпочитайте const и inline использованию #define Этот правило лучше было бы назвать «Компилятор предпочтительнее препро- препроцессора», поскольку #def inc зачастую вообще не относят к языку C++. В этом и заключается одна из проблем. Рассмотрим простой пример; попробуйте напи- написать что-нибудь вроде: Me fine ASPECT_RATIO 1.653 Символическое обозначение может так и остаться неизвестным компилятору или быть удалено препроцессором, прежде чем код попадет в компилятор. Если это произойдет, то обозначение ASPECT_RATIO не окажется в таблице символов. Поэтому в ходе компиляции вы получите ошибку, связанную с использованием константы (в сообщении об ошибке будет сказано 1. 6 53, а не ASPECT_RATIO).
Правило 1 Это вызовет путаницу. Если файл заголовков писали не вы, а кто-либо другой, у вас не будет никакого представления о том, откуда взялось значение 1. 653, и на поиски ответа вы потеряете много времени. Та же проблема может возникнуть и при отладке, поскольку обозначение, выбранное вами, будет отсутствовать в таб- таблице символов. Указанная задача решается просто и быстро. Вместо использования макроса препроцессора определите константу: const double ASPECT_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 GanePiayer::NUM_TURN5; // Обязательное объявление // находится в файле реализации. Впрочем, терять сон из-за подобных пустяков не стоит. Если об определении забудете вы, то напомнит компоновщик. Старые компиляторы могут не поддерживать принятый здесь синтаксис, так как в более ранних версиях языка было запрещено задавать значения статических членов класса во время их объявления. Более того, инициализация в классе до- допускалась только для целых типов (таких как int, bool, char и пр.) и для кон- констант. Если вышеприведенный синтаксис не работает, то начальное значение сле- следует задавать в определении:
Переход от С к C++ class EngineeringConstants { // Это находится в файле заголовка класса, private: static const double ?UDGE_FACTOR; // Это находится в файле реализации класса, const double EngineeringConstants::FUDGE_FACTOR = 1.35; Единственное исключение обнаруживается тогда, когда для компиляции класса необходима константа. Например, при объявлении массива GamePlayer: : scores в листинге, приведенном выше, в момент компиляции может потребоваться задание его размера. Для того чтобы работать с компилятором, ошибочно запрещающим ини- инициализировать целые константы внутри класса, следует применять технику, которая шутливо называется «трюком с перечислением». Она основана на том, что перемен- переменные перечисляемого типа можно использовать там, где ожидаются целые числа, по- поэтому GamePlayer определяют следующим образом: class GamePlayer { private: enum { NUM_TURNS = 5 }; // "Трюк с перечислением" - делает //из NUM_TURNS символ со значением 5. int scores[NUM_TURNS]; // Нормально. Если вы имеете дело не с примитивным компилятором, написанным до 1995 года и представляющим собой только исторический интерес, то считайте, что вам повезло: необходимость использовать этот трюк отпадет сама собой. Тем не менее его нужно знать, поскольку для многих из нас устаревший компилятор - «тяже- «тяжелое наследство», доставшееся от прошлых, не столь изысканных времен. Вернемся к препроцессору. Другой частый случай неправильного использова- использования директивы #def ine - создание макросов, которые выглядят как функции, но не обременены накладными расходами функционального вызова. Каноничес- Канонический пример - вычисление максимума двух значений: ttdefir.e n-.ax(a,b) ((a) > (b) ? (a) : (b) ) В этой небольшой строчке содержится так много недостатков, что даже не со- совсем понятно, с какого проще начать. Всякий раз, когда вы пишете макрос подобный этому, необходимо помнить, что все аргументы следует заключать в скобки. В противном случае у пользовате- пользователей будут возникать серьезные проблемы с применением таких макросов в выра- выражениях. Но, даже если вы все сделаете верно, посмотрите, какие странные вещи могут при этом произойти: int а = 5, b = 0; гаах(++а, Ь); // а увеличивается дважды. тах(++а, Ь+10); //а увеличивается один раз. Происходящее внутри max зависит от того, что с чем сравнивается!
Правило 2 К счастью, вам нет нужды мириться с поведением, так сильно противореча- противоречащим привычной логике. Существует метод, позволяющий добиться такой же эф- эффективности, как при использовании макросов. В таком случае обеспечиваются как предсказуемость поведения, так и контроль типов аргументов (что характер- характерно для обычных функций). Этот результат достигается применением нстраинае- мых функций (см. правило 33): inline int max(int a, int b) { return a > b ? a : b; } Новая версия max несколько отличается от предыдущей, поскольку она мо- может работать только с целыми аргументами. Возникшую проблему удачно ре- решает шаблон: template<class T> inline const T& max(const T& a, const T& b) { return a > b ? а : b; } Он генерирует целое семейство функций, каждая из которых берет два приво- приводимых к одному типу объекта и возвращает ссылку (с модификатором const) на больший из двух объектов. Поскольку вам неизвестно, каким будет тип т, для эф- эффективности передача и возврат значения происходят по ссылке (см. правило 22). Кстати говоря, прежде чем вы решите писать шаблон для какой-либо функ- функции, подобной max, узнайте, не присутствует ли она уже в стандартной библиоте- библиотеке (см. правило 49). В случае с max вы можете воспользоваться плодами чужих усилий: max является частью стандартной библиотеки C++. Возможность использования const и inline уменьшает необходимость в препроцессоре, но не устраняет ее полностью. Еще далек тот день, когда вы смо- сможете обходиться без #include, между тем как #ifdef /#ifndef продолжают играть важную роль в контроле над компиляцией. Пока рано списывать со счетов препроцессор, но, без сомнения, уже сейчас стоит задуматься над тем, как освобо- освободиться от него в дальнейшем. Правило 2. Предпочитайте <iostream> использованию <stdio.h> Да, они переносимы. Да, они эффективны. Да, вы уже знаете, как их использо- использовать. Но какой бы благоговейный восторг они ни вызывали, факт остается фактом: операторы scanf и print f и им подобные далеки от совершенства. В частности, они не осуществляют контроль типа и к тому же нерасширяемы. Поскольку кон- контроль типов и расширяемость - краеугольные камни идеологии C++, то лучше всего с самого начала во всем опираться на них. Кроме того, семейство функций printf /scanf отделяет переменные, которые необходимо прочитать или запи- записать, от форматирующей информации, управляющей записью и чтением, в точ- точности так же, как это делает FORTRAN. Настало время распрощаться с пятидеся- пятидесятыми. Неудивительно, что эти слабости функции pr int f /scanf - сила операторов >> и <<.
Переход от С к C++ inr. i; Rational г; // г является рациональным числом. ci n >> i >> г ; cout << i << r; Ноли этот код предназначен для компиляции, должны быть в наличии функ- функции oporator>> и operator<<, которые могли бы работать с объектом типа Rational. Отсутствие данных функций является ошибкой. (Для int имеются стандартные версии.) Более того, компилятор берет на себя заботу о том, какие версии операторов вызывать для разных переменных; таким образом, вам нет не- необходимости беспокоиться о том, что первый читаемый или записываемый объект имеет тип int, а второй - Rational. Кроме того, считывание объектов происходит с использованием той же син- синтаксической формы, что и при записи. Поэтому нет необходимости помнить о том, что, если вы работаете не с указателем, важно не забыть взять адрес, а если имеете дело с указателем, следует убедиться, что вы не берете адрес. Пусть о таких дета- деталях заботится компилятор C++. Это его дело, у вас же есть задачи посерьезнее. И наконец, заметьте, что встроенные типы, подобные int, читаются и записывают- записываются совершенно аналогично тинам, определенным пользователями, таким, напри- например, как Rational. Попробуйте сделать то же самое, используя scanf и print f! Ниже приводится пример того, как можно написать функцию для вывода класса рациональных чисел: class Rational { public: Rational(int numerator = 0, int denominator = 1) ; private: int n, d; // Числитель и знаменатель, friend ostreamSc cperator<< (ostrearnk s, const Rationale r) ; }; ostrcam& operator<<(ostreamfc s, const Rational& r) { s << r.n << '/' << r.d; return s; } Эта версия operator<< демонстрирует некоторые тонкости (притом весьма важные!), обсуждаемые п других разделах книги. Например, она не является функцией-членом (правило 19 объясняет, почему), а объект Rational передает- передается operator<< по ссылке const, а не как объект (см. правило 22). Соответствую- Соответствующая функция ввода, operator», объявляется и реализуется аналогичным образом. Как ни обидно мне это признавать, в ряде случаев имеет смысл вернуться к старому и проверенному способу. Во-первых, некоторые реализации операций потоков ввода/вывода менее эффективны, чем соответствующие операции С, и возможно (хотя маловероятно), что в отдельных приложениях это может ока- оказаться существенным. Помните, однако: это относится не к потокам ввода/выво- ввода/вывода вообще, а только к той или иной реализации. Во-вторых, библиотека потоков
Правило 3 ввода/вывода в ходе своей стандартизации претерпела ряд кардинальных изме- изменений (см. правило 49); следовательно, приложения, требующие максимальной переносимости, могут столкнуться с тем фактом, что различные поставщики под- поддерживают различные приближения стандарта. И наконец, поскольку классы библиотеки потоков ввода/вынода имеют конструкторы, а функции <stdio. h> - нет, в редких случаях существенным будет порядок инициализации статических объектов (см. правило 47), и стандартная библиотека С окажется более удобной просто потому, что вы можете ею пользоваться без опасений. Контроль типов и расширяемость, предлагаемые классами и функциями биб- библиотеки потоков ввода/вывода, являются более важными, чем это может пока- показаться, - не стоит отвергать их только из-за того, что вы привыкли к <stdio. h>. В конце концов, никто не посягает на ваши воспоминания. Между прочим, это не опечатка - в названии данного правила действитель- действительно фигурирует <iostream>, а не <iostream.h>. Строго говоря, такого заголов- заголовка, как <iostreani.h>, не существует: Комитет по стандартам отказался от него в пользу названия <iostream> при сокращении имен стандартных файлов заголовков, отсутствующих в библиотеке языка С. Причина объясняется в пра- правиле 49, но на самом деле важно уяснить лишь следующее: если (что весьма ве- вероятно) ваш компилятор поддерживает как файл заголовков <iostream>, так и <iostream. h>, необходимо иметь в виду, что они слегка отличаются друг от друга. В частности, если вы включаете <iostream>, элементы библиотеки пото- потоков ввода/вывода весьма удобно расположены в пространстве имен std (см. пра- правило 28); включая <iostream.h>, вы получаете те же элементы, но в глобаль- глобальном пространстве имен. Их определение в нем может вести к конфликтам, предотвращению которых и должно было послужить введение понятия простран- пространства имен. Кроме того, <iostream> короче, чем <iostream.h>. Для многих это оказывается достаточным аргументом в пользу нового названия. Правило 3. Предпочитайте new и delete использованию malloc и free Проблема, связанная с malloc, free, а также их вариациями, очень проста: эти функции ничего не «знают» о конструкторах и деструкторах. Рассмотрим следующие два способа выделить память для десяти объектов string: сначала с использованием malloc, а затем - new: string *strir.gArrayl = stacic_cast<string*>(mallocA0 * sizeof (string) )); string *strir.gArray2 = new string [10] ; Здесь stringArrayl является указателем на область памяти, достаточную для размещения десяти объектов string, но ни один объект в этой памяти не размещен. Более того, у вас нет никакого способа инициализировать объект, не прибегая к неко- некоторым весьма изощренным уловкам. Другими словами, stringArrayl практически бесполезен. В противоположность ему stringArray2 - это указатель на массив из десяти полностью сконструированных объектов string, каждый из которых можно использовать в операциях с этим типом.
Переход от С к C++ И все-таки давайте предположим, что вам магическим образом удалось ини- инициализировать объекты массива stringArrayl. Тогда далее в программе необ- необходимо сделать следующее: free(stringArrayl); delete [] strir.gArray2; // См. правило 5, для чего требуются "[]"• Вызов free высвобождает память, на которую ссылается stringArrayl, но никаких деструкторов для размещенных в памяти объектов string при этом не вызывается. Если объекты string сами выделяют память (что они обычно и де- делают), то вся выделенная ими намять будет потеряна. С другой стороны, если для stringArray2 вызывается delete, то прежде чем происходит высвобождение какой-либо памяти, для каждого объекта массива вызывается деструктор. Поскольку new и delete должным образом взаимодействуют с конструкто- конструкторами и деструкторами, очевидно, что их выбор более предпочтителен. Одновременное использование new и delete cmalloc и 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 внутри библиотеки, или/и за выделение с помощью та 11 ос памяти, кото- которую библиотека сама потом освободит функцией free. Это нормально. В вызове malloc и free внутри программы на C++ нет ничего плохого, пока вы следите за тем, чтобы указатели, полученные при помощи malloc, всегда высвобожда- высвобождались посредством free, а к указателям, полученным с помощью new, применялся
Правило 4 delete. Проблемы начинаются тогда, когда вы проявляете небрежность и ис- используете одновременно new с free и malloc с delete. При этом вы, к сожале- сожалению, сами напрашиваетесь на неприятности. Учитывая, что malloc и free не взаимодействуют с конструкторами и де- деструкторами, а последствия одновременного использования malloc/ free с new/ delete гораздо менее предсказуемы, чем финал студенческой вечеринки, лучше всего по возможности применять new и delete. Правило 4. Предпочитайте комментарии в стиле C++ Старый добрый стиль комментариев С работает и в C++, но новый синтаксис комментариев C++ до конца строки имеет свои отчетливые преимущества. На- Например, рассмотрим следующую ситуацию: if ( а > b ) { // int temp = а; // Поменять а и b. // а = Ь; // b = temp; } У нас есть блок с кодом, закомментированным но той или иной причине. Яв- Являя достойный подражания пример, программист, написавший изначальный код, в дополнение к этому включил в него комментарий, который подсказывает, что же здесь происходит. Когда комментарий к блоку пишется в форме, принятой в C++, вложенный комментарий не составляет проблемы, но если бы оба комментария были написаны в стиле С, у нас могли бы возникнуть трудности: if ( а > b ) { /* int temp = а; /* поменять а и b */ а = Ь; b = temp; */ } Обратите внимание на то, что вложенный комментарий непреднамеренно раз- разрывает комментарий, который должен был относиться к блоку кода. Комментарии в стиле С по-прежнему играют важную роль. Например, они неоценимы в файлах заголовков, обрабатываемых компиляторами С и C++. Од- Однако если вы можете использовать комментарии в стиле C++, то лучше приме- применять именно их. Стоит отметить, что старые препроцессоры, легко обрабатывающие С, «не знают», как быть с комментариями в стиле C++, поэтому иногда использование таковых может вызывать неожиданные эффекты: «define LIGHT_SPEED 3e8 // м/сек (в вакууме). Если препроцессор «не знает» C++, комментарий в конце строчки становит- становится частью макроса! Тем более, как уже говорилось в правиле 1, использовать пре- препроцессор для определения констант не следует. 2зак.125
Глава 2. Управление памятью Вопросы управления памятью в C++ распадаются на две основные группы: как делать это правильно, с одной стороны, и эффективно, с другой. Хорошие про- программисты понимают, что данные проблемы следует решать именно в таком по- порядке, поскольку исключительно быстрая и удивительно маленькая программа никому не нужна, если она не выполняет того, что от нее ожидается. Для большин- большинства программистов правильное управление памятью - это наличие корректного вызова функций выделения и высвобождения памяти. Эффективность же озна- означает написание своих собственных функций выделения и высвобождения памяти. Следует признать, что C++ наследует от С наиболее существенную проблему, характерную для данного языка, - потенциальную возможность утечек памяти. Даже виртуальная память, каким бы замечательным изобретением она ни была, ограничена и к тому же не всем доступна. В С утечка памяти происходит всякий раз, когда память, выделенная посред- посредством malloc, не возвращается при помощи free. В C++ действуют r.ew и delete, но в таком случае происходит примерно то же самое. В какой-то степени, однако, ситуацию облегчают деструкторы, предоставляя место для вызовов delete, ко- которые необходимо выполнить при уничтожении объекта. В то же время у вас по- появляется больше проблем, поскольку new влечет неявный вызов конструктора, a delete - вызовы деструкторов. Более того, существует еще одно осложнение: вы можете определять свои собственные версии операторов new и delete как для классов, так и вне них. Таким образом, не исключена возможность ошибиться. После- Последующие правила должны помочь вам избежать наиболее распространенных ошибок. Правило 5. Используйте одинаковые формы new и delete Что неправильно в этом примере? r>V. ring *r>r.rir.gArray - new string [100] ; celouc strir.crArray; На первый взгляд все в полном порядке - использованию new соответствует применение delete, но что-то здесь совершенно неверно: поведение программы непредсказуемо. По крайней мере 99 из 100 объектов string, на которые указыва- указывает stringArray, скорее всего, не будут надлежащим образом удалены, поскольку их деструкторы, вероятно, так и не вызваны. При использовании new происходят два события. Во-первых, выделяется па- память (посредством оператора new, о чем я еще буду говорить в правилах 7-10).
Правило 5 Во-вторых, для этой памяти высыпается один или несколько конструкторов. При вы;юве delete вызывается один или несколько деструкторов, а затем посредством функции operator delete высвобождается память (см. правило 8). Большой вопрос, возникающий в связи с использованием delete, заключается и следующем: сколько объектов следует удалить из памяти? Ответ зависит от того, сколько де- деструкторов должно быть вызвано. В действительности вопрос гораздо проще: является ли удаляемый указатель указателем на один объект или на массив объектов? Если, применяя delete, вы не используете квадратных скобок, он предполагает, что это указатель на оди- одиночный объект. В противном случае это указатель на массив: string *strir.gPtrl = new string; string *stringPtr2 = new string[100] ; delete stringPtrl; // Удаляется объект. delete [] stringPtr2; // Удаляется массиз объектов. Что произойдет, если использовать форму с [ ] для stringPtrl? Результат не определен. Что случится, если вы не используете [ ] для string?tr2? Неиз- Неизвестно. Более того, это не определено даже для встроенных типов, подобных int, несмотря на то, что у таких типов нет деструкторов. Правило выглядит просто: если вы применяете [] при вызове new, то должны использовать [] при вызове delete. Если вы не применяете [] при вызове new, не используйте [] при вызо- вызове delete. Это правило особенно важно помнить при написании классов, содержащих указатели на конструкторы разного рода, поскольку вам необходимо соблюдать осторожность, используя для всех конструкторов во время инициализации указа- указателей-членов класса одну и ту же форму new. Если этого не делать, то как узнать, какую форму delete использовать в деструкторе? Для дальнейшего ознакомле- ознакомления с этим вопросом обратитесь к правилу 11. Данное правило также важно для тех, кто часто прибегает к использованию 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 для создания типов с массивами. Это, однако, не должно вызывать
Управление памятью особых затруднений, поскольку библиотека C++ (см. правило 49) включает в себя шаблоны string и vector, практически сводящие к нулю необходимость исполь- использования встроенных массивов. В нашем случае, например, тип AddressLines мог бы быть определен как вектор строк. Иными словами, AddressLines мог иметь тип vector<string>. Правило б. Используйте delete в деструкторах для указателей членов В большинстве случаев классы, применяющие динамическое выделение па- памяти, будут использовать в конструкторах new для выделения памяти, а затем - delete в деструкторах для ее высвобождения. Нетрудно сделать это правильно при условии, что вы не забудете использовать delete для всех членов класса, которым когда-либо могла быть выделена память в любом из конструкторов. По мерс эксплуатации и усовершенствования классов ситуация усложняется, так как вносить изменения в классы могут те программисты, которые первона- первоначально не создали их. При этом легко забыть, что добавление указателя - члена класса практически всегда требует выполнения следующих действий: а инициализации указателя во всех конструкторах. Если в данном конструк- конструкторе для указателя выделять память не требуется, то указатель должен быть инициализирован как нулевой; Q удаления имеющейся памяти и выделения новой в операторе присваивания (см. также правило 17); ? удаления указателя в деструкторе. Если вы забудете инициализировать указатель в конструкторе или обработать его внутри оператора присваивания, проблема, вероятнее всего, станет очевидной очень быстро, поэтому на практике подобные ошибки не доставляют серьезных неприятностей. Если, однако, ire удалить указатель в деструкторе, то можно вообще не заметить никаких симптомов. Просто начнут происходить небольшие утечки памяти, и получится нечто, напоминающее медленно растущую раковую опухоль, которая в конечном итоге «пожрет» имеющуюся память и приведет к преждевре- преждевременной кончине вашей программы. Важно помнить об этой проблеме всякий раз, когда вы добавляете в объявление класса член, являющийся указателем. Заметьте, между прочим, что удаление нулевого указателя всегда безопасно (при этом ничего не происходит). Таким образом, если конструкторы, операторы присваивания и другие члены-функции написаны так, что каждый указатель-член класса всегда либо указывает на память необходимого типа, либо равен нулю, вы можете, ни о чем не беспокоясь, удалять их посредством delete, независимо от того, применяли ли вы для них new или нет. Не стоит, впрочем, впадать в крайности. Например, не нужно использовать delete для указателей, не инициализированных посредством new, и практически никогда не следует удалять указатель, который был вам ранее передан. Другими словами, ваш деструктор класса обычно не должен использовать delete, если в ваших членах класса вы не применяли new.
Правило 6. Правило 7 Правило 7. Будьте готовы к нехватке памяти Когда оператор new не может выделить запрошенную память, он генерирует исключение. (Раньше он возвращал 0, и некоторые старые компиляторы до сих пор работают таким образом. При необходимости можно заставить компилятор делать это снова, но я воздержусь от обсуждения указанного момента в рамках данного правила.) В глубине души вы прекрасно знаете, что обработка исключений не- нехватки памяти - единственно правильный способ действий. Вместе с тем не при- приходится сомневаться, что это добавит вам много неприятной работы. В итоге вы станете время от времени, а возможно даже всегда, опускать подобную обработку. При этом на душе у вас будет неспокойно. А что, если new действительно даст исключение? Вы можете решить, что разумным способом решения данной проблемы будет возврат к давно минувшим дням, то есть к использованию препроцессора. Напри- Например, распространенный прием С - определение макроса, который не зависит от передаваемого типа, гарантирующего успех выделения памяти. В C++ такой макрос мог бы выглядеть следующим образом: «define NSW(PTR, TYPE) \ try { (PTR) = new TYPE; } \ catch (std::bad_alloc&) { assert@); } «Подождите-ка! Что означает 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 T; new T(аргументы конструктора) ; new T[размер] ; Данное представление, однако, является упрощенным, так как пользователь определяет свои собственные (перегруженные) формы оператора new, - следова- следовательно, программа может содержать произвольное количество различных синтак- синтаксических форм применения new.
Управление памятью Как же тогда быть? Один из возможных вариантов ¦- выбрать простейшую страте- стратегию обработки ошибок: сделать так, чтобы, если запрос на выделение памяти не может быть удовлетворен, вызывалась определенная нами функция-обработчик ошибок. Эта стратегия основана на договоренности о том, что, когда operator new не может удов- удовлетворить запрос, перед генерацией исключения он вызывает определенную пользо- пользователем функцию обработки ошибок, часто называемую обработчиком new. (В дей- действительности new делает все немного сложнее - подробнее см. правило 8.) Для того чтобы задать функцию, обрабатывающую нехватку памяти, пользо- пользователь вызывает функцию set_r.ew_handler, определенную в файле <:icw>, приблизительно следующим образом: typede: void (*T.ew_r.<?.r.c<Lor) () ; nev»_l,ar.dler set_r.ev.:_ha:idler (r.ew_handler p) tr.rov.'O; Ясно видно, что new_ha:idler - это определяемый typedef указатель на функцию, которую operator new должен вызывать, если он не может выделить требуемую память. Значение, возвращаемое set_new_handler, -этоуказательна функцию, отвечавшую за обработку исключения до вызова sct_new_handler. Используйте set_new_handler следующим образом: // Втииат;, эту функцию, еспл new но сможет тзыдел^т;» ::ахять. void noMoreMenory() corr << "Unable to satisfy request [or mcnoryXn"; abort(); } int: main () f so:._new_har.ciler (noXoreK.emcry) ; i:;t +p3ig3ataArray ¦ new int [: OOOCCCCO] ; Ксли operator new не может выделить память для 100000000 целых (что представляется весьма вероятным), то будет вызвана функция noMoreMemory, и после выдачи сообщения об ошибке программа прервет выполнение. Это лишь немногим лучше, чем завершить выполнение системным сообщением. (Кстати, подумайте, что произойдет, если для записи сообщения об ошибке в сегг требу- требуется динамическое выделение памяти...) Когда оператор new не может удовлетворить запрос памяти, он вызывает на- назначенную функцию-обработчик непрерывно, пока не сможет найти необходимое количество памяти. Код этих последовательных вызовов рассматривается в пра- правиле 8, но и такого высокоуровневого описания достаточно, чтобы сделать вывод о том, что хорошо спроектированная функция-обработчик new должна выполнить одно из следующих действий: Li сделать доступным дополнительное количество памяти. Это может позволить оператору new успешно выполнить операцию выделения памяти во время следующей попытки. Одни из способов реализации этой стратегии- выде- выделение большого блока памяти при запуске программы и его высвобождение
Правило 7 при первом вызове обработчика new. Оно часто сопровождается предупреж- предупреждением пользователю о том, что памяти остается мало и последующие за- запросы могут потерпеть неудачу, если каким-либо образом не будет выделе- выделено нужное количество памяти; Q установить другой обработчик new. Если текущий обработчик не может вы- выделить большее количество памяти, скорее всего, он установит другой обработ- обработчик (вызывая set_new_handler), который проявит больше «изобретательно- «изобретательности». В следующий pa's, когда 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 outOf.Memory () ; class Y { public: static void outOfKerr.ory () X* pi = new X; // Ксли не удается выделить память, зызвать X: rouLOfKerr.ory () . Y* р2 = new Y; // Если не удается выделить память, вызвать Y: rouLOfMemory {) . C++ не поддерживает специфичные для классов обработчики new, но в этом и нет необходимости. Вы можете реализовать такое поведение самостоятельно. Просто каждый класс должен обеспечивать свою собственную версию set_new_handler и оператора new. Функция set_new_handler позволит задать для класса глобаль- глобальный обработчик new (подобно тому, как стандартная функция set_new_handler дает возможность указать глобальный обработчик new). Определение для класса
Управление памятью оператора new гарантирует, что при выделении памяти для объектов класса вме- вместо глобального будет иснолыюнаться специфичный обработчик new. Рассмотрим класс X, для которого вы хотите обрабатывать неудачные попытки выделения памяти. Вам необходимо отслеживать, какую функцию следует вызывать, когда оператор new не может выделить достаточное количество памяти для объекта типа X, поэтому требуется объявить статический член типа new_handler, указыва- указывающий на функцию-обработчик new этого класса. Ваш класс X будет выглядеть так: class X { public: static new_handler set_new_handler (r.ew_har.dicr p) ; static void * operator now(size_t size); pri vate: static new_handler currentHandler; }; Статические члены класса должны быть указаны вне определения класса. По- Поскольку статические объекты предпочтительнее инициализировать нулем, ука- укажите X: : currentHandler, не инициализируя его. new_hanclor X::currentHandler; // Устанавливает currer.tKandler // з 0 (null) по умолчанию. Функция set_new_handler из класса X будет сохранять передаваемый ей указатель и возвращать указатель, имевшийся до вызова. Это в точности то, что делает стандартная версия set_new_handler: new_handler X::sct_r.ow_handler(new_handlcr p) { ncw_handler oldHandler = currentHandler; currentKandler - 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 снова вызовет стандартную
Правило 7 функцию set_new_handler и посстаповит глобальную функцию обработ- обработки ошибок исходного состояния. Затем он вернет указатель на выделенную память. Теперь посмотрите, как все это будет выглядеть на C++: void * X: :opcrator r.ew(size_t size) { new_hancier globalHar.dler -¦ // Установить обработчик г,):я X. std: :scc_:iew_handlor (c.irrencHanclcr) ; void *nemory; try { // Попытаться лыделить память. memory = ::operator new(sizc); } catch (std::bad_alloc&) { // Восстановить обработчик. std: : set_new_har.dler (globalHandler) ; throw; // Сгенерировать исключение снова. } std::set_new_handler(globalHandler); // Восстановить обработчик, return memory; } Пользователи класса Х применяют возможности обработки now следующим образом: void noMoreMemory() ; // Объявить функцию, которую требуется зызвать, если //но удастся выделить память для объектов класса X. X: : set_new_handler (noMoreMerr.ory) ; // Установить noMoreMemory //в качестве обработчика r.ew для X. X *pxl = new X,- // При нехватке памяти вызвать noMoroMemory. string *ps = new string; // При нехватке памяти выззать глобальный // обработчик new (если есть). X::set_new_handler@); // Установить обработчик new для X в 0 // (то есть отменить обработку). X *рх2 = new X; // При нехватке памяти сразу же сгенерировать исключение. // (Обработчик new для класса X отсутствует.) Как вы могли заметить, код (независимо от класса), реализующий эту схему, остается одинаковым, поэтому возникает желание использовать его повторно. Как объясняется в правиле 41, для создания повторно используемого кода можно при- применять как наследование, так и шаблоны. Однако в этом случае то, что нам необ- необходимо, получается в результате комбинации обоих методов. Нам нужно создать базовый класс-примесь, то есть базовый класс, спроекти- спроектированный так, чтобы производные классы наследовали одно специфическое свой- свойство - в данном случае способность устанавливать для класса свой обработчик new. Затем вы превратите базовый класс в шаблон. Часть схемы, относящаяся к насле- наследованию, позволяет производным классам наследовать необходимые им функции set_new_handler и оператор new, а шаблонная часть схемы гарантирует, что каж- каждый класс-наследник получает различные члены currentHar.dler. Все это зву- звучит немного туманно, но, как нетрудно заметить, код выглядит обнадеживающе. Единственная разница заключается в том, что он теперь может быть использован в любом классе, который в этом нуждается:
Управление памятью tenplatc<class ¦'!.'> // Базовый класс-примесь для поддержки cias NewHandlerSupport. { //set_new_handler на уровне класса, public: static new_hancler set_new_handler(new_handler p) ; static void * operator new(size_t size); private: static r.ew_handler currentHandler; }; torr.p] ate<class T> new_handler NowHandlerSupport<T>::set_new_har.dler(new_handler p) { new_handler oidHandlcr = currentHandler; currer.tHandl er = p; return oidliandler; } te.T.plate<ciass T> void * NewKandlcrSupport<T>::operator new(size_t size) { new_handier globalHar.dler = std: :set_new_har.dler (currentHandler) ; void *пет.огу; try { nenory = : :cperator r.ew(size) ; } catch (std::bad_alloc&) { set_new_handier(globalHandler); throw; } std: : set_r.ew_handler (globalHandler) ; return memory; i // Устанавливаем каждый currentHandler в ноль. terr,pLate<class T> new_handler NewHar.dlerSupport<T>: :currentHandler ; С использованием этого шаблона класса добавление для класса X поддержки set_new__handler выглядит просто - X наследует от newHandlerSupport<X>: // Обратите внимание на наследование от шаблока-примеси. class X: public NewKandlerSupport<X> { ...II Как ранее, но без объявлений sct_new_handler и operator new. } ; Пользователь X остается в неведении относительно «кухни»; старый код про- продолжает функционировать. И это хорошо, поскольку единственная вещь, на кото- которую вы можете положиться, - это неведение пользователя. Применение set_new_handler - удобный и легкий способ обрабатывать со- состояние нехватки памяти, более эффективный, чем обрамление каждого new бло- блоком try. Кроме того, шаблоны, подобные NewHandlerSupport, облегчают добав- добавление отдельных обработчиков new для классов, требующих этого. Наследование примесей, однако, неизбежно подводит нас к теме множественного наследования, поэтому, прежде чем ступить на этот тернистый путь, прочитайте правило 43.
Правило 8 До 1993 года язык С~ + требовал, чтобы оператор new, если он не может вы- выполнить запрос на выделение памяти, возврата.! 0. Сейчас now должен генери- генерировать исключение std: :bad_alloc, но большая част!) кода C++ била написа- написана до пересмотра спецификации. Комитет по стандартизации C++ не хотел отказываться от уже созданного кода, промеряющего на 0, поэтому предложи:! альтернативную форму оператора new (и оператора new [ ] - см. правило 8), ко- которая продолжает при неудаче возвращать 0. Эти формы называются r.othrow, поскольку они никогда не используют uhrow и применяют объекты p.othrow, определенные в стандартном заголовочном файле <r.evi>: class Widgor { ... }; Widqer. *pw'. - new W;cccc; // 1'енеркрует si:d: :cad_al !oc лр;: ?:ехватке памяти. if (pwl -= 0) . . . // Это условие ;:е должно заполняться. Widget. *pw2 = new (nochrow) Widget; // Возвращает О прл нехватке памяти. ii (pw2 == 0) . . . // Это усг.опж кожет заполниться. Независимо от того, используете ли вы нормальную (то есть генерирующую исключение) или версию nothrow new, важно, чтобы вы были готовы к обработ- обработке ошибок недостатка памяти. Самый легкий способ сделать это - воспользовать- воспользоваться функцией set_new_handler, которая работает с обеими формами. Правило 8. При написании операторов new и delete придерживайтесь ряда простых правил Когда вы беретесь за написание оператора new (правило 10 объясняет, зачем это может понадобиться), важно, чтобы поведение функций, написанных вами, соответствовало действиям оператора new по умолчанию. Па практике это озна- означает правильное возвращаемое значение, вызов функции обработки ошибки при недостаточном количестве памяти (см. правило 7) и готовность к обработке за- запроса на нулевой размер памяти. Вам также необходимо избегать неумышленно- неумышленного сокрытия «нормальной» формы new, но это уже тема правила 9. Что же касается возвращаемого значения, здесь все просто. Мели вы можете вы- выделить запрошенную память, то просто возвращаете указатель на нее. Если не може- можете- следуйте правилу 7 и генерируете исключение типа std: :bac_al. loc. Однако на самом деле оператор new пытается выделить память более одного раза, после каждой неудачной попытки вызывая функцию обработки ошибки и предполагая при этом, что функция-обработчик может сделать что-нибудь для высвобождения некоторого количества памяти. Только когда указатель функции обработки ошибки равен нулю, оператор new генерирует исключение. Псевдокод оператора new, не являющегося членом класса, выглядит следую- следующим образом: void * operator ncw(size_:. size) // У загсего оператора r.ew могут // быть г,о:ю!::глтепьныо аргумента. if (size == 0) { // Обрабатываем запросы ка 0 байт, size = 1; // как ес.чк Ьк Ьиг. запрошен 5 байт памяти.
Управление памятью while (true) { пытаемся выделить size байтов памяти if (выделение прошло успешно) return (указатель на память) ; II Попытка разместить память прошла неудачно; // найдем текущую функцию обработки ошибок (см. правило 7). new_har.dler globalHandler = set_new_handler @) ; 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 ze не равно 0). Такое поведение вполне разумно, поскольку именно этот аргумент передан функции. Однако большинство версий опе- операторов new для классов (включая ту, которую вы обнаружите в правиле 10) спроек- спроектировано только для конкретного класса, а не для класса со всеми его подклассами. То есть при наличии у класса определения функции operator new ее поведение практически всегда настраивается для работы с объектами размера sizeof (X) - не больше и не меньше. Из-за наследования, однако, оператор new базового класса будет вызван для выделения памяти объекту производного класса: class Base { public: static void * operator new(size_t size); class Derived: public Base // Derived не объявляет operator new. { . . . } ; Derived *p = new Derived; // Вызов Base::operator new!
Правило 8 Если оператор new для класса Base не был спроектирован для того, чтобы справляться с подобной ситуацией (вероятнее всего, так и есть), лучше всего пе- перенести обязанность выделения «неправильного» количества памяти на стандарт- стандартный оператор new следующим образом: void * Base: :operator r.ew(size_t size) { if (size != sizeof (Base) ) // F.cjw размер "не тот", return ::operator r.ew(size); // пусть запрос обработает //' стандартный о::ератор now. ... // 3 протизном сг.учае обрабатываем запрос здесь. } «Постойте-ка! - скажете вы. - Вы забыли проверить не вполне типичный случай, когда size равен нулю!» На самом деле я об этом не забыл. Такая про- проверка здесь содержится, и встроена она в сравнение sizecsizeof(Base). Нуги стандарта C++ неисповедимы, и одно из подтверждений тому - соглашение, что все самостоятельные1 классы имеют ненулевой размер. По определению значе- значение sizeof (Base) никогда не может быть равно нулю (даже если класс не со- содержит членов), поэтому, если size равен 0, запрос будет передан функции : : operator new и она станет отвечать за адекватную обработку этого запроса. Если вы хотите управлять выделением памяти массивам для отдельных классов, вам потребуется реализовать родственную оператору new функцию - operator new[]. (Она обычно называется new для массивов, поскольку трудно понять, как следует произносить operator new [ ].) Если вы решите написать свой operator new[ ], помните, что вы отвечаете только за выделение неструктурированной па- памяти. С несуществующими объектами массива вы ничего делать не можете. В дей- действительности вы даже не можете рассчитать, сколько объектов будет в массиве, так как вам неизвестен размер каждого объекта. В конце концов, operator new [ ], объявленный в базовом классе, может быть вызван с целью выделения памяти для массивов объектов производных классов, но такие объекты обычно больше по размерам, чем объекты базовых классов. Поэтому внутри Base: : operator new [ ] нельзя предполагать, что размер каждого объекта массива равен sizeof (Base), а количество объектов в массиве - значению выражения (запрошенное количество байтов)/вizeof (Base). Вот и все правила, которым необходимо следовать, когда вы определяете operator new (и operator new[ ]). Для оператора delete и его аналога для мас- массивов - оператора delete [ ] все намного проще. C++ гарантирует безопасность удаления нулевого объекта, поэтому вам также следует соблюдать эту гарантию. Приведем псевдокод для оператора delete: void operator delete(void *rawMemory) { if (rawMerr.ory =- 0) return; // Ничего не делаем при удалении // нулезого указателя. удаляем память, на которую указывает rawMemory 1 Имеются к пилу невложенные классы. - Прим. научного редактора.
Управление памятью reuurr. ,- } Версия этой функции, определенной как член класса, тоже проста; единствен- единственный нюанс заключается в том, чтобы проверять размер удаляемого объекта. Если operator new передаст запросы памяти «неправильного» размера функции : : operator new, то запросы на удаление объектов «неправильного» размера сле- следует передавать функции : : operator delete: class Base { // Тот же класс, только добавлен оператор delete. pub1. Lc: sr.aric void * operator new(sizc_t size) ; static void operator delete(void *rawMenory, size_t size); i ; void Base: :opcrator delete(void *rawMeir.ory, size_t size) { if (rawKcmory == 0) return; // Проверить нулевой указатель. if (size != sizeof (Base) ) { // Если размер "не тот", пусть запрос ::operator delete(rawMemory); // обработает стандартный return; // оператор delete. } удаляем память, на которую указывает rawMemory return; } Правила для операторов new и delete (и их аналогов для массивов) не осо- особенно обременительны, но придерживаться их следует. Если написанные вами функции выделения памяти поддерживают функции-обработчики new и коррект- корректно обрабатывают запросы нулевого размера, то это практически все, что требует- требуется, а если эти функции высвобождения памяти умеют работать с нулевыми указа- указателями, то вам осталось совсем немного - добавить поддержку наследования для функций-членов. Правило 9. Старайтесь не скрывать «нормальную» форму new Определение имени во внутренней области видимости скрывает это имя во всех внешних областях, так что если функция f определена и в глобальной обла- области видимости, и в области видимости класса, то функция-член скроет глобаль- глобальную функцию: void fib- class X { public: void f() X x; f 0; X . f ( ) ; // ; // // // Глобальная функция. Функция-член. Вызывает глобальную f. Вызывает X::f.
Правило 9 Такое поведение в нормальной ситуации не приведет к путанице, поскольку глобальные функции и функции-члены обычно вызываются с использованием разных синтаксических форм. Однако, если добавить к классу оператор по;, тре- требующий дополнительных аргументов, результат может вас неприятно удивить: class X { public: void f(); // Оггератор now, которому необходимо указать функцию-обработчик. static void * operator new(size_t size, r.ow_har.dler p) ; }; void specialSrrorHar.dler () ; // Определение и другом месте. X *pxl = new (specialilrrcrHandlcr) X; // Зь:зъ:вает X::cperator now. X *px2 = new X; // Ошибка! Определив функцию по имени operator new внутри этого класса, вы, сами того не заметив, заблокируете доступ к «нормальной» форме оператора new. По- Почему так происходит, обсуждается в правиле 50. Здесь же нас больше интересует, как можно этого избежать. Одно из решений проблемы - написать для класса оператор new, поддержи- поддерживающий вызов «нормальной» функции. Если он делает то же самое, что и глобаль- глобальная функция, это может быть эффективно и изящно реализовано с помощью встра- встраиваемой функции: class X { public: void f() ; static void * operator r.ew(size_t size, new_nandlor p) ; static void * operator new(size_t size) { return ::operator new(size); } }; X *pxl = new (specialErrorKancler) X; // Вызывает X::cperator // new(size_u, ncw_hand:er ). X* px2 = new X; // Вызывает X: ropcrat.or new(sizo_t) . В качестве альтернативы для каждого дополнительного аргумента оператора new можно задать значение по умолчанию (см. правило 24): class X { public: void f(); static void * operator new(size_t size, :iew_haridlcr p = G) ; // Заметьте: у р есть значение i:o умолчанию. }; X *pxl = new (spocialErrorKandlor) X; // Нормапьпо. X* px2 = now X; // V. это токо. В любом случае, если вы позднее решите модифицировать поведение «нормаль- «нормальной» функции new, все, что вам необходимо будет сделать, - это переписать ее. После перекомпоновки вы сразу добьетесь желаемого поведения и месте вызова функции new.
Управление памятью Правило 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 *pa = new Airplane; то вряд ли получаете блок памяти, выглядящий следующим образом: ра л Memory for = G Airplane object •
Правило 10 Чаще всего получаемый блок памяти выглядит примерно так: Data on size of memory block pa- Memory for Airplane object Для небольших объектов, подобных классу Airplane, эта дополнительная бух- бухгалтерия может более чем вдвое увеличить количество памяти для каждого дина- динамически создаваемого объекта (особенно если класс имеет виртуальные функции). Если вы разрабатываете программное обеспечение для тех случаев, когда память является цепным ресурсом, то, вероятно, не сможете позволить себе подобную расточительность. При написании вашего собственного оператора new для класса Airplane воспользуйтесь тем обстоятельством, что объекты Airplane имеют одинаковый размер. Поэтому нет никакой необходимости сопровождать каждый выделенный блок дополнительной информацией об использовании ресурсов. Один из способов реализации ваших собственных операторов new - запрашивать у оператора new по умолчанию большие блоки неструктурированной памяти, имею- имеющие размеры, достаточные для значительного количества объектов Airplane. Пор- Порции памяти для самих объектов Airplane мы будем брать из этих больших блоков. Не используемые в текущий момент порции будут организовываться в связный список свободных блоков, доступных для Airplane. Может показаться, что вам нужно будет выделять дополнительную память для указателей next каждого объ- объекта (для поддержки списка), но это не так. Память для поля rep, необходимого только для блоков памяти, используемых в качестве объектов 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;
Управление памятью Здесь мы добавили объявление оператора new, объединение, которое позво- позволяет полям rep и next занимать одну и ту же память, константу, указывающую размер каждого выделяемого блока, и статический указатель, который должен от- отслеживать заголовок списка свободных блоков. Важно использовать именно ста- статический член класса, так как существует общий список свободных блоков для всего класса, а не для каждого объекта Airplane. Далее следует написать оператор new: void * Airplane::operator new(size_l size) { // Перенаправляем запросы "неправильного" размера // стандартному ::operator new (подробнее см. празило 8). if (size != sizeof(Airplane)) return ::operator new(size); Airplane *p = headOfFreeList; // Теперь р указывает на заголовок списка свободных блоков. // Если р ккеет допустимое значение, передвигаем // заголовок списка свободных блоков на следующий элемент, if (P) 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) newBlockfi].next = &newBlock[i+l]; // Завершаем связный список нулевым указателем. newBlock[BLOCK_SIZE-l].next = 0; // Устанавливаем р на начало списка, //a headOf FreeList - на следующий элемент. р = 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.
Правило 10 Если у вас есть в наличии такой operator new, единственное, что нам остает- :я сделать, - определить статические члены класса: Airplane *ALrplane: iheadOfFreel.isL; // Эти огредо::он;:я находятся в фа;":дг- const ir.t Airplane: :3LCCX_S:ZS = 512;// pea;:;i3a:;;:;:, с) но заголовка. Нет необходимости явно устанавливать heaciOtl'reebist. в ноль, поскольку то умолчанию статические члены инициализируются нулем. Величина BI.OCK_G I '/.'¦/. )пределяет размер каждого блока памяти, получаемого от : : operator new. Эта версия функции operator new - именно то, что требуется. Она не толь- только использует для объектов Airplane намного меньше памяти, чем оператор riev: ю умолчанию, но, надо полагать, станет исполняться быстрее - возможно, даже ia два порядка быстрее. Впрочем, это и неудивительно. В итоге общая версия operator new должна уметь работать с запросами различных размеров, уделять зиимание внутренней и внешней фрагментации и т.д., в то время как наша вер- :ия operator new всего лишь манипулирует парой указателей в связном списке. Когда не нужно заботиться о гибкости, достичь быстродействия нетрудно. Теперь, наконец, мы готовы к обсуждению оператора delete. Вы о нем еще помни- ге? Это правило посвящено именно ему. Пока что мы определили в классе Ai rplane только operator new, но не определили operator delete. Теперь посмотрим, что юлучится, если написать следующий, вполне естественный код: Airplane *ра = new Airplane; // Вызывает Ai rp". a no :: operator now. delete pa; // Вызывает .— operator delete. При прочтении этого кода вы сможете услышать грохот разбивающегося л горящего самолета и вопли программистов, имевших к этому отношение. Про- Проблема заключается в том, что operator new (написанный для Airplane) воз- возвращает указатель на память без какой-либо информации в заголовке, a operate r delete (глобальный, принятый по умолчанию) предполагает, что передаваемая :\му намять содержит заголовочную информацию. Этот пример иллюстрирует общее правило: операторы new и de" ete должны эыть согласованными и исходить из одинаковых предпосылок. Нел и вы собирае гесь разрабатывать свою собственную функцию выделения памяти, не забывайте и о функции высвобождения памяти. Вот как можно решить проблему для класса Airplane: class Airplane { // Тот же, что ;-. ьь::::е, по тегх-рь public: //с собъяв-конлем оператора dei. (..¦•..';. scat Lc void operator delete (volt: *doadObjocc, :;:;:c t. :;.:ze); }; // Оператору delete передается блок ::жяти, которь::*, cry.:: ::-¦:::¦: ¦¦.;::' II ::о размеру, добавляется к началу с::;:ска свобод:--:,.::-: блоков -:амят::. void Airplane: :operator delcte(void *c:eadOo;;ec;., s:zfi_: s:zo! { if (deadObject •••- 0) return; // Ск. ::разг:о - . i? (size != sizeoi (Airplane) ) { // См. ::\:.\\::::v> '¦'. : : operator delete (deadObjec:!:) ; ret. urn;
Управление памятью Airplane *carcass = static_cast<Airplane*>(deadObject); carcass->r.oxt ;: headOf l-'reeList; heacCf KreoljisU = carcass; } Проявив осмотрительность при отправке запросов «неправильного» размера глобальному оператору new в нашем операторе now (см. правило 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 *pa = new Airplane; // Первое размещение: выделить большой // блок, организовать список и т.д. delete pa; // Теперь блок пуст, освобождаем его. pa = new Airplane;// Снова выделяем блок, создаем список и т.д. delete pa; // Ну вот, блок снова пуст, поэтому освобождаем его. ... // Надеюсь, вы понимаете. . . return 0; } Эта «противная» программка будет работать медленнее и использовать боль- больше памяти, чем даже с применением операторов new и delete no умолчанию, не говоря уже о версии этих функций, использующих пул! Конечно, существуют способы бороться с такими отклонениями, но чем больше кода вы напишете для различных специальных случаев, тем ближе подойдете к ис- исходным функциям управления памятью, принятым по умолчанию. Пул памяти не решает всех вопросов управления, но во многих случаях это вполне разумный выбор. Поскольку использование пулов памяти - часто самое рациональное реше- решение, у вас может возникнуть потребность облегчить его реализацию для различ- различных классов. «Несомненно, - скажете вы, - должен существовать способ, облег- облегчающий использование стратегии выделения памяти фиксированного размера в различных классах». Он в самом деле существует, но это правило уже и так отня- отняло у нас много драгоценного времени, поэтому выяснение деталей оставлю чита- читателю в качестве занимательного упражнения. Вместо этого я просто покажу минимальный интерфейс (см. правило 18) клас- класса Pool, где каждый объект типа Pool предназначен для выделения памяти объек- объектам фиксированного размера, который задан в конструкторе класса Pool: class Pool { public:
Управление памятью Pool(size_^ n) ; // Создайте распределитель памяти // д;:я объектов размером п байт, void * alloc(sizc_t n);// Разместите память дня одного объекта, следуя // правилам для оператора new кз правила 8. void free (void *p, size_t г.) ; // Верните память, на которую ссылается р, //в пул, следуя правилам для оператора // delete кз правила 8. -Pool() ; // Освободите всю память в пуле. }; Данный класс позволяет создавать и удалять объекты Pool и выполнять опе- операции выделения и высвобождения памяти. Когда объект Pool удаляется, он вы- высвобождает выделенную память. Следовательно, у вас есть способ избежать си- ситуации, похожей на утечку памяти, что характерно для Airplane. Однако это также означает, что если деструктор будет вызван слишком быстро (до того как все объекты, использующие его память, будут разрушены), то вы удалите память, вы- выделенную некоторым объектам, прежде, чем они закончат ее использовать. В та- таком случае сказать, что поведение программы не определено, - значит слишком смягчить реальную ситуацию. С помощью класса Pool даже программист на Java сможет добавить функ- функции управления памятью в класс Airplane: class Airplane { public: ...II Обычные функции класса Airplane. static void * operator new(size_t size); static void operator delete(void *p, size_t size); private: AirplaneRep *rep; // Указатель на реализацию. static Pool men?ool; // Пул памяти для объектов класса. }; 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: :T.emPool (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 *vnlue); -String!); ... // Без конструктора копирования v. оператора приезаи^анхя. private: char *data; }; String::String(const char *vaiue) ( if (value) { data = new char [strlcn (value) ¦- I i ; strcpy(data, value); } else { data = new char[l] ; ¦data - '\0'; inline String::-String!) { delete [j data; } Обратите внимание на то, что данный класс не объявляет ни оператор присваи- присваивания, ни конструктор копирования. Как вы увидите, это влечет за собой некоторые нежелательные последствия.
Конструкторы и операторы Нел и ны дадите следующее определение объектов: String a("Hcl.о") ; 5г.ring b( "World") ; то получите вот что: Внутри объекта а располагается указатель на память, содержащую символь- символьную строку "Hello". Отдельно расположен объект b с указателем на символь- символьную строку "World". Если теперь вы выполните присваивание: а; то, поскольку вы не определили operator=, C++ сгенерирует и вызовет вместо него оператор присваивания, определенный по умолчанию (см. правило 45). Этот глобальный оператор присваивания производит почленное присваивание членов класса из а в Ь, что для указателей (а. data и b. data) означает простое побито- побитовое копирование. Результат показан ниже: IWloIr 11 ldl\OI При таком положении дел существует по меньшей мере две проблемы. Во- первых, память, на которую указывал объект Ь, никогда не будет удалена; она те- теряется навсегда. Это классический пример, демонстрирующий, как возникают утечки памяти. Во-вторых, и а, и b теперь содержат указатели на одну и ту же сим- символьную строку. Когда один из этих объектов удаляется, его деструктор удаляет намять, на которую по-прежнему указывает другой. Например: String а("Hello") ; t II Определяем и создаем а. String b("World"; b = а; // Открываем область видимости, // определяем и создаем Ь. // Вызываем operator =, теряем память объекта Ь. } // Закрываем область вкдкмости, вызываем деструктор Ь. Я1.ring с - а; // с.data ке определен! a.data уже удален.
Правило 11 Последняя строчка этого примера - вызов конструктора копирования, кото- который также не определен внутри класса, а следовательно, генерируется C++ ана- аналогично оператору присваивания (см. правило 45) и будет обладать сходными свойствами: побитово копировать указатели. Это порождает все те же проблемы нетлишь необходимости беспокоиться об утечках памяти, инициализируемый объ- объект еще не указывает ни на какую память. Например, в случае с кодом, приведен- приведенным выше, когда с . data инициализируется значением а . data, никаких утечек нет, поскольку с. data пока ни на что не указывает. Однако после того, как с ини- инициализируется а, и с . data, и а . data указывают на одну и ту же память, поэтому она будет удалена дважды: один раз при удалении с, другой - при удалении а. Случай с копирующим конструктором не слишком сильно, но все-таки отли- отличается от случая с оператором присваивания. Разница в том, каким образом он мо- может создать вам проблемы: передачей по значению. Конечно, в правиле 22 объ- объяснено, что вы лишь в редких случаях должны использовать передачу объектов по значению; тем не менее рассмотрите следующий пример: void doNothing(String iocalString) {} String s = "The Truth Is Out There"; doNothing(s); Все выглядит достаточно безобидно, но, поскольку IocalString передается по значению, она должна быть инициализирована из s посредством копирующе- копирующего конструктора (по умолчанию). Следовательно, IocalString содержит копию указателя, находящегося внутри s. Когда doNothing заканчивает выполнение, IocalString оказывается за пределами видимости и для нес вызывается де- деструктор. Конечный результат вам знаком: s содержит указатель на намять, кото- которая уже была удалена IocalString. Между прочим, результат использования delete для удаленного указателя не определен, поэтому даже если s больше не используется, неприятности могут возникнуть, когда s выйдет за пределы видимости. Решение подобных проблем совмещения указателей заключается в написании для классов, внутри которых имеются какие-либо указатели, своих собственных вер- версий конструкторов копирования и операторов присваивания. Внутри этих функций вы можете либо копировать члены классов, либо реализовывать некоторую схему подсчета, отслеживающую, сколько объектов в настоящее время указывают на кон- конкретную структуру данных. Подход с подсчетом указателей намного более сложен; он также требует дополнительной работы внутри конструкторов и деструкторов, но в некоторых (далеко не во всех) приложениях может дать значительный выигрыш памяти и значительно увеличить скорость. В некоторых случаях создание конструкторов и операторов присваивания ско- скорее вызывает дополнительные проблемы, чем облетает ваши задачи, особенно если у вас есть основания предполагать, что пользователь не будет делать копии или при- применять операцию присваивания. Вышеприведенные примеры показывают, что пре- пренебрежение соответствующими функциями-членами свидетельствует о плохом качестве программирования, но что вам делать, если их написание также нерацио- нерационально? Все просто: следуйте совету этого правила. Объявите функции private,
Ш Конструкторы и операторы но не определяйте их, то есть не создавайте реализацию. Это не даст клиенту воз- возможности вызывать их и одновременно предотвращает их генерирование компи- компилятором. Подробное описание этого остроумного приема приводится в правиле 27. II еще одно замечание относительно класса string, упомянутого в данном правиле. В теле конструктора предусмотрительно использованы скобки [] для обоих вызовов new, хотя в одном месте я хотел создать только один объект. Как было описано в правиле 5, применяя new и delete, важно использовать одну и ту же форму. Поэтому, делая ставку на new, я проявляю последовательность, о чем и вам не следует забивать. Всегда используйте [] с delete, если вы применяли i ; при использовании new! Правило 12. Предпочитайте инициализацию присваиванию в конструкторах Рассмотрим применение шаблонов для генерирования классов, позволяющих ассоциировать идентификатор name с указателем на объект некоторого типа Т: cliiSS Named l/tr ' pub:ic: XanedPtr (cor.se strings ir.itNane, T *initPtr); private: strine name; 7 *ptr; (В свете проблем, которые могут возникать при присваивании и создании пу- путем копирования объектов с указателями - см. правило 11 - возникает вопрос, нужно ли ре;1лизовывать эти функции. Подсказка: да, нужно - см. правило 27.) При написании конструктора для NamedPtr возникает необходимость пере- передавать значения параметров соответствующим членам класса. Существует два способа сделать это. Во-первых, используется список инициализируемых членов: :.o:-pinte<clasG T> Na:r.edl>t.r<T>: :Xarr.edPtr (const string^ initNamc, T *initptr) : r.ax.e (initNair.e) , ptr (initi'tr) Во-вторых, присваивания можно сделать в теле конструктора: terr.p: at.e<class 7> >:ar;c:cl:tr<?>: :Xa~(".c;?tr (const strings initXax.c, T *initPtr) nar.o = iiiitNarr.e; ptr = "! r.i;. ?:r; } Между этими двумя подходами существует важное отличие. Если придерживаться чисто практической точки зрения, следует отметить, что встречаются случаи, когда необходимо использовать список инициализации. Так,
Правило 12 члены const и ссылки можно только инициализировать, а не присваивать. Поэтому, если вы решили, что объект NamedPtr<T> не может изменить свой идентификатор или указатель, вы должны следовать совету в правиле 21 и объявлять члены с const: template<class T> class NamedPtr { public: NancdPtr(const strings ir.itNax.e, T *initPrr) ; private: const string name; T * const ptr; }; Это определение класса требует, чтобы вы использовали список инициализа- инициализации членов, поскольку члены const не могут быть инициализированы путем при- присваивания. Если же вы решите, что объект Named?tr<T> должен содержать ссылку на существующий идентификатор, то получите совершенно иную картину. И все рав- равно в этом случае вам следует инициализировать ссылку в списке инициализации вашего конструктора. Конечно, вы можете скомбинировать оба подхода, получая объекты NamedPtr<T> с доступом только для чтения к идентификаторам, кото- которые могут быть модифицированы вне класса: terr.plate<class T> class NamedPtr { public: NamedPtr(const strings initName, T *initPtr); private: const strings name; // Требует инициализации з списке инициализации. Т * const ptr; // Требует инициализации в списке инициализации. }; Исходный шаблон класса не содержит, однако, членов const или ссылок. По даже и в этом случае использование списка инициализации более предпочти- предпочтительно, чем выполнение присваивания внутри конструктора. На этот раз причина кроется в большей эффективности первого подхода. Когда используется список инициализации, вызывается только одна функция-член класса string. При при- присваивании внутри конструктора вызываются две функции. Для того чтобы понять почему, рассмотрим, что происходит, когда вы объявляете объект NamedPtr<T>. Создание объектов подразумевает два этапа: 1. Инициализация членов класса (см. также правило 13). 2. Выполнение тела вызываемого конструктора. (Для объектов базовых классов инициализация и выполнение кода внутри конструктора происходит до инициализации производных классов.) В отношении классов NamePtr это означает, что конструктор для идентифи- идентификатора типа string всегда вызывается прежде, чем вы войдете в тело конструктора Nair.ePtr. Встает единственный вопрос: какой конструктор string будет вызван?
Конструкторы и операторы Это зависит от списка инициализации класса NamePtr. Если для name вы не укажете инициализирующий аргумент, для string будет вызван конструктор по умолчанию. Когда позднее для name будете выполняться присваивание внутри конструкторов NamePtr, будет вызван operator= . Это приведет к тому, что про- произойдут два вызова функций-членов string: один раз - конструктора но умолча- умолчанию, а другой раз - оператора присваивания. С другой стороны, если вы используете список инициализации членов, чтобы указать, что name следует инициализировать значением initName, то name бу- будет инициализировано только с использованием копирующего конструктора, по- посредством одного-единственного функционального вызова. Даже в случае простого типа string стоимость лишнего вызова функции мо- может быть значительной, и по мере того как классы становятся больше и сложнее, усложняются их конструкторы и увеличивается стоимость их вызова. Если вы возьмете за привычку везде, где это возможно, использовать список инициализа- инициализации членов, не ограничиваясь только членами с const и ссылками, для которых это является обязательным, вы также уменьшите шансы неэффективной инициа- инициализации членов класса. Другими словами, инициализация посредством списка инициализации всегда корректна и не менее, а часто более эффективна, чем присваивание внутри тела конструктора. Кроме того, она упрощает дальнейшую поддержку класса, посколь- поскольку, если тин члена класса позднее изменится так, что потребуется использование списка инициализации данных, вам ничего не придется менять. Тем не менее может возникнуть ситуация, когда будет целесообразно исполь- использовать присваивание, а не инициализацию членов класса. Это случай, когда у вас имеется большое количество членов класса встроенных типов, и вы хотите, чтобы в каждом конструкторе все они инициализировались одинаковым образом. Вот пример класса, который можно отнести к данной категории: class ManyDataMbrs { public: // Конструктор по умолчанию. ManyDataMbrs(); / / Конструктор копирования. ManyDataMbrs(const ManyDataMbrs& x) ; private: int a, b, c, d, e, f, g, h; double i, j , k, 1, m; }; Предположим, что вы хотите инициализировать все переменные типа int зна- значением 1, а все double — 0, даже если используется конструктор копирования. Ис- Используя список инициализации членов, вы должны были бы написать следующее: ManyDataMbrs::ManyDataMbrs() : аA), ЬA), сA), d(l), еA), f A) , j@) , k@), 1@) , m@)
Правило 12 ManyDataKbrs::Mar.yDataMbrs(const ManyDataMbrsk x) : a A) , b A) , с A) , d A) , e A) , f A) , g A) , h A) , i @) , j@) , к@) , 1@), m@) { ... } Это не просто неприятная и нудная работа. В краткосрочном плане такой код увеличивает вероятность ошибки, а в долгосрочном - затрудняет поддержку. Вы, однако, можете воспользоваться тем фактом, что между инициализацией и присваиванием объектов встроенного типа (если они не константные и не ссыл- ссылки) нет никакого функционального отличия, поэтому вполне допустимо заме- заменить инициализационный список вызовом общей инициализирующей функции: class ManyDataMbrs { public: // Конструктор по умолчанию. ManyDataMbrs(); // Конструктор копирования. ManyDataMbrs (const Mar.yDataMbrsk x) ; private: int a, b, c, d, e, f, g, h; double i, j, k, 1, m; void initO; // Используется для инициализация членов-лакнкх. }; 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) { initO ; Поскольку инициализирующая функция - это внутреннее дело реализации класса, не забудьте сделать эту функцию private. Обратите внимание на то, что статические члены класса никогда не должны инициализироваться в конструкторе. Они инициализируются только однажды в ходе выполнения программы, поэтому нет смысла пытаться инициализировать их каждый раз при создании объекта данного типа. В самом лучшем случае это будет неэффективным: зачем несколько раз платить за инициализацию объекта? Кроме того, инициализация статических членов класса отличается от инициали- инициализации их нсстатических эквивалентов, и этой теме посвящено правило 47.
i Конструкторы и операторы Правило 13. Перечисляйте члены в списке инициализации в порядке их объявления Программисты на Pascal и Ada часто страдают от невозможности объявлять массивы с произвольными границами, то есть от 10 до 20 (вместо 0...10). Тот, кто давно имеет дело с С, считает, что любой уважающий себя программист всегда на- начинает отсчет с 0. Умиротворить приверженцев begin/end совсем не сложно. Все, что для этого необходимо, - определить свой шаблон класса массива: t o:r.p" atc<class T> clasa Array { r;ubl ic: Array (ir.t lowBound, int highBound) ; private: vecter<T> da с a,- // Данные массива хранятся в векторе; /7 о шаблоне vector см. празило 49. г.izc. z size; // Число элементов массива, ir.t iBound, hBound; // Нижняя и верхняя границы. terr.plate<class 'Г> Array<T>: : Array (int lowBound, ir.t highBound) : size (highBound - lowBound + 1), IBound(lowBound), hBound(highBound), data(size) {} Любой конструктор программного продукта промышленного уровня выпол- выполнял бы проверку допустимости параметров, чтобы убедиться, что highBound по крайней мере не меньше lowBound, но здесь кроется и гораздо более неприятная ошибка: даже при идеальных граничных значениях никак нельзя сказать, сколько элементов содержит data. «Как это может быть? - слышу я. ¦ Мы аккуратно инициализировали size, прежде чем передавать его конструктору vector!» К сожалению, вы заблуждаетесь: вы лишь пытались это сделать. Здесь действует следующее правило: члены списка инициали- инициализации класса инициализируются в том же порядке, в котором они объявляются в классе; порядок их следования в списке инициализации не имеет ни малейшего значения. В классах, генерируемых из нашего шаблона Array, вначале будет инициализиро- инициализирован data, затем s i zc, I Bound, и, наконец, hBound. Так будет происходить всегда. Хотя это может показаться дикостью, для упомянутого правила есть причина. Рассмотрите следующий сценарий: class Wacko { publ i с: Wacko(const char *s): sl(s), s2@) {} Wacko(const Wacko& rhs) : s2(rhs.sl), si@) {} private: string si, s2; Wacko wl - "Hello world! "; Wacko w2 = wl;
Правило 13 Если бы члены класса инициализировались в порядке их следования в списке инициализации, порядок создания элементов данных wl и w2 был бы различным. Вспомните, что деструкторы элементов данных объекта всегда вызываются в поряд- порядке, обратном порядку вызова их конструкторов. Таким образом, если бы порядок инициализации определялся порядком следования в списке инициализации, для того чтобы гарантировать, что деструкторы будут вызваны в правильной последова- последовательности, компилятору необходимо было бы отслеживать порядок инициализа- инициализации каждого объекта. Занятие, согласитесь, весьма дорогостоящее... Дабы избежать лишних затрат ресурсов, порядок создания и уничтожения для объектов данного типа всегда один и тот же, а порядок в списке инициализации игнорируется. Если уж вдаваться в детали, следует оговориться, что согласно этому правилу инициализируются только нсстатические члены класса. Статические члены класса подобны глобальным объектам и объектам, определенным в пространстве имен, и поэтому инициализируются только один раз; подробнее см. правило 47. Более того, члены базовых классов инициализируются прежде членов производных классов, по- поэтому если вы используете наследование, то должны инициализировать члены базо- базовых классов в самом начале списка. (Если используется множественное наследование, созданные вами базовые классы будут инициализированы в порядке наследования; порядок, в котором они перечисляются в списке инициализации, снова окажется про- проигнорирован. Однако при использовании множественного наследования у вас по- появятся более серьезные причины для беспокойства. Правило 43 подскажет вам, ка- какие аспекты множественного наследования заслуживают пристального внимания.) Подытожим все вышесказанное: если вы действительно хотите понимать, что происходит при инициализации объектов, не забывайте вносить их в список ини- инициализации в том порядке, в котором они объявляются в классе. Правило 14. Убедитесь, что базовые классы имеют виртуальные деструкторы Иногда бывает удобно отслеживать, сколько всего объектов данного класса су- существует в вашей программе. Наиболее простой способ достичь этого - создать ста- статический член класса для подсчета объектов. Этот член инициализируется нулем, инкрементируется в конструкторах класса и декрементируется в деструкторах. Вы можете представить себе некоторое военное приложение, в котором класс, представляющий собой вражеские цели, мог бы выглядеть приблизительно следу- следующим образом: class EnemyTarget { public: EnemyTarget () { ++nurr.Targets; } EnemyTarget(const EnemyTargets) { ++numTargets; } -EnemyTarget() { --nuraTargets; } static size_t r.umberOfTargets () { return numTargets; } virtual bool destroy!); // Удачна ли была попытка уничтожить // объект EnemyTarget?
Конструкторы и операторы private: static size_t numTargets; // Счетчик объектов. }; // Статистические данные должны быть определены вне класса; // переменная инициализируется 0 по умолчанию. size_t EnemyTarget::numTargets; Этот класс вряд ли поможет вам подписать контракт с министерством оборо- обороны, но он вполне подойдет для наших целей. По крайней мере, я на это надеюсь. Давайте предположим, что один из типов вражеских целей - это танк, кото- который вполне логично моделировать (см. правило 35) классом, открыто наследую- наследующим от EnemyTarget. Поскольку наряду с суммарным количеством вражеских целей для вас представляет интерес суммарное количество танков, вы проделыва- проделываете для него то же самое, что и для базового класса: class EnemyTank: public EnemyTarget { public: EnemyTank() { ++numTanks; } EnemyTank(const EnemyTankk 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), резуль- результат не определен. Это означает, что компилятор может создать код, который спо- способен делать что угодно: форматировать диск, посылать вашему начальнику непотребные письма, отправлять факсы с кодом ваших программ конкурирующим фирмам и т.п. Чаще всего при выполнении программы нигде не вызывается де- деструктор производного класса. В нашем примере это говорит о том, что счетчик
Правило 14 в EnemyTank не будет уменьшаться при удалении 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-би- 32-битовом регистре. Более того, объект Point может быть передан функции, написан- написанной на другом языке, например С или Fortran, как 32-битовое значение. Если же деструктор класса Point объявлен виртуальным, ситуация изменяется. Реализация виртуальных функций требует, чтобы объект содержал дополни- дополнительную информацию, которую можно было бы использовать в момент выполнения программы, дабы определить, чья виртуальная функция должна быть вызвана для объекта. В большинстве компиляторов эта дополнительная информация вводится в форме указателя, называемого vpt r (указатель на виртуальную таблицу), vptr ука- указывает на массив указателей функций, именуемый vtbl (виртуальная таблица); каж- каждый класс, содержащий виртуальные функции, имеет связанную с ним таблицу vtbl. Подробности, касающиеся того, как реализуются виртуальные функции, не важны. По-настоящему важно то, что, если класс Point содержит виртуальную функцию, объекты этого типа автоматически увеличат свой размер от 16-битового short до двух 16-битовых short плюс 32-битовый указатель vptr. Объекты Point не будут более помещаться в 32-битовый регистр. Более того, объекты Point в C++ уже не выглядят так, как аналогичные структуры в других языках, подобных С, поскольку структуры из других языков не содержат vptr. В результа- результате отсутствует возможность передавать Point в функции и из функций, написан- написанных на других языках, если вы явным образом не учтете vptr, который является элементом реализации, и, следовательно, программа будет плохо переносима.
Конструкторы и операторы В итоге можно сделать вывод, что объявление всех деструкторов виртуальны- виртуальными так же неправильно, как и полный отказ от объявления виртуальных деструк- деструкторов. Можно сформулировать это таким образом: объявляйте виртуальный де- деструктор для класса тогда и только тогда, когда этот класс содержит по крайней мере одну виртуальную функцию. Это хорошее правило - одно из тех, которые верны для большинства случаев, но, к сожалению, проблема отсутствия виртуальных деструкторов может возникать даже при отсутствии виртуальных функций. Например, в правиле 13 содержится шаблон класса для реализации массивов с задаваемыми пользователем пределами. Предположим, что вы решили написать шаблон для получения производных клас- классов, представляющих именованные массивы, то есть таких классов, где каждый массив имеет название: template<class T> // Шаблон базового класса (из правила 13). class Array { public: Array(int iowlicund, in- hiqhBound) ; -Array(); private: vector<T> data; size_t si ze; int lBound, hBound; }; template<class T> class NanedArray: public Array<T> { public: NanedArray(int lowBounc, int high-Bound, const strings name); private- string arrayNamc; }; Если где-либо в приложении вы преобразуете указатель на NamedArray в ука- указатель на Array и затем используете delete для указателя на Array, то мгновен- мгновенно окажетесь в области неопределенного поведения: NarriedArray<int> *pna = new XanedArray<int>A0, 20, "Impending Doom"); Array<int> *pa; pa = pna; // XamedArray<int> -> Array<int> delete pa; // He определено! Скорее всего, pa->ArrayName //не буг,ет уничтожена, что вызозет утечку памяти. С таким положением дел приходится сталкиваться чаще, чем вы можете себе представить, поскольку ситуация, когда берут класс, выполняющий что-либо, в дан- данном случае Array, и получают из него производный класс, делающий еще больше,
Правило 14 не столь уж редка. NamedArray не переопределяет поведение класса Array - он наследует его функции без изменений и просто добавляет некоторые дополнитель- дополнительные позможиости. Тем не менее проблема невиртуального деструктора остается в силе. И наконец, следует отметить, что в некоторых классах бывает удобно объяв- объявлять чисто виртуальные деструкторы. Вспомните, что чисто виртуальные функции дают абстрактные классы, которые не могут быть инстанцированы (то есть вы не можете создавать объекты этого типа). Иногда, однако, встречаются классы, кото- которые бы хотелось сделать абстрактными, но для этого в вашем распоряжении не оказывается чисто виртуальных функций. Что делать в этом случае? Поскольку абстрактный класс предназначается для использования в качестве базового класса, который должен содержать виртуальный деструктор, а кроме того, чисто виртуаль- виртуальная функция дает абстрактный класс, решение просто: объявите в классе, который должен быть абстрактным, чисто виртуальный деструктор. Ниже приведен пример: class AWOV { // Абстрактный класс без виртуальных функций, public: virtual -AWOV() = 0; // Объявляем чисто виртуальный деструктор. }; Этот класс включает в себя чисто виртуальную функцию, поэтому он абстрак- абстрактный. Он содержит виртуальный деструктор, а стало быть, вы можете жить спо- спокойно, зная, что проблема с деструктором вам не грозит. Однако вы должны дать определение чисто виртуального деструктора: AWOV::-AWOV() {} // Определение чисто виртуального деструктора. Оно необходимо, поскольку виртуальный деструктор работает таким образом, что вначале вызывается деструктор самого верхнего производного класса, а за- затем - деструкторы каждого базового класса. Это означает, что компилятор будет генерировать вызов -AWOW, даже когда класс является абстрактным, поэтому тело функции надо определять обязательно. Если этого не сделать, компоновщик вы- выдаст ошибку отсутствия символа, и вам придется вернуться и дать определение. В этой функции можно делать что угодно, но, как и в примере выше, зачастую в ней не делается ничего. Разумеется, у вас возникнет искушение избежать на- накладных расходов вызова пустой функции посредством объявления встраиваемо- встраиваемого деструктора. Это вполне разумная стратегия, но здесь есть один момент, кото- который нужно принимать во внимание. Поскольку деструктор виртуален, его адрес содержится в таблице vtbl. Но встраиваемые функции не предназначены для автономного существования (что, собственно, и подразумевает inline), поэтому для получения адреса требу- требуется принять специальные меры. В правиле 33 можно найти более подробное объ- объяснение, но общий вывод таков: если вы объявляете виртуальный деструктор как inline, то, возможно, избежите накладных расходов при вызове функции, одна- однако компилятор все равно должен будет где-то генерировать отдельную не встраи- встраиваемую функцию. 3'
Конструкторы и операторы Правило 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.operator=, x.operator= и у .operator= является значе- значение, возвращаемое предыдущим вызовом функции operators В результате тип, возвращаемый функцией operators должен быть приемлемым в качестве аргу- аргумента этой же функции. В случае версии но умолчанию operator= для класса С общий вид функции выглядит следующим образом (см. правило 45): С& С::operator=(const C&); Как правило, соглашение о том, что operator= принимает и возвращает ссылку на объект класса, выполняется, хотя иногда можно перегрузить operator= так, что- чтобы он принимал аргументы различных типов. Например, стандартный тип string поддерживает две различных версии оператора присваивания: strings operator=(const strings rhs); // Присзоить string объекту типа string. strings operator=(const char *rhs); // Присвоить char * объекту типа string.
Правило 15 Заметьте, однако, что даже при перегрузке возвращаемый тип является ссыл- ссылкой на объект класса. Типичная среди начинающих программистов C++ ошибка - возвращать для operator= тип void. Данное решение будет казаться разумным до тех пор, пока вы не осознаете, что оно препятствует последовательным присваиваниям. Вот по- почему этого делать не следует. Другая распространенная ошибка - возвращать ссылку на объект const, как показано ниже: class Widget { public: ... // Заметьте: возвращается значение с const. const Widget& operator=(const Widget& 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; // Возвращаем ссылку на объект слева.
Конструкторы и операторы Strings string::operator=(const Stringb rhs) return rhs; // Возвращаем ссылку на объект справа. Может показаться, что указанные возможности практически идентичны, од- однако между ними имеется существенное различие. Во-первых, версия, возвращающая rsh, не будет компилироваться: данное обстоятельство объясняется тем, что rsh ~ это ссылка на const String, а опе- оператор = возвращает ссылку на String. Пока для объекта, объявленного с const, вы будете пытаться вернуть неконстантную ссылку, компилятор не даст вам покоя. Может показаться, что этого достаточно легко избежать, - просто нужно переопределить ooerator= следующим образом: Strings String: :operator= (Strir.gk rhs) {... } Увы, теперь не будет компилироваться код пользователя! Давайте опять взглянем на последнюю часть цепочки равенств: х= "Hello"; //Токе, что и х.operator=("Hello") Поскольку аргумент справа от знака равенства имеет неправильный тип - это массив char, а не String, - для того чтобы вызов функции прошел успешно, ком- компилятору придется создавать временный объект String (посредством конструк- конструктора String). Иными словами, будет сгенерирован код, приблизительно эквива- эквивалентный следующему: const String temp("Kello") ; // Создание временного объекта, х = Lenp; // Передача его оператору =. Компилятор пытается создать такие временные объекты (если необходимый конструктор не объявлен как explicit, см. правило 19), однако обратите вни- внимание на то, что временньи) объект объявлен с const. Это важно, поскольку предотвращает случайную передачу временных объектов в функции, которые модифицируют свои аргументы. Если бы такая передача была возможна, про- программисты с удивлением обнаружили бы, что модифицируется только сгенери- сгенерированный компилятором временный объект, а не сам аргумент, переданный ими в функцию. (Мы знаем это точно, поскольку ранние версии C++ разрешали по- подобного рода генерацию, объекты передавались функции и модифицировались, а в результате только возрастало число недоумевающих программистов.) Теперь становится ясным, почему код пользователя не будет компилировать- компилироваться, если operator= для String объявляется с аргументом без модификатора const: неверно передавать объект с const функции, в которой соответствующий аргумент объявлен без const. Это элементарные правила корректности при ис- использовании const. Таким образом, вы оказываетесь в ситуации, когда нет иного выбора, как возвра- возвращать ссылку на левосторонний аргумент *this. Если вы пойдете иным путем, то разорвете цепочку знаков равенства, или создадите препятствие на пути неявных
Правило 16 правил преобразования типов при вызове функций, или сделаете и то и другое одновременно. Правило 16. В operator^ присваивайте значения всем элементам данных Как показано в правиле 45, если вы не объявите оператор присваивания, C++ сделает это самостоятельно, а в правиле 11 объясняется, почему в ряде случаев генерируемый оператор не удовлетворяет запросам программиста. Вам, навер- наверное, интересно, можно ли позволить C++ генерировать оператор присваивания по умолчанию и при этом выборочно переписывать те фрагменты, которые вы сочли неудачными. К сожалению, такой возможности у вас нет. Если желаете взять на себя управление любой частью процесса присваивания, вы должны сде- сделать все сами. На практике это означает, что при написании оператора (операторов) присва- присваивания вам необходимо присваивать каждый элемент созданного вами объекта: tomplate<class Т> // Паблон для классов, связывающих указатели и икона class NamedPtr { // (из правила 12). public: NamedPtr(const strings initName, T *initPtr); NamedPtrk operator=(const Named?tr& rhs) ; private: string name; T *ptr; }; template<class T> NamedPtr<T>& Named?tr<T>::operator=(const NamedPtr<T>& rhs) { if (this == &rhs) return *this; // См. правило 17. пахе = rhs.name; // Присваиваем имя. *ptr = *rhs.ptr; // Для указателя присваиваем указываемый объект, // а не значение самого указателя. return *this; // См. правило 15. } Этого правила легко придерживаться при первоначальном написании клас- класса, но не менее важно помнить о том, что обновлять операторы присваивания необходимо и при добавлении новых членов класса. Например, если вы реши- решите усовершенствовать шаблон NamedPtr так, чтобы он включал в себя маркер последнего времени изменения, вам придется добавить новые члены класса, что потребует обновления конструктора (конструкторов), а также операторов присваивания. В спешке и суете усовершенствования класса, добавления но- новых функций-членов и т. д. необходимость подобных изменений может с лег- легкостью ускользнуть от вашего внимания. По-настоящему «весело» становится, когда сюда подключается наследование, поскольку оператор (операторы) присваивания производных классов должны
Конструкторы и операторы обрабатывать операцию присваивания своих базовых классов. Рассмотрим сле- следующий пример: class Base { public: Base(int initialVaiue = 0): x(initialValue) {} private: int x; class Derived: public Base { public: Derived(int initialVaiue) : Base(initialVaiue) , у(initialVaiue) {} Derived& operator=(const Derivedk rhs); private: int y; Логичный способ написания оператора присваивания для Derived был бы представлен следующим образом: Derivedk Derived::operator=(const Derived& rhs) if (this == &rhs) return *this; // См. правило 17. у = rhs.у; // Присвоить член класса, определенный в Derived. return *this; // См. сразило 15. К сожалению, этот вариант некорректен, поскольку элемент данных х базово- базового класса Base объекта Derived при использовании этого оператора присваива- присваивания остается неизменным. Рассмотрим, например, следующий фрагмент кода: void assignmentTester() Derived dl@) ; // dl.x = 0, dl.у = 0 Derived d2(l); // d2.x = 1, d2.y = 1 Gl = d2; // dl.x = 0, dl.y = 1! i Заметьте, что Base-составляющая dl в ходе присваивания остается неизменной. Непосредственным способом решения проблемы было бы присваивание х в Derived: :operator=. К сожалению, оно невозможно, поскольку х является закрытым членом класса Base. Вместо этого необходимо внутри оператора при- присваивания Derived явным образом записать присваивание Base-составляющей данного класса. Это можно сделать следующим образом: // Правильный оператор еркезаизания. Derivedk Derived::operator=(const Derived& rhs) if (this == &rhs) return *this; Base: :operator= (rhs) ,- // Вызов this->Base: :operator= .
Правило 16 у = гha.у; return *this; } Здесь вы явно вызываете Base: :operators Этот вызов, подобно любому вызову функции-члена из другой функции-члена, использует в качестве неявного левостороннего объекта *this. В результате Base : : operator= сделает все не- необходимое с Base частью *this - в точности то, что нам и требуется. К сожалению, некоторые компиляторы ошибочно не допускают подобного рода вызовов операторов присваивания, если таковые генерируются компилято- компилятором (см. правило 45). Имея дело с такими компиляторами, Derived: : operator3 необходимо реализовать следующим образом: Derived& Derived::operator=(const DerivedS rhs) { if (this == &rhs) return *this; static_cast<3ase&>(*this) = rhs; // Вызов operator= для // Base-составляющей *this. у = rhs.у; return *this; } Этот «монстр» приводит *this к ссылке на Base, а затем для результата преобразования осуществляет присваивание. Таким образом, мы выполняем при- присваивание только для Base-части объекта Derive. Теперь внимание! Важно осу- осуществлять преобразование к ссылке на объект Base, а не к объекту Base как тако- таковому. Если произвести преобразование к объекту Base, это закончится вызовом конструктора копирования Base, а объектом присваивания будет вновь создавае- создаваемый вами объект; *this же останется неизменным, между тем как мы хотели до- добиться иного результата. Независимо от того, какой из этих подходов применяется, как только вы при- присвоили Base часть объекта Derive, вы переходите к оператору присваивания класса Derive, делая присваивания всем членам класса Derived. Подобные проблемы, связанные с наследованием, часто возникают и при реа- реализации конструкторов копирования класса. Рассмотрим следующий пример, ана- аналог предыдущего примера конструктора копирования: class Base { public: Base(int initialValue = 0): x(initiaiValue) {} Base(const Base& rhs): x(rhs.x) {} private: int x; }; class Derived: public 3ase { public: Derived(int initialValue) : Base(initialValue), у(initialValue) {} Derivedfconst Derived^ rhs) // Неправильный конструктор копирования. : у(rhs.у) {}
Конструкторы и операторы private: ir.t у; Класс 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 - другое обозначение для а (например, ссылка, инициализированная а) и присваивание самому себе, хотя внешне этого и не видно: один и тот же объект имеет два или более имени. Как будет показано в конце этого правила, совмещение имен может появиться в результате самых разных действий, поэтому при написа- написании функции всякий раз необходимо принимать во внимание такую возможность. Существуют веские причины проявлять особую осторожность относительно возможного совмещения имен в операторах присваивания. Наименее важная при- причина- эффективность. Если в самом начале оператора присваивания вы обна- обнаруживаете присваивание самому себе, то сразу можете выйти из него, возможно
Правило 17 предупреждая большую работу, которую в противном случае необходимо было бы проделать. Например, в правиле 16 указано, что правильный оператор присваива- присваивания в производном классе должен вызывать операторы присваивания для всех своих базовых классов, а эти классы могут сами быть производными классами, поэтому, проскочив код оператора присваивания и производном классе, мы можем избежать целого ряда функциональных вызовов. Более важная причина для проверки на присваивание самому себе - это про- проблема гарантии корректности. Помните, что оператор присваивания перед выде- выделением новых ресурсов должен высвобождать ресурсы, вылеченные объекту ра- ранее (то есть избавляться от своих старых значений). При присваивании самому себе высвобождение ресурсов может быть просто гибельным, поскольку старые ресурсы могут понадобиться в процессе создания новых. Рассмотрим присваивание объектов String, когда оператор присваивания не делает проверки на присваивание самому себе: class String { public: Stringfconst char *value); // Определение функции - си. правило 11. -String(); // Определение функции - см. правило 11. Strings operator=(const Strings rhs); private: char *data; }; // Оператор присваивания, не проверяющий присваивание объекта самому себе. Strings String::operator=(const Strings rhs) { delete [] data; // Удаляем старую память. // Выделяем новую память и копирум в нее значение rhs data = new char[strlen(rhs.data) + 1]; strcpyfdata, rhs.data); return *th±s; // См. празило 15. } Рассмотрим, что получится в этом случае: String a = "Hello"; а = а; // То же, что и a .operator= (а) . Внутри оператора присваивания *this и rhs на первый взгляд кажутся раз- разными объектами, но в данном случае они являются различными идентификатора- идентификаторами одного и того же объекта. Вы можете представить себе это следующим образом:
Конструкторы и операторы 11срвое, что делает оператор присваивания, - вызывает delete для указателя datadelete, в результате чего мы приходим к следующему положению вещей: Теперь результат применения strlen к rhs .data в операторе присваивания не определен. Это объясняется тем, что при удалении data мы удалили rhs . data, поскольку data, this->data и rhs .data - один и тот же указатель! Далее не- неприятности только множатся. Решение проблемы - проверка на присваивание самому себе и немедленный выход в этом случае. К сожалению, о такой проверке гораздо ле["че говорить, чем ее сделать, ибо сразу возникает вопрос, какие объекты считать «одинаковыми». Проблема, с которой мы сталкиваемся, известная как тождественность объек- объектов, - популярная тема объектного программирования. В задачу автора не входит написание трактата о тождественности объектов; тем не менее есть смысл упомя- упомянуть два основных подхода к данному вопросу. Один подход заключается в том, чтобы считать два объекта одинаковыми (тождественными), если они имеют одинаковое значение. Например, два объекта String будут одинаковыми, если содержат идентичный набор символов: String a = "Hello"; String b = "World" ; String с = "Hello"; Здесь а и с имеют одно и то же значение, поэтому их можно считать тожде- тождественными, a b отлично от них обоих. Если в нашем классе String использовать это определение тождественности, оператор присваивания мог бы выглядеть сле- следующим образом: Strings String::operator=(const Strings rhs) { if (strenptdata, rhs.data) == 0) return *this; Равенство значений обычно определяет operator==, поэтому общая форма для оператора присваивания класса С, реализующего тождественность в смысле равенства значений объектов, будет следующей: С& С::operator=(const C& rhs) // Проверка ка присваивание себе. if (*this == rhs) // Предполагается, что opcrator== определен.
Правило 17 return *this; Обратите внимание, что эта функция сравнивает объекты (посредством operator= = ), а не указатели. При использовании тождественности по значе- значению не важно, занимают ли оба объекта одну и ту же память: единственное, что принимается во внимание, - это содержащиеся в ней значения. Другая возможность - определять тождественность объектов по их адре- адресам в памяти. При использовании этого определения равенства объектов два объекта тождественны тогда и только тогда, когда они имеют один адрес. Это определение более распространено в программах на C++, возможно потому, что его легко реализовать, и необходимые вычисления выполняются быстро. Эти два преимущества не всегда достижимы, если тождественность определя- определяется по значениям. При использовании эквивалентности адресов общий вид оператора присваивания имеет следующую форму: С& С::operator=(const C& rhs) // Проверка на присваивание себе, if (this == &rhs) return *this; Для большинства программ это вполне приемлемо. Если вам необходим более изощренный механизм определения эквивалент- эквивалентности объектов, вам придется реализовывать его самим. Наиболее часто исполь- используемый подход основан на использовании функции-члена, возвращающей неко- некоторый идентификатор объекта: class С { public: ObjectID identity() const; // См. также правило 36. В этом случае, имея два указателя на объекты а и Ь, мы считаем объекты, на ко- которые они указывают, тождественными только в том случае, если a->identity () = =b->identity (). При этом, разумеется, в вашу задачу входит написание функ- функции operator = = для ObjectID. Проблема совмещения имен и тождественности объектов не ограничивается оператором присваивания. Просто это функция, для которой возникновение дан- данной проблемы наиболее вероятно. При наличии ссылок и указателей любые два объекта совместимого типа могут в действительности ссылаться на один и тот же объект. Вот еще несколько ситуаций, в которых совмещение имен может создать существенные трудности: class Base { void rr.flCase& rb) ; // rb и *this могут совпадать.
Конструкторы и операторы }; void fl(Base& rbl,Base& rb2); // rbl и rb2 могут совпадать. class Derived: public Base { void .T.f2 (Basc& rb) ; // rb к *this могут совпадать. inc. f2(Derived& rd, 3ase& rb) ; // rd и rb могут совпадать. В этих примерах используются ссылки, но вполне могли бы подойти и ука- указатели. Как видите, совмещение может принимать множество форм, так что не стоит забывать о нем, надеясь, что вы никогда с ним не столкнетесь. Впрочем, может быть, лично вам это удастся, но большинству программистов - нет. Образно выра- выражаясь, это именно тот случай, когда предосторожность ценится на вес золота. Вся- Всякий раз, когда вы создаете функцию, в которой возможно совмещение имен, вам следует учитывать такую возможность при написании кода.
Глава 4. Классы и функции: проектирование и объявление Объявление в программе нового класса создает ионий тип: создание класса экви- эквивалентно созданию типа. Весьма вероятно, что у вас нет большого опыта создания типов, поскольку большинство языков не предоставляет нам возможности по- поупражняться в этом. В C++ создание типов имеет фундаментальное значение, и не только потому, что эта операция в принципе возможна, но и потому, что незави- независимо от вашего желания вы производите ее всякий раз при объявлении классов. Создание хороших классов - это сложная задача, поскольку создание типов требует усилий. Хороший тип имеет естественный синтаксис, интуитивную се- семантику и одну или несколько эффективных реализаций. В C++ плохо проду- продуманное определение класса означает, что вы не добьетесь таких характеристик. Даже производительность зависит как от определения, так и от объявления функ- функций-членов. Как в таком случае решить задачу проектирования эффективных классов? Прежде всего вы должны разобраться с ограничениями, которые возникают при создании практически любого класса: акак следует создавать и удалять объекты? От этого сильно зависит вид ваших конструкторов и деструкторов, равно как и ваших версий operator new, operator new [ ], operator delete и operator delete [ ], если вы их реализуете; ? отличается ли инициализация объекта от его присваивания? Ответ на этот вопрос определяет поведение конструкторов и операторов присваивания, а также различия между ними; Q что означает передача объекта нового типа по значению? Помните, именно конструктором копирования определяется, что означает передача по зна- значению; ? каковы ограничения на значения нового типа? Эти ограничения определяют тип контроля ошибок, который необходимо осуществлять внутри функций- членов, особенно конструкторов и операторов присваивания. Это также мо- может повлиять на генерируемые функциями исключения, и, при их исполь- использовании, на спецификацию исключений; ? каково место нового типа в иерархии классов? Нел и вы наследуете от суще- существующих классов, неизбежно возникают определенные ограничения; от них зависит, например, виртуальны ли наследуемые вами функции. Нел и вы хо- хотите, чтобы класс использовался как базовый, ваше решение повлияет на то, будут ли объявляемые функции виртуальными.
Классы и функции ? каковы допустимые преобразования типов? Если вы хотите разрешить неяв- неявное преобразование объектов типа А п объекты типа В, необходимо либо напи- написать функцию преобразования типов в классе А, либо конструктор в классе В, который не объявлен как explicit и может вызываться с одним аргумен- аргументом. Если вы хотите разрешить только явные преобразования типов, необхо- необходимо написать функции, выполняющие преобразования, избегая при этом операторов преобразования типов или не объявленных explicit конструк- конструкторов с одним аргументом; а какие операторы и функции имеют смысл для нового типа? От ответа на этот вопрос зависит, какие функции вы определите для интерфейса класса; ? какие стандартные операторы и функции явно следует сделать недоступ- недоступными? Их необходимо объявить закрытыми; а кто должен иметь доступ к членам создаваемого типа? Этот вопрос помо- поможет вам определить, какие члены должны быть открытыми, какие защищен- защищенными, а какие - закрытыми. Это также поможет вам установить, какие клас- классы и/или функции должны быть дружественными, а также есть ли смысл вкладывать один класс в другой; и насколько новый тип будет общим? Возможно, вы в действительности соз- создаете не новый тип, а определяете целое семейство типов. Если это действи- действительно так, вам нужно определить не новый класс, а шаблон класса. Ответить на перечисленные вопросы нелегко, и поэтому создание эффектив- эффективных классов — далеко не простое дело. Однако если взяться за него должным об- образом, определяемые пользователем классы C++ дают типы, которые почти не- неотличимы от встроенных, и это оправдывает все затраченные усилия. Обсуждению деталей каждого из приведенных вопросов можно было бы по- посвятить отдельную книгу, поэтому нижеизложенные руководящие принципы не претендуют на всесторонность. Тем не менее они высвечивают наиболее важные вопросы проектирования, предостерегают против самых распространенных оши- ошибок и предлагают решение проблем, часто встречающихся при проектировании классов. Многие из советов также применимы и для функций, не являющихся чле- членами классов, так что в этом разделе помимо всего прочего рассматривается и про- проектирование, и объявления глобальных функций, и объявления функций в про- пространствах имен. Правило 18. Стремитесь к таким интерфейсам классов, которые будут полными и минимальными Пользовательский интерфейс класса - это интерфейс, доступный программи- программистам, использующим данный класс. Обычно в таком интерфейсе присутствуют только функции, поскольку наличие в нем элементов данных имеет целый ряд не- недостатков (см. правило 20). Попытки понять, какие функции должны присутствовать в пользовательском интерфейсе, Moiyr просто свести с ума. Вы разрываетесь между двумя совершенно
Правило 18 противоположными направлениями. С одной стороны, вам бы хотелось создать классы, которые будут просты для понимания, использования и реализации. Обычно это означает, что требуется достаточно небольшое количество функций, каждая из которых будет выполнять весьма узкую задачу. С другой стороны, хо- хотелось бы, чтобы класс был разносторонним и удобным в применении, что обыч- обычно требует добавления функций для поддержки часто выполняемых задач. Как определить, какие функции должны войти в класс, а какие нет? Попробуйте подойти к этому так: поставьте перед собой задачу построения класса, который был бы полным и минимальным. Полный интерфейс дает возможность пользователю в некоторых пределах де- делать все, что ему хочется. То есть для любой разумной задачи, которую пользова- пользователь желает решить, должен существовать реальный способ достижения постав- поставленной цели, хотя он может и не быть столь удобным, как хотелось бы. С другой стороны, минимальный интерфейс - это интерфейс с минимально возможным ко- количеством функций, таким, что никакие две функции интерфейса не обладают пе- перекрывающейся функциональностью. Если вы предлагаете полный минимальный интерфейс, пользователи могут делать все, что им заблагорассудится, но интер- интерфейс класса не должен быть более сложным, чем это в принципе необходимо. Стремление к полному интерфейсу кажется достаточно разумным, но зачем нужна минимальность? Почему просто не дать пользователю все, что он хочет, совершенствуя функциональность до тех пор, пока все не будут удовлетворены? Помимо морального аспекта (насколько это правильно - баловать пользовате- пользователей?) у интерфейса класса, перегруженного функциями, существуют и технические недостатки. Во-первых, чем больше функций в интерфейсе, тем труднее его понять потенциальному клиенту, и, соответственно, с тем большей неохотой он будет обу- обучаться использованию такого интерфейса. Класс с десятью функциями кажется по- посильным большинству пользователей; класс со ста функциями у большинства про- программистов, как правило, вызывает желание никогда больше его не видеть. Расширяя функциональность так, чтобы сделать класс как можно более привлекательным, вы в действительности можете добиться того, что отобьете охоту к его изучению. Перегруженный интерфейс может даже привести к путанице. Предположим, что вы создаете класс, который поддерживает мышление для системы искусственного интеллекта. Одна из функций-членов называется think (думать), но позднее вы обнаруживаете, что некоторым хотелось бы иметь функцию, называемую ponder (раздумывать), другие же предпочитают название ruminate (размышлять). Пыта- Пытаясь понравиться всем, вы предлагаете все три функции, хотя они делают одно и то же. Подумайте, в каком состоянии оказывается пользователь, столкнувшийся с тремя различными функциями, которые должны играть одну роль. Он наверняка задума- задумается, на самом ли деле это так? Нет ли между тремя функциями некоторых малоза- малозаметных различий, возможно в эффективности или надежности? Если нет, то почему их три? Вместо того чтобы оценить вашу гибкость, потенциальный пользователь будет теряться в догадках, о чем вы думали (или раздумывали, или размышляли), поступая таким образом. Второй недостаток перегруженных интерфейсов классов - трудности в эксплуа- эксплуатации. Действительно, класс со многими функциями намного труднее поддерживать
Классы и функции и совершенствовать, чем класс, содержащий лишь несколько функций. Намного сложнее избегать дублирования кода (и сопутствующего дублирования ошибок), равно как и оставаться последовательным в интерфейсе. Такой класс также го- гораздо труднее документировать. И наконец, перегруженные определения классов приводят к использованию длинных файлов заголовков. Поскольку файлы заголовков обычно необходимо читать всякий раз при компиляции программы (см. правило 34), определения классов, раздутые сверх меры, вызывают значительное увеличение времени ком- компиляции. Короче говоря, непродуманное добавление функций к интерфейсу обходится дорого, поэтому следует хорошо подумать, оправдано ли дополнительное удоб- удобство, обеспечиваемое несколькими функциями (если интерфейс полон, новые функции добавляются только ради удобства). Нередко создание более чем минимального набора функций вполне оправданно. Если часто выполняемая задача может быть реализована намного более эффективно как функция-член, это вполне достойный повод для добавлений к интерфейсу. Если добавление функции-члена делает класс значительно более удобным в использова- использовании, этого может быть достаточно для включения в класс. И, если добавление функ- функции может помочь предотвратить ошибки пользователя, это также должно служить весомым аргументом в пользу ее включения в интерфейс класса. Давайте рассмотрим конкретный пример: шаблон класса, реализующий мас- массив с задаваемой пользователем верхней и нижней границей и предлагающий фа- факультативную проверку на выход за границы индекса. Первые шаги к созданию такого шаблона массива выглядят так: template<class T> class Array { public: enum BoundsCheckingStatus {NO_CHECK_BOUNDS = 0, CKECK_BOUNDS = 1}; Array(int lowBound, int highBound, 3oundsChcckingStatus check = NO_CHECK_BOUNDS); Array(const Arrays rhs) ; -Array(); Array& operator=(const Arrayk rhs) ; private: int lBound, hBound; // Нижняя и верхняя границы. vector<T> data; // Содержимое массива; информацию //о vector см. в правиле 49. BoundsCheckingStatus checkingBounds; }; Функции-члены, объявленные до сих пор, практически не вызывают вопро- вопросов. У вас имеется конструктор, позволяющий пользователю задавать границы массива, конструктор копирования, оператор присваивания и деструктор. В дан- данном случае вы объявляете функции как невиртуальные, подразумевая, что класс не будет использоваться в качестве базового (см. правило 14). В действительности объявление оператора присваивания - гораздо менее оче- очевидное решение, чем это может показаться. В конце концов, встроенные массивы
Правило 18 C++ нс допускают присваивания, поэтому, вероятно, вам также захочется запре- запретить эту возможность (см, правило 27). С другой стороны, подобный массиву шаб- шаблон vector (из стандартной библиотеки, см. правило 49) допускает операцию присваивания. В этом примере мы будем действовать но образцу vector, и это решение, как вы увидите в дальнейшем, окажет влияние на другие составля- составляющие интерфейса класса. Вид этого интерфейса заставит поежиться приверженцев старого С. Где под- поддержка для объявления массива определенного размера? Для этого достаточно было бы просто добавить другой конструктор, Arrayfint size, Bo-jndsCheckir.gStatus check = K0_CHECK_30'JNDS) ; но это уже не минимальный интерфейс, поскольку для достижения той же цели могут быть применены конструкторы, использующие верхнюю и нижнюю грани- границу. Однако было бы неплохо ублажить этих «старичков», возможно под девизом совместимости с базовым языком. Какие еще функции могут нам понадобиться? Несомненно, что индексация массива - тоже часть полного интерфейса: // Вернуть элемент для чтения/записи. Т& operator!](int index); // Зеркуть элемент только для чтеняя. const T& operator[] (int index) const; Объявляя одну и ту же функцию дважды, один раз с const и один раз без const, вы обеспечиваете поддержку как const-, так и не const-объектов Array. Как объясняется в правиле 21, разница в типах имеет большое значение. Теперь шаблон Array поддерживает создание, удаление и передачу по значе- значению, присваивание и индексацию, и может показаться, что процесс создания ин- интерфейса завершен. Но приглядитесь внимательнее. Предположим, пользователь хочет пройтись но массиву целых чисел, распечатывая каждый элемент, напри- например так: Array<int> а A0, 20); // Границы а равны 10 -л 20. for (int i = нижняя граница a; i <= верхняя граница a; +~i) cout << "а[" <<i<<"]= " << a[i] << "\n"; Как пользователь может получить границы для а? Ответ зависит от того, что наблюдается в ходе присваивания объектов Array, то есть от того, что происхо- происходит внутри Array: :operator=. В частности, если присваивание может менять границы объекта Array, необходимо создать функцию-член, возвращающую те- текущие границы, поскольку априори пользователь не имеет средств определения границ в данном участке программы. В вышеприведенном примере, если с момен- момента создания массива и до его использования в цикле а присваивались новые зна- значения, пользователь не может определить текущие границы а. С другой стороны, если границы объекта Array в результате присваивания меняться не могут, то они фиксируются в момент определения и их отслеживание пользователем вполне возможно (хотя и трудоемко). И в этом случае, однако, было
Классы и функции бы удобно использовать функции, возвращающие текущие границы, - функции, которые в действительности не являются частью минимального интерфейса. Если исходить из того, что присваивание может модифицировать границы объ- объекта, функции, возвращающие границы, могли бы выглядеть следующим образом: in:: lowBound () const; ir.t: highBour.d () const; Поскольку эти функции не изменяют объект, для которого они вызваны, и так как вы, очевидно, предпочитаете использовать const всегда, когда это возможно (см. правило 21), обе эти функции-члена объявлены с модификатором const. С помощью данных функций приведенный выше цикл может быть записан сле- следующим образом: for (:г.с i = q. Iow3ound() ; i <= a.high3ound() ; + + i) cout << "a[" << i << "] = " << a[i] << '\n'; Нет необходимости говорить, что для того, чтобы такой цикл работал и в от- отношении объектов типа т, для указанного типа должна быть определена функция operator<<. (Это не совсем верно. В действительности operator<< должен быть определен либо для Т, либо для некоторого другого типа, к которому Т мо- может быть неявно преобразован. Но я думаю, что суть идеи вы уловили.) Некоторые разработчики будут утверждать, что класс Array должен также содержать функцию, возвращающую количество элементов массива объектов. Количество элементов - это просто highBound () -lowBound () +1, поэтому та- такая функция не является действительно необходимой, однако было бы неплохо добавить се ввиду распространенности ошибки на единицу при подсчете. Другие функции, которые могут оказаться полезными для рассматриваемого класса, включают в себя ввод/вывод, а также различные операторы сравнения (например, <, > и т.п.). Ни одна из этих функций не является при этом частью минимального интерфейса, поскольку все они могут быть реализованы в рамках циклов, содержащих вызовы operator [ ]. Наконец, рассмотрим функции operator<<, operator>>, операторы срав- сравнения и им подобные. В правиле 19 обсуждается причина, по которой их часто реализуют как дружественные функции, а не как члены класса. При этом не забы- забывайте, что с практической точки зрения дружественные функции являются час- частью интерфейса класса. Это означает, что они вносят свой вклад в полноту интер- интерфейса класса и обеспечивают его минимальность. Правило 19. Проводите различие между функциями-членами, функциями, не являющимися членами класса, и дружественными функциями Самос существенное различие между функциями-членами и не членами класса заключается в том, что функции-члены могут быть виртуальными, а не входящие
Правило 19 в класс - нет. В результате, если вам нужна функция, для которой будет осуществ- осуществляться динамическое связывание (см. правило 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 Rationale rhs) const; }; (Если вы не знаете, почему эта функция объявляется как возвращающая ре- результат по значению с модификатором const и требующая ссылку на const в качестве аргумента, обратитесь к правилам 21-23.) Теперь вы с легкостью можете перемножать рациональные числа: Rational oneEighthd, 8) ; Rational oneHalfd, 2); Rational result = oneHalf * oneEighth; // Правильно. result = result * oneEighth; // Правильно. Но на этом вы не останавливаетесь. Вам бы хотелось поддерживать сме- смешанные операции так, чтобы Rational можно было умножать на int. Одна- Однако при попытке выполнить такую операцию обнаруживается, что она действен- действенна только наполовину: result = oneHalf * 2; // Правильно. result = 2 * oneHalf; // Ошибка!
Классы и функции Это плохое предзнаменование. Умножение должно быть коммутативным, не правда ли? Источник проблемы становится очевидным, когда вы переписываете послед- последние два примера в эквивалентной функциональной форме: result = or.eHalf .operator* B) ; // Правильно, result = 2 .operator* (or.eHalf); // Ошибка! Объект oneHalf является примером класса, содержащего operator*, и ком- компилятор вызывает эту функцию. Однако целое число 2 не относится к какому- либо классу, и, следовательно, не имеет функции-члена operator*. Ваш ком- компилятор будет искать функцию operator*, не являющуюся членом класса (то есть глобальную функцию или функцию в видимом пространстве имен), кото- которую можно было бы вызвать следующим образом: result = operator*B, oneHalf) ; // Ошибка! но не являющаяся членом функция operator*, принимающая аргументы int и Rational, отсутствует, поэтому поиск заканчивается безрезультатно. Снова взглянем на успешный вызов функции. Вы видите, что второй аргу- аргумент - целое число 2, a Rational: : operator* требует в качестве аргумента объект Rational. Что здесь происходит? Почему в одном случае все в поряд- порядке, а в другом - нет? Все дело в неявном преобразовании типов. Ваш компилятор знает, что вы пере- передаете :.nt, а функция требует Rational, но он также знает, что может создать не- необходимый объект Rational, вызывая конструктор Rational с передаваемым вами целым числом, что и делает. Другими словами, компилятор рассматривает вызов так, как будто бы он был написан приблизительно следующим образом: const Rational tempB) ; // Создаем временный объект класса // 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 Rational& rhs) const; ни одно из нижеследующих предложений не было бы скомпилировано: result = oneHalf * 2; // Ошибка! result = 2 * oneHalf; // Ошибка!
Правило 19 Это вряд ли можно было бы квалифицировать как поддержку арифметики сме- смешанных типов, но, по крайней мере, поведение компилятора считалось бы последо- последовательным. Рассмотренный нами класс Rational спроектирован так, чтобы допускать не- неявные преобразования из встроенных типов в Rational,- причина, по которой конструктор для Rational не объявлен с explicit. В этом случае компилятор будет выполнять неявное преобразование, необходимое для того, чтобы компили- компилировалась первая строчка с result. В действительности ваш компилятор будет при необходимости выполнять неявное преобразование типа для каждого api-умента при каждом вызове функции. Но он делает это только для параметров из списка ар1"ументов, а не для объекта, вызывающего функцию, то есть объекта, соответству- соответствующего *this внутри функции-члена. Вот почему этот вызов станет работать: result = oneHalf.operator*B) ; // Преобразует int n Rational. а этот - нет: result; = 2.operator*(oneHalf); // He преобразует inz в Rational. В первом случае параметр дан в объявлении функции, а во втором - нет. Несмотря на это не исключено, что вам все равно понадобится поддерживать сме- смешанную арифметику, и способ, которым можно воспользоваться теперь, пожалуй, до- достаточно очевиден: сделайте operator* функцией, не являющейся членим класса, позволяя компилятору выполнять неявное преобразование типов всех аргументов: class Rational { ... //Не содержит operator*. }; // Это объявление либо глобально, либо в пространстве икон, const Rational operator*(const Rationale Ins, const Rationale rhs) { return Rational(Ihs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator()); } Rational oneFourthd, 4); Rational result; result = oneFourth * 2; // Хорошо, result = 2 * oneFourth; // Ура, заработало! Можно считать, что история подошла к «счастливому концу», но остается не- некоторое беспокойство. Следует ли делать operator* дружественным классу Rational? В данном случае ответ будет отрицательный, поскольку operator* может быть полностью реализован в рамках открытого интерфейса класса. Предложен- Предложенный код демонстрирует один из способов достижения этого результата. Если у вас имеется возможность избежать использования дружественных функций, ею все- всегда нужно пользоваться, потому что здесь, как бывает и в реальной жизни, дру- друзья порой приносят больше вреда, чем пользы. Однако часто функции, не являющиеся членами класса, но концептуально входящие в его интерфейс, нуждаются в доступе к неоткрытым членам класса.
Классы и функции Для примера лапайте вновь обратимся к «коньку» этой книги - классу String. Нел и вы попытаетесь перегрузить operator>> и operator<< для чтения и за- записи объектов String, то быстро обнаружите, что они не должны быть членами класса. Иначе вам пришлось бы при вызове этих функций использовать слева от них объект String: // Класс, некорректно объявляющий operator>> //и operator<< функциями-членами, class String { public: String(const char *value); istream& operator>> (istream& input) ; ostreamk operator<< (ostreaT.& output) ; private: char *data; }; String s; s >> cin; // Допустимо, но противоречит принятым соглашениям, s << cout; // Аналогично. Это сбило бы с толку кого угодно. Итак, данные функции не должны быть членами класса. Заметьте, что рассматриваемый случай отличается от обсуж- обсуждавшегося нами выше. Здесь целью является естественный синтаксис, а ранее мы были озабочены неявным преобразованием типов. Нел и бы вы стали проектировать эти функции, то изобрели бы что-нибудь i$ таком роде: istreamk operator>> (istreamk input, String^ string) { delete [] string.data; читаем из входного потока в некоторую область памяти и устанавливаем на нее указатель string.data return input; } ostrea:r,& operator<< (ostreamk output, const Strings string) return output << string.data; } Обратите внимание на то, что обеим функциям необходим доступ к полю data класса String, - нолю, являющемуся закрытым. Однако вы уже знаете, что эти функции не должны быть членами класса. У вас нет выбора: функция, не яв- являющаяся членом класса, но имеющая доступ к его закрытым членам, должна быть дружественной функцией этого класса. Основные идеи этого правила обобщены ниже (будем считать, что f - функ- функция, которую вы пытаетесь объявить надлежащим образом, а С - класс, с которым она концептуально связана):
Правило 20 ? виртуальные функции должны быть членами класса. Если f должна быть виртуальной, ее следует сделать функцией-членом С; ? operator>> и operator<< никогда не являются членами класса. Если ? - это operator>> или operator<<, объявляйте f вне класса. Ксли, кроме того, f необходим доступ к закрытым членам С, объявляйте f дружествен- дружественной для С; ? только функции, не являющиеся членами класса, производят преобразование типа для аргумента слева. Если f необходимо преобразование типа для ар- аргумента слева, определите f вне класса. Когда, кроме того, ? необходим до- доступ к закрытым членам С, сделайте f дружественной для С; ? в остальных случаях функции должны быть членами класса. Если ни одна из вышеприведенных ситуаций не имеет места, сделайте f функцией-членом С. Правило 20. Избегайте данных в открытом интерфейсе Во-первых, давайте рассмотрим вопрос об открытом интерфейсе с точки зре- зрения последовательности нашего подхода. Если в открытом интерфейсе имеются только функции, пользователям вашего класса не придется напрягаться, стараясь вспомнить, следует ли при доступе к члену вашего класса использовать скобки. Они просто будут их использовать, поскольку все члены интерфейса - функции. Это избавит вас от множества проблем. Вас не убелили аргументы в пользу последовательности? А тот факт, что ис- использование функций дает вам намного более точный контроль над доступнос- доступностью членов класса? Если вы делаете член класса открытым, в этом случае каждый имеет к нему доступ чтения-записи; если же вы используете его для получения и установки значений функции, то можете запретить доступ и открывать его толь- только для чтения или для чтения-записи. При желании вы можете даже реализовать доступ только для записи: class AccessLevels { public: int getReacOnly () const{ return readonly; } void setReadWrite(int value) { readWrito = value,- } int getRcadWrite() const { return rcadWritc; } void setWriteOnly(int value) { writeOnly :: value; } private: int r.oAccess; // Доступа к этому целому нет. int readonly; //А здесь доступ только для чтения. int reacWrite; // Доступ для чтения-записи. int writeCnly; // Доступ только для записи. }; Все еще сомневаетесь? Тогда настало время привести самый весомый аргу- аргумент - функциональную абстракцию. Если вы реализуете доступ посредством функции, то позднее можете заменить член класса вычислением, и никто из пользователей вашего класса об этом не догадается.
Классы и функции Например, предположим, что вы пишете приложение, в котором некоторое автоматическое оборудование отслеживает скорость проходящих мимо машин. Когда мимо проезжает машина, тут же вычисляется ее скорость, а затем эта вели- величина добавляется к данным о скорости, собранным до этого момента: class SpeedDataCollection { public: void addValue(ir.t speed); // Добавляем новые данные. double averageSot'ar() const; // Возвращаем среднюю скорость. }; Теперь давайте рассмотрим возможные варианты реализации функции-члена averageSoFar. Один из способов - добавить член класса, который будет содер- содержать среднюю величину всех собранных данных о скорости. При вызове функции averageSoFar она просто вернет значение этого члена класса. Другой подход - вычислять значение каждый раз при вызове функции, что можно сделать, распо- располагая значениями всех элементов множества. Первый подход - получение текущего среднего - увеличивает каждый объ- объект SpeedDataCollection, поскольку вы должны выделить место для эле- элемента данных, хранящего текущую среднюю величину. Зато можно очень эф- эффективно реализовать averageSoFar: это просто встраиваемая функция (см. правило 33), которая возвращает значение члена класса. И наоборот, вычисле- вычисление среднего при каждом вызове замедляет исполнение averageSoFar, но каждый объект SpeedDataCollection будет меньше. Кто возьмется решить, что лучше? Что касается машин с ограниченным объ- объемом памяти, если средние величины необходимы лишь изредка, то их вычисле- вычисление при каждом вызове - наилучшее решение. В приложении, где средние значе- значения нужны достаточно часто, где важна скорость, а не память, предпочтительно сохранение текущего среднего. Существенно то, что посредством доступа к сред- среднему при помощи функции-члена вы можете использовать любую реализацию - а значит, получаете простор для действий, которого лишитесь, если решите вклю- включить текущее среднее в открытый интерфейс. Подведем итог: включая данные в открытый интерфейс класса, вы сами на- напрашиваетесь на неприятности, поэтому обезопасьте себя, пряча все элементы данных за стеной функциональной абстракции. Сделайте это, и получите в награ- награду последовательный подход и регулируемое управление доступом! Правило 21. Везде, где только можно, используйте const Замечательное свойство модификатора const состоит в том, что он наклады- накладывает определенное семантическое ограничение: данный объект не должен моди- модифицироваться, - и компилятор будет проводить это ограничение в жизнь, const позволяет указать компилятору и программистам, что определенная величина должна оставаться инвариантной. Во всех подобных случаях вы должны обозна- обозначить это явным образом, призывая себе на помощь компилятор и гарантируя, что ограничение не будет нарушено.
Правило 21 Ключевое слово const удивительно разносторонне. Вне классов вы може- можете использовать его для глобальных констант или констант пространств имен (см. правила 1 и 47), атакже для статических объектов (внутри файла или бло- блока). Внутри классов допустимо применять его для статических либо нестати- нестатических данных (см. также правило 12). Для указателей вы можете объявить, является ли сам указатель const, одно- одновременно являются ли данные, на которые он указывает, const, или и то, и другое: char *р = "Hello"; // Неконстантный указатель, неконстантные данные1, const char *p = "Hello"; // Неконстантный указатель, константные данные, char * const p = "Hello"; // Константный указатель, неконстантные данные, const char * const p = "Kello"; // Константный указатель, константные данные. Этот синтаксис не так страшен, как кажется. Вы проводите в воображении вертикальную линию через звездочку объявления первого указателя, и если сло- слово 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 Ratior.alb lhs, const Rational& rhs); 1 Согласно стандарту C++ тип "Kello" - const char [ ], который практически всегда эквивален- эквивалентен const char*. Поэтому мри инициализации переменных char* строками, подобными "Hello", мы могли бы ожидать ошибки нарушения условия consc. Эта практика, однако настолько распростране- распространена и С. что стандарт вводит специальное допущение для подобных инициализаций. Тем не менее вам следует их избегать, поскольку их использование не одобряется.
Классы и функции Многие программисты удивятся, впервые увидев такое объявление. Почему результат функции operator* является объектом const? Потому, что в против- противном случае пользователь получил бы возможность делать вещи, которые иначе как надругательством над здравым смыслом не назовешь: Rational a, b, с; (а * Ь) = с; // Присваивание произведению а*Ь! Я не знаю, с какой стати программисту пришло бы в голову присваивать зна- значение произведению двух чисел, но могу сказать точно, что для a, b и с встроенных типов это было бы однозначно некорректной операцией. Один из критериев хоро- хорошо определенного пользовательского типа - отсутствие необоснованной несовмес- несовместимости с поведением встроенных типов, а возможность присваивания значения произведению двух чисел мне кажется совершенно необоснованной. Объявление возвращаемого типа функции operator* как const препятствует этому, поэтому оно вполне целесообразно. В отношении аргументов с модификатором const невозможно прибавить ничего нового; они просто аналогичны локальным объектам с const. Функции- члены с const - совсем другое дело. Цель функций-членов с const безусловно состоит в том, чтобы определить, какие функции-члены могжно вызвать для константных объектов const. Многие упускают из виду, что функции, отличающиеся только объявлением const, могут быть перегружены. Это, однако, очень важное свойство C++. Давайте еще раз рас- рассмотрим класс String: class String { public: // operator!] для неконстантных объектов. char& operator!](int position) { return data[position]; } // operator[] для константных объектов. const char& operator!](int position) const { return data[position]; } private: char *data; }; String si = "Hello"; cout << si [ 0] ; // Вызывает неконстантный String::operator!]. const String s2 = "World" ; cout << s2 [0]; // Вызывает константный String::operator[]. Перегружая operator [ ] и создавая различные версии с разными возвраща- возвращаемыми типами, вы можете по-разному обрабатывать константные и неконстант- неконстантные объекты String: String s = "Hello"; // Неконстантный объект String. cout << s[0] ; // Правильно - считываем неконстантный объект String. s[0] = 'х'; //Правильно - записываем неконстантный объект String.
Правило 21 const String cs = "World"; // Константный объект String. cout << cs[0]; // Правильно - считываем константный объект String. cs[0] = 'x'; // Ошибка! Запись б константный объект String. Заметьте, между прочим, что ошибка здесь генерируется только значением, воз- возвращаемым функцией operator [ ]; сами вызовы operator [ ] вполне коррект- корректны. Ошибки возникают из-за попытки присвоить значение ссылке на констант- константный символ const char&, то есть возвращаемому типу const-версии функции operator[]const. Обратите также внимание на то, что тип, возвращаемый operator [ ] без const, должен быть ссылкой на char - просто char не работает. Если бы operator [ J дей- действительно возвращал просто char, не компилировались бы предложения, подобные следующему: s [ 0 ] = ' х' ; Это объясняется тем, что возвращаемое функцией значение встроенного типа модифицировать некорректно. И даже если бы это было допустимо, тот факт, что 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; // Низов operator char* () const. *nasty = 'M'; // Модифицирует s.data[0]. cout << s; // Выводит "Meiio" . Несомненно, есть нечто некорректное в том, что вы создаете константный объ- объект с определенным значен нем, вызываете для нeгoтoлькoфyнкции-члeныcconst и тем не менее изменяете его значение! (Более детальное обсуждение этого при- примера см. в правиле 29.) /Данная проблема приводит к понятию концептуальной константности. Адепты этой философии утверждают, что функции-члены с const могут модифицировать некоторые биты вызвавшего их объекта, но только так, чтобы пользователь этого не обнаружил. Например, ваш класс String мог бы при каждом запросе кэширо- вать размер объекта: class String { public: //Конструктор, устанавливающий указатель data //на то, на что указывает value. String(const char *value): lengthlsValid(false) {... } size_t length() const; private: char *data; size_t dataLer.gth; // Последнее значение, полученное для длины строки. 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 char *data; mutable size_t dataLength; // Эти члены данных объявлены // теперь с mutable; их можно mutable bool lengthlsValid; // модифицировать где угодно, даже // внутри константной функции-члена. size_t String::length() const if (!lengthlsValid) { dataLength = strlen(data); // Теперь нормально. lengthlsValid = true; // Тоже нормально. return dataLength; Использование 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->lengthIsValid = true- return dataLength; Результат, конечно, непригляден, но иногда программисту приходится посту- поступать подобным образом. Никто, впрочем, не гарантирует, что это будет работать, и иногда старый трюк с отбрасыванием константности не удается. В частности, если объект, на который
Классы и функции указывает this, действительно const, то есть был объявлен как const при опреде- определении, результат" отбрасывания константности не определен. Если в одной из функ- функций-членов вы хотите отбросить константность, вам лучше заранее убедиться, что объект, для которого вы применяете приведение типа, не был определен с const. Существует еще один случай, когда отбрасывание константности может быть одновременно и безопасным, и полезным. Это случай, когда у вас имеется объект const, который вы хотите передать функции, принимающей аргумент без const, и знаете, что внутри функции аргумент не будет модифицирован. Второе условие очень важно, поскольку безопасно отбрасывать лишь константность объекта, ко- который будет только читаться, но не изменяться. Например, некоторые библиотеки, как известно, неправильно объявляют функ- функцию strlen следующим образом: size_t strlen(char *s) ; Несомненно, strlen не собирается модифицировать то, на что указывает s, - по крайней мере тот strlen, на котором я учился программированию. Из-за этого определения, однако, недопустимо вызывать strlen для указателей типа const char *. Чтобы избежать проблемы, вполне возможно отбросить константность указателя при его передаче strlen: const char *klingonGreeting = "nuqneH"; // "nuqneH" - это "привет" // по-клингонски. size_t length = strlen(const_cast<char*>(klingonGreeting)); He будьте, однако, чрезмерно самоуверенны. Вас ждет удача, только если вы- вызываемая функция, в данном случае strlen, действительно не пытается модифи- модифицировать данные, на которые указывает аргумент. Правило 22. Предпочитайте передачу параметров по ссылке передаче по значению В С все передается по значению, и C++ уважает эту традицию, по умолчанию принимая соглашение о передаче по значению. Если не указано обратное, аргу- аргументы функции инициализируются копиями реальных аргументов, а вызов функ- функции возвращает копию возвращаемой функцией величины. Как я указывал во введении к данной книге, что именно означает передача объекта по значению, определяется конструктором копирования класса, к кото- которому принадлежит объект. В связи с этим передача по значению может стать чрез- чрезвычайно проблематичной. Рассмотрим, например, следующую (достаточно искус- искусственную) иерархию классов: class Person { public: Person() ; // Параметры для простоты опускаем. -Person(); private: string name, address;
Правило 22 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 по значению - шесть конструкторов и шесть деструкторов. Поскольку функция returnStudent использует переда- передачу по значению дважды (один раз для аргумента и один раз для возвращаемого значения), «суммарная стоимость» вызова функции - двенадцать конструкторов и двенадцать деструкторов! К чести разработчиков компиляторов C++, это наихудший сценарий. Компи- Компиляторам разрешается эмулировать некоторые из таких вызовов конструкторов ко- копирования (стандарт C++ четко формулирует условия, при которых подобная опе- операция возможна — см. правило 50). Встречаются компиляторы, пользующиеся этой возможностью оптимизации. Но до тех пор, пока они не распространятся
Классы и функции повсеместно, вам не стоит забывать о возможных издержках передачи объектов по значению. Для того чтобы избежать непомерных «накладных расходов», следует пользо- пользоваться передачей по ссылке, а не по значению: const Studer.t& returnStudent (const Stucer.t& 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 printNameAncDisplay (Window w) { cout << w.nane () ; w.display(); } Посмотрим, что получится, если вы вызовете эту функцию для объекта WindowWithScrollBars: WindowWithScroilBars wwsb; printNaneAncDisplay(wwsb);
Правило 23 Аргумент w (помните, что он передается по значению?) будет сконструирован как объект Window, и вся специальная информация, благодаря которой wwsb действовал как объект WindowWithScrollBars, отсечется. Внутри printNameAndDisplay w будет всегда вести себя, как объект класса Window (поскольку это и есть объект класса window), независимо от типа объекта, передаваемого функции. В частности, вызов display внутри printNameAndDisplay всегда вызывает Window: : display и никогда- WindowWithScrollBars::display. Способ, которым можно обойти проблему усечения - передача w по ссылке: // Функция, для которой не актуальна проблема усечения. void printNameAndDisplay(const Windows w) { cout << w.na:r.e () ; w.display(); } Теперь w действует в соответствии с тем, какой тип окна передан функции. Для того чтобы подчеркнуть, что w в этой функции не модифицируется, хотя и переда- передается по ссылке, мы воспользовались советом из правила 21 и осмотрительно объ- объявили его с const. Какие мы молодцы! Передача по ссылке - замечательная вещь, но она влечет за собой некоторые осложнения, наиболее известное из которых - совмещение имен, обсуждавшееся в правиле 17. Кроме того, важно понимать, что иногда вы не можете передавать аргумент по ссылке (см. правило 23). Наконец, жестокая реальность свидетель- свидетельствует, что ссылки практически всегда реализуются как указатели, поэтому пере- передача по ссылке обычно в действительности почти всегда означает передачу указа- указателя. В результате, если ваш объект мал (например, это int), возможно, что его более эффективно передавать по значению. Правило 23. Не пытайтесь вернуть ссылку, когда вы должны вернуть объект Говорят, что Альберт Эйнштейн однажды дал такой совет: делайте все настоль- настолько просто, насколько возможно, но не проще. Программистам на C++ можно посо- посоветовать делать все настолько эффективно, насколько возможно, но не более того. Как только программисты осознают проблемы эффективности, связанные с передачей объектов по значению (см. правило 22), они, подобно крестоносцам, преисполняются решимости искоренить зло - передачу по значению - везде, где бы оно ни пряталось. Непреклонные в своем «святом» порыве, они с неизбежно- неизбежностью совершают фатальную ошибку: начинают передавать по ссылке значения не- несуществующих объектов. А это неправильно. Рассмотрим класс рациональных чисел, включающий в себя дружественную функцию (см. правило 19) для перемножения двух рациональных чисел: class Rational { public: 4'
Классы и функции Rational(int numerator = 0, int denominator = 1) ; private: int n, d; // Числитель и знаменатель, friend const Rational // Почему возвращаемое значение константно, // см. в правиле 21. operator*(const Rationale Ihs, const Rationalk rhs) ; }; inline const Rational operator*(const Rationale Ihs, const Rational& rhs) { return Rational(lhs.n * rhs.n, lhs.d * rhs.d); } Ясно, что эта версия operator* возвращает результирующий объект по ве- величине, и вы обнаружили бы непрофессиональный подход, если бы не уделили внимание вопросу о затратах на создание и удаление объекта. Кроме того, очевид- очевидно, что количество имеющихся у вас ресурсов ограничено, и если есть возмож- возможность избежать затрат на создание промежуточного объекта, ее надо реализовать. Стало быть, вопрос заключен в следующем: реальна ли такая возможность? Вообще-то вы устраните данную проблему, если вернете ссылку. Но ссылка - это просто идентификатор какого-то уже существующего объекта. Всякий раз, сталкиваясь с объявлением ссылки, требуется отдавать себе отчет, для чего пред- предназначен этот идентификатор, поскольку он должен идентифицировать нечто конкретное. В случае с operator*, если функции надлежит возвращать ссылку, она должна вернуть ссылку на некий уже существующий объект Rational, со- содержащий произведение двух объектов, которые следовало перемножить. Очевидно, нет никаких оснований полагать, что такой объект существовал до вызова operator*. Например, если у вас есть Rational аA, 2); // а = 1/2. Rational bC, 5); //b=3/5. Rational с = a * b; //с должно равняться 3/10. кажется неразумным ожидать, что уже существует рациональное число со значени- значением одна десятая. Если operator* будет возвращать такое число, он должен создать его самостоятельно. Функция может создать новый объект только двумя способами: в стеке или в куче. Создание в стеке совершается посредством определения локальной пере- переменной. Используя эту стратегию, вы пытаетесь записать operator* следую- следующим образом: // Первый способ написать эту функцию неправильно. inline const Rationalk operator*(const Rational& Ihs, const Rational& rhs) { Rational result(Ihs.n * rhs.n, lhs.d * rhs.d); return result;
Правило 23 Этот подход можно отвергнуть сразу, поскольку вашей целью было избежать вызова конструктора, a result должен быть создан подобно любому другому объек- объекту. Кроме того, эта функция порождает и более серьезные проблемы, поскольку воз- возвращает локальный объект - ошибка, более подробно рассмотренная в правиле 31. Таким образом, вам предоставлена лишь возможность создания объекта в куче и возвращения его ссылки. Объекты в куче создаются с использованием new. Вот как мог бы выглядеть operator* в этом случае: // Второй способ написать эту функцию неправильно. inline const Rationale operator*(const Rational& lhs, const Rationale rhs) { Rational *result = new Rational(Ihs.n * rhs.n, Ihs.d * rhs.cl); return *result; } Да, вам все же придется расплачиваться за вызов конструктора, поскольку па- память, выделяемая new, инициализируется вызовом соответствующего конструктора (см. правило 5), но теперь встает другая проблема: как будет вызван delete для объекта, созданного вами с использованием new? По правде говоря, это гарантирует утечку памяти. Даже если пользователя, вызвавшего operator*, можно было бы убедить взять адрес возвращаемого функ- функцией результата и использовать для него delete (такая вероятность чрезвычайно мала; правило 31 показывает, как должен был бы выглядеть этот код), сложные выражения приводили бы к созданию безымянных временных объектов, никак не доступных программисту. Например, в Rational w, x, у, z; w = х * у * z; оба вызова operator* создают безымянные временные объекты, которые не видны программисту, а следовательно, не могут быть удалены (и снова см. правило 31). Но предположим, что вы умнее среднего программиста. Возможно, вы обрати- обратили внимание, что недостатком обоих подходов, как со стеком, так и с кучей, являет- является необходимость вызова конструктора для каждого результата, возвращаемого функцией operator*. Возможно, вы еще не забыли о своем первоначальном желании избежать таких вызовов. Вероятно, вы знаете способ избежать всех вы- вызовов конструктора, кроме одного. Вполне допустимо, что вы придумали следую- следующую реализацию функции operator*, возвращающую ссылку на статический объект Rational, определенный внутри функции: // Третий способ написать эту функцию неправильно. inline const Rational& operator*(const Rationalk lhs, const Ratior.al& rhs) { static Rational result; // Статический объект, //на который возвращается ссылка. каким-либо образом перемножьте lsh и rsh и поместите результат в переменную result return result;
Классы и функции Это выглядит многообещающе, хотя, попытавшись реализовать на C++ псев- псевдокод инициализации, показанный выше, вы обнаружите, что практически невоз- невозможно придать необходимое значение result, не вызывая конструктор Rational, а стремление избежать такого вызова, собственно, и послужило поводом для всей этой деятельности. Более того, давайте предположим, что ваш маневр будет иметь успех - и все же никакой острый ум не сможет в конечном счете спасти эту про- программу, родившуюся под несчастливой звездой. Для того чтобы вскрыть причину, давайте рассмотрим следующий совершен- совершенно разумный код: // operator== для объектов Rational. bool operator== (const Nationals Ihs, const. Rational!» rhs) ; Rational a, b, с, с; if { (a * b) == (c * d)) { действия, необходимые в случае, если произведения равны } else { действия, необходимые в противном случае } Теперь обратите внимание, что выражение ( (a*b)=-(c*d) ) всегда равняет- равняется true, независимо от значений а, Ь, с, и d! Легче всего найти объяснение такому неприятному поведению, переписав проверку на равенство в эквивалентной функциональной форме: if (operator==(operator*(a, b) , operator*(с, d) ) ) Заметьте, что, когда вызывается operator = =, всегда уже присутствуют два активных вызова функции operator*, каждый из которых будет возвращать operator* ссылку на статический объект Rat ional. Таким образом, opera tor== будет сравнивать статический объект Rational, определенный в функции ope- operator*, со значением статического объекта Rational внутри operator* той же функции. Стоило бы удивиться, если бы при сравнении они не были равны всегда. При некотором везении этого может быть достаточно, чтобы убедить вас, что возвращать ссылку из функции, подобной operator*, - пустая потеря времени, но я не настолько наивен, чтобы полагаться на везение. Кое-кто из вас в настоя- настоящий момент думает: «Хорошо, - если не достаточно одного статического объекта, может, для этого подойдет статический массив...» Подождите, разве мы уже не достаточно намучились? Я не снизойду до того, чтобы посвятить такой программе отдельный пример, но вкратце могу пояснить, почему даже возникновение такой идеи должно повергать вас в стыд. Во-первых, вы должны выбрать п - размер массива. Если п слишком мало, у вас может закончиться место для хранения, и вы ничего не выиграете по срав- сравнению с вышеописанной программой с одним статическим объектом. Если же п че- чересчур велико, вы уменьшаете производительность вашей программы, поскольку каждый объект в массиве конструируется при первом вызове функции. Это будет стоить вам г. конструкторов и п деструкторов, даже если данная функция вызывается
Правило 23 только один раз. Если процесс улучшения производительности программного обес- обеспечения называется оптимизацией, тогда самое верное название происходяще- происходящему - «пессимизация». Наконец, подумайте о том, как заносить необходимые вам значения в массив объектов и во что это обойдется. Наиболее прямой способ пе- передачи объектов — операция присваивания, но с чем она связана? В общем случае это вызов деструктора (для удаления старого значения) илюс вызов конструктора (для копирования нового значения). А ваша цель - избежать вызова конструк- конструктора и деструктора! Так что затея весьма неудачна. Правильный способ написания функции заключается в том, что функция дол- должна возвращать новый объект. В применении к operator* для класса Rational это означает либо следующий код (который мы видели в самом начале страницы 100), либо что-нибудь вроде: inline const Rational operator*(const Rationals lhs, const Ratior.al& rhs) { return Rational(lhs.n * rhs.n, lhs.d* rhs.d); } Несомненно, вам придется смириться с издержками вызова конструктора и де- деструктора для объекта, возвращаемого функцией operator*, но в глобальном мас- масштабе это небольшая цена за корректное поведение. Притом, вероятно, не все так уж страшно. Подобно всем языкам программирования, C++ позволяет разработчикам компиляторов для улучшения производительности генерируемого кода применять определенную оптимизацию, и, как оказывается, в некоторых случаях коду не ну- нужен результат вызова функции operator*. Когда компилятор пользуется этим фактом (а современные компиляторы очень часто так и поступают), ваша про- программа продолжает делать то, что вы от нее хотите, даже быстрее, чем ожидалось. Подведем итог: когда вы выбираете между возвратом ссылки и возвратом объекта, ваша задача заключается в том, чтобы в результате все работало правиль- правильно. О том, как сделать этот выбор наименее накладным, должен позаботиться раз- разработчик компилятора. Правило 24. Тщательно обдумывайте выбор между перегрузкой функции и аргументами по умолчанию Проблема выбора между перегрузкой функции и заданием для аргумента зна- значения по умолчанию связана с тем, что оба варианта позволяют осуществлять вызов одного имени функции несколькими способами: void f () ,- lit перегружена. void f(int x); f () ; / / Вызов f О . fA0); // Вызов f(int). void glint x = 0) ; // Для g задано значение аргумента по умолчанию. g(); // Вызов g@). gA0);// Вызов gA0).
Классы и функции Что и когда следует использовать? Все зависит от ответа на два других вопроса. Во-первых, существует ли зна- значение, которое вы можете использовать по умолчанию? Во-вторых, как много ал- алгоритмов вам надлежит применить? В целом, если вы достаточно разумно выбе- выберете аргумент со значением но умолчанию и намерены использовать только один алгоритм, вам следует прибегнуть к аргументу по умолчанию (см. правило 38). В противном случае вы используете перегрузку функций. Рассмотрим функцию для вычисления максимума из пяти чисел int. Она ис- использует в качестве значения аргумента по умолчанию - вдохните поглубже - std: :numeric_limits<int> : :min (). Я еще вернусь к этому, но прежде взгля- взгляните на код: int max(int а, int b = std::numeric_limits<int>::min(), int с = std::numeric_limits<int>::min(), int d = std::numeric_limits<int>::min(), int e = std::nuxeric_limits<int>::min()) { int temp = a > b ? a : b; temp = temp > с ? temp : c; temp = temp > d ? temp : d; return temp > e ? temp : e; } Расслабьтесь. Использование std: :numeric_limits<int>: :min() - всего лишь новомодный способ реализовать в стандартной библиотеке C++ то, что С делает посредством макроса INT_MIN из <limits.h>: это минимальное значе- значение int для того компилятора C++, с которым вы работаете. Да, здесь сказыва- сказывается отход от лаконичности, которой славится С, но за всеми этими двоеточиями и другими синтаксическими хитростями стоит определенный подход. Предположим, ваша задача - написать шаблон функции, требующий в каче- качестве параметра любой встроенный тип чисел, и вам хотелось бы, чтобы функции, генерируемые шаблоном, распечатывали минимальное значение, возможное для данного типа: template<class T> void printMinimumValue() { cout << минимальное значение, представляемое типом Т; } Это достаточно трудно сделать, если все, чем вы располагаете, - <limits .h> и <f loat .h>. Вам неизвестно, с чем вы имеете дело, поэтому невозможно по- понять, печатать ли вам INT_MIN или DBL_MIN. Для того чтобы снять проблему, в стандартной библиотеке C++ (см. прави- правило 49) в заголовочном файле <limits> определен шаблон numeric_limits, который сам определяет несколько статических функций-членов. Каждая фун- функция возвращает информацию о типе, для которого реализуется шаблон. Так,
Правило 24 функции в numeric_limits<int> возвращают информацию о типе int, функ- функции в numeric_limits<double> - информацию о типе double и т.д. Среди функций в numeric_limits функция min возвращает минимальное возможное число этого типа, поэтому numeric_limits<int>: :min () возвращает мини- минимальное целое число. При наличии numeric_limits (шаблона, который, как почти все шаблоны в стандартной библиотеке, располагается в пространстве имен std, - см. прави- правило 28; сам по себе шаблон numeric_limits расположен в заголовочном файле <limits>) написание printMinimumValue становится настолько легким, на- насколько это можно себе представить: template<class T> 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 INT_MIN; } } Поскольку данная функция определена как встраиваемая, ее вызов заменяется ее телом (см. правило 33). А это дает INT_MIN, что само по себе является определе- определением с помощью #def ine - константы, зависящей от реализации. Поэтому, хотя функция max в начале этого правила выглядит так, как будто она делает функцио- функциональный вызов для каждого значения аргумента по умолчанию, это просто хитрый способ доступа к типизированной константе, в данном случае к значению int_min. Стандартная библиотека C++ изобилует примерами таких курьезов. В связи с этим вам следует обязательно прочитать правило 49. Но вернемся к функции max. Принципиально важно отметить, что max ис- использует тот же самый (довольно неэффективный) алгоритм вычисления, не за- зависящий от количества аргументов, передаваемых при вызове. Нигде в функ- функции вы не пытаетесь определить, какие аргументы «настоящие», а какие заданы по умолчанию. Вместо этого вы выбрали значения по умолчанию, которые ни- никак не влияют на вычисления вашего алгоритма. В результате использование ар- аргумента по умолчанию оказывается жизнеспособным решением. Для многих функций разумных значений по умолчанию нет. Предположим, что вы хотите написать функцию для вычисления средней величины из пяти int. При этом вы не можете использовать аргументы, задаваемые по умолчанию,
Классы и функции поскольку функция зависит от количества переданных аргументов: если вам передается три значения, вы делите их сумму на 3; если передается пять значе- значений, вы делите сумму на 5. Более того, нет «магического числа», которое бы указывало на то, что аргумент не был в действительности передан пользовате- пользователем, поскольку все возможные значения int подходят для использования в ка- качестве аргументов. В этом случае у вас нет выбора и вы должны использовать перегрузку функции: double avgdnt а); double avgdnt a, int b) ; double avgdnt a, int b, int c) ; double avgdnt a, int b, int c, int d) ; double avgdnt a, int b, int c, int d, int e) ; Другой случай, когда возникает необходимость использования перегруженных функций: вы хотите решить определенную задачу, но применяемый вами алго- алгоритм зависит от входных данных. Для конструкторов это обычное дело: конструк- конструктор но умолчанию создает объект «с нуля», в то время как конструктор копирова- копирования создает его на основании уже существующего объекта: // Класс, представляющий натуральные числа. class Natural { public: Natural(int initValue); Natural(const Naturals rhs); private: unsigned int value; void initdnt initValue) ; void error(const strings 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 Naturals x) { init(x.value); } Конструктор, принимающий int, должен осуществлять контроль наличия ошибок, а конструктор копирования - нет, поэтому здесь необходимы две функ- функции; возникает перегрузка. Однако заметьте, что обе функции должны иници- инициализировать начальное значение нового объекта. Это может вести к дублиро- дублированию кода в конструкторах, поэтому допустимо устранить данную проблему написанием закрытой функции-члена init, содержащей общий код двух кон- конструкторов. Эту тактику - использование перегруженных функций, вызываю- вызывающих общие функции для выполнения некоторой работы, - стоит запомнить, поскольку она часто оказывается полезной (см., например, правило 12).
Правило 25 Правило 25. Избегайте перегрузки по указателю и численному типу Для начала заладим себе простой вопрос: что такое ноль? Выражаясь кон- конкретнее, что здесь произойдет? void f ( int. x) ; void f(string *ps) ; f@); // Вызов f(ir.t.) или f (string* )? Ответ: 0 - это int (если быть точным, литерная целая константа), потому все- всегда будет вызываться f (int). Здесь и кроется проблема, поскольку это требуется не каждый раз. Описанная ситуация уникальна в мире C++: люди думают, что вы- вызов функции должен быть неоднозначным, а компиляторы так не «считают». Было бы здорово, если бы мы могли каким-либо способом избавиться от этой проблемы (скажем, используя для нулевого указателя NULL), но она оказывается гораздо более сложной, чем можно себе представить. Вашим первым побуждением может быть объявление константы с идентифика- идентификатором NULL, но константы имеют типы, а какой тип должен тогда иметь NULL? Он должен быть совместим с указателями всех типов, но единственный подходящий тип - это void, а вы не можете передавать указатели void* типизируемым указате- указателям, не делая явного преобразования чипов. Это выглядит достаточно непривлека- непривлекательно, и более того, на первый взгляд ненамного лучше, чем исходная ситуация: void * const NULL = 0; // Возможное определение NULL, f@); //По прежнему вызывает f(int) . f(static_cast<string*>(NULL)); // Вызывает f(string*), f(static_cast<string*>@)); // Вызывает f(string*). Однако при более внимательном рассмотрении использование NULL - кон- константы void* - не особенно лучше того, с чего мы начали, поскольку мы избега- избегаем неоднозначности, если для нулевых указателей используем NULL: f@); // Вызов f(int). f (NULL); // Ошибка! Несоответствие ткг.ов. f(static_cast<string*>(NULL)); // Отлично, вызов f(string*). По крайней мере, мы достигли того, что вместо ошибки в момент выполнения программы (вызов «неправильного» f для 0) получаем ошибку в момент компиля- компиляции (попытка передачи void* для аргумента string*). Это слегка улучшает дело (см. правило 46), но преобразование типов - неудовлетворительное решение. Если вы, хоть и краснея со стыда, прибегнете к помощи препроцессора, то об- обнаружите, что он в действительности не может предложить удовлетворительного выхода, поскольку наиболее очевидным выбором кажется tf define NULL 0 ИЛИ #defir.e NULL ((void*) 0),
Классы и функции где первая возможность - это просто литерал 0, то есть по существу целая кон- константа (как вы помните, это и было нашим исходным затруднением), в то время как вторая возможность возвращает нас к проблеме передачи указателей void* для типизированных указателей. Если вы хорошо знакомы с правилами, отвечающими за преобразования типов, вам должно быть известно, что с точки зрения C++ преобразование из long int в int не лучше и не хуже, чем преобразование значения long int 0 в нулевой ука- указатель. Вы можете воспользоваться этим обстоятельством для введения неоднознач- неоднозначности в выборе int/указатель, которая, как вы, возможно, считаете, должна была присутствовать с самого начала: #define NULL OL // NULL - это теперь long int. void f(int x); void f(string *p); f(NULL); // Ошибка! - неопределенность. Однако это не помогает решению проблемы, когда вы перегружаете функции по long int и указателю: «define NULL OL void f(long int x) ; // Эта функция f теперь принимает параметр типа // long. void f(string *p); f(NULL); // Хорошо, вызывает f(long int). На практике это, вероятно, более безопасно, чем простое определение NULL типа int, но перед нами скорее способ обойти проблему, чем устранить ее. Проблема, впрочем, разрешима, но это требует использования совсем недав- недавнего дополнения к языку: шаблонов функций-членов (часто называемых просто шаблонами членов). Шаблоны функций-членов подразумевают именно то, что можно ожидать из названия: шаблоны внутри классов, генерирующие функции- члены этих классов. В случае с NULL вам необходим объект, который ведет себя подобно выражению static_cast<T*> @) для всех типов т. Подразумевается, что NULL должен быть объектом класса, содержащего операторы неявного преоб- преобразования типа для всех возможных типов указателей. Таким образом, требуется множество операторов преобразований типов, но благодаря шаблону членов вы способны заставить C++ генерировать их вместо вас: // Первый подход к классу, дающему указатели NULL, class NullClass { public: template<class T>// Шаблон генерирует operator T* для всех типов Т; operator T*() const { return 0; } // каждая функция возвращает }; // нулевой указатель, const NullClass NULL,- // NULL - это объект типа NullClass. void f (int x) ; // To же, что было в начале, void f(string *p); // Аналогично. f(NULL); // Хорошо, преобразует NULL в string*, // затем вызывает f(string*).
Правило 25 Для начала это неплохо, но возможен ряд улучшений. Во-первых, на самом деле нет необходимости использовать более чем один объект NullClass, поэтому не имеет смысла давать классу имя; мы просто можем использовать неименованный класс и соз- создать NULL этого типа. Во-вторых, как только мы сделаем возможным преобразование NULL к любому типу указателя, понадобится также обрабатывать и указатели на чле- члены классов. Это требует использования второго шаблона членов, преобразующего О к типу ТС::* (указателя на член типа Т класса С) для всех классов с и всех типов т. (Если вы не видите в этом смысла или никогда не слышали об указателях на члены - и уж тем более не пользовались ими - не переживайте. Указатели на члены доста- достаточно редко встречаются и, возможно, вам никогда не придется иметь с ними дело. Если они все-таки вас интересуют, можете обратиться к правилу 30, в котором ука- указатели на члены классов обсуждаются немного более подробно.) Наконец, мы не должны давать пользователю возможность пытаться получить адрес для NULL, так как NULL не предназначен исполнять роль указателя; он существует в качестве зна- значения указателя, а значение указателя (например, 0х253АВ002) не имеет адреса. Обновленное определение для NULL выглядит следующим образом: const // Этот константный объект преобразуется к нулевому class { // указателю на не член класса любого типа или к нулевому public: // указателю на член класса любого типа, чей адрес template<class Т>// получить нельзя (см. правило 27) и чье имя - NULL, operator T*() const { return 0; } template<class C, class T> operator T С: : * () const { return 0; } private: void operators() const; } NULL; На это действительно стоит полюбоваться, хотя вы можете пойти на некото- некоторые уступки и все-таки дать классу имя. В противном случае, весьма вероятно, что сообщения компилятора об ошибках, связанных с типом NULL, будут совер- совершенно невразумительными. Что касается попыток создать работающий NULL, важно, что они оказывают- оказываются действенными, только если вы вызываете функции. Если же вы разработчик вызываемых функций, «защита от дурака» в виде NULL ничем вам не поможет, поскольку заставить пользователя ее применять не в ваших силах. Например, даже если вы предложите пользователям вышеописанный высокотехнологичный NULL, то все равно не сможете помешать им делать следующее: f@); // По-прежнему вызывает f(int), поскольку 0 - это по-прежнему // int. Этот код подвержен тем же самым проблемам, которые рассматривались в на- начале данного правила. Для разработчика перегруженных функций итоговый совет будет выглядеть следующим образом: лучше всего избегать перегрузки по численным типам и ука- указателям, если у вас имеется такая возможность.
Классы и функции Правило 26. Примите меры предосторожности против потенциальной неоднозначности Все мы придерживаемся некоторой философии. Некоторые люди верят в прин- принцип невмешательства в экономику, другие - в переселение душ. Некоторые даже убеждены, что COBOL является настоящим языком программирования. У C++ тоже есть своя философия: она заключается в том, что потенциальная неоднознач- неоднозначность - это не ошибка. Ниже дан пример потенциальной неоднозначности: class 3; // Предварительное объявление класса В. class A { public: A(const B&); //А можно создать из В. }; class В { public: operator A() const; //В может быть преобразовано в А. }; В этих объявлениях классов нет ничего неправильного: они без проблем мо- могут сосуществовать в одной и той же программе. Однако посмотрим, что случит- случится, если классы столкнутся в функции, которая требует объекта А, но которой в действительности передается объект В: void f(const A&); В b; f(b); //Ошибка! Неоднозначность. Видя вызов f, компилятор «знает», что он должен каким-либо способом прий- прийти к объекту типа А, даже если «на руках» у него тип В. Существуют два одинако- одинаково хороших способа добиться этого. С одной стороны, можно вызвать конструк- конструктор класса А; при этом новый объект А будет создан с использованием b в качестве аргумента. С другой стороны, b может быть преобразован в объект А посредством вызова оператора преобразования, определенного пользователем в классе В. По- Поскольку оба подхода считаются одинаково хорошими, компилятор отказывается выбирать между ними. Конечно, некоторое время вы можете пользоваться этой программой, не по- подозревая о наличии неоднозначности. Это и составляет коварную сущность по- потенциальной неоднозначности. В течение долгого времени, никем не замеченная и ничем себя не обнаруживающая, она может скрываться в коде программы, пока однажды программист по неведению не совершит чего-нибудь действительно не- неоднозначного, и тогда джинн вырвется из бутылки. В результате не исключены неприятности: можно, ничего не подозревая, создать библиотеку, которую с пол- полным основанием следует назвать неоднозначной. Неоднозначность подобного типа возникает и для стандартных преобразова- преобразований типов - нет даже необходимости в классах:
Правило 26 void f (inr.) ; void t(char); double d - 6.02; f(d); //Ошибка! Неоднозначность. Следует ли преобразовывать d в int или char? Преобразования одинаково хо- хороши, поэтому компилятор здесь- не су- судья. К счастью, вы можете обойти эту про- проблему, используя явное приведение типов: f(static_cast<ir>.t> (с) ) ; // Правильно, вызывает f(ir.t). f (static_cast<c'nar> (d) ) ; // Правильно, вызывает f(char). Множественное наследование (см. правило 43) является богатым источни- источником потенциальных неоднозначностей. Наиболее простой случай возникает, ког- когда производный класс наследует одно и то же название и нескольких базовых классах: class Basel { public: int dolt(); class Basc2 { public: void dolt(); // Derived не объявляет функции по имепл colt, class Derived: public Basel, public Base2 { Derived d; c.doItO; // Оаибка! Неоднозначность. Когда класс Derived наследует две функции с одним именем, C++ «не под- поднимает шума»; на данный момент это лишь потенциальная неопределенность. Однако вызов dolt вынуждает компилятор определиться, и если вы не указыва- указываете, какой базовый класс вам нужен, вызов функции генерирует ошибку: d.Basel::dolt () ; // Правильно, вызывает Basel::do;t. d.3ase2::doTt () ; // Правильно, вызывает Base2::dolt. Большинство программистов такое положение не беспокоит, но то, что огра- ограничения доступа не вписываются в общую схему, подтолкнуло многих «мирных» людей к явно не миролюбивым действиям: class Basel {...}; // То же, что и выше, class 3ase2 { private: void dolt(); // Это теперь закрытая функция. \ ¦
Классы и функции 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++ массивам за одним исключени- исключением: они осуществляют проверку выхода индекса за границы. Одна из возникающих
Правило 27 проблем - как запретить присваивание объектов Array, поскольку в C++ присва- присваивание запрещено для массивов: double valuesl[10]; double values2[10] ; valuesl = values2; // Ошибка! Для большинства функций это не составляет проблемы. Если функция вам не- ненужна, вы просто не объявите ее в классе. Однако оператор присваивания отно- относится к избранным функциям-членам: если вы не пишете их сами, услужливый помощник C++ всегда напишет их за вас (см. правило 45). Что делать? Решение заключается в том, чтобы объявить функцию (в данном случае operator = ) закрытой. Объявляя функцию-член явным образом, вы ме даете компилятору генерировать свои собственные версии, а делая функцию закрытой, вы не позволяете ее вызвать. Однако это не защищает вас от ее случайного использования: члены класса и дружественные функции все же могут вызвать вашу закрытую функцию. Ко- Конечно, это справедливо только в том случае, если вы не опустили ее определение. Тогда при неумышленном вызове функции вы получите ошибку в момент компо- компоновки (см. правило 46). Для Array ваше определение шаблона начиналось бы следующим образом: template<class T> class Array { private: //Не определяйте эту функцию! Arrays operator=(const Arrayk rhs) ; Теперь, если пользователь вознамерится выполнить присваивание объектов Array, компилятор пресечет эту попытку, а если вы по неосторожности попыта- попытаетесь сделать это в функции-члене или дружественной функции, начнет роптать компоновщик. Не делайте на основании этого примера вывод, что данное правило применимо только к оператору присваивания. Совсем нет. Оно действует в отношении каж- каждой генерируемой компилятором функции, описанной в правиле 45. Па практике вы обнаружите, что функциональное подобие между оператором присваивания и конструктором копирования (см. правила 11 и 16) практически всегда означает, что когда вы хотите запретить использование одного, вам необходимо запретить и использование другого. Правило 28. Расчленяйте глобальное пространство имен Самая серьезная проблема, касающаяся глобальной области видимости, связана с тем, что эта область только одна. В написании большого программного продукта
Классы и функции участвует огромное количество людей, каждый из которых создает имена в одной и той же области видимости, что с неизбежностью ведет к конфликтам имен. На- Например, заголовочный файл libraryl. h может определять ряд констант, вклю- включая следующие: const double LIB_VEHSION = 1.204; То же самое и для 1 ibrary2 . h: const int LIB_VERSION = 3 ; He требуется особенной проницательности, чтобы заметить, что при одновре- одновременном включении как libraryl. h, так и Iibrary2 . hy вас возникнут неприят- неприятности. К сожалению, кроме проклятий сквозь зубы, отправки недоброжелательных писем разработчикам библиотеки и редактирования файлов заголовков вы мало что можете сделать для разрешения конфликтов подобного типа. Все, что в ваших силах, - это милосердно снизойти до тех несчастных, кому придется использовать написанные вами библиотеки. Вполне вероятно, вы уже предваряете ваши глобальные символы префиксами, которые, надеюсь, будут уни- уникальными, но, несомненно, следует признать, что получаемые идентификаторы оказываются более чем непривлекательными на вид. Использование пространства имен C++ удачно разрешает это недоразумение. По своей сути пространство имен - это изысканный способ использования люби- любимых вами префиксов без того, чтобы они все время мозолили глаза. Поэтому вме- вместо фрагмента const double sdmBOOK_VERSION =2.0; //В этой библиотеке каждый символ class sdmHandle {...}; // начинается с "sdrn", для чего может sdmHandlek sdraGetHar.dle () ; // потребоваться объязленке подобной // функции - см. правило 47. вы пишете следующее: namespace sdm { const double BOOK_VERSION = 2.0; class Handle { ... }; Handles getHandleO ; } При этом пользователь получает доступ к символам в созданном вами простран- пространстве имен любым из трех стандартных способов: импортируя символы простран- пространства имен в область видимости, импортируя в область видимости индивидуальные символы или каждый раз явно указывая символ. Вот несколько примеров: void fl() { using nair.espaco s<±r; // Сделайте все символы в sdm доступными без // квалификации, в этой области видимости. cout << BOOK_VERSION; // Правильно, разрезается в sdn::BOOK_VERSICK. Handle h = getHandleO; // Правильно, Handle разрешается в sdx.::Handle, // a getHandle разрешается в sdm::get!Iandie.
Правило 28 void f2 using sdm::BOOK_VERSION; // Без полного указания имени в этой // области доступно только BOOK_VERSION. cout << BOOK_VERSION; // Правильно, разрешается в sdm::300K_VERSI0N. Handle h = getHandleO ; // Ошибка! Ни Hanoie, ни gev.Har.cle в эту область // видимости импортированы не были. void f3 cout « sdrr.: :BOOK_VERSION; // Правильно, BOOK_VERS7ON доступно только ... / / в этом месте. double d = BOOK_VERSION; // Ошибка! 300K_VERSI0N в этой области // видимости недоступно. Handle h = getHandleO ; // Озибка! Ни Handle, ни getHandle //в эту область видимости // импортированы не были. Вот наиболее приятное свойство пространств имен: потенциальная неодноз- неоднозначность не является ошибкой (см. правило 26). В результате вы можете импорти- импортировать те же самые символы из нескольких пространств имен и тем не менее вести вполне беззаботную жизнь (при условии, что вы никогда в действительности не ис- используете эти символы). Например, в дополнение к стандартному sdm вы решили использовать следующее пространство имен: namespace AcmeWindowSystem { typedef int Handle; В этом случае можно, не опасаясь конфликтов, использовать как sdm, так и AcmeWindowSystem - при условии, что вы нигде не обращаетесь к Handle. Если же вы к нему обращаетесь, то следует явным образом указать, из какого про- пространства имен вам нужен Handle: void f () { using namespace sdm; // Импортирует символы из sdm. using namespace AcrceWir.dowSystem; // Импортирует символы из Acme. ...II Можно свободно обращаться к символам из sdin и Ас.т.е, кроме Handle. Handle h; // Ошибка! Который Handle? sdm::Handle hi; // Правильно, кет неоднозначности. AcmeWindowSystem::Handle h2; // Также никакой неоднозначности.
Классы и функции Сравните приведенный пример со стандартным подходом, использующим заголовочные файлы, где простое включение sdm.h и acme .h вызвало бы ошиб- ошибку компилятора - повторное объявление символа Handle. Пространства имен были введены в C++ достаточно поздно, в процессе стан- стандартизации. Вероятно, поэтому непосвященному читателю кажется, что они не так уж важны и без них вполне можно прожить. Нет, нельзя. Нельзя, поскольку практически все п стандартной библиотеке (см. правило 49) располагается внут- внутри пространства имен std. Вы можете счесть эту особенность несущественной, но на самом деле она непосредственно касается любого программиста. Именно по этой причине C++ теперь щеголяет забавными названиями заголовков, например: <iostream>, <string> и т.п. (более подробно см. правило 49). Поскольку пространства имен были введены сравнительно поздно, ваш ком- компилятор может их еще не поддерживать. Даже в этом случае нет причин для засо- засорения глобального пространства имен, поскольку можно аппроксимировать про- пространства имен структурами. Вы делаете это посредством создания структуры, содержащей глобальные идентификаторы, и объявляете их как статические чле- члены структуры: // Определение структуры, эмулирующей пространство имен, struct sdm ( static const double BOOK_VERSION; class Handle { ... }; static Handles getHandleO; }; const double sdm: :BOOK_VERSION = 2.0; // Обязательное определение // статических членов данных. Теперь, если кому-нибудь потребуется доступ к глобальному имени, достаточ- достаточно просто воспользоваться префиксом названия структуры: void f() { cout « s&T.: :BOOK_VSRSION; sdm::Handle h = sdm::getHandleO; Если конфликты имен на глобальном уровне отсутствуют, то пользователи вашей библиотеки могут посчитать использование полных имен обременитель- обременительным. К счастью, здесь также предусмотрен способ, позволяющий задавать об- область видимости и игнорировать полные имена. Для названий типов используйте оператор typedef, который бы устранил не- необходимость в явном задании области видимости. Итак, для типа Т имитирую- имитирующей пространство имен структуры запишите глобальное определение типа, при котором Т окажется синонимом S : : Т: typedef sdm::Handie Handle;
Правило 28 Для каждого статического объекта X новой структуры определите глобальную ссылку, инициализируемую S: :Х: const doublek 300K_VERSI0N = sdm::300K_V2RSICN; По правде говоря, после того как вы прочтете правило 47, при одной только мыс- мысли об определении нелокальных статических объектов, подобных BOOK_VERSION, вам будет делаться дурно. У вас появится желание заменить такие объекты функ- функциями, определенными в правиле 47. Функции во многом подобны объектам, и, хотя определение с"сылок на функ- функции вполне корректно, все же предпочтительно вместо ссылок на функции при- применять указатели на них: // getHandle - это константный указатель (см. правило 21) на sdrn: igctliandle. sdm::Handle& (* const getHandle)() = sdm::getKandlo; Обратите внимание на то, что getHandle - это константный указатель const. Не хотите же вы, в самом деле, чтобы пользователи делали его указателем на что- нибудь отличное от sdm: :getHandle? Если вам очень интересно, как определить ссылку на функцию, это освежит вашу память: // getHandle - это ссылка на sdm::getHandle. sdm::Handles (&getHandle)() = sdm::getHandle; Лично я думаю, что такой вариант весьма неплох, но но ряду причин вы, воз- возможно, не сталкивались с этим раньше. Кроме разницы в способе инициализа- инициализации, ссылки и указатели на функции const практически ничем не отличаются. При этом преимущество указателей на функции состоит в том, что с ними гораз- гораздо проще разобраться. Имея эти определения типов и ссылки, пользователи, не сталкивающиеся с конфликтами глобальных имен, могут просто применять типы и имена объек- объектов, в то время как пользователи, которым знакома проблема конфликтов имен, должны определять имена полностью. Маловероятно, что все захотят пользовать- пользоваться короткими названиями, поэтому обязательно поместите определения типов и ссылки в отдельных файлах заголовков, не содержащих структуру, эмулирую- эмулирующую пространство имен. Структуры — хорошая аппроксимация пространств имен, но им очень дале- далеко до самого оригинала. Структуры обладают целым рядом недостатков, причем один из самых очевидных — работа с операторами. Коротко говоря, операторы, определенные как статические функции-члены структуры, Moi-ут быть вызваны только посредством функционального вызова, а не путем синтаксиса двуместных операций, для поддержки которых они созданы: // Определяем структуру, эмулирующую пространство имен //и содержащую типы и функции для Widgets. // Объекты Widgets поддерживают операцию сложения с помощью operator -*¦. struct widgets { class Widget { ... };
Классы и функции //В правиле 21 объясняется, почему возвращается константа. static const Widget operator*(const Widget& lbs, const Widgetk rhs); // Попытка определить глобальные (краткие) имена для Widget //и описанного выше operator +. typedef widgets: .-Widget Widget; // Ошибка! operator- не может быть именем указателя. const Widget (* const operator*)(const Widgets, const Widgetk); 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(); operator char *() const; private: char *data; // Возможную реализацию // вы можете найти в правиле 11. // Преобразование String з char*. const String BC'Hello World"); // В - это константный объект.
Классы и функции: реализация Поскольку В объявлен как const, было бы лучше всего, если бы значение В отныне и навсегда оставалось равным "Hello World". Конечно, при этом пред- предполагается, что программисты, работающие с В, будут вести «честную игру». В част- частности, многое зависит от того, станут ли они посредством «грязных приемов» об- обходить константность (см. правило 21): // Делаем alsoB другим именем для В, но без константности. Stringk alsoB = const_cast<String&>(B); Если, впрочем, никто не совершает таких злодеяний, то вполне можно пору- поручиться, что в никогда не изменится. Или все-таки нет? Рассмотрим следующую последовательность событий: char *str = В; // Вызов В.operator char*() . scrcpy(str, "Hi Mom") ; // Модифицируем то, на что указывает str. Действительно ли значение В по-прежнему равно "Hello World", или оно неожиданно превратилось в "Hi Mom"? Ответ полностью зависит от реализации String::operator char*. Ниже приведена небрежная реализация, которая ведет себя неправильно. Тем не менее она работает очень эффективно, поэтому многие программисты попада- попадаются в эту ловушку: // Быстрая, но некорректная реализация, inline String: .-operator char*() const { return data; } Проблема с этой функцией заключается в том, что она возвращает дескриптор (в данном случае указатель) для информации, содержащейся внутри объекта String, для которого функция вызывается. Дескриптор дает пользователю не- неограниченный доступ к тому, на что указывает закрытый член data. Другими сло- словами, после строчки char *str = В; ситуация выглядит следующим образом: Ясно, что любая модификация памяти, на которую указывает str, изменит так- также и фактическое значение в. Таким образом, несмотря на то, что в объявлен как const и вызываются только константные функции-члены в, по ходу выполнения программы он все равно может принять другое значение. В частности, если изме- изменится значение, на которое указывает str, то в также подвергнется изменению.
Правило 29 В том, как написана функция String: : operator char*, нет ничего плохого. Плохо то, что она может быть использована для константных объектов. Если бы функция не была объявлена как const, то проблемы бы не возникло, поскольку функцию было бы невозможно вызвать для объектов, подобных в. Тем не менее преобразование объекта String (даже константного) в эквива- эквивалентный указатель char* кажется вполне разумным, так что вы предпочтете оста- оставить объявление этой функции const. Если вам это необходимо, следует пере- переписать реализацию так, чтобы избежать возврата дескриптора внутренних данных: // Более медленная, но и более безопасная реализация. inline String::operator char*() const { char *copy = new char [strlen (data) + 1] ; strcpyfeopy, 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: return case 1: return case 2 : return } return ""; 'Margaret Mitchell" 'Stephen King" Meyers"; // Случайным образом выбирает //и возвращает имя автора. // randO имеется в <stdlib.h> // (и <cstdlib> - см. правило 49). // Написала "Унесенные ветром", // настоящий классик. // Его рассказы не давали уснуть // миллионам людей. // Гк! Этот пункт чем-то // отличается от двух других. . . //Мы никак не можем попасть сюда, но, увы, // любой путь исполнения в функции, возвращающей // значение, должен возвращать значение.
Правило 29 Давайте оставим на время в стороне вашу вполне обоснованную обеспоко- обеспокоенность тем, насколько «случайны» величины, возвращаемые rand, и, пожалуй- пожалуйста, будьте снисходительны к моей мании величия - попытке причислить себя к настоящим писателям. Вместо этого сфокусируемся на том, что значение, воз- возвращаемое someFamousAuthor, - это временный объект String. Время жизни таких объектов обычно простирается только до конца выражения, содержащего вызов создающей их функции, в данном случае - до конца выражения, содер- содержащего вызов someFamousAuthor. Рассмотрим еще один случай использования этой функции. Предполагает- Предполагается, что String объявляет функцию-член operator const char*, описанную выше: const char *pc = soxeFanousAuthor(); cout << pc; // Ой. . . Хотите - верьте, хотите - нет, но предсказать с какой-либо определенностью, что будет делать этот код, вы не сможете. К тому времени, когда вы попытаетесь распечатать последовательность символов, на которую указывает рс, она уже бу- будет не определена. Неопределенность объясняется особой последовательностью событий, имеющей место при инициализации рс: 1. Создается временный объект String, который должен содержать значение, возвращаемое функцией someFamousAuthor. 2. Затем он конвертируется в const char* посредством operator const char* из String, а рс инициализируется результирующим указателем. 3. Временный объект String удаляется, что означает вызов деструктора. В де- деструкторе удаляется указатель data (код приведен в правиле 11). Однако data указывает на ту же память, что и рс, поэтому рс теперь указывает на память с неопределенным содержимым. Поскольку рс был инициализирован дескриптором временного объекта, а вре- временные объекты вскоре после своего создания удаляются, дескриптор становится недействительным до того, как рс получаст возможность с ним что-нибудь делать. В каком-то смысле рс «мертв еще до своего появления на свет». Вот какую опас- опасность таят в себе дескрипторы временных объектов. Таким образом, для функций-членов, содержащих const, возврат дескрип- дескрипторов - опрометчивое решение, поскольку оно приводит к разрушению абстрак- абстракции. Возврат дескриптора может привести к проблемам даже для функции-чле- функции-члена, не содержащей const, особенно если используются временные объекты. Дескрипторы, как и указатели, могут «зависать», и, так же как вы избегаете ви- висящих указателей, следует избегать и висящих дескрипторов. Однако нет причин быть чрезмерно осторожными. В нетривиальной програм- программе нельзя устранить все возможности появления висящих указателей и редко ког- когда можно полностью гарантировать отсутствие висящих дескрипторов. И все- таки, если вы будете избегать возвращения дескрипторов без особых на то причин, от этого выиграют и ваши программы, и ваша репутация.
Классы и функции: реализация Правило 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 kaddress; }
Правило 30 private: Address address; Address *addrPtr = scott .personAddress () ; // Та же проблема, что и вьлс. При использовании указателей необходимо обращать внимание не только на члены классов, но и на функции-члены. Дело в том, что вы можете вернуть указа- указатель и на функцию-член: class Person; // Предварительное объявление. // PPMF указатель на функцию-член класса Person. typedef void (Person::*PPMF) () ; class Person { public: static PPMF verificationFunctionO { return SPerson: .-verifyAddress; } private: Address address; void verifyAddress () ; }; Если вы до сих пор не сталкивались с указателями на функции-члены, то объявление Person: : verif icationFunct ion может показаться устрашающим. Не стоит пугаться. Все, что там написано, - это: a verif icationFunction — функция-член, не требующая аргументов; а возвращаемое значение - указатель на функцию-член класса Person; а функция, на которую указывает указатель (то есть значение, возвращаемое verif icationFunction), не требует параметров и ничего не возвращает, то есть void. Что же касается слова static, оно означает то же, что и всегда при объявлении членов классов: для всего класса существует только одна копия члена, и к ней мож- можно получить доступ, не используя объекта. О подробностях справьтесь в своем учебнике по C++. (Если ваш учебник по C++ не содержит информации о стати- статических членах класса, разорвите его и сдайте в макулатуру, а затем одолжите или купите лучший.) В данном примере verifyAddress - закрытая функция. Об этой детали реа- реализации класса должны быть «осведомлены» только члены класса и, конечно дру- друзья. Однако открытая функция-член verif icationFunction возвращает ука- указатель на verifyAddress, поэтому пользователи могут сделать что-нибудь вроде: PPMF pmf = scott.verificationFunction() ,- (scott.*pmf)(); // To же самое, что и вызов scott.verifyAddress. Здесь pmf становится синонимом Person: :verifyAddress с той лишь су- существенной разницей, что нет никаких ограничений на его использование.
Классы и функции: реализация Иногда из соображений производительности бывает чрезвычайно важно на- написать функцию-член, возвращающую ссылку или указатель на член класса с более ограниченным доступом. В то же время жертвовать ограничениями досту- доступа, даваемыми 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 Rationale operator*(const Rationale lhs, const Rationalk rhs); }; // Некорректная реализация operator*, inline const Rationale operator*(const Ratior.al& lhs, const Rational& rhs) { Rational result(lhs.n * rhs.n, lhs.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 Rationalb Ihs, const Rationale rhs) { // Создаем в куче новый объект. Rational * result = new Rational(lhs.n * rhs.n, Ihs.d * rhs.d); // Возвращаем его. return *rcsult; } При таком подходе вы действительно избегаете проблемы, рассмотренной в предыдущем примере, но на ее месте создаете новую. Для того чтобы предот- предотвратить утечки памяти в программном обеспечении, вы должны быть уверены, что каждому указателю, созданному с помощью new, соответствует вызов delete. Загвоздка лишь в том, кто будет отслеживать, чтобы каждому вызову new соответствовал вызов delete? Очевидно, что ответственность за вызов delete лежит на том, кто вызвал operator*. На практике же с такого рода ответственностью кос у кого из програм- программистов дело обстоит совершенно безнадежно. Для пессимизма есть две причины. Во-первых, хорошо известно, что программисты - большие разгильдяи. Не то чтобы вы или я были разгильдяями, но редко кто из нас не встречался по работе с людьми, так сказать, немного рассеянными. И стоит ли надеяться, что такие спе- специалисты (мы-то с вами знаем - они существуют) запомнят, что всякий раз, ког- когда они вызывают operator*, им следует получить возвращаемый адрес и затем
Классы и функции: реализация использовать для него delete? Другими словами, что они должны использовать operator* следующим образом: cons!; Rationale four = two * two; // Получаем разыменованный // указатель, сохраняем // ссылку на него. delete Stfour; // Получаем указатель и удаляем его. Шансы на это близки к нулю. Помните, что даже если один-единственный пользователь operator* не будет следовать этим правилам, произойдет утечка памяти. Образование недоступных указателей - вторая и более серьезная проблема, от которой не застрахованы даже самые сознательные программисты. Часто резуль- результат действия функции operator* - временное промежуточное значение, объект, создаваемый только ради вычисления большего выражения. Например: Rational one(I) , twoB), threeC), fourD); 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& terr.p2 = tempi * three; const Rationale temp3 = temp2 * four; delete ktempl; delete &temp2; delete &temp3; Лучшее, на что можно надеяться, - это то, что вас проигнорируют. Более веро- вероятно, что с вас живьем снимут кожу или приговорят к десяти годам тяжелой рабо- работы по написанию микрокоманд для вафельниц или тостеров. Поэтому лучше усвоить для себя следующий урок: написание функций, воз- возвращающих разыменованные указатели, - это путь, ведущий к утечке памяти. Кстати, если вы думаете, что нашли способ избежать неопределенности пове- поведения, присущей возврату ссылки на локальный объект, а вместе с тем и потенци- потенциальной утечки памяти, сопутствующей возврату ссылки на объект, размещае- размещаемый в куче, - вернитесь к правилу 23 и прочитайте, почему возврат ссылки на локальный статический объект также работает некорректно. Это может уберечь вас от вредных для здоровья попыток «достать левое ухо через правое плечо».
Правило 32 Правило 32. Откладывайте определение переменных до последнего момента Итак, пас обратили в религию, проповедуемую С: переменные должны опре- определяться в начале блока. Откажитесь от нее! В C++ это необязательно, неестест- неестественно и «дорого». Помните, что когда вы определяете типизированную переменную с конструкто- конструктором или деструктором, то всякий раз при достижении места определения принима- принимаете на себя расходы по созданию объекта и по удалению переменной при выходе из области видимости. Это означает, что появление всякой (в том числе и неиспользу- неиспользуемой) переменной влечет за собой некоторые расходы. Поэтому надо стремиться избегать этого везде, где только возможно. При вашем опыте и искушенности в программировании вы, вероятно, полага- полагаете, что никогда не определяете неиспользуемых переменных, и поэтому совет, из- изложенный в настоящей главе, к вашему лаконичному и сдержанному стилю про- программирования не относится. Возможно, вам есть смысл еще раз задуматься над этим. Рассмотрим следующую функцию, возвращающую зашифрованную версию пароля, если его длина превышает минимально допустимую. Если пароль черес- чересчур короток, функция генерирует исключение типа logic_error, определенное в стандартной библиотеке C++ (см. правило 49): // Эта функция слишком рано определяет перекенную encrypted. string encryptPassword(const strings password) { string encrypted; if (password.length() < MINIMUM_?ASSWORD_LENGTH) { throw logic_error("Password is too short"); } здесь вы делаете то, что необходимо для получения в encrypted зашифрованного пароля return encrypted; } Объект encrypted назвать совершенно неиспользуемым нельзя, но при гене- генерации исключения он не используется. Следовательно, создание и удаление encrypted ляжет на вас, даже если encryptPassword сгенерирует исключение. Поэтому определение encrypted было бы лучше отложить до того момента, ког- когда станет ясно, что это необходимо: // Данная функция воздерживается от определения encrypted, // пока это не станет действительно необходимо, string encryptPassword(const strings password) { if (password.length)) < MINIMUM_PASSWORO_LENGTH) { throw logic_error("Password is too short"); } string encrypted; здесь вы делаете то, что необходимо для получения в encrypted зашифрованного пароля 5зак. 125
Классы и функции: реализация return encrypted; Написанный выше код все еще не столь компактен, как хотелось бы, поскольку encrypted объявляется без аргументов инициализации. Это означает, что во вре- время его выполнения будет использован конструктор по умолчанию. Во многих слу- случаях первое, что вы делаете с объектом, - даете ему некоторое значение, часто по- посредством присваивания. В правиле 12 объясняется, почему создание объекта конструктором но умолчанию и последующее присваивание значения намного ме- менее эффективно, чем инициализация желаемым значением. Аналогичные рассуж- рассуждения применимы и здесь. Например, предположим, что основная часть работы encryptPassword выполняется в функции: 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. Следует не просто откладывать определение переменной до того, как вам необхо- необходимо будет ее использовать; вы должны попытаться отложить определение функ- функции до тех пор, пока не появятся аргументы инициализации. Поступая подобным образом, вы не только избегаете создания и удаления не- ненужных объектов, но и отказываетесь от использования бессмысленных конструк- конструкторов по умолчанию. Более того, инициализируя переменные в контексте, кото- который проясняет их значение, вы способствуете документированию переменной.
Правило 33 Помните, как после каждого определения переменной в С вам советовали писать короткие комментарии, объясняющие, для чего она используется? Комбинируйте подходящие названия переменных (см. правило 28) с понятными из контекста аргументами инициализации, и вы реализуете мечту любого программиста: у вас будет убедительная аргументация в пользу исключения отдельных комментариев. Откладывая определение переменных, вы улучшаете эффективность програм- программы, делаете ее более понятной и уменьшаете необходимость в документировании смысла переменных. Похоже, пришла пора распрощаться с длинными списками определений переменных в начале блоков. Правило 33. Тщательно обдумывайте использование встраиваемых функций Встраиваемые функции... какая замечательная идея! Они выглядят подобно функциям, они работают подобно функциям, они намного лучше, чем макросы (см. правило 1). Их можно вызывать, не опасаясь накладных расходов функцио- функционального вызова. Чего еще желать? В действительности вы получаете больше, чем рассчитывали, поскольку воз- возможность избежать затраты функционального вызова - это только полдела. Оп- Оптимизация, выполняемая компилятором, обычно наиболее эффективна на участ- участках кода, не содержащих вызовов функций. Таким образом вы даете компилятору возможность выполнять оптимизацию тела встраиваемой функции в зависимос- зависимости от контекста, в который она попадает. При использовании «обычного» функ- функционального вызова такая оптимизация была бы невозможна. Все же давайте не будем слишком увлекаться. В программировании, как и в ре- реальной жизни, не бывает «бесплатных завтраков», и встраиваемые функции здесь не исключение. Идея их использования состоит в замене каждого вызова такой функции ее телом. Не надо быть доктором математических наук, чтобы за- заметить, что это увеличит общий размер вашего объектного кода. Слишком час- частое применение встраиваемых функций на машинах с ограниченной памятью приводит к созданию программы, размер которой превосходит доступную па- память. Даже при наличии виртуальной памяти раздувание кода, вызванное при- применением встраиваемых функций, может привести к патологиям (пробуксовкам, thrashing) в механизме подкачки страниц, тормозящим выполнение программы. (Это, однако, отличная разминка для контроллера диска.) Злоупотребление встраиваемыми функциями может также уменьшить коэффициент попадания кэша команд процессора, что в свою очередь снизит скорость извлечения команд из кэша в основную память. С другой стороны, если тело функции очень короткое, код, генерируемый для тела функции, может на самом деле быть короче кода, генерируемого для вызова функции. В таком случае встраивание функции может в действительнос- действительности вести к уменьшению объектного кода и к более высокому коэффициенту попа- попадания кэша! Следует иметь в виду, что директива inline, так же как и register, - это совет, а не команда компилятору. В результате всякий раз, когда компилятор сочтет 5*
Классы и функции: реализация необходимым, он может игнорировать вашу директиву встраивания. И вынудить его к этому достаточно просто. Например, большинство компиляторов отказывает- отказывается встраивать сложные функции (в частности, содержащие циклы или рекурсии), и, за исключением наиболее тривиальных случаев, вызов виртуальной функции от- отменяет встраивание. (В этом нет ничего удивительного: virtual значит «вызов конкретной функции определяется в момент выполнения», a inline - «в процессе компиляции замените вызов функции ее кодом». Если компилятор не «знает», какой вызов функции осуществлять, его трудно упрекнуть в том, что он отказыва- отказывается делать встраивание функции.) Все это в конечном счете сводится к следую- следующему: от реализации используемого компилятора зависит, встраивается ли в дей- действительности встраиваемая функция. К счастью, большинство компиляторов обладает достаточными диагностическими возможностями и выдает предупреж- предупреждения (см. правило 48) в том случае, если не может выполнить заказанное вами встраивание. Предположим, вы написали некоторую функцию f и объявили ее inline. Что получится, если компилятор по какой-либо причине решит отказаться от встра- встраивания функции? Очевидный ответ заключается в том, что f не будет рассматри- рассматриваться как встраиваемая функция: ее код сгенерируется так, как если бы это была нормальная (невстраивасмая) функция, а вызов f будет соответствовать обычно- обычному вызову функции. Теоретически все должно произойти именно так, но мы рассматриваем как раз тот случай, когда практика может сильно отличаться от теории. Данная ситуа- ситуация объясняется тем, что столь тонкое решение проблемы, связанной с не- встраиваемыми inline-функциями, было предложено в процессе стандартиза- стандартизации C++ относительно поздно. Более ранние спецификации языка (такие, как ARM - см. правило 50) предписывали компиляторам другое поведение, и «старо- «старорежимное» поведение все еще является достаточно распространенным, поэтому следует понимать, в чем оно заключается. Немного подумав, вы обнаружите, что определения встраиваемых функций практически всегда размещаются в заголовочных файлах. Это позволяет включать одни и те же заголовочные файлы в несколько единиц трансляции (исходных фай- файлов) для использования преимуществ, предоставляемых встраиваемыми функ- функциями. Вот пример, в котором я придерживаюсь соглашения о том, что исходные файлы оканчиваются на . ерр - это, наверное, наиболее распространенное в мире C++ соглашение об именах файлов: // Это файл example.h. inline void f(){...} // Определение f . // Это файл sourcel.cpp. #include "example.h" // Включает определение f. ... // Содержит вызовы f. // Это файл source2.cpp. #include "exair.ple.h" // Тоже включает определение f . ... // Также вызывает f.
Правило 33 В соответствии со старым правилом нсвстраиваемых inline-фумкний, если f не встраивается при компиляции sourcel. срр, то результирующий объектный файл содержит функцию, называемую f, так, как будто f и не определялась inline. Аналогично при компиляции source2.cpp генерируемый объектный файл также будет содержать функцию, называемую f. Когда вы попытаетесь ском- скомпоновать эти два объектных файла, компоновщик вполне обоснованно выдаст вам сообщение: программа содержит два определения f, что является ошибкой. Для разрешения данной проблемы старые правила требуют, чтобы компиля- компиляторы рассматривали невстраиваемые inline-функции так, как будто они были объявлены со static, то есть локально для компилируемого в данный момент файла. В только что рассмотренном примере компилятор, следуя старым прави- правилам, при компиляции файла sourcel .срр будет рассматривать f как статичес- статическую функцию так же, как и при компиляции source2 . срр. Такой подход разре- разрешает проблему компоновки, но «не бесплатно»: каждая единица трансляции, содержащая определение (и вызывающая f), содержит свою собственную стати- статическую копию f. Если в f входит объявление статических переменных, то каждая копия также содержит и свою собственную копию переменных, что, несомненно, удивит программистов, полагающих, что static внутри функции означает «толь- «только один экземпляр». Это ведет к удивительному открытию. И в соответствии со старыми, и в соот- соответствии с новыми правилами, если встраиваемая функция не встраивается, вы все равно «платите» за функциональный вызов, а в соответствии со старым пра- правилом можете даже увеличить размер объектного файла, поскольку каждая еди- единица трансляции, содержащая вызов f, содержит также свою копию кода f и ее статических переменных! (Еще больше положение осложняет то, что каждая ко- копия f со своими статическими переменными имеет тенденцию располагаться на отдельной странице виртуальной памяти, поэтому обращения к различным копиям f могут повлечь за собой один или несколько страничных отказов - page fault.) Более того, иногда вашим бедным компиляторам приходится генерировать тело встраиваемой функции даже в тех случаях, когда они всей душой хотели бы се встроить. В частности, если ваша программа берет адрес встраиваемой функ- функции, то компилятор должен генерировать ее тело. Как иначе вы можете получить указатель функции, когда она не существует? inline void f(){...} // Как выше. void (*pf) () = f; // pf указывает на f. int main() { f(); // Встраиваемый вызов f. pf(); // Невстраиваемый вызов f посредством pf . В данном случае вы оказываетесь в достаточно парадоксальной ситуации, ког- когда вызываемая функция f является встраиваемой, но в соответствии со старыми
Классы и функции: реализация правилами каждая единица трансляции, требующая адреса 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 = :toperator 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 требуют только перекомпоновки. Это намного менее обременительная операция, чем перекомпиляция, а если библиотека, содер- содержащая функцию, загружается динамически, то изменения могут остаться для пользователей незамеченными.
Классы и функции: реализация При разработке программного обеспечения важно иметь в виду все вышепе- вышеперечисленные соображения, но с практической точки зрения при написании кода один факт доминирует над всеми остальными: у большинства отладчиков возни- возникают проблемы со встраиваемыми функциями. Это совсем не удивительно. Как установить точки останова в функции, которой нет? Как трассировать такую функцию? Как перехватывать ее вызовы? Если вы не проявите чрезвычайную изобретательность, вам не удастся выполнить ничего по- подобного. К счастью, проблемы с отладкой дают нам логически обоснованную стра- стратегию для определения, какую функцию нужно объявлять inline, а какую - нет. Первоначально никакие функции не следует делать встраиваемыми, или, по крайней мере, нужно ограничиться самыми тривиальными функциями, вроде при- приведенной ниже функции age: class Person { public: int age() const { return personAge; } private: int personAge; Применяя встраиваемые функции с должной аккуратностью, вы не только по- получаете возможность пользоваться отладчиком, но и определяете встраиванию подобающий удел: тонкая оптимизация вручную. Не забывайте об эмпирическом правиле «80-20», которое утверждает, что программа тратит 80% времени на вы- выполнение 20% кода. Это важное правило, поскольку оно напоминает, что цель раз- разработчика программного обеспечения - идентифицировать те 20% кода, которые действительно способны увеличить производительность программы. Можно до бес- бесконечности оптимизировать и объявлять функции inline, но все это будет про- простой тратой времени, пока вы не сделаете этого с нужными функциями. Как только вы идентифипировали наиболее важные функции приложения, - те, для которых встраивание в самом деле может сыграть определенную роль (на- (набор этих функций зависит от используемой вами архитектуры), - без колебаний объявляйте их inline. В то же время отслеживайте проблемы, возникающие при чрезмерном увеличении профаммного кода, а также следите за предупреждениями компилятора (см. правило 28),. указывающими, что функции не были встроены. При разумном использовании встраиваемые функции - неоценимый инстру- инструмент программиста на C++, но, как было продемонстрировано выше, они не на- настолько просты и однозначны, сколь могло показаться. Правило 34. Уменьшайте зависимости файлов при компиляции Рассмотрим самую обыкновенную ситуацию. Вы открываете свою программу на C++ и вносите незначительные изменения в реализацию класса. Заметьте, не в ин- интерфейс, а просто в реализацию - только в закрытые члены. Далее вы начинаете
Правило 34 перестраивать программу, рассчитывая, что это займет лишь несколько секунд, по- поскольку был модифицирован только один класс. Вы щелкаете по Rebuild или на- набираете make (либо какой-то эквивалент) и... удивлены, а затем подавлены, когда обнаруживаете, что перекомпилируется и заново компонуется весьмир\ Не правда ли, вам это скоро надоест? Проблема связана с тем, что C++ не проводит сколько-нибудь значительного различия между интерфейсом и реализацией. В частности, определения классов включают в себя не только спецификацию интерфейса, но также и целый ряд де- деталей реализации. Например: class Person { public: Person(const strings name, const Dates birthday, const Address& acdr, const Councryk country); virtual -Person(); ...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, вы найдете что-нибудь подобное: ttinclude <string> // Для типа string (см. правило 49). #include "date.h" #include "address.h" #include "country.h" К сожалению, это устанавливает зависимости между файлом, определяющим Person, и включаемыми файлами. В результате, если любой из этих вспомогательных классов изменит свою реализацию, или если изменится реализация любого из классов, от которых они зависят, то файл, содержащий Person, необходимо будет перекомпили- перекомпилировать, а вместе с ним и все остальные файлы, использующие класс Person. Для полыо- вателей Person это может быть более чем обременительным и поэтому совершенно неприемлемым.
Классы и функции: реализация Можно задаться вопросом: почему C++ настаивает на размещении деталей реализации в определениях классов? Почему, например, нельзя определить Person следующим образом: class string; // "Концептуальное" предварительное объявление // для типа string (подробнее см. правило 49) . class Date; // Предварительное объявление, class Address; // Предварительное объявление, class Country; // Предварительное объявление, class Person { public: Person(const strings name, const Date& birthday, const Addressk addr, const Country& country); virtual -Person(); ...II Конструктор копирования, operator=. string name() const; string birthDateO const; string address)) const; string nationality() const; }; Разве нельзя указать детали реализации класса в другом месте? Если бы это было возможно, пользователи Person должны были бы его перекомпилировать только при изменении его интерфейса. Поскольку при работе над большим про- программным проектом интерфейс, как правило, четко оформляется прежде, чем реа- реализация, такое отделение интерфейса от реализации могло бы сэкономить многие часы перекомпиляции и перекомпоновки. Увы, этот идеалистичный сценарий не выдерживает столкновения с реальным миром, что иллюстрирует следующий пример: int main() { int x; // Определяем целое. Person p(...); // Определяем Person // (аргументы для простоты опущены) . Когда компилятор обнаруживает определение х, он «понимает», что должен выделить пространство, достаточное для размещения int. Нет проблем: каждый компилятор «знает», какова длина int. Встречая определение р, он, безусловно, «учитывает» тот факт, что нужно выделить место, необходимое для Person, но откуда ему «знать», сколько именно места потребуется? Единственный способ получить эту информацию — справиться в определении класса, но если бы в опре- определении классов можно было опускать детали реализации, как компилятор «вы- «выяснил» бы, сколько памяти необходимо выделить? В принципе это не является непреодолимой трудностью. Языки, подобные Smalltalk, Eiffel, Java, легко обходят ее. Способ, которым они пользуются, - выде- выделение достаточного пространства для указателей на определяемый объект. Иначе
Правило 34 говоря, эти языки интерпретируют вышеприведенный код так, как если бы по- последний был написан следующим образом: int main() { int x,- // Определяем целое. Person *p; // Определяем указатель на Person. Легко заметить, что это вполне законный код на C++. Оказывается, вы и сами можете имитировать «сокрытие реализации объекта указателем». Приведем пример, иллюстрирующий возможность применения рассматрива- рассматриваемого приема для отделения интерфейса класса Person от его реализации. Во- первых, в заголовочном файле Person располагается следующее: // Компиляторам для конструктора Person по-прежнему / / необходима информация об этих типах. class string; // В правиле 49 рассказывается, почему это // неверное объявление для string, class Date; class Address; class Country; // Класс Personlmpl содержит детали реализации объекта Person; // это просто предварительное объявление имени класса, class Personlmpl; class Person { public: Person(const strings name, const Date& birthday, const Address& addr, const Countryk country); virtual -Person(); ...II Конструктор копирования, operator=. string name() const; string birthDate() const; string address() const; string nationality() const; private: Personlmpl *impl,- // Указатель на реализацию. }; Теперь пользователи класса Person не имеют никакого понятия об устрой- устройстве строк, дат, адресов и национальностей. Эти классы можно необходимым об- образом модифицировать, оставляя пользователей Person в блаженном неведении происходящего. Более того, пользователи могут счастливо избежать и перекомпи- перекомпиляции. Далее, поскольку они не видят подробности реализации Person, они вряд ли напишут код, зависящий от этих деталей. Вот настоящее разделение интерфей- интерфейса и реализации! Ключом к такому разделению служит замена зависимостей между определения- определениями классов и объявлениями классов. Это все, что вам необходимо знать об уменьше- уменьшении зависимостей. Каждый раз, когда это целесообразно, делайте заголовочные
Классы и функции: реализация файлы самодостаточными; в противном случае используйте зависимость между объявлениями, а не определениями классов. Все остальное вытекает из только что изложенной стратегии проектирования. Сформулируем три практических следствия: а избегайте использования объектов, если есть шанс обойтись ссылками или указателями. Вы можете определить ссылки и указатели, имея только объяв- объявление этого типа. Определение объектов требует наличия определения типа; ? по возможности используйте объявления, а не определения классов. Обратите внимание, что для объявления функции, использующей некоторый класс, вам никогда не требуется определение этого класса, даже если функция по- получает или возвращает объект класса по значению: class Date; // Объявление класса. Date returnADate() // Правильно, необходимость void takeADate(Date d) ; //в определение отсутствует. Как правило, передача объекта по значению - не очень хорошая идея (см. правило 22), но если по той или иной причине вы будете вынуждены ею вос- воспользоваться, это никак не оправдает введение ненужных зависимостей. Если вы были удивлены, узнав, что такие объявления для returnADate и takeADate компилируются, не требуя определения Date, вы не одиноки: в свое время удивился и я. Все, однако, не так уж странно, поскольку опре- определение Date должно быть доступным, когда кто-либо вызывает данные функции. Да, я знаю, о чем вы думаете: зачем определять функции, которые никто не вызывает? Ответ прост. Дело не в том, что никто не вызывает эти функции, а в том, что их вызывают не все. Например, если у вас имеется биб- библиотека, содержащая сотни объявлений функций, возможно разнесенных но пространствам имен (см. правило 28), маловероятно, что каждый пользова- пользователь вызовет каждую функцию. Таким образом можно не навязывать пользо- пользователю искусственную зависимость от определений типов, которые в дей- действительности ему не требуются; бремя ответственности за определения классов переносится (посредством директивы #include) с объявлений функций в файлах заголовков на файлы, содержащие вызовы функций; ? не включайте в ваш заголовочный файл те файлы, которые не нужны для его компиляции. Вместо этого вручную объявите необходимые вам классы, и пусть пользователи сами включают дополнительные файлы, необходимые для компиляции их кода. Некоторые пользователи могут начать жаловать- жаловаться на «причиненные неудобства», но будьте уверены: вы гораздо больше экономите их усилия, чем доставляете им хлопот. В действительности этот прием считается настолько удачным, что он нашел применение в стандарт- стандартной библиотеке C++ (см. правило 49): заголовочный файл <iosfwd> со- содержит объявления (и только объявления) типов библиотеки потоков вво- ввода-вывода. Классы, которые подобно Person содержат только указатели на реализации, часто называются классами-дескрипторами или конвертами. (В первом случае класс, на который они указывают, именуется телом, а во втором - письмом.)
Правило 34 Иногда вы можете услышать, что такие классы называют Чеширскими Котами (в честь кота из сказки «Алиса в стране чудес», который при желании мог после исчезновения оставить только свою улыбку). Ответ на вопрос, каким образом работают классы-дескрипторы, прост: они перенаправляют все вызовы функций соответствующим классам-телам, а послед- последние и производят всю реальную работу. Вот как были бы реализованы функции- члены класса Person: #include "Person.h" // Поскольку мы реализуем класс Person, мы должны // включить определение этого класса. tinclude "Personlmpl.h" // Мы должны также включить определение класса // Personlmpl, иначе мы не сможем вызывать // его функции-члены. Заметьте, что PersonlT.pl // имеет в точности те же самые функции-члены, // что и Person: их интерфейсы идентичны. Person::Person(const strings name, const Date& birthday, const Address^ addr, const Countryk 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 -Person О; virtual string name() const = 0; virtual string birthDate() const = 0; virtual string address () const = 0; virtual string nationality () const = 0; }; Пользователи этого класса Person должны программировать в терминах ука- указателей и ссылок на Person, поскольку инстанцировать класс, содержащий чисто
Классы и функции: реализация виртуальные функции, невозможно. (Однако возможно инстанцировать классы, производные от Person - об этом см. ниже.) Пользователям классов-протоколов, как и пользователям классов-дескрипторов, нет нужды проводить перекомпиля- перекомпиляцию до тех пор, пока не изменяется интерфейс класса-протокола. Конечно, пользователи класса-протокола должны иметь некоторый способ соз- создания новых объектов. Обычно они это делают, вызывая функцию, исполняющую роль конструктора для спрятанных (производных) классов - тех, которые ин- станцируются. Такие функции называют по-разному (например, функциями-фаб- функциями-фабриками или виртуальными конструкторами), но все они действут одинаково: воз- возвращают указатели на динамически размещаемые объекты, поддерживающие интерфейс классов-протоколов. Такую функцию можно было бы объявить сле- следующим образом: // makePerson - это "виртуальный конструктор" (также называемый // "функция-фабрика") для объектов, поддерживающих интерфейс // класса Person. Person* makePerson(const string& name, // Возвращает указатель const Date& birthday, //на новый объект Person, const Addressb addr, // инициализированный const Countryb country); // с данными аргументами. а использовать так: 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: ... // Как выше. // Теперь makePerson - член класса. static Person * makePerson(const strings name, const Date& birthday, const AddressSc addr,
Правило 34 const CountryS country) ; }; Это поможет избежать загромождения глобального пространства имен - или любых других пространств имен - множеством функций такого рода (см. также правило 28). Конечно, в какой-то момент времени должны быть реализованы конкретные классы, поддерживающие интерфейс класса-протокола, и вызваны настоящие конструкторы. Все это скрыто внутри файлов, реализующих виртуальные кон- конструкторы. Например, класс-протокол Person мог бы иметь конкретный произ- производный класс RealPerson, обеспечивающий реализацию наследуемых виртуаль- виртуальных функций: class RealPerson: public Person { public: RealPerson(const strings name, const Dates birthday, const AddressS addr, const CountryS country) : name_(name), birthday_(birthday), address_(addr), country_(country) {} virtual -RealPerson() {} string name() const; // Реализация этих string birthDatet) 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 Countryb country) { return new RealPerson(name, birthday, addr, country); } RealPerson демонстрирует один из двух наиболее распространенных меха- механизмов реализации классов-протоколов: он наследует спецификацию своего ин- интерфейса от класса-протокола (Person), а затем реализует функции этого интер- интерфейса. Второй способ реализации класса-протокола использует множественное наследование, которое подробно рассматривается в правиле 43. Итак, классы-дескрипторы и классы-протоколы отделяют интерфейс от реа- реализации, тем самым уменьшая зависимость между файлами при компиляции.
Классы и функции: реализация Теперь, я уверен, вы ждете примечания мелким шрифтом: «Во сколько обойдет- обойдется этот хитрый фокус». Цена вполне обычная в мире программирования: неко- некоторое уменьшение скорости выполнения программы плюс дополнительный рас- расход памяти для каждого из объектов. Применительно к классам-дескрипторам функции-члены должны использо- использовать указатель на реализацию, чтобы добраться до данных самого объекта. Для каждого обращения это добавляет один уровень косвенной адресации. Кроме того, к количеству памяти, требуемому для хранения каждого объекта, вам необ- необходимо добавить размер указателя. И наконец, указатель на реализацию должен быть инициализирован (в конструкторе класса-дескриптора), чтобы он указывал на динамически размещаемый объект реализации; следовательно, вы навлекаете на себя еще и «накладные расходы», сопровождающие динамическое выделение памяти и последующее ее высвобождение (см. правило 10). Для классов-протоколов каждый функциональный вызов виртуален, поэто- поэтому всякий раз при вызове функции вы платите за косвенный переход (см. пра- правило 14). Кроме того, классы, производные от класса-протокола, должны содер- содержать указатель на таблицу виртуальных функций (и снова см. нравило 14). Этот указатель может увеличить количество памяти, необходимое для хранения объекта, в зависимости от того, является ли класс-протокол единственным ис- источником виртуальных функций объекта. Наконец, ни классы-дескрипторы, ни классы-протоколы не могут извлечь вы- выгоду из использования встраиваемых функций. Для практического применения встраиваемых функций требуется доступ к деталям реализации, а именно его классы-дескрипторы и классы-протоколы призваны в первую очередь ограничить. Однако было бы серьезной ошибкой отказываться от классов-дескрипторов и классов-протоколов просто потому, что их использование связано с «дополни- «дополнительными расходами». То же самое можно сказать и о виртуальных функциях, но вы ведь не отказываетесь от их применения. (В противном случае вы читаете не ту кишу.) Рассмотрите возможность использования предлагаемых приемов в про- процессе эволюции ваших программ. Применяйте классы-дескрипторы и классы- протоколы в процессе разработки для того, чтобы умеиьшить влияние изменений в реализации на пользователей. Если вы можете показать, что различие в скоро- скорости и/или размере настолько существенно, что во имя повышения эффективно- эффективности оно оправдывает увеличение зависимости между классами, то на конечной стадии реализации заменяйте классы-дескрипторы и классы-протоколы конкрет- конкретными классами. Надеюсь, однажды появятся средства, позволяющие выполнять этот тип преобразования автоматически. Умелое использование классов-дескрипторов, классов-протоколов и конкрет- конкретных классов позволит вам разрабатывать эффективно выполняемое и легко моди- модифицируемое программное обеспечение, но при этом появляется новый серьезный недостаток: вы лишаете себя перекуров на время перекомпиляции ваших программ.
Глава 6. Наследование и объектно-ориентированное проектирование Многие полагают, что наследование - это основа объектно-ориентированного программирования. Действительно ли это так - вопрос спорный, но количество правил в других разделах данной книги должно убедить вас, что для эффектив- эффективного программирования на C++ в вашем распоряжении имеется гораздо больше средств, чем просто определение того, какие классы от каких наследуют. Тем не менее проектирование и реализация иерархии классов не имеет анало- аналогии в мире С. Несомненно, именно в сфере наследования и объектно-ориентиро- объектно-ориентированного программирования вы с наибольшей вероятностью можете столкнуться с необходимостью переосмысления своего подхода к разработке программного обеспечения. Более того, C++ предоставляет широкий набор объектно-ориенти- объектно-ориентированных «строительных блоков», включая открытые, защищенные и закрытые базовые классы, виртуальные и невиртуальные базовые классы, виртуальные и не- невиртуальные функции-члены. Все эти возможности взаимодействуют не только одна с другой, но также и с другими компонентами языка. В результате, пытаясь понять, что означает каждый из этих инструментов, когда он может быть исполь- использован и как взаимодействует с теми аспектами C++, которые не являются объек- объектно-ориентированными, вы можете столкнуться с рядом трудностей. Ситуация еще больше осложняется ввиду того обстоятельства, что различные средства языка на первый взгляд служат практически одинаковым целям. Приве- Приведем некоторые примеры: 1. Вам нужен набор классов, у которых имеется множество общих характерис- характеристик. Следует ли использовать наследование и производить все эти классы из общего базового класса или требуется применять шаблоны и генериро- генерировать их на основе общего скелета кода? 2. Класс Л должен быть реализован с помощью класса В. Должен ли А содер- содержать элемент данных типа В, или Л надо использовать закрытое наследова- наследование от класса В? 3. Вам необходимо спроектировать типизированный гомогенный контейнер- контейнерный класс, отсутствующий в стандартной библиотеке. (Список контейнеров, содержащихся в стандартной библиотеке, приведен в правиле 49.) Следует ли вам использовать шаблоны, или более целесообразно создать интерфейс с контролем типов для класса, который сам реализован с использованием не- тинизированных (void*) указателей?
Наследование и ООП В правилах этого раздела я даю подсказки, как решать задачи такого рода, хотя и не беру на себя смелость осветить псе вопросы объектно-ориентированного про- программирования. Вместо этого я сосредоточиваюсь на объяснении того, что озна- означают различные возможности C++ или что в действительности происходит, когда вы используете ту или иную возможность. Например, открытое наследование оз- означает «класс есть разновидность класса» (см. правило 35), и, если вы попытае- попытаетесь использовать его иначе, вас ожидают проблемы. Аналогично виртуальная функция означает «интерфейс должен быть наследован», а невиртуальная - «на- «наследуется и интерфейс, и реализация». Недопонимание разницы между этими значениями сослужило плохую службу многим программистам. Когда вы осознаете смысл разнообразных конструкций C++, ваш подход к объектно-ориентированному программированию станет совсем другим. Из упражнения по сравнению языковых конструкций процесс проектирования пре- превратится в раздумье над тем, что вы хотите сказать о вашей программе. Как толь- только это станет ясно, вы без труда сможете перевести свой замысел в соответствую- соответствующие конструкции C++. Исключительно важна возможность говорить то, что вы думаете, и понимать то, что говорите. Нижеследующие правила посвящены подробному исследова- исследованию средств эффективного достижения этой цели. Правило 44 обобщает соотно- соотношения между объектно-ориентированными конструкциями C++ и их значением. Оно служит подходящим завершением этого раздела, а также кратким справоч- справочником для дальнейшего использования. Правило 35. Используйте открытое наследование для моделирования отношения «есть разновидность» В своей книге «Кто-то должен бодрствовать, пока остальные спят» (Some must watch while some must sleep. W. H. Freeman and Company, 1974) Вильям Демент (Wil- (William Dement) рассказывает о том, как он пытался донести до студентов наиболее важные идеи своего курса. Утверждается, говорил он своей ipynne, что средний анг- английский школьник помнит из уроков истории только то, что битва при Гастингсе произошла в 1066 году. Даже если ученик почти ничего не запомнил из курса исто- истории, 1066 год остается в его памяти. Демент пытался внушить слушателям несколь- несколько основных идей, в частности ту любопытную истину, что таблетки против бес- бессонницы вызывают бессонницу. Он призывал своих студентов хранить в памяти ряд ключевых фактов, даже если забудется все, что обсуждалось на протяжении курса, и в течение семестра неоднократно возвращался к нескольким фундаменталь- фундаментальным заповедям. Последним на заключительном экзамене был следующий вопрос: «Напишите, какой факт из тех, которые обсуждались на лекциях, вы запомните на всю жизнь». Проверяя работы, Демент был ошеломлен. Практически все упомянули 1066 год. Теперь я с трепетом хочу провозгласить, что единственным наиболее важным правилом в объектно-ориентированном программировании на C++ является сле- следующее: открытое наследование означает «класс есть разновидность класса». Твердо запомните это.
Правило 35 Если вы пишете, что класс 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 Studentk s) ; // Только студенты учатся. Person p; // p - человек (Person). Student s; // s - студент (Student:) . dance(p); // Нормально, р типа Person. dance(s); // Нормально, s типа Student, а студент // есть разновидность человека. study(s); // Хорошо, study(p); // Ошибка! р не студент. Это верно только для открытого наследования. C++ будет вести себя так, как было описано выше, только если Student является производным классом от Person, который использует открытое наследование. Закрытое наследование означает нечто совершенно иное (см. правило 42), и никто, по-видимому, не зна- знает, что должно означать защищенное наследование. Идея тождества открытого наследования и понятия «есть разновидность» ка- кажется достаточно очевидной, но не всегда все так просто. Иногда интуиция может ввести в заблуждение. Рассмотрим следующий пример: во-первых, пингвин - пти- птица; во-вторых, птицы умеют летать. Простодушно попытавшись выразить это на 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 strings msg); // Определение в другом месте, class Penguin: public Bird i public: virtual void fly() { error("Penguins can'¦_ fly!"); } Интерпретируемые языки, например Smalltalk, часто используют такой подход, но важно осознавать, что при этом утверждается нечто совершенно от- отличное от того, что может показаться на первый взгляд. Вы не говорите: «пин- «пингвины не умеют летать». Вы говорите: «пингвины умеют летать, но с их сторо- стороны было бы ошибкой пытаться делать это». В чем различие? Во времени обнаружения ошибки. Утверждение «пингвин не умеет летать» может быть поддержано на уровне компилятора, а соответствие утверждения «Попытка полета ошибочна для пингвинов» реальному положению дел может быть обнаружено только в момент выполнения программы. Чтобы обозначить ограничение «Пингвины не умеют летать», следует убе- убедиться, что для объектов Penguin эта функция не определена: class Bird { class NonFlyingBird: public Bird { class Penguin: public Nor.FlyirigBird { // Оуккция fly не объявлена. // Функция fly не объявлена // Функг.кя fly но объявлена. Если вы попытаетесь заставить пингвина летать, компилятор сделает вам вы- выговор за нарушение правил: Penguin р; p.fly О; // Ошибка! Это сильно отличается от подхода, принятого в языке Smalltalk, где ком- компилятор не скажет ни слова. Философия C++ принципиально отличается от философии Smalltalk, и до тех пор, пока вы профаммируете на C++, вам лучше следовать его рецептам. Кроме того, обнаружение ошибок на этапе компиляции, а не в момент исполнения, имеет опре- определенные технические преимущества (см. правило 46). Возможно, вы согласитесь с тем, что вам недостает орнитоло- орнитологической интуиции, но вы вполне можете полагаться на свои по- познания в элементарной геометрии, не так ли? Тогда ответьте на сле- следующий простой вопрос: должен ли класс Square (квадрат) открыто наследовать свойства класса Rectangle (прямоугольник)? «Конечно! - скажете вы. - Каждый знает, что квадрат - это прямоугольник, а обратное утверждение в общем случае невер- неверно». Что ж, это правильно, по крайней мерс для школы. Но мы ведь решаем задачи посложнее школьных.
Наследование и ООП Рассмотрим следующий код: class Rectangle { public: virtual void setHeight(int newHeight); virtual void setwidth(inc newWidth); virtual int height!) const; // Возвращают virtual int width() const; // текучие значения. }; void makeBigger (Rectangles r) // Функция увеличивает площадь г. { int oldKoicht = r.heightO; r.setWidth(r .width() + 10); // Увеличить ширину г на 10. assert(r.height() == oldHeight); //Убедиться, что высота } //г неизменна. Ясно, что утверждение assert выполнится всегда. Функция makeBigger из- изменяет только ширину г. Высота остается постоянной. Теперь приведем следующий код, который посредством открытого наследова- наследования позволяет рассматривать квадраты как частный случай прямоугольников: class Square: public Rectangle { ... }; Square s; assert(s.width() == s.height О); // Должно быть справедливо // для всех квадратов. makeBigger(s); // Из-за наследования s есть разновидность Rectangle, // поэтому мы можем узеличить его площадь. assert(s.width () == s.height О); // Все равно должно быть // справедливо для всех квадратов. Как и в предыдущем примере, здесь ясно, что последнее утверждение будет выполняться всегда. По определению ширина квадрата равна его высоте. Но теперь перед нами встает проблема, как следующие утверждения: ? перед вызовом makeBigger высота s равна ширине; а внутри makeBigger ширина s изменяется, а высота нет; ? после выхода из makeBigger высота s снова равна ширине. (Заметьте, что s передается по ссылке, поэтому makeBigger модифицирует s, а не его копию.) Так что же? Добро пожаловать в удивительный мир открытого наследования, где интуиция, приобретенная вами в других сферах науки, включая математику, иногда оказыва- оказывается плохим помощником! Основная трудность в данном случае состоит в том, что некоторые утверждения, справедливые для прямоугольника (его ширина может быть изменена независимо от высоты), не выполняются для квадрата (его ширина и высота должны оставаться одинаковыми). Открытое наследование, тем не менее, предполагает, что все, что применимо к базовому объекту - все\ - также примени- применимо и к объектам производных классов. В ситуации с прямоугольниками и квадрата- квадратами (а также в аналогичных случаях, включая множества и списки из правила 40), это условие не выполняется, поэтому использование открытого наследования для
Правило 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 objection const; class Rectangle: public Shape { ... } ; class Ellipse: public Shape { ... };
Наследование и ООП Shape - это абстрактный класс; таковым его делает чисто виртуальная функ- функция draw. В результате пользователи могут инстанцировать не объекты класса Shape, а только объекты производных от него классов. Тем не менее Shape ока- оказывает сильное влияние на все производные классы, использующие открытое на- наследование, по следующей причине: интерфейс функций-членов наследуется все- всегда. Как объясняется в правиле 35, открытое наследование означает «есть разновидность», поэтому все, что верно для базового класса, также верно и для производных классов. Следовательно, если функция имеет смысл для класса, она остается применимой и для подклассов. В классе Shape объявлены три функции. Первая, draw, выводит текущий объект на дисплей, подразумеваемый по умолчанию. Вторая, error, вызывается функциями-членами, если необходимо сообщить об ошибке. Третья, objectID, возвращает уникальный идентификатор текущего объекта; правило 17 содержит пример использования подобной функции. Каждая из трех функций объявлена по-разному: draw - как чисто виртуальная; error - как просто виртуальная; obj ectID - как невиртуальная функция. Каковы практические последствия этих различий? Рассмотрим вначале чисто виртуальную функцию draw. Две наиболее яркие характеристики чисто виртуальных функций - они должны быть заново объявле- объявлены в любом конкретном наследующем их классе, и в абстрактном классе они обыч- обычно не определяются. Сопоставьте эти два свойства, и вы придете к пониманию сле- следующего обстоятельства: цель объявления чисто виртуальной функции состоит в том, чтобы производные классы наследовали только ее интерфейс. Это в полной мере относится к функции Shape: :draw, поскольку наиболее разумное требование ко всем объектам класса Shape заключается в том, что они должны быть отображены на дисплее, но Shape не может обеспечить разумной реализации этой функции по умолчанию. Алгоритм создания изображения эллип- эллипса очень сильно отличается, например, от аналогичного алгоритма построения прямоугольника. Объявление Shape: : draw можно интерпретировать как следу- следующее сообщение разработчикам подклассов: «Вы должны обеспечить наличие функции draw, но у меня нет ни малейшего представления, как вы это собирае- собираетесь сделать». Между прочим, дать определение чисто виртуальной функции возможно. Иными словами, вы можете создать реализацию для Shape: : draw, и C++ будет ее компилировать, но единственный способ вызвать ее - квалифицировать имя функции названием класса: Shape *ps = new Shape; // Озибха! Shape абстрактный. Shape *psl = new Rectangle; // Хорошо. psl->draw(); // Вызов Rectangle::draw. Shape *ps2 = new Ellipse; // Хорошо. ps2->draw(); // Вызов Ellipse::draw. psl->Shape::draw(); // Вызов Shape::draw. ps2->Shape::draw() ; // Вызов Shape::draw. Кроме перспективы блеснуть перед друзьями-ирограммистамиво время вече- вечеринки, знание этой особенности вряд ли даст вам что-то значительное. Тем не менее,
Правило 36 как вы увидите в дальнейшем, возможность определения чисто виртуальной функ- функции может быть использована в качестве механизма обеспечения более безопасной реализации по умолчанию обычных виртуальных функций. Иногда бывает полезно объявить класс, не содержащий ничего, кроме чисто виртуальных функций. Такой класс-протокол может предоставить производным классам только интерфейс без какой-либо реализации. Классы-протоколы описа- описаны в правиле 34 и еще раз упоминаются в правиле 43. Ситуация с обычными виртуальными функциями несколько отличается от си- ситуации с чисто виртуальными функциями. Как всегда, производные классы насле- наследуют интерфейс функции, но обычные виртуальные функции традиционно обеспе- обеспечивают реализацию, которую производные классы по своему усмотрению могут либо переопределять, либо нет. Если вы на минуту задумаетесь над этим, то пойме- поймете, что цель объявления обычной виртуальной функции - наследовать в производ- производных классах как интерфейс функции, так и ее реализацию по умолчанию. Интерфейс функции Shape: : error говорит о том, что каждый класс должен поддерживать функцию, которую необходимо вызывать при возникновении ошибки, но каждый класс волен обрабатывать ошибки наиболее подходящим для него способом. Если класс не предполагает производить специальные действия, он может просто прибегнуть к обработке ошибок по умолчанию, обеспечиваемой классом Shape. To есть объявление 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 ly определена как
Наследование и ООП виртуальная. При этом во избежание написания идентичного кода для классов Model А и Mode IB в качестве стандартного поведения используется тело функции Airplane: : fly, которую наследуют как ModelA, так и ModelB. Это классический пример объектно-ориентированного проектирования. Два класса имеют общие свойства (способ реализации fly), и, как следствие, общее свойство реализуется в базовом классе и наследуется обоими классами. Благода- Благодаря этому проект явным образом выделяет общие свойства, избегает дублирования кода, благоприятствует проведению будущих модернизаций, облегчает долгосроч- долгосрочную эксплуатацию - иными словами, обеспечивает все то, за что так ценится объектно-ориентированная технология. Программисты компании XYZ Airlines MOiyT собой гордиться. Теперь предположим, что дела XYZ идут в гору, и фирма решает приобрести новый самолет модели С. Модель С отличается от моделей А и в, например, тем, что летает по-другому. Программисты компании XYZ добавляют к иерархии класс для модели С, но в спешке забывают переопределить функцию fly: class ModelC: public Airplane { ... // Функция fly не объявлена. }; Далее они делают что-нибудь в таком роде: Airport JFK(...); // Аэропорт Джона Кеннеди в Нью-Йорке. Airplane *pa = new ModelC; pa->fly(JFK); // Вызов Airplane::fly! Назревает катастрофа: делается попытка летать на объекте ModelC так, как если бы это был объект ModelA или ModelB. Такой образ действий вряд ли мо- может внушить доверие пассажирам. Проблема здесь заключается не в том, что Airplane: : fly ведет себя опре- определенным образом по умолчанию, а в том, что такое наследование допускает неявное применение данной функции для ModelC. К счастью, легко можно пред- предложить подклассам поведение по умолчанию, но не предоставлять им его, если они сами об этом не попросят. Весь фокус состоит в том, чтобы разделить интер- интерфейс виртуальной функции и ее реализацию по умолчанию. Вот один из спосо- способов, который позволяет добиться этого: class Airplane { public: virtual void fly(const Airports destination) = 0; protected: void defaultFly(const Airport!* destination); }; void Airplane::defaultFly(const Airport& destination) { код no умолчанию для полета самолета в заданный пункт назначения - destination
Правило 36 Обратите внимание, что функция Airplane: : fly была преобразована п чис- чисто виртуальную функцию. Это обеспечивает интерфейс для полета. В классе Airplane присутствует и реализация по умолчанию, но теперь она представлена в форме независимой функции, def aultFly. Классы, подобные ModelA и ModelB, которые хотят использовать поведение по умолчанию, просто применяют встраива- встраиваемый вызов def aultFly внутри fly (см. также информацию о взаимодействии встраиваемости и виртуальности функций в правиле 33): class ModelA: public Airplane { public: virtual void fly(const Airports destination) { defaultFly(destination); } class ModelB: public Airplane { public: virtual void flylconst Airports destination) { defaultFly(destination) ; } Теперь для oaccaModelC возможность случайного наследования некорректной реализации fly исключена, поскольку чисто виртуальная функция в Airplane вынуждает Mode 1С создавать свою собственную версию 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. Прежде всего, отмечают программисты, это засо- засоряет пространство имен класса близкими названиями функций. Все же они
Наследование и ООП соглашаются с тем, что интерфейс и реализация по умолчанию должны быть разде- разделены. Как разрешить кажущееся противоречие? Для этого используют тот факт, что производные классы должны переопределять чисто виртуальные функции, которые могут иметь и свою собственную реализацию. Вот как допускается использовать возможность определения чисто виртуальных функций в иерархии Airplane: с": ass Airplane { public: virtual void fly(const. Airportk destination) = 0; void Airplane::fly(const Airports destination) { код по умолчанию для полета самолета в заданный пункт назначения - destination } class ModelA: public Airplane { public: virtual void fly(const Airports destination) { Airplane::fly(destination); } class KoriclB: public Airplane { public: virtual void fly(const Airports destination) { Airplane::fly(destination); } class MocelC: public Airplane { public: virtual void fly(const Airport& destination); void ModeIC: : fly (cons'- Airports destination) { код для полета самолети типа ModeIC n заданный пункт назначения } Это практически такой же подход, как и прежде, за исключением toio, что тело чисто виртуальной функции Airplane: : fly играет роль отдельной функции Airplane: :defauitFly. По существу, fly была разбита на две основные состав- составляющие. Объявление задает интерфейс (который должен быть использован в про- производном классе), а определение задает действия по умолчанию (которые могут быть использованы производным классом, но только по явному требованию). Однако, производя слияние fly и ciefaultFly, мы теряем возможность задать для функций различные уровни доступа: код, который был защищенным (функ- (функция def aultFly), стал открытым (поскольку теперь он находится в fly). И наконец, пришла очередь невиртуальной функции класса Shape - obj ectlD. Когда функция-член объявлена невиртуальной, не предполагается, что она будет вести себя иначе в производных классах. В действительности невиртуальные
Правило 36 функции-члены выражают инвариант относительно специализации, поскольку они определяют поведение, которое должно сохраняться вне зависимости от того, как специализируются производные классы. Справедливо следующее: цель объявления невиртуальной функции - заставить производные классы наследовать интерфейс функции, а также ее обязательную реализацию. Вы можете представлять себе объявление Shape: : object ID как утвержде- утверждение: «Каждый объект Shape имеет функцию, которая дает идентификатор объ- объекта, и идентификатор объекта всегда вычисляется одним и тем же образом. Это поведение задается определением функции Shape: :objectID, и никакой про- производный класс не должен изменять поведение». Поскольку невиртуальная функ- функция определяет инвариант относительно специализации, ее не следует перегру- перегружать в производных классах (этот вопрос подробно обсуждается в правиле 37). Различия в объявлениях чисто виртуальных, просто виртуальных и невирту- невиртуальных функций позволяют точно указать, что, по вашему замыслу, должны на- наследовать производные классы: только интерфейс, интерфейс и реализацию по умолчанию или интерфейс и обязательную реализацию соответственно. По- Поскольку эти типы объявлений означают принципиально разные вещи, следует осуществлять продуманный выбор между этими вариантами, когда вы объявля- объявляете функции-члены класса. При этом вы должны избегать двух ошибок, чаще все- всего совершаемых неопытными разработчиками классов. Первая ошибка - объявление всех функций невиртуальными. Это не оставля- оставляет возможности для маневров в производных классах; при этом больше всего про- проблем вызывают невиртуальные деструкторы (см. правило 14). Конечно, нет ниче- ничего плохого в проектировании классов, которые не следует использовать в качестве базовых. В этом случае вполне уместен набор из одних только невиртуальных функций-членов. Однако очень часто такие классы объявляются либо из-за незна- незнания различий между виртуальными и невиртуальными функциями, либо в ре- результате необоснованного беспокойства по поводу потери производительности при использовании виртуальных функций. Факт остается фактом: практически любой класс, который должен использоваться как базовый, будет содержать вир- виртуальные функции (снова см. правило 14). Если вы обеспокоены тем, во что вам обходится использование виртуальных функций, разрешите мне напомнить о так называемом «правиле 80-20» (см. также правило 33), которое утверждает, что в типичной программе 80% времени исполне- исполнения затрачивается на 20% кода. Это правило крайне важно, поскольку оно означа- означает, что в среднем 80% ваших функций могут быть виртуальными, не оказывая ни- никакого ощутимого влияния на общую производительность вашей программы. Поэтому, прежде чем начать беспокоиться о том, можете ли вы позволить себе ис- использование виртуальных функций, сначала убедитесь, что вы имеете дело с теми двадцатью процентами программы, для которых ваши решения окажут существен- существенное влияние на производительность. Другая распространенная ошибка — объявлять все функции-члены виртуаль- виртуальными. Иногда это вполне правильный подход, о чем свидетельствуют, например, классы-протоколы (см. правило 34). Однако данное решение может также навести на мысль, что у разработчика нет ясного понимания задачи. Некоторые функции не должны переопределяться в производных классах, и в таком случае вы должны
Наследование и ООП подтвердить это, делая функции невиртуальными. Не имеет смысла делать вид, что ваш класс годится на все случаи жизни, стоит лишь переопределить все его функции. Помните, что если у вас имеется базовый класс В, производный класс D и функция- член mf, каждый из приведенных вызовов mf должен работать надлежащим образом: D *pd = new D; 3 *pb = pd; pb->T.f () ; // Вызов mf с использованием указателя на базовый класс. pd->mf(); // Вызов mf с использованием указателя на производный класс. Иногда вы должны определить mf как невиртуальную функцию, чтобы гаран- гарантировать, что все будет работать так, как и задумывалось (см. правило 37). Если у вас имеется инвариант относительно специализации, не следует бояться гово- говорить об этом! Правило 37. Никогда не переопределяйте наследуемые невиртуальные функции К вопросу переопределения невиртуальных функций можно подойти двояко: теоретически и практически. Давайте начнем с прагматиков - в конце концов, тео- теоретики люди терпеливые. Предположим, я сообщаю вам, что класс D открыто наследует от класса В, и что в классе В определена открытая функция-член mf. Аргументы и тип, возвращаемый mf, не важны, поэтому давайте просто предположим, что это void. Другими слова- словами, я говорю следующее: class В { public: void mf () ; class D: public В { ... } ; Даже не зная ничего о В, D, или mf, имея объект х типа D, D х; // х - объект типа D. вы бы, наверное, удивились, когда код 3 *рВ = &х; // Получить указатель на х. pB->mf(); // Вызов mf с помощью указателя. вел себя отлично от следующего кода: D *pD = &х; // Получить указатель на х. pD->mf(); // Вызов mf с помощью указателя. Ведь в обоих случаях вы вызываете функцию-член mf объекта х. Поскольку вы имеете дело с одной и той же функцией и одним и тем же объектом, в обоих случаях она должна вести себя одинаково, не так ли? Да, так должно быть, но не всегда бывает. В частности, вы получите иной ре- результат, если mf невиртуальна, a D определяет свою собственную версию mf:
Правило 37 class D: public В { public: void mf(); // Скрывает 3::mf, см. правило 50. pB->mf(); // Вызов 3::mf. pD->mf(); // Вызов D::mf. Причина такого «двуличного» поведения заключается is том, что невиртуаль- невиртуальные функции, подобные В: :mf и D: :mf, связываются статически (см. правило 38). Это означает, что, когда рВ объявляется в качестве указателя типа 3, невиртуаль- невиртуальные функции, вызываемые посредством рВ, - это всегда функции, определение которых дано в классе в, даже если рВ, как в данном примере, указывает на класс, производный от в. Виртуальные функции, с другой стороны, связываются динамически (снова см. правило 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 действительно есть разновидность объекта
Наследование и ООП класса В, и если mf - действительно инвариант относительно специализации В, тогда, по правде говоря, D не нуждается в переопределении mf и не должен пы- пытаться это делать. Независимо от того, какой аргумент применим в вашем случае, чем-то придет- придется пожертвовагь, но в любых обстоятельствах запрет на перегрузку наследуемых невиртуальных функций остается неизменным. Правило 38. Никогда не переопределяйте наследуемое значение аргумента по умолчанию Давайте с самого начала упростим обсуждение. Аргумент по умолчанию мо- может существовать только как часть функции, а наследовать можно только два типа функций: виртуальные и невиртуальные. Следовательно, единственный способ переопределить значение аргумента по умолчанию - переопределение наследуе- наследуемом функции. Однако переопределять наследуемые невиртуальные функции в любом случае ошибочно (см. правило 37), поэтому мы вполне можем ограничить наше обсуждение случаем наследования виртуальной функции со значениями аргументов но умолчанию. В этих обстоятельствах мотивировка данного правила становится достаточно очевидной: виртуальные функции связываются динамически, а значения аргумен- аргументов по умолчанию - статически. Что это значит? Вы говорите, что не разбираетесь в современном объектно- ориентированном жаргоне, или, возможно, уже давно позабыли, в чем заключа- заключается разница между динамическим и статическим связыванием? Тогда давайте освежим вашу память. Статический тип объекта - это тип, определяемый вами в тексте программы. Рассмотрим следующую иерархию классов: enum ShapeColor { RED, GREEN, BLUE } ; // Класс для представления геометрических фигур. class Shape { public: // Вес фигуры должны иметь функцию для кх рисования. virtual void craw(ShapeColor color = RED) const = 0; class Rcct.ar.gle: public Shape { publ:с: // Заметьте, другое значение параметра ко умолчанию - плохо. virtual void draw(ShapeColor color = GRE2N) const; class Circle: public Shape { public: virtual void draw(ShapeColor color) const;
Правило 38 Графически это можно представить следующим образом: Теперь рассмотрим следующие указатели: Shape *ps; // Статический тип = Shape*. Shape *pc = new Circle; // Статический тип = Shape*. Shape *pr = new Rectangle; // Статический тип = Shape*. В этом примере ps, pc и рг объявляются как указатели на Shape, так что для них всех он и будет выступать в роли их статического типа. Заметьте, что совер- совершенно безразлично, на что они в действительности указывают, - независимо от этого они имеют статический тип Shape*. Динамический тип объекта определяется типом объекта, на который он в дан- данный момент ссылается. Иными словами, динамический тип определяет поведение объекта. В приведенном выше примере динамический тип для рс - это Circle*, а для рг - Rectangle*. Что касается ps, он в действительности не имеет дина- динамического типа, поскольку не ссылается на какой-либо объект (пока). Динамические типы, как и подразумевает их название, могут влиять на выпол- выполнение программы, обычно посредством присваиваний: ps = рс; // Теперь динамический тип переменной ps = Circle*. ps = рг; // А теперь динамический тип переменной ps = Rectangle*. Виртуальные функции связываются динамически; иными словами, динами- динамический тип вызывающего объекта определяет, какая конкретная функция вызы- вызывается: pc->draw(RED); // Вызов Circle::draw(RED). pr->draw(RED); // Вызов Rectangle::draw(RED). Я знаю, что все это давно известно, и вы, несомненно, разбираетесь в вирту- виртуальных функциях. Самое интересное начинается, когда мы подходим к виртуаль- виртуальным функциям с аргументами, принимающими значения по умолчанию, посколь- поскольку, как я сказал, виртуальные функции связываются динамически, а аргументы по умолчанию - статически. Следовательно, вы можете прийти к тому, что будете вы- вызывать виртуальные функции, определенные в производном классе, но использую- использующие аргументы по умолчанию, заданные в базовом классе: pr->draw(); // Вызов Rectangle::draw(RED)! В этом случае динамический тип рг - это Rectangle*, поэтому, как вы и ожи- ожидали, вызывается виртуальная функция класса Rectangle. Для Rectangle: : draw аргумент по умолчанию - GREEN. Однако, поскольку статический тип рг - Shape*, бзак 125
Наследование и ООП значения аргументов по умолчанию берутся из класса Shape, а не Rectangle! В результате получаем вызов, состоящий из странной, абсолютно непредвиденной комбинации объявлений draw для классов Shape и Rectangle. Поверьте мне на слово: ваше программное обеспечение не должно работать подобным образом или, во всяком случае, ваши пользователи уж точно не желают, чтобы оно так работало. Разумеется, в рассматриваемой ситуации не важно, что ps, pc и рг являются указателями. Будь они ссылками, проблема бы не исчезла. Существенно только, что draw — виртуальная функция и одно из значений ее параметров по умолча- умолчанию переопределяется в подклассе. Почему C++ настаивает на таком диковинном поведении? Ответ на этот вопрос связан с эффективностью исполнения программы. Если бы значения аргументов по умолчанию связывались динамически, компилятору пришлось бы найти способ определять значения аргументов по умолчанию в момент выполнения програм- программы, что медленнее и технически сложнее нынешнего метода определения зна- значения аргументов при компиляции. Решение было принято в пользу скорости и простоты реализации; в результате вы можете пользоваться преимуществами эффективного выполнения кода программы, хотя если не последуете совету, из- изложенному в этом правиле, поведение вашей программы будет не вполне логично. Правило 39. Избегайте приведения типов вниз по иерархии наследования В наше беспокойное время нелишне быть к курсе финансовых новостей, по- поэтому рассмотрим класс-протокол (см. правило 34) для банковских счетов: class Person { ... } ; class BankAccount { public: BankAccount(const Person *primaryOwner, 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 creditlnterest (); // Начислить проценты на счет.
Правило 39 Трудно, конечно назвать его крупным сберегательным счетом, но, сами пони- понимаете, время нынче трудное. В любом случае для наших целей этого достаточно. Банк, вероятно, должен поддерживать список своих счетов, например посред- посредством шаблона класса list из стандартной библиотеки (см. правило 49). Пред- Предположим, что этот список изобретательно назван allAccounts: list<3ankAccount*> allAccounts; // Все счета в банке. Подобно всем стандартным контейнерам, контейнеры list хранят копии по- помещаемых в них объектов, поэтому во избежание хранения нескольких копий каж- каждого счета BankAccount банк решил, что allAccounts будет содержать указа- указатели, а не сами объекты BankAccount. Теперь представьте себе, что вам необходимо написать код для итерации по всем счетам с определением процентов, начисляемых по каждому счету. Вы може- можете испробовать следующий вариант: // Этот цикл не будет компилироваться (смотрите далее, если вам // никогда не встречался код с итераторами). for (list<BankAccount*>::iterator p = allAccounts.begin(); p != allAccounts.end(); ++P) { (*p)->creditlnterest (); // Оиибка! } но ваш компилятор быстро приведет вас в чувство: allAccounts содержит ука- указатели на объекты BankAccount, а не на объекты SavingsAccount, поэтому в цикле р указывает на BankAccount. Это делает вызов creditlnterest не- недопустимым, поскольку creditlnterest объявлена только для объектов SavingsAccount, а не BankAccount. Если строка list<BankAccount*>::iterator p=allAccounts.begin() скорее напоминает вам помехи на линии, чем код на C++, вы, очевидно, не имели удовольствия познакомиться с шаблонами контейнерных классов из стандартной библиотеки. Эта часть библиотеки известна как Стандартная библиотека шаблонов (STL); ее обзор вы найдете в правиле 49. На данный же момент вам лишь необходи- необходимо знать, что переменная р выступает как указатель, который проходит в цикле по всем элементам allAccounts от первого до последнего. Иными словами, р работа- работает так, как будто его тип - BankAccount* *, а список элементов хранится в массиве. То, что вышеприведенный цикл не компилируется, крайне неприятно. Ко- Конечно, allAccounts определен как содержащий BankAccount*, но вы-то зна- знаете, что на самом деле он содержит указатели SavingsAccount*,- единст- единственный класс, который может быть инстанцирован. Глупый компилятор! И вы решаете, что нужно сообщить ему то, что вы считаете очевидным, и о чем у него «не хватает мозгов» догадаться самому: allAccounts в действительности со- содержит SavingsAccount. // Этот цикл откомпилируется, но он все равно неудачен, for (list<BankAccount*>::iterator p = allAccounts.begin(); p != allAccounts.end(); ++P) { 6*
Наследование и ООП static_cast<SavingsAccount*>(*p)->creditlnterest (); Все ваши проблемы решены четко, изящно, кратко, и все с использованием при- приведения типов. Вы знаете, какой тип указателя в действительности содержит allAccounts, а ваш «глупый» компилятор - нет, и вы используете приведение ти- типов, чтобы сообщить ему об этом. Что может быть более логичным? Хотелось бы привести здесь библейскую аналогию. Преобразование типов для программиста на C++ - то же самое, что яблоко для Евы. Приведение типов такого рода - указателей на базовые классы в указатели на производные классы - называется понижающим приведением (downcast), по- поскольку вы преобразуете типы вниз по иерархии наследования. В только что рас- рассмотренном примере понижающее приведение типов сработало, но, как вы увиди- увидите, при дальнейшей поддержке кода это приведет к серьезным проблемам. Однако вернемся к банку. Предположим, что воодушевленный успехом сберега- сберегательных счетов, банк решил ввести и текущие счета. При этом на текущие счета, точ- точно так же, как и на сберегательные, начисляются проценты: class CheckingAccount: public BankAccount { public: void creditlnterest(); // Начислить проценты на счет. Нет смысла говорить, что allAccounts теперь будет содержать список ука- указателей как на сберегательные, так и на текущие счета. И неожиданно в цикле на- начисления процентов, написанном вами, возникают серьезные проблемы. Первая проблема связана с тем, что цикл, как и прежде, будет компилировать- компилироваться, не требуя изменений, отражающих появление объектов CheckingAccount. Это произойдет, поскольку компилятор будет по-прежнему верить вам, когда вы сооб- сообщаете ему посредством static_cast, что *р в действительности указывает на SavingsAccount*. (В конце концов, вы босс.) Вот первая проблема, возникаю- возникающая при поддержке такого кода. Проблема номер два заключается в том, что для решения первой у вас появится искушение написать код примерно в таком духе: for (list<BankAccount*>::iterator p = allAccounts.begin(); p != allAccounts.end(); ++P) { if (*p указывает на SavingsAccount) static_cast<SavingsAccount*>(*p)->creditlnterest(); else static_cast<CheckingAccount*>(*p)->creditlnterest(); } Создавая код по принципу «если это объект типа Т1, - сделать что-либо; если же это тип Т2, - сделать что-либо другое», знайте, что вы в корне не правы. Такое решение противоречит духу C++. Да, подобная стратегия является разумной для С и Pascal, но не для C++. В C++ для той же цели служат виртуальные функции. Помните, что при работе с виртуальными функциями компилятор несет от- ответственность за то, чтобы в зависимости от типа объекта была вызвана нужная
Правило 39 I функция. Не засоряйте код условными операторами и операторами выбора; по- позвольте компилятору выполнять такую работу за вас. Вот каким образом это де- делается: class BankAccount {...}; // Как выше. // Новый класс, предстазляющий счета с начисляемыми процентами. class InterestBearingAccount: public BankAccount { public: virtual void creditlnterest() = 0; class SavingsAccount: public InterestBearingAccount { ... // Как зыше. }; class CheckingAccount: public InterestBearingAccount { ... // Как выше. }; Графически это выглядит так: InterestBearingAccount \ CheckingAccount) (SavingsAccount Поскольку как на сберегательные, так и на текущие счета начисляются проценты, желание поместить эти общие функции в общем базовом классе вполне закономер- закономерно. Однако если допустить, что Fie на все счета банка будут начисляться проценты (исходя из моего опыта, это весьма разумное предположение), вы не можете помес- поместить эти счета в класс BankAccount. В результате вы вводите новый гюдкласс класса BankAccount, называемый InterestBearingAccount, и делаете так, чтобы SavingsAccount и CheckingAccount наследовали от пего. То обстоятельство, что проценты начисляются и на сберегательные, и на те- текущие счета, выражается в следующем: функция creditlnterest класса InterestBearingAccount объявлена как чисто виртуалытя, что предполага- предполагает ее определение в подклассах SavingsAccount и CheckingAccount. Эта новая иерархия классов позволяет переписать ваш цикл таким образом: / / Уже лучше, хотя еще не идеально. for (list<BankAccount*>::iterator p = allAccounts.begin!); p != allAccounts.end();
Наследование и ООП ++р) { static_cast<InterestBearingAccount*>(*p)->creditInterest(); } Хотя этот цикл все еще содержит не слишком приятное приведение типов, он намного лучше защищен от ошибок, поскольку будет продолжать правильно работать, даже если к вашему приложению будут добавлены новые подклассы классаInterestBearingAccount. Для того чтобы полностью избавиться от приведения типов, вы должны вне- внести в проект дополнительные изменения. Один из подходов заключается в том, чтобы сделать спецификацию списка счетов более строгой. Если бы вы могли ис- использовать список объектов InterestBearingAccount, а не объектов BankAc- count, все вышло бы просто замечательно: // Все счета в банке, на которые начисляются проценты. list<InterestBearingAccount*> alllBAccounts; // Цикл компилируется и работает как сейчас, так и в дальнейшем, for (list;<InterestBearingAccount*>::iterator p = alllBAccounts.begin(); p != alllBAccounts.end(); ++P) { (*p)->creditInterest(); } Если замена списка на более специализированный невозможна, тогда, вероят- вероятно, имеет смысл сказать, что операция creditlnterest применима ко всем бан- банковским счетам, но для беспроцентных счетов она - просто пустая функция. Это можно выразить следующим образом: class BankAccount { public: virtual void creditlnterest() {} class SavingsAccount: public BankAccount { ... } ; class CheckingAccount: public BankAccount { ... } ; list<BankAccount*> allAccounts; // Смотрите-ка, нет приведения типа! for (list<BankAccount*>::iterator p = allAccounts.begin(); p != allAccounts.end(); ++P) { (*p)->creditInterest(); } Заметим, что виртуальная функция BankAccount: :creditlnterest име- имеет пустую реализацию но умолчанию. Это удачный способ указать, что поведе- поведение данной функции по умолчанию заключается в том, чтобы ничего не делать; однако такая реализация может вызвать, в свою очередь, непредвиденные ослож- осложнения. Подробное рассмотрение проблемы, а также обсуждение способов ее реше- решения приводятся в правиле 36. Заметим также, что creditlnterest неявным об- образом представляет собой встраиваемую функцию. В этом нет ничего плохого,
Правило 39 но, поскольку она виртуальна, ее встраивание, вероятно, не будет осуществляться компилятором. Почему так происходит, объясняется в правиле 33. Как вы видели, понижающего приведения типов можно избежать несколькими способами. Наилучший из них - заменить такие преобразования типов вызова- вызовами виртуальных функций, при этом по возможности делая каждую виртуальную функцию пустой в тех классах, к которым она в действительности неприменима. Второй метод - усилить строгость типизации так, чтобы между объявляемым типом указателя и типом указателя, который, как вы знаете, реально находится в программе, не возникало неоднозначности. Каких бы усилий ни стоило избав- избавление от понижающих приведений типов, эти усилия будут потрачены не зря, по- поскольку понижающее приведение типов выглядит безобразно, часто является ис- источником ошибок и дает код, трудный для понимания, разработки и поддержки. То, что я написал, - правда. Однако это еще не вся правда. Существуют случаи, когда вам действительно требуется использовать понижающее приведение типов. Предположим, например, что вы столкнулись с ситуацией, которую мы рас- рассматривали в начале этого правила, то есть AllAccounts содержит указатели BankAccount, функция creditlnterest определена только для объектов SavingsAccount, и вам необходимо написать цикл для начисления процентов по каждому счету. Далее предположим, что эти проценты вне вашего контроля; вы не можете изменить определения BankAccount, SavingsAccount или AllAccounts. (Например, они определены в библиотеке, которая доступна только для чтения.) В этом случае потребуется использовать понижающее при- приведение типов, независимо от того, насколько вам неприятна такая идея. Тем не менее есть лучший способ добиться результата, чем использовать про- простое приведение типов, как это делалось выше. Более подходящий метод называ- называется «безопасное понижающее приведение типов» или «динамическое приведение типов» и реализуется посредством оператора C++ dynamic_cast. При исполь- использовании dynamic_cast для указателя предпринимается попытка приведения ти- типов, и если она удается, то есть динамический тип указателя (см. правило 38) со- совместим с тем типом, для которого происходит вызов функции, - возвращается допустимый указатель нового типа. Если же dynamic_cast заканчивается не- неудачно, то возвращается нулевой указатель. Так выглядит наш банковский пример, переписанный с использованием без- безопасного понижающего приведения типов: class BankAccount {...},- // Как в начале этого правила, class SavingsAccount: // Аналогично. public BankAccount { ... } ; class CheckingAccount: // Аналогично. public BankAccount { ... }; list<BankAccount*> allAccounts; // И это тоже должно быть // знакомо... void error(const string& msg); // Функция обработки ошибок, см. ниже. //По крайней мере, здесь приведения типов безопасны, for (list<BankAccount*>::iterator p = allAccounts.begin(); p != allAccounts.end 0; ++P) {
Наследование и ООП // Попытка безопасного понижающего приведения // *р к SavingsAccount *; см. ниже по поводу определения psa. if (SavingsAccount *psa = dynamic_cast<SavingsAccount*>(*p)) { psa->creditlnterest(); } // Попытка безопасного приведения к CheckingAccount*. else if (CheckingAccount *pca = dynamic_cast<CheckingAccount*>(*p)) { pca->creditlnterest(); } // Увы - неизвестный тип счета! else { error("Unknown account type!") ; Эта схема далека от идеала, но, по крайней мере, вы можете определить, что пони- понижающее приведение типа завершилось неудачно, а без использования dynamic_cast у вас не было бы такой возможности. Заметьте, однако, что осторожность требу- требует, чтобы вы также проводили проверку на тот случай, если неудачей заканчиваются все попытки приведения типов. В этом и состоит смысл последнего оператора else в предложенном коде. При использовании виртуальных функций в таком тесте нет необходимости, поскольку каждый вызов виртуальной функции должен разрешить- разрешиться некоторой функцией. Однако, когда вы используете приведение типов, никаких га- гарантий у вас уже нет. Например, если в иерархию счетов кто-нибудь добавляет новый тип, забывая при этом о необходимости обновления вышеприведенного кода, то все приведения типов окончатся неудачей. Вот почему важно, чтобы вы прорабатывали и такой вариант. Конечно, маловероятно, что ни одно из приведений типов в этом коде не завершится успехом, но когда допускается использование понижающих приведений типов, даже с хорошими программистами начинают происходить нехорошие вещи. Увидев определения переменных в условных операторах if, вы сняли очки, что- чтобы протереть их? Не беспокойтесь, ваше зрение вас не обманывает. Возможность объ- объявления таких переменных была добавлена в язык одновременно с dynamic_cast. Это позволяет создавать более «элегантный» код, поскольку на самом деле необяза- необязательно использовать psa или рса, если приведения типов с помощью dynamic_cast не проходят успешно; а при использовании нового синтаксиса нет необходимости определять эти переменные вне условных операторов, содержащих приведения типов. (В правиле 32 объясняется, почему в общем случае следует избегать излишних объявлений переменных.) Если ваш компилятор еще не поддерживает этот новый способ определения переменных, вы можете определить их старым способом: for (list<BankAccount*>::iterator p = allAccounts.begin(); p != allAccounts.end(); ++P) { SavingsAccount *psa; // Традиционное определение. CheckingAccount *pca; // Традиционное определение, if (psa = dynamic_cast<SavingsAccount*>(*p)) { psa->creditlnterest();
Правило 40 else if (pea = dynamic_cast<CheckingAccount*>(*p)) { pca->creditlnterest(); else { error("Unknown account type!"); По большому счету, разумеется, не имеет особого значения, где вы размещаете определения таких переменных, как psa или рса. Важно следующее: програм- программирование в стиле if-then-else, к которому с неизбежностью ведет понижающее приведение типов, намного менее привлекательно, чем использование вирту- виртуальных функций, и прибегать к нему следует, только если у вас нет никакой аль- альтернативы. Надо надеяться, вам все же не придется исследовать столь унылые программные ландшафты. Правило 40. Моделируйте отношения «содержит» и «реализуется посредством» с помощью вложения Вложение — это метод построения одного класса поверх другого, когда один класс включает в себя объект другого класса в качестве элемента данных. Например: class Address {...}; // Чье-либо место жительства. class PhoneNuraber { ... } ; class Person { public: private: string name; // Вложенный объект. Address address; // To же. PhoneNumber voiceNumber; // To же. PhoneNumber faxNumber; // To же. }; В данном случае о классе Person говорят, что в него вложены классы string, Address и PhoneNumber, поскольку он содержит переменные этих типов. Тер- Термин вложение имеет ряд синонимов. Данное понятие тоже обозначается такими терминами, как композиция, содержание и включение. В правиле 35 объясняется, что открытое наследование означает «класс есть разновидность класса». В противоположность этому вложение означает либо «класс содержит класс», либо «класс реализуется посредством класса». Вышеприведенный класс Person демонстрирует взаимосвязь типа «класс со- содержит класс». Объект Person имеет имя, адрес и номера телефонов для голосо- голосового и факсимильного общения. Нельзя сказать, что человек есть разновидность имени, или что человек есть разновидность адреса. Можно сказать, что человек имеет («содержит») имя и адрес. Большинство людей не испытывает затрудне- затруднений в проведении подобных различий, поэтому путаница между «есть разновид- разновидность» и «содержит» возникает сравнительно редко.
Наследование и ООП Чуть сложнее провести различие между отношениями «есть разновидность» и «реализуется посредством». Предположим, например, что вам необходим шаб- шаблон для классов, представляющих множества произвольных объектов, то есть кол- коллекции без дубликатов. Поскольку повторное использование - это великолепная вещь, и вы к тому же предусмотрительно ознакомились с обзором стандартной библиотеки C++ в правиле 49, ваше первое побуждение - применить библиотеч- библиотечный шаблон set. В конце концов, зачем писать новый шаблон, когда есть возмож- возможность использовать уже готовый? Однако, углубившись в документацию по set, вы обнаружите ограничение, неприемлемое для вашего приложения: set требует, чтобы содержащиеся в нем элементы были полностью упорядочены, то есть для каждой пары объектов а и b из множества можно было бы определить, что либо а<Ь, либо Ь<а. Применительно к некоторым типам удовлетворить этому требованию достаточ- достаточно легко, а полная упорядоченность объектов позволяет шаблону set дать пользова- пользователям достаточно привлекательные гарантии относительно производительности. (Подробности, касающиеся гарантий производительности стандартной библиотеки, приводятся в правиле 49.) Однако вам необходимо нечто более общее: класс, подоб- подобный set, объекты которого могут не удовлетворять критерию полного упорядочения, а единственное требование, предъявляемое к ним, - возможность определения для объектов а и b одного типа, что а==Ь. Это более скромное требование гораздо лучше подходит для того, чтобы моделировать такие характеристики, как цвет, например. Меньше ли красный, чем зеленый, или зеленый меньше, чем красный? Для нашего приложения, по-видимому, придется написать свой собственный шаблон. Тем не менее повторное использование - это великолепная вещь. Будучи экс- экспертом в области структур данных, вы знаете, что при наличии практически без- безграничного набора возможных реализаций один из самых простых способов - применение связных списков. Что дальше? Шаблон list (который генерирует классы связных списков) уже имеется в стандартной библиотеке! Вы принимае- принимаете решение использовать его повторно. В частности, вы решаете, что создаваемый вами шаблон Set должен наследо- наследовать от list, то есть Set<T> будет наследовать от list<T>. В итоге в вашей реа- реализации объект Set будет выступать как объект list. Соответственно, вы опре- определяете Set следующим образом: // Неправильный способ использовать list для определения Set. template<class T> class Set: public list<T> { ... } ; До сих пор все вроде бы шло превосходно, но, если присмотреться, в код вкра- вкралась ошибка. Как объясняется в правиле 35, если D есть разновидность в, все, что верно для в, также верно и для D. Однако объект list может содержать дублика- дубликаты, поэтому, если значение 3051 вставляется в список list<int> дважды, список будет содержать две копии 3051. Напротив, Set не может содержать дубликатов, поэтому, если значение 3051 вставляется в Set<int> дважды, множество содержит только одну копию данного значения. Следовательно, утверждение, что объект класса Set есть разновидность объекта типа list, является ложным: ведь некото- некоторые вещи, которые верны для объектов list, неверны для объектов Set.
Правило 40 Из-за того что отношение между данными объектами не основано на принци- принципе «есть разновидность», открытое наследование - неправильный способ моде- моделирования этой взаимосвязи. Правильный подход обеспечит понимание того, что объект Set может быть реализован посредством объекта list: // Правильный способ использовать list для определения Set. template<class T> 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 и другими частями стандартной библиотеки, поэтому их реализа- реализацию нетрудно написать, хотя она и не особенно поражает воображение при чтении: template<class T> bool Set<T>::member(const T& item) const { return find(rep.begin(), rep.endl), item) !=rep.end(); } template<class T> void Set<T>::insert(const T& item) { if ('member(item) ) rep.push_back(item) ; } template<class T> void Set<T>::remove(const T& item) { list<T>::iterator it = find(rep.begin)), rep.endO, item); if (it != rep.endO) rep.erase(it); } template<class T> int Set<T>::cardinality О const { return rep.size(); } Эти функции достаточно просты для того, чтобы быть использованными в ка- качестве встроенных, хотя перед принятием окончательного решения вам необходи- необходимо снова просмотреть правило 33. (В коде, приведенном выше, find, begin, end, push_back и т.д. - функции стандартной библиотеки, которые составляют основу для работы с контейнерными классами наподобие list. Вы найдете обзор этих средств в правиле 49.) Стоит отметить, что интерфейс класса Set не пройдет тест на минимальность и полноту, предложенный в правиле 18. В том, что касается полноты класса, глав- главным упущением является отсутствие способа итерации по массиву, который может понадобиться многим приложениям (и реализуется во всех классах стандартной библиотеки, включая set). Недостатком также является то, что Set не следует соглашениям, принятым контейнерными классами стандартной библиотеки
Наследование и ООП (см. правило 49), а это затрудняет его совместное использование с другими частя- частями библиотеки. Недостатки интерфейса класса Set не должны, однако, затенять несомнен- несомненного достоинства, которое характеризует его реализацию, а именно отношения между классами Set и list. Оно не является взаимосвязью типа «есть разновид- разновидность» (как могло бы показаться вначале); это «реализация посредством», а ре- решением использовать для реализации этой взаимосвязи вложение мог бы но пра- праву гордиться любой разработчик классов. Между прочим, если для установления отношения между двумя классами вы используете вложение, это создаст зависимость на момент компиляции. По- Почему на это стоит обращать внимание и как разрешить данную проблему, объяс- объясняет правило 34. Правило 41. Различайте наследование и шаблоны Рассмотрим две задачи проектирования: 1. Прилежно изучая курс информатики, вы хотите создать классы для пред- представления стеков. Вам понадобится несколько различных классов, посколь- поскольку каждый стек должен быть гомогенным, то есть содержать только объекты одного типа. Например, у вас может быть один класс для стеков int, дру- другой - для стеков double, третий - для стеков string и т.д. Вам требуется поддерживать лишь минимальный интерфейс класса (см. правило 18), поэто- поэтому необходимо ограничиться операциями создания стека, удаления и встав- вставки объектов в стек, извлечения объектов и проверки на пустоту. Выполняя данное упражнение, вы игнорируете стандартную библиотеку (включая stack - см. правило 49), поскольку стремитесь получить опыт самостоя- самостоятельного написания классов. Повторное использование - это прекрасно, но когда вы ставите перед собой задачу основательно разобраться в устройстве чего-либо, лучший способ добиться этого - засучить рукава и начать «раз- «разбирать механизм». 2. Будучи страстным любителем кошек, вы хотите спроектировать класс для их описания. Вам потребуется несколько различных классов, поскольку каж- каждая порода кошек незначительно отличается от остальных. Подобно любым объектам, «кошек» в программе можно создавать и удалять, но, как извест- известно всякому любителю животных, помимо этого о кошках можно сказать, что они только едят и спят. Однако каждая порода ест и спит присушим только ей неподражаемым способом. Эти две задачи кажутся достаточно близкими, однако требуют совершенно разных подходов к проектированию. Почему? Ответ кроется во взаимосвязи между поведением каждого класса и типом обра- обрабатываемого объекта. Как в случае со стеками, так и с кошками вы имеете дело с различными типами объектов (стек, содержаший объекты типа Т; кошки породы т), но вопрос, который необходимо себе задать, звучит так: влияет ли тип т на поведе- поведение класса? Если Т не влияет на поведение, вы можете использовать шаблон; если
Правило 41 же влияет, вам необходимы виртуальные функции, а значит, вы будете использо- использовать наследование. Вот как мы могли бы определить реализацию класса Stack, использующую связные списки, предполагая, что объекты, помещаемые в стек, имеют тин Т: class Stack { public: Stack(); -Stack(); void push(const T& object); T pop () ; bool empty() const; // Пуст ли стек? private: struct StackNode { // Узел связного списка. T data; // Данные в этом узле. StackNode *next; // Следующий узел в списке. // Конструктор StackNode инициализирует оба поля. StackNode(const T& newData, StackNode *nextNode) : data(newData), next(nextNode) {} StackNode *top; Stack(const Stacks rhs); Stacks operator=(const Stacks rhs) // Вершина стека. // Запретить копирование //и присваивание - //см. правило 27. Объекты Stack будут, таким образом, формировать структуры данных, подоб- подобные следующей: StackNode Objects Сам связный список составлен из объектов StackNode, но это деталь реализа- реализации класса Stack, а потому StackNode был объявлен как закрытый тип класса Stack. Обратите внимание, что StackNode имеет конструктор, гарантирующий, что все поля будут инициализированы надлежащим образом. Даже если вы може- можете написать связные списки, будучи разбуженными посреди ночи, не стоит пренеб- пренебрегать достижениями технологии, каковыми являются конструкторы. Ниже приведена разумная реализация функций-членов класса Stack в пер- первом приближении. Как бывает во многих прототипах реализации (и, к сожалению, даже в коммерческих программах), контроль ошибок здесь отсутствует, посколь- поскольку в мире прототипов, как известно, никогда ничего плохого не случается.
Наследование и ООП Stack::Stack(): top(O) {} // Инициализация вершины значением null. void Stack::push(const T& object) { top = new StackNode(object, top) ; // Добавить новый узел } //в начале списка. Т Stack::pop() { StackNode *topOfStack = top; // Запомнить верхний узел. top = top->next; Т 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 в шаблон настолько элементарно, что это ни для кого не составит труда: template<class T> class Stack { ... //В точности то же, что и выше. }; Но вернемся к нашим кошкам. Почему шаблоны не подойдут для кошек? Перечитайте спецификацию и обратите внимание на то, что «каждая порода кошек ест и спит присущим только ей неподражаемым способом». Это означает, что вам необходимо реализовать различное поведение для различных типов ко- кошек. Вы не можете просто написать одну-единственную функцию, которая бы ра- работала со всеми объектами: все, что можно сделать, - это определить интерфейс функции, которую должны реализовать все типы кошек. Способ, который позво- позволяет задать только интерфейс функции, заключается в объявлении чисто вирту- виртуальной функции (см. правило 36):
Правило 41 class Cat { public: virtual -Cat(); // См. правило 14. virtual void eat О = 0; // Все кошки едят. virtual void sleepO = 0; // Все кошки спят. }; Подклассы класса Cat (скажем, Siamese и Brit ishShortHairedTabby) долж- должны, конечно, переопределять функции eat и sleep наследуемого интерфейса: class Siamese: public Cat { public: void eat () ; void sleep () ; class BritishShortKairedTabby: 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 вам тоже не noMOiyr. Вы просто не сможете проигнорировать тре- требование о том, чтобы объявления виртуальных функций в производных классах
Наследование и ООП не противоречили объявлениям в базовых классах. Однако указатели типа void могут вам пригодиться в другом случае, связанном с эффективностью генерации шаблонов классов. Более подробно об этом говорится в правиле 42. Теперь, когда мы разделались со стеками и кошками, можем подытожить из- извлеченные из этого правила уроки следующим образом: Q шаблоны должны быть использованы для генерации семейств классов, тин объектов которых не влияет на поведение функций этих классов; ? наследование следует использовать для создания семейств классов, тип объектов которых влияет на поведение функций создаваемых классов. Усвойте эти два положения, и вы сделаете важный шаг к пониманию различия между наследованием и шаблонами. Правило 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; // p - человек (Person). Student s,- // s - студент (Student) . dance(p); // Нормально, р типа Person, dance(s); // Ошибка! Объект Student //не является объектом Person. Ясно, что закрытое наследование не означает «есть разновидность». Что же это тогда означает? «Стоп! - восклицаете вы. - Прежде чем говорить о значении, давайте погово- поговорим о свойствах. Как ведет себя закрытое наследование?» Первое из правил закры- закрытого наследования вы только что сами имели возможность наблюдать в действии: в противоположность открытому наследованию, компиляторы в общем случае не преобразуют объекты производного класса (такие как Student) в объекты базо- базового класса (такие как Person). Именно поэтому вызов dance для s ошибочен. Вто- Второе правило состоит в том, что члены, наследуемые от закрытого базового класса, становятся закрытыми, даже если для базового класса они были объявлены как за- защищенные или открытые. Это то, что касается поведения. А теперь вернемся к значению. Закрытое наследование означает «класс реа- реализуется посредством класса». Делая класс D закрытым наследником класса В, вы
Правило 42 поступаете так потому, что заинтересованы в использовании некоторого кода, уже написанного для класса В, а не потому, что между объектами типов В и 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 {
i Наследование и ООП void *data; // Данные в этом узле. StackNoce *r:ext; // Следующий узел в списке. StackNode (void *r.cw:;ata, StackNode *r.cxtNode) : data(new:;ata) , next (r.extNodc) {} }; StackNodc *top; . // Вершина стека. GenericStack(const GenericStackk rhs) ; // Запретить GenericStack& // копирование operator=(const Ger.ericStack& rhs); // и присваивание - //см. правило 27. }; Поскольку этот класс храпит указатели, а не объекты, существует вероятность, что указатель на объект имеется более чем в одном стеке (то есть он был помещен в несколько стеков). В связи с этим представляется принципиально важным, чтобы функция pop и деструктор класса не вызывали delete для указателя data из уда- удаляемых объектов StackNode, хотя они по прежнему должны удалять сами объек- объекты StackNode. В конце концов, объекты StackNode разметаются внутри класса GenericStack, поэтому и высвобождаться они должны внутри него. В результате реализация для класса Stack из правила 41 вполне подойдет для класса Generic Stack. Единственное, что необходимо изменить, - вместо Т поставить void*. Класс GenericStack сам по себе малопригоден - его зачастую можно ис- использовать неправильно. Например, пользователь может но ошибке поместить указатель на объект Cat в стек, предназначенный для хранения только указателей на int, и компилятор не будет возражать. В конце концов, указатель есть указа- указатель, если речь идет об аргументах типа void*. Чтобы вернуться к контролю типов, к которому вы уже успели привыкнуть, для GenericStack необходимо создать классы-интерфейсы, например такие: class IntStack { // Класс-интерфейс для целых чисел, public: void push (i.nt *int?tr) { s .push (ir.tPtr) ; } int * pop() { return static_cast<int*>(s.pop()) ; } bool ex.pty () const { return s. empty (); } private: GenericStack s; // Реализация. }; class CatStack ( // Класс-интерфейс для кошек, public: void push (Cat *caV.Ptr) { 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 посредством GenericStack - а такое отношение выражается с помощью вложе- вложения (см. правило 40); и IntStack, и CatStack совместно используют кол функ- функций из класса GenericStack, который фактически реализует их поведение. Бо- Более того, поскольку все функции-члены классов IntStack и CatStack по умолчанию определены как inline, использование этих интерфейсных классов происходит почти «даром». Но что, если потенциальные пользователи этого не понимают? Что, если они ошибочно полагают, будто применение GenericStack более эффективно, или по наивности думают, что только «чайникам» требуются «узы» безопасных типов? Что можно сделать для того, чтобы удержать таких пользователей от применения GenericStack в обход классов IntStack и CatStack, напрямую, когда они сво- свободно смогут совершать те ошибки с типами, для предотвращения которых и был, собственно, придуман C++? Ничего. Ничто этому не мешает. А сделать что-нибудь следовало бы. В начале этого раздела я упоминал, что альтернативным способом выраже- выражения взаимоотношения «реализуется посредством» служит использование закры- закрытого наследования. В данном случае такая техника дает ряд преимуществ, посколь- поскольку позволяет сообщить, что GenericStack слишком небезопасен для обычного использования, и его следует применять только для реализации других классов. Вы утверждаете это, делая функции-члены GenericStack защищенными: class GenericStack { protected: GenericStack(); --GenericStack () ; void pushfvoid *object) ; void * pop() ; bool empty() const; private: ... // To же, что и выше. }; GenericStack s; // Ошибка! Конструктор защищен. class IntStack: private GenericStack { public: void push(int *intPtr) { GenericStack::p\ish(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; // Тоже нормально. Как и при подходе с использованием вложения, реализация, основанная на закрытом наследовании, позволяет избежать дублирования кода, поскольку
Наследование и ООП классы с интерфейсом контроля типов - это просто встраиваемые вызовы функ- функций класса GenericStack. Построение интерфейса с контролем типов поверх GenericStack - достаточно ловкий маневр, но описывать все эти классы вручную достаточно неудобно. К счас- счастью, в этом нет необходимости. Для их автоматической генерации вы можете исполь- использовать шаблоны. Вот шаблон, генерирующий интерфейсы классов стеков со встроен- встроенным контролем типов и использующий закрытое наследование: template<class T> class Stack: private GenericStack { public: voidpush(T *objectPtr) { GenericStack::push(objectPtr); } T * pop() { return static_cast<T*>(GenericStack::pop()); } bool empty() const { return GenericStack::empty(); } }; Перед вами удивительный код, хотя вы, возможно, этого еще и не поняли. Ис- Используя шаблон, компилятор будет генерировать столько классов интерфейсов, сколько вам необходимо. Поскольку в эти классы встроен контроль типов, ошиб- ошибки пользователя обнаруживаются в ходе компиляции. Из-за того, что функции- члены класса GenericStack являются защищенными и функции-члены классов- интерфейсов используют его в качестве закрытого базового класса, пользователи не MOiyr обойти классы-интерфейсы. Так как все функции-члены классов интер- интерфейсов неявно объявляются inline, использование классов с контролем типов не влечет за собой дополнительных «расходов» в момент выполнения; генерируе- генерируемый код абсолютно аналогичен коду, который пользователи получили бы, рабо- работая непосредственно с GenericStack (если компилятор не отклонит требование встраивания функций - см. правило 33). А поскольку GenericStack использует указатели типа void*, независимо от того, сколько типов стеков вы применяете в своей программе, для управления стеками используется только одна копия кода. Короче говоря, код разработан таким образом, что он является максимально эф- эффективным, а также поддерживает строгий контроль типов. Трудно добиться луч- лучших результатов. Одна из основных посылок этой книги состоит в том, что различные конструк- конструкции языка C++ способны взаимодействовать друг с другом весьма примечатель- примечательными способами. Этот пример, я надеюсь, вы найдете достаточно нетривиальным. Мораль, которую необходимо отсюда вывести, такова: полученных результа- результатов нельзя было добиться, используя вложение. Только наследование дает доступ к защищенным членам класса, и только оно позволяет переопределять виртуаль- виртуальные функции. (Пример того, как наличие виртуальных функций может подтолк- подтолкнуть к использованию закрытого наследования, приведен в правиле 43.) Посколь- Поскольку в C++ имеются виртуальные функции и защищенные члены классов, иногда почти единственный способ выразить взаимоотношение «реализация посред- посредством» - закрытое наследование. Итак, не надо бояться использовать закрытое наследование, если это наиболее подходящая техника, имеющаяся в вашем рас- распоряжении. Вместе с тем использование вложения в целом предпочтительней, по- поэтому его следует применять всегда, когда это возможно.
Правило 43 Правило 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 Lottery-Simulation: public Lottery, public GraphicalObject { ... //He объявляет draw. }; LotterySimulation *pls = new LotterySimulation; pls->draw(); // Ошибка! Неоднозначность. pls->Lottery::draw(); //Нормально pls->GraphicalObject::draw(); // Нормально. Код выглядит довольно неуклюже, но, по крайней мере, работает. К сожалению, избежать такой неуклюжести достаточно трудно. Даже если бы одна из наследуе- наследуемых функций draw была закрытой и, следовательно, недоступной, неоднозначность все равно осталась бы. (Для этого имеются веские причины, но их подробное разъяс- разъяснение можно найти в правиле 26, поэтому здесь не буду повторяться.) Явная квалификация членов не только выглядит неуклюже, но еще и илечет за собой ряд ограничений. Когда вы явным образом вызываете виртуаль «у-. • функ- функцию, используя название класса, она перестает быть виртуальной. !:-:•¦• >того
I Наследование и ООП вызывается именно та функция, которая была вами указана, даже для объекта производного класса: class SpecialLotterySirr.ulation: public LotterySimulation { public: virtual int crawl); pis = new SpecialLottcrySix.ulatior.; pls->draw(); // Ошибка! По-прежнему неоднозначность. pls->Lottery::draw(); // Вызов Lottery::draw. pls->GraphicaiObject::drawl); // Вызов GraphicalObject::draw. В данном случае следует обратить внимание на то, что хотя pis указывает на объект SpecialLotterySimulation, нет способа (исключая приведение ти- типов - см. правило 39) для вызова функции draw этого класса. Но подождите - и это еще не все! Функции draw в классах Lottery и Gra- phicalObject объявлены виртуальными так, чтобы подклассы могли их пере- переопределять (см. правило 36), но что если класс LotterySimulation захочет пе- переопределить их обе! Неприятность заключается в том, что этого сделать не удаст- удастся, поскольку класс может иметь только одну функцию с названием draw, не требующую аргументов. (Из этого закона существует исключение, когда одна функция объявлена с const, а другая - без const, см. правило 21.) Одно время эта проблема считалась достаточно серьезной для внесения изме- изменений в стандарт языка. В ARM, руководстве по C++, рассмотрен вопрос, как добиться того, чтобы наследуемые виртуальные функции можно было «переиме- «переименовывать», но затем обнаружилось, что данную проблему разрешается обойти до- добавлением пары дополнительных классов: class AuxLottery: public Lottery { public: virtual ir.t lotteryDrawl) = 0; virtual int draw() { return lotteryDrawl); } }; class AuxGraphicalObject: public GraphicalObject { public: virtual inn graphicalObjcctDrawl) = 0; virtual int drawl) { return graphicalObjectDrawl) ; } }; class LotterySiir.ulation: public AuxLottery, public AuxGraphicalObject { public: virtual int lottcryDrawl); virtual int graphicalObjectDrawl); Каждый из двух новых классов AuxLottery и AuxGraphicalObject, no существу, объявляет новое имя для наследуемых функций draw. Они принимают форму чисто виртуальных, в данном случае функций lotteryDraw и graphi- calObjectDraw, и поэтому подклассы обязаны их переопределить. Более того,
Правило 43 каждый класс переопределяет наследуемую функцию draw, вызывая в ней новую виртуальную функцию. Суммарный эффект состоит в том, что внутри иерархии классов неоднозначное название draw фактически расщепляется на два функцио- функционально эквивалентных названия: lotteryDrawи graphicalObjectDraw. LotterySimulation *pls = new LotterySinulation; Lottery *pl = pis; GraphicalObject *pgo = pis; // Это приводит к вызову LotterySimulation::lotteryDraw. pl->draw(); // Это приводит к вызову LotterySimulation:: graphicalObjectDraw. pgo->draw(); Подобный образ действий, предусматривающий активное использование про- продуманной комбинации чисто виртуальных, обычных виртуальных и встраиваемых функций (см. правило 33), следует взять на заметку. Во-первых, это позволяет ре- решить проблему, с которой вы можете столкнуться в любой момент; во-вторых, мо- может служить вам напоминанием о трудностях, возникающих при множественном наследовании. Да, такая тактика действенна, но действительно ли вы хотите, чтобы вам приходилось вводить новые классы только для переопределения виртуальных функций? Наличие классов AuxLottery и AuxGraphicalObject существенно для корректного функционирования иерархии, но они не соответствуют абстрак- абстракции ни в предметной области, ни в области реализации. Это просто аппарат реали- реализации - и ничего более. Как известно, хорошее программное обеспечение аппарат- но независимо. Это правило вполне применимо и в данном случае. Проблема неоднозначности, хотя сама но себе она и интересна, - это только малая толика тех курьезов, которые возникают при увлечении множественным наследованием. Другая проблема вырастает из эмпирического наблюдения, что иерархия наследования, начинающаяся следующим образом: V class В { ... }; class С { ... } ; class D: public В, public С { ... }; имеет неприятное свойство заканчиваться чем-нибудь вроде: class А { ... }; class В: virtual public A { ... } ; class С: virtual public Л { ... }; class D: public В, public С { ... } ; Такие иерархии наследования не очень дружелюбны. Если вы создаете подоб- подобную иерархию, перед вами немедленно встает вопрос: следует ли делать класс виртуальным базовым классом, то есть должно ли наследование от А быть вирту- виртуальным? На практике ответ утвердительный; лишь изредка у вас может возник- возникнуть необходимость в том, чтобы объект типа D содержал несколько копий эле- элементов данных класса А. В силу признания этой закономерности объявленные выше классы В и С объявляют класс А виртуальным базовым классом. Увы, к моменту определения в и С вам могло быть неизвестно, будет ли ка- какой-либо класс наследовать от них обоих, и в принципе для их корректного
Наследование и ООП определения вам нет необходимости знать это. Как разработчик классов вы оказы- оказываетесь перед весьма неприятной дилеммой. Если вы не объявите А как виртуаль- виртуальный базовый класс для классов В и С, позднее разработчику D может потребовать- потребоваться переопределить В и С для их более эффективного использования. Часто это невозможно постольку, поскольку иногда определения А, в и С могут быть доступ- доступны только для чтения. Например, так и окажется в случае, когда А, в и С находят- находятся в библиотеке, a D написан пользователем. С другой стороны, если вы объявляете А виртуальным базовым классом для в и С, то обычно обрекаете пользователей этих классов на дополнительные расходы памяти и времени исполнения. Это связано с тем, что виртуальные базовые классы обычно реализуются как указатели, а не как сами объекты. Само собой разумеется, что схема размещения объектов в памяти зависит от компилятора, но в принципе схема размещения в памяти объектов типа D с невиртуальным базовым классом - это, как правило, непрерывная последовательность блоков памяти, в то время как схема размещения объектов типа D с виртуальным базовым классом А - это зачас- зачастую последовательность блоков памяти, два из которых содержат указатели на блок памяти, в котором присутствуют элементы данных виртуального класса: А В А С D Part Part Part Part Part В С D A Part Part Part Part Даже те компиляторы, которые не придерживаются данной стратегии реали- реализации, все равно вызывают дополнительный расход памяти при использовании множественного наследования. В свете этих соображений может показаться, что для эффективной разработ- разработки классов при наличии множественного наследования разработчикам библио- библиотек необходимо обладать даром ясновидения. При том, что в наши дни даже обычное здравомыслие становится дефицитом, вряд ли стоит слишком сильно полагаться на такие свойства языка программирования, которые не только тре- требуют от разработчика предвидения будущих потребностей, но практически вы- вынуждают его становиться пророком. Конечно, то же самое можно было бы сказать и относительно выбора между вир- виртуальной и невиртуальной функцией, но здесь существует принципиальное отли- отличие. В правиле 36 объясняется, что виртуальная функция имеет вполне определен- определенную высокоуровневую интерпретацию, отличную от интерпретации невиртуальных функций, поэтому между ними можно выбирать, основываясь на том, что вы хотите сообщить разработчикам производных классов. Заметим, что при принятии решения
Правило 43 относительно того, следует ли делать базовый класс виртуальным, подобного руко- руководящего принципа у вас нет. Вместо этого решение обычно основывается на струк- структуре всей иерархии наследования, и, следовательно, его нельзя окончательно принять до тех пор, пока не станет известна вся иерархия. Если для корректного определения класса вам необходимо точно знать, как он будет использоваться, проектирование эффективных классов становится затруднительным. Как только вы разберетесь с проблемой неоднозначности и решите вопрос о том, должно ли наследование от вашего базового класса быть виртуальным, вы столкнетесь с рядом других проблем. Чтобы не тратить времени, я просто упо- упомяну еще два вопроса, о которых вам необходимо помнить: 1. Передача аргументов конструкторам виртуальных базовых классов. При не- невиртуальном наследовании аргументы конструктора базового класса зада- задаются в списке инициализации непосредственного производного класса. По- Поскольку при простом наследовании используются только невиртуальные базовые классы, аргументы в иерархии наследования передаются весьма естественным способом: классы уровня п передают аргументы классам уровня п-1. Аргументы же конструкторов виртуальных базовых классов задаются в списке инициализации самого старшего производного класса. В результате класс, инициализирующий виртуальный базовый класс, мо- может находиться на произвольном удалении от него в графе иерархии клас- классов, а тот, который ответствен за инициализацию, может меняться но мерс добавления к иерархии новых классов. (Вы имеете шанс избежать данной проблемы, устранив необходимость передачи аргументов конструкторам виртуальных базовых классов. Наиболее легкий способ достижения этой цели заключается в том, чтобы не определять в таких классах элементы дан- данных. Таким образом, например, решается проблема в языке Java: «интерфей- «интерфейсам» запрещено содержать данные.) 2. Доминирование виртуальных функций. Только вы начинаете думать, что избави- избавились от всех неоднозначностей, как они меняют форму. Вернемся к ромбовидно- ромбовидному графу иерархии, включающему в себя классы А, в, С и D. Предположим, что А определяет виртуальную функцию-член mf, а С ее переопределяет; при этом В и D не переопределяют mf. По аналогии со сказанным ранее можно ожи- (^д^ virtual void mf () ,• дать, что нижеследующий вызов будет неодно- неоднозначен: D *pd = new D; pd->mf () ; // A: :mf или С: :mf? VJL . . ^ ^ virtual void mf () ; Какая функция mf должна вызываться для объекта D: та, которая непосредственно насле- наследуется от С, или та, которая опосредованно (че- (через В) наследуется от А? Все зависит от того, каким образом объявлен класс А в списке базовых классов для В и для С. В частно- частности, если А - невиртуальный базовый класс для В и С, то вызов функции будет неоднозначным, однако если А - виртуальный базовый класс для В и С, тогда гово- говорят, что переопределение функции mf в классе С доминирует над первоначальным
Наследование и ООП определением в классе А, и вызов mf через pd будет однозначно разрешен как С: :mf. Если вы сядете и хорошенько во всем этом разберетесь, то поймете, что имен- именно такого повеления вы стремились добиться; но начинать разбираться прежде, чем все станет и без того понятным - это сущее наказание. Наверное, теперь вы согласны, что множественное наследование приводит к различным осложнениям. Вероятно, вы даже убеждены, что ни один человек, находящийся в здравом уме, не будет использовать эту возможность. Пожалуй, вы готовы предложить Международному комитету но стандартизации C++ удалить из языка множественное наследование или намекнуть своему менеджеру, чтобы программистам вашей компании строго-настрого запретили его использовать. Скорее всего, вы немного торопитесь. Необходимо помнить о том, что разработчики C++ не ставили своей целью сделать использование множественного наследования как можно более затрудни- затруднительным: просто оказалось, что попытка заставить разные части этого механизма работать вместе более или менее разумным образом неизбежно приводит к опре- определенному усложнению процесса. В процессе обсуждения данной проблемы вы могли заметить, что бо'лыпая часть затруднений связана с использованием вир- виртуальных базовых классов. Когда есть возможность избежать их применения - то есть образования опасного ромбовидного графа наследования - ситуация ста- становится намного благоприятнее. Например, в правиле 34 описаны классы-протоколы, служащие исключитель- исключительно для определения интерфейсов производных классов; они не содержат эле- элементов данных и конструкторов, но включают в себя виртуальный деструктор (см. правило 14) и набор чисто виртуальных функций, задающих интерфейс. Класс-протокол Person мог бы выглядеть следующим образом: 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; }; Поскольку абстрактные классы не могут быть инстанцированы, пользователи этого класса должны программировать с использованием указателей и ссылок типа Person. Для создания объектов, с которыми можно работать как с объектами класса Person, пользователи применяют функции-фабрики (см. правило 34), тем самым инстанцируя производные от Person классы: // Функция-фабрика для создания объекта класса Person //по уникальному идентификатору из базы данных. Person * makePerson(DatabaselD personldentifier); DatabaselD ask*JserForDatabaseID() ; DatabaselD pid = askUserForDatabaseID() ;
Правило 43 Person *pp = rr.akc?erson(pic) ; // Создаем объект, // поддержинаюцлй интерфейс Persor.. ... // Манипулируем *рр при помощи функций-члпнон класса Person, delete pp; // Удаляем объект, когда он 6on->::ie ке нужен. Сразу встает вопрос: каким образом makePerson создает объекты, на кото- которые он возвращает указатели? Ясно, что для этого должен быть отдельный кон- конкретный класс, производный от Person. Пусть такой класс называется MyPerson. Как конкретный класс, MyPerson должен обеспечить реализацию для чисто виртуальных функций, наследуемых от Person. Вы можете написать эти функции «с нуля», но лучше было бы вос- воспользоваться существующими компонентами, которые уже делают все необходи- необходимое или, по крайней мере, большую часть. Предположим, например, что уже су- существует старый класс Personlnfo, работающий с базами данных, и что он реализует основную функциональность, необходимую для MyPerson: class Personlnfo { public: Personlnfo(DatabaselD pic); virtual -Personlnfо(); virtual const char * theName() const; virtual const char * theBirthDate() const; virtual const char * theAddress() const; virtual const char * theNationality() const; virtual const char * valueDelimOpen() const; // Смотрите virtual const char * valueDelimClose() const; // ниже. Понятно, что это старый класс, поскольку функции-члены возвращают const char*, а не объекты string. Тем не менее, если он работает, то почему бы его не использовать? Судя по названиям функций этого класса, результат может ока- оказаться вполне удовлетворительным. Вы обнаруживаете, однако, что Personlnfo был создан для распечатки по- полей базы данных в определенном формате, причем начало и конец каждого поля отделялись специальной строкой. По умолчанию начальными и конечными огра- ограничителями полей служили квадратные скобки, поэтому значение поля «Лемур с окрашенным разноцветными кольцами хвостом» - Лемур колыдехвостый - будет представлено следующим образом: [Лемур колыдехвостый] Тот факт, что не все пользователи Personlnfo обязательно захотят применять квадратные скобки, находит отражение в виртуальности функций valueDelimOpen HvalueDelimClose, позволяющей производным классам задавать свои собствен- собственные начальные и конечные строки-разделители. Реализации функций Per- Personlnfo - theName, theBirthDate, theAddressиtheNationality - вы- вызывают указанные виртуальные функции для того, чтобы добавить к возвраща- возвращаемым значениям соответствующие разделители. Выбрав в качестве примера Personlnfo: :name, мы найдем в коде следующее:
Наследование и ООП const char * Persor.Infc: : valueDeiix.Open () const { return "["; // Открывающей разделитель по умолчанию. } const char * Personlnfo::valueDelirnClose() const { return "]"; // Закрывающий разделитель по умолчанию. } const char * Persor.lr.f о: : theNamc () const { // Зарезервировать буфер для возвращаемого значения. Поскольку // он объявлен static, то автоматически инициализирован нулями. static char value[MAX_FORMAT?ED_?IELD_VALUE_LENGTH]; // Записать открьзающий разделитель. strcpy (value, valueDelirr.Open () ) ; // Добавить поле name этого объекта к строке value. // Записать закрывающий, разделитель. strcat(value, valueDelimClosef)); return value; } Можно было бы начать придираться к реализации Personlnfo: : theName (особенно к использованию статического буфера фиксированного размера - см. пра- правило 23), но давайте пока забудем о придирках, а вместо этого обратим внимание на следующее: для получения начального разграничителя возвращаемой строки theName вызывает valueDelimOpen, затем генерирует само значение и, наконец, вызывает valueDelimClose. Поскольку valueDelimOpen и ValueDelimClose - это виртуальные функции, результат, возвращаемый theName, зависит не только от Personlnfo, но также и от классов, производных от Personlnfo. Для разработчика MyPerson это хорошая новость, поскольку, покопавшись в документации на класс Person, вы обнаружите, что name и аналогичные функции- члены должны возвращать несрорматированные значения, то есть использование раз- разделителей не допускается. Таким образом, для человека из Мадагаскара вызов функ- функции nationality должен возвращать Мадагаскар, а не [Мадагаскар]. Единственное связующее звено между MyPerson и Personlnfo -тот факт, что Personlnfo, как выяснилось, содержит некоторые функции, облегчающие реа- реализацию My Per -son. Между ними нет никакого отношения типа «есть разновидность» или «содержи!». Таким образом, они связаны отношением «реализован посред- посредством», а .мы знаем, что представить его можно двумя способами: с помощью вложе- вложения (см. правило 40) и с помощью закрытого наследования (см. правило 42). В прави- правиле 42'указано, что вложение - это, вообще говоря, более предпочтительный подход, но если требуется перегрузить виртуальные функции, то необходимо закрытое насле- наследование. В нашем случае классу MyPerson необходимо переопределить valueDe- valueDelimOpen и ValueDelimClose, поэтому подойдет не вложение, а закрытое насле- наследование. С его помощью MyPerson и должен быть произведен от Personlnfo. С другой стороны, MyPerson должен так же реализовывать интерфейс клас- класса Person, а это требует открытого наследования. Вот вам и одно из применений множественного наследования: объединение открытого наследования интерфей- интерфейса и закрытого наследования реализации.
Правило 43 class Person { // Этот класс задает интерфейс, подлежащий реализации, public: virtual -Person(); virtual string name() const = 0; virtual string birthDatel) const = 0; virtual string address() const = 0; virtual string nationality)) const = 0; class DatabaselD { ... }; // Используется ниже; детали несущественны, class Personlnfo {...}; // Этот класс содержит функции, public: // полезные для реализации Personlnfo(DatabaselD pid); // интерфейса Person. virtual -PersonlnfoО; virtual const char * theNameO 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 My Person:public Person, // Обратите внимание private Personlnfo { // на множественное наследование, public: MyPerson(DatabaselD pid) : Personlnfo(pid) {} // Переопределение унаследованных функций для разделителей. const char * valueDelimOpenf) const { return ""; } const char * valueDelimClose() const { return ""; } // Определение требуемых интерфейсом Person функций-членов. string name() const { return Personlnfo::theName(); } string birthDate() const { return Personlnfo::theBirthDate(); } string address!) const { return Personlnfo::theAddress(); } string nationality0 const { return Personlnfo::theNationalityО ; } }; Графически это выглядит так: Этот пример демонстрирует, что множественное наследование может быть одновременно и полезным, и понятным, хотя бросающееся в глаза отсутствие одиозного ромбовидного графа не случайно.
Наследование и ООП Тем не менее вы должны научиться избегать соблазнов. Иногда можно попасть в ловушку, используя множественное наследование для быстрого исправления иерархии наследования там, где для этой цели лучше подошло бы полное пе- перестраивание иерархии. Предположим, например, что вы работаете с иерархией персонажей мультфильма. В принципе каждый персонаж может танцевать и петь, но как он это делает, зависит от его особенностей. При этом поведение по умолча- умолчанию для функций танца и пения - ничего не делать. Выразить это на C++ можно следующим образом: class CartoonCharacter { public: virtual void dar.ee () {} virtual void singO {} }; Виртуальные функции естественным образом отражают тот факт, что пение и танец имеют смысл для всех объектов CartoonCharact ег. Поведение по умол- умолчанию можно выразить пустыми определениями соответствующих функций клас- класса (см. правило 36). Допустим, что один из типов персонажей — кузнечик, который танцует и поет особым образом: class Grasshopper: public CartoonCharacter { public: virtual void dance(); // Определение в другом месте. virtual void sir.gt) ; // Определение в другом месте. }; Теперь предположим, что после реализации класса Grasshopper вам пона- понадобился класс для сверчков: class Cricket: public CartoonCharacter { public: virtual void dance(); virtual void sing(); }; Когда вы принимаетесь за реализацию класса Cricket, вы осознаете, что мо- можете повторно использовать значительную часть класса Grasshopper. Однако этот код в разных местах необходимо слегка модифицировать, чтобы отразить раз- различия в пении и танце кузнечиков и сверчков. Вас неожиданно осеняет, каким об- образом повторно использовать существующий код: вы реализуете класс Cricket по- посредством класса Grasshopper и применяете виртуальные функции для того, чтобы позволить классу Cricket изменить поведение класса Grasshopper! Сразу же становится ясно, что одновременное выполнение двух требований - наличия отношения «реализация посредством» и возможности переопределять виртуальные функции - означает необходимость закрытого наследования Cricket от Grasshopper. При этом сверчок, разумеется, - персонаж мультфильма, поэто- поэтому вы переопределяете класс Cricket так, чтобы он наследовал как от Grass- Grasshopper, так и от CartoonCharacter:
Правило 43 class Cricket: :public CartoonCharactcr, private Grasshopper { public: virtual void dance(); virtual void singt); }; Затем вы начинаете вносить необходимые изменения в класс Grasshopper. В частности, требуется объявить ряд новых виртуальных функций, которые мож- можно будет потом переопределять в Cricket: class Grasshopper: public CartoonCharaccer { public: virtual void dance () ,- virtual void sing(); protected: virtual void danceCusconizationl(); virtual void danceCuscomization?. () ; virtual void singCustomization () ; }; Танец кузнечика теперь определяется следующим образом: void Grasshopper::dance() { выполнить общие танцевальные действия dar.ceCustomizatior.l () ; выполнить другие общие танцевальные действия danceCustomization?. () ; завершить общие танцевальные действия } Пение кузнечика определяется аналогичным образом. Ясно, что класс Cricket должен быть обновлен с учетом новых виртуальных функций, требующих переопределения: class Cricket:public CartoonCharacter, private Grasshopper { public: virtual void dance () { Grasshopper:: dar.ee () ; } virtual void sing О ( Grasshopper::sing(); } protected: virtual void danceCustomizationl{); virtual void dar.ceCustonization2 () ; virtual void singCustornizatior.() ; }; Кажется, что все работает здорово. Когда вы предлагаете объекту Cricket танцевать, он выполняет обычный код dance из класса Grasshopper, затем - специальный код класса Cricket, после чего продолжает выполнение кода из Grasshopper: :dance и т.д. Однако данный фрагмент таит в себе существенный недостаток: вы опро- опрометчиво попали под бритву Оккама, что, вообще говоря, плохо, какова бы ни была бритва, а уж тем более плохо, если это бритва Уильяма Оккама. Он учит,
Наследование и ООП что сущности не следует умножать без необходимости, а в нашем случае сущнос- сущности - это отношения наследования. Если вы считаете, что множественное наследо- наследование сложнее простого (а я подозреваю, что это именно так), то подобная реа- реализация класса Cricket является чрезмерно сложной. Суть проблемы состоит в том, что утверждение «класс Cricket реализуется посредством класса Grasshopper» неверно. На самом деле класс Cricket и класс Grasshopper используют общий код. В частности, они оба применяют код, опре- определяющий общие черты в поведении кузнечиков и сверчков при танце и пении. Выразить некоторую общность двух классов следует не с помощью наследова- наследования одного от другого, а с помощью наследования от общего базового класса. Об- Общий для кузнечика и сверчка код не принадлежит ни классу Cricket, ни классу Grasshopper. Он относится к новому классу, от которого они оба наследуют, - скажем, Insect: class Car^oonCharacter { ... }; class Insect: public CartoonCharacter { public: virtual void dance(); // Общий код для кузнечиков virtual void sir.gO; // и сверчков, protected: virtual void danceCustomizationl() = 0; virtual void danceCustomization2() = 0; virtual void singCustomizationf) = 0; class Grasshopper: public Insect { protected: virtual void danceCustomizationl virtual void danccCustomization2() virtual void singCustonizationl); class Cricket: public Insect { protected: virtual void danceCustomizatior.l () ; virtual void canceCustomization2(); virtual void singCustomizationt); Обратите внимание, насколько проще стала конструкция. В ней встречается только одиночное наследование и, более того, только открытое наследование. Клас- Классы Grasshopper и Cricket определяют исключительно функции, уточняющие поведение; функции dance и sing, наследуемые от Insect, остаются неизменны- неизменными. Уильям Оккам остался бы доволен. Хотя эта конструкция проще, чем конструкция с использованием множествен- множественного наследования, на первый взгляд она может показаться хуже. В конце концов, в отличие от подхода, основанного на использовании множественного наследова- наследования, применение простого наследования требует введения совершенно нового клас- класса. Зачем вводить дополнительный класс, если в этом нет необходимости? Здесь мы неизбежно сталкиваемся с соблазном использования множественно- множественного наследования. При поверхностном рассмотрении интерфейс множественного
Правило 44 наследования кажется более легким. Он не требует добавления новых классов, и хотя необходимо добавить в класс Grasshopper несколько новых виртуаль- виртуальных функций, это не составляет проблемы, поскольку такие функции все равно где-то пришлось бы объявлять. Представьте себе программиста, поддерживающего большую библиотеку классов C++, к которой необходимо добавить новый класс, подобно тому как Cricket должен быть добавлен к существующей иерархии CartoonCharactcr / Grasshopper. Программист знает, что существующей иерархией пользуются очень и очень многие, поэтому чем больше изменений вносится в библиотеку, тем больше хлопот доставит это пользователям. Разработчик намерен свести к минимуму возникающие неудобства. Поразмыслив над имеющимися возмож- возможностями, он осознает, что если добавить дополнительное закрытое наследование класса Cricket от Grasshopper, то другие изменения в иерархии не потре- потребуются. Программист радостно улыбается этой мысли, обрадованный перспек- перспективой большого выигрыша в функциональности за счет небольшого проигрыша в сложности. Представьте себя на месте этого программиста и... удержитесь от соблазна. Правило 44. Говорите то, что думаете, понимайте то, что говорите Во введении к этому разделу, посвященному объектно-ориентированному программированию, я подчеркнул, насколько важно понимать, что означают раз- различные объектно-ориентированные конструкции C++. Такое понимание во мно- многом отличается от простого знания правил языка. Например, правила C++ гово- говорят, что если класс D открыто наследует от класса в, то существует стандартное преобразование указателей D в указатели В; что открытые функции-члены клас- класса В наследуются как открытые функции-члены класса D и т.д. Все это верно, по практически бесполезно, если вы стараетесь перевести ваш проект на язык C++. Вместо этого необходимо осознавать, что открытое наследование означает «есть разновидность», что если D открыто наследует от В, то каждый объект типа D является разновидностью объекта типа В. Таким образом, если при проектирова- проектировании вы имеете в виду отношение «есть разновидность», то вам следует использо- использовать открытое наследование. Говорить то, что вы имеете в виду, - значит выиграть лишь половину сраже- сражения. Оборотная сторона медали, а именно понимание того, что вы говорите, не менее важна. Например, было бы безответственно, если не сказать аморально, на- наспех объявить функции класса невиртуальными, не осознавая, что при таком оп- определении вы налагаете ограничения на производные классы. Объявляя невирту- невиртуальную функцию-член, вы в действительности утверждаете следующее: данная функция представляет собой инвариант относительно специализации; если это- этого не учитывать, последствия будут плачевными. Эквивалентность открытого наследования и отношения «есть разновидность», а также эквивалентность невиртуальных функций-членов и инвариантности по отношению к специализации - это примеры, иллюстрирующие, насколько
и Наследование и ООП определенные конструкции C++ соответствуют идеям разработчика. Следующий список содержит наиболее важные из этих соответствий: 1. Наличие общего базового класса озлачает наличие общих свойств. Если класс D1 и класс D2 объявляют класс 3 своим базовым классом, то D1 и D2 наследуют общие элементы данных и/или общие функции-члены в (см. правило 43). 2. Открытое наследование означает «есть разновидность». Если класс D откры- открыто наследует от класса В, то каждый объект типа D также является объектом типа В, но не наоборот (см. правило 35). 3. Закрытое наследование означает «реализацию посредством». Если класс D закрыто наследует от класса В, объекты тина D достаточно просто реализу- реализуются с помощью объектов типа В; между объектами типов В и D нет концеп- концептуальной взаимосвязи (см. правило 42). 4. Вложемие означает «содержит» или «реализуется посредством». Если класс А содержит элементы данных типа В, то объекты типа А либо имеют компо- компонент типа В, либо реализуются посредством объектов типа В (см. правило 40). Следующие соответствия применимы только тогда, когда речь идет об откры- открытом наследовании: 1. Чистая виртуальность функции означает, что наследуется только интер- интерфейс функции. Если класс С объявляет чисто виртуальную функцию-член mf, то подклассы С должны наследовать интерфейс mf и предоставить для нее свои собственные реализации (см. правило 36). 2. Обычная виртуальность функции означает, что наследуется интерфейс плюс реализация по умолчанию. Если класс С объявляет простую виртуаль- виртуальную функцию ir.f, то подклассы С должны наследовать интерфейс mf и мо- могут также по вашему желанию наследовать реализацию по умолчанию (см. правило 36). 3. Невиртуальность функции означает, что наследуется интерфейс плюс обязательная реализация функции. Если класс С объявляет невиртуаль- невиртуальную функцию-член mf, то подклассы С должны наследовать как интер- интерфейс mf, так и ее реализацию. В действительности mf определяет инва- инвариант относительно специализации С (см. правило 36).
Глава 7. Другие принципы Некоторые принципы эффективного программирования на C++ с трудом подда- поддаются классификации. Они-то и собраны в данном разделе. Указанная особенность, впрочем, не умаляет их значимости. Если ваша цель - написание эффективного программного обеспечения, необходимо понимать, что за вашей спиной делает компилятор; как добиться того, чтобы нелокальные статические объекты инициа- инициализировались прежде, чем вы их используете; чего можно ожидать от стандарт- стандартной библиотеки; как усвоить философию, лежащую в основе языка. В заключи- заключительном разделе книги я постараюсь дать ответ на эти и некоторые другие вопросы. Правило 45. Необходимо знать, какие функции неявно создает и вызывает C++ Когда пустой класс не является пустым классом? Когда за него берется C++. Если этого не сделаете вы, «заботливый» компилятор объявит свою собственную версию конструктора копирования, оператора присваивания, деструктора и пары операторов получения адреса. Более того, если вы не определите конструктор, то компилятор и это сделает за вас. Все указанные функции будут открытыми. Дру- Другими словами, если вы напишете следующее; class Empty{}; это будет равносильно тому, что вы скажете: class Err.pty { public: Empty () ,- // Конструктор по умолчанию. Empty(const Empty& rhs); // Конструктор копирования. -EmptyO; // Деструктор - смотрите ниже, виртуальный ли он. Empty&/ operator = (const F.mptyk rhs) ; // Оператор присваивания. Empty* operators(); // Операторы получения адреса. const Empty* operator^О const; }; Заметьте, что эти функции генерируются, только если они необходимы; впро- впрочем, необходимость в них возникает очень часто. Следующий код приводит к созданию таких функций: const Empty el; // Конструктор по умолчанию; деструктор. Empty o2(el); // Конструктор копирования.
Другие принципы е2 = el; // Оператор присваивания. Empty *pe2 = &е2; // Оператор получения адреса (не-const). const Empty *pel = &el; // Оператор получения адреса (const) . Что делает компилятор, когда он пишет за вас функции? Конструкторы и де- деструкторы по умолчанию в действительности ничего не делают. Они просто дают вам возможность создавать и удалять объекты класса. (Это также удобное место, где раз- разработчики компилятора могли разместить код, ответственный за «закулисное» пове- поведение - см. правило 33.) Обратите внимание, что генерируемый деструктор не явля- является виртуальным (см. правило 14), если данный класс сам не наследует от базового класса, объявляющего виртуальный деструктор. Операторы получения адреса по умолчанию просто возвращают адрес объекта. Все вышеперечисленные функции ведут себя так, как если бы они определялись следующим образом: inline Empty::Empty() {} inline Empty::-Empty() {} inline Empty * Empty::operator&() { return this; } inline const Empty * Empty::operators() const { return this; } Для конструктора копирования и оператора присваивания формальное прави- правило таково: конструктор копирования по умолчанию (оператор присваивания) вы- выполняет почленное копирование (присваивание) нестатических членов класса. Иными словами, если m - нестатический член класса С типа т и С не объявляет конструктор копирования (оператор присваивания), то m будет создан копи- копированием (присваиванием) с использованием конструктора копирования (опера- (оператора присваивания), определенного для т, если таковой имеется. Если же кон- конструктор отсутствует, правило рекурсивно применяется к членам класса до тех пор, пока мы не доходим до конструктора копирования (оператора присваива- присваивания) или встроенного типа данных (например, int, double, указателей и т.д.). По умолчанию объекты встроенных типов создаются побитовым копированием (присваиванием) исходного объекта в конечный. Для классов, наследующих от других классов, это правило применимо на каждом уровне иерархии наследо- наследования, так что определенные пользователем конструкторы копирования и опера- операторы присваивания вызываются на тех уровнях, на которых они определяются. Надеюсь, что здесь все предельно ясно. Но на тот случай, если что-то осталось непроясненным, изучите следующий пример. Рассмотрим определение шаблона NamedObject, который генерирует классы, позволяющие связывать с объектами идентификаторы: template<class T> class NamedObject { public: NamedObject(const char *name, const T& value); NamedObject(const strings name, const T& value); private: string nameValue; T objectValue;
Правило 45 Поскольку класс NamedObj ect определяет по крайней мере один конструктор, компилятор не будет генерировать конструктор по умолчанию, а так как этот класс не определяет ни конструктор копирования, ни оператор присваивания, компиля- компилятор будет генерировать эти функции (если в них возникнет необходимость). Рассмотрим следующий вызов конструктора копирования: NamedObject<int> nol("Smallest Prime Number", 2); NamedObject<:r.t> no2(nol); // Вызов конструктора копирования. Конструктор копирования, генерируемый вашим компилятором, должен ини- инициализировать по2 .nameValue и no2 .objectValue, используя соответственно nol .nameValue и nol .objectValue. Тип nameValue - string, a string имеет конструктор копирования (в чем вы можете убедиться, исследуя string из стан- стандартной библиотеки - см. правило 49); таким образом, no2 .nameValue будет инициализирован вызовом конструктора копирования string с аргументом nol. nameValue. С другой стороны, тип NamedObj ect<int>: : obj ectValue - int (поскольку Т при данной инстанциации шаблона - int), а для типа int кон- конструктор копирования не определяется, поэтому no2 .objectValue будет ини- инициализирован побитовым копированием nol .objectValue. Генерируемый компилятором оператор присваивания для NamedObj ect<int> будет действовать аналогичным образом, но только тогда, когда получаемый код, во-первых, корректен, а во-вторых, может иметь смысл. Если любое из этих условий не выполняется, компилятор откажется генерировать для вашего класса operator=, и в ходе компиляции вы получите ряд забавных диагностических сообщений. Например, предположим, что NamedObj ect был определен следующим обра- образом (здесь nameValue - это ссылка на строку, a ob j ectValue имеет тип const T): template<class T> class NamedObject { public: // Этот конструктор уже не имеет константного параметра // name, поскольку nameValue теперь является ссылкой // на неконстантный объект string. Конструктора, использующего // char *, больше нет, поскольку теперь есть ссылка на string. NamedObject(strings name, const T& value). ...II Как и выше, предположим, что оператор= не объявлен. private: strings nameValue; // Теперь это ссылка, const T objectValue; // Теперь это const. }; Давайте посмотрим, что произойдет дальше: string newDogf"Persephone"); string oldDog("Satch"); NamedObject<int> pfr.ewDog, 2) ,- // На момент написания этой книги нашей // собаке Персефоне чуть меньше двух лет. NamedObject<int> s(oldDog, 29);// Собаке Сэтч, жившей а нашей семье, // когда я был ребенком, исполнилось бы //29 лет, будь она еще жива. р = s; // Что должно произойти с даннкки объекта р?
Другие принципы Перед присваиванием p.nameValue ссылается на некоторый объект string, так же как и s .nameValue, который ссылается на другую строку strir.q. Как присваивание отразится nap.nameValuc? Будет ли p. nameValue после присва- присваивания ссылаться на ту же строку string, на которую ссылается s .nameValue, то есть должна ли изменяться сама ссылка? Если должна, то это создаст преце- прецедент, поскольку C++ не обеспечивает средств, позволяющих ссылкам ссылаться на другие объекты. С другой стороны, должен ли модифицироваться объект string, на который ссылается p.nameValue, что повлияет и на другие указате- указатели и ссылки, указывающие на лгу строку, - то есть на объекты, непосредственно не вовлеченные в операцию присваивания? И как прикажете поступать опера- оператору присваивания, генерируемому компилятором? Столкнувшись с такими головоломками, C++ отказывается компилировать код. Если вы хотите по;ьчерживать присваивание для классов, содержащих ссылки, опе- оператор присваивания вам необходимо определить самостоятельно. Аналогичным об- образом компиляторы ведут себя с классами, содержащими члены с const (такими, как objectValue в рассмотренном выше классе); модифицировать члены с const за- запрещено, поэтому как с ними быть в генерируемой по умолчанию функции присва- присваивания, компилятор не «знает». И наконец, компиляторы отказываются генериро- генерировать операторы присваивания производных классов, если базовые классы объявляют стандартный оператор присваивания закрытым. В конечном счете генерируемые компилятором операторы присваивания производных классов должны обраба- обрабатывать и части, относящиеся к базовым классам (см. правило 16), но при этом не должны вызывать функции, вызывать которые у производного класса нет права. Приведенный анализ генерируемых компилятором функций ставит вопрос о том, что необходимо делать, если вы хотите запретить использование этих функ- функций. Л что, если вы преднамеренно не объявляете, например, operator=, по- поскольку вам вообще не хочется допускать присваиваний объектов вашего класса? Решение этой небольшой задачки - тема правила 27. Вопрос о том, какие пробле- проблемы (часто упускаемые из виду) связаны с наличием в классе членов-указателей при создании компиляторами конструкторов копирования и операторов присва- присваивания, обсуждается в правиле 11. Правило 46. Предпочитайте ошибки во время компиляции ошибкам во время выполнения Кроме тех редких случаев, когда C++ вынужден генерировать исключения (например, при нехватке памяти, см. правило 7), понятие ошибок во время выпол- выполнения (runtime error) настолько же чуждо языку C++, насколько оно чуждо С. В обоих языках отсутствуют средства обнаружения ошибок округления, перепол- переполнения, деления на ноль; нет проверки выхода за границы массива и т.д. Как толь- только программа проходит компиляцию и компоновку, вся ответственность ложит- ложится на вас - здесь нет «ремня безопасности», защищающего от последствий тех или иных действий. 'Гак же, как и в случае затяжных прыжков с парашютом, одних людей это привлекает, других приводит в ужас. В основе такой философии, бе- безусловно, лежит стремление к эффективности: без контроля ошибок в момент вы- выполнения программа получается меньше и быстрее.
Правило 46 й| Существует и другой подход. Языки, подобные 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 :
Другие принципы Datednt day, Month month, int year); К сожалению, это не решает всех ваших проблем, поскольку перечислимыс типы не требуют обязательной инициализации: Month X; Date dB2, m, 1857); // m не определено. В результате конструктор Date все еще нуждается в проверке допустимости значения ар1"умента 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 Months month, int year); Несколько аспектов этого варианта решения в своей совокупности обеспечи- обеспечивают его действенность. Во-первых, конструктор для Month — закрытый. Это не дает пользователям самостоятельно создавать новые месяцы. Единственно воз- возможные значения возвращаются статическими функциями-членами Month либо получаются их копированием. Во вторых, каждый объект Month - это объект типа const, поэтому его нельзя изменять. (Иначе соблазн преобразовать январь в июнь мог бы однажды оказаться непреодолимым, по крайней мере, в северных широ- широтах.) И наконец, единственный способ получить объект Month - это вызов функ- функции или копирование существующего объекта Month (посредством неявного конструктора копирования, см. правило 45). Теперь объекты Month можно ис- использовать в любом месте и в любое время; нет нужды беспокоиться о том, чтобы случайно не использовать объект до того, как он будет инициализирован. (Пра- (Правило 47 объясняет, почему в противном случае у вас могут возникнуть проблемы.) С такими классами пользователь почти не имеет возможности задать недопу- недопустимый месяц. Это было бы вообще невозможно, если бы удалось предотвратить следующую ситуацию:
Правило 47 Month *pm; // Определяем неинициализированный указатель. Date d(l, *pm, 1997); // Бррр! Используем его! Здесь имеет место обращение к неинициализированному указателю, - опера- операция, результат которой не определен. (Мои соображения по поводу неопределен- неопределенного поведения изложены в правиле 3.) К сожалению, я не знаю способа, который позволил бы свести на нет подобную вероятность. Однако если мы предположим, что ничего похожего не произойдет, или если нам безразлично, что будет делать программа в этом случае, мы можем избежать проверки аргумента Month в кон- конструкторе Date. С другой стороны, конструктор псе еще должен проверять аргу- аргумент day - сколько дней в сентябре, апреле, июне, ноябре. В этом примере с Date проверка во время выполнения программы заменяется проверкой во время компиляции. Вероятно, для вас остается загадкой, когда мож- можно использовать проверку во время компоновки. На самом деле это позволяется делать не очень часто. C++ использует компоновщик для того, чтобы гарантиро- гарантировать, что необходимые функции определяются только один раз (определение не- необходимости функции - см. правило 45). Он также применяет компоновщик для того, чтобы статические объекты (см. правило 47) определялись только один раз. Например, в правиле 27 показано, как проверки, осуществляемые компоновщи- компоновщиком, помогают избежать определения функций, которые вы явно объявляете. Мой вам совет: не впадайте в крайности. Попытка устранить все проверки во время выполнения была бы непрактичной. Например, любая программа, до- допускающая интерактивный ввод, должна, по-видимому, осуществлять проверку на допустимость вводимых значений. Аналогично класс, реализующий массивы с проверкой диапазона (см. правило 18), обычно должен проверять индекс масси- массива всякий раз, когда к нему осуществляется доступ. Тем не менее перенос провер- проверки с момента выполнения на момент компиляции или компоновки - это достой- достойная цель, к которой всегда следует стремиться. Наградой вам будут меньшие по объему, более быстрые и надежные программы. Правило 47. Обеспечьте инициализацию нелокальных статических объектов до их использования Вы уже многое узнали, и поэтому нет необходимости объяснять вам, что неле- нелепо использовать объекты до их инициализации. Собственно, сама постановка во- вопроса может показаться вам абсурдной; ведь конструкторы и так гарантируют инициализацию объектов в момент, их создания, не правда ли? И да, и нет. В пределах данной единицы трансляции (то есть исходного фай- файла) все работает хорошо, но ситуация становится намного сложнее, когда инициа- инициализация объекта в одной единице трансляции зависит от значения другого объекта в другой единице трансляции, а другой объект сам нуждается в инициализации. Допустим, что вы являетесь автором библиотеки, реализующей абстракцию файловой системы, которая включает возможность сделать файлы из Internet не- неотличимыми от локальных. Поскольку для вашей библиотеки мир представляет собой единую файловую систему, вы могли бы создать внутри пространства имен
Другие принципы специальный объект 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 Необходимо, чтобы поведение программы не зависело от порядка инициали- инициализации нелокальных статических объектов в различных единицах трансляции, по- поскольку изменить этот порядок вы не можете. Повторим еще раз. Вы никак не можете управлять порядком, в котором инициализируются нелокальные ста- статические объекты, определенные в различных единицах трансляции. Естественно, возникает вопрос, почему так происходит. Причина в том, что очень трудно, практически невозможно определить над- надлежащий порядок инициализации нелокальных статических объектов. В самом общем случае - при наличии нескольких единиц трансляции и нелокальных ста- статических объектов, неявно генерированных из шаблонов (которые и сами могут возникать из-за неявной генерации шаблонов) -• не только невозможно опреде- определить правильный порядок инициализации, но даже и нет смысла искать частные случаи, для которых было бы реально определить такой порядок. В теории хаоса существует понятие «эффект бабочки». Суть его в том, что не- небольшое возмущение, вызванное взмахом крыльев бабочки в одной части мира, может привести к серьезным изменениям погоды на большом удалении от этого места. Или, выражаясь более строго, для некоторых типов систем небольшое из- изменение на входе может вести к радикальным изменениям на выходе. В отношении разработки программного обеспечения также проявляется «эф- «эффект бабочки». Некоторые системы весьма чувствительны к мельчайшим нюан- нюансам исходных требований, малые изменения которых могут существенно повли- повлиять на сложность реализации системы. Например, правило 29 описывает, как изменение спецификации преобразования по умолчанию со String в char* на String в const char* дает возможность заменить медленные и ненадежные функции быстрыми и безопасными. Простота решения проблемы инициализации нелокальных статических объ- объектов перед их использованием также зависит от того, как вы формулируете свои цели. Если вместо доступа к нелокальным статическим объектам вы хотите осу- осуществить доступ к объектам, которые во всем функционируют подобно нелокаль- нелокальным статическим (за исключением неприятностей с инициализацией), то проб- проблема снимается. Вернее, остается задача, которую настолько легко решить, что нет даже смысла называть ее проблемой. Прием, известный как синглетоп (Singleton pattern), необычайно прост. Сна- Сначала вы перемещаете каждый нелокальный статический объект в отдельную функцию, где объявляете его static. Далее необходимо сделать так, чтобы функ- функция возвращала ссылку на содержащийся в ней объект. Пользователи вместо ссылки на объект вызывают функцию. Другими словами, нелокальные стати- статические объекты заменяются объектами, статическими внутри функций. В основе этого подхода лежит наблюдение, что C++ довольно точно опреде- определяет, когда инициализируются статические объекты внутри функции (например, локальный статический объект), - в первый раз, когда определение объекта встре- встречается во время вызова данной функции. Таким образом, если вы замените пря- прямой доступ к нелокальным статическим объектам вызовом функций, возвращаю- возвращающих ссылки на расположенные внутри них локальные статические объекты, то можете быть уверены, что ссылки, возвращаемые из функций, будут ссылаться на
Другие принципы инициализированные объекты. Дополнительное преимущество заключается в том, что, если вы никогда не вызываете функцию, эмулирующую нелокальный статический объект, вам не приходится «платить» за создание и удаление объекта, чего нельзя сказать о настоящих нелокальных статических объектах. Вот как этот прием применяется для theFileSystem и tempDir: class FileSystem { ... }; // Тот же, что и выше. FiIeSystern& thcFileSystem() // Эта функция заменяет { // объект theFileSystem. static FileSystem tfs; // Объявляем и инициализируем локальный // статический объект (tfs = "the file // system"). return tfs; // Возвращаем ссылку на него. } class Directory {...}; // Тот же, что и выше. Directory::Directory О { // Тот же код, что и выае, только вместо theFileSystem // теперь используется theFileSystem() . } DirectorySc tempDirO // Эта функция заменяет объект tempDir. { static Directory td; // Объявляем и инициализируем // локальный статический объект. return td; // Возвращаем ссылку на него. } Пользователи этой модифицированной системы продолжают программи- программировать так, как привыкли, только теперь они используют theFileSystem() и tompDir () вместо theFileSystem и tempDir. Иными словами, применяют- применяются лишь функции, возвращающие эти объекты, и никогда не используются напря- напрямую сами объекты. Функции, которые в соответствии с данной схемой возвращают ссылки, все- всегда просты: определить и инициализировать локальный статический объект в строчке 1 и вернуть его в строчке 2, ничего более. В связи с этим у вас может возникнуть искушение объявить их встраиваемыми. В правиле 33 объясняется, что последние изменения в спецификации языка C++ допускают такую страте- стратегию реализации, и, кроме того, показано, почему предварительно стоит убедить- убедиться, что ваш компилятор удовлетворяет соответствующей спецификации стандар- стандарта. Если вы попытаетесь выполнить такую процедуру, используя компилятор, который еще не соответствует стандарту в части, касающейся обсуждаемых воз- возможностей, вы рискуете получить несколько копий как функции доступа, так и статического объекта, определенного в ней. От этого впору зарыдать даже опытному программисту. Следует сказать, что рассмотренный прием чудес не творит. Он будет рабо- работать при условии правильного порядка инициализации объектов. Если вы напи- напишете код, в котором объект А должен будет инициализироваться прежде, чем объ- объект В, и одновременно сделаете инициализацию Л зависимой от инициализации В,
Правило 48 то вас ждут проблемы - и поделом! Если, однако, вы будете избегать таких пато- патологических ситуаций, то схема, описанная в данном правиле, сослужит вам доб- добрую службу. Правило 48. Уделяйте внимание предупреждениям компилятора Многие программисты зачастую игнорируют предупреждения компилятора. В конце концов, если бы проблема была по-настоящему серьезной, то компиля- компилятор выдал бы ошибку! Подобные рассуждения могут быть сравнительно безвред- безвредными при работе с какими-нибудь другими языками, но в отношении C++ мож- можно поручиться, что создатели компиляторов точнее вас оценивают истинное положение дел. Например, ниже приведена ошибка, которую рано или поздно со- совершает каждый из нас: class 3 { public: virtual void f () const; }; class D: public В { public: virtual void f () ; } Предполагается, что функция D: : f будет переопределять виртуальную функ- функцию В: : f, но ошибка состоит в следующем: в В функция-член f - константная, а в D она не объявляется как const. Один из известных мне компиляторов выда- выдаст следующее: warning: D::F() hides virtual B::F() Многие неопытные программисты, получив подобное сообщение, говорят себе: «Конечно, D: : f скрывает В: : f - так и должно было быть!» Они неправы. Вот что пытается «сказать» компилятор: f, определенная в в, была не переопре- переопределена в D, а полностью спрятана (объяснение причины этого явления содержит- содержится в правиле 50). Если оставить без внимания данное предупреждение компиля- компилятора, это наверняка приведет к неопределенному поведению программы, и, чтобы найти причину, потребуются долгие часы отладки - при том, что компилятор дав- давно уже все обнаружил. После того как вы приобретете опыт работы с предупреждениями опреде- определенного компилятора, уже нетрудно будет понимать, что означают различные со- сообщения (к сожалению, нередко реальное значение сообщения кардинально от- отличается от предполагаемого). Потренировавшись, вы впоследствии сможете спокойно игнорировать целый ряд предупреждений. Это ничуть не опасно при одном условии: прежде чем отклонить предупреждение, важно убедиться, что вы точно вникли в его смысл. Раз уж мы затронули тему предупреждений, стоит заметить, что они по своей природе зависимы от реализации, поэтому не следует слишком расслабляться
Другие принципы и перекладывать на компилятор обнаружение ваших ошибок. Например, код с со- сокрытием функции, приведенный выше, проходит через другой (к сожалению, широкораспространенный) компилятор без появления каких-либо предупреж- предупреждений. Компиляторы служат для трансляции C++ в формат исполняемого фай- файла, а не в качестве «ремней безопасности». Вам требуются персональные ремни безопасности? Программируйте на языке Ada. Правило 49. Ознакомьтесь со стандартной библиотекой В C++ имеется очень большая стандартная библиотека. Насколько большая? Я бы сформулировал это следующим образом: спецификация стандарта занима- занимает свыше 300 страниц мелким шрифтом, и все это помимо стандартной библио- библиотеки С, которая включена в библиотеку C++ «по ссылке» (принято использовать именно этот термин). Чем больше функциональности в стандартной библиотеке, тем на большую функциональность вы можете рассчитывать, разрабатывая приложение. Библио- Библиотека C++ не универсальна (бросается в глаза отсутствие поддержки параллель- параллельных вычислений и графического интерфейса), но чрезвычайно обширна. На нее может уверенно опереться практически любое приложение. Прежде чем дать обзор содержания библиотеки, необходимо рассказать о том, как она организована. Поскольку компонентов очень много, есть шанс, что вы (или кто-нибудь еще) можете выбрать имя класса или функции, которое совпа- совпадает с именем из стандартной библиотеки. Чтобы вам не пришлось столкнуться с подобными конфликтами имен, практически все в ней помешено в простран- пространство имен std (см. правило 28). По это ведет к новой проблеме. Миллионы строк существующего в C++ кода опираются на функциональность псевдостандартной библиотеки, использовавшейся долгие годы, когда функции, например, объявля- объявлялись в файлах-заголовках <iostream.h>, <complex.h>, <limits .h> и т.п. Это уже существующее программное обеспечение не было рассчитано на использо- использование пространств имен, и было бы жаль, если бы перенос стандартной библио- библиотеки в std привел к проблемам с таким кодом. (Авторы существующего кода, наверное, использовали бы более сильные выражения, нежели «жаль», будучи лишенными возможности опираться на стандартную библиотеку.) Не желая вызвать праведный гнев программистов, Комитет по стандартиза- стандартизации решил создать новые включаемые файлы с элементами, определенными в пространстве имен std. Алгоритм, избранный для создания новых названий заголовков файлов, настолько же прост, насколько его результаты вызывают раз- раздражение: в существующих заголовочных файлах C++ всего-навсего опущено .h. Таким образом, <iostrcam.h> становится <iostream>, <complex.h>- <complex> и т.д. Применительно к заголовкам С использован тот же принцип, но дополнительно в начале имени заголовка была добавлена буква с. Следова- Следовательно, <string.h> становится <cstring>, <stdio.h> - <cstdio> и т.д. И по- последний штрих: старые заголовки C++ официально не рекомендованы (то есть
Правило 49 они более не поддерживаются), а по отношению к старым заголовкам С этого сделано не было ввиду поддержки совместимости с С. В действительности у раз- разработчиков компиляторов нет повода отрекаться от унаследованного ими про- программного обеспечения, а потому можно ожидать, что старые заголовочные фай- файлы будут поддерживаться еще в течение очень долгого времени. Если оценить вышесказанное с практической точки зрения, то ситуация с за- заголовками в C++ такова: ? старые названия заголовков C++, такие как < lost ream. h>, скорее всего, будут по-прежнему поддерживаться, даже несмотря на то, что они не отно- относятся к официальному стандарту. Содержимое подобных заголовков не вхо- входит в пространство имен std; ? новые названия заголовков, такие как <iostream>, наделены в основном теми же возможностями, что и соответствующие старые заголовки, но со- содержание заголовков размещено в пространстве имен std. (В ходе стандар- стандартизации некоторые компоненты библиотеки претерпели незначительные из- изменения, поэтому между отдельными элементами старых и новых заголовочных файлов нет точного соответствия); ? по-прежнему поддерживаются стандартные заголовки С, такие как <stdio. h>. Содержимое подобных заголовков находится вне std; ? новым заголовкам C++ для функций библиотеки С присвоены имена типа <cstdio>. Они предлагают то же самое, что и прежние заголовочные фай- файлы С, но их содержимое находится в пространстве имен std. Ситуация на первый взгляд кажется немного запутанной, но на самом деле в нее не так уж сложно вникнуть. Наибольшая трудность состоит в том, чтобы разобраться с заголовками для строк: <string.h> - это старый заголовок для фун- функций, работающих со строками char*; <string> - действующий в std заголо- заголовочный файл новых классов для работы со строками (см. ниже), a <cstring> - это std-версия старого заголовочного файла С. Если вы сможете в этом разобраться (лично я уверен, что вы справитесь), освоить все остальное будет довольно просто. Вам необходимо знать еще об одной особенности стандартной библиотеки: в ней практически все представлено в виде шаблонов. Давайте посмотрим на по- потоки ввода/вывода. (Если вы не в ладах с ними, обратитесь к правилу 2.) Потоки ввода/вывода помогают вам работать с потоками символов, но что такое символ? Это char? Или wchar_t? Символ Unicode? Какой-то другой многобайтовый символ? Очевидно, здесь не существует единственно правильного ответа, поэтому библиотека позволяет вам самостоятельно осуществить выбор. Все классы с по- потоками - это в действительности шаблоны классов, и при инстанциации классов потоков вы можете выбрать тип символа. Например, стандартная библиотека определяет тип cout KaKostream, но в действительности ostream - это typedef для basic_ostream<char>. Аналогичные соображения применимы к большинству других классов стан- стандартной библиотеки. Например, string - это не класс, а шаблон класса: параметр- тип определяет тип символов в каждом из классов string; complex - не класс, а шаблон класса: параметр-тип определяет тип реальной и мнимой компоненты
Другие принципы каждого класса complex; vector - это не класс, а шаблон класса. Подобных при- примеров очень много. Вам не удастся избежать использования шаблонов стандартной библиотеки, но если вы работаете только с потоками и строками char, то можете их игнори- игнорировать. Суть в следующем: библиотека определяет типы с помощью typedef для специализаций этих компонентов, использующих char в качестве параметра typedef, что позволяет вам программировать, используя объекты cin, cout, cerr, типы istream, ostream, string и не беспокоясь о том, что в действи- действительности тип cin - basic_istream<char>, a string - basic_string<char>. Многие компоненты библиотеки в гораздо большей степени опираются на шаблоны, чем можно было бы предположить. Давайте опять рассмотрим простое, казалось бы, понятие строки. Несомненно, строка может быть параметризована на основании типа содержащихся в ней символов, но наборы символов иногда немного различаются, например специальными символами конца файла, наиболее эффективными способами копирования массивов символов и т.п. Такие характе- характеристики называются в стандарте свойствами (traits) и задаются дополнительны- дополнительными аргументами шаблонов. В дополнение объекты string должны производить динамическое выделение и высвобождение памяти, но эту задачу можно решить множеством различных способов (см. правило 10). Какой из них лучше? У вас есть выбор: шаблон string требует параметра Allocator, а объекты типа Allocator выделяют и высвобождают память, используемую объектами string. Вот вполне законченное объявление шаблона basic_string и использую- использующее его определение typedef; в файле заголовка <string> вы можете увидеть что-то вроде: namespace std { template<class charT, class traits = char_traits<charT>, class Allocator = allocator<charT> > class basic_string; typedef basic_string<char> string; } Заметьте, что для traits и Allocator в basic_string определены значе- значения по умолчанию. Для стандартной библиотеки это типичная ситуация. Пользо- Пользователи, которые хотят делать «обычные» вещи, могут проигнорировать сложности, сопутствующие гибкости. Другими словами, если вы хотите получить объекты- строки, которые ведут себя более или менее аналогично строкам С, то можете ис- использовать объекты string и оставаться в счастливом неведении касательно того, что в действительности применяете объекты типа basic_string<char, char_traits<char>, allocator<char> >. Да, в большинстве случаев разрешается так поступать. Иногда, однако, воз- возникает потребность «заглянуть под капот». Например, в правиле 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; template<class 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++: ? стандартная библиотека С. Она никуда не исчезла, и вы по-прежнему мо- можете ее использовать. Кое-где в нее были внесены незначительные измене- изменения, но это все та же старая библиотека С, которая служит тем же целям, что и раньше; ? потоки ввода и вывода. В сравнении с традиционными потоками ввода-вы- ввода-вывода здесь были использованы шаблоны; изменилась иерархия наследова- наследования, в библиотеке появилась возможность генерации исключений. Также была обновлена поддержка строк (с использованием классов stringstream) и введена поддержка интернационализации (посредством локалей - locale, см. ниже). Тем не менее большая часть того, что вы привыкли ожидать от биб- библиотеки потоков ввода и вывода, осталась неизменной. В частности, по-пре- по-прежнему поддерживаются буферизация потоков, средства форматирования, ма- манипуляторы и файлы, а также объекты cin, cout, cerr и clog. Это означает, что и строки, и файлы позволяется рассматривать как потоки, поведением ко- которых, включая буферизацию и форматирование, можно управлять со зна- значительной степенью гибкости; 8зак. 125
Другие принципы а строки. Объекты string были разработаны так, чтобы исключить необхо- необходимость использования указателей char* для большинства приложений. Эти объекты поддерживают все операции, которые от них естественно было бы ожидать (например, конкатенацию, прямой доступ к отдельным симво- символам посредством operator [ ] и т.п.), могут быть конвертированы в char* для совместимости со старым кодом и автоматически осуществляют выделе- выделение памяти. Некоторые реализации string применяют подсчет ссылок, что может улучшить производительность но сравнению со строками типа char* (как и смысле времени, так и в смысле памяти); а контейнеры. Хватит писать свои собственные контейнерные классы! Биб- Библиотека предлагает эффективную реализацию векторов (они работают по- подобно динамически расширяемым массивам), списки (двусвязные), очере- очереди, стеки, двусторонние очереди (деки), таблицы, множества и битовые множества. Увы, отсутствуют хэш-таблицы (хотя многие разработчики пред- предлагают их в качестве дополнения), но до некоторой степени это компенси- компенсируется тем фактом, что объекты string являются контейнерами. Данное обстоятельство важно, поскольку отсюда вытекает следствие: все, что мож- можно делать с контейнером (см. ниже), можно также делать и с типом string. Хотите знать, откуда мне известно, что реализация библиотеки эффектив- эффективна? Все просто: библиотека содержит спецификацию каждого интерфейса, и часть каждой спецификации — это набор требований к характеристикам производительности. Иными словами, независимо от того, как реализуется vector, недостаточно предложить просто доступ к его элементам; необхо- необходимо гарантировать доступ за постоянное время. Если вы этого не делаете, то реализация vector неправильна. Во многих программах на C++ динамическое размещение строк и массивов является основной причиной использования new и delete, а ошибки, связан- связанные с new/delete, - особенно утечки, вызванные неосвобождением памя- памяти, выделенной с помощью new, - к сожалению, необычайно распростра- распространены. Если вместо char* и указателей на динамически размещаемые массивы вы применяете объекты string и vector (каждый из которых осу- осуществляет свое собственное управление памятью), то необходимость во м?ю- гих операторах new и delete исчезнет, равно как и трудности, с которыми часто сопряжено их использование (примеры приведены в правилах 6 и 11); а алгоритмы. Наличие стандартных контейнеров - это здорово, но еще луч- лучше, когда с ними легко работать. Стандартная библиотека предлагает вам свыше двух десятков способов легкой работы (то есть предопределенных функций, официально называемых алгоритмами, а в действительности - шаблонов функций), большинство из которых применимо ко всем контей- контейнерам библиотеки, а также к встроенным массивам! Алгоритмы рассматривают содержимое контейнера как последовательность, и каждый алгоритм может быть применен либо ко всей последовательности значений контейнера, либо к некоторой подпоследовательности. Среди стан- стандартных алгоритмов - f or_each (применить функцию к каждому элементу последовательности), find (найти первый элемент последовательности,
Правило 49 содержащий данное значение), count_if (считает количество элементов последовательности, для которой верно некоторое утверждение), equal (определяет, содержат ли две последовательности элементы с равными зна- значениями), search (найти первое вхождение второй последовательности в первую), сору (копировать одну последовательность в другую), unique (удалить из последовательности элементы-дубликаты), rotate (сдвигать элементы последовательности по циклу) и sort (сортировать элементы по- последовательности). Обратите внимание, что это только некоторые из имею- имеющихся алгоритмов; кроме них библиотека содержит множество других. Как и для операций с контейнерами, для алгоритмов предусмотрены некото- некоторые гарантии относительно их производительности. Например, алгоритм stable_sort должен выполнять не более O(N x logN) сравнений. (Если обо- обозначение «О большое», использованное в формуле, вам незнакомо, я сейчас все объясню. Подразумевается, что алгоритм stable_sort должен обеспе- обеспечить уровень производительности, предлагаемый наиболее эффективными алгоритмами сортировки последовательности общего назначения); а поддержка интернационализации. Различные национальные культуры само- самобытны и неповторимы. Подобно библиотеке С библиотека C++ предлагает инструменты, позволяющие создавать программное обеспечение для разных стран, но подход C++, хотя он концептуально сродни подходу С, значитель- значительно от него отличается. Вас не должно, например, удивлять то, что поддержка интернационализации в C++ в значительной степени опирается на исполь- использование шаблонов, а также на применение наследования и виртуальных функций. Основные компоненты библиотеки, применяемые для поддержки интерна- интернационализации, — фасеты (facets) и локали (locales). Фасеты описывают, как следует обрабатывать конкретные национальные символы, включая схемы упорядочения (то есть как надо сортировать строки, состоящие из нацио- национальных символов), каким образом работать с датами и временем, как пред- представлять денежный формат, каково соответствие между идентификаторами сообщений и самими сообщениями на данном языке и т.д. Локали группи- группируют воедино множества фасетов. Например, локаль для США будет вклю- включать в себя фасеты, описывающие, как сортировать строки американского варианта английского языка, считывать и записывать даты и время, денеж- денежные и численные величины и т.п. согласно стандартам США. А локаль для Франции будет описывать, как решать эти задачи согласно установкам, при- принятым во Франции. C++ позволяет в одной программе использовать не- несколько локалей, так что отдельные части приложения могут придерживать- придерживаться различных соглашений; а поддержка математических вычислений. Возможно, конец эры Фортрана уже не за горами. Библиотека C++ содержит шаблон для классов комплекс- комплексных чисел (точность реальной и мнимой частей может быть float, double или long double), а также специальные типы массивов, разработанные для поддержки математических вычислений. Объекты с типом valarray, на- например, спроектированы так, что для элементов, которые они содержат,
Другие принципы не используется совмещение имен, а это позволяет компиляторам применять более агрессивные стратегии оптимизации, особенно на векторных машинах. Библиотека также предлагает поддержку двух различных типов срезов (slice) массивов и обеспечивает алгоритмы для вычисления скалярных про- произведений, частичных сумм, смежных разностей и т.п.; а диагностика. Стандартная библиотека поддерживает три метода сообщений об ошибках: утверждения (assert) из библиотеки С (см. правило 7), коды ошибок и исключения. Чтобы придать типам исключений некоторую струк- структуру, библиотека определяет следующую иерархию классов исключений: runtime error domain error ) / ( length_error )\ ( range_error ) \ (overflow error —- ---^1—O^-~I_—-^ invalicLargumenO (out_of_range ) (underflow error Исключения типа logic_error (и его подклассы) представляют ошибки в логике приложения. Теоретически такие ошибки можно было бы предотвра- предотвратить посредством более тщательного программирования. Исключения типа runtime_error (и его производные классы) представляют ошибки, обнаружи- мые только во время выполнения. Вы можете использовать эти классы в том виде, в каком они существуют, или наследовать от них для создания своих собственных классов исключений, или игнорировать их. Использование таких классов не является обязательным. В этом обзоре упомянуто далеко не все, что содержится в стандартной биб- библиотеке. Помните, что ее спецификация занимает около 300 страниц. Тем не ме- менее вы можете составить себе некоторое общее представление. Та часть библиотеки, которая относится к контейнерам и алгоритмам, обычно называется Стандартной библиотекой шаблонов (STL). Как правило, выделяют еще одну составную часть STL, которая здесь не описана, - итераторы. Итерато- Итераторы - это объекты, похожие на указатели, которые позволяют алгоритмам STL ра- работать совместно с контейнерами. Для приведенного выше высокоуровневого опи- описания стандартной библиотеки знание итераторов необязательно; если они вас заинтересовали, вы можете найти примеры их использования в правиле 39. STL - это наиболее революционная часть стандартной библиотеки, причем не столько из-за предлагаемых ею контейнеров и алгоритмов (хотя они, вне вся- всякого сомнения, полезны), сколько из-за своей архитектуры. Дело в том, что эта
Правило 50 архитектура расширяема: вы можете вносить в STL добавления. Конечно, сами по себе компоненты стандартной библиотеки фиксированы, но если вы будете следовать соглашениям, на которых построена STL, то можете написать свои собственные контейнеры, алгоритмы и итераторы, которые так же хорошо будут работать со стандартными компонентами STL, как STL-компоненты работают друг с другом. Кроме того, вы вправе воспользоваться преимуществами STL-со- STL-совместимых контейнеров, алгоритмов и итераторов, написанных другими раз- разработчиками, которые также могут применить на практике ваши достижения. Революционной библиотеку STL можно назвать потому, что в действительности это не программное обеспечение, а ряд соглашений. Компоненты STL, входящие в стандартную библиотеку, - это проявления преимуществ, вытекающих из этих соглашений. Используя компоненты стандартной библиотеки, вы в общем случае можете избавиться от необходимости разработки своих собственных механизмов пото- потоков ввода/вывода, строк, контейнеров (включая итерации и общую обработку), интернационализации, числовых структур данных, диагностики. Это позволит вам сохранить много времени и сил для важной составляющей разработки про- программного обеспечения: реализации тех аспектов, которые отличают ваши про- продукты от программ конкурентов. Правило 50. Старайтесь понимать цели C++ У C++ богатые возможности, к числу которых относятся наследие С, пере- перегрузка функций, объектно-ориентированное программирование, шаблоны, ис- исключения, пространства имен и так далее, и так далее, и так далее! Подчас такое изобилие просто пугает. Как во всем этом разобраться? Задача не слишком сложна, если только вы осознаете цели, которые побуди- побудили разработчиков сделать язык C++ таким, каков он сейчас. Наиболее важными из этих целей являются следующие: ? совместимость с языком С. Существует немало компиляторов С, на которых работает множество программистов. C++ пользуется преимуществами С и опи- опирается на них, прибавляя к этой основе новые возможности; а эффективность. Бьерн Страуструп, разработчик и первый программист на C++, с самого начала понимал, что программисты на С, которых он надеял- надеялся привлечь на свою сторону, дважды подумают, прежде чем сделать выбор, если за переход к другому языку им придется заплатить эффективностью. Поэтому он позаботился о том, чтобы сделать C++ конкурентоспособным по отношению к С в смысле эффективности - различие между этими языками лежит в пределах 5%; а совместимость с традиционными инструментами и окружением. Время от времени на той или иной платформе возникают оригинальные инструмен- инструменты разработки, но компиляторы, компоновщики и редакторы могут работать практически везде. C++ создан для работы в любом окружении - от мини- компьютеров до мэйнфреймов, поэтому он нетребователен и обходится скромным «багажом». Вы хотите перенести C++? Вы переносите только
Другие принципы язык и используете уже имеющиеся на платформе инструменты. (Однако часто можно добиться лучшей реализации, если, например, есть возмож- возможность изменить компоновщик, чтобы было удобнее работать с некоторыми сложными аспектами встраивания функций и шаблонов); ? применимость для решения реальных задач. C++ не разрабатывался как ра- рафинированный язык, предназначенный для обучения студентов програм- программированию; он был создан как мощный инструмент для профессионалов, решающих реальные задачи из разных областей программирования. Наш мир полон острых углов, поэтому неудивительно, что даже в «отшлифован- «отшлифованном» языке для профессионалов встречаются шероховатости. Перечисленные задачи вскрывают подоплеку многих особенностей языка, ко- которые без этого объяснения могли бы вызвать лишь раздражение. Почему неявно генерируемые конструкторы копирования и операторы присваивания ведут себя именно так, а не иначе, особенно с указателями (см. правила 11 и 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++ стал тем, чем он является сейчас; однако это никоим образом не есть формальная спецификация языка. Чтобы получить представление о ней, вы должны обратиться к международному стандарту языка C++ - объемному труду,
Правило 50 написанному официальным стилем и насчитывающему около 700 страниц. Здесь вы можете найти, например, такой захватывающий пассаж: «При вызове виртуальной функции используются аргументы по умолчанию из объявления виртуальной функции, определяемые статическим типом указателя или ссылки, обозначающей объект. Переопределяющая функция из производного класса не получает ар1ументов но умолчанию из переопределяемой ею функции». Приведенный абзац - основа правила 38 (никогда не переопределяйте насле- наследуемые аргументы по умолчанию), но, смею надеяться, мое изложение темы чуть более доступно, нежели вышеприведенный текст. Стандарт вряд ли подойдет для чтения перед сном, но он окажет вам не- неоценимую помощь, если вы с кем-либо еще (скажем, с создателем компилятора или другого инструмента для обработки кода) не можете прийти к общему мне- мнению по поводу того, что такое C++. Ведь цель стандарта как раз и состоит в том, чтобы предоставить четкую информацию, устраняющую необходимость в подоб- подобных спорах. Официальное название стандарта трудно выговорить, но, возможно, вам оно понадобится в дальнейшем, так что запомните его: International Standard for Infor- Information 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, поэтому книга Эл- Эллис и Страуструпа уже не считается официальным справочником, как было ранее. ' М. Эллис. 15. Страуструп. Справочное руководство но языку программирования C++ с коммента- комментариями. - М.: Мир, 1992.
Другие принципы Однако она остается полезным руководством, поскольку большинство сведений, представленных в ней, по-прежнему верно, а разработчики C++ зачастую придер- придерживаются ARM в тех областях спецификации C++, где стандарт был разработан лишь недавно. Однако по-настоящему полезным ARM делает не то, что скрывается за аб- аббревиатурой «RM» (Справочное руководство), а то, что обозначено буквой «А»: аннотации, то есть комментарии. ARM содержит обширные комментарии, объяс- объясняющие, почему многие инструменты C++ ведут себя так, а не иначе. Часть этой информации можно найти и в D&E, но необходимо знать ее во всей полноте. Вот пример, который обычно доводит до белого каления тех, кто сталкивается с ним впервые: class Base { public: virtual void f(ir.t x) ; }; class Derived: public Base { public: virtual void f(double *pd) ; }; Derived *pd = new Derived; pd->fA0); // Сшибка! Проблема состоит в том, что Derived: : f скрывает Base: : f, даже несмотря на то, что они имеют разные типы аргументов, поэтому компилятор требует, чтобы вызов f имел аргумент типа double*, каковым литерал 10, конечно, не является. Такое поведение неудобно, но ARM приводит объяснение этому. Допустим, вызывая f, вы в действительности хотели вызвать версию из Derived, но слу- случайно использовали неправильный тип аргумента. Далее предположим, что Derived находится где-то далеко в конце иерархии наследования, и вы даже не подозреваете, что Derived косвенно наследует от некоего класса BaseClass, в котором объявляется функция f, требующая параметр целого типа. В этом слу- случае вы непреднамеренно вызвали бы BaseClass : : f - функцию, о существова- существовании которой вовсе не знали! Подобные ошибки могут часто возникать там, где применяются большие иерархии классов, и потому Страуструп решил уничто- уничтожить зло в самом зародыше, определив, что члены производных классов скрыва- скрывают члены базовых классов на основе их имени. Кстати, обратите внимание на то, что если разработчик Derived хочет разре- разрешить пользователям доступ к Base: : t, этого можно легко добиться объявлени- объявлением using: class Derived: public Base { public: using Base::f; // Вносим Base::f з область видимости Derived. virtual void f(double *pd) ; }; Derived *pd = new Derived; pd->fA0); // Нормально, вызывает 3asc::f.
Правило 50 Для компиляторов, еще не поддерживающих объявления с using, альтерна- альтернативный вариант состоит в применении встраиваемых функций: class Derived: public Rase { public: virtual void f(int x) { Base::?(x); } virtual void f(double *pd); Derived *pd :i new Derived; pd->fA0); // Нормально, вызов 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/cp/ mec++.html. А сейчас можете изучить содержание этой книги: Основы Правило 1. Различайте указатели и ссылки Правило 2. Предпочитайте приведение типов в стиле C++ Правило 3. Никогда не используйте полиморфизм для массивов Правило 4. Избегайте автоматических конструкторов по умолчанию Операторы Правило 5. Остерегайтесь определенных пользователем операторов преобра- преобразования Правило 6. Различайте префиксную и постфиксную формы операторов ин- инкремента и декремента Правило 7. Никогда не перегружайте &&, Ми, Правило 8. Различайте значения new и delete Исключения Правило 9. Во избежание утечки ресурсов используйте деструкторы Правило 10. Не допускайте утечки ресурсов в конструкторах Правило 11. Не позволяйте исключениям выходить за границы деструкторов Правило 12. Помните, чем генерация исключения отличается от передачи ар- аргумента или вызова виртуальной функции Правило 13. Перехватывайте исключения по ссылке Правило 14. Обдуманно используйте спецификации исключений Правило 15. Не забывайте, во что обходится обработка исключений Эффективность Правило 16. Помните о правиле «80-20» Правило 17. Рассмотрите возможность использования отложенных вычислений Правило 18. Демпфируйте предполагаемые вычислительные затраты
Послесловие Правило 19. Изучите причины возникновения временных объектов Правило 20. Облегчайте процесс оптимизации возвращаемых значений Правило 21. Во избежание неявных преобразований типов используйте пере- перегрузку Правило 22. Примите во внимание возможность использования ор= вместо отдельного ор Правило 23. Рассмотрите возможность использования альтернативных билиотек Правило 24. Учитывайте затраты, связанные с виртуальными функциями, множественным наследованием, виртуальными базовыми клас- классами и RTTI Приемы Правило 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 21 1 Аппроксимация bool 23 пространств имен 116 Аргументы по умолчанию в сравнении с перегрузкой 103 статическое связывание 161 у оператора new 47 Базовые классы аргументы конструкторов 185 виртуальные инициализация 185 и operator3 в производных классах 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 стоимость 1 84
Алфавитный указатель деструкторы свойства 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 и закрытое наследование 177 значение 169 и зависимости при компиляции 172 Вложенные типы примеры 200 Возврат по значению 96 и конструктор копирования 20 Возвращаемое значение 122 время жизни 122 Возвращаемый тип для функции operator[] 93 константный 91, 121 Временные объекты 70, 128 дескрипторы на них 122 Встраиваемые функции адрес 133 в сравнении с макрокомандами эффективность 29 дублирование кода 133 M#define 28 и оптимизация компиляторами 1 31 и отладчики 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
в производных классах 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 Зависимости времени компиляции и определения классов 139 минимизация 136 указатели, ссылки и объекты 140 Заголовки <assert.h> 37 <cassert> 37 Эффективное использование C++ <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 <stdlib.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
Алфавитный указатель И Импорт пространств имен 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
Эффективное использование C++ повторное использование 1 87 и плохое проектирование 190 раздувание из-за шаблонов 177 размер и встраивание функций 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 копирование 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 hNDEBUG 37 Маленькие объекты передача 99 размещение в памяти 48 Массивы и new 45 объекты с конструкторами 19 Математика и наследование 150 Минимальное значение для встроенных типов 104
Алфавитный указатель Минимальность интерфейса 171 плюсы и минусы 81 Множественное наследование 181 и библиотеки 184 и доминирование виртуальных функций 185 и неоднозначность 111, 181, 185 и порядок инициализации классов 63 и ясновидение 184 комбинация из public и private 188 плохое проектирование 190 размещение в памяти 184 сложности 181 споры вокруг 1 81 Модификация значения, возвращаемого функцией 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
Эффективное использование C++ удаление объектов посредством delete для массивов 35 Несколько указателей на один объект и деструкторы 178 Неявно генерируемые функции 195 Новые формы приведения типов 23 Ноль KaKint 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 массивы 1 9 объявления 18 определения 19, 129 и конструкторы 129 ошибка при подсчете 84 подсчет 63 присваивание 22 равенство 76 размеры,определение 138 схема размещения в памяти 1 84 тождественность 76 Объявления 1 8 вместо определений 139 классов 1 8 объектов 1 8 функций 18 шаблонов 1 8 Обычные виртуальные функции 153 Ограничения доступа при наследовании 111, 112 Оккам, Уильям 192 Оператор switch по типу объекта в сравнении с виртуальными функциями 164 Операторы и аппроксимации пространства имен 117 Определения замена объявлениями 139 классов 19 зависимости при компиляции 139 и объявления классов 140 размеры объектов 138 неявно генерируемых функций 196 новыхтипов для аппроксимации пространств имен 1 1 6 и new/delete 35 объектов 19, 129 переменных 129 в операторе if 168 статических членов класса 40 функций 19 чисто виртуальных функций 152, 156 шаблонов 19 Оптимизация посредством использования виртуальных функций 190 при компиляции 97, 103, 131, 212 встраиваемые функции 131 Отделение интерфейса от реализации 137
Алфавитный указатель Открытое наследование 146, 194 Отладчики n#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 Переобъявление виртуальных функций 175 Переопределение виртуальных функций 182 унаследованных невиртуальных функций 158 Плата 96 Плохое проектирование и множественное наследование 1 90 и повторное использование кода 190 Побитово константные функции-члены 93 Побитовое копирование при присваивании 56, 196 при создании копированием 57, 196 Поведение модификация путем использования виртуальных функций 190 Повторное использование разработанной стратегии управления памятью 53 Поддержка кода большие интерфейсы классов 81 добавление членов класса 36 и понижающее приведение типов 164 общие базовые классы 154 и множественное наследование 193 список членов инициализации 61 ссылки на функции 1 17 Подсчет ссылок 57 Полный интерфейс 81, 171 Пользователи определение термина 23 Понижающее приведение типов безопасное 167 использование с библиотеками, доступными только для чтения 1 67 определение термина 164 Порядок инициализации в иерархии 63 значимость 202 статических объектов 31 Последовательность подхода и открытые интерфейсы 89 совместимость со встроенными типами 69, 80, 83, 92 Потенциальная неоднозначность 110 и пространства имен 1 15 Почленное копирование в конструкторе 1 96 присваивание 196
Эффективное использование C++ Пошаговая отладка и встраивание функций 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 Равенство адресов 77 значений 76 Разделители полей реализация с помощью виртуальных функций 1 87 Раздельная компиляция влияние на особенности языка C++211, 214 Размер классов 45 объектов 138 Размещение объектов в памяти 184 Реализация классов-протоколов 143 конструкторов и деструкторов производных классов 134 наследование 151 отделение от интерфейса 154 по умолчанию operator3 196 виртуальных функций, опасность 153 конструктора копирования 1 96 сокрытие 139 «Реализуется посредством», отношение 169, 176,179, 192 Рекурсивные функции и встраивание 132 Решение проблемы инициализации нелокальных статических объектов 202 Самостоятельное управление памятью 53 Символ подчеркивания соглашение имен 137
Алфавитный указатель Синглетон 203 Скалярное произведение 212 Сложности при множественном наследовании 181 Смежные разности 212 Смешанная арифметика 85, 87 Смешивание free и delete 32 new и malloc 32 открытого и закрытого наследования 188 Совместимость с С как цель при создании C++ 213 с другими языками и vptr 65 Совмещение имен 57, 77, 99 Соглашения для имен 24, 132,137 «Содержит», отношение 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
Статические объекты возвращение ссылки 101 типы определение термина 160 функции, сгенерированные из встраиваемых 133 члены классов 125 инициализация 40, 61, 63 инициализация в классах 27 использование для аппроксимации пространств имен 1 1 6 константные функции-члены 93 Статический массив возвращение ссылки 102 Статическое связывание аргументов по умолчанию 161 невиртуальных функций 159 Стоимость виртуальных базовых классов 184 инициализация и присваивание 60 классов-дескрипторов 144 классов-протоколов 144 неиспользуемых объектов 129 неподставляемых встраиваемых функций 133 присваивания как удаления плюс создания 103 уменьшения зависимостей времени компиляции 144 Строгий контроль типов реализация 178 Строки С и C++ стандартные заголовочные файлы 207 Структуры для аппроксимации пространств имен 116 и конструкторы 173 Теория хаоса 203 Терминология, принятая в этой книге 1 8 Термины (определения) вложение 1 69 динамический тип 161 Эффективное использование С+н динамическое связывание 161 единица трансляции 201 класс Чеширский Кот 141 классы-дескрипторы 140 классы-конверты 140 классы-протоколы 141 множественное наследование 181 нелокальные статические объекты 20! отношение «есть разновидность» 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
Алфавитный указатель в контейнерах 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 объявления 1 8 обычные виртуальные 194 определения 1 9 проектирование 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
Эффективное использование 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 и минимальность интерфейсов классов 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 «Эффект бабочки» 203 Эффективность iostream и stdio 30 numericjimits 105 дополнительный расход памяти при множественном наследовании 184 и виртуальные функции 157 и возврат указателей и ссылок на члены класса 126 и кэширование 90 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 DBL_MIN 104 delete 79 взаимосвязь с деструкторами 35 и free 32 и new 48 и operator delete 34 и виртуальный деструктор 52 и удаленный указатель 57 нулевой указатель 36 оператор - не член класса псевдокод 45 свойства 45 эффективность 48 delete[] 45, 79 deque, шаблон 210 dynamic_cast 23, 167 пример использования 168 Е Eiffel 138, 181 F false 23 free и delete 32 и деструкторы 32 FUDGE_FACTOR 28 I INT.MIN 104 ISO/IECJTC1/SC22/WG21 215 istream, определение typedef 208 Java 54, 138, 181 интерфейсы 185 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
Эффективное использование C++ operator new 34 бесконечный цикл внутри 44 в комбинации с delete 48 в комбинации с malloc 32 возврат 0 43 и std::bad_alloc 43 и запросы неправильного размера и массивы 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 возвращающий константный тип 69 тип void 69 запрет на использование 57 и константные члены класса 198 и члены класса ссылки 197 45 наследование 71 неявная генерация 196 перегрузка 68 по умолчанию общий вид 68 побитовое копирование 56, 196 почленное копирование 196 присваивание 196 реализация по умолчанию 56, 196 указатели-члены 56 operator» и scanf 29 operatorf] возврат дескриптора 122 возвращаемый тип 93 перегрузка 92 пример объявления 83 ostreamKaKrypedef 207 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 Pool, класс 53 printf и operator« 29 rand 123 register 131 reinterpret_cast 23, 24 rhs, аргумент 24 runtime_error, класс 212 scanf и оператор» 29 set, шаблон 170 set_new_handler 38 для классов, реализация 39 и блоки try 42 sizeof 44 и классы 45
Алфавитный указатель Smalltalk 138, 149, 181, 199 static_cast 23 примеры использования 31, 50 std, пространство имен bad_alloc 37 <iostream> и <iostream.h> 31 numericjimits 105 и set_new_handler 40 и стандартная библиотека C++ 11 6 имена заголовочных файлов 206, 207 stdio и iostreams 31 эффективность 30 STL (Стандартная библиотека шаблонов) 212 расширяемость 213 strdup 32 string, тип 21, 207 typedef 208 и String 21 как стандартный контейнер 210 stringstream, шаблон 209 strlen 9o this присваивание 135 тип 95 true 23 и union 49 V valarray, шаблон 21 1 vector, шаблон 36, 62, 66, 83 vptr 65 vtbl 65, 67
Скотт Мейерс Эффективное использование C++ 50 рекомендаций по улучшению ваших программ и проектов Главный редактор Захаров И. М. Перевод Хаванов А. В. Научный редактор Зубков С. В. Литературный редактор ГотплибО. В. Верстка Капитпанников А. А. Графика Поиявин С. А. Подписано в печать 23.10.2006. Формат 70х100'/16. Гарнитура «Петербург». Печать офсетная. Усл. печ. л. 15. Тираж 1000 экз. Зак. №115 Издательство «ДМ К Пресс» Web-сайт издательства: www.dmk-press.ru Internet-магазин: www.abook.ru Отпечатано с готовых диапозитивов в ООО "Арт-диал", 143983. Московская обл., г. Железнодорожный, ул. Керамическая, д. 3