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


Серия «Для программистов» Наиболее эффективное использование C++ 35 новых рекомендаций по улучшению ваших программ и проектов Скотт Мейерс &т*№-^*&& ^д—^^^"й^ау*^?^**^4* Москва, 2000
ББК 32.973.26-018.1 М46 Мейерс С. М46 Наиболее эффективное использование C++. 35 новых рекомендаций по улучшению ваших программ и проектов: Пер. с англ. - М.: ДМК Пресс, 2000. - 304 с: ил. (Серия «Для программистов»). ISBN 5-94074-033-2 В новой книге С. Мейерса, которая является продолжением популярного издания Effective C++, приводятся рекомендации по наиболее эффективному использованию конструкций языка C++. Рассматриваются правила перегрузки операторов, способы приведения типов, реализация механизма RTTI и многое другое. Даны практические советы по применению буферизованного оператора new, виртуальных конструкторов, интеллектуальных указателей, proxy-классов и двойной диспетчеризации. Особое внимание уделяется работе с исключениями и возможностям использования кода С в программах, написанных на C++. Подробно описаны новейшие средства языка и показано, как с их помощью повысить производительность программ. Приложения содержат код шаблона autojptr и аннотированный список литературы и Internet-ресурсов, посвященных C++. ББК 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 reproduced, 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-63371-Х (англ.) Copyright © 1996 by Addison Wesley Longman, Inc. ISBN 5-94074-033-2 (рус.) © Перевод на русский язык, оформление ДМК Пресс, 2000
Содержание Введение 14 Глава 1. Основы 23 Правило 1. Различайте указатели и ссылки 23 Правило 2. Предпочитайте приведение типов в стиле C++ 25 Правило 3. Никогда не используйте полиморфизм в массивах 30 Правило 4. Избегайте неоправданных конструкторов по умолчанию ... 33 Глава 2. Операторы 38 Правило 5. Опасайтесь определяемых пользователем функций преобразования типа 38 Правило 6. Различайте префиксную и постфиксную формы операторов инкремента и декремента 45 Правило 7. Никогда не перегружайте операторы &&, 11 и , 48 Правило 8. Различайте значение операторов new и delete 51 Глава 3. Исключения 57 Правило 9. Чтобы избежать утечки ресурсов, используйте деструкторы 58 Правило 10. Не допускайте утечки ресурсов в конструкторах 63 Правило 11. Не распространяйте обработку исключений за пределы деструктора 71 Правило 12. Отличайте генерацию исключения от передачи параметра или вызова виртуальной функции 73 Правило 13. Перехватывайте исключения, передаваемые по ссылке 80 Правило 14. Разумно используйте спецификации исключений 84 Правило 15. Оценивайте затраты на обработку исключений 90 Глава 4. Эффективность 94 Правило 16. Не забывайте о правиле «80-20» 95 Правило 17. Используйте отложенные вычисления 97 Правило 18. Снижайте затраты на ожидаемые вычисления 106 Правило 19. Изучите причины возникновения временных объектов 110 Правило 20. Облегчайте оптимизацию возвращаемого значения 113 Правило 21. Используйте перегрузку, чтобы избежать неявного преобразования типов 116 Правило 22. По возможности применяйте оператор присваивания вместо отдельного оператора 118
EHHHIMli Содержание Правило 23. Используйте разные библиотеки 121 Правило 24. Учитывайте затраты, связанные с виртуальными функциями, множественным наследованием, виртуальными базовыми классами и RTTI 124 Глава 5. Приемы 134 Правило 25. Делайте виртуальными конструкторы и функции, не являющиеся членами класса 134 Правило 26. Ограничивайте число объектов в классе 140 Правило 27. В зависимости от ситуации требуйте или запрещайте размещать объекты в куче 154 Правило 28. Используйте интеллектуальные указатели 167 Правило 29. Используйте подсчет ссылок 190 Правило 30. Применяйте proxy-классы 218 Правило 31. Создавайте функций, виртуальные по отношению более чем к одному объекту 231 Глава 6. Разное 254 Правило 32. Программируйте, заглядывая в будущее 254 Правило 33. Делайте нетерминальные классы абстрактными 259 Правило 34. Умейте использовать водной программе С и C++ 270 Правило 35. Ознакомьтесь со стандартом языка 276 Приложение 1. Список рекомендуемой литературы 284 Приложение 2. Реализация шаблона auto_ptr 289
Это блестящая книга, проясняющая многие аспекты языка C++, начиная с редко используемых его свойств и заканчивая разделами, которые программисты считают простыми и недвусмысленными. Только глубоко понимая, каким образом компилятор C++ обрабатывает исходные тексты программ, можно надеяться на создание надежного программного обеспечения. Эта книга является бесценным источником такого понимания. Прочитав книгу, я как будто вместе с большим специалистом по C++ отредактировал огромное количество исходных текстов и получил от него массу очень ценных наставлений. Фред Вайлд (Fred Wild), вице-президент по технологиям, Advantage Software Technologies Эта книга описывает множество важных приемов, позволяющих писать эффективные программы на C++. В ней объясняется, как придумывать и реализовывать идеи и как не попасться впросак, используя ту или иную архитектуру программы. В книге также подробнейше рассматриваются новые свойства, недавно добавленные к C++. Любой программист, желающий использовать эти свойства, обязательно захочет иметь под рукой такую книгу. Кристофер Дж. Ван Вык (Christopher J. Van Wyk), профессор, подразделение математики и компьютерных наук, Университет Дрю В пособии представлены возможности промышленного применения языка C++ в лучшем смысле этого слова. Превосходная книга для тех, кто читал предыдущую - «Эффективное использование C++». Эрик Наглер (Eric Nagler), преподаватель и автор книг, Калифорнийский университет, отделение в Санта Круз «Наиболее эффективное использование C++» - ценное продолжение первой книги Скотта «Эффективное использование C++». Я считаю, что каждый профессиональный разработчик на C++ должен прочесть и постоянно держать в памяти советы из этих двух книг. Все они, по моему мнению, касаются очень важных, но плохо понимаемых аспектов языка. Я настоятельно рекомендую эту книгу, также как и предыдущую, разработчикам, бета-тестерам и руководителям проектов; глубокие знания автора и превосходный стиль изложения делают ее полезной для всех. Стив Беркетт (Steve Burkett), консультант по программному обеспечению
Клэнси, моему любимому внутреннему противнику, посвящается
Благодарности В создании этой книги принимало участие множество людей. Одни предложили важные технические идеи, другие помогли подготовить ее к печати, а третьи просто скрашивали мою жизнь, пока я работал над ней. Часто, когда количество людей, принимавших участие в работе над книгой, достаточно велико, появляется соблазн отказаться от перечисления участников проекта, ограничившись стандартной фразой «Список людей, работавших над книгой, слишком длинен, чтобы быть приведенным здесь». Я, однако, предпочитаю подход Джона Л. Хеннеси (John L. Hennessey) и Дэвида А. Петерсона (David A. Patterson) - см. «Компьютерные архитектуры: численный подход», изд. Морган Кауфман (Morgan Kaufman), 1-ое издание, 1990. Один из аргументов за включение полного списка благодарностей, приведенного ниже, - статистические данные для закона «80-20», на который я ссылаюсь в правиле 16. Источники идей За исключением прямого цитирования, весь текст этой книги принадлежит мне. Тем не менее, многие описанные в ней идеи были придуманы другими. Я всячески пытался отслеживать авторство нововведений, но мне все же пришлось включить информацию из источников, названия которых я уже не могу вспомнить, в основном это сообщения из конференций Usenet comp.lang.c++ и comp.std .C++. Многие идеи в сообществе C++ зарождаются почти одновременно и совершенно независимо в головах многих людей. Ниже я указываю только, где услышал ту или иную мысль, что не всегда совпадает с тем, где она была озвучена впервые. Брайан Керниган (Brian Kernighan) предложил использовать макроопределения для приближения к синтаксису новых операторов приведения типа, описанных в правиле 2. Предупреждение по поводу удаления массива объектов производного класса с помощью указателя на базовый класс, изложенное в правиле 3, основано на материалах лекции Дэна Сакса (Dan Saks), прочитанной им на нескольких конференциях и торговых выставках. Техника использования proxy-классов из правила 5, позволяющая избежать нежелательного вызова конструкторов с одним аргументом, основана на материалах колонки Эндрю Кенига (Andrew Koenig) в журнале C++ Report за январь 1994 года. Джеймс Канце (James Kanze) прислал сообщение в comp.lang.c++ относительно реализации постфиксных декрементных и инкрементных операторов через соответствующие префиксные операторы. Этот прием рассматривается в правиле 6. Дэвид Кок (David Cok), написав мне по одному вопросу, затронутому в «Эффективном использовании C++», привлек мое внимание к различию между operator new и оператором new, положенному в основу правила 8. Даже прочитав письмо, я не в полной мере осознал существующую разницу, но если бы не этот первый толчок, то, скорее всего, не понимал бы ее до сих пор.
|ГЯН|ННВ|1|| Наиболее эффективное использование C++ Метод записи деструкторов, позволяющий избежать утечки ресурсов (см. правило 9), взят из раздела 15.3 книги Маргарет А. Эллис (Margaret A. Ellis) и Бьерна Страуструпа (Bjarne Stroustrup) The Annotated C++ Reference Manual. Там этот метод имеет название «Выделение ресурса - инициализация». Том Кар- гилл (Tom Cargill) предложил перенести акцент с выделения ресурсов на их освобождение. Часть рассуждений в разделе, посвященном правилу 11, была навеяна содержимым главы 4 книги Taligent's Guide to Designing Programs, изд. Addison-Wesley, 1994. Описание предварительного выделения памяти для класса DynArray в правиле 18 основано на статье Тома Каргилла «Динамический вектор сложнее, чем кажется», опубликованной в журнале C++ Report за июнь 1992 года. Информацию о более сложной архитектуре для класса динамического массива можно найти в заметке того же автора (номер C++ Report за январь 1994 года). Правило 21 появилось благодаря докладу Брайана Кернигана «AWK для транслятора C++» на конференции USENIX по C++ в 1991 году. Его идея использовать перегруженные операторы (общим числом 67!) для выполнения арифметических операций с операндами разных типов хотя и не была связана с проблемой, обсуждаемой в правиле 21, но заставила меня рассмотреть множественную перегрузку операторов в качестве решения задачи по созданию временных объектов. Мой вариант шаблона класса для подсчета объектов, рассмотренный в правиле 26, основан на сообщении Джамшида Афшара (Jamshid Afshar) в конференцию comp.lang.c++. Идея смешанного класса, позволяющего отслеживать указатели, созданные с помощью operator new (см. правило 27), базируется на предложении Дона Бокса (Don Box). Стив Клемидж (Steve Clamage) придал этой идее практическое значение, объяснив, как можно использовать dynamic_cast для нахождения начала области памяти, занимаемой объектом. Описание smart-указателей в правиле 28 основано: частично на заметке Стивена Буроффа (Steven Buroff) и Роба Мюррея (Rob Murray) C++ Oracle в журнале C++ Report за октябрь 1993 года, на классической работе Даниэла Р. Эдельсона (Daniel R. Edelson) «Интеллектуальные (smart) указатели: интеллектуальные, но не указатели» в материалах конференции USENIX по C++ от 1992 года, на содержимом раздела 15.9.1 книги Бьерна Страуструпа «Архитектура и развитие C++», на докладе Грегори Колвина (Gregory Colvin) «Управление памятью в C++» на учебном семинаре «Решения для C/C++ '95» и на заметке Кея Хорстманна (Сау Horstmann) в мартовском и апрельском номерах C++ Report за 1993 год. Но кое- что сделал и я сам. Использованный в правиле 29 метод хранения в базовом классе счетчиков ссылок и smart-указателей для работы с этими счетчиками основан на идее Роба Мюррея (см. разделы 6.3.2 и 7.4.2 его книги «Стратегия и тактика в C++»). Прием, позволяющий добавлять счетчики ссылок к существующим классам, аналогичен тому, что был предложен Кеем Хорстманном в заметке, опубликованной в мартовском и апрельском номерах журнала C++ Report за 1993 год. Источником для правила 30, касающегося контекстов lvalue, послужили комментарии к заметке Дэна Сакса в журнале С User's Journal (теперь C/C++
Об этой книге Н1Ш User's Journal) за январь 1993 года. Наблюдение, что методы классов, которые не являются методами proxy-классов, не доступны при вызове по proxy-механизму, взято из неопубликованной работы Кея Хорстманна. Способ, как использовать динамическую информацию о типах для того, чтобы построить похожие на vtbl массивы указателей функций (в правиле 31), основан на идеях Бьерна Страуструпа, выдвинутых им в сообщениях в конферен- ' цию comp.lang.c++ и разделе 13.8.1 его книги «Архитектура и развитие C++». Сведения, на базе которых появилось правило 33, частично были опубликованы в моих колонках журнала C++ Report за 1994 и 1995 года. Эти колонки, в свою очередь, включали замечания об использовании dynamic_cast для реализации виртуального оператора operator=, определяющего наличие аргументов некорректного типа, которые я получил от Клауса Крефта (Klaus Kreft). Большая часть рассуждений в правиле 34 вызвана статьей Стива Клемиджа «Связывание C++ с другими языками» в мартовском номере журнала C++ Report за 1992 год. Мой подход к решению проблем, вызванных использованием таких функций, как strdup, был инициирован замечаниями читателя, не сообщившего своего имени. Об этой книге Просмотр черновых вариантов книги - работа неблагодарная, но жизненно необходимая. Мне повезло, что так много людей пожелали вложить в нее свое время и энергию. Хочу особенно поблагодарить: Джил Хатчитэл (Jill Huchital), Тима Джонсона (Tim Johnson), Брайана Кернигана, Ерика Наглера и Криса Ван Вык (Chris Van Wyk), потому что они прочли мою книгу (или ее значительную часть) более одного раза. Кроме этих любителей неприятной работы полностью черновик книги прочли: Катрина Эвери (Katrina Avery), Дон Бокс, Стив Буркетт (Steve Burkett), Том Каргилл, Тони Дэвис (Tony Davis), Кэролин Даби (Carolyn Duby), Брюс Экель (Bruce Eckel), Рид Флеминг (Read Fleming), Кей Хорстманн, Джеймс Канце, Расе Пейли (Russ Paielly), Стив Розенталь (Steve Rosenthal), Робин Руйе (Robin Rowe), Дэн Сакс, Крис Селлз (Chris Sells), Уэбб Стейси (Webb Stacy), Дэйв Свифт (Dave Swift), Стив Виноски (Steve Vinosky) и Фред Уайлд (Fred Wild). Частично черновики прочли: Боб Бьючейн (Bob Beauchaine), Герд Хойрен (Gerd Hoeren), Джефф Джексон (Jeff Jackson) и Нэнси Л. Урбано (Nancy L. Urbano). Замечания каждого из них помогли представить материал более точно и доступно. После выхода книги я получил исправления и предложения от множества людей. Ниже эти наблюдательные читатели перечислены в порядке получения от них сообщений: Льюис Кида (Luis Kida), Джон Поттер (John Potter), Тим Уттор- марк (Tim Uttormark), Майк Фелькерсон (Mike Fulkerson), Дэн Сакс, Вольфганг Глунц (Wolfgang Glunz), Кловис Тондо (Clovis Tondo), Майкл Лофтус (Michael Loftus), Лиз Хэнке (Liz Hanks), Вил Эверс (Wil Evers), Стефан Кухлинз (Stefan Kuhlins), Джим МакКракен (Jim McCracken), Элан Дучан (Alan Duchan), Джон Джекобсма (John Jacobsma), Рамеш Нагабушнам (RameshNagabushnam), Эд Вил- линк (Ed Willink), Кирк Свенсон (Kirk Swenson), Джек Ривз (Jack Reeves), Дуг
НЦЯИН11 Наиболее эффективное использование C++ Шмидт (Doug Schmidt), Тим Бучовски (Tim Buchowski), Пол Чисхолм (Paul Chisholm), Эндрю Клейн (Andrew Klein), Эрик Наглер, Джеффри Смит (Jeffrey Smith), Сэм Бент (Sam Bent), Олег Штейнбук (Oleg Shteynbuk), Антон Доблмай- ер (Anton Doblmaier), Ульф Михаэлис (Ulf Michaelis), Секхар Муддана (Sekhar Muddana), Майкл Бейкер (Michael Baker), Йечил Кимчи (Yechiel Kimchi), Дэвид Папюрт (David Papurt), Йан Хаггард (Ian Haggard), Роберт Шварц (Robert Schwartz), Дэвид Хэлпин (David Halpin), Грэхам Марк (Graham Mark), Дэвид Баретт (David Barett), Дэмьен Канарек (Damian Kanarek), Рон Коуттс (Ron Coutts), Ланс Витцель (Lance Whitesel), Йон Лачелт (Jon Lachelt), Шерил Фер- гюсон (Cheryl Ferguson), Мунир Махмуд (Munir Mahmood), Клаус-Георг Адами (Klaus-Georg Adams), Дэвди Гох (David Goh), Крис Морли (Chris Morley), Рей- нер Баумшлагер (Rainer Baumschlager), Брайан Керниган, Чарльз Грин (Charles Green), Марк Роджерс (Mark Rodgers), Бобби Шмидт (Bobby Schmidt), Шивара- махришан Дж. (Sivaramakrishnan J.) и Эрик Андерсон (Eric Anderson). Их предложения позволили мне улучшить книгу, и я очень благодарен им за помощь. При подготовке этой книги я сталкивался с множеством вопросов, связанных с появлением стандарта ISO/ANSI для языка C++, решить которые мне помогли Стив Клэмидж и Дэн Сакс. Они не пожалели времени, отвечая на мои беспрестанные вопросы по электронной почте. Джон Макс Скаллер (John Max Skaller) и Стив Рамсби (Steve Rumsby) помогли мне получить текст ANSI-стандарта C++ в формате HTML до его публикации. Вивиан Hey (Vivian Neou) подсказала мне, что для просмотра HTML-документов в 16-битной системе Microsoft Windows можно использовать браузер Netscape. Я глубоко благодарен сотрудникам компании Netscape Communications за бесплатное распространение своего браузера для этой системы. Брайан Хоббс (Bryan Hobbs) и Хачеми Зенад (Hachemi Zenad) предоставили мне предварительную версию компилятора MetaWare C++, что позволило проверить тексты программ, приведенных в этой книге, с использованием самых новых свойств языка. Кей Хорстманн помог мне с установкой и запуском компилятора в чуждых для меня мирах DOS и защищенного режима DOS. Корпорация Borland (теперь Inprise) предоставила мне последнюю бета-версию своего компилятора, а Эрик Наглер и Крис Селлз обеспечили неоценимую помощь, проверив тексты программ на недоступных для меня компиляторах. Книга не могла бы появиться на свет без помощи сотрудников отдела корпоративной и специальной литературы издательства Addison-Wesley. Я очень обязан: Ким Доули (Kim Dawley), Лане Лэнглуа (Lana Langlois), Симоне Пэй- мент (Simone Payment), Марти Рабинович (Marty Rabinowitz), Прадипе Сива (Pradeepa Siva), Джону Уэйту (John Wait) и другим сотрудникам за их терпение, поддержку и помощь в подготовке этой работы. Крис Гузиковски (Chris Guzikovsky) помогал проектировать обложку книги, а Тим Джонсон (Tim Johnson) уделил часть своего времени, обычно всецело посвященного исследованиям в области низкотемпературной физики, для критических замечаний по последним версиям этого текста.
Люди, которые мне помогали SllHIH^IKE Том Каргилл благородно согласился на размещение его статьи по исключениям из журнала C++ Report на сайте издательства Addison-Wesley в Internet. Люди, которые мне помогали Кэти Рид (Kathy Reed) ввела меня в мир программирования. Дональд Френч (Donald French) поверил в мою способность разрабатывать и представлять учебные материалы по C++ при отсутствии у меня значительного опыта в этой области. Он также представил меня редактору издательства Addison-Wesley Джону Вэйту (John Wait), за что я всегда буду ему благодарен. Троица в Бивер Ридж - Джейни Бесо 0аУп1 Besaw), Лорри Филдс (Lorry Fields) и Бет МакКи (Beth ]УЦКее) позволяла мне развлечься и отдохнуть в перерывах между работой над книгой. Моя жена, Нэнси Л. Урбано, стоически перенесла все этапы подготовки книги. Сколько раз она слышала, что мы обязательно сделаем что-нибудь, после того как книга будет опубликована! Теперь работа завершена, и я выполню все свои обещания. Она удивительная. Я люблю ее. И наконец, я должен вспомнить собаку Персефону, чье появление навсегда изменило наш мир. Без нее эта книга была бы закончена быстрее, и спал бы я больше, но значительно меньше смеялся.
Введение Сейчас у программистов C++ горячие денечки. Хотя коммерческие версии компиляторов языка C++ появились менее чем десять лет назад, за это время C++ стал стандартным языком для создания сложных систем почти на всех вычислительных платформах. Компании и программисты, решая серьезные задачи по разработке программного обеспечения, постоянно расширяют круг пользователей языка. Перед теми, кто пока не имел дело с C++, чаще стоит вопрос «Когда начать использование языка?», а не «Что будет, если мы начнем применять этот язык?». Стандартизация C++ завершена, а богатая функциональность и разнообразие тематик сопровождающих язык библиотек, которые включают и расширяют библиотеки С, позволяют создавать сложные, многофункциональные программы, не теряющие при этом переносимости, а также реализовывать стандартные алгоритмы и структуры данных «с нуля». Компиляторы C++ продолжают совершенствоваться, их возможности расширяются, а качество генерируемого кода постоянно улучшается. Среды и средства для разработки на C++ становятся все более многочисленными, мощными и полнофункциональными. Библиотеки программного обеспечения, распространяемые на коммерческой основе, во многом устранили саму необходимость написания исходных текстов. По мере «взросления» языка и роста опытности его пользователей изменилась и потребность в информации о нем. В 1990 году специалисты хотели знать, что представляет собой язык C++. К 1992 году их интересовало, как его применять. Сейчас программисты на C++ задают вопросы более высокого уровня. Как создавать программное обеспечение с учетом его адаптации к будущим потребностям? Как сделать программный код более эффективным, при этом не усложняя его и сохраняя корректность работы? Как реализовать ту или иную функцию, не поддерживаемую языком непосредственно? В книге приводятся ответы на эти и многие похожие вопросы. Книга показывает, как разрабатывать и внедрять более эффективное, чем то, которое вы создавали до сих пор, программное обеспечение на языке C++: содержащее меньшее количество ошибок, более надежное в экстремальных ситуациях, более производительное, более переносимое, более полно использующее возможности языка, требующее меньших затрат при поддержке, более пригодное для работы в системах, где задействовано несколько языков программирования, более простое при правильном использовании, затрудняющее неправильное использование. Короче, программное обеспечение, которое просто лучше. Содержание этой книги разделено на 35 правил. В каждом разделе собраны накопленные сообществом C++ сведения по какому-то определенному вопросу. Большинство правил сформулированы как рекомендации, а объяснение, сопутствующее каждому правилу, содержит информацию о том, почему эта рекомендация
Введение 11ШИЭ имеет право на существование, что происходит, если не следовать ей, и при каких условиях стоит все же ее нарушать. Правила можно разбить на несколько категорий. Одни относятся к отдельным свойствам языка, по преимуществу недавно появившимся, для которых еще не накоплено опыта по применению. Например, правила с 9 по 15 посвящены исключениям. Другие правила объясняют, как объединить возможности языка для выполнения нестандартных задач. В эту группу входят правила с 25 по 31, которые описывают, как ограничить количество или размещение объектов, как создавать функции, являющиеся виртуальными по отношению к объектам разных типов, как создавать интеллектуальные указатели и т.п. Некоторые правила касаются более сложных случаев, так, правила с 16 по 24 связаны с проблемами эффективности. Но чему бы ни было посвящено правило, вопрос обсуждается серьезно и всесторонне. Эта книга учит, как использовать C++ наиболее эффективно. Описание конструкций языка, что составляет львиную долю текста других книг по C++, здесь является вспомогательной информацией. Поэтому, приступая к чтению данной книги, вы должны быть уже знакомы с языком C++. Вы должны знать, что такое классы, уровни изоляции, виртуальные и невиртуальные функции и т.п., а также должны иметь представление о шаблонах и исключениях. Но пусть эти требования не смущают тех, кто не является специалистом по языку: исследуя закоулки C++, я всегда буду объяснять, как и что происходит. Язык C++ в этой книге Язык C++, представленный в этой книге, соответствует документу Final Draft International Standard (Финальный проект международного стандарта), выпущенному комитетом по стандартизации ISO/ANSI в ноябре 1997 года. Поэтому некоторые свойства языка, представленные в книге, ваши компиляторы, возможно, еще не поддерживают. Не волнуйтесь. Предполагается, что единственное «новое» свойство, которое вам потребуется, - шаблоны, а шаблоны реализованы почти везде. Я также использую исключения, но это использование в значительной мере ограничено правилами с 9 по 15, которые как раз и посвящены исключениям. Если у вас нет доступа к компилятору, поддерживающему исключения, ничего страшного. Это не повлияет на вашу работу с остальными частями книги. Но правила с 9 по 15 вам все же стоит почитать, потому что эти разделы помогут вам получить информацию, которой вы еще не владеете, но которую должны знать. Допускаю, что благословение комитета по стандартам для какого-либо свойства языка или введение его в общепринятую практику не дает гарантии, что ваши компиляторы поддерживают это свойство и что известные способы применимы к существующим средам программирования. В тех случаях, когда возникает расхождение между теорией (утверждено комитетом по стандартам) и практикой (должно работать), я обсуждаю обе возможности, хотя склоняюсь к практическому решению. Так как рассматриваются обе стороны вопроса, у вас будет повод заглянуть в эту книгу всякий раз, когда ваши компиляторы на очередной шаг приблизятся к требованиям стандарта. Она покажет вам, как использовать
ПД^ИИШИМ Наиболее эффективное использование C++ существующие конструкции языка для реализации новых свойств, не поддерживаемых пока вашими компиляторами, и даст рекомендации, как преобразовать эти обходные пути, когда ваши компиляторы начнут поддерживать новые свойства. Заметьте, что я ссылаюсь на ваши компиляторы — во множественном числе. Различные компиляторы реализуют различные приближения к стандарту, поэтому я призываю вас вести разработку программного обеспечения с использованием как минимум двух компиляторов. Такая практика поможет вам избежать ненужной зависимости от нестандартных расширений языка, поддерживаемых только одним производителем компиляторов, или отклонений от стандарта. Это также позволит вам держаться подальше от переднего края компьютерных технологий, то есть от новых свойств языка, поддерживаемых только одним производителем компиляторов. Реализация таких свойств часто имеет значительные недостатки (наличие ошибок, низкая производительность, а иногда и то и другое). Кроме того, сообщество C++ еще не накопило достаточно опыта, чтобы предоставить разработчикам информацию, как правильно использовать новейшие свойства. Прокладывать дорогу - это здорово, но если ваша цель - создание надежного кода, нащупывать путь предоставьте другим. В книге описаны две конструкции, с которыми вы можете быть незнакомы. Обе являются сравнительно новыми расширениями языка. Некоторые компиляторы поддерживают их, но если ваши компиляторы не настолько современны, вы легко можете воспроизвести эти расширения имеющимися в наличии средствами. Первая конструкция - это тип bool, имеющий в качестве значений константы true и false. Даже если ваши компиляторы не поддерживают данный тип, существуют два способа реализовать его. Один состоит в использовании глобального оператора enum: enum bool { false, true } ; Такой прием позволяет перегружать функции, руководствуясь типом входящего аргумента bool или int. Однако у этого способа есть недостаток: встроенные операторы сравнения (такие как ==, <, >= и т.п.) все равно возвращают тип int. В результате приведенный ниже код не будет работать так, как это задумывалось: voidf(int); void f(bool); intx, y; f ( x < у ) ; / / Вызывает f (int) , а должен //вызыватьf(bool). Применение оператора enum может нарушить работу программы после перехода на компилятор, поддерживающий тип bool. Другой вариант состоит в том, чтобы использовать typedef для типа bool и константы для true и false: typedef int bool ; const bool false = 0 ; const booltrue = 1;
Введение 1НВНПЕЗ Такой подход соответствует традиционной семантике С и C++, а поведение программ, использующих данный прием, не изменится с переходом на компиляторы, обеспечивающие поддержку bool. Недостаток же этого подхода состоит в том, что при перегрузке функций тип bool нельзя отличить от int. Оба варианта неплохи. Выбирайте тот из них, который лучше всего подходит в каждом конкретном случае. Вторая новая конструкция на самом деле состоит из четырех, это операторы приведения типа: static_cast, const_cast, dynamic_cast и reinterpret_cast. Если вы не знакомы с перечисленными конструкциями, обратитесь к правилу 2, где содержится полная информация о них. Они не только делают больше, чем операторы приведения типов в стиле С, но и лучше выполняют все поставленные программистом задачи. Поэтому для приведения типов в данной книге я использовал именно их. C++ - это не только язык. В него входит также и стандартная библиотека. Везде, где возможно, вместо указателей char* в книге использован стандартный тип string, и я призываю вас поступать также. Применение объектов string не создает дополнительных трудностей по сравнению со строками, обработка которых производится с помощью указателей char*, но значительно облегчает управление памятью. Кроме того, когда используются объекты string, снижается риск утечек памяти при возникновении исключений (см. правила 9 и 10). Хорошо реализованный тип string по эффективности не уступит своему эквиваленту char*, а, возможно, и превзойдет его (чтобы понять, как это сделать, см. правило 29). Если стандартный тип string не реализован в вашем компиляторе, то в нем почти наверняка реализован какой-либо класс, подобный string. Обязательно используйте его. Это всегда предпочтительней, чем включение в код указателей char*. В книге при любой возможности используются структуры данных, взятые из Standard Template Library (STL - стандартная библиотека шаблонов, см. правило 35). STL содержит битовые наборы, векторы, списки, очереди, стеки, карты, множества и т.д., и лучше иметь дело со стандартизованными объектами, чем пытаться разработать их эквиваленты самостоятельно. В состав ваших компиляторов STL может быть не включена, но благодаря компании Silicon Graphics бесплатная копия библиотеки доступна на Web-сайте SGI STL: http://www.sgi.com/ Technology/STL/. Если вы довольны используемой вами в настоящее время библиотекой алгоритмов и структур данных, то не нужно переходить на STL только потому, что она «стандартная». Однако если вы стоите перед выбором, взять ли компонент из STL или написать какой-либо код самому «с нуля», то вариант с STL, конечно, предпочтительнее. Помните о принципе повторного применения кода? STL (и остальная часть стандартной библиотеки) содержит множество модулей, которые очень и очень стоит использовать повторно. Соглашения и терминология Когда в книге упоминается наследование, я всегда имею в виду открытое наследование. Использование закрытого наследования каждый раз оговаривается
IHIIlli Наиболее эффективное использование C++ специально. При изображении иерархии наследования стрелки проводятся от производных классов к базовому. Например, на рис. 1 приведена иерархия классов для правила 31: Рис. 1 Эта запись обратна той, которую я использовал в первом (но не во втором) издании книги «Эффективное использование C++». Теперь я убежден, что большинство пользователей C++ проводят стрелки наследования от производного класса к базовому, и с удовольствием соглашаюсь с ними. На диаграммах подобного типа абстрактные классы (например, GameObj ect (Игровой объект)) закрашены темно-серым цветом, а конкретные классы (такие как Spaceship (Космический корабль)) заштрихованы более светлым оттенком. Наследование автоматически приводит к появлению указателей и ссылок двух различных типов: статического и динамического. Статический тип указателя или ссылки соответствует типу объявления. Динамический тип - типу объекта, на который в данный момент указывает указатель или ссылка. Вот несколько примеров, основанных на приведенных выше классах: GameObj ect *pgo = new Spaceship; Asteroid *pa = new Asteroid; pgo = pa; GameObject& rgo= *pa; //Статический типpgo- //GameObj ect,динамический тип - //Spaceship*. // Статический тип pa -Asteroid* . / / Динамический тип - тоже //Asteroid*. //Статический типpgo //не изменился (ине изменится) , // он по-прежнему равен //GameObject*. Его //динамический тип теперь - //Asteroid*. //Статический тип rgo - //GameObj ect,динамический тип - //Asteroid. Эти примеры также демонстрируют соглашение об именах переменных, используемое в этой книге. Переменная pgo- это указатель на GameObj ect,
Введение IlllfiHHKH pa — указатель на Asteroid, rgo - ссылка на GameObject. Я часто составляю имена переменных и ссылок подобным образом. Два моих любимых имени для параметров - это lhs и rhs, сокращения для «слева» (left-hand side) и «справа» (right-hand side) соответственно. Чтобы понять, что стоит за этими именами, рассмотрим класс для представления действительных чисел: Class Rational{ } ; Функция для попарного сравнения объектов класса Rational может быть объявлена следующим образом: bool operator== (const Rationalk lhs, constRationalkrhs) ; Такое объявление позволяет писать программы следующим образом: Rational rl, r2 ; if (rl==r2) ... В операторе сравнения rl стоит слева от оператора == и соответствует аргументу lhs при вызове operator==, r2 расположено справа от оператора = = и соответствует аргументу rhs. Другие сокращения, использованные в этой книге: ctor обозначает конструктор (constructor), dtor- деструктор (destructor), RTTI- динамическое определение типов в C++ (оператор dynamic_cast - наиболее часто используемый компонент этого механизма). Если выделить память, а затем не освободить ее, то происходит утечка памяти. Утечки могут возникать как в С, так и в C++, но в последнем они приводят к более серьезным последствиям. Это связано с тем, что в C++ при создании объектов автоматически вызываются конструкторы, которые могут сами выделять ресурсы. Посмотрите на этот пример: class Widget { }; // Некий класс -неважно, / / что он делает. Widget *pw = new Widget; //Динамическое выделение / / памяти под объект Wi dge t. ... //Предположим, что область, / / на которую указывает pw, / / никогда не освобождается. Этот код вызывает утечку памяти, потому что объект Widget, на который указывает переменная pw, никогда не будет удален. Возможна ситуация, когда конструктор Widget потребует выделения дополнительных ресурсов (таких как дескрипторы, семафоры, манипуляторы окон, блокираторы баз данных* и т.п.), * В других книгах вы можете встретить иные варианты перевода английских понятий window handle и database lock, поскольку в русском языке компьютерная терминология еще недостаточно устоялась. Обычно программисты обобщенно называют эти инструменты хэндлами (от англ. handle). (Прим. ред.)
Qjimilll Наиболее эффективное использование C++ которые должны освобождаться при удалении объекта Widget. Если Widget не будет удален, то доступ к этим ресурсам будет утерян так же, как и к занимаемой ими области памяти. Чтобы подчеркнуть, что утечки памяти в C++ часто приводят к утечкам других ресурсов, я буду использовать выражение «утечка ресурсов» во всех случаях. Примеры кода, приведенные в книге, редко включают встраиваемые функции. Это не значит, что я их не люблю. Встраиваемые функции, безусловно, являются важной чертой языка C++. Однако критерии для определения, должна ли функция объявляться как встраиваемая, могут быть довольно сложными, нечеткими и зависеть от платформы. В результате я избегаю использовать встраиваемые функции, если только это не связано напрямую с обсуждаемой темой. Когда вы встречаете в примерах невстраиваемую функцию, это не означает, что ее объявление как inline создаст дополнительные проблемы, просто вопрос, объявлять ли функцию встраиваемой или нет, не влияет на изложение темы в данном месте книги. Некоторые свойства C++ комитет по стандартам объявил устаревшими. В них больше нет необходимости, потому что к языку были добавлены новые свойства, которые намного лучше выполняют функции прежних. В этой книге я специально обращаю внимание на устаревшие конструкции и объясняю, чем их можно заменить. Программистам не следует работать с такими конструкциями, хотя испытывать особые угрызения совести от их использования также не стоит. Для сохранения обратной совместимости производители компиляторов, скорее всего, будут поддерживать устаревшие свойства еще много лет. Клиент (или пользователь) - это кто-то (возможно, программист) или что-то (обычно класс, функция), использующий написанный вами код. Например, вы создали класс Date (для описания дней рождения, календарных сроков, дня второго пришествия и т.п.). Тогда всякий, обращающийся к этому классу, является вашим клиентом. Далее, любые модули, использующие класс Date, также окажутся вашими клиентами. Ради клиентов и ведется разработка! Если ваше программное обеспечение никому не требуется, зачем его создавать? Читая книгу, вы заметите, что я прикладываю массу усилий, чтобы облегчить жизнь клиентам, часто за ваш счет, поскольку хорошее программное обеспечение должно быть «клиенто- центричным»: оно должно «вращаться» вокруг клиентов. Если это кажется вам излишней филантропией, посмотрите на проблему с точки зрения собственных интересов. Используете ли вы ваши классы и функции повторно? Если да, то вы - ваш собственный клиент, и облегчая жизнь клиентам вообще, вы облегчаете ее самому себе. Рассуждая о шаблонах классов или функций и сгенерированных по этим шаблонам объектах, я позволю себе быть несколько неряшливым и особо не подчеркивать разницу между шаблонами и созданными по ним объектами. Например, если Array - это шаблон класса с параметром Т, то я могу ссылаться на шаблонный класс как на Array, хотя на самом деле его правильное имя - Аг гау<Т>. Аналогично, если swap - шаблон функции с параметром типа Т, то имя swap (вместо swap<T>) также может обозначать и шаблонную функцию. Разумеется, если
Введение ННННЕЯ такая сокращенная запись может привести к недоразумению, я записываю полные имена объектов. Принятые обозначения Для более простого восприятия материала в книге приняты следующие условные обозначения. Все листинги, приведенные в книге, напечатаны моноширинным шрифтом. Имена классов, объектов, переменных, констант и т.д., встречающиеся непосредственно в тексте, также даны моноширинным шрифтом. Информация, которую необходимо обязательно принять к сведению, выделена курсивом. Как сообщить об ошибках, внести предложения, получить обновления книги Насколько это возможно, я старался сделать книгу точной, удобной для чтения и полезной, однако нет предела совершенству. Если вы обнаружите в ней какую-либо ошибку: техническую, грамматическую, опечатку, какую-нибудь - пожалуйста, сообщите мне об этом. Я постараюсь исправить допущенную оплошность в последующих изданиях книги, а если вы окажетесь первым, кто сообщит об ошибке, с удовольствием добавлю ваше имя в список благодарностей. Если у вас появятся другие предложения по улучшению книги, также буду вам очень признателен. Я по-прежнему продолжаю собирать рекомендации по эффективному программированию на C++. Если у вас есть какие-нибудь идеи на этот счет, буду очень благодарен, если вы поделитесь ими со мной. Шлите ваши рекомендации, комментарии, замечания и сообщения об ошибках по адресу: Scott Meyers с/о Editor-in-Chief, Corporate and Professional Publishing Addison-Wesley Publishing Company 1 Jacob Way Reading, MA 01867 U.S.A. Вы также можете послать сообщение электронной почты по адресу: mec++@awl.com. Я веду список изменений, таких как исправления ошибок, пояснения и обновления, внесенных в книгу с первого издания. Этот список, а также другие материалы, связанные с данной книгой, размещен на Web-сайте издательства Addison- Wesley по адресу: http://wwwawLcom/cp/mec++.html. Он также находится на FTP-сайте по адресу: ftp.awl.com в каталоге ср/тес++. Если у вас нет доступа в Internet, то для получения списка изменений пошлите запрос по одному из приведенных выше двух адресов, и я прослежу, чтобы список был вам выслан.
ЕЕЯ^ИИИИШ Наиболее эффективное использование C++ В настоящее время существует также список рассылки, подписаться на который можно, послав сообщение по адресу scott_meyers-subscribe@egroups.com. Архив списка рассылки находится на странице http://www.egroups.com/messages/ scott_meyers. Он предназначен для рассылки объявлений программистам, интересующимся языком C++. Объем рассылки небольшой, обычно не более двух сообщений в месяц. Более подробные сведения о списке рассылки можно получить на странице http://www.aristeia.com/MailingList/index.html.
Глава Л. Основы Указатели, ссылки, приведение типов, массивы, конструкторы - это то, что составляет основу языка. Все программы на языке C++, за исключением самых простых, используют большую часть названных понятий, а многие программы используют их все. Даже самые знакомые вещи иногда могут нас удивлять. Особенно это справедливо для программистов, переходящих с языка С на C++, так как концепции, на которых базируются понятия ссылок, динамического приведения типов, конструкторов по умолчанию и других, не принадлежащих языку С, обычно не всегда очевидны. Эта глава объясняет разницу между указателями и ссылками и содержит советы, когда следует использовать каждое из этих понятий. В ней также описан новый синтаксис языка C++ для приведения типов и объясняется, чем новый стиль превосходит заменяемый стиль языка С. Кроме того, рассматривается концепция массивов в языке С и концепция полиморфизма в языке C++, а также говорится, почему их никогда не стоит использовать одновременно. Наконец, в ней рассказано о плюсах и минусах конструкторов по умолчанию и предложены пути для обхода ограничений языка, которые требуют существования такого конструктора, даже если это не имеет практического смысла. Следуя советам, приведенным в нижеизложенных правилах, вы сможете создавать такое программное обеспечение, где ваш замысел будет реализован ясно и правильно. Правило 1. Различайте указатели и ссылки Указатель (pointer) и ссылка (reference) существенно отличаются по внешнему виду (указатели используют операторы * (умножить) и -> (стрелка), ссылки используют оператор . (точка)), но применяются для решения одних и тех же задач. И указатели и ссылки позволяют неявно ссылаться на другие объекты. Как же тогда решить, когда применять указатели, а когда ссылки? Во-первых, запомните, что не существует нулевых ссылок. Ссылка должна всегда ссылаться на какой-либо объект. Если ваша переменная обеспечивает доступ к объекту, которого может и не быть, вы должны использовать указатель, потому что это позволит приравнять его нулю. С другой стороны, если переменная должна всегда ссылаться на существующий объект, то есть не должна иметь нулевого значения, то, скорее всего, лучше использовать в качестве такой переменной ссылку. «Но подождите!», - воскликнет читатель, - «а как же будет работать следующий кусок кода?»:
ЕЯНМШШ Основы char *pc = 0; / / Присвоить указателю значение null. char& re = *рс; / / Установить ссылку на содержимое // нулевого указателя. Надо сказать, это пример самого настоящего безобразия. Результаты работы такой программы не определены: компиляторы могут генерировать программный код, который будет делать все, что угодно. Если у вас возникают подобные проблемы, то лучше вообще отказаться от использования ссылок. В качестве другого выхода вы можете поискать для сотрудничества программистов более высокого класса. В дальнейшем мы не будем считаться с возможным существованием нулевых ссылок. Так как ссылка должна ссылаться на объект, C++ требует ее инициализации: str ing& r s; / / Ошибка! Ссылки должны быть //проинициализированы. strings("xyzzy"); string& rs = s; / / Нормально, rs ссылается на s . На указатели таких ограничений не налагается: string *ps; //Неинициализированный указатель: //допустимо, но рискованно. Невозможность существования нулевых ссылок подразумевает, что использование ссылок более эффективно, чем использование указателей. Корректность ссылки не нужно предварительно проверять: voidprintDouble(const doublek rd) { cout << rd; // Нет необходимости проверять rd; } //она должна ссылаться на double. Указатели же, наоборот, обычно должны проверяться на равенство нулю: voidprintDouble (const double *pd) { if (pd) { // Проверка на значение null. cout << *pd; } } Другое важное различие между указателями и ссылками состоит в возможности присваивать указателям различные значения для доступа к разным объектам. Ссылка же всегда указывает на один и тот же объект, заданный при ее инициализации: string si("Nancy") ; strings2("Clancy"); string&rs = si; // rs ссылается на si. string *ps = &sl; // ps указывает на si. rs = s2; // rs все еще ссылается на si, // но теперь si имеет значение //"Clancy".
Правило 1 ИМННЕЭ ps = &s2; //ps указывает на s2 ; // значение si не изменилось. Вообще говоря, указатель следует использовать, если есть вероятность, что объект, связанный с указателем, отсутствует (в этом случае ему присваивается нулевое значение) или периодически возникает необходимость доступа к разным объектам (в этом случае изменяется значение указателя). Ссылку же следует использовать, если объект, к которому необходимо обеспечить доступ, будет существовать всегда, и не потребуется получить доступ к другому объекту с помощью все той же ссылки. Существует еще одна ситуация, в которой вы должны задействовать ссылки - при реализации некоторых операторов; из них наиболее часто встречается оператор [ ] (скобки). Обычно этот оператор должен вернуть некое значение, которое затем будет использовано как принимающее в операторе присваивания: vector<int>vA0); //Создаем векторцелых значений //размерности 10; //векторявляетсяшаблоном //из стандартной библиотеки C++ // (см.правило 35). v[5] =10; //Значение,возвращаемое //оператором [],является //принимающей стороной оператора //присваивания. Если бы оператор [ ] возвращал указатель, то последнюю строку этого кода надо было бы записать так: *v[5] = 10; Но создавалось бы ложное впечатление, что v является вектором указателей. Поэтому почти всегда желательно, чтобы оператор [ ] возвращал ссылку. (Интересное исключение из этого правила приведено в правиле 30.) Итак, использование ссылок оправдано, когда доподлинно известно, что объект ссылки существует, когда нет необходимости изменять значение ссылки и при реализации операторов, в которых применение указателей нежелательно из- за синтаксических требований. Во всех других случаях используйте указатели. Правило 2. Предпочитайте приведение типов в стиле C++ Рассмотрим прямое приведение типов. Это почти такой же изгой, как и оператор goto, но тем не менее оно продолжает использоваться, потому что, когда ситуация становится «хуже некуда», может оказаться необходимым. Приведение типов в стиле языка С не применяется так широко, как могло бы. Во- первых, это довольно грубый инструмент, практически позволяющий привести произвольный тип к любому другому. Было бы неплохо более точно определять цель каждого приведения. Например, существует большая разница между приведением
вавншш 0сновы указателя на объект const к указателю на объект, не являющийся const (то есть меняющим только атрибут const объекта), и приведением указателя на объект базового класса к указателю на объект производного класса (то есть приведение типа, которое полностью меняет тип указателя). Традиционное приведение типа в стиле языка С не различает эти два случая (и неудивительно - приведение типов в стиле языка С было разработано для С, а не для C++). Вторая проблема с приведением типов заключается в том, что случаи его применения трудно обнаружить. Синтаксически приведение типов состоит всего-навсего из пары скобок и идентификатора, а скобки и идентификаторы используются в C++ повсеместно. Таким образом, нелегко ответить даже на основной вопрос: «Использует ли программа приведение типов?». Это происходит потому, что человеческий глаз не всегда замечает код приведения типов, а такие утилиты, как grep, не могут отличить его от других, синтаксически схожих, конструкций. Чтобы преодолеть недостатки приведения типов в стиле языка С, в язык C++ введены четыре новых оператора приведения типов: static_cast, const_cast, dynamic_cast и reinterpret_cast. Для большинства задач программист должен знать только, что там, где он привык писать (type) expression теперь следует писать static_cast<type>(expression) Допустим, вам необходимо привести выражение типа int к типу double, чтобы получить число с плавающей точкой в результате вычисления целочисленного выражения. Используя приведение типов в стиле языка С, это можно было сделать следующим образом: int firstNumber,secondNumber; double result = ((double)firstNumber)/secondNumber; С новыми операторами приведения типа это делается так: double result = static_cast<double>(firstNumber)/secondNumber; Теперь у нас есть приведение типов, которое легко обнаружат как человеческий глаз, так и программы. Оператор stat ic_cast обладает теми же возможностями, что и приведение типов общего назначения в стиле языка С. На него налагаются аналогичные ограничения. Например, также как и в языке С, с помощью static_cast вы не можете преобразовать переменную типа struct в int или переменную типа double в указатель. Более того, с помощью оператора static_cast нельзя убрать атрибут const в выражении, для этого служит специальный оператор const_cast. Другие операторы приведения типов, введенные в C++, используются для более узкого круга задач. Оператор const_cast предназначен для работы с атрибутами const и volatile в выражениях. Используя оператор const_cast, вы
Правило 2 ¦МП подчеркиваете (как для человека, так и для компьютера), что собираетесь только изменить атрибут const или volatile какого-либо объекта. Это значение оператора поддерживают и компиляторы. Если попытаться использовать оператор const_cast для других задач, отличных от изменения атрибутов const или volatile, то такое приведение типов будет отвергнуто. Вот некоторые примеры: class Widget { ... } ; class SpecialWidget: public Widget{ voidupdate(SpecialWidget *psw); SpecialWidget sw; const SpecialWidgetk csw = sw; }; update(&csw) ; // swHe const объект, // но csw ссылается на него, / / как на cons t объект. / / Ошибка! Нельзя передавать const //указатель SpecialWidget* //функции, которая принимает //указатель SpecialWidget*. update(const_cast<SpecialWidget*>(&csw)); //Нормально, // атрибут const у &csw / / удален в результате //преобразования типа // (McswMswMoryT6HTbH3MeHeHH // в теле функцииupdate). update((SpecialWidget*)&csw); / / To же самое, но используя / / более трудное для обнаружения / / преобразование типа в стиле //языкаС. //Ошибка! pwKMeeTTnnWidget*, / / а функция update принимает // аргумент типа SpecialWidget* . (pw)); //Ошибка!Операторconst_cast //можно использовать только / / для изменения атрибутов // const HHHVolatile // и'нельзя применять для приведения //наследования. В настоящее время оператор const_cast чаще всего используется для изменения атрибута const. Второй специализированный оператор - dynamic_cast - для безопасного приведения типа между уровнями иерархии наследования. Это означает, что оператор dynamic_cast позволяет приводить указатели или ссылки на объекты базового класса к указателям или ссылкам на объекты производных или дочерних классов таким образом, чтобы можно было определить, была ли попытка приведения Widget *pw = new SpecialWidget-; update(pw); update(const_cast<SpecialWidget*>
Sill Основы ¦ill типа успешной*. В результате неудачной попытки возвращается нулевой указатель (при преобразовании указателей) или возникает исключение (при преобразовании ссылок): Widget *pw; update(dynamic_cast<SpecialWidget*>(pw)); //Нормально, функции update //передаетсяуказатель / / на объект класса SpecialWidget, // еслирюдействительноуказывает //на этот объект, в противном //случае передается нулевой //указатель. voidupdateViaRef(SpecialWidget&rsw); updateViaRef(dynamic_cast<SpecialWidget&>(*pw)); //Нормально, передача функции / / updateViaRef ссылки на объект //классаSpecialWidget, еслиръг //действительно указывает на этот //объект, в противном случае //возникает исключение. Применение операторов dynamic_cast ограничено возможностью навигации по иерархии наследования. Операторы нельзя использовать для приведения типов, не имеющих виртуальных функций (см. также правило 24), или для работы с атрибутом const: intfirstNumber,secondNumber; double result= dynamic_cast<double>(firstNumber)/secondNumber; //Ошибка! int не имеет //виртуальных функций. const SpecialWidget sw; update(dynamic_cast<SpecialWidget*>(&sw)); //Ошибка! dynamic_cast не может //работать с атрибутом cons t. Если вы хотите выполнить преобразование ненаследуемых типов, то лучше всего подойдет оператор static_cast. Чтобы удалить атрибут const, всегда применяется оператор const_cast. Последний из четырех новых операторов - оператор reinterpret_cast. Он используется для приведения типов в тех случаях, когда результат приведения почти всегда зависит от реализации. Из-за этого переносимость операторов reinterpret_cast существенно ограничена. * Оператор dynamic_cast всегда используется для того, чтобы найти начало памяти, занимаемой объектом. Подробнее об этом рассказано в правиле 27.
Правило 2 ШШНМБ!] Оператор reinterpret_cast обычно применяется для приведения указателей на функции. Предположим, например, что у вас есть массив указателей на функции определенного типа: typedef void (*FuncPtr) () ; //FuncPtr - это указательна / / функцию, которая не имеет / / аргументов и возвращает //значениеvoid. FuncPtr funcPtrArray [10] ; // funcPtrArray-это массив //из 10 указателей FuncPtr. Предположим, что вы хотите по какой-либо причине поместить в funcPtrArray указатель на следующую функцию: int doSomething() ; Вы не можете сделать это без приведения типа, потому что функция doSomething имеет тип, не соответствующий типу элементов массива funcPtrArray. Функции в funcPtrArray возвращают значение типа void, а функция do- Something возвращает значение int: funcPtrArray [0] = kdoSomething; //Ошибка! типы не совпадают. Оператор reinterperet_cast позволяет подчинить работу компиляторов вашему замыслу: funcPtrArray[0] = //Компилируется нормально. reinterpret_cast<FuncPtr> (ScdoSomething) ; Операция преобразования указателей на функции не является переносимой (язык C++ не гарантирует, что все указатели на функции хранятся единообразно), и в некоторых случаях такая операция дает неправильный результат (см. правило 31). Из-за этого использовать указатели на функции следует только в самом крайнем случае. Если ваши компиляторы не поддерживают новые виды операторов приведения типа, вместо static_cast, const_cast и reinterpret_cast допускается использовать традиционное приведение типа. Более того, придать синтаксису их использования сходство с новым синтаксисом можно с помощью макроопределений: #definestatic_cast(TYPE, EXPR) ((TYPE) (EXPR)) #defineconst_cast(TYPE, EXPR) ((TYPE) (EXPR)) #definereinterpret_cast(TYPE, EXPR) ((TYPE) (EXPR)) Эти макроопределения допускается использовать следующим образом: double result = static_cast(double, firstNumber)/secondNumber; update(const_cast(SpecialWidget*, &sw) ) ; funcPtrArray [0] =reinterpret_cast (FuncPtr, kdoSomething) ; Включение в программу макроопределений не столь безопасно, как использование настоящих операторов, но они облегчат модификацию вашего кода, когда компиляторы, с которыми вы работаете, начнут поддерживать новые операторы.
ЕШМНШ Основы Не существует простого способа эмулировать оператор dynamic_cast, но многие библиотеки содержат функции, выполняющие безопасные операции приведения типов, использующих наследование. Если такие функции отсутствуют, а вам просто необходимо выполнить операцию приведения типа, то и в этом случае можно обратиться к стилю языка С, но учтите, что вы не сумеете обнаружить безуспешное приведение типов. Разумеется, в такой ситуации также допускается создать макроопределение, похожее на оператор dynamic_cast: #definedynamic_cast(TYPE,EXPR) ((TYPE) (EXPR)) Помните, что это лишь приблизительный аналог, который не выполняет все функции dynamic_cast: нельзя обнаружить безуспешное преобразование типов. Как вы, наверное, заметили, новые операторы приведения типов выглядят непривычно и их сложно вводить с клавиатуры. Если вы находите их вид неприглядным, возможно, вас утешит информация о том, что в C++ работает и приведение типов в стиле языка С. Однако, теряя в красоте, новые операторы делают приведение типов более ясным и распознаваемым. Программы, их использующие, проще разбирать (как человеку, так и программным инструментам). Новые операторы позволяют компиляторам находить ошибки приведения типов, которые в противном случае остались бы не выявленными. Согласитесь, это серьезные аргументы в пользу того, чтобы не использовать преобразование типов в стиле языка С. Правило 3. Никогда не используйте полиморфизм в массивах Одна из наиболее важных черт наследования состоит в том, что, используя указатели и ссылки на объекты производного класса, вы можете получить доступ к объектам базового класса. Такое поведение указателей и ссылок называют полиморфизмом (polymorphically), - они ведут себя так, как будто принадлежат нескольким типам. Язык C++ теоретически позволяет управлять массивами из объектов производного класса через указатели и ссылки на объекты базового класса, но на практике это почти всегда работает не так, как хотелось бы. Представим, например, что у вас есть класс BST (для объектов двоичного дерева поиска) и второй класс, BalancedBST, наследующий от BST: classBST{ . . . }; classBalancedBST:publicBST {-...} ; В реальной программе такие классы реализуются через шаблоны, но здесь это не существенно, а синтаксис шаблонов только затрудняет чтение. Чтобы не усложнять пример, предположим, что BST и BalancedBST содержат только переменные типа int. Рассмотрим функцию, распечатывающую содержимое каждого объекта BST в массиве элементов BST: voidprintBSTArray (ostream& s, const BST array [] , int numEl ement s)
Правило 3 ИИИННКЯ { for (int i = 0; i <numElements; ++i) { s << array[i]; //Предполагается, что оператор << / / определен для объектов BST. } } Этот код нормально работает, когда в качестве аргумента функции передается массив объектов BST: BSTBSTArray[10] ; printBSTArray(cout, BSTArray,10); // Работает нормально. Однако посмотрите, что происходит, когда вы передаете функции printBSTArray массив объектов типа BalancedBST: balancedBSTbBSTArray[10]; printBSTArray(cout, bBSTArray,10); //Нормально работает? Ваши компиляторы без проблем обработают вызов этой функции, но взгляните на цикл, для которого он должен сгенерировать код: for (int i = 0; i < numElements; ++i) { s << array [i] ; } Выражение array [ i ] на самом деле является сокращением для арифметического выражения, вычисляющего указатель, оно эквивалентно выражению * (array + i). Мы знаем, что array - это указатель на начало массива, но как далеко от ячейки памяти, на которую указывает array, находится память, на которую указывает выражение array + i? Расстояние между ними определяется выражением i * sizeof (элемент массива), потому что между array [0] и array [ i ] как раз i элементов. Для того чтобы генерировать код, обеспечивающий корректный доступ к элементам массива, компиляторы должны знать размер каждого элемента массива. Это легко. Переменная array описана как массив элементов типа BST, так что расстояние между указателями array и array + i равно i * sizeof(BST). По крайней мере, так это выглядит с точки зрения компиляторов. Но если вы передали функции printBSTArray в качестве аргумента массив объектов типа BalancedBST, то компиляторы скорее всего ошибаются. В этом случае они полагают, что размер элемента массива равен размеру объекта BST, в действительности же размер элемента равен размеру объекта BalancedBST. Обычно производные классы имеют большее количество членов класса, чем базовый класс, поэтому они, как правило, превосходят базовый класс по размеру. Таким образом, следует ожидать, что объект типа BalancedBST будет больше, чем объект типа BST. Если это так, то выражение для вычисления указателей, созданное в теле функции printBSTArray, будет неверным для массивов элементов типа BalancedBST, и нельзя предсказать, что произойдет при вызове функции printBSTArray,
1118 Основы имеющей в качестве аргумента массив элементов BalancedBST. Но что бы ни случилось, вам это явно не понравится. В другом обличье проблема возникает, если вы пытаетесь удалить массив элементов производного класса, используя указатели на базовый класс. Вот один из способов, который вы, не подозревая подвоха, могли бы испробовать: / / Удаляеммассив, предварительно сообщив об этом. voiddeleteArray(ostreamk logStream, BST array [] ) { logStream« "Удаляеммассив, расположенный по адресу" «static_cast<void*>(array) << '\n'; delete [] array; } BalancedBST *balTreeArray = // Создаеммассивэлементов newBalancedBST[50]; //типаBalancedBST. deleteArray (cout, balTreeArray) ; // Сообщаем об удалении //массива. Вы этого не замечаете, но здесь также происходит вычисление указателей. При удалении массива для каждого его элемента должен быть вызван деструктор (см. правило 8). Когда компилятор обрабатывает выражение delete [ ] array; он создает код, который делает приблизительно следующее: / / Удаляем элементы в массиве *аггау в порядке, обратном / / порядку их создания. for (int i = количество элементов в массиве - 1; i >= 0 ; — i) { array[i].BST::~BST; //Вызываетсядеструктор } / / для элемента array [ i ] . Подобный цикл не работал, когда вы сами написали его, точно так же не будет он работать и после того, как его напишет компилятор. В определении языка сказано, что удаление массива элементов производного класса через указатель на базовый класс приводит к непредсказуемому результату, но на практике это почти наверняка означает печальный результат. Полиморфизм и арифметические действия с указателями просто не сочетаются. А поскольку операции с массивами почти всегда требуют арифметических действий с указателями, то массивы и полиморфизм тоже плохо совместимы. Заметьте, что вы вряд ли совершите ошибку по использованию полиморфизма в массивах, если ваш конкретный (не абстрактный) класс (такой как BalancedBST) не будет наследовать от другого конкретного класса (подобного BST). Как объяснено в правиле 33, разработка программ, в которых конкретные классы не наследуют один от другого, имеет много преимуществ. Советую вам перейти к правилу 33 и прочитать все об этих преимуществах.
Правило 4 11НИНЕЗ Правило 4. Избегайте неоправданного использования конструкторов по умолчанию Default constructor (конструктор по умолчанию) - это конструктор, который можно вызывать без аргументов. Конструкторы инициализируют объекты, а конструкторы по умолчанию инициализируют объекты, не используя никакую информацию из контекста, в котором создается объект. Иногда это очень разумно. Например, объекты, представляющие собой числа с известным основанием можно инициализировать нулем или специальным значением «не определено». Объекты, выполняющие функции указателей (см. правило 28), можно инициализировать аналогичным образом. Структуры данных (связанные и индексированные списки, хэш-таблицы и т.п.) допускается создавать как пустые контейнеры. Но не все объекты обладают этим свойством. Для многих из них не существует разумных способов инициализации без информации извне. Например, объект, соответствующий записи в адресной книге, не имеет смысла, если отсутствует наименование записи. В некоторых компаниях каждая единица оборудования получает корпоративный идентификационный номер, поэтому создание объекта, соответствующего единице оборудования, без указания номера также бессмысленно. В идеале классы, объекты которых для своего создания не требуют дополнительной информации, должны были бы иметь конструкторы по умолчанию, а классы, объекты которых не могут быть созданы без дополнительной информации, - нет. К сожалению, до идеала нашему миру еще далеко, поэтому мы вынуждены руководствоваться дополнительными соображениями. В частности, если у класса отсутствует конструктор по умолчанию, то на его использование накладываются определенные ограничения. Рассмотрим класс, описывающий единицу оборудования компании, в реализации которого корпоративный идентификационный номер является обязательным аргументом конструктора: class EquipmentPiece{ public: EquipmentPiece(intIDNumber); }; У класса EquipmentPiece нет конструктора по умолчанию, поэтому его использование в некоторых случаях вызывает сложности. Первый - это создание массивов. Вообще говоря, не существует способов передать аргументы конструктору элементов массива, поэтому стандартным образом создать массив элементов EquipmentPiece невозможно: EquipmentPiecebestPieces[10] ; // Ошибка!Невозможно вызвать //конструктор EquipmentPiece. EquipmentPiece *bestPieces = new Equipment Piece [10] ; //Ошибка! Та же проблема. 2-679
ЕЯВПМ111 0cH0Bb> Есть три варианта, как преодолеть это ограничение. Для массивов, размещаемых статически, решение состоит в том, чтобы определить необходимые аргументы в момент создания массива: int ID1, ID2 , ID3 , ...,ID10; //Переменные для хранения //идентификаторов оборудования. EquipmentPiecebestPieces[] = { //Нормально,аргументы EquipmentPiece(ID1) , //передаются EquipmentPiece(ID2) , //в конструкторы. EquipmentPiece(ID3), • • ¦ / EquipmentPiece(ID10) }; К сожалению, этот прием не годится для динамических массивов. Более общий подход состоит в использовании массива указателей взамен массива объектов: typedef EquipmentPiece* PEP; // PEP-это указатель на //EquipmentPiece. PEPbestPieces[10] ; //Нормально, конструкторне //вызывается. PEP*bestPieces = new PEP [10] ; //Тоже нормально. Затем нужно связать каждый указатель с соответствующим объектом EquipmentPiece: for (inti = 0; i < 10; ++i) bestPiece[i] = new EquipmentPiece {идентификационный номер) ; У этого подхода есть два недостатка. Во-первых, нужно не забыть удалить все объекты, на которые указывают элементы массива, иначе возникнет утечка памяти. Во-вторых, общее количество использованной памяти оказывается больше, потому что наряду с объектами EquipmentPiece нужно хранить также и указатели. Нерационального расхода памяти можно избежать, если сначала выделить для размещения массива область неструктурированной памяти, а затем использовать буферизованный оператор new (см. правило 8) для создания в памяти объектов EquipmentPiece: // Выделяем неструктурированную область памяти, необходимую // для размещения 10 объектов EquipmentPiece; более подробное / / описание функции operator new [ ] приведено в правиле 8 . void *rawMemory = operatornew[] A0*sizeof(EquipmentPiece)); // HacTpoHMyKa3aTenbbestPieces на эту область, чтобы его / / можно было рассматривать как массив EquipmentPiece. EquipmentPiece *bestPieces =
Правило 4 E11HHHES static_cast<EquipmentPiece*>(rawMemory); // Создадим объекты EquipmentPiece в памяти, используя //буферизованный оператор new (см. правило 8) . for (inti = 0; i< 10,- ++i) new (bestPieces[i]) EquipmentPiece(идентификационныйномер); Заметьте, что все еще нужно передавать аргумент в конструктор каждого объекта EquipmentPiece. Этот прием (как и использование массива указателей) позволяет создать массив объектов без конструктора по умолчанию, но не позволяет обойтись без передачи аргументов конструктору. Такого способа вообще не существует. В противном случае сама идея назначения конструкторов, гарантирующих инициализацию объектов, была бы скомпрометирована. У использования буферизованного оператора new есть и обратная сторона. Большинство программистов с ним вовсе не знакомо, из-за чего затруднена последующая поддержка. Но главный недостаток состоит в том, что когда время жизни объектов в массиве истечет, необходимо вручную вызывать деструкторы этих объектов, а затем опять же вручную освободить область памяти, вызвав operator delete [ ] (снова см. правило 8): / / Уничтожаем объекты в массиве best Pieces в порядке, // обратном порядку их создания, for (int i = 9; i>=0; --i) bestPieces[i].-EquipmentPiece(); //Освободим область памяти. ' operator delete[] (rawMemory); Если забыть о данном требовании и использовать стандартный синтаксис удаления массива, то программа поведет себя непредсказуемо. Это вызвано тем, что операция удаления указателя, не инициализированного с помощью оператора new, не определена: delete [] bestPieces; //He определено!BestPieces / / не был инициализирован // оператором new. Более подробная информация об операторе new, буферизованном операторе new и их взаимодействии с конструкторами и деструкторами приведена в правиле 8. Вторая проблема с классами, не имеющими конструкторов по умолчанию, - невозможность их использования со многими контейнерными шаблонами классов. Это связано с тем, что часто требуется иметь конструктор по умолчанию для параметра, с помощью которого генерируется шаблон. Это требование почти всегда вызвано тем, что внутри шаблона создается массив элементов типа параметра шаблона. Например, шаблон для класса Array может выглядеть примерно так: template<class T> class Array{ public: Array (int size) ; 2*
ЕЗНН111Ш Основы private: T*data; ); template<class T> Array<T>::Array(int size) { data = newT[size] ; //ВызываетТ: :Тдлякаждого ... / / элемента массива. } В большинстве случаев хорошо продуманная архитектура шаблона могла бы устранить необходимость в конструкторе по умолчанию. Например, стандартный шаблон vector (он генерирует классы, которые ведут себя как массивы переменной длины) не требует, чтобы его параметр имел конструктор по умолчанию. К сожалению, архитектуру большинства шаблонов можно назвать какой угодно, только не хорошо продуманной. Из-за этого классы, не имеющие конструктора по умолчанию, оказываются несовместимыми с большинством шаблонов. Конечно, когда все программисты на C++ овладеют навыками корректного создания шаблонов, данная проблема потеряет свое значение, но как скоро это произойдет, остается только гадать. Последний аргумент в дилемме «создавать или не создавать конструктор по умолчанию» связан с виртуальными базовыми классами. С такими классами, если они не имеют конструкторов по умолчанию, очень сложно работать. Аргументы для конструкторов виртуального базового класса должны передаваться от самого последнего в иерархии наследующих классов. В результате виртуальный базовый класс без конструктора по умолчанию требует, чтобы все наследующие от него классы, независимо от глубины наследования, знали об аргументах конструктора виртуального базового класса, понимали их назначение и обеспечивали их передачу при вызовах. Авторы производных классов не всегда помнят об этом и не всегда оценивают по достоинству данное требование. Из-за ограничений, налагаемых на использование классов, не имеющих конструкторов по умолчанию, некоторые программисты считают, что во всех классах должны быть такие конструкторы, даже если сами конструкторы не обладают достаточной информацией для полной инициализации объекта. Например, сторонники подобной философии могли бы изменить код класса EquipmentPiece следующим образом: classEquipmentPiece{ public: EquipmentPiece (int IDNumber = UNSPECIFIED) ; private: static const int UNSPECIFIED; //Особое значение, //которое задается,если //идентификационный // номер не был указан. };
Правило 4 ШМШМЕ] Этот код позволяет создавать объекты EquipmentPiece следующим образом: EquipmentPiece e; //Теперь нормально. Такое изменение почти всегда усложняет реализацию других членов - функций класса, потому что нет гарантии, что поля объекта EquipmentPiece были корректно проинициализированы. Зная, что существование объекта EquipmentPiece с незаполненным идентификационным номером бессмысленно, большинство членов-функций должно будет проверять, заполнено или не заполнено данное поле. Даже если поле пусто, им нужно будет каким-то образом обработать возникшую ситуацию. Зачастую не очень понятно, как это сделать, и многие выбирают решение, не отличающееся оригинальностью: либо генерируют исключение, либо вызывают функцию, которая завершает выполнение программы. Но такое неоправданное включение в класс конструктора по умолчанию явно не улучшает качество программы. Включение бессмысленных конструкторов по умолчанию влияет также и на эффективность работы классов. Когда члены-функции вынуждены проверять корректность инициализации полей, клиенты этих функций тратят на проверки немалое время. Код, который генерируется для проверок, и код, обрабатывающий аварийные ситуации, также увеличивает размер программ и библиотек. Подобных расходов можно избежать, если корректная инициализация полей достигается с помощью конструкторов. Но конструкторы по умолчанию часто не могут обеспечить этого, поэтому их стоит включать в программы только по очень веским причинам. Такое ограничение, накладываемое на использование классов, гарантирует, что генерируемые ими объекты будут корректно инициализированы и эффективно реализованы.
Глава 2. Операторы К перегружаемым операторам следует относиться с уважением. Они позволяют применять для типов, определяемых пользователем, такой же синтаксис, как и для встроенных типов, а также обеспечивают неслыханные перспективы благодаря функциям, стоящим за этими операторами. Но возможность заставить такие символы, как + или ==, делать все что угодно, означает также, что из-за перегружаемых операторов программы могут оказаться совершенно непонятными. Тем не менее, есть много искусных программистов на C++, которые знают, как использовать мощь перегружаемых операторов, не превратив программу в черный ящик. Менее искусным, к сожалению, легко совершить ошибку. Конструкторы с единственным аргументом и операторы неявного преобразования типа могут доставить особенно много хлопот, поскольку их вызовы не всегда имеют соответствия в исходных текстах программ. Это ведет к появлению программ, поведение которых понять очень трудно. Другая проблема возникает при перегрузке таких операторов, как && и II, потому что переход от встроенных операторов к функциям, написанным пользователем, приводит к незначительным изменениям в семантике, и эти изменения легко проглядеть. Наконец, множество операторов соотносится друг с другом по стандартным правилам, а из-за перегруженных операторов общепринятые соотношения иногда нарушаются. В изложенных далее правилах я попытался объяснить, когда и как используются перегруженные операторы, как они ведут себя, как должны соотноситься друг с другом и как всем этим можно управлять. Освоив материалы данной главы, вы будете перегружать (или не перегружать) операторы с той же уверенностью, что и настоящий профессионал. Правило 5. Опасайтесь определяемых пользователем функций преобразования типа Язык C++ позволяет компиляторам осуществлять неявное преобразование типов. Так же как и его предшественник, язык С, он позволяет неявное преобразование из char в int или из short в double. Вот почему можно передать функции, имеющей аргумент типа double, параметр типа short, и несмотря на это вызов функции будет осуществлен корректно. Наиболее страшные по своим последствиям преобразования в С, при которых происходит потеря информации, сохранились и в C++, включая преобразование из int в short и из double в (самый распространенный вариант) char. С такими преобразованиями ничего нельзя поделать, они встроены в язык. Однако при добавлении собственных типов программист может лучше управлять
Правило 5 _ HI ими, выбирая, включать или нет в программу функции, которые компиляторы потом будут использовать для неявного преобразования типов. Существуют два типа функций, позволяющих компиляторам выполнять такие преобразования: конструкторы с единственным аргументом и операторы неявного преобразования типов. Конструктор с единственным аргументом требует при вызове только один аргумент. Такой конструктор может быть объявлен с одним или несколькими параметрами при условии, что все параметры, начиная со второго, имеют значение по умолчанию. Вот два примера: class Name { //Описывает имена объектов. public: Name(conststringk s) ; //Преобразование string в Name. }; classRational{ //Класс действительныхчисел, public: Rational(intnumerator = 0, //Преобразует int denominator = 1) ; // int в Rational. }; Оператор неявного преобразования типа - это просто функция, являющаяся членом класса со странным именем operator, за которым следует спецификация типа. Нельзя задать тип возвращаемого значения, потому что данный тип представляет собой просто имя функции. Например, чтобы обеспечить неявное преобразование объектов типа Rational к типу double (что позволяет вычислять арифметические выражения смешанного типа с объектами типа Rational), можно определить класс Rational следующим образом: classRational { public: operator double()const; //Преобразует Rational }; // к типу double. Эта функция будет автоматически вызываться в следующем контексте: Rational г A,2) ; // Значение г равно 1/2 . double d= 0 . 5 * г; // Преобразует г к типу //double, а затем выполняет //операцию умножения. Приведенные фрагменты кода, вероятно, показались вам похожими на повторение пройденного. Это хорошо, потому что я хочу объяснить, почему программисты обычно не хотят вводить никакие функции преобразования типа. Главная проблема состоит в том, что появление таких функций приводит к их вызову тогда, когда вы и не ожидаете, и не желаете этого. Результат часто оказывается неверным, а поведение программы не поддается интуитивному анализу, и ее безумно сложно отлаживать.
ШМНШШ ' Операторы Давайте сначала займемся простейшим случаем - операторами неявного преобразования типа. Допустим, вы хотите, чтобы класс рациональных чисел, похожий на описанный выше, выводил на печать объекты Rational, как будто это объекты одного из встроенных типов. То есть вы хотите иметь возможность выполнить следующее: Rational г A, 2 ) ; cout«r; //Должно выводить /2" . Предположим далее, что вы забыли определить operator << для объекта Rational. Вероятно, вы полагаете, что попытка вывести г на печать окончится неудачей, ведь operator << не определен. Вы ошиблись. Ваши компиляторы, обрабатывая вызов функции с именем operator << и аргументом Rational, обнаружат, что такая функция не определена, и попытаются найти подходящую последовательность операторов неявного преобразования типа, которая обеспечит выполнение вызова. Правила подбора подходящей последовательности достаточно сложны, но в этом конкретном случае компиляторы обнаружат, что вызов можно выполнить, если неявно преобразовать г в double, вызвав Rational: :operator double. В результате выполнения программы, приведенной выше, значение г будет напечатано как число с плавающей точкой, а не как действительное число. Это вряд ли повлечет за собой катастрофические последствия, но, тем не менее, хорошо демонстрирует, какие неприятности могут доставить операторы неявного преобразования типа: они могут привести к вызову неверной функции (то есть не той, которую хотел вызвать программист). Решение проблемы состоит в замене операторов на эквивалентные функции, не имеющие аналогов с тем же именем. Например, для приведения объекта Rational к типу double заменим operator double на функцию с именем asDouble: class Rational{ public: doubleasDouble() const; //Приведение от типа // Rational к типу double. }; Такие функции-члены должны вызываться явно: RationalrA,2); cout << г; / / Ошибка! Оператор << для / / Rat ionalне определен. cout << г.asDouble(); / / Нормально, г будет // напечатано как double . В большинстве случаев неудобство от необходимости вызывать функции преобразования типов явно более чем компенсируется исчезновением нежелательных вызовов функций. Вообще говоря, чем опытнее программисты на языке C++, тем реже они прибегают к операторам преобразования типа. Например, члены комитета, работающие над стандартной библиотекой C++ (см. правило 35),
Правило 5 ШИНКИ принадлежат к наиболее знающим, и, вероятно, поэтому объект string, включенный ими в библиотеку, не допускает неявного приведения от типа string к char*. Вместо этого существует функция-член c_str, выполняющая данное преобразование. Совпадение? Думаю, нет. С неявным преобразованием, выполняемым конструктором с единственным аргументом, бороться труднее. Более того, проблемы, создаваемые такими конструкторами, зачастую серьезнее, чем те, которые возникают из-за операторов неявного преобразования типа. В качестве примера рассмотрим шаблон класса для массивов. Эти массивы позволяют определять верхнюю и нижнюю границы индексов: template<class Т> class Array { public: Array (int lowBound, inthighBound) ; Array(int size); T&operator[] (int index); }; Первый конструктор позволяет клиенту задавать диапазон индексов массива, например от 10 до 20. Так как у него два аргумента, то эта функция не может быть использована для преобразования типа. Напротив, с помощью второго конструктора допускается создавать объекты Array, указывая только количество элементов в массиве (так же, как это делается для встроенных массивов). Он может быть использован для преобразования типов, и потому является источником постоянного раздражения. Рассмотрим реализацию сравнения объектов Array<int> и пример обработки таких объектов: booloperator==(const Array<int>& lhs, const Array<int>&rhs); Array<int>aA0); Array<int>bA0); for (inti=0;i<10; ++i) if(a==b[i]){ //Вместо"а" //должно быть " a [ i]". / / Если а [ i ] и b [ i ] равны, //выполнить какие-тодействия. } else { выполнить другие действия, если они не равны; } Мы намеревались сравнить элементы а с соответствующими элементами Ь, но случайно пропустили индекс при а. Конечно, хотелось бы, чтобы эта ошибка вызвала у компиляторов поток нелицеприятных комментариев, но они не будут жаловаться, поскольку встретят вызов operator== с аргументами типа
ЕЭНННП11 Операторы Array<int> (для а) и int (для Ь). И хотя operator== с аргументами такого типа не определен, компиляторы заметят, что они могут преобразовать int в объект типа Array< int >, вызвав конструктор Array<int>, принимающий int в качестве единственного аргумента. Именно это они и сделают, создав код, который явно не предполагался: for (inti = 0; i< 10; + + i) if (a== static_cast<Array<int»(b[i])) ... На каждом шаге цикла программа сравнивает содержимое а с содержимым временного массива размерности b [ i ] (содержимое которого, скорее всего, не определено). Эта программа не только некорректна, но и крайне неэффективна, потому что на каждом шаге создает и удаляет временный объект Array<int> (см. правило 19). Проблем, возникающих из-за операторов неявного преобразования типа, можно избежать, если просто не объявлять эти операторы, но для конструкторов с единственным аргументом такой подход неприемлем. Данные конструкторы могут быть действительно нужны клиентам. В то же время нельзя и позволять компиляторам вызывать эти конструкторы без разбора. К счастью, существует возможность удовлетворить оба требования. Есть даже два пути: простой и тот, которым вам придется пользоваться, если ваши компиляторы пока не поддерживают первый путь. Простой вариант состоит в использовании одной из новых черт C++, а именно ключевого слова explicit. Эта особенность была специально введена для решения проблемы неявного преобразования типов, и ее применение предельно облегчено. Если конструктор объявлен с атрибутом explicit, то компиляторам запрещается вызывать его для неявного преобразования типа, в то время как явное преобразование разрешено: template<class T> сlassArray{ public: explicit Array(int size) }; Array<int> a A0); Array<int>bA0); if <a==b[i]) ... if (a==Array<int>(b[i] ) ) . //Использование explicit. // Нормально, конструктор, // объявленный как explicit, / / может быть использован / / для создания объектов. / / Также нормально. //Ошибка!Невозможно // неявно преобразовать int // BArray<int>. //Нормально, явное //преобразование типа //от int BArray<int>
¦¦¦¦¦¦IKS //разрешено (хотя логика / / программы и выглядит //подозрительно). static_cast< Array<int> >(b[i])) ... //Также нормально, / / логика подозрительна. (Array<int>)b[i]) . . . //Преобразования / / в стиле С также разрешены, / / но программа //продолжает оставаться //подозрительной. В примере, использующем static_cast (см. правило 2), пробел, разделяющий два знака >, неслучаен. Если бы выражение было написано следующим образом: if (a== static_cast<Array<int»(b[i]) ) .. . оно имело бы другой смысл. Это вызвано тем, что компиляторы C++ рассматривают выражение » как единую конструкцию. Без пробела между символами > выражение породило бы синтаксическую ошибку. Если ваши компиляторы пока не поддерживают explicit, вам придется освоить приемы, которые предотвращают использование конструкторов с одним аргументом в качестве функций неявного преобразования типа. Чтобы понять эти методы, достаточно увидеть их хотя бы один раз. Ранее я упоминал, что правила, по которым определяется, какие последовательности неявного преобразования типа корректны, а какие нет, довольно сложны. Одно из этих правил состоит в том, что последовательность преобразований не может содержать более одного преобразования, определенного пользователем (то есть вызова конструктора с единственным аргументом или оператора неявного преобразования типа). Правильно сконструировав классы, нетрудно использовать это правило таким образом, чтобы создание объектов было разрешено, а нежелательные неявные преобразования типов оказались запрещены. Снова рассмотрим шаблон Array. Допустим, требуется разработать способ, который бы разрешал передавать конструктору целочисленный размер массива в качестве аргумента и в то же время запрещал неявное преобразование целых чисел во временный объект Array. Сначала создается новый класс ArraySize. Объекты этого типа служат единственной цели: они описывают размерность массива, который будет создан. Затем конструктор с одним аргументом Array переписывается так, чтобы принимать в качестве аргумента ArraySize, а не int. Исходный текст выглядит следующим образом: template<class T> class Array { public: class ArraySize{ //Новыйкласс. Правило 5 if (a = if (a=:
шимми public: ArraySize(intnumElements):theSize(numElements) {} int size() const{return theSize;} private: int theSize; }; Array (int lowBound, int highBound) ; Array(ArraySizesize); // Обратите внимание на //изменения в объявлении. }; Здесь класс ArraySize размещен внутри Array, чтобы подчеркнуть, что он всегда используется вместе с классом Array. Класс ArraySize также объявлен как publ i с, благодаря чему доступ к нему открыт. Посмотрим, что случится, если определить объект Array при помощи конструктора с единственным аргументом: Array<int> a A0) ; Ваши компиляторы генерируют вызов конструктора класса Array<int>, принимающий int в качестве аргумента, но такого конструктора не существует. Компиляторы могут преобразовать аргумент типа int во временный объект ArraySize, поскольку объект ArraySize - именно то, что нужно для конструктора Array<int>, и выполняют такие преобразования по своим обычным правилам. Эта процедура обеспечивает успешный вызов функции и сопутствующее создание объекта. Мысль о том, что объекты Array могут быть созданы с помощью аргументов типа int, успокаивает, но нужно еще убедиться, что запрещены нежелательные преобразования типа. Снова взглянем на этот код: bool operator== (const Array<int>& lhs , const Array<int>& rhs) ; Array<int> a A0); Array<int> bA0); for (int i=0,- i<10; + + i) if(a==b[i])... //Вместо"а"должно // стоять "a [i] " ; / / теперь это ошибка. Компиляторам нужен объект типа Array<int> справа от ==, чтобы можно было вызвать operator = = для объектов Array<int>, но конструктора, принимающего единственный аргумент типа int, не существует. Компиляторы не могут преобразовать int во временный объект ArraySize, а затем создать необходимый объект Array<int> из временного, потому что это потребует вызова двух преобразований, определенных пользователем: из int в ArraySize и из ArraySize в Array<int>. Такая последовательность Операторы
правило б щшшштшп преобразований запрещена, поэтому при попытке выполнить сравнение компиляторы сгенерируют ошибку. Использование класса Arraysize может показаться искусственным приемом, но на самом деле это частный случай более универсального метода. Классы типа ArraySize часто называют proxy-классами, потому что каждый объект такого класса соответствует другому объекту (замещает его). Объект ArraySize действительно замещает целое число, определяющее размерность создаваемого массива Array. Proxy-объекты предоставляют контроль над некоторыми сторонами поведения программы, в данном случае неявными преобразованиями типа, что невозможно получить другими способами, поэтому приглядитесь к ним попристальнее (см. правило 30). Однако прежде чем обратиться к proxy-классам, еще раз обратите внимание на следующий факт: разрешая компиляторам выполнять неявные преобразования типа, вы почти наверняка получите больше сложностей, чем положительных результатов, поэтому не создавайте функций преобразования типа, если не уверены в их необходимости. Правило 6. Различайте префиксную и постфиксную формы операторов инкремента и декремента Давным-давно (в конце 80-х) в далеком-далеком языке (C++ того времени) разницы между префиксными и постфиксными формами операторов ++ и — не существовало. Программистам не хватало этой возможности, и C++ был дополнен разрешением перегружать обе формы инкрементных и декрементных операторов. Однако при этом возникла синтаксическая проблема, связанная с тем, что компилятор различает перегруженные функции по типу передаваемых аргументов, а префиксные и постфиксные формы инкрементных и декрементных операторов не имеют аргументов. Чтобы преодолеть синтаксическую ловушку, было принято волевое решение: постфиксные формы принимают аргумент типа int, а компиляторы при вызове этих функций по умолчанию Подставляют 0 в качестве аргумента: classUPInt { // intнеограниченного размера. public: UPIntk operator++(); //Префиксная форма ++, constUPIntoperator++(int) ; //постфиксная форма++. UPIntkoperator--(); //Префиксная форма — , const UPInt & operator-- ( int) ; // постфиксная форма -- . UPInt& operator + =(int); //Оператор += для UPInt //nint. }; UPInti; ++i; // Вызывается i .operator^ ¦» () . i + + ; // Вызывается i . operator + +¦ @) .
EaBMlli Операторы --i; //Вызывается i.operator--() . i--; //Вызывается i.operator--@) . Это соглашение выглядит несколько странно, но вы привыкнете. Важен другой факт: префиксные и постфиксные формы операторов возвращают значения различного типа. В частности, префиксные формы возвращают ссылку, а постфиксные формы - объект с атрибутом const. Далее я буду говорить только о префиксной и постфиксной форме оператора ++, поскольку механизм работы оператора — аналогичен. Возможно, еще с тех времен, когда вы программировали на С, вы помните, что префиксная форма инкрементного оператора иногда называлась «увеличить и подставить», а постфиксная форма - «подставить и увеличить». Важно не забыть эти две фразы, потому что они почти полностью соответствуют формальной спецификации использования префиксной и постфиксной форм: / / Префиксная форма: увеличить и подставить. UPIntk UPInt::operator++() { *this+=l; //Увеличить. return*this; //Подставить. } //Постфиксная форма:подставить //иувеличить. const UPInt UPInt: : operator++ (int) { UPIntoldValue = *this; //Запомнить. ++(*this); //Увеличить. return oldValue; // Вернуть то, что //нужно подставить. } Обратите внимание, что постфиксный оператор не использует переданный параметр. Это нормально. Единственное назначение параметра - обеспечить различие вызовов префиксной и постфиксной форм функций. Многие компиляторы генерируют предупреждения, если параметры, передаваемые функции, не используются в ее теле. Чтобы избежать таких предупреждений, можно не указывать имена неиспользуемых в теле функции параметров, как показано выше. Понятно, почему постфиксная форма инкрементного оператора должна возвращать объект (она возвращает сохраненное значение), но почему объект имеет атрибут const? Представим себе, что атрибут отсутствует. Тогда следующий код будет корректным: UPInti; i++++; //Применяемпостфиксное //увеличение дважды. Этот код аналогичен следующему: i.operator++@).operator++@);
правило б 'ШШШШШБ При этом ясно, что второй вызов operator++ применяется к объекту, возвращаемому в результате первого вызова. Есть две причины, по которым нужно отказаться от такой возможности. Во- первых, она несовместима с поведением встроенных типов. При конструировании классов существует хорошее правило: если сомневаешься, обработай аналогично типу int, а тип int уж точно не позволяет двойное применение постфиксного инкрементного оператора: int i ; i++++; //Ошибка! Вторая причина состоит в том, что двойное применение постфиксного инкрементного оператора почти никогда не соответствует потребностям клиента. Как уже было отмечено выше, второй вызов operator++ изменяет значение объекта, возвращаемого после первого вызова, а не значение исходного объекта. Таким образом, если бы выражение i++++; было корректным, то значение переменной i увеличивалось бы только один раз. Это противоречит привычной практике и может приводить к ошибкам (и для типа int, и для типа UPInt), так что такой синтаксис лучше запретить. Язык C++ запрещает его для типа int, но для классов, написанных пользователем, программа должна делать это самостоятельно. Самый простой путь состоит в том, чтобы придать значению, возвращаемому постфиксным инкрементным оператором, атрибут const. Тогда если компиляторы видят выражение i++++; //Эквивалентноi.operator++@).operator++@) . они понимают, что объект const, возвращаемый после первого вызова, используется для повторного вызова operator++. Однако operator++ имеет атрибут const, поэтому объекты с данным атрибутом не могут вызывать этот оператор*. Итак, теперь вы знаете, что функции, возвращающие значение с атрибутом const, иногда нужны, например для использования в инкрементном и декрементном операторах. Если вы относитесь к программистам, которые заботятся об эффективности кода, то вас, вероятно, обеспокоила реализация постфиксного инкрементного оператора. Эта функция создает временный объект для возвращаемого значения (см. правило 19), а ее реализация, приведенная выше, создает также временный объект (oldValue), для которого вызывается и конструктор, и деструктор. Префиксная инкрементная функция вообще не генерирует временных объектов. Это приводит к впечатляющему выводу: из соображений эффективности клиенты UPInt должны всегда предпочитать префиксную форму инкрементного оператора, если им только не требуется функциональность постфиксной формы. Давайте проясним это. Работая с пользовательскими типами, программист должен применять * К сожалению, не все компиляторы поддерживают это ограничение. Прежде чем полагаться на компиляторы, протестируйте их.
ЕШНН1НМ Операторы префиксную форму при каждой возможности, потому что она обеспечивает более эффективный код. И еще одно замечание относительно префиксной и постфиксной форм. Если не обращать внимания на возвращаемые значения, обе они делают одно и то же: увеличивают значение переменной. То есть предполагается, что они делают одно и то же. Как же обеспечить согласованность их поведения? Какую гарантию можно дать, что со временем, например в результате действий различных программистов, поддерживающих программное обеспечение, реализации операторов не начнут отличаться? Это можно гарантировать, только следуя вышеописанным принципам. В соответствии с ними постфиксная форма декрементного и инкре- ментного операторов должна быть реализована через их префиксную форму. Тогда придется поддерживать только префиксную форму, потому что постфиксная будет вести себя аналогично. Как вы видите, легко овладеть постфиксной и префиксной формами декрементного и инкрементного операторов. Если правильно задать тип возвращаемого значения и реализовать постфиксную форму оператора через префиксную, то дальше работы остается немного. Правило 7. Никогда не перегружайте операторы &&, || и, Как и язык С, C++ использует оптимизированную схему оценки логических выражений. Это означает, что вычисление выражения заканчивается, как только установлена его истинность или ложность, даже если рассмотрены не все части выражения. Например, в следующем случае: char *р; if ( (р ! = 0) && (strlen(p) >10) ) . . . не нужно волноваться по поводу вызова функции strlen с нулевым указателем, потому что при р = 0 функция strlen попросту не будет вызвана. Аналогично, в функции int. rangeCheck (int index) { if ( (index< lowerBound) I I (index>upperBound) ) . . . } сравнение значений index и upperBound никогда не произойдет, если значение переменной index меньше lowerBound. . Этот образ мышления свойственен программистам на С и C++ с незапамятных времен, и они ожидают такого поведения. Более того, все написанные ими программы рассчитаны на оптимизацию оценки логических выражений. Например, в первом фрагменте кода, приведенном выше, существенно, чтобы функция strlen не могла быть вызвана с нулевым аргументом, потому что, как утверждает
Прешло 7 ЛИШНЕЕ] стандарт языка C++ (и стандарт С тоже), результат вызова функции strlen с нулевым указателем не определен. Язык C++ позволяет программисту модифицировать поведение операторов && и | I для определенных пользователем типов. Это можно сделать, перегрузив функции operator&& и operator I I как глобально, так и внутри некоторых классов. Однако если решите пойти по этому пути, вы должны отдавать себе отчет, что радикально изменяете правила игры, поскольку оптимизирующая семантика меняется на семантику вызовов функций. Это означает, что перегруженный operator&&, который для вас выглядит как if (expression! && expression^ ) ... для компилятора выглядит следующим образом: if (expressionl.operator&&(expression2)) ... //operator&& является функцией-членом. if (operator&k(expressionl,expression2)) ... //operator&& является глобальной функцией. Семантика вызова функции имеет два чрезвычайно важных отличия от оптимизированной семантики оценки логического выражения. Во-первых, при вызове функции вычисляются все ее аргументы, поэтому при вызове функций operator&& и operator I I вычисляются выражения справа и слева от символа оператора. Иными словами, этот вариант делает код менее оптимальным. Во-вторых, спецификация языка не определяет порядок вычисления аргументов функции, поэтому невозможно определить, какое из двух выражений, expressionl или expression2, будет вычислено первым. Это также полностью противоречит оптимизированной оценке логических выражений, при которой аргументы всегда вычисляются слева направо. В результате при перегрузке операторов && или I I не существует способа создать операторы, которые ведут себя ожидаемым и привычным образом. Поэтому операторы && и | I вообще не следует перегружать. Ситуация с оператором-запятая (,) выглядит аналогично, но прежде чем рассматривать его свойства, стоит пояснить, для чего он нужен. Оператор-запятая используется для формирования выражений, и вполне вероятно, что вы встречали его в цикле for. Приведенная ниже функция, например, почти аналогична функции из второго издания классической работы Кернигана и Ричи (Kernighan and Ritchie) «Программирование на языке С» (The С Programming Language, Prentice-Hall, 1988): //Меняет порядок следования симоволов / / в строке s на обратный. voidreverse(char s[]) { for (int i=0, j=strlen (s) -1 ; i , J; ++i, --j) //Авотиоператор-запятая! {
} } В этом примере в заключительной части заголовка цикла i увеличивается, a j уменьшается на 1. Здесь удобно использовать оператор-запятую, потому что в заключительной части заголовка можно поставить только одно выражение; отдельные операторы, меняющие значения i и j, здесь были бы недопустимы. Оператор-запятая подчиняется правилам, аналогичным тем, которые в языке C++ описывают поведение операторов && и I I для встроенных типов. В выражении, содержащем запятую, сначала оценивается часть выражения слева от запятой, затем - справа. За значение всего выражения принимается значение части, стоящей справа от запятой. Таким образом, в выражении в заключительной части цикла for сначала оценивается ++i, затем --j, а результат, возвращаемый как значение всего выражения, равен —j. Если вы захотите написать свой собственный оператор-запятую, вам придется скопировать его поведение, описанное выше. К сожалению, это невозможно. Если operator, не принадлежит к какому-либо классу, вы не получите гарантии, что левое выражение будет оценено раньше, чем правое, потому что оба выражения будут переданы функции operator, в качестве аргументов, а порядок вычисления аргументов при вызове функции контролировать нельзя. Поэтому «внеклассовый» подход исключен. У вас остается единственная возможность- написать operator, как член класса. Но даже здесь нельзя быть твердо уверенным, что левый по отношению к запятой операнд будет вычислен сначала, ведь компиляторы не обязаны обеспечивать такой порядок. Следовательно, вы не можете перегрузить оператор- запя- тую и гарантировать его надлежащее поведение. Поэтому перегружать его было бы неразумно. Наверное, вы интересуетесь, придет ли конец этому перегружаемому безумию. В конце концов, если можно перегрузить даже оператор-запятую, неужели существует что-то, чего перегружать нельзя? Оказывается, да. Нельзя перегружать следующие операторы: . * : : ? : new delete sizeof typeid static_cast dynamic_cast const_cast reinterpret_cast А вот эти операторы перегружать можно: operator new operator delete operator new[] operator delete[] + - * / % Л & I !=<>+=-=*= /= %= Л= &= |= « >> >>= «= == ! = <=>=&& I I ++ - , ->* -> 0 [] Операторы
правило 8 тшшштш\ (Об операторах new и delete, а также об operator new, operator delete, operator new [ ] и operator delete [ ] см. правило 8). Конечно, сама по себе возможность переопределить операторы является недостаточной причиной, чтобы заняться этим немедленно. Цель перегрузки операторов состоит в том, чтобы программы было проще писать, читать и понимать, а не поражать других знанием оператора-запятой. Не следует перегружать оператор, если у вас нет для этого серьезной причины. В случае же операторов &&, | | и , достойный повод вообще трудно найти, потому что, как бы вы ни старались, заставить эти операторы вести себя, как положено, вам не удастся. Правило 8. Различайте значение операторов new и delete Иногда кажется, что люди специально стараются затруднить понимание терминов языка C++. Примером тому может служить разница между операторами new и operator new. В коде: string *ps = new string ("Memory Management") ; используется оператор new. Этот оператор является встроенным, так же как, например, sizeof, и его действие нельзя изменить. Он выполняет две функции. Во- первых, выделяет память, достаточную для размещения объекта указанного типа. В вышеприведенном примере это объект string. Во-вторых, вызывает конструктор для инициализации объекта в выделенной памяти. Оператор new всегда выполняет обе названные функции, и его поведение изменить нельзя. Зато можно изменить способ, которым выделяется память для объекта. Для выделения памяти оператор new вызывает специальную функцию и, чтобы изменить поведение оператора, эту функцию допускается переписать или перегрузить. Имя функции — operator new. Функция operator new обычно объявляется следующим образом: void* operator new(size_t size) ; Тип возвращаемого значения — void*, потому что функция возвращает указатель на неинициализированную память. (По желанию можно написать другую версию функции operator new, записывающую в выделяемую память какое- либо значение, но обычно никто этого не делает.) Аргумент типа size_t указывает размер памяти, которую нужно выделить. Можно перегрузить функцию operator new, добавив дополнительные аргументы, но тип первого аргумента всегда должен быть равен size_t. У программистов очень редко возникает необходимость вызывать функцию operator new непосредственно, но когда это приходится делать, она вызывается, как любая другая функция: void*rawMemory = operator new(sizeof (string) ) ; В этом примере функция operator new вернет указатель на область памяти, достаточно большую для хранения объекта string.
Ill Операторы Единственная задача функции operator new, так же как и оператора malloc, состоит в выделении памяти. О конструкторах она ничего не знает. Оператор new получит неинициализированную память, выделенную функцией operator new, и преобразует ее в объект. При обработке строки программы типа: string *ps = new string ("Memory Management") ; компиляторы создадут код, похожий на: void *memory = //Получить operator new (sizeof(string)); //неинициализированную / / память для объекта //string, call string::string("MemoryManagement") on *memory; //Инициализировать объект // в памяти, string*ps= //Создатьуказатель static_cast<string*>(memory); //на новый объект. Заметьте, что на втором шаге вызывается конструктор. Использовать конструктор для инициализации уже выделенной памяти (включая такой ключевой элемент, как таблица виртуальных функций объекта - см. правило 24) программисту запрещено. В этом смысле компиляторы выше простых смертных, они могут делать, что захотят. Программист же должен использовать для создания динамического объекта оператор new. Буферизованный оператор new Иногда необходимо вызвать конструктор непосредственно. Вызов конструктора для уже существующего объекта особого смысла не имеет, потому что инициализировать объект по начальным данным можно только один раз. Но иногда уже есть область выделенной памяти, и требуется создать в ней объект. Для этого служит специальная форма функции operator new, которая называется буферизованным оператором new. В качестве примера использования данного оператора рассмотрим следующий код: class Widget{ public: Widget(intwidgetsize) ; }; Widget * constructWidgetlnBuf fer (void*buf f er, Intwidgetsize) { return new (buffer) Widget (widgetSize) ; } Эта функция возвращает указатель на объект Widget, который создается в буфере, передаваемом функции в качестве аргумента. Данная функция может
Правило 8 И1ШМШ применяться в приложениях, которые используют разделяемую память или ввод/ вывод с отображением в память, потому что объекты в таких приложениях размещаются по известным адресам памяти или тем, которые выделены при помощи специальных процедур. (Другой пример использования буферизованного оператора new см. в правиле 4.) Функция constructWidgetlnBuf f er возвращает значение: new (buffer)Widget(widgetSize) Приведенное выражение поначалу может показаться вам немного странным, но на самом деле это просто форма оператора new с дополнительным аргументом (buffer), который передается для неявного вызова функции operator new. Здесь operator new помимо обязательного аргумента типа size_t принимает дополнительный параметр типа void*, указывающий на область памяти, в которой должен разместиться создаваемый объект. Эта функция operator new представляет собой буферизованный оператор new, и ее реализация выглядит примерно так: void* operator new(size_t, void*location) { return location; } Возможно, реализация выглядит проще, чем вы предполагали, но это все, что должен сделать буферизованный оператор new. Действительно, основная задача функции operator new состоит в том, чтобы найти область памяти для размещения объекта и вернуть указатель на нее. В случае буферизованного оператора new вызывающий модуль уже знает, где должен быть расположен объект и чему должен быть равен указатель на содержащую его область памяти. Поэтому все, что должен сделать буферизованный оператор new, - это вернуть переданный ему указатель. (Имя неиспользованного (но обязательного) аргумента типа size_t не указано, чтобы компиляторы не генерировали соответствующее предупреждение - см. правило 6.) Буферизованный оператор new является частью стандартной библиотеки C++. Для его использования достаточно добавить директиву #include <new> (или, если ваши компиляторы пока не поддерживают имена файлов заголовков нового типа, <new.h>). Если на минуту отвлечься от буферизованного оператора new, то вы увидите, что взаимоотношения между оператором new и функцией operator new хотя и запутаны терминологически, по своей концепции довольно ясны. Для создания динамического объекта нужно использовать оператор new. Он и выделяет память, и вызывает конструктор объекта. Чтобы выделить память без вызова конструктора, нужно включить в код функцию operator new. Если для создания динамических объектов вам требуется какой-либо специальный механизм выделения памяти, придется написать собственную версию функции operator new. При вызове оператора new эта функция вызывается автоматически. Для создания объекта в области памяти, указатель на которую уже получен, следует использовать буферизованный оператор new.
ВМН1113 Операторы Удаление объектов и освобождение памяти Чтобы избежать утечки ресурсов, каждый выделенный участок памяти нужно после использования освободить. Функция operator delete соотносится со встроенным оператором delete так же, как функция operator new с оператором new. При компиляции следующего примера: string*ps; deleteps; //Использование оператора delete. ваши компиляторы должны создать код не только для удаления объекта, на который указывает переменная ps, но и для освобождения памяти, занимаемой этим объектом. Освобождение памяти производит функция operator delete, обычно объявляемая так: voidoperator delete (void*memory ToBeDeallocated) ; Следовательно, при компиляции deleteps; генерируется код, который примерно соответствует следующему: ps->~string(); //Вызов деструктора объекта, operator delete(ps); // Освободим память,занятую //объектом. Отсюда следует, что если вы хотите работать только с неинициализированной памятью, нельзя работать с операторами new и delete. Вместо этого для выделения памяти следует использовать функцию operator new, а для освобождения памяти - функцию operator delete: void*buffer = //Выделяемпамятьдляхранения operator newE0*sizeof(char)) ; //50 символов; / / конструкторы не вызываем. operator delete(buffer); //Освобождаемпамять; // деструкторы не вызываем. Эти функции языка C++ эквивалентны функциям malloc и free. Если для создания объекта в некоторой области памяти был использован буферизованный оператор new, то для ее освобождения нельзя использовать оператор delete. Это связано с тем, что оператор delete вызывает для освобождения памяти функцию operator delete, но поскольку память, содержащая объект, не была выделена в результате вызова функции operator new; буферизованный оператор new просто вернул указатель, переданный ему в качестве аргумента. Причем неизвестно, откуда взялся этот указатель. Поэтому необходимо отменить действие конструктора явным вызовом деструктора объекта: void * mallocshared(size_t size); // Функции для выделения voidfreeShared(void*memory); //и освобождения областей
Правило 8 HI void *sharedMemory = mallocShared(sizeof(Widget)); //разделяемой памяти. Widget *pw= // Как и в предыдущих примерах, ConstructWidgetlnBuffer(sharedMemory, 10); // используется буферизованный / / оператор new. delete pw; //He определено! sharedMemory // создана функцией mallocShared, / / а не operator new. pw->~Widget(); // Нормально, удаляет // o6beKTWidget, / / на который указывает pw, но не //освобождает память, занятую Widget. freeShared(pw); //Нормально,освобождает память, на / / которую указывает pw, но не вызывает //деструктор. Итак, чтобы предотвратить утечки памяти, неинициализированную память, выделенную динамически (каким-либо специальным способом) и переданную буферизованному оператору new, следует освобождать. Массивы Все рассмотренные выше примеры относились к единичным объектам. А как насчет выделения памяти для массива? Что произойдет, например, здесь? string *ps = new string [10] ; // Создаем массив объектов. Оператор new остался тем же самым, но поскольку создается массив объектов, его поведение слегка меняется. Во-первых, память выделяется не с использованием функции operator new, а при помощи эквивалентной функции для работы с массивами operator new[] (чаще ее называют «функция new для массивов»). Аналогично функции operator new, функцию operator new[] можно перегрузить. Это позволяет контролировать выделение памяти для массивов таким же образом, как и для единичных объектов. Функция operator new [ ] была добавлена в стандарт языка C++ сравнительно недавно, и возможно, что ваши компиляторы ее не поддерживают. Тогда для выделения памяти массиву, независимо от типа его элементов, будет использован глобальный оператор new. Настройка механизма выделения памяти для массивов при этом усложняется, так как требует изменений в глобальной функции operator new. Не следует недооценивать данную задачу. По умолчанию глобальная функция operator new отвечает за все операции выделения памяти в программе, и вмешательство в ее механизм может привести к очень серьезным последствиям. Более того, существует только одна глобальная функция operator new «нормального» вида (то есть имеющая единственный аргумент типа size_t), поэтому если вы перепишете ее, ваше программное обеспечение тут же станет несовместимым с любой библиотекой, использующей эту функцию. (См. также правило 27). Поэтому тем, чьи компиляторы не поддерживают функцию operator new [ ], не стоит пытаться разрабатывать собственные механизмы выделения памяти для массивов.
ШМПН111 Операторы Второе отличие оператора new для массивов от оператора new для единичных объектов - это количество вызываемых конструкторов. Для создания массива необходимо вызвать конструктор для каждого его элемента: string *ps = //Вызываем operator new [ ] для new string[10]; //выделения памяти,способной / / хранить 10 объектов типа //string, затемвызываем / / конструктор по умолчанию для //каждого элемента массива. Аналогично, при вызове оператора delete для массива вызывается сначала деструктор для каждого элемента массива, а затем функция operator delete [ ] для освобождения памяти: delete [] ps; //Вызываем деструктор string / / для каждого элемента массива, // a 3aTeMoperatordelete[] / / для освобождения //памяти, занятой массивом. Так же, как и функция operator delete, функция operator delete [ ] может быть переопределена или перегружена. Однако на код, перегружающий эту функцию, накладываются строгие ограничения, с которыми можно ознакомиться в книгах по языку C++ для профессиональных программистов. (См. список на стр. 287.) Операторы new и delete являются встроенными и не могут быть изменены программистом, но функции, отвечающие за выделение и освобождение памяти, модифицировать допускается. Таким образом, можно попытаться повлиять на то, как операторы new и delete выполняют свои функции, но что они при этом делают, определяется спецификациями языка.
Глава 3. Исключения Введение исключений в стандарт языка C++ совершенно изменило жизнь программистов, хотя, возможно, и не в лучшую сторону. Например, использование простых указателей стало рискованным. Увеличилось количество потенциальных источников утечки ресурсов. Стало труднее разрабатывать конструкторы и деструкторы, которые бы вели себя предсказуемым образом. Приходится принимать специальные меры, чтобы программы не останавливались внезапно во время своей работы. В целом исполняемые модули и библиотеки выросли в размерах и потеряли в производительности. И это только вершина айсберга. Сообщество C++ еще не знает о многих затруднениях, связанных с корректной разработкой программ, использующих механизм исключений. В настоящее время не существует стандартных технологий, применение которых обеспечивает предсказуемость и надежность обработки исключений. (Чтобы получить представление о некоторых проблемах, см. статью Тома Каргилла, ссылка на которую приведена на стр. 285.) Пока известно только следующее: использующие механизм исключений и при этом корректно работающие программы появляются не случайно, а требуют тщательного проектирования. Вероятность того, что не спроектированная специальным образом программа поведет себя нормально при возникновении исключения, примерно равна вероятности того, что не предназначенная для многопоточного исполнения программа будет работать в многопоточной реализации, то есть равна нулю. Если это так, зачем вообще использовать исключения? Программистам на языке С было вполне достаточно кодов ошибок еще со времен изобретения С, зачем же связываться с исключениями, если они несут столько проблем? Причина все-таки есть: исключения нельзя просто проигнорировать. Если функция сообщает о возникновении исключительной ситуации, устанавливая флаг статуса или возвращая код ошибки, то нет никакой гарантии, что модуль, вызвавший функцию, проверит этот флаг или код возврата. В результате работа программы может продолжиться, невзирая на исключительную ситуацию. Если же функция сообщает об опасной ситуации с помощью исключения, а это исключение не обрабатывается, выполнение программы тут же прекращается. Такое поведение на языке С можно смоделировать, используя функции set jmp и longjmp. Будучи перенесена в язык C++, функция longjmp приобрела один очень существенный недостаток: при выравнивании стека она не вызывает деструкторы локальных объектов. Но без вызова этих деструкторов большинство программ на языке C++ просто не будут работать, поэтому set jmp и long j mp вряд ли можно использовать вместо настоящих исключений. Если вам
II Исключения нужен метод, который позволяет отследить возникновение исключительных ситуаций и не позволяет их игнорировать, а также гарантирует вызов локальных деструкторов при просмотре стека в поисках обработчика исключительных ситуаций, то вам просто не обойтись без исключений языка C++. Поскольку вам предстоит еще многое узнать про обработку исключительных ситуаций, последующие правила представляют собой далеко не полное руководство по написанию надежного программного обеспечения. Тем не менее, они содержат важные принципы, полезные всякому, кто хочет использовать исключения в стиле языка C++. Следуя приведенным ниже рекомендациям, вы увеличите корректность, надежность и эффективность ваших программ, избежав многих ловушек, которые обычно возникают при использовании исключений. Правило 9. Чтобы избежать утечки ресурсов, используйте деструкторы Итак, попрощаемся с указателями, хотя, конечно, не со всеми. Однако указатели, используемые, например, для доступа к локальным ресурсам, нам больше не понадобятся. Предположим, что вы пишете программное обеспечение для организации, которая называется «Приют милых маленьких животных» и подыскивает новый дом для щенков и котят. Каждый день в организации создается файл, где перечисляются все случаи передачи питомцев в хорошие руки за день, и ваша задача - написать программу, считывающую этот файл и осуществляющую необходимую обработку данных. Разумнее всего создать базовый класс ALA (Adorable Little Animal - милое маленькое животное) и конкретные производные классы: Puppy для щенков и Kitten для котят (см. рис. 3.1). Виртуальная функция processAdoption производит необходимую обработку данных, зависящую от вида животного: С class ALA { public: virtualvoidprocessAdoption() = 0 ; }; class Puppy: public ALA { public: virtualvoidprocessAdoption() ; };
Правило 9 j|| classKitten: publicALA { public: virtual voidprocessAdoption() ; }; Также потребуется функция, считывающая информацию из файла и создающая, в зависимости от типа записи в нем, либо объект Puppy (щенок), либо объект Kitten (котенок). С этой задачей отлично справится виртуальный конструктор, функция, описанная в правиле 25. Здесь достаточно привести только ее объявление: // Считываем информацию о животном из s, затем возвращаем //указатель на только что созданный объект подходящего типа. ALA * readALA(istreamk s) ; Скорее всего, центральным местом вашей программы будет примерно такая функция: voidprocessAdoptions(istream&dataSource) { while (dataSource) .{ //Пока есть данные, ALA*pa = readALA(dataSource); //считываем //следующую запись / / о животном. pa->processAdoption() ; // Обработываем сведения //опередаче животного, deletepa; //Удаляемобъект,который } //вернула функция readALA. } Эта функция последовательно считывает информацию из потока data- Source и последовательно же обрабатывает каждую запись. Единственная тонкость состоит в том, чтобы не забыть удалять объект ра в конце каждой итерации цикла (это необходимо, поскольку функция readALA создает при каждом вызове новый динамический объект). В противном случае возникнет утечка ресурсов. Теперь посмотрим, что случится, если во время выполнения функции pa->processAdoption возникнет исключение. Функция processAdoptions не содержит нужного обработчика, поэтому исключение распространится в модуль, вызвавший processAdoptions. В результате все операторы в теле processAdoptions, находящиеся там после вызова pa->processAdopt ion, будут пропущены, и объект ра не удалится. Таким образом, утечка ресурсов будет происходить каждый раз, когдаpa->processAdoption сгенерирует исключение. Прекратить утечку несложно: voidprocessAdoptions(istream&datasource) { while (dataSource) { ALA *pa = readALA(dataSource); try { pa->processAdoption(); }
ЕЕММИ1! catch(...) { delete pa; throw; } delete pa; } } Однако при этом текст программы засоряется блоками try и catch. Кроме того, приходится дублировать завершающий код, общий для нормального и аварийного выполнения программы. Как и при каждом дублировании, этот код неприятно писать и трудно поддерживать, но, что еще хуже, появляется мысль о его ошибочности. Ведь удалять объект ра нужно независимо от того, нормальным или аварийным путем вы покидаете функцию processAdoptions, зачем же делать это в нескольких местах? Повтора можно было бы избежать, если бы удалось каким-либо образом поместить завершающий код в деструктор локального по отношению к process- Adoptions объекта. Локальные объекты при выходе из функции удаляются всегда, независимо от причины выхода. (Единственное исключение из данного правила - вызов longjmp, и именно этот недостаток longjmp является основной причиной, по которой язык C++ вообще поддерживает исключения.) Итак, ваша задача состоит в том, чтобы перенести оператор delete из функции process-Adopt ions в деструктор локального для функции processAdoptions объекта. Для решения задачи необходимо заменить указатель ра на объект, который действует как указатель. Тогда при удалении (автоматическом) этого объекта, похожего на указатель, можно заставить его деструктор вызвать delete. Объекты, которые похожи на указатели, но таковыми не являются, называют smart-указателями (интеллектуальными указателями - подробнее о них см. в правиле 28). В данном случае не нужен особенно «умный» указатель; требуется только похожий на указатель объект, умеющий удалять объект, на который указывает, когда покидает область видимости. Самостоятельно создавать класс для таких объектов несложно, но в этом и нет особой нужды. Стандартная библиотека языка C++ содержит шаблон классов с именем auto_ptr, который делает все необходимое. Каждый класс auto_ptr принимает указатель на динамический объект в качестве аргумента конструктора и удаляет данный объект в деструкторе. Если ограничиться этими важными функциями, то реализация auto_ptr выглядит следующим образом: template<class T> class auto_ptr { public: auto_ptr(T*p = 0) :ptr(p) {} // Сохраняемуказательptr / / на объект. Исключения // Перехватываем все исключения. //Устраняемутечку ресурсов //при возбуждении исключения. //Передаемисключение вызывающему //модулю. // Устраняемутечку ресурсов / / в отсутствие исключительных //ситуаций.
Правило 9 illMHEI] ~auto_ptr() {deleteptr; } //Удаляемуказатель // на объект, private: Т *ptr; //Неиницализированный / / указатель на объект. }; Стандартная версия класса auto_ptr выглядит гораздо более изощренно, а приведенная усеченная реализация не годится для практического использования*. Для этого к ней следует добавить, по крайней мере, конструктор копирования, оператор присваивания и функции, эмулирующие поведение указателя (см. правило 28). Однако основная идея ясна: если использовать объекты auto_ptr вместо обычных указателей, то даже при возникновении исключений не нужно будет беспокоиться о неудаленных динамических объектах. Из-за того, что деструктору auto_ptr соответствует оператор delete для единичных объектов, объекты auto_ptr не могут применяться вместо указателей на массивы элементов. Шаблон, выполняющий функции auto_ptr для массивов, вам придется разработать самостоятельно. Вообще же говоря, в таких случаях более удачным конструктивным решением является использование вместо массива объекта типа vector. При замене указателя на объект auto_ptr функция processAdoptions приобретет следующий вид: void processAdoptions (istream&dataSource) { while(dataSource){ auto_ptr<ALA>pa(readALA(dataSource)) ; pa->processAdoption(); } } Эта версия функции processAdoptions имеет два отличия от предыдущей. Во-первых, ра объявлен как объект типа auto_ptr<ALA>, а не как указатель на ALA*. Во-вторых, в конце тела цикла отсутствует оператор delete. Все остальное не изменилось, потому что, за исключением удаления, объекты auto_ptr ведут себя как обычные указатели. Просто, не правда ли? Идея, лежащая в основе auto_ptr - использовать объект для хранения ресурсов, которые должны освобождаться автоматически, и возложить функцию освобождения на деструктор этого объекта, - может применяться не только для указателей. Рассмотрим функцию визуального приложения, создающую окно для отображения некоторой информации: / / Эта функция может порождать утечку ресурсов // при возбуждении исключения, voiddisplaylnfo(constInformationk info) { * Полная реализация почти стандартного шаблона auto-ptr приведена на стр. 289-292.
¦11 Исключения ИЗвдшь. WINDOW_HANDLEw(createWindow()) ; здесь отображаем информацию в окне с дескриптором w; destroyWindow(w); } Многие оконные системы имеют интерфейс, подобный тому, что создается с помощью языка С. Такой интерфейс использует функции типа createWindow и releaseWindow для захвата и освобождения ресурсов окна. Если исключение возникнет в процессе отображения данных info в окне w, то ресурсы этого окна будут потеряны так же, как и любые другие динамически выделяемые ресурсы. Решение проблемы остается прежним. Надо создать класс, конструктор и деструктор которого захватывают и освобождают необходимый ресурс: / / Класс для захвата и освобождения дескриптора окна. classWindowHandle{ public: WindowHandle(WINDOW_HANDLEhandle) :w(handle) {} -WindowHandle() { destroyWindow(w) ; } operatorWINDOW_HANDLE() {returnw; } //См. ниже, private: WINDOW_HANDLEw; // Нижеприведенные функции объявлены как private, чтобы / / запретить создание нескольких копий WINDOW_HANDLE. / / См. правило 2 8 , где описан более гибкий подход. WindowHandle(const WindowHandlek); WindowHandlekoperator=(constWindowHandlek) ; }; Все это очень похоже на шаблон auto_ptr, но с тем отличием, что операторы присваивания и копирования явно запрещены, а для преобразования WindowHandle в WINDOW_HANDLE определен оператор неявного преобразования типа. Этот оператор имеет серьезное практическое значение, потому что теперь везде, где раньше использовался обычный WIND0W_HANDLE, можно вместо него включать в код WindowHandle. (Как вы помните, с операторами неявного преобразования типа нужно обращаться очень осторожно - см. правило 5.) С помощью класса WindowHandle можно переписать функцию display Info следующим образом: // Эта функция предотвращает утечку ресурсов // при возникновенииисключения. voiddisplaylnfo(constInformationk info) { WindowHandlew(createWindow()) ; отображаем данные info в окне w; } Даже при возникновении исключения в теле функции displayInf о окно, созданное функцией createWindow, будет удалено. Итак, даже используя исключения, можно избежать утечек ресурсов, если следовать правилу размещения ресурсов внутри объектов. Но что произойдет, если
Правило 10 ||Ц исключение возникнет в тот момент, когда программа как раз находится в процессе захвата ресурса, то есть внутри конструктора класса, который требует выделения ресурсов? А что произойдет, если исключение будет возбуждено во время автоматического освобождения таких ресурсов? Может быть, конструкторы и деструкторы требуют специального обращения? Ответам на эти вопросы посвящены правила 10 и 11. Правило 10. Не допускайте утечки ресурсов в конструкторах Представьте, что вы разрабатываете программу для мультимедийной адресной книги. Наряду с обычной текстовой информацией: полным именем, адресом и телефонным номером - книга могла бы содержать фотографии людей и образцы их речи (например, в виде правильного произношения имени). Ваша программа могла бы иметь следующий вид: class Image { //Изображение. public: Image(const stringk imageDataFileName); }; classAudioClip{ //Звуковые данные, public: AudioClip (const string&audioDataFileName) ; }; class PhoneNumber{...}; //Телефонныйномер, class BookEntry { / / Единичная запись в адресной public: //книге. BookEntry(const string&name, const stringk address = "", const stringk imageFileName = "", const string& audioClipFileName = ""); -BookEntry(); / / Эта функция добавляет / / телефонный номер. voidaddPhoneNumber(const PhoneNumberknumber); private: stringtheName; //Полноеимячеловека. stringtheAddress; //Адрес. list<PhoneNumber> thePhones; //Телефонные номера. Image*thelmage; //Изображение. AudioClip *theAudioClip; //Аудиоклип. }; Каждая запись BookEntry должна содержать полное имя человека. Следовательно, это обязательный аргумент конструктора (см. правило 4), а все остальные
¦¦¦¦Ill Исключения данные - адрес человека, имена файлов, содержащих видео- и аудиоинформацию - не обязательны. Обратите внимание на использование класса list для хранения телефонных номеров, который является одним из контейнерных классов стандартной библиотеки языка C++ (см. правило 35). Простейший вариант конструктора BookEntry выглядит так: BookEntry: : BookEntry (const string&name, const stringk address = "", const stringk imageFileName = "", const stringk audioClipFileName = "") : theName(name),theAddress(address), thelmage(O),theAudioClip@) { if (imagefileName!= "") { thelmage = new Image (imageFileName) ; } if(audioClipFileName!= "") { theAudioClip = new AudioClip (audioClipFileName) ; } } BookEntry::-BookEntry() { delete thelmage ; delete theAudioClip; } Конструктор обнуляет указатели thelmage и theAudioClip, а затем, если соответствующие аргументы содержат нормальные имена файлов, создает для них реальные объекты. Деструктор удаляет эти объекты, не допуская тем самым утечек памяти в объекте BookEntry. Язык C++ гарантирует безопасность удаления нулевых указателей, поэтому деструктор BookEntry не проверяет, ссылаются ли указатели на реальные объекты. Пока все выглядит хорошо, и при нормальных условиях все бы прекрасно работало, но при исключительных ситуациях появятся некоторые проблемы. Посмотрим, что произойдет, если исключение возникнет во время выполнения следующей части конструктора BookEntry: if (audioClipFileName!= "") { theAudioClip = new AudioClip(audioClipFileName); } Исключение может возникнуть, например, потому что operator new (см. правило 8) не может выделить достаточно памяти для объекта AudioClip. Оно может также возникнуть в самом конструкторе AudioClip. Но, независимо от причины появления, исключение, возникшее в конструкторе BookEntry, распространится в точку, где создается объект BookEntry. Итак, если исключение возникает во время создания объекта, на который должен указывать theAudioClip (передавая управление в точку, внешнюю по
Правило 10 (Щ отношению к конструктору BookEntry), что удалит объект, на который уже указывает the Image? Ответ, как кажется, очевиден - деструктор BookEntry, но это неверно. Деструктор BookEntry не будет вызван никогда. В языке C++ удаляются только полностью сконструированные объекты, то есть такие, конструкторы которых уже завершили выполнение кода. Тем самым, если объект b типа BookEntry создается как локальный объект: void testBookEntryClass() { BookEntryb( "Addison-Wesley Publishing Company", "OneJacobWay,Reading,MA 01867"); } и в процессе создания Ь возникает исключение, то деструктор для объекта b не будет вызван. Можно попытаться взять контроль в свои руки и выделить память для b динамически, а затем, при возникновении исключения, вызывать оператор delete: voidtestBookEntryClass() { BookEntry *pb= 0,¦ try { pb = new BookEnt ry b ( "Addison-Wesley Publishing Company", "OneJacobWay, Reading, MA01867") ; } catch (... ) { // Перехватываем все исключения. delete pb; // Удаляем pb при появлении //исключения, throw; //Передаемобработку исключения //вызывающемумодулю. } deletepb; //УдаляемрЬобычнымобразом. } Тем не менее, объект Image, созданный в конструкторе BookEntry, будет потерян, потому что присваивание pb произойдет не раньше, чем успешно закончит работу оператор new. Если исключение возникнет внутри конструктора BookEntry, то значение pb останется равным нулю, поэтому его удаление в блоке catch не вызовет никаких действий. Использование вместо BookEntry* smart-указателей, а именно класса auto_ptr<BookEntry> (см. правило 9), также не даст результата, потому что присваивание pb все равно не произойдет по той же причине. Конечно, деструкторы для не полностью построенных объектов не вызываются в языке C++ вовсе не потому, чтобы усложнить жизнь программистам. Во многих случаях подобные действия не только не имели бы никакого смысла, но 3-679
¦¦¦¦Ill Исключения и были бы потенциально опасными. Если бы деструкторы вызывались для объектов, создание которых не завершено, как деструкторы могли бы определить, что им делать? Единственный выход состоял бы в том, чтобы снабдить объекты статусными битами, показывающими, насколько продвинулось выполнение конструктора. Но это увеличило бы размеры объектов и замедлило выполнение конструкторов. Примененный в языке C++ подход позволяет избежать такой дополнительной работы, но платить приходится тем, что частично сконструированные объекты не удаляются автоматически. Поскольку в языке C++ не освобождаются ресурсы, выделенные объектам, во время создания которых возникают исключения, то необходимо проектировать конструкторы так, чтобы они делали это сами. Часто бывает достаточно просто перехватить все возможные исключения, выполнить код завершения, а затем передать исключение для дальнейшей обработки. Для объекта BookEntry это может выглядеть следующим образом: BookEntry: : BookEntry (const strings name, const strings address = "", const strings imageFileName = "" , const strings audioClipFileName = "") :theName(name),theAddress(address) , thelmage(O) , theAudioClip(O) { try { // Это новый блок try. if (imageFileName!= "") { thelmage = new Image (imageFileName) ; } if (audioClipFileName != "") { theAudioClip= new AudioClip(audioClipFileName); } } catch(...){ //Перехватываем все исключения, delete theImage; //Выполняемнеобходимые delete theAudioClip; // операции очистки, throw; //Вызываем исключение / / для дальнейшей обработки . } } Не следует волноваться по поводу элементов-данных, не являющихся указателями. Они инициализируются автоматически перед вызовом конструктора класса, поэтому, когда конструктор BookEntry начинает выполняться, элементы данных объекта theName, theAddress и thePhones уже полностью созданы. Значит, они будут удалены одновременно с объектом типа BookEntry, которому принадлежат, и вмешательство программиста здесь не нужно. Разумеется, если конструкторы этих объектов вызывают функции, способные привести к исключениям, то такие конструкторы должны побеспокоиться о перехвате исключений и выполнении операций очистки перед тем, как передать исключения далее.
Правило 10 ПН IHfa Можно заметить, что код в блоке catch конструктора BookEntry почти совпадает с кодом в деструкторе BookEntry. He стоит дублировать код ни в этом месте программы, ни где бы то ни было еще, поэтому чтобы улучшить программу, стоит поместить общий код в закрытую функцию и вызывать ее из конструктора и деструктора: class BookEntry { public: ... / / Без изменений. private: voidcleanup() ; //Общиеоперацииочистки. }; voidBookEntry::cleanup() { delete thelmage; delete theAudioClip; } BookEntry: : BookEntry (const string& name, const stringk address = "", const stringk imageFileName = "", const string&audioClipFileName= "") : theName(name) , theAddress(address) , thelmage@),theAudioClip@) { try { //Без изменений. } catch (...) { cleanup(); //Освобождаемресурсы, throw; //Передаемисключение / / для дальнейшей обработки. } } BookEntry::-BookEntry() { cleanup(); } Все это хорошо, но кое-какие проблемы еще остаются. Переделаем класс BookEntry таким образом, чтобы thelmage и theAudioClip стали константными указателями: classBookEntry { public: private: // Как и ранее. ,Image * const thelmage; // Теперь указатели имеют AudioClip * const theAudioClip; // атрибут const. }; 3*
ЕЁШММШ Исключения Указатели данного типа необходимо инициализировать с помощью списков инициализации в конструкторе BookEntry, потому что другого способа сделать это для указателей с атрибутом const нет. Очень часто возникает искушение инициализировать thelmage и theAudioClip следующим образом: // Реализация, которая может привести к утечке ресурсов // при возникновенииисключения. BookEntry: : BookEntry (const strings name, const strings address = "", const strings imageFileName= "", const strings audioClipFileName = "") :theName(name),theAddress(address) , thelmage(imageFileName!= "" ? new Iamge (imageFileName) ' : 0), theAudioClip(audioClipFileName != "" ? new AudioClip(audioClipFileName) : 0) {} Но это снова приводит к начальной проблеме: если исключение возникает во время инициализации theAudioClip, объект, на который указывает thelmage, не будет удален никогда. Более того, нельзя решить проблему, добавив в конструктор блоки try и catch, потому что try и catch являются операторами, а список инициализации элементов может включать только выражения. (В частности поэтому при инициализации указателей thelmage и theAudioClip пришлось использовать символы ? : вместо конструкции if-then-else.) Единственный способ выполнить код завершения перед тем, как обработка исключения выйдет за пределы конструктора, - перехватить эти исключения. Поэтому, если нельзя разместить блоки try и catch в списке инициализации, значит, придется разместить их где-то еще. Например, внутри внутренних функций, возвращающих указатели, значениями которых инициализируются объекты thelmage и theAudioClip: class BookEntry { public: ... //Без изменений, private: ... // Элементы данных без изменений. Image * initlmage (const strings imageFileName) ; AudioClip * initAudioClip (const strings AudioClipFileName); }; BookEntry : : BookEntry (const strings name , const strings address = "", const strings imageFileName = "" , const strings audioClipFileName = "")
Правило 10 HI : theName(name) , theAddress(address) , thelmage(initlmage(imageFileName)), theAudioClip(initAudioClip(audioClipFileName)) {} // Объект thelmage инициализируется первым, следовательно, не стоит // волноваться об утечке ресурсов, если инициализация / / окончится неудачно. Поэтому данная функция // не обрабатывает исключения. Image * BookEntry: : initlmage (const strings imageFileName) { if (imageFileName != "") return new Image (imageFileName) ; else return 0; } // Объект theAudioClip инициализируется вторым. // Значит, необходимо принять меры по освобождению ресурсов, // выделенных объекту thelmage, // если во время инициализации / / theAudioClip возникнет исключение. // Поэтому функция содержит блоки try и catch. AudioClip * BookEntry::initAudioClip(const strings AudioClipFileName) { try { if (audioClipFileName != "") { return new AudioClip(audioClipFileName) } else return 0; } catch (...) { delete thelmage; throw; } } Этот фрагмент программы вполне работоспособен, и в нем даже решена главная проблема. Недостаток такого подхода состоит в том, что код, концептуально принадлежащий конструктору, оказался разбросанным по нескольким функциям, осложняя поддержку подобного программного обеспечения. Более грамотно будет принять рекомендацию правила 9 и считать объекты, на которые указывают thelmage и theAudioClip, ресурсами, управляемыми с помощью локальных объектов. Можно использовать то обстоятельство, что thelmage и theAudioClip являются указателями на динамически построенные объекты и при удалении указателей также должны быть удалены. Это именно те условия, для которых были сконструированы классы auto_ptr (см. правило 9). Следовательно, можно заменить обычные указатели типов thelmage и theAudioClip на их эквиваленты типа auto_ptr:
IHill Исключения class BookEntry { public: private: // Без изменений. constauto_ptr<Image> thelmage; //Теперь это const auto_ptr<AudioClip>theAudioClip; //объекты //типа auto_ptr. }; Такой подход предотвращает утечку ресурсов в конструкторе BookEntry при возникновении исключений и позволяет инициализировать объекты thelmage и theAudioClip в списке инициализации: BookEntry: : BookEntry (const strings name, const strings address = "", const strings imageFileName = "", const strings audioClipFileName = "") : theName(name) , theAddress(address) , thelmage(imageFileName ! = "" ? new Image (imageFi leName) :0), theAudioClip(audioClipFileName != "" ? new AudioClip(audioClipFileName) : 0) {} Если исключение возникнет во время инициализации объекта theAudioClip, то объект thelmage уже будет полностью сконструирован, а значит, и автоматически удален наряду с объектами theName, theAddress и thePhones. Более того, поскольку теперь thelmage и theAudioClip являются объектами, они будут уничтожены в момент удаления включающего их объекта BookEntry. Следовательно, нет необходимости вручную удалять объекты, на которые они указывают. Это значительно упрощает деструктор BookEntry: BookEntry::-BookEntry() {} //Ничего не надо делать! Таким образом, деструктор BookEntry можно вообще не создавать. Все вышеизложенное сводится к следующему: если заменить указатели на соответствующие объекты auto_ptr, то снижается риск утечки ресурсов при возникновении исключений и исчезает необходимость освобождать ресурсы в деструкторах. Кроме того, члены-указатели с атрибутом const обрабатываются так же изящно, как и указатели без этого атрибута. Обработка исключения во время функционирования конструктора может оказаться не простым делом, но использование объектов auto_ptr (и классов типа auto_ptr) существенно облегчает задачу. Их применение позволяет создать простой для понимания и надежный код.
пр^и/ю^^^ _____ Ш1МШЕ] Правило 11. Не распространяйте обработку исключении за пределы деструктора Деструктор может быть вызван в двух ситуациях. Во-первых, при нормальных условиях, то есть когда объект покидает область видимости или явно вызван оператор delete. Во-вторых - когда объект удаляется механизмом обработки исключений во время выравнивания стека. Таким образом, в момент вызова деструктора исключение уже может быть сгенерировано, а может и не быть. К сожалению, при исполнении кода деструктора определить нельзя*. Поэтому приходится писать деструкторы, исходя из пессимистического предположения, что исключение уже возникло; ведь если деструктор при уже имеющемся исключении также генерирует исключение и управление передается в вызывающий модуль, то C++ запускает функцию terminate. Действие этой функции полностью соответствует ее имени: она завершает выполнение программы. Более того, выполнение программы заканчивается немедленно, не удаляются даже локальные объекты. Рассмотрим в качестве примера класс Session, обеспечивающий обработку подключения к компьютерной сети, то есть событий, которые происходят между моментом подключения и отключения. Каждый объект Session регистрирует время своего создания и удаления: classSession { public: Session(); -Session(); private: static void logCreat ion (Session *objAddr) ; static void logDestruct ion (Session *objAddr) ; }; Функции logCreation и logDestruction используются для регистрации создания и удаления объекта соответственно. Можно предположить, что деструктор Session будет иметь вид: Session::-Session() { logDestruction(this); }; Посмотрите теперь, что произойдет, если функция logDestruction сгенерирует исключение. Оно не будет перехвачено в деструкторе Session, а передастся * В июле 1995 года комитет стандартов по C++ ISO/ANSI добавил в спецификацию языка функцию uncaught_exception, возвращающую true, если исключение возникло, но не было обработано.
¦ННМ111 Исключения модулю, вызвавшему деструктор. Но если сам деструктор был вызван в результате обработки другого исключения, то функция terminate вызывается автоматически, и ваша программа окажется полностью разрушенной. Не думаю, чтобы вы этого хотели. Конечно, невозможность запротоколировать уничтожение объекта Session - серьезный недостаток, и иногда он вызывает значительные неудобства, но ничего особенно ужасного в остановке программы нет. Поэтому достаточно принять меры, чтобы исключение, возникшее в logDe- s t rue t i on, не покинуло тело деструктора S e s s i on. Этого можно добиться только с помощью блоков try и catch. Первый вариант будет иметь, например, такой вид: Session::~Session() { try { logDestruction(this); } catch (...) { cerr<< "Немогу запротоколировать уничтожение объекта Session, " « "расположенного по адресу" «this «".\п"; } } Однако вряд ли этот код безопаснее предыдущего варианта. Если какой-либо вызов оператора << в блоке catch приведет к генерации исключения, оно опять покинет деструктор, и вы снова окажетесь там, откуда начали. Вы легко можете разместить блок try внутри блока catch, но это решение кажется уж слишком хитроумным. Лучше не протоколировать удаление деструктора Session, если функция logDestruction генерирует исключение: Session::~Session() { try{ logDestruction(this) ; } catch (...) {} } He заметно, чтобы блок catch выполнял какие-либо действия, но видимость часто обманчива. Данный блок не позволяет исключениям, сгенерированным в теле logDestruction, выходить за пределы деструктора Session, а именно это и требуется. Теперь есть уверенность, что если объект Session удаляется в процессе выравнивания стека, функция terminate вызвана не будет. Существует еще одна причина, по которой не стоит допускать выход исключений за пределы деструктора. Если исключение возникает в теле деструктора и не перехвачено там же, то его выполнение останется незавершенным. (Выполнение остановится в точке генерации исключения.) Незавершенный деструктор не
Правило 12 ЕгНВЁВ выполнит своих функций до конца. Взгляните, например, на измененную версию класса Session, в которой начало сессии запускает транзакцию в базе данных, а завершение сессии эту транзакцию закрывает: Session::Session() // Для упрощения этот { //конструктор не //обрабатывает исключения. logCreation(this); startTransaction() ; // Начнемтранзакцию //в базе данных. } Session::~Session() { logDestruction(this) ; endTransaction() ; //Завершимтранзакцию //в базе данных. } В этом варианте, если функция logDestruction генерирует исключение, то транзакция, открытая в конструкторе Session, никогда не будет завершена. Возможный вариант действий - попытаться изменить порядок вызова функций в деструкторе Session, но если исключение способно возникнуть и внутри endTransaction, то обязательно надо использовать блоки try и catch. Итак, есть две серьезные причины не допускать распространение исключений за пределы деструкторов. Во-первых, это предотвращает вызов функции terminate во время процедуры выравнивания стека. Во-вторых, гарантирует, что деструкторы полностью выполнили свои функции. Каждый из названных аргументов убедителен сам по себе, но вместе они не оставляют и тени сомнении. Правило 12. Отличайте генерацию исключения от передачи параметра или вызова виртуальной функции Объявление списка параметров функции синтаксически очень сходно с аргументом блока catch: class Widget{ ... }; //Некий класс; неважно, / / что он делает. void f I (Widget w) ; / / Все эти функции имеют аргументы void f 2 (Widget&w) ; //типов Widget, Widgeta- void f3(const Widget&w) ; // илиWidget*. void f 4 (Widget *pw) ; void f 5 (const Widget *pw) ; catch (Widget w) ... // Все эти catch-блоки catch(Widget&w) ... //перехватывают исключения типов catch (const Widget&w) ... // Widget, Widget&nraWidget* . catch (Widget *pw) . . . catch (const Widget *pw) . . .
внмми Можно было бы предположить, что передача исключения от точки throw в точку catch происходит по механизму, в общих чертах совпадающему с механизмом передачи аргумента функции из точки ее вызова в ее тело. Однако есть и существенные отличия. Начнем со сходства. И аргументы функций, и исключения можно передавать тремя способами: по значению, по ссылке и при помощи указателя. Но последствия этих вариантов передачи различаются довольно серьезно. Причина в том, что после вызова функции управление в конечном итоге вернется в вызывающий модуль (по крайней мере, если в функции нет ошибок), но при генерации исключения управление не возвращается в модуль, содержащий оператор throw. Рассмотрим функцию, которая и передает тип Widget в качестве параметра, и генерирует его как исключение: / / Функция читает значение Widget из потока. istreamoperator>> (istreamk s, Widget&w) ; voidpassAndThrowWidget() { WidgetlocalWidget; cin>>localWidget; //ПередаетlocalWidget в качестве //параметра оператору >>. throwlocalWidget; //ГенерируетlocalWidget //как исключение. } Когда объект localWidget передается в operator», сам localWidget не копируется. Вместо этого ссылка w в теле operator» оказывается связанной с объектом localWidget, и все, что происходит со ссылкой w, на самом деле происходит с объектом localWidget. Если же localWidget генерируется в качестве исключения, то события развиваются совсем иначе. Независимо от того, перехватывается ли исключение по ссылке или по значению (оно не может перехватываться по указателю - это привело бы к ошибке несоответствия типов), создается копия localWidget, которая передается в качестве аргумента блоку catch. Так и должно быть, потому что localWidget покинет область видимости, как только управление покинет функцию passAndThrowWidget, и тогда будет вызван деструктор localWidget. Если бы в блок catch передавался сам localWidget, то catch получал бы полуразрушенный объект типа Widget или нечто, что было когда-то объектом типа Widget. Это было бы бесполезно, и спецификация C++ определяет, что объект, генерируемый в качестве исключения, всегда копируется. Такое копирование происходит даже тогда, когда нет опасности, что объект может быть уничтожен. Например, если объект localWidget в функции passAndThrowWidget объявлен с атрибутом static: voidpassAndThrowWidget() { static Widget localWidget; // Теперь это статический //объект, онбудет Исключения
Правило 12 cin >>localWidget; throwlocalWidget ; //существовать до конца //программы. / / Данная часть работает //по-прежнему. / / По-прежнему создается //копияlocalWidget. } для генерации исключения по-прелснему будет использована копия объекта localWidget. Это означает, что даже если исключение будет перехвачено по ссылке, блок catch сможет изменить не сам объект localWidget, а только его копию. Такое обязательное копирование объектов-исключений поможет вам понять и другое отличие между передачей параметров и генерацией исключений: последнее обычно выполняется намного медленнее (см. правило 15). Когда объект копируется для использования в качестве исключения, данная операция производится с помощью конструктора копирования. Это - единственный конструктор в классе, соответствующий не динамическому, а статическому типу объекта. Посмотрим, например, на слегка измененную версию функции passAndThrowWidget: class Widget{...}; class SpecialWidget: public Widget{...); voidpassAndThrowWidget() { SpecialWidgetlocalSpecialWidget; Widget& rw =localSpecialWidget; throw rw; / / rw - это ссылка // на объектSpecialWidget. //Генерируется / / исключение типа //Widget! В этом примере исключение имеет тип Widget, хотя rw является ссылкой на SpecialWidget. Так происходит потому, что статический тип rw равен Widget, а не SpecailWidget. В действительности же rw ссылается на SpecialWidget, однако ваши компиляторы данный факт не воспринимают, они видят только статический тип rw. Возможно, такое поведение не совпадает с желаемым, но оно согласуется со всеми другими случаями копирования объектов в C++. Копирование всегда основано на статическом типе объекта (кроме метода, описанного в правиле 25 и позволяющего осуществлять копирование на основе динамического типа объекта). То обстоятельство, что исключения являются копиями других объектов, влияет на их распространение за пределы блоков catch. Посмотрите на эти два блока catch, которые на первый взгляд выполняют одни и те же операции: catch (Widget&w) { / / Перехватываем исключение типа Widget . / / Обрабатываем исключение .
Ill Исключения throw; //Повторно генерируем исключение для //последующей обработки. } catch(Widget&w) //Перехватываемисключение типа Widget. { ... //Обрабатываемисключение. throw w; / / Генерируем копию перехваченного / / исключения для последующей обработки. } Как видите, первый блок повторно генерирует перехваченное исключение, а второй - создает его новую копию. Очевидно, что во втором варианте дополнительная операция копирования снижает производительность программы. Но существуют ли другие различия между этими двумя подходами? Да, существуют. Первый блок повторно генерирует перехваченное исключение независимо от его типа. В частности, если было перехвачено исключение типа SpecialWidget, то первый блок повторно сгенерирует исключение типа SpecialWidget, хотя статический тип w и равен Widget. Это происходит потому, что при повторной генерации копия не создается. Второй блок catch генерирует новое исключение, которое всегда будет иметь тип Widget из-за статического типа w. Вообще говоря, желательно использовать throw; для повторной генерации исключения, чтобы гарантировать неизменность типа. Кроме того, это оптимальнее с точки зрения эффективности, потому что программе не приходится создавать новый объект-исключение. (Кстати, копия исключения является временным объектом. Как показано в правиле 19, это позволяет компиляторам при оптимизации генерируемого кода уклоняться от создания копий. Тем не менее, не стоит всецело полагаться на компиляторы. Исключения используются довольно редко, поэтому маловероятно, что производители компиляторов будут прикладывать много усилий для оптимизации обработки исключений). Рассмотрим три типа аргументов catch, с помощью которых можно перехватить исключение Widget, сгенерированное функцией passAndThrowWidget: catch (Widget w) ... // Перехват исключения по значению, catch (Widget&w) ... //Перехват исключения по ссылке, catch(const Widget&w) ... //Перехватисключения по ссылке //на const. Этот фрагмент демонстрирует еще одно различие между передачей параметров и распространением исключений. Генерируемый объект-исключение (который, как пояснено выше, всегда является временным) может быть перехвачен по простой ссылке, ссылку на const можно не использовать. Передача временного объекта по ссылке без атрибута const запрещена при вызове функции (см. правило 19) и разрешена для исключений. Не будем пока обращать внимание на это различие и вернемся к копированию объектов-исключений. Известно, что при передаче функции аргумента по
Правило 12 ШШШШЯЕП значению создается копия передаваемого объекта, которая и передается функции. То же самое происходит при передаче исключения по значению. Таким образом, если определить аргумент catch следующим образом: catch (Widget w) ... //Перехват по значению. придется создавать две копии объекта-исключения: одна для временного объекта, генерируемого для всех исключений, и вторая - для помещения этого временного значения в w. Аналогично, при перехвате исключения по ссылке: catch (Widget&w) //Перехватпоссылке. catch(const Widget&w) ... //Такжеперехватпоссылке. придется учитывать создание копии исключения, необходимой для временного объекта. Если же функция передается аргументу по ссылке, копирования не происходит. Таким образом, при генерации исключения приходится создавать (и затем удалять) на одну копию объекта-исключения больше, чем при передаче этого объекта в качестве аргумента функции. До сих пор не рассматривалась генерация исключения через указатель, но этот случай эквивалентен передаче аргумента через указатель. И в том, и в другом случае передается копия указателя. Главное не использовать указатель для исключения, которое было создано как локальный объект, потому что локальный объект будет удален, когда исключение покинет его область видимости. В этом случае блоку catch передается указатель на уже удаленный объект. Именно для таких ситуаций и придумано правило обязательного копирования. То, что жизненный цикл объектов равен времени от точки вызова функции или оператора throw до передачи аргументов или блока catch, составляет первое отличие передачи параметров от распространения исключений. Второе отличие состоит в правилах сопоставления типа, которое осуществляют, с одной стороны, вызывающий модуль или генератор исключения, а с другой - вызываемая функция или перехватчик исключения. Взгляните на функцию sqrt из стандартной математической библиотеки: double sqrt (double) ; // Функция из <cmath.h>iwiM<math.h> . Квадратный корень из целого числа можно извлечь следующим образом: int i ; double sqrtOf i = sqrt (i) ; Здесь нет ничего удивительного. Язык допускает неявное преобразование из типа int в double, поэтому при вызове функции sqrt переменная i по умолчанию преобразуется к типу double и результат выполнения sqrt относится к этому параметру double. (См. правило 5, где более полно обсуждаются операции неявного преобразования типа). Но, как правило, при соотнесении типа исключения с типом аргумента catch такие преобразования не выполняются. В следующем примере: voidf(intvalue) {
¦ II try { if (someFunction()) { throw value; } Исключения //Если // someFunction() //возвращает true, //генерируем / / исключение типа //int. } catch(doubled){ //Здесьдолжны обрабатываться / / исключения типа double. } возникшее в блоке try исключение типа int никогда не будет перехвачено оператором catch, который требует аргумент типа double. Этот оператор перехватывает только исключения типа double, никакие преобразования типа не производятся. Следовательно, если нужно перехватить исключение типа int, то необходимо использовать другой оператор catch (динамически включаемый), принимающий int или int& (возможно, с атрибутами const или volatile) в качестве типа исключения. Тем не менее, существуют два вида преобразований, которые могут применяться при сопоставлении типов в операторах catch. Первый вид - это приведение наследуемых типов. Оператору catch, перехватывающему исключения типа базового класса, разрешено перехватывать также исключения типа производного класса. Рассмотрим, например, иерархию исключений для обработки ошибок, определенную в стандартной библиотеке C++ (см. рис. 3.2). Оператор catch, перехватывающий исключения типа runtime_error, может также перехватывать исключения типа range_error, а оператор catch, перехватывающий исключения базового класса exception, - вообще любое исключение из этой иерархии. Это правило преобразования наследуемых исключений действует по отношению к значениям, ссылкам и указателям по следующему общему принципу: catch(runtime_error) ... catch(runtime_error&)... catch(construntime_error&) catch (runtime_error*) ... catch (const runtiem_error*) / / Также перехватывает //исключения типа //runtime_error, // range_error или //overflow_error. / / Также перехватывает //исключения типа // runtime_error*, //range_error* или // overflow_error*.
правило 12 тшшшмшмп Рис. 3.2 Второй вид разрешает преобразование из типизированного в нетипизированный указатель, так что оператор catch, имеющий в качестве аргумента указатель const void*, перехватывает все исключения, имеющие тип произвольного указателя: catch(const void*) ... //Перехватывает все / / исключения типа //"указатель". Наконец, последнее различие между передачей аргумента и распространением исключения состоит в том, что операторы catch всегда обслуживаются в порядке их перечисления. Таким образом, исключение типа производного класса может быть обработано оператором catch для базового класса, даже если блок catch для производного класса и соответствующий блок try находятся в одном модуле. Например: try{ } catch(logic_error&ex) { } catch(invalid_argument&ex) { / / Этот блок перехватит не / / только все исключения // типа logic_error, / / но и наследующие от них. / / Этот блок никогда не //получит управление, / / потому что все исключения // типа invalid_argument //будут перехвачены //предыдущим блоком.
ГТЯ1ИИИИ11 Исключения Сравните это поведение с вызовом виртуальной функции. В такой ситуации всегда вызывается функция класса, наиболее близкого к динамическому типу объекта, для которого вызывается функция. Можно сказать, что виртуальные функции используют алгоритм «наиболее подходящего», а исключения - алгоритм «первого подходящего». Иногда компиляторы генерируют предупреждение, если оператор catch для производного класса следует за таким же оператором для базового класса (некоторые компиляторы генерируют сообщение об ошибке, потому что ранее в C++ подобный код считался некорректным), но опасность лучше предупреждать: избегайте описанной выше последовательности операторов catch. Например, предыдущий пример может быть переработан следующим образом: try{ } catch(invalid_argument&ex) { //Здесь обрабатываются ... //исключения типа } • //invalid_argument. catch(logic_error&ex) { //Здесь обрабатываются ... // все другие исключения } //типа1од1с_еггог. Таким образом, между передачей объекта функции в качестве параметра или использованием объекта для вызова виртуальной функции и генерацией объекта- исключения существуют три принципиальных отличия. Во-первых, для объектов- исключений всегда создаются копии; при перехвате по значению операция копирования повторяется дважды. Для объектов, передаваемых функции в качестве аргументов, операция копирования может не производиться вообще. Во-вторых, для объектов, используемых в качестве исключений, существует меньше операторов преобразования типов, чем для объектов, передаваемых функциям в качестве аргументов. Наконец, в-третьих, операторы catch обслуживаются в порядке их перечисления в исходном тексте программы, выполняется же первый из них, который может перехватить данное исключение. При вызове виртуальной функции выбирается та из них, которая может обеспечить наилучшее совпадение с типом объекта, даже если она не первая по порядку в исходных текстах программы. Правило 13. Перехватывайте исключения, передаваемые по ссылке При создании оператора catch необходимо указать способ передачи объектов исключения данному оператору. Как и при указании способов передачи параметров функциям, здесь предусмотрено три варианта: по указателю, по значению или по ссылке. Рассмотрим сначала обработку исключения по указателю. Теоретически, это самый неэффективный способ реализации и без того медленного процесса передачи исключения из точки throw оператору catch (см. правило 15). Причина
Правило 13 gj в том, что генерация исключения по указателю является единственным способом передачи информации об исключении, при котором не требуется копировать объект (см. правило 12). Приведем пример: class exception {...} //Из иерархии исключений //стандартнойбиблиотеки // C++ (см.правило 12) . voidsomeFunction () { static exception ex; // Объектисключения. throw &ex; //Сгенерировать в качестве ... //исключенияуказатель на ex. } voiddoSomething () { try{ someFunction() //Может генерировать исключения. } catch (exception*ex) { //Обнаруживает исключения; ... //объект не копируется. Код выглядит чисто и аккуратно, но это впечатление обманчиво. Подобная программа будет работать, только если программист сможет, сгенерировав соответствующие указатели, обозначить объекты исключения таким образом, чтобы гарантировать их существование после того, как функции потеряют управление. Поскольку глобальные и статистические объекты работают прекрасно, программисты обычно легко забывают об этом ограничении и пишут примерно следующий код: voidsomeFunction() { exception ex // Локальный объект исключения; //он будет уничтожен, когда //программа выйдет за //область действия этой ... / /функции. throw &ех; //Генерируется указатель ... //на объект,который будет } //удален. Но это совершенно бесполезно, потому что при обработке такого исключения оператор catch получает указатель на объект, который больше не существует. В качестве альтернативы можно поместить указатель на новый динамический объект: voidsomeFunction() {
ЦЦ Исключенирав! throw new exception; // Установим указатель на новый ... //динамическийобъект // (остается лишь надеяться, clc } //что новый оператор - // см.правило8 -небудет cl< //генерировать исключения! ) ри Это поможет в будущем избежать проблемы «Я только что обнаружил указа тель на уже уничтоженный объект», но сейчас необходимо решить: стоит ли уда лять полученный указатель? Если объект исключения был создан в куче, полу ченный указатель лучше удалить, чтобы не допустить утечки ресурсов. В против ном случае этот указатель следует оставить, иначе программа будет работать не *; предсказуемо. Возникает вопрос: что же делать? v< Четко ответить на него невозможно. Некоторые клиенты могли бы пропустил адрес глобального или статического объекта, другие могли бы передать адрес ис- ^ ключения в куче. Таким образом, перехват по указателю напоминает дилемм} Гамлета: удалять или не удалять? Это вопрос, не имеющий однозначного ответа, поэтому лучше в него не углубляться. Более того, перехват исключения по указателю противоречит соглашениям, принятым в данном языке программирования. Все четыре стандартных исключения: bad_alloc (генерируется, когда operator new (см. правило 8) не может удовлетворить запрос на выделение памяти), bad_cast (генерируется при невозможности приведения типов посредством dynamic_cast, см. правило 2), bad_typeid (генерируется при применении dynamic_cast к нулевому указателю и bad_exception (генерируется при неожиданных исключениях, см. правило 14) - являются объектами, а не указателями на объекты. Поэтому их придется перехватывать по значению или по ссылке. Если программа работает со стандартными типами исключений, поиск по значению снимает вопрос об их удалении. Но поиск по значению требует, чтобы же объекты исключения копировались дважды при каждой генерации исключения Va (см. правило 12). При этом возникает проблема потери данных, которая заключа- но ется в том, что объекты исключения производного класса, обрабатываемые как исключения базового класса, теряют признаки своей «производности». Такие «от- ва слоенные» объекты являются объектами базового класса: в них отсутствуют за объекты данных, определенные как элемент производного класса, и когда вирту- п< альные функции обращаются к ним, они сами распадаются на виртуальные функ- pi ции базового класса. (Абсолютно то же самое происходит, когда объект передается Д в функцию по значению.) Например, рассмотрим приложение, использующее иерархию классов исключений, которая расширяет стандартную: n class exception { //Как и в предыдущем примере , это public: //стандартный класс исключений, virtual const char * what () throw () ; / / Выдает краткое описание исключения ... // (см. в правиле 14 информацию
Правило 13 class runtime_error: public exception{ . . . } class Validat ion_error: public runtime_error { public: virtual const char * what I }; voidsomeFunctionf) { //об операторе throw() / / в конце объявления) . / / Также из стандартной иерархии // исключений языка C++ . //Этот класс добавлен //пользователем. throw(); / / Это переопределение функции, / / объявленной выше в исключении //класса. // Может генерировать / / подтверждающее исключение. if (проверка правильное тине прошла) { throwvalidation_error(); voiddoSomethingf) { try{ someFunctionf) ; { cerr << ex.what () } } //Может генерировать //подтверждающее исключение. // Вызывает исключение: :what (] // и никогда // Validation_error::what(). Вызываемая версия оператора what принадлежит к базовому классу, даже если генерируемое исключение относится к типу Validation_error и если Validation_error переопределяет виртуальную функцию. Уверен, что подобное «расщепляющее» поведение никогда не удовлетворит вас. Таким образом, остается перехват исключения по ссылке, который не вызывает ни одной из выше обсуждавшихся проблем. В отличие от перехвата по указателю, вопрос удаления объекта теряет здесь свою актуальность, и поэтому при перехвате исключений стандартного типа не возникает никаких трудностей. От перехвата по значению перехват по ссылке отличается тем, что не вызывает потери данных, а объекты исключения копируются только один раз. Если переписать последний пример, используя перехват по ссылке, то он примет следующий вид: void someFunction() { / / В этой функции ничего //не меняется. if (проверка правильности не прошла) { throwValidation_error() ;
EZMMi } Исключения } voiddoSomething() { try{ someFunction() ; // Здесь без изменений. } catch(exceptions ex) { //Здесь происходит / / перехват по ссылке вместо / / перехвата по значению. cerr«ex.what() ; //Теперь вызывает //Validation_error::what(), // а не exception: :what () . } } При этом в точке throw ничего не меняется. Единственная перемена, которая происходит в операторе catch, состоит в добавлении амперсанда (&). Тем не менее, столь незначительная модификация программы приводит к довольно значительным различиям, так как виртуальные функции в блоке catch теперь работают вполне предсказуемо: функции в Validation_error активизируются в случае их переопределения в exception. Какое счастливое совпадение! При перехвате по ссылке вы обходите стороной вопросы удаления объектов, которые в любом случае повлекут за собой проблемы; вы также избегаете потери данных в объектах исключения и сохраняете возможность перехвата стандартных исключений; кроме того, вы ограничиваете количество необходимых копий объектов исключения. Согласитесь, это убедительные аргументы, чтобы всегда перехватывать исключения по ссылке! Правило 14. Разумно используйте спецификации исключений Нет смысла отрицать, что определения исключений нравятся всем. Они делают программу более понятной, потому что четко определяют, какие исключения может генерировать функция. Иногда компиляторы обнаруживают противоречивые определения исключений во время компиляции. Более того, если функция генерирует исключение, которое отсутствует в ее спецификации исключений, ошибка выявляется во время работы программы, и автоматически вызывается специальная функция unexpected. Таким образом, определение исключений кажется достаточно привлекательным не только для документирования программы, но и для задания ограничений при использовании исключений. Но красота и привлекательность обычно обманчивы. Заданное по умолчанию поведение функции unexpected заключается в вызове функции terminate, и по умолчанию функция terminate вызывает функцию abort. Следовательно, по
Правило 14 ЕШНИЕЭ умолчанию при возникновении исключения, не входящего в спецификацию, работа программы останавливается. Однако локальные переменные в стековых фреймах функции уничтожены не будут, так как функция abort завершает выполнение программы без проведения очистки. Следовательно, нарушение спецификации исключений можно отнести к разряду катаклизмов, которые лучше никогда не допускать. К сожалению, допустить ошибку, по невнимательности или незнанию написав функции, из-за которых происходит эта ужасная вещь, не составляет особого труда. Компилирующие программы проверяют использование исключений на согласованность со спецификациями исключений только частично. И самое печальное - стандарт языка не предусматривает (хотя компиляторы и могут вывести предупреждение) проверки на вызов функции, которая могла бы нарушить спецификацию исключений в вызывающей функции. Рассмотрим объявление функции f 1, не обладающей спецификацией исключений. Такая функция может генерировать любые исключения: extern void f 1 () // Может генерировать все что угодно. Теперь рассмотрим функцию f 2, спецификация исключений которой определяет, что она может вызывать исключения только типа int: voidf2 () throw(int) ; В языке C++ функция f 1 может вызывать функцию f 2, даже если функция f 1 способна генерировать исключение, которое нарушает спецификацию исключений функции f2: voidf2() throw(int) { f 1 () ; / / Допустимо, даже если f 1 могла бы //генерировать что-либо,кроме int. ) Подобная гибкость играет существенную роль в случае, если необходимо интегрировать новый код, в котором есть спецификация исключений, с устаревшим, не имеющим ее. Очень важно составлять программное обеспечение таким образом, чтобы минимизировать несогласованность. Во-первых, потому что компиляторы не будут препятствовать вызову функций, спецификации исключений в которых не согласованы со спецификациями исключений в процедурах, содержащих вызовы. Вторая причина кроется в том, что такие вызовы могут остановить выполнение вашей программы. Для начала попробуйте не вводить спецификации исключений в шаблоны, содержащие параметры типов. Рассмотрим шаблон, который на первый взгляд не может генерировать никаких исключений: //Плохо разработанный шаблон без спецификаций исключений. template<classT>
ЕЭННИЕЦ Исключения bool operator = = (const T& lhs , const TS rhs) throw () { return &lhs == &rhs ; } Шаблон определяет функцию operator == для всех типов. Для любой пары объектов одного и того же типа шаблон выдает true, если объекты имеют один и тот же адрес, в противном случае он возвращает false. Данный шаблон содержит спецификацию исключений, согласно которой функции, созданные при его помощи, не будут генерировать никаких исключений. Но это не всегда так, потому что сохраняется возможность перегрузки operators (адресного оператора) для некоторых типов. При этом оператор operators может генерировать исключение при вызове из operator ==. Если возникает исключение, то произойдет нарушение спецификации, и вы все-таки попадете в функцию unexpected. На этом типичном примере показана общая проблема: не существует способа узнать что-либо об исключениях, генерируемых параметрами типов шаблона. Дело в том, что почти невозможно создать реальную спецификацию исключений для каждого шаблона, поскольку шаблоны в любом случае как-то используют свой параметр типа. Какой же напрашивается вывод? Нельзя смешивать шаблоны и спецификации исключений. Избежать вызовов функции unexpected можно и другим способом: необходимо опустить спецификацию исключений в функциях, которые вызывают функции без спецификации исключений. Это просто соображения здравого смысла, но существует одна тонкость, которая касается разрешения пользователям регистрировать функции обратного вызова: / / Тип указателя функции для обратного вызова оконной системы, / / когда в этой системе происходит событие. typedefvoid (*CallBackPtr) (inteventXLocation, int eventYLocation, void*dataToPassBack); / / Класс оконной системы для перехода к функциям обратного вызова, //зарегистрированным клиентами системы. classCallBack { public: CallBack (CallBackPtr fPtr, void *dataToPassBack) :func(fPtr) , data(dataToPassBack){} voidmakeCallBack(int eventXlocation, int eventYLocation) const throw (); private: CallBackPtrfunc; //Функция, вызываемая //при обратномвызове. Void*data; //Данные,передаваемые функции }; //обратного вызова.
тттшш 11 Чтобы реализовать обратный вызов, // вызываем зарегистрированную функцию / / с координатами события / / и зарегистрированные данные. voidCallBack::makeCallBack(inteventXlocation, int eventYLocation) const throw () { func(eventXlocation, eventYLocation, data); } В данном случае вызов func в функции makeCal lBack иногда нарушает спецификацию исключений из-за того, что невозможно наверняка узнать, какие исключения могут быть сгенерированы функцией func. Проблему легко устранить, подогнав спецификацию исключений в типе CallBackPtr*: typedefvoid (*CallBackPtr) (inteventxLocation, int eventYLocation, void*dataToPassBack) throw() ; Если задан этот тип, происходит ошибка регистрации функции callback, и вы уже не застрахованы от генерации исключений. // Функция обратного вызова без спецификации исключений. voidcallBackFcnl, (inteventXLocation, inteventYLocation, void*dataToPassBack) ; void *callBackData; CallBackcl(callBackFcnl,callBackData) ; //Ошибка!CallBackFcnlможет //генерировать исключение. //Функция обратного вызова // со спецификацией исключений. voidcallBackFcn2 (inteventXLocation, inteventYLocation, void*dataToPassBack)throw(); callBackc2(callBackFcn2,callBackData) ; //Нормально, callBackFcn2имеет //соответствующее определение ex. Проверка спецификаций исключений при передаче указателей функции - относительно недавнее дополнение, появившееся в языке C++, так что не удивляйтесь, * Увы, это невозможно, по крайней мере не будет переноситься. Хотя многие компиляторы и принимают код, приведенный здесь, комитет по стандартизации по непонятной причине установил, что «спецификация исключений не должна появляться в определении типа». Если вам необходимо быстро решить эту проблему, придется создать макрос CallBackPtr. Правило 14
QUHHMIi Исключения если ваши компиляторы еще не поддерживают его. В этом случае вы сами должны заботиться о том, чтобы не совершать подобных ошибок. Третья методика, позволяющая избежать вызова функции unexpected, состоит в обработке исключений, которые могут генерироваться «системой». Из этих исключений чаще всего встречается bad_alloc, обычно генерируемое функциями operator new и operator new [ ] при ошибке выделения памяти (см. правило 8). При использовании в любой функции оператора new (см. там же) будьте готовы к тому, что в функции может возникнуть исключение bad_alloc. Хотя профилактика обычно лучше лечения, иногда предотвратить проблему сложнее, чем устранить ее последствия. Это значит, что в некоторых случаях справиться с неожиданными исключениями проще, чем предотвратить их появление. Например, если вы пишете программы строго с использованием спецификаций исключений, но вынуждены вызывать функции из библиотек, в которых не предусмотрены такие спецификации, будет непрактично предотвращать возникновение неожиданных исключений, потому что для этого придется изменить код в библиотеках. В подобных ситуациях вы можете использовать свойство языка C++ заменять исключения одного типа на другой. Например, предположим, что вы хотели бы заменить все неожиданные исключения на объекты типаUnexpectedException. Это нетрудно сделать следующим образом: classUnexpectedException{}; // Все объектынеожиданных / / исключений будут заменены / / на объекты этого типа. voidconvertUnexpected() //Функция,которуюследует { //вызыватьпри генерации throwUnexpectedException() ; //неожиданногоисключения. } В результате заданная по умолчанию функция unexpected будет заменяться на функцию convertUnexpected: set_unexpected (convertUnexpected); После этого любое неожиданное исключение вызовет функцию convertUnexpected и будет заменено новым исключением типа UnexpectedException. Если нарушенная спецификация исключений имеет в своем составе тип UnexpectedException, то распространение исключения будет продолжено далее, как будто ничего и не произошло. (Если же спецификация исключений не содержит UnexpectedExcept ion, будет вызвана функция terminate, как если бы вы не заменяли unexpected.) Другой способ перевода неожиданных исключений в стандартный тип основан на том, что если замена функции unexpected снова генерирует текущее исключение, то оно будет замещено новым исключением стандартного типа bad_exception. Как же это осуществить? Очень просто:
Правилом ¦¦¦¦¦¦ШЯ voidconvertUnexpected() //Функция, которую следует { / / вызвать при генерации throw; //неожиданного исключения; просто } / / сгенерируйте снова текущее //исключение. set_unexpected(convertUnexpected); / / Установите convertUnexpected //для замены неожиданного / / исключения. Поступив таким образом и включив bad_exception (или соответствующий базовый класс, стандартный класс exception) во все спецификации исключений, вам больше никогда не придется беспокоиться об остановке программы в случае возникновения неожиданного исключения. Любое случайное исключение будет заменено на bad_exception, распространяемое вместо исходного исключения. Теперь вы понимаете, что спецификация исключений может вызвать множество неприятностей. Компиляторы осуществляют только частичную проверку их согласованного использования, их применение в шаблонах также проблематично, поскольку легко допустить нечаянную ошибку. По умолчанию при нарушении спецификации исключений выполнение программы останавливается. Спецификации исключений имеют еще один недостаток, который заключается в том, что они приводят к вызову функции unexpected, даже если для работы с возникшим исключением уже подготовлен вызывающий оператор более высокого уровня. В качестве примера рассмотрим следующую программу, которая взята без изменений из правила 11: class Session{ //Для моделирования сеансов public: //работы в сети. -Session(); private: staticvoidlogDestruction(Session*objAddr)throw(); }; Session::~Session() { try{ logdestruction(this); } catch(...) {} } Деструктор Session вызывает logDestruction, чтобы зафиксировать факт уничтожения объекта Session, но в действительности он обрабатывает любые исключения, которые могут быть сгенерированы функцией logDestruction.
EOHHBIIJ Исключения Тем не менее, спецификация исключений функции logDe struct ion утверждает, что эта функция не должна генерировать никаких исключений. Теперь предположим, что некая функция, вызванная logDestruction, генерирует какое-либо исключение, которое logDestruction не в состоянии перехватить. Конечно, вероятность такого развития событий невелика, но, как вы удостоверились, совсем несложно написать программу, ведущую к нарушению спецификации исключений. Когда непредвиденное исключение распространится на функцию logDestruction, будет вызвана функция unexpected, что по умолчанию приведет к завершению работы программы. Это корректное поведение, но разве такого результата хотел добиться автор деструктора Session? Напротив, автор приложил много усилий, чтобы обработать все возможные исключения, поэтому завершение работы программы без отработки блока catch в деструкторе Session кажется несправедливым. Если функция logDestruction не имеет спецификации исключений, то сценарий типа «я готов обрабатывать исключения, дайте мне только шанс» никогда не возникнет. (Предотвратить это можно, например, заменив функцию unexpected, как было описано выше.) Как видите, очень важно иметь точное представление о спецификациях исключений. Они обеспечивают прекрасное документирование различных видов исключений, генерируемых функцией. Для ситуаций, в которых нарушение спецификации исключений настолько «ужасно», что влечет за собой немедленное завершение работы программы, они предлагают такое поведение по умолчанию. Наряду с этим спецификации проверяются компиляторами только частично и, соответственно, могут быть нарушены случайно. Более того, они могут блокировать обработку неожиданных исключений на более высоком уровне, даже если соответствующие обработчики знают, как это сделать. При таком положении дел спецификации исключений следует использовать разумно. Перед тем как вводить их в функции, подумайте, будет ли их поведение в программах соответствовать вашим планам. Правило 15. Оценивайте затраты на обработку исключений При обработке исключений во время выполнения программы немало усилий уходит на то, чтобы учитывать использование системных ресурсов. В каждый момент своего выполнения программа должна идентифицировать объекты, которые требуется уничтожить при генерации исключения. Объекты должны отметить каждый вход и выход из блока try, и для каждого блока try надо отслеживать соответствующие операторы catch и разновидности исключений, допускающие обработку этими операторами. Учет использования системных ресурсов обходится недешево. Так же, как и сравнения в ходе выполнения программы, необходимые для проверки
Правило 15 УЁИШНЕО спецификаций исключений на нарушения, или работа, затраченная на уничтожение соответствующих объектов и поиск корректного оператора catch при возникновении исключения. Без сомнения, обработка исключений требует серьезных затрат, и вам в любом случае придется покрыть хотя бы часть из них, даже если вы никогда не используете зарезервированные слова try, throw или catch. Начнем с обсуждения затрат, которые возникают, даже если вы никогда не производите обработку исключений. Они включают, во-первых, пространство, используемое специальными структурами данных для отслеживания полностью созданных объектов (см. правило 10), а также время, которое уходит на обновление этих структур. Конечно, такие затраты весьма невелики. Тем не менее, программы, составленные без поддержки исключений, обычно и выполняются быстрее, и занимают меньше места, чем их аналоги, скомпилированные с поддержкой исключений. Теоретически, у вас нет выбора: исключения являются неотъемлемой частью языка C++, и компиляторам так или иначе приходится их поддерживать. Вам также не приходится рассчитывать на то, что производители компиляторов будут удалять соответствующий код из программ, если вы не захотите использовать обработку исключений. Ведь типичные программы состоят из множества независимо генерируемых объектных файлов, и если один объектный файл не применяет исключения, вовсе не факт, что другие файлы также не делают этого. Более того, если при создании исполняемого файла ни один из связываемых объектных файлов не использует исключений, как быть с подключаемыми библиотеками? Если какая-либо часть программы обращается к исключениям, то и вся остальная программа должна тоже их поддерживать. В противном случае было бы невозможно обеспечить корректное поведение программы при обработке исключений. С теорией мы разобрались. На практике же большинство компиляторов, поддерживающих обработку исключений, позволяют вам самому решать, нужна ли эта возможность в создаваемом коде. Если вы уверены, что ни одна из частей вашей программы не содержит try, throw или catch и что ни одна из подключаемых библиотек также их не использует, то вы совершенно свободно можете компилировать программу без поддержки исключений. Это поможет сократить размер программы и увеличить скорость ее выполнения. В будущем, когда большинство библиотек станет применять исключения, такая стратегия потеряет свое значение. Однако на нынешней стадии развития языка C++, если вы все же решили отказаться от исключений, компиляция программ без их поддержки является наиболее рациональной. Эта стратегия также поможет оптимизировать обращения к библиотекам, которые «избегают» работы с исключениями, но только в случае, если есть гарантия, что исключения, генерируемые клиентской программой, никогда не распространятся на библиотеку. А обеспечить такую гарантию довольно трудно, потому что она запрещает переопределения клиентами виртуальных функций, объявленных в библиотеке, и определение клиентами функций обратного вызова.
ЕШНММН Исключения Дополнительные затраты по обработке исключений связаны с выполнением блоков try. Они возникают при каждом использовании такого блока, то есть при каждой обработке исключений. Блоки try реализованы в различных компиляторах по-разному, поэтому затраты зависят еще и от компилятора. По приблизительным оценкам, использование блоков try приведет к увеличению общего объема программы и времени ее выполнения на 5-10%. И это без учета генерации исключений; сюда входят только затраты, связанные с наличием в программе блоков try. Следовательно, чтобы уменьшить затраты, необходимо избегать ненужных блоков try. Компиляторы обычно генерируют код для спецификаций исключений примерно так же, как и для блоков try, с примерно теми же затратами. Извините, вы полагали, что спецификация исключений - это только спецификация и не приводит к генерации кода? Ну, теперь вам есть над чем подумать. Именно затраты, связанные с генерацией исключения, и открывают суть проблемы. Однако это не должно вызывать значительного беспокойства, хотя бы потому, что исключения встречаются довольно редко, так как связаны с возникновением исключительных событий. Как следует из правила «80-20» (см. правило 16), события такого рода почти никогда не должны оказывать значительного влияния на работу программы в целом. Тем не менее, я предвижу ваш вопрос, насколько сильный удар нанесет вам возникшее исключение. Отвечу прямо: возможно, очень сильный! По сравнению с нормальным возвратом из функции, возврат из функции при помощи генерации исключения иногда выполняется на три порядка медленнее. Достаточно мощный удар! Но он будет нанесен, только если возникнет исключение, а этого не должно происходить практически никогда. Поэтому не используйте исключения для индикации относительно обычных ситуаций, таких как завершение обхода структуры данных или завершение цикла. Но подождите! Откуда я могу обо всем этом знать? Если многие компиляторы сравнительно недавно начали поддерживать исключения (а это на самом деле так); если разные компиляторы реализуют такую поддержку по-разному (и это так); то как же я могу сделать вывод о том, что объем программы возрастет примерно на 5-10%, что скорость программы понизится примерно настолько же и что программа будет выполняться на несколько порядков медленнее при одновременной генерации множества исключений? Такой вывод основан на предположениях и проведенных тестах (см. правило 23). Дело в том, что большинство программистов - включая производителей компиляторов - имеют недостаточный опыт работы с исключениями, так что хоть и известно, что с исключениями связаны издержки, дать им точную оценку довольно сложно. Какими бы не были затраты на обработку исключений, не хочется платить больше, чем нужно. Чтобы уменьшить затраты, связанные с исключениями, по возможности компилируйте программы без поддержки исключений; используйте
пРавил°15^ _.. . ._ ШНШЕВ блоки try и спецификации исключений только там, где необходимо; и генерируйте исключения только в действительно исключительных случаях. Если производительность программ все равно будет слишком низкой, выполните отладку программ (см. правило 16), чтобы определить, вызвано ли это поддержкой исключений. Если да, рассмотрите возможность перейти на другие компиляторы, которые более эффективно реализуют обработку исключений языка C++.
Глава 4. Эффективность Я подозреваю, что кто-то проводил секретные эксперименты по выработке условного рефлекса у разработчиков программного обеспечения на C++. Чем еще можно объяснить тот факт, что при упоминании слова «эффективность» большинство программистов начинает истекать слюной? Но шутки в сторону. Эффективность на самом деле очень важна. Слишком большие или слишком медленные программы не находят признания, независимо от их достоинств. Так и должно быть. Ведь программы призваны помогать нам в работе, и вряд ли кто-либо станет утверждать, что медленнее значит лучше, что требование 32 Мб памяти вместо 16 выгоднее и что «пережевывание» 100 Мб дискового пространства предпочтительнее, чем «поглощение» только 50. Конечно, есть программы, которые имеют большой размер и используют много памяти, так как выполняют сложные вычисления, но все же в том, что у приложения слишком «медленная поступь» или «раздутый размер», обычно виноваты плохой дизайн и небрежное программирование. Чтобы научиться создавать эффективные программы на языке C++, прежде всего вы должны уяснить, что C++ может не иметь ничего общего с возникшими у вас проблемами производительности. Если вы действительно хотите написать эффективную программу на C++, то сначала должны научиться писать эффективные программы вообще. Многие программисты игнорируют эту простую истину. Конечно, можно развертывать циклы вручную, а умножение заменить операциями сдвига, но такие тонкие изменения ни к чему не приведут, если используемые вами алгоритмы неэффективны сами по себе. Используете ли вы квадратичные алгоритмы, когда достаточно использовать линейные? Вычисляете ли вы одно и то же значение снова и снова? Пренебрегаете ли вы возможностью сократить средние затраты на выполнение сложных операций? Если да, то неудивительно, что ваши программы похожи на второсортные «приманки для туристов». Материал данной главы рассматривает тему эффективности с двух направлений. Первое из них не зависит от языка программирования, и основное внимание уделяется моментам, общим для всех языков. Язык C++ является очень привлекательным средством для реализации самых смелых идей, так как сильная поддержка инкапсуляции в нем позволяет заменить неэффективную реализацию класса улучшенными алгоритмами и структурами данных, поддерживающих единый интерфейс. Второе направление сконцентрировано на самом языке программирования C++. Высокопроизводительные алгоритмы и структуры данных - это замечательно, но нестабильная практика реализации может значительно снизить их эффективность. Самую коварную ошибку - создание и уничтожение слишком большого числа объектов - настолько же просто допустить, насколько распознать.
пРавил°1б шишнш Излишнее создание и удаление объектов для программы подобно кровотечению, когда драгоценное время неумолимо «истекает» при каждой операции с ненужным объектом. Эта проблема настолько актуальна, что я вынужден посвятить четыре отдельных раздела описанию того, откуда берутся такие объекты и как от них избавиться, не нарушив корректной работы программы. Создание слишком большого количества объектов не единственная причина, по которой увеличивается размер программы и замедляется ее выполнение. Среди прочих «выбоин» необходимо отметить неправильный выбор библиотек и путей реализации возможностей языка. Эти проблемы также затрагиваются в настоящей главе. Изучая материал данной главы, вы ознакомитесь с некоторыми принципами, которые способны улучшить производительность практически любого приложения. Вы узнаете, как предотвратить попадание ненужных объектов в ваши программы, а также будете иметь более четкое представление о том, как компиляторы ведут себя при создании исполняемых файлов. Говорят, кто предупрежден, тот вооружен. В таком случае расценивайте следующую информацию как подготовку к битве. Правило 16. Не забывайте о правиле «80-20» Правило «80-20» гласит, что 80% программных ресурсов и памяти используется примерно 20 процентами кода программы: 80% времени уходит на выполнение примерно 20% кода, 80% обращений к диску осуществляется примерно из 20% кода, 80% усилий по поддержке тратится примерно на 20% кода. Правило «80-20» неоднократно проверялось на различных компьютерах, операционных системах и приложениях. Это не просто запоминающаяся формула, а оценка производительности системы, широко применяемая и имеющая солидную эмпирическую основу. При рассмотрении правила «80-20» очень важно не слишком вдаваться в цифры. Некоторые считают более точным соотношение «90-10», которое также подтверждается экспериментально. Независимо от точности цифр суть остается неизменной: суммарная производительность программы почти всегда определяется небольшой частью кода, лежащей в ее основе. Правило «80-20» как упрощает, так и усложняет жизнь программиста, стремящегося увеличить производительность своей программы. С одной стороны, правило «80-20» подразумевает, что большая часть создаваемого вами кода может, честно говоря, иметь довольно среднюю производительность, поскольку в течение 80% времени его эффективность не влияет на суммарную производительность системы, над которой вы работаете. Это, конечно же, не льстит самолюбию любого программиста, но должно немного охладить ваш пыл. С другой стороны, правило подразумевает, что если возникают проблемы с производительностью, то вам предстоит хорошенько поработать, чтобы не только обнаружить участки неэффективного кода, но и найти способ, как их усовершенствовать. Обычно сложнее всего найти «узкие места». Существует два абсолютно
ШПНПШШ Эффективность разных подхода к данной проблеме: тот, который используется большинством программистов, и правильный. Большинство программистов стараются просто угадать, где находятся критические участки кода. На основе опыта, интуиции, карт Таро и спиритических сеансов, слухов или чего-нибудь похуже, разработчик за разработчиком торжественно объявляют, что проблемы эффективного функционирования программ восходят к сетевым задержкам, плохо настроенному распределению памяти, к компиляторам, которые плохо выполняют оптимизацию, или к запрету «тупоголовыми» менеджерами использовать язык ассемблера для критических внутренних циклов. Подобные оценки обычно высказываются со снисходительной усмешкой, но, как правило, и сами насмешники, и их прогнозы оказываются совершенно несостоятельными. Почти все программисты обладают отвратительной интуицией в том, что касается характеристик производительности их программ, так как эти характеристики сами по себе обычно совершенно «не интуитивны». И в результате невообразимые усилия тратятся на повышение эффективности тех частей программы, которые не оказывают ощутимого влияния на ее суммарное поведение. Например, представим, что в программу можно добавить замечательные алгоритмы и структуры данных, снижающие вычислительные затраты, но, если производительность программы ограничивается возможностями ввода-вывода, то все усилия будут потрачены зря. Конечно, нетрудно заменить поставляемые с компилятором библиотеки ввода-вывода на более мощные (см. правило 23), но это не даст значительных изменений, если использующие их программы ограничены техническими параметрами процессора. Что же делать в случае, если ваша программа работает слишком медленно или требует чрезмерно много памяти? Согласно правилу «80-20», совершенствование выбранных наугад частей программы вряд ли исправит положение. Производительность программы обычно определяется не слишком явными характеристиками, поэтому попытки найти наугад причину падения производительности ничем не отличаются от простого усовершенствования произвольно выбранных частей программы. Что же вам поможет в этом случае? Самое эффективное средство - эмпирически, то есть при помощи отладчика, определить те 20% программы, которые причиняют вам «головную боль». Но не всякий отладчик подойдет для этой задачи. Вам необходим такой, чтобы непосредственно измерял интересующие вас ресурсы программы. Например, если программа работает слишком медленно, то необходимо использовать отладчик, который измеряет время, затрачиваемое на выполнение ее отдельных участков. Это позволит вам сконцентрироваться на тех местах, где усовершенствование локальной эффективности вызовет значительное улучшение суммарной работоспособности приложения. Польза от отладчиков, определяющих, сколько раз выполняется каждый оператор, сколько раз вызывается каждая функция, скажем прямо, невелика. С точки зрения производительности эти сведения совершенно не важны. Мало кого из
Правилом ШИШМЕВ пользователей программ или подключаемых библиотек интересует число выполненных операторов или вызовов функций. Единственное, что имеет для них какое-либо значение, - время. Пользователи ненавидят ждать, а если ваша программа заставляет их это делать, то они ненавидят и вас. Тем не менее, знание частоты выполнения операторов и вызова функций может иногда пролить свет на то, как функционирует программа. Если, например, вы создаете около сотни объектов определенного типа, вам будет небезынтересно узнать, что при этом конструкторы в данном классе вызываются уже не сотни, а тысячи раз. Более того, подсчет числа вызовов операторов и функций иногда помогает понять скрытые аспекты поведения программы. Если вы, например, не имеете прямой возможности оценить использование динамической памяти, то вам пригодится информация о том, как часто вызываются функции выделения и освобождения памяти (например, операторы new, new [], delete и delete [ ] - см. правило 8). Конечно, даже лучшие из отладчиков являются заложниками обрабатываемых данных. Не удивляйтесь, когда при обработке нетипичных входных данных отладчик укажет, что требуется доводка части программы, попадающей в 80%, хотя, как вы знаете, это никак не отражается на общей работе программы. Помните, что отладчик может сообщить вам о поведении приложения во время конкретного «прогона» (или нескольких «прогонов»), поэтому если вы отлаживаете программу, используя нетипичные входные данные, то в итоге получите нетипичные значения измеряемых параметров. Это, в свою очередь, приведет к тому, что вы будете оптимизировать работу программы для нетипичного случая, а общий эффект может быть даже отрицательным. Лучший способ избежать таких «патологических» результатов заключается в том, чтобы отлаживать программу, используя как можно больше различных наборов входных данных. Кроме того, вы должны убедиться, что каждый набор данных типичен для клиентов (или по крайней мере для наиболее важных из них). Типичный набор данных обычно получить легко, потому что многие пользователи с удовольствием предоставят в ваше распоряжение свои данные для отладки. В таком случае вы будете настраивать программное обеспечение, исходя из их потребностей, и от этого выиграют все. Правило 17. Используйте отложенные вычисления С точки зрения эффективности наилучшими вычислениями являются те, которые вообще никогда не выполняются. Но зачем помещать такой код в программу? А уж если по каким-либо причинам без данного кода в программе не обойтись, как избежать его выполнения? Выход в том, чтобы отложить вычисления. Помните, когда вы были еще ребенком, родители заставляли вас убирать свою комнату? Если вы были похожи на меня в детстве, то сказали бы: «Хорошо», а затем быстро вернулись к тому, чем занимались до этого. Фактически, уборка 4-679
БЗННШИ Эффективность была бы для вас последним делом - но только до тех пор, пока вы не услышали бы, как родители идут, чтобы посмотреть на наведенный порядок. Тогда вы бросились бы в свою комнату и сразу же принялись за работу. Но если бы вам повезло, и родители не стали проверять, как выполнено их поручение, то вам вообще удалось бы избежать уборки комнаты. Оказывается, тактика «задержки», которой вовсю пользуется пятилетний ребенок, характерна и для программиста, работающего с C++. В информатике такая тактика называется отложенным (буквально «ленивым») вычислением (lazy evaluation). При использовании отложенных вычислений классы записываются таким образом, что вычисления не производятся до тех пор, пока не потребуются их результаты. Если результаты не потребуются никогда, то эти вычисления никогда и не будут выполнены. Таким образом, ни пользователи вашего программного обеспечения, ни ваши родители не могли бы выбрать более мудрый путь. Отложенное вычисление применимо для множества приложений, но в настоящей главе описываются только четыре наиболее частых случая. Подсчет ссылок Рассмотрим следующую программу: class String {...}; // Класс string // (стандартный тип string // может быть реализован, как описано // ниже, но это не обязательно) . String si = "Hello"; String s2 = si; // Вызывает конструктор // копирования String. Обычная реализация конструктора копирования String привела бы к созданию объектов si и s2, каждый из которых имел бы собственную копию строки "Hello" после инициализации s2 значением из si. Это повлекло бы за собой довольно большие расходы, ведь конструктору пришлось бы скопировать значение si, чтобы передать его s2, а это обычно требует выделения динамической памяти при помощи оператора new (см. правило 8) и вызова функции strcpy для копирования данных из si в память, выделенную для s2. Таким образом, будет производиться энергичное вычисление (eager evaluation): создание копии s 1 и помещение ее в s2 только потому, что был вызван конструктор копирования string. Однако в данном случае не было реальной необходимости в том, чтобы в s2 находилась копия значения, так как s2 еще совсем не использовался. При «ленивом» подходе выполняется намного меньше работы. Вместо передачи объекту s2 копии значения si, объект s2 разделяет значение si. Достаточно знать, кто и что совместно использует, и это позволит избавиться от затрат на вызов оператора new и копирование. То, что структура данных совместно используется объектами si и s2, прозрачно для клиентов, и, конечно, не вносит различий в подобные операторы, поскольку они только считывают значения, а не записывают их:
пр^ило 17 UMHE] cout «si; // Считывает значение si. cout « si + s2; // Считывает значение si и s2 . Фактически, совместное использование значений существенно, только когда происходит модификация той или иной строки; в этом случае важно, чтобы изменения вносились только в одну строку, но не в обе. В операторе s2.convertToUpperCase(); должно меняться только значение s2, а не значение si. Для обработки подобных операторов нужно реализовать функцию convertToUpperCase объекта String так, чтобы она копировала значение s2, а в самой s2 до модификации делала это значение закрытым. Внутри функции convertToUpperCase вы не можете больше придерживаться «ленивой» стратегии: необходимо сделать копию значения s2 (совместно используемую) для использования внутри s2. С другой стороны, если объект s2 никогда не изменяется, вам не понадобится делать закрытую копию его значения. Значение может использоваться совместно, пока оно существует. Если вам повезет, то s2 никогда не будет изменяться, и тогда вообще не придется задавать ему собственное значение. Более подробно о совместном использовании значений (включая весь код) рассказано в правиле 29, однако, надеюсь, основная идея ясна вам уже сейчас: надо делать копию чего-либо только в тот момент, когда в ней возникнет насущная необходимость. Будьте ленивыми - используйте любую подходящую копию, пока это можно делать безнаказанно (в некоторых случаях такое состояние длится бесконечно). Как отличить считывание от записи Изучая далее пример использования строк для подсчета ссылок, можно столкнуться со второй ситуацией, в которой будет полезно отложенное вычисление. Рассмотрим следующую программу: String s = "Homer's Iliad"; // Строка s содержит // ссылку. cout << s[3] ; // Вызов operator [] для считывания s[3] . s[3] = 'x'; // Вызов operator[] для записи s[3]. Первый вызов operator [ ] соответствует считыванию части строки, второй - выполнению записи. Ваша задача - научиться различать вызов оператора чтения и вызов оператора записи, так как чтение строки со ссылками выполнить просто, а для записи в такую строку может потребоваться предварительное создание новой копии значения строки. Это затрудняет реализацию. Чтобы достигнуть поставленной цели, необходимо выполнять различные преобразования внутри operator [ ] (в зависимости от того, вызывается ли он для выполнения чтения или записи). Как же определить, был ли operator [ ] вызван в контексте чтения или записи? К сожалению, это сделать нельзя. Но используя отложенное вычисление и proxy-классы, как 4*
ЦЯЯНИШ Эффективность описано в правиле 30, вы, тем не менее, сможете отложить решение, осуществлять ли операцию чтения или записи, пока не определите, какое действие является правильным. Отложенная выборка Теперь третий пример отложенного вычисления. Представьте, что у вас есть программа, которая использует большие объекты, содержащие много полей. Эти объекты должны существовать после завершения работы программы, поэтому они хранятся в базе данных. Каждый объект имеет уникальный идентификатор, который может использоваться для извлечения объекта из базы: class LargeObject { // Большой постоянный объект. public: LargeObject (ObjectID id) ; // Восстановить объект с диска. const strings fieldlf) const; // Значение поля 1. int field2() const; // Значение поля 2. double field3() const; II ... const strings field4() const; const strings field5() const; }; Теперь рассмотрим затраты на восстановление объекта LargeObject с диска. void restoreAndProcessObject(ObjectID id) { LargeObject object (id) ; // Восстановление объекта. } Поскольку экземпляры объекта LargeOb j ect велики, получение всех данных для такого объекта может оказаться достаточно сложной операцией, особенно, если информация должна быть извлечена из удаленной базы и передана по сети. В некоторых случаях чтение всех данных и не требуется. Например, рассмотрим такое приложение: void restoreAndProcessObject(ObjectID id) { LargeObject object(id); if (object.field2() == 0) { cout « "Object " « id « ": null field2.\n"; } } В этом случае требуется лишь значение f ield2, а любые попытки установить значение других полей бесполезны. «Ленивый» подход к описываемой проблеме заключается в том, чтобы не считывать данные с диска при создании объекта LargeOb j ect. Таким образом, вместо объекта создается только его «оболочка», и данные извлекаются из базы
Правило 17 ИШШННЕШ] только тогда, когда нужно, чтобы они заняли свое место внутри объекта. Ниже приводится один из способов реализации объектов с подкачкой страниц по требованию: class LargeObject { public: LargeObject(ObjectID id) ; const strings fieldl() const; int field2() const; double field3() const; const strings field4() const; private: ObjectID oid; mutable string *fieldlValue; mutable int *field2Value; mutable double *field3Value; mutable string *field4Value; }; LargeObject::LargeObject(Object ID id) : oid(id), fieldlValue@), field2Value@), field3Value@), ... {} const strings LargeObject::fieldl () const { if (fieldlValue == 0) { считать данные для поля field 1 из базы данных и задать указатель fieldlValue на них; } return *fieldlValue; } Каждое поле объекта представлено указателем на необходимые данные, а конструктор LargeObject обнуляет все указатели. Такие нулевые указатели обозначают поля, которые еще не были считаны из базы данных. Каждая функция-член класса LargeObj ect должна проверить состояние указателя поля перед доступом к данным, на которые он указывает. Если указатель нулевой, то соответствующие данные будут считываться из базы перед выполнением любых действий над ними. При осуществлении отложенной выборки вы сталкиваетесь со следующей проблемой: может потребоваться инициализация нулевых указателей, чтобы они ссылались на реальные данные из любой функции-члена, включая функции с атрибутом const, подобные fieldl. Однако компиляторы «ругаются», если вы пытаетесь изменить элементы данных внутри функции-члена с атрибутом const, так что надо найти способ сказать: «Все в порядке, я знаю, что делаю». Лучший способ для этого - объявить поля указателя как mutable, чтобы они могли изменяться внутри любой функции-члена, даже внутри функции-члена с атрибутом const. Именно поэтому поля в объекте LargeObj ect были объявлены выше как mutable. //См. ниже //о применении mutable.
Эффективность Ключевое слово mutable - относительно недавнее дополнение к языку C++, так что, возможно, ваши компиляторы еще не поддерживают его. В таком случае вам придется найти другой способ убедить компилятор позволить вам изменять данные внутри функции-члена с атрибутом const. С одной стороны, можно использовать стратегию псевдоуказателя this, при которой создается указатель без атрибута cons t, ссылающийся на тот же самый объект, что и this. Если вы хотите изменить данные, то обращаетесь к объекту через псевдоуказатель: class LargeObject { public: const strings fieldl() const; // Без изменений. private: string *fieldlValue; // He объявляется как mutable, // чтобы старые компиляторы // принимали его. const strings LargeObject::fieldl () const { // Объявить указатель fakeThis, указывающий на то же, что и this, // но не имеющий атрибута const. LargeObject * const fakeThis = const_cast<LargeObject* const>(this); if (fieldlValue == 0) { fakeThis->fieldlValue соответствующие данные из базы данных; } return *fieldlValue; } Эта функция использует оператор const_cast (см. правило 2), чтобы избавиться от атрибута const в указателе *this. Если ваши компиляторы не поддерживают const_cast, вы можете использовать приведение типа языка С: // Приведение типа языка С для эмуляции mutable. const strings LargeObject:: fieldl ()• const { LargeObject * const fakeThis = (LargeObject* const)this; ... / / Как и выше. } Рассмотрим снова указатели внутри объекта LargeObject. Скажем прямо, утомительно было бы обнулять все указатели, а затем проверять каждый из них перед использованием (кроме того, это может привести к ошибкам). К счастью, такую тяжелую работу нетрудно автоматизировать с помощью smart-указателей, EEMMMll // Это присваивание // допустимо, так как то, //на что указывает fakeThis, //не имеет атрибута const.
Правило 17 11Ш1Н1Ш о которых рассказано в правиле 28. Используя smart-указатели внутри объекта LargeObject, вы также обнаружите, что больше не нужно объявлять указатели как mutable. Увы, это только временная отсрочка, потому что необходимость в присвоении mutable указателю возникнет снова, как только вы приметесь за реализацию классов smart-указателей. Отложенная оценка выражения Заключительный пример отложенного вычисления характерен для числовых приложений. Рассмотрим следующий код: template<class T> class Matrix {...}; // Для однородных матриц. Matrix<int> mlA000, 1000); // То же самое для матриц Matrix<int> m2A000, 1000); // 1000 на 1000. Matrix<int> m3 = ml + m2; // Сложить ml и m2. При обычной реализации operator + используется «энергичное» вычисление; в этом случае была бы высчитана и возвращена сумма ml и т2. Это достаточно большой объем вычислений A 000 000 операций сложения). Кроме того, для хранения значений потребуется выделение памяти, что также сопряжено с определенными затратами. Согласно стратегии отложенного вычисления, если способ реализации содержит слишком много работы, она не выполняется. Вместо этого создается структура данных внутри тЗ, которая указывает, что значение тЗ является суммой ml и m2. Такая структура данных состоит всего лишь из указателей на ml и т2 и перечисления, указывающего, что для них должна быть выполнена операция сложения. Конечно же, быстрее задать структуру данных, чем сложить ml и т2, и при этом используется намного меньше памяти. Предположим, что затем в программе перед использованием тЗ выполняется следующий код: Matrix<int>m4A000, 1000); ... // Присвоить т4 некоторые значения. тЗ = т4 * ml ; Теперь можно забыть, что тЗ было равно сумме ml и т2 (и таким образом «сэкономить» затраты на вычисления), и считать с этого момента, что тЗ равно произведению т4 на ml. Но само умножение выполняться не будет. Зачем беспокоиться? Хорошие программисты ленивы, помните? Этот пример выглядит достаточно надуманным, потому что ни один профессионал не написал бы программу, которая вычисляла бы сумму двух матриц и не использовала результат вычислений, но он не так гипотетичен, как кажется. Ни один хороший программист не стал бы преднамеренно вычислять значение, в котором нет необходимости, но при сопровождении программы нередко ее отдельные ветви модифицируются таким образом, что прежде полезное вычисление
ЕЕНННММП Эффективность становится ненужным. Чтобы уменьшить вероятность такой ситуации, надо определять объекты перед самым их использованием, но время от времени проблема все равно будет возникать. Если бы определение суммы матриц было единственным случаем, когда отложенное вычисление окупается, то едва ли стоило бы прикладывать столь значительные усилия. Чаще бывает, что нужна только часть вычисления. Например, предположим, что матрица тЗ используется после ее инициализации к сумме ml и т2 следующим образом: cout << тЗ[4]; // Вывести 4-ую строку тЗ. Ясно, что нельзя отложить все вычисления - нужно определить значение в четвертой строке тЗ. Но при этом не нужно вычислять ничего, кроме четвертой строки матрицы тЗ; остальная часть матрицы тЗ может остаться не вычисленной до тех пор, пока не потребуется программе. При благоприятном стечении обстоятельств этого никогда не произойдет. Какова же вероятность такой удачи? Опыт в области матричных вычислений говорит, что преимущество на нашей стороне. Отложенное вычисление скрывается за чудом, которое называется «язык программирования APL». Этот язык был разработан в 1960-ых для интерактивного использования теми, кому приходилось выполнять матричные вычисления. Функционируя в компьютерах, имевших меньшую вычислительную мощность, чем чипы, применяемые сегодня в микроволновых печах, язык APL был способен складывать, умножать и даже делить большие матрицы, как тогда казалось, почти мгновенно! Его «изюминка» заключалась в отложенном вычислении. Этот подход был очень эффективным, так как пользователи APL в основном складывали, умножали или делили матрицы не потому, что им была нужна полная результирующая матрица, а потому, что требовалась только небольшая ее часть. Язык APL применял отложенное вычисление, задерживая осуществление операции до тех пор, пока не было точно известно, какая часть результирующей матрицы необходима, а затем вычислял только ее. Это позволяло выполнять сложные вычислительные задачи в интерактивном режиме на компьютере, мощность которого была бы совершенно недостаточна для реализации, использующей «энергичное» вычисление. Сегодня компьютеры работают быстрее, но наборы данных стали больше, а пользователи менее терпеливы, так что многие из современных библиотек матричных функций продолжают применять отложенные вычисления. Честно говоря, лень иногда не окупается. Если тЗ использовать так: cout « тЗ; // Вывести всю строку тЗ . то игра окончена, и придется полностью вычислять значение матрицы тЗ. Таким же образом, если одна из матриц, от которых зависит тЗ, должна быть изменена, потребуется выполнить действия немедленно: тЗ = ml + т2; // Помните, что тЗ является / / суммой ml и т2 .
Правило 17 Ё1МНКШ ml = m4; // Теперь m3 является суммой т2 //и старого значения ml! Здесь надо каким-то способом гарантировать, что присвоение нового значения ml не изменяет тЗ. Внутри оператора присваивания Matrix <int> можно было бы вычислить значение тЗ до изменения ml, или создать копию старого значения ml и сделать тЗ зависящим от нее. В любом случае нужно обеспечить, чтобы тЗ имела правильное значение после того, как матрица ml будет изменена. Другие функции, которые могли бы изменить матрицу, следует обрабатывать аналогично. Из-за необходимости сохранять зависимости между значениями, поддерживать структуры данных, сохраняющие значения и зависимости, или и то, и другое, а также перезагружать операторы (присваивания, копирования и сложения), отложенные вычисление требуют очень много работы. С другой стороны, они часто позволяют существенно сэкономить время и дисковое пространство при выполнении программы, и во многих приложениях это с лихвой окупает усилия, которые требуются для реализации отложенных вычислений. Резюме Приведенные четыре примера показывают, что отложенное вычисление может быть полезно в самых разных ситуациях: для того чтобы избежать напрасного копирования объектов, отличить чтение от записи при использовании operator [ ], не делать лишнего считывания информации из баз данных и многочисленных ненужных расчетов. Однако использование отложенного вычисления не всегда оправдано. Точно так же, как откладывание обычной уборки не спасет, если родители всегда проверяют вас, отложенное вычисление не сократит работу программы, если все вычисления необходимы. Действительно, в таком случае отложенное вычисление может даже замедлить выполнение программы и увеличить расход памяти, потому что вам придется не только проделывать все вычисления, которых вы надеялись избежать, но и управлять сложными структурами данных (они, собственно, и осуществляют отложенное вычисление). Отложенное вычисление полезно только тогда, когда точно известно, что ваша программа будет выполнять операции, без которых можно обойтись. Заметим, что нет никаких специфических особенностей отложенного вычисления в языке C++. Данная методика может применяться в любом языке программирования (хотя основные языки программирования обычно используют «энергичное» вычисление), и для некоторых из них - особенно APL, части диалектов языка Lisp и фактически всех языков, работающих с потоками данных, - эта идея является фундаментальной. Все же, C++ больше подходит в качестве средства для реализации пользователем отложенного вычисления. Это связано с тем, что поддержка инкапсуляции позволяет добавлять отложенное вычисление к классу без ведома его клиентов. Взгляните снова на приведенные выше фрагменты кода и вы убедитесь, что интерфейсы класса не содержат сведений о том, какое вычисление используется классами: «энергичное» или отложенное. Следовательно, можно реализовать класс с помощью «прямой» стратегии «энергичного» вычисления, а затем, если
Эффективность | во время отладки (см. правило 16) выяснится, что от способа реализации класса \ зависит производительность, выполнить реализацию класса на основе отложен- | ных вычислений. Единственным изменением, которое заметят ваши пользователи (после повторной компиляции или компоновки программы), будет улучшение | производительности. Клиентам нравятся такие усовершенствования, и это может заставить вас гордиться своей ленью. Правило 18. Снижайте затраты на ожидаемые вычисления В правиле 17 я превозносил достоинства лени, откладывания дел «в долгий ящик» и объяснил, как лень может повысить эффективность ваших программ. В этом правиле я отстаиваю совершенно иную позицию. Здесь лени нет места! Сейчас я расскажу вам, как улучшить работу программы, заставив ее выполнять больше, чем от нее требуется. Философия этого правила может быть названа «сверхэнергичным вычислением»: выполнение заданий до того, как их поручили выполнить. Рассмотрим, например, шаблон для классов, которые представляют большие наборы численных данных: template<class NumericalType> class DataCollection { public: NumericalType min() const; NumericalType max() const; NumericalType avg() const ; }; Если предположить, что функции min, max и avg возвращают текущие минимальное, максимальное и среднее значения набора, то существуют три пути реализации этих функций. Используя «энергичное» вычисление, вы исследовали бы все данные в наборе при вызове min, max или avg и вернули бы соответствующее значение. При отложенном вычислении заставили бы функции возвращать структуры данных, с помощью которых можно было бы определять соответствующее значение, когда оно будет использоваться. Если же вы выберете «предварительное» вычисление, программа будет следить за текущими минимальным, максимальным и средним значениями набора данных, чтобы при вызове функций min, max или avg возвратить правильное значение немедленно - без вычислений. Если бы функции min, max и avg вызывались часто, можно было бы компенсировать затраты на слежение за соответствующими значениями данных для всех обращений к этим функциям, и средние затраты на вызов функции были бы ниже, чем при использовании энергичного или отложенного вычисления. Идея, лежащая в основе «сверхэнергичного» вычисления, такова: если вы ожидаете, что вычисление будет выполняться часто, вы можете снизить средние
Правило 18 МИИМЖЕЯ затраты на вызов функции, создавая свои структуры данных таким образом, чтобы обрабатывать запросы особенно эффективно. Один из самых простых способов добиться этого - кэшировать уже вычисленные значения, которые, как вы думаете, понадобятся снова. Допустим, вы пишете программу для предоставления информации о служащих и предполагаете, что будет часто запрашиваться номер комнаты сотрудника. Информация о служащем хранится в базе данных, но большинству приложений номер комнаты не нужен, поэтому база данных не оптимизирована для его поиска. Чтобы не производить повторный поиск в базе, вы можете написать функцию f indCubicleNumber, которая кэширует искомые номера комнат. Последующие запросы номера комнаты, который уже был найден, могут быть удовлетворены обращением к кэшу вместо запроса из базы данных. Ниже приводится один из способов реализации функции f indCubicleNumber; при этом объект тар взят из стандартной библиотеки шаблонов (Standard Template Library - STL, см. правило 35) и используется в качестве местного кэша: int findCubicleNumber(const strings employeeName) { // Определите статический объект map (имя служащего, номер комнаты) . // Этот объект - локальный кэш данных. typedef map<string, int> CubicleMap; static CubicleMap cubes; // Попытайтесь найти пункт employeeName в кэше; затем итератор STL it // укажет на найденную ячейку, если она существует (см. подробности //в правиле 35) . CubicleMap::iterator it = cubes.find(employeeName); // Значение it будет равно cubes.end(), если ячейка не найдена // (это стандартная реакция STL). В таком случае обратитесь //за номером комнаты, затем добавьте его в кэш. if (it == cubes.end()) { int cubicle = результат поиска номера ячейки employeeName's в базе данных; cubes [employeeName] = cubicle; // Добавьте пару // (employeeName, // cubicle) в кэш. return cubicle; } else { //it указывает на корректный // элемент кэша (имя сотрудника, номер // комнаты). Вам нужен только второй // компонент, то есть компонент second. return (*it).second; } }
ЦП Эффективность Постарайтесь не углубляться в подробности кода STL (которые будут более ясными после того, как вы прочитаете о правиле 35). Вместо этого сконцентрируйтесь на общей стратегии данной функции. Она состоит в том, чтобы использовать локальный кэш для замены сравнительно дорогих запросов из базы данных дешевым поиском в структуре данных, находящейся в оперативной памяти. Если предположение о том, что номера комнат будут запрашиваться часто, верно, то использование кэша в функции f indCubicleNumber должно привести к сокращению средних затрат на определение номера комнаты служащего. (Один нюанс кода все же требует объяснения. Последний оператор возвращает (* it) . second вместо обычного it->second. Почему? Это связано с соглашениями, которым следует STL. В двух словах, итератор it является объектом, а не указателем, поэтому нет гарантии, что оператор -> может применяться Kit*. Спецификации STL требуют, чтобы операции "." и "*" были допустимы для итераторов, так что конструкция (* it) . second, будучи синтаксически громоздкой, гарантированно работает.) Кэширование - один из способов снизить затраты на предполагаемые вычисления. Упреждающая выборка из памяти - другой. Вы можете считать упреждающую выборку вычислительным эквивалентом оптовой скидки. Контроллеры диска, например, считывают блоки или сектора данных целиком, даже если программа запрашивает лишь небольшой объем данных. Это обусловлено тем, что машине быстрее считать большой блок информации сразу, чем считывать два или три маленьких блока в разное время. Кроме того, опыт показывает, что если потребовались одни данные, соседние данные тоже понадобятся. Это печально известное явление называется локальной взаимосвязанностью (данных), и проектировщики систем ориентируются на него, создавая кэш диска, памяти команд и данных и упреждающей выборки команд. Если вас не волнуют такие низкоуровневые вещи, как контроллеры диска или кэш процессора, нет проблем. Все равно предварительная выборка из памяти будет вам полезна. Представьте, например, что вы хотели бы реализовать шаблон для динамических массивов, то есть массивов, которые начинаются с одного размера и автоматически расширяются: template<class T> // Шаблон для динамических class DynArray {...); // массивов. DynArray<double> a; // Пока только а[0] // является правильным // элементом массива, а [22] = 3.5; // Массив а автоматически // расширяется: // допустимые индексы 0-22. а [32] = 0; // Массив а расширяется снова; // теперь допустимы индексы а[0]-а[32] . * В июле 1995 года комитет ISO/ANSI по стандартизации языка C++ добавил требование, согласно которому большинство итераторов STL должны поддерживать оператор->, так что выражение it-> second должно теперь работать. Однако некоторые реализации STL не соответствуют данному требованию, поэтому пока чаще используется конструкция (*it) .second.
Правило 18 JMWKTiE] Как же объект DynArray расширяется? Можно было бы просто выделить столько дополнительной памяти, сколько необходимо, например, так: template<class T> Т& DynArray<T>::operator[](int index) { if (index < 0) { поднять исключения; II Отрицательные индексы } // все еще недопустимы, if (index > текущего максимального значения индекса) ( Вызов new, для того чтобы выделить достаточный объем дополнительной памяти и тем самым сделать индекс допустимым; } return элемент массива с индексом index; } При таком подходе оператор new вызывается каждый раз, когда требуется увеличить размер массива, но запросы new вызывают функцию operator new (см. правило 8), а вызовы функций operator new (и operator delete) достаточно «дороги». Дело в том, что они обычно приводят к системным вызовам, которые выполняются медленнее, чем вызовы функций внутри процесса. Поэтому лучше сократить число системных вызовов до минимума. Стратегия «сверхэнергичного» вычисления опирается на следующее рассуждение: если необходимо увеличить размер массива для того, чтобы он соответствовал индексу 1, то по принципу локальной взаимосвязанности, вероятно, придется увеличивать его и в будущем, чтобы он соответствовал какому-либо другому индексу, немного большему, чем i. Избежать затрат на выделение памяти во время второго (ожидаемого) увеличения можно, если сразу сделать размер массива DynArray настолько большим, чтобы последующие увеличения оказались в пределах предусмотренного диапазона. Например, можно записать DynArray: : operator [ ] следующим образом: template<class T> Т& DynArray<T>::operator[](int index) { if (index < 0) сгенерировать исключение; if (index > текущего максимального значения индекса) { int dif f = index - текущее максимальное значение индекса; вызов new, чтобы предоставить достаточно дополнительной памяти и тем самым сделать index+diff действительным; } return элемент массива с индексом index; } Эта функция запрашивает вдвое больше памяти, чем необходимо при каждом увеличении массива. Если вы снова посмотрите на предыдущий сценарий, то заметите, что массив DynArray должен запрашивать дополнительную память только один раз, даже если ее логический размер увеличивается вдвое:
ЕПЯННИШ Эффективность DynArray<double> а; // Допустим только индекс а[0] . а [22] =3.5; // Вызывается new для расширения // хранилища а посредством // индекса 44; логический размер а // достигает 23. а[32] = 0; // Логический размер а // изменяется для учета а[32] , //но при этом new не вызывается. Если придется снова увеличить размер а, то ожидаемое увеличение также будет «недорогим», но при условии, что новый максимальный индекс не превысит 44. Через весь этот раздел красной нитью проходит одна мысль: в большинстве случаев увеличение скорости выполнения программы может быть достигнуто ценой увеличения расхода памяти. Отслеживание текущих минимальных, максимальных и средних значений требует дополнительного пространства, но экономит время. Хранение результатов в кэше использует больше памяти, но сокращает время, результаты не вычисляются, а восстанавливаются из кэша. Упреждающая выборка требует места для размещения данных, которые были предварительно выбраны из памяти, но экономит время на то, чтобы получить доступ к этим данным. Эта история столь же стара, как информатика: пространство часто приносится в жертву времени. (Однако не всегда. Использование больших объектов означает, что на странице виртуальной памяти или кэша поместится меньше объектов. В редких случаях увеличение объектов уменьшает производительность программы, потому что возрастает число обращений к файлу подкачки, понижается частота успешных обращений к кэшу или происходит и то, и другое. Как же выяснить, не столкнулись ли вы с аналогичной проблемой? Используйте отладчик снова и снова (см. правило 16).) Совет, который я предлагаю в этом разделе, таков: снижайте затраты на ожидаемые вычисления с помощью «сверхэнергичных» стратегий, то есть кэширования и упреждающей выборки данных. Это не противоречит использованию отложенного вычисления, о котором говорилось в правиле 17. Отложенное вычисление - это методика, предназначенная для повышения эффективности программ при выполнении операций, результаты которых необходимы не всегда. Сверхэнергичное вычисление повышает эффективность программ при выполнении операций, результаты которых необходимы почти всегда или часто. Обе вышеупомянутые методики гораздо труднее реализовать, чем обычную методику «энергичного» вычисления, но их использование может существенно повысить производительность программ. Правило 19. Изучите причины возникновения временныхобъектов Общаясь друг с другом, программисты часто называют кратковременно необходимые переменные «временными». Например, в следующей процедуре swap:
Правило 19 бИННПВВ template<class T> void swap(T& objectl, T& object2) { T temp = objectl; objectl = object2; object2 = temp; } temp - временная переменная. Однако в рамках языка C++ объект temp не является временным. Это просто локальный объект функции. Истинные временные объекты в C++ невидимы - они не появляются в исходном коде программы. Они возникают, когда создается, но не называется, статический объект. Такие неименованные объекты обычно возникают в двух случаях: во-первых, когда для успешного вызова функций применяются неявные преобразования типов, и во-вторых, когда функции возвращают объекты. Важно понимать, как и почему эти временные объекты создаются и уничтожаются, потому что затраты на их создание и уничтожение могут оказать заметное влияние на производительность программы. Рассмотрим вначале первый случай, когда временные объекты создаются для успешного вызова функций. Это происходит, если тип объекта, передаваемого функции, не совпадает с типом ее параметра. Например, проанализируем функцию, которая считает число заданных символов в строке: // Возвращает число символов ch в строке str. size_t countChar(const stringk str, char ch) ; char buffer[MAX_STRING_LEN]; char c; // Считать символ и строку, используя функцию setw, чтобы избежать переполнения буфера при чтении строки. cin >> с » setw(MAX_STRING_LEN) » buffer; cout « "Обнаружено" « countChar(buffer, с) << " символов " << с << " в строке " « buffer << endl; Взгляните на вызов функции countChar. Первый переданный параметр- массив char, но соответствующий параметр функции имеет тип const strings. Вызов может быть успешным, только если устранить несоответствие типов, и ваши компиляторы это сделают, создавая временный объект типа string. Данный временный объект инициализируется при помощи вызова конструктора string с параметром buffer. После этого параметр str в countChar связывается с временным объектом string. После возврата из функции countChar временный объект автоматически уничтожается. Преобразования такого рода удобны (хотя и опасны - см. правило 5). Но с точки зрения эффективности создание и уничтожение временного объекта string влечет за собой ненужные расходы. Устранить их можно двумя путями.
1ШМММ11 Эффективность Первый способ - изменить программу так, чтобы подобные преобразования не могли возникать. Данная стратегия описывается в правиле 5. Альтернативный путь - сделать так, чтобы преобразования были не нужны. Этому посвящено правило 21. Рассматриваемые преобразования происходят только при передаче объектов по значению или при передаче параметра «ссылка на const». Они не возникают при передаче объекта параметру «ссылка не на const». Проанализируем следующую функцию: void uppercasify(strings str); // Переводит все символы //в str в верхний регистр. В примере с подсчетом знаков массив char можно было бы успешно передать функции countChar, но здесь попытка вызвать функцию uppercasi f у с массивом char не удается: char subtleBookPlug[] = "Effective C+ + " ; uppercasify(subtleBookPlug); // Ошибка! При этом не создается никаких временных объектов, чтобы сделать вызов успешным. Почему? Предположим, что временный объект создан. Тогда он был бы передан функции uppercasify, которая перевела бы все его символы в верхний регистр. Но фактический параметр в вызове функции - subtleBookPlug - не подвергся бы модификации; был бы изменен только временный объект string, полученный из subtleBookPlug. Конечно, это не совпадает с замыслом программиста. Программист передавал в функцию uppercasify параметр subtleBookPlug, ожидая, что переменная subtleBookPlug изменится. Неявное преобразование типов для объектов «ссылка не на const» позволило бы изменять временные объекты, тогда как программист ожидал модификации «невременных» объектов. Именно поэтому язык запрещает создание временных объектов для параметров ссылки не на const. Ссылка на параметры с атрибутом const не вызывает данной проблемы, потому что такие параметры не могут быть изменены. Временные объекты могут также создаваться, если функция возвращает объект. Например, функция operator* должна вернуть объект, который представляет собой сумму ее операндов. Если задан тип Number, то operator* для этого типа объявляется примерно так: const Number operator+(const Numbers lhs, const Numbers rhs); Возвращаемое значение данной функции является временным, потому что оно не имеет имени: это только возвращаемое функцией значение. Следовательно, вызов operator* каждый раз приводит к затратам на создание и удаление такого объекта. (Объяснение того, почему возвращаемое значение является const, вы найдете в правиле 6.)
Правило 20 КНШПВЕЗ Как обычно, вам не нужны лишние затраты. Чтобы избежать их, в рассматриваемом случае вы можете использовать похожую функцию, operator+=; это преобразование описано в правиле 22. Но для большинства функций, выдающих объекты, не существует аналогов, то есть в принципе, концептуально, нельзя избежать создания и удаления возвращаемого значения. Однако между концепцией и действительностью лежит «сумеречная зона», называемая оптимизацией, и иногда вы можете записывать функции, возвращающие объекты, так, что это позволит вам не создавать временные объекты. Наиболее часто применяется оптимизация возвращаемого значения, которая обсуждается в правиле 20. Подводя итог, можно сказать, что создание временных объектов - довольно «дорогое» удовольствие, поэтому постарайтесь его избегать. Важнее научиться находить места, где могут создаваться временные объекты. Когда вы видите параметр типа «ссылка на const», помните, что допускается создание временного объекта. Когда вы видите функцию, возвращающую объект, знайте, что будет создан (а затем удален) временный объект. Учитесь находить такие конструкции, и вы лучше поймете, какие ресурсы уходят на выполнение «закулисных» действий компилятора. Правило 20. Облегчайте оптимизацию возвращаемого значения Функция, которая возвращает объекты, не по вкусу ревностным поклонникам эффективности, потому что возврат по значению, включая подразумеваемые вызовы конструктора и деструктора (см. правило 19), не могут быть устранены. Проблема проста: функция либо должна вернуть объект, чтобы выполнить работу, либо нет. Если она возвращает объект, то нет никакого способа избавиться от этого. Рассмотрим функцию operator* для действительных чисел: class Rational { public: Rational (int numerator = 0, int denominator = 1) ; int numerator() const; int denominator() const ; }; // Причины постоянства (const) вызываемого // значения указаны в правиле 6. const Rational operator*(const Rationalk lhs, const Rationalk rhs); Даже не глядя на код функции operator*, вы знаете, что она должна вернуть объект, потому что возвращает произведение двух произвольных чисел. Как функция operator* может избежать создания нового объекта для хранения их произведения? Никак, поэтому она должна создавать новый объект и возвращать его. Тем не менее, программисты C++ приложили усилия, достойные Геркулеса, чтобы найти способ избавиться от возврата результатов по значению.
DQHHBIlii Эффективность Некоторые программисты возвращают указатели, что приводит к синтаксической пародии: // Неблагоразумный способ избежать возвращения объекта. const Rational * operator*(const Rationalk lhs, const Rational& rhs); Rational a = 10; Rational b(l, 2); Rational с = * (a * b) ; // По-вашему, это естественно? В связи с этим возникает вопрос. Должна ли вызывающая программа удалять указатель, возвращенный функцией? Обычно отвечают «да», но это, как правило, ведет к утечке ресурсов. Другие разработчики возвращают ссылки, что дает в общем-то приемлемый синтаксис: // Опасный (и неправильный) способ избежать возвращения объекта. const Rationalk operator*(const Rationalk lhs, const Rationalk rhs) ; Rational a = 10; Rational b(l, 2); Rational с = a * b; // Выглядит вполне благоразумно. Но поведение таких функций нельзя реализовать корректно. Обычная попытка выглядит примерно так: // Еще один опасный (и неправильный) способ избежать возвращения объекта. const Rationale operator*(const Rationalk lhs, const Rational& rhs) { Rational result(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator()); return result; } Эта функция возвращает ссылку на объект, который больше не существует. В частности, она возвращает ссылку на локальный объект result, но result был автоматически удален при выходе из operator*. Возвращение ссылки на объект, который был уничтожен, вряд ли полезно. Поверьте, некоторые функции (operator* в том числе) все равно будут возвращать объекты. Не стоит бороться с тем, чего вы не сможете победить. Вы не получите желаемого результата, пытаясь устранить возвращение объектов по значению функциями, которые требуют этого. В принципе, вас и не должно волновать то, что функция возвращает объект, вам необходимо заботиться только о затратах, которые приносит возврат объекта. Поэтому главное - найти способ сократить затраты на возврат объектов, а не устранять сами объекты. Если создание таких объектов не влечет никаких затрат, то не все ли равно, сколько их будет?
Правило 20 111ШШН1Ш Очень часто функции, которые возвращают объекты, используются таким образом, что компиляторы могут устранить затраты на создание временных объектов. Тонкость заключается в том, чтобы возвращать аргументы конструктора вместо объектов: // Эффективный и правильный способ реализации функции, // которая возвращает объект. const Rational operator*(const Rationalk Ihs, const Rationalk rhs) { return Rational(lhs.numerator() * rhs.numerator() , lhs.denominator() * rhs.denominator()) ; } Взгляните внимательнее на возвращаемое выражение. Оно выглядит как вызов конструктора Rational, и фактически так оно и есть. Вы создаете временный объект Rational при помощи выражения: Rational(lhs.numerator() * rhs.numerator(), lhs.denominator () * rhs.denominator ()) ; Именно этот временный объект и копируется функцией в качестве возвращаемого ею значения. Кажется, что возврат аргументов конструктора вместо локальных объектов не принесет вам много пользы, потому что вы все еще должны покрывать затраты на создание и удаление временного объекта, появляющегося внутри функции, и вы все еще должны покрывать затраты на создание и удаление объекта, который возвращается функцией. Но вы уже получили кое-что. Правила языка C++ позволяют компиляторам выполнять оптимизацию за счет удаления временных объектов. В результате, если вы вызываете operator* в следующем контексте: Rational a = 10; Rational b(l, 2) ; Rational с = а * b; // operator* вызывается здесь. то ваши компиляторы могут устранять как временный объект внутри operator*, так и временный объект, возвращаемый operator*. Они могут создавать объект, определенный выражением return в памяти, выделенной под объект с. Если ваши компиляторы делают это, то общие затраты на образование временных объектов в результате вызова operator* сводятся к нулю. Чтобы создать объект с, выполняется только один вызов конструктора. Кроме того, нельзя предложить ничего лучшего, потому что с - именованный объект, а именованные объекты не могут быть удалены (см. также правило 22)*. Однако вы можете * В июле 1996 комитет по стандартизации ISO/ANSI объявил, что как именованные, так и неименованные объекты могут быть оптимизированы при помощи оптимизации возвращаемого значения, так что обе версии operator* (см. выше) могут теперь выдавать один и тот же (оптимизированный) объектный код.
ЕПИННММ Эффективность избавиться от накладных расходов, связанных с вызовом operator*, объявив функцию как inline: // Наиболее эффективный способ реализовать функцию, // возвращающую объект. inline const Rational operator*(const Rationalk lhs, const Rational&: rhs) { return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator()) ; } «Да, да», - бормочете вы, - «оптимизация, оптимизация. Какая разница, что компиляторы могут сделать? Я хочу знать то, что они делают] Работает ли эта штука в реальных компиляторах?» Работает! Такая специфическая оптимизация - устранить локальный временный объект, используя точку возврата функции (и, возможно, заменяя его объектом в месте вызова функции) - хорошо известна и широко применяется. Она даже имеет название: оптимизация возвращаемого значения. Наличие особого названия для этого типа оптимизации может объяснить, почему она так широко доступна. Программисты, ищущие компилятор C++, могут спросить продавцов, реализуется ли в нем оптимизация возвращаемого значения. Если один продавец отвечает «Да», а другой спрашивает: «Что-что реализуется?», то первый их них обладает определенным конкурентным преимуществом. Ах, капитализм! Иногда невозможно его не любить! Правило 21. Используйте перегрузку, чтобы избежать неявного преобразования типов Вот код, который выглядит совершенно правильным: class UPInt { // Класс для целых чисел public: // неограниченной точности. UPInt () ; UPInt(int value)¦ } // Почему возвращаемое значение равно // const, объясняется в правиле 6. const UPInt operator+(const UPIntk lhs, const UPIntb rhs) ; UPInt upil, upi2; UPInt upi3 = upil + upi2; Здесь нет сюрпризов. И upil, и upi2 являются объектами типа UPInt, поэтому их сложение просто приводит к вызову operator+ для UPInt. Рассмотрим теперь следующие операторы: upi3 = upil + 10; upi3 = 10 + upi2;
Правило 21 Ш11ИМНШ Их выполнение также будет успешным, при этом для преобразования целого числа 10 в тип UPInt будут созданы временные объекты (см. правило 19). Такие преобразования обычно выполняются компиляторами автоматически, но вряд ли вам нравится тратить дополнительные ресурсы компьютера на создание временных объектов. Так же как многие хотят получать кредиты от правительства, не возвращая их, так и большинство программистов C++ желали бы использовать неявные преобразования типов, не неся расходов по созданию временных объектов. Но как это сделать - ведь не существует вычислительного эквивалента покрытия бюджетного дефицита? Если сделать шаг назад, то обнаружится, что цель на самом деле состоит не в преобразовании типов, а в том, чтобы можно было вызывать operator+ с аргументами типов UPInt и int. Для этого подойдет неявное преобразование типов, но не нужно путать цели и средства. Существует другой способ успешного выполнения вызовов operator* для аргументов разных типов, который устраняет необходимость преобразования типов. Если вам нужно сложить объекты типа UPInt и int, вы можете сделать это, объявив несколько функций с разными наборами типов параметров: const UPInt operator*(const UPInt& lhs, // Сложить UPInt const UPInt& rhs); //и UPInt. const UPInt operator*(const UPIntk lhs, // Сложить UPInt int rhs); //и int. const UPInt operator*(int lhs, // Сложить int const UPIntk rhs) //и UPInt. UPInt upil, upi2; UPInt upi3 = upil + upi2; // Все в порядке, // временные объекты // для upil или upi2 //не создаются. upi3 = upil + 10; // Все в порядке, // временные объекты // для upil или 10 //не создаются. upi3 = 10 + upi2; // Все в порядке, // временные объекты // для 10 или upi2 //не создаются. После выполнения перегрузки, что позволяет избежать преобразования типов, главное не увлечься и не объявить еще и функции типа: const UPInt operator*(int lhs, int rhs); // Ошибка! Хотя определенная логика в этом построении есть: перегрузить все возможные комбинации operator* для типов UPInt и int. После того как заданы три вышеприведенные функции, остается только operator*, оба аргумента которого имеют тип int, поэтому-то и возникает соблазн добавить его.
ВЕШНИМ Эффективность Но в языке C++ существуют свои правила игры, и согласно им один из аргументов любого перегруженного оператора должен иметь определенный пользователем тип. Тип int является встроенным, поэтому нельзя перегружать оператор, задавая только аргументы этого типа. (Если бы разрешалось изменять значение встроенных операций, это привело бы к хаосу К примеру, попытка перегрузки operator* в данном случае изменила бы значение сложения двух целых чисел.) Перегрузка функций для того, чтобы избежать создания временных объектов, касается не только функций операторов. Например, во многих программах вам может потребоваться сделать допустимым использование объекта string везде, где есть char*, и наоборот. Аналогично, если вы включаете в код класс чисел (допустим, класс комплексных чисел complex - см. правило 35), вам может понадобиться, чтобы в любом месте вместо численного объекта такого типа разрешалось подставить число типа int или double. Этого нетрудно достичь, перегрузив все функции с аргументами типа string, char*, complex и избежав тем самым преобразований типов. Кроме того, важно не забывать о соотношении «80-20» (см. правило 16). Нет смысла реализовать множество перегруженных функций, если это не приведет к заметному улучшению суммарной эффективности использующих их программ. Правило 22. По возможности применяйте оператор присваивания вместо отдельного оператора Большинство программистов ожидает, что если можно написать х = х + у; х = X - у; то можно написать и х += у; х -= у; Но если х и у имеют определенный пользователем тип, то нет гарантии, что это действительно так. В языке C++ не предусмотрено связи между operator*, operator= и operator+=, поэтому если вы хотите, чтобы все три оператора существовали и привычно соотносились, вам придется реализовать такое поведение самостоятельно. То же самое относится к операторам -, *, / и т.д. Хороший способ гарантировать естественное соотношение между оператором присваивания (например, operator+=) и соответствующим отдельным оператором (таким как operator*) - реализовать второй оператор через первый (см. также правило 6). Сделать это достаточно легко: class Rational { public: Rational& operator* Rational& operator- }; (const Rationalk rhs); (const Rational& rhs);
Правило 22 ШПШНБШ // operator+ реализован через operator+=. // Почему возвращаемое значение имеет атрибут const, // объясняется в правиле б, //см. также предупреждение о реализации на стр. 121. const Rational operatorf(const Rationalk lhs, const Rationalk rhs) { return Rational(lhs) += rhs; } // operator- реализован через operator—. - const Rational operator-(const Rationalk lhs, const Rationalk rhs) { return Rational(lhs) -= rhs; } В этом примере операторы += и -= реализованы с нуля в каком-то другом месте, a operator+ и operator- вызывают их, обеспечивая собственные функции. В таком случае необходимо поддерживать только операторы присваивания. Более того, если операторы присваивания реализованы в открытом интерфейсе класса, то не нужно, чтобы отдельные операторы были дружественными к классу. Если нет причин, по которым все отдельные операторы нельзя сделать глобальными, то устранить необходимость написания отдельных операторов можно с помощью шаблонов: template<class T> const T operator+ (const T& lhs, const T& rhs) { return T(lhs) += rhs; //См. комментарии выше. } template<class T> const T operator-(const T& lhs, const T& rhs) { return T(lhs) -= rhs; // См. комментарии выше. } Тогда, если для какого-либо типа Т определен оператор присваивания, то соответствующий отдельный оператор при необходимости будет генерироваться автоматически. Все это хорошо, но пока еще не рассматривались вопросы эффективности, которым посвящена настоящая глава. Здесь нужно обратить внимание на три момента. Во-первых, в общем случае операторы присваивания обычно более эффективны, чем соответствующие версии отдельных операторов, поскольку отдельные операторы, как правило, возвращают новый объект, и при этом также создается и уничтожается временный объект (см. правила 19 и 20). Операторы
IIS Эффективность же присваивания производят запись в аргумент слева, поэтому в данном случае нет необходимости создавать временный объект для хранения возвращаемого значения оператора. Во-вторых, создавая и отдельные операторы, и соответствующие им операторы присваивания, вы позволяете клиентам ваших классов достигать компромисса между эффективностью и удобством. При этом пользователи могут выбирать, писать ли код так: Rational а, Ь, с, d, result; result =a+b+c+d; или так: result = а; result += b result += с result += d // Возможно использует // 3 временных объекта, по одному // для каждого вызова operator+. //Не нужно создавать временный объект. //Не нужно создавать временный объект. //Не нужно создавать временный объект. //Не нужно создавать временный объект. Первый вариант проще написать, отлаживать и поддерживать, при этом в 80% случаев он обеспечивает приемлемую производительность (см. правило 16). Второй вариант более эффективен, и, возможно, более понятен для программистов на языке ассемблера. Включая оба варианта, вы позволяете пользователям разрабатывать и отлаживать код с помощью более простых отдельных операторов, сохраняя при этом возможность их замены более эффективными операторами присваивания. Более того, реализовав отдельные операторы через операторы присваивания, вы гарантируете, что при переходе от одного варианта к другому семантика операторов останется неизменной. И последнее замечание об эффективности касается реализации отдельных операторов. Снова рассмотрим реализацию operator+: template<class T> const Т operator+ (const Т& lhs, const T& rhs) { return T(lhs) += rhs; } Выражение T (lhs) - это вызов конструктора копирования объекта Т, создающего временный объект, значение которого равно lhs. Затем полученный временный объект используется для вызова operator+= с параметром rhs, а результат операции возвращается operator + *. Кажется, что этот код слишком запутан. Может быть, лучше написать его так: * По меньшей мере предполагается, что это так. Упы, некоторые компиляторы интерпретируют T(lhs) как приведение типа, снимающее атрибут const с lhs, затем складывают rhs и lhs и возвращают ссылку на измененный параметр lhs! Поэтому прежде чем полагаться на описанное выше поведение, протестируйте ваш компилятор.
Правило 23 ШИШНЕИ template<class T> const Т operator+(const Т& lhs, const T& rhs) { T result(lhs); // Скопировать lhs в результат. return result += rhs; // Прибавить к нему rhs и вернуть } //в качестве результата. Этот шаблон почти эквивалентен предыдущему, но между ними есть важное различие. Второй шаблон содержит именованный объект result. А это означает, что оптимизация возвращаемого значения (см. правило 20) для такой реализации operator + до недавнего времени была невозможной (см. сноску на стр. 119). Для первой же реализации оптимизация возвращаемого значения разрешалась всегда, поэтому существует больше шансов, что ваш компилятор сможет сгенерировать оптимизированный код. Но принципы честной рекламы вынуждают меня отметить также, что выражение return T(lhs) += rhs; многие компиляторы рассматривают как слишком сложное для оптимизации возвращаемого значения. Поэтому первая реализация может потребовать создания одного временного объекта, так же как и при использовании именованного объекта result. Тем не менее, неименованные объекты всегда было проще устранять, чем именованные, поэтому при выборе между именованным объектом и временным, возможно, будет лучше использовать второй из них. Это никогда не обойдется дороже, чем применение именованного объекта, а в некоторых компиляторах, особенно в старых, потребует даже меньше затрат. Все рассуждения об именованных и неименованных объектах и оптимизациях компиляторов, конечно же, небезынтересны, но не стоит забывать о главном. Операторы присваивания (такие как operator+=) обычно более эффективны, чем соответствующие отдельные операторы (например, operator+). При разработке библиотек следует включать в нее обе версии операторов, а для выигрыша в производительности по возможности использовать операторы присваивания вместо соответствующих отдельных операторов. Правило 23. Используйте разные библиотеки Разработка библиотеки - один из примеров компромисса. В идеале, библиотека должна быть компактной, быстрой, мощной, гибкой, расширяемой, интуитивно понятной, универсальной, легко поддерживаться, быть свободной от ограничений и безошибочной. Но таких библиотек не существует. Библиотеки, оптимизированные по скорости выполнения и размеру, обычно не переносимы на другие компьютеры. Библиотеки с богатыми функциональными возможностями редко бывают интуитивно понятными. Безошибочные библиотеки обладают ограниченными возможностями. В нашем мире нельзя получить все сразу: чем-то всегда приходится жертвовать. Разработчики присваивают каждому из этих критериев различные приоритеты, принося в жертву то одно, то другое. В результате зачастую две аналогичные
ЦП Эффективность по функциям библиотеки имеют совершенно различные характеристики производительности. Например, рассмотрим библиотеки ввода-вывода iostream и stdio, доступные во всех компиляторах языка C++. Библиотека iostream имеет ряд преимуществ перед аналогичной библиотекой языка С. Например, она является расширяемой и более безопасной. Что же касается эффективности, библиотека iostream обычно проигрывает библиотеке stdio, и исполняемые файлы, созданные при помощи stdio, обычно имеют меньший размер и работают быстрее. Проанализируем вначале скорость выполнения. Оценить различие в производительности между этими библиотеками можно, выполнив тесты для каждой из них. При этом надо иметь в виду, что все тесты производительности «врут». Сложно не только определить набор входных данных, соответствующий «типичному» применению программы или библиотеки, но и выяснить, насколько «типичными» являются ваши задачи или задачи ваших пользователей. Тем не менее, тестовые приложения могут дать некоторое понятие о сравнительной производительности различных походов к задаче, поэтому хотя и глупо полностью полагаться на них, игнорировать их также неразумно. Рассмотрим простую тестовую программу, выполняющую только основные функции ввода-вывода. Эта программа считывает из стандартного ввода 30.000 чисел с плавающей точкой и записывает их стандартный вывод в формате с фиксированным числом знаков после запятой. Выбор между библиотеками iostream и stdio осуществляется во время компиляции и зависит от переменной препроцессора STDIO. Если она определена, то используется библиотека stdio, в противном случае применяется библиотека iostream. #ifdef STDIO #include <stdio.h> #else #include <iostream> #include <iomanip> using namespace std; #endif const int VALUES =30000; // Число повторений цикла. int main() { double d; for (int n = 1; n <= VALUES; ++n) { #ifdef STDIO scanf("%lf", &d); printf("%10.5f", d); #else cin >> d; cout << setwA0) // Задать ширину поля. << setprecisionE) // Знаков после запятой. << setiosf lags (ios : : showpoint) // Дополнить нулями. << setiosf lags (ios : :fixed) // Использовать эти значения.
0, 2, 2. 3. .00000 .07944 .70805 .09104 0, 2 2. 3, .69315 .19722 .77259 .13549 1. 2 2. 3. .09861 .30259 .83321 .17805 1. 2. 2. 3. .38629 .39790 .89037 .21888 1. 2 2 .60944 .48491 .94444 Правило 23 Ш1ШПН1Ш « d; #endif if (n % 5 == 0) { #ifdef STDIO printf(«\n»); #else cout « "\n" ; #endif } } return 0; } Когда на вход программы попадают натуральные логарифмы положительных чисел, на ее выходе получится: 1.79176 1.94591 2.56495 2.63906 2.99573 3.04452 Это по крайней мере демонстрирует, что при помощи библиотеки iostream можно выводить числа в формате с фиксированным числом знаков после запятой. Конечно же, запись cout « setwA0) « setprecisionE) « setiosflags(ios::showpoint) « setiosflags(ios::fixed) « d; совсем не так проста, как printf("%10.5f", d); но operator« является расширяемым и обеспечивает безопасность при работе с различными типами, а функция printf - нет. Я запускал эту программу на разных компьютерах, под разными операционными системами и использовал разные компиляторы, и во всех случаях версия на основе библиотеки stdio была быстрее. Иногда лишь немного (примерно на 20%), иногда значительно (почти на 200%), но никогда реализация на основе библиотеки iostream не могла сравниться по скорости с соответствующей реализацией на основе библиотеки stdio. Кроме того, обычно размер исполняемого файла этой простой программы, скомпилированной с использованием библиотеки stdio, был меньше (иногда намного меньше), чем размер соответствующего исполняемого файла при использовании библиотеки iostream. (Для реальных программ это различие обычно не является столь существенным.) Степень преимущества библиотеки stdio сильно зависит от способа ее реализации, поэтому в будущих версиях тестировавшихся систем или в каких-либо
[ШВПИНР Эффективность других системах разница в производительности между этими двумя библиотеками может быть пренебрежимо мала. Может существовать реализация библиотеки iostream, которая быстрее, чем библиотека stdio, так как типы операндов в библиотеке iostream определяются во время компиляции, а функции библиотеки stdio обычно обрабатывают строку формата во время выполнения программы. Различие между производительностью этих двух библиотек не столь важно, и рассматривается только в качестве примера. Основная мысль вышеизложенного заключается в том, что разные библиотеки с аналогичной функциональностью часто обладают различной производительностью, поэтому после определения «узких» мест программы (при помощи отладчика- см. правило 16), вы увидите, удастся ли избавиться от них, заменив одну библиотеку на другую. Например, если «узким» местом программы является скорость ввода-вывода, можно попробовать заменить библиотеку iostreams на stdio, если же значительная часть времени тратится на выделение и освобождение памяти, попытайтесь использовать различные реализации operator new и operator delete (см. правило 8). Так как разные библиотеки демонстрируют различные подходы к эффективности, расширяемости, переносимости, «типовой» безопасности и другим вопросам, то иногда можно повысить эффективность программного обеспечения, использовав библиотеку, разработчики которой уделили больше внимания вопросам производительности. Правило 24. Учитывайте затраты, связанные с виртуальными функциями, множественным наследованием, виртуальными базовыми классами и RTT1 Компиляторы C++ должны реализовывать все функции языка. Детали реализации, конечно же, зависят от конкретного компилятора, и разные компиляторы по-разному реализуют те или иные свойства языка. В большинстве случаев вам не придется заботиться об этих различиях. Но иногда реализация некоторых свойств языка заметно влияет на размер объектов и скорость, с которой выполняются функции-члены, поэтому вам важно иметь общее представление о том, что происходит «за сценой». В качестве примера можно в первую очередь привести виртуальные функции. При вызове виртуальной функции выполняемый код должен соответствовать динамическому типу объекта, для которого вызывается функция; независимо от типа указателя или ссылки на объект. Как без лишних затрат обеспечить такое поведение в компиляторе? Большинство реализаций используют виртуальные таблицы (virtual table, сокращенно vtbl) и указатели на виртуальные таблицы (virtual table pointer, сокращенно vptr). Виртуальная таблица обычно является массивом указателей на функции. (Некоторые компиляторы используют вместо массива разновидность связного списка, но принцип действия остается тем же самым.) Каждый класс программы,
Правило 24 ШИН который объявляет или наследует виртуальные функции, имеет собственную виртуальную таблицу, где элементы представляют собой указатели на реализацию виртуальных функций для этого класса. Например, если класс определен следующим образом: class C1 { public: С1 () ; virtual ~С1() ; virtual void fl(); virtual int f2(char c) const; virtual void f3(const strings s) void f4() const; };. то массив виртуальной таблицы класса С1 будет выглядеть примерно так, как показано на рис. 4.1. реализация С1::-С1 реализация C1::f1 реализация C1::f2 реализация C1::f3 Рис. 4.1 Обратите внимание, что в таблице отсутствуют невиртуальная функция f 4 и конструктор С1. Невиртуальные функции, включая конструкторы, которые по умолчанию являются невиртуальными, реализуются точно так же, как обычные функции С, и имеют поэтому ту же производительность. Если класс С2 наследует от класса С1, переопределяет некоторые из наследуемых виртуальных функций и добавляет еще несколько своих: class C2: public Cl { public: С2 () ; virtual ~C2() ; virtual void fl () ; virtual void f5(char *str); // Невиртуальная функция. // Переопределенная функция. // Переопределенная функция. // Новая виртуальная функция. }; то элементы в виртуальной таблице указывают на функции, соответствующие типу его объектов. Эти элементы включают указатели на виртуальные функции класса С1, которые не переопределяются в классе С2 (см. рис. 4.2).
ЕШННМ111 Эффективность ¦- реализация С2::-С2 >- реализация C2::f1 *. реализация C1::f2 *. реализация C1::f3 *. реализация C2::f5 Рис. 4.2 Из этого рассуждения видно, к каким затратам может привести использование виртуальных функций: необходимо выделить память для виртуальной таблицы каждого класса, содержащего данные функции. Размер виртуальной таблицы класса пропорционален числу объявленных в нем виртуальных функций (включая унаследованные от базовых классов). Каждый класс должен содержать только одну виртуальную таблицу, поэтому общий объем, занимаемый такими таблицами, обычно незначителен, но если вы создаете большое число классов или большое число виртуальных функций в каждом классе, то таблицы могут при этом занимать достаточно много места в адресном пространстве. Так как в программе нужна только одна копия виртуальной таблицы класса, компилятор должен решить, куда ее удобнее поместить. Большинство программ и библиотек создаются при помощи компоновки большого числа объектных файлов, каждый из которых создается независимо. Какой из объектных файлов должен содержать виртуальную таблицу данного класса? Можно предположить, что она должна помещаться в объектный файл, содержащий функцию main, но многие библиотеки не включают эту функцию, кроме того, содержащий ее исходный файл может не упоминаться во многих классах, в которых нужны виртуальные таблицы. Как тогда компилятор узнает, какие виртуальные таблицы он должен создать? Можно использовать различные подходы, и в связи с этим производители разделились на два лагеря. Те из них, которые поставляют интегрированную среду, включающую и компилятор, и компоновщик, обычно создают копии виртуальной таблицы в каждом объектном файле, где она нужна. Затем компоновщик удаляет дубликаты, оставляя только по одной копии каждой виртуальной таблицы в конечном исполняемом файле или библиотеке. Более распространенный подход состоит в использовании эвристики, чтобы определить объектный файл, который должен содержать виртуальную таблицу для класса. Обычно применяется такая эвристика: виртуальная таблица класса создается в объектном файле, содержащем определение (то есть тело) первой невстроен- ной не полностью виртуальной функции класса. В этом случае виртуальная таблица для приведенного класса С1 была бы помещена в объектный файл, где имеется определение С1: : ~С1 (если эта функция не определена как inline), а виртуальная таблица для класса С 2 разместилась бы в объектном файле, содержащем определение С2 : : ~С2 (если эта функция также не была определена как inline). С2 vtbl
Правило 24 ШПШНЕЗ На практике такая эвристика прекрасно действует, но она может вызвать неприятности, если виртуальные функции объявляются как inline. Если все виртуальные функции в классе объявлены как встроенные, то эвристика не сработает, и большинство основанных на ней компиляторов создадут по копии виртуальной таблицы класса в каждом объектном файле, использующем ее. В больших системах это может привести к образованию программ, содержащих сотни и тысячи копий виртуальной таблицы класса! Большинство компиляторов, применяющих описанную эвристику, позволяют в какой-то степени вручную управлять созданием виртуальной таблицы, но лучшее решение в данном случае - не объявлять виртуальные функции как inline. Кроме того, как вы впоследствии увидите, имеются причины, по которым современные компиляторы обычно игнорируют директиву inline для виртуальных функций. Виртуальные таблицы составляют половину механизма виртуальных функций, но сами по себе они бесполезны. Они приобретают значение, только если существует способ определить, какая виртуальная таблица соответствует каждому объекту, и это соответствие устанавливается при помощи указателя виртуальной таблицы. Каждый объект, класс которого объявляет виртуальные функции, содержит виртуальный элемент данных - указатель виртуальной таблицы, добавляемый компилятором к объекту. Объект, имеющий виртуальные функции, схематично изображен на рис. 4.3. Данные vptr Рис. 4.3 Здесь показан указатель виртуальной таблицы в конце объекта, но следует иметь в виду, что различные компиляторы могут размещать его по-разному. В случае наследования указатель виртуальной таблицы часто бывает окружен элементами данных. Множественное наследование еще более усложняет эту картину, но об этом речь пойдет несколько позже. Пока просто отметим, что при использовании виртуальных функций приходится включать еще один указатель в каждый объект класса, который содержит такие функции. Если объекты малы, это увеличение может оказаться существенным. Например, если объект в среднем содержит четыре байта данных, то добавление указателя виртуальной таблицы увеличит его размер вдвое (если указатель занимает четыре байта). При ограниченном размере памяти это будет означать, что у вас получится создать меньше объектов. Даже на системах с неограниченной памятью производительность вашей программы может упасть, так как меньше объектов будет помещаться в кэш или на страницу виртуальной памяти, что скорее всего приведет к увеличению числа переключений страниц.
IHIli Эффективность Предположим, имеется программа, содержащая несколько объектов типов С1 и С2. Если задано описанное отношение между объектами, указателями виртуальных таблиц и виртуальными таблицами, объекты в программе можно представить себе примерно так, как они изображены на рис. 4.4. Объект С1 Объект С2 С1 vtbl —I в> Реализация виртуальных функций С1 С2 vtbl ___ Реализация виртуальных функций С2 Рис. 4.4 Рассмотрим фрагмент программы: void makeACall(Cl *pCl) { } pCl->fl(); Это вызов виртуальной функции f 1 при помощи указателя рС1. Здесь нельзя понять, какую из функций f 1 (С1: : f 1 или С2 : : f 1) необходимо вызвать, так как рС1 может указывать как на объект С1, так и на объект С2. Компилятор, тем не менее, должен сгенерировать код для вызова функции fl внутри функции makeACal 1, причем должна вызываться правильная функция независимо от того, на что указывает рС1. Для этого компиляторы генерируют код, выполняющий следующее:
Правило 24 IllMMHES 1. Проследить указатель виртуальной таблицы до соответствующей виртуальной таблицы. Это простая операция, так как компиляторы знают положение указателя виртуальной таблицы в объекте. (В конце концов, они сами его туда поместили.) В результате ресурсы системы тратятся только на вычисление смещения (для получения указателя виртуальной таблицы) и доступ по указателю (для получения виртуальной таблицы). 2. Найти указатель виртуальной таблицы, соответствующий вызываемой функции (в данном примере функции f 1). Это также несложно, поскольку компиляторы присваивают каждой виртуальной функции уникальный индекс в таблице. Затраты на этот шаг сводятся просто к вычислению смещения в массиве виртуальной таблицы. 3. Вызвать функцию, на которую ссылается указатель, найденный в шаге 2. Если представить, что каждый объект имеет невидимый элемент данных vpt r и что индекс виртуальной таблицы функции f 1 равен i, то оператор pCl->fl(); будет генерировать код (*pCl->vptr[i])(pCl); // Вызывать функцию, на которую указывает // i-й элемент виртуальной таблицы, // заданной pCl->vptr; указатель рС1 передается // функции со значением this. Данный подход почти так же эффективен, как и вызов невиртуальной функции: на большинстве компьютеров при этом выполняется всего на несколько команд больше. Виртуальные функции сами по себе обычно не влияют на производительность. Реальные затраты на виртуальные функции во время выполнения программы связаны с тем, что из практических соображений они не реализуются как встроенные. Директива inline означает «в процессе компиляции подставить вместо вызова функции ее тело», но «виртуальная» предполагает «определить вызываемую функцию во время выполнения программы». Если компилятор не знает, какая функция будет вызвана, то он не сможет сделать ее встроенной. Таким образом, надо сделать так, чтобы не реализовывать виртуальные функции как встроенные. (Можно делать виртуальные функции встроенными при их вызове с помощью объектов, но большинство виртуальных функций вызывается при помощи указателей или ссылок на объекты, а такие вызовы не бывают встроенными. А поскольку эти вызовы общеприняты, виртуальные функции обычно не делаются встроенными.) Все сказанное до сих пор относится и к одиночному, и к множественному наследованию, но во втором случае картина усложняется. Нет смысла вдаваться в детали, но при множественном наследовании вычислить смещение, чтобы найти указатели виртуальной таблицы в объектах, намного сложнее; в одном объекте содержится несколько таких указателей (по одному для каждого из базовых классов); и кроме отдельных виртуальных таблиц должны создаваться еще специальные виртуальные таблицы для базовых классов. В результате возрастают расходы памяти, 5-679
ПЯИИНШ Эффективность используемой классами и объектами для виртуальных функций, и немного увеличивается стоимость вызова функции во время выполнения программы. При множественном наследовании часто приходится создавать виртуальные базовые классы. В противном случае, если производный класс имеет более одного пути наследования от базового класса, элементы данных этого базового класса копируются в каждый объект производного класса, по одному экземпляру для каждого пути между производным и базовым классами. Программисты обычно пытаются не допустить такого копирования, сделав базовые классы виртуальными. Но включение в код виртуальных базовых классов все равно приводит к дополнительным издержкам, поскольку их реализация часто использует указатели на части виртуального базового класса, чтобы избежать копирования, и в объектах могут храниться один или более таких указателей. Рассмотрим следующий пример, который я называю «ужасным бриллиантом множественного наследования» (см. рис. 4.5). А class А { ... } ; class В: virtual public A { . . . }; В С class С: virtual public A { . . . }; \ f class D: public В, public С { . . . }; \ / D Рис. 4.5 В этом примере А является виртуальным базовым классом, так как классы В и С виртуально наследуют от него. В некоторых компиляторах (особенно старых) объект типа D может иметь вид, представленный на рис. 4.6. Кажется немного странным помещать элементы данных базового класса в конец объекта, но зачастую это делается именно так. Конечно же, в разных реализациях память организована по-разному, поэтому предложенный рисунок Данные класса В Vujjin нииртуинныи (il,jR.t1 fc"jRC Данные класса С Указатель на виртуальный базовый класс Данные класса D Данные класса А /\ 4 Рис. 4.6
Правило 24 ШШМНЕЯ только в общих чертах иллюстрирует то, как использование виртуальных базовых классов может приводить к появлению скрытых указателей на объекты. В некоторых реализациях добавляется меньшее число указателей, а в некоторых они не добавляются вообще (в таком случае указатели виртуальных таблиц и виртуальной таблицы несут двойную нагрузку). Если объединить этот рисунок с предыдущим, на котором было показано, как к объектам добавляются указатели виртуальной таблицы, станет ясно, что если базовый класс А в иерархии, приведенной на станице 130, имеет виртуальные функции, то объект типа D будет устроен примерно следующим образом (см. рис. 4.7). »h 1 Vm Данные класса В i i п.| i,i i 1 «М t" Данные класса С > i . i 1 И It П ' i L'МП Данные класса D Данные класса А vptr Ni nil1 Рис. 4.7 На рисунке закрашены части объекта, которые добавляются компилятором. Отношение закрашенных и пустых областей определяется объемом данных в классе. Для маленьких классов дополнительные расходы будут относительно велики. Для классов с большим объемом данных затраты окажутся менее значительными, хотя и достаточно заметными. Странность этой схемы в том, что хотя она включает четыре класса, на ней изображены только три указателя виртуальных таблиц. Различные реализации могут создавать и четыре указателя виртуальных таблиц, но достаточно всего трех (оказывается, объекты В и D могут совместно использовать один указатель), и большинство реализаций с помощью этого добивается снижения накладных расходов, привносимых компилятором. Итак, вы увидели, что применение виртуальных функций делает объекты больше и мешает использованию встроенных функций, и убедились, что множественное наследование и виртуальные базовые классы также могут увеличивать размер объектов. Обратимся теперь к последней теме, затратам на идентификацию типов во время выполнения программы (runtime type identification, сокращенно RTTI). 5*
ill Эффективность Идентификация типов во время выполнения программы позволяет получать информацию об объектах и классах. При этом для хранения запрашиваемой информации нужно отвести определенное место. Информация содержится в объекте типа type_inf о, доступ к которому в классе можно получить при помощи оператора type id. По логике в каждом классе должна быть только одна копия данных RTTI, и должен существовать способ получения такой информации для каждого объекта. В действительности это не совсем так. В спецификации языка сказано, что гарантируется получение точных сведений о динамическом типе объекта, только если этот тип содержит хотя бы одну виртуальную функцию. Данные RTTI выполняют примерно ту же задачу, что и таблица виртуальных функций. Вам нужна только одна копия информации для каждого класса, и нужно иметь способ получения соответствующей информации из любого объекта, содержащего виртуальную функцию. Параллель между RTTI и таблицами виртуальных функций не случайна: RTTI была разработана для ее реализации с помощью виртуальной таблицы класса. Например, индекс 0 в массиве виртуальной таблицы может содержать указатель на объект type_inf о соответствующего таблице класса. Виртуальная таблица класса С1 со стр. 125 имела бы тогда вид, представленный на рис. 4.8. С1 vtbl typejnfo класса С1 реализация С1::~С1 реализация C1::f1 реализация C1::f2 реализация C1::f3 Рис. 4.8 При такой реализации память будет тратиться только на добавление еще одной ячейки в каждую виртуальную таблицу и выделение места для хранения одного объекта type_inf о для каждого класса. Так же как потери памяти на создание виртуальных таблиц вряд ли будут заметны в большинстве приложений, так же маловероятно, что возникнут проблемы из-за размера объектов type_inf о. В табл. 4.1 приведены сведения о затратах, сопровождающих использование виртуальных функций, множественного наследования, виртуальных базовых классов и идентификации типов. Некоторые, увидев эту таблицу, могут ужаснуться и заявить: «Я продолжаю писать на С!». Но помните, что каждое из описанных свойств обеспечивает какие- то функции, которые иначе придется программировать вручную. В большинстве случаев самостоятельная реализация оказывается менее эффективной и надежной, чем код, созданный компилятором. Например, эмуляция вызовов виртуальных функций с помощью вложенных операторов switch или каскадирование операторов if-then-else дает больше кода, чем вызовы виртуальных функций, и этот код к тому же медленнее выполняется. Более того, вам придется отслеживать типы
Правило 24 111НШИШЗ Таблица 4.1 Свойство Увеличивает Увеличивает Препятствует размер объектов объем данных использованию в классе встроенных функций Виртуальные Да Да Да функции Множественное Да Да Нет наследование Виртуальные Часто Иногда Нет базовые классы RTTI Нет Да Нет объектов вручную, из-за чего объекты будут также содержать метки типов; поэтому зачастую вы не получите никакого выигрыша даже от меньшего размера объектов. Важно понимать, к каким затратам приводит использование виртуальных функций, множественного наследования, виртуальных базовых классов и RTTI, но также важно понимать, что если вам нужна функциональность этих свойств, то вы должны так или иначе заплатить за нее. Иногда существуют разумные причины для обхода предоставляемых компиляторами возможностей. Например, скрытые указатели виртуальных таблиц и указатели на виртуальные базовые классы могут усложнить сохранение объектов C++ в базе данных или их передачу между процессами, поэтому стоит эмулировать эти свойства, чтобы было легче выполнять подобные задачи. Но с точки зрения эффективности, запрограммировав свойства самостоятельно, вы вряд ли выиграете по сравнению с их реализацией при помощи компилятора.
Глава 5. Приемы Большая часть книги посвящена принципам программирования. Они, конечно, важны, но программисты не живут только принципами. Как говорилось в старом мультсериале «Кот Феликс»: «Попадая впросак, достает он трюков рюкзак». Если персонаж мультфильма может иметь рюкзак трюков, это могут и программисты на C++. Считайте эту главу началом вашей коллекции нестандартных приемов. При разработке программ на языке C++ специалисты постоянно сталкиваются с одними и теми же проблемами. Как заставить конструкторы и функции, не являющиеся членами класса, работать подобно виртуальным функциям? Как предотвратить создание объектов в куче? Как, наоборот, гарантировать их создание там? Как создавать объекты, которые автоматически выполняют какие-либо функции при вызове других функций - членов класса? Как разные объекты могут совместно использовать структуры данных, чтобы у клиентов при этом возникала иллюзия, будто каждый из объектов обладает собственной копией? Как различать выполнение записи и чтения в operator [ ] ? Как создать виртуальную функцию, поведение которой зависит от динамического типа нескольких объектов? В настоящей главе, где описаны проверенные решения для задач, часто возникающих перед программистами на C++, даются ответы на все эти (и многие другие) вопросы. Я называю такие решения приемами, но они также известны как идиомы или, если изложены стилизованно, прототипы. Независимо от того, как вы будете их называть, изложенная ниже информацию обязательно пригодится вам в ежедневной практической работе. Эта глава должна также убедить вас, что для языка C++ практически нет ничего невозможного. Правило 25. Делайте виртуальными конструкторы и функции, не являющиеся членами класса Для начала надо пояснить, что подразумевается под термином «виртуальные конструкторы». Виртуальные функции вызываются для выполнения операций, привязанных к конкретному типу, если есть указатель или ссылка на объект, но не известно, какой тип имеет объект. Конструктор же вызывается, когда объекта еще нет, но уже в точности известно, какой тип он будет иметь. Как же тогда можно говорить о виртуальных конструкторах? Это легко объяснить. Хотя виртуальные конструкторы многим кажутся бессмысленными, они весьма полезны. (Если вы считаете, что бессмысленные идеи бесполезны, чем вы тогда объясните успехи современной физики?) Например, предположим, вы пишете приложение, работающее с информационными сообщениями, которые
Правило 25 11МНЕШ состоят из блоков текста или графики. Можно организовать программу следующим образом: class NLComponent { // Абстрактный базовый класс public: // для блоков сообщения. }; class TextBlock: public NLComponent { public: ... //He содержит }; // абстрактных функций. class Graphic: public NLComponent { public: ... //He содержит }; // абстрактных функций. class NewsLetter { // Объект сообщения, public: // Состоит из списка ... // объектов NLComponent. private: list<NLComponent*> components; }; Классы связаны так, как показано на рис. 5.1. Рис. 5.1 Класс list, используемый внутри объекта NewsLetter, является частью стандартной библиотеки шаблонов, которая входит в стандартную библиотеку языка C++ (см. правило 35). Объекты типа list ведут себя как двусвязные списки, хотя они и не обязательно должны быть реализованы именно так. Объекты NewsLetter, к которым еще не обращались, вероятно, находятся на диске. Чтобы формировать объект NewsLetter из его образа на диске, было бы удобно создать для него конструктор с параметром istream. Этот конструктор
ШШШШШт Приемы станет считывать информацию из потока после создания необходимых внутренних структур данных: class NewsLetter { public: NewsLetter(istreamk str); }; Псевдокод конструктора мог бы выглядеть примерно так: NewsLetter::NewsLetter(istreamk str) { while (str) { считать следующий объект из потока str; добавить объект к списку блоков сообщения; } } или, если поместить код для чтения из потока в отдельную функцию readComponent, так: class NewsLetter { public: private: // Считать данные следующего блока NLComponent из str, // создать блок и вернуть указатель на него, static NLComponent * readComponent(istreamk str) ; }; NewsLetter::NewsLetter(istreamk str) { while (str) { // Добавить указатель, который вернула функция // readComponent в конец списка блоков; push_back - // это функция - член списка, вставляющая элемент //в конец списка. components.push_back(readComponent(str)); } } Рассмотрим работу функции readComponent. Она создает новый объект, имеющий тип TextBlock или Graphic в зависимости от считываемых данных. Так как она создает новые объекты, то работает почти как конструктор, но поскольку при этом могут создаваться объекты разных типов, ее обычно называют виртуальным конструктором. Виртуальный конструктор - это функция, которая создает объекты разного типа в зависимости от входных данных*. Виртуальные * Строго говоря, эта функция не является конструктором, но поскольку виртуальные конструкторы в C++ в отличие от Object Pascal или SmallTalk отсутствуют, использование данного термина применительно к такого рода функциям не должно вызывать затруднений у читателя. (Прим. ред.)
Правило 25 ilMHHEa конструкторы полезны в различных обстоятельствах, например при чтении данных объекта с диска (или по сети, или с накопителя на магнитной ленте и т.д.). Широко используется также разновидность виртуального конструктора - виртуальный конструктор копирования; он возвращает указатель на новую копию объекта, вызывающего функцию. Из-за такого поведения виртуальные конструкторы копирования обычно имеют имена типа copySelf, cloneSelf или просто clone, как в следующем примере. Немного найдется функций, которые были бы реализованы столь же просто. class NLComponent { public: // Объявление виртуального конструктора копирования. virtual NLComponent * clone() const = 0; }; class TextBlock: public NLComponent { public: virtual TextBlock * clone() const // Виртуальный { return new TextBlock (*this) ; } //конструктор ... // копирования. }; class Graphic: public NLComponent { public: virtual Graphic * clone() const // Виртуальный { return new Graphic (*this) ; } // конструктор // копирования. }; Как видите, виртуальный конструктор копирования просто вызывает реальный конструктор копирования объекта. Значение «копирования», следовательно, одинаково в обеих функциях. Если реальный конструктор копирования выполняет простое копирование, то же самое делает и виртуальный конструктор копирования. Если реальный конструктор выполняет сложное копирование, аналогично поступает и виртуальный. Если реальный конструктор копирования осуществляет какие-то особые действия, например подсчет ссылок или копирование по записи (см. правило 29), то же самое делает и виртуальный конструктор. Совместимость - замечательная вещь. Обратите внимание, что приведенная выше реализация использует преимущества ослабленных ограничений на тип возвращаемого виртуальной функцией значения, которые были приняты относительно недавно. Переопределенная в производном классе виртуальная функция базового класса больше не должна объявлять тот же самый тип возвращаемого значения. Вместо этого, если функция возвращает указатель (или ссылку) на базовый класс, то функция производного класса может возвращать указатель (или ссылку) на класс, производный от базового. Это не приводит к появлению «дыр» в системе типов C++ и позволяет корректно объявлять такие функции, как виртуальные конструкторы копирования. Поэтому виртуальный конструктор копирования clone объекта TextBlock может
ЕШНММ1Ш Приемы возвращать указатель TextBlock*, а виртуальный конструктор копирования clone объекта Graphic - указатель Craphic*, хотя функция clone объекта NLComponent возвращает указатель NLComponent*. Существование виртуального конструктора копирования в объекте NLComponent позволяет легко реализовать конструктор копирования (обычный) для объекта NewsLetter: class NewsLetter { public: NewsLetter(const NewsLetterk rhs) ; private: list<NLComponent*> components; }; NewsLetter::NewsLetter(const NewsLetterk rhs) { / / Итерация по списку rhs, для копирования элемента в список // блоков объекта используется его виртуальный конструктор // копирования. Детали работы этого кода // обсуждаются в правиле 35. for (list<NLComponent*>::const_iterator it = rhs.components.begin(); it != rhs.components.end(); ++it) { // it указывает на текущий элемент rhs . components , // поэтому для получения копиии вызывается // его функция clone, и полученный дубликат добавляется //в конец списка блоков этого объекта, components.push_back((*it)->clone()) ; } } Если вы не знакомы со стандартной библиотекой шаблонов, этот код может показаться вам странным, но основная его идея проста: обходится список блоков для копируемого объекта NewsLetter, и для каждого блока вызывается его виртуальный конструктор копирования. В данном случае вам нужен виртуальный конструктор копирования, так как список содержит указатели на объекты NLComponent, хотя на самом деле каждый из указателей ссылается на объект типа TextBlock или Graphic. Необходимо скопировать то, на что ссылается указатель, и сделать это можно при помощи виртуального конструктора копирования. Виртуализация функций - не членов класса Как и конструкторы, функции - не члены класса в действительности не могут быть виртуальными. Однако так же, как имеет смысл представлять виртуальными функции, создающие новые объекты разных типов, точно так же имеет смысл представлять виртуальными функции - не члены класса, поведение которых зависит от динамического типа их параметров. Предположим, например, что вы хотите реализовать операторы вывода для классов TextBlock и Graphic. Очевидный
Правило 25 Ш1НННШЕ1 подход к решению подобной задачи состоит в том, чтобы сделать операторы вывода виртуальными. Но оператор вывода - это operator«, а левым аргументом данной функции является ostream&, что не позволяет сделать его функцией - членом класса TextBlock или Graphic. Если попытаться обойти запрет, посмотрите, что произойдет: class NLComponent { public: // Необычное объявление оператора вывода. vitual ostreamk operator« (ostreamk str) const = 0; class TextBlock,: public NLComponent { public: // Виртуальный оператор вывода (также не принято) . virtual ostreamk operator«(ostreamk str) const; }; class Graphic: public NLComponent { public: // Виртуальный оператор вывода (также не принято) . virtual ostream& operator«(ostreamk str) const; }; TextBlock t; Graphic g; t « cout; // Вывести t в cout при помощи // виртуального operator<< // (необычный синтаксис). g « cout; // Вывести t в cout при помощи // виртуального operator<< // (необычный синтаксис) . Клиенты должны помещать объект потока справа от символа «, что противоречит соглашению для операторов вывода. Чтобы вернуться к обычному синтаксису, придется вывести operator« из классов TextBlock и Graphic, но если делать это, нельзя будет объявлять его как виртуальный. Альтернативный подход - объявить виртуальную функцию для вывода (например, функцию print) и определить ее в классах TextBlock и Graphic. Но в этом случае синтаксис вывода объектов TextBlock и Graphic будет не совпадать с синтаксисом остальных типов языка, использующих operator« в качестве оператора вывода. Ни одно из предложенных решений не является удовлетворительным. Вам нужно, чтобы функция - не член класса вызывала operator«, который вел бы себя подобно виртуальной функции, такой как print f. Обратите внимание: описание того, что нужно, очень близко к описанию того, как это можно сделать. Необходимо определить обе функции operator« и print и вызвать вторую из первой! class NLComponent { public:
юшннш virtual ostream& print(ostreamk s) const = 0; }; class TextBlock: public NLComponent { public: virtual ostream& print(ostreamk s) const; }; class Graphic: public NLComponent { public: virtual ostreamk print(ostreamk s) const; }; inline ostream& operator<<(ostream& s, const NLComponentk c) { return с.print(s); } Подобным образом легко создавать функции - не члены класса, действующие как виртуальные. Вначале пишутся виртуальные функции, которые выполняют работу, а затем функция - не член класса, всего лишь вызывающая виртуальную функцию. Чтобы избежать расходов на вызов функции в этом синтаксическом фокусе, нужно сделать невиртуальную функцию встроенной. Теперь, когда вы знаете, как сделать, чтобы функции - не члены класса работали как виртуальные в случае одного аргумента, вы можете поинтересоваться, можно ли сделать то же самое для нескольких аргументов. Да, но это непросто. Насколько непросто? Обратитесь к правилу 31, которое посвящено данному вопросу. Правило 26. Ограничивайте число объектов в классе Иногда для эффективной работы компьютера приходится ограничивать число используемых объектов. Например, в вашей системе есть только один принтер и вы хотите, чтобы объект принтера был единственным. Или вы можете выдать только 16 файловых дескрипторов, поэтому вы хотите быть уверены, что существует не больше 16 объектов файловых дескрипторов. Как можно сделать это? Как ограничить число объектов? Если взять за образец доказательство при помощи математической индукции, следовало бы начать с п = 1, а затем действовать по индукции. К счастью, это не доказательство. Более того, будет поучительным начать с п = 0, чтобы узнать, как можно предотвратить создание экземпляров объектов? Разрешение создания нулевого числа объектов или одного объекта При каждом создании экземпляра объекта будет вызван его конструктор. В этом случае проще всего предотвратить создание объектов определенного класса, объявив его конструкторы как закрытые: Приемы
Правило 26 ЯНПНШ class CantBelnstantiated { private: CantBelnstantiated(); CantBeInstantiated(const CantBelnstantiatedk); }; Запретив при этом создание объектов, можно избирательно ослаблять заданное ограничение. Если, например, вы хотите создать класс для принтеров, но желаете видеть в своей системе только один принтер, можно инкапсулировать объект принтера в функции так, чтобы все имели доступ к принтеру, но создавался только один объект: class Print Job; // Предварительное объявление. class Printer { public: void submitJob(const PrintJob& job) ; void reset () ; void performSelfTest(); friend Printers thePrinter(); private: Printer(); Printer(const Printers rhs) ; }; Printers thePrinter() { static Printer p; // Единственный объект принтера. return p; } Эта схема состоит из трех компонентов. Во-первых, конструкторы класса Printer являются закрытыми. Это предотвращает создание объектов. Во-вторых, объявляется дружественная к классу глобальная функция thePrinter. Это позволяет функции thePrinter получать доступ к закрытым конструкторам. И наконец, функция thePrinter содержит статический объект Printer. Это означает, что может быть создан только один объект. Код клиента осуществляет взаимодействие с единственным принтером системы при помощи функции thePrinter. Возвращая указатель на объект Printer, функция thePrinter может использоваться везде, где мог бы находиться объект Printer: class PrintJob { public: PrintJob(const strings whatToPrint); }; string buffer; // Поместить данные в буфер.
ЕШВНМ11 пРиемы thePrinter().reset() ; thePrinter().submitJob(buffer); Вероятно, функция thePrinter покажется вам ненужным добавлением к глобальному пространству имен. Вы можете сказать: «Эта глобальная функция больше похожа на глобальную переменную, а использовать глобальные переменные плохо, и я бы предпочел, чтобы все функции, связанные с принтером, находились внутри класса Printer». Я не буду спорить с теми, кто считает, что это плохо. Можно сделать функцию thePrinter статической функцией- членом класса Printer. Это также устраняет необходимость объявления ее как friend, что многие считают нелогичным. При использовании статической функции-члена класс Printer примет следующий вид: class Printer { public: static Printers thePrinter(); private: Printer)) ; Printer(const Printers rhs); }; Printers Printer::thePrinter() { static Printer p; return p; } Клиентам придется вызывать принтер более многословно: Printer::thePrinter().reset(); Printer::thePrinter().submitJob(buffer); Другой подход заключается в том, чтобы переместить класс Printer и функцию thePrinter из глобального в отдельное пространство имен (namespace). Пространства имен являются сравнительно недавним дополнением языка C++. Все, что можно объявить глобально, можно объявить и в отдельном пространстве имен, включая классы, структуры, функции, переменные, объекты, определения типов и т.д. Выделение в отдельное пространство имен не изменяет поведение всех этих субстанций, но позволяет предотвратить конфликты между различными пространствами имен. Поместив класс Printer и функцию thePrinter в отдельное пространство имен, вы можете не беспокоиться о том, что кто-либо еще выберет имена Printer и thePrinter; отдельное пространство имен предотвращает конфликты имен. Синтаксически пространства имен очень похожи на классы, но в них нет открытых, закрытых или защищенных разделов; вся информация является открытой. Вы можете поместить класс Printer и функцию thePrinter в пространство имен PrintingStuf f следующим образом: namespace PrintingStuff { class Printer { // Этот класс находится public: //в пространстве имен PrintingStuf f.
Правило 26 _ Ш1ММИШ void submit Job(const PrintJob& job); void reset () ; void performSelfTest(); friend Printers thePrinter(); private: Printer(); Printer(const Printers rhs) ; }; Printers thePrinterO // И эта функция тоже. { static Printer p; return p; } } // Конец пространства имен. Если задано пространство имен, то пользователи могут вызывать функцию thePrinter при помощи полного имени (которое включает имя пространства имен): PrintingStuff::thePrinter().reset() ; PrintingStuff::thePrinter().submitJob(buffer); но также могут применить объявление us ing, чтобы записать это более компактно: using PrintingStuff::thePrinter; // Импортировать имя // thePrinter //из пространства имен // PrintingStuff. thePrinter () . reset () ; // Теперь можно использовать thePrinter().submitJob(buffer); // thePrinter // как локальное имя. В реализации функции thePrinter есть две тонкости, достойные отдельного рассмотрения. Во-первых, важно, чтобы единственный объект Printer был статическим в функции, а не в классе. Объект, который является статическим в классе, всегда создается (и уничтожается), даже если он не используется. Если же объект является статическим в функции, то он создается при первом вызове функции, поэтому если она вовсе не вызывается, то и объект не будет создан. (Но тогда при каждом вызове функции придется проверять, нужно ли создавать объект.) Одно из философских оснований, на которых был построен язык C++, - представление о том, что не стоит платить за вещи, которые не используются, и определение такого объекта, как принтер, в качестве статического в функции является одним из способов придерживаться данного тезиса. Чтобы ваши программы были эффективными, следуйте этому принципу везде, где возможно. Если сделать объект принтера статическим в классе, а не в функции, то обнаружится еще один недостаток такого подхода: не будет известно время инициализации объекта. Вы знаете, что статический элемент функции инициализируется при первом вызове функции в точке определения статического элемента. В случае определения
EEOHHUP . пРиемы объекта статическим в классе (или глобальным статическим, если вы додумались сделать это) время инициализации объекта определено не столь точно. Язык C++ гарантирует порядок инициализации статических объектов в определенном транслируемом модуле (то есть блоке исходного кода, компиляция которого дает один объектный файл), но в спецификации языка ничего не говорится о порядке инициализации статических объектов в различных транслируемых модулях. На практике это вызывает бесконечные проблемы, которых нетрудно избежать, если в данной ситуации допустимо определение статических объектов в функции. В нашем примере это возможно. Вторая тонкость связана с взаимодействием встраивания и статических объектов внутри функций. Посмотрите снова на код версии, в которой функция thePrinter не является членом класса: Printer thePrinter () { static Printer p; return р; } Кроме первого вызова (когда должен быть создан объект р) в этой функции выполняется только одна строка- return p;. Если и есть функции, которые могут быть встраиваемыми, то это, похоже, одна из них. Тем не менее, она не объявлена как встраиваемая. Почему? Вспомним, зачем объект объявляется как статический. Это обычно вызвано тем, что нужна только одна копия данного объекта, не так ли? Теперь вспомним, что означает директива inline: концептуально компиляторы должны заменять вызов функции подстановкой ее тела. Но для функций - не членов класса значение директивы inline этим не ограничивается, потому что для них выполняется внутренняя компоновка. Обычно не следует беспокоиться о подобных нюансах, главное помнить: функции с внутренней компоновкой (internal linkage) могут дублироваться в программе (то есть код объекта может содержать более одной копии каждой функции с внутренней компоновкой), и такое дублирование включает статические объекты внутри функций. Что получится в результате? Если вы создаете встраиваемую функцию - не член класса, содержащую локальный статический объект, это может привести к созданию более одной копии статического объекта в программе! Поэтому не создавайте встраиваемые функции - не члены класса со статическими данными.* * В июле 1996 года комитет ISO/ANSI по стандартизации изменил для встраиваемых функций компоновку по умолчанию на внешнюю, поэтому описываемая мной проблема уже устранена, по крайней мере на бумаге. Тем не менее, ваши компиляторы могут еще не соответствовать стандарту, поэтому лучше избегать применения встраиваемых функций со статическими данными.
Правило 26 11НПНШ Но может быть, вы считаете, что создание функции, возвращающей ссылку на скрытый объект, - это не совсем правильный способ ограничить число объектов. Может быть, вы думаете, что лучше просто подсчитать число существующих объектов и, если запрашивается слишком много объектов, генерировать исключение в конструкторе. Другими словами, может быть, вы считаете, что необходимо обрабатывать создание объекта принтера так: class Printer { public: class TooManyObjects{}; // Класс исключений; // используется, если // запрашивается слишком // много объектов. Printer(); -Printer(); private: static size_t numObjects; Printer(const Printers rhs); //He больше 1 принтера, // поэтому запретить }; // копирование. Идея заключается в том, чтобы использовать переменную NumObjects для отслеживания числа существующих объектов Printer. Ее значение увеличивается на единицу в конструкторе класса и уменьшается на единицу в его деструкторе. Если делается попытка создать слишком много объектов Printer, то генерируется исключение типа TooManyObjects: // Обязательное определение // статического объекта класса. size_t Printer::numObjects = 0; Printer::Printer() { if (numObjects >= 1) { throw TooManyObjects(); } продолжить создание объекта; ++numObjects; } Printer::-Printer() { выполнить обычное уничтожение объекта ; --numObjects; } Такой подход к ограничению числа создаваемых объектов привлекателен по двум причинам. Во-первых, он просто реализован - несложно понять, что при этом происходит. Во-вторых, его легко обобщить в случае, если максимально возможное число объектов должно быть больше единицы.
Hi Приемы Контекст создания объектов Однако при использовании данной стратегии возникает одна проблема. Предположим, что имеется особый тип принтера, например цветной принтер. Класс таких принтеров будет иметь много общего с обобщенным классом принтера, поэтому логично выполнить наследование от него: class ColorPrinter: public Printer { }; Предположим теперь, что в системе есть один простой принтер и один цветной: Printer p; ColorPrinter cp; Сколько объектов Printer получится в результате этих определений объектов? Два: один для р и один для части Printer в ср. Во время работы программы при создании части базового класса в ср будет сгенерировано исключение TooManyObj ects. Для большинства программистов это будет и нежелательным, и неожиданным. (Если при разработке не использовать наследование конкретных классов от других конкретных классов, эта проблема не возникает. Детали такого подхода см. в правиле 33.) Похожие сложности возникают, если объекты Printer находятся внутри других объектов: class CPFMachine { // Устройства, которые могут // копировать, private: // печатать и отправлять факсы. Printer p; // Принтер. FaxMachine f; // Факс. CopyMachine с; // .Копировальный аппарат. }; CPFMachine ml; // Нормально. CPFMachine m2; // Генерирует исключение // TooManyObjects. Проблема состоит в том, что объекты Printer могут существовать в трех различных контекстах: сами по себе, как части базового класса в производных объектах и как части более крупных объектов. Наличие этих различных контекстов значительно размывает понятие «число существующих объектов», так как ваш взгляд на существование объекта может отличаться от точки зрения ваших компиляторов. Часто вы заинтересованы в том, чтобы разрешить создание только отдельных объектов, и вам нужно ограничить число только таких экземпляров объекта. Этого легко добиться, используя исходную стратегию для класса Printer, поскольку конструкторы класса Printer при этом являются закрытыми, а классы с закрытыми конструкторами не могут быть использованы в качестве базовых классов (при отсутствии объявлений friend) и не могут быть встроены в другие объекты.
Правило 26 Ш1МНШ Запрет на создание производных классов от классов с закрытыми конструкторами приводит к общей схеме предотвращения создания производных классов, которая не обязательно должна сочетаться с ограничением числа экземпляров объекта. Предположим, например, что существует класс FSA для представления конечных автоматов. (Такие машины состояний полезны во многих случаях, включая разработку пользовательского интерфейса.) Предположим далее, вы хотите, чтобы можно было создать любое число объектов FSA, но в то же время иметь гарантию, что ни один класс не сможет наследовать от класса FSA. (Это может потребоваться, например, для того чтобы обеспечить существование невиртуального деструктора в классе FSA. Как объясняется в правиле 24, классы без виртуальных функций дают объекты меньшего размера, чем эквивалентные классы с виртуальными функциями.) Удовлетворить обоим критериям можно, реализовав класс FSA следующим образом: class FSA { public: // Псевдоконструкторы. static FSA * makeFSAO ; static FSA * makeFSA(const FSA& rhs) ; private: FSA () ; FSA(const FSA& rhs); }; FSA * FSA: :makeFSAO { return new FSA(); } FSA * FSA: : makeFSA (const FSA& rhs) { return new FSA(rhs) ; } В отличие о функции thePrinter, которая всегда возвращала ссылку на единственный объект, каждый псевдоконструктор makeFSA возвращает указатель на уникальный объект. Это позволяет создавать неограниченное число объектов FSA. Однако вызов в каждом псевдоконструкторе оператора new подразумевает, что необходимо не забыть затем вызвать оператор delete, иначе возникнет утечка ресурсов. Для автоматического вызова delete при выходе из зоны видимости можно сохранять указатель, возвращаемый функцией makeFSA в объекте auto_ptr (см. правило 9); такие объекты автоматически удаляют то, на что они ссылаются, когда покидают зону видимости: // Косвенный вызов конструктора по умолчанию FSA. auto_ptr<FSA> pfsal(FSA::makeFSA()); // Косвенный вызов конструктора копирования FSA. auto_ptr<FSA> pfsa2(FSA::makeFSA(*pfsal)); ... // Используйте pfsal и pfsa2 как обычные указатели, // но не беспокойтесь об их удалении.
ЕШНН1НК .. Приемы Разрешение создания и удаления объектов Теперь вы знаете, как сформировать класс, разрешающий создание только одного экземпляра объекта. Кроме того, вам известно: отслеживание числа объектов определенного класса осложняется тем, что конструкторы объекта вызываются в трех различных контекстах, и чтобы избавиться от этой путаницы, надо сделать конструкторы закрытыми. И еще одно, последнее замечание. Применение функции thePrinter для инкапсуляции доступа к единственному объекту сводит число объектов Printer к одному, но во время каждого запуска программы может быть всего один объект Printer. В результате нельзя написать такой код: создать объект р! типа Printer; использовать pi ; уничтожить pi ; создать объект р2 типа Printer; использовать р2; уничтожить р2; При таком подходе не существует более одного экземпляра объекта Printer одновременно, но в разных частях программы используются различные объекты типа Printer. Кажется неблагоразумным, что указанные в коде действия невозможны, так как запрет на существование не более чем одного принтера не нарушается. Можно ли сделать это допустимым? Да. Достаточно объединить код для подсчета числа объектов, который вы использовали ранее, с только что введенными псевдоконструкторами: class Printer { public: class TooManyObjects{}; // Псевдоконструктор. static Printer * makePrinter(); -Printer(); void submitJob(const PrintJob& job) ; void reset(); void performSelfTest(); private: static size_t numObjects; Printer(); Printer(const Printers rhs); // Эта функция }; //не определяется, так как // копирование не разрешено. // Обязательное определение статического объекта класса. size_t Printer::numObjects = 0; Printer::Printer() { if (numObjects >= 1) { throw TooManyObjects(); )
Правило 26 1НШШМШ продолжить обычное создание объекта; ++numObjects ; } Printer * Printer::makePrinter() { return new Printer; } Если требование того, что при попытке создания слишком большого числа объектов надо генерировать исключение, кажется вам чрезвычайно строгим, вы можете вместо этого возвращать в псевдоконструкторе нулевой указатель. Конечно, тогда клиентам придется проверять указатель перед тем, как работать с ним. Клиенты используют класс Printer так же, как и любой другой, только вместо настоящего конструктора они должны вызывать псевдоконструктор: Printer pi; // Ошибка! конструктор //по умолчанию - закрытый. Printer *р2 = Printer::makePrinter();// Нормально, // косвенно вызывает // конструктор по умолчанию. Printer рЗ = *р2; // Ошибка! конструктор // копирования - закрытый. p2->performSelfTest(); // Все остальные функции p2->reset(); // вызываются как обычно. delete p2; // Надо, чтобы избежать // утечки ресурсов; //не нужно, если указатель // р2 имеет тип auto_ptr. Этот метод применим к любому числу объектов. Вы должны только заменить постоянную 1 на нужное значение, а затем снять запрет на копирование объектов. Например, следующая реализация класса Printer допускает существование до 10 объектов Printer: class Printer { public: class TooManyObj ec'ts{}; // Псевдоконструкторы. static Printer * makePrinter(); static Printer * makePrinter(const Printers rhs) ; private: static size_t numObjects; static const size_t maxObjects =10; // См. ниже. Printer(); Printer(const Printers rhs); }; // Обязательное объявление статических объектов класса. size_t Printer::numObjects = 0;
шминм const size_t Printer::maxobjects; Printer::Printer() { if (numObjects >= maxObjects) { throw TooManyObj ects () ; } } Printer::Printer(const Printers rhs) { if (numObjects >= maxObjects) { throw TooManyObj ects () ; } } Printer * Printer::makePrinter() { return new Printer; } Printer * Printer::makePrinter(const Printers rhs) { return new Printer (rhs) ; } He удивляйтесь, если объявление Printer: :maxObjects в определении этого класса компиляторы не примут. В особенности если они будут сообщать об ошибке при присвоении переменной начального значения 10. Возможность присваивать начальные значения статическим элементам, объявленным как const (целочисленных типов, например int, char, enum и т.д.) внутри определения класса была добавлена в язык C++ сравнительно недавно, поэтому некоторые компиляторы еще не позволяют этого. Если ваши компиляторы относятся к ним, вы можете объявить maxObjects как счетчик в закрытом неименованном перечисляемом типе: class Printer { private: enum { maxObjects = 10 }; //В этом классе ... // maxObjects - постоянная, }; // равная 10. или при помощи инициализации постоянного статического объекта как статического элемента не const: class Printer { private: static const size_t maxObjects; // He задано начальное // значение. }; // Это находится в отдельном файле реализации, const size_t Printer::maxObjects = 10; Последний способ действует так же, как и вышеприведенный исходный код, но явное определение начального значения облегчает понимание кода другими Приемы
Правило 26 1ПММИШ программистами. Если ваши компиляторы поддерживают задание начального значения для статических const элементов в определении класса, вы должны использовать эту возможность. Базовый класс для подсчета объектов У вышеописанного метода есть один негативный аспект. Если существует множество классов, подобных классу Printer, число экземпляров которых требуется ограничивать, то вам придется писать один и тот же код снова и снова для каждого из классов. В таком «шикарном» языке, как C++, должен быть способ автоматизировать этот процесс. Как же инкапсулировать понятие подсчета числа экземпляров и заключить его в класс? Это легко сделать, создав базовый класс для подсчета числа экземпляров объекта и сделать такие классы, как Printer, его наследниками, но, оказывается, есть лучшее решение. Можно разработать способ инкапсуляции всего набора для подсчета, под которым я подразумеваю не только функции, управляющие подсчетом экземпляров, но и сам алгоритм подсчета экземпляров. (Вы увидите необходимость аналогичного приема при рассмотрении счетчика ссылок в правиле 29.) В классе Printer счетчиком является статическая переменная NumObj ects, поэтому вам нужно поместить эту переменную в класс счетчика экземпляров. Вы должны быть также уверены, что каждый из классов, число экземпляров которых подсчитывается, имеет отдельный счетчик. Применение шаблона класса счетчика позволяет автоматически создавать соответствующее число счетчиков: можно сделать счетчик статическим элементом классов, образованных на основе этого шаблона: template<class BeingCounted> class Counted { public: class TooManyObjects{}; // Для генерации исключений. static int objectCount() { return numObjects; } protected: Counted(); Counted(const Countedk rhs) ; ~Counted() { -numObjects; } private: static int numObjects; static const size_t maxObjects; void init() ; // Чтобы избежать дублирования }; / / кода конструктора. template<class BeingCounted> Counted<BeingCounted>::Counted() { init(); } template<class BeingCounted> Counted<BeingCounted>::Counted (const Counted<BeingCounted>&) • { init(); }
Еавммн template<class BeingCounted> void Counted<BeingCounted>::init() { if (numObjects >= maxObjects) throw TooManyObjects(); ++numObjects; } Классы, созданные на основе этого шаблона, предназначены для использования только в качестве базовых классов, поэтому в код включены закрытые конструктор и деструктор. Обратите внимание, что с помощью закрытой функции - члена класса init удается избежать дублирования операторов в двух конструкторах Counted. Теперь можно изменить класс Printer, используя шаблон Counted: class Printer: private Counted<Printer> { public: // Псевдоконструкторы. static Printer * makePrinter(); static Printer * makePrinter(const Printers rhs); -Printer(); void submitJob(const PrintJob& job); void reset(); void performSelfTest(); using Counted<Printer>::objectCount; using Counted<Printer>::TooManyObjects; private: Printer() ; Printer(const Printers rhs) ; }; To, что класс Printer отслеживает число существующих объектов Printer с помощью шаблона Counted, честно говоря, не касается никого, кроме автора класса Printer. Такие детали реализации лучше всего делать закрытыми, и поэтому здесь применяется закрытое наследование. Альтернативный метод - использовать открытое наследование между Printer и Counted<Printer>, но в этом случае вы были бы вынуждены определить в классах Counted виртуальный деструктор. (Иначе возник бы риск неправильного поведения, если кто-либо удалил бы объект Printer при помощи указателя Counted<Printer>*.) Как поясняется в правиле 24, наличие виртуальной функции в классе Counted практически всегда будет влиять на размер и состав объектов классов, наследующих от Counted. Дополнительные расходы вам не нужны, и использование закрытого наследования позволяет избежать этого. Совершенно правильно, что большая часть действий класса Counted скрыта от клиентов объекта Printer, но клиенты должны иметь способ определять число существующих объектов Printer. Шаблон Counted включает функцию obj ectCount, которая выдает эту информацию, однако данная функция в объекте Приемы / / См. ниже. //См. ниже.
Правило 26 Ш1ИНЯ1ЕШ Printer становится закрытой из-за использования закрытого наследования. Доступ к ней обеспечивается при помощи объявления using: class Printer: private Counted<Printer> { public: using Counted<Printer>::objectCount; // Сделать эту // функцию открытой ... // для клиентов // Printer. }; Это вполне допустимо, но только если ваши компиляторы поддерживают пространства имен. В противном случае вы можете использовать старый синтаксис объявления доступа: class Printer: private Counted<Printer> { public: Counted<Printer>::objectCount; // Сделать objectCount // открытой в объекте ... // Printer. }; Этот более традиционный синтаксис означает то же самое, что и объявление us ing, но он обладает определенными недостатками. Класс TooManyObj ect s обрабатывается аналогично классу ObjectCount, так как клиенты объекта Printer должны иметь доступ к TooManyObj ects, если они могут перехватывать исключения данного типа. Если класс Printer наследует от класса Counted<Printer>, он может «забыть» о подсчете объектов. Иногда этот класс пишется так, как будто действия по подсчету выполняются где-то еще, а именно в классе (Counted<Printer>). Конструктор Printer в таком случае выглядит следующим образом: Printer::Printer() { продолжить обычное создание объекта; } Здесь интересно не то, что вы видите, а то, чего вы не видите. Здесь нет проверки числа объектов, счетчик объектов не увеличивается после завершения конструктора. Все эти операции выполняются конструкторами Counted<Printer>, и так как Counted<Printer> является базовым классом для класса Printer, то вы знаете, что перед конструктором Printer будет всегда вызываться конструктор Counted<Printer>. Если создается слишком много объектов, то конструктор Counted<Printer> сгенерирует исключение, и конструктор Printer не будет вызываться вообще. Остроумно, не так ли? Как бы это не было остроумно, существует один недостаток, который нужно исправить и который заключается в обязательном определении статических
EEZMMMHI .... Приемы объектов внутри класса Counted. Можно достаточно просто обойтись с переменной NumObjects, поместив в файл Counted следующий код: template<class BeingCounted> // Определяет переменную int Counted<BeingCounted>::numObjects; // numObjects //и автоматически // инициализирует ее нулем. Ситуация с переменной MaxObj ects немного сложнее. Какое начальное значение вы должны присвоить этой переменной? Если вы хотите разрешить создание не более 10 принтеров, следует проинициализировать Counted<Prin- ter>: :maxObjects значением 10. С другой стороны, если вы допускаете существование не более 16 файловых дескрипторов, то надо проинициализировать Counted<FileDescriptor>: :maxObjects значением 16. Что же делать? Выбирайте самый простой путь: не делайте ничего. Не инициализируйте переменные maxObj ects. Вместо этого требуйте, чтобы соответствующую инициализацию выполняли клиенты данного класса. Автор класса Printer должен добавить в файл реализации следующее: const size_t Counted<Printer>::maxObjects = 10; Аналогично, автору класса FileDescriptor необходимо добавить строку: const size_t Counted<FileDescriptor>::maxObjects = 16; Что произойдет, если авторы этих классов забудут определить переменную maxObj ects? Они просто получат сообщение об ошибке во время компоновки, так как переменная maxobjects останется неопределенной. Если вы описали данное требование для пользователей класса Counted, они вспомнят об этом и добавят необходимую инициализацию. Правило 27. В зависимости от ситуации требуйте или запрещайте размещать объекты в куче Иногда необходимо организовать работу так, чтобы объекты определенного типа могли самоуничтожаться, то есть выполнять оператор delete this. Очевидно, что при этом объекты должны быть динамическими. Иногда же нужно быть уверенными, что для определенного класса не возникнут утечки памяти, так как все его объекты расположены не в куче. Это может потребоваться при разработке системы для промышленных применений, когда утечки памяти особенно опасны, а размер кучи ограничен. Можно ли создать код, гарантирующий или запрещающий размещение объектов в куче? Во многих случаях да, но имейте в виду, что понятие «объект находится в куче» часто трактуется по-разному. Гарантированное размещение объектов в куче Рассмотрим вначале запрет на создание объектов вне кучи. Чтобы ввести такое ограничение, вы должны найти способ запретить клиентам создание объектов любым способом, кроме вызова оператора new. Сделать это легко. Объекты, не
Правило 27 ЛИННЕИ размещаемые в куче, обычно создаются в момент их определения и автоматически уничтожаются в конце их существования, поэтому достаточно запретить эти явные создания и уничтожения. Простейший способ не допустить такие вызовы - объявить конструкторы и деструкторы как закрытые, но это чрезмерное требование. Нет необходимости, чтобы они оба были закрытыми. Лучше будет сделать деструктор закрытым, а конструкторы открытыми. Затем, так же как и в правиле 26, можно ввести привилегированную функцию псевдодеструктора, которая будет иметь доступ к настоящему деструктору. После этого для уничтожения созданных объектов клиенты станут вызывать псевдодеструктор. Если, например, нужно гарантировать, чтобы объекты, представляющие числа с неограниченной точностью, создавались только в куче, можно сделать это так: class UPNumber { public: UPNumber(); UPNumber(int initValue); UPNumber(double initValue); UPNumber(const UPNumber& rhs) ; // Псевдодеструктор (функция-член с атрибутом const, // так как объекты с атрибутом const могут уничтожаться) . void destroy() const { delete this; } private: -UPNumber(); }; Пользователи этого класса тогда должны писать примерно следующий код: UPNumber n; // Ошибка! (Здесь разрешается, //но недопустимо, если позже // деструктор п вызывается явно) . UPNumber *p = new UPNumber; // Нормально. delete p; // Ошибка! Попытка вызвать // закрытый деструктор. p->destroy(); //Нормально. В качестве альтернативы можно объявить все конструкторы как закрытые. Недостаток такого подхода состоит в том, что класс часто имеет несколько конструкторов, а автор класса должен помнить о том, чтобы объявить их все как закрытые. В их число может входить конструктор копирования и иногда также конструктор по умолчанию, если эти функции будут созданы компилятором. Функции, создаваемые компилятором, всегда являются открытыми. В результате проще объявить закрытым только деструктор, так как он всего один в классе. Ограничение доступа к деструктору класса или его конструкторам предотвращает создание объектов не в куче. Но по причинами, изложенным в правиле 26, это также запрещает наследование и ограничение области действия (containment):
ill Приемы class UPNumber {...}; class NonNegativeUPNumber: public UPNumber {...}; class Asset { private: UPNumber value; }; // Объявляет закрытые // конструкторы или деструктор. // Ошибка! Деструктор или // конструкторы не скомпилируются. // Ошибка! Деструктор или // конструкторы не скомпилируются. Ни одна из этих трудностей не является непреодолимой. Проблему наследования можно решить, сделав деструктор UPNumber закрытым (оставив конструкторы открытыми), и изменив классы, которые должны содержать объекты типа UPNumber, так, чтобы они содержали вместо этого указатели на объекты UPNumber: class UPNumber {...}; class NonNegativeUPNumber: public UPNumber {...}; class Asset { public: Asset(int initValue) -Asset(); // Объявляет закрытый деструктор. // Теперь нормально; производные // классы имеют доступ к закрытым // членам класса. private: UPNumber * value; }; Asset::Asset(int initValue) : value(new UPNumber(initValue)) ( ... } Asset::-Asset() { value->destroy(); } // Нормально. // Также нормально. Определение, находится ли объекте куче Если вы решите следовать описанной стратегии, то должны уточнить, что подразумевается под понятием «быть в куче». Если класс определен вышеуказанным образом, то допустимо определить объект NonNegativeUPNumber, находящийся не в куче. NonNegativeUPNumber n; // Нормально. Теперь часть UPNumber объекта п типа NonNegativeUPNumber не будет находиться в куче. Приемлемо ли это? Ответ зависит от деталей структуры и реализации класса, но предположим, что это не допустимо, что все объекты типа UPNumber - даже части базового класса в производных объектах - должны находиться в куче. Как можно наложить это ограничение?
Правило 27 Ц Простого способа не существует. Конструктор UPNumber не может определить, вызывается ли он как часть базового класса объекта, расположенного в куче. То есть конструктор UPnumber не определяет, что следующие контексты различны: NonNegativeUPNumber *nl = new NonNegativeUPNumber; // В куче. NonNegativeUPNumber n2; // Не в куче. Возможно, вы мне не верите. Возможно, думаете, что лучше «поиграть» взаимодействием между оператором new, operator new и конструктором, вызываемым оператором new (см. правило 8). Возможно, вы думаете, что способны перехитрить их, изменив класс UPNumber следующим образом: class UPNumber { public: // Исключение, которое генерируется // при создании объекта не в куче, class HeapConstraintViolation {}; static void * operator new(size_t size); UPNumber(); private: static bool onTheHeap; // Флаг в конструкторах, // определяющий, создается ... / / ли объект в куче. }; // Обязательное определение // статического объекта класса, bool UPNumber::onTheHeap = false; void *UPNumber::operator new(size_t size) { onTheHeap = true; return::operator new(size) ; } UPNumber::UPNumber() { if (!onTheHeap) { throw HeapConstraintViolation () ; } продолжить обычное создание объекта; onTheHeap = false; // Сбросить флаг } // для следующего объекта. Здесь не происходит ничего сложного. Основная идея - воспользоваться тем, что объект находится в куче. Для выделения неинициализированной памяти применяется функция operator new, а затем вызывается конструктор для инициализации объекта в этой памяти. В частности, operator new устанавливает флаг onTheHeap равным true, и каждый из конструкторов проверяет данный флаг, чтобы определить, была ли неинициализированная память для объекта выделена при помощи operator new. Если нет, генерируется исключение
EEZlHHHlii Приемы типа HeapConstraintViolation. Иначе конструктор продолжает работу как обычно, и когда завершится создание объекта, флаг onTheHeap устанавливается равным false, что соответствует значению по умолчанию для следующего создаваемого объекта. Хотя это и хорошая идея, она не сработает. Рассмотрим возможный клиентский код: UPNumber *numberArray = new UPNumber[100]; Первая проблема заключается в том, что память для массива выделяется при помощи operator new [ ], а не operator new, но первую функцию (если ее поддерживают ваши компиляторы) можно написать так же просто, как и вторую. Более проблематично, что массив numberArray содержит 100 элементов, поэтому конструктор будет вызван 100 раз. Однако память будет выделена всего единожды, поэтому флаг onTheHeap примет значение true только для первого из этих 100 конструкторов. При вызове второго конструктора будет сгенерировано исключение, и горе вам! Даже без применения массивов работа с флагами может закончиться неудачей. Рассмотрим оператор: UPNumber *pn = new UPNumber (*new UPNumber) ; Здесь в куче создаются два объекта класса UPNumber, и переменная рп указывает на один из них; она инициализируется значением второго из объектов. Этот код приводит к утечке ресурсов, но пока проигнорируем ее и рассмотрим, что происходит при выполнении следующего выражения: new UPNumber (*new UPNumber) Оно содержит два вызова оператора new и, следовательно, два вызова operator new, а также два вызова конструктора UPNumber (см. правило 8). Программисты обычно ожидают, что эти функции будут выполняться поэтапно: 1. Вызвать функцию operator new для первого объекта. 2. Вызвать конструктор для первого объекта. 3. Вызвать operator new для второго объекта. 4. Вызвать конструктор для второго объекта. Но спецификация языка не гарантирует такой порядок выполнения. Некоторые компиляторы вместо этого генерируют вызовы функций следующим образом: 1. Вызвать функцию operator new для первого объекта. 2. Вызвать функцию operator new для второго объекта. 3. Вызвать конструктор для второго объекта. 4. Вызвать конструктор для первого объекта. Компиляторы, которые генерируют такой код, не содержат ошибки, но прием с установкой флага и operator new здесь не работает. Это связано с тем, что флаг устанавливается на 1-ом и 2-ом шаге и сбрасывается на 3-ем шаге, поэтому объект, создаваемый на 4-ом шаге, считает, что он находится не в куче, даже если это и не так.
Правило 27 тшшшшш Подобные затруднения не сводят на нет основную идею проверки в конструкторе, находится ли *this в куче. Они лишь показывают, что проверка флага, устанавливаемого в operator new (или operator new[ ] ), является не самым лучшим способом получения этой информации. Нужен более надежный способ. Если вы достаточно безрассудны, то перед вами может возникнуть искушение «поиграть» с несовместимостью. Например, вы можете использовать то, что во многих системах адресное пространство программы организовано в виде линейной последовательности адресов, и стек программы растет вниз от вершины адресного пространства, а куча поднимается снизу (см. рис. 5.2). Адресное пространство программы (неполная картина) Старшие адреса Младшие адреса Рис. 5.2 Вы можете полагать, что в системах, где память программы организована таким образом (а это немалая часть приложений), будет удобно использовать следующую функцию для проверки, находится ли определенный адрес в куче: // Некорректная попытка определить, / / находится ли адрес в куче. bool onHeap(const void *address) { } char onTheStack; // Локальная стековая переменная, return address < konTheStack; Рассуждения, лежащие в основе этого подхода, достаточно интересны. Переменная onTheStack является локальной переменной функции onHeap и поэтому находится в стеке. При вызове функции onHeap ее кадр стека (то есть ее запись активации) будет помещена на вершину стека программы, и поскольку стек в такой архитектуре растет вниз (в сторону младших адресов), то адрес переменной onTheStack должен быть меньше адреса любых других переменных или объектов, находящихся в стеке. Если параметр address меньше, чем положение переменной onTheStack, то он не может находиться в стеке, следовательно, он находится в куче. Все эти рассуждения прекрасны, но они многого не учитывают. Основная проблема заключается в том, что объекты могут располагаться в трех различных областях, а не в двух. Да, объекты могут находиться в стеке и куче, но не будем забывать
Ill Приемы о статических объектах. Статические объекты - это объекты, которые инициализируются при выполнении программы только один раз. Статические объекты включают в себя не только объекты, объявленные как static, но также объекты, объявленные в глобальном или других пространствах имен. Эти объекты должны где-то находиться, и они находятся не в стеке и не в куче. Где они размещаются, зависит от системы, но на многих системах, в которых стек и куча растут навстречу друг другу, они располагаются под кучей. Предыдущая схема организации памяти, хотя и частично (а на некоторых системах и полностью) соответствует действительности, все же не является универсальной. Если включить в схему статические объекты, она примет вид, представленный на рис. 5.3. / Адресное пространство программы (полная картина) V A.» 111 II- Кун (Pll ll И I t [!') Ми 'и i I l М I Старшие адреса Младшие адреса Рис. 5.3 Неожиданно становится очевидным, почему функция опНеар не будет работать даже на системах, для которых предназначена: она не может различать объекты в куче и статические объекты: void allocateSomeObjects() { char *pc = new chat; char с; static char sc; // Объект в куче: опНеар(рс) // вернет значение true. // Объект в стеке: опНеар(&рс) //вернет значение false. // Статический объект: onHeap(&sc) // вернет значение true. Если вы во что бы то ни стало хотите найти способ отличать объекты в куче от объектов в стеке, то в своем отчаянии можете пойти на сделку с дьяволом переносимости, но настолько ли вы безрассудны, чтобы заключить сделку, которая все равно не гарантирует получение правильных ответов? Конечно же, нет, поэтому
Правило 27 ШИННЕШ вы наверняка отвергнете этот соблазнительный, но ненадежный трюк со сравнением адресов. Печальный факт состоит в том, что не только не существует переносимого способа определить, находится ли объект в куче, не существует также и полупереносимого способа, который работал бы в большинстве случаев. Если вам совершенно необходимо определить, находится ли адрес в куче, придется обратиться к непереносимым, зависящим от реализации системным вызовам, и это все, что можно сделать. Вам лучше попытаться изменить структуру программы, чтобы не было столь необходимо знать, находится ли объект в куче. От ответа на вопрос, где расположен объект, зависит, безопасно ли вызывать для него оператор delete? Часто такое удаление принимает форму печально известного оператора delete this. Однако возможность безопасно удалить указатель говорит не только о том, что он ссылается на что-то в куче, поскольку не для любого указателя на объект в куче можно безнаказанно вызвать оператор delete. Рассмотрим снова объект Asset, содержащий объект UPNumber: class Asset { private: UPNumber value; }; Asset *pa = new Asset; / - Очевидно, что объект *ра (включая его элемент value) находится в куче. Так же очевидно, что небезопасно вызывать оператор delete для указателя на ра-> value, так как этот указатель не был получен в результате вызова оператора new. К счастью, легче определить, можно ли безопасно удалить указатель, чем выяснить, указывает ли он на что-либо в куче, так как все, что нужно для ответа на первый вопрос, - это набор адресов, которые были возвращены функцией operator new. Поскольку можно написать функцию operator new самостоятельно, то легко создать такой набор, например, следующим образом: void *operator new(size_t size) { void *p = getMemory (size) ; // Вызвать функцию // для выделения памяти //и обработки событий // нехватки памяти. добавить р к набору выделенных адресов; return p; } void operator delete(void *ptr) { releaseMemory(ptr); // Освободить память. удалить ptr из набора выделенных адресов памяти; } bool isSafeToDelete(const void *address) { 6-679
Еавпин вернуть результат в зависимости от того, находится ли адрес в наборе выделенных адресов } Это почти так же просто, как и возвращаемое функцией значение. Функция operator new добавляет элементы в набор выделенных адресов, функция operator delete удаляет элементы, а функция isSafeToDelete просматривает набор, определяя, находится ли в нем заданный адрес. Если функции operator new и operator delete являются глобальными, то эта схема будет работать для всех типов, даже для встроенных. На практике три вещи могут ослабить ваш энтузиазм. Первой из них является общее крайнее нежелание определять что-либо глобально, в особенности такие функции с предопределенным значением, как operator new и operator delete. Зная, что существует только одно глобальное пространство имен и только одна версия функций operator new и operator delete с «обычными» сигнатурами (то есть наборами типов параметров) в этом пространстве, меньше всего хотелось бы «захватывать» данные сигнатуры для собственного использования. При этом код стал бы несовместимым с любым другим, где также реализованы глобальные версии operator new и operator delete (такими являются многие объектно-ориентированные системы баз данных). Второе соображение касается эффективности: зачем обременять все операции выделения памяти из кучи накладными расходами на отслеживание возвращаемых адресов, если это не нужно? Последнее соображение является неинтересным, но важным. Оказывается, невозможно реализовать функцию isSafeToDelete так, чтобы она работала во всех случаях: объекты с несколькими или виртуальными базовыми классами имеют несколько адресов, поэтому нет гарантии, что адрес, переданный функции isSafeToDelete, тот же самый, который был возвращен функцией operator new, даже если данный объект и размещался в куче. Дополнительные сведения по этому вопросу см. в правиле 24 и 31. В действительности хотелось бы обеспечить выполнение указанных функций без сопутствующего «загрязнения» глобального пространства имен, дополнительных расходов и проблем с правильностью работы. К счастью, язык C++ позволяет сделать в точности то, что нужно, при помощи абстрактного смешанного базового класса (abstract mixin base class). Абстрактный базовый класс - это класс, экземпляры которого создавать нельзя, то есть класс, имеющий хотя бы одну абстрактную функцию*. Смешанный класс - класс, обеспечивающий выполнение одной определенной функции, разработанный так, чтобы быть совместимым с любыми другими функциями, которые может обеспечивать наследующий класс. Такие классы почти всегда являются абстрактными. Поэтому можно создать абстрактный смешанный базовый класс, позволяющий производным классам определять, был ли указатель возвращен функцией operator new: Приемы * В оригинале абстрактные методы названы «полностью виртуальными». (Прим. ред.)
Правило 27 11МНШ class HeapTracked { // Смешанный класс; отслеживает public: //указатели, возвращаемые // operator new. class MissingAddress{}; // Класс исключений; см. ниже. virtual -HeapTracked() = 0; static void *operator new(size_t size); static void operator delete (void *ptr) ; bool isOnHeapO const; private: typedef const void* RawAddress; static list<RawAddress> addresses; }; Этот класс использует для отслеживания указателей, возвращаемых функцией operator new, структуру list, которая является частью стандартной библиотеки C++ (см. правило 35). Данная функция выделяет память и добавляет элементы в список; функция operator delete возвращает память и удаляет элементы из списка; а функция isOnHeap сообщает, находится ли адрес объекта в списке. Реализация класса HeapTracked проста, так для выделения и возврата памяти вызываются глобальные функции operator new и operator delete, а класс list содержит функции для вставки, удаления и поиска элементов в списке. Вот полная реализация класса HeapTracked: // Обязательное определение статического элемента класса. list<RawAddress> HeapTracked::addresses; // Деструктор класса HeapTracked абстрактный, // чтобы сделать класс абстрактным. Но деструктор должен // быть определен, поэтому приводится пустое определение. HeapTracked::-HeapTracked() {} void * HeapTracked: :operator new(size_t size) { void *memPtr = : :operator new(size) ; // Получить память. addresses. push_front (memPtr); // Поместить ее адрес //в начало списка. return memPtr; } void HeapTracked::operator delete(void *ptr) { // Получить "итератор", определяющий элемент списка //и содержащий ptr; подробности см. в правиле 35. list<RawAddress>::iterator it = find(addresses.begin(), addresses.end(), ptr); if (it != addresses.end() ) { // Если элемент был найден, addresses.erase (it); // удалить его, освободить ::operator delete(ptr); // память; } else { // иначе ptr не был выделен throw MissingAddress () ; // при помощи operator new, } // поэтому сгенерировать } // исключение. 6*
ЕЗШН111 bool HeapTracked::isOnHeap() const { // Получить указатель на начало памяти, // занятой *this; подробнее см. ниже. const void *rawAddress = dynamic_cast<const void*>(this) // Поиск указателя в списке адресов, // возвращенных operator new. list<RawAddress>::iterator it = find(addresses.begin(), addresses.end(), rawAddress); return it != addresses.end(); // Вернуть it, если } // адрес был найден. Приведенный код довольно прост, хотя если вы не знакомы с классом list и другими компонентами стандартной библиотеки шаблонов, он может показаться вам сложным. В правиле 35 все это объясняется подробно, хотя комментариев в самом коде должно быть достаточно, чтобы объяснить, что происходит в примере. Вас может поставить в тупик только следующий оператор (в функции isOnHeap): const void *rawAddress = dynamic_cast<const void*>(this); Как я уже упоминал, написание глобальной функции isSaf eToDelete усложняется из-за того, что объекты с несколькими или виртуальными базовыми классами имеют несколько адресов. Эта проблема досаждает и в функции isOnHeap, но поскольку функция isOnHeap применяется только к объектам HeapTracked, можно использовать особенность оператора dynamic_cast (см. правило 2). Оператор dynamic_cast, если его просто применить к указателю на void* (или const void*, или volatile void*, или - когда не хватает модификаторов - const volatile void*), дает указатель на начало памяти объекта, на который он ссылается. Но оператор dynamic_cast применим только к указателям на объекты, имеющие хотя бы одну виртуальную функцию. Наша злосчастная функция isSaf eToDelete должна была работать со всеми типами указателей, поэтому оператор dynamic_cast не помог бы ей. Функция isOnHeap является более избирательной (она проверяет только указатели на объекты типа HeapTracked), поэтому в ней приведение this к const void* при помощи оператора dynamic_cast дает указатель на начало памяти текущего объекта. Это указатель, который теперь должна возвращать функция HeapTracked: : operator new, если память для текущего объекта была первоначально выделена при помощи HeapTracked: : operator new. Если ваши компиляторы поддерживают оператор dynamic_cast, то предложенный метод является совершенно переносимым. Имея этот класс, даже программисты на Basic могут отслеживать указатели на объекты в куче. Для этого достаточно сделать класс наследником класса HeapTracked. Если, например, требуется знать, является ли указатель на объект типа Asset указателем на объект в куче, можно изменить определение класса Asset, задав HeapTracked в качестве базового класса: Приемы class Asset: public HeapTracked { private:
Правило 27 UPNumber value; }; Затем можно опрашивать указатели Asset* следующим образом: void inventoryAsset (const Asset *ap) { if (ap->isOnHeap()) { ар находится в куче - занести его в список } else { ар не находится в куче - записать это } } Недостаток смешанного класса, такого как HeapTracked, состоит в том, что он не может использоваться со встроенными типами, поскольку типы, подобные int и char, не способны наследовать от чего бы то ни было. Но обычно классы вроде HeapTracked создаются благодаря возможности определить, допускается ли выполние операции delete this, а такие операции никогда не нужно применять к встроенным типам, поскольку последние не имеют указателя this. Запрет создания объектов в куче На этом закончим обсуждение того, как можно определить, находится ли объект в куче. Теперь поговорим о предотвращении создания объектов в куче. Как обычно, имеется три случая: объекты, которые создаются непосредственно, объекты, создаваемые как части базового класса в производных классах, и объекты, встроенные в другие объекты. Рассмотрим каждый из них поочередно. Запретить прямое создание объектов в куче легко, поскольку такие объекты всегда создаются при помощи вызова new, и можно сделать, чтобы клиенты не могли вызвать new. Нельзя ограничить доступность оператора new (который является встроенным), но можно воспользоваться тем, что он всегда вызывает функцию operator new (см. правило 8), а эту функцию допускается переопределить. В частности, ее удобно объявить как private. Например, если вы хотите запретить клиентам создание объектов типа UPNumber, то вот один из способов: class UPNumber { private: static void *operator new(size_t size); static void operator delete(void *ptr); }; Пользователи могут сделать только то, что им разрешено: UPNumber nl; // Нормально. static UPNumber n2; // Также нормально. UPNumber *p = new UPNumber; // Ошибка! попытка вызвать // закрытую функцию operator new. ¦¦¦¦¦¦ЕЕ]
¦11 Приемы Достаточно объявить функцию operator new закрытой. Вообще кажется странным делать operator new открытой, оставляя функцию operator delete закрытой, поэтому если нет особой необходимости разбивать пару этих функций, лучше всего объявлять их в одной части класса. Если вам также нужно запретить создание в куче массивов объектов типа UPNumber, то вы также можете объявить закрытыми функции operator new [ ] и operator delete [ ] (см. правило 8). Интересно, что объявление функции operator new закрытой также предотвращает создание объектов типа UPNumber как части базового класса в производных классах. Это происходит потому, что функции operator new и operator delete наследуются, поэтому если данные функции не объявлены открытыми в производном классе, то класс наследует закрытые версии, объявленные в базовом классе: class UPNumber { ... ) ; class NonNegativeUPNumber: public UPNumber { }; NonNegativeUPNumber nl; static NonNegativeUPNumber n2 ; NonNegativeUPNumber *p = new NonNegativeUPNumber; // Как и выше. // Предположим, что в этом // классе не объявляется // функция operator new. // Нормально. // Также нормально. // Ошибка! Попытка вызывать // закрытую функцию // operator new. Если в производном классе объявляется собственная функция operator new, то она будет вызываться при создании объектов производного класса в куче, и придется найти другой способ предотвратить.попадание в кучу объектов типа UPNumber как частей базового класса. Аналогично то, что функция operator new класса UPNumber является закрытой, не влияет на попытки создания в куче объектов, содержащих объекты типа UPNumber в качестве членов класса: class Asset { public: Asset(int initValue); private: UPNumber value; }; Asset *pa = new Asset A00) ; // Нормально, вызывает // Asset::operator new // или : :operator new, а не // UPNumber::operator new. По сути это возвращает вас в точку, где вы находились, когда собирались генерировать исключение в конструкторах UPNumber, если объект типа UPNumber создавался в памяти, которая не находится в куче. На этот раз, конечно, хотелось бы генерировать исключение, если данный объект находится в куче. Но так же, как не существует переносимого способа определить, находится ли адрес в куче,
Правило 28 |Ц нет переносимого способа определить обратное, поэтому все попытки обречены на неудачу. Это неудивительно. В конце концов, если бы можно было определить, что адрес находится в куче, был бы способ определить, что его там нет. Но нельзя сделать ни того, ни, соответственно, другого. Правило 28. Используйте интеллектуальные указатели Интеллектуальные указатели (smart pointers) являются объектами, которые разработаны так, чтобы выглядеть и действовать как встроенные указатели, обеспечивая при этом большую функциональность. Они находят множество применений, включая управление ресурсами (см. правила 9, 10, 25 и 31) и автоматизацию программирования повторяющихся задач (см. правила 17 и 29). Если вы используете интеллектуальные указатели вместо встроенных указателей языка C++ (то есть неинтеллектуальных, или обычных указателей (dumb pointers)), вы можете управлять следующими аспектами поведения указателей: ? созданием и уничтожением. Вы определяете, что происходит при создании и уничтожении интеллектуального указателя. Обычно интеллектуальным указателям по умолчанию присваивается значение 0, чтобы не допустить ошибок, возникающих из-за неинициализированных указателей. Некоторые интеллектуальные указатели отвечают за удаление объекта после того, как будет уничтожен последний указывающий на объект интеллектуальный указатель. Это косвенно позволяет избежать утечек памяти; ? копирование и присваивание. Вы управляете тем, что происходит при копировании или присвоении интеллектуального указателя. Для некоторых типов интеллектуальных указателей нужно, чтобы он автоматически копировал или присваивал то, на что указывает, то есть выполнял бы детальную копию (deep copy). Для других должен копироваться или присваиваться только сам указатель. Для третьих эти операции следует запретить. Но независимо от поведения разных типов интеллектуальных указателей их грамотное использование позволяет добиться нужного функционирования программы; а разыменование (dereferencing). Что должно произойти, если клиент обращается к объекту, на который ссылается интеллектуальный указатель? Это решаете вы. Например, вы можете использовать интеллектуальные указатели, чтобы реализовать стратегию отложенной выборки, описанную в правиле 17. Интеллектуальные указатели создаются на основе шаблонов, поскольку так же, как и встроенные указатели, они должны быть максимально типизированы; параметр шаблона определяет тип объекта, на который он указывает. Большинство шаблонов интеллектуальных указателей выглядят примерно так: template<class T> // Шаблон для объектов class SmartPtr { // интеллектуальных public: // указателей.
SmartPtr (T* realPtr = 0) ; // Создать интеллектуальный указатель //на объект на основе // простого указателя; неинициализированным // указателям присваивается значение 0 (null). SmartPtr(const SmartPtrk rhs); // Скопировать интеллектуальныйуказатель. -SmartPtr(); // Уничтожить интеллектуальный указатель. // Присвоить интеллектуальный указатель. SmartPtrk operator=(const SmartPtrk rhs); T* operator->() const; // Разыменовать интеллектуальный указатель, // получив элемент, на который он указывает. Т& operator* () const; // Разыменовать интеллектуальный указатель, private: Т *pointee; // To, на что указывает }; //интеллектуальныйуказатель. Здесь и конструктор копирования, и оператор присваивания являются открытыми. В интеллектуальных указателях, копирование и присваивание для которых запрещено, они обычно объявляются закрытыми. Два оператора разыменования определяются с атрибутом const, поскольку разыменование указателя не изменяет его (хотя и может привести к модификации того, на что он указывает). И наконец, каждый интеллектуальный указатель на объект Т реализован так, что он содержит внутри себя простой указатель на Т, который, собственно, и ссылается на объект. Прежде чем перейти к деталям реализации интеллектуальных указателей, стоит обсудить, как клиенты могут их использовать. Рассмотрим распределенную систему, в которой одни объекты являются локальными, а другие удаленными. Доступ к локальным объектам обычно выполняется быстрее и проще, чем к удаленным, поскольку удаленный доступ может потребовать удаленного вызова процедур или еще какого-либо механизма взаимодействия с удаленным компьютером. Программистам, пишущим код приложения, несомненно, мешает различие в работе с локальными и удаленными объектами. Более удобно было бы, если бы казалось, что все объекты находятся в одном месте. Применение интеллектуальных указателей в библиотеке позволяет создавать такую иллюзию: template<class T> class DBPtr { public: DBPtr (T *realPtr =0); DBPtr(DataBaselD id); // Шаблон для интеллектуальных // указателей на объекты //в распределенной базе данных. // Создать интеллектуальный // указатель на объект базы данных //на основе простого локального // указателя на него. // Создать интеллектуальный
Правило 28 11MI }; class Tuple { public: // указатель на объект базы данных //на основе уникального // идентификатора в базе данных. // Остальные функции // интеллектуального указателя // реализуются, как изложено выше. // Класс для кортежей базы данных. void displayEditDialog() bool isValidf) const; }; // Вывести диалоговое окно, // позволяющее пользователю // редактировать кортеж. // Возвращается, если указатель *this // проходит проверку //на достоверность. // Шаблон класса для записи сведений //об изменении // объекта Т; подробнее см. ниже. template<class T> class LogEntry { public: LogEntry(const T& objectToBeModified); -LogEntry(); }; void editTuple(DBPtr<Tuple>& pt) { LogEntry<Tuple> entry(*pt) // Сделать запись об этой // операции редактирования. // Подробнее см. ниже. // Выводить диалоговое окно // редактирования, пока не будут // указаны правильные значения. do { pt->displayEditDialog(); } while (pt->isValid() == false); } Кортеж (tuple), редактируемый в функции EditTuple, может быть размещен на удаленном компьютере, но программист, который пишет функцию, не должен беспокоиться об этом; класс интеллектуального указателя скрывает данное свойство системы. С точки зрения программиста доступ ко всем кортежам осуществляется при помощи объектов, которые после объявления ведут себя в точности как обычные указатели. Обратите внимание на использование в функции editTuple объекта Log- Entry. Более традиционным было бы окружить вызов функции display- EditDialogue вызовами для начала и окончания записи. В вышеприведенном примере запись начинается с конструктора LogEntry, а заканчивается его деструктором. Как объясняется в правиле 9, обращение к объекту в начале и конце
ЕВММИН! Приемы записи более надежно при генерации исключений, чем-явный вызов функций, поэтому привыкайте использовать классы, подобные LogEntry. Кроме того, проще создать один объект LogEntry, чем добавлять отдельные вызовы для начала и окончания записи. Создание, присваивание и уничтожение интеллектуальных указателей Создать интеллектуальный указатель легко: определяется объект, на который он должен указывать (обычно при помощи аргументов конструктора интеллектуального указателя), а затем находящемуся внутри интеллектуального указателя обычному указателю присваивается значение указателя на этот объект. Если нельзя определить исходный объект, то внутреннему указателю присваивается значение О или сообщается об ошибке (возможно, при помощи генерации исключения). Реализация конструктора копирования, оператора(ов) присваивания и деструктора интеллектуального указателя несколько осложняется вопросом о владельце объекта. Если интеллектуальный указатель является владельцем объекта, на который указывает, то он отвечает за удаление объекта после своего уничтожения. При этом предполагается, что объект, на который указывает интеллектуальный указатель, является динамическим. Такое предположение является обычным при работе с интеллектуальными указателями. (Как убедиться в том, что это предположение выполняется, см. в правиле 27.) Рассмотрим шаблон auto_ptr из стандартной библиотеки C++. В правиле 9 объяснялось, что объект типа auto_ptr является интеллектуальным указателем на объект в куче до тех пор, пока указатель auto_ptr не будет уничтожен. После этого деструктор auto_ptr уничтожает объект, на который он (auto_ptr) указывал. Шаблон указателя auto_ptr может быть реализован так: template<class T> class auto_ptr { public: auto_ptr(T *ptr = 0) : pointee(ptr) {} ~auto_ptr() { delete pointee; } private: T *pointee; }; Такая реализация работает прекрасно, если auto_ptr является владельцем объекта. Но что произойдет после копирования или присвоения auto_ptr? auto_ptr<TreeNode> ptnl(new TreeNode) ; auto_ptr<TreeNode> ptn2 = ptnl; // Вызов конструктора // копирования. // Что произойдет? auto_ptr<TreeNode> ptn3 ; ptn3 = ptn2; // Вызов operator=. // Что произойдет?
Правило 28 ilMMMQQ Если вы просто скопировали внутренний обычный указатель, то в результате два объекта auto_ptr будут указывать на один и тот же объект. Это приведет к проблемам, поскольку деструктор каждого из объектов auto_ptr должен удалить объект, на который указывает объект auto_ptr. Следовательно, вы будете пытаться удалить объект несколько раз. Результат такого многократного удаления не известен (и часто приводит к печальным последствиям). В качестве альтернативы можно было бы создать новую копию объекта, на который указывает auto_ptr, при помощи оператора new. У вас появится гарантия, что на один объект не будет указывать несколько объектов auto_ptr, но от этого может снизиться производительность из-за создания (и уничтожения) нового объекта. Кроме того, тип создаваемого объекта не всегда будет известен, так как объект auto_ptr<T> не обязательно должен указывать на объект типа Т; он может указывать на объект производного от Т типа. Это противоречие нетрудно устранить при помощи виртуальных конструкторов (см. правило 25), но их применение кажется неуместным в таком универсальном классе, как auto_ptr. Все названные проблемы исчезнут, если запретить копирование и присвоение объектов auto_ptr, но для классов auto_ptr было принято более гибкое решение: владелец объекта изменяется при копировании или присвоении оператора auto_ptr: template<class T> class auto_j?tr { public: auto_ptr (auto_ptr<T>& rhs) ; // Конструктор копирования. auto_ptr<T>& // Оператор operator=(auto_ptr<T>& rhs) ; // присваивания. }; template<class T> auto_ptr<T>::auto_ptr(auto_ptr<T>& rhs) { pointee = rhs.pointee; // Владельцем объекта *pointee // становится указатель *this. rhs.pointee =0; // Указатель rhs больше не владеет } // ничем. template<class T> auto_ptr<T>& auto_ptr<T>::operator=(auto_ptr<T>& rhs) { if (this == &rhs) // Если объект this return *this; // присваивается сам себе, // ничего не делать. delete pointee; // Удалить объект. pointee = rhs.pointee; // Владельцем объекта *pointee rhs. pointee =0; // вместо указателя rhs // становится *this. return *this;
ШШШШШШй Приемы Обратите внимание, что оператор присваивания должен удалять объект, владельцем которого он является, перед тем как стать владельцем нового объекта. Если он не сможет этого сделать, то объект никогда не будет удален. Помните: только объект auto_ptr является владельцем того, на что он указывает. Поскольку при вызове конструктора копирования auto_ptr меняется владелец объекта, то передача auto_ptr по значению часто является очень неудачной, и вот почему: // Эта функция часто приводит к серьезным ошибкам. void printTreeNode(ostreamk s, auto ptr<TreeNode> p) { s « *p; } int main() { auto_ptr<TreeNode> ptn(new TreeNode); printTreeNode(cout, ptn); // Передать auto_ptr по значению. } Во время инициализации параметра р функции printTreeNode (при помощи вызова конструктора копирования auto_ptr), владельцем объекта, на который указывает ptn, становится р. После завершения работы функции print TreeNode указатель р выходит из области видимости, и его деструктор удаляет то, на что р указывает (то есть то, на что раньше указывал ptn). Однако ptn больше ни на что не указывает (лежащий в его основе простой указатель равен нулю), поэтому почти всякая попытка использовать его после вызова функции printTreeNode даст непредсказуемые результаты. Следовательно, передача указателей auto_ptr должна осуществляться, только если вы уверены, что вам нужно поменять владельца объекта на (переменный) параметр функции. Но такое действие требуется очень редко. Это не означает, что вы не можете передавать указатели auto_ptr в качестве параметров. Просто передача по значению является не совсем подходящим способом для этого, в отличие от передачи по ссылке на const: / / Поведение этой функции намного нагляднее. void printTreeNode(ostreamk s, const auto_ptr<TreeNode>& p) { s « *p; } В приведенной функции р является ссылкой, а не объектом, поэтому для инициализации р не вызывается конструктор. Когда этой версии функции printTreeNode передается указатель ptn, он остается владельцем объекта, на который указывает, и может безопасно использоваться после вызова функции printTreeNode. Таким образом, передача указателя auto_ptr как ссылки на const позволяет избежать опасностей, возникающих при передаче по значению. Знание процедуры смены владельца при копировании и присвоении интеллектуальных указателей, несомненно, полезно, но вы можете быть не менее
Правило 28 ' ЦШ11Ш заинтересованы необычными объявлениями в конструкторе копирования и операторе присваивания. Такие функции обычно имеют параметры с атрибутом const, но в данном случае это не так. В вышеприведенном коде параметры изменяются во время копирования или присваивания. Другими словами, объекты auto_ptr изменяются, если они копируются или являются источником при присваивании! Да, именно это и происходит. Не прекрасно ли, что язык C++ настолько гибок, что позволяет выполнять такие операции? Если бы требовалось, чтобы параметры конструкторов копирования и операторов присваивания имели параметры с атрибутом const, пришлось бы снимать атрибуты const или реализовывать передачу прав владельца каким-либо другим образом. Но удобнее прямо сказать, что вам нужно: объект должен измениться, если он копируется или является источником при присваивании. Это может показаться не очень наглядным, но это простой и понятный способ и в данном случае правильный. Если вас заинтересовало рассмотрение функций - членов указателя auto_ptr, вы можете ознакомиться с его полной реализацией, которую найдете на страницах 289-292. Как вы увидите, шаблон auto_ptr в стандартной библиотеке C++ имеет более гибкие конструкторы копирования и операторы присваивания, чем описанные выше. В стандартном шаблоне auto_ptr эти функции являются шаблонами функций-членов, а не просто функциями-членами. (Шаблоны функций- -членов описываются в этом правиле позже.) Деструктор интеллектуального указателя часто выглядит так: template<class T> SmartPtr<T>::-SmartPtr() { if (*this является владельцем *pointee) { delete pointee; } } Иногда не нужно выполнять такую проверку. Например, указатель auto_ptr всегда является владельцем того, на что он указывает. В других случаях проверка бывает несколько более сложной. Интеллектуальный указатель, использующий счетчик ссылок (см. правило 29) должен корректировать счетчик ссылок перед тем, как определить, может ли он удалить то, на что указывает. Некоторые интеллектуальные указатели не отличаются от обычных: они не выполняют никаких действий над объектом, на который указывают при своем уничтожении. Реализация операторов разыменования Обратим теперь внимание на «сердце» интеллектуальных указателей, функции operator* и operator->. Вторая функция возвращает объект, на который ссылается указатель. В теории это просто: template<class T> Т& SmartPtr<T>::operator*() const
швнмн { выполнить обработку интеллектульного указателя; return *pointee; } Во-первых, функция осуществляет необходимые вычисления для инициализации pointee или какие-либо другие процедуры, обеспечивающие его правильность. Например, при использовании отложенной выборки (см. правило 17) этой функции может потребоваться создать новый объект, на который будет указывать pointee. Если ссылка pointee задана корректно, то функция operator* просто возвращает ссылку на указываемый объект. Обратите внимание, что возвращается ссылка. Возвращение объекта могло бы привести к ошибке, поэтому компиляторы не допускают этого. Имейте в виду, что ссылка pointee не обязательно должна указывать на объект типа Т; она может указывать на объект производного от Т класса. Если бы это было так, и функция operator* возвращала бы объект Т вместо ссылки на объект производного класса, то функция возвращала бы объект неправильного типа! (Это проблема потери данных - см. правило 13.) Виртуальные функции, которые вызываются в объекте, возвращаемом несчастным operator*, не вызывали бы функцию, соответствующую динамическому типу указываемого объекта. В сущности, такой интеллектуальный указатель не мог бы корректно поддерживать виртуальные функции, и насколько же интеллектуальным он бы был? Кроме того, возврат ссылки более эффективен, так как не требует создания временного объекта (см. правило 19). Это один из тех редких случаев, когда корректность и эффективность идут рука об руку. Если вы из тех, кто любит беспокоиться по различным поводам, вы можете поинтересоваться, как надо поступить, если кто-то вызовет operator* для нулевого интеллектуального указателя, то есть указателя, простой указатель внутри которого равен нулю. Расслабьтесь. Вы можете сделать все, что угодно. Результат разыменования нулевого указателя не определен, поэтому не существует «неправильного» поведения. Хотите сгенерировать исключение? Давайте, генерируйте. Хотите вызвать функцию abort (возможно, завершив неудачей вызов assert)? Прекрасно, вызывайте ее. Желаете обойти всю память, присвоив каждому байту дату вашего рождения по модулю 256? Это также допустимо. В данном случае вы совершенно свободны от ограничений языка. Описание operator-> аналогично описанию operator*, но прежде чем рассмотреть operator->, вспомним о необычном значении вызова этой функции. Рассмотрим снова функцию editTuple, использующую интеллектуальный указатель на объект Tuple: void editTuple(DBPtr<Tuple>& pt) { LogEntry<Tuple> entry(*pt) ; do { pt->displayEditDialog(); Приемы
Правило 28 тШШШШШМШ } while (pt->isValid() == false); } Оператор pt->displayEditDialog(); интерпретируется компиляторами как (pt.operator->())->displayEditDialog(); Это означает, что ко всему возвращающему operator-> допускается применить оператор выбора элемента (->). Поэтому функция operator-> может возвращать только две вещи: обычный указатель на объект или другой объект интеллектуального указателя. В большинстве случаев вам будет нужно возвращать обычный указатель. При этом operator-> реализуется следующим образом: template<class T> Т* SmartPtr<T>::operator->() const { выполнить обработку интеллектуального указателя; return pointee; } Эта конструкция будет прекрасно работать. Поскольку данная функция возвращает указатель, вызовы виртуальных функций при помощи operator-> будут вести себя так, как и предполагается. Для большинства приложений это все, что вам нужно знать об интеллектуальных указателях. Например, код для подсчета ссылок из правила 29 использует не больше функций, чем было описано выше. Однако если вы хотите обогатить свои представления об интеллектуальных указателях, вы должны больше узнать о поведении обычных указателей и о том, когда интеллектуальные указатели могут и не могут эмулировать такое поведение. Если ваш девиз «Большинство людей останавливаются, дойдя до буквы Z, но только не я!», то следующий раздел предназначен для вас. Проверка равенства интеллектуальных указателей нулю При помощи функций, которые уже были рассмотрены, вы можете создавать, уничтожать, копировать, присваивать и разыменовывать интеллектуальные указатели. Но не можете определить, равен ли интеллектуальный указатель нулю: SmartPtr<TreeNode> ptn; if (ptn == 0) . . . // Ошибка! if (ptn) ... // Ошибка! if (!ptn) ... //Ошибка! Это серьезное ограничение.
¦li ¦ Hi Приемы Легко было бы добавить к имеющимся классам интеллектуальных указателей функцию-член isNull, но это не помогло бы решить проблему, которая заключается в том, что при проверке на равенство нулю интеллектуальные указатели не ведут себя как простые указатели. Другой подход - задать оператор явного преобразования, который позволяет компилироваться вышеприведенному коду проверок. Обычно при этом выполняется преобразование к типу void*: template<class T> class SmartPtr { public: operator void*(); }; SmartPtr<TreeNode> ptn; if (ptn == if (ptn) . if (!ptn) 0) // Возвращает О, если // интеллектуальный указатель // равен null, иначе ненулевое // значение. // Теперь нормально. // Также нормально. // Нормально. Это аналогично преобразованию для классов входных потоков и объясняет, почему можно написать такой код: ifstream inputFile("datafile.dat"); if (inputFile) . . . // Проверка успешного открытия // файла inputFile. Данная функция, так же как и все функции преобразования типов, имеет один недостаток - вызовы функций завершаются успешно, хотя большинство программистов ожидает, что они завершатся неудачей (см. правило 5). В частности, такое преобразование позволяет сравнивать интеллектуальные указатели совершенно различных типов: SmartPtr<Apple> pa; SmartPtr<Orange> po; if (pa == po) // Компилируется нормально! Даже если для типов SmartPtr<Apple> и SmartPtr<Orange> не предусмотрена функция operator=, этот код будет компилироваться без ошибок, поскольку интеллектуальные указатели могут быть неявно преобразованы к указателям типа void*, а для встроенных указателей существует встроенная функция сравнения. Такое поведение делает функции неявного преобразования типов довольно опасными (см. правило 5). Это вариации на тему преобразования к void*. Некоторые разработчики поддерживают возможность преобразования к const void*, другие выбирают преобразование в тип bool. Ни одна из названных разновидностей не устраняет проблему сравнений различных типов.
Правило 28 ШШШШШП Существует промежуточное решение, позволяющее использовать приемлемую синтаксическую форму для проверки на равенство нулю, уменьшая при этом риск случайного сравнения интеллектуальных указателей разных типов. Оно состоит в перегрузке operator! в классе интеллектуального указателя, чтобы функция operator! возвращала значение true тогда и только тогда, когда интеллектуальный указатель, для которого она вызывается, равен нулю. template<class T> class SmartPtr { public: bool operator!() const; // Возвращает true тогда и только ... // тогда, когда интеллектуальный // указатель равен null. }; Это позволяет создавать такие клиентские программы: SmartPtr<TreeNode> ptn; if (!ptn) { // Нормально; ... // указатель ptn равен null. } else { ... // ptn не равен null. } но не такие: if (ptn == 0) ... // Все еще ошибка, if (ptn) ... // Также ошибка. Риск возникает только при таких сравнениях различных типов: SmartPtr<Apple> pa; SmartPtr<Orange> po; if (!pa== !po) ... //Увы, компилируется. К счастью, программисты не часто пишут подобный код. Интересно, что в библиотеке iostream кроме неявного преобразования к void* задана функция operator!, но эти две функции обычно проверяют немного различные состояния потока. (В стандарте библиотек языка C++ (см. правило 35) неявное преобразование к типу void* заменено неявным преобразованием к типу bool, а функция operator bool всегда возвращает отрицание operator!.) Преобразование интеллектуальных указателей в обычные Иногда требуется добавить интеллектуальные указатели к приложению или библиотеке, где уже используются обычные указатели. Например, распределенная база данных может изначально не быть распределенной, так что некоторые старые библиотечные функции могут не предназначаться для работы с интеллектуальными указателями:
ill Приемы class Tuple {...}; // Как и раньше. void normalize (Tuple *pt); // Привести указатель *pt //к канонической форме; // используется обычный // указатель. Рассмотрим, что произойдет, если вы попытаетесь вызвать функцию normalize для интеллектуального указателя на объект Tuple: DBPtr<Tuple> pt; normalize(pt); //Ошибка! Этот вызов не скомпилируется, поскольку не существует способа преобразовать DBPtr<Tuple> в Tuple*. Вы можете заставить его работать, сделав следующее: normalize(&*pt); // Грубо, но допустимо. но я надеюсь, вы согласитесь со мной, что это далеко не лучший вариант. Можно сделать вызов успешным, добавив к шаблону интеллектуального указателя на Т оператор явного преобразования в обычный указатель на Т: template<class T> // Как и раньше. class DBPtr ( public: operator T* () { return pointee; } }; DBPtr<Tuple> pt; normalize(pt) ; // Теперь работает. Добавление этой функции также устраняет проблему проверки на равенство нулю: if (pt == 0) ... // Нормально, преобразует // указатель pt в Tuple*, if (pt) ... // То же. if (!pt) ... // То же. Тем не менее, у таких функций преобразования есть и недостатки. (Это почти всегда так - см. правило 5.) Данные функции позволяют клиентам непосредственно использовать в программе обычные указатели, пренебрегая интеллектуальностью разработанных вами объектов-указателей. void processTuple(DBPtr<Tuple>& pt) { Tuple *rawTuplePtr = pt; // Преобразует DBPtr<Tuple> // в Tuple*. для изменения tuple используется rawTuplePtr; }
П?а^ло28__ Ш1ШПНШЗ Обычно «умное» поведение, обеспечиваемое интеллектуальным указателем, является необходимым компонентом разработки, поэтому разрешение клиентам использовать обычные указатели, вы, возможно, спровоцируете катастрофу. Например, если интеллектуальный указатель DBPtr реализует стратегию подсчета ссылок, описанную в правиле 29, и вы позволите клиентам напрямую работать с простыми указателями, это почти неизбежно вызовет ошибки, которые нарушат структуры данных, отвечающие за подсчет ссылок. Даже если создать оператор неявного преобразования из интеллектуального указателя в обычный, интеллектуальные указатели и обычные не будут равнозначны. Это связано с тем, что преобразование из интеллектуального указателя в обычный определено пользователем, а компилятору запрещено применять более одного такого преобразования одновременно. Например, предположим, что имеется класс, представляющий всех клиентов, получивших доступ к определенному кортежу: class TupleAccessors { public: TupleAccessors(const Tuple *pt); // Указатель pt определяет ... // заданный кортеж. }; Как обычно, конструктор с одним аргументом TupleAccessor также служит оператором преобразования типа из Tuple* в TupleAccessors (см. правило 5). Рассмотрим теперь функцию для слияния данных из двух объектов TupleAccessors: TupleAccessors merge(const TupleAccessorsk tal, const TupleAccessors& ta2); Поскольку указатель Tuple* может быть неявно преобразован в объект TupleAccessors, вызов функции merge с двумя обычными указателями Tuple* компилируется без каких-либо проблем: Tuple *ptl, *pt2; merge(ptl, pt2); // Нормально, оба указателя // преобразуются // в объекты TupleAccessors. Соответствующий же вызов для интеллектуальных указателей DBPtr- <Tuple> компилироваться не будет: DBPtr<Tuple> ptl, pt2; merge(ptl, pt2); // Ошибка! Невозможно // преобразовать ptl // и pt2 в объект TupleAccessors. Это связано с тем, что преобразование DBPtr<Tuple> в объект TupleAccessors требует вызова двух определенных пользователем преобразований (одно из
ЩЗННМ11 Приемы DBPtr<Tuple> в Tuple* и одно из Tuple* в TupleAccessors), а такие последовательности преобразований в языке запрещены. Классы интеллектуальных указателей, в которых есть функции неявного преобразования в обычный указатель, приводят к очень неприятной ошибке. Рассмотрим код: DBPtr<Tuple> pt = new Tuple; delete pt; Такой код не должен был бы компилироваться. В конце концов, объект pt не является указателем, и его нельзя удалить. Ведь можно удалять только указатели, не так ли? Да, это так. Но вы должны помнить из правила 5, что компиляторы используют неявное преобразование типов для того, чтобы по возможности сделать вызовы функций успешными, а из правила 8 - что использование оператора delete приводит к вызову двух функций (деструктора и operator delete). Компиляторы пытаются сделать вызов данных функций успешным, поэтому в операторе delete они неявно преобразуют pt в Tuple*, а затем удаляют его. Это почти наверняка приведет к остановке вашей программы. Если интеллектуальный указатель pt является владельцем объекта, на который он указывает, то этот объект удаляется дважды, один раз в точке вызова оператора delete, а второй - при вызове деструктора pt. Если объектом не владеет pt, то им владеет кто-то другой. Возможно, это был тот, кто удалил pt, и тогда все в порядке. В противном случае настоящий владелец скорее всего попытается позже снова удалить объект. Первый и последний варианты означают, что объект удаляется дважды, а удаление объекта более одного раза приводит к неопределенному поведению. Эта ошибка очень опасна, так как основная идея, лежащая в основе интеллектуальных указателей - сделать их максимально похожими на обычные указатели. Чем ближе вы приближаетесь к этому идеалу, тем более вероятно, что клиенты забудут о том, что они используют интеллектуальные указатели. И если это произойдет, кто сможет обвинить их в вызове delete после new, ведь тем самым они стремятся (по их мнению) избежать утечек ресурсов? Итог всего вышесказанного достаточно прост: не создавайте операторов неявного преобразования в обычные указатели без настоятельной необходимости. Интеллектуальные указатели и преобразования типов при наследовании Предположим, что имеется открытая иерархия наследования, соответствующая музыкальным носителям для бытовой аппаратуры (см. рис. 5.4). class MusicProduct { public: MusicProduct(const strings title); virtual void playO const = 0;
Правило 28 ИШМИ virtual void displayTitle () const = 0; }; class Cassette: public MusicProduct { public: Cassette(const stringk title); virtual void play() const; virtual void displayTitle () const; }; class CD: public MusicProduct { public: CD(const strings title); virtual void play() const; virtual void displayTitle() const; }; MubicProdnot J Рис. 5.4 Предположим далее, что имеется функция, выводящая название объекта MusicProduct, а затем проигрывающая соответствующий носитель: void displayAndPlay (const MusicProduct* pmp, int numTimes) { for (int i = 1; i <= numTimes; ++i) { pmp->displayTitle(); pmp->play(); } } Такую функцию можно использовать следующим образом: Cassette *funMusic = new Cassette("Alapalooza"); CD *nightmareMusic = new CD ("Disco Hits of the 70s") ; displayAndPlay(funMusic, 10); displayAndPlay(nightmareMusic, 0) ; В данном случае никаких сюрпризов нет, но посмотрим, что произойдет, если заменить обычные указатели на их якобы «интеллектуальные» аналоги: void displayAndPlay(const SmartPtr<MusicProduct>& pmp, int numTimes);
ЕЭЯМИИШ ... Приемы SmartPtr<Cassette> funMusic(new Cassette("Alapalooza")); SmartPtr<CD> nightmareMusic(new CD("Disco Hits of the 70s")); displayAndPlay(funMusic, 10); //Ошибка! displayAndPlay(nightmareMusic, 0); //Ошибка! Если интеллектуальные указатели так умны, почему этот код не компилируется? Он не компилируется из-за того, что не существует преобразования из SmartPtr<CD> или SmartPtr<Cassette> в SmartPtr<MusicProduct>. С точки зрения компилятора, это три отдельных класса, которые не связаны друг с другом. С какой стати компиляторам думать иначе? Непохоже, что Smart Ptr<CD> или SmartPtr<Cassette> наследуют от SmartPtr<MusicProduct>. Если данные классы не связаны между собой отношениями наследования, едва ли можно ожидать, что компиляторы будут сами по себе преобразовывать объекты одного типа в объекты другого. К счастью, существует простой (если дело не касается практики) способ обойти это ограничение: определить в каждом классе интеллектуального указателя оператор явного преобразования типов (см. правило 5) для каждого из других классов интеллектуальных указателей, в которые он должен неявно конвертироваться. Например, в иерархии музыкальных носителей к классам интеллектуальных указателей Cassette и CD добавляются функции operator Smart Ptr<Mu- sicProductx class SmartPtr<Cassette> { public: operator SmartPtr<MusicProduct>() { return SmartPtr<MusicProduct>(pointee); } private: Cassette *pointee; }; class SmartPtr<CD> { public: operator SmartPtr<MusicProduct>() { return SmartPtr<MusicProduct>(pointee); } private: CD *pointee; }; Такой подход имеет два недостатка. Во-первых, нужно вручную перечислять экземпляры класса SmartPtr, чтобы добавить необходимые операторы неявного преобразования типов, но это сводит практически на нет выгоду от применения шаблонов. Во-вторых, иногда требуется несколько таких операторов преобразования, поскольку объект может находиться в иерархии наследования достаточно глубоко, и нужно создать оператор преобразования для каждого базового класса, от которого объект явно или неявно наследует. (Если вы считаете, что этой проблемы
Правило 28 ill i 89111118 легко избежать, создав оператор неявного преобразования только для каждого из прямых базовых классов, то вы ошибаетесь. Поскольку компиляторам запрещено использовать более одной определенной пользователем функции преобразования одновременно, они могут превратить интеллектуальный указатель на Т в интеллектуальный указатель на непрямой базовый класс Т всего за один шаг.) Если бы можно было как-то заставить компилятор автоматически написать функции неявного преобразования типов, то это сэкономило бы массу времени. И такое возможно благодаря недавнему расширению языка, которое позволяет объявлять (невиртуальные) шаблоны функций-членов (member function templates или часто member templates). Эти шаблоны вы можете использовать для создания таких функций преобразования типов для интеллектуальных указателей: template<class T> class SmartPtr { public: SmartPtr(T* realPtr = 0); // Шаблон класса // для интеллектуальных // указателей на объекты Т. Т* operator->() const; Т& operator* () const; template<class newType> // Функция шаблона operator SmartPtr<newType> () // для операторов неявного { // преобразования типов. return SmartPtr<newType>(pointee); } }; Это почти волшебство, и сейчас вы узнаете, как оно происходит. (Я вскоре приведу конкретный пример, поэтому не отчаивайтесь, если остаток параграфа покажется вам бессмысленным набором слов. После того как вы увидите код, вам все станет ясно, обещаю.) Предположим, компилятор располагает интеллектуальным указателем на объект Т, и он должен превратить этот объект в интеллектуальный указатель на базовый класс объекта Т. Компилятор проверяет, объявлен ли искомый оператор преобразования в классе SmartPtr<T>, но такой оператор не объявлен. (И этого не может быть: в вышеприведенном шаблоне не объявлены операторы преобразования.) Затем компилятор проверяет, существует ли какая- нибудь функция-член, позволяющая выполнить требуемое преобразование. Он находит шаблон такой функции (с формальным параметром newType), потом создает экземпляр шаблона, в котором параметр newType привязан к базовому классу Т, являющемуся целью преобразования. Единственный вопрос заключается в том, будет ли код полученной функции-члена компилироваться. Для этого должна быть допустима передача указателя (обычного) pointee конструктору интеллектуального указателя на базовый класс т. Указатель pointee имеет тип Т, поэтому, конечно, разрешается его превращение в указатель на соответствующие
ШШННВНИ Приемы базовые классы (отрытые или защищенные). Следовательно, код для оператора преобразования типа будет компилироваться, и неявная конвертация из интеллектуального указателя на Т в интеллектуальный указатель на базовый класс Т окажется успешной. Приведем пример. Вернемся к иерархии компакт-дисков, кассети других музыкальных носителей. Как вы уже знаете, приведенный ниже код не компилируется, поскольку компиляторы не могут преобразовать интеллектуальные указатели на компакт-диски или кассеты в интеллектуальные указатели на музыкальные носители вообще: void displayAndPlay(const SmartPtr<MusicProduct>& pmp, int howMany); SmartPtr<Cassette> funMusic(new Cassette("Alapalooza")); SmartPtr<CD> nightmareMusic (new CD ("Disco Hits of the 70s") ) ; displayAndPlay(funMusic, 10); // Здесь была ошибка. displayAndPlay(nightmareMusic, 0); // Здесь была ошибка. Если же исправить класс интеллектуальных указателей, содержащих шаблон функции-члена для операторов неявного преобразования типов, этот код будет успешно компилироваться. Чтобы понять почему, рассмотрим вызов: displayAndPlay(funMusic, 10); Объект funMusic имеет тип SmartPtr<Cassette>. Функция же displayAndPlay ожидает объект SmartPtr<MusicProduct>. Компиляторы обнаруживают несоответствие типов и пытаются найти способ преобразования объекта funMusic в объект SmartPtr<MusicProduct>. Они ищут в классе SmartPtr<MusicProduct> конструктор с единственным аргументом типа SmartPtr<Cassette> (см. правило 5), но не находят его. Затем пытаются обнаружить в классе SmartPtr<Cassette> оператор неявного преобразования типов, дающий класс SmartPtr<MusicProduct>, но этот поиск также заканчивается неудачей. Потом компиляторы ищут шаблон функции-члена, экземпляр которой могут создать, и выясняют, что шаблон внутри SmartPtr <Cassette>, если связать параметр newType с MusicProduct, генерирует нужную функцию. Тогда они создают эту функцию, давая в реаультате следующий код: SmartPtr<Cassette>::operator SmartPtr<MusicProduct>() { return SmartPtr<MusicProduct>(pointee); } Будет ли этот код компилироваться? В нем не происходит ничего, кроме вызова конструктора SmartPtr<MusicProduct> с аргументом pointee, поэтому вопрос сводится к тому, можно ли создать объект SmartPtr<MusicProduct> при помощи указателя Cassette*. Конструктор SmartPtr<MusicProduct> ожидает указатель MusicProduct*, но теперь есть твердое основание для сравнения между двумя типами обычных указателей, и ясно, что можно подставить Cassette* там, где ожидается MusicProduct*. Поэтому создание объекта
Правило 28 JllHHHIH] SmartPtr<MusicProduct> будет успешным, так же как и преобразование SmartPtr<Cassette> в SmartPtr<MusicProduct>. Вуаля! Неявное преобразование типов интеллектуальных указателей. Что может быть проще? Более того, что может быть более мощным? Пусть вас не вводит в заблуждение рассмотренный пример - не нужно думать, что этот метод работает только для преобразования указателей в иерархии наследования. Он применим для любых неявных преобразований типов указателей. Если имеется обычный указатель типа Т1 * и другой обычный указатель типа Т2 *, то интеллектуальный указатель на Т1 разрешается неявно преобразовать в интеллектуальный указатель на Т2 тогда и только тогда, когда можно неявно преобразовать Т1* в Т2 *. Этот метод дает почти такое же поведение, которое вам необходимо. Предположим, что вы добавите к иерархии MusicProduct новый класс CasSingle, представляющий синглы на кассетах. Иерархия примет вид, изображенный на рис. 5.5. Рис. 5.5 Рассмотрим теперь следующий код: template<class T> // Как и выше, шаблон class SmartPtr {...}; // функции-члена для операций // преобразования, void displayAndPlay(const SmartPtr<MusicProduct>& pmp, int howMany); void displayAndPlay(const SmartPtr<Cassette>& pc, int howMany) ; SmartPtr<CasSingle> dumbMusic (new CasSingle ("Achy Breaky Heart") ) ; displayAndPlay(dumbMusic, 1); //Ошибка! В этом примере функция displayAndPlay перегружается: в одном случае функцией, принимающей объект SmartPtr<MusicProduct>, а в другом -функцией Smart Ptr<Casset te>. Если вы вызываете функцию displayAndPlay для SmartPtr<CasSingle>, то ожидаете, что будет выбрана функция Smart- Ptr<Cassette>, поскольку CasSingle прямо наследует от Cassette и только неявно от Mus icProduct. Конечно, так оно и должно быть для обычных указателей.
ЕШМНИ1Ш . пРиемы Но, увы, наши интеллектуальные указатели не настолько умны. Они используют функции-члены в качестве операторов преобразования, а с точки зрения компиляторов C++ все вызовы функций преобразования равноценны. В результате вызов функции displayAndPlay дает неопределенный результат, так как преобразование из SmartPtr<CasSingle> в SmartPtr<Cassette> ничем не определено. Реализация преобразования интеллектуальных указателей при помощи шаблонов функций-членов имеет еще два недостатка. Во-первых, поддержка шаблонов функций-членов встречается редко, поэтому данный метод не всегда может применяться в других системах. Во-вторых, логика работы далеко не прозрачна, и будет понятна только тем, кто хорошо знает правила соответствия аргументов вызовов функций, функций неявного преобразования типов, неявного создания экземпляров функций из шаблонов и существования шаблонов функций-членов. Жаль бедного программиста, который никогда не видел данный прием и которому требуется поддерживать или дополнять основанный на нем код. Этот способ, несомненно, ловко придуман, но здесь есть опасность перехитрить самого себя. Не будем ходить вокруг да около. В действительности вам нужно знать, можно ли сделать так, чтобы классы интеллектуальных указателей вели себя подобно обычным указателям при преобразованиях типов, основанных на наследовании. Ответ прост: нет, нельзя. Как заметил Дэниэл Эдельсон (Daniel Edelson), интеллектуальные указатели интеллектуальны, но это не указатели. Лучшее, что можно сделать - применять для создания функций преобразования шаблоны функций- членов, а если возникает неоднозначность - использовать операторы приведения типа (см. правило 2). Это не самый лучший выход, но он достаточно хорош. Кроме того, необходимость иногда включать в код операторы приведения - невысокая цена за сложную функциональность, которую могут обеспечивать интеллектуальные указатели. Интеллектуальные указатели и атрибут const Как вы помните, для обычных указателей атрибут const может относиться к самому указателю; тому, на что он указывает; или и к тому, и другому: CD goodCD("Flood"); const CD *p; // p - не-const указатель //на const объект CD. CD * const p = kgoodCD; // p const указатель //на не-const объект CD; // так как р - const, нужна // его инициализация, const CD * const p = kgoodCD; // р - const указатель //на const объект CD. Естественно, вам бы хотелось, чтобы интеллектуальные указатели были столь же гибкими. К сожалению, атрибут cons t может относиться только к указателю, а не к объекту, на который он указывает:
Правило 28 ЛШИШ const SmartPtr<CD> p = // р - const интеллектуальный kgoodCD; // указатель на не-const // объект CD. Кажется, что это легко исправить, просто создав интеллектуальный указатель на const объект CD: SmartPtr<const CD> p = // р - не-const интеллектуальный kgoodCD; // указатель на const объект CD. Теперь можно создать четыре искомые комбинации атрибутов const для объекта и указателя: SmartPtr<CD> p; // He-const объект, // He-const указатель. SmartPtr<const CD> p; // const объект, // не-const указатель, const SmartPtr<CD> p = kgoodCD; // He-const объект, // const указатель, const SmartPtr<const CD> p = kgoodCD; // const объект, // const указатель. Увы, в этой бочке меда есть ложка дегтя. Если используются обычные указатели, можно инициализировать const указатели при помощи не-const указателей и указатели на const объекты при помощи указателей на не-const объекты; правила присвоения не изменяются. Например: CD *pCD = new CD("Famous Movie Themes"); const CD * pConstCD = pCD; // Нормально. Но посмотрите, что произойдет, если сделать то же самое при помощи интеллектуальных указателей: SmartPtr<CD> pCD = new CD("Famous Movie Themes"); SmartPtr<const CD> pConstCD = pCD; // Нормально ли? Типы SmartPtr<CD> и SmartPtr<const CD> совершенно различны. С точки зрения компиляторов они не связаны между собой, поэтому нельзя полагать, что они совместимы по принципу присваивания. Раньше единственный способ обеспечить совместимость заключался в создании функции для преобразования объектов типа SmartPtr<CD> в объекты типа SmartPtr<const CD>. Если ваш компилятор поддерживает шаблоны функций-членов, вы можете использовать приведенный выше метод автоматического формирования необходимых операторов неявного преобразования типов. (Я уже отмечал, что этот метод успешен всегда, когда работает соответствующее преобразование для обычных указателей. Преобразования, включающие const, не являются исключением из данного правила.) Если же ваш компилятор не поддерживает такие шаблоны, вам придется пройти еще через одно испытание. Преобразования, включающие const, - это улица с односторонним движением: можно безбоязненно переходить от не-const к const, но небезопасно от const к не-const. Кроме того, все, что можно делать с const указателем, можно
IMlii Приемы делать и с не-const указателем, но для не-const указателя разрешается кое-что еще (например, присваивать ему новое значение). Аналогично все что можно сделать с указателем на const, дозволяется делать и с указателем на не-const, но с указателями на не-const можно делать и другие вещи (например, присваивать объекту, на который указывает указатель, новое значение), которые недопустимы для указателей на const. Эти принципы похожи на принципы открытого наследования (см. правило 35). Разрешается преобразовывать объект производного класса в объект базового класса, но не наоборот, и с объектом производного класса можно делать все, что и с объектом базового класса, но обычно с объектом производного класса допускается делать что-то еще. При реализации интеллектуальных указателей можно воспользоваться этой схожестью, создав каждый интеллектуальный указатель на класс Т при помощи открытого наследования от соответствующего интеллектуального указателя на класс const-T (см. рис. 5.6). Рис. 5.6 template<class T> class SmartPtrToConst { protected: union { const T* constPointee; T* pointee; }; }; template<class T> class SmartPtr: public SmartPtrToConst<T> { // Интеллектуальные указатели //на const объекты. // Обычные функции - члены // интеллектуального указателя. // Для доступа //к SmartPtrToConst. // Для доступа к SmartPtr. // Интеллектуальные указа- // тели на не-const объекты. }; / Элементов данных нет.
Правило 28 , ШШШШШШ При такой реализации интеллектуальный указатель на объект не-const-T должен содержать обычный указатель на не-const-T, а интеллектуальный указатель на const-T - обычный указатель на const-T. Можно было бы сделать это, поместив в базовый класс обычный указатель на const-T, а в производный - обычный указатель на не-const-T. Но получилось бы расточительно, так как объекты SmartPtr содержали бы тогда два' обычных указателя: один, унаследованный от SmartPtrToConst, а другой из самого SmartPtr. Эту проблему нетрудно разрешить при помощи объединения (union) - старого оружия языка С, которое может быть столь же полезно и в C++. Объединение является защищенным, поэтому оба класса имеют к нему доступ, и оно содержит оба необходимых типа обычных указателей. Объекты SmartPtrToConst<T> используют указатель constPointee, а объекты SmartPtr<T> - указатель pointee. Таким образом, вы имеете преимущества двух различных указателей, отводя память только под один. (См. еще один аналогичный пример в правиле 10.) В этом и заключается красота объединений. Конечно же, функции-члены должны ограничиваться использованием только соответствующего указателя, но компиляторы не могут наложить такое ограничение, и это делает использование объединений рискованным. При такой реализации вы получаете нужное поведение: SmartPtr<CD> pCD = new CD("Famous Movie Themes"); SmartPtrToConst<CD> pConstCD = pCD; // Нормально. Оценка На этом завершается рассмотрение интеллектуальных указателей, но напоследок зададимся вопросом: а имеет ли смысл их использовать, в особенности, если ваши компиляторы не поддерживают шаблоны функций-членов? Часто интеллектуальные указатели стоят возможного риска. Например, код для подсчета ссылок в правиле 29 благодаря их применению становится намного проще. Кроме того, как показывает практика, иногда интеллектуальные указатели используются ограниченно, и большинство возможных проблем (проверка на равенство нулю, преобразование к обычным указателям, преобразования типов, основанные на наследовании, и поддержка указателей на const) при этом не возникают. В целом, конечно, реализация, понимание и поддержка интеллектуальных указателей довольно сложны. Код, использующий интеллектуальные указатели, труднее отлаживать, чем код на основе обычных указателей. Как бы вы ни пытались, у вас не получится разработать универсальный интеллектуальный указатель, который сможет заменить его обычный аналог незаметно для пользователя. Тем не менее, интеллектуальные указатели позволяют выполнять в программе действия, которые было бы сложно реализовать иным способом. Если такие указатели использовать разумно, они будут полезны каждому программисту на языке C++.
Ш*ШМ1Ш Приемы Правило 29. Используйте подсчет ссылок Подсчет ссылок - это метод, который позволяет нескольким объектам, имеющим одинаковое значение, хранить его в одном и том же месте. Он обычно применяется по двум причинам. Во-первых, чтобы упростить учет системных ресурсов для объектов в куче. После выделения памяти для объекта при помощи оператора new важно отслеживать владельца этого объекта, так как только владелец отвечает за вызов оператора delete для удаления объекта. Но владелец объекта может меняться в процессе работы программы (например, при передаче указателей в качестве параметров), поэтому отслеживание владельца объекта - достаточно сложная задача. Облегчить ее выполнение можно при помощи таких классов, как auto_ptr (см. правило 9), но практика показала, что в большинстве программ все еще не удается правильно реализовать данный метод. Подсчет ссылок избавляет программиста от необходимости отслеживать владельца объекта, поскольку в этом случае объект сам является своим владельцем и автоматически самоуничтожается, если никто больше его не использует. Таким образом, подсчет ссылок по сути является простой формой сборки мусора (garbage collection). Подсчет ссылок применяется также из соображений здравого смысла. Если множество объектов имеют одно и то же значение, то глупо хранить несколько его копий. Лучше совместно использовать его в нескольких объектах с тем же самым значением. Это не только экономит память, но и ускоряет выполнение программ, так как не нужно создавать и уничтожать лишние копии с одним и тем же значением. Как и большинство простых идей, она имеет множество нюансов. В деталях и заключена успешная реализация подсчета ссылок. Но прежде чем углубиться в детали, нужно овладеть основами. Лучше всего начать с рассмотрения того, как можно создать несколько объектов с одним и тем же значением. Вот один из способов: class String { // Стандартный тип string не public: // обязательно должен использовать // методы из этого правила. String(const char *value = "") • Strings operator=(const Strings rhs) ; private: char *data; }; String a, b, c, d, e; a = b = c = d = e = "Hello"; Очевидно, что объекты а-е имеют одно и то же значение "Hello". Его представление зависит от реализации класса String, но обычно каждый объект String имеет собственную копию этого значения. Например, оператор присваивания класса String может быть реализован так: Strings String::operator=(const Strings rhs) {
Правило 29 1НННШ if (this == &rhs) return *this; delete [] data; data = new char [strlen (rhs.data) + 1], strcpyfdata, rhs.data); return *this; } Если задана такая реализация, то можно представить пять объектов и их значения так, как показано на рис. 5.7. ©—-Qj[ 0- (d) »-| Hollo Н i 0- Hello Рис. 5.7 Избыточность такого подхода очевидна. В идеале хотелось бы, чтобы схема программы была аналогична рис. 5.8. Рис. 5.8 В памяти хранится только одна копия значения "Hello", и его представление совместно используется объектами string, имеющим данное значение. На практике достичь этого идеала невозможно, так как нужно отслеживать число объектов, совместно использующих значение. Если объекту а присваивается новое значение, то вы не можете уничтожить значение "Hello", поскольку оно все еще нужно четырем другим объектам. С другой стороны, если только один объект имеет значение "Hello", и этот объект уходит из области видимости, то больше ни один объект не будет обладать таким же значением, и вы должны уничтожить его, чтобы избежать утечки ресурсов. Необходимость сохранять информацию о числе совместно используемых объектов приводит к тому, что идеальная схема должна учитывать существование счетчика ссылок (reference count) - см. рис. 5.9. (Некоторые называют это число счетчиком использования (use count), но я к ним не отношусь. В языке C++ достаточно стилистических особенностей, и меньше всего он нуждается в терминологической путанице.)
Hill Приемы Рис. 5.9 Реализация подсчета ссылок Создать класс String со счетчиком ссылок несложно, но при этом требуется учесть множество деталей, поэтому сейчас будет рассказано о реализации основных функций - членов такого класса. Но сначала важно понять, что необходимо выделить память под каждый счетчик ссылок для всех значений String. Память не должна находиться в объекте String, так как нужен один счетчик для каждого значения строки, а не для каждого объекта. Это подразумевает наличие связи между значениями и счетчиками ссылок, поэтому стоит создать класс для хранения счетчиков ссылок и отслеживаемых ими значений. Назовем его stringValue, и поскольку весь смысл его создания заключается в том, чтобы помочь нам реализовать класс String, то поместим его в закрытую область класса String. Кроме этого, было бы удобно предоставить всем функциям - членам класса string полный доступ к структуре данных класса StringValue, поэтому объявим String- Value как struct. Это полезный прием: помещение структуры в закрытую часть класса является обычным способом обеспечить доступ к структуре членам класса, закрыв его для всех остальных (конечно же, за исключением дружественных классов). Код мог бы выглядеть примерно так: class String { public: private: struct StringValue { StringValue *value; // Здесь находятся функции // члены класса String. // Содержит счетчик ссылок // и значение строки. // Значение класса String. }; Вы можете дать этому классу другое имя (например, RCString), чтобы подчеркнуть, что он реализован с применением счетчика ссылок, но реализация класса не должна волновать его пользователей, которых обычно интересует только открытый интерфейс класса. Реализация интерфейса String со счетчиком ссылок поддерживает те же самые операции, что и версия без него, поэтому зачем усложнять программу, включая в имена классов, соответствующих абстрактным понятиям, сведения об их реализации?
Правило 29 ilMMHEES Вот код класса StringValue: class String { private: struct StringValue { int refCount; char *data; StringValue(const char *initValue); ~StringValue(); }; }; String::StringValue::StringValue(const char *initValue) : refCountA) { data = new char [strlen(initValue) + 1] ; strcpy(data, initValue); } String::StringValue::-StringValue() { delete [] data; } Только и всего, но, как вы понимаете, это совсем не полная реализация нужной функциональности. Во-первых, не реализованы ни конструктор копирования, ни оператор присваивания, во-вторых, не обрабатывается поле ref Count. He беспокойтесь - отсутствующие функции обеспечит класс String. Класс String- Value в основном предназначен для того, чтобы связать определенное значение со счетчиком числа объектов типа string, совместно использующих это значение. Теперь можно перейти к функциям - членам класса String. Начнем с конструкторов: class String { public: String(const char *initValue = "") ; String(const Strings rhs); }; Первый конструктор реализован настолько просто, насколько возможно. Для создания объекта StringValue используется передаваемая строка char*: String::String(const char *initValue) : value(new StringValue(initValue)) {} Для следующего пользовательского кода: String s("More Effective C++"); получится структура данных, которая выглядит примерно так, как показано на рис. 5.10. 7-679
1 ИИ 111 Приемы 0- <з> MoroFffootivoO Рис. 5.10 Создаваемые по отдельности объекты String с одинаковым начальным значением не будут использовать структуру данных совместно, поэтому такой код: String si("More Effective C++"); String s2("More Effective C++"); дает структуру данных, изображенную на рис. 5.11. —KD— fs11 More Effective С+-1 <2> More Effective С >¦-• Рис. 5.11 Избежать такого дублирования можно, отслеживая в объекте String (или StringValue) существующие объекты StringValue и создавая новые объекты только для уникальных строк, но такое усовершенствование подсчета ссылок находится в стороне от основного пути. Поэтому я оставлю его в качестве страшного и ненавистного упражнения для читателей. Конструктор копирования класса String совсем не сложен и очень эффективен: вновь созданный объект String совместно использует тот же самый объект StringValue, что и копируемый объект String. String::String(const Strings rhs) : value(rhs.value) ++value->refCount; } Следующий код: String si("More Effective C++"); String s2 = si; дает в результате структуру данных, показанную на рис. 5.12. More Ef loctive C+-> Рис. 5.12 Такой класс намного эффективнее, чем обычная (без подсчета ссылок) реализация класса String, поскольку при этом не нужно выделять память для второй копии значения строки, нет необходимости впоследствии освобождать память, и не требуется копировать значение, которое будет помещено в эту память. Вместо этого достаточно скопировать указатель и увеличить значение счетчика ссылок на единицу.
Правило 29 ЛМННЕШ Реализация деструктора String тоже довольно проста, так как большую часть времени он ничего не делает. Пока счетчик ссылок на объект StringValue не равен нулю, по меньшей мере один объект String использует данное значение, поэтому оно не должно быть уничтожено. Только когда уничтожаемый объект String является единственным объектом, использующим данное значение, то есть когда счетчик ссылок на него равен 1, деструктор string должен уничтожать объект StringValue: class String { public: -String(); }; String::~String() { if (--value->refCount == 0) delete value; } Сравните эффективность этой функции с деструктором в реализации без счетчика ссылок. Такая функция всегда вызывала бы оператор delete, и ее выполнение почти всегда требовало бы значительных затрат. Если у различных объектов string вдруг окажутся одинаковые значения, то приведенная реализация будет просто уменьшать значение счетчика на единицу и проверять его на равенство нулю. Это все, что относится к созданию и уничтожению объектов String, поэтому перейдем к оператору присваивания класса string: class String { public: Strings operator=(const Strings rhs); }; Когда пользователь пишет такой код: si = s2; // И si, и s2 - объекты типа String. то в результате присваивания si и s2 должны указывать на один и тот же объект StringValue. Поэтому во время присваивания значение счетчика ссылок должно быть увеличено на единицу. Кроме того, значение счетчика ссылок на объект StringValue, на который указывал объект si до присваивания, должно уменьшиться на единицу, поскольку s 1 уже не имеет это значение. Если объект s 1 был единственным объектом типа String с данным значением, то оно должно быть уничтожено. В языке C++ это выглядит примерно так: Strings String::operator=(const Strings rhs) { if (value == rhs.value) { . // Если значения уже return *this; // одинаковы, ничего не } // делать; обычная проверка // для значения &rhs.
IMli Приемы if (--value->refCount delete value; ) value = rhs.value; ++value->refCount; return *this; == 0) { // Уничтожить значение *this, // если его больше никто не // использует. // Объекты *this и rhs // использует одно значение. } Копирование при записи Завершая изучение строк со счетчиком ссылок, рассмотрим оператор [ ], зволяющий считывать и записывать отдельные символы в строке: class String { public: const char& operator!](int index) const; // Для const String. char& operator!](int index); // Для не-const // объектов String. ПО- }; Реализовать const версию функции несложно, так это просто операция чтения; значение строки не может изменяться: const char& String::operator!](int index) const { } return value->data[index] (В этой функции проверка правильности index выполняется в соответствии с главной традицией C++, то есть не выполняется вообще. Если вам нужна проверка корректности параметра, вы можете легко добавить ее сами.) He-const версия operator [ ] - совсем другая история. Эта функция может вызываться как для считывания символа, так и для его записи: String s; cout << s[3]; s[5] = "x"; // Это чтение. // Это запись. Конечно, хотелось бы по-разному обрабатывать чтение и запись. Простое чтение можно выполнять точно так же, как и для вышеприведенной const версии operator [ ], но запись должна быть реализована иначе. Когда вы изменяете значение объекта string, будьте внимательны, чтобы не изменить значение других объектов String, совместно использующих тот же самый объект StringValue. К несчастью, компиляторы C++ не могут сообщить вам, запись или чтение осуществляется посредством функции operator [ ], поэтому
Правило 29 "ШШЖШ приходится предполагать, что все вызовы не-cons t operator [ ] выполняются для записи. (Вы можете различать запись и чтение при помощи proxy-классов, см. правило 30.) Для безопасной реализации не-const operator [ ] надо убедиться, что ни один из других объектов String, совместно использующих изменяемое значение StringValue, не будет изменен при выполнении предполагаемой записи. Короче говоря, вы должны быть уверены, что счетчик ссылок для соответствующего объекта StringValue равен единице всегда, когда вы возвращаете ссылку на символ внутри объекта StringValue. Вот как это сделать: char& String::operator[](int index) { // Если значение совместно используется другими // объектами String, создать отдельную копию значения. if (value->refCount > 1) { // Уменьшить счетчик ссылок --value->refCount; // refCount для текущего // значения - оно больше не // будет использоваться, value = // Сделать копию значения, new StringValue(value->data); } // Вернуть ссылку на символ в созданном // отдельном объекте StringValue. return value->data[index]; } Эта идея - совместное использование значения до тех пор, пока не нужно будет записать что-либо в отдельную его копию, - имеет давнюю и славную историю в информатике, в особенности в операционных системах, где процессы могут совместно использовать страницы до тех пор, пока им не потребуется изменить данные в своей копии страницы. Описанный метод достаточно распространен и называется копирование при записи (copy-on-write). Это специфическая разновидность более общего подхода к увеличению эффективности - отложенного вычисления (см. правило 17). Указатели, ссылки и копирование при записи Эта реализация копирования при записи почти позволяет сохранить и правильность, и эффективность. Остается одна давняя проблема. Рассмотрим следующий код: String si = "Hello"; char *p = &sl[l] ; Структура данных будет при этом выглядеть примерно так, как показано на рис. 5.13.
Приемы Cs1) Ho'lo Рис. 5.13 Теперь рассмотрим еще один оператор: String s2 = si; Конструктор копирования класса String позволяет объектам s2 и si совместно использовать значение StringValue, поэтому полученная в результате структура данных будет иметь вид, представленный на рис. 5.14. ®г~ Рис. 5.14 Тогда происходит следующее: *р = "х"; // Изменяются и si, и s2 ! Конструктор копирования класса String не может обнаружить эту проблему, так как нельзя узнать, существует ли указатель на объект StringValue со значением объекта si. И проблема не ограничивается указателями: она будет возникать и в том случае, если кто-либо сохранил ссылку на результат вызова не-const operator [] класса String. С этой проблемой можно справиться тремя различными способами. Первый состоит в том, чтобы игнорировать ее, сделать вид, что она не существует. Такой подход, к несчастью, слишком часто встречается в библиотеках классов, реализующих строки с подсчетом ссылок. Если вы располагаете одной из подобных библиотек, проверьте ее, выполнив вышеприведенный пример. Если вы не уверены, выполняется ли в классе подсчет ссылок, все равно попробуйте выполнить пример. Благодаря чуду инкапсуляции может оказаться, что вы все же используете такой тип, даже не зная об этом. Но проблема игнорируется не во всех реализациях. Несколько более сложный способ справиться с трудностями - объявить их использование недопустимым. В документации таких реализаций обычно говорится примерно следующее: «Не делайте этого, в противном случае результат будет неопределенным». Если вы все же сделаете это, сознательно или нет, и жалуетесь на результаты, вам отвечают: «Мы же вас предупреждали». Такие реализации часто достаточно эффективны, но удобство их использования оставляет желать лучшего.
Правило 29 1ИШМШ Существует и третье решение, которое состоит в устранении проблемы. Реализовать его несложно, но оно иногда уменьшает степень совместного использования значений объектами. Суть решения в следующем: к каждому объекту StringValue добавляется флаг, показывающий, может ли объект использоваться совместно. Первоначально флаг устанавливается (объект может использоваться совместно), а затем сбрасывается при вызове для представленного объектом значения не-const operator [ ]. После того как этот флаг принимает значение false, оно остается таким навсегда.* Вот измененная версия StringValue, включающая флаг, которая определяет возможность совместного использования: class String { private: struct StringValue { int refCount; bool shareable; // Добавить это. char *data; StringValue(const char *initValue); -StringValue(); }; }; String::StringValue::StringValue(const char *initValue) : refCountA), shareable(true) // И это. { data = new char[strlen(initValue) + 1]; strcpy(data, initValue); } String::StringValue::-StringValue() { delete [] data; } Как видите, здесь нужны лишь небольшие изменения: две строки, которые их требуют, помечены комментариями. Соответственно, необходимо обновить и функции - члены класса String, чтобы учесть наличие поля shareable. Вот как это можно сделать для конструктора копирования: String::String(const Strings rhs) С if (rhs.value->shareable) { * Тип string в стандартной библиотеке C++ (см. правило 35) использует комбинацию второго и третьего решений. Гарантируется, что ссылка, возвращаемая не-const operator [ ], является корректной до следующего вызова функции, который может изменить данную строку. После этого использование ссылки (или символа, на который она указывает) дает неопределенный результат. Это позволяет восстанавливать значение true для флага после каждого вызова функции, которая могла изменить строку.
ЕЕПНИ1 value = rhs.value,• ++value->refCount; } else { value = new StringValue(rhs.value->data); ) } Все остальные функции - члены класса String должны проверять поле shareable аналогичным образом. Единственная функция, которая будет присваивать флагу shareable значение false,- это не-const версия operator[]. char& String::operator[](int index) { if (value->refCount > 1) { --value->refCount; value = new StringValue(value->data); ) value->shareable = false; // Добавить эту строку, return value->data[index]; } Если чтобы различать чтение и запись в operator [ ], вы используете метод proxy-класса из правила 30, скорее всего это приводит к уменьшению числа объектов StringValue, которые должны быть помечены как недоступные для совместного использования. Базовый класс для подсчета ссылок Подсчет ссылок полезен не только для строк. Кандидатом для этой операции является любой класс, в котором несколько объектов могут иметь общие значения. Но чтобы переписать класс для использования подсчета ссылок, требуется приложить немало усилий, а большинство из нас и так достаточно заняты. Не было бы лучше, если бы можно было как-то написать (а также проверить и документировать) контекстно-независимый код для подсчета ссылок, а затем просто включать его в классы при необходимости? Конечно, это было бы лучше. К счастью, существует способ сделать это полностью или почти полностью. Первый шаг состоит в создании базового класса RCOb j ect для объектов с подсчетом ссылок, от которого должны наследовать все классы, собирающиеся использовать автоматический подсчет ссылок. Класс RCOb j ect инкапсулирует счетчик ссылок, а также функции для уменьшения и увеличения этого счетчика. Он также содержит код для уничтожения значения после того, как оно перестает быть нужным, то есть когда счетчик ссылок на него становится равным 0. И наконец, он имеет поле, определяющее, может ли это значение использоваться совместно, и функции для проверки и установки данного значения равным false. Нет необходимости придавать этому полю значение true, так как по умолчанию все значения могут использоваться совместно. Как указано выше, если объект был помечен как недоступный для совместного использования, он остается таким навсегда. Приемы
Правило 29 ММНННБШ Определение класса RCObject выглядит следующим образом: class RCObject { public: RCObject(); RCObject(const RCObjectSc rhs) ; RCObjectSc operator=(const RCObjectSc rhs) ; virtual -RCObject() = 0; void addReference(); void removeReference(); void markUnshareable(); bool isShareable() const; bool isSharedf) const; private: int refCount; bool shareable; }; Объекты RCObject могут создаваться и уничтожаться (как части базового класса в производных классах); к ним могут добавляться новые ссылки и удаляться существующие; разрешается запрашивать и устанавливать значение флага, определяющего возможность их совместного использования; они также могут сообщать, используются ли они уже совместно. Это все, что они способны предложить. Но большего от них как от класса, инкапсулирующего понятие подсчета ссылок, и не ожидалось. Обратите внимание на виртуальный деструктор - верный знак, что класс разработан для использования в качестве базового. Кроме того, деструктор является абстрактной функцией, а это свидетельствует, что класс будет применяться только в качестве базового. Код класса RCObject достаточно краток: RCObj ect::RCObj ect() : refCount(O), shareable(true) {} RCObject: : RCObject (const RCObjectSc) : refCount(O), shareable(true) {} RCObjectSc RCObject::operator=(const RCObjectSc) { return *this; } RCObject::-RCObject () {} // Виртуальные деструкторы должны // быть всегда реализованы, даже // если они являются полностью // виртуальными и ничего не делают // (см. также правило 33) . void RCObject::addReference() { ++refCount; } void RCObject::removeReference() { if (--refCount == 0) delete this; } void RCObject::markUnshareable() { shareable = false; } bool RCObject::isShareable() const { return shareable; } bool RCObject::isShared() const { return refCount > 1; }
ЕШННИК Приемы Странно, что в обоих конструкторах счетчику re f Count в конструкторе присваивается значение 0. Интуиция подсказывает обратное. По крайней мере, создатель нового объекта RCObject должен ссылаться на него! Оказывается, проще сделать так, чтобы создатели объектов RCObj ect сами присваивали счетчику ref Count значение 1, поэтому предусмотрено такое поведение конструктора в классе ref Count, которое обязывает их сделать это. Как вы вскоре увидите, в результате код значительно упростится. Другая странность состоит в том, что конструктор копирования всегда присваивает счетчику ref Count значение 0, независимо от значения ref Count в копируемом объекте RCObject. Это объясняется следующим образом: вы создаете новый объект, представляющий значение, а на новые значения всегда ссылается только их создатель, и они не используются совместно. И в этом случае автор объекта отвечает за установку правильного значения счетчика ref Count. Оператор присваивания класса RCObj ect ведет себя совершенно необычно: он не делает ничего. К счастью, маловероятно, что он будет вызываться вообще. Базовый класс RCObj ect предназначен для объекта совместно используемого значения, и в системе, основанной на подсчете ссылок, такие объекты не присваиваются друг другу, а вместо этого выполняется присваивание для объектов, указывающих на них. В рассматриваемом случае мы не ожидаем, что объекты StringValue будут присваиваться друг другу, предполагается, что присваивание будет выполняться для объектов String. При этом значение StringValue останется неизменным - будет меняться только значение счетчика ссылок. Тем не менее, существует вероятность, что в каком-то классе, который когда- либо будет наследовать от класса RCObj ect, потребуется разрешить присваивание значений, предусматривающих подсчет ссылок (см. правило 32). И в этом случае оператор присваивания класса RCObject должен сделать то, что нужно, то есть ничего. Чтобы понять, почему это так, представьте, будто вы хотели разрешить присвоение между объектами StringValue. Если имеются объекты svl и sv2 типа StringValue, что произойдет со счетчиками ссылок объектов svl и sv2? svl = sv2; // Что произойдет со счетчиками ссылок // объектов svl и sv2? До присваивания несколько объектов String указывает на svl. Их число не изменяется в результате присваивания, так как меняется только значение svl. Аналогично, до присваивания какое-то число объектов String указывает на sv2, и после присваивания те же самые объекты String указывают на sv2. Счетчик ссылок на объект sv2 также остается неизменным. Таким образом, при присваивании объектов RCObject число ссылок на эти объекты остается прежним, следовательно, функция RCObj ect: -. operator= не должна изменять счетчики ссылок. Это как раз то, что и делает приведенная выше реализация. Интуиция подсказывает обратное? Возможно, но предложенный код все же является корректным. Код функции RCObject: :removeReference отвечает не только за уменьшение счетчика ссылок ref Count, но и за уничтожение объекта, если новое значение счетчика ref Count равно 0. Функция выполняет последнюю задачу, вызывая delete this, а это безопасно, только если объект *this находится в куче
Правило 29 : ¦¦¦¦¦EH (см. правило 27). Для успешной работы данного класса вы должны спроектировать все так, чтобы объекты RCOb j ect могли быть только динамическими. Обычные способы реализации этого обсуждаются в правиле 27, но есть и особые меры, которые будут применены в рассматриваемом примере и описаны в резюме к этому разделу. Чтобы воспользоваться новым базовым классом для подсчета ссылок, изменим класс StringValue: он будет наследовать свойства для подсчета ссылок от класса RCObj ect. class String { private: struct StringValue: public RCObject { char *data; StringValue(const char *initValue); -StringValue(); }; }; String::StringValue::StringValue(const char *initValue) { data = new char[strlen(initValue) + 1] ; strcpyfdata, initValue); } String::StringValue::-StringValue() { delete [] data; } Эта версия класса StringValue почти идентична предыдущей. Только функции - члены класса StringValue больше не работают с полем ref Count. Эти действия теперь выполняет класс RCObj ect. Не бледнейте, увидев, что вложенный класс (StringValue) наследует от класса (RCObj ect), не связанного с классом (String), внутри которого он находится. Хоть такой подход на первый взгляд кажется непонятным, все здесь совершенно нормально. Вложенный класс - это такой же класс, как и любой другой, поэтому он может наследовать от какого угодно класса. Спустя некоторое время подобные отношения наследования будут восприниматься вами как вполне естественные. Автоматизация работы со счетчиком ссылок Класс RCObj ect позволяет вам размещать счетчик ссылок и предоставляет функции - члены класса, при помощи которых можно работать со счетчиками ссылок, но вызовы этих функций должны вставляться в другие классы вручную. А вызов функций addRef егепсе и removeRef егепсе для объектов StringValue все еще осуществляется конструктором копирования и оператором присваивания класса String. Это не слишком удобно. Хотелось бы переместить и эти функции в какой-нибудь класс, освободив авторов таких классов, как String, от всех забот
EQMHWlt Приемы по подсчету ссылок. Можно ли это сделать? Должен ли язык C++ поддерживать повторное использование кода? Он может и делает это. Не существует простого способа сделать так, чтобы все операции по подсчету ссылок были выведены из прикладных классов, но есть способ удалить большинство из них из большинства классов. (В некоторых прикладных классах можно удалить весь код для подсчета ссылок, но, увы, наш класс String не является таким. Все дело портит одна функция-член, и я полагаю, что вы не слишком удивитесь, узнав, что это наш старый враг - не-const версия operator [ ]. Соберитесь с духом, мы в конце концов усмирим злодея.) Обратите внимание, что каждый объект String содержит указатель на объект StringValue, представляющий значение объекта String: class String { private: struct StringValue: public RCObject { ... }; StringValue *value; // Значение объекта String. }; Если с одним из ссылающихся на объект указателей происходит что-нибудь интересное, вы должны оперировать полем refCount объекта StringValue. «Что-нибудь интересное» включает в себя копирование, присваивание или уничтожение указателя. Если бы был способ сделать, чтобы сам указатель как-то обнаруживал эти операции и автоматически выполнял необходимые действия с полем refCount, вы могли бы быть свободны. Увы, указатели - довольно тупые создания, и вероятность того, что они обнаружат что-либо, а тем более автоматически среагируют на найденное, очень невелика. К счастью, существует возможность заставить их работать лучше: заменить их объектами, которые действуют, как указатели, но делают больше, чем они. Такие объекты называются интеллектуальными указателями, и о них подробно рассказано в правиле 28. В данном случае вам нужно знать только, что объекты интеллектуальных указателей, так же как и настоящие указатели (которые часто называются обычными указателями), поддерживают операции выбора элемента (->) и разыменования (*), и, как и обычные указатели, они имеют строго заданный тип: нельзя сделать так, чтобы интеллектуальный указатель на объект типа Т ссылался на объект другого типа. Вот шаблон для объектов, которые ведут себя как интеллектуальные указатели на объекты для подсчета ссылок: // Класс шаблона для интеллектуальных указателей на объекты Т. // Класс Т должен поддерживать интерфейс RCObject, // обычно при помощи наследования от класса RCObject. template<class T> class RCPtr { public: RCPtr(T* realPtr = 0) ;
Правило 29 ЛИ RCPtr(const RCPtr& rhs); -RCPtr(); RCPtrSt operator= (const RCPtr& rhs) ; T* operator->() const; T& operator*() const; private: T *pointee; void init(); //См. правило 28. //См. правило 28. // Обычный указатель, который // эмулирует этот объект. // Общий код инициализации. }; Этот шаблон позволяет объектам интеллектуальных указателей управлять тем, что происходит при их создании, присваивании и уничтожении. Когда происходят такие события, объекты могут автоматически выполнять соответствующие операции с полем re f Count в объектах, на которые они указывают. Например, при создании объекта RCPtr счетчик ссылок для объекта, на который он указывает, должен быть увеличен. Нет необходимости обременять прикладных разработчиков требованием вручную выполнять такие скучные операции, поскольку конструкторы RCPtr могут сделать это сами. Код для двух конструкторов почти идентичен (отличаются только списки инициализации членов), поэтому вместо того, чтобы писать их дважды, можно поместить этот код в закрытую функцию - член класса init и вызвать ее из обоих конструкторов: template<class T> RCPtr<T>::RCPtr(T* realPtr): pointee(realPtr) { init () ; } template<class T> RCPtr<T>::RCPtr(const RCPtr& rhs): pointee(rhs.pointee) { init(); } template<class T> void RCPtr<T>::init() { if (pointee == 0) { return; } if (pointee->isShareable() == false) { pointee = new T(*pointee); } pointee->addReference() } // Если обычный указатель равен // null, то интеллектуальный // указатель тоже нулевой. // Если значение //не может // использоваться совместно, // скопировать его. // Теперь существует новая // ссылка на значение. Размещение общего кода в отдельной функции, такой как init, кажется блестящим решением, но его глянец тускнеет, если функция ведет себя некорректно, как это происходит в данном примере.
ЕШШШШШВ: Приемы Проблема заключается в следующем. Когда функция init должна создать новую копию значения (так как существующая копия не может использоваться совместно), она выполняет следующий код: pointee = new T(*pointee); Объект pointee имеет тип указателя на Т, поэтому этот оператор создает новый объект Т и инициализирует его, вызывая конструктор копирования т. Для указателя RCPtr в классе String объектом Т будет String: : StringValue, поэтому приведенный оператор вызовет конструктор копирования для String: :String- Value. Но вы не объявили для этого класса конструктор копирования, поэтому он будет создан компилятором. Полученный конструктор копирования будет, в соответствии с правилами для автоматически создаваемых конструкторов копирования языка C++, копировать только указатель data объекта StringValue, он не будет копировать строку char*, на которую ссылается указатель data. Такое поведение - бедствие почти для любого класса (а не только для класса с подсчетом ссылок), и поэтому вам следует создавать конструктор копирования (и оператор присваивания) для всех классов, содержащих указатели. Корректность поведения шаблона RCPtr<T> зависит от того, содержит ли класс Т конструктор копирования, создающий полностью независимую копию (то есть выполняющий детальное копирование) значения, представляемого этим объектом. Вы должны добавить такой конструктор в класс StringValue прежде, чем использовать его в классе RCPtr: class String { private: struct StringValue: public RCObject { StringValue(const StringValuek rhs) ; }; }; String::StringValue::StringValue(const StringValuek rhs) { data = new char[strlen(rhs.data) + 1]; strcpy(data, rhs.data); } Существование конструктора детального копирования не единственное требование, которое интеллектуальный указатель RCPtr<T> предъявляет к классу Т. Кроме того, класс Т должен наследовать от класса RCOb j ect или обеспечивать ту же функциональность. Это достаточно разумно, если принять во внимание, что объекты RCOb j ect были разработаны для того, чтобы указывать только на объекты со счетчиком ссылок. Но несмотря на его очевидность, данное требование следует документировать. И наконец, предполагается, что RCPtr<T> указывает на объект типа т. Это также кажется достаточно очевидным. В конце концов, тип pointee объявляется как Т*. Но в действительности pointee может указывать на производный от Т класс.
Правило 29 ^j Например, если бы имелся класс SpecialStringValue, наследующий от класса String-. : StringValue: class String ( private: struct StringValue: public RCObject { ... } ; struct SpecialStringValue: public StringValue { ... }; }; то в итоге мог бы получиться класс String, в котором RCPtr<StringValue> указывал бы на объект SpecialStringValue. В таком случае хотелось бы, чтобы эта часть функции init: pointee = new T(*pointee); // T имеет тип StringValue, //но pointee указывает //на тип SpecialStringValue. вызывала конструктор копирования класса SpecialStringValue, а не String- Value. Это можно сделать при помощи виртуального конструктора копирования (см. правило 25). В случае рассматриваемого класса String не предполагается, что какие-либо классы будут наследовать от класса StringValue, поэтому этим вопросом можно пренебречь. После того как вы разобрались с конструкторами класса RCPtr, с остальными функциями класса справитесь намного быстрее. Присваивание для объектов RCPtr выполняется достаточно просто, нужно только проверить, можно ли использовать вновь присвоенное значение совместно. К счастью, такая проверка уже выполняется функцией init, которая была создана для конструкторов RCPtr. Снова используем ее здесь: template<class T> RCPtr<T>& RCPtr<T>::operator=(const RCPtr& rhs) { if (pointee != rhs.pointee) { //He выполнять // присваивание, если // значение не меняется, if (pointee) { pointee->removeReference(); // Удалить } // ссылку на текущее // значение, pointee = rhs.pointee; // Указывает на новое initО; // значение, если возможно } // использовать его // совместно, иначе создать return *this; // собственную копию. } Деструктор устроен проще. При уничтожении объекта RCPtr он просто удаляет его ссылку на объект, для которого выполняется подсчет ссылок:
ill Приемы template<class T> RCPtr<T>::-RCPtr( if (pointee)pointee->removeReference() ; } Если этот интеллектуальный указатель RCPtr был последней ссылкой на объект, то будет уничтожен объект внутри функции removeRef егепсе - члена класса RCObject. Следовательно, объекты RCPtr не должны беспокоиться об уничтожении значений, на которые они указывают. И наконец, операторы в классе RCPtr, эмулирующие указатели, являются частью стандартной библиотеки интеллектуальных указателей, о которой вы можете прочитать в правиле 28: template<class T> Т* RCPtr<T>::operator->() const { return pointee; } template<class T> T& RCPtr<T>::operator*() const { return *pointee; } Резюме Теперь можно свести все части вместе и построить класс String с подсчетом ссылок, основанный на классах RCObject и RCPtr. Надеюсь, вы не забыли, что это и было исходной целью упражнения. Каждая строка с подсчетом ссылок реализуется при помощи структуры данных, схема которой представлена на рис. 5.15. Рис. 5.15
Правило 29 ИНШВГС21 Образующие эту структуру классы определяются так: template<class T> // Шаблон класса для интеллектуальных class RCPtr { // указателей на объекты Т; класс Т public: // должен наследовать от класса RCObject. RCPtr(T* realPtr = 0) ; RCPtr(const RCPtr& rhs) ; -RCPtr(); RCPtr& operator=(const RCPtr& rhs); T* operator->() const; T& operator* () const; private: T *pointee; void init(); }; class RCObject { // Базовый класс для объектов public: //со счетчиком ссылок. void addReference(); void removeReference(); void markUnshareable(); bool isShareable() const; bool isShared() const; protected: RCObject () ; RCObject(const RCObjectk rhs); RCObject& operator=(const RCObject& rhs) ; virtual -RCObject() = 0; private: int refCount; bool shareable; }; class String { // Класс для использования public: // прикладными разработчиками. String(const char *value = ""); const char& operator!](int index) const; char& operator!](int index); private: // Класс для хранения строковых значений struct StringValue: public RCObject { char *data; StringValue (const char *initValue) ,- StringValue(const StringValue& rhs); void init (const char *initValue) ,- -StringValue(); }; RCPtr<StringValue> value; }; Этот код в основном суммирует то, что было разработано ранее, поэтому здесь для вас не должно быть сюрпризов. Привнимательном рассмотрении можно увидеть,
ШШШШШт Приемы что к классу String: : StringValue добавлена функция init, но как будет показано дальше, она предназначена для того же, для чего и соответствующая функция в классе RCPtr: предотвращает дублирование кода в конструкторах. Открытый интерфейс приведенного класса string значительно отличается от того, который использовался в начале этого раздела. Где конструктор копирования? Где оператор присваивания? Где деструктор? Здесь явно что-то неладно. Нет, все в порядке. Если вы не видите, что это так, приготовьтесь к крещению языком C++. Эти функции вам больше не нужны. Несомненно, копирование объектов String допустимо, и такое копирование будет корректно обрабатывать лежащие в их основе объекты StringValue со счетчиками ссылок, но в классе String не написано ни одной соответствующей строчки. Причина в том, что созданный компилятором конструктор копирования String автоматически вызовет конструктор копирования элемента RCPtr класса String, который и выполнит все необходимые действия над объектом StringValue, включая и подсчет ссылок на него. Вы не забыли, что указатель RCPtr является интеллектуальным? Он создан для обработки подсчета ссылок, и именно это и делает. Он также выполняет присваивание и уничтожение, поэтому писать данные функции для класса String не нужно. Первоначальная цель упражнения состояла в том, чтобы переместить код для подсчета ссылок из класса String в контекстно-независимые классы, где они были бы доступны для использования любым классом. Теперь это сделано (в форме классов RCObject и RCPtr) и отлично работает. Для того чтобы свести все вместе, приведем реализацию класса RCObject: RCObject::RCObject() : refCount(O), shareable(true) {} RCObject: : RCObject (const RCObjectSc) : refCount(O), shareable(true) {} RCObjectSc RCObject::operator=(const RCObjectSc) { return *this; } RCObject::-RCObject() {} void RCObject::addReference() { ++refCount; } void RCObject::removeReference() { if (-refCount == 0) delete this; } void RCObject::markUnshareable() { shareable = false; } bool RCObject::isShareable() const { return shareable; } bool RCObject::isShared() const { return refCount > 1; } А это реализация класса RCPtr: template<class T> void RCPtr<T>::init() { if (pointee == 0) return; if (pointee->isShareable() == false) { pointee = new T(*pointee);
Правило 29 . ОЕ^ШЕШ } pointee->addReference(); } template<class T> RCPtr<T>::RCPtr(T* realPtr): pointee(realPtr) { init(); } template<class T> RCPtr<T>::RCPtr(const RCPtr& rhs) : pointee(rhs.pointee) { init(); } template<class T> RCPtr<T>::~RCPtr() { if (pointee)pointee->removeReference(); } template<class T> RCPtr<T>& RCPtr<T>::operator=(const RCPtr& rhs) { if (pointee != rhs.pointee) { T *oldPointee = pointee; pointee = rhs.pointee; init(); if (oldPointee) oldPointee->removeReference (); } return *this; } template<class T> T* RCPtr<T>::operator->() const { return pointee; } template<class T> T& RCPtr<T>::operator*() const { return *pointee; } Реализация String: : StringValue выглядит так: void String::StringValue::init(const char *initValue) { data = new char[strlen(initValue) + 1] ; strcpy(data, initValue); } String::StringValue::StringValue(const char *initValue) { init(initValue); } String::StringValue::StringValue(const StringValuek rhs) { init(rhs.data); } String::StringValue::-StringValue() { delete [] data; ) В конце концов, все пути ведут к классу String, который реализован следующим образом: String::String(const char *initValue): value(new StringValue(initValue)) {} const chars String::operator[](int index) const { return value->data[index]; } char& String::operator[] (int index)
{ if (value->isShared()) { value = new StringValue(value->data); } value->markUnshareable(); return value->data[index]; } Если вы сравните этот код класса string с кодом, который был разработан при помощи обычных указателей, то будете поражены двумя вещами. Во-первых, класс стал намного меньше, поскольку класс RCPtr принимает на себя основные функции, которые выполнял класс String. Во-вторых, код, оставшийся в классе String, практически не изменился: интеллектуальный указатель почти незаметно заменил обычный. Единственное новшество коснулось функции operator [ ], где вызывается функция is Shared вместо прямой проверки значения re f Count, а применение объекта интеллектуального указателя RCPtr устраняет необходимость вручную изменять счетчик ссылок во время копирования при записи. Все это замечательно. Кто возражает против уменьшения кода? Кто против успешного применения инкапсуляции? Но итог скорее определяется не деталями реализации, а тем, как новоиспеченный класс string воспринимается пользователями, и именно здесь его преимущество наиболее очевидно. Если отсутствие новостей - хорошие новости, тогда то, что интерфейс класса String не изменился - действительно хорошо. Вы добавили к нему подсчет ссылок и возможность помечать отдельные значения строк как недоступные для совместного использования, затем переместили подсчет ссылок в новый базовый класс, включили в код интеллектуальные указатели для автоматизации обработки подсчета ссылок, и при этом ни одна строка пользовательского кода не изменилась. Конечно, было изменено определение класса, поэтому для того, чтобы использовать строки со счетчиком ссылок, пользователям придется снова выполнить компиляцию и компоновку, но их капиталовложения в разработанный код остаются в целости и сохранности. Видите? Инкапсуляция - это действительно замечательная вещь. Добавление подсчета ссылок к существующим классам До сих пор предполагалось, что вам доступен исходный код интересующих вас классов. Но как быть, если нужно использовать подсчет ссылок в каком-то классе Widget, находящемся в библиотеке, которую вы не можете изменять? Нельзя сделать Widget наследником класса RCObject, поэтому недопустимо использовать в нем интеллектуальные указатели RCPtr. Однако, слегка изменив схему, вы можете добавить подсчет ссылок к любому типу. Во-первых, рассмотрим, как бы выглядела схема, если бы была возможность сделать класс Widget наследником класса RCObj ect. В этом случае пришлось бы добавить класс RCWidget, с которым работали бы пользователи, и классы RCWidget и Widget были бы аналогичны соответственно классам String и StringValue в рассмотренном примере. Схема такой программы приведена на рис. 5.16. Приемы
Правило 29 \ Otu »¦ ' \ I ош>еч V4 i У 1111 класс RCObject Открытое наследование .' ' Указатель / 1 \^ • ) Рис. 5.16 Теперь стоит вспомнить принцип, который гласит, что большинство проблем теории программирования можно решить, добавив еще один уровень косвенной адресации. Добавим для счетчика ссылок еще один класс CountHolder и сделаем класс CountHolder наследником класса RCObject. Также включим в класс CountHolder указатель на Widget. Затем заменим шаблон RCPtr таким же интеллектуальным шаблоном RCIPtr, который знает о существовании класса CountHolder. (Буква I в названии класса RCIPtr означает indirect, то есть косвенный.) Измененная схема представлена на рис. 5.17. / \ <"' ' i , , ч / класс RCObject Открытое наследование ; Указатель / ГЧ-1.Г I I \ \ " ' ./ _*У объект Указатель Vwi^et Рис. 5.17
Hi Приемы // Ниже объясняется, // почему эти функции // объявлены таким // образом. Так же как детали реализации класса StringValue скрыты от пользователей класса String, класс CountHolder является одной из деталей реализации, скрытой от пользователей класса RCWidget. Фактически, это одна из деталей реализации класса RCIPtr, поэтому данный класс вложен в класс RCIPtr, реализованный следующим образом: template<class T> class RCIPtr { public: RCIPtr(T* realPtr = 0) ; RCIPtr(const RCIPtrb rhs); -RCIPtr(); RCIPtrk operator=(const RCIPtrb rhs); const T* operator->() const; T* operator->(); const T& operator*() const; T& operator*(); private: struct CountHolder: public RCObject { -CountHolder() { delete pointee; } T *pointee; }; CountHolder *counter; void init () ; void makeCopy () ; // См. ниже. }; template<class T> void RCIPtr<T>::init () { if (counter->isShareable() == false) { T *oldValue = counter->pointee; counter = new CountHolder; counter->pointee = new T(*oldValue); } counter->addReference(); } template<class T> RCIPtr<T>::RCIPtr(T* realPtr) : counter(new CountHolder) { counter->pointee = realPtr; init(); } template<class T> RCIPtr<T>::RCIPtr(const RCIPtr& rhs) : counter(rhs.counter) { init(); } template<class T> RCIPtr<T>::-RCIPtr()
Правило 29 { counter->removeReference() ; } template<class T> RCIPtr<T>& RCIPtr<T>::operator= { if (counter != rhs.counter) { counter->removeReference(); counter = rhs.counter; init(); } return *this; } template<class T> void RCIPtr<T>::makeCopy() iconst RCIPtrk rhs) { if (counter->isShared()) { T *oldValue = counter->pointee; counter->removeReference(); counter = new CountHolder; counter->pointee = new T(*oldValue) counter->addReference(); } } template<class T> const T* RCIPtr<T>::operator->() const { return counter->pointee; } template<class T> T* RCIPtr<T>::operator->() { makeCopy(); return counter->pointee; } template<class T> const T& RCIPtr<T>::operator*() const { return *(counter->pointee); } template<class T> T& RCIPtr<T>::operator*() { makeCopyO; return * (counter->pointee) // Реализация копирования // для копирования // при записи. // Доступ к const; // копирование при // записи не нужно. // Доступ к не-const; // нужно копирование // при записи. // Доступ к const; // копирование при // записи не нужно. // Доступ к не-const; // нужно копирование // при записи. Класс RCIPtr имеет только два отличия от класса RCPtr. Во-первых, объекты RCPtr непосредственно указывают на значения, а объекты RCIPtr указывают на значения через промежуточные объекты CountHolder. Во-вторых, класс RCIPtr перегружает функции operator-> и operator*, поэтому в случае не-const доступа к указываемому объекту копирование при записи происходит автоматически. Если имеется класс RCIPtr, достаточно просто реализовать класс RCWidget, поскольку каждая функция в классе RCWidget реализуется с помощью передачи вызова через лежащий ниже класс RCIPtr объекту Widget. Например, если класс Widget выглядит так: class Widget { public:
швммп Widget(int size); Widget(const Widgetk rhs); -Widget (); Widget& operator=(const Widgetk rhs); void doThis(); int showThat() const; }; то класс RCWidget будет определяться следующим образом: class RCWidget { public: RCWidget(int size) : value(new Widget(size)) {} void doThisO { value->doThis() ; } int showThat() const ( return value->showThat(); } private: RCIPtr<Widget> value; }; Обратите внимание: конструктор RCWidget вызывает конструктор Widget (при помощи оператора new - см. правило 8) с переданным ему аргументом; функция doThi s класса RCWi dget вызывает функцию doThi s в классе Widget; а функция RCWidget::showThat возвращает то же самое, что и ее двойник в классе Widget. Обратите также внимание на то, что в классе RCWidget не объявлены конструктор копирования, оператор присваивания и деструктор. Как и в случае класса String нет необходимости писать эти функции. Благодаря поведению класса RCIPtr версии, установленные по умолчанию, работают правильно. Если вам кажется, что создание класса RCWidget настолько предопределено, что его можно автоматизировать, то вы не ошиблись. Было бы несложно написать программу, на вход которой подавался бы класс, подобный Widget, а на выходе получался бы аналог класса RCWidget. Если вы напишете такую программу, пожалуйста, сообщите мне об этом. Оценка Давайте теперь попробуем выпутаться из деталей строк, значений, интеллектуальных указателей и базовых классов для подсчета ссылок. Для этого взглянем на процесс подсчета ссылок как бы со стороны и попытаемся решить вопрос более высокого уровня: является ли подсчет ссылок подходящим методом? Реализация подсчета ссылок имеет свою цену. Каждое значение содержит счетчик ссылок, и большинство операций требует проверки или изменения этого счетчика. Поэтому для значений объектов необходимо больше памяти, и иногда при работе с ними выполняется больше кода. Кроме этого, лежащий в основе метода исходный код намного сложнее для класса с подсчетом ссылок, чем для менее трудоемкой реализации. Класс строк с подсчетом ссылок обычно ни от чего не зависит, а последняя версия нашего класса String бесполезна, если она не основана на трех вспомогательных классах (StringValue, RCObject и RCPtr). Но сложная схема обещает большую эффективность за счет того, что значения Приемы
Правило 29 ОИНИЕЗ могут использоваться совместно, устраняется необходимость отслеживать владельца объекта и стимулируется повторное применение кода для подсчета ссылок. Однако все четыре класса нужно написать, протестировать, документировать и поддерживать, что, конечно, потребует больше работы, чем соответствующие операции для одного класса. Это способен понять даже менеджер. Подсчет ссылок - метод оптимизации, основанный на предположении, что объекты обычно будут совместно использовать значения (см. правило 18). Если же это предположение не подтверждается, то подсчет ссылок потребует больше памяти, чем обычная реализация, и при этом будет выполняться больше кода. С другой стороны, если объекты часто имеют общие значения, подсчет ссылок позволит сэкономить время и память. Чем больше значения объектов и чем больше объектов используют значения совместно, тем больше памяти вы сэкономите. Чем чаще выполняется копирование и присваивание значений объектов, тем значительнее будет экономия времени. Чем больших затрат требует создание и уничтожение значения, тем больше вы сэкономите времени. Короче говоря, подсчет ссылок позволяет повысить эффективность программы при следующих условиях: ? немного значений совместно используется большим числом объектов. Такое совместное использование обычно возникает при вызовах операторов присваивания и конструкторов копирования. Чем больше отношение «число объектов/число значений», тем лучше этот случай подходит для подсчета ссылок; ? создание и уничтожение значений объектов требует больших затрат, или они используют много памяти. Даже если это так, подсчет ссылок ничего вам не дает, если значения не могут использоваться совместно несколькими объектами. Есть только один надежный способ выяснить, удовлетворяются ли эти условия. Не стоит гадать или полагаться на программистскую интуицию (см. правило 16), лучше сразу использовать отладчик, чтобы определить, выиграет ли программа от применения подсчета ссылок. При этом вы можете определить, является ли создание и разрушение значений «узким местом» производительности, и измерить отношение «число объектов/число значений». Только такие данные позволят вам выяснить, перевешивают ли преимущества подсчета ссылок (которых множество) недостатки этого метода (которых тоже предостаточно). Даже если удовлетворяются вышеприведенные условия, подсчет ссылок может все же оказаться неподходящим методом. Некоторые структуры данных (например, неориентированные графы) порождают структуры, ссылающиеся сами на себя или с кольцевой зависимостью. В таких структурах данных возникают изолированные наборы объектов, которые никто не использует, и счетчики ссылок на которые никогда не становятся равными нулю. Это связано с тем, что на каждый объект в неиспользуемой структуре указывает по меньшей мере один другой объект в той же структуре. Коммерческие схемы для сборки мусора применяют специальные методы для поиска и устранения таких структур, но простой прием подсчета ссылок, который рассматривался выше, нелегко расширить, чтобы включить в него эти методы.
ЕШНММ1Р Приемы Подсчет ссылок пригодится, даже если эффективность не имеет для вас решающего значения. Если вас обременяет неопределенность того, кто может удалить объект, подсчет ссылок - именно тот метод, который облегчит вашу ношу Многие программисты преданы методу подсчета ссылок только по этой причине. В заключение разговора о подсчете ссылок надо указать, как устранить последний недочет. Уменьшая счетчик ссылок на объект на единицу, функция RCObject: :removeReference проверяет, становится ли он равным 0. Если это так, то функция removeRef erence уничтожает объект, выполняя оператор delete this. Данная операция безопасна, только если объект был создан при помощи вызова оператора new, поэтому нужен какой-то способ, гарантирующий, что объекты RCObj ect создаются только так. Здесь это делается по соглашению, класс RCOb j ect предназначен для использования в качестве базового класса для объектов со счетчиками ссылок на значения, и на такие объекты нужно ссылаться только при помощи интеллектуальных указателей RCPtr. Кроме того, экземпляры объектов должны создаваться только при помощи прикладных объектов, которые представляют используемые совместно значения; классы, описывающие объекты значений, не должны быть доступны для общего использования. В рассматриваемом примере классом для объектов значений является класс StringValue, и чтобы ограничить его использование, он сделан закрытым элементом класса string. Только объекты String могут создавать объекты StringValue, поэтому автор класса String должен гарантировать, что такие объекты будут создаваться при помощи оператора new. Создание объектов RCOb j ect будет возможно только в куче. Вся ответственность за выполнение этого ограничения ложится на хорошо определенный набор классов. Кроме того, гарантируется, что только этот набор классов способен создавать объекты RCObject. Пользователи не могут случайно (или намеренно) сформировать объекты RCObject другим образом. Право создавать объекты со счетчиком ссылок ограничено, и оно сопровождается сопутствующей ответственностью за соблюдение правил, контролирующих создание объектов. Правило 30. Применяйте proxy-классы Мир многомерен, но язык C++ еще не осознал этого. По крайней мере, к такому выводу подводит предложенная в языке реализация поддержки массивов. Можно создавать двумерные, трехмерные, даже n-мерные массивы в языках Fortran, Basic, даже в Cobol (Fortran позволяет работать только с семью измерениями, но не будем придираться), а можно ли сделать это в языке C++? Только иногда и только частично. Например, разрешается написать: int data[10] [20] ; // Двумерный массив 10 на 20. Но соответствующая конструкция, использующая переменные для задания размерности массива, недопустима: void processlnput (int diml, int dim2) {
Правило 30 Ш1ШПНЕЗЗ int data[diml][dim2]; // Ошибка! Размер массива ... // должен быть известен } //во время компиляции. Это недопустимо даже при динамическом выделении памяти: int *data = new int[diml][dim2]; // Ошибка! Реализация двумерных массивов Многомерные массивы так же полезны в C++, как и в любом другом языке, поэтому важно найти хороший способ обеспечить их поддержку. Обычный метод стандартен для C++: создать класс для представления нужных вам, но отсутствующих в языке объектов. Следовательно, можно определить шаблон класса для двумерных массивов: template<class T> class Array2D { public: Array2D(int diml, int dim2); }; Теперь достаточно определить нужные массивы: Array2D<int> dataA0, 20); //Нормально. Array2D<float> *data = new Array2D<float>A0, 20); //Нормально, void processlnput(int diml, int dim2) { Array2D<int> data(diml, dim2); // Нормально. } Использовать эти объекты массивов не совсем просто. Если следовать традиционному синтаксису С и C++, то для индексирования массивов должны использоваться квадратные скобки: cout << data[3] [6] ; Но как объявить в классе оператор Array2D индексирования, который позволит сделать это? Первое побуждение - объявить функции operator [ ] [ ], например, так: template<class T> class Array2D { public: // Объявления, которые не будут компилироваться. Т& operator[][](int indexl, int index2); const T& operator!] [] (int indexl, int index2) const; };
ЕЕЕММВИШ Приемы Но функция operator [ ] [ ] не существует, и не думайте, что ваши компиляторы об этом забудут. (См. в правиле 7 полный список операторов, как перегружаемых, так и неперегружаемых.) Придется сделать что-то другое. Если для вас приемлем такой синтаксис, то вы можете последовать примеру множества языков программирования, использующих для индексации массива круглые скобки. Для этого вам достаточно перегрузить функцию operator (): template<class T> class Array2D { public: // Объявления, которые будут компилироваться. Т& operator()(int indexl, int index2); const T& operator()(int indexl, int index2) const; }; Тогда пользователи будут использовать массивы следующим образом: cout << dataC , 6); Такие массивы легко реализовать и обобщать на произвольное число измерений. Недостаток этого подхода состоит в том, что объекты Array2D больше не похожи на встроенные массивы. Фактически, доступ к элементу C, 6) массива data выглядит как вызов функции. Если отказаться от мысли о том, что массивы должны быть похожи на «эмигрантов» из языка Fortran, можно снова обратиться к записи оператора индексирования с помощью квадратных скобок. Хотя operator [ ] [ ] и не существует, допустимо написать код, который имитирует его использование: int data[10] [20] ; cout « data[3] [6] ; ¦ // Нормально. Что это дает? Переменная data в действительности не будет двумерным массивом, а одномерным из 10 элементов. Каждый из этих 10 элементов, в свою очередь, представляет собой массив из 20 элементов, поэтому выражение data [ 3 ] [ 6 ] на самом деле означает (data [ 3 ] ) [ 6 ], то есть седьмой элемент массива, который является четвертым элементом массива data. Короче говоря, значение, получаемое в результате первого применения квадратных скобок, является другим массивом, поэтому второе применение скобок извлекает элемент из второстепенного массива. Можно выполнить то же самое для класса Array2D, перегрузив функцию operator [ ] так, чтобы она возвращала новый класс ArraylD. Затем снова перегрузить функцию operator [ ] в классе ArraylD, в результате чего она будет возвращать элемент в исходном двумерном массиве: template<class T> class Array2D { public: class ArraylD {
Правило 30 11МНЕ51 public: T& operator[](int index); const T& operator[](int index) const; }; ArraylD operator[](int index); const ArraylD operator[](int index) const; }; Тогда становится допустимым следующее: Array2D<float> dataA0, 20); cout « data[3][6]; // Нормально. В данном случае data [3 ] обозначает объект ArraylD, а вызов функции operator [ ] для этого объекта соответствует запятой в записи C , 6) в исходном двумерном массиве. Пользователи класса Array2D не обязательно должны знать о существовании класса ArraylD. Объекты последнего класса представляют собой одномерные массивы, которые не существуют с точки зрения клиентов класса Array2D. Пользовательские приложения пишутся так, как будто они используют реальные двумерные массивы. Пользователей не касается, что для того, чтобы удовлетворить капризам языка C++, классы Array2D должны быть синтаксически совместимы с одномерными массивами. Каждый объект ArraylD обозначает одномерный массив, отсутствующий в абстрактной модели, применяемой пользователями класса Array2D. Объекты, которые обозначают другие объекты, часто называются proxy-объектами (proxy objects), а классы, на основе которых создаются proxy-объекты, часто называются proxy-классами (proxy classes). В этом примере ArraylD является proxy-классом. Экземпляры объектов этого класса соответствуют одномерным массивам, которые теоретически не существуют. (Терминология proxy-классов и объектов не является универсальной; иногда объекты таких классов называются заместителями (surrogates).) Различение записи и чтения в функции operator[] Использование proxy-классов для реализации классов, экземпляры которых действуют как многомерные массивы, является общепринятым, но этим возможности proxy-классов не исчерпываются. Например, в правиле 5 показано, как с помощью таких классов не допустить, чтобы конструкторы с единственным аргументом выполняли ненужные преобразования типов. Но наиболее известным применением proxy-классов является различение чтения и записи в функции operator[]. Рассмотрим строковый тип с подсчетом ссылок, который поддерживает operator [ ]. Такой тип подробно описан в предыдущем разделе. Если вы не знакомы с понятиями, лежащими в основе подсчета ссылок, ознакомьтесь с правилом 29 сейчас.
ппшшшшт приемы Строковый тип, поддерживающий operator [ ], позволяет пользователям писать подобный код: String si, s2; // Строковый класс; // использование proxy-классов //не позволяет этому классу // соответствовать стандартному ... // интерфейсу строк. cout « sl[5]; // Считать si. s2[5] = "х"; // Записать s2. si [3] = s2[8] ; // Записать si, считать s2. Обратите внимание, что функция operator [ ] может вызываться как для чтения, так и для записи символа. При чтении оператор стоит справа от оператора присваивания, при записи - слева. Вообще говоря, использование объекта слева от оператора присваивания (как lvalue) означает, что он может изменяться, использование его справа (как rvalue) - что он не может быть изменен. Так как реализация чтения, в особенности для структур данных со счетчиком ссылок, требует намного меньших усилий, чем реализация записи, хотелось бы различать использование функции operator [ ] слева и справа от оператора присваивания. Как объясняется в правиле 29, запись объектов со счетчиком ссылок может включать копирование всей структуры данных, а для чтения достаточно возврата значения. К несчастью, не существует способа определить контекст вызова operator [ ] внутри самой функции, то есть невозможно различить ее использование как lvalue и rvalue. «Постойте», - скажете вы, - «нам не нужно делать этого. Мы можем перегрузить функцию operator [ ], воспользовавшись тем, что она объявлена как const, и это позволит нам различать чтение и запись». Другими словами, предлагается решить задачу таким образом: class String { public: const char& operator[](int index) const; // Для чтения. char& operator[](int index); // Для записи. }; Увы, это не сработает. Компиляторы делают выбор между const и не-const функциями - членами класса на основе того, имеет ли атрибут const вызывающий функцию объект. Контекст вызова функции при этом не учитывается. Следовательно: String si, s2; cout « si[5]; // Вызывает не-const operator[] , // так как si не-const объект. s2[5] = "x"; // Также вызывает не-const // operator[], так как s2 не-const объект. sl[3]=s2[8]; //В обоих случаях вызывается
Правило 30 ?11ШКШ // не-const operator[], так как // и si, и s2 - не-const объекты. В таком случае перегрузка функции operator [ ] не позволяет различать запись и чтение. В примере, иллюстрирующем правило 29, все вызовы operator [ ] выполнялись для записи. На этот раз поступим иначе. Различить использование operator [ ] слева и справа от оператора присваивания в самой функции нельзя, но, может быть, все-таки найдется способ обойти это ограничение? Да, невозможно определить внутри самой функции operator [ ], используется ли она в контексте lvalue или rvalue, однако ничто не мешает по-разному обрабатывать чтение и запись, если отложить действия до тех пор, пока не будет видно, как используется результат функции operator [ ]. Все что нужно - отсрочить решение о том, будет ли выполняться чтение или запись объекта, до тех пор пока не произойдет возврат из вызова функции operator [ ]. (Это пример отложенной оценки - см. правило 17.) Proxy-класс позволяет получить нужный выигрыш во времени, поскольку функцию operator [ ] можно изменить так, чтобы она возвращала proxy-объект для символа строки, а не сам символ. Затем надо подождать и посмотреть, как этот proxy-объект будет использоваться. Если он считывается, значит, вызов функции operator [ ] следует рассматривать как чтение, если записывается - как запись. Соответствующий код приведен ниже, но вначале поговорим, каким образом используются proxy-объекты. С proxy-объектами можно делать три вещи: ? создавать, то есть определять, какой символ строки они заменяют; ? использовать в качестве цели присваивания, при этом выполняется присваи вание символу строки, который они заменяют. В данном случае proxy-объект используется как lvalue; ? использовать любым другим способом. При этом proxy-объект выступает как rvalue. Вот определение для класса string с подсчетом ссылок, где распознавание функции operator [ ] (используется ли она как lvalue или rvalue) осуществляется с помощью proxy-класса: class String { // Строки с подсчетом ссылок; public: // подробнее см. правило 29. class CharProxy { // Proxy-объекты для символов. public: CharProxy(Strings str, int index); // Создание. CharProxyk operator=(const CharProxyk rhs) ; CharProxy& operator=(char c); // Как lvalue, operator char() const; // Как rvalue, private: Strings theString; // Строка, которой // принадлежит proxy-объект, int charlndex; // Символ в строке, который // заменяет proxy-объект. };
Hi Приемы // Продолжение класса String, const CharProxy operator!] (int index) const; // Для const String. CharProxy operator[](int index); // Для не-const String. friend class CharProxy; private: RCPtr<StringValue> value; }; Кроме добавления класса CharProxy (который будет рассмотрен ниже), этот класс String отличается от последней версии класса String из правила 29 только тем, что обе функции operator [ ] возвращают теперь объекты CharProxy. Но пользователи класса String могут игнорировать этот факт и писать программу как обычно: будто функции operator [ ] возвращают символы (или ссылки на символы - см. правило 1): String si, s2; // Строки с подсчетом ссылок, // использующие proxy-объекты. cout « si [5] ; // Допустимо и работает. s2[5] = "х"; // Также допустимо и работает. si [3] = s2[8] ; // Естественно, допустимо, // естественно, работает. Интересно не то, что прием работает. Интересно, как это происходит. Рассмотрим вначале оператор cout << si[5] ; Выражение si [ 5 ] соответствует объекту CharProxy. Для таких объектов не определен оператор вывода, поэтому компиляторам придется найти неявное преобразование типов, которое они могут применить, чтобы сделать вызов функции operator« успешным (см. правило 5). И они находят неявное преобразование из CharProxy в char, объявленное в классе CharProxy, и автоматически вызывают этот оператор преобразования, и в результате выводится символ строки, представленный объектом CharProxy. Это пример преобразования из CharProxy в char, которое выполняется для всех объектов CharProxy, используемых в качестве rvalue. Использование их в качестве lvalue обрабатывается по-другому. Рассмотрим снова оператор s2[5] = "х"; Выражение s2 [ 5 ], как и раньше, возвращает объект CharProxy, но на этот раз объект является целью присваивания. Какой оператор присваивания вызывается при этом? Цель присваивания - объект CharProxy, поэтому вызывается оператор присваивания класса CharProxy. Это важно, поскольку объект, принимающий новое значение в операторе присваивания CharProxy, используется как lvalue. Отсюда известно, что символ строки, замещаемый proxy-объектом,
Правило 30 11ММНШЗ функционирует как lvalue, и, следовательно, надо предпринять необходимые действия для реализации доступа к символу как к lvalue. Аналогично, оператор sl[3] = s2[8]; вызывает оператор присваивания для двух объектов CharProxy, и в этом операторе объект слева используется как lvalue, а объект справа - как rvalue. Вот код для функций operator [ ] класса String: const String::CharProxy String::operator[](int index) const { return CharProxy(const_cast<String&>(*this), index); } String::CharProxy String::operator[](int index) { return CharProxy(*this, index); } Каждая функция просто создает и возвращает proxy-объект для запрашиваемого символа. Над самим символом не совершается никаких действий: они откладываются до тех пор, пока не будет известно, выполняется ли его чтение или запись. Обратите внимание, что const версия функции operator [] возвращает const proxy-объект. Поскольку функция-член CharProxy: : operator= не объявлена как const, такие proxy-объекты не могут использоваться в качестве цели присваивания. Следовательно, ни proxy-объект, возвращаемый const версией функции operator [ ], ни символ, который он замещает, не могут использоваться в качестве lvalue. Это в точности то поведение, которое и требуется от const версии operator [ ] в рассматриваемом примере. Обратите внимание на использования для *this оператора const_cast (см. правило 2) при создании объекта CharProxy, возвращаемого const operator [ ]. Это необходимо, чтобы удовлетворить ограничениям конструктора CharProxy, который принимает только не-const объекты String. Операторы приведения типов часто создают проблемы, но в этом случае объект CharProxy, возвращаемый функцией operator [ ], сам имеет атрибут const, поэтому нет опасности, что будет изменен объект типа string, содержащий символ, на который ссылается proxy-объект. Каждый proxy-объект, возвращаемый функцией operator [ ], помнит, к какой строке он относится и индекс представляемого им символа в этой строке: String::CharProxy::CharProxy(Strings str, int index) : theString(str), charlndex(index) {} Преобразование proxy-объекта к типу rvalue выполняется несложно - надо просто возвратить копию символа, представленного proxy-объектом: String::CharProxy::operator char() const { return theString.value->data[charlndex]; } 8-679
ШЗВМИНЕ пР"емы Если вы забыли о связи между объектом String, его элементом value и элементом data, на который он указывает, обратитесь к правилу 29. Так как эта функция возвращает символ по значению, а C++ разрешает делать это только для значений, используемых в качестве rvalue, данная функция преобразования может использоваться только там, где допустимо использовать rvalue. Таким образом, вы обращаетесь к реализации операторов присваивания класса CharProxy, в которых символ, представленный proxy-объектом, используется в качестве цели присваивания, то есть как lvalue. Можно реализовать обычный оператор присваивания класса CharProxy следующим образом: String::CharProxy& String::CharProxy::operator=(const CharProxyk rhs) { // Если значение строки используется совместно несколькими // объектами String, создать собственную копию значения. if (theString.value->isShared()) { theString.value = new StringValue(theString.value->data); } // Выполнить присваивание: присвоить значение символа, // представленного rhs, символу, представленному *this. theString.value->data[charlndex] = rhs.theString.value->data[rhs.charlndex]; return *this; } Сравнивая этот код с реализацией не-const функции String: : operator [] на странице 211, вы увидите, что они поразительно похожи. Этого и следовало ожидать. В правиле 29 предполагалось, что все вызовы не-const функции operator [ ] выполнялись для записи и обрабатывались соответственно. Здесь же код для записи перемещен в операторы присваивания класса CharProxy, что позволило избежать затрат на выполнение записи, если не-const функция operator [ ] используется только как rvalue. Кстати, обратите внимание, что эта функция требует доступа к закрытому элементу value класса String, поэтому класс CharProxy объявлен дружественным классу String. Второй оператор присваивания класса CharProxy почти идентичен первому: String::CharProxy& String::CharProxy::operator=(char c) { if (theString.value->isShared()) { theString.value = new StringValue(theString.value->data) ; } theString.value->data[charlndex] = c; return *this; } Как грамотный программист, вы, конечно же, чтобы избежать дублирования кода в этих двух операторах присваивания, поместили бы его в закрытую функцию - член класса CharProxy, которая вызывалась бы из обоих операторов, не так ли?
Правило 30 ЛНМШКШ Ограничения Использование proxy-классов - удобный способ различать использование operator [ ] в качестве lvalue и rvalue, но этот метод не лишен недостатков. Конечно, было бы великолепно, если бы proxy-объекты незаметно замещали объекты, которые они представляют, однако идеала достичь нелегко. Это связано с тем, что объекты могут использоваться как lvalue не только в операторе присваивания, и такое использование proxy-объектов часто приводит к поведению, отличному от поведения настоящих объектов. Рассмотрим снова фрагмент кода из правила 29, из-за которого было принято решение добавить к объектам StringValue флаг, указывающий на возможность совместного использования. Если функция string::operator [ ] возвращает CharProxy вместо char&, то следующий код не будет больше компилироваться: String si = "Hello"; char *p = &sl[l]; // Ошибка! Выражение si [ 1 ] возвращает proxy-объект CharProxy, поэтому выражение справа от знака равенства (=) будет иметь тип CharProxy*. Преобразование из типа CharProxy* в char* не определено, из-за чего код инициализации р не будет компилироваться. Вообще, адрес proxy-объекта имеет тип, отличный от адреса настоящего объекта. Чтобы устранить этот недостаток, необходимо перегрузить операторы адресации для класса CharProxy: class String { public: class CharProxy { public: char * operatork(); const char * operator&() const; }; }; Реализовать эти функции несложно. Функция const просто возвращает указатель на const версию представленного proxy-объектом символа: const char * String::CharProxy::operator&() const { return &(theString.value->data[charIndex]); } He-const функция потребует несколько больше усилий, поскольку она возвращает указатель на символ, который может быть изменен. Ее поведение аналогично поведению не-const версии функции String::operator [ ] в правиле 29, и реализация также аналогична: 8*
118 Приемы char * String::CharProxy::operators() { // Убедиться, что символ, указатель на который возвращает // эта функция, не используется другими объектами String. if (theString.value->isShared()) { theString.value = new StringValue(theString.value->data); } //He известно, как долго указатель, возращаемый этой // функцией, будетнужен клиентам, поэтому объект StringValue //не может использоваться совместно. theString.value->markUnshareable(); return &(theString.value->data[charlndex]); } Большая часть этого кода является общей для других функций - членов класса CharProxy, поэтому его следует инкапсулировать в закрытую функцию - член класса, которую будут вызывать все остальные функции. Второе отличие между символами char и заменяющими их объектами CharProxy проявляется, если имеется шаблон для массивов с подсчетом ссылок, который использует proxy-классы, чтобы различить вызов operator [ ] в качестве lvalue и rvalue: template<class T> // Массив со счетчиком ссылок, class Array { // использующий proxy-объекты, public: class Proxy { public: Proxy(Array<T>& array, int index); Proxyb operator=(const T& rhs) ; operator TO const; }; const Proxy operator[](int index) const; Proxy operator[](int index); }; Рассмотрим, как могут использоваться эти массивы: Array<int> intArray; intArray[5] =22; // Нормально. intArray[5] +=5; // бшибка! ++intArray[5]; //Ошибка! Как и ожидалось, использование функции operator [ ] в качестве цели простого присваивания завершается успехом, но если она расположена в левой части вызова функций operator+= или operator++, код будет выполнен некорректно. Это связано с тем, что функция operator [] возвращает proxy-объект, а для объектов Proxy не определены операторы operator+= или operator++.
правило зо тштмшшш Аналогичная ситуация возникает и в случае других операторов, требующих lvalue в качестве аргумента, включая operator * =, operator«=, operator— и т. д. Если вы хотите, чтобы названные операторы работали с функциями operator [ ], возвращающими proxy-объекты, вы должны определить каждую из этих функций для класса Array<T>::Proxy, что потребует немалой работы. К сожалению, вам придется либо выполнить ее, либо обойтись без данных операторов. Похожая проблема возникает при вызове функций - членов класса для реальных объектов через proxy-объекты. Говоря прямо, вы не можете этого сделать. Например, предположим, что вы хотите работать с массивами действительных чисел, использующими подсчет ссылок. Можно было бы определить класс Rational, а затем использовать выше приведенный шаблон Array: class Rational { public: Rational (int numerator = 0, int denominator = 1) ; int numerator() const; int denominator() const; }; Array<Rational> array; Но, оказывается, использовать эти массивы нельзя: cout << array[4].numerator(); // Ошибка! int denom = array[22].denominator(); // Ошибка! Проблема была предсказуема: функция operator [ ] возвращает proxy-объект для действительного числа, а не настоящий объект Rational. Но функции-члены numerator и denominator существуют только для объектов типа Rat ional, а не для соответствующих proxy-объектов. Поэтому ваши компиляторы и жалуются. Чтобы proxy-объекты были более похожи на замещаемые ими объекты, вы должны перегрузить все функции, применимые к настоящим объектам. Другая ситуация, в которой proxy-объекты не могут заменить настоящие объекты, возникает при передаче функциям, принимающим ссылку на не-const объекты: void swap(char& a, char& b) ; // Переставляет значения а и b. String s = "+С+"; // Ошибка, должно быть "C++". swap(s[0] , s[l]) ,- // Такая перестановка исправила //бы ошибку, но она не будет // компилироваться.. Функция String::operator [ ] возвращает объект типа CharProxy, а функция swap требует, чтобы оба ее аргумента имели тип char&. Объект CharProxy может быть неявно преобразован к типу char, но функции его преобразования в тип char& не предусмотрено. Кроме того, объект типа char, в который он может быть преобразован, нельзя связать с параметрами char& функции swap, поскольку данный объект - временный (это значение, возвращаемое функцией operator char) и, как объясняется в правиле 19, существуют веские причины
ШШШШШШ Приемы для того, чтобы не привязывать временные объекты к параметрам, представляющими собой не-const ссылки. И последняя ситуация, когда proxy-объекты не могут незаметно подменять настоящие, возникает при неявном преобразовании типов. Если proxy-объект неявно преобразуется в настоящий объект, который он заменяет, вызывается определенная пользователем функция преобразования. Например, объект CharProxy может быть преобразован в заменяемый им объект типа char при помощи вызова operator char. Как объясняется в правиле 5, компиляторы используют только одну определенную пользователем функцию преобразования при переводе параметра вызова в тип параметра соответствующей функции. В результате может получиться так, что вызовы функций, успешно завершающиеся при передаче в качестве параметров настоящих объектов, будут неудачны при передаче вместо них proxy-объектов. Например, предположим, что имеется класс TVStation (ТВ-станция) и функция watchTV (смотреть ТВ): class TVStation { public: TVStation(int channel); }; void watchTVfconst TVStationk station, float hoursToWatch); Благодаря неявному преобразованию типов из int в TVStation (см. правило 5) можно затем сделать следующее: watchTVA0,2.5); // Смотреть 10 канал //в течение 2.5 часов. Применив шаблон для массивов с подсчетом ссылок, где вызовы operator [ ] как lvalue и rvalue различаются с помощью proxy-классов, можно было бы записать так: Array<int> intArray; intArray[4] =10; watchTV(intArray[4], 2.5); //Ошибка! преобразование //из Proxy<int> в TVStation //не существует. Если не забывать о проблемах, которые сопровождают неявные преобразования типов, то такое поведение не очень шокирует. Хотя лучше было бы объявить конструктор класса TVStation как explicit, в этом случае компилятор не пропустил бы даже первый вызов функции watchTV. Подробнее о неявном преобразовании типов и влиянии на них директивы explicit см. правило 5. Оценка Proxy-классы позволяют добиться поведения, которое сложно или даже невозможно реализовать без них. Самые распространенные примеры - использование многомерных массивов, распознавание lvalue и rvalue и подавление неявных преобразований (см. правило 5).
Правило 31 111НМНЕШ В то же время proxy-классы имеют и недостатки. Будучи возвращаемыми функцией значениями, proxy-объекты являются временными (см. правило 19), поэтому они должны создаваться и уничтожаться. Это приводит к расходам памяти и времени, которые, однако, почти всегда с лихвой компенсируются возможностью отличать операции записи от операций чтения. Само существование proxy-классов увеличивает сложность использующих их программных систем, так как дополнительные классы усложняют, а не облегчают разработку, реализацию, понимание и поддержку. И наконец, переход от класса, который работает с настоящими объектами, к классу, который взаимодействуют с proxy-объектами, часто изменяет семантику класса, поскольку proxy-объекты обычно ведут себя немного иначе, чем настоящие объекты, которые они представляют. Иногда из-за этого proxy-объекты оказываются не лучшим выбором при разработке системы, но во многих классах нет особой необходимости делать присутствие proxy-объектов очевидным для пользователей. Например, вряд ли пользователям понадобится получать адрес объекта Array ID в примере двумерного массива, приведенного в начале этого раздела, и маловероятно, что объект Arraylndex (см. правило 5) будет передаваться функции, ожидающей другой тип. Во многих случаях proxy-объекты могут вполне приемлемо заменять настоящие объекты. Правило 31. Создавайте функции, виртуальные по отношению более чем к одному объекту Иногда, как говорит Джаклин Сьюзан (Jacqueline Susann), одного раза недостаточно. Предположим, например, что вы пытаетесь занять одну из престижных высокооплачиваемых должностей в известной компании, производящей программное обеспечение, штаб-квартира которой находится в Редмонде, штат Вашингтон (я, конечно же, имею в виду Nintendo). Чтобы привлечь к себе внимание менеджеров компании Nintendo, вы решаете написать видеоигру. Ее действие может происходить в космосе, где находятся разные объекты (Game Obj ect): межпланетные корабли (Space Ship), станции (Space Station) и астероиды (Asteroid). Когда эти объекты проносятся в вашем искусственном мире, они, естественно, периодически сталкиваются друг с другом. Предположим, что законы столкновений таковы: ? если корабль и космическая станция сталкиваются на малой скорости, корабль стыкуется со станцией. В противном случае корабль и станция испытывают повреждения, пропорциональные скорости столкновения; ? если сталкиваются корабль с кораблем или станция со станцией, то оба объекта испытывают повреждения, пропорциональные скорости удара; ? если с кораблем или станцией сталкивается небольшой астероид, то астероид уничтожается. В случае большого астероида уничтожается корабль или станция; а если сталкиваются два астероида, то оба разбиваются на осколки (маленькие астероиды), которые разлетаются во всех направлениях.
ЕЕЕШМИ1Ш ^ приемы Эта игра может показаться довольно скучной, но она приведена здесь не для развлечения, а чтобы рассмотреть структуру кода C++, который обрабатывает столкновения между объектами. Вначале отметим, что астероиды, станции и корабли имеют несколько общих свойств. В частности, это движение, поэтому они все имеют свою скорость перемещения. Будет естественно определить для этих общих свойств базовый класс, от которого все объекты будут наследовать. На практике такой класс почти неизменно будет абстрактным базовым классом (базовые классы вообще всегда должны быть абстрактными - см. правило 33). Следовательно, иерархия может выглядеть примерно так, как представлено на рис. 5.18. Рис. 5.18 class GameObject { ... } ; class Spaceship: public GameObject { ... } ; class SpaceStation: public GameObject { ... } ; class Asteroid: public GameObject { ... } ; Теперь предположим, что вы решили написать код для проверки столкновений объектов. Для этого пригодится, например, такая функция: void checkForCollision(GameObject& objectl, GameObjectk object2) { if (theyJustCollided(objectl, object2)) { processCollision(objectl, object2); } else { } } И здесь сложность программирования этой задачи становится очевидной. Когда вы вызываете функцию processCollision, вы знаете: objectl и object2 только что столкнулись, и результат столкновения зависит от того, что собой представляют два эти объекта. Но вы не знаете, чем именно они являются, вам известно только то, что оба они имеют тип GameOb j ect. Если бы обработка столкновения определялась динамическим типом объекта objectl, то можно было бы сделать функцию processCollision в классе GameObject виртуальной и вызывать функцию objectl .processCollision (object2 ). Если бы на результат
Правило 31 С11Н1ПЯЕШ влиял только динамический тип объекта object2, то это же самое можно было бы сделать и для него. Однако все, что происходит во время столкновения, зависит от динамических типов обоих объектов. Как видите, при этом недостаточно вызова функции, которая является виртуальной только для одного объекта. Вам необходима функция, поведение которой было бы виртуальным для более чем одного типа объекта. В языке C++ не существует таких функций. Но вам все равно придется реализовать требуемое поведение. Таким образом, надо найти способ, как это сделать. Один из вариантов заключается в том, чтобы вместо C++ выбрать какой-либо другой язык программирования. Вы можете, например, обратиться к языку CLOS (сокращение от Common Lisp Object System - общая система объектов Lisp). Язык CLOS поддерживает наиболее общий объектно-ориентированный механизм вызова функций - мульти-методы (multi-methods). Мульти-метод - это функция, которая является виртуальной по отношению к произвольному числу параметров. Более того, язык CLOS позволяет управлять разрешением вызовов перегруженных мульти-методов. Но предположим, вы должны написать код игры именно на языке C++, то есть найти собственный способ реализовать двойную диспетчеризацию (double- dispatching). (Этот термин возник в сообществе объектно-ориентированных программистов, которые то, что программисты C++ знают под именем виртуальной функции, называют диспетчером сообщений. Вызов функции, виртуальной по отношению к двум параметрам, называется двойной диспетчеризацией. А функция, виртуальная по отношению к нескольким параметрам, - это, соответственно, множественная диспетчеризация.) Существует несколько подходов к решению поставленной задачи. Все они не лишены недостатков, но это не должно вас удивлять. Язык C++ не обеспечивает двойной диспетчеризации, поэтому вам придется самостоятельно выполнить ту работу, которую обычно делают за вас компиляторы, реализуя виртуальные функции (см. правило 24). Использование виртуальных функций nRTTI Виртуальные функции обеспечивают одиночную диспетчеризацию, а это уже половина того, что требуется для решения задачи. Кроме того, и виртуальные функции создаются компиляторами, поэтому начнем с объявления в классе GameObject виртуальной функции collide. В производных классах эта функция как обычно перегружается: class GameObject { public: virtual void collide(GameObjectk otherObject) = 0; }; class Spaceship: public GameObject { public: virtual void collide(GameObjectk otherObject); };
ппшшшшт . приемы Здесь показан только производный класс Spaceship, поскольку классы SpaceStation и Asteroid устроены таким же образом. Самый простой способ двойной диспетчеризации - эмулировать виртуальные функции при помощи цепочек if-then-else. Вначале надо определить настоящий тип объекта otherObj ect, а затем перебрать все возможные варианты: // При столкновении с объектом неизвестного типа генерируется // исключение типа CollisionWithUnknownObject: class CollisionWithUnknownObject { public: CollisionWithUnknownObject(GameObjectk whatWeHit); }; void Spaceship::collide(GameObjectk otherObject) { const type_info& objectType = typeid(otherObject) ; if (objectType == typeid(Spaceship)) { SpaceShipb ss = static_cast<SpaceShip&>(otherObject); обработка столкновения SpaceShip-SpaceShip; } else if (objectType == typeid(SpaceStation)) { SpaceStationb ss = static_cast<SpaceStation&>(otherObject); обработка столкновения Spaceship-SpaceStation; } else if (objectType == typeid(Asteroid)) { Asteroidk a = static_cast<Asteroid&>(otherObject); обработка столкновения SpaceShip-Asteroid; } else { throw CollisionWithUnknownObject(otherObject); } } Обратите внимание, что понадобилось определить тип только одного из сталкивающихся объектов. Второй объект- это *this, и его тип обусловлен механизмом виртуальных функций. Так как мы находимся внутри функции - члена класса Spaceship, то объект *this должен иметь тип Spaceship. Поэтому необходимо определить настоящий тип только объекта otherObject. В этом коде нет ничего сложного. Его легко написать и даже заставить работать. Но хотя код выглядит совершенно безобидно, могут возникнуть проблемы с RTTI (сокращение от Run Time Type Information - информация о типах в процессе исполнения - механизм, позволяющий определять тип объекта во время выполнения программы). Намек на реальную опасность скрыт в последнем операторе else и генерируемом там исключении. Инкапсуляция здесь не пригодится, поскольку каждая функция col 1 ide должна знать обо всех своих родственных классах, то есть классах, наследующих от
Правило 31 <11ИНЯ1ЕШ класса GameObj ect. В частности, если в игру вводится новый тип объектов - и добавляется новый класс - придется обновить каждую RTTI цепочку i f -then- el se программе, в которой может встречаться новый тип объекта. Если вы забудете хотя бы про одну из них, в программе появится неочевидная ошибка. И компиляторы не помогут вам ее обнаружить, поскольку не знают о том, что вы делаете. Такой подход к работе с типами данных имеет давнюю историю в языке С и обычно приводит к созданию программ, которые, в сущности, невозможно поддерживать. Улучшение подобных программ практически немыслимо. Это одна из основных причин, из-за которых были изобретены виртуальные функции: чтобы перенести тяжесть создания и поддержки типизированных вызовов функций с плеч программистов на компиляторы. Но когда для реализации двойной диспетчеризации используется RTTI, вы снова возвращаетесь в старые недобрые времена. Прежние методы приводили к ошибкам в С, и они также станут причиной проблем в C++. Поэтому в функцию collide включен заключительный оператор else, который перехватывает управление при столкновении с неизвестным объектом. Такая ситуация, в принципе, невозможна, но где были ваши принципы, когда вы решили использовать RTTI? Существуют различные способы обработки непредвиденных столкновений, но ни один из них не является удовлетворительным. В рассмотренном случае сгенерировано исключение, но неясно, будет ли оно лучше обработано в вызвавшей его программе, чем при помощи написанного вами кода, поскольку игровой объект столкнулся с чем-то таким, о существовании чего вы даже не догадывались. Использование только виртуальных функций Существует способ снизить вероятность ошибок, присущих реализации двойной диспетчеризации при помощи RTTI, но перед тем как перейти к нему, рассмотрим, как можно решить данную задачу, используя только виртуальные функции. Эта стратегия начинается с той же основной структуры, что и подход RTTI. Функция collide в классе GameObj ect объявляется виртуальной и переопределяется в каждом из производных классов. Кроме того, функция collide перегружается в каждом из классов, по одной перегрузке для каждого из производных классов в иерархии: class Spaceship; // Предварительное объявление. class SpaceStation,- class Asteroid; class GameObject { public: virtual void collide(GameObjectk otherObject) = 0; virtual void collide(SpaceShipk otherObject) = 0; virtual void collide(SpaceStationk otherObject) = 0; virtual void collide(Asteroidk otherobject) = 0; }; class Spaceship: public GameObject {
Ill .. . Приемы public: virtual void collide(GameObjectk otherObject) virtual void collide(SpaceShipk otherObject) virtual void collide(SpaceStationk otherObject) virtual void collide(Asteroidk otherobject) }; Основная идея заключается в том, чтобы реализовать двойную диспетчеризацию как две одиночных, то есть как два отдельных вызова виртуальных функций; первый определяет динамический тип первого объекта, а второй - второго объекта. Как и в предыдущем примере, вначале вызывается виртуальная функция coll ide с параметром GameOb j ect&. Код этой функции в данном случае поразительно прост: void Spaceship::collide(GameObjectk otherObject) { otherObject.collide(*this); } На первый взгляд кажется, что это не более чем рекурсивный вызов функции collide с параметрами, заданными в обратном порядке, то есть что otherObject становится объектом, вызывающим функцию - член класса, а объект *this - параметром функции. Но взгляните на код еще раз и вы поймете: это не рекурсивный вызов. Как известно, компиляторы определяют, какая из набора функций должна быть вызвана, основываясь на статическом типе передаваемых функции аргументов. В этом случае могут быть вызваны четыре различные функции collide, но из них выбирается одна на основе статического типа *this. Что это за статический тип? Так как вы находитесь внутри функции - члена класса Spaceship, то объект *this должен иметь тип Spaceship. Поэтому вызывается функция collide, принимающая параметр Spaceships, а не GameObject&. Все функции collide являются виртуальными, следовательно, при вызове функции Spaceship: : collide выполняется подстановка вызова функции, соответствующей настоящему типу объекта otherObj ect. Внутри этой реализации функции collide известен настоящий тип обоих объектов, так как объект слева - это * thi s (и поэтому имеет тип класса, в котором реализована данная функция), а объект справа имеет тип, объявленный как тип параметра. Все это станет для вас более ясным, когда вы увидите реализацию других функций collide в классе Spaceship: void Spaceship: : collide (SpaceShipb otherObject) ( обработка столкновения Spaceship-Spaceship; } void Spaceship::collide(SpaceStationk otherObject) { обработка столкновения SpaceShip-SpaceStation; }
Правило 31 ШПШНЕШ void Spaceship::collide(Asteroids otherObject) { обработка столкновения SpaceShip-Asteroid; } Как видите, здесь нет ни RTTI, ни необходимости генерировать исключения для неизвестных типов объектов. Неизвестных типов объектов просто не может быть - в этом и состоит особенность использования виртуальных функций. И если бы ни одна неисправимая ошибка, это было бы идеальным решением проблемы двойной диспетчеризации. Такая ошибка присуща и рассмотренному ранее подходу RTTI: каждый из классов должен знать обо всех родственных классах. Код должен обновляться по мере добавления новых классов. Однако способ обновления кода в данном случае другой. Не нужно модифицировать цепочки if-then-else, но есть нечто гораздо худшее: в каждое определение класса должна быть внесена новая виртуальная функция. Если, например, вы решите включить в код игры новый класс Satellite (наследующий от класса GameObject), то вам придется добавить новую функцию collide к каждому из существующих классов в программе. Во многих случаях изменить существующие классы нельзя. Если вместо того, чтобы писать всю видеоигру целиком, вы начали с готовой библиотеки, определяющей прикладной интерфейс видеоигры, у вас может не быть доступа на запись к классу GameObject или производным от него классам. При этом невозможно добавление новых функций, независимо от того, виртуальные они или нет. Или классы, требующие изменения, могут быть доступны физически, но не практически. Предположим, вы были-таки приняты на работу в корпорацию Nintendo и начали работу над программами, которые используют библиотеку, содержащую класс GameObject и другие полезные классы. Несомненно, вы не будете единственным клиентом библиотеки, и руководство наверняка не будет слишком радо тому, что при каждом добавлении нового типа к вашей программе будут перекомпилироваться все приложения, работающие с этой библиотекой. Обычно часто используемые библиотеки изменяются очень редко, так как стоимость перекомпиляции всего основанного на них кода слишком велика. Короче говоря, если вам необходимо реализовать двойную диспетчеризацию в вашей программе, прежде всего попытайтесь изменить концепцию программы, чтобы избежать перекомпиляции. Если же это невозможно, то подход с использованием виртуальных функций является более безопасным, чем стратегия RTTI. Последняя ограничивает расширяемость системы, которая определяется возможностью редактировать заголовочные файлы, но, с другой стороны, не требует перекомпиляции. Однако, если методика RTTI реализована так, как показано выше, это обычно приводит к созданию программ, которые сложно поддерживать. Вы платите, и вы делаете выбор. Эмуляция таблиц виртуальных функций Ваши шансы на успех можно увеличить. Вспомните, в правиле 24 говорилось о том, что компиляторы обычно реализуют виртуальные функции, создавая
ЕЕЁШМШ11 Приемы массив указателей на функции (виртуальные таблицы) и затем выполняя индексирование в этом массиве при вызове виртуальной функции. Использование виртуальной таблицы устраняет необходимость выполнять цепочки if-then-else и позволяет компилятором генерировать один и тот же код для всех вызовов виртуальных функций: определить правильный индекс в виртуальной таблице, а затем держа, вызвать функцию, указанную этим положением в ней. В результате ваш RTTI-код станет более эффективным (индексирование в массиве и вызов функции по указателю почти всегда эффективнее, чем выполнение серии проверок if-then-else, и дает более компактный код), кроме этого, RTTI используется только в одном месте: там, где инициализируется массив указателей на функции. Итак, внесем несколько изменений в функции в иерархии GameObject: class GameObject { public: virtual void collide(GameObjectk otherObject) = 0; }; class Spaceship: public GameObject { public: virtual void collide(GameObjectk otherObject); virtual void hitSpaceShip(SpaceShipb otherObject); virtual void hitSpaceStation(SpaceStation& otherObject); virtual void hitAsteroid(Asteroid& otherobject); void Spaceship::hitSpaceShip(Spaceships otherObject) обработать столкновение SpaceShip-SpaceShip; void Spaceship::hitSpaceStation(SpaceStation& otherObject) обработать столкновение SpaceShip-SpaceStation; void Spaceship::hitAsteroid(Asteroid& otherObject) обработать столкновение SpaceShip-Asteroid; Так же как и в RTTI-иерархии, рассмотренной выше, класс GameOb j ect содержит только одну функцию для обработки столкновений, которая выполняет первую диспетчеризацию (из двух необходимых). Так же как и в иерархии, основанной на виртуальных функциях, здесь каждый тип столкновений инкапсулирован в отдельную функцию, хотя в этом случае функции имеют различные имена, а не одно и то же - collide. Для того чтобы отказаться от перегрузки, есть свои причины, и вскоре они будут разъяснены. Пока же заметим, что вышеприведенная схема содержит все необходимое, кроме реализации функции Spaceship: : coll ide; именно в ней будут вызываться различные функции hit. Как и раньше, после
Правило 31 ^¦¦¦¦¦Ш1 успешной реализации класса Spaceship, классы SpaceStation и Asteroid можно реализовать аналогично. В функции SpaceShip::collide нужно каким-то образом сопоставить динамическому типу параметра otherObject указатель на соответствующую функцию для обработки столкновений. Простейший способ сделать это заключается в создании ассоциативного массива, который дает указатель на соответствующую функцию - член класса по заданному имени класса. Можно напрямую реализовать функцию collide при помощи такого ассоциативного массива, но лучше добавить промежуточную функцию lookup, принимающую объект GameObject и возвращающую соответствующий указатель на функцию. То есть вы передаете функции lookup объект GameObj ect, и она возвращает указатель на функцию - член класса, которая будет вызываться, если вы столкнетесь с чем-то типа GameObject. Функция lookup объявляется так: class Spaceship: public GameObject { private: typedef void (Spaceship::*HitFunctionPtr)(GameObject&); static HitFunctionPtr lookup(const GameObject& whatWeHit); }; Синтаксис указателей на функции никогда не бывает особенно удобным, а для указателей на функции - члены класса он еще хуже, чем обычно, поэтому директивой typedef HitFunctionPtr был определен тип, который служит сокращением для указателя на функцию - член класса Spaceship, принимающую в качестве параметра GameObj ееt& и ничего не возвращающую. После того как получена функция lookup, реализация функции collide становится легче легкого: void Spaceship::collide(GameObject& otherObject) { HitFunctionPtr hfp = lookup(otherObject); // Найти функцию, if (hfp) { // Если функция найдена, (this->*hfp)(otherObject); // вызвать ее. } else { throw CollisionWithUnknownObject(otherObject); } } Если вы синхронизировали содержимое ассоциативного массива с иерархией классов, производных от GameObject, функция lookup всегда должна находить допустимый указатель на функцию для объекта, который вы ей передаете. Но люди всегда остаются людьми, и ошибки иногда появляются даже в очень аккуратно написанных программных системах. Вот почему надо проверить, возвращается ли функцией lookup корректный указатель, и сгенерировать исключение, если происходит невозможное, в результате чего вызов функции завершается неудачей.
ЕШШЛШШШЁ Приемы Остается теперь написать функцию lookup. Если имеется ассоциативный массив, который сопоставляет типы объектов указателям на функции - члены соответствующих классов, сам поиск выполняется достаточно легко, но создание, инициализация и уничтожение ассоциативного массива само по себе является непростой задачей. Такой массив должен создаваться и инициализироваться до того, как он будет использоваться, и уничтожаться, когда он больше не нужен. Можно было бы создавать и удалять массив вручную с помощью операторов new и delete, но это иногда приводит к ошибкам: как гарантировать, что массив не был использован прежде, чем вы соберетесь проинициализировать его? Лучше автоматизировать этот процесс при помощи компилятора, объявив ассоциативный массив статическим в функции lookup. На этот раз он будет создаваться и инициализироваться при первом вызове функции lookup и автоматически уничтожаться через, какое- то время после выхода из функции main. Кроме того, вы можете использовать в качестве ассоциативного массива шаблон тар из стандартной библиотеки шаблонов (см. правило 35): class Spaceship: public GameObject { private: typedef void (Spaceship::*HitFunctionPtr)(GameObject&); typedef map<string, HitFunctionPtr> HitMap; }; Spaceship::HitFunctionPtr Spaceship::lookup(const GameObjectk whatWeHit) { static HitMap collisionMap; } Здесь collisionMap и является ассоциативным массивом. Он сопоставляет имени класса (как объекту типа string) указатель на функцию- член класса Spaceship. Так как выговорить map<string, HitFunctionPtr> нелегко, было использовано определение типа. (Можете попытаться в качестве развлечения переписать определение collisionMap, отказавшись от определения типов HitMap и HitFunctionPtr. Немногие отважатся сделать это дважды.) После создания collisionMap реализация функции lookup становится достаточно простой. Это связано с тем, что операция поиска непосредственно поддерживается классом тар, и единственная переносимая функция - член класса, с помощью которой можно всегда проконтролировать результат вызова type id. Это функция name (она, как и следовало ожидать*, возвращает имя динами- * Оказывается, что это не столь предсказуемо. Стандарт языка C++ не определяет возвращаемое функцией type_info: :name значение, и различные реализации ведут себя по-разному. (В одной из реализаций, например, функция type_inf о: : name возвращает class Spaceship, если задан класс Spaceship.) Лучше было бы идентифицировать класс по адресу связанного с ним объекта type_inf о, так как этот адрес гарантированно является уникальным. Тогда тип HitMap объявлялся бы как map<const type_info* ,HitFunctionPtr>.
Правило 31 ШШМНЕШ ческого типа объекта). Тогда для реализации функции lookup достаточно найти в массиве collisionMap ячейку, соответствующую динамическому типу аргумента функции lookup. Код для функции lookup довольно прост, но если вы не знакомы со стандартной библиотекой шаблонов (см. правило 35), он может вызвать у вас затруднения. Не беспокойтесь. Комментарии в функции объясняют, что происходит. Spaceship::HitFunctionPtr Spaceship::lookup(const GameObjectk whatWeHit) { static HitMap collisionMap; // Вы увидите инициализацию // collisionMap позже. // Поиск функции обработки столкновения для типа whatWeHit. // Возвращаемое значение - это похожий на указатель объект, // который называется итератором (см. правило 35) . HitMap::iterator mapEntry= collisionMap.find(typeid(whatWeHit).name()); // mapEntry == collisionMap.end() , если поиск был неудачным; / / это стандартное поведение для тар. // Снова см. правило 35. if (mapEntry == collisionMap.end()) return 0; // Если вы оказались здесь, поиск был успешным. mapEntry указывает //на полную ячейку карты, состоящую из пары // (string, HitFunctionPtr) . Вам нужна только вторая часть // пары, поэтому она и возвращается. return (*mapEntry).second; } Последний оператор в функции возвращает ( *mapEntry) . second вместо более привычного mapEntry->second, чтобы удовлетворить капризам стандартной библиотеки шаблонов. Подробнее см. на стр. 108. Инициализация эмулированных таблиц виртуальных функций Теперь настала пора инициализировать массив collisionMap. Вы, наверное, хотели бы написать что-то вроде: // Неправильная реализация. Spaceship::HitFunctionPtr Spaceship: : lookup (const GameObjectk whatWeHit) { static HitMap collisionMap; collisionMap["Spaceship" ] = SchitSpaceShip; collisionMap["SpaceStation"] = khitSpaceStation; collisionMap["Asteroid"] = khitAsteroid; )
ЕШВНИ1Р: Приемы Но при этом в массив collisionMap будут вставляться указатели на функции - члены класса при каждом вызове функции lookup, что крайне неэффективно. Код также не будет компилироваться, но эта проблема будет рассмотрена чуть позже. Сейчас нужно добиться, чтобы указатели на функции - члены класса помещались в массив collisionMap только один раз, а именно при его создании. Сделать это достаточно легко: просто напишите закрытую статическую функцию - член initializeCollisionMap для создания и инициализации тар, а затем инициализируйте массив collisionMap при помощи возвращаемого функцией initializeCollisionMap значения: class Spaceship: public GameObject { private: static HitMap initializeCollisionMap(); }; Spaceship::HitFunctionPtr Spaceship::lookup(const GameObjectk whatWeHit) { static HitMap collisionMap = initializeCollisionMap(); } Но это означает, что, возможно, возникнут расходы на копирование возвращаемого функцией initializeCollisionMap объекта map в массив collisionMap (см. правила 19 и 20). Вы наверняка предпочли бы этого не делать. Можно было бы обойтись без таких расходов, если бы функция initializeCollisionMap возвращала указатель, но тогда пришлось бы позаботиться о том, чтобы объект тар, на который ссылался бы данный указатель, уничтожался в соответствующее время. К счастью, существует способ все это сделать. Можно превратить colli s ionMap в интеллектуальный указатель, который автоматически удаляет то, на что он указывает, при уничтожении самого указателя. Стандартная библиотека C++ как раз для таких интеллектуальных указателей содержит шаблон auto_ptr (см. правило 9). Если определить collisionMap в функции lookup как статический интеллектуальный указатель типа auto_ptr, то функция initializeCollisionMap будет возвращать указатель на инициализированный объект тар, причем без риска утечки ресурсов; объект тар, на который указывает collisionMap, будет автоматически уничтожен одновременно с collisionMap. Таким образом: class Spaceship: public GameObject { private: static HitMap * initializeCollisionMap(); }; Spaceship::HitFunctionPtr
Правило 31 11МНКШ Spaceship: : lookup (const GameObjectSc whatWeHit) { static auto_ptr<HitMap> collisionMap(initializeCollisionMap()); } Наиболее логичный способ реализации функции initializeCollisionMap, казалось бы, следующий: Spaceship::HitMap * Spaceship::initializeCollisionMap() { HitMap *phm = new HitMap; (*phm)["Spaceship"] = SchitSpaceShip; (*phm)["SpaceStation"] = SchitSpaceStation; (*phm) ["Asteroid"] = SchitAsteroid; return phm; } Но, как я уже отмечал, такой код не будет компилироваться. Это связано с тем, что HitMap объявлен как массив указателей на функции - члены класса, имеющие одинаковый тип аргумента, а именно GameObject. Но аргумент функции hitSpaceShip имеет тип Spaceship, аргумент функции hitSpaceStation - тип SpaceStation, а аргумент функции hitAsteroid - тип Asteroid. И хотя объекты Spaceship, SpaceStation и Asteroid могут быть неявно преобразованы к типу GameObj ect, для указателей на функции с этими аргументами такого преобразования не существует. У вас может возникнуть искушение использовать операторы приведения типов reinterpret_cast (см. правило 2), с помощью которых обычно производят преобразования между типами указателей на функции: // Неудачная идея. . . Spaceship::HitMap * Spaceship::initializeCollisionMap() { HitMap *phm = new HitMap; (*phm)["Spaceship"] = reinterpret_cast<HitFunctionPtr>(SchitSpaceShip); (*phm)["SpaceStation"] = reinterpret_cast<HitFunctionPtr>(SchitSpaceStation); (*phm)["Asteroid"] = reinterpret_cast<HitFunctionPtr>(SchitAsteroid); return phm; } Этот код будет компилироваться, но сама идея неудачна. Вы собираетесь лгать компилятору, чего никогда нельзя делать. Неправда, что функции hitSpaceShip, hitSpaceStation и hitAsteroid ожидают аргумент GameObject. Функция
ППШШШШШ: Приемы hitSpaceShip ожидает аргумент типа Spaceship, функция hitSpaceStation - аргумент типа SpaceSt at ion, а функция hi t Asteroid- аргумент типа Asteroid. Операторы приведения типов лгут, говоря, что это не так. Компиляторам не нравится, когда им лгут, и они часто находят способ отомстить, если обнаруживают обман. Обычно они генерируют неоптимальный код для функций, вызываемых при помощи *phm, если производные от GameObject классы используют множественное наследование или имеют виртуальные базовые классы. Другими словами, если бы SpaceStation, Spaceship или Asteroid имели другие базовые классы (кроме GameOb j ect), то вы могли бы обнаружить, что вызовы функций для обработки столкновений внутри функции collide ведут- себя очень грубо. Элементы данных объекта В I " I. i I «г' >¦ I' L I i Т П г И I, I < 1,И hi' Г» ' I W yi I »ni4ii I i<iFi> Г)г is.ii I 1.1 >Л| Элементы данных объекта D Элементы данных объекта А Виртуальный указатель Рис. 5.19 Рассмотрим снова иерархию наследования A-B-C-D и возможный формат объекта D, описанный в правиле 24 (см. рис. 5.19). Адреса всех четырех частей класса в объекте D различны. Это важно, так как хотя указатели и ссылки ведут себя по-разному (см. правило 1), компиляторы обычно реализуют ссылки, генерируя в создаваемом коде указатели. Таким образом, передача по ссылке обычно осуществляется при помощи передачи указателя на объект. Если по ссылке передается объект, имеющий несколько базовых классов (например, объект D), важно, чтобы компилятор передавал правильный адрес - тот, который соответствует объявленному типу параметра вызываемой функции. Но что, если вы солгали компиляторам и сообщили им, что ваша функция ожидает параметр типа GameOb j ect, хотя в действительности она ожидает объект типа Spaceship или SpaceStation? В таком случае они передадут неверный адрес при вызове функции, что, вероятно, приведет к «кровавой бойне» во время выполнения программы, а причину конфликта будет определить очень сложно. Это одна из веских причин для того, чтобы отказаться от использования операторов приведения типов. А /\ В С
Правило 31 Ш1НННЕЕ9 Итак, вопрос с приведением типов решен - оно не подходит. Но остается еще одна проблема: несовпадение между типами указателей на функции, которые должен содержать массив HitMap, и указателями на функции hitSpaceShip, hitSpaceStation и hitAsteroid. Существует только один способ исправить ситуацию. Надо изменить функции так, чтобы они все принимали аргументы типа GameObject: class GameObject { // Здесь без изменений. public: virtual void collide(GameObjectk otherObject) = 0; }; class Spaceship: public GameObject { public: virtual void collide(GameObjectk otherObject); // Теперь параметр всех этих функций имеет тип GameObject. virtual void hitSpaceShip(GameObjectk spaceship); virtual void hitSpaceStation(GameObjectk spaceStation); virtual void hitAsteroid(GameObject& asteroid); }; Когда проблема двойной диспетчеризации решалась с помощью виртуальных функций, вы перегружали функцию collide. Здесь же вместо этого использован ассоциативный массив указателей на функции - члены класса, поскольку тип параметра один и тот же для всех функции hit, а значит, они должны иметь различные имена. Теперь можно записать функцию initializeCollisionMap так: Spaceship::HitMap * Spaceship::initializeCollisionMap() { HitMap *phm = new HitMap; (*phm) ["Spaceship"] = bhitSpaceShip,- (*phm)["SpaceStation"] = khitSpaceStation; (*phm)["Asteroid"] = khitAsteroid; return phm; } К сожалению, параметры функций hit теперь имеют тип GameObject вместо типа производного класса, как раньше. Привести действительность в соответствие с ожиданиями можно с помощью оператора dynamic_cast (см. правило 2), примененного в каждой функции: void Spaceship::hitSpaceShip(GameObjectk spaceship) { Spaceships otherShip= dynamic_cast<SpaceShip&>(spaceship); обработать столкновение SpaceShip-SpaceShip; } void Spaceship::hitSpaceStation(GameObject& spaceStation)
ЕЗЗНМИН { SpaceStationk station= dynamic_cast<SpaceStation&>(spaceStation); обработать столкновение SpaceShip-SpaceStation; } void Spaceship::hitAsteroid(GameObject& asteroid) { Asteroidk theAsteroid = dynamic_cast<Asteroid&>(asteroid); обработать столкновение SpaceShip-Asteroid; } Выполнение каждого из операторов dynamic_cast вызывает генерацию исключения bad_cast, если попытка приведения типа окончилась неудачей. Но скорее всего они всегда будут успешными, так как функции hi t никогда не должны вызываться с некорректными типами параметров. Однако лучше все же перестраховаться. Использование для обработки столкновений функций, не являющихся членами класса Теперь вы знаете, как построить ассоциативный массив, похожий на таблицу виртуальных функций, который позволяет реализовать вторую часть двойной диспетчеризации, и как инкапсулировать детали ассоциативного массива в функции поиска. Но, поскольку этот массив содержит указатели на функции - члены класса, вам все равно придется изменять определения класса, если в игру вводится новый тип объектов GameObj ect, а это потребует обязательной перекомпиляции. Например, если в игре появляется тип Satellite, придется добавить к классу Spaceship объявление функции для обработки столкновений между спутниками (satellites) и космическими кораблями (spaceships). Все пользователи класса Spaceship будут вынуждены выполнить перекомпиляцию, даже если в своем варианте игры они обходятся без спутников. Именно из-за этого пришлось отказаться от реализации двойной диспетчеризации, основанной только на виртуальных функциях, хотя такое решение требовало намного меньше усилий, чем то, которое вы только что увидели. Перекомпиляции можно было бы избежать, если бы ассоциативный массив содержал указатели на функции, не являющиеся членами класса. Кроме этого, переход к функциям для обработки столкновений, не являющихся членами класса, позволит решить один до сих пор не рассматривавшийся организационный вопрос: в каком классе должно обрабатываться столкновение между объектами различных типов? В последней из предложенных реализаций столкновение объектов 1 и 2 обрабатывалось в классе 1-го объекта, если этот объект был левым аргументом функции processCollision. Если же левым аргументом функции processCollision был 2-й объект, то столкновение должно было бы обрабатываться в классе для 2-го объекта. Целесообразно ли это? Не лучше ли сделать так, чтобы столкновения между объектами А и в обрабатывались в каком-то нейтральном месте вне этих классов? Приемы
Правило 31 ШИШМКШ Если вывести функции обработки столкновений из классов, то можно будет предоставить пользователям заголовочные файлы, содержащие определения классов без функций hit или collide. Затем функция processCollision приобретет следующую структуру: #include "Spaceship.h" #include "SpaceStation.h" #include "Asteroid.h" namespace { // Неименованное пространство имен - см. ниже. // Основные функции обработки столкновений, void shipAsteroid(GameObject& spaceship, GameObjectk asteroid) ; void shipStation(GameObject& spaceship, GameObjectk spaceStation); void asteroidStation(GameObjectk asteroid, GameObjectk spaceStation); // Вспомогательные функции обработки столкновений, // обеспечивающие симметрию: // меняют местами параметры и вызывают основную функцию. void asteroidShip(GameObject& asteroid, GameObjectk spaceship) { shipAsteroid(spaceship, asteroid); } void stationShip(GameObjectk spaceStation, GameObjectk spaceship) { shipStation(spaceship, spaceStation); } void stationAsteroid(GameObject& spaceStation, GameObjectk asteroid) { asteroidStation(asteroid, spaceStation); } // См. описание этих типов/функций ниже. typedef void (*HitFunctionPtr) (GameObjectk, GameObjectk) ; typedef map< pair<string,string>, HitFunctionPtr > HitMap; pair<string,string> makeStringPair(const char *sl, const char *s2); HitMap * initializeCollisionMapO; HitFunctionPtr lookup(const stringk classl, const string& class2) ; } // Конец пространства имен. void processCollision(GameObject& objectl, GameObjectSc object2) { \ HitFunctionPtr phf = lookup(typeid(objectl).name(), typeid(object2).name()); if (phf) phf(objectl, object2); else throw UnknownCollision(objectl, object2); }
ЕШННИ1Ш Приемы Обратите внимание на неименованное пространство имен, содержащее функции для реализации processCollision. Все, находящееся в таком неименованном пространстве имен, будет закрытым внутри текущего модуля трансляции (по сути, в текущем файле) - это то же самое, если бы функции были объявлены как static для файла. Но с введением пространств имен такое объявление устарело, и вам следует привыкать использовать неименованные пространства имен, если ваши компиляторы поддерживают их. Концептуально такая реализация очень похожа на реализацию с использованием функций - членов класса, за исключением небольших различий. Во-первых, HitFunctionPtr теперь является указателем на функцию, не являющуюся членом класса. Во-вторых, класс исключений CollisionWithUnknownObject переименован в UnknownCollision и имеет в качестве аргументов два объекта, а не один. И наконец, функция lookup обладает двумя параметрами и выполняет обе части двойной диспетчеризации. Это означает, что карта столкновений должна теперь содержать три блока данных: два имени типов и указатель HitFunctionPtr. Но класс тар способен включать только два блока данных. Обойти возникшую проблему можно с помощью стандартного шаблона pair, который позволяет упаковать два имени типа в один объект initializeCollisionMap. Вместе со вспомогательной функцией makeStringPair этот объект будет выглядеть так: / / Эта функция создает пару объектов // pair<string, string> из двух строк char*. // Эти пары используются позже в функции initializeCollisionMap. // Обратите внимание,что данная функция позволяет выполнять // оптимизацию возвращаемого значения (см. правило 20). namespace { // Неименованное пространство имен - см. ниже. pair<string,string> makeStringPair(const char *sl, const char *s2) { return pair<string,string>(si, s2); } } // Конец пространства имен. namespace { // Еще одно неименованное пространство имен. HitMap * initializeCollisionMap() { HitMap *phm = new HitMap; (*phm)[makeStringPair("Spaceship", "Asteroid")] = &shipAsteroid; (*phm)[makeStringPair("Spaceship", "SpaceStation")] = &shipStation; return phm; } } // Конец пространства имен. Нужно изменить также функцию lookup, чтобы она работала с объектами pair<string, string>, которые теперь включают первый компонент карты столкновений:
Правило 31 ШПННКШ namespace { // Это будет объяснено ниже. HitFunctionPtr lookup(const stringk classl, const stringk class2) { static auto_ptr<HitMap> collisionMap(initializeCollisionMap()); //См. ниже описание функции make_pair. HitMap::iterator mapEntry= collisionMap->find(make_pair(classl, class2)); if (mapEntry == collisionMap->end()) return 0; return (*mapEntry).second; , } } // Конец пространства имен. Это почти то же, чем вы располагали раньше. Единственное различие состоит в использовании функции make_pair в операторе: HitMap::iterator mapEntry= collisionMap->find(make_pair(classl, class2)); Функция (шаблон) make_pai г введена в стандартную библиотеку только для удобства (см. правило 35) и избавляет вас от хлопот по заданию типов при создании объекта pair. С тем же успехом можно было бы записать этот оператор следующим образом: HitMap::iterator mapEntry= collisionMap->f ind(pair<string, string> (classl, class2) ) ,- Но такая запись длиннее, и задание типов для pair является избыточным (они совпадают с типами для classl и class2), поэтому чаще используется форма с функцией make_pair. Поскольку функции makeStringPair, initializeCollisionMapH lookup были объявлены в неименованном пространстве имен, то каждая из них должна быть реализована именно там. Вот почему эти функции реализованы в неименованном пространстве имен (в том же модуле трансляции, где находятся их объявления): компоновщик корректно свяжет их определения (то есть реализацию) с ранее сделанными объявлениями. Итак, цель достигнута. Если в иерархию добавляются новые под классы класса GameObj ect, то существующие классы не будут нуждаться в перекомпиляции (если они не собираются использовать новые классы). Вы избавились от неразберихи, которая возникает при использовании переключателя switch, основного на RTTI, и поддержке условных операторов if-then-else. Добавление к иерархии новых классов требует только хорошо определенных и локализованных изменений в системе: одной или двух вставок в функции initializeCollisionMap и объявления новых функций для обработки столкновений в неименованном пространстве имен, связанном с реализацией функции processCollision. Чтобы дойти до этого места, потребовалось много усилий, но, согласитесь, путешествие того стоило.
ШНН11Ш . пРиемы Наследование и эмулированные таблицы виртуальных функций Осталось устранить последнюю проблему. (Впрочем, механизм реализации виртуальных функций настолько сложен, что практически всегда за «последней» проблемой будет всплывать еще какая-нибудь.) Все что вы сделали, будет прекрасно действовать до тех, пор пока вам не понадобится разрешить при вызове функций для обработки столкновений преобразование типов, основанное на наследовании. Но предположим, что в создаваемой игре иногда требуется различать военные и гражданские космические корабли. Можно было бы модифицировать иерархию, руководствуясь правилом 33 и сделав реальные классы Commercial Ship (Гражданский корабль) и MilitaryShip (Военный корабль) наследниками нового абстрактного класса Spaceship (см. рис. 5.20). Рис. 5.20 Допустим, что гражданские и военные корабли при столкновении с чем- либо ведут себя одинаково. Следовательно, они смогут использовать те же функции обработки столкновений, которые применялись до добавления классов CommercialShip и MilitaryShip. В таком случае при столкновении, например, объектов MilitaryShip и Asteroid должна вызываться функция: void shipAsteroid(GameObject& spaceship, GameObject& asteroid); Но это не так. В действительности будет сгенерировано исключкние Unknown- Col 1 i s ion. Это произойдет потому, что функция lookup должна будет найти функции, соответствующие типам с именами MilitaryShip и Asteroid, а в массиве collisionMap нет таких функций. Даже если с объектом MilitaryShip разрешалось бы обращаться как с объектом Spaceship, функция lookup не может ничего знать об этом. Если вам нужно реализовать двойную диспетчеризацию, и при этом поддерживать основанное на наследовании преобразование параметров, то единственным
правило 31 тштммлш практическим выходом будет возврат к механизму двойного вызова виртуальных функций, который был рассмотрен ранее. Значит, вам придется примириться с необходимостью перекомпиляции при введении новых классов в иерархию наследования. Инициализация эмулированных таблиц виртуальных функций (повторно) Это все, что можно сказать о двойной диспетчеризации, но неприятно заканчивать главу на такой грустной ноте. Поэтому вашему вниманию предлагается набросок альтернативного подхода к инициализации массива collisionMap. Схема программы до сих пор оставалась полностью статической. Будучи раз зарегистрирована, функция для обработки столкновений объектов двух разных типов остается навсегда. А что, если пользователю захочется добавлять, удалять или изменять функции для обработки столкновений во время игры? Чтобы сделать это, можно заключить понятие карты, хранящей функции обработки столкновений, в класс, где находятся функции-члены, позволяющие динамически изменять содержимое карты, например: class CollisionMap { public: typedef void (*HitFunctionPtr)(GameObjectSc, GameObjectSc); void addEntry(const stringk typel, const stringk type2, HitFunctionPtr collisionFunction, bool symmetric = true); //См. ниже, void removeEntry(const stringk typel, const stringk type2); HitFunctionPtr lookup(const stringk typel, const stringk type2); // Эта функция возвращает ссылку на одну //и только одну карту - см. правило 26. static CollisionMapk theCollisionMap(); private: // Эти функции объявлены как закрытые, чтобы // предотвратить создание нескольких карт - см. правило 26. CollisionMap(); CollisionMap(const CollisionMapk); }; Этот класс позволяет добавлять в карту игры новые элементы, удалять их из нее и выполнять поиск функции обработки столкновений, связанную с определенной парой имен типов. Он также использует методы из правила 26, чтобы создавался всего один объект CollisionMap, так как в системе существует всего одна карта. (Можно легко представить себе более сложные игры с несколькими картами.) И наконец, она позволяет упростить добавление к карте симметричных столкновений (то есть если столкновение объекта типа Т1 с объектом типа Т2 имеет тот же эффект, что и столкновение объекта типа Т2 с объектом типа Т1),
Ill Приемы автоматически добавляя соответствующую ячейку карты при вызове функции addEntry со значением необязательного параметра symmetric равным true. Имея такой класс CollisionMap, каждый из пользователей, которому нужно добавить ячейку к карте, делает это напрямую: void shipAsteroid(GameObject& spaceship, GameObjectk asteroid); CollisionMap::theCollisionMap(.addEntry("Spaceship", "Asteroid", kshipAsteroid); void shipStation(GameObjectk spaceship, GameObjectk spaceStation); CollisionMap::theCollisionMap().addEntry("Spaceship", "SpaceStation", &shipStation); void asteroidstation(GameObjectk asteroid, GameObjectk spaceStation); CollisionMap::theCollisionMap().addEntry("Asteroid", "SpaceStation", kasteroidStation); Нужно гарантировать, что ячейки карты будут добавлены до того, как произойдут столкновения, вызывающие связанные с ними функции. Один из способов сделать это - проверять в конструкторах подклассов класса GameObj ect, что соответствующие изображения уже были добавлены к карте при создании каждого объекта. Но такой подход немного снизит производительность программы. В качестве альтернативы можно было бы создать класс Register- CollisionFunction: class RegisterCollisionFunction { public: RegisterCollisionFunction( const stringk typel, const stringb type2, CollisionMap::HitFunctionPtr collisionFunction, bool symmetric = true) { } }; CollisionMap::theCollisionMap().addEntry(typel, type2, collisionFunction, symmetric); После этого пользователи могли бы с помощью глобальных объектов данного типа автоматически регистрировать необходимые им функции: RegisterCollisionFunction cfl("Spaceship", "Asteroid", &shipAsteroid);
Правило 31 МШПНЕШ RegisterCollisionFunction cf2("Spaceship", "SpaceStation", kshipStation); RegisterCollisionFunction cf3("Asteroid" , "SpaceStation", basteroidStation); int main(int argc, char * argv[]) { } Так как эти объекты создаются до вызова функции main, то функции, регистрируемые их конструкторами, также добавляются к карте до вызова функции main. Если позже в игру включается новый производный класс: class Satellite: public GameObject { ... } ; и одна или несколько новых функций для обработки столкновений: void satelliteShip(GameObject& satellite, GameObjectk spaceship); void satelliteAsteroid(GameObject& satellite, GameObjectk asteroid); то все новые функции вы сможете добавить к карте аналогичным образом, не затрагивая соответствующий код: RegisterCollisionFunction cf4("Satellite", "Spaceship", ksatelliteShip); RegisterCollisionFunction cf5("Satellite", "Asteroid", ksatelliteAsteroid); Конечно, это далеко не безупречный способ реализации множественной диспетчеризации (такого попросту нет), но он облегчает ввод данных в основанную на карте реализацию.
Глава 6. Разное Эта глава содержит информацию, которую по соображениям логичной организации материала нельзя было поместить ни в одну другую. Для начала поговорим о двух правилах объектно-ориентированной разработки программ на языке C++, которые помогут вам создавать системы, приспосабливающиеся к изменениям. Затем рассмотрим, как объединить в одной программе С и C++, что иногда бывает просто необходимо. И наконец, в последнем разделе книги описаны изменения в стандарте языка C++, произошедшие со времени публикации его фактического описания. Отдельное внимание уделено изменениям, которые были внесены в стандартную библиотеку. Если вы не следите за процессом стандартизации, здесь вас ожидает несколько приятных сюрпризов. Правило 32. Программируйте, заглядывая в будущее Все меняется. Не всегда известно, что именно изменится, как будут осуществляться эти изменения, когда они произойдут или почему, но точно ясно: все меняется. Хорошо написанные программы легко адаптируются к изменениям. Они воспринимают новые свойства, переносятся на новые платформы, подстраиваются под новые требования, обрабатывают новые разновидности входных данных. Гибкие, надежные и устойчивые программы не создаются случайно. Они разрабатываются и реализуются программистами, которые приспосабливаются к сегодняшним ограничениям, не забывая про нужды будущего. Программы, элегантно принимающие изменения, пишутся профессионалами, которые умеют заглядывать в будущее. Программировать с прицелом на будущее означает осознавать, что все меняется, и быть готовым к этому. Несомненно, к библиотекам будут добавляться новые функции, существующие функции станут снова перегружаться, и надо быть готовым к тому, что это может привести к потенциально двусмысленным вызовам функций. Вполне вероятно, в иерархиях появятся новые классы, а сегодняшние производные классы могут завтра стать базовыми. Постоянно будут писаться новые приложения, из-за чего функции будут вызываться в новом контексте, и ваша задача - заранее побеспокоиться о корректности их выполнения. Программисты, ответственные за поддержку ПО, обычно не являются разработчиками оригинального кода, следовательно, необходимо проектировать и реализовать программы так, чтобы облегчить их понимание, изменение и расширение другими.
Правило 32 тШШШШШ Один из способов сделать это - выражать ограничения разработки в самом коде, а не (или не только) в комментариях или другой документации. Например, если класс не должен иметь производных классов, не просто вставляйте комментарий в заголовочный файл класса, а предотвращайте наследование средствами C++ (см. правило 26). Если требуется, чтобы все экземпляры класса размещались в куче, не просто сообщайте об этом пользователям, а введите ограничение явно, применив подход, описанный в правиле 27. Если копирование и присваивание для класса не имеет смысла, запретите данные операции, объявив закрытыми конструктор копирования и оператор присваивания. C++ - это мощный, гибкий и выразительный язык. Воспользуйтесь его свойствами, чтобы ввести в программы нужные ограничения. Поскольку все меняется, пишите классы, которые могут противостоять беспорядочной эволюции программного обеспечения. Избегайте делать функции виртуальными «по требованию», то есть тогда, когда кто-то подойдет и попросит вас сделать это. Лучше определите значение функции и целесообразность ее переопределения в производных классах. Если такое переопределение имеет смысл, объявляйте ее как виртуальную, даже если пока это никому не нужно. В противном случае объявите ее невиртуальной и потом не меняйте своего решения просто из-за того, что кому-то так будет удобнее, не убедившись, что изменение имеет смысл в контексте всего класса и представляемой им абстракции. Включайте во все классы операторы присваивания и конструктор копирования, даже если «никто никогда не будет их использовать». Их невостребованность «здесь и сейчас» не означает, что они не потребуются в будущем. Если реализовать эти функции сложно, объявите их как private. Тогда никто не сможет нечаянно вызывать функции, сгенерированные компилятором и делающие что-то не так (что часто происходит с операторами присваивания и конструкторами копирования, созданными по умолчанию). Придерживайтесь принципа минимальной новизны, то есть старайтесь создавать классы, операторы и функции, которые имеют естественный синтаксис и наглядную семантику. Сохраняйте согласованность со встроенными типами: если не знаете, как поступить, сделайте так же, как и для int. Помните: все, что можно сделать, пользователи сделают обязательно. Они будут генерировать исключения, присваивать объекты самим себе, использовать объекты перед присваиванием им значения, присваивать объектам значения и никогда к ним не обращаться, задавать слишком большие, слишком маленькие и нулевые значения. В общем, все, что может откомпилироваться, наверняка будет кем-то сделано. Поэтому программируйте такие классы, с которыми легко работать правильно и сложно - неправильно. Предполагайте, что пользователи будут делать ошибки, и проектируйте классы так, чтобы ошибки можно было предотвращать, обнаруживать или исправлять (см., например, правило 33). Старайтесь создавать переносимый код. Писать переносимые программы ненамного сложнее, чем непереносимые, и разница в производительности редко
ш»имш Разное бывает достаточно существенной, чтобы оправдать применение непереносимых конструкций (см. правило 16). Даже программы, разработанные для оборудования, сделанного на заказ, часто потом переписываются как переносимые, так как обычно стандартное оборудование через несколько лет достигает такого же уровня производительности. Написание переносимых программ позволяет вам легко переходить с одной платформы на другую, расширять пользовательскую базу и хвастаться поддержкой открытых систем. Это тоже помогает легче наверстать упущенное, если вы поставили не на ту операционную систему. Разрабатывайте код так, чтобы влияние необходимых изменений было локализовано. Инкапсулируйте все, что можно, делайте детали реализации закрытыми. Почаще используйте неименованные пространства имен для статических в файле объектов и функций (см. правило 31). Пытайтесь избегать создания виртуальных базовых классов, поскольку такие классы должны инициализироваться во всех производных от них классов - даже косвенных производных (см. правило 4). Не применяйте без крайней нужды подход RTTI, при котором используются каскады операторов if-then-else (снова см. правило 31). Иногда при каждом изменении иерархии классов придется обновлять весь набор операторов, и если вы забудете хотя бы об одном из них, компиляторы не выдадут никакого предупреждения. Это призывы повторяются часто, но большинство программистов к ним не прислушиваются. К сожалению, и многие авторы тоже. Рассмотрим следующий совет известного эксперта по C++: «Если кто-то удаляет объект В*, который указывает на D, то вам понадобится виртуальный деструктор». Здесь В является базовым классом, a D - производным. Другими словами, автор предполагает, что если ваша программа выглядит, как показано ниже, то в классе В вам не понадобится виртуальный деструктор: class В { . . . }; // Виртуальный деструктор не нужен, class D: public В { ... }; В *pb = new D; Но ситуация меняется, если добавить оператор: delete pb; // Теперь вам нужен виртуальный // деструктор в классе В. Небольшое изменение в пользовательском коде - добавление оператора delete - приводит к необходимости изменять определение класса В, а значит, все пользователи класса В должны будут выполнить перекомпиляцию. Если последовать совету процитированного автора, то добавление единственного оператора к одной функции может потребовать обширной перекомпиляции и перекомпоновки кода для всех пользователей данной библиотеки. Это какой угодно, только не эффективный подход к разработке программ. Другой автор пишет на ту же тему: «Если открытый базовый класс не имеет виртуального деструктора, то ни производный класс, ни его члены не должны иметь деструкторов».
Правило 32 : 1МШННЕШ Другими словами, следующий код допустим: class string { // Из стандартной библиотеки C++, public: -string(); }; class В { . . . }; // Нет элементов данных с деструкторами, // виртуальный деструктор не нужен. но в производном от В классе ситуация меняется: class D: public В { string name; // теперь В должен быть виртуальным. }; И снова небольшое изменение в способе использования класса в (в данном случае добавление производного класса, содержащего член с деструктором) может привести к перекомпиляции и перекомпоновке во всех клиентах. Но небольшие изменения в программе должны иметь небольшое влияние на систему. Эта разработка не проходит данный тест. Тот же автор пишет: «Если в иерархии множественного наследования есть деструкторы, то каждый базовый класс должен иметь виртуальный деструктор». Обратите внимание, что во всех этих цитатах авторы говорят в настоящем времени. Как пользователи работают с указателями сейчас? Какие члены класса сейчас имеют деструкторы? Какие классы в иерархии сейчас имеют деструкторы? Размышления в будущем времени ведутся совсем по-другому. Вместо того чтобы задаваться вопросом, как класс используется сейчас, спрашивается, для чего класс разработан? Если класс разработан с целью сделать его базовым (даже если он сейчас и не используется в качестве такового), он должен иметь виртуальный деструктор. Такие классы корректно ведут себя и сейчас, и в будущем, и они не влияют на других пользователей библиотеки при создании новых производных от них классов. (По меньшей мере в том, что касается их деструктора. Если в класс нужно внести еще какие-то изменения, это может оказать влияние на других пользователей класса.) Коммерческая библиотека классов (созданная до введения спецификации string в стандартную библиотеку C++) содержит класс строк без виртуального деструктора. Как это объясняют разработчики? «Мы не сделали деструктор виртуальным потому, что не хотели, чтобы класс String содержал таблицу виртуальных функций. Мы не намерены когда-либо использовать String*, хотя знаем о том, какие затруднения это может вызвать.» Рассуждая так, думали ли они о будущем или только о настоящем? Конечно же, использование таблицы виртуальных функций технически допустимо (см. правило 24). Реализация большинства классов String содержит внутри каждого объекта String единственный указатель char*, поэтому добавление к каждому объекту String виртуального указателя удвоит размер объектов. Легко понять, почему разработчикам программного обеспечения хочется избежать 9 - 679
ЕШННИ1 Разное этого, особенно для таких часто использующихся классов, как String. Производительность подобного класса может снизиться на 20% (см. правило 16). Но обычно память, выделенная под весь объект строки - память под сам объект плюс динамическая память для хранения значения строки - намного больше, чем пространство, необходимое для хранения указателя char*. С этой точки зрения накладные расходы на создание виртуального указателя не столь существенны. Тем не менее, это допустимое техническое соображение. (Комитет стандартизации ISO/ANSI, похоже, тоже так думает: стандартный тип string имеет невиртуальный деструктор.) Меня больше беспокоит замечание разработчиков: «Мы не намерены когда- либо использовать String*». Это может быть так, но класс String является частью библиотеки, к которой будут обращаться тысячи программистов. Каждый из них имеет различный опыт работы с C++, и все они делают разные вещи. Все ли они понимают, к чему приводит отсутствие виртуального деструктора в классе String? Знают ли они, что из-за этого создание производных от String классов является весьма рискованным предприятием? Уверены ли разработчики класса, что его пользователи поймут, что удаление объектов при помощи указателей String* будет выполняться неправильно и операции RTTI над указателями и ссылками на объекты типа String будут возвращать некорректные данные? Легко ли использовать этот класс правильно, и сложно ли сделать что-то не так? Разработчики должны ясно указать в документации, что класс String не предназначен для создания производных от него классов, но вдруг программисты не обратят внимания на это предупреждение или просто не будут читать документацию? Альтернативой было бы запретить создание производных классов при помощи средств языка C++. Правило 26 описывает, как можно сделать это, ограничив создание объектов в куче и затем используя для работы с объектами в куче объекты auto_ptr. Но тогда интерфейс для создания объектов String был бы непривычным и неудобным, поскольку требовал бы записи: auto_ptr<String> ps(String::makeString("Future tense C++")); ... // ps можно рассматривать как указатель //на объект String, который не нужно удалять. вместо: String s("Future tense C++"); Но, возможно, уменьшение риска неправильного поведения производных классов стоило бы такого синтаксического неудобства. (Для класса String это маловероятно, но для других классов подобный компромисс может быть выгодным.) Конечно же, нужно думать и о настоящем. Проектируемые вами программы должны работать на существующих компиляторах; вы не можете ждать, пока будут реализованы последние свойства языка. Программы должны выполняться на поддерживаемом оборудовании и на всех конфигурациях пользователей; вы не можете заставлять пользователей выполнять обновление оборудования или операционной среды. Ваши разработки должны иметь приемлемую производительность
Правило 32 НИШ сейчас; обещания сделать программу более быстрой и компактной через несколько лет обычно не греют сердца потенциальных пользователей. Обычно программное обеспечение, над которым вы трудитесь, должно быть доступно «сегодня», что часто означает «несколько дней назад». Это важные ограничения, и игнорировать их нельзя. Вот несколько советов, которые наверняка вам пригодятся: ? создавайте полные классы, даже если пока используются только их части. Тогда при возникновении новых требований к классам вам с меньшей вероятностью придется возвращаться к ним снова и модифицировать их; ? разрабатывайте интерфейсы так, чтобы облегчить выполнение обычных операций и предотвратить появление типичных ошибок. Должно быть легко использовать классы правильно, и сложно - неправильно. Запрещайте, например, копирование и присваивание для классов, в которых эти операции бессмысленны. Предотвращайте частичное присваивание (см. правило 33); ? обобщайте код, если это не приводит к большим затратам. Например, если вы пишете алгоритм для обхода дерева, подумайте, нельзя ли обобщить его так, чтобы он мог обрабатывать все типы ориентированных графов без циклов. Думая о будущем, вы увеличите шансы своих программ на повторное использование, облегчите его поддержку, сделаете его более устойчивым и в то же время изменяемым. Сегодняшние и будущие требования должны быть уравновешены. Но слишком много программистов фокусируют свое внимание только на текущих потребностях, и, делая это, они приносят в жертву долгосрочную жизнеспособность разрабатываемых и реализуемых ими программ. Будьте другим. Программируйте, заглядывая в будущее. Правило 33. Делайте нетерминальные классы абстрактными Предположим, что вы работаете над программным проектом, в котором определены классы животных (Animal). При этом большинство типов животных обрабатывается одинаково, но два класса - Li sard (ящерицы) и Chicken (куры) - требуют специальной обра- ботки. В этом случае, очевидно, классы ящериц, кур и всех остальных животных должны быть связаны между собой так, как представлено на рис. 6.1. Класс Animal включает в себя свойства, общие для всех животных, а классы Lizard и Chicken адаптируют класс Animal соответственно для работы с типами «ящерицы» и «куры»: Вот набросок определений для этих классов: class Animal { public: Animals operator=(const Animals rhs)¦ Г
ЕШЗВНИН! Разное }; class Lizard: public Animal { public: Lizards operator=(const Lizards rhs); }; class Chicken: public Animal { public: Chickens operator=(const Chickens rhs) ; }; Здесь показаны только операторы присваивания, но этого пока более чем достаточно. Рассмотрим следующий код: Lizard lizl; Lizard liz2 ; Animal *pAnimall = Slizl; Animal *pAnimal2 = Sliz2; *pAnimall = *pAnimal2; Здесь есть две проблемы. Во-первых, оператор присваивания, вызываемый в последней строке, принадлежит классу Animal, хотя объекты имеют тип Lizard. В результате, в объекте lizl будет изменена только часть класса Animal. Эточастичпое присваивание. После присваивания члены класса Animal в объекте lizl будут иметь значения, полученные от 1 iz2, но члены класса Lizard в, останутся без изменений. Вторая проблема состоит в том, что программисты действительно могут писать такой код. Опытные программисты С, перешедшие на C++, довольно часто выполняют присваивание объектам при помощи указателей. Поэтому надо сделать так, чтобы присваивание выполнялось более приемлемым образом. Как говорится в правиле 32, классы должно быть легко использовать правильно, и сложно - неправильно, а классы из вышеприведенной иерархии легко использовать неправильно. Один из вариантов решения проблемы - сделать операторы присваивания виртуальными. Если бы функции Animal::operator= были виртуальными, то присваивание привело бы к вызову оператора присваивания класса Lizard, и такое поведение было бы корректным. Но посмотрите, что произойдет, если объявить операторы присваивания виртуальными: class Animal { public: virtual Animals operator=(const Animals rhs) ; }; class Lizard: public Animal { public: virtual Lizards operator=(const Animals rhs);
Правило 33 НМПНЕШ }; class Chicken: public Animal { public: virtual Chickenk operator=(const Animals rhs); }; Благодаря относительно недавним изменениям в языке можно сделать так, чтобы значение, возвращаемое каждым из операторов присваивания, было ссылкой на корректный класс, но правила языка C++ требуют объявлять идентичные типы параметров виртуальных функций во всех классах, в которых они объявлены. Это означает, что операторы присваивания в классах Lizard и Chicken должны быть готовы принять любой тип объектов Animal в правой части присваивания. Следовательно, надо учитывать, что допустим подобный код: Lizard liz; Chicken chick; Animal *pAnimall = &liz; Animal *pAnimal2 = &chick; *pAnimall = *pAnimal2; // Присвоить курице // ящерицу'! Это смешанное присваивание: слева стоит объект типа Lizard, а справа - объект типа chicken. Смешанные присваивания обычно не приводят к проблемам в C++, потому что благодаря строгой типизации языка они оказываются недопустимыми. Но, если оператор присваивания класса Animal стал виртуальным, появляется возможность таких смешанных операций присваивания. Это ставит нас в сложное положение. Хотелось бы разрешить присваивание с помощью указателей одинаковых типов, запретив при этом смешанное присваивание посредством тех же самых указателей. Другими словами, разрешить: Animal *pAnimall = &lizl; Animal *pAnimal2 = &liz2; *pAnimall = *pAnimal2; // Присвоить ящерицу ящерице. и запретить: Animal *pAnimall = &liz; Animal *pAnimal2 = Schick; *pAnimall = *pAnimal2; // Присвоить курицу ящерице. Различить эти ситуации можно только во время выполнения программы, так как иногда допустимо присваивать *pAnimall значение *pAnimal2, а иногда нет. При этом вы вступаете в мрачный мир ошибок типа во время выполнения программы. В частности, вы должны сообщить об ошибке в функции operator=,
ЕЗВНИН; Разное если столкнулись со смешанным присваиванием, если же типы одинаковы, надо выполнить присваивание как обычно. Можно использовать для реализации такого поведения оператор dynamic_cast (см. правило 2). Вот как это делается для оператора присваивания класса Lizard: Lizards Lizard::operator=(const Animals rhs) { // Убедиться, что rhs имеет тип lizard. const Lizards rhs_liz = dynamic_cast<const Lizards>(rhs); выполнить обычное присваивание *this значения rhs_liz; } Данная функция присваивает объекту *this значение rhs, только если rhs имеет тип Lizard. Если это не так, то функция передает исключение bad_cast, генерируемое оператором dynamic_cast при неудачной попытке приведения типа. (На самом деле исключение имеет тип std: : bad_cast, потому что компоненты стандартной библиотеки, включая генерируемые ими исключения, находятся в пространстве имен std. Обзор стандартной библиотеки см. в правиле 35.) Даже если не беспокоиться об исключениях, эта функция кажется слишком сложной и требующей больших затрат: оператор dynamic_cast должен обращаться к структуре type_inf о, если один объект Lizard присваивается другому объекту того же типа (см. правило 24): Lizard lizl, liz2; lizl = liz2; // Нет необходимости применять // dynamic_cast; это присваивание // должно быть допустимо. Чтобы при обработке этого случая избежать чрезмерного усложнения кода или применения оператора dynamic_cast, надо добавить к классу Lizard обычный оператор присваивания: class Lizard: public Animal { public: virtual Lizards operator=(const Animals rhs) ; Lizards operator=(const Lizards rhs); // Добавить это. }; Lizard lizl, liz2; lizl = liz2; Animal *pAnimall = Slizl; Animal *pAnimal2 = Sliz2; *pAnimall = *pAnimal2; // Вызов operator= // с аргументом const Animals. // Вызов operator= // с аргументом const izardS.
Правило 33 ЖШтШ Фактически, если задана последняя функция operator=, на ее основе чрезвычайно просто реализовать предыдущую функцию: Lizards Lizard::operator=(const Animals rhs) { return operator=(dynamic_cast<const Lizard&>(rhs)); } Эта функция пытается привести значение rhs к типу Lizard. Если такое приведение успешно, вызывается обычный оператор присваивания класса. В противном случае генерируется исключение bad_cast. Честно говоря, вся эта деятельность по проверке типов во время выполнения программы и использование операторов dynamic_cast меня беспокоит. Во-первых, некоторые компиляторы все еще не поддерживают dynamic_cast, поэтому теоретически переносимый код, использующий их, на практике не обязательно будет таковым. Что более важно, клиенты классов Lizard и Chicken должны быть готовы перехватывать и обрабатывать при выполнении присваивания исключения bad_cast. Судя по моему опыту, немного найдется программистов, которые любят писать программы подобным образом. Если же не сделать этого, то неочевидно, выиграем ли мы что-либо по сравнению с исходной ситуацией, когда пытались избежать частичного присваивания. При таком неудовлетворительном положении дел с виртуальными операторами присваивания имеет смысл перенаправить усилия и попытаться, прежде всего, найти способ предотвратить выполнение пользователями рискованного присваивания. Если такое присваивание будет отвергаться во время компиляции, вам не придется беспокоиться о том, что оно сделает что-то не так. Простейший способ не допустить такого присваивания - сделать функцию operator= в классе Animal закрытой. Тогда значение «ящерицы» можно будет присваивать ящерицам, а «куры» - курам, но частичные и смешанные присваивания будут запрещены: class Animal { private: Animals operator=(const Animals rhs); // Теперь эта функция ... // является закрытой. }; class Lizard: public Animal { public: Lizards operator=(const Lizards rhs) ; }; class Chicken: public Animal { public: Chickens operator=(const Chickens rhs) ; }; Lizard lizl, liz2 ;
IHI1I Разное lizl = liz2; Chicken chickl, chick2; chickl = chick2; Animal *pAnimall = &lizl; Animal *pAnimal2 = &chickl; *pAnimall = *pAnimal2; // Нормально. // Также нормально. // Ошибка! Попытка вызова закрытой // функции Animal::operator=. К сожалению, класс Animal является реальным, и такой подход запрещает присваивание между объектами Animal: Animal animall, animal2; animall = animal2; // Ошибка! Попытка вызова закрытой // функции Animal::operator=. Более того, это делает невозможной корректную реализацию операторов присваивания классов Lizard и Chicken, поскольку операторы присваивания в производных классах отвечают за вызов операторов присваивания в своих базовых классах: Lizards Lizard::operator=(const Lizards rhs) { if (this == &rhs) return *this; Animal::operator=(rhs); } // Ошибка! Попытка вызова закрытой // функции. Но Lizard::operator= // должен вызывать эту функцию // для присваивания частей Animal // в объекте *this! Нетрудно решить последнюю проблему, объявив Animal: :operator= как protected, но головоломка- как сделать так, чтобы можно было присваивать объекты Animal, запретив частичное присваивание объектов Lizard и Chicken при помощи указателей Animal, - остается нерешенной. Что делать бедному программисту? Проще всего устранить необходимость разрешать присваивание между объектами Animal, например, сделав класс Animal абстрактным. Тогда нельзя будет создавать экземпляры класса Апл mal, поэтому не нужно будет разрешать присваивание между объектами Animal. Конечно же, это приводит к новой проблеме, так как первоначальная схема предполагала необходимость создания объектов Animal. Существует простой способ обойти данное затруднение. Вместо того чтобы делать абстрактным сам класс Animal, можно создать новый класс, скажем, Abstract- Рис. 6.2 Animal, состоящий из общих свойств объектов
Правило 33 111IHES Animal, Lizard и Chicken, и объявить абстрактным этот класс. Тогда каждый из реальных классов будет наследовать от класса AbstractAnimal. Исправленная иерархия показана на рис. 6.2. А определения классов выглядят следующим образом: class AbstractAnimal { protected: AbstractAnimalk operator=(const AbstractAnimalk rhs) ; public: virtual -AbstractAnimal () =0; // См. ниже. }; class Animal: public AbstractAnimal { public: Animalk operator^(const Animals rhs); }; class Lizard: public AbstractAnimal { public: Lizardk operator=(const Lizardk rhs); }; class Chicken: public AbstractAnimal { public: Chickenk operator=(const Chickenk rhs); }; Эта схема дает нам все, что нужно. Для классов Lizard, Chicken, Animal разрешены однотипные присваивания; частичные и разнотипные присваивания запрещены; и операторы присваивания в производных классах могут вызывать операторы присваивания в базовом классе. Более того, код, написанный на основе классов Animal, Lizard или Chicken не требует изменений, поскольку эти классы продолжают существовать и вести себя так, как они вели себя до введения класса AbstractAnimal. Код, конечно, придется перекомпилировать, но это не слишком большая цена за уверенность в том, что перекомпилированные присваивания будут вести себя интуитивно понятно, а те, которые ведут себя неправильно, не станут компилироваться. Чтобы все это работало, класс AbstractAnimal должен быть абстрактным - он должен содержать хотя бы одну абстрактную функцию. В большинстве случаев создание подходящей функции не является проблемой, но иногда возникает необходимость в классе типа AbstractAnimal, где, естественно, ни одна функция не может быть объявлена абстрактной. В этом случае обычно объявляется абстрактным деструктор, как и показано выше. Чтобы корректно поддержать полиморфизм при помощи указателей, базовые классы все равно должны иметь виртуальные деструкторы, поэтому единственные затраты на «абстрагирование» деструкторов состоят в неудобстве их реализации вне определений классов. (См., например, стр. 201.)
Если понятие о реализации абстрактной функции вас удивляет, вы просто недостаточно разбираетесь в данном вопросе. Объявление функции абстрактной не означает, что она не имеет реализации, это означает, что: ? текущий класс является абстрактным ? и любой наследующий от него реальный класс должен объявлять эту функцию как «обычную» виртуальную функцию (то есть без =0). Хотя большинство абстрактных функций никогда не реализуются, абстрактные деструкторы представляют собой особый случай. Они должны быть реализованы, поскольку всегда вызываются при вызове деструктора производного класса. Кроме того, они часто выполняют полезные действия, такие как высвобождение ресурсов (см. правило 9) или запись сообщений в лог-файл. Хотя реализация абстрактных функций в общем случае встречается довольно редко, для абстрактных деструкторов это не только обычно, но и обязательно. Возможно, вы заметили, что рассказ о присваивании при помощи указателей базового класса основан на предположении, что реальные базовые классы, такие как Animal, содержат элементы данных. Если элементов данных нет, то проблемы не существует, и будет безопаснее сделать реальный класс наследником другого реального класса без данных. Существуют два варианта дальнейшего существования класса без данных, который был бы реальным базовым классом: может ли он в будущем содержать элементы данных или нет. Если да, то можно просто отложить проблему до тех пор, пока в класс не будут добавлены элементы данных, в этом случае вы получаете сиюминутную выгоду за счет возможных неприятностей в будущем (см. также правило 32). С другой стороны, если базовый класс точно не будет никогда иметь элементов данных, то похоже, что он скорее должен быть абстрактным классом. Что толку от реального базового класса без данных? Замена такого реального базового класса, как Animal, на абстрактный базовый класс типа AbstractAnimal не просто облегчает понимание поведения operators Это также уменьшает вероятность того, что вы попытаетесь обращаться с массивами полиморфно; неприятные последствия такого подхода были рассмотрены в правиле 3. Но более важный выигрыш от использования этого метода обнаруживается на уровне разработки, так как замена реальных базовых классов абстрактными заставляет явно выделять полезные абстракции. То есть это заставляет вас создавать новые абстрактные классы на основе полезных понятий, даже если вы не осознаете их существование. Если имеются два реальных класса С1 и С2, и вы С1) fA) хотите, чтобы класс С2 открыто наследовал от класса 11 С1, вам нужно преобразовать иерархию, состоящую из двух классов, в иерархию с тремя классами, создав новый абстрактный класс А и сделав оба класса С1 С2^ Сс\} (ся) и с2 его 0ТКРЫТЫМИ наследниками (см. рис. 6.3). Данное изменение заставляет вас определить аб- Рис. 6.3 страктный базовый класс А, и это главное. Очевидно,
Правило 33 ВШШШШШШ что классы С1 и С2 имеют что-то общее, поэтому они и связаны открытым наследованием. Чтобы выполнить такое преобразование, вы должны определить, в чем эта общность заключается. Кроме того, вы должны формализовать это «что-то» в виде класса C++. В результате «что-то» становится не просто чем-то неопреде- деленным, а получает статус формальной абстракции, имеющей определенные функции-члены и определенную семантику. Но каждый класс представляет некоторый тип абстракции, поэтому не должны ли мы создавать по два класса для каждого понятия в иерархии, один абстрактный (воплощающий абстрактную часть абстракции) а второй - реальный (воплощающий часть абстракции, связанную с созданием объектов)? Нет, не должны. Если сделать это, полученная иерархия будет содержать слишком много классов. Такую иерархию сложно понимать, поддерживать и компилировать, что противоречит целям объектно-ориентированного программирования. Цель его состоит в том, чтобы определить полезные абстракции и вводить их - и только их - в абстрактные классы. Но как выделить полезные абстракции? Кто может знать, какие абстракции окажутся полезными в будущем? Кто может предсказать, кто будет наследовать и от чего? Я не знаю, как предсказать будущее использование иерархии наследования, однако уверен: необходимость абстракции в одной ситуации может быть случайной, но если абстракция требуется в нескольких случаях, это обычно является значимым. Таким образом, полезные абстракции - те абстракции, которые полезные в различных ситуациях. Они соответствуют классам, которые полезны как сами по себе (то есть нужны объекты этого типа), так и для того, чтобы создавать от них производные классы. Это в точности соответствует выгоде от преобразования реального базового класса в абстрактный: такое преобразование заставляет вводить новый абстрактный класс, только если существующий реальный класс будет применяться в качестве базового, то есть когда класс будет (повторно) использоваться в новой ситуации. Как было показано, такие абстракции полезны. Когда нужно просто ввести новое понятие, мы не можем оправдать создание одновременно и абстрактного класса (для самого понятия) и реального класса (для объектов, соответствующих этому понятию), но когда оно потребуется во второй раз, оправдание налицо. Описанное преобразование просто автоматизирует этот процесс и заставляет разработчиков и программистов явно представлять полезные абстракции, даже если они еще не точно представляют, какие понятия пригодятся в будущем. Также оказывается, что при этом гораздо проще обеспечить разумное поведение операторов присваивания. Проанализируем короткий пример. Допустим, вы разрабатываете приложение, которое занимается переносом данных между компьютерами в локальной сети, разбивая их на пакеты и передавая в соответствии с некоторым протоколом. (Здесь будет рассмотрен только класс или классы для представления пакетов.) Предположим далее, что имеется только один тип протокола передачи и только один тип пакетов. Возможно, вы слышали о существовании других протоколов
Разное и типов пакетов, но никогда их не поддерживали и не собираетесь делать этого в будущем. Должны ли вы создавать абстрактные классы пакетов (для представления понятия пакета) наряду с реальными классами для пакетов, которые точно будете использовать? Если вы сделаете это, то сможете затем добавлять новые типы пакетов, не изменяя соответствующий базовый класс. Это избавит вас от необходимости после добавления нового типа пакетов перекомпилировать все использующие пакеты приложения. Но такой подход требует создания двух классов, а сейчас вам нужен только один (для используемого типа пакетов). Стоит ли сейчас усложнять схему с тем, чтобы разрешить будущее развитие, которое, возможно, и не потребуется? В данном случае не существует однозначно правильного выбора, но практика показывает, что не получается создавать хорошие классы для понятий, которые мы недостаточно понимаем. Если вы создадите абстрактный класс для пакетов, насколько вероятно, что вы сделаете его таким, как нужно, в особенности, если ваш опыт ограничивается только одним типом пакетов? Помните, что вы получите выигрыш от использования абстрактного класса для пакетов, только если разработаете его так, чтобы будущие классы могли наследовать от него без изменения его самого. (Если потребуется изменение абстрактного класса, то вам придется перекомпилировать весь код, использующий пакеты, и вы ничего не выиграете.) Маловероятно, что вам удастся разработать удовлетворительный абстрактный класс пакетов, если вы не слишком хорошо разбираетесь в различных типах пакетов и ситуациях, когда они используются. В этом случае, учитывая ваш ограниченный опыт, я бы посоветовал вам не определять абстрактный класс для пакетов, добавив его позже, если вам потребуется выполнить наследование от реального класса пакета. Описанное преобразование является одним, но не единственным способом определить необходимость абстрактных классов. Существует много других методик выявить приемлемые кандидатуры для абстрактных классов (вы найдете их в книгах по объектно-ориентированному анализу). Абстрактные классы можно вводить не только для того, чтобы сделать реальный класс наследником другого реального класса. Но желание связать два реальных класса при помощи открытого наследования обычно указывает на необходимость создания нового абстрактного класса. Как это часто бывает, реальность в этом случае грубо вторгается в мирное теоретическое размышление. Библиотеки классов C++ от независимых производителей плодятся очень быстро, и что, если вы захотите создать реальный класс, наследующий от реального класса в библиотеке, к которой у вас есть доступ только на чтение? Вы не можете изменить библиотеку, добавив в нее новый абстрактный класс, поэтому ваш выбор ограничен и непривлекателен: ? сделать реальный класс производным от существующего реального класса и примириться с проблемами, присущими присваиванию, которые обсуждались
Правило 33 ' 11МННЕШ в начале этого раздела. Вы также должны остерегаться неприятностей, связанных с массивами (см. правило 3); ? попытаться найти абстрактный класс, который находится более высоко в иерархии библиотеки и делает большую часть того, что вам нужно, а затем выполнить наследование от этого класса. Конечно, подходящего класса может и не быть, и даже если такой класс существует, вам может потребовать ся повторить значительную часть работы, которую вы уже сделали при реализации реального класса, функциональность которого хотите расширить; а реализовать новый класс при помощи библиотечного класса, от которого вы бы хотели наследовать. Например, можно включить объект библиотечного класса в качестве элемента данных, а затем реализовать интерфейс библиотечного класса в вашем новом классе: class Window { // Это библиотечный класс, public: virtual void resize(int newWidth, int newHeight) ; virtual void repaint() const; int width() const; int height() const; }; class SpecialWindow { // Это класс, который вы хотите public: // сделать наследником класса ... // Window. // реализация невиртуальных функций intwidthO const { return w.widthf) ; } int height() const { return w.height(); } // реализация унаследованных // виртуальных функций virtual void resize(int newWidth, int newHeight) ; virtual void repaint () const; private: Window w; }; Эта стратегия требует обновлять ваш класс при каждом обновлении версии библиотечного класса, от которого он зависит. Вы также будете вынуждены отказаться от возможности переопределять объявленные в библиотечном классе виртуальные функции, так как нельзя переопределять не унаследованные виртуальные функции; ? примириться с тем, что есть. Использовать реальный класс из библиотеки и изменить программу так, чтобы этого класса было достаточно. Писать функции - не члены класса, обеспечивающие функциональность, которую выхотите, но не можете добавить к классу. Полученная в результате программа может быть не так эффективна, не так переносима и расширяема, как вам бы этого хотелось, но по крайней мере работоспособна.
Ни один из этих вариантов не является особенно привлекательным, поэтому вам придется выбирать наименьшее из зол. Это не слишком весело, но уж так устроен мир. Чтобы в будущем облегчить жизнь себе (и остальным), выражайте недовольство создателям библиотек, которые вы считаете плохо разработанными. В случае удачи (и большого числа жалоб пользователей), структура этих библиотек может со временем улучшиться. Каким бы ни был ваш выбор, основное правило остается в силе: нетерминальные классы должны быть абстрактными. Вы не всегда сможете следовать ему, работая с чужими библиотеками, но в коде, которым управляете вы сами, соблюдение этого правила принесет вам дивиденды в виде надежности, живучести, понятности и расширяемости ваших программ. Правило 34. Умейте использовать в одной программе С и C++ Проблемы, возникающие при создании программы, часть которой написана на C++, а часть на С, во многом совпадает с трудностями, с которыми вы сталкиваетесь, пытаясь «сшить» программу из объектных файлов, сгенерированных несколькими компиляторами С. Не существует способа объединить такие файлы, если различные компиляторы не согласуются по параметрам, зависящим от реализации, например размеру чисел типа int и double и механизму передачи параметров при вызове функции. Практические аспекты разработки программ с помощью нескольких компиляторов почти полностью игнорируются при стандартизации языка, поэтому единственный надежный способ проверить, можно ли объединять в одной программе объектные файлы, созданные при помощи компиляторов А и В, - получить от поставщиков А и В подтверждение, что их продукты дают совместимый выход. Это верно и для программ, созданных при помощи С и C++, поэтому перед тем, как попытаться сочетать С и C++ в одной программе, убедитесь, что ваши компиляторы С и C++ генерируют совместимые объектные файлы. После этого вам нужно будет рассмотреть еще четыре аспекта: коррекцию имен, инициализацию статических объектов, динамическое выделение памяти и совместимость структур данных. Коррекция имен Как вы, наверное, уже знаете, коррекция имен (name mangling) - это процесс, во время которого компиляторы C++ дают каждой функции в программе уникальное имя. В языке С такой процесс не нужен, поскольку нельзя перегружать имена функций, но почти любая программа C++ содержит несколько функций с одним и тем же именем. (Рассмотрим, например, библиотеку iostream, в которой объявляются несколько версий функций operator<< и operator».) Перегрузка несовместима со многими компоновщиками, так как они обычно скептически относятся к нескольким функциям с одним именем. Коррекция имен является уступкой компоновщикам; в частности их требованию, чтобы все имена функций были уникальными.
правило 34 $ш:штшт Пока вы работаете только с C++, коррекция имен вряд ли будет вас беспокоить. Если имеется функция drawLine, имя которой компилятор корректирует как xyzzy, то вы будете всегда использовать имя drawLine, и вам не будет дела до того, что в объектных файлах она называется xyzzy. Ситуация будет совсем другой, если функция drawLine находится в библиотеке С. В этом случае ваш исходный файл, вероятно, будет включать заголовочный файл, содержащий такое объявление: void drawLine (int xl, int yl, int x2, int y2) ; а код, как обычно, будет содержать вызовы функции drawLine. Каждый такой вызов будет транслироваться компилятором в вызов скорректированного имени функции, поэтому если записать следующее: drawLine(a, b, с, d) ; // Вызов нескорректированного // имени функции. то объектные файлы будут содержать соответствующий вызов функции: xyzzy(a, b, с, d) ; // Вызов скорректированного / / имени функции. Но если drawLine является функцией С, то объектный файл (или архив, или динамически подключаемая библиотека и т.д.) будет содержать скомпилированную версию функции drawLine с тем же именем drawLine, корректировка имени не будет выполняться. Когда вы попытаетесь скомпоновать объектные файлы вместе, то получите сообщение об ошибке, потому что компоновщик ищет функцию с именем xyzzy, а такой функции не существует. Чтобы решить проблему, вам нужен какой-то способ сообщить компилятору C++, что не нужно выполнять корректировку определенных имен функций. Это не нужно будет делать для функций, написанных на других языках, будь то С, ассемблер, Fortran, Lisp, Forth или какой-то другой язык. (Включая и Cobol, но что вам до этого?) В конце концов, если вы вызываете функцию С с именем drawLine, она на самом деле называется drawLine, и объектный код должен содержать ссылку именно на это имя, а не на его откорректированную версию. Используйте для подавления коррекции имен директиву C++ extern "С": // Объявить функцию drawLine; //не корректировать ее имя. extern "С" void drawLine (int xl, int yl, int x2 , int y2); He попадитесь в ловушку, предположив, что если есть extern "С", то должен быть и extern " Pascal" и extern "FORTRAN". Таких директив нет, по крайней мере в стандарте языка. Лучше всего рассматривать директиву extern "С" не как утверждение, что вызываемая функция написана на С, а как утверждение, что функция должна вызываться так, будто бы она была написана на С. (Технически, директива extern "С" означает, что функция должна компоноваться принятым
II разное в С образом, но как же именно, не слишком понятно. Тем не менее, это всегда значит, что корректировка имени функции должна подавляться.) Например, если вам пришлось написать функцию на ассемблере, вы также можете объявить ее при помощи директивы extern "С": // Эта функция написна на ассемблере - //не корректировать ее имя. extern "С" void twiddleBits (unsigned char bits) ; Можно даже объявлять с extern " С" функции C++. Это может быть полезно, если вы пишете библиотеку на C++ и хотите, чтобы она была доступна пользователям других языков программирования. Благодаря подавлению корректировки имен для функций C++ программисты могут использовать естественные и наглядные имена вместо тех, которые бы автоматически создал компилятор: // Следующая функция C++ предназначена для использования //в других языках, поэтому ее имя не должно корректироваться, extern "С" void simulate(int iterations); Для многих функций корректировка имени не должна выполняться, и было бы тяжело писать extern "С" перед каждой из них. К счастью, это не нужно. Можно применить директиву extern "С" ко всему набору функций, просто заключив его в фигурные скобки: extern "С" { // Запретить корректировку имени // для всех этих функций. int y2) ; void drawLine (int xl, intyl, int x2 , void twiddleBits (unsigned char bits) ; void simulate(int iterations); } Применение директивы extern "С" упрощает поддержку заголовочных файлов, которые должны использоваться и в С, и в C++. При компиляции в C++ нужно включать директиву extern " С", а при компиляции в С нет. Воспользовавшись тем, что символ препроцессора cplusplus определен только при компиляции в C++, можно придать многоязычным заголовочным файлам следующую структуру: #ifdef cplusplus extern "С" { #endif int y2); void drawLine(int xl, int yl, int x2, void twiddleBits (unsigned char bits) ; void simulate(int iterations); #ifdef cplusplus } #endif
Правило 34 ^ШЖШШШШ Между прочим, не существует «стандартного» алгоритма коррекции имен. Различные компиляторы могут выполнять коррекцию имен по-разному, и они так и делают. Это неплохо. Если бы все компиляторы выполняли коррекцию имени одинаковым образом, сложилось бы впечатление, будто они создают совместимый код. При существующем положении вещей, если попытаться смешать объектный код, полученный при помощи несовместимых компиляторов C++, весьма вероятно, что во время компоновки у вас на экране появится сообщение об ошибке, так как скорректированные имена функций не будут совпадать. Это будет означать, что, вероятно, кроме рассмотренной, есть еще и другие проблемы совместимости, и лучше обнаружить их как можно раньше. Инициализация статических объектов После того как вы овладели коррекцией имен, вам нужно справиться с тем, что в C++ большая часть кода может выполняться как до, так и после вызова функции main. В частности, конструкторы статических объектов, принадлежащих классам, и объектов в глобальном и других пространствах имен обычно вызываются до выполнения данной функции. Такой процесс называется инициализацией статических объектов (static initialization). Это прямо противоположно нашему обычному взгляду на программы С и C++, когда функция main рассматривается как точка входа в программу. Аналогично, деструкторы объектов, создаваемые в процессе инициализации статических объектов, должны вызываться во время уничтожения статических объектов (static destruction); этот процесс обычно происходит после того, как выполнение функции main уже завершено. Для разрешения этой дилеммы, когда с одной стороны предполагается, что функция main должна вызываться первой, а с другой стороны - что до выполнения функции main должны создаваться объекты, многие компиляторы вставляют в начало функции main специально сгенерированную компилятором функцию, которая и заботится об инициализации статических объектов. Аналогично, компиляторы часто вставляют в конце функции main вызов другой специальной функции, которая заботится об уничтожении статических объектов. Код, автоматически созданный для функции main, часто выглядит примерно так: int main(int argc, char *argv[]) { performStaticInitialization(); // Создается // компилятором, операторы, входящие в функцию main; performStaticDestruction(); // Создается // компилятором. ) Не воспринимайте это буквально. Функции performstaticlnitialization и performStaticDestruction обычно имеют более загадочные имена, а также могут быть встроенными, и в этом случае вы не увидите их в объектном файле. Важно следующее: при выборе компилятором C++ такого подхода к инициализации и уничтожению статических объектов они будут создаваться и уничтожаться, 10 - 679
EZIHHU Разное только если функция main написана на C++. Поскольку это общепринятый подход к инициализации и уничтожению статических объектов, то если какая-то часть системы написана на C++, вы должны постараться написать функцию main также на C++. Иногда кажется более осмысленным написать функцию main на С - скажем, когда большая часть программы написана на С, а на C++ только вспомогательная библиотека. Тем не менее, не исключено, что библиотека C++ содержит статические объекты (если не сейчас, то возможно в будущем - см. правило 32), поэтому все же лучше написать функцию main на C++. Это не означает, что вам нужно переписывать код С. Просто переименуйте написанную на С функцию main в realMain, а затем вызовите ее из функции main, написанной на C++. extern "С" // Напишите эту int realMain(int argc, char *argv[] ) ; // функцию на С. int main (int argc, char *argv[] ) //А эту на C++. { return realMain(argc, argv); } При этом неплохо поместить перед функцией main комментарий, объясняющий, что происходит. Если вы не можете написать функцию main на C++, наверняка столкнетесь с проблемой, поскольку нет другого переносимого способа убедиться в том, что будут вызываться конструкторы и деструкторы статических объектов. Это не означает, что все потеряно, но вам придется больше потрудиться. Производители компиляторов хорошо знакомы с данной проблемой, поэтому почти все из них предоставляют какой-либо межъязыковый механизм для запуска процесса инициализации и уничтожения статических объектов. За соответствующей информацией обратитесь к документации компилятора или свяжитесь с производителем. Динамическое выделение памяти Вопрос инициализации и уничтожения статических объектов подводит нас к проблеме динамического выделения памяти. Здесь действует общее простое правило: части программы, написанные на C++, используют операторы new и delete (см. правило 8), а части, написанные на С, используют функции malloc (и ее варианты) и free. Пока память, полученная при помощи new, освобождается посредством delete, а память, полученная при помощи malloc, освобождается функцией free, все будет в порядке. Однако вызов функции free для указателя, полученного при помощи new, приведет к неопределенному поведению, так же как и удаление оператором delete указателя, полученного при помощи функции malloc. Следовательно, нужно строго разделять использование new и delete от malloc и free. Иногда это легче сказать, чем выполнить. Рассмотрим скромную (но удобную) функцию strdup, которая, не являясь стандартной ни в С, ни в C++, применяется очень широко:
Правило 34 11НМНШ char * strdup(const char *ps); // Вернуть копию строки, //на которую указывает ps. Чтобы избежать утечек, память, выделенная внутри функции strdup, должна освобождаться в функции, которая вызывала strdup. Но как это сделать? При помощи оператора delete? Вызвав функцию free? Если вызывается функция strdup из библиотеки С, придется выбрать второй вариант. Если функция была написана для библиотеки C++, вероятно, первый. Следовательно, то, что нужно сделать для функции s t rdup, меняется не только от системы к системе, но и от компилятора к компилятору. Чтобы обеспечить переносимость, старайтесь избегать вызова функций, которые не входят в стандартную библиотеку (см. правило 35) или не доступны в устойчивой форме на большинстве компьютерных платформ. Совместимость структур данных Теперь пора поговорить о передаче данных между программами С и C++. Нельзя надеяться, что функции С будут понимать свойства функций C++, поэтому уровень общения между двумя языками должен быть ограничен понятиями, которые можно выразить на С. Не существует переносимого способа передавать процедурам, написанным на С, объекты или указатели на функции - члены класса. Но язык С понимает обычные указатели, поэтому если ваши компиляторы С и C++ дают совместимый выход, то функции в двух языках могут безопасно обмениваться указателями на объекты и указателями на статические функции или функции, не являющиеся членами класса. Как и следовало ожидать, структуры и переменные встроенных типов (например, int, char и т.д.) также могут свободно пересекать границу C/C++. Поскольку правила, обусловливающие формат struct в C++, согласуются с соответствующими правилами С, то можно с уверенностью предположить, что определение структуры, которое компилируется в обоих языках, будет расположено обоими компиляторами одинаковым образом. Такие структуры можно безопасно передавать из С в C++ и обратно. Если добавить невиртуальные функции в версию структуры, написанную на C++, то формат структуры в памяти не должен измениться, поэтому объекты структуры (или класса), содержащие только невиртуальные функции, будут совместимы со своими собратьями в С, в определении структуры которых отсутствует только объявление функций - членов класса. Но все это перестанет работать при введении виртуальных функций, поскольку добавление к классу виртуальных функций приводит к тому, что формат объектов этого типа в памяти становится другим (см. правило 24). Наследование структуры от другой структуры (или класса) также изменяет ее формат, поэтому структуры с базовыми структурами (или классами) являются не лучшей кандидатурой для обмена с функциями С. С точки зрения структуры данных все вышесказанное сводится к следующему: можно безопасно передавать структуры данных из C++ в С и обратно, если определение этих структур компилируется и в С, и в C++. Добавление невиртуальных ю*
Л ^^¦^¦¦¦¦i II It Разное функций-членов к C++ версии структуры, которая до этого была совместимой с С, вероятнее всего, не повлияет на совместимость, в отличие от почти всех других изменений. Резюме Если вы хотите сочетать С и C++ в одной программе, не забывайте о следующих простых принципах: а убедитесь, что компиляторы С и C++ создают совместимые объектные файлы; ? объявляйте функции, которые будут использоваться в обоих языках, с директивой extern "С"; ? если возможно, напишите функцию main на C++; ? всегда используйте delete для памяти, выделенной при помощи new; всегда используйте free для памяти, выделенной при помощи malloc; ? ограничьтесь передачей только структур данных, которые компилируются в С; версии структур в C++ могут содержать также невиртуальные функции-члены. Правило 35. Ознакомьтесь со стандартом языка С момента своей публикации в 1990 году книга The Annotated C++ Reference Manual (Справочное руководство по C++ с комментариями) являлась исчерпывающим справочником для профессиональных программистов, которым нужно было знать, что входит в язык C++, а что нет. За время, прошедшее после выхода ARM (часто употребляемое сокращение для справочника), комитет по стандартизации ISO/ANSI внес в язык довольно много изменений, как незначительных, так и крупных (в основном добавления). Поэтому сейчас справочник ARM уже не может считаться исчерпывающим. Внесенные в C++ изменения помогают писать хорошие программы. И тем, кто хочет быть настоящим профессионалом, необходимо знать различия текущего стандарта C++ и его версии, описанной в ARM. Производители программного обеспечения руководствуются стандартом ISO/ANSI для C++ при разработке компиляторов, авторы изучают его при написании книг, а программисты ищут в нем ответы на практические вопросы. Наиболее важные изменения в языке C++, введенные после выхода ARM, таковы: ? добавлены новые свойства: RTTI, пространства имен, тип bool, ключевые слова mutable и explicit, возможность перегружать операторы для перечислимых типов и инициализировать константные целые статические члены класса в определении класса; ? расширено применение шаблонов: разрешено создание шаблонов функций - членов класса, определен стандартный синтаксис, при помощи которого можно потребовать создания экземпляров шаблонов, в шаблонах функций теперь разрешены аргументы без типа, и шаблоны классов сами могут использоваться в качестве аргументов шаблонов;
правило 35 :-тшшшшш ? усовершенствована обработка исключений: спецификации исключений более строго проверяются во время компиляции, и функция unexpected может порождать объект bad_exception; ? изменены процедуры выделения памяти: добавлены функции operator new [ ] и operator delete [ ], операторы new/new [ ] теперь генерируют исключение, если память не может быть выделена, и введены альтернативные версии операторов new /new [ ], возвращающие 0 в случае неудачной попытки выделения памяти; Q добавлены новые формы приведения типов: static_cast, dynamic_cast, const_cast и reinterpret_cast; ? усовершенствованы правила языка: тип значения, возвращаемого переопределенными виртуальными функциями, не должен больше в точности совпадать с типом переопределяемой функции, а время жизни временных объектов определено точно. Почти все эти изменения описаны в книге The Design and Evolution of C++ (Разработка и эволюция C++). Современные учебники по C++ (написанные после 1994 года) также должны включать их. (Если вам попадется книга, в которой они не описаны, откажитесь от ее покупки). Книга «Наиболее эффективное использование C++» (которую вы держите в руках) содержит примеры практического применения большинства из этих новых свойств. Если вас интересует какое-либо из них, поищите соответствующую запись в указателе. Но изменения в языке C++ бледнеют по сравнению с тем, что произошло со стандартной библиотекой. Кроме того, эволюция стандартной библиотеки не была отражена так же широко, как эволюция языка. Например, книга The Design and Evolution of C++ почти не упоминает стандартную библиотеку. Большинство книг, описывающих библиотеку, безнадежно устарели, так как она была значительно изменена в 1994 году. Возможности стандартной библиотеки условно делятся на следующие основные категории: ? поддержка стандартной библиотеки С. C++ не забывает про свои корни. Небольшая доработка привела C++ версию библиотеки С в соответствие с более строгой проверкой типов в языке C++, но практически все, что вы знали и любили (или ненавидели) в библиотеках С, осталось и в C++; ? поддержка строк. Как было заявлено председателю рабочей группы стандартной библиотеки C++ Майку Вайлоту (Mike Vilot), «если не появится стандартный тип string, на улицах прольется кровь!». (Некоторые люди бывают столь эмоциональны.) Успокойтесь и отложите топоры и дубинки - стандартная библиотека C++ содержит строки; ? поддержка локализации. В разных странах используются разные наборы символов и соглашения при выводе даты и времени, сортировке строк, выводе значений денежных знаков и т.д. Поддержка локализации в стандартной библиотеке облегчает разработку программ, приспособленных к тради циям той или иной культуры;
ШМШШШШ"- Разное ? поддержка ввода/вывода. Библиотека iostream остается частью стандарта C++. Хотя некоторые классы и были исключены (в частности классы iostream и f stream), а некоторые заменены (например, stringstream, основанный на классе string, занял место устаревшего strstream, основанного на char*), главные возможности стандартных классов библиотеки iostream отражают уже существующие реализации; ? поддержка вычислительных приложений. Комплексные числа наконец помещены в стандартную библиотеку. Кроме того, библиотека теперь содержит специальные классы массивов (valarray), ограничивающие совместное использование. Эти массивы более подходят для эффективной оптимизации, чем встроенные массивы, особенно в многопроцессорных архитектурах. Библиотека также предоставляет несколько полезных численных функций; ? поддержка универсальных контейнеров и алгоритмов. В стандартной библиотеке C++ содержится набор классов и шаблонов функций, которые известны как стандартная библиотека шаблонов (the Standard Template Library - STL). Стандартная библиотека шаблонов является наиболее революционной частью библиотеки C++. Ее основные черты рассматриваются ниже. Но прежде чем описать STL, я должен рассказать о двух характерных чертах стандартной библиотеки C++, о которых вы должны знать. Во-первых, почти все в библиотеке является шаблоном. В книге я говорил о стандартном классе string, но фактически такого класса не существует. Вместо него есть шаблон класса с именем basic_string, представляющий последовательности символов, и этот шаблон принимает в качестве параметра тип символов, образующий такую последовательность. Это позволяет создавать строки из символов любых видов (символов двойной ширины, символов Unicode и т.д.). То, что мы обычно представляем себе в виде класса string, на самом деле является экземпляром шаблона basic_string<char>. Поскольку он часто используется, в стандартной библиотеке шаблонов определяется тип: typedef basic_string<char> string; Даже такая запись скрывает многие детали, поскольку шаблон basic_string имеет три аргумента, все из которых, кроме первого, имеют значения по умолчанию. Для того чтобы действительно понять код типа string, нужно увидеть полное, без пропусков, объявление basic_string: template<class charT, class traits = string_char_traits<charT>, class Allocator = allocator> > class basic_string,- Чтобы использовать тип string, вам не обязательно понимать этот бессмысленный набор слов: хотя тип string и является определением типа для экземпляра адского шаблона, он ведет себя так же, как скромный класс без шаблона, на который он похож благодаря определению typedef. Просто сохраните в дальнем уголке мозга запись о том, что если вам когда-либо понадобится изменить тип
тшштшт символов в строках, или тонко настроить параметры символов, или управлять выделением памяти для строк, то вы сможете сделать это при помощи шаблона basic_string. Подход, принятый при разработке типа string - обобщить его в шаблоне, - повторяется во всей стандартной библиотеке C++. Потоки ввода/вывода? Они также являются шаблонами; параметр типа определяет тип образующих потоки символов. Комплексные числа? Тоже шаблоны; параметр типа определяет, как должны записываться компоненты чисел. Массивы valarray? Шаблоны; параметр типа определяет, что находится в каждом из массивов. И, конечно же, стандартная библиотека почти полностью состоит из шаблонов. Если вы чувствуете себя не слишком свободно при работе с шаблонами, сейчас как раз подходящее время для того, чтобы их освоить. Нужно знать еще, что почти все в стандартной библиотеке находится в пространстве имен std. Чтобы использовать что-то из стандартной библиотеки, не задавая имя полностью, вам придется задействовать директиву using или (что предпочтительнее) объявление using. К счастью, эти синтаксические операции выполняются автоматически, когда вы включаете в код соответствующие заголовки с помощью директивы # include. Стандартная библиотека шаблонов Самые значительные изменения произошли в стандартной библиотеке шаблонов (Standard Template Library, сокращенно STL). (Так как почти все в библиотеке C++ является шаблонами, название STL не слишком удачно. Но поскольку для части библиотеки, содержащей контейнеры и алгоритмы, это название стало уже привычным, его все и используют, не обращая внимания на то, хорошо оно подходит или плохо.) Вероятно, STL повлияет на развитие большинства библиотек C++, поэтому важно, чтобы вы знали ее основы. Стандартная библиотека шаблонов базируется на трех фундаментальных понятиях: контейнеры, итераторы и алгоритмы. Контейнеры содержат наборы объектов. Итератор - это объекты, похожие на указатели и позволяющие перемещаться по контейнерам STL так же, как указатели позволяют перемещаться по встроенным массивам. Алгоритмы представляют собой функции, которые работают с контейнерами STL и используют итераторы. Проще всего разобраться в принципе действия STL, если вспомнить главное правило C++ (и С) для работы с массивами; указатель на массив может указывать на любой элемент массива или на элемент сразу за концом массива. Если указатель ссылается на элемент, расположенный сразу за концом массива, то его можно сравнивать только с другими указателями на массив; результат его разыменования не определен. Воспользовавшись этим правилом, можно написать функцию, находящую в массиве определенное значение. Для массива целых чисел она будет выглядеть, например, так: int * find(int *begin, int *end, int value) { while (begin != end && *begin != value) ++begin; Правило 35
ЗИЙНИШ Разное return begin; } Эта функция ищет элемент со значением value в диапазоне от begin до end (исключая end, который указывает1 на элемент сразу за концом массива) и возвращает указатель на первый встретившийся элемента массива со значением value; если такой элемент не будет найден, функция возвращает end. Возврат end кажется довольно странным способом сообщить о бесплодном поиске. Не лучше ли было бы возвращать 0 (нулевой указатель null)? Конечно, значение null более привычно в подобной ситуации, но это не делает его «лучше». Функция find должна возвращать некоторое особенное значение указателя, обозначающее неудачное завершение поиска, а для такой цели указатель end подходит ничуть не хуже, чем нулевой указатель. Кроме того, как вы вскоре увидите, указатель end лучше обобщает другие типы контейнеров, чем нулевой указатель. Возможно, вам никогда и не понадобится писать функцию find, но такой подход достаточно разумен и прекрасно обобщается. Если вы выполнили этот простой пример, значит, уже овладели большинством идей, на которых основана библиотека STL. Вы можете использовать функцию find так: int values[50]; int *firstFive = findfvalues, // Поиск в диапазоне values+50, // values[0]-values[49] 5) ; // значения 5 . if (firstFive != values+50) { // Был ли поиск успешным? } else { } // Да. // Нет, поиск был неудачным. Вы также можете с помощью функции find осуществлять поиск в поддиапазоне массива: int *firstFive = find(values, // Поиск в диапазоне values+10, // values[0]-values[9] 5); // значения 5 . int age = 3 6; int *firstValue = find(values+10, // Поиск в диапазоне values+20, // values[10]-values[19] age); // значения age. В функции find нет ничего, что бы ограничивало ее применимость к массивам целых чисел, поэтому по сути она является шаблоном: template<class T> Т * find(T *begin, T *end, const Т& value)
Правило 35 ШШШШМШШ { while (begin != end && *begin != value) ++begin; return begin; } Обратите внимание: при преобразовании в шаблон вместо передачи value по значению выполняется передача по ссылке на const. Это связано с тем, что теперь передаются произвольные типы, и поэтому приходится учитывать затраты на передачу по значению. Для каждого передаваемого по значению параметра приходится вызывать его конструктор и деструктор при всяком вызове функции. Чтобы избежать этих затрат, надо использовать передачу по ссылке, исключая создание и удаление объекта. Этот шаблон достаточно хорош, но его можно еще улучшить. Посмотрите на операции с begin и end. Из них используются только сравнение на равенство и неравенство, разыменование, префиксный инкремент (см. правило 6) и копирование (для возвращаемого значения функции - см. правило 19). Все эти операции можно перегрузить, поэтому зачем ограничивать функцию find работой с указателями? Почему не разрешить использование не только указателей, но и любых других объектов, поддерживающих эти операции? В результате вы освободите функцию find от встроенного значения операций с указателями. Например, можно будет определить похожий на указатель объект для связного списка, префиксный оператор инкремента которого перемещал бы вас на следующий элемент в списке. Эта концепция и лежит в основе итераторов STL. Итераторы - это, как уже говорилось, похожие на указатели объекты, предназначенные для использования с контейнерами STL. Они «близкие родственники» интеллектуальных указателей, описанных в правиле 28, но последние обычно более «амбициозны». С технической точки зрения и те, и другие реализуются при помощи одинаковых методов. Рассматривая итераторы как похожие на указатели объекты, можно заменить в функции find указатели на итераторы, переписав функцию find: template<class Iterator, class T> Iterator find(Iterator begin, Iterator end, const T& value) { while (begin != end && *begin != value) ++begin; return begin; } Поздравляю! Вы только что написали часть стандартной библиотеки шаблонов. Она содержит десятки алгоритмов, работающих с контейнерами и итераторами, и функция find является одним из них. Контейнеры в STL имеют типы bitset, vector, list, deque, queue, priority_queue, stack, set и map, и вы можете применять функцию find к любому из этих типов: list<char> charList; // Создать объект STL // для списка символов. // Найти положение первого символа "х" в charList.
ЕШННМ11 Разное list<char>::iterator it = find(charList.begin(), charList.end(), "x"); «Постойте!», - кричите вы, - «Это совсем не похоже на предыдущие примеры массивов!». Нет, они похожи; вы просто должны знать, в чем искать это сходство. Для вызова функции find для объекта list вам нужны итераторы, указывающие на первый элемент списка и на элементрасположенный сразу же за концом списка. Без помощи со стороны класса list это было бы нелегкой задачей, так как реализация list не известна. К счастью, класс list (как и все контейнеры STL) должен содержать функции-члены begin и end. Эти функции-члены возвращают нужные вам итераторы, которые и передаются в качестве двух первых параметров функции find в примере. Завершив свое выполнение, функция find возвращает объект-итератор, указывающий на найденный элемент (если он существует) или на charList. end () (если искомого элемента нет в списке). Поскольку вы ничего не знаете о реализации класса list, то вы ничего не знаете и о реализации итераторов в list. Как тогда вы сможете выяснить тип объекта, возвращаемого функцией find? И снова вам на помощь приходит класс list, который, как и все контейнеры STL, содержит определение типа iterator, соответствующее типу итераторов в 1 i s t. Так как объект charList является списком символов, то итераторы для такого списка имеют тип list<char>::iterator, и именно этот тип используется в вышеприведенном примере. (На самом деле в каждом классе-контейнере STL определяются два типа итераторов, iterator и const_iterator. Первый из них выступает в качестве обычного указателя, а последний - в качестве указателя на const.) Такой же подход может использоваться и для других контейнеров STL. Кроме того, указатели C++ также являются итераторами STL, поэтому исходные примеры работы с массивами будут работать с функцией find из STL: int values[50] ; int *firstFive = find(values, // Нормально, values+50, // вызывает функцию 5) ; // find из STL. Принцип организации стандартной библиотеки шаблонов очень прост. Она представляет собой набор шаблонов классов и функций, придерживающихся ряда соглашений. Набор классов STL содержит функции, такие как begin и end, которые возвращают объекты-итераторы определенных в классе типов. Функции алгоритмов STL перемещаются между наборами объектов, используя объекты итераторы для наборов STL. Итераторы STL работают как указатели. Вот в сущности, и все. Нет большой иерархии наследования, нет виртуальных функций и того подобного. Просто несколько шаблонов классов и функций и набор соглашений, которых они придерживаются.
Правило 35 ШИШМНЕШ Это приводит к новому откровению: стандартная библиотека шаблонов является расширяемой. Вы можете добавлять к семейству STL свои наборы, алгоритмы и итераторы. До тех пор пока вы следуете соглашениям STL, стандартные наборы STL будут работать с вашими алгоритмами, а ваши наборы со стандартными алгоритмами STL. Конечно, ваши шаблоны не станут частью стандартной библиотеки C++, но они будут построены на тех же принципах, что позволит использовать их повторно. Библиотека C++ содержит намного больше возможностей, чем описано в этой книге. Поэтому чтобы эффективно использовать ее, обратитесь к другим источникам информации (их список приведен в приложении 1), где вы найдете подробные сведения как о библиотеке,так и о соглашениях STL. Стандартная библиотека C++ намного богаче библиотеки С, и время, которое вы потратите на ознакомление с ней, будет потрачено не напрасно. Кроме того, воплощенные в библиотеке принципы разработки - универсальность, расширяемость, эффективность, возможность настройки и повторного использования - сами по себе стоят вашего внимания. Изучая стандартную библиотеку C++, вы не только пополните ваши знания о готовых компонентах, которые сможете использовать в своих программах, но и поймете, как более эффективно применять C++ и разрабатывать собственные библиотеки.
Приложение 1 Список рекомендуемой литературы Итак, ваш аппетит к информации о языке C++ остался неудовлетворенным. Утолить его помогут источники, описанные в этом приложении. Конечно, характеристики, которые я даю книгам и журналам, субъективны, но в любом случае помогут вам сориентироваться в море информации по C++. Книги Существуют сотни, а возможно, и тысячи книг по C++, и их число постоянно увеличивается. Я не видел всех этих книг, а читал и того меньше, поэтому далее следует список книг, к которым я сам обращался, когда у меня возникали вопросы по разработке программ на C++. Я уверен, что есть и другие хорошие книги, но я использую эти, и именно их могу искренне рекомендовать вам. Начать стоит с книг, описывающих сам язык. The Annotated C++ Reference Manual, Margaret A. Ellis and Bjarne Stroustrup (Маргарет А. Эллис и Бьерн Страуструп. Справочное руководство по C++ с комментариями), Addison-Wesley, 1990, ISBN 0-201-51459-1. The Design and Evolution of C++, Bjarne Stroustrup (Бьерн Страуструп. Разработка и эволюция C++), Addison-Wesley, 1994, ISBN 0-201-54330-3. Эти книги содержат не просто описание того, что находится в языке, они также приводят логическое обоснование выбора принятых решений, чего вы не найдете в официальных стандартах. Книга «Справочное руководство по C++ с комментариями» немного устарела (со времени ее публикации в язык были добавлены новые возможности - см. правило 35), но до сих пор остается наилучшим руководством по основным составляющим языка, включая шаблоны и исключения. Книга «Разработка и эволюция C++» охватывает многие темы, отсутствующие в книге «Справочное руководство по C++ с комментариями»; в ней не хватает только описания стандартной библиотеки шаблонов (см. снова правило 35). Эти книги являются не учебниками, а справочными руководствами, но если вы понимаете изложенный в них материал, вы поймете C++. В качестве более общего справочника по языку, стандартной библиотеке и их применению лучше всего использовать книгу, написанную основоположником C++: The C++ Programming Language (Third Edition), Bjarne Stroustrup (Бьерн Страуструп. Язык программирования C++ (Третье издание)). Addison-Wesley, 1997, ISBN 0-201-88954-4.
Список рекомендуемой литературы ШШНЕШ Страуструп был тесно связан с разработкой, реализацией, применением и стандартизацией языка с момента его возникновения, и он, возможно, знает о языке больше, чем кто-либо еще. Его описание свойств читать нелегко, но главным образом из-за того, что оно содержит так много информации. Главы о стандартной библиотеке C++ особенно полезны. Если вы готовы перейти от изучения самого языка к его эффективному при менению, можете обратиться к моей предыдущей книге: Effective C++, Second Edition: 50 Specific Ways to Improve Your Programs and Designs, Scott Meyers (Скотт Мейерс. Эффективное использование C++: 50 способов улучшить ваши программы и проекты. - ДМК, 2000.) Addison- Wesley, 1998, ISBN 0-201-92488-9. Эта книга аналогична той, которую вы сейчас читаете, но охватывает другие (возможно, более фундаментальные) вопросы. Примерно на том же уровне, как и мои книги об эффективном использовании C++, находится и книга C++ Strategies and Tactics, Robert Murray (Роберт Мюррей. Стратегия и тактика C++), Addison-Wesley, 1993, ISBN 0-201-56382-7, но она охватывает другие темы. Книга Мюррея особенно сильна в том, что касается основ разработки шаблонов. Она также содержит главу, посвященную важной теме миграции от С к C++. Большая часть моих рассуждений о подсчете ссылок (см. правило 29) основана на идеях из книги «Стратегия и тактика C++». Если вы относитесь к тем, кто предпочитает изучать программирование, читая код, то вам наверняка понравится книга C++ Programming Style, Tom Cargill (Том Каргилл. Стиль программирования на C++), Addison-Wesley, 1992, ISBN 0-201-56365-7. Все главы этой книги открываются кодом C++. Затем Каргилл начинает анатомирование - нет, скорее вивисекцию - каждой из программ, определяя места возможных проблем, неудачный подход к разработке, неудачную реализацию и просто явные ошибки. Постепенно переписывая каждый пример, устраняя слабые места, он в результате создает более устойчивый, легкий в поддержке, эффективный и переносимый код, который решает исходную задачу. Всем программистам на C++ стоило бы обратить внимание на уроки из этой книги, но особенно важна она для тех, кто занимается проверкой кода. В книге Каргилла не рассматриваются исключения. Этой теме посвящена его статья Exception Handling: A False Sense of Security (Обработка исключений: ложное чувство безопасности) C++ Report, Volume 6, Number 9, November-December 1994, pages 21-24. Если вы не можете достать старые выпуски журнала C++ Report, посетите Web-сайт издательства Addison-Wesley, где она размещена по адресу http:// www.awl.com/cp/mec++.html. Если вы предпочитаете использовать FTP, то можете найти статью в каталоге ср/тес++ сайта ftp.awl.com.)
ЕШЭ^НННИИ Приложение 1 Тем, кто уже овладел основами C++ и готов двигаться дальше, рекомендую познакомиться с книгой Advanced C++: Programming Styles and Idioms, James Coplien (Джеймс Копль- ен. Современный C++. Стили программирования и диалекты языка), Addison- Wesley, 1992, ISBN 0-201-54855-0. Я называю эту книгу «психоделической», так как она имеет фиолетовую обложку и расширяет сознание. Копльен рассматривает и простые вопросы, но в основном его интересует, как сделать на C++ вещи, которые кажутся невозможными. Вы хотите создавать объекты на основе других объектов? Обходить строгую проверку типов? Добавить данные и функции к классам, которые выполняют ваши программы? Копльен объяснит вам, как это сделать. В большинстве случаев вам лучше избегать описываемых им методов, но иногда только они позволяют решить сложную проблему, с которой вы столкнулись. Эта книга может испугать вас или ослепить, но если вы ее прочтете, уже никогда не будете смотреть на C++ по-старому. Если вы имеете отношение к разработке и реализации библиотек C++, с вашей стороны было бы безрассудно не обратить внимания на книгу Designing and Coding Reusable C++, Martin D. Carroll and Margaret A. Ellis (Мартин Д. Кэрролл и Маргарет А. Эллис. Разработка и создание повторно используемого кода на C++), Addison-Wesley, 1995, ISBN 0-201-51284-Х. Кэрролл и Эллис анализируют множество практических аспектов разработки и реализации библиотек, которые просто игнорируются остальными. Хорошая библиотека должна быть компактной, быстрой, расширяемой, легко обновляться, элегантной при создании экземпляров шаблонов, мощной и устойчивой. Невозможно выполнять оптимизацию по всем этим критериям, поэтому нужно выбирать компромиссные варианты, улучшающие одни стороны библиотеки за счет других. Книга «Разработка и создание повторно используемого кода на C++» рассматривает эти компромиссы и предлагает практические советы по их выполнению. Независимо от того, для каких приложений вы пишете свои программы, вы обязательно должны взглянуть на книгу Scientific and Engineering C++, John J. Barton and Lee R. Nackman (Джон Дж. Бартон и Ли К. Нэкман. C++ для ученых и инженеров), Addison-Wesley, 1994, ISBN 0-201-53393-6. Первая часть книги рассказывает о C++ программистам на Fortran (незавидная участь в настоящее время), но вторая часть описывает методы, которые пригодятся всем. Исчерпывающий материал по шаблонам почти революционен; возможно, это самое подробное из существующих исследований, и я полагаю, что когда вы увидите чудеса, которые можно делать при помощи шаблонов, вы не будете больше думать о них как просто об улучшенных макрокомандах. И наконец, возникающая в разработке объектно-ориентированного программного обеспечения дисциплина паттернов описана в книге Design Patterns: Elements of Reusable Object-Oriented Software, Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides (Эрих Гамма, Ричард Хелм,
Список рекомендуемой литературы ВШШШШЕШ Ральф Джонсон и Джон Влиссид. Паттерны разработки: элементы повторно используемого объектно-ориентированного программного обеспечения), Addison-Wesley, 1995, ISBN 0-201-63361-2. Эта книга дает обзор идей, на которых основаны паттерны, но главное в ней - каталог из 23 важнейших паттернов, полезных во многих прикладных областях. Путешествуя по этим страницам, вы почти обязательно обнаружите паттерн, кото рый вам пришлось бы изобретать самостоятельно, и поймете, что его дизайн в книге превосходит подход, специально разработанный вами для такого случая. Имена этих паттернов уже стали частью создающегося словаря объект но-ориентированного программирования; их незнание в скором времени может поставить под угрозу ваше взаимодействие с коллегами. Основное преимущество данной книги заключается в том, что в ней делается акцент на разработку и реализацию программ, способных элегантно приспосабливаться к будущей эволюции (см. правила 32 и 33). Книга доступна также на компакт-диске: Design Patterns CD: Elements of Reusable Object-Oriented Software, Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides, Addison-Wesley, 1998, ISBN 0-201-63498-8. Журналы Для настоящих программистов на C++ существует только один журнал: C++ Report, SIGS Publications, New York, NY. Некоторое время назад руководством журнала было принято сознательное решение отойти от рассмотрения только C++. Большее внимание теперь уделяется вопросам программирования на уровне доменов и систем, что ценно само по себе, но и материал по C++, хоть подчас и не столь глубокий, продолжает оставаться лучшим из возможных. Если вы чувствуете себя более уверенно в С, чем в C++, или находите материал из журнала C++ Report слишком экстремистским, чтобы его можно было использовать, вероятно, вам больше придутся по вкусу статьи из журнала C/C++ Users Journal, Miller Freeman, Inc., Lawrence, KS. Как и следует из его названия, журнал публикует материалы как о С, так и о C++. Статьи по C++ рассчитаны на менее подготовленную аудиторию, чем статьи в C++ Report. Кроме того, редакция строго контролирует авторов, поэтому материал в журнале меньше отклоняется от основного направления. Это помогает отсеивать идеи фанатиков языка, но в то же время ограничивает проявление действительно авангардных методов. Группы новостей USENET Языку C++ посвящены три группы новостей USENET. Группа comp.lang.c++ является универсальной, и в нее сваливается все подряд. Диапазон сообщений простирается от подробных объяснений сложных методов программирования до проповедей и бреда тех, кто любит и ненавидит C++, и студентов со всего мира,
ШШННММН Приложение 1 которые просят помочь им сделать домашнее задание, откладываемое до тех пор, пока не стало слишком поздно. Объем сообщений в этой группе очень велик. Если вы не располагаете часами свободного времени, вам потребуется фильтр, чтобы отделить зерна от плевел, причем хороший фильтр, так как мусора в конференции много. В ноябре 1995 года была создана модерируемая версия группы comp.lang.c++. Эта группа новостей, которая называется comp.lang.c++.moderated. также предназначена для общего обсуждения C++ и связанных с ним вопросов, но модераторы стремятся удалять вопросы, относящиеся к конкретной реализации; вопросы, ответы на которые содержатся в списке FAQ (Frequently Asked Questions - часто задаваемые вопросы); «флейм» и другие вещи, которые мало интересуют большинство практикующих программистов на C++. Группа новостей comp.std.c++ имеет более узкий фокус и посвящена обсуждению самого стандарта C++. Здесь много знатоков языка, но сюда имеет смысл обратиться, если ваши сложные вопросы о C++ остаются без ответа в других доступных справочниках. Эта группа также является модерируемой, поэтому отношение сигнал/шум в ней достаточно высоко; здесь вы не встретите просьбы помочь с домашним заданием.
Приложение 2 Реализация шаблона auto_§»tr В правилах 9, 10, 26, 31 и 32 упоминается исключительная полезность шаблона auto_ptr. К сожалению, мало компиляторов содержат его «правильную» реализацию.* В правилах 9 и 28 описано в общих чертах, как можно создать этот шаблон самостоятельно, но, начиная работу над реальными проектами, лучше иметь не просто набросок. Ниже представлены два варианта реализации шаблона auto_ptr. Первый из них документирует интерфейс класса и реализует все функции-члены вне определения класса. Второй реализует каждую функцию-член внутри определения класса. Стилистически вторая реализация уступает первой, так как в ней нельзя отделить интерфейс класса от его реализации. Но на основе шаблона auto_ptr образуются простые классы, и из второй версии это намного очевиднее, чем из первой. Вот шаблон auto_ptr, интерфейс которого документирован: template<class T> class auto_ptr { public: explicit auto_ptr(T *p = 0); // См. описание "explicit" //в правиле 5. template<class U> // Шаблон конструктора auto_ptr(auto_ptr<U>& rhs); // копирования (см. правило 28) : // инициализация нового // auto_ptr при помощи // любого совместимого // auto_ptr. ~auto_ptr(); template<class U> // Шаблон оператора auto_ptr<T>& // присваивания (см. правило 28) : operator=(auto_ptr<U>& rhs); // присвоить от любого // совместимого auto_ptr. Т& operator*() const; // См. правило 28. Это связано в основном с тем, что спецификация шаблона auto_ptr менялась в течение ряда лет. Последняя спецификация была принята только в ноябре 1997 года. Вы можете найти подробную информацию о шаблоне auto_pt г на WWW- и FTP -сайтах этой книги (см. стр. 21 -22). Обратите внимание, что в описанной здесь реализации auto_ptr опущены некоторые детали официальной версии, например то, что auto_ptr находится в пространстве имен std (см. правило 35) и что его функции-члены не должны генерировать исключений.
IHIII Приложение 2 T* operator->() const; T* get () const; T* released ; //См. правило 28. // Вернуть значение текущего // обычного указателя. // Отказаться от обладания // текущим обычным // указателем и вернуть // его значение. // Удалить указатель, // считая, что обладаем р. // Сделать все классы // auto_ptr дружественными. кР> void reset (T *р = 0) ; private: Т *pointee; template<class U> friend class auto_ptr<U>; }; template<class T> inline auto_ptr<T>::auto_ptr(T : pointee(p) {} template<class T> inline auto_ptr<T>::auto_ptr(auto_ptr<U>& rhs) : pointee(rhs.release()) {} template<class T> inline auto_ptr<T>::~auto_ptr () { delete pointee; } template<class T> template<class U> inline auto_ptr<T>& auto_ptr<T>: :operator=(auto_ptr<U>& rhs) { } if (this !=&rhs) reset(rhs.release ()); return *this; template<class T> inline T& auto_ptr<T>::operator*() const { return *pointee; } template<class T> inline T* auto_ptr<T>::operator->() const { return pointee; } template<class T> inline T* auto_ptr<T>::get() const { return pointee; } template<class T> inline T* auto_ptr<T>::release () { T *oldPointee = pointee; pointee = 0; return oldPointee; } template<class T> inline void auto_ptr<T>: : reset (T *p)
Реализация шаблона auto_ptr 81MIHHIED { if (pointee != p) { delete pointee; pointee = p; } } А вот вариант auto_ptr, в котором все функции реализованы в определении класса: template<class T> class auto_ptr { public: explicit auto_ptr(T *p = 0) : pointee(p) {} template<class U> auto_ptr(auto_ptr<U>& rhs): pointee(rhs.release()) {} ~auto_ptr() { delete pointee; } template<class U> auto_ptr<T>& operator=(auto_ptr<U>& rhs) { if (this !=&rhs) reset(rhs.release()); return *this; } T& operator*() const { return *pointee; } T* operator->() const { return pointee; } T* get() const { return pointee; } T* release() { T *oldPointee = pointee; pointee = 0; return oldPointee; } void reset (T *p = 0) { if (pointee != p) { delete pointee; pointee = p; } } private: T *pointee; template<class U> friend class auto_ptr<U>; }; Если ваши компиляторы еще не поддерживают директиву explicit, вы можете определить ее при помощи директивы #def ine: #define explicit Это нисколько не уменьшит функциональности шаблона auto_ptr, но сделает его чуть менее безопасным (подробнее об этом сказано в правиле 5).
ШШМИ1Ш Приложение 2 Если ваши компиляторы не поддерживают шаблоны функций-членов, вы можете использовать не входящие в шаблон auto_ptr конструктор копирования и оператор присваивания, описанные в правиле 29. Из-за этого шаблон auto_ptr станет менее удобным для применения, но, увы, не существует способа приблизиться к поведению шаблонов функций-членов. Если шаблоны функций-членов (или другие возможности языка) важны для вас, сообщите об этом производителям программного обеспечения. Чем больше покупателей будут требовать введения новых возможностей языка, тем скорее они будут реализованы.
Алфавитный указатель Базовый класс абстрактный 162 виртуальный 130 смешанный 162 Библиотека iostream 122 stdio 122 Буферизованный оператор new 34 В Виртуальная функция эмуляция при помощи if-then-else 234 Владелец объекта 170 Вычисление отложенное 98 энергичное 98 д Диспетчеризация двойная 233 множественная 233 одиночная 233 И Идентификация типов времени выполнения программы 132 Инициализация статических объектов 273 Исключение bad_alloc 82 bad cast 82 bad_exception 82 bad_typeid 82 Unexpected Exception 88 Итератор 281 К Класс auto_ptr 61 exception 89 proxy 45, 221 заместитель 221 Ключевое слово explicit 42 mutable 102 Компоновка внешняя 144 внутренняя 144 Конструктор виртуальный 136 копирования, виртуальный 137 по умолчанию 33 с единственным аргументом 39 Копирование, детальное 206 Коррекция имен 270 л Логические выражения, оптимизированная оценка 48 Локальная взаимосвязанность 108 М Метод копирования при записи 197
Е2ШНКЖ Наиболее О Объект proxy 221 typejnfo 132 неименованный 111 Оператор -- 46 &&, модификация 49 ++ 46 += 119 -= 119 [] 25, 196 ||, модификация 48 const_cast 26 dynamic_cast 27 new 51 new буферизованный 52 operator, 50 reinterpret_cast 28 sizeof 51 static_cast 26 typeid 132 запятая 49 неявного преобразования типа 39 приведения типов 26 Оптимизация возвращаемого значения 116 Оценка, отложенная 223 П Паттерны 286 Подсчет ссылок 190 Полиморфизм 30 Правило«80-20» 95 Приведение типа const_cast 17 dynamic_cast 17 reinterpret_cast 17 static_cast 17 Присваивание смешанное 261 частичное 260 эффективное использование C++ Проблема потери данных 83, 174 Пространство имен 142 неименованное 247 Псевдоуказатель 102 Р Разыменование 167 С Сборка мусора 190 Семантика вызовов функций 49 оптимизирующая 49 Ссылка 23 Статические объекты инициализация 273 уничтожение 273 Счетчик ссылок 191 Т Таблица виртуальная 124 Тип bool 16 string 17 true 16 У Указатель 23 smart 60 интеллектуальный 60, 167 на виртуальную таблицу 124 Уничтожение статических объектов 273 Ф Функция operator bool 177 operator delete 54 operator delete[] 56 operator new 51 operator new[] 55 operator* 173
Алфавитный указатель 1ШВШШШШШ operator+ 119 operator++ 46 operator- 119 operator-> 173 operator« 123 operator[] 196 unexpected 85 Ш Шаблон auto_ptr 170 map 240 vector 36 функции-члена 183 auto_ptr 61 В bad_alloc 82 bad_cast 82 bad_exception 82 bad_typeid 82 bool 16 const_cast 17 Copy on write 197 D Deep copy 167 Default constructor 33 Dereferencing 167 Double-dispatching 233 Dumb pointer 167 dynamic_cast 17, 27 Eager evaluation 98 exception 89 explicit 42 Garbage collection 190 Internal linkage 144 iostream 122 Lazy evaluation 98 M Member function templates 183 Mixin base class 162 mutable 102 N Name mangling 270 Namespace 142 new 51 operator, 50 operator« 40 operator bool 177 operator delete 54 operator delete[] 35 operator new 51 operator&&, модификация 49 operator* 173 operator-l- 119 operator++ 46 operator- 119 operator-> 173 operator« 123 operator]] 196 operator|[, модификация 49 Pointer 23 Polymorphically 30
EESHiHfillli Наиболее эффективное использование C++ Proxy class 45, 221 Proxy objects 221 R Reference 23 Reference count 191 reinterpret_cast 17, 28 RTTI 132, 234 Runtime type identification 132 S sizeof 51 Smart pointer 167 Static initialization 273 static_cast 17, 26 stdio 122 STL 17 string 17 Surrogate 221 true 16 U unexpected 85 Unexpected Exception 88 V vector 36 Virtual table 124 pointer 124
Скотт Мейерс Наиболее эффективное использование C++ 35 новых рекомендаций по улучшению ваших программ и проектов Главный редактор Захаров И. М. Перевод Павлов Р. В. Научный редактор Шилов М. В., Разоренов А. А. Литературный редактор Виноградова Н. В. Технический редактор Прока С. В. Верстка Тарасов С. А. Графика Бахарев А. А. Дизайн обложки Антонов А. И. ИД № 01903 от 30.05.2000 Подписано в печать 22.09.2000. Формат 70х100'/16. Гарнитура «Петербург». Печать офсетная. Усл. печ. л. 19. Тираж 3000 экз. Зак. № 679 Издательство «ДМК Пресс», 105023, Москва, пл. Журавлева, д. 2/8. Отпечатано в полном соответствии с качеством предоставленных диапозитивов в ППП «Типография «Наука» 121099, Москва, Шубинский пер., д. 6
tfObjifei; Hi*- ' Vt'deJ-di'Sn «Я искрение рекомендую книгу "Наиболее } эффективное использование C++" всем, кто .стремится овладеть C++ на среднем или бо- ' лее высоком уровне». -Эксперт C/C++ User's Journal эффективное использование C++ 35 HOibu рОКОМОИДОЦМЙ по улучшению мили программ и проектов Скотт Мейерс Автор книги «Наиболее эффективное спользование C++» предлагает 35 новых пособов улучшения ваших программ. Ос- овываясь на своем многолетнем опыте, Мейерс объясняет, как писать наиболее ффективные программы: надежные, совместимые, переносимые и пригодные для Ш повторного использования, то есть про- 1 граммы, безупречные во всех отношениях. Настоящая книга описывает приемы, которые позволяют значительно повысить производительность программ, выбрав оптимальное соотношение затрат времени/памяти на различные операции. Здесь вы найдете примеры обработки исключений и анализ их влияния на структуру и поведение классов и функций C++, а также варианты практического применения новых возможностей языка, таких как тип bool, ключевые слова mutable и explicit, пространства имен, шаблоны функций- членов, стандартная библиотека шаблонов и многое другое. ISBN 5-94074-033-2 www.dmk.ru <(J 9 78594040339