Текст
                    

Яшавант Канеткар 3-е издание Санкт-Петербург «БХВ-Петербург» 2023
УДК 004.43 ББК 32.973.26-018.1 К19 Канеткар Я. К19 С++ — это просто. — 3-е изд.: Пер. с англ. — СПб.: БХВ-Петербург, 2023. — 336 с.: ил. ISBN 978-5-9775-1194-0 Доступно изложены основы синтаксиса языка C++ и объектно-ориентированного программирования. Описаны функции С++, подробно рассмотрены классы и объекты, инкапсуляция, наследование, полиморфизм, система ввода-вывода С++. Даны советы и рекомендации по разработке современных приложений. Приведены примеры использования шаблонов и библиотеки стандартных шаблонов, описана обработка исключений. Отдельная глава посвящена расширенным возможностям C++. Для начинающих программистов УДК 004.43 ББК 32.973.26-018.1 Группа подготовки издания: Руководитель проекта Зав. редакцией Перевод с английского Редактор Компьютерная верстка Оформление обложки Павел Шалин Людмила Гауль Сергея Гусева Анна Ардашева Ольги Сергиенко Зои Канторович Copyright 2021 BPB Publications, India. All rights reserved. First published in the English language under the title Let Us C++. Third Edition, ISBN 9789388176644 by BPB Publications India (sales@bpbonline.com) Russian translation rights arranged with BPB Publications, India © 2021 BPB Publications, Индия. Все права защищены. Впервые опубликовано на английском языке под названием Let Us C++. Third Edition, ISBN 9789388176644 издательством BPB Publications India (sales@bpbonline.com) Права на перевод на русский язык предоставлены издательством BPB Publications, Индия. "БХВ-Петербург", 191036, Санкт-Петербург, Гончарная ул., 20 ISBN 978-9-388-17664-4 (англ.) ISBN 978-5-9775-1194-0 (рус.) © BPB Publications, India, 2021 © Перевод на русский язык, оформление. ООО "БХВ-Петербург", ООО "БХВ", 2023
Оглавление Об авторе ......................................................................................................................... 13 Благодарности ................................................................................................................ 15 Предисловие к третьему изданию .............................................................................. 17 Предисловие к первому изданию ............................................................................... 19 Глава 1. Введение в ООП ............................................................................................. 21 Истоки............................................................................................................................................. 23 Структурное программирование ..................................................................................................24 Объектно-ориентированное программирование ........................................................................ 26 Характеристики объектно-ориентированных языков ................................................................ 28 Объекты .................................................................................................................................. 28 Классы .................................................................................................................................... 29 Инкапсуляция ......................................................................................................................... 29 Скрытие данных..................................................................................................................... 29 Наследование ......................................................................................................................... 30 Полиморфизм ......................................................................................................................... 31 Отношения включения ..........................................................................................................31 Шаблоны................................................................................................................................. 32 Обработка исключений ......................................................................................................... 32 Многократное использование............................................................................................... 32 Упражнения.................................................................................................................................... 33 Важное ............................................................................................................................................ 35 Глава 2. Переходим на C++ ......................................................................................... 37 Комментарии .................................................................................................................................. 39 Ввод и вывод в C++ ....................................................................................................................... 40 Динамическое объявление переменных ...................................................................................... 42 Динамическая инициализация ...................................................................................................... 43 Вывод типов ................................................................................................................................... 43 Синтаксис структуры (struct), объединения (union) и перечисления (enum) ........................... 43 Неименованные объединения и перечисления ........................................................................... 44 Приведение типов .......................................................................................................................... 45 Пустой указатель (void) ................................................................................................................. 46 Оператор ::...................................................................................................................................... 46 Ссылки ............................................................................................................................................ 47 Типы обращений к функциям....................................................................................................... 48
6 Оглавление Возвращение значения по ссылке ................................................................................................51 Спецификатор const ....................................................................................................................... 52 Const-указатели ...................................................................................................................... 53 Const-ссылки .......................................................................................................................... 54 Возврат значений const-переменных ................................................................................... 57 Функции-члены типа const.................................................................................................... 57 Логический тип данных (bool) ..................................................................................................... 58 Упражнения.................................................................................................................................... 59 Важное ............................................................................................................................................ 65 Глава 3. Функции .......................................................................................................... 69 Строгая проверка типов ................................................................................................................ 71 Исходные значения для аргументов функции ............................................................................ 72 Перегрузка функции ...................................................................................................................... 73 Разница в типе возвращаемого значения ............................................................................. 74 Можно ли задать разные типы данных при помощи typedef?............................................ 75 Можно ли задать разные типы данных при помощи const?............................................... 75 Разные задачи, одно имя .......................................................................................................76 Перегрузка операторов.................................................................................................................. 76 FAQ по перегрузке операторов ............................................................................................ 79 Встраиваемые функции................................................................................................................. 79 Зачем полагаться на компилятор? ........................................................................................ 80 А где гарантия? ...................................................................................................................... 80 Когда ими пользоваться? ......................................................................................................81 Новый синтаксис возвращаемого типа ........................................................................................ 81 Функции instance, static, virtual и friend ...................................................................................... 81 Упражнения.................................................................................................................................... 82 Важное ............................................................................................................................................ 85 Глава 4. Классы и объекты ......................................................................................... 87 Структуры и классы ...................................................................................................................... 89 Классы и конструкторы ................................................................................................................ 92 Деструкторы ................................................................................................................................... 94 Класс Complex ................................................................................................................................ 95 Указатель this ................................................................................................................................. 97 Перегрузка унарных операторов .................................................................................................. 98 Объекты и память ........................................................................................................................ 100 Еще раз о структурах и классах ................................................................................................. 101 Идеальная организация программы ........................................................................................... 102 Упражнения.................................................................................................................................. 106 Важное .......................................................................................................................................... 112 Глава 5. Премудрости классов .................................................................................. 115 Статическое и динамическое выделение памяти ...................................................................... 117 Выделение памяти для массивов и структур .................................................................... 118 Выделение памяти для объектов ........................................................................................ 120 Статические члены класса .......................................................................................................... 122 Универсальный спецификатор const .......................................................................................... 124 Перегруженный оператор присваивания и конструктор копирования................................... 126
Оглавление 7 Преобразование данных .............................................................................................................. 129 Преобразование между встроенными типами .................................................................. 129 Преобразование между встроенными и пользовательскими типами.............................. 130 Преобразование между различными пользовательскими типами данных ..................... 132 Процедура преобразования в исходном объекте .............................................................. 132 Процедура преобразования в целевом объекте ................................................................. 134 Упражнения.................................................................................................................................. 137 Важное .......................................................................................................................................... 139 Глава 6. Наследование................................................................................................ 141 Наследование ............................................................................................................................... 143 Еще один пример наследования .................................................................................................147 Варианты применения наследования......................................................................................... 149 Наследование и конструкторы ...................................................................................................151 Виды наследования ..................................................................................................................... 154 Одиночное наследование .................................................................................................... 154 Многоуровневое наследование .......................................................................................... 155 Множественное наследование ............................................................................................ 156 Предупреждение .......................................................................................................................... 157 Поэтапная разработка.................................................................................................................. 158 Упражнения.................................................................................................................................. 158 Важное .......................................................................................................................................... 160 Глава 7. Полиморфизм ............................................................................................... 163 Виртуальная функция ................................................................................................................. 165 Чистая виртуальная функция...................................................................................................... 167 Абстрактный класс ...................................................................................................................... 168 Связывание функций ................................................................................................................... 168 Анатомия виртуальных функций ............................................................................................... 170 Для чего применять виртуальные функции?............................................................................. 177 Срезание объекта ......................................................................................................................... 177 Виртуальные деструкторы .......................................................................................................... 179 Вызов виртуальных функций из конструкторов/деструкторов ....................................... 181 Виртуальные базовые классы ..................................................................................................... 181 Упражнения.................................................................................................................................. 183 Важное .......................................................................................................................................... 184 Глава 8. Система ввода-вывода в C++ .................................................................... 187 Требования к системе ввода-вывода .......................................................................................... 189 Решение с использованием потоков в C++ ............................................................................... 190 Предопределенные потоковые объекты .................................................................................... 191 Библиотека iostream .................................................................................................................... 191 Класс istream ................................................................................................................................ 192 Класс ostream ............................................................................................................................... 194 Вывод символов в кодировке Unicode ............................................................................... 195 Класс iostream ..............................................................................................................................196 Манипуляторы потока................................................................................................................. 196 Пользовательские манипуляторы .............................................................................................. 199 Пользовательские манипуляторы с аргументами ............................................................. 200 Работа с потоками ввода-вывода в файл ................................................................................... 202
8 Оглавление Символьный ввод-вывод............................................................................................................. 203 Открытие файла ................................................................................................................... 203 Чтение данных ..................................................................................................................... 204 Обнаружение конца файла (EOF)....................................................................................... 204 Закрытие файла .................................................................................................................... 204 Программа копирования файлов ................................................................................................ 204 Ввод-вывод строк ........................................................................................................................ 205 Ввод-вывод записей .................................................................................................................... 206 Прямой доступ ............................................................................................................................. 208 Режимы открытия файла ............................................................................................................. 210 Строковые потоки ....................................................................................................................... 211 Работа с istrstream ................................................................................................................ 212 Ввод-вывод объектов .................................................................................................................. 213 Сериализация ............................................................................................................................... 214 Обработка ошибок ввода-вывода ............................................................................................... 215 Взаимодействие с файловой системой ...................................................................................... 217 Упражнения.................................................................................................................................. 220 Важное .......................................................................................................................................... 222 Глава 9. Расширенные возможности C++ ............................................................... 225 Отношения включения ................................................................................................................ 227 Дружественные (friend) функции и классы ............................................................................... 229 Еще одно применение дружественной функции ...................................................................... 231 Предупреждение .......................................................................................................................... 234 Ключевое слово explicit ............................................................................................................... 234 Ключевое слово mutable.............................................................................................................. 236 Пространство имен ...................................................................................................................... 237 Способы применения пространства имен ................................................................................. 240 Использование оператора разрешения контекста ............................................................. 240 Ключевое слово using .......................................................................................................... 241 Динамическая идентификация типа (RTTI) .............................................................................. 242 Приведение типов в C++ ............................................................................................................. 244 static_cast ..............................................................................................................................245 dynamic_cast ......................................................................................................................... 246 const_cast ..............................................................................................................................248 reinterpret_cast ..................................................................................................................... 248 Предупреждение .................................................................................................................. 249 Указатели на члены классов ....................................................................................................... 249 Упражнения.................................................................................................................................. 253 Важное .......................................................................................................................................... 254 Глава 10. Шаблоны ..................................................................................................... 257 Шаблоны функций ...................................................................................................................... 259 Что происходит во время компиляции?............................................................................. 261 Шаблоны функций для пользовательских типов...................................................................... 261 Еще одна шаблонная функция ...................................................................................................262 Явная специализация обобщенной функции..................................................................... 264 Функция с набором обобщенных типов .................................................................................... 264 Шаблоны и макросы .................................................................................................................... 265 Сортировка на основе шаблона ..................................................................................................266
Оглавление 9 Шаблоны классов ........................................................................................................................ 267 Шаблон класса связного списка .................................................................................................271 Полезные советы по шаблонам ..................................................................................................273 Вариативные шаблоны ................................................................................................................ 275 Области применения шаблонов ................................................................................................. 276 Упражнения.................................................................................................................................. 276 Важное .......................................................................................................................................... 278 Глава 11. Обработка исключений ............................................................................ 279 Обработка исключений в C++ ....................................................................................................281 Работа с библиотечными классами исключений ...................................................................... 284 Библиотечные исключения при создании очереди........................................................... 286 Еще один пример ................................................................................................................. 288 Работа с пользовательскими классами исключений ................................................................ 290 Полезные советы ......................................................................................................................... 292 Спецификация исключений ........................................................................................................ 293 Необработанные исключения .....................................................................................................294 Интеллектуальные указатели и динамические контейнеры .................................................... 295 Упражнения.................................................................................................................................. 297 Важное .......................................................................................................................................... 298 Глава 12. Стандартная библиотека шаблонов ...................................................... 301 Стандартная библиотека шаблонов ........................................................................................... 303 Компоненты STL ......................................................................................................................... 304 Контейнеры .......................................................................................................................... 304 Итераторы............................................................................................................................. 305 Алгоритмы............................................................................................................................ 306 Вектор (vector) ............................................................................................................................. 307 Другие операции .................................................................................................................. 308 Вектор объектов класса Point ..................................................................................................... 309 Список (list) .................................................................................................................................. 311 Множество (set) и мультимножество (multi-set) ....................................................................... 313 Отображение (map) и мультиотображение (multi-map) ........................................................... 317 Стек (stack) ................................................................................................................................... 319 Очередь (queue)............................................................................................................................320 Объект-функция ................................................................................................................... 322 Упражнения.................................................................................................................................. 323 Важное .......................................................................................................................................... 324 Предметный указатель ............................................................................................... 327
10 Оглавление
Посвящается Налини и Прабхакар Канеткар...

Об авторе За последние два с половиной десятилетия книги Яшаванта Канеткара и его учебные видеокурсы Quest Video Courseware по языкам C и C++, структурам данных, VC++, платформе .NET, встраиваемым системам и др. внесли весомый вклад в развитие и подготовку многих первоклассных специалистов в сфере ИТ как в Индии, так и за ее пределами. Книги Яшаванта обрели всемирное признание, став подспорьем для миллионов программистов. Они переведены на многие языки и опубликованы в Индии, США, Японии, Сингапуре, Корее и Китае. Будучи весьма востребованным лектором в области ИТ-технологий, Яшавант проводил семинары и мастер-классы в TedEx, IIT, REC и других транснациональных компаниях-разработчиках ПО. За свои предпринимательские, профессиональные и научные достижения Яшавант удостоен престижной награды «Выдающийся выпускник» Индийского технологического института Канпур. Эта награда была вручена 50 лучшим выпускникам вуза, внесшим значительный вклад в развитие своей профессиональной сферы и улучшение общества за последние 50 лет. В знак признания его огромного вклада в ИТ-образование в Индии корпорация Microsoft удостаивала его наград «Лучший технический вклад в .NET» и «Самый ценный профессионал» в течение 5 лет подряд. Яшавант имеет степень бакалавра технических наук Технологического института Веермата Джиджабай и степень магистра технических наук ИИТ Канпур. В настоящее время Яшавант является директором компаний KICIT Pvt. Ltd. и KSET Pvt. Ltd.

Благодарности Упоение творчеством! Наверное, именно поэтому меня так привлекает написание книг. Я верю, что на каком-то этапе создания книга начинает жить своей жизнью. Как только это происходит, автору остается лишь пасть на колени, целовать землю и, воздев руки к небу, молить о дожде. Дальше книга способна расти сама, и особой заслуги писателя тут нет. Многие люди помогали мне в создании этого трудоемкого книжного проекта. Именно их и следует благодарить за все достоинства нашего совместного продукта, я же с готовностью приму все критические отзывы. Шакил Али помогал в решении двух задач: вместе с ним мы исправляли все шероховатости и неясности в тексте. Кроме того, он, как искусный повар, неустанно мял, кромсал и препарировал исходный код примеров, пока не удовлетворился полученным результатом. Нандита Хингве прорешала все упражнения в книге и позаботилась о том, чтобы контрольные вопросы были понятны читателю. Все рисунки и таблицы тоже подготовила она. Я признателен студентам, посещавшим мои лекции и семинары по C++ и помогавшим мне глубже постичь многие концепции этого языка. Именно от них я узнал, что если вы открыты для новых идей, то круг ваших друзей может стать куда шире радиуса ваших ежедневных прогулок. За годы я стал несколько привередлив в выборе обложки. Художник Винай Индория настолько искусен в своем неподражаемом мастерстве, что создает подходящий вариант с первой попытки. Огромное ему спасибо за еще один выдающийся шедевр. И наконец, та единственная, что непоколебимо поддерживала меня во всех писательских начинаниях, включая эту книгу, — моя жена Сима. Она помогла мне гораздо больше, чем можно выразить словами.

Предисловие к третьему изданию Многое изменилось за те десять лет, что прошло с момента первого издания этой книги. В те времена C++ был еще новичком в своей отрасли. Сейчас это высокоразвитый, мощный и популярный инструмент программирования. В новом издании я попытался отразить эти перемены. Для этого я проверил все программы из корпуса примеров, работают ли они с популярными сегодня компиляторами, такими как Visual Studio и GCC, внес необходимые изменения, и, наконец, представляю их на ваш суд. Учебный материал также был изменен с учетом модификаций, внесенных в программный код. Многие программисты переходят на C++ с C. Пожалуй, самая распространенная среди них ошибка заключается в том, что по большей мере они так и продолжают программировать на C, но уже при помощи компилятора C++. И мне хотелось бы изменить эту тенденцию, поскольку, если этого не сделать, вы никогда не овладеете теми возможностями, что дарит вам C++. С этой целью самую первую главу я посвятил тому, как следует воспринимать любую проблему с точки зрения C++. Очень рассчитываю на то, что вы оцените такой подход и не преминете им воспользоваться. В ходе бесед со слушателями учебных курсов, семинаров и практикумов меня нередко спрашивают: «Как освежить в памяти все аспекты программирования на C++ перед важным выступлением или собеседованием?». Я ответил на этот вопрос, введя раздел под названием «Важное» в заключение каждой главы. Надеюсь, что эта идея придется вам по душе. В настоящем издании я значительно переработал ряд глав, в частности, главы о наследовании, полиморфизме, шаблонах и обработке исключений. Поскольку большинство профессиональных программ на C++ используют стандартную библиотеку шаблонов для работы с наборами данных, я посвятил ей отдельную главу. C++ претерпел множество редакций от C++98 до C++11, C++14 и C++17. В настоящий момент большинство компиляторов C++ полностью реализовали функции C++14, чего не скажешь про версию С++17. По этой причине я протестировал все программы этой книги на компиляторах C++14. В целом, я бы сказал, что данное учебное пособие по C++ создано для того, чтобы помочь вам в освоении этого замечательного языка при помощи новейших средств разработки и с надлежащей точки зрения. Желаю вам всего наилучшего и надеюсь, что книга вас заинтересует.

Предисловие к первому изданию Завершить работу над этой книгой оказалось едва ли не самым трудным делом моей жизни. Мне потребовалось почти два года, чтобы придать ей сегодняшний вид. Так, листая свой черновик, я всякий раз принимался переписывать текст заново. На то было две причины: во-первых, С++ абстрактен, во-вторых, сложен. Как и большинство программистов на С++, я когда-то начинал с C. И потому я чувствовал, что не справлюсь со своей задачей до тех пор, пока не смогу сопоставить любое новшество С++ с теми средствами, какими оно было реализовано в C, и продемонстрировать читателям преимущества использования С++. Литература о С++ изобилует высокопарными жаргонизмами. Одна из задач этой книги состояла в том, чтобы свести подобного рода риторику к минимуму, сосредоточившись на основных концепциях языка. В большинстве случаев я пытался показать, как все это работает, и, что еще важнее, почему это работает именно так. Попросите меня охарактеризовать эту книгу одним словом, и я отвечу — простота. Будь то программный код или текст, я стремился сделать его настолько простым, насколько это возможно. Что касается листингов, то я хотел снабдить своих читателей простыми и наглядными примерами, которые легко редактировать, компилировать и запускать. В этих программах нет ничего лишнего. Моя цель заключалась не в том, чтобы продемонстрировать, насколько я хороший программист, а в том, чтобы проиллюстрировать конкретные концепции программирования. Вы также заметите, что почти все примеры в этой книге представляют собой исполняемые программы. Я обнаружил, что законченная программа, пусть и небольшая, позволяет освоить предмет куда лучше, чем отдельные фрагменты исходного кода. По моему опыту, несложные упражнения исключительно полезны для закрепления пройденного материала. Подборку таких практических задач вы найдете в конце каждой главы. Однако прежде всего я мечтал написать книгу для программиста, покоряющего новый и сложный язык программирования. В эти страницы я вложил все свои знания и опыт. И потому верю, что вы сочтете ее полезной. Всего вам наилучшего и счастливого программирования! Яшавант Канеткар
20 Глава 1
Введение в ООП Объектно-ориентированные языки программирования, такие как С++ и Java, считаются оптимальным выбором при создании комплексных приложений для современного цифрового мира. Прежде чем мы углубимся в тонкости языка С++, важно понять, почему программисты предпочитают ООП и С++
22 Глава 1  Истоки...  Структурное программирование  Объектно-ориентированное программирование  Характеристики объектно-ориентированных языков • Объекты • Классы • Инкапсуляция • Скрытие данных • Наследование • Полиморфизм • Отношения включения • Шаблоны • Обработка исключений • Многократное использование  Упражнения  Важное
Введение в ООП Ч 23 то такое объектно-ориентированное программирование (ООП)? На этот вопрос несложно ответить, поскольку разработчики программного обеспечения обожают сыпать терминами и модными выражениями. Еще не так давно слова «искусственный интеллект», «WAP» и «Java» звучали не иначе как пропуск в рай небесный. Похоже, что фразой «объектно-ориентированный» злоупотребляют не меньше. Коль скоро было доказано, что объектно-ориентированные методы позволяют создавать более качественные программы, каждый стремится наклеить ярлык «объектно-ориентированный» на свои программные продукты. Следовательно, нам важно понять, что такое ООП, для чего оно нужно, в чем его преимущество перед традиционными языками программирования, такими как C, Pascal и Basic, и каковы основополагающие принципы ООП. Эти вопросы рассматриваются в настоящей главе, посвященной обзору характерных особенностей, которые мы подробно обсудим далее в книге. Здесь же мы ограничимся общим и кратким экскурсом. Не переживайте, если вам не удастся усвоить весь материал этой главы с первого раза; понимание комплексной структуры ООП требует времени. Мы еще раз вернемся к этим характерным особенностям объектно-ориентированного программирования в последующих главах. Цель языка программирования — представить решение некой проблемы при помощи алгоритма (пошаговой процедуры). Удачность решения напрямую зависит от того, каким образом оно моделирует (представляет) эту проблему. Для моделирования решений проблем на протяжении многих лет развивались различные подходы. Основными среди них являются модель структурного программирования (также называемая моделью процедурного программирования) и модель объектноориентированного программирования. В последние годы модель объектно-ориентированного программирования вытесняет модель структурного программирования. Для понимания этих моделей нам необходимо начать с изучения истории моделей программирования. Истоки... Программы для самых ранних компьютеров писались в двоичном коде. Для загрузки таких программ применялись механические коммутаторы. С появлением устройств хранения данных и более дешевой памяти большей емкости возникли и первые высокоуровневые языки программирования. С их появлением у программистов отпала необходимость мыслить в категориях битов и байтов; теперь они могли написать ряд инструкций на языке, напоминающем английский, которые компилятор мог затем транслировать в двоичный компьютерный код. Эти языки были просты по своему устройству и легки в использовании, поскольку в то время программы в основном были связаны с относительно несложными задачами, такими как математические вычисления и расчеты. В результате программы были короткими и в среднем насчитывали несколько сотен строк инструкций. По мере того как мощность и потенциал компьютеров увеличивались, росла и способность обрабатывать более сложные программы. Однако вскоре обнаружилось,
24 Глава 1 что ранние языки плохо справляются с комплексными задачами программирования. У этих языков выявился ряд существенных недостатков:  Отсутствовали средства повторного использования существующего программ- ного кода. Там, где требовался идентичный фрагмент кода, он просто дублировался.  Для управления потоком инструкций использовался ненадежный оператор goto, превращавший программу в запутанный клубок переходов от одного фрагмента кода к другому, зачастую в ущерб управляющей логике.  Все переменные в программах были глобальными. Отслеживание случайных изменений глобальных данных в длинном запутанном коде было весьма утомительным делом.  Написание, понимание работы и сопровождение больших программ оборачива- лись для программистов сущим адом. Иными словами, этот ранний метод разработки программ можно смело окрестить неструктурным программированием. Структурное программирование Для преодоления вышеупомянутых ограничений начались разработки новых языков с новыми функциями, способствующими созданию более сложных приложений. Развитие в конце 1960-х — начале 1970-х годов структурного программирования стало настоящим прорывом. Длинные программы, с которыми программисту было трудно разобраться, теперь можно было разбить на более мелкие блоки из нескольких сотен инструкций. В этих языках появились специальные подпрограммы — функции и процедуры, позволяющие упростить программный код для лучшего понимания его создателями. Теперь программа была разбита на функции, каждая из которых выполняла четко определенную задачу. Ниже показано, как структурное программирование справилось с недостатками неструктурного программирования.  Повторное использование программного кода. В случаях, когда требуется ис- полнить идентичный фрагмент кода в разных частях программы, используется функция, содержащая этот код. В результате нет необходимости многократно копировать один и тот же фрагмент.  Злоупотребление оператором goto — необходимость в его применении была сведена к минимуму благодаря мощным управляющим инструкциям, способным управлять программным потоком в простой для понимания форме.  Случайные изменения в глобальных переменных — с введением функций по- требность в глобальных переменных также была сведена к минимуму.  Сложность программ — комплексной структурой программного кода стало лег- че управлять, поскольку структурное программирование позволяло лучше организовать программу.
25 Введение в ООП Структурно-организованная программа строится путем разбиения решения на более мелкие части (такой метод называется «разделяй и властвуй), которые затем становятся функциями в рамках этой программы. Каждая функция может иметь свои локальные переменные и свою логику. Выполнение начинается с одной функции, а затем, напрямую или косвенно, из этой функции вызываются все остальные. Такая структура продемонстрирована на рис. 1.1. Рис. 1.1. Структурное программирование В структурном программировании потребность в глобальных переменных меньше, поскольку здесь они заменены локальными переменными с меньшей и более контролируемой областью действия. Данные передаются между функциями посредством аргументов, а функции могут иметь свои локальные переменные, к которым нельзя получить доступ за пределами области действия функции. Изолируя процессы внутри функций, структурно-организованная программа сводит к минимуму вероятность того, что одна функция повлияет на другую. Это также упрощает обнаружение ошибок, если таковые имеются. Структурное программирование помогает создавать более прозрачный код и контролировать работу каждой функции. Всё это делает разработку и сопровождение кода проще и эффективнее. С возникновением структурного программирования появилось новое понятие — абстракция. Абстракция позволяет программисту рассматривать задачу в обобщенном представлении, не вникая во внутренние детали. В структурно-организованной программе достаточно знать, какую задачу выполняет та или иная функция. До тех пор, пока функция надежно справляется с задачей, программисту не важно, каким образом эта задача выполняется. Это называется функциональной абстракцией и является краеугольным камнем структурного программирования. Структурное программирование господствовало в мире программного обеспечения почти два десятилетия — с 1970 по 1990 год. С постоянным улучшением оборудования и растущим спросом пользователей на многофункциональные программы
26 Глава 1 сложность программ возросла в несколько раз, и именно тогда стали обнаруживаться слабые места структурного программирования:  Основные компоненты структурного программирования — функции и структу- ры данных — не позволяли моделировать проблемы реального мира естественным, привычным образом.  Механизмы повторного использования программного кода оставались ограни- ченными.  Сопровождение, отладка и обновление больших программ по-прежнему были нелегкой задачей. То, каким образом преодолевались эти ограничения, мы обсудим в следующем разделе. Объектно-ориентированное программирование В нашем мире задачи и их решения не представляют собой набор отделенных друг от друга значений и процедур. Те, кому приходится решать эти задачи, не воспринимают мир таким образом. Они работают в своих предметных областях, сосредотачиваясь на объектах и позволяя характеристикам этих объектов определять применяемые к ним процедуры. Чтобы построить дом, вырастить помидор или отремонтировать двигатель, мы сначала думает о самом объекте, его назначении и поведении. Затем мы выбираем инструменты и методы. Решение соответствует задаче. Объект можно обозначить с помощью двух основных компонентов: присутствующих у объекта свойств и характерного для объекта поведения. Таким образом, мир вокруг нас «объектно-ориентирован», и методология объектноориентированного программирования позволяет строить компьютерные программы способами, моделирующими восприятие людьми реальной действительности. Поскольку программисты тоже люди, то вполне естественно, что их подход к работе над задачами реального мира отражает их ви́дение этого мира. Объектно-ориентированная методика зиждется на фундаменте, заложенном концепциями структурного программирования и абстрагированием данных. Абстрагирование данных позволяет делать с данными то, что функциональная абстракция позволяет делать с операциями. Благодаря абстрагированию данных можно применять структуры данных, не заботясь о точных деталях реализации. Например, в языках программирования абстрагируются числа с плавающей точкой. Вам вовсе не обязательно знать, как число с плавающей точкой представлено в двоичном формате, когда вы присваиваете ему значение. Аналогичным образом вас не беспокоит, как происходит двоичное умножение при умножении чисел с плавающей точкой. Абстракция для чисел с плавающей точкой уже давно существует в языках программирования. Однако языки, позволяющие определять собственные абстрактные типы данных, были разработаны лишь недавно. Принципиальное новшество в ООП заключается в том, что программа разрабатывается для обработки данных, а не для совершения самих операций. Это и неудиви-
27 Введение в ООП тельно, раз уж мы понимаем, что самой целью программы является доступ к данным или их использование. Основная задача объектно-ориентированного языка состоит в том, чтобы объединить в единое целое как данные, так и функции, работающие с данными. Такая единица называется объектом. Функции объекта, называемые функциями-членами (компонентными функциями) или методами в С++, обычно предоставляют единственно возможный способ доступа к его данным. Если вы хотите получить доступ к элементу данных в объекте, вы вызываете функцию-член в объекте. Она считает элемент и вернет вам его значение. Вы не можете получить доступ к данным напрямую. Если вам необходимо изменить данные в объекте, вы точно знаете, какие функции взаимодействуют с ним — компонентные функции в данном объекте. Никакие другие функции не могут получить доступ к этим данным. Это упрощает написание, отладку и поддержку программы. Как правило, программа на языке С++ состоит из ряда объектов, которые взаимодействуют, вызывая функции-члены друг друга. Организация программы на С показана на рис. 1.2. Рис. 1.2. Объектно-ориентированное программирование Когда вы подходите к проблеме программирования в рамках объектно-ориентированного подхода, вы уже не задаетесь вопросом, как разделить задачу на функции, а думаете над тем, как разделить ее на объекты. Мышление в терминах объектов, а не функций, удивительно способствует облегчению работы по созданию программ. Это происходит в силу значительного сходства объектов в мире программирования с объектами реальной действительности. Близость объектов программирования объектам реальной действительности — плод удачного сочетания данных и функций. Полученные в результате объекты знаменовали подлинную революцию в разработке компьютерных программ. Ни
28 Глава 1 в одном процедурном языке не существует такого близкого соответствия между конструкциями программирования и моделируемыми понятиями. ООП — это больше, нежели просто объединение данных и функций в единое целое. Ниже приведены некоторые из новых концепций, введенных в ООП:  Инкапсуляция.  Скрытие данных.  Наследование.  Отношения включения.  Полиморфизм.  Шаблоны.  Обработка исключений.  Многократное использование. И пусть этот список новых понятий вас не пугает. Их значение кратко объяснено в следующем разделе. А в последующих главах мы подробно поговорим о каждом из них. Характеристики объектно-ориентированных языков Объектно-ориентированное программирование использует словарь, незнакомый программисту на процедурных языках. Давайте кратко рассмотрим эти термины, сфокусировавшись на основных элементах объектно-ориентированных языков. Объекты В структурном программировании задача решается путем ее разделения на функции, тогда как в объектно-ориентированном программировании задача разбивается на объекты. Мышление в терминах объектов, а не функций, упрощает разработку программы. Ниже приведен ряд кандидатов на роль объектов в соответствующих контекстах:  Сотрудники компании в системе расчета заработной платы.  Структуры данных, такие как связанные списки, стеки, очереди и т. п.  Элементы графического пользовательского интерфейса, такие как окна, меню, иконки и т. п.  Аппаратные устройства, такие как CD-или DVD-Rom, клавиатура, принтер и т. п.  Различные элементы в компьютерных играх, такие как пушки, орудия, животные и т. п.  Покупатели и продавцы в системе отслеживания продаж.  Компьютеры в сетевой модели.
Введение в ООП 29 Классы Большинство языков предлагают элементарные типы данных, такие как int (целочисленный тип), long (длинный целый тип) и float (число с плавающей точкой). Их представление данных и реакция на арифметическое присваивание, операторы присваивания и сравнения определяются как часть языка. Однако не вся информация об объектах реального мира может быть представлена с помощью этих ограниченных встроенных типов данных. Программисту часто приходится создавать свои собственные типы данных, выделяя их в отдельный класс. К примеру, можно задать пользовательский тип данных для представления дат. Компилятору и компьютеру ничего не известно о датах. Программисты должны сами определить поведение дат, разработав класс Date. Этот класс описывает формат даты и операции, которые можно с ней выполнять. Подобно тому как можно декларировать множество переменных элементарного типа int, мы можем задать множество объектов класса Date. Класс служит чертежом, черновым планом или шаблоном. Он указывает, какие данные и какие функции будут включены в объекты этого типа. Определение класса не создает никаких объектов, так же как и существование типа int само по себе не создает никаких переменных. Инкапсуляция Инкапсуляцией называется размещение значений данных и функций внутри объекта. Предположим, что мы определяем пользовательский тип (класс) с именем Box и создаем объекты b1 и b2 этого пользовательского типа. Затем эти объекты могут инкапсулировать элементы данных, такие как length (длина), breadth (ширина) и height (высота) объекта типа Box, а также компонентные функции calcVolume( ) (Вычислить объем), calcSurfaceArea( ) (Вычислить площадь поверхности) и displayDimensions( ) (Показать габариты). Другим примером могут послужить объекты пользовательского класса с именем Patient. Объекты этого класса могут инкапсулировать такие данные, как patientId (идентификатор пациента), name (имя), age (возраст), height (рост), weight (вес), а также такие функции, как RecordData( ) (Сохранить данные), displayData( ) (Показать данные) и т. п. Скрытие данных С++ располагает механизмами, при помощи которых можно разрешать или запрещать доступ к членам данных и компонентным функциям в объекте. Как правило, данные недоступны извне объекта. Доступ к ним возможен лишь через функциичлены, присутствующие в самом объекте. Процедуру, запрещающую доступ к данным извне объекта, часто называют скрытием данных. Вы спросите, что это — запретительный подход, т. е. «запрещено всё, что не разрешено»? Какая польза от членов данных, к которым нельзя получить доступ или изменить их извне объекта? Всё не так страшно. На самом деле, если потребуется
30 Глава 1 получить доступ или изменить данные, вы всегда сможете сделать это регламентированным способом, через функции-члены объекта, что позволяет исключить непреднамеренный доступ и манипуляции с данными. Наследование ООП позволяет создавать собственные типы данных (пользовательские классы), подобные тем, что изначально встроены в язык. Однако, в отличие от встроенных типов данных, пользовательские классы могут использовать в качестве своей основы другие классы. Благодаря наследованию новые классы могут создаваться поверх старых. Новый класс, называемый производным (дочерним) классом, может наследовать данные и функции исходного или базового (родительского) класса. Новый класс может добавлять собственные элементы данных и функции дополнительно к тем, которые он наследует от своего родительского класса. Например, мы можем создать набор классов, описывающих библиотеку публикаций. Существуют два основных вида публикаций — периодика и книги. Мы можем создать общий класс Publication (Публикация), определив элементы данных для имени издателя, количества страниц и инвентарного номера. Публикации можно найти по поиску, сохранить и прочесть. Это можно сделать посредством функций класса Publication. Затем мы можем задать два класса с именами Periodical (Периодическое издание) и Book (Книга). Оба этих класса будут производными от родительского класса Publication. И это интуитивно понятно, поскольку периодическое издание и книга имеют общие свойства: название издательства, количество страниц и инвентарный номер. Кроме того, периодическое издание также имеет номер тома, номер выпуска и содержит статьи, написанные разными авторами. Элементы данных для этих свойств Рис. 1.3. Пример наследования
Введение в ООП 31 должны быть включены в определение класса Periodical. Классу Periodical также понадобится функция subscribe (Подписаться). Элементы данных для класса Book будут включать имя автора, тип обложки (твердая или мягкая) и номер ISBN. У этого класса также будет функция с именем subscribe. Как видите, классы Book и Periodical разделяют ряд характеристик с классом Publication, имея при этом собственные уникальные атрибуты. Эта схема отображена на рис. 1.3. Полиморфизм Развивая наш пример с классами Publication, Periodical и Book, давайте разберем еще одно ключевое понятие. Наш родительский класс Publication определяет методы для хранения и извлечения данных. Периодическое издание может храниться в папке для журналов, а книга обычно ставится на полку. Кроме того, процедура поиска определенного периодического издания отличается от поиска книги. Периодика ищется по указателю периодической литературы, а книги — по картотечному каталогу. Основываясь на этом, мы можем разработать функцию «поиск по периодической литературе» для периодики и функцию «поиск по картотечному каталогу» для книг. Для решения подобных задач ООП располагает элегантным средством, именуемым полиморфизм. В нашем примере метод поиска периодического издания отличается от метода поиска книги, хотя конечный результат одинаков. Полиморфизм позволяет нам задать функцию для поиска публикации, которую можно применить как для периодики, так и для книг. При поиске периодики задействуется функция выборки, специфичная для периодического издания, а когда запрашивается книга, подключается функция поиска по книжному каталогу. В итоге функция с одним именем может использоваться для одной и той же операции, выполняемой над связанными производными классами, даже если реализация этой функции варьируется от класса к классу. Понятие полиморфизма (одного явления, принимающего различные формы) может быть распространено даже на операторы, как мы увидим в следующих главах. Отношения включения В обычном супермаркете каждый товар может быть представлен как класс. Товары, в свою очередь, относятся к разным категориям, таким как косметика, продукты питания, прохладительные напитки, одежда, книги, электробытовая техника и т. п. Взаимодействие категорий и классов может быть выражено посредством отношений включения. Например, такие объекты, как крем для лица, очищающий гель, шампунь, могут быть включены в объект категории с именем «косметика». Отношения включения можно обнаружить во многих практических задачах.
32 Глава 1 Шаблоны Иногда при создании программы возникает необходимость в нескольких похожих функциях или классах, код которых отличается лишь типом обрабатываемых данных. К примеру, вам может понадобиться набор функций abs( ), одна из которых возвращает абсолютное значение целого числа (с типом int), другая — абсолютное значение числа с плавающей точкой (с типом float) и т. д. Вместо того чтобы задавать и сопровождать этот набор функций, C++ позволяет в таких ситуациях воспользоваться инструментом под названием Template (Шаблон) для создания единой универсальной функции. И тогда при необходимости компилятор будет генерировать различные версии этой функции для работы с данными разных типов. Аналогичным образом вы можете представить универсальный шаблонный класс с именем Stack (Стек). Из этого универсального класса компилятор может сгенерировать специальные классы — один для поддержки стека целых чисел, другой для поддержки стека чисел с плавающей точкой и т. д. Обработка исключений Надежная программа должна быть способна предвидеть аппаратные и программные ошибки или непредусмотренные события, которые могут возникать во время ее выполнения, и уметь их обрабатывать. Например, аппаратная ошибка может возникнуть при доступе к файлу или базе данных, обращении к принтеру или другому устройству, а программная ошибка может возникнуть, если выполняется деление на ноль. Это непредвиденные ситуации, и поэтому они удачно названы исключениями. С++ позволяет обрабатывать эти исключения средствами объектно-ориентированного программирования. Для этого мы создаем объект-исключение, помещаем в него соответствующие данные об условиях ошибки, а затем генерируем этот объект-исключение (при помощи ключевого слова throw). Затем это сгенерированное исключение может быть перехвачено в другой части кода (при помощи ключевого слова catch) и соответствующим образом обработано. Многократное использование Объектно-ориентированные программы состоят из повторно используемых программных компонентов. После того как окончательный вариант класса создан и протестирован, его можно передать другим разработчикам для использования в их собственных программах. Это называется возможностью многократного (повторного) использования. Если другие программисты хотят добавить новые функции или модифицировать уже имеющиеся, на основе существующих классов могут быть созданы новые. Многократно испытанный на практике функционал базовых классов не требует повторной разработки. Программисты могут посвятить свое время написанию нового программного кода вместо того, чтобы тратить его на переписывание существующего. В результате программное обеспечение легче тес-
Введение в ООП 33 тировать, поскольку ошибки программирования могут быть изолированы в новом коде производных классов. Представьте, что у вас есть написанный вами (или приобретенный у кого-то) класс, создающий систему меню. Вас устраивает работа этого класса и вы не хотите его менять, но хотите добавить возможность отображения справки для каждого пункта меню. Для этого вы просто создаете новый класс, который наследует все возможности существующего, но добавляет функцию справки. Эта простота многократного использования существующего программного обеспечения является основным преимуществом ООП. 1. Укажите, верны ли следующие утверждения:  Объектно-ориентированное программирование позволяет многократно исполь- зовать существующий код.  В языках, предшествовавших процедурным языкам программирования, приме- нялись только глобальные переменные.  В объектно-ориентированных языках программирования единственный способ передать управление потоком инструкций из одной части кода в другую — это использовать оператор goto.  Разрабатывать, понимать и сопровождать программы легче, если они использу- ют модель объектно-ориентированного, а не структурного программирования.  В процедурном программировании данным не придается должного значения по сравнению с процедурами.  Модель структурного программирования не позволяет сформулировать задачи из реальной жизни настолько хорошо, как модель объектно-ориентированного программирования.  Модель объектно-ориентированного программирования допускает функцио- нальную абстракцию, а также абстрагирование данных.  Класс позволяет создавать пользовательские типы данных.  Объекты относятся к классам так же, как переменные относятся к встроенным типам данных.  Возможность создания нового класса из существующего способствует много- кратному использованию программного кода.  Инкапсуляция позволяет использовать функцию с одним именем для одной и той же операции, выполняемой над связанными дочерними классами.  Благодаря полиморфизму реализация функций с одинаковыми именами может варьироваться от класса к классу.  Может быть создано множество объектов, принадлежащих одному классу.
34 Глава 1 2. Заполните пропуски:  Двумя основными компонентами объекта являются ______ и ______.  Способность функции или оператора действовать по-разному с разными типами данных называется ________.  Процесс создания новых классов из существующих известен как _______.  Если класс A наследует свои свойства от класса B, то A и B называются _______ класс и ______ класс соответственно.  Pascal и C — это _______ языки, а С++ — это _______ язык. 3. Установите соответствия между объектами: (A) ООП (1) Класс (B) Структурное программирование (2) Универсальные функции/класс (C) Пользовательский тип данных (3) Базовый класс, производный класс (D) C, Pascal (4) Данные и функции (E) Содержимое объектов (5) Взаимодействие объектов (F) Наследование (6) Языки структурного программирования (G) Шаблоны (7) Языки ООП (H) С++, Java (8) Исключения (I) Отношения включения (9) Взаимодействие функций (J) int, float (10) Пользовательские типы данных (K) Сотрудник, студент (11) Стандартные типы данных (L) Обработка ошибок в ООП (12) Вложенные объекты 4. Ответьте на следующие вопросы:  В чем основное различие между моделью структурного программирования и моделью объектно-ориентированного программирования?  В чем различие между классом и объектом?  Приведите не менее 5 примеров классов и объектов.  Что такое инкапсуляция?  Что означает наследование?  Что такое полиморфизм?
Введение в ООП 35  Структурное программирование ставит во главу угла взаимодействие функций.  Объектно-ориентированное программирование ставит во главу угла взаимодей- ствие объектов.  Класс — это пользовательский тип данных, на основе которого создаются объ- екты.  Объект содержит определенные значения данных, а также функции, которые могут обращаться к ним и/или манипулировать ими.  Члены данных и функции-члены инкапсулированы в объект.  Скрытие данных означает отказ в прямом доступе к данным извне объекта.  Наследование позволяет наследовать свойства базового класса производным классом.  Полиморфизм позволяет использовать одну и ту же функцию для специфичных реализаций в разных производных классах.  Отношения включения позволяют вкладывать объекты.  Шаблоны позволяют писать универсальные функции/классы, оставляя компиля- тору возможность генерировать их версии, соответствующие конкретным требованиям.  Исключения С++ позволяют обрабатывать ошибки при выполнении программы средствами ООП.  С++ поддерживает многократное использование кода при помощи механизмов наследования, отношений включения и шаблонов.

Переходим на C++ С++ появился на свет почти на два десятилетия позднее C. Это означает, что у С++ было преимущество — опыт предшественника. Этот опыт позволил усовершенствовать ряд функций, уже существовавших в языке C. Важно, чтобы по мере перехода от C к С++ мы хорошо разобрались в этих изменениях
38 Глава 2  Комментарии  Ввод и вывод в С++  Динамическое объявление переменных  Вывод типов  Синтаксис структуры (struct), объединения (union) и перечисления (enum)  Неименованные объединения и перечисления  Приведение типов  Пустой указатель (void)  Оператор ::  Ссылки • Типы обращений к функциям • Возвращение значения по ссылке  Спецификатор const • Const-указатели • Const -ссылки • Возврат значений const-переменных • Функции-члены типа const  Логический тип данных (bool)  Упражнения  Важное
39 Переходим на C++ К ак и язык C, С++ — это детище корпорации Bell Labs. Он был разработан Бьёрном Страуструпом в 1979 году. Страуструп взял C как основу в силу его краткости и широкой распространенности. Как следствие, любая программа на C будет корректно выполняться на С++. И все типы данных, команды управления, функции, массивы, строки, структуры, объединения, перечисления enum и указатели работают в С++ точно так же, как и в C. Из чего следует, что знание C пригодится при переходе на С++. С момента выхода первоначальной версии С++ претерпел ряд существенных редакций. Важными вехами в эволюции языка стали версии С++98, С++11, С++14 и С++17. Все программы в этой книге работают с компиляторами, совместимыми с С++17, такими как VisualStudio или GCC. Основная цель создания С++ — сделать так, чтобы программисту стало легче и проще создавать отличные программы. Для ее достижения Страуструп добавил к языку C функции ООП без существенных изменений самого языка. Таким образом, С++ по сути является расширенной версией C. Для поддержки функций ООП в С++ был введен ряд новых ключевых слов, представленных в табл. 2.1. Таблица 2.1. Новые ключевые слова в С++ asm explicit private true bool false protected try catch friend public typeid class inline reinterpret_cast typename const_cast mutable static_cast using decltype namespace template virtual delete new this wchar_t dynamic_cast opertaor throw В последующих главах мы обсудим эти ключевые слова и объектно-ориентированные средства С++. А в этой главе мы рассмотрим необъектно-ориентированные расширения, предусмотренные в С++. И начнем с комментариев к коду в С++. Комментарии В отличие от C, С++ поддерживает два типа комментариев. Программистам на языке C известен стиль комментирования /* */. Все, что размещено внутри пар символов /* и */, игнорируется компилятором. Кроме того, С++ поддерживает для комментариев обозначение //. К примеру: c = 5.0 / 9 * ( f - 32 ) ; // формула преобразования
40 Глава 2 Все, что следует после // и до конца строки, интерпретируется как комментарий. Как правило, стиль /* */ используется для того, чтобы закомментировать многострочный блок программного кода, тогда как стиль // служит для создания однострочных комментариев. Ввод и вывод в C++ Рассмотрим следующую инструкцию: cout << "Боже, ниспошли мне толику здравомыслия!" ; Она выводит на экран фразу, заключенную в кавычки. Полное описание этой команды требует понимания объектов, перегрузки операторов и других тем, которые мы рассмотрим далее, а здесь приведем лишь краткий обзор. В действительности идентификатор cout (произносится как «си-аут») является объектом. Он предопределен для стандартного потока вывода. Поток является абстракцией, относящейся к потоку данных. Стандартный вывод обычно осуществляется на экран монитора, хотя его можно перенаправить на другие устройства вывода. Мы обсудим потоки в главе 9. Символ << является оператором вывода данных. Он направляет содержимое переменной, расположенной справа, объекту слева от него. В нашем примере он передает строковую константу «Боже, ниспошли мне толику здравомыслия!» идентификатору cout, который выводит ее на экран. Если вам знаком язык C, вы без труда узнаете в << оператор сдвига влево и спросите в удивлении, разве он предназначен для прямого вывода? В С++ операторы могут быть перегруженными. Иными словами, они могут выполнять различные действия в зависимости от контекста. Мы рассмотрим перегрузку в главе 4. Хотя концепции, лежащие в основе работы cout и <<, на данный момент могут показаться вам несколько туманными, их несложно применять. Они будут появляться почти в каждом листинге этой книги. Теперь, когда мы знаем, как можно осуществить вывод на экран, посмотрим, как считать ввод с клавиатуры. Для получения ввода с клавиатуры используется идентификатор cin. Слово cin (произносится как «си-ин») — это объект, предопределенный в С++ в качестве стандартного потока ввода. Этот поток представляет данные, поступающие с клавиатуры (если они не были перенаправлены). Символ >> является оператором ввода данных. Он берет значение из объекта потока, расположенного слева, и присваивает его переменной справа от него. Следующий пример иллюстрирует работу cout и cin. #include <iostream> using namespace std ; int main( ) { char str[ 40 ] ; int m1, m2, m3, avg ;
Переходим на C++ 41 cout << "Введите ваше имя: " ; cin >> str ; cout << "Введите отметки по трем предметам: " ; cin >> m1 >> m2 >> m3 ; avg = ( m1 + m2 + m3 ) / 3 ; cout << "Вас зовут " << str ; cout << endl << "Ваш средний балл " << avg << endl ; return 0 ; } Ниже приведен пример работы с программой: Введите ваше имя: Saurav Gupta Введите отметки по трем предметам: 44 72 64 Вас зовут Saurav Gupta Ваш средний балл 60 В первой строке программы мы подключили файл ‘iostream’. В отличие от C, заголовочные файлы С++ не имеют расширения .h. Файл ′iostream′ содержит декларации, необходимые идентификаторам cout и cin, а также операторам << и >> . Без этих деклараций компилятор не распознает cout и cin и посчитает, что << и >> используются некорректно. Следующая строка, using namespace std;, присутствует во всех программах этой книги. Чтобы разобраться со значением этой инструкции, нужно прежде всего понять, что такое пространство имен (namespace). Пространство имен есть не что иное, как набор идентификаторов (имен переменных, а также имен некоторых других типов, которые мы еще не обсуждали), принадлежащих одной группе или семейству. К примеру, все идентификаторы в стандартной библиотеке С++ принадлежат пространству имен с названием std. Существуют два способа сослаться на конкретный идентификатор, принадлежащий некоему пространству имен:  Использовать команду using в начале программы, как мы сделали это в нашем примере.  Указать перед идентификатором имя пространства имен в виде префикса, за ко- торым следуют два двоеточия, как показано ниже: std::cout << "Введите ваше имя: " ; Естественно, первый способ куда лучше, чем предварять каждый cout, cin и endl префиксом std::. Использование первой пары cout и cin в нашей программе достаточно прозрачно. Так, cout выводит сообщение с запросом имени, а cin присваивает это имя строке str[ ]. Обратите внимание на многократное использование оператора ввода >> во второй команде cin. Это абсолютно корректная конструкция, известная как каскадирование операторов. Она избавляет нас от необходимости три раза повторять оператор
42 Глава 2 cin. Тем не менее ее использование исключает возможность отображения подсказки в процессе ввода данных. Как вы должно быть уже догадались, также допускается каскадирование операторов вывода. Взгляните на последнюю команду с cout. Вы обнаружите, что в этой инструкции используется незнакомое слово, endl. Это манипулятор, вставляющий перевод строки в поток вывода. Фраза, следующая за ним, начинается с новой строки. Можно сделать то же самое, отправив символ новой строки "\n" в поток вывода, воспользовавшись любой из следующих инструкций: cout << "\n" << "Ваш средний балл " << avg ; cout << '\n' << "Ваш средний балл " << avg ; cout << "\nВаш средний балл " << avg ; Все эти команды взаимозаменяемы. Тем не менее использование манипулятора endl является, пожалуй, самым прозрачным. А сейчас вопрос на миллион! Дало ли нам что-либо применение cin и cout вместо scanf( ) и printf( )? Безусловно! В scanf( ) и printf( ) необходимо указывать спецификаторы формата в строке формата (%d для целого числа, %s для строки и т. д.), чего можно полностью избежать, применив cin и cout. Кроме того, как мы узнаем в главе 9, операторами cin и cout можно воспользоваться и для ввода-вывода пользовательских типов данных. Динамическое объявление переменных Языку C не хватает гибкости в том, что касается объявления переменных. В нем требуется, чтобы все переменные были объявлены перед первой исполняемой инструкцией. Напротив, C++ позволяет задавать переменные в той части кода, где они применяются. Это хорошо видно на следующем примере: #include <iostream> using namespace std ; int main( ) { int f ;cin >> f ; int c = ( f - 32 ) * 5 / 9 ; cout << c ; for ( int j = 10 ;j <= 100 ;j++ ) cout << endl << j << endl ; return 0 ; } Заметьте, что переменные c и j объявлены не в начале функции main( ), а там, где они используются для присвоения значения. Объявление переменных там, где они используются, облегчает понимание программы, поскольку не требуется повторно обращаться к началу функции, чтобы найти объявления переменных. Однако прибегать к подобной практике следует с осмотрительностью. Возможно, те переменные, что используются в разных частях функции, лучше объявлять в ее начале.
Переходим на C++ 43 Динамическая инициализация В C++ есть целый ряд способов инициализации переменной. Продемонстрируем их на примерах: int age1 = 32 ; int age2 ( 32 ) ; int age3 { 32 } ; Все три способа инициализации переменных, приведенные выше, абсолютно корректны и эквивалентны друг другу. Каждая из этих инструкций присваивает переменной значение 32. Вывод типов Компилятор C++ также может определить тип переменной по присваиваемому ей значению. Для этого необходимо указать auto в качестве спецификатора типа для переменной, как показано ниже: auto age1 = 32 ; auto age2 = age1 ; Поскольку 32 — это целочисленный тип (int), предполагаемым типом для переменной age1 станет int. По аналогии коль скоро age1 имеет значение с типом int и присваивается переменной age2, то предполагается, что age2 также является целым числом. Необходимо ли нам инициализировать переменную, чтобы компилятор мог корректно определить ее тип? Нет, инициализировать переменную не нужно. Мы можем сделать это следующим образом: char ch ; decltype ( ch ) dh; Здесь тип переменной dh будет таким же, как тип переменной ch. Функции auto и decltype были добавлены в язык не так давно. Мы привели их в весьма упрощенных примерах. Их ценность обнаруживается в тех случаях, когда тип данных не может быть установлен другими способами. Синтаксис структуры (struct), объединения (union) и перечисления (enum) Рассмотрим пример: struct employee { char name[ 20 ] ; int age ; } ;
44 Глава 2 union data { char ch[ 2 ] ; int i ; } ; enum status { married, unmarried, divorced } ; employee e ; data d ; status s ; Обратите внимание на то, как заданы переменные e, d и s. В отличие от C, при объявлении переменных e, d и s язык C++ не нуждается в ключевых словах struct, union и enum. Таким образом, формат для объявления структурной переменной точно такой же, как формат, используемый для переменной встроенного типа данных, такого как int: int i ; employee e ; Сходство отнюдь не случайно. Одна из задач C++ — организовать работу с пользовательскими типами данных по аналогии со встроенными типами данных. Есть еще одно существенное различие между структурами и объединениями в C++ и C. Структуры и объединения C++ могут содержать в качестве своих элементов не только различные типы данных, но и функции. Мы рассмотрим этот аспект структур и объединений в главе 5, когда начнем изучать классы в С++. Неименованные объединения и перечисления C++ позволяет использовать неименованные объединения (union) и перечисления (enum). У неименованного объединения нет имени (тега), и к его элементам можно обращаться напрямую, без использования переменной объединения. Проиллюстрируем это на следующем примере: union { int i ; char ch[ 2 ] ; } ; Переменная i и массив ch[ ] используют одни и те же ячейки памяти, и к ним можно обратиться напрямую, просто написав: i = 10 ; ch[ 0 ] = 'A' ; Аналогичным образом можно создавать неименованные перечисления: enum { sleeper, acthreetier, actwotier, acfirstclass } ; int bogietype = actwotier ;
45 Переходим на C++ Неименованное объединение полезно, когда оно вложено в структуру, как на примере ниже: struct employee { char name[ 20 ] union { int emptype char grade[ } ; } ; int main( ) { struct employee e.emptype = 3 ; return 0; } ; ; 4 ] ; e ; В этой декларации emptype и grade занимают одни и те же ячейки памяти и допускают прямой доступ. Он возможен благодаря неименованному объединению. Если бы объединение было именованным, то для обращения к emptype или grade нам пришлось бы использовать несколько операторов. Классы потокового ввода-вывода, представленные в главе 9, используют целый ряд неименованных перечисляемых типов. Приведение типов Если мы выполняем операцию между целым числом (int) и числом с плавающей точкой (float), то перед ее выполнением целое число конвертируется в число с плавающей точкой. Это преобразование происходит автоматически. В языках C и C++ возможен и ряд других автоматических преобразований. В качестве своего рода противовеса для выполнения необходимых программисту преобразований данных используется приведение (или явное преобразование) типов. В C++ поддерживаются две разновидности синтаксиса приведения типов. Проиллюстрируем их на примере: int y = 1001, j = 365, n ; n = ( y - 1 ) * ( long ) j ; // Стиль приведения типов C, также поддерживаемый в C++ n = ( y - 1 ) * long ( j ) ; // Новый стиль приведения типов в C++ C задействует лишь первый стиль приведения типов, тогда как в C++ применимы оба. Вместо явного преобразования типов мы могли бы также объявить j как переменную типа long. В данном фрагменте кода это вполне уместно. Однако если в дальнейшем мы намереваемся использовать j как переменную типа int, альтернативы приведению типов нет.
46 Глава 2 Пустой указатель (void) Ключевое слово void может использоваться в качестве указателя на обобщенный термин (т. е. объект неизвестного типа). В отличие от языка C, в C++ необходимо проявлять особую осторожность при присваивании указателей void другим типам указателей. Приведем пример возможной ошибки: void *p ; char *s ; p = s ; s = p ; В этом фрагменте кода второе присваивание выдаст ошибку, указывающую на несоответствие типа. Вы можете присвоить указатель любого типа указателю void, однако, чтобы сделать наоборот, необходимо явным образом привести указатель к соответствующему типу, как показано ниже: s = ( char * ) p ; Оператор :: Оператор расширения области :: разрешает конфликт в области видимости, когда существуют локальные и глобальные переменные с одинаковыми именами. Посмотрим, как это выглядит на практике: #include <iostream> using namespace std ; int num = 10 ; int main( ) { int num = 15 ; cout << "Локальная num = " << num << endl ; cout << "Глобальная num = " << ::num << endl ; ::num = 20 ; cout << " Локальная num = " << num << endl ; cout << " Глобальная num = " << ::num << endl ; return 0 ; } Результат работы программы: Локальная Глобальная Локальная Глобальная num = 15 num = 10 num = 15 num = 20 Обратите внимание, что у нас есть две переменные с одинаковым именем num. Одна из них глобальная, а другая — локальная. Благодаря оператору расширения области :: обеспечивается гибкость доступа к обеим переменным.
47 Переходим на C++ Так, в приведенном выше листинге при помощи num мы получаем доступ к локальной переменной num, тогда как ::num позволяет обратиться к глобальной переменной с тем же именем. Результат работы программы показывает, что мы можем не только обратиться к глобальной переменной и получить ее значение, но даже присвоить ей новое значение. Ссылки Ссылки весьма схожи с указателями. Есть, разумеется, и отличие, но с ним мы разберемся чуть позже. А сначала напишем программу с использованием ссылки: #include <iostream> using namespace std ; int main( ) { int i = 10 ; int &j = i ; cout << "i = " j = 20 ; cout << "i = " i = 30 ; cout << "i = " i++ ; cout << "i = " j++ ; cout << "i = " cout << "Адрес return 0 ; } << i << " j = " << j << endl ; << i << " j = " << j << endl ; << i << " j = " << j << endl ; << i << " j = " << j << endl ; << i << " j = " << j << endl ; i = " << &i << "адрес j =" << &j << endl ; Результат исполнения кода: i = 10 j = 10 i = 20 j = 20 i = 30 j = 30 i = 31 j = 31 i = 32 j = 32 Адрес i = 61342 Адрес j = 61342 В этом листинге j является ссылкой на переменную i. Ссылка объявляется при помощи оператора & (амперсанд) подобно тому, как оператор * (звездочка) применяется для объявления указателя. Ссылка содержит адрес переменной, на которую она ссылается. Ссылка отличается от указателя лишь тем, что для доступа к значению, на которое указывает ссылка, не нужно использовать оператор *. Иными словами, ссылка автоматически разыменовывается.
48 Глава 2 Поэтому всякий раз, когда мы обращаемся в нашей программе к j, на самом деле используется *j, с того самого момента, как ссылка j связывается с переменной i. Поскольку j содержит адрес i, *j возвращает значение i. Вот почему, когда мы пытаемся присвоить j значение 20, оно присваивается *j, т. е. переменной i. Аналогичным образом, когда мы инкрементируем (увеличиваем) j, происходит приращение *j, т. е. переменной i. Более того, когда мы пытаемся вывести адрес j, выводится адрес *j, т. е. адрес i. Сопоставьте это с результатом исполнения кода, приведенного выше. Ниже несколько важных моментов использования ссылок:  Ссылка должна быть инициализирована. Поэтому, например, следующий набор инструкций приводит к ошибке: int i = 4 ; int &j ; // ошибка j = i ;  После связывания ссылки с определенной переменной она не может ссылаться ни на какую другую переменную. Иными словами, с момента инициализации ссылка и переменная неразрывно связаны друг с другом.  Можно создать ссылку на целочисленный тип данных (int), тип данных с пла- вающей точкой (float) или символьный тип данных (char), а также ссылку на указатель. Объявление такой ссылки выглядит следующим образом: char *p = "Привет" ; char *&q = p ;  Допускается множество ссылок на одну переменную. Изменение значения любой из них влечет за собой изменение всех остальных.  В С++ допустим массив указателей, а вот массив ссылок — нет (то самое отли- чие, о котором говорили в начале раздела). Типы обращений к функциям В программировании на C возможны два типа обращений к функциям — вызов по значению и вызов по адресу. В C++ существует еще один тип обращений к функциям. Он известен как вызов по ссылке. Он работает так же, как и вызов по адресу. В обоих случаях мы можем изменять значения фактических аргументов при помощи формальных аргументов, используемых в вызываемой функции. В то же время код вызова по ссылке намного прозрачнее, что делает его более читабельным: # include <iostream> using namespace std ; // объявления прототипов void swapv ( int, int ) ;
Переходим на C++ 49 void swapr ( int &, int & ) ; void swapa ( int *, int * ) ; int main( ) { int a =10, b = 20 ; swapv ( a, b ) ; // вызов по значению cout << endl << a << "\t" << b ; swapa ( &a, &b ) ; // вызов по адресу cout << endl << a << "\t" << b ; swapr ( a, b ) ; // вызов по ссылке cout << endl << a << "\t" << b << endl ; return 0 ; } void swapv ( { int t ; t = i ; i = j ; j = t ; cout << i } void swapa ( { int t ; t = *i ; *i = *j ; *j = t ; } void swapr ( { int t ; t = i ; i = j ; j = t ; } int i, int j ) << "\t" << j ; int *i, int *j ) int &i, int &j ) В этом листинге функция swapv( ) вызывается по значению. Обмен значениями i и j в этой функции не влияет на значения a и b в глобальной функции main( ). Функция swapa( ) демонстрирует обращение по адресу, где при помощи указателей мы изменяем значения a и b в вызывающей функции. Функция swapr( ) выполняет задачу, аналогичную функции swapa( ), с той лишь разницей, что эта функция вызывается по ссылке. Согласитесь, что применение ссылок для обмена значениями выглядит более элегантным и приятным для глаз. Кроме того, указатель должен быть разыменован, прежде чем вы сможете с его помощью получить доступ к значению, в то время как
50 Глава 2 для ссылки это не обязательно. В этом смысле ссылка действует более прямолинейно. Синтаксис вызова функции swapr( ) также намного проще в сравнении с вызовом swapa( ). Таким образом, ссылки представляют собой ясный, элегантный и эффективный способ передачи аргументов функциям, в которых предполагается изменение их значений. Вызов функции по ссылке полезен еще в одном случае. При вызове по значению в вызываемой функции создается копия передаваемых аргументов. Если размер аргументов велик, это приводит к пустой трате драгоценного пространства памяти. Тогда как при обращении по адресу или по ссылке копия передаваемых аргументов не создается. Это связано с тем, что в обоих случаях передаются лишь адреса. Ниже приведена программа, демонстрирующая три типа обращений к функциям. #include <iostream> using namespace std ; struct employee { char name[ 20 ] ; int age ; float salary ; } ; void displayVal ( employee ) ; void displayAddr ( employee * ) ; void displayRef ( employee & ) ; int main( ) { employee e = { "Sanjay", 32, 3200.00 } ; displayVal ( e ) ; displayAddr ( &e ) ; displayRef ( e ) ; return 0 ; } void displayVal ( employee e ) { cout << e.name << endl << e.age << endl << e.salary << endl ; } void displayAddr ( employee *p ) { cout << p->name << endl << p->age << endl << p->salary << endl ; } void displayRef ( employee &p ) { cout << p.name << endl << p.age << endl << p.salary << endl ; }
Переходим на C++ 51 Из трех вызовов функций, используемых в этом примере, наилучшим является обращение по ссылке, т. к. оно позволяет избежать копирования аргументов, а также использования указателей. Возвращение значения по ссылке Мы уже знаем, как передать ссылку функции в качестве ее аргумента. Мы также умеем возвращать ссылку из функции. Когда функция возвращает ссылку, вызов функции может существовать в любом контексте, где может существовать ссылка, в том числе в левой части присваивания. Поясним это на следующем примере: #include <iostream> using namespace std ; struct emp { char name[ 20 ] ; int age ; float sal ; } ; emp e1 = { "Амол", 21, 2345.00 } ; emp e2 = { "Эджей", 23, 4500.75 } ; emp &fun( ) ; int main( ) { fun( ) = e2 ; cout << e1.name << endl << e1.age << endl << e1.sal << endl ; return 0 ; } emp &fun( ) { cout << e1.name << endl << e1.age << endl << e1.sal << endl ; return e1 ; } В этой небольшой программе мы объявили структуру emp и инициализировали две глобальные переменные e1 и e2, присвоив им некоторые значения. Выведя в функции fun( ) значения, хранящиеся в e1, мы вернули их по ссылке. Необычен здесь сам вызов функции fun( ). Он происходит в левой части оператора присваивания: fun( ) = e2 ; Функция fun( ) возвращает значение переменной e1 по ссылке. Этой ссылке присваивается значение e2. Поскольку ссылка автоматически разыменовывается, e2 присваивается e1. Вывод оператора cout в main( ) подтверждает, что присваивание и правда имело место.
52 Глава 2 В результате выполнения программы мы получим: Эджей 23 4500.75 Но будьте осторожны! Не пытайтесь вернуть локальную переменную по ссылке. Это связано с тем, что локальная переменная остается за пределами области видимости, когда функция возвращает значение. Таким образом, вы возвращаете ссылку на переменную, которой больше не существует, а вызывающая функция ссылается на несуществующую переменную. Некоторые компиляторы C++ выдают предупреждение, когда встречают код, возвращающий ссылки на локальные переменные. Проигнорировав такое предупреждение, вы получите непредсказуемые результаты. Иной раз кажется, что программа корректно работает, поскольку ячейка в стеке, где находилась локальная переменная, не задействуется при использовании ссылки. Работая в одних случаях, программа может дать сбой в других из-за прерываний устройства или многозадачного режима с использованием стека. Спецификатор const Переменная может быть объявлена с использованием спецификатора типа const (от «константа»), и тогда ее значение не сможет изменяться во время выполнения программы. Любая попытка модифицировать значение переменной, инициированная посредством спецификатора const, приведет к тому, что компилятор выдаст сообщение об ошибке. Спецификатор const, как правило, применяется вместо символических констант, представленных при помощи директивы #define. Спецификатор const не позволит вашей программе изменить значение переменной, которой вы отвели роль константы. Это также служит указанием всем, кто читает программу, что переменная не предназначена для модификации. Имена constпеременных часто записываются в верхнем регистре, как напоминание о том, что они являются именованными константами. Следующая программа демонстрирует применение спецификатора const: #include <iostream> using namespace std ; int main( ) { float r, a ; const float PI = 3.14f ; cin >> r ; a = PI * r * r ; cout << endl << "Площадь круга равна " << a << endl ; return 0 ; }
Переходим на C++ 53 Спецификатор типа const предпочтительнее директивы #define, поскольку областью видимости const-переменной можно управлять, размещая ее либо внутри функции, либо вне всех функций. Так, если const-переменная помещена внутрь функции, область ее действия будет локализована для данной функции, а если она размещена вне всех функций, то область ее действия будет глобальной. При помощи директивы #define невозможно добиться столь точной регулировки. Const-указатели Начнем сразу с программы: #include <iostream> using namespace std ; void xstrcpy ( char *, char * ) ; int main( ) { char str1[ ] = "Nagpur" ; char str2[ 10 ] ; xstrcpy ( str2, str1 ) ; cout << str2 << endl ; return 0 ; } void xstrcpy ( char *t, char *s ) { while ( *s != '\0' ) { *t = *s ; t++ ; s++ ; } *t = '\0' ; } Данный фрагмент кода просто копирует содержимое строки str1[ ] в строку str2[ ], используя функцию xstrcpy( ). Что произойдет, если мы добавим следующие строки после последней инструкции xstrcpy( )? s = s - 6 ; *s = 'K' ; Исходная строка изменится на «Kagpur». Можем ли мы быть уверены в том, что исходная строка не изменится даже случайно при работе xstrcpy( )? Можем, если предварим ключевым словом const параметр-указатель, изменив прототип функции на: void xstrcpy ( char *, const char * ) ;
54 Глава 2 Соответственно ее объявление изменится на: void xstrcpy ( char *t, const char *s ) { // код программы } Следующий фрагмент кода более подробно представит работу спецификатора const: char *p = "Hello" ; // указатель — переменная, строка — константа *p = 'M' ; // ошибка p = "Bye" ; // работает const char *q = "Hello" ; // указатель — переменная, строка — константа *q = 'M' ; // ошибка q = "Bye" ; // работает char const *s = "Hello" ; // указатель — переменная, строка — константа *s = 'M' ; // ошибка s = "Bye" ; // работает char * const t = "Hello" ; // указатель и строка — константы *t = 'M' ; // работает t = "Bye" ; // ошибка const char * const u = "Hello" ; // указатель и строка — константы *u = 'M' ; // ошибка u = "Bye" ; // ошибка Const-ссылки Ранее мы узнали, что можем передать ссылку на переменную в функцию, если нам требуется модифицировать переменную в функции без использования указателей. И это не единственное применение передачи по ссылке. Если мы передадим переменную по значению, то она будет собрана в другую переменную. Тем самым мы создадим еще одну переменную. Сколько переменных передается по значению, столько создается дополнительных переменных. Этого можно избежать, передавая переменные по ссылке. Так мы повысим эффективность программного кода. Тем не менее возникает вероятность того, что переменные будут случайно изменены внутри функции. Этого можно избежать, объявив их константами, как показано в следующем примере: #include <iostream> using namespace std ; void change ( const int & ) ;
Переходим на C++ 55 int main( ) { int i = 32 ; change ( i ) ; cout << endl << i ; return 0 ; } void change ( const int &j ) { j = 45 ; } Передача переменной i по ссылке позволяет избежать создания новой переменной. В то же время спецификатор const предотвращает изменение j (и, в свою очередь, i). Любая попытка модифицировать j вызовет сообщение об ошибке — «Невозможно изменить объект-константу» (‘Cannot modify a constant object’). А теперь рассмотрим несколько примеров, которые подробнее проясняют концепцию спецификатора const и ссылок: #include <iostream> using namespace std ; int main( ) { const int i = 10 ; int &j = i ; cout << "i = " << i << " j = " << j ; j = 20 ; cout << "i = " << i << " j = " << j ; return 0 ; } В этом примере мы связали j с i. Таким образом, изменение j не должно изменять i, т. к. мы объявили переменную i константой, и потому ее нельзя модифицировать, даже через ссылку на нее. Рассуждая таким образом, мы ожидаем, что программа выдаст следующее: i j i j = = = = 10 10 10 20 Однако в ходе компиляции этого фрагмента мы получим сообщение об ошибке, гласящее: «Невозможно преобразовать const int в int &» (‘Cannot convert from const int to int &’). Ошибка возникает из-за того, что мы объявили j ссылкой на int, а не на const int. Возьмем другой похожий случай с const-ссылкой: #include <iostream> using namespace std ;
56 Глава 2 int main( ) { int i = 10 ; const int &j = i ; cout << i << j ; j = 20 ; cout << i << j ; return 0 ; } Эту программу компилятор тоже не пропустит. Что неудивительно, ведь мы опять делаем что-то совсем не то. Здесь мы инициализировали j в качестве константы, а затем пытаемся изменить ее значение через j = 20. Мораль, которую можно извлечь из обеих программ, приведенных выше, проста — как только мы объявляем переменную или ссылку в качестве константы, мы уже не можем изменить ее значение. Как вы думаете, каким будет результат в примере ниже? #include <iostream> using namespace std ; int main( ) { int i = 10 ; const int &j = i ; i = 20 ; cout << endl << "i = " << i << " j = " << j << endl ; return 0 ; } Программа выдаст: i = 20 j = 20 Но почему? Ссылка зависит от переменной, с которой она связана. Если мы изменим значение переменной, значение ссылки изменится автоматически, будь то константа или что-либо иное. Можем ли мы использовать ссылки для строк, как мы делаем это с целыми числами? Разумеется! И вот каким образом: #include <iostream> using namespace std ; int main( ) { char *str1 = "Снова, снова дождик льет" ; char * &str2 = str1 ; cout << endl << str1 << endl << str2 ; *str1 = 'M' ; cout << endl << str1 << endl << str2 ;
Переходим на C++ 57 *str2 = 'P' ; cout << endl << str1 << endl << str2 ; return 0 ; } Здесь str1 — это указатель на строковую константу «Снова, снова дождик льет». А str2 — это указатель, действующий как ссылка на str1. Таким образом, str1 и str2 указывают на одну и ту же строку. Далее мы пробуем изменить строковую константу сначала через str1, затем через str2. Обе попытки приводят к ошибке выполнения кода, т. к. в них мы пытаемся модифицировать строку, являющуюся константой. Возврат значений const-переменных Функция может возвращать указатель на строковую константу, как показано ниже: #include <iostream> using namespace std ; const char *fun( ) ; int main( ) { const char *p ; p = fun( ) ; *p = 'A' ; // Ошибка cout << p ; return 0 ; } const char *fun( ) { return "Дождик" ; } Здесь, поскольку функция fun( ) возвращает строковую константу, мы не можем воспользоваться указателем p для ее модификации. Более того, следующие операции также недопустимы:  Функция main( ) не может присвоить возвращаемое значение указателю, если тот не указывает на строковую константу.  Функция main( ) не может передать возвращаемое значение функции, если та не ожидает указатель на строковую константу. Функции-члены типа const В языке C структуры могут содержать только типы данных, тогда как в C++ они могут содержать и функции. Помимо этого, в C++ есть еще одна сущность, именуемая классом, который также может содержать члены данных и функции-члены.
58 Глава 2 Спецификатор const может использоваться для определения функций-членов в структуре или классе. О таком применении const мы поговорим в главе 5. Логический тип данных (bool) Логические (булевы) типы данных названы в честь Джорджа Буля, английского математика XIX века, разработавшего теорию применения логических операторов со значениями "верно/истина" (true) и "неверно/ложь" (false). Логические значения часто называют булевы значения. Этот тип данных может принимать только два значения, "верно" (true) или "неверно" (false). Чаще всего он используется для хранения результатов сравнений, например: bool x, y ; int a = 10, b = 20, c = 30 ; x = a < b ; y = b >= c ; Здесь x принимает значение true, а y — значение false. При преобразовании в целое число true имеет значение 1, а false — значение 0. И наоборот, целые числа могут быть имплицитно преобразованы в булевы значения — ненулевые целые числа преобразуются в true, а 0 — в false. Логический тип можно использовать в арифметических и логических выражениях. Там они преобразуются в целые числа, и над этими целыми числами выполняются целочисленные арифметические и логические операции. Результат этих операций может быть преобразован обратно в логический тип: 0 преобразуется в false, а ненулевое значение — в true. Небольшая программа и ее выдача помогут вам понять механизм этих преобразований: #include <iostream> using namespace std ; int main( ) { bool b = 32 ; int i = false ; cout << endl << b << endl << i ; int j = b + b ; bool k = b + b ; cout << endl << j << endl << k << endl ; return 0 ; } Результат работы программы: 1 0 2 1
Переходим на C++ 59 Логический тип bool также может использоваться как возвращаемый тип функции, обычно указывающий на удачное либо неудачное выполнение назначенного задания. Старые компиляторы C++, такие как Turbo С++ 3.0, не распознают логический тип данных. 1. Укажите, верны ли следующие утверждения:  В C++ структура может содержать члены данных, а также функции, способные работать с членами данных.  В C++ объединение может содержать члены данных, а также функции, способ- ные работать с членами данных.  Можно создать массив ссылок.  Как только происходит связывание ссылки с переменной, ссылка уже не может быть связана с другой переменной.  Переменная может быть связана с несколькими ссылками.  В C++ вызов функции может происходить даже в левой части оператора при- сваивания.  Небезопасно возвращать локальную переменную по ссылке.  cin и cout являются объектами.  C++ позволяет использовать неименованные объединения.  Указатель иного типа может быть назначен указателю void без необходимости приведения типов.  Два следующих объявления взаимозаменимы: enum grade g ; grade g ;  Три следующих объявления дают одинаковый результат: int a = 10 ; int a ( 10 ) ; int a { 10 } ;  Два следующих объявления дают одинаковый результат: bool a ; BOOL a ;  Три следующих объявления дают одинаковый результат: cout << "\n" ; cout << '\n' ; cout << endl ;
60 Глава 2 2. Каков будет результат работы следующих программ:  (A) #include <iostream> using namespace std ; int main( ) { int i = 5 ;int &j = i ; int p = 10 ; j = p ; cout << i << endl << j << endl ; p = 20 ; cout << i << endl << j << endl ; return 0 ; }  (B) #include <iostream> using namespace std ; int main( ) { char *p = "Привет!" ; char *q = p ; cout << p << endl << q << endl ; q = "До встречи!" ; cout << p << endl << q << endl ; return 0 ; }  (C) #include <iostream> using namespace std ; int i = 20 ; int main( ) { int i = 5 ; cout << i << endl << ::i << endl ; return 0 ; }  (D) #include <iostream> using namespace std ;
Переходим на C++ int i = 20 ; int main( ) { int i = 5 cout << i { int i = cout << } return 0 ; } 61 ; << endl << ::i << endl ; 10 ; i << endl << ::i << endl ;  (E) #include <iostream> using namespace std ; const int i = 10 ; int main( ) { const int i = 20 ; cout << i << endl << ::i << endl ; cout << &i << endl << &::i << endl ; return 0 ; }  (F) #include <iostream> using namespace std ; int main( ) { int i ; cout << sizeof ( i ) << endl << sizeof ( 'i' ) << endl ; return 0 ; } 3. Укажите ошибки, если таковые имеются, в следующих фрагментах программного кода:  (A) #include <iostream> using namespace std ; int main( ) { for ( int i = 1 ;i <= 10 ;i++ ) cout << i << endl ; cout << i ; return 0 ; }
62 Глава 2  (B) #include <iostream> using namespace std ; int main( ) { int i = 5 ; int &j = i ; int &k = j ; int &l = i ; cout << i << j << k << l ; return 0 ; }  (C) #include <iostream> using namespace std ; int main( ) { int a = 10, b = 20 ; long int c ; c = a * long int ( b ) ; cout << c ; return 0 ; }  (D) #include <iostream> using namespace std ; const int i = 10 ; int main( ) { const int i = 20 ; cout << &i endl << &::i ; return 0 ; }  (E) #include <iostream> using namespace std ; int main( ) { char *p = "Здравствуй" ; p = "Привет" ;
Переходим на C++ *p = 'G' ; cout << p ; return 0 ; }  (F) #include <iostream> using namespace std ; int main( ) { enum result { first, second, third } ; result a = first ; int b = a ; result c = 1 ; result d = result ( 1 ) ; return 0 ; }  (G) #include <iostream> using namespace std ; int a = 10 ; int main( ) { int a = 20 ; { int a = 30 ; cout << a << ::a << ::::a ; } return 0 ; }  (H) #include <iostream> using namespace std ; struct emp { char name[ 20 ] ; int age ; float sal ; } ; emp e1 = { "Amol", 21, 2345.00 } ; emp e2 = { "Ajay", 19, 2300.00 } ; emp &fun( ) ; int main( ) 63
64 Глава 2 { fun( ) = e2 ; cout << endl << e1.name << endl << e1.age << endl << e1.sal ; return 0 ; } emp &fun( ) { emp e3 = { "Aditya", 21, 3300.75 } ; return e3 ; }  (I) #include <iostream> using namespace std ; int main( ) { char t[ ] = "Строковые функции - это просто!" ; int l = strlen ( t ) ; cout << l ; return 0 ; } 4. Ответьте на следующие вопросы:  Как бы вы объявили q в следующей программе, если первый cout должен дважды выводить «Интернет», а второй cout — дважды выводить «Интранет»? #include <iostream> using namespace std ; int main( ) { char *p = "Интернет" ; cout << p << q << endl ; q = "Интранет" ; cout << p << q << endl ; return 0 ; }  Если employee является структурой, REGS — союзом, а maritalstatus — пере- числением, то есть ли какой-либо иной способ, которым можно сделать следующие объявления? struct employee e ; union REGS i ; enum maritalstatus m ;  Могут ли следующие инструкции быть записаны иным способом: employee *p ; p = ( employee * ) malloc ( sizeof ( e ) ) ;
Переходим на C++ 65 float q ; int a, b ; q = ( float ) a / b ; 5. Создайте четыре целых числа, четыре указателя на эти числа и четыре ссылки на них. Сохраните эти указатели и ссылки в двух массивах и распечатайте значения четырех целых чисел, используя эти массивы. 6. Завершите следующую программу, определив функцию swapb( ) и ее прототип таким образом, чтобы результат выполнения программы был равен (20, 10): #include <iostream> using namespace std ; void swapa ( int &, int & ) ; int main( ) { int a = 10, b = 20 ; swapa ( a, b ) ; cout << a << b ; return 0 ; } void swapa ( int &x, int &y ) { swapb ( x, y ) ; } 7. Ответьте на вопросы ниже:  В каких случаях мы должны делать вызов по ссылке?  Почему использовать спецификатор const лучше, чем директиву #define?  Каковы преимущества cout и cin по сравнению с printf( ) и scanf( )?  Допустим ли такой комментарий? // Это не /* допустимый */ комментарий  На что указывает следующий прототип: const char *fun ( char const*, const char * ) ;  Любая программа на языке C также является корректной программой на C++.  С++98, С++11, С++14 и С++17 являются различными версиями С++.  C++ поддерживает два типа комментариев. Как правило, стиль // служит для создания однострочных, а стиль /* .. */ — для многострочных комментариев.
66 Глава 2  cout является объектом класса ostream. Он используется для вывода на экран.  endl используется для вывода на экран '\n'.  << является оператором вывода данных.  cin является объектом класса istream. Он используется для считывания ввода с клавиатуры.  >> является оператором ввода данных.  Операторы << и >> могут каскадироваться.  Классы ostream и istream объявлены в заголовочном файле iostream.  Объекты cout и cin определены в пространстве имен с именем std.  Чтобы использовать cout и cin, необходимо включить файл istream и использо- вать оператор using namespace в начале программы.  Если оператор using namespace не используется, то cout и cin должны иметь префикс std::.  cin и cout лучше, чем printf( ) и scanf( ), по двум причинам:  Не нужно запоминать и указывать спецификаторы формата.  Их можно использовать для ввода/вывода множества значений в объекте.  Переменную можно инициализировать тремя способами: int i = 20 ; int j ( 20 ) ; int k { 30 } ;  Можно использовать ключевое слово auto для вывода типа переменной по при- сваиваемому ей значению.  Можно использовать ключевое слово decltype для вывода типа переменной без присвоения ей значения.  Для объявления переменной структуры, объединения или перечисления не обя- зательно использовать ключевые слова struct, union или enum.  Неименованные объединения и перечисления обеспечивают прямой доступ к своим элементам, не требуя создания переменных объединения или перечисления.  Неименованные объединения удобно применять, когда они вложены в струк- туру.  Можно объявлять переменные непосредственно перед их использованием в про- граммном коде.  Если переменная объявлена в цикле, то она теряет свое значение, когда управле- ние из него выходит.  Допустимы обе операции приведения типов: n = ( y - 1 ) * ( long ) j ; n = ( y - 1 ) * long ( j ) ;
Переходим на C++ 67  Любой тип указателя может быть назначен пустому указателю (void).  Чтобы присвоить пустой указатель любому другому типу указателя, он должен быть соответствующим образом приведен к типу.  Оператор расширения области :: используется для доступа к глобальной пере- менной с тем же именем, что и у локальной переменной.  Ссылки — это постоянные указатели, которые автоматически разыменовыва- ются.  Ссылка может быть связана только с одной переменной.  Допускается множество ссылок на одну переменную.  Не допускается ссылка на ссылку.  Не допускается массив ссылок.  C++ поддерживает три типа вызовов функций:.  Вызов по значению — передача значений фактических аргументов.  Вызов по адресу — передача адресов фактических аргументов.  Вызов по ссылке — передача адресов фактических аргументов.  При вызове по ссылке изменение формальных аргументов в функции не приво- дит к изменению фактических аргументов.  При вызове по адресу и вызове по ссылке с использованием формальных аргу- ментов фактические аргументы могут подвергаться модификации.  В функции, вызываемой по адресу, для получения фактических аргументов не- обходимо использовать указатели и соответствующий синтаксис.  В функции, вызываемой по ссылке, для доступа к фактическим аргументам не- обходимо использовать ссылки и соответствующий синтаксис (упрощенный).  Вызов по адресу и вызов по ссылке также полезны при отправке больших объек- тов в функцию, поскольку при этом не создается копии передаваемого объекта.  Когда функция возвращает ссылку, вызов функции может существовать в лю- бом контексте, где может существовать ссылка, в том числе в левой части операции присваивания.  В ходе выполнения программного кода значение const-переменной нельзя изме- нить программными средствами.  Спецификатор const лучше, чем директива #define, поскольку область видимо- сти const может быть как локальной, так и глобальной.  У директивы #define всегда глобальная область видимости.  Спецификатор const также может использоваться для создания const-указателей, const-ссылок и строковых констант.  Если ссылка объявлена как константа, то при ее использовании переменная, с которой связана ссылка, не может быть изменена.
68 Глава 2  Структура и класс могут содержать функции-члены. Они также могут быть за- даны при помощи спецификатора const.  Логический (булев) тип данных может принимать только два значения — true («верно/истина») или false («неверно/ложь»).  Если в арифметических операциях используется логический тип, то значение true обрабатывается как 1, а false — как 0.  Если в логическом выражении используется целое число, то ненулевое значение рассматривается как true, а 0 — как false.
Функции Разработка программ на C строилась вокруг функций. С++ выводит их на абсолютно новый уровень. Функции в С++ эффективнее и мощнее. Помимо обычных функций, С++ также поддерживает экземплярные, статические, дружественные и виртуальные функции. Эта глава поможет вам освоить и по достоинству оценить мощь функций в С++
70 Глава 3  Строгая проверка типов  Исходные значения для аргументов функции  Перегрузка функций • Разница в типе возвращаемого значения • Можно ли задать разные типы данных при помощи typedef? • Можно ли задать разные типы данных при помощи const? • Разные задачи, одно имя  Перегрузка операторов • FAQ по перегрузке операторов  Встраиваемые функции • Зачем полагаться на компилятор? • А где гарантия? • Когда ими пользоваться?  Новый синтаксис возвращаемого типа  Функции instance, static, virtual и friend  Упражнения  Важное
Функции Ф 71 ункции (или методы) служат основным структурным компонентом, вокруг которого строятся программы на C. С++ выводит их на совершенно новый уровень. Функции С++ мощнее и гибче, причем во многих отношениях. Эти усовершенствования были нацелены на то, чтобы сделать программы на С++ более безопасными и читабельными, чем их предшественники на C. Некоторые средства, добавленные в функции С++, актуальны лишь для тех случаев, когда мы задействуем объектно-ориентированные возможности С++, тогда как другие применимы ко всем программам, включая объектно-ориентированные. В этой главе мы ограничимся теми улучшениями функций С++, которые применимы к программе любого типа. К числу таких средств относятся:  Строгая проверка прототипа.  Значения по умолчанию для аргументов функции.  Перегрузка функций.  Перегрузка операторов.  Встраиваемые функции. Рассмотрим каждое из них по отдельности. Строгая проверка типов Прототип функции — это объявление, определяющее имя функции, ее аргументы и тип возвращаемого значения. Ниже приведен ряд примеров объявлений прототипов функций: float square ( float ) ; char * strConvert ( char *, int ) ; double nthRoot ( float, float ) ; Каждое из этих объявлений четко указывает имя функции, количество, порядок и тип аргументов, которые каждая функция будет получать во время вызова, а также тип значения, которое каждая функция будет возвращать. Ни одна функция С++ не может быть вызвана, если ее прототип не доступен для компилятора. Компилятор использует прототип функции для перекрестной проверки на совпадение типов аргументов и типов возвращаемого значения в вызове и определении. Это называется строгой проверкой типов. В С она отсутствует. Обнаружив несовпадение типа, компилятор С++ незамедлительно указывает на ошибку. Кроме того, в C проверка типов относительно нестрогая. К примеру, там допустимо передавать значение как двойное число (double), а возвращать как число с плавающей точкой (float). С++ выдаст на это ошибку или предупреждение. Задание прототипов функций может означать чуть больше работы на начальном этапе написания программы. Тем не менее прототипы являются бесценными инструментами для обнаружения трудноуловимых ошибок. Язык С++ был разработан в стремлении предотвратить множество проблем, вызванных небрежностью программистов, в том числе связанных с передачей неправильных типов данных функ-
72 Глава 3 циям. Если вам действительно требуется передать число типа double как float, это можно сделать при помощи приведения типов. В таком случае вы явным (а не случайным) образом передаете значение одного типа в качестве аргумента параметру другого типа. Исходные значения для аргументов функции Функциям в С++ можно назначать исходные значения аргументов (значения по умолчанию). Если значения для параметров функции не переданы, то задействуются предустановленные значения аргументов. Если же значения переданы, то исходные значения переопределяются и вместо них используются переданные значения. Рассмотрим, как это происходит, на примере программы: #include <iostream> using namespace std ; int sum ( int n1, int n2, int n3 = 0, int n4 = 0 ) ; int main( ) { int s1, s2, s3 ; s1 = sum ( 10, 20 ) ; s2 = sum ( 10, 20, 30 ) ; s3 = sum ( 10, 20, 30, 40 ) ; cout << s1 << endl << s2 << endl << s3 << endl ; return 0 ; } int sum ( int n1, int n2, int n3, int n4 ) { return ( n1 + n2 + n3 + n4 ) ; } Обратите внимание на объявление прототипа функции sum( ). В нем указано, что 0 будет использоваться как значение параметров n3 и n4, если мы не передаем их в вызове. Таким образом, при первом вызове sum( ) n3 и n4 устанавливаются равными 0. Во втором вызове n3 присваивается значение 30, тогда как n4 принимает значение 0. В последнем вызове n3 и n4 присваиваются значения 30 и 40. Так, если значения не передаются в вызове, то для параметров функции применяются значения по умолчанию. Исходные значения для аргументов функции подчиняются ряду правил:  Значение по умолчанию для аргументов указывается лишь в прототипе функции, а не в ее определении. Для построения вызова компилятор использует информацию из прототипа функции, а не из ее определения.  Значения по умолчанию могут быть назначены только для конечных аргументов, но не для ведущих аргументов или аргументов в середине списка. Таким образом, следующее объявление неверно: int sum ( int n1, int n2, int n3 = 0, int n4 ) ;
Функции 73  Значением по умолчанию для аргумента может быть глобальная константа, гло- бальная переменная или даже вызов функции. Например, приведенный ниже прототип функции является допустимым: int myfunc ( int flag = display( ) ) ; В данном примере, если myfunc( ) вызывается без аргумента, то значение, возвращаемое функцией display( ), будет рассматриваться как флаговое значение по умолчанию. Исходные значения для аргументов функции полезны в двух случаях:  Если аргументы функции при ее вызове почти всегда имеют одно и то же значение.  Если после написания программы мы хотим расширить возможности функции, добавив еще один аргумент. При использовании предустановленных аргументов уже существующие вызовы функции могут продолжать использовать прежнее число аргументов, в то время как в новых вызовах можно задействовать большее число аргументов. Перегрузка функции В C каждая функция в программе должна иметь уникальное имя. Иногда это начинает раздражать. К примеру, если нам требуется привести к абсолютной величине значение числового аргумента, мы должны определить следующие три функции для каждого числового типа данных: int abs ( int i ) ; long labs ( long l ) ; double fabs ( double d ) ; Все эти функции выполняют одну и ту же задачу, поэтому присваивать каждой из них свое имя кажется излишним. Для разрешения такой ситуации С++ позволяет программисту создавать три разные функции с одним именем. Такой процесс называется перегрузкой функции. Рассмотрим следующий пример: #include <iostream> using namespace std ; int abso ( int ) ; long abso ( long ) ; double abso ( double ) ; int main( ) { int i = -25, j ; long l = -100000L, m ; double d = -12.34, e ;
74 Глава 3 j = abso ( i ) ; m = abso ( l ) ; e = abso ( d ) ; cout << j << endl << m << endl << e << endl ; return 0 ; } int abso ( int ii ) { return ( ii > 0 ? ii } long abso ( long ll ) { return ( ll > 0 ? ll } double abso ( double dd { return ( dd > 0 ? dd } : ii * -1 ) ; : ll * -1 ) ; ) : dd * -1 ) ; Откуда компилятору С++ известно, какую из функций abso( ) следует выбирать при вызове? Он выбирает по типу аргумента, передаваемого при вызове функции. К примеру, если передается целочисленный тип (int), то вызывается целочисленная версия abso( ), а если передается число двойной точности (double), то вызывается версия abso( ) для этого типа и т. д. Согласитесь, всё достаточно логично. А что произойдет, если мы сделаем такие вызовы? char ch = abso ( 'A' ) ; float f = abso ( 3.14f ) ; Мы не объявляли функцию abso( ) для обработки типов char или float. И, как следствие, компилятор С++ вызывает версию int в первом случае и версию double во втором. Если бы мы определили соответствующие функции, то вызывались бы они. Заметьте, что мы не использовали имя abs( ), т. к. оно конфликтует с одноименной функцией, присутствующей в стандартной библиотеке С++. А теперь перейдем к ряду специфичных вопросов, связанных с перегрузкой функций. Разница в типе возвращаемого значения Рассмотрим следующую программу для преобразования числовой строки в число: #include <iostream> using namespace std ; int stringToNumber ( char * ) ; long int stringToNumber ( char * ) ;
Функции 75 int main( ) { int n1 ; long int n2 ; char *ptr1 = "155" ; char *ptr2 = "400000" ; n1 = stringToNumber ( ptr1 ) ; n2 = stringToNumber ( ptr2 ) ; cout << n1 << endl << n2 ; return 0 ; } Даже если мы зададим перегруженные функции stringToNumber( ), компилятор все равно выдаст ошибку. Дело в том, что эти функции будут получать аргументы одного и того же типа и различаться лишь типом возвращаемого значения. Перегруженные функции должны как минимум отличаться типом данных, количеством или порядком принимаемых параметров. Разницы лишь в типе возвращаемого значения недостаточно, чтобы считать их перегруженными функциями. Можно ли задать разные типы данных при помощи typedef? Можем ли мы обмануть компилятор С++, представив ему два типа данных как различные, если переименуем один из них при помощи typedef-инструкции? Нет. Ключевое слово typedef просто присваивает еще одно имя в качестве дополнения к существующему имени типа, но не образует новый тип. Поэтому следующий фрагмент программного кода вызовет ошибку: typedef int INT; void display ( int ) ; void display ( INT ) ; Здесь INT является лишь еще одним именем для int. Следовательно, компилятор не сможет различить две версии функции display( ). Можно ли задать разные типы данных при помощи const? Следующая программа предназначена для отображения двух строк: #include <iostream> using namespace std ; void display ( char * ) ; void display ( const char * ) ;
76 Глава 3 int main( ) { char *ch1 = "Hello" ; const char *ch2 = "Пока!" ; display ( ch1 ) ; display ( ch2 ) ; return 0 ; } void display ( char *p ) { cout << p << endl ; } void display ( const char *p ) { cout << p << endl ; } Функция display( ) здесь построена корректно, поскольку типы char * и const char * различны. Так, первая версия display( ) получает указатель на строку, тогда как вторая получает указатель на константную строку. Разные задачи, одно имя Однако создавать перегруженные функции для выполнения разных видов задач — не самая удачная идея в программировании; функции с одинаковыми именами должны иметь одно общее назначение. К примеру, если мы напишем функцию abso( ), которая возвращает квадратный корень числа, это будет нелогично и лишь создаст путаницу. Поэтому применять перегруженные функции следует благоразумно. Их предназначение — предоставить общее имя для набора «схожих, но несколько отличающихся» функций. Злоупотребление перегруженными функциями может сделать программный код трудночитаемым. Перегрузка операторов Перегрузка операторов — одна из самых потрясающих возможностей С++. Благодаря ей сложноорганизованные и неудобочитаемые листинги программ преображаются в интуитивно понятный исходный код. Такие операторы, как +, *, -, <=, >=, обычно применяются лишь к стандартным типам данных, в частности, к целым числам (int), числам с плавающей точкой (float) и т. п. Однако в С++ мы можем перегрузить эти операторы, чтобы они работали даже с пользовательскими типами данных. Предположим, мы хотим выполнить вычисление комплексных чисел. Как известно, не существует стандартного типа данных для представления комплексного числа. Значит, мы можем использовать структуру для объявления пользовательского типа, как показано ниже:
77 Функции struct Complex { double real, imag ; } ; Затем мы можем создавать переменные этого типа и выполнять общие операции, такие как сложение, вычитание и умножение, как показано в следующем фрагменте кода: Complex a, b, c, d, e ; c = add ( a, b ) ; d = subtract ( a, b ) ; e = multiply ( a, b ) ; Вместо определения функций add( ), subtract( ) или multiply( ) следующая форма будет интуитивно более понятной: Complex c = a + d = a e = a * a, b, c, d, e ; b ; b ; b ; Но С++ не знает, как обрабатывать +, - или * для пользовательского типа Complex. Следовательно, нам нужно обучить его, перегрузив эти операторы. Как это сделать, показано в следующей программе: # include <iostream> using namespace std ; struct Complex { double real, imag ; } ; Complex setComplex ( double r, double i ) ; void printComplex ( Complex c ) ; Complex operator + ( Complex c1, Complex c2 ) ; Complex operator - ( Complex c1, Complex c2 ) ; int main( ) { Complex a, b, c, d ; a = setComplex ( 1.0, 1.0 ) ; b = setComplex ( 2.0, 2.0 ) ; c = a + b ; d = b + c - a ; cout << "c = " ;printComplex ( c ) ;cout << "d = " ; printComplex ( d ) ;
78 Глава 3 return 0 ; } Complex setComplex ( double r, double i ) { Complex temp ; temp.real = r ; temp.imag = i ; return temp ; } void printComplex ( Complex t ) { cout << "( " << t.real << ", " << t.imag << ")" << endl ; } Complex operator + ( Complex c1, Complex c2 ) { Complex temp ; temp.real = c1.real + c2.real ; temp.imag = c1.imag + c2.imag ; return temp ; } Complex operator - ( Complex c1, Complex c2 ) { Complex temp ; temp.real = c1.real - c2.real ; temp.imag = c1.imag - c2.imag ; return temp ; } В этой программе операторы + и - обучаются работать с пользовательским типом данных Complex. Достигается это путем объявления функции с использованием ключевого слова operator и самого оператора, предназначенного для перегрузки, как показано ниже. Complex operator + (Complex c1, Complex c2 ) ; Это объявление сообщает компилятору, что функция перегруженного оператора operator + () получает два аргумента типа Complex и возвращает тип Complex. Эта функция будет вызываться, если операция сложения выполняется над операндами типа Complex. В действительности перегрузка операторов позволяет существенно расширить возможности операторов С++. Перегрузка операторов проявляет себя во всей красе в сочетании с классами в С++. Мы продемонстрируем это в главе 4. Уверен, что тогда вы сможете по-настоящему оценить концепцию перегрузки операторов.
Функции 79 FAQ по перегрузке операторов Прежде чем закрыть эту тему, позвольте мне ответить на три наиболее часто задаваемых вопроса о перегрузке операторов.  Какие операторы не подлежат перегрузке?  Нельзя перегрузить операторы ., :: и ? :. Если у оператора есть унарная и бинарная формы (например, операторы + или &), то обе могут быть перегружены.  Каков приоритет операторных функций? Операторные функции имеют тот же приоритет, что и собственные операции с тем же оператором. К примеру, оператор * всегда имеет более высокий уровень старшинства, чем оператор +. Приоритет оператора изменить невозможно.  Можно ли переопределить встроенные операторы? На самом деле вы спрашиваете о том, возможно ли создать свой собственный оператор для сложения пары целых чисел. Ответ — нет. Если позволить изменять поведение собственных операций, то любая программа может стать нечитаемой. Встраиваемые функции Одним из значительных преимуществ применения функций является то, что они позволяют экономить место в памяти. Поскольку при каждом вызове функции выполняется один и тот же код, тело функции не нужно дублировать в памяти. Представьте ситуацию, когда небольшая функция вызывается в программе несколько раз. При каждом вызове функции возникает определенная непроизводительная нагрузка: время тратится на передачу значений, передачу управления, возврат значения и возврат управления. В программе на языке C этих нагрузок можно избежать, задав вместо функции эквивалентный ей макрос. Это повышает скорость выполнения кода, однако у макросов есть ряд недостатков, особенно при наличии аргументов. Рассмотрим, к примеру, следующий макрос: #define SQUARE( x ) ( x * x ) Если использовать его в выражении int y = SQUARE ( 3 + 1 ) ; он преобразуется в y = 3 + 1 * 3 + 1 вместо ожидаемого y = (3 + 1) * (3 + 1). Во избежание таких нежелательных результатов в C++ была введена концепция встраиваемой функции. При ее использовании обращения ко встраиваемой функции заменяются определенными в ней инструкциями. За отсутствием вызовов функции снимается и связанная с ними лишняя нагрузка. Следующий листинг демонстрирует работу встраиваемой функции:
80 Глава 3 #include <iostream> using namespace std ; inline void reportError ( char *str ) { cout << endl << str ; exit ( 1 ) ; } int main( ) { // код открытия исходного файла if ( fileOpeningFailed ) reportError ( "Невозможно открыть исходный файл" ) ; // код открытия целевого файла if (fileOpeningFailed ) reportError ( "Невозможно открыть целевой файл" ) ; // код копирования содержимого исходного файла в целевой файл return 0 ; } Заметьте, что функция должна быть объявлена встраиваемой (с помощью ключевого слова inline) до ее вызова. При компиляции содержимое функции reportError( ) будет вставлено в две части нашей программы, туда, где вызывается функция reportError( ). Зачем полагаться на компилятор? У вас наверняка возник вопрос: зачем использовать компилятор, чтобы встроить код функции в код другой программы, когда мы легко можем сделать это сами? Проблема с многократной вставкой одного и того же кода заключается в том, что вы теряете преимущества четкого и ясного построения программы, которые привносит использование функций. Программа может работать быстрее и занимать меньше пространства в памяти, но листинг будет длиннее и сложнее. И напротив, если мы помещаем код во встраиваемую функцию, исходный файл остается хорошо организованным и легко читаемым, поскольку функция отображается как отдельный объект. А при компиляции в объектный код тело функции размещается непосредственно в тех позициях, где происходит ее вызов. А где гарантия? Заметьте, что при определении встраиваемой функции нет гарантии, что ее код будет размещен там, где выполняется вызов. Происходит это потому, что мы всего лишь делаем запрос к компилятору. Язык C++ не регламентирует, при каких усло-
Функции 81 виях компилятор может игнорировать запрос. В силу данной неоднозначности в спецификации языка разработчики компиляторов могут достаточно свободно интерпретировать эти требования. Когда ими пользоваться? Использовать спецификатор встраиваемой функции inline следует лишь для небольших функций. При большом размере правильнее предпочесть обычные функции, т. к. экономия места в памяти стоит сравнительно небольшой потери в скорости выполнения. Новый синтаксис возвращаемого типа Начиная с версии С++11, был введен новый синтаксис, называемый хвостовой возвращаемый тип, для указания типа значения, возвращаемого функцией. Рассмотрим, к примеру, следующее объявление прототипа: int addNum ( int x, int y ) ; Это объявление может быть также записано следующим образом: auto addNum ( int x, int y ) -> int ; Здесь ключевое слово auto никак не связано с определением типа. Это всего лишь часть синтаксиса для хвостового возвращаемого типа. В С++14 ключевое слово auto также может определять тип возвращаемого значения функции. Например, в следующей функции, раз сумма x + y вычисляется как целое число, компилятор решает, что тип возвращаемого значения этой функции — int auto addNum ( int x, int y ) { return x + y ; } По моему убеждению, такой формы записи следует избегать, т. к. при отсутствии определенного типа возвращаемого значения вызывающая сторона может неправильно его интерпретировать, что, в свою очередь, может привести к непредсказуемым ошибкам. Функции instance, static, virtual и friend В C++ существует еще четыре типа функций: экземплярные (instance), статические (static), виртуальные (virtual) и дружественные (friend). Однако все они связаны с классами. Поэтому их обсуждение мы продолжим, когда познакомимся с классами. Экземплярные функции (instance) объясняются в главе 4, статические функции (static) — в главе 6, виртуальные функции (virtual) — в главе 8 и дружественные функции (friend) — в главе 10.
82 Глава 3 Имея за плечами столько знаний, мы уже вполне готовы погрузиться в объектноориентированный мир... что мы и сделаем уже в следующей главе, но пока несколько упражнений. 1. Укажите, верны ли следующие утверждения:  Обязательно объявлять прототип вызываемой функции.  Две функции могут быть перегружены, если их аргументы похожи, но возвра- щаемые значения различны.  Две функции могут быть перегружены только тогда, когда их аргументы разли- чаются по количеству, порядку или типу.  Если для четырех аргументов в прототипе функции указаны значения по умол- чанию, мы можем вызвать эту функцию и передать ей первый и четвертый аргументы.  Функция может быть перегружена сколько угодно раз.  Оператор присваивания не может быть перегружен.  Когда мы определяем функцию как встраиваемую (inline), нет гарантии, что ее код будет вставлен в то место, где выполняется вызов.  Нежелательных результатов, связанных с определением макросов, удастся из- бежать, если использовать вместо них встраиваемые функции. 2. Укажите ошибки, если таковые имеются, в следующих фрагментах программного кода:  (A) int main( ) { int a = 30 ; fun( ) ; return 0 ; } void fun( ) { int b = 20 ; }  (B) #include <iostream> using namespace std ; void fun( ) { cout << "Привет" ; }
Функции int main( ) { fun( ) ;return 0 ; }  (C) #include <iostream> using namespace std ; int fun ( int, int ) ; int fun ( int, int ) ; int main( ) { int a ; a = fun ( 10, 30 ) ; cout << a ; return 0 ; } void fun ( int x, int y ) { return x + y ; }  (D) #include <iostream> using namespace std ; int main( ) { void fun1 ( void ) ; void fun2 ( void ) ; fun1( ) ; return 0 ; } void fun1 ( void ) { fun2( ) ; cout << endl << "Привет!...Доброго" ; } void fun2 ( void ) { cout << endl << "вам дня" ; }  (E) #include <iostream> using namespace std ; 83
84 Глава 3 void fun ( int, float ) ; int main( ) { fun( ) ; return 0 ; } void fun ( int i = 10, float a = 3.14 ) { cout << i << a ; }  (F) #include <iostream> using namespace std ; void fun ( int = 10, int = 20, int = 30 ) ; void fun ( int, int ) ; int main( ) { fun ( 1, 2 ) ; return 0 ; } void fun ( int x, int y, int z ) { cout << endl << x << endl << y << endl << z ; } void fun ( int x, int y ) { cout << endl << x << endl << y ; } 3. Решите следующие задачи и ответьте на вопросы:  Напишите программу, вызывающую функцию cls( ). Эта функция должна очищать часть экрана. При вызове функции без каких-либо аргументов она должна очищать весь экран. За максимальный размер экрана примите 25 строк и 80 столбцов.  Предположим, что задана функция со следующим прототипом: void fun ( int = 10, int = 20, int = 30, int = 40 ) ; Если эта функция вызывается с передачей ей двух аргументов, то как нам удостовериться в том, что эти аргументы обрабатываются как первый и третий, тогда как второй и четвертый принимаются по умолчанию.  Составьте программу, задающую функцию display( ), прототип которой приве- ден ниже: void display ( char ch = '*', int num = 80 ) ;
Функции 85 Вызовите эту функцию для вывода следующих строк: ***** ------=====  Напишите перегруженные функции для преобразования int в строку и для пре- образования float в строку.  Напишите перегруженные функции для преобразования строки в int и преобра- зования строки в float.  Объявите структуру с именем matrix, содержащую массив целых чисел 3 x 3. Перегрузите оператор +, чтобы выполнить сложение двух матриц.  Составьте прототипы для: • функции, которая получает числа типов int и float, а возвращает число типа double. • функции, которая получает указатель типа int и ссылку типа float, а возвращает указатель типа int. • функции, которая ничего не получает и ничего не возвращает. • функции, которая получает массив чисел типа int и ссылку типа float и ничего не возвращает.  Какие операторы нельзя перегрузить?  Можно ли изменить приоритет операторов, перегрузив их?  Требуется объявлять прототип вызываемой функции.  Компилятор C++ использует строгую проверку прототипов для корректной передачи вызова функции.  При объявлении прототипа значения по умолчанию могут быть назначены толь- ко для конечных аргументов функции.  Можно перегрузить несколько функций, выполняющих схожие задачи, но отли- чающихся аргументами.  Перегруженные функции имеют одинаковое имя, но их аргументы различаются по количеству, порядку или типу.  Различие в возвращаемых типах значений не является достаточным основанием для перегрузки функций.  Различия в аргументах перегруженных функций не могут быть основаны на переименовании типа при помощи ключевого слова typedef.
86 Глава 3  Операторы могут быть перегружены для того, чтобы сделать операции с пользо- вательскими типами интуитивно понятными.  Приоритет операторов нельзя изменить при помощи их перегрузки.  Нельзя перегрузить операторы ., :: и ? :.  Перегрузка операторов невозможна для фундаментальных типов, таких как int или float.  Встраиваемые функции сочетают преимущества функции и макроса.  Вызов встраиваемой функции заменяется кодом, содержащимся в ее опреде- лении.  Определение функции как встраиваемой не гарантирует, что компилятор C++ выполнит ее вставку.
Классы и объекты Классы и объекты играют в ООП ту же роль, что и функции в процедурном программировании. Добротные базовые знания о классах и объектах сделают вас умелым программистом на С++
88 Глава 4  Структуры и классы  Классы и конструкторы  Деструкторы  Класс Complex  Указатель this  Перегрузка унарных операторов  Объекты и память  Еще раз о структурах и классах  Идеальная организация программы  Упражнения  Важное
Классы и объекты П 89 ознакомившись с необъектно-ориентированными расширениями С++ (исходными значениями для аргументов функций, перегруженными функциями, перегруженными операторами, вызовом по ссылке, спецификатором const), пришло время перейти к объектно-ориентированным функциям C++. Сначала познакомимся со структурами и классами. Структуры и классы Структура в C — это набор похожих или различающихся типов данных. C++ расширяет возможности структур, позволяя включать в структуры функции. Объединение данных и функций, их обрабатывающих, в единое целое является краеугольным камнем объектно-ориентированного программирования. В C++ к структурам добавляется ещё один элемент: класс, который также может хранить данные и функции. В синтаксисе структуры и класса почти нет отличий, и потому (по крайней мере в теории) они взаимозаменяемы. Однако большинство программистов на C++ используют структуры для хранения данных, а классы для хранения данных и функций. Начнем с программы, демонстрирующей синтаксис и общие свойства класса: #include <iostream> using namespace std ; class Rectangle { private : int len, br ; public : void getData( ) { cout << endl << "Введите длину и ширину " ;cin >> len >> br ; } void setData ( int l, int b ) { len = l ;br = b ; } void displayData( ) { cout << endl << "длина = " << len ;cout << endl << "ширина = " << br ; } void areaPeri ( ) { int a, p ; a = len * br ; p = 2 * ( len + br ) ; cout << endl << "площадь = " << a ; cout << endl << "периметр = " << p << endl ; } } ;
90 Глава 4 int main( ) { Rectangle r1, r2, r3 ; // определение трех объектов класса Rectangle r1.setData ( 10, 20 ) ; // присвоение данных элементам объекта r1.displayData( ) ; // отображение данных, присвоенных в setData( ) r1.areaPeri( ) ; // расчет и вывод площади и периметра r2.setData ( 5, 8 ) ; r2.displayData( ) ; r2.areaPeri( ) ; r3.getData( ) ; // получение данных с клавиатуры r3.displayData( ) ; r3.areaPeri( ) ; return 0 ; } Взгляните на объявление класса Rectangle в нашей программе:  Слово class ключевое, за ним следует слово Rectangle, являющееся именем класса. Аналогично структуре тело класса заключается в фигурные скобки и ограничивается точкой с запятой.  Переменные, объявленные в классе, называются членами данных, а функции, объявленные в нем, называются функциями-членами (компонентными функциями) или методами.  Класс Rectangle включает два члена данных, len и br, а также четыре функции- члена — setData( ), getData( ), displayData( ) и areaPeri( ). Класс содержит два незнакомых нам ключевых слова — private и public. Они управляют доступом к членам класса. Закрытые члены (private members) недоступны за пределами класса, а открытые члены (public members) доступны. Хотя это и не правило, обычно члены данных объявляются закрытыми (private), а функции-члены — открытыми (public). Таким образом, члены данных хранятся в безопасности. К ним нельзя получить доступ или преобразовывать их извне класса. Если же нам потребуется это сделать, то мы должны обращаться к ним через открытые функции-члены класса: в пределах класса любой его член может получить доступ к любому другому члену этого класса. Эта концепция обеспечения безопасности членов данных и предоставления им доступа через функции-члены называется скрытием данных. Поскольку функциичлены обеспечивают систематический доступ к скрытым членам данных, их нередко называют интерфейсом класса. Не путайте скрытие данных с методами безопасности, применяемыми для защиты компьютерных данных. Методы безопасности предотвращают неавторизированный доступ к информации злоумышленников, тогда как скрытие данных используется для защиты благонамеренных пользователей от случайных ошибок.
91 Классы и объекты Посмотрим теперь, как функция main( ) задействует объявленный класс. Для этого нам требуется создать объекты этого класса. Так, в main( ) мы создали три объекта r1, r2 и r3 типа Rectangle. Для сравнения взглянем на следующие объявления: int i ; Rectangle r1 ; В этом примере int — встроенный тип данных, а Rectangle — пользовательский. Элементы i и r1 являются переменными типов int и Rectangle соответственно. В терминологии ООП r1 называется объектом класса Rectangle. Объект также часто называют экземпляром (instance) класса, а операция создания объекта называется инстанцированием (instantiation), созданием экземпляра. В нашей программе мы определили три объекта — r1, r2 и r3. У каждого объекта есть собственные члены данных len и br. Эти объекты схематично представлены на рис. 4.1. Рис. 4.1. Схема объектов Следующая инструкция r1.setData ( 10, 20 ) ; вызывает компонентную функцию setData( ). Поскольку setData( ) — это функциячлен (или метод) класса Rectangle, она всегда должна обращаться к объекту этого класса. Это обусловлено тем, что метод можно вызывать лишь относительно заданного объекта. Операция точки (.) связывает имя объекта и функцию-член. Синтаксис вызова аналогичен тому, как мы ссылаемся на элементы структуры, при этом круглые скобки указывают на то, что мы обращаемся к функции-члену, а не к полю данных. Оператор «точка» также называется «оператором доступа к члену класса». Первый вызов метода setData( ): r1.setData ( 10, 20 ) ; выполняет функцию-член setData( ) объекта r1. Эта функция присваивает переменным len и br объекта r1 значения 10 и 20 соответственно. Таким же образом второй вызов setData( ) устанавливает значения переменных во втором объекте. Метод displayData( ) отображает значения переменных len и br заданного объекта. Аналогичным образом функция areaPeri( ) вычисляет и выводит на экран площадь и периметр, используя значения переменных len и br заданного объекта.
92 Глава 4 Если мы попытаемся произвести вызов: setData ( 10, 20 ) ; компилятор сообщит об ошибке, т. к. мы не указали, с данными какого объекта мы собираемся работать. Классы и конструкторы В предыдущем разделе мы познакомились с классами. Напомню, класс содержит члены данных (поля класса) и функции-члены. Давайте продвинемся еще на шаг вперед. Внимательно изучите следующую программу: #include <iostream> using namespace std ; class Integer { private : int i ;public : void getData( ) { cout << endl << "Введите любое целое число " ;cin >> i ; } void setData ( int j ) { i = j ; } Integer( ) // конструктор без аргументов { } Integer ( int j ) // конструктор с одним аргументом { i = j ; } void displayData( ) { cout << endl << "значение i = " << i << endl ; } } ; int main( ) { Integer i1 ( 100 ), i2, i3 ; i1.displayData( ) ; i2.setData ( 200 ) ; i2.displayData( ) ;
Классы и объекты 93 i3.getData( ) ; i3.displayData( ) ; return 0 ; } Наша программа демонстрирует три способа, при помощи которых мы можем предварительно присвоить значения членам данных объекта:  Первый способ — посредством функции setData( ), которой мы передаем устанавливаемое значение.  Второй способ — получение значений с клавиатуры, как показано на примере функции getData( ). Это подводит нас к третьему методу, в котором используется новый элемент, именуемый конструктором. Это специальная компонентная функция, позволяющая устанавливать значения при инициализации объекта без необходимости обращаться к какой-либо функции-члену. Иными словами, конструктор — это функция-член, которая выполняется автоматически всякий раз, когда создается объект. С функциями-конструкторами связан ряд необычных аспектов:  Во-первых, они не случайно имеют в точности то же имя, что и класс, членами которого они являются. На деле существует правило, по которому класс и его конструктор обязаны иметь одинаковые имена. Благодаря этому компилятор узнает, что функция-член является конструктором.  Во-вторых, в объявлении конструктора отсутствует тип возвращаемого значе- ния. Почему? Потому что конструктор вызывается автоматически при создании объекта и, следовательно, нет смысла возвращать значение. В нашей программе инструкция: Integer i1 ( 100 ), i2, i3 ; создает три объекта типа Integer. При создании каждого из них выполняется конструктор: функция Integer( ). Таким образом, результатом выполнения этой простой команды становится не только создание трех объектов, но и инициализация их переменных i. Обратите внимание на то, что класс Integer имеет два конструктора с одинаковыми именами Integer( ). Они называются перегруженными конструкторами. От того, сколько аргументов используется в определении объекта, зависит какой из двух конструкторов вызывается при его создании: integer i2 ; // вызывает конструктор без аргументов integer i1 ( 100 ) ; // вызывает конструктор с одним аргументом Класс может содержать несколько перегруженных конструкторов в зависимости от того, как мы хотим инициализировать его объекты. Вы спросите, можно ли создать объект на основе класса, не имеющего конструктора? Да, поскольку в тех случаях, когда мы не определяем конструктор сами, компи-
94 Глава 4 лятор автоматически создает конструктор без аргументов. Конструктор без аргументов часто называют неявным конструктором (implicit constructor) или конструктором по умолчанию (default constructor). На заметку: если мы задаем конструктор с одним аргументом, мы уже не можем рассчитывать на то, что компилятор предоставит конструктор по умолчанию. При необходимости мы должны определить его сами. И наконец, конструктор можно вызвать для любого объекта лишь один раз — при его создании. Если же мы хотим позже изменить значения в нашем объекте, мы можем сделать это через вызов функции setData( ). Деструкторы Теперь мы знаем, что специальная функция-член (конструктор) вызывается автоматически при создании объекта. Аналогичным образом при разрушении объекта автоматически вызывается функция, называемая деструктором. У деструктора то же имя, что и у конструктора (и они оба совпадают с именем класса), но оно предваряется символом «тильда» (⁓). Следующий листинг демонстрирует работу деструктора: #include <iostream> using namespace std ; class example { private : int data ; public : example( ) // конструктор { cout << endl << "Внутри } ~example( ) // деструктор { cout << endl << "Внутри } } ; int main( ) { example e ; return 0 ; } (то же имя, что у класса) конструктора" ; (то же имя с тильдой) деструктора" << endl ; При создании объекта e вызывается конструктор. Когда управление выходит за пределы функции main( ), объект e разрушается. Это вызывает функцию деструктора. Как и конструкторы, деструкторы не возвращают значения. Они также не принимают аргументов (предполагается, что есть лишь один способ разрушить объект).
Классы и объекты 95 Чаще всего деструкторы используются для освобождения памяти, выделенной под объект конструктором. Мы подробнее рассмотрим этот вопрос дальше. Класс Complex Как известно, комплексное число состоит из вещественной части и мнимой части. Следующая программа воплощает на практике концепцию перегрузки конструкторов, операторов и деструкторов при разработке класса для реализации комплексных чисел: #include <iostream> using namespace std ; class Complex { private : float real, imag ; public : Complex( ) { } Complex ( float r, float i ) { real = r ;imag = i ; } void displayData( ) { cout << "вещественная часть = " << real << " мнимая часть = " << imag << endl ; } void addComplex ( Complex x, Complex y ) { real = x.real + y.real ; imag = x.imag + y.imag ; } Complex addComplex ( Complex y ) { Complex t ; t.real = real + y.real ;t.imag = imag + y.imag ; return ( t ) ; } Complex operator + ( Complex y ) { Complex t ; t.real = real + y.real ;t.imag = imag + y.imag ; return ( t ) ; } } ;
96 Глава 4 int main( ) { Complex c1 ( 1.1f, 2.2f ), c2 ( 3.5f, 4.4f ), c3, c4, c5 ; c3.addComplex ( c1, c2 ) ; c3.displayData( ) ; c4 = c1.addComplex ( c2 ) ; c4.displayData( ) ; c5 = c1 + c2 ; c5.displayData( ) ; return 0 ; } В этой программе мы задействовали параметризованные конструкторы с двумя аргументами для инициализации объектов c1 и c2. Далее мы применили три разных способа сложения двух комплексных чисел. Они показаны ниже. c3.addComplex ( c1, c2 ) ; c4 = c1.addComplex ( c2 ) ; c5 = c1 + c2 ; Как видите, addComplex( ) — это перегруженная функция. В ее первой версии, где складываются два комплексных числа, ей передаются c1 и c2. Они присваиваются x и y, корректно складываются и сохраняются в c3. Почему результат сложения сохраняется в c3? Потому что мы вызвали компонентную функцию для c3. Второй вызов функции addComplex( ) производится для c1. В этом вызове функции передается c2. Следовательно, при выполнении сложения real относится к real из c1, а y.real относится к real из c2. Результат сложения сохраняется в объекте t типа Complex. В конце сложения t возвращается и присваивается c4 в main( ). Рассмотрим более подробно вызов перегруженного оператора +: c5 = c1 + c2 ; // вызов Компилятор преобразует эту команду в: c5 = c1.operator + ( c2 ) ; Таким образом, вызов функции operator +( ) производится для c1. В этом вызове функции передается c2. Следовательно, при выполнении сложения real относится к real из c1, а y.real относится к real из c2. Результат сложения сохраняется в объекте t типа Complex. В конце сложения t возвращается и присваивается c5 в теле функции main( ). В качестве упражнения можете попробовать реализовать функцию перегрузки оператора * для умножения двух комплексных чисел. Также удостоверьтесь в том, что вы получили корректный результат для операции: c6 = c1 + c2 * c3 ;
Классы и объекты 97 Указатель this Мы знаем, что каждый объект, созданный на основе класса, имеет свой собственный набор членов данных. Когда мы вызываем компонентную функцию для объекта, задействованные в ней члены данных ссылаются на данные того объекта, посредством которого она вызывается. Давайте разберемся в этом на примере следующей программы. class Sample { private : int i ; float j ; public : void setData ( int x, float y ) { i = x ; j = y ; } } ; int main( ) { Sample s1, s2 ; s1.setData ( 10, 3.14f ) ; s2.setData ( 20, 6.28f ) ; } Здесь два объекта s1 и s2 типа Sample имеют по своему набору элементов данных i и j. Когда мы используем i и j в методе setData( ), какому объекту они принадлежат, s1 или s2? Для простоты понимания мы говорим, что когда мы производим вызов для объекта s1, i и j являются членами данных объекта s1, а когда мы производим вызов для объекта s2, i и j являются членами данных объекта s2. Реальный же механизм, который здесь задействован, — это специальный указатель, именуемый thisуказателем. Попытаемся понять, как работает this-указатель. Когда мы совершаем вызов: s1.setData ( 10, 3.14f ) ; вместе с 10 и 3.14f в setData( ) также передается и адрес s1. Функция setData( ) хранит этот адрес в указателе с именем this. Тип указателя this — Sample *. Когда мы обращаемся к i и j в функции setData( ), они задействуются в форме: this->i = x ; this->j = y ;
98 Глава 4 Поскольку this содержит в этом вызове адрес объекта s1, i и j принадлежат объекту s1. Когда же мы совершаем вызов посредством объекта s2, this-указатель содержит адрес объекта s2, а i и j относятся к i и j объекта s2. Легко убедиться в том, что this-указатель содержит адреса объектов s1 и s2 в первом и втором вызове соответственно, добавив следующую команду в setData( ): cout << "Текущий адрес указателя this = " << this << endl ; Заметьте, что тип this-указателя — Sample * const. По этой причине он не может измениться во время вызова и начать указывать на какой-либо другой объект, кроме того, который используется для вызова setData( ). Также, подобно любой локальной переменной, он разрушается, когда функция setData( ) возвращает управление. Указатель this работает для каждого вызова, сделанного с использованием объекта. Перегрузка унарных операторов Как известно, унарные операторы производят действие над одним операндом. Примерами унарных операторов являются операторы инкремента и декремента ++ и --, а также унарный минус, как в -45, например. Давайте реализуем перегруженный унарный оператор «++» для класса с именем Index. При перегрузке этого оператора мы должны иметь возможность различать его префиксную и постфиксную формы, приведенные ниже: d = ++c ; // префиксная версия оператора «++» f = e++ ; // постфиксная версия оператора «++» В первом случае оператор «++» сначала инкрементирует объект c, а затем его значение присваивается объекту d. И напротив, во втором случае сначала значение объекта e присваивается f, а уже затем происходит инкрементирование e. Чтобы различать две версии перегруженной операторной функции ++, в постфиксной форме в качестве аргумента используется int. На самом деле int не является аргументом и не означает целое число. Это лишь сигнал компилятору о том, что следует различать две версии операторных функций. Ниже приведена реализация класса Index, содержащего обе версии перегруженной функции оператора «++»: #include <iostream> using namespace std ; class Index { private : int count ; public : Index( )
Классы и объекты { count = 0 ; } Index ( int i ) { count = i ; } Index operator ++ ( ) { ++count ;return *this ; } Index operator ++ ( int ) { Index temp ( count ) ; // вызывает конструктор count++ с одним аргументом ; return temp; } void showdata( ) { cout << count << endl ; } } ; int main( ) { Index c, d, e, f ; cout << "c = " ; c.showdata( ) ; d = ++c ; cout << "c = " ; c.showdata( ) ; cout << "d = " ; d.showdata( ) ; cout << "e = " ; e.showdata( ) ; f = e++ ; cout << "e = " ; e.showdata( ) ; cout << "f = " ; f.showdata( ) ; return 0 ; } А вот результат работы программы: c c d e = = = = 0 1 1 0 99
100 Глава 4 e = 1 f = 0 Обратите внимание на следующие моменты:  Вызовы перегруженной операторной функции приводятся к форме: d = c.operator ++ ( ) ; // вызывает префиксную версию f = e.operator ++ ( 0 ) ; // вызывает постфиксную версию Поскольку количество аргументов у каждого вызова разное, компилятору легко определить, к какой версии перегруженной функции должен быть привязан вызов.  В обеих версиях мы возвращаем объект класса Index, поскольку он должен быть присвоен объектам d или f.  При вызове префиксной формы инкремента this-указатель содержит адрес объекта c. Мы инкрементируем счетчик (count) c и возвращаем его, используя выражение *this. Мы могли бы сделать то же самое другим способом: Index temp ; ++count ; temp.count = count ; return temp ; Однако создание нового объекта temp потребляет лишние ресурсы. Так что лучше им не пользоваться. Объекты и память Если объект подобен переменной, можем ли мы узнать количество байтов, занимаемых им в памяти? Да, можем, при помощи оператора sizeof, который применяется для встроенных типов данных. Испытаем его на деле: #include <iostream> using namespace std ; class Sample { private : int i ; float a ; char ch ; public : Sample ( int j, float b, char dh ) { i = j ; a = b ; ch = dh ; } } ;
101 Классы и объекты int main( ) { Sample s1 ( 10, 3.14f, 'A' ) ; Sample s2 ( 20, 6.28f, 'B' ) ; cout << sizeof ( s1 ) << endl ; cout << sizeof ( s2 ) << endl ; return 0 ; } Программа сообщает размер объектов s1 и s2, равный 12 байтам, что является суммой размеров i, a и ch. Это означает, что размер любого объекта представляет собой сумму размеров его членов данных. Отсюда другой важный вопрос: влияют ли функции на размер объекта? Нет, не влияют, поскольку все объекты данного класса используют одни и те же функции-члены. И это логично, поскольку функции каждого объекта идентичны. Нет смысла дублировать все компонентные функции всякий раз, когда создается новый объект. А вот полям класса будут присваиваться различные значения, поэтому у каждого объекта должен быть свой собственный набор членов данных. В нашей программе два объекта типа Sample, поэтому в памяти хранится по два экземпляра полей i, a и ch. Тем не менее имеется лишь одна копия конструктора и функции display( ). Эти функции общие для всех объектов класса, что не приводит к конфликту, поскольку в каждый отдельно взятый момент времени выполняется лишь одна функция. В процессе написания программного кода мы редко задумываемся о том, существует ли отдельная копия функции-члена для каждого объекта или все объекты совместно используют единственную копию. Однако понимание того, что реально происходит за кулисами, никогда не бывает лишним. Еще раз о структурах и классах В принципе, во всех случаях, где используется класс, можно использовать и структуру. Поэтому два приведенных ниже объявления эквивалентны: class Sample { private : int data ; public : void fun( ) { // код } } ; struct Sample { private : int data ; public : void fun( ) { // код } } ;
102 Глава 4 Единственная формальная разница между классом и структурой заключается в том, что члены класса по умолчанию закрыты (private), а в структуре они по умолчанию открыты (public). Таким образом, можно смело удалить ключевое слово private из объявления класса, приведенного выше. Однако в целях ясности рекомендуется явно его указать. И хотя по умолчанию все поля структуры открыты, мы не можем отказаться от ключевого слова public. При этом определение private распространится даже на функцию fun( ). Если мы по-прежнему хотим избавиться от ключевого слова public, нам придется определить функцию перед членами данных, как показано ниже: struct Sample { void fun( ) { // код } private : int data ; } ; Хотя теоретически можно обойтись структурой везде, где используется класс, в большинстве случаев программисты на C++ предпочитают использовать структуры для объединения данных, а классы — для объединения данных и функций. Идеальная организация программы Во всех программах этой главы мы определили класс и клиентский код, использующий этот класс, т. е. функцию main( ), в одном файле. Это не идеальный способ организации программы по следующим причинам. Если в какой-либо другой программе потребуется использовать тот же класс, нам придется скопировать его из одного файла и вставить в файл новый программы. Это трудоемкая задача, если речь идет о множестве классов. Если сторонний программист захочет использовать наш класс, нам также придется поделиться исходным кодом класса, что равносильно разглашению коммерческой тайны. Чтобы избежать подобных затруднений в будущем, нам следует разбить программу на 3 файла:  файл .h, в котором мы просто объявляем класс;  файл .cpp, в котором мы определяем функции-члены класса;  файл .cpp, использующий первые два файла класса (его часто называют клиент- ским кодом). Объявление класса в файле .h содержит поля данных и объявление прототипа функций-членов. Основа класса, т. е. определение функций-членов, хранится в от-
Классы и объекты 103 дельном файле .cpp. Такой подход позволяет отделить интерфейс класса от его реализации, что считается хорошей (и безопасной) практикой разработки программного обеспечения. Более того, программистам, желающим использовать наш класс, мы можем предоставить только скомпилированный код реализации класса и файл .h. Поскольку мы не предоставляем файл .cpp, секрет реализации остается у нас. Мы вынуждены предоставить файл .h, иначе те, кто захочет применить наш класс в работе, никогда не узнают ни имя класса, ни имена его функций-членов, их параметры и типы возвращаемых значений и т. п. Без таких сведений они не смогут полноценно воспользоваться нашим классом. И наконец, такая организация способствует повторному использованию исходного кода, т. к. позволяет легко скопировать файлы классов в другой проект. Ниже приведен листинг программы, код которой разбит на 3 файла в соответствии с вышеозначенными принципами: // Employee.h - Объявление класса // Этот файл содержит объявление класса Employee, не раскрывая // реализацию его функций-членов #include <string> using namespace std ; class Employee { private : string name ; int age ; public : Employee( ) ; Employee ( string n, int a ) ; void getData( ) ; void showData( ) ; } ; // Employee.cpp – Реализация класса // Этот файл содержит реализацию функций-членов класса Employee #include <iostream> #include "Employee.h" using namespace std ; Employee::Employee( ) { name = "" ;
104 Глава 4 age = 0 ; } Employee::Employee ( string n, int a ) { name = n ; age = a ; } void Employee::getData( ) { cout << endl << "Введите имя и возраст" << endl ;cin >> name >> age ; } void Employee::showData( ) { cout << "Имя = " << name << endl << "Возраст = " << age << endl ; } // Main.cpp – Клиентский код // Этот файл содержит код для использования класса Employee, // интерфейс которого отделен от его реализации #include <iostream> #include "Employee.h" using namespace std ; int main( ) { Employee e1 ( "Sanjay", 34 ) ; e1.showData( ) ; Employee e2 ; e2.getData( ) ; e2.showData( ) ; return 0 ; } Изучив приведенные выше фрагменты кода, можно сделать ряд важных наблюдений:  Для хранения имени сотрудника вместо массива символов мы использовали класс стандартной библиотеки string. Преимущество класса string по сравнению с массивом символов заключается в том, что он содержит несколько функций, упрощающих операции со строками.  Класс string определен в заголовочном файле ′string′. Для применения класса нужно подключить этот файл.  В заголовочном файле ′Employee.h′ мы объявили using namespace std, поэтому нам не требуется указывать std::string там, где должен использоваться класс string.
105 Классы и объекты  Файл ′Employee.cpp′ содержит определения функций-членов класса Employee. Чтобы сделать класс Employee доступным в этом файле, нам нужно подключить ′Employee.h′.  Мы подключили ′Employee.h′, применив кавычки вместо обычных угловых скобок. Это связано с тем, что ′Employee.h′ будет находиться в папке нашего проекта, а не в стандартной папке для подключаемых файлов.  Каждому определению функции-члена предшествует имя класса Employee и оператор разрешения области видимости. Это указывает на то, что данные функции принадлежат классу Employee, а не являются глобальными.  В функции getData( ) мы получаем имя сотрудника при помощи cin >> name. Этого достаточно, если имя состоит из одного слова. Если же оно состоит из нескольких слов, можно использовать инструкцию: getLine ( cin, name ) ;  К Main.cpp необходимо подключить ′Employee.h′. Без этого мы не сможем ис- пользовать класс Employee в функции main( ). И наконец, как нам организовать код в трех файлах? Если вы работаете в Visual Studio, выполните следующие шаги:  Создайте проект под названием ′Client′. Введите исходный код из листинга ′Client.cpp′.  Щелкните правой кнопкой мыши по проекту ′Client′ в «Обозревателе решений» (Solution Explorer). Выберите в меню «Добавить | Класс...» (Add | Class...).  Выберите «Класс С++...» (С++ class...). Затем нажмите кнопку «Добавить» (Add). В появившемся диалоговом окне введите имя класса ′Employee′, затем нажмите на кнопку «Завершить» (Finish). Будут созданы два файла — ′Employee.h′ и ′Employee.cpp′. Рис. 4.2. Процесс компиляции и сборки
106 Глава 4  Введите код объявления класса в ′Employee.h′, а код реализации класса — в ′Employee.cpp′.  Сформируйте и запустите программу, нажав Ctrl+F5. Для другой среды разработки следует выполнить аналогичные шаги, предусмотренные в ней. На рис. 4.2 показано, как осуществляется компиляция и сборка. 1. Укажите, верны ли следующие утверждения:  По умолчанию члены структуры являются открытыми (public), а члены класса — закрытыми (private).  В классе члены данных всегда закрыты (private), тогда как функции-члены всегда открыты (public).  Конструктор класса всегда должен быть открытым (public).  Если класс содержит конструктор с 3 аргументами, то необходимо явно задать конструктор без аргумента, конструктор с одним и двумя аргументами.  Функции-члены класса должны вызываться явно, тогда как конструктор вызывается автоматически.  Конструктор вызывается всякий раз, когда создается экземпляр объекта.  Конструкторы могут быть перегруженными.  Конструктор не возвращает значение.  Размер объекта равен сумме размеров членов данных и функций-членов класса.  Если оператор ++ перегружен, то выражения j++ и ++j вызовут одну и ту же перегруженную функцию.  Когда объект выходит из области видимости, автоматически вызывается его деструктор.  Если бинарный оператор + перегружен внутри класса, то при его вызове необходимо передать лишь один аргумент.  Указатель this всегда содержит адрес объекта, посредством которого осуществляется доступ к функции или членам данных.  Указатель this можно использовать даже за пределами класса.  Если функции-члены класса определены за его пределами, требуется объявить их внутри класса. 2. Укажите ошибки, если таковые имеются, в следующих фрагментах программного кода:  (A) #include <string> using namespace std ;
Классы и объекты class Address { private : string name, city ; public : Address ( string p, string q ) { name = p ; city = q ; } } int main( ) { Address my ( "Мак", "Лондон" ) ; return 0 ; }  (B) class Date { private : int day, month, year ; Date( ) { day = 7 ;month = 9 ;year = 2019 ; } } ; int main( ) { Date today ; return 0 ; }  (C) class Value { private : int i ; float f ; public : Value( ) { i = 0 ; f = 0.0 ; return 1 ; } } ; 107
108 Глава 4 int main( ) { Value v1 ; return 0 ; }  (D) #include <iostream> using namespace std ; class Triplets { private : int t1, t2, t3 ; public : Triplets ( int x, int y, int z ) { t1 = x ; t2 = y ; t3 = z ; } void display( ) { cout << endl << } } ; int main( ) { Triplets r ( r.display( ) s.display( ) return 0 ; } t1 << t2 << t3 ; 2, 3, 4 ), s ; ; ;  (E) #include <iostream> using namespace std ; class Sample { private : int data1 ; float data2 ; public : void Sample( ) ; void showData( ) ; } ;
Классы и объекты Sample::void Sample( ) { data1 = 10 ; data2 = 20 ; } Sample::void showData( ) { cout << endl << data1 << data2 ; } int main( ) { Sample s ; s.showData( ) ; return 0 ; }  (F) #include <iostream> using namespace std ; class List { private : class Node { int data ; Node *link ; } *p ; public : void create( ) { p = new Node ; p.data = 10 ; p.link = NULL ; } } ; int main( ) { List l1 ; l1.create( ) ; return 0 ; } 3. Каков результат выполнения следующих программ?  (A) #include <iostream> using namespace std ; 109
110 class User { private : int i ; float f ; char c ; public : void displayData( ) { cout << i << '\n' << f << "\n" << c << endl ; } } ; int main( ) { cout << sizeof ( User ) << endl ; User u1 ; cout << sizeof ( u1 ) << endl ; u1.displayData( ) ; return 0 ; }  (B) #include <iostream> using namespace std ; class Date { private : int dd, mm, yy ; public : Date( ) { cout << "Сегодня у нас" << endl ; } } ; int main( ) { Date today ; Date *p = &today ; cout << p << endl ; return 0 ; }  (C) #include <iostream> using namespace std ; Глава 4
Классы и объекты class Student { private : int m1, m2, m3 ; float per ; public : Student( ) { m1 = m2 = m3 = 0 ; per = 0.0 ; } void calcPer ( int x, int y , int z ) { m1 = x ; m2 = y ; m3 = z ; per = ( m1 + m2 + m3 ) / 3.0 ; displayPer( ) ; } void displayPer( ) { cout << "Проценты = " << per << "%" << endl ; } } ; int main( ) { Student s1 ; s1.displayPer( ) ; s1.calcPer ( 35, 35, 35 ) ; s1.displayPer( ) ; return 0 ; }  (D) #include <iostream> using namespace std ; class Control { public : Control( ) { calculate( ) ; cout << "Конструктор" << endl ; } 111
112 Глава 4 void calculate( ) { display( ) ; cout << "Калькулятор" << endl ; } void display( ) { cout << "отображен" << endl ; } } ; int main( ) { Control c1 ; return 0 ; } 4. Решите следующие задачи и ответьте на вопросы:  Измените класс Rectangle, обсуждаемый в этой главе, таким образом, чтобы операция Rectangle r1 = 3 ; присваивала значение 3 переменным len и br.  Если в программе используются четыре объекта, то сколько указателей this будет существовать для этих объектов и почему?  Определите класс Matrix, содержащий матрицу 3 x 3. Определите в этом классе перегруженные операторы для выполнения сложения, умножения и сравнения двух матриц. Создайте объекты класса Matrix в теле функции main( ) и вызовите перегруженные функции для выполнения над ними матричных операций.  Определите класс Time, содержащий часы и минуты в качестве закрытых чле- нов и перегруженную операторную функцию ++. Создайте объекты этого класса в теле функции main( ) и вызовите операторную функцию ++, чтобы увеличить время на 1 минуту.  Процедурное программирование строится на взаимодействии функций.  Классы — это данные пользовательского типа.  Классы описывают, как будут выглядеть объекты, созданные на их основе.  Объекты содержат определенный набор данных. Каждый объект является от- дельным экземпляром своего класса.  Значения данных в объектах часто называют данными экземпляра или состояни- ем объекта.
Классы и объекты 113  Открытые члены класса доступны за пределами класса.  Закрытые члены класса НЕ доступны за пределами класса.  Внутри класса любой его член может получить доступ к любому другому члену этого класса.  По умолчанию все члены класса являются закрытыми, а все члены структуры открытыми.  Как правило, данные класса являются закрытыми, а доступ и управление ими осуществляются через открытые функции-члены этого класса.  Существуют два способа инициализации объекта: • Способ 1: при помощи обычных функций-членов, таких как getData( ) и setData( ).  Преимущество 1 — данные защищены от преобразований.  Преимущество 2 — улучшенная валидация (проверка данных), т. к. она производится в одной части кода.  Преимущество 3 — валидация осуществляется разработчиком класса. • Способ 2: при помощи специальной функции-члена — конструктора.  Преимущество 1 — улучшенная организация программного кода.  Преимущество 2 — гарантированная инициализация при помощи конструктора.  При создании объекта выделяется место в памяти и вызывается конструктор.  Имя конструктора должно совпадать с именем класса.  Конструктор — это функция.  Конструктор не возвращает значения.  Конструктор вызывается автоматически при создании объекта.  Конструктор вызывается лишь один раз за все время существования объекта.  Конструктор может быть перегруженным.  Если не задать определение конструктора, компилятор вставит непараметризи- рованный конструктор (без аргументов).  У класса может быть и конструктор, и функция setData( ).  Конструктор инициализирует объект.  Функция setData( ) изменяет объект.  Метод деструктора вызывается при разрушении объекта.  Префиксная и постфиксная формы перегруженного оператора инкремента раз- личаются передачей 0 в постфиксную версию операторной функции.  this — это константный указатель, поэтому его нельзя изменить во время вы- полнения метода.
114 Глава 4  При возвращении управления из функции указатель this разрушается.  Формально у каждого объекта имеются данные экземпляра и функции-члены.  В действительности у каждого объекта имеются данные экземпляра, тогда как функции-члены используются совместно всеми объектами класса.  Совместное использование функций оправдано, т. к. функции-члены одинаковы для любого объекта класса.  Хорошей практикой разработки программного обеспечения является размеще- ние объявления класса в заголовочном файле .h, а его реализации — в файле .cpp.
Премудрости классов Недостаточно знать, что такое классы и как создавать из них объекты. Не освоив всех тонкостей, вы не сможете в полной мере воспользоваться их преимуществами
116 Глава 5  Статическое и динамическое выделение памяти • Выделение памяти для массивов и структур • Выделение памяти для объектов  Статические члены класса  Универсальный спецификатор const  Перегруженный оператор присваивания и конструктор копирования  Преобразование данных: • Преобразование между встроенными типами • Преобразование между встроенными и пользовательскими типами • Преобразование между различными пользовательскими типами • Процедура преобразования в исходном объекте • Процедура преобразования в целевом объекте  Упражнения  Важное
Премудрости классов В 117 прошлой главе мы разобрались с основами классов и объектов. Теперь нам известно, что класс — это пользовательский тип данных, а объект — его экземпляр. В этой главе рассматриваются некоторые нюансы классов и объектов. К ним относятся различные способы создания объектов, их содержимого, копирования, преобразования и т. п. Все эти тонкости позволят вам лучше понять классы и объекты. И начнем мы с распределения памяти. Статическое и динамическое выделение памяти Как известно, при создании переменных/объектов под них выделяется память. C++ позволяет осуществить это двумя способами:  Статическое выделение памяти  Динамическое выделение памяти В первом случае пространство выделяется для переменных/объектов в области памяти, известной как стек. Поскольку решение о том, сколько потребуется места и где его выделить, принимается на этапе компиляции, этот метод известен как статическое выделение памяти. Фактическое создание переменных/объектов происходит лишь на этапе выполнения объектного кода. Класс хранения этих переменных/объектов решает, как долго они должны существовать. Во втором случае решение об объеме выделяемого пространства и само фактическое выделение осуществляются на этапе выполнения. Распределение происходит в области памяти, называемой динамической памятью (heap). Программисты C++ называют динамическую память свободной памятью или свободным хранилищем (free store). Объекты, создаваемые в динамической памяти, безымянны, и потому доступ к ним можно получить лишь по их адресам. Так, когда для них выделяется область памяти, то возвращается их адрес, который назначается указателю. Указатель этот создается в стеке. В отличие от статического выделения памяти, мы можем удалить объекты, созданные в динамической памяти, в любой момент выполнения программы. Динамическое выделение памяти называется «динамическим», поскольку оно позволяет контролировать распределение и освобождение памяти для любого встроенного или пользовательского типа данных. Динамическое выделение памяти осуществляется при помощи оператора new, а освобождение — при помощи оператора delete. Ниже приведен ряд примеров статического и динамического размещения встроенных типов данных: // статическое выделение памяти int i ; float a ; char ch ;
118 Глава 5 // динамическое выделение памяти int *ptrInt = new int ; float *ptrFloat = new float ; char *ptrChar = new char ; // применение выделенных объектов *ptrInt = 35 ; *ptrFloat = 3.14 ; *ptrChar = ‘A’ ; // удаление выделенных объектов delete ptrInt ; delete ptrFloat ; delete ptrChar ; Оператор new выделяет участок памяти для встроенного типа данных и возвращает его адрес. Мы присваиваем этот адрес соответствующему указателю. Чтобы задействовать этот тип данных, нам нужно получить к нему доступ, используя указатель. Оператор delete производит действие, обратное оператору new. Он возвращает в свободное хранилище память, принадлежащую выделенному объекту. Динамическое распределение памяти обеспечивает повышенную гибкость — мы можем очищать динамически созданные объекты, когда захотим, используя оператор delete. Однако и тут есть свои нюансы. Для каждого объекта, созданного в динамической памяти, должен быть создан связанный с ним указатель в стеке. Так, для одного целого числа (int) нам потребуется 4 байта в динамической памяти и 4-байтовый указатель в стеке. В то же время статически созданное целое число занимает лишь 4 байта в стеке. Выделение памяти для массивов и структур Из-за упомянутых выше недостатков этого метода динамическое выделение памяти не следует применять для создания небольших объектов. Оно уместно при работе с объектами большого размера, такими как структуры или массивы, и позволит избавиться от них, как только они выполнят свою роль. В следующем фрагменте исходного кода продемонстрировано статическое и динамическое выделение памяти для массивов и структур: // объявление структуры struct Employee { string name ; int age ; float salary ; } ; // статическое выделение памяти int num[ 25 ] ; struct Employee e ;
Премудрости классов 119 // динамическое выделение памяти int *ptrInt = new int[ 25 ] ; struct Employee *ptr = new struct Employee ; // применение выделенных объектов ptrInt [ 0 ] = 35 ; ptrInt[ 20 ] = 40 ; ptr->name = "Sanjay" ; ptr->age = 23 ; ptr->salary = 4500.00f ; // удаление выделенных объектов delete [ ] ptrInt ; delete ptr ; Обратите внимание на то, как используется оператор new для динамического выделения массива. При распределении пространства под массив возвращается базовый адрес массива, который сразу присваивается ptrInt. Также заметьте, как динамически выделяемый массив удаляется из памяти при помощи оператора delete. Как пример delete [ ] ptrInt ; уточняет, что мы удаляем не один элемент thing, а массив элементов thing (в данном случае под thing подразумевается целое число), на который ссылается указатель ptrInt. Подойдет ли в таком случае простая форма записи? delete ptrInt ; Нет. Компилятор может и пропустить эту ошибку, однако корректность работы варьируется от компилятора к компилятору. В некоторых случаях это может привести к повреждению данных в динамической памяти, а в ряде других будет удален лишь первый элемент массива. Проще говоря, лучше всякий раз использовать delete [ ], когда мы выделяем память, используя ключевое слово new [ ]. В статически выделенных массивах мы должны определиться с размером массива на момент написания программы. Это значит, что мы не можем задать массив таким образом: int n ; cin >> n ; int arr[ n ] ; Связано это с тем, что компилятор уже во время компиляции должен знать, сколько байтов необходимо выделить для этого массива. Во время компиляции n не имеет никакого значения; возможность указать значение для n появляется только во время выполнения программы. Однако на момент написания программы мы не можем всякий раз знать наверняка, сколько элементов предполагается хранить в массиве. В итоге мы нередко выделяем слишком мало или слишком много места для массива. Впрочем, этого легко избежать, выделяя массив динамически, как показано ниже:
120 Глава 5 int n ; cin >> n ; int *p = new int[ n ] ; Этот массив инициализируется на основе значения n, указанного пользователем во время выполнения программы. Выделение памяти для объектов При динамическом размещении объектов возникает один дополнительный аспект по сравнению с динамическим размещением встроенных типов и массивов. При создании объектов в динамической памяти при помощи оператора new им выполняется три действия:  Выделяет для объекта память требуемого размера.  Запускает функцию-конструктор объекта.  Возвращает указатель соответствующего типа. Выполнение конструктора гарантирует корректную инициализацию объекта перед его использованием. Подтвердим это на примере программы: #include <iostream> using namespace std ; class Sample { private : int i ; float a ; public : Sample( ) { i = 10 ; a = 3.14f ; } void showData( ) { cout << i << endl << a << endl ; } ~Sample( ) { cout << "Достигнут деструктор" << endl ; } } ; int main( ) { Sample *ptr ; ptr = new Sample( ) ; ptr->showData( ) ; delete ptr ; }
Премудрости классов 121 В результате программы получим: 10 3.14 Достигнут деструктор Как явствует из результата, конструктор вызывается, когда мы создаем объект динамически, используя оператор new, а деструктор вызывается, когда мы удаляем этот объект, используя delete. Прежде чем перейти к рассмотрению нового материала, рассмотрим несколько моментов, связанных с применением операторов new и delete, которые позволяют лучше понять их работу:  Объект, созданный при помощи new, существует до тех пор, пока не будет явно разрушен при помощи delete.  delete может применяться только к указателю, возвращаемому оператором new, или к нулю. Применение delete к нулю не совершает ровно никакого действия. Иными словами, передача delete нулевого указателя (NULL pointer) безопасна и гарантированно не имеет никакого эффекта. Это упрощает код, используемый оператором delete, позволяя такому коду удалять указатель, возможно нулевой, без необходимости проверять его, прибегая к оператору if.  Команда delete p не удаляет указатель. Вместо этого она удаляет объект, на ко- торый указывает p. Так что буквально имя ключевого слова должно было бы звучать 'deletethethingpointedtoby' («удалить то, на что здесь указано»), а не просто delete. Есть среди вас сторонники такого оператора?  Никогда не удаляйте указатель дважды. Предположим, у вас есть переменная- указатель p. При первом выполнении команды delete p объект *p надежно разрушается, а область памяти, на которую ссылается p, гарантированно возвращается в хранилище свободной памяти. Когда же вы передаете delete тот же указатель во второй раз, остатки того, что было объектом, на который указывал *p, передаются деструктору (что может иметь самые плачевные последствия), тогда как область, на которую указывает p, возвращается в свободное хранилище во второй раз. Это может нарушить работу динамической памяти.  Неудаление объекта не является ошибкой с точки зрения языка. Однако это пло- хая практика программирования. Чем дольше будет работать программа, тем важнее удалить объект, как только он исполнит свое назначение.  Если указатель является членом данных класса, и мы инициализируем его в кон- структоре с помощью new, мы должны не забыть удалить его в деструкторе при помощи delete. В противном случае произойдет так называемая утечка памяти. Это ошибка, при которой память останется выделенной, но доступа к ней не будет.
122 Глава 5 Статические члены класса Мы знаем, что каждый новый объект класса (их ещё называют инстантами, от англ. instance) имеет собственный набор членов данных, тогда как функции-члены являются общими для всех них. Однако, если мы хотим, чтобы какой-либо член данных также стал общим для всех инстантов этого класса, это вполне реализуемо. Нам всего лишь необходимо объявить такой член данных статическим (static). Позвольте мне привести пример, демонстрирующий потребность в совместном использовании члена данных всеми объектами класса. Предположим, у нас есть класс Circle, и мы хотим отслеживать, сколько инстантов этого класса создано. Для этого мы можем предусмотреть в классе статическую переменную-счетчик (count). При создании каждого нового объекта можно инкрементировать значение счетчика count в конструкторе. Таким образом, его последнее значение всегда сообщит нам, сколько объектов класса Circle было создано на настоящий момент. Рассмотрим на примере: #include <iostream> using namespace std ; class Circle { private : char color ; float radius ; static int count ; public: Circle ( char c, float r ) { color = c ; radius = r ; count++ ; } static void showCount( ) { cout << "счетчик = " << count << endl ; } } ; int Circle::count = 0 ; // определение счетчика int main( ) { Circle c1 ( 'R', 1.2f ) ; Circle::showCount( ) ; Circle c2 ( 'G', 2.2f ) ;
Премудрости классов 123 Circle::showCount( ) ; Circle c3 ( 'B', 3.2f ) ; Circle::showCount( ) ; return 0 ; } Класс Circle содержит три члена данных — color (цвет), radius (радиус) и count (счетчик). Из них первые два являются членами данных конкретного инстанта, тогда как третий является статическим (static) членом данных, общим для всех объектов класса. В теле основной функции main( ) мы определили три объекта класса Circle. Каждый раз при создании объекта мы устанавливаем цвет (color) и радиус (radius) объекта Circle и приращиваем значение счетчика count. Как следствие, count будет инкрементирован трижды. Статическая функция-член showData( ) отображает текущее значение count. Вот результат работы программы: count = 1 count = 2 count = 3 Как мы и ожидали, значение count увеличивается всякий раз, когда создается новый объект Circle. Внимательно следите за тем, чтобы счетчик был объявлен внутри класса, но инициализирован за его пределами. Почему такой подход используется для статических членов данных? Если бы статические члены данных определялись в теле объявления класса, это бы нарушило стройную логику, по которой объявление класса является лишь чертежом и не резервирует никакой памяти. Однако будьте осторожны! Если вы включите объявление статической переменной, но забудете ее определение, компилятор передаст ее, тогда как компоновщик сообщит, что вы пытаетесь сослаться на неразрешенную внешнюю переменную. Также обратите внимание на синтаксис вызова статической функции-члена: showCount( ): Circle::showCount( ) ; Как видите, она вызывается без ссылки на объект. И это естественно, поскольку счетчик (count), отображаемый функцией showCount( ), не относится к какомулибо конкретному объекту. Вот еще ряд полезных сведений о статических членах класса:  Функция-член класса (такая как конструктор, например) имеет доступ как к ста- тическим членам данных, так и к членам данных конкретного объекта.  Статическая функция-член может обращаться только к статическим членам данных.  Для статической функции не существует указателя this.  Статический член данных не имеет никакого отношения к статическому классу памяти.
124 Глава 5 Идею совместного использования объектами одной переменной можно применить при создании системы лицензирования программного обеспечения. Предположим, фирма-разработчик программного обеспечения продает заказчику лицензию на 5 рабочих мест. И всякий раз, когда ПО устанавливает новый пользователь, счетчик инкрементируется. Когда же его пытается установить шестой пользователь, поступает сообщение о нарушении условий лицензии. Универсальный спецификатор const В главе 2 мы рассмотрели применение ключевого слова const для решения двух задач:  Предотвращение модификации значений переменных.  Запрет функции не модифицировать фактические аргументы при помощи формальных аргументов. Но на этом область применения const не ограничивается, его можно применять еще в трех случаях:  С объектами.  С функциями-членами классов.  С аргументами функций-членов. Следующая программа демонстрирует применение оператора const и действия, которые производятся им в этих трех случаях: #include <iostream> using namespace std ; class Sample { private : int data ; public : Sample( ) { data = 0 ; } void changeData( ) const { data = 10 ; } void showData( ) { cout << endl << "данные = " << data << endl ; } void add ( Sample const &s, Sample const &t ) { data = s.data + t.data ;
Премудрости классов 125 s.data = 45 ; // ошибка t.data = 50 ; // ошибка } void getData( ) { cin >> data ; } } ; int main( ) { const Sample s1 ; s1.getData( ) ; // ошибка Sample s2 ; s2.changeData( ) ; Sample s3 ; s3.changeData( ) ; Sample s4 ; s4.add ( s2, s3 ) ; s4.showData( ) ; return 0 ; } Мы инициализировали объект s1 с использованием const. Когда мы пытаемся вызвать функцию modifyData( ), чтобы изменить его, компилятор сообщает об ошибке. Мы не можем изменить s1 при помощи функции-члена. И хотя мы не пометили объект s2 как const, мы не можем изменить и его, вызвав changeData( ). Происходит это оттого, что мы объявили саму функцию-член с использованием const. Константная функция-член гарантированно не вносит изменений в члены данных своего класса. Заметьте, что, в отличие от s1, s2 можно изменить посредством других функций-членов, таких как getData( ). Из чего следует, что с const-объектами можно использовать только константные функции-члены, поскольку лишь они гарантированно не допускают модификацию объекта. Обратите внимание: чтобы объявить функцию changeData( ) константной, ключевое слово const помещается после декларатора, но перед телом функции. Если функция объявлена внутри класса, но определена за его пределами, то необходимо использовать const как в объявлении, так и в определении. Наконец, в add( ) мы обозначили s и t как ссылки на const-объекты Sample. В результате, когда мы пытаемся изменить объекты s2 и s3 через ссылки s и t, компилятор выдает ошибку. Что само по себе и неплохо, ибо при выполнении операции сложения нет причин, по которым s2 и s3 должны измениться.
126 Глава 5 Перегруженный оператор присваивания и конструктор копирования Как мы уже знаем, компилятор C++ автоматически подставляет непараметризированный конструктор и деструктор, если мы не позаботились о них сами. Точно так же компилятор предоставляет функцию перегруженного оператора присваивания и конструктор копирования по умолчанию. Давайте разберемся с этими двумя новыми функциями. Рассмотрим подробнее: Circle c1, c2 ; c1 = c2 ; // вызывает функцию перегруженного оператора присваивания Circle c3 = c2 ; // вызывает конструктор копирования Здесь c1 и c2 — объекты определенного ранее класса Circle. Инструкция c1 = c2 дает команду компилятору поэлементно копировать данные из c2, член за членом, в c1. Это то, что перегруженный оператор присваивания делает по умолчанию. В следующей инструкции мы инициализировали один объект другим при его определении. Эта команда производит аналогичное действие. Создается новый объект c3, и данные из c2 поэлементно копируются в c3. Это то, что по умолчанию делает конструктор копирования. Заметьте, что в обоих случаях мы задействуем оператор присваивания ′=′, однако при этом вызываются разные функции. Почему команда c1 = c2 вызывает конструктор копирования? Все просто: для c1 непараметризированный конструктор уже был вызван при его определении, а конструктор может быть вызван для объекта лишь один раз. В то же время почему функция перегруженного оператора присваивания не вызывается в Circle c3 = c2? Это связано с тем, что c3 еще не создан, а функция оператора присваивания может быть вызвана только для существующего объекта. Более того, поскольку эта инструкция содержит определение объекта c3, для него необходимо вызвать конструктор. Это не может быть обычный конструктор, т. к. объект при этом должен быть инициализирован значениями из c2. Вот почему вызывается специальный конструктор, называемый конструктором копирования. Если требуется, чтобы оператор присваивания или конструктор копирования делали что-то отличное от того, что заложено в их версиях, предоставленных компилятором, вы всегда можете их переопределить. Что и продемонстрировано в следующем листинге: #include <iostream> using namespace std ; class Circle { private : int radius ; float x, y ;
Премудрости классов public : Circle( ) { } Circle ( int rr, float xx, float yy ) { radius = rr ; x = xx ; y = yy ; } Circle& operator = ( Circle& c ) { cout << " Вызван оператор присваивания" << endl ; radius = c.radius ; x = c.x ; y = c.y ; return *this ; } Circle ( Circle& c ) { cout << "Вызван конструктор копирования" << endl ; radius = c.radius ; x = c.x ; y = c.y ; } void showData( ) { cout << "Радиус = " << radius << endl ; cout << "Координата X = " << x << endl ; cout << "Координата Y = " << y << endl << endl ; } } ; int main( ) { Circle c1 ( 10, 2.5f, 2.5f ) ; Circle c2, c3 ; c3 = c2 = c1 ; Circle c4 = c1 ; c1.showData( ) ; c2.showData( ) ; c3.showData( ) ; c4.showData( ) ; return 0 ; } Результат работы программы: Вызван оператор присваивания Вызван оператор присваивания Вызван конструктор копирования 127
128 Глава 5 Радиус = 10 Координата X = 2.5 Координата Y = 2.5 Радиус = 10 Координата X = 2.5 Координата Y = 2.5 Радиус = 10 Координата X = 2.5 Координата Y = 2.5 Радиус = 10 Координата X = 2.5 Координата Y = 2.5 Большая часть программы достаточно прозрачна. Здесь важно обратить внимание на функцию operator = ( ), которая перегружает оператор присваивания, и на конструктор копирования. При выполнении команды c3 = c2 = c1 вызывается функция перегрузки оператора. Поскольку здесь происходит двойное присваивание, оператор присваивания выполняется дважды. Вот почему сообщение «Вызван оператор присваивания» выводится на экран два раза. Перегруженный оператор = выполняет копирование членов данных из одного объекта в другой. Обратите внимание на то, что мы передаем аргумент функции перегрузки оператора по ссылке. Нередко это желательно, хотя и совсем не обязательно. Будь аргумент передан по значению, он создал бы новый локальный объект в функции. Для нашей программы это не имело бы большого значения, однако при работе с объектами большой величины это привело бы к значительной трате памяти. Рассмотрим команду: c3 = c2 = c1 ; Как известно, сначала выполняется команда c2 = c1. Внутри она преобразуется в: c2.operator = ( c1 ) ; Аргумент c1 передается в функцию оператора присваивания по ссылке. Поэтому ссылка c в этой функции относится к c1. Теперь данные c1 копируются в поля данных c2 посредством команд: radius = c.radius ; x = c.x ; y = c.y ; На данном этапе this-указатель содержит адрес c2. Таким образом, возвращая *this, мы просто возвращаем объект c2. Более того, он возвращается по ссылке. Поэтому его копия не создается в функции main( ). Следующим шагом присваивания становится c3 = c2. Здесь все происходит так же, как описано выше.
Премудрости классов 129 Теперь давайте внимательно рассмотрим конструктор копирования. При выполнении инструкции Circle c4 = c1 вызывается перегруженный конструктор копирования. Конструктор копирования принимает один аргумент — объект класса Circle, передаваемый по ссылке. Вот его прототип: Circle ( Circle & ) ; Обязательно ли использовать ссылку в аргументе на конструктор копирования? Нельзя ли вместо нее передать значение? Ни в коем случае. Ведь если мы передаем аргумент по значению, то при помощи конструктора копирования создается его копия. Это означает, что конструктор копирования будет вызывать сам себя, чтобы сделать эту копию. И процесс этот будет продолжаться до тех пор, пока у компилятора не закончится свободная память. Запомните, что в конструкторе копирования аргумент всегда должен передаваться по ссылке. Кроме того, конструктор копирования вызывается, когда объекты передаются по значению в функции и когда объекты возвращаются из функций. Когда объект передается по значению, копия, с которой работает функция, создается посредством конструктора копирования. Если же мы передаем адрес или ссылку на объект, конструктор копирования, разумеется, вызываться не будет, поскольку в этих случаях копии объектов создаваться не должны. Когда же объект возвращается из функции, вызывается конструктор копирования для создания копии значения, возвращаемого функцией. Преобразование данных Существует три возможных вида преобразования данных:  Преобразование между встроенными типами данных.  Преобразование между встроенными и пользовательскими типами данных.  Преобразование между различными пользовательскими типами данных. Ниже мы рассмотрим все эти виды преобразований. Преобразование между встроенными типами Рассмотрим фрагмент кода, содержащий операции со встроенными типами: float a = 30 ; // int в double double b = 1.55f ; // float в double int a = 3.14 ; // double в int float b = 6.28 ; // double во float При компиляции первые два присваивания легко проходят валидацию, т. к. это расширяющие преобразования. В то же время при компиляции следующих двух операций выводятся предупреждения, т. к. это сужающие преобразования — такие,
130 Глава 5 при которых может произойти потеря точности вычислений. Чтобы избежать предупреждений, можно использовать приведение типов, как показано ниже: int a = int ( 3.14 ) ; // double в int float b = float ( 6.28 ) ; // double во float Расширяющие преобразования осуществляются имплицитно, тогда как для сужающих преобразований необходимо использовать приведение типов. Преобразование между встроенными и пользовательскими типами Когда необходимо выполнить преобразование между пользовательскими и простыми (встроенными) типами данных, то нельзя полагаться на встроенные процедуры преобразования, поскольку компилятору ничего не известно о пользовательских типах, кроме того, что мы ему сообщили. Нам следует самим написать эти процедуры преобразования. Следующая программа демонстрирует, как преобразовать базовый тип в пользовательский и наоборот. В этой программе пользовательский тип — это класс String, а встроенный — int. Программа иллюстрирует преобразование String в int и int в String. Вот ее исходный код: // преобразования: String в int, int в String #include <iostream> #include <cstdlib> using namespace std ; class String { private : char str[ 20 ] ; public : String( ) { str[ 0 ] = '\0' ; } String ( char *s ) { strcpy ( str, s ) ; } String ( int a ) { itoa ( a, str, 10 ); } operator int( ) { int i = 0, l, num = 0, k = 1 ; l = strlen ( str ) - 1 ;
Премудрости классов 131 while ( l >= 0 ) { num = num + ( str[ l ] - 48 ) * k ; l-- ; k *= 10 ; } return ( num ) ; } void displayData( ) { cout << str << endl ; } } ; int main( ) { String s1 = 123 cout << "s1 = " s1.displayData( s1 = 150 ; cout << "s1 = " s1.displayData( ; ; ) ; ; ) ; String s2 ( "123" ) ; int i = int ( s2 ) ; cout << "i = " << i << endl ; String s3 ( "456" ) ; i = s3 ; cout << "i = " << i << endl ; return 0 ; } Для преобразования int в пользовательский тип String мы использовали конструктор с одним аргументом. Он вызывается, когда объект типа String создается с одним аргументом, как в String s1 = 123 ; // то же, что String s1 ( 123 ) ; Конструктор преобразует int в строку и присваивает ее переменной str, используя функцию itoa( ). В строке s1 = 150 ; мы преобразуем int в String, но не создаем новый объект. Даже в этом случае вызывается конструктор с одним аргументом. Когда компилятор встречает оператор, нуждающийся в преобразовании, он ищет любой инструмент, способный выпол-
132 Глава 5 нить за него эту работу. В нашей программе он находит конструктор, который преобразует int в String, и задействует его в операторе присваивания, сначала создавая безымянный временный объект, где str содержит значение, соответствующее целому числу 150, а затем присваивая этот объект s1. Таким образом, если компилятор не находит перегруженный оператор присваивания ′=′, он ищет конструктор для выполнения той же задачи. Для преобразования String в int используется перегруженный оператор приведения типов. Это нередко называется функцией преобразования. Этот оператор принимает значение объекта String, членом которого он является, приводит его значение к типу int, а затем возвращает это целочисленное значение. Вызывается этот оператор в двух случаях: i = int ( s2 ) ; и i = s3 ; Во втором присваивании компилятор сначала ищет перегруженный оператор присваивания. Не найдя его, компилятор использует для выполнения задачи функцию преобразования. Может показаться, что процедурное преобразование из одного типа в другой трудно считать разумной практикой программирования. Однако гибкость, обеспечиваемая средствами преобразования, зачастую перевешивает риск совершения ошибок из-за возможности смешивания типов данных. Преобразование между различными пользовательскими типами данных Как и в случае преобразования между пользовательскими и встроенными типами данных, для преобразования данных между объектами разных пользовательских классов используются те же два метода — конструктор с одним аргументом или функция преобразования. Выбор зависит от того, где мы хотим разместить процедуру преобразования — в объявлении класса исходного или целевого объекта. Давайте рассмотрим оба случая. Процедура преобразования в исходном объекте Когда процедура преобразования находится в исходном классе, она обычно реализуется как функция преобразования, как показано в следующей программе. Здесь используются два класса: Date и dmy. Оба класса созданы для обработки дат, с той лишь разницей, что класс Date обрабатывает их как строку (dt), тогда как класс dmy обрабатывает их как три целых числа, представляющих день (day), месяц (mth) и год (yr). Ниже приведен листинг программы: #include <iostream> #include <cstdlib> using namespace std ;
Премудрости классов class Date { private : char dt[ 9 ] ; public : Date( ) { dt[ 0 ] = '\0' ; } Date ( char *s ) { strcpy ( dt, s ) ; } void displayData( ) { cout << dt << endl ; } } ; class dmy { private : int day, mth, yr ; public : dmy( ) { day = mth = yr = 0 ; } dmy ( int d, int m, int y ) { day = d ; mth = m ; yr = y ; } operator Date( ) { char temp[ 3 ], str[ 9 ] ; itoa ( strcat itoa ( strcat strcat itoa ( strcat return } day, str, 10 ) ; ( str, "/" ) ; mth, temp, 10 ) ; ( str, temp ) ; ( str, "/" ) ; yr, temp, 10 ) ; ( str, temp ) ; ( Date ( str ) ) ; 133
134 Глава 5 void displayData( ) { cout << day << "/" << mth << "/" << yr << endl ; } } ; int main( ) { Date d1 ; dmy d2 ( 17, 11, 94 ) ; d1 = d2 ; cout << "d1 = " ; d1.displayData( ) ; cout << "d2 = " ; d2.displayData( ) ; return 0 ; } В теле функции main( ) мы определили объект d1 типа Date, который не инициализирован. Мы также определили объект d2 типа dmy, который был инициализирован. Далее выполняется присваивание посредством команды d1 = d2. Поскольку d1 и d2 являются объектами разных классов, присваивание включает в себя преобразование, и, как мы указали, в этой программе функция преобразования Date( ) является членом класса dmy, т. е. исходного класса. Эта функция преобразует объект, членом которого она является, в объект Date и возвращает его в основную функцию main( ), где он присваивается d1. Процедура преобразования в целевом объекте А теперь давайте посмотрим, как выполняется это же преобразование, когда процедура преобразования присутствует в целевом классе. Как правило, в таких случаях используется конструктор с одним аргументом. Однако сложность здесь заключается в том, что для выполнения преобразования конструктор в целевом классе должен иметь доступ к данным в исходном классе. Иными словами, поскольку элементы данных day, mth и yr в классе dmy являются закрытыми, мы должны предоставить функции getDay( ), getMth( ) и getYr( ) для их возврата. Что и реализовано в следующей программе: #include <iostream> #include <cstdlib> using namespace std ; class dmy { private : int day, mth, yr ;
Премудрости классов public : dmy( ) { day = mth = yr = 0 ; } dmy ( int d, int m, int y ) { day = d ; mth = m ; yr = y ; } int getDay( ) { return ( day ) ; } int getMth( ) { return ( mth ) ; } int getYr( ) { return ( yr ) ; } void displayData( ) { cout << day << "/" << mth << "/" << yr << endl ; } } ; class Date { private : char dt[ 9 ] ; public : Date( ) { dt[ 0 ] = '\0' ; } Date ( char *s ) { strcpy ( dt, s ) ; } void displayData( ) { cout << dt << endl ; } 135
136 Глава 5 Date ( dmy t ) { int d = t.getDay( ) ; int m = t.getMth( ) ; int y = t.getYr( ) ; char temp[ 3 ] ; itoa ( d, dt, 10 ) ; strcat ( dt, "/" ) ; itoa ( m, temp, 10 ) ; strcat ( dt, temp ) ; strcat ( dt, "/" ) ; itoa ( y, temp, 10 ) ; strcat ( dt, temp ) ; } } ; int main( ) { Date d1 ; dmy d2 ( 17, 11, 19 ) ; d1 = d2 ; cout << "d1 = " ; d1.displayData( ) ; cout << "d2 = " ; d2.displayData( ) ; return 0 ; } При выполнении команды d1 = d2 вызывается конструктор с одним аргументом в классе Date (аргументом которого является объект dmy). Эта функция-конструктор получает доступ к данным d2, вызывая функции getDay( ), getMth( ) и getYr( ). Затем эти данные преобразуются в строку. Результат работы этой программы сходен с результатом предыдущей. Все отличия остаются за кадром. Здесь преобразование обрабатывает конструктор в целевом объекте, а не функция преобразования в исходном объекте. Это подводит нас к важному вопросу: когда мы должны использовать конструктор с одним аргументом в целевом классе, а когда — функцию преобразования в исходном классе? Часто этот выбор очевиден:  Если у вас есть библиотека классов, у вас может не быть доступа к ее исходному коду. И если вы используете объект такого класса в качестве источника в преобразовании, у вас будет доступ только к целевому классу, и вам придется использовать конструктор с одним аргументом.  И напротив, если целевым является объект библиотечного класса, вам потре- буется использовать функцию преобразования в исходном коде. А что если мы
Премудрости классов 137 используем и функцию преобразования, и конструктор с одним аргументом? Компилятор сообщит об ошибке, поскольку в таком случае возникает логическое противоречие. 1. Укажите, верны ли следующие утверждения:  Статическое выделение памяти происходит во время компиляции программы, тогда как динамическое выделение памяти происходит во время ее выполнения.  Если память выделяется при помощи оператора new [ ], она должна освобож- даться посредством оператора delete [ ].  Ключевое слово new не только выделяет память, но и вызывает конструктор объекта.  Динамическая память и хранилище свободной памяти — это две разные вещи.  Применение delete к нулевому (NULL) указателю безопасно и гарантированно не производит никаких действий.  В C++ для перераспределения памяти следует использовать оператор renew.  Оператор new всегда возвращает указатель типа void, который необходимо яв- ным образом привести к типу.  Как и другие операторы, оператор new также может быть перегружен.  Ни в коем случае нельзя применять оператор delete на указатель дважды.  Статический член данных полезен, когда всем объектам одного класса нужно иметь общий информационный элемент.  Если у класса есть статический член данных и созданы три объекта этого класса, то каждый объект имеет свой собственный статический член данных.  Класс может иметь как статические члены данных, так и статические функции- члены.  Определение статического члена данных включается в объявление класса, но сама переменная объявляется за пределами класса.  Если display( ) является статической функцией-членом класса с именем Sample, то ее можно вызвать следующим образом: Sample s1 ; s1.display( ) ;  Если display( ) является статической функцией-членом класса с именем Sample, то ее можно вызвать следующим образом: Sample::display( ) ;  Ключевое слово const можно использовать как с функциями-членами класса, так и с аргументами функций-членов.
138 Глава 5  Функция-член со спецификатором const предотвращает изменение любых чле     нов данных своего класса. Если функция-член класса должна быть константной, то необходимо использовать ключевое слово const и в объявлении, и в определении этой функции-члена. С const-объектами можно использовать лишь константные функции-члены. Если не включить оператор присваивания в объявление класса, то компилятор автоматически добавит его в этот класс. Если не включить конструктор копирования в объявление класса, то его автоматически добавит компилятор. Следующие два набора команд тождественны: Sample s1 ; s1 = s2 ; и Sample s1 = s2 ;  Невозможно вернуть локальный объект по ссылке.  Когда объект передается в функцию или возвращается из функции, вызывается   2.    конструктор копирования. Для выполнения преобразования из объекта в простой тип или наоборот необходимо предусмотреть функции преобразования. Для выполнения преобразования из объекта одного пользовательского типа в другой необходимо предусмотреть функции преобразования. Решите следующие задачи и ответьте на вопросы: Что удаляет команда delete p — указатель или объект, на который указывает p? Напишите программу, которая будет выделять память для одномерного, двухмерного и трехмерного массивов целых чисел. Сохраните в этих массивах ряд значений, а затем выведите их на экран. Мы должны иметь возможность обращаться к элементам этих массивов, используя формы записи a[ i ], b[ i ][ j ] и c[ i ][ j ][ k ]. Сколько байтов будет выделено следующим программным кодом? int main( ) { const int MAXROW = 3 ; const int MAXCOL = 4 ; int ( *p )[ MAXCOL ] ; p = new int[ MAXROW ][ MAXCOL ] ; return 0 ; }  Каков будет результат работы следующей программы? #include <iostream> using namespace std ;
Премудрости классов 139 int main( ) { const int MAXROW = 3 ; const int MAXCOL = 4 ; int ( *p )[ MAXCOL ] ; p = new int[ MAXROW ][ MAXCOL ] ; cout << endl << sizeof ( p ) << endl << sizeof ( *p ) ; return 0 ; }  В каких случаях применим спецификатор const? • С обычными переменными. • С аргументами глобальных функций. • С функциями-членами класса. • С аргументами функций-членов. • С объектами.  Если нам необходимо реализовать оператор присваивания и конструктор копи- рования в классе Rectangle, каковы будут их прототипы?  В чем различие между этими двумя командами? delete a ; delete [ ] a ;  Что выполняет оператор delete, помимо освобождения памяти, используемой объектом?  Разработайте программу, реализующую два класса, time12 и time24. Первый поддерживает 12-часовой формат времени, а второй — 24-часовой. Обеспечьте функции преобразования для выполнения преобразования из объекта одного класса в объект другого класса.  Разработайте программу, реализующую класс date ("Дата"), который содержит поля данных day ("День"), month ("Месяц") и year ("Год"). Напишите функцию перегрузки оператора присваивания и конструктор копирования для этого класса.  При статическом выделении памяти решения о том, в какой области памяти и сколько байтов выделить, принимаются на этапе компиляции.  При динамическом выделении памяти решения о том, в какой области памяти и сколько байтов выделить, принимаются на этапе выполнения программы.  Стек и динамическая память — это области памяти.  Статическое распределение памяти осуществляется в стеке.  Динамическое распределение памяти осуществляется в динамической памяти.
140 Глава 5  Динамическая память также называется свободной памятью или свободным хранилищем.  Обычные переменные рекомендуется создавать в стеке.  Массивы, структуры и объекты рекомендуется создавать в динамической па- мяти.  Если в конструкторе используется оператор new, в деструкторе необходимо ис- пользовать оператор delete.  delete ptr удаляет объект, на который указывает ptr.  Память, выделенная при помощи new [ ], должна быть очищена при помощи delete [].  Утечка памяти происходит, когда память выделена, но нет возможности ее ис- пользовать или освободить.  Статические члены данных совместно используются множеством экземпляров класса.  Статические функции-члены могут обращаться только к статическим членам данных.  Статический член данных должен быть объявлен внутри класса, но определен за его пределами.  Доступ к статической функции-члену можно получить, используя форму записи имя_класса::имя_функции( ).  Переменные, объекты, аргументы функции, объявленные с ключевым словом const, не подлежат модификации.  Функция, объявленная с ключевым словом const, не может модифицировать объект, для которого она вызывается.  Компилятор предоставляет конструктор копирования и функцию перегруженно- го оператора присваивания в тех случаях, когда они не заданы разработчиком.  Для встроенных типов данных расширяющие преобразования выполняются не- явно, тогда как сужающие преобразования должны выполняться посредством приведения типов.  Преобразование встроенного типа данных в пользовательский можно выполнить при помощи конструктора.  Преобразование пользовательского типа данных во встроенный можно выпол- нить при помощи функции перегруженного оператора приведения типов.  Преобразование из одного пользовательского типа данных в другой может быть реализовано посредством функции преобразования, когда функция преобразования находится в исходном объекте.  Преобразование из одного пользовательского типа данных в другой может быть реализовано при помощи конструктора, когда функция преобразования находится в целевом объекте.
Наследование Технологии процветают скорее благодаря многократному использованию, чем изобретению нового. Повторное использование экономит время, силы и средства. То же самое и с программированием. В C++ заложен ряд средств многократного использования. И самым новаторским среди них является, пожалуй, наследование. В этой главе мы раскроем применение этого средства на конкретных примерах
142 Глава 6  Наследование  Еще один пример наследования  Варианты применения наследования  Наследование и конструкторы  Виды наследования • Одиночное наследование • Многоуровневое наследование • Множественное наследование  Предупреждение  Поэтапная разработка  Упражнения  Важное
Наследование Т 143 еперь, когда мы уже неплохо знакомы с классами и объектами — основными структурными элементами объектно-ориентированного программирования — давайте рассмотрим еще одно ключевое понятие C++, известное как средства многократного (повторного) использования. Язык C++ содержит три таких средства:  Наследование.  Отношения включения.  Шаблоны. Все три средства позволяют нам повторно использовать существующие классы и создавать на их основе новые расширенные классы. Для применения такого средства многократного использования, как шаблоны, обязательно должен быть доступен исходный код существующего класса. И напротив, отношения включения и наследование могут задействовать существующие классы, даже если их объектный код недоступен. В этой главе мы подробно рассмотрим механизм наследования. Наследование Наследование — это процесс создания новых классов, называемых производными классами, на основе существующих классов. Эти существующие классы часто называют базовыми классами. Производный класс наследует все возможности базового класса, но может содержать свой уникальный дополнительный функционал и различные усовершенствования. При добавлении этих усовершенствований базовый класс не претерпевает никаких изменений. После того как базовый класс написан и отлажен, в нем уже ничего не нужно менять, и в то же время его всегда можно адаптировать для решения различных задач. Повторное использование существующего кода экономит время и средства, повышая при этом надежность программы. Наследование также играет полезную роль в первоначальной концептуализации технического задания и в общем проектировании программы. Возможность многократного использования кода способствует распространению библиотек классов. Программист может использовать класс, созданный его коллегами, и, не изменяя его, производить от него другие классы, подходящие для конкретных задач разработки. А теперь давайте приступим к изучению концепции наследования на примере программы. Предположим, мы разработали класс с именем Index, который служит счетчиком общего назначения. Предположим также, что мы долго и усердно работали над тем, чтобы класс Index работал именно так, как мы хотим, и мы довольны результатами, за исключением одного момента. Класс Index может лишь инкрементировать счетчик, но не умеет декрементировать его. Исправить это мы можем, внедрив функцию декремента непосредственно в исходный код класса Index.
144 Глава 6 Однако есть ряд причин воздержаться от подобного решения. Прежде всего, класс Index работает хорошо, он был тщательно протестирован и отлажен. В данном случае это преувеличение, но это было бы верно для более крупного и сложного класса. Если же мы начнем модифицировать исходный код класса Index, тестирование нужно будет проводить снова. К тому же всегда существует вероятность того, что в конечном итоге исходный класс будет работать неудовлетворительно. Иногда может быть и другая причина не изменять класс Index — у нас может просто не быть доступа к его исходному коду, особенно если он распространялся как часть библиотеки классов. Во избежание этих проблем можно пойти совершенно другим путем и применить наследование для создания нового класса на основе класса Index, не изменяя его. Вот как этого можно достичь: #include <iostream> using namespace std ; class Index // базовый класс { protected : int count ; public : Index( ) { count = 0 ; } void display( ) { cout << "счетчик = " << count << endl ; } void operator ++ ( ) { count++ ; } } ; class NewIndex : public Index // производный класс { public : void operator -- ( ) { count-- ; } } ; { NewIndex i ; ++i ; ++i ;
145 Наследование display( ) ; --i ; i.display( ) ; return 0 ; } Здесь мы сначала объявили базовый класс с именем Index, а затем создали на его основе производный класс с именем NewIndex. NewIndex наследует все особенности базового класса Index. Ему не требуются функции operator ++( ) и display( ), поскольку они уже представлены в базовом классе. В первой строке класса NewIndex class NewIndex : public Index указывается, что класс NewIndex является производным от базового класса Index. Позднее мы увидим, что произойдет, если мы используем спецификатор private вместо public. Рис. 6.1 демонстрирует взаимоотношения между базовым и производным классом. Рис. 6.1. Наследование Важно понимать, что стрелка на рисунке означает «выведен из». Направление стрелки указывает на то, что производный класс ссылается на функции и данные базового класса, в то время как базовый класс не имеет доступа к данным или функциям производного класса. Поскольку мы не объявили никакого конструктора в классе NewIndex, компилятор добавит в него непараметризированный конструктор. При создании объекта i класса NewIndex сначала будет вызван конструктор в базовом классе, а затем конструктор в производном классе. При вызове функций operator ++( ) или display( ) для объекта i их поиск сначала осуществляется в классе NewIndex. Поскольку в NewIndex их нет, поиск продолжается в базовом классе Index. В классе Index они обнаруживаются и выполняются оттуда же.
146 Глава 6 При вызове функции operator --( ) для объекта i ее поиск сначала производится в классе NewIndex. Она обнаруживается в классе NewIndex и оттуда же выполняется, обеспечивая декрементирование счетчика count. Члены данных в классах, созданных нами до сих пор, использовали спецификатор доступа private. Однако в этой программе мы пометили count как protected («защищенный») член данных. Будь он помечен как private, он был бы недоступен функции operator --( ), т. к. она находится за пределами класса Index. Мы также не хотели придавать счетчику count статус public, т. к. это позволило бы получить к нему доступ через любую функцию из любой части программы, тем самым мы бы лишились всех преимуществ скрытия данных. С другой стороны, к protected-члену могут обращаться функции-члены в его собственном классе или в любом классе, производном от его собственного класса. К нему нельзя получить доступ из функций за пределами этих классов, таких как main( ). И это то, что нам нужно. Поэтому мы задействуем спецификатор доступа protected. На рис. 6.2 наглядно продемонстрировано, что и к чему имеет доступ в отношениях между базовыми и производными классами. Вместо терминов «базовый — производный» некоторые авторы пользуются их синонимами, «родительский — дочерний». Таким образом, благодаря производному классу NewIndex (и в целом благодаря наследованию) мы расширили функционал класса Index, не модифицируя его. Важно отметить, что наследование не работает в обратную сторону. То есть базовый класс и его объекты ничего не знают о каких-либо классах, производных от базового класса. Если бы в нашей программе мы сгенерировали объект j класса Index, то функция operator --( ) осталась бы недоступной для этого объекта. Обратите внимание, что наша программа может объявлять объекты как базовых, так и производных классов. Эти два объекта независимы друг от друга. Рис. 6.2. Доступ при наследовании
Наследование 147 Еще один пример наследования Давайте теперь перейдем к другому примеру, иллюстрирующему принцип наследования в действии. Внимательно изучите программу и попытайтесь понять, как она работает. Далее мы подробно рассмотрим еще несколько особенностей наследования. #include <iostream> using namespace std ; const int MAX = 10 ; class Stack // базовый класс { protected : int arr[ MAX ] ;int top ; public : Stack( ) { top = -1 ; } void push ( int num ) { top++ ; arr[ top ] = num ; } int pop( ) { int num ; num = arr[ top ] ; top-- ; return ( num ) ; } } ; class NewStack : public Stack // производный класс { public : bool isFull( ) { if ( top == MAX - 1 ) return true ; else return false ; } bool isEmpty( ) { if ( top == -1 ) return true ; else return false ; } } ;
148 int main( ) { NewStack stk ; if ( !stk.isFull( ) ) stk.push ( 10 ) ; else cout << "Стек заполнен" << endl ; if ( !stk.isFull( ) ) stk.push ( 20 ) ; else cout << "Стек заполнен" << endl ; if ( !stk.isFull( ) ) stk.push ( 30 ) ; else cout << "Стек заполнен" << endl ; int n ; if ( !stk.isEmpty( ) ) { n = stk.pop( ) ; cout << "Текущий элемент = " << n << endl ; } else cout << "Стек пуст" << endl ; if ( !stk.isEmpty( ) ) { n = stk.pop( ) ; cout << "Текущий элемент = " << n << endl ; } else cout << "Стек пуст" << endl ; if ( !stk.isEmpty( ) ) { n = stk.pop( ) ; cout << "Текущий элемент = " << n << endl ; } else cout << "Стек пуст" << endl ; if ( !stk.isEmpty( ) ) { n = stk.pop( ) ; cout << "Текущий элемент = " << n << endl ; } Глава 6
Наследование 149 else cout << "Стек пуст" << endl ; return 0 ; } Как вы уже, возможно, знаете, структура данных типа стек (stack) является списком, организованным по принципу LIFO («последним пришел, первым вышел»). Такая структура реализована в базовом классе stack. Она позволяет помещать элементы в стек и извлекать их из него. Однако функция Stack::push( ) не учитывает возможность полного заполнения стека. Так же функция Stack::pop( ) не предусматривает ситуации, когда в какой-то момент стек окажется пустым, и она не сможет удалить элемент из пустого стека. Чтобы справиться с этими задачами, мы создали класс NewStack, наследующий класс Stack. Мы включили в него две функции — isFull( ) и isEmpty( ). Эти функции сообщают о том, полон стек или нет и пуст он или нет. Заметьте, что в функции main( ) мы создали объект производного класса. Через него доступны не только функции isFull( ) и isEmpty( ), но также функции push( ) и pop( ) класса Stack. Таким образом, при помощи наследования мы придали дополнительную функциональность стеку, не изменяя базовый класс. Варианты применения наследования Теперь, когда у нас есть общее представление о наследовании, давайте посмотрим, в каких сценариях оно используется в C++. Так, существуют четыре распространенных варианта применения наследования:  Применение существующего функционала.  Переопределение существующего функционала.  Создание нового функционала.  Сочетание нового и существующего функционалов. Разберем несколько примеров и начнем со сценария, демонстрирующего все четыре варианта применения наследования, перечисленные выше. Вот небольшая программа: // Программа, демонстрирующая различные варианты применения наследования #include <iostream> using namespace std ; class Ex { public : void fun( ) { cout << "Внутри Ex - fun( )" << endl ; }
150 void save( ) { cout << " Внутри Ex - save( )" << endl ; } void enc( ) { cout << " Внутри Ex - enc( )" << endl ; } void open( ) { cout << " Внутри Ex - open( )" << endl ; } } ; class NewEx : public Ex { public : void save( ) { cout << " Внутри NewEx - save( )" << endl ; } void enc( ) { cout << " Внутри NewEx - enc( )" << endl ; } void autoUpdate( ) { cout << " Внутри NewEx - autoUpdate( )" << endl ; } void open( ) { cout << " Внутри NewEx - open( )" << endl ; Ex::open( ) ; } } ; int main( ) { NewEx e ; e.fun( ) ; e.save( ) ; e.enc( ) ; e.autoUpdate( ) ; e.open( ) ; } Ниже приведен результат работы программы: Внутри Ex - fun( ) Внутри NewEx - save( ) Глава 6
Наследование Внутри Внутри Внутри Внутри 151 NewEx - enc( ) NewEx - autoUpdate( ) NewEx - open( ) Ex - open( ) В ней мы определили два класса — Ex в качестве базового и NewEx в качестве производного. Класс Ex содержит функции-члены fun( ), save( ), enc( ) и open( ), тогда как класс NewEx содержит функции-члены save( ), enc( ), open( ) и autoUpdate( ). В основной функции main( ) мы создали объект класса NewEx, а затем вызвали несколько функций-членов. Теперь взгляните на вывод программы, чтобы оценить различные сценарии, применяющие концепцию наследования. При вызове функции fun( ) ее поиск сначала производится в классе NewEx. Поскольку в NewEx ее нет, поиск продолжается в базовом классе, где она и обнаруживается. В результате вызывается функция базового класса и выводится сообщение «Внутри Ex — Fun( )». Тем самым мы продемонстрировали вызов одной из функций базового класса, а именно функции fun( ), как она есть, для объекта производного класса. При вызове функции save( ) ее поиск снова осуществляется в производном классе NewEx. Поскольку поиск в NewEx дает положительный результат, вызывается именно эта версия функции save( ) (а не та, что находится в классе Ex). Это пример того, как с помощью наследования мы можем переопределить существующий функционал. То же самое относится к функции enc( ). При вызове функции autoUpdate( ) она обнаруживается в классе NewEx. Поскольку в базовом классе нет функции autoUpdate( ), это тот случай, когда благодаря наследованию создается новый функционал. Наконец, при вызове функции open( ) она обнаруживается в классе NewEx. Вызывается данная версия open( ), которая, в свою очередь, вызывает версию функции open( ) из базового класса посредством инструкции Ex::open( ). В итоге на экран выводится: Внутри NewEx - open( ) Внутри Ex - open( ) Здесь мы имеем дело с сочетанием нового и существующего функционала благодаря применению принципа наследования. Наследование и конструкторы Как явствует из кода последней программы, компонентные функции базового класса можно вызывать из компонентных функций производного класса, используя форму записи: имя_базового_класса::имя_функции( ) ; При отсутствии явного указания функция базового класса не вызывается из тела функции производного класса. Конструкторы в цепочке наследования обрабатыва-
152 Глава 6 ются по-другому. В отношении конструкторов в цепочке наследования следует уяснить два момента:  При создании объекта производного класса сначала вызывается конструктор базового класса, а затем конструктор производного класса.  Если при создании объекта производного класса мы не вызываем конструктор базового класса, то по умолчанию вызывается непараметризированный конструктор базового класса. Проиллюстрируем эти факты на примере следующей небольшой программы: // Код, демонстрирующий вызовы конструкторов в цепочке наследования #include <iostream> using namespace std ; class Base { public : Base( ) { cout << "Непараметризированный конструктор базового класса" << endl ; } Base ( int xx ) { cout << "Конструктор базового класса с одним аргументом" << endl ; } } ; class Der : public Base { public : Der( ) { cout << "Непараметризированный конструктор производного класса" << endl ; } Der ( int x ) : Base ( x ) { cout << "Конструктор производного класса с одним аргументом" << endl ; } } ; int main( ) { Der y ; Der z ( 10 ) ; } При выполнении программы на экран выводится: Непараметризированный конструктор базового класса Непараметризированный конструктор производного класса
Наследование 153 Конструктор базового класса с одним аргументом Конструктор производного класса с одним аргументом Из результата работы программы следует, что, когда мы создаем объекты y и z, сначала вызывается конструктор базового класса, а затем конструктор производного класса. При создании объекта y автоматически вызывается непараметризированный конструктор базового класса. Однако при создании z конструктор с одним аргументом должен был вызываться явно при помощи следующей формы записи: Der ( int x ) : Base ( x ) Если бы мы не использовали Base (x), тогда был бы вызван непараметризированный конструктор базового класса. Это означает, что список параметров функции-конструктора производного класса может отличаться от списка параметров функции-конструктора базового класса. Поэтому функция-конструктор для производного класса должна сообщать компилятору, какие значения использовать в качестве аргументов функции-конструктора для базового класса. Должно быть, вы задаетесь вопросом, почему необходимо, чтобы построение шло в порядке происхождения классов. Лучше всего это можно прояснить при помощи следующей небольшой программы: // Порядок создания объектов в цепочке наследования class Base { protected : int i ; public : Base( ) { i = 4 ; } } ; class Der : public Base { private : int j ; public : Der( ) { j = i * 4 ; } } ; int main( ) { Der d ; } Здесь i помечен как защищенный член данных, поэтому он доступен функциям производного класса Der. На секунду представьте, что порядок построения был бы
154 Глава 6 не от базового класса к производному. В этом случае сначала будет вызван конструктор Der. И поскольку i доступен в конструкторе, а конструктор базового класса еще не вызван, ему не было присвоено значение. В результате переменная j не будет установлена корректно. Значит, и значение в объекте d не будет присвоено корректно. И напротив, при соблюдении порядка происхождения классов объект d будет инициализирован надлежащим образом. В отношениях наследования наследуются не все функции. В частности, конструктор, деструктор, функция перегруженного оператора присваивания и конструктор копирования не наследуются. В общем и целом эти функции связаны с инициализацией, присваиванием или разрушением объекта, и они знают, что делать с этими аспектами объекта только на своем конкретном уровне в иерархии классов. Следовательно, они не наследуются. Виды наследования Язык C++ допускает три различных вида наследования:  Одиночное наследование.  Многоуровневое наследование.  Множественное наследование. Рассмотрим каждый из них в отдельности. Одиночное наследование Все программы в этой главе относились одиночному наследованию, когда один класс производился от другого. И даже в этом конкретном виде наследования можно выделить три подвида:  Открытое (public) наследование.  Защищенное (protected) наследование.  Закрытое (private) наследование. Во всех наших программах открытое наследование реализовывалось посредством таких объявлений, как class Der : public Base class NewStack : public Stack Мы можем выбрать защищенное или закрытое наследование, указав: class Der : protected Base class NewStack : private Stack Объявление производного класса типа: class Der : Base подразумевает, что мы выбираем закрытое (private) наследование.
155 Наследование Важно понимать, что, когда мы выбираем открытое (public) наследование, publicчлены и protected-члены сохраняют свой статус в производном классе. Тогда как при защищенном (protected) наследовании public-члены и protected-члены базового класса становятся защищенными в производном классе. При закрытом наследовании public-члены и protected-члены базового класса становятся закрытыми (private) в производном классе. Это отражено на рис. 6.3. Рис. 6.3. Подвиды одиночного наследования Могут ли два класса наследовать базовый класс? Безусловно могут. К примеру, у нас может быть базовый класс Shape, который наследуется классами Circle и Rectangle путем одиночного наследования. Могут возникать ситуации, когда необходимо скрыть часть функциональных средств базового класса. К примеру, мы хотим, чтобы функция display( ) класса Base была доступна за пределами класса, при этом остальные функции оставались бы недоступными. Достичь этого можно, реализовав закрытое наследование и добавив в производный класс следующую инструкцию: base :: display ; К защищенному наследованию прибегают не так часто; в языке С++ оно скорее присутствует для полноты картины. Многоуровневое наследование Класс может наследовать класс, который тоже является производным от другого класса. Таким образом, может существовать несколько уровней наследования. К примеру, у нас может быть базовый класс LinkedList, который наследуется классом LinkedList1. А из класса LinkedList1 мы можем вывести класс LinkedList2. При многоуровневом наследовании число уровней не ограничено.
156 Глава 6 Множественное наследование При множественном наследовании класс является производным от более чем одного базового класса. Давайте рассмотрим простой пример множественного наследования. Представим, что некая компания продает аппаратное и программное обеспечение. Для моделирования системы продаж этой компании мы можем создать четыре класса — Item (Товар), Sales (Продажи), HardwareItem (Оборудование) и SoftwareItem (ПО). На рис. 6.4 отображено содержимое этих классов, а также взаимосвязь между ними. Рис. 6.4. Множественное наследование Объявления этих классов приведены ниже: class Item { private : string title ; float price ; public : void getData( ) ; void displayData( ) ; } ; class Sales { private : float salesFigure[ 3 ] ; public : void getData( ) ;
Наследование 157 void displayData( ) ; } ; class HardwareItem : private Item, private Sales { private : string category ; string oem ; public : void getData( ) ; void displayData( ) ; } ; class SoftwareItem : private Item, private Sales { private : string category ; string os ; public : void getData( ) ; void displayData( ) ; } ; Хотя большинство из этих объявлений самоочевидны, стоит отметить два момента:  Объявление class HardwareItem : public Item, public Sales указывает на то, что класс HardwareItem происходит от двух классов: Item и Sales. Таким образом, он наследует свойства обоих этих классов. В этом объявлении наследования важен порядок перечисления классов. Если мы создадим объект HardwareItem, то конструкторы будут вызываться в последовательности Item( ), Sales( ). После этого будет выполнен код, помещенный в теле конструктора HardwareItem( ). То же самое относится к конструкторам с несколькими аргументами.  Если мы вызовем функцию displayData( ) из функции displayData( ) объекта HardwareItem (производный класс), компилятор не будет знать, функцию displayData( ) какого базового класса (Sales или Item) следует вызвать. Это логическое противоречие легко устранить, указав перед функцией displayData( ) имя класса и оператор разрешения контекста, как показано ниже: Item :: displayData( ) ; Sales :: displayData( ) ; Предупреждение Представьте ситуацию, когда класс наследует от двух базовых классов. Предположим, что два базовых класса имеют функции с одинаковыми именами, к примеру fun( ), а производный класс не имеет функции с таким именем. Что произойдет,
158 Глава 6 если объект производного класса попытается вызвать функцию базового класса fun( )? Произойдет ошибка, поскольку компилятор не сможет определить функцию fun( ) базового класса, которую вы хотите вызвать. Проблема эта решается при помощи оператора разрешения контекста, как показано ниже: // Base1 и Base2 — базовые классы // Оба класса содержат функцию fun( ) // Класс Der наследует от Base1 и Base2 Der d ; d.fun( ) ; // Ошибка d.Base1::fun( ) ; d.Base2::fun( ) ; Поэтапная разработка Одним из преимуществ наследования является поддержка поэтапной разработки, что позволяет добавлять новый программный код, не вызывая сбоев в уже имеющемся. Наследуя от существующего функционального класса и добавляя члены данных и функции-члены (а также переопределяя существующие функции-члены), вы оставляете существующий код, которым смогут воспользоваться другие, нетронутым и отлаженным. При возникновении ошибки вам наверняка известно, что искать ее нужно в вашем новом коде, который намного короче и легче читается, чем код всей программы. Не перестает восхищать та четкость, с которой разделены классы. Вам даже не нужен исходный код компонентных функций, чтобы повторно его использовать. Достаточно иметь заголовочный файл с описанием класса, и объектный файл или библиотечный. Важно понимать, что разработка программы — это поэтапный процесс. Еще никому не удалось продумать программу во всех мелочах до начала работы над ней. Программа должна пытаться создавать объекты различных типов и управлять ими, дабы реализовать модель на условиях, сформулированных в поставленной задаче. Программа — это не здание, которое целиком возводится по готовому чертежу; скорее, она должна вырасти как живое, эволюционирующее существо. Разумеется, в какой-то момент, когда все окончательно прояснится, вам будет необходимо поновому взглянуть на иерархию классов, чтобы свести ее к некой сообразной структуре. И наследование идеально подходит для этой задачи. 1. Укажите, верны ли следующие утверждения:  Можно создать производный класс из базового класса, даже если исходный код базового класса недоступен.  Множественное наследование отличается от многоуровневого наследования.
Наследование 159  Точно так же как функция-член производного класса может получить доступ к защищенным (protected) и открытым (public) членам базового класса, функции-члены базового класса могут получить доступ к защищенным (protected) и открытым (public) функциям-членам производного класса.  Производный класс можно создать при помощи открытого (public), закрытого (private) или защищенного (protected) наследования.  Функция-член производного класса имеет доступ к protected-членам и public- членам базового класса независимо от того, с каким спецификатором доступа был создан производный класс, public или private.  Если производный класс создан со спецификатором доступа public, то объект производного класса может получить доступ к public-членам базового класса.  Объект производного класса (любого происхождения) не может получить дос- туп к private-членам или protected-членам своего базового класса.  Закрытые члены базового класса недоступны для компонентных функций и объ- ектов производного класса.  При открытом (public) наследовании защищенные (protected) члены базового класса получают public-статус для функций за пределами производного класса.  Закрытое (private) и защищенное (protected) наследование ничем не отличаются.  При закрытом наследовании часть интерфейса базового класса может быть дос- тупна функциям за пределами производного класса.  Размер объекта производного класса равен сумме размеров членов данных в ба- зовом и производном классах.  Создание производного класса требует коренных изменений в базовом классе.  Если базовый класс содержит функцию-член func( ), а в производном классе нет функции с таким именем, то объект производного класса не может получить доступ к func( ).  Если для производного класса не указаны конструкторы, объекты производного класса будут задействовать конструкторы базового класса.  Если базовый класс и производный класс содержат функцию-член с одним и тем же именем, объектом производного класса будет вызываться функция-член производного класса  Класс D может происходить от класса C, являющегося производным от класса B, наследующего класс A.  Запрещается делать объекты одного класса членами другого класса. 2. Решите следующие задачи и ответьте на вопросы:  Создайте класс String, содержащий следующие функции: • Функцию перегруженного оператора сложения для выполнения строковой конкатенации. • Функцию перегруженного оператора присваивания для копирования строк.
160 Глава 6 • Функцию перегруженного сокращенного оператора присваивания +=. • Функцию для отображения длины строки. • Функцию для отображения размера строки. • Функцию toLower( ) для преобразования прописных букв в строчные. • Функцию toUpper( ) для преобразования строчных букв в прописные.  Предположим, что есть базовый класс B и класс D, производный от B. Класс B имеет две открытые функции-члены b1( ) и b2( ), тогда как в классе D есть две компонентные функции d1( ) и d2( ). Создайте эти классы для следующих условий: • Функция b1( ) должна быть доступна из main( ), а функция b2( ) — нет. • Функции b1( ) и b2( ) не должны быть доступны из main( ). • Функции b1( ) и b2( ) должны быть доступны из main( ).  Класс D наследует два базовых класса B1 и B2. Создайте эти классы так, чтобы в каждом из них присутствовал непараметризированный конструктор. Удостоверьтесь в том, что при построении объекта класса D сначала вызывается конструктор B2, а затем конструктор B1. Также реализуйте деструктор для каждого класса. В каком порядке будут вызываться эти деструкторы?  Предположим, что класс D произведен от класса B при помощи закрытого на- следования. К какому из перечисленных элементов может получить доступ объект класса D, расположенный в основной функции main( )? • К public-членам класса D. • К protected-членам класса D. • К private-членам класса D. • К public-членам класса B. • К protected-членам класса B. • К private-членам класса B.  C++ упрощает многократное использование кода на двух уровнях: • на уровне исходного кода; • на уровне объектного кода.  Многократное использование на уровне исходного кода осуществляется при по- мощи шаблонных функций (Template functions) и шаблонных (параметризированных) классов (Template classes).  Шаблоны позволяют нам создавать обобщенные функции/классы, а компилятор генерирует из них специализированные функции/классы.
Наследование 161  Для создания специализированных функций/классов должен быть доступен ис- ходный код.  Многократное использование на уровне объектного кода осуществляется благо- даря отношениям включения и наследованию.  Отношения включения следует применять, когда два класса связывают родови- довые отношения.  Наследование следует применять, когда два класса связывают отношения сход- ства.  Отношения включения и наследование могут быть реализованы, даже если ис- ходный код недоступен.  Терминология наследования: базовый — производный, родительский — дочер- ний.  В цепочке наследования доступны защищенные (protected) члены.  Объект производного класса содержит все данные базового класса.  Объект производного класса может не иметь доступа ко всем данным базового класса.  Создание объекта всегда строится в порядке происхождения классов.  Конструктор базового класса можно вызвать, используя форму записи: имя_базового_класса( )  Наследование обеспечивает: • Применение существующего функционала, для чего достаточно установить отношения наследования. • Скрытие существующего функционала путем определения той же функции в производном классе. • Расширение существующего функционала посредством вызова функции базового класса из производного класса, используя форму записи: имя_базового_класса::функция_базового класса( )  Существуют три вида наследования: • одиночное; • многоуровневое; • множественное.  При множественном наследовании класс наследует два или более базовых класса.  При множественном наследовании порядок классов в объявлении производного класса задает порядок, в котором вызываются конструкторы базовых классов.

Полиморфизм После классов/объектов и наследования полиморфизм является третьей ключевой особенностью C++. Ограничившись наследованием, можно упустить из виду наиважнейший раздел языка, а именно полиморфизм. В этой главе мы подробно разберем концепцию полиморфизма
164 Глава 7  Виртуальная функция  Чистая виртуальная функция  Абстрактный класс  Связывание функций  Анатомия виртуальных функций  Для чего применять виртуальные функции?  Срезание (нарезка) объектов  Виртуальные деструкторы • Вызов виртуальных функций из конструкторов/деструкторов  Виртуальные базовые классы  Упражнения  Важное
Полиморфизм П 165 охоже, что на практике программисты переходят с C на C++ в три этапа. На первом этапе они начинают использовать C++ просто как «улучшенный вариант C». На этой стадии они активно задействуют прототипы функций, оператор разрешения контекста, спецификатор const, ссылки и ряд других инструментов. Всё это имеет весьма отдаленное отношение к объектно-ориентированному программированию. Освоившись с необъектно-ориентированными функциями, C++ начинают применять в качестве языка программирования «на базе объектов». На этом этапе программисты открывают для себя преимущества объединения данных с функциями, которые их обрабатывают, значение конструкторов и деструкторов, а также некоторые азы наследования. Многие и правда считают, что они овладели объектно-ориентированным программированием с тех самых пор, как освоили классы, объекты и наследование. Какими бы лестными ни казались подобного рода иллюзии, не дайте себя обмануть. Наиважнейшая часть C++ — это полиморфизм. В C++ полиморфизм представлен в двух основных формах:  Одно явление существует в нескольких различных формах.  Одно действие ведет к совершению различных операций. С первой формой мы познакомились, когда изучали перегруженные функции и перегруженные операторы. Во второй форме под действием понимается вызов одного метода, приводящий к совершению различных операций, т. е. к вызову различных функций в иерархии классов. Эта форма полиморфизма реализуется при помощи виртуальных функций. Виртуальная функция Допустим, у нас есть два класса Circle («Круг») и Rectangle («Прямоугольник»), оба производные от класса Shape («Фигура»). Каждый класс содержит функцию draw( ) для отображения соответствующей фигуры на экране монитора. Если необходимо создать изображение, содержащее множество кругов и прямоугольников, то можно сделать это при помощи программы, приведенной ниже: #include <iostream> using namespace std ; class Shape { public : virtual void draw( ) { cout << "В классе Shape" << endl ; } } ;
166 Глава 7 class Circle : public Shape { public : void draw( ) { cout << "В классе Circle" << endl ; } } ; class Rectangle : public Shape { public : void draw( ) { cout << "В классе Rectangle" << endl ; } } ; int main( ) { Circle c1, c2, c3 ; Rectangle r1, r2, r3 ; int i ; Shape *p[ ] = { &c1, &c2, &r1, &r2, &r3, &c3 } ; for ( i = 0 ;i <= 5 ;i++ ) p[ i ]->draw( ) ; return 0 ; } Вот результат работы программы: В В В В В В классе классе классе классе классе классе Circle Circle Rectangle Rectangle Rectangle Circle Обратите внимание на тот факт, что в нашей небольшой программе класс Shape, а также производные от него классы Circle и Rectangle содержат метод с именем draw( ). Вместо отрисовки фигуры эти функции просто отображают сообщение. В основной функции main( ) мы создали массив указателей Shape и занесли в него адреса объектов Circle и Rectangle. И хотя наблюдается несоответствие типов слева (Shape *) и справа (Circle * / Rectangle *), оно допускается, поскольку Shape является базовым классом, а Circle и Rectangle — его наследниками. Когда мы сохраняем адрес объекта производного класса в указателе на объект базового класса, он называется указателем с приведением вверх (upcasting). Так, p[ ] представляет собой массив таких указателей.
Полиморфизм 167 В момент, когда нужно отобразить рисунок, мы можем просто запустить цикл: for ( i = 0 ;i <= 5 ;i++ ) p[ i ]->draw( ) ; Когда p[i] адресует объект Circle, вызывается метод Circle::draw( ). Аналогичным образом, когда он адресует объект Rectangle, вызывается метод Rectangle::draw( ). Таким образом, одно и то же действие приводит к совершению разных операций. Это и есть полиморфизм в действии. Для того чтобы данный полиморфный подход работал, необходимо соблюсти ряд условий:  Классы Circle и Rectangle должны наследовать один базовый класс, Shape.  Классы Shape, Circle и Rectangle должны содержать функцию с именем draw( ).  Функция draw( ) родительского класса Shape должна быть объявлена виртуальной (virtual).  Вызов метода draw( ) должен осуществляться посредством указателя. Рассмотрим два возможных варианта.  Если функция draw( ) используется без ключевого слова virtual, инструкция p[i ]->draw( ) вызовет метод Shape::draw( ).  Если p[ ] адресует объекты класса Shape, инструкция p[ i ]->draw( ) вызовет метод Shape::draw( ). Чистая виртуальная функция Можно добавить еще одно усовершенствование виртуальной функции, объявленной в классе Shape последней программы. Раз уж функция draw( ) в базовом классе никогда не выполняется, мы можем смело избавиться от тела этой виртуальной функции, а также добавить запись =0 в ее объявление, как показано ниже: class base { public : virtual void draw( ) = 0 ; } ; Функция draw( ) известна как чистая виртуальная функция. Знак «=» не имеет здесь ничего общего с операцией присваивания; значение 0 ничему не присваивается. Это выражение используется лишь для того, чтобы сообщить компилятору, что функция будет чистой, т. е. не будет иметь тела. Если можно удалить тело виртуальной функции, то можно ли удалить всю функцию целиком? Нет, это не сработает. Без метода draw( ) в базовом классе инструкции типа p[i]->draw( ) ; станут некорректными.
168 Глава 7 Абстрактный класс Выражение «нарисовать фигуру» не имеет особого смысла, т. к. в нем не уточняется, какую конкретно фигуру нужно нарисовать. Поэтому мы должны каким-то образом предотвратить попытку создания объекта класса Shape. Если класс Shape не предназначен для реализации собственных объектов, то не может быть и речи о вызове для него метода draw( ). Об этом позаботится чистая виртуальная функция. Если класс содержит чистую виртуальную функцию, то мы не можем создать из него объект. Такой класс называется абстрактным классом. Всякий раз, когда чистая виртуальная функция помещается в базовый класс, вы должны переопределить ее во всех производных классах, из которых планируете создавать объекты. Если не сделать этого, то производный класс тоже станет абстрактным. Тем не менее абстрактный класс может содержать ряд функций экземпляра, виртуальные функции и чистые виртуальные функции. Связывание функций Термин «связывание» применим к отношению между вызовом функции и фактическим кодом, выполняемым в результате такого вызова. В целом связывание решает, какую функцию вызывать. Если решение это принимается во время компиляции, оно называется статическим или ранним связыванием. Если же оно принимается позднее, т. е. в ходе выполнения программы, то это называется динамическим или поздним связыванием. Допустим, имеется глобальная функция с именем fun( ), и она вызывается из какойлибо части программы. Поскольку существует лишь одна версия функции fun( ), компилятор без труда связывает этот вызов с определением функции fun( ). То же верно и в отношении функции экземпляра, вызываемой посредством объекта или указателя на объект, членом которого она является. Как будет связываться перегруженная глобальная функция abs( )? В данном случае компилятор может решить, какую версию abs( ) следует вызывать, основываясь на типе аргумента, используемого при вызове. Таким образом, и в этом случае имело бы место статическое связывание. То же самое случится, если функция abs( ) перегружена в классе и вызывается посредством объекта этого класса или указателя на этот класс. Теперь давайте посмотрим, при каких условиях происходит позднее связывание функции. Возьмем тот же пример базового класса Shape и производных классов Circle и Rectangle. В каждом из них есть функция draw( ), при этом версия базового класса определена как виртуальная. Проанализируйте следующие вызовы: Shape s ; Circle c ; Rectangle r ;
Полиморфизм 169 s.draw( ) ; // вызывает Shape::draw( ) c.draw( ) ; // вызывает Circle::draw( ) r.draw( ) ; // вызывает Rectangle::draw( ) Все вызовы связываются статически, поскольку они производятся для заданного объекта. Поэтому здесь не возникает неоднозначности, какую функцию объекта следует вызывать. Однако для следующих вызовов будет применено позднее связывание: Shape s ; Circle c ; Rectangle r ; fun ( &s ) ; fun ( &c ) ; fun ( &r ) ; // место для кода // определение функции void fun ( Shape *p ) { p->draw( ) ; // позднее связывание } Здесь все вызовы функции draw( ) связываются динамически, поскольку они осуществляются при помощи указателя. Почему эти вызовы не могли быть подготовлены во время компиляции? Потому что на этапе компиляции содержимое указателя p неизвестно. В ходе выполнения программа запускает соответствующую версию draw( ) в зависимости от содержимого p. К примеру, в первом обращении к fun( ) указатель p содержит адрес объекта Shape, следовательно, p->draw( ) вызывает Shape::draw( ). Во втором обращении p адресует объект Circle, поэтому p->draw( ) вызывает Circle::draw( ). И наконец, в третьем обращении p адресует объект Rectangle, поэтому p->draw( ) вызывает Rectangle::draw( ). Должно быть, вы обратили внимание на тот факт, что в последних двух обращениях применяется указатель с приведением вверх. Так, можно сделать вывод о том, что для функции применяется динамическое связывание, когда ее вызов производится при помощи указателя, вне зависимости от того, происходит при этом приведение вверх или нет. Итак, для динамического связывания необходимы следующие условия:  Должно быть задействовано наследование.  Имя функции по единому прототипу должно присутствовать как в базовом, так и в производном классе.  Функция базового класса должна быть определена как виртуальная.  Вызов функции должен осуществляться через указатель на базовый класс (в том числе с приведением вверх).
170 Глава 7 Ключевое слово virtual сообщает компилятору, что он должен автоматически подготовить все механизмы, необходимые для выполнения позднего связывания. Для понимания работы этих механизмов нужно глубже изучить их устройство. Этим мы и займемся в следующем разделе. Анатомия виртуальных функций Недостаточно научиться применять виртуальные функции — необходимо также знать, как их обрабатывает компилятор. С первой частью мы уже знакомы, однако прежде чем мы перейдем ко второй, давайте рассмотрим небольшую программу: #include <iostream> using namespace std ; class Sample { private : int i ; public : virtual void { cout << endl } } ; class Trial { public : virtual void { cout << endl } } ; int main( ) { Sample s ; Trial t ; cout << sizeof return 0 ; } display( ) << "В классе Sample"; display( ) << " В классе Trial" ; ( s ) << endl << sizeof ( t ) ; Программа выводит на экран: 8 4
Полиморфизм 171 Мягко говоря, результат неожиданный. В объекте s лишь одно целое число, однако указывается, что размер объекта — 8 байт. Размер объекта t без целого числа указан как 4 байта. Попробуем разобраться, почему так происходит. Когда у класса есть одна или несколько виртуальных функций, размер любого объекта этого класса равен размеру его членов данных плюс размер указателя. Указатель этот внедряется в объект, если у класса есть одна или несколько виртуальных функций. Он называется указателем на таблицу виртуальных методов (VPTR) или на координирующую таблицу (VTABLE). Для выполнения позднего связывания компилятор создает координирующую таблицу VTABLE для каждого класса, содержащего виртуальные функции, а также для производных от него классов. Туда он помещает адреса виртуальных функций этого конкретного класса. Если вы не переопределяете функцию, которая была объявлена виртуальной в базовом классе, компилятор задействует адрес версии базового класса в VTABLE производного класса. Таким образом, указатель на таблицу виртуальных методов (VPTR) используется для каждого объекта, тогда как сама таблица (VTABLE) — для каждого класса. Кроме того, VPTR объекта всегда указывает на VTABLE того класса, из которого сгенерирован объект. VPTR настраивается в конструкторе. При вызове виртуальной функции через указатель базового класса компилятор автоматически внедряет код для получения VPTR и поиска адреса функции в VTABLE, тем самым вызывая требуемую функцию и осуществляя динамическое связывание. Допустим, указатель на объект базового класса содержит адрес объекта производного класса, и вы вызываете виртуальную функцию, используя этот указатель. Тут происходит нечто неожиданное. Вместо типичного вызова функции, представляющего собой обычную ассемблерную программу CALL по заданному адресу, компилятор генерирует другой код для выполнения вызова функции. Компилятор начинает с содержимого указателя базового класса. Таким содержимым является адрес объекта производного класса. По этому адресу извлекается VPTR-указатель объекта производного класса, посредством которого осуществляется доступ к таблице VTABLE производного класса. Из этой таблицы извлекается адрес вызываемой функции. Наконец, при помощи этого адреса вызывается функция производного класса. Все это — настройка координирующей таблицы для каждого класса, инициализация VPTR-указателя, вставка кода для вызова виртуальной функции — происходит автоматически, поэтому вам не нужно об этом беспокоиться. Уверен, что сказанное здесь прозвучит слишком отвлеченно, если мы не увидим, как эти механизмы работают на практике. Итак, составим небольшую программу. На рис. 7.1 представлена иерархия классов, которую предлагается в ней реализовать.
172 Глава 7 Рис. 7.1. Иерархия классов Ниже приведена программа, реализующая данную иерархию классов: #include <iostream> using namespace std ; class Vehicle { public : virtual void speed( ) { cout << endl << "В теле speed класса Vehicle" ; } virtual void maintenance( ) { cout << endl << "В теле maintenance класса Vehicle" << endl ; } void value( ) { cout << endl << "В теле value класса Vehicle" ; } } ; class FourWheeler : public Vehicle { public : void speed( ) { cout << endl << "В теле speed класса FourWheeler" ; } void maintenance( ) { cout << endl << "В теле maintenance класса FourWheeler" ; } } ;
Полиморфизм class TwoWheeler : public Vehicle { public : void speed( ) { cout << endl << "В теле speed класса TwoWheeler" ; } void maintenance( ) { cout << endl << "В теле maintenance класса TwoWheeler" ; } void value( ) { cout << endl << "В теле value класса TwoWheeler" ; } } ; class AirBorne : public Vehicle { public : void speed( ) { cout << endl << "В теле speed класса AirBorne" ; } } ; int main( ) { Vehicle *ptr1 ; Vehicle v ; ptr1 ptr1 ptr1 ptr1 = &v ; -> speed( ) ; -> maintenance( ) ; -> value( ) ; Vehicle *ptr2, *ptr3, *ptr4 ; FourWheeler maruti ; TwoWheeler bajaj ; AirBorne jumbo ; ptr2 = &maruti ;ptr3 = &bajaj ; ptr4 = &jumbo ; ptr2 -> speed( ) ; ptr2 -> maintenance( ) ; 173
174 Глава 7 ptr3 -> speed( ) ; ptr3 -> maintenance( ) ; ptr4 -> speed( ) ; ptr4 -> maintenance( ) ; ptr2 -> value( ) ; ptr3 -> value( ) ; Vehicle w ; w.speed( ) ; FourWheeler f ; f.speed( ) ; AirBorne a ; a.maintenance( ) ; return 0 ; } Результат работы программы: В теле speed класса Vehicle В теле maintenance класса Vehicle В В В В В В В теле теле теле теле теле теле теле value класса Vehicle speed класса FourWheeler maintenance класса FourWheeler speed класса TwoWheeler maintenance класса TwoWheeler speed класса AirBorne maintenance класса Vehicle В В В В В теле теле теле теле теле value класса Vehicle value класса Vehicle speed класса Vehicle speed класса FourWheeler maintenance класса Vehicle Как мы уже знаем, координирующая таблица VTABLE создается для каждого класса, имеющего виртуальные функции, и для порожденных от него классов. Это означает, что в нашей программе будет построена VTABLE для всех четырех классов: Vehicle, TwoWheeler, FourWheeler и AirBorne. Каждая из этих таблиц будет содержать адреса виртуальных функций. Ко всем объектам, реализованным из этих классов, компилятор автоматически добавит VPTR, адресующий VTABLE соответствующего класса. Это продемонстрировано на рис. 7.2. Заметьте, что класс AirBorne не содержит определения функции maintenance( ). Поэтому его VTABLE содержит адрес функции maintenance( ) базового класса.
Полиморфизм 175 Рис. 7.2. VPTR-указатели и координирующие таблицы для иерархии классов А теперь давайте разберем шаг за шагом работу программы. Для начала мы занесли адрес объекта базового класса в указатель на базовый класс, используя команды: Vehicle *ptr1 ; Vehicle v ; ptr1 = &v ; Затем мы вызвали функции-члены базового класса: ptr1 -> speed( ) ; ptr1 -> maintenance( ) ; ptr1 -> value( ) ; Хотя по форме записи эти вызовы кажутся аналогичными, внутренне они устроены по-разному. Причина в том, что первые две функции были объявлены виртуальными в базовом классе, а третья нет. Раз функция value( ) не определена как virtual, то независимо от того, присвоен ли указателю ptr1 адрес объекта базового или производного класса, всегда будет вызываться функция value( ) базового класса. Поскольку функция speed( ) была определена с ключевым словом virtual в базовом классе, выбор конкретной ее реализации (для Vehicle, TwoWheeler, FourWheeler либо AirBorne) зависит от адреса, занесенного в указатель ptr1. В нашем случае им оказывается адрес объекта Vehicle. Так, при обращении к методу speed( )VPTR сначала адресует объект v класса Vehicle. При помощи этого указателя осуществляется доступ к VTABLE класса Vehicle. Из этой координирующей таблицы извлекается адрес функции speed( ), принадлежащей классу Vehicle. И наконец, при помощи этого адреса вызывается сама функция Vehicle::speed( ). Точь-в-точь по такой же схеме осуществляется вызов функции maintenance( ). Поскольку получение vptr и установление фактического адреса функции происходит во время выполнения, мы получаем позднее связывание, к чему и стремились.
176 Глава 7 Следующий случай еще любопытнее. Взгляните на следующий фрагмент кода: Vehicle *ptr2 ; FourWheeler maruti ; ptr2 = &maruti ; ptr2 -> speed( ) ; ptr2 -> maintenance( ) ; Здесь мы присвоили адрес объекта производного класса указателю на объект базового класса. Теперь, когда мы вызываем функцию speed( ), для доступа к координирующей таблице класса FourWheeler будет задействован VPTR-указатель объекта maruti. Из этой таблицы будет извлекаться адрес функции FourWheeler:: speed( ). Через этот адрес будет вызываться функция FourWheeler::speed( ). Рассуждая в том же ключе, вы сможете разобраться с остальными вызовами, произведенными при помощи указателей ptr2, ptr3 и ptr4. Теперь рассмотрим вызовы: ptr2 -> value( ) ; ptr3 -> value( ) ; Поскольку функция value( ) была объявлена без спецификатора virtual в базовом классе, координирующие таблицы не участвуют в генерировании вызова Vehicle::value( ). В нашей программе мы не переопределили value( ) в производном классе. Однако даже если бы мы это сделали, все равно вызывалась бы реализация базового класса. Небезынтересны следующие три обращения: Vehicle w ; w.speed( ) ; FourWheeler f ; f.speed( ) ; AirBorne a ; a.maintenance( ) ; В них VPTR-указатели и координирующие таблицы вообще не участвуют. При вызове метода f.speed( ) не возникает неоднозначности. Поскольку у компилятора есть сам объект (а не просто его адрес), он знает точный тип и, следовательно, не будет задействовать позднее связывание для вызовов каких-либо функций. В целом, эффективности ради, большинство компиляторов будут выполнять раннее связывание при вызове виртуальной функции для объекта, когда им точно известен его тип. Теперь, когда мы закончили обзор программного кода, рассмотрим ряд тонкостей, связанных с виртуальными функциями:  В силу той исключительно важной роли, которую играет указатель на таблицу виртуальных методов при вызове виртуальных функций, необходимо, чтобы он всегда указывал на правильную координирующую таблицу. Вызов виртуальной
Полиморфизм 177 функции не должен выполняться до корректной инициализации VPTR-указателя. И потому лучше всего осуществлять эту инициализацию в конструкторе. Вот почему компилятор всегда добавляет в наш класс конструктор с нулевым аргументом, когда мы не задаем его сами — чтобы при необходимости инициализировать VPTR-указатель.  Как компилятору удается получить значение VPTR из объекта? Все объекты хранят свой указатель в одной и той же области (часто в самом начале), поэтому компилятор может с легкостью вычленить его из тела объекта.  Как только функция объявлена виртуальной в базовом классе, она считается виртуальной в производном классе, даже если она не была явно помечена как virtual в производном классе. Для чего применять виртуальные функции? Это немаловажный вопрос. Если прием с использованием виртуальных функций так хорош и они безошибочно вызывают «правильный» метод, то к чему другие варианты? Зачем нам вообще в это вникать? Ответ прост: механизм виртуальных функций недостаточно эффективен. Для подготовки вызова виртуальной функции требуются куда более сложные инструкции ассемблера, чем простая команда CALL. При этом увеличивается как объем кода, так и время его выполнения. Некоторые объектно-ориентированные языки, такие как Java, задействуют динамическое связывание для всех вызовов функций. Там ему нет альтернативы, и, следовательно, программисту не нужно об этом задумываться. Однако C++ является наследником C, где производительность имеет решающее значение. C++ был призван повысить продуктивность программистов на C. Если бы все обращения к методам в C++ реализовывались при помощи позднего связывания, то от этого серьезно пострадали бы производительность и эффективность программного продукта. Именно поэтому виртуальные функции представляют собой один из вариантов, и по умолчанию язык задействует невиртуальные средства, которые, естественно, работают быстрее. Как говорится, кто не пользуется, тот не платит. Тем не менее при разработке классов вам не стоит беспокоиться о потере в производительности из-за применения виртуальных функций. Если вы хотите использовать полиморфизм, задействуйте везде виртуальные функции. Поскольку их применение, как правило, дает гораздо больше преимуществ в других областях. Срезание объекта В чем преимущество виртуальных функций? Они служат гарантией того, что код, работающий с объектами базового типа, будет без каких-либо модификаций работать и с объектами производного типа. Однако виртуальные функции всегда следует вызывать при помощи указателя или ссылки. Попытайся мы сделать это посредством объекта, возникнет то, что называ-
178 Глава 7 ется срезанием объекта (object slicing) или нарезкой объекта. Следующая небольшая программа поможет нам разобраться: #include <iostream> using namespace std ; class base { private : int i ; public : base ( int ii ) { i = ii ; } virtual void fun1( ) { cout << endl << i << endl ; } } ; class derived : public base { private : int j ; public : derived ( int ii, int jj ) : base ( ii ) { j = jj ; } void fun1( ) { base::fun1( ) ; cout << endl << j << endl ; } } ; int main( ) { base b ( 10 ) ; derived d ( 15, 20 ) ; base *ptr1 = &b ; ptr1->fun1( ) ; base *ptr2 = &d ; ptr2->fun1( ) ; base &ref1 = b ; ref1.fun1( ) ; base &ref2 = d ; ref2.fun1( ) ;
Полиморфизм 179 b = d ; // срезанный объект b.fun1( ) ; return 0 ; } Уверен, вы самостоятельно разобрались с вызовами fun1( ), осуществленными при помощи указателей и ссылок. Проблема возникает в последнем присваивании. При использовании объекта вместо указателя или ссылки в качестве получателя с приведением вверх объект «срезается» до тех пор, пока от него не остается лишь подобъект, соответствующий его получателю. Иными словами, если объект производного класса присваивается объекту базового класса, компилятор принимает его, но копирует лишь базовую часть объекта, срезая производную его часть. И потому при обращении к b.fun1( ) вызывается только функция-член базового класса. Срезание объекта фактически удаляет часть объекта, а не просто меняет значение адреса, как при использовании указателя или ссылки. По этой причине к присваиванию объекта производного класса объекту базового класса с приведением вверх прибегают нечасто: в обычной практике этого следует избегать. Можно явным образом предотвратить срезание объекта, поместив чистые виртуальные функции в базовый класс: при попытке создать объект базового класса это вызовет ошибку на этапе компиляции. Виртуальные деструкторы Так же как работа конструктора состоит в создании объекта по частям, работа деструктора состоит в его разрушении по частям. При создании и разрушении объектов должны вызываться все конструкторы и деструкторы в иерархии классов. Разница лишь в порядке их вызова. Создание объекта начинается с вызова конструктора базового класса, затем вызывается конструктор производного от него класса и далее до последнего класса в цепочке иерархии. При разрушении объекта порядок меняется на обратный. То есть сначала вызывается деструктор самого последнего по происхождению класса и далее вверх, вплоть до базового класса. Это не только логично, но и безопасно. Конструктор производного класса может получить доступ к любому открытому и защищенному члену базового класса. Следовательно, к моменту доступа к ним они должны быть надлежащим образом настроены. Этого можно достичь, вызвав конструктор базового класса перед конструктором производного класса. Точно так же при разрушении объекта текущему деструктору всегда известно, что члены базового класса живы и активны. Не должно случиться так, чтобы члены базового класса разрушались при помощи деструктора базового класса, а затем деструктор производного класса пытался получить к ним доступ. Этого можно гарантированно избежать, вызвав сначала деструктор производного класса, а затем деструктор базового класса. Так, деструктор может выполнить свою чистку, а затем вызвать деструктор базового класса, который выполнит свою. Он в состоянии осуществить это, поскольку ему известно, из чего он произведен, но не известно, что произведено из него.
180 Глава 7 Следует иметь в виду, что конструкторы и деструкторы — единственные функции, при работе с которыми должна соблюдаться иерархия вызовов. Во всех остальных случаях будет вызываться один определенный метод, вне зависимости от того, виртуальный он или нет. Так, единственный способ вызвать версию определенной функции базового класса в обычных методах (со спецификатором virtual или без) — это эксплицитно вызвать данную конкретную функцию. Рассмотрим ситуацию, когда объект производного класса создается при помощи ключевого слова new. Адрес этого объекта следует занести в указатель на объект базового класса. Теперь, если мы удалим этот указатель посредством оператора delete, это приведет к вызову деструктора базового класса. В идеале сначала должен быть вызван деструктор производного класса, а затем деструктор базового класса. Этого можно достичь, используя виртуальный деструктор в базовом классе. Посмотрим на практике: #include <iostream> using namespace std ; class Base { public : Base( ) { cout << "В конструкторе класса Base" << endl ; } virtual ~Base( ) { cout << "В деструкторе класса Base" << endl ; } } ; class Derived : public Base { public : Derived( ) { cout << "В конструкторе класса Derived" << endl ; } ~Derived( ) { cout << "В деструкторе класса Derived" << endl ; } } ; int main( ) { Base *b ; b = new Derived ;delete b ; return 0 ; }
Полиморфизм 181 Программа выводит на экран: В В В В конструкторе класса Base конструкторе класса Derived деструкторе класса Derived деструкторе класса Base Несмотря на то что деструктор, как и конструктор, является специальной функцией, деструктор может быть виртуальным, потому что существующему объекту уже известен его тип (тогда как на момент его создания еще нет). Как только объект реализован, инициализируется его VPTR-указатель, и тогда могут выполняться вызовы виртуальных функций. Точно так же, как создается чистая виртуальная функция, можно создать и чистый виртуальный деструктор. Однако мы должны предоставить чистому виртуальному деструктору тело функции, потому что (в отличие от обычных функций) в иерархии классов всегда вызываются все деструкторы. Так, в конечном итоге будет вызвано и тело чистого виртуального деструктора. Объявляя чистый виртуальный деструктор в базовом классе, мы в действительности вынуждаем производный класс переопределить свой деструктор. Тело деструктора, заданное при определении базового класса, по-прежнему вызывается как часть процедуры разрушения объекта. Возьмите за правило — каждый раз, когда вы объявляете виртуальную функцию в классе, незамедлительно добавьте и виртуальный деструктор (даже если он ничего не делает). Так вы застрахуете себя от неприятных сюрпризов в будущем. Вызов виртуальных функций из конструкторов/деструкторов При обращении к виртуальной функции из тела обычной функции-члена для ее вызова используется механизм позднего связывания. Это не относится к конструкторам и деструкторам. Из тела конструктора/деструктора вызывается лишь локальная версия функции-члена; средства виртуализации игнорируются. В следующем листинге мы найдем тому подтверждение. Виртуальные базовые классы Рассмотрим еще один нюанс, связанный с виртуальными функциями. Предположим, имеется один родительский класс с именем Base и два производных от него класса, Derived1 и Derived2. Мы создаем класс Derived3, наследующий Derived1 и Derived2. Допустим теперь, что функция-член класса Derived3 хочет получить доступ к данным или функциям в классе Base. Поскольку классы Derived1 и Derived2 происходят от Base, каждый наследует его копию. Эта копия называется подобъектом (subobject). Каждый подобъект содержит копию данных класса Base. Это проиллюстрировано на рис. 7.3.
182 Глава 7 Рис. 7.3. Множественные подобъекты при множественном наследовании Теперь, когда Derived3 ссылается на данные класса Base, к какой из двух копий этих данных он будет обращаться? Это неоднозначная ситуация для компилятора, поэтому он сообщает об ошибке. Чтобы избежать такой неоднозначности, мы должны объявить Derived1 и Derived2 виртуальными базовыми классами, как показано в следующей программе. #include <iostream> using namespace std ; class Base { protected : int data ; public : Base( ) { data = 500 ; } } ; class Derived1 : virtual public Base { } ; class Derived2 : virtual public Base { } ; class Derived3 : public Derived1, public Derived2 { public : int getData( )
Полиморфизм 183 { return data ; } } ; int main( ) { Derived3 ch ; int a ; a = ch.getData( ) ; cout << a << endl ;return 0 ; } Применение ключевого слова virtual в двух базовых классах приводит к тому, что они совместно используют один подобъект класса Base. А поскольку имеется лишь один подобъект, то и ссылка на него в классе Derived3 не порождает логического противоречия. В этом примере классы Derived1 и Derived2 выступают в роли виртуальных базовых классов. Допустим, в классе Base есть функция fun( ), и мы обращаемся к ней, используя объект Derived3. В таком случае компилятор сообщает об ошибке, т. к. Derived3 наследует две копии fun( ), одну от Derived1, а другую от Derived2. Следовательно, когда мы пытаемся вызвать fun( ), компилятору не будет известно, намерены ли мы вызвать копию Derived1 или копию Derived2. Опять же, ошибки можно избежать, объявив Derived1 и Derived2 виртуальными базовыми классами. Иерархия наследования, используемая в этой программе, часто называется ромбовидной структурой (см. рис. 7.3). 1. Укажите, верны ли следующие утверждения:  Виртуальные функции позволяют обращаться к методам производного класса по указателю базового класса.  На каждую координирующую таблицу (VTABLE) и на каждый объект прихо- дится один VPTR-указатель.  VPTR-указатель всегда адресует координирующую таблицу класса.  Виртуальные функции позволяют выполнять методы из разных классов через единый вызов функции.  Чистые виртуальные функции не имеют тела.  Невозможно создать объект из класса, содержащего чистую виртуальную функ- цию.  Класс, содержащий чистую виртуальную функцию, называется абстрактным базовым классом.
184 Глава 7  Вызовы виртуальных функций исполняются быстрее, чем вызовы обычных функций.  Во многоуровневой иерархии классов функция должна быть объявлена вирту- альной в базовом классе, если требуется, чтобы функция любого уровня вызывалась через указатель базового класса.  Виртуальные функции можно смело вызывать посредством объектов.  Виртуальные функции ведут себя одинаково вне зависимости от того, вызваны они через указатели или ссылки.  При создании объекта не имеет значения, что вызывается в первую очередь — конструктор базового или производного класса.  При разрушении объекта сначала должен вызываться деструктор производного класса, а затем деструктор базового класса.  Виртуальный деструктор обеспечивает корректный порядок вызова деструкто- ров в иерархии классов.  Чистый виртуальный деструктор может иметь тело функции. 2. Решите следующие задачи и ответьте на вопросы:  Что представляют собой виртуальные базовые классы? Когда их следует применять?  Разработайте программу, содержащую класс Derived, производный от класса Base. Класс Base должен содержать виртуальную функцию fun( ), и она должна быть переопределена в классе Derived. Попытайтесь вызвать fun( ) из конструктора класса Derived и проанализируйте полученный результат.  Полиморфизм имеет два значения: • одно явление существует в нескольких формах; • одно действие ведет к совершению различных операций.  Виртуальные функции способствуют реализации полиморфизма второго типа.  Связывание означает решение, какую функцию вызывать.  Если связывание осуществляется во время компиляции, оно называется ранним связыванием.  Если связывание осуществляется во время выполнения программы, оно называ- ется поздним связыванием.  C++ выполняет раннее связывание, когда это возможно, а также позднее связы- вание, когда раннее связывание невозможно.  Раннее связывание также известно как статическое связывание или связывание во время компиляции.
Полиморфизм 185  Позднее связывание также известно как динамическое связывание или связыва- ние во время выполнения.  Для позднего связывания вызываемая функция должна присутствовать в базо- вом классе, а также в производном классе, и вызов должен выполняться с использованием указателя.  Если вызов осуществляется для объекта, то он всегда подлежит раннему связы- ванию.  Указатель с приведением вверх — это указатель базового класса, содержащий адрес объекта производного класса.  Если вызов виртуальной функции осуществляется при помощи указателя (с при- ведением вверх или без), он всегда подлежит позднему связыванию.  Чтобы запретить создание объекта из класса, он должен содержать хотя бы одну чистую виртуальную функцию.  Класс, из которого нельзя создать объект, называется абстрактным классом.  Если класс содержит виртуальную функцию, для нее создается координирую- щая таблица (VTABLE). Все объекты этого класса будут содержать VPTR-указатель.  Если базовый класс содержит виртуальную функцию, а производный от него класс содержит функцию с таким же прототипом, то функция производного класса рассматривается как виртуальная, даже если она эксплицитно не объявлена как виртуальная.  Если базовый класс содержит виртуальную функцию, а производный от него класс не содержит функции с таким же прототипом, то VTABLE производного класса содержит адрес виртуальной функции базового класса.  Виртуальный деструктор обеспечивает надлежащий порядок вызова деструкто- ров в иерархии классов.  При ромбовидном наследовании можно избежать создания нескольких подобъ- ектов, объявив промежуточные классы как виртуальные.

Система ввода-вывода в C++ Нет особого смысла писать программу, которая занята лишь тем, что общается сама с собой. Практически все программы вынуждены в той или иной форме выполнять операции ввода-вывода данных. В этой главе мы исследуем этот важный аспект программирования на C++
188 Глава 8  Требования к системе ввода-вывода  Решение с использованием потоков в C++  Предопределенные потоковые объекты  Библиотека iostream  Класс istream  Класс ostream • Вывод символов в кодировке Unicode  Класс iostream  Манипуляторы потока  Пользовательские манипуляторы • Пользовательские манипуляторы с аргументами  Работа с потоками ввода-вывода в файл  Символьный ввод-вывод • Открытие файла • Чтение данных • Обнаружение конца файла (EOF) • Закрытие файла  Программа копирования файлов  Ввод-вывод строк  Ввод-вывод записей  Прямой доступ  Режимы открытия файла  Строковые потоки • Работа с istrstream  Ввод-вывод объектов  Сериализация  Обработка ошибок ввода-вывода  Взаимодействие с файловой системой  Упражнения  Важное
Система ввода-вывода в C++ Я 189 зыки компьютерного программирования существуют уже свыше полувека. И когда появился C++ на основе ООП, программисты ожидали полноценную систему ввода-вывода, которая была бы простой, ясной, надежной и адаптируемой. Это мы уже видели при использовании cout и cin:  Для применения cout и cin нам не требуется запоминать спецификаторы форма- та, такие как %c, %d, %f и т. п., которые обязательны в функциях scanf( ) и printf( ) в языке C. Это просто и удобно.  cout и cin адаптируемы, поскольку могут стать входными или выходными объектами для пользовательских типов данных.  Если операция ввода-вывода не была определена для какого-либо типа данных, компилятор выдаст ошибку при попытке использовать cout и cin с этим типом. Однако cout и cin составляют лишь малую толику тех средств, что предлагает программистам система ввода-вывода в C++. В этой главе предлагается изучить эти средства с целью эффективной реализации потребностей программы в инструментах ввода/вывода. Требования к системе ввода-вывода Общепринятые требования к системе ввода/вывода обычно включают в себя:  Связь между различными исходными и целевыми устройствами, т. е. программа на C++ должна быть способна выполнять операции чтения с устройств ввода, таких как клавиатура, порт, диск и т. п., а также выполнять операции записи на диск или иное внешнее устройство.  Возможность ввода-вывода различных объектов — программа должна иметь возможность ввода-вывода символов, чисел всех видов, строк, записей и объектов.  Многообразие средств связи — ввод-вывод должен осуществляться в различных режимах, последовательный и прямой доступ и т. п.  Связь с файловой системой — под этим мы понимаем возможность взаимодей- ствовать с объектами файловой системы, такими как файлы и каталоги, а также иметь возможность получать доступ и управлять путями, установкой дат, правами доступа и т. д.  Надежная система сообщений об ошибках т. е. программа должна быть способ- на эффективно обрабатывать ошибки, возникающие при выполнении операций ввода-вывода. Посмотрим, насколько C++ удовлетворяет этим требованиям. Библиотека C для выполнения ввода-вывода основана на функциях, а библиотека C++ — на классах. Оба языка не имеют ключевых слов для выполнения вводавывода. Библиотека C для выполнения ввода-вывода известна как библиотека stdio, а библиотека C++ — как библиотека iostream.
190 Глава 8 Решение с использованием потоков в C++ Для соответствия требованиям к полноценной системе ввода-вывода разработчик C++ решил, что весь ввод-вывод должен выполняться при помощи потоков вводавывода. Поток — это последовательность байтов, которые перемещаются от источника к целевому устройству по каналу связи. Программа может считывать данные из потока или записывать данные в поток. Потоки связываются с физическими устройствами при помощи C++-системы ввода-вывода. Большинство составляющих элементов такого сообщения скрыты от нас системой ввода-вывода, дабы мы смогли сосредоточиться исключительно на том, что и откуда мы хотим считать и что и куда хотим записать. Рис. 8.1 поможет вам визуально представить эту концепцию. Рис. 8.1. Потоки ввода-вывода Потоки реализованы при помощи классов в библиотеке iostream. Эта абстракция операций ввода-вывода с использованием потоков дает одно принципиальное преимущество — поток ведет себя одинаково вне зависимости от того, откуда мы считываем или куда записываем. К примеру, мы вызываем один и тот же метод независимо от того, происходит ли считывание из сетевого подключения или с дискового файла. Реализация метода для различных устройств варьируется. Так, благодаря потоковому вводу-выводу программисту не нужно беспокоиться о специфике операционной системы и основных устройств при осуществлении вводавывода, как показано на рис. 8.2. В библиотеке iostream отличительные особенности устройств и ОС скрыты от нас в разных классах потоков. Рис. 8.2. Потоковый вывод на различные устройства
Система ввода-вывода в C++ 191 Чтение и запись — две основные операции, которые можно выполнять с потоком. Чтение включает передачу данных из потока в структуру данных, такую как массив байтов. Запись состоит из передачи данных из источника данных в поток. Впрочем, не каждый поток поддерживает чтение и запись. Предопределенные потоковые объекты Как мы убедились, поток (stream) — это общее название потока данных. Для представления различных видов информации используются разные потоки. К примеру, стандартный входной поток направлен от клавиатуры в программу. По аналогии стандартный выходной поток, а также стандартные потоки вывода информации об ошибках передаются из программы на экран. В C++ поток представлен объектом определенного класса. Так, для вышеупомянутых потоков библиотека iostream предоставляет предопределенные объекты cin, cout, cerr и clog. Из них cin — это объект класса istream, а cout, cerr и clog — объекты класса ostream. Все они применяются для выполнения текстового вводавывода для символов ASCII (размером 1 байт). Для символов Unicode (занимающих 2 байта) используются их аналоги wcin, wcout, wcerr и wclog. Они являются объектами классов wistream и wostream соответственно. Вы, должно быть, заметили, что объекты cout, cerr и clog представляют собой поток данных из программы на экран. В чем же между ними разница? Дело в том, что cout предназначен для вывода на экран, cerr — для отправки ошибок на стандартное устройство вывода информации об ошибках, а clog — для передачи записей в журнала событий (логов). Под двумя последними устройствами также может пониматься экран. Заметьте, что вывод при помощи clog буферизуется, а при помощи cerr — нет. Это означает, что данные, отправленные в поток cerr, выводятся немедленно — что целесообразно, т. к. позволяет оперативно уведомлять пользователя об ошибках. И напротив, поток clog буферизирован. Это означает, что сообщения, отправленные с помощью clog, сначала записываются в буфер. Эти сообщения выводятся на экран, как только буфер полностью заполняется. Часто сообщения журнала перенаправляются в файл, чтобы не загромождать ими экран. Библиотека iostream Библиотека iostream предоставляет множество шаблонных классов для обработки общих операций ввода-вывода. Шаблонные классы — это универсальные классы, из которых можно создавать классы для определенных типов данных. Иерархия классов в библиотеке iostream частично проиллюстрирована на рис. 8.3. Как видно из рис. 8.3, класс спецификации ввода-вывода (basic_ios) расположен в корне иерархии классов iostream. Этот класс содержит специфичные для всех потоков особенности. Сюда входят флаги форматирования данных потока, флаги
192 Глава 8 статуса проверки ошибок и режимы файловых операций. Вскоре мы подробно их рассмотрим. Класс ios имеет указатель на класс basic_streambuf, содержащий фактический буфер памяти, откуда считываются и куда записываются данные, а также процедуры для обработки этих данных. Обычно программистам не нужно специально определять basic_streambuf, на который ссылаются другие классы. Однако при необходимости можно получить доступ к буферу, вызвав функции-члены класса basic_streambuf. Рис. 8.3. Иерархия классов ввода-вывода Класс basic_istream является шаблонным. Из этого универсального класса создаются специализированные классы istream и wistream для выполнения операций потокового ввода с 8-битовыми и 16-битовыми символами соответственно. По аналогии шаблонный класс basic_ostream и специализированные классы ostream и wostream поддерживают операции потокового вывода. И точно так же basic_iostream, iostream и wiostream поддерживают операции потокового ввода и вывода. Класс istream Класс istream выведен из класса ios. Он осуществляет операции, специфичные для ввода. Одной из наиболее востребованных компонентных функций этого класса является перегруженный оператор ввода >>. Он перегружен для извлечения значений всех основных типов. С его помощью можно извлечь даже строку, как показано ниже: char str[ 100 ] ; cin >> str ; Направление оператора >> дает представление об исходном и целевом устройствах. Здесь символы извлекаются из объекта входного потока cin и помещаются в строку str. Однако в таком случае мы не можем контролировать количество знаков, которые будут извлечены в строку. Всегда существует опасность переполнения строки. Кроме того, если мы указываем строку из нескольких слов, cin извлекает из нее
193 Система ввода-вывода в C++ в строку str лишь первое слово. В подобных случаях мы можем воспользоваться функцией-членом get( ) или getline( ) класса istream. Функция get( ) представлена в нескольких формах, приведенных в табл. 8.1. Таблица 8.1. Функции ввода в классе istream Функция Назначение get ( ch ) Извлекает один символ в ch get ( str, MAX ) Извлекает до MAX числа знаков в str get ( str, DELIM ) Извлекает символы в массив str до указанного символа-разделителя (обычно ′\n′). Оставляет разделитель в потоке get ( str, MAX, DELIM ) Извлекает символы в str до MAX числа знаков либо до символаразделителя DELIM Оставляет разделитель в потоке getline ( str, MAX, DELIM ) Извлекает символы в массив str до MAX числа знаков либо до символа-разделителя DELIM. Извлекает символ-разделитель В дополнение к этому класс istream поддерживает несколько различных функций. Они приведены в табл. 8.2. Таблица 8.2. Различные функции класса istream Функция Назначение putback ( ch ) Вставляет последний считанный символ обратно во входной поток peek ( ch ) Считывает один символ, оставляет его в потоке num = gcount( ) Возвращает число символов, считанных (непосредственно предшествующим) вызовом get( ), getline( ) или read( ) ignore ( MAX, DELIM ) Извлекает и отбрасывает до MAX числа знаков до указанного разделителя (включительно) (обычно ′\n′) Следующая короткая программа демонстрирует работу этих функций: #include <iostream> using namespace std ; int main( ) { char ch ; cout << endl << "Введите символ: " ; cin.get ( ch ) ; cout << ch ; cin.putback ( ch ) ;
194 Глава 8 cin.get ( ch ) ; cout << endl << ch ; int count = cin.gcount( ) ; cout << endl << "Символы, извлеченные при последнем вызове get( ) = " << count ; // вставить в поток символ Z cin.putback ( 'Z' ) ; ch = cin.peek( ) ; cout << endl << ch ; // Z всё еще в потоке cin.get ( ch ) ; cout << endl << ch << endl ; return 0 ; } Наконец, класс istream включает функции, которые работают именно с файлами. Мы обсудим их в этой главе чуть позже, когда займемся файловым вводомвыводом. Класс ostream Класс ostream обрабатывает операции вывода или вставки. Наиболее часто используемой компонентной функцией этого класса является перегруженный оператор вывода <<. Две другие широко востребованные функции-члены этого класса — put( ) и flush( ). Первый помещает символ в поток, а второй сбрасывает содержимое буфера и вставляет символ новой строки. Помимо стандартных типов данных, при помощи оператора << на консоль также могут быть выведены строки. Приведенная ниже программа иллюстрирует несколько типичных случаев использования cout. #include <iostream> using namespace std ; int main( ) { int ch = 90 ; cout << char ( 65 ) << endl ; cout << char ( ch ) << endl ; char str[ ] = "Молчи. Пусть говорит производительность!" ; char *p = "Будь красноречив. Вырази себя!" ; cout << str << endl ; cout << p << endl ; cout << static_cast < void * > ( str ) << endl ; cout << static_cast < void * > ( p ) << endl ; return 0 ; }
Система ввода-вывода в C++ 195 Программа выводит на экран следующее: A Z Молчи. Пусть говорит производительность! Будь красноречив. Вырази себя! 0032FBEC 000C7830 Обратите внимание, что для отображения символов, эквивалентных 65 и 90, мы должны преобразовать число в char. Помимо этого, для получения адреса строки str или адреса, хранящегося в указателе p, необходимо преобразовать char * в void *, используя синтаксис статического приведения. Этот вид приведения типов обсуждается в главе 9. Вывод символов в кодировке Unicode Давайте рассмотрим еще одну интересную программу. Допустим, нам необходимо вывести символы кириллицы или деванагари. Для этого нам нужно сначала получить значения кодировки Unicode для символов в этих системах письма. Эти значения доступны по адресу https://jrgraphix.net/r/Unicode. Значения Unicode для кириллических символов Б, Д, Ж, Й, Л — шестнадцатеричные 0411, 0414, 0416, 0419 и 041B. Символам अ, आ, इ, ई и उ соответствуют шестнадцатеричные значения, 0906, 0907, 0908 и 0909. Приведенная ниже программа показывает, как использовать эти значения для вывода на экран символов на кириллице и хинди: #include <iostream> #include <io.h> #include <fcntl.h> using namespace std ; int main( ) { _setmode wcout << wcout << wcout << wcout << return 0 } ( _fileno ( stdout ), _O_U16TEXT ) ; "Вывод символов на кириллице:" << endl ; L"\x0411\x0414\x0416\x0419\x041B" << endl ; "Вывод символов на деванагари:" << endl ; L"\x0905\x0906\x0907\x0908\x0909" << endl ; ; При выполнении программы на экран выводится: Вывод символов на кириллице: БДЖЙЛ Вывод символов на деванагари: अआइईउ В этой программе следует обратить внимание на ряд моментов:  Чтобы указать, что мы отправляем на экран 16-битные символы в кодировке Unicode, следует вызвать функцию _setmode( ). Прототип этой функции объявлен в файле ′fcntl.h′.
196 Глава 8  Макрос _O_U16TEXT определен в файле ′io.h′.  Чтобы указать, что строка закодирована в Unicode, требуется добавить к ней букву «L»; \x перед каждым значением Unicode указывает на то, что это шестнадцатеричное значение.  Поскольку мы выводим на экран 16-битные значения, необходимо использовать wcout вместо cout.  При выполнении программы вы можете получить корректный вывод кирилли- ческих букв, однако вместо символов деванагари могут появиться только прямоугольники. Происходит это потому, что окно консоли не знает, как отобразить алфавит деванагари. Когда те же значения Unicode использовались для вывода символов в приложении Windows, я получил следующий результат: Класс iostream Класс iostream выведен из istream и ostream путем множественного наследования. Он выступает лишь в роли базового класса, из которого могут быть произведены другие классы. Он не содержит никаких компонентных функций, кроме конструкторов и деструкторов. Как можно догадаться из названия класса iostream, порожденные им классы могут выполнять как ввод, так и вывод. Манипуляторы потока C++ располагает различными манипуляторами потоков, выполняющими задачи форматирования. Манипуляторы потоков предоставляют такие возможности, как установка ширины поля, настройка разрядности, сброс потоков, вставка новой строки в выходной поток, пропуск пробельных символов во входном потоке и т. п. Манипуляторы бывают двух видов — с аргументами и без них. Манипуляторы без аргументов собраны в ′iostream′, а параметризированные манипуляторы — в ′iomanip′. В табл. 8.3 приведен список всех манипуляторов с указанием их назначения.
197 Система ввода-вывода в C++ Таблица 8.3. Различные манипуляторы потока Манипулятор Назначение skipws / noskipws Пропускать/не пропускать пробельные символы при вводе dec / oct / hex Преобразовать в десятичный, восьмеричный, шестнадцатеричный формат left / right Выровнять по левому/правому краю, заполнить символами справа/слева internal Использовать заполнение символами между знаковым или базовым показателем и значением endl Вставить новую строку и сбросить выходной поток showpos / noshowspos Показать/скрыть знак плюса для положительных значений uppercase / nouppercase Показывать/скрыть прописные буквы A–F для шестнадцатеричных значений и E для научных значений showpoint / noshowpoint Показывать/скрыть десятичную точку и нулевые младшие разряды для значений с плавающей точкой scientific / fixed Использовать научную запись для вывода значений с плавающей точкой или представление с фиксированной точкой ends Вставить псевдосимвол для завершения строки flush Сбросить выходной поток lock / unlock Установить/снять блокировку дескриптора файла setw ( int n ) Изменить ширину поля для вывода на n setfill ( char n ) Изменить символ-заполнитель на n (по умолчанию пробел) setprecision ( int n ) Установить разрядность до n знаков после точки setbase ( base n ) Изменить основание системы счисления на n, где n равно 8, 10 или 16 setiosflags ( fmtflags n ) Установить флаги формата, обозначенные n. Настройка действует до следующего изменения resetiosflags ( fmtflags n) Очистить только флаги формата, обозначенные n. Настройка действует до следующего изменения Следующая программа демонстрирует применение этих манипуляторов: #include <iostream> #include <iomanip> using namespace std ; int main( ) { int i = 752 ; float a = 425 ; float b = 123.500328f ; char str[ ] = "Мечта. Воплоти же ее!" ;
198 Глава 8 ios_base::fmtflags oldFlags ; oldFlags = cout.flags( ) ; cout << "Текущие флаги = " << cout.flags( ) << endl ; cout cout cout cout cout cout << << << << << << hex << i << endl ; showbase << hex << i << endl ; uppercase << showbase << hex << i << endl ; dec << i << endl ; internal << showpos << setw ( 10 ) << i << endl ; i << endl ; cout cout cout cout << << << << setfill ( '0' ) ; "Символ-заполнитель:" << cout.fill( ) << endl ; setw ( 40 ) << str << endl ; left << setw ( 40 ) << str << endl ; cout.precision ( 6 ) ; cout << "Разрядность: " << cout.precision( ) << endl ; cout << showpoint << showpos << a << endl ; cout << fixed << b << endl ; cout << scientific << b << endl ; cout << "Текущие флаги = " << cout.flags( ) << endl ; cout.flags ( oldFlags ) ; cout << "Текущие флаги = " << cout.flags( ) << endl ; return 0 ; } Вот результат работы программы: Текущие флаги = 513 2f0 0x2f0 0X2F0 752 +752 + 752 Символ-заполнитель:0 0000000000000Мечта. Воплоти же ее! Мечта. Воплоти же ее!0000000000000 Разрядность:+6 +425.000 +123.500328 +1.235003E+002 Текущие флаги = +4733 Текущие флаги = 513 В результате работы программы можно легко разобраться, изучив назначение манипуляторов потока из табл. 8.3.
Система ввода-вывода в C++ 199 Обратите внимание на действие таких манипуляторов, как hex, showbase, left, showpos и др. После применения они остаются в силе и для последующих применений cout. Рекомендуется сохранить настройки флага по умолчанию, прежде чем начать манипулировать потоком, затем произвести все преобразования и, наконец, восстановить сохраненные настройки вместо того, чтобы приводить их в исходное значение одну за другой. Функция-член flags( ) класса ios_base возвращает текущие настройки формата как тип данных fmtflags. Мы занесли эти настройки в переменную oldFlags. Совершив все операции, мы восстановили старые настройки, вызвав функцию flags( ) и передав ей настройки, сохраненные в oldFlags. Значение исходных настроек, возвращаемое функцией flags( ), в разных системах может быть различным. Пользовательские манипуляторы Хотя список манипуляторов, предоставляемых библиотекой iostream, весьма внушителен, иногда может понадобиться создать свои собственные манипуляторы. Давайте посмотрим, как создавать оба типа манипуляторов — с аргументами и без них. И начнем со вторых. Чтобы понять, как разработать манипулятор без аргументов, нам необходимо разобраться в устройстве какого-нибудь существующего манипулятора, скажем, endl. Манипулятор endl — это функция, объявление которой в iostream выглядит следующим образом: ostream& endl ( ostream& ) ; Теперь рассмотрим строку cout << endl ; Поскольку << является перегруженным оператором, внутри он преобразуется в cout.operator << ( endl ) ; Поскольку endl( ) является функцией, ее адрес передается функции перегруженного оператора. Оператор << определен в ′iostream′ следующим образом: ostream & ostream::operator << ( ostream & ( *_f ) (ostream & ) ) { return ( *_f )( *this ) ; } Это означает, что когда мы передаем адрес endl( ) этой функции, она заносит его в указатель на функцию, которая получает ссылку на ostream и возвращает ссылку на ostream. Если внимательно присмотреться, то можно заметить, что это соответствует прототипу функции endl( ). Поскольку функция оператора << вызывается для объекта cout, this-указатель содержит его адрес. Следовательно, *this создает объект. Затем он передается в функцию endl( ) при помощи следующей команды: ( *_f )( *this ) ; При вызове функция endl( ) лишь выводит ′\n′ в выходной поток.
200 Глава 8 Все достаточно просто! Теперь мы можем приступить к разработке собственного манипулятора под названием tab. Вот он: #include <iostream> using namespace std ; ostream & tab ( ostream &o ) { return o << '\t' ; } int main( ) { cout << "Не" ; cout << tab << tab ; cout << "паникуй" << endl ; return 0 ; } Эта программа работает по тому же принципу, что и предыдущая. Единственное отличие состоит в том, что вместо вызова функции endl( ) здесь вызывается функция tab( ). Проследите за ее выполнением в отладчике. Это поможет лучше понять работу программы. Пользовательские манипуляторы с аргументами Давайте создадим манипулятор с именем roman, который получает число типа unsigned long в качестве аргумента и выводит его эквивалент в римской нотации. Для этого нам необходимо определить класс с именем roman. Он должен состоять из конструктора и функции перегрузки оператора <<. Эта функция реализуется как дружественная (friend). Здесь мы не будем обсуждать, почему мы воспользовались именно дружественной функцией. Если вам не терпится подробнее узнать об этом виде функций, загляните в главу 9. А мы тем временем рассмотрим листинг программы, в которой определяется и реализуется класс roman: #include <iostream> using namespace std ; class roman { private : unsigned long num ; public : roman ( unsigned long n ) ; friend ostream & operator << ( ostream& o, roman& r) ; } ; roman :: roman ( unsigned long n ) { num = n ; }
Система ввода-вывода в C++ 201 ostream & operator << ( ostream& o, roman& r) { struct key { char ch ; int val ; } ; key z[ ] = { { 'm', 1000 }, { 'd', 500 }, { 'c', 100 }, { 'l', 50 }, { 'x', 10 }, { 'v', 5 }, { 'i', 1 } } ; int sz, k ; sz = sizeof for ( int i i < sz ;i++ { k = r.num for ( int o << ( z ) / sizeof ( z[ 0 ] ) ; = 0 ; ) / z[ i ].val ; j = 1 ;j <= k ;j++ ) z[ i ].ch ; r.num = r.num % z[ i ].val ; } return o ; } int main( ) { long yr = 2019 ; cout << roman ( yr ) ; cout << endl << roman ( 1752 ) << endl ; return 0 ; } Программа выводит на экран: mmxviiii mdcclii Обратите внимание на следующую инструкцию: cout << roman ( yr ) ; В ней roman ( yr ) создает временный объект класса roman. Разумеется, при его создании вызывается конструктор, и переданное ему значение присваивается закрытой переменной num. Затем этот временный объект и объект cout передаются перегруженной функции operator << ( ). Заметьте, что при использовании дружественной функции вызов операторной функции не принимает вид cout.operator << ( roman ( yr ) ) ;
202 Глава 8 В операторной функции мы преобразовали значение num в его римский эквивалент и вывели его посредством ссылки на объект cout. В конце мы вернули ссылку. Возврат ссылки необходим, если мы хотим использовать наш манипулятор в каскадном выражении с cout типа: cout << endl << roman ( 1752 ) << endl ; Таким образом, мы можем реализовать манипулятор с аргументом, разработав для него класс. Значит ли это, что для каждого манипулятора с одним аргументом из библиотеки iostream существует отдельный класс? Да. Однако вместо того, чтобы писать отдельный класс для каждого манипулятора, авторы библиотеки iostream собрали универсальный класс, спрятанный за сложным набором директив #define в файле ′iomanip′. При желании вы можете изучить этот файл и попытаться разобраться в устройстве этого универсального класса. Работа с потоками ввода-вывода в файл До сих пор для выполнения консольных операций ввода-вывода мы применяли объекты istream и ostream (cin и cout). В аналогичном ключе производится и файловый ввод-вывод. Для этого нам необходимо использовать объекты классов ifstream (для ввода), ofstream (для вывода) и fstream (для ввода и вывода). Как вы, должно быть, уже догадались, эти классы являются специализированными версиями шаблонных классов basic_ifstream (для ввода в файл), basic_ofstream (для вывода в файл) и basic_fstream (для ввода и вывода в файл). Иерархия этих классов отображена на рис. 8.4. Рис. 8.4. Иерархия классов файлового ввода-вывода Благодаря отношению наследования все функции-члены, операторы и манипуляторы, принадлежащие шаблонам базового класса, также могут применяться к файловым потокам. Когда дело доходит до чтения из файла / записи в файл, в основном применяются следующие три сценария:
Система ввода-вывода в C++ 203  Чтение/запись файла по символам.  Чтение/запись файла по строкам.  Чтение/запись файла по записям. Давайте разберемся в этих сценариях применительно к конкретным задачам программирования. Символьный ввод-вывод Рассмотрим простую программу, которая считывает файл посимвольно и отображает его на экране. Вот ее код: #include <fstream> #include <iostream> using namespace std ; int main( ) { char ch ; ifstream infile ( "Sample.cpp" ) ; while ( ! infile.eof( ) ) { infile.get ( ch ) ; cout << ch ; } infile.close( ) ; return 0 ; } Хотя программа и невелика, в ней появляется целый ряд новых моментов. Рассмотрим их по очереди. Открытие файла Для начала мы задали объект с именем infile класса ifstream при помощи команды ifstream infile ( "Sample.cpp" ) ; При этом вызывается конструктор с одним аргументом класса ifstream. Этот конструктор выделяет ресурсы и открывает файл ′Sample.cpp′. Однако мы не указали, должен ли файл открываться для чтения или записи. В этом нет необходимости, поскольку конструктор задействует настройки по умолчанию. Прототип конструктора выглядит следующим образом: ifstream ( const char*, int = ios::in ) ; Если конструктору не передан второй параметр, он по умолчанию использует ios::in, устанавливая тем самым режим открытия файла. Следовательно, файл открыт для чтения.
204 Глава 8 Нередко при создании объекта ifstream имя файла нам еще неизвестно. В таком случае мы можем сначала создать объект, а затем вызвать функцию ifstream:: open( ) для открытия файла. Эта процедура показана ниже: ifstream infile ; infile.open ( "Sample.cpp" ) ; И снова мы не указали режим открытия файла. Причина в том, что ios::in (ORed) включен по умолчанию в режиме чтения (второй параметр). Чтение данных После открытия файла мы воспользовались функцией ifstream::get( ) для посимвольного чтения файла. Каждый символ, считанный из открытого файла, выводится на экран при помощи обычного объекта cout. Классы ifstream, ofstream и fstream объявлены в заголовочном файле ′fstream′. Поэтому мы эксплицитно подключили его. Обнаружение конца файла (EOF) В цикле while мы продолжаем проверять, считано ли содержимое файла до конца. Для этого мы воспользовались функцией ifstream::eof( ). Она возвращает значение «истинно» (true), если достигнут конец файла, во всех остальных случаях она возвращает значение «ложно» (false). Можно достичь того же результата при помощи выражения while ( infile ) Закрытие файла Мы закрываем файл после завершения чтения, вызвав функцию ifstream::close( ). Закрытие файла отключает его от потока. Программа будет работать корректно, даже если мы не закроем файл после считывания из него данных. Это связано с тем, что при завершении программы объект infile выйдет из области видимости. В результате будет вызван деструктор, закрывающий файл. Программа копирования файлов Попробуем теперь составить программу, копирующую содержимое одного файла в другой. Чтобы произвести копирование файла, мы будем читать исходный файл посимвольно и записывать каждый считанный символ в целевой файл. Процесс будет продолжаться до тех пор, пока не будет достигнут конец исходного файла. #include <fstream> #include <iostream> #include <string>
Система ввода-вывода в C++ 205 using namespace std ; int main( ) { char ch ; string source, target ; cout << "Введите имя исходного файла: " ; cin >> source ; cout << "Введите имя целевого файла: " ; cin >> target ; ifstream infile ( source ) ; ofstream outfile ( target ) ; while ( infile ) { infile.get ( ch ) ; outfile.put ( ch ) ; } return 0 ; } Здесь символы считываются функцией istream::get( ) и записываются в целевой файл при помощи ostream::put( ). Ввод-вывод строк Если файл, открытый для чтения/записи, является текстовым, его можно обрабатывать посимвольно либо построчно. К первой операции следует прибегнуть, если нам действительно необходимо обработать каждый считанный символ. К примеру, предположим, что мы хотим считать символ, зашифровать его, а затем записать зашифрованный символ в целевой файл. В таком случае имеет смысл воспользоваться посимвольным вводом-выводом. Однако если требуется обрабатывать символы в большом количестве, как в операции копирования содержимого файла, то следует предпочесть построчный вводвывод. Каждую строчку в текстовом файле можно рассматривать как строку. Следующая программа демонстрирует, как можно улучшить программу копирования файлов, считывая/записывая файл по строкам. #include <iostream> #include <fstream> #include <string> using namespace std ; int main( ) {
206 Глава 8 string source, target ; char str[ 80 ] ; cout << "Введите имя исходного файла: " ; cin >> source ; cout << "Введите имя целевого файла: " ; cin >> target ; ifstream infile ( source ) ; ofstream outfile ( target ) ; while ( infile ) { infile.getline ( str, 79, '\n' ) ; outfile << str << endl ; } infile.close( ) ; return 0 ; } Здесь мы производим чтение текста из файла построчно при помощи функции getline( ). Эта функция является членом класса istream (из которого выводится ifstream). Чтение строки завершается, когда считано 79 символов в строке, или когда встречается символ конца строки ′\n′. Перед возвратом функция getline( ) помещает в файл символ завершения строки (′\0′). Затем содержимое str[ ] записывается в целевой файл посредством команды outfile << str ; Этот процесс будет повторяться до тех пор, пока не считаются все строки исходного файла. Заметьте, что функция getline( ) не работает со строковым типом string, поэтому для представления текстовой строки нам пришлось воспользоваться обычным массивом символов типа char. Ввод-вывод записей Допустим, нам нужно считать из файла или записать в файл записи о сотрудниках. Каждая наша запись о сотруднике содержит такие сведения, как имя (name), возраст (age), базовая зарплата (basic salary) и зарплата до вычета налогов (gross salary). Если применить перегруженный оператор << для сохранения таких записей в файл, нам в конечном итоге потребуется больше места для записи на диске по сравнению с областью, занимаемой той же записью в памяти. Произойдет так потому, что каждое число (целого или вещественного типа) будет записано в виде строки символов, а не двоичных битов. К примеру, 4250.55 будет записано как 7-байтовая строка, содержащая «4», «2», «5», «0», «.», «5», «5», а не как 4-байтовое число с плавающей точкой.
Система ввода-вывода в C++ 207 Если значительное количество записей о сотрудниках будет храниться в файле, это приведет к большой трате драгоценного дискового пространства. Чтобы этого избежать, нам нужно открыть файл в двоичном режиме, а затем выполнить вводвывод записей. Посмотрим, как это сделать: #include <fstream> #include <iostream> #include <string> using namespace std ; int main( ) { struct Employee { string name ; int age ; float basic, gross ; } ; Employee e ;c har ch = 'Y' ; // Создаем файл для записи ofstream outfile ; outfile.open ( "EMPLOYEE.DAT", ios::out | ios::binary ) ; while ( ch == 'Y' || ch == 'y' ) { cout << endl << "Введите имя, возраст, базовую зарплату и оклад брутто:" << endl ; cin >> e.name >> e.age >> e.basic >> e.gross ; outfile.write ( reinterpret_cast< const char * > ( &e ), sizeof ( e ) ) ; cout << "Добавить запись? (Y/N) " ; cin >> ch ; } outfile.close( ) ; // Открываем файл для чтения ifstream infile ; infile.open ( "EMPLOYEE.DAT", ios::in | ios::binary ) ; cout << endl ; while ( infile.read ( reinterpret_cast< char * > ( &e ), sizeof ( e ) ) ) { cout << e.name << '\t' << e.age << '\t' << e.basic << '\t' << e.gross << endl ; }
208 Глава 8 return 0 ; } Приведем несколько примеров взаимодействия с программой: Введите имя, возраст, базовую зарплату и оклад брутто: Dinesh 23 3456.55 5644.45 Добавить запись? (Y/N) Y Введите имя, возраст, базовую зарплату и оклад брутто: Shirish 34 4455.55 5566.55 Добавить запись? (Y/N) Y Введите имя, возраст, базовую зарплату и оклад брутто: Leena 21 3455.55 3655.55 Добавить запись? (Y/N) N Dinesh 23 Shirish 34 Leena 21 3456.55 5644.45 4455.55 5566.55 3455.55 3655.55 При выполнении программа предлагает пользователю ввести данные о сотрудниках. Каждая введенная запись сохраняется в файл посредством функции ofstream::write( ). После того как все записи сохранены, файл закрывается. Затем этот же файл открывается для чтения в двоичном режиме и считывается запись за записью. Каждая прочитанная запись отображается на экране. Здесь мы использовали две новые функции — write( ), член класса ofstream, и read( ), член класса ifstream. Эти функции работают с данными как с набором байтов. Им не важно, как форматируются данные, они просто перемещают буферизированные данные из файла на диске и в него. Рассмотрим вызов метода write( ): outfile.write ( reinterpret_cast< const char * > ( &e ), sizeof ( e ) ) ; Здесь мы указываем методу write( ) записать все, начиная с адреса, заданного &e, и заканчивая следующим количеством sizeof (e) байт. Обратите внимание, что адрес, переданный методу write( ), необходимо преобразовать в const char *, т. к. write( ) ничего не известно об employee *. Для такого преобразования из указателя одного типа в несвязанный тип указателя язык C++ предоставляет оператор reinterpret_cast. Параметры, передаваемые в read( ), аналогичны тем, что передаются в write( ) — адрес буфера данных и его длина в байтах. Указатель этого типа должен быть преобразован в char *. Прямой доступ В предыдущем разделе мы составили программу для сохранения записей в файл или их чтения из него. Временами нам может потребоваться изменить существующую запись. Внеся изменения, мы можем захотеть добавить новые записи в конец
Система ввода-вывода в C++ 209 файла, а после этого считать все записи или конкретную запись. Получается, у нас должна быть возможность перемещаться внутри файла для выполнения желаемой операции. Посмотрим, как библиотека iostream облегчает выполнение этой задачи. С каждым потоком связаны два длинных целых числа — указатель считывания (get pointer) и указатель записи (put pointer). Значение в указателе считывания адресует номер байта в файле, из которого будет считан следующий символ. По аналогии значение в указателе записи отмечает, где в файле будет происходить следующая запись. Термин «указатель» (pointer) в этом контексте не следует путать с обычными указателями C++. Таким образом, можно управлять процессом чтения/записи, меняя значения этих указателей при помощи функций-членов. К примеру, нам хотелось бы добавлять новые записи в конец файла, после уже существующих, а при отображении записей начать считывание с первой и далее по порядку. Кроме того, желательно иметь возможность изменять любую запись, хранящуюся в любой части файла. Это означает, что для выполнения перечисленных операций нам потребуется перемещать указатели считывания и записи. Функции istream::seekg( ) и ostream::seekp( ) позволяют установить значение указателя считывания и указателя записи соответственно. Для получения текущих позиций можно использовать функции istream::tellg( ) и ostream::tellp( ). Функции поиска бывают двух видов. Первая форма требует указания абсолютной позиции в файле. Так, команда file.seekg ( 1000L ) ; поместит указатель считывания на 1000-й байт в потоке file. Вторая форма требует двух аргументов. Первый аргумент указывает сдвиг от позиции в файле, тогда как второй указывает саму позицию, по отношению к которой измеряется сдвиг. Первый аргумент может принимать как положительные, так и отрицательные значения. Второй аргумент может быть одним из трех — beg, cur или end. К примеру: file.seekg ( -25L, ios::cur ) ; переместит указатель считывания на 25 байтов влево от его текущей (current) позиции. Соответственно beg и end относятся к началу (beginning) и концу (end) файла. Если мы хотим навсегда удалить запись из файла, мы должны открыть другой файл, занести в него все записи, кроме той, что подлежит удалению, удалить исходный файл и переименовать новый файл в исходный. И хотя данный способ может показаться топорным, он позволяет полностью и навсегда избавиться от записи. Тем не менее чем больше записей будет содержать база данных, тем медленнее будет производиться эта операция. Для повышения эффективности мы можем принять двухэтапную политику — «Удаление» и «Упаковка». «Удаление» лишь пометит запись для удаления. Запись не будет физически удалена из файла. Эту пометку можно сделать, поставив, к примеру, символ «звездочки» (*) перед удаляемой записью. Когда же мы захотим
210 Глава 8 «упаковать» файл, все помеченные звездочкой записи будут удалены из файла. Поскольку при «удалении» запись лишь помечается для удаления, мы можем даже предоставить возможность ее восстановить. При этом «звездочку» можно заменить пробелом. Режимы открытия файла До сих пор мы открывали файл для чтения либо для записи. Существует ряд других режимов открытия файла. Каждый режим задается определенным битом в классе ios. Мы можем совмещать эти биты при помощи логического оператора OR. В табл. 8.4 представлены различные варианты. Таблица 8.4. Режимы открытия файла Бит режима Результат in Открытие файла для чтения (по умолчанию для ifstream) out Открытие файла для записи (по умолчанию для ofstream) ate Начало чтения или записи с конца файла (AT End) app Начало записи с конца файла trunc Усечение файла до нулевой длины, если файл существует norcreate Ошибка при открытии, если файл еще не создан noreplace Ошибка при открытии для записи в существующий файл, если не установлены ate или app binary Открытие файла в двоичном (не текстовом) режиме Если желательно сохранить содержимое файла, следует использовать ios::app. В таком случае все, что мы запишем, будет добавлено в конец файла. Если мы хотим осуществлять как ввод, так и вывод файловых данных в двоичном режиме, можно воспользоваться следующей командой открытия: fstream file ; file.open ( filename, ios::in | ios::out | ios::binary ) ; Вертикальная черта «|» между флагами логически объединяет биты, представляющие эти флаги, в одно целое число, что позволяет применять несколько флагов одновременно. Блиц-вопрос! Можете ли вы предложить комбинацию однобитовых флагов для открытия файла для чтения и записи? Если файла не существует, он должен быть создан, а если он существует, то не должен перезаписываться. Вот ответ: fstream file ; file.open ( filename, ios::in | ios::out | ios::noreplace ) ;
Система ввода-вывода в C++ 211 Строковые потоки До сих пор мы работали с двумя типами потоков ввода-вывода (iostream) — стандартным и файловым. Третий тип iostream работает со строками в памяти и называется strstream. Прелесть потоков ввода-вывода заключается в том, что и с этим потоком мы можем применять те же самые методы чтения и форматирования. Имена классов strstreams схожи с именами в файловых потоках. Так, для извлечения символов из strstream создается istrstream, а для их вставки — ostrstream. Читателям, знакомым с программированием для Windows, наверняка известен ряд функций (таких как MessageBox( )), которым в качестве одного из параметров требуется строка. Чтобы отобразить символ, целое число, число с плавающей точкой и строку в MessageBox( ), нужно сначала преобразовать их в строки, соединить эти строки, а затем передать итоговую строку в MessageBox( ). В C++ мы используем для этого strstreams, что иллюстрирует следующая короткая программа. При передаче данных в strstream производится их форматирование. #include <iostream> #include <iomanip> #include <strstream> using namespace std ; int main( ) { char ch = 'Z' ; int i = 350 ; float a = 3.141528f ; char str[ ] = "strstreams в работе" ; ostrstream s ; s << << << << << "ch = " << ch << endl "i = " << hex << i << endl "a = " << fixed << a << endl "str = " << str ends ; cout << s.str( ) << endl ; return 0 ; } Результат работы программы демонстрирует аккуратное согласование всех элементов: ch = Z i = 15e a = 3.141528 str = strstreams в работе
212 Глава 8 Сначала мы создали объект s класса ostrstream. Сделав это, можно вывести в него любой тип данных, применяя обычные средства форматирования, которые мы использовали с cout. Используя ostrstreams важно помнить, что нулевой флаг завершения, необходимый, как правило, в конце массива символов, здесь не добавляется. Нам нужно вставить его самим, используя манипулятор ends. Построив строку, мы можем получить к ней доступ при помощи ostrstream::str( ). Работа с istrstream Если можно записать отформатированные данные в строку, то можно сделать и наоборот. Посредством istrstream мы можем извлечь из строки байты. Перед вами простая программа, показывающая, как это реализовать. #include <iostream> #include <strstream> #include <string> using namespace std ; int main( ) { int age ; float salary ; string fname, mname, sname ; char str[ ] = "Sameer Shekhar Deshpande 35 12004.50" ; istrstream s ( str ) ; s >> fname >> mname >> sname >> age >> salary ; cout << fname << endl << mname << endl << sname << endl << age << endl << salary ; return 0 ; } При создании объекта s вызывается конструктор istrstream. Он принимает указатель на массив символов с нулевым флагом завершения. Вместо массива мы также можем передать ему указатель на строку с нулевым флагом завершения, выделенную в динамической памяти. После создания объекта istrstream мы можем извлекать из него байты до обнаружения знака '\0' в конце строки. Выражение s >> fname >> mname >> sname >> age >> salary ; извлекает различные элементы данных из istrstream. Таким образом, strstreams представляют собой гибкое и универсальное средство преобразования строк символов в типизированные значения.
Система ввода-вывода в C++ 213 Ввод-вывод объектов Для начала мы сосредоточились на вводе-выводе стандартных типов, таких как char, int, float, string и их варианты. Помимо этого, можно производить вводвывод объектов пользовательского типа, перегрузив операторы >> и << для соответствующих потоков. К примеру, если у нас есть объект c1 пользовательского класса Сomplex, мы можем считать его и отобразить посредством команд Complex c1 ; cin >> c1 ; cout << c1 ; Следующая программа демонстрирует, как реализовать такую перегрузку: #include <iostream> using namespace std ; class Complex { private : double real, imag ; public : Complex( ) { } Complex ( double r, double i ) { real = r ; imag = i ; } friend ostream& operator << ( ostream& s, Complex& c ) ; friend istream& operator >> ( istream& s, Complex& c ) ; } ; ostream& operator << ( ostream& s, Complex& c ) { s << "( " << c.real << ", " << c.imag << " )" ; return s ; } istream& operator >> ( istream& s, Complex& c ) { s >> c.real >> c.imag ; return s ; } int main( ) { Complex c1 ( 1.5, 2.5 ), c2 ( 3.5, 4.5 ), c3 ; cout << "c1 = " << c1 << endl << "c2 = " << c2 << endl ;
214 Глава 8 cout << "Введите комплексное число: " ; cin >> c3 ; cout << "c3 = " << c3 << endl ; return 0 ; } Обратите внимание на то, что инструкции cout << "c1 = " << c1 << endl << "c2 = " << c2 << endl ; cout << "Введите комплексное число: " ; cin >> c3 ; cout << "c3 = " << c3 << endl ; гораздо более выразительны и схожи с тем, как мы выполняем ввод-вывод со стандартными типами данных. Здесь мы определили две дружественные функции: operator << ( ) и operator >> ( ). Мы пометили эти функции как друзей класса Complex, чтобы они могли обращаться к закрытым данным класса Complex. Поскольку объявление этих функций дружескими происходит лишь в классе Complex, а не в классах istream или ostream, они могут получить доступ только к закрытым данным класса Complex. Операторные функции не являются членами класса Complex. Поэтому команда cin >> c3 не преобразуется в форму cin.operator >> ( c3 ) ; Объекты по обе стороны от >> передаются операторной функции, оба как ссылки, что предотвращает создание их копий. Функция возвращает объект istream по ссылке, чтобы сделать возможным каскадирование, как в cin >> c4 >> c5 ; Точно такой же аргумент применяется к функции operator << ( ). Сериализация Мы также можем перегрузить операторы >> и << класса Complex, речь о которых шла в предыдущем разделе, для чтения/записи объектов из файла. Однако в таком случае в файл будут заноситься лишь значения членов данных объекта. Не будет записана информация о типе объекта, т. е. не сохранится тот факт, что два значения, записываемые для объекта, относятся к типу double и принадлежат объекту класса Complex. Если программа, считывающая данные из файла, знает тип объекта, которому соответствуют данные, то она может считать данные в объект класса Complex. Если же нет, то она не сможет его реконструировать. Кроме того, не исключена вероятность, что мы сохраним объекты разных типов в одном файле. И тогда мы не сможем различить их при чтении.
Система ввода-вывода в C++ 215 Решением в обоих случаях является сериализация и десериализация объектов. В ходе сериализации вместе со значениями полей объекта в файл также записываются их типы и класс объекта. При десериализации все эти данные считываются из файла и с их помощью объект реконструируется в памяти. C++ не предоставляет встроенного инструмента сериализации/десериализации. Для их осуществления требуется подключить популярную библиотеку с открытым исходным кодом под названием Boost. Она обеспечивает поддержку сериализации объектов в текстовом, двоичном и XML-форматах. Обработка ошибок ввода-вывода Программы ввода-вывода, что мы составляли до сих пор, не были снабжены проверкой ошибок. Если таковая и имелась, то она ограничивалась лишь достижением состояния конца файла. Однако ни одна профессиональная программа не выдержит испытания временем, если в ней не будет внедрена исчерпывающая система выявления ошибок. Такая система включает в себя проверку ошибок при открытии файла, а также при чтении/записи в файл. Следующая программа демонстрирует, каким образом реализуется проверка на ошибки: #include <fstream> #include <stdlib> #include <iostream> using namespace std ; int main( ) { void report ( ofstream& ) ; ofstream file ; file.open ( "SAMPLE.TXT", ios::noreplace ) ; if ( ! file ) { report ( file ) ; exit ( 1 ) ; } else { file << " Если бы автомобили строились как операционные системы," << endl << "больниц было бы больше, чем домов" ; if ( ! file ) { report ( file ) ;exit ( 2 ) ; } }
216 Глава 8 file.close( ) ; return 0 ; } void report ( ofstream &file ) { cout << endl << "Невозможно открыть SAMPLE.TXT" ; cout << endl << "Статус проверки ошибок = " << file.rdstate( ) ; cout << endl << "good( ) = " << file.good( ) ; cout << endl << "eof( ) = " << file.eof( ) ; cout << endl << "fail( ) = " << file.fail( ) ; cout << endl << "bad( ) = " << file.bad( ) ; } До сих пор, открывая файл для чтения, мы предполагали, что он существует. Аналогичным образом открывая файл для записи, мы исходили из того, что его создание всегда возможно. Полагаться на такие предположения отнюдь не безопасно, если мы хотим сделать программу защищенной от случайных ошибок. Поэтому всякий раз, когда мы открываем файл, следует проверять, было ли открытие файла успешным. Кроме того, при записи на диск нельзя исключать вероятность заполнения диска или защиты от записи. Не учитывать возможность аппаратной ошибки при чтении из файла — значит утратить связь с реальностью. В приведенной выше программе мы открываем файл для записи. Если это не удается, мы сообщаем об ошибке. Должно быть, вы помните, что в выражении file.open ( "SAMPLE.TXT", ios::noreplace ) ; file возвращает ноль (или false), если файл не открывается. Так, мы можем проверить статус file на предмет успешного либо неудачного завершения операции открытия файла if ( ! file ) Если это условие выполнено, мы вызываем функцию report( ), отображающую сведения о различных флагах ошибок. Класс ios поддерживает переменную состояния, биты которой обозначают различные статусы ошибки. Позиции этих битов и их значение приведены на рис. 8.5. Значение переменной состояния можно получить посредством функции ios:: rdstate( ). Другие функции, такие как eof( ), good( ), fail( ), hard( ) и bad( ), возвращают статус остальных битов. Распишем назначение этих функций в табл. 8.5. Таблица 8.5. Функции выявления ошибок Функция Назначение eof( ) Возвращает "верно", если установлен флаг EOF fail( ) Возвращает "верно", если установлен флаг failbit, badbit или hardfail bad( ) Возвращает "верно", если установлен флаг badbit или hardfail
217 Система ввода-вывода в C++ Таблица 8.5 (окончание) Функция Назначение good( ) Возвращает "верно", если все в порядке; ни один флаг не установлен clear ( int = 0 ) Без аргумента очищает все биты ошибок; в остальных случаях устанавливает указанные флаги, как в clear ( ios::failbit ) Рис. 8.5. Значение битов в переменной состояния Вот результат работы программы, когда присутствовал SAMPLE.TXT и noreplace не позволял заменить его новым файлом Невозможно открыть SAMPLE.TXT" Статус проверки ошибок = 4 good( ) = 0 eof( ) = 0 fail( ) = 4 bad( ) = 4 Статус проверки ошибок, возвращаемый функцией rdstate( ), равен 4. Это означает, что файл невозможно открыть. Функция good( ) возвращает 1 (true) лишь когда битовые флаги не установлены, и потому в нашем случае она возвращает 0. Мы не достигли конца файла, поэтому eof( ) возвращает 0. Функции fail( ) и bad( ) возвращают ненулевые значения, поскольку возникла ошибка. В профессионально написанной программе все или некоторые из этих функций следует использовать после каждой операции ввода-вывода, чтобы удостовериться в том, что все происходит ожидаемым образом. Взаимодействие с файловой системой Для работы с файлами и каталогами программе на языке C++ может потребоваться доступ к файловой системе машины, на которой она исполняется. Сюда входят такие операции, как создание, переименование, удаление, навигация и т. п. Разные ОС поддерживают различные файловые системы. К примеру, Windows использует
218 Глава 8 NTFS, а Linux — EXT3. Поскольку каждая файловая система управляет файлами и каталогами по-разному, работа C++ программ, обращающихся к ее ресурсам, подчинена требованиям конкретной ОС и файловой системы. Далее приведена программа на C++, которая иллюстрирует общие операции с дисками, файлами и каталогами для ОС Windows с использованием файловой системы NTFS. #include <windows.h> #include <iostream> using namespace std ; int main( ) { char path[ MAX_PATH ] = "" ; unsigned long int spc, bps, nfc, tnc ; GetDiskFreeSpaceA ( "C:\\", &spc, &bps, &nfc, &tnc ) ; cout cout cout cout << << << << "Секторов на кластер = " << spc << endl ; "Байтов на сектор =" << bps << endl ; "Кол-во свободных кластеров =" << nfc << endl ; "Общее число кластеров = " << tnc << endl ; float capacity, avlbl, multiplier ; multiplier = spc * bps / 1024.0f / 1024.0f / 1024.0f ; capacity = tnc * multiplier ; avlbl = nfc * multiplier ; cout << "Емкость диска = " << capacity << " Гб" << endl ; cout << "Доступное свободное место = " << avlbl << " Гб" << endl ; GetModuleFileNameA ( NULL, path, MAX_PATH ) ; cout << "Исполняемый файл программы = " << path << endl ; GetCurrentDirectoryA ( MAX_PATH, path ) ; cout << "Текущий каталог = " << path << endl ; cout << "Создание каталога myDir..." << endl ; CreateDirectoryA ( "myDir", NULL ) ; cout << "Переход к каталогу myDir..." << endl ; SetCurrentDirectoryA ( "myDir" ) ; GetCurrentDirectoryA ( MAX_PATH, path ) ; cout << "Текущий каталог = " << path << endl ; cout << "Переход к родительскому каталогу myDir..." << endl ; SetCurrentDirectoryA ( ".." ) ; GetCurrentDirectoryA ( MAX_PATH, path ) ; cout << "Текущий каталог = " << path << endl ;
Система ввода-вывода в C++ 219 cout << "Удаление каталога myDir..." << endl ; RemoveDirectoryA ( "myDir" ) ; cout << "Создание файла tempFile..." << endl ; HANDLE hFile ; hFile = CreateFileA ( "tempFile", GENERIC_WRITE, FILE_SHARE_WRITE, NULL, CREATE_ALWAYS, 0 , NULL ) ; FILETIME ftCreate, ftAccess, ftWrite ; SYSTEMTIME stUTC, stLocal ; // Получение файловых атрибутов времени для файла GetFileTime ( hFile, &ftCreate, &ftAccess, &ftWrite ) ; // Приведение времени создания к местному времени FileTimeToSystemTime ( &ftCreate, &stUTC ) ; SystemTimeToTzSpecificLocalTime ( NULL, &stUTC, &stLocal ) ; cout << "Дата создания файла = " ; cout << stLocal.wDay << "/" << stLocal.wMonth << "/" << stLocal.wYear << endl ; cout << "Время создания файла = " ; cout << stLocal.wHour << ":" << stLocal.wMinute << endl ; return 0 ; } Результат работы программы: Секторов на кластер = 8 Байтов на сектор = 512 Кол-во свободных кластеров = 6715617 Общее число кластеров = 40650239 Емкость диска = 155.068 Гб Доступное свободное место = 25.618 Гб Исполняемый файл программы = C:\Users\Kanetkar\Desktop\Sample\Debug\Sample.exe Текущий каталог = C:\Users\Kanetkar\Desktop\Sample\Sample Создание каталога myDir... Переход к каталогу myDir... Текущий каталог = C:\Users\Kanetkar\Desktop\Sample\Sample\myDir Переход к родительскому каталогу myDir... Текущий каталог = C:\Users\Kanetkar\Desktop\Sample\Sample Удаление каталога myDir... Создание файла tempFile... Дата создания файла = 25/3/2019 Время создания файла = 17:13 Обратите внимание на букву «А» в конце имен большинства функций, задействованных в этой программе. Она указывает на кодировку ANSI. Это означает, что такие функции работают со строками обычных символов. Если предполагается
220 Глава 8 использовать строки символов Unicode, то следует добавить «L» перед строкой и заменить «A» на «W», как показано в следующем вызове функции: CreateDirectoryW ( L"myDir", NULL ) ; 1. Укажите, верны ли следующие утверждения:  Класс ios расположен в корне библиотеки iostream.  Файл ′iomanip.h′ требуется подключать лишь для параметризированных мани- пуляторов.  При использовании манипулятора в действительности вызывается функция- член.  Можно создавать собственные манипуляторы.  Объекты cout, cin и cprn являются предопределенными потоковыми объектами.  Объекты могут считывать и записывать самих себя.  Классы strstreams используются для ввода-вывода в строку и из строки.  Функция istream::getline( ) не предназначена для работы со строками из не- скольких слов.  Решите следующие задачи и ответьте на вопросы:  Составьте диаграмму, иллюстрирующую иерархию различных классов в биб- лиотеке iostream.  Как можно использовать следующую команду для проверки достижения конца файла, если infile является объектом ifstream: while ( infile ) ;  Что обеспечивают флаги nocreate и noreplace, когда они задействованы при от- крытии файла?  На ваш взгляд, с какой проблемой мы столкнемся, если следующий код будет выполнен дважды: file.seekg ( 0L, ios::beg ) ; или while ( file.read ( char * ) &p, sizeof ( p ) ) cout << t.name << endl << p.age ; Как бы вы решили эту проблему?  Как получить значение переменной состояния? 2. Каков будет результат работы следующих программ:  (A) #include <iostream> using namespace std ;
Система ввода-вывода в C++ 221 int main( ) { char str[ ] = "Здесь скучно" ; char *p = "А вот это интересно" ; cout << endl << str ;cout << endl << p ; cout << endl << ( void * ) str ; cout << endl << ( void * ) p ; return 0 ; }  (B) #include <iostream> using namespace std ; int main( ) { int i = 650 ; float a = 425.123 ; cout << setiosflags ( ios::showbase | ios::uppercase ) ; cout << hex << i << endl ; cout.precision ( 4 ) ; cout << setiosflags ( ios::showpoint ) << a ; return 0 ; }  (C) #include <iostream> using namespace std ; int main( ) { char str[ ] = "Просто перечень" ; cout.width ( 40 ) ; cout << str << endl ; cout.setf ( ios::left, ios::adjustfield ) ; cout.width ( 40 ) ; cout << str ; return 0 ; } 3. Решите следующие задачи:  Какое ограничение у следующей функции перегрузки оператора для отображения объектов класса Complex? ostream& operator << ( ostream& s, Complex& c ) { s << "( " << c.real << ", " << c.imag << " )" ; }
222 Глава 8  Составьте программу, создающую путь к каталогу d1/d2/d3. В d3 создайте файл 'sample.txt'. Запишите в этот файл ваше имя и адрес.  В файле имеется 100 записей, каждая из которых содержит 6-символьный код товара, 20-символьный номер товара и его количество (целый тип). Напишите программу, которая считывает эти записи, упорядочивает их в порядке возрастания и записывает в тот же файл, перезаписывая более ранние записи.  Требования к системе ввода-вывода: • Должна обеспечивать связь с исходными и целевыми устройствами. • Должна обеспечивать ввод-вывод различных объектов. • Должна обеспечивать связь различными способами. • Должна обеспечивать работу с любой файловой системой.  Решение C++ — производить весь ввод-вывод при помощи потоков.  Поток — это последовательность байтов, которые перемещаются от исходного к целевому устройству по каналу связи.  Потоки реализованы в виде классов библиотеки iostream.  Привязка потоков к физическим устройствам осуществляется системой ввода- вывода C++.  Программа C++ выполняет ввод-вывод, считая/записывая данные из потока / в поток.  Преимущества применения потоков. • Потоки скрывают от программиста технические детали связи. • Методы одинаковы, а их реализация меняется в зависимости от устройства.  Предопределенные потоковые объекты: • cin, cout, cerr, clog — ввод-вывод символов в кодировке ASCII;  cin — объект класса istream;  cout, cerr, clog — объекты класса ostream; • wcin, wcout, wcerr, wclog — ввод-вывод символов в кодировке Unicode;  wcin — объект класса wistream;  wcout, wcerr, wclog — объекты класса wostream.  Существует множество манипуляторов потоков, которые помогают управлять выводом при его отправке на выводное устройство.  Для манипуляторов, использующих аргументы, необходимо подключать файл ′iomanip′.
Система ввода-вывода в C++ 223  Возможно создание пользовательских манипуляторов.  При помощи классов, представленных в 'fstream', можно производить три типа файловых операций ввода-вывода: • Ввод-вывод символов — чтение/запись файла по символам. • Ввод-вывод строк — чтение/запись файла по строкам. • Ввод-вывод записей — чтение/запись файла по записям.  Для прямого доступа к файлу полезны следующие функции: • seekg( ), seekp( ) — устанавливают в файле позицию указателей считывания и записи • tellg( ), tellp( ) — возвращают текущую позицию в файле указателей считывания и записи.  Существует несколько режимов открытия файла.  Для выполнения строкового ввода-вывода полезны следующие классы strstream: • istrstream — чтение данных из строки; • ostrstream — запись данных в строку.  Ввод-вывод объектов можно осуществлять, перегружая операторы << и >>.  Сериализация — запись объекта в файл.  Десериализация — чтение объекта из файла.  Если при открытии файла возникает ошибка, то ее причина указывается в битах переменной состояния.  Взаимодействие с файловой системой зависит от платформы.  Программы на C++ позволяют создавать, изменять, удалять файлы и каталоги, а также перемещаться по ним.

Расширенные возможности C++ Возможно, более специализированные средства языка, описанные в настоящей главе, и не так часто задействуются на практике, однако, усвоив их, вы, безусловно, усовершенствуете ваши навыки программирования на C++
226 Глава 9  Отношения включения  Дружественные (friend) функции и классы  Еще одно применение дружественной функции  Предупреждение  Ключевое слово explicit  Ключевое слово mutable  Пространство имен  Способы применения пространства имен • Использование оператора разрешения контекста • Ключевое слово using  Динамическая идентификация типа (RTTI)  Приведение типов в C++ • static_cast • dynamic_cast • const_cast • reinterpret_cast • Предупреждение  Указатели на члены классов  Упражнения  Важное
Расширенные возможности C++ В 227 этой главе рассматривается набор тем, с которыми новички обычно не сталкиваются. Однако по мере того, как вы углубитесь в программирование на C++, вы начнете ценить полезность этих продвинутых средств. Я планирую осветить здесь такие темы, как отношения включения, дружественные функции, ключевые слова explicit и mutable, пространства имен, динамическая идентификация типа данных (RTTI) и новый синтаксис приведения. И начнем мы с отношений включения. Отношения включения Нам уже известны отношения наследования между классами. При наследовании, если класс Y выведен из класса X, мы говорим, что новый класс Y подобен старому классу X, или, еще проще, «Y подобен X». Это означает, что класс Y обладает всеми характеристиками класса X и, сверх того, рядом собственных черт. Это все равно, что сказать, что грузовик подобен автомобилю: у грузовика есть свойства, общие для всех автомобилей (наличие колес и двигателя, работает на топливе и т. п.), но имеет некоторые собственные отличительные характеристики (например, кузов). По этой причине наследование часто называют «отношениями сходства». Существует еще один тип отношений между двумя родственными классами. Он известен как «отношения принадлежности». Допустим, есть два связанных класса — Carburettor (Карбюратор) и Car (Автомобиль). Это родственные классы, поскольку оба связаны с транспортным средством. Тем не менее ни карбюратор не похож на автомобиль, ни автомобиль не похож на карбюратор. На самом деле отношения между ними могут быть только такими: у автомобиля «есть» карбюратор, или, другими словами, автомобиль содержит в себе карбюратор. Это и называется «отношениями включения», или «композицией». Как и наследование, отношения включения — это еще одно средство многократного использования в арсенале C++. Оба механизма реализуют многократное использование на уровне объектного кода, т. е. исходный код класса не требуется при создании другого класса. Ниже приведена программа, иллюстрирующая отношения включения между классом Car и классом Carburettor: #include <iostream> #include <string> using namespace std ; class Carburettor { private : char type ; float cost ; string mfr ; public : void setData ( char t, float c, string m )
228 { type = t ;cost = c ;mfr = m ; } void displayData( ) { cout << type << endl << cost << endl << mfr << endl ; } } ; class Car { private : string model ; string drivetype ; Carburettor cc ; // внедренный объект public : void setData ( char t, float c, string mf, string m, string d ) { model = m ; drivetype = d ; cc.setData( t, c, mf ) ; } void displayData( ) { cout << model << endl << drivetype << endl ; cc.displayData( ) ; } } ; int main( ) { Car mycar ; mycar.setData ( 'A', 18500.00, "Mico", "Спортивный", "С приводом на 4 колеса" ) ; mycar.displayData( ) ; return 0 ; } Результат работы программы: Спортивный С приводом на 4 колеса A 18500 Mico Глава 9
Расширенные возможности C++ 229 Дружественные (friend) функции и классы Как нам уже известно, только функции-члены класса могут получить доступ к его закрытым данным. В этом суть инкапсуляции данных. Однако для удобства программирования иной раз можно сделать исключение и из этого правила. В таких случаях требуется разрешить функциям за пределами класса получать доступ и модифицировать закрытые данные-члены класса. Для этого в C++ предусмотрено ключевое слово friend (друг, дружественный). Следующая программа поможет вам разобраться в том, как концепция дружественных функций реализуется на практике: #include <iostream> using namespace std ; class Sample2 ; class Sample1 { private : int data1 ; public : Sample1( ) { data1 = 100 ; } friend void accessOne ( Sample1 ) ; friend void accessBoth ( Sample1, Sample2 ) ; } ; class Sample2 { private : int data2 ; public : Sample2( ) { data2 = 200 ; } friend void accessBoth ( Sample1, Sample2 ) ; } ; void accessOne ( Sample1 x ) { cout << x.data1 << endl ; } void accessBoth ( Sample1 x, Sample2 y ) { cout << x.data1 + y.data2 << endl ; }
230 Глава 9 int main( ) { Sample1 a ; Sample2 b ; accessOne ( a ) ; accessBoth ( a, b ) ; return 0 ; } Здесь мы объявляем два класса, Sample1 и Sample2. Конструкторы в этих классах инициализируют свои закрытые члены данных фиксированным значением (100 в Sample1 и 200 в Sample2). Мы хотим, чтобы функция accessOne( ) имела доступ к закрытым данным Sample1, а функция accessBoth( ) — к закрытым данным обоих классов. Для этого мы объявили обе функции дружественными при помощи ключевого слова friend: friend void accessOne ( Sample1 ) ; friend void accessBoth ( Sample1, Sample2 ) ; Из них первое объявление было помещено в Sample1, поскольку функции accessOne требуется доступ лишь к закрытым данным этого класса. И напротив, второе объявление было размещено в обоих классах, поскольку этой функции требуется доступ к закрытым данным обоих классов. Заметьте также, что эти объявления могут помещаться в закрытом либо в открытом разделе класса. Объект Sample1 передается функции accessOne( ), тогда как функции accessBoth( ) передаются оба объекта, Sample1 и Sample2. Будучи «друзьями» классов, accessOne( ) может обращаться к закрытым данным Sample1, а accessBoth( ) — к закрытым данным Sample1 и Sample2. И хотя функции практически не производят никаких действий, я полагаю, что эта небольшая программа неплохо иллюстрирует саму концепцию. Обратите внимание на тот факт, что дружественные функции являются глобальными и не являются при этом членами какого-либо класса. Поэтому this-указатель им никогда не передается. Рассмотрим объявление в начале программы class Sample2 ; Такая декларация необходима, поскольку на класс нельзя ссылаться до тех пор, пока он не будет объявлен. Объявление функции accessBoth( ) в классе Sample1 ссылается на класс Sample2. Поэтому Sample2 должен быть объявлен перед Sample1. Эта декларация сообщает компилятору, что класс Sample2 определен далее. Этот аспект дружественной функции — доступ к закрытым данным класса в глобальной функции — демонстрировался дважды в главе 8, один раз — при определении параметризированного пользовательского манипулятора, а другой — при перегрузке операторов << и >>. Сейчас вы можете вернуться к этим разделам и внимательно изучить программный код в свете того, что мы здесь узнали. Вместо того чтобы определять дружественную функцию, мы можем применить спецификатор friend ко всему классу, как показано ниже:
Расширенные возможности C++ 231 class Sample2 ; class Sample1 { private : // поля данных public : // функции-члены friend Sample2 ; } ; Поскольку Sample2 помечен как дружественный класс, все его функции-члены могут обращаться к закрытым данным класса Sample1. Еще одно применение дружественной функции Дружественные функции иногда используются для повышения универсальности перегруженных операторов. Попытаемся освоить этот способ применения дружественных функций на примере программы, приведенной ниже: #include <iostream> using namespace std ; class Example { private : int i ; float j ; public : Example ( int ii = 0, float jj = 0.0f ) { i = ii ; j = jj ; } void showData( ) { cout << i << " " << j << endl ; } Example operator * ( Example e ) { Example temp ; temp.i = i * e.i ; temp.j = j * e.j ; return ( temp ) ; } } ;
232 Глава 9 int main( ) { Example e1 ( 10, 3.14f ), e2 ( 2, 1.5f ), e3, e4, e5 ; e3 = e1 * e2 ; // Example * Example работает e4 = e1 * 2 ; // Example * int работает e3.showData( ) ; e4.showData( ) ; // e5 = 2 * e1 ; // int * Example не работает return 0 ; } В этой программе мы определили класс Example, имеющий int и float в качестве закрытых членов данных. У него также есть две функции-члена — конструктор и перегруженный оператор * для умножения двух объектов класса Example. Выражение e3 = e1 * e2 ; внутри преобразуется в e3 = e1.operator * ( e2 ) ; Это вызывает функции перегрузки оператора и возвращает произведение двух объектов Example. Аналогичным образом выражение e4 = e1 * 2 ; внутри преобразуется в e4 = e1.operator * ( 2 ) ; Число 2, передаваемое операторной функции, необходимо преобразовать в объект типа Example. Компилятор задействует конструктор для преобразования этого целого числа в объект класса Example, а затем выполняет умножение. Будет ли работать следующее выражение? e5 = 2 * e1 ; Нет, поскольку внутри оно преобразуется в e5 = 2.operator ( e1 ) ; не имеющее смысла прежде всего потому, что 2 не является объектом. Компилятор не справляется с такой ситуацией и потому выдает ошибку. И здесь нам может помочь лишь дружественная функция. Следующий фрагмент кода покажет, каким образом. #include <iostream> using namespace std ;
Расширенные возможности C++ 233 class Example { private : int i ; float j ; public : Example ( int ii = 0, float jj = 0.0f ) { i = ii ; j = jj ; } void showData( ) { cout << i << "\t" << j << endl ; } friend Example operator * ( Example, Example ) ; } ; Example operator * ( Example k, Example l ) { Example temp ; temp.i = k.i * l.i ; temp.j = k.j * l.j ; return ( temp ) ; } int main( ) { Example e1 ( 10, 3.14f ), e2 ( 1, 1.5f ), e3, e4, e5 ; e3 = e1 * 2 ; e4 = 2 * e2 ; e5 = e1 * e2 * 2 ; e3.showData( ) ; e4.showData( ) ; e5.showData( ) ; return 0 ; } Заметьте, что в предыдущей программе функция operator *( ) принимала только один аргумент, тогда как в этой программе она принимает два аргумента. Причина в том, что операторная функция больше не является функцией-членом класса. Теперь это друг класса Example. Поэтому инструкция e3 = e1 * 2 ; не преобразуется в вид e3.operator * ( 2 ) Эта небольшая программа показывает, что применение дружественной функции позволяет повысить функциональность перегруженных операторов.
234 Глава 9 Предупреждение Использование дружеских функций порой вызывает споры. Хотя они и добавляют гибкости языку, облегчая написание программного кода в определенных ситуациях, они не укладываются в ту концепцию, согласно которой только функции-члены могут получить доступ к закрытым данным класса. Впрочем, C++ не является стопроцентно объектно-ориентированным языком. Одной из причин этого и является возможность доступа к закрытым данным за пределами класса при помощи дружественных функций. В конце концов, C++ был разработан для решения практических задач программирования, а не для победы на конкурсе языкового пуризма. Если функции-друзья не вполне соответствуют высоким идеалам ООП, однако служат при этом удобству программирования, то так тому и быть. Есть здесь и другая крайность. Так, если вы используете много дружеских функций, то, возможно, в структуре вашей программы кроется серьезный недостаток, и вам стоит ее пересмотреть. Ключевое слово explicit В главе 5 мы узнали, что преобразование данных из стандартного типа в пользовательский возможно при помощи оператора преобразования и конструктора класса. Впрочем, бывают и такие преобразования, которых хотелось бы избежать. Для этого достаточно не определять сам оператор преобразования. Но что в таком случае делать с конструктором? Он может потребоваться для создания объекта. В то же время вы можете захотеть, чтобы он не участвовал в выполнении преобразований. Таких нежелательных результатов можно избежать, воспользовавшись ключевым словом explicit. Следующая программа демонстрирует его применение: #include <iostream> using namespace std ; class Complex { private : float real, imag ; public : Complex ( float r = 0.0f, float i = 0.0f ) { real = r ; imag = i ; } Complex operator + ( Complex c ) { Complex t ;
Расширенные возможности C++ 235 t.real = real + c.real ; t.imag = imag + c.imag ; return t ; } void display( ) { cout << real << " " << imag << endl ; } } ; int main( ) { Complex c1 ( 1.5, 3.5 ), c2 ; c2 = c1 + 1.25f ; c2.display( ) ; return 0 ; } В выражении c2 = c1 + 1.25f ; должна вызываться функция перегруженного оператора operator +( ). Компилятор обнаруживает, что эта функция ожидает аргумент типа Complex, тогда как мы пытаемся передать ей число вещественного типа. Поэтому он ищет функцию преобразования, которая может привести это число к типу Complex. Конструктор обнаруживает такую функцию, и компилятор решает к ней обратиться. Это хорошо. Однако инструкция c2 = 1.25f + c1 ; не работает. Такая форма записи неприемлема, т. к. сложение всегда представляло собой перестановочную операцию. В целях согласованности следует удостовериться в том, что обе следующие инструкции выполняться не будут c2 = c1 + 1.25f ; c2 = 1.25f + c1 ; Этого можно гарантированно добиться, объявив конструктор с ключевым словом explicit, как показано ниже: explicit Complex ( float r = 0.0f, float i = 0.0f ) { real = r ;i mag = i ; } Теперь при любой попытке неявно преобразовать вещественный тип в Complex компилятор сообщит, что не может выполнить преобразование. Заметьте, что ключевое слово explicit работает только с конструкторами.
236 Глава 9 Ключевое слово mutable При создании константного объекта ни один из его членов данных не подлежит модификации. Впрочем, в редких случаях может потребоваться разрешить изменение некоторых полей, несмотря на то, что объект определен при помощи спецификатора const. Этого можно достичь, воспользовавшись ключевым словом mutable, как показано в следующей программе. #include <iostream> #include <string> using namespace std ; class Car { private : string model ; mutable string owner ; int yrOfMfg ; string regNo ; public : Car ( string m, string o, int y, string r ) { model = m ;owner = o ;yrOfMfg = y ;regNo = r ; } void changeOwner ( string o ) const { owner = o ; } void changeModel ( string m ) { model = m ; } void display( ) const { cout << model << endl << owner << endl << yrOfMfg << endl << regNo << endl << endl ; } } ; int main( ) { const Car c1 ( "VX", "Fundu", 2000, "MH31-G6175" ) ; c1.display( ) ; c1.changeOwner ( "MahaFundu" ) ; c1.display( ) ;
Расширенные возможности C++ 237 // c1.changemodel ( "AX" ) ; // выдает ошибку c1.display( ) ; return 0 ; } При продаже автомобиля его владелец изменится, тогда как остальные его атрибуты должны остаться прежними. Поскольку объект c1 объявлен со спецификатором const, ни один из его членов данных не может быть изменен. Однако для поля «владелец» (owner) делается исключение, поскольку его объявлению предшествует ключевое слово mutable. Модификация осуществляется посредством функции changeOwner( ). Попробуйте удалить комментарий в основной функции main( ). Это приведет к ошибке, поскольку поле «модель» (model) не объявлено как mutable и, следовательно, не может быть изменено. Если бы c1 не был constобъектом, можно было бы изменить как поле «владелец», так и поле «модель». Пространство имен Как ни банально это звучит, но создание имен — одно из самых основных действий в программировании. Имена переменных, имена массивов, имена функций, имена структур, имена классов, имена объединений, имена перечислений — все они подпадают под одну общую категорию — имена. При написании больших программ с участием нескольких программистов процесс может выйти из-под контроля, если не будет осуществляться надлежащий контроль над видимостью этих имен. Рассмотрим, к примеру, следующие два файла заголовков: // mylib.h char fun1( ) ; void display( ) ; class BigNumber { ... } ; // somelib.h class BigNumber { ... } ; void display( ) ; Если подключить к программе оба файла, возникнет конфликт между двумя классами BigNumber и двумя функциями display( ), что, как следствие, вызовет ошибки. Одним из решений может стать использование длинных имен для уменьшения вероятности случайных совпадений. Но тогда придется оперировать этими длинными именами. Кроме того, это всего лишь компромиссное решение, придуманное программистами, а не заложенное в концепцию самого языка. Решение подобного рода вопросов при помощи инструкции namespace, предлагаемое C++, намного лучше. Язык C++ предоставляет единое глобальное пространство имен. Используя такое средство, как namespace, можно разбить глобальное пространство имен на более специализированные декларативные области. Фрагмент кода, приведенный далее, показывает, как осуществить это на практике: // mylib.h namespace myLib { char fun1( ) { ... } ;
238 Глава 9 void display( ) { ... } ; class BigNumber { ... } ; } // somelib.h namespace someLib { class BigNumber { ... } ; void display( ) { ... } ; } Теперь имена классов не будут конфликтовать, поскольку они преобразуются в myLib::BigNumber и someLib::BigNumber соответственно. То же самое произойдет и с именами функций. Они преобразуются в myLib::display( ) и someLib::display( ), что позволяет избежать нежелательного наложения имен. Теперь можно без конфликтов использовать одно и то же имя, но в разных контекстах. До тех пор, пока они появляются в разных пространствах имен, каждое имя будет уникальным благодаря добавлению идентификатора namespace. Далее приведем ряд моментов о пространствах имен, которые следует помнить:  Синтаксис создания пространства имен (namespace) аналогичен синтаксису класса, за исключением того, что в пространстве имен после закрывающей скобки точка с запятой не ставится.  Объявления, выходящие за границы всех пространств имен, по-прежнему явля- ются членами глобального пространства имен.  Определение пространства имен может быть разбито на несколько заголовоч- ных файлов, как показано ниже: // mylib.h namespace myLib { char fun1( ) { ... } ; void display( ) { ... } ; } // mylib1.h namespace myLib { extern int var1 ; void display( ) { ... } ; } Когда пространство имен разбито таким образом, после его первичного определения продолжение называется определением расширения пространства имен (extension-namespace-definition).  Пространству имен можно задать альтернативное имя, что предотвращает ввод громоздких имен. Это псевдоним пространства имен (namespace-alias): namespace hardAndSoftLibrary { class hwItem { ... } ;
Расширенные возможности C++ 239 class swItem { ... } ; } namespace hwsw = hardAndSoftLibrary ;  Имя глобального пространства имен не должно совпадать с любым другим име- нем глобального объекта в данной программе.  Члены именованного пространства имен могут быть определены за пределами пространства имен, в котором они объявлены. В этом случае требуется явно указать определяемое имя, как показано ниже: namespace mine { void fun1( ) ; } void mine::fun1( ) { }  Если имя в пространстве имен определяется вне его пределов, то определение должно стоять после точки объявления. void mine::fun2( ) // ошибка, fun2( ) еще не член пространства имен mine { } namespace mine { void fun2( ) ; }  Определение пространства имен может размещаться лишь в глобальном кон- тексте. Поэтому следующий программный код вызовет ошибку: int main( ) { namespace local // ошибка: не в глобальном контексте { } return 0 ; }  Определение пространства имен может быть вложено в определение другого пространства имен, к примеру: namespace outer { int n = 6 ; int fun2( ) ; namespace inner { float a = 3.14 ; } }
240 Глава 9 Способы применения пространства имен Обратиться к имени в пространстве имен можно двумя способами:  При помощи оператора разрешения контекста.  Посредством ключевого слова using. Разберем каждый из них по очереди. Использование оператора разрешения контекста Можно указать любое имя в пространстве имен, используя оператор разрешения контекста, как проиллюстрировано ниже: #include <iostream> #include <string> using namespace std ; namespace mine { class MyClass { private : int yr ; public : void changeYear( ) ; } ; class YourClass ; void fun1( ) ; } void mine::MyClass::changeYear( ) { yr = 2000 ; cout << "Годы не меняются" ; } class mine::YourClass { public : YourClass( ) ; void show( ) ; } ; mine::YourClass::YourClass( ) { cout << "Достигнут непараметризированный конструктор YourClass" << endl ; }
Расширенные возможности C++ 241 void mine::YourClass::show( ) { cout << "Сделай. И забудь" << endl ; } void mine::fun1( ) { cout << "Будь спонтанным. Соблюдай осторожность" << endl ; } int main( ) { mine::MyClass m ; m.changeYear( ) ; mine::fun1( ) ; mine::YourClass y ; y.show( ) ; return 0 ; } Получение имени в пространстве имен при помощи оператора разрешения контекста несколько утомительно. Гораздо удобнее воспользоваться ключевым словом using. Ключевое слово using Ключевое слово using позволяет импортировать сразу все пространство имен вместо того, чтобы использовать оператор разрешения контекста перед каждым именем. Внесем соответствующую правку в предыдущую программу и оценим удобство этого метода: #include <iostream> #include <string> using namespace std ; namespace mine { class MyClass { private : int yr ; public : void changeYear( ) ; } ; class YourClass ; void fun1( ) ; } void mine::MyClass::changeYear( ) { yr = 2000 ;
242 Глава 9 cout << "Годы не меняются" << endl ; } class mine::YourClass { public : YourClass( ) ; void show( ) ; } ; mine::YourClass::YourClass( ) { cout << "Достигнут непараметризированный конструктор YourClass" << endl ; } void mine::YourClass::show( ) { cout << "Сделай. И забудь" << endl ; } void mine::fun1( ) { cout << "Будь спонтанным. Соблюдай осторожность" << endl ; } int main( ) { using namespace mine ; MyClass m ; m.changeYear( ) ; fun1( ) ; YourClass y ; y.show( ) ; return 0 ; } Ключевое слово using объявляет, что все имена в пространстве имен находятся в текущей области. Таким образом, мы можем использовать имена без каких-либо спецификаторов. Динамическая идентификация типа (RTTI) RTTI означает «идентификация типа во время выполнения» (Run Time Type Identification) или динамическая идентификация типа. Как следует из самого названия, это средство позволяет узнать тип объекта во время выполнения программы. Как мы узнали в главе 7, при использовании виртуальных функций необходимо идентифицировать типы данных в ходе выполнения программы, чтобы связать вызов метода с функцией соответствующего объекта. Кроме того, в больших программах мы можем захотеть регистрировать имена типов во время выполнения.
Расширенные возможности C++ 243 Тип объекта можно установить во время выполнения программы посредством оператора typeid. Следующий листинг иллюстрирует его применение: #include <typeinfo> #include <iostream> using namespace std ; class Base { public : virtual void fun1( ) { } } ; class MyClass : public Base { } ; class YourClass : public Base { } ; int main( ) { Base *b1, *b2 ; MyClass m ; YourClass y ; b1 = b2 = cout cout cout cout &m &y << << << << ; ; typeid typeid typeid typeid ( ( ( ( b1 ).name( ) << endl ; b2 ).name( ) << endl ; *b1 ).name( ) << endl ; *b2 ).name( ) << endl ; if ( typeid ( *b1 ) == typeid ( *b2 ) ) cout << "Соответствие" << endl ; else cout << "Несоответствие" << endl ; cout << typeid ( 45 ).name( ) << endl ; cout << typeid ( '4' ).name( ) << endl ; cout << typeid ( 4.5 + 2.2 ).name( ) << endl ; return 0 ; } Результат работы программы: class Base * class MyClass class YourClass Несоответствие int char double
244 Глава 9 В этой программе мы вывели два класса — MyClass и YourClass — из базового класса Base. Затем мы создали два указателя базового класса b1 и b2 и два объекта производных классов m и y. Далее мы выполнили преобразование с приведением с помощью команд: b2 = &y ; b1 = &m ; Затем мы использовали оператор typeid для получения типов указателей, а также адресуемых ими типов. Оператор typeid находит тип своего операнда, сохраняет его в const-объекте типа typeinfo и возвращает на него ссылку. При помощи этой ссылки мы можем вызвать функцию name( ) для получения имени типа в строковом формате (string). Сравнение типов, возвращаемых typeid, также можно осуществлять при помощи операторов == и !=. Оператор typeid также применим к встроенным типам данных или даже к целым выражениям. Приведение типов в C++ C++ — это строго типизированный язык. Это означает, что любое несоответствие типов вызывает сообщение об ошибке. Тем не менее иногда требуется, чтобы значение интерпретировалось иначе, чем его текущий тип. В таких случаях необходимо эксплицитно выполнять преобразование из одного типа в другой, используя операцию приведения типов. Фрагмент кода, приведенный ниже, иллюстрирует подобные преобразования: float x = 3.14 ;int y ; y = ( int ) x ; // приведение типов в стиле C y = int ( x ) ; // приведение типов в стиле C++ Этот стиль приведения типов подходит для встроенных типов данных. Однако, если использовать их без разбора на несвязанных между собой пользовательских типах, то такой программный код может вызвать ошибки во время выполнения или привести к неожиданным результатам, оставаясь при этом грамматически корректным. Чтобы надежно обеспечить обнаружение таких ошибок во время компиляции, C++ предоставляет 4 оператора приведения типов. Эти операторы и их синтаксис приведены ниже: static_cast < тип > ( выражение ) dynamic_cast < тип > ( выражение ) reinterpret_cast < тип > ( выражение ) const_cast < тип > ( выражение ) Давайте подробно рассмотрим каждый из них.
Расширенные возможности C++ 245 static_cast Оператор static_cast применяется в четко определенных преобразованиях:  Преобразования ограничивающего типа.  Преобразования к пустому типу (void*). Их действие продемонстрировано в следующей программе. Не пропустите указания в комментариях. #include <iostream> using namespace std ; class Base { } ; class Derived : public Base { } ; class Sample { } ; int main( ) { int i = 10 ; long l ; float f ; char str[ ] = "Nagpur" ; // преобразования ограничивающего типа i = static_cast < int > ( l ) ; i = static_cast < int > ( f ) ; // преобразование к void-указателю для отображения адреса void *vptr ; vptr = static_cast < void * > ( str ) ; cout << vptr << endl ; // преобразование несвязанных типов Sample *sptr ; sptr = ( Sample * ) baseptr ; // sptr = static_cast < Sample * > ( baseptr ) ; // ошибка return 0 ; } Большая часть программы не нуждается в пояснениях. Мы сосредоточимся на двух последних инструкциях программы. Когда мы пытаемся преобразовать указатель на класс Base в несвязанный указатель на класс Sample, используя традиционный механизм приведения, это не вызывает сообщение об ошибке. Но стоит нам попытаться сделать то же самое посредством static_cast, ошибка тут как тут. Связано это с тем, что static_cast не позволяет нам бесконтрольно осуществлять приведение типов, что делает этот оператор надежнее традиционных средств преобразования.
246 Глава 9 dynamic_cast Этот оператор приведения типов применяется лишь с указателями и ссылками на классы либо с пустым указателем (void*). Он называется «динамическим», поскольку проверяет во время выполнения программы, можем ли мы «безопасно» присвоить тип указателя указателю другого типа. Если нет, он устанавливает нулевое значение в указателе, которое мы можем проверить, прежде чем использовать указатель для вызова функций. Рассмотрим механизм динамической проверки в действии: #include <iostream> #include <string> using namespace std ; class Employee { private : string name ; public: Employee ( string n ) { name = n ; } virtual void showData( ) { cout << "Имя: " << name << endl ; } } ; class Manager : public Employee { private : double commision ; public: Manager ( string n, double comm ) : Employee ( n ) { commision = comm ; } virtual void showData( ) { cout << "Комиссия :" << commision << endl ; } } ; int main( ) { Employee e1 ( "Dinesh" ) ; Manager m1 ( "Suresh", 3000.0 ) ;
Расширенные возможности C++ 247 Employee *pemp1, *pemp2 ; Manager *pmgr ; pemp1 = &e1 ; pemp2 = &m1 ; pmgr = dynamic_cast < Manager * > ( pemp1 ) ; cout << "pmgr = " << pmgr << endl ; if ( pmgr ) pmgr -> showData( ) ; else cout << "при приведении вниз pmgr содержит null" << endl ; pmgr = dynamic_cast < Manager * > ( pemp2 ) ; cout << "pmgr = " << pmgr << endl ; if ( pmgr ) pmgr -> showData( ) ; else cout << "при приведении вниз pmgr содержит null" << endl ; } Потребность в dynamic_cast обычно возникает, когда необходимо выполнить операцию производного класса над объектом производного класса, однако у нас имеется лишь указатель или ссылка на базовый класс. В таком случае мы понижаем указатель базового класса до производного класса, а затем выполняем требуемую операцию. Когда мы совершаем приведение вниз при помощи dynamic_cast, этот оператор проверяет, является ли объект, привязанный к указателю, объектом целевого типа. Если нет, он присваивает указателю значение 0. Давайте проследим, как реализуется этот механизм в нашей программе. Рассмотрим следующие инструкции: pmgr = dynamic_cast < Manager * > ( pemp1 ) ; pmgr = dynamic_cast < Manager * > ( pemp2 ) ; Здесь pemp1 хранит адрес объекта Employee, а pemp2 — адрес объекта Manager. Поэтому, когда мы пытаемся преобразовать pemp1 в pmgr (целевой тип указателя), преобразование невозможно, т. к. pemp1 содержит адрес объекта Employee. И потому в этом случае pmgr присваивается null. Вторая операция приведения работает, т. к. в этом случае pmgr указывает на объект класса Manager. Позволим себе ряд полезных советов:  Если бы мы использовали в программе static_cast или традиционный механизм преобразования, то проверка условия во время выполнения не запустилась бы. Оператор static_cast не видит здесь ошибки, и это может ввести в заблуждение.
248 Глава 9  Хотя преобразования при помощи dynamic_cast безопаснее, этот оператор рабо- тает лишь с указателями или ссылками, тогда как проверка типа во время выполнения повышает затраты вычислительных ресурсов.  Поскольку приведение вверх является неявным преобразованием, то не имеет значения, пользуемся ли мы static_cast, dynamic_cast или вообще совершаем приведение типов.  Если вместо указателей используются ссылки и не удается осуществить динамическое приведение, то выдается исключение типа bad_cast. const_cast Этот оператор приведения позволяет манипулировать const-статусом объекта. С его помощью можно преобразовать константный объект в неконстантный или наоборот. #include <iostream> using namespace std ; void display ( char * ) ; int main( ) { const char *ptr = "В чем дело?" ; display ( const_cast < char * > ( ptr ) ) ; return 0 ; } void display ( char *p ) { cout << p << endl ; } Поскольку функция display( ) ожидает тип char *, необходимо преобразовать const char * в char * перед тем, как передать ее в display( ). Удалять const-статус объекта не рекомендуется. Приведенная выше программа работает, поскольку функция display( ) не пытается манипулировать объектом, на который указывает p. Если мы попытаемся сделать это при помощи инструкции вроде *p = ’H’ ; программа будет скомпилирована, но вызовет неопределенное поведение во время выполнения. reinterpret_cast Этот оператор может применяться для выполнения следующих преобразований:  Приведение одного типа указателя к другому типу указателя.  Приведение указателя к целому числу и наоборот.
Расширенные возможности C++ 249 Первое преобразование просто копирует значение из одного указателя в другой, предоставляя программисту возможность решить, имеет ли это преобразование смысл или нет. Этот механизм приведения наименее безопасен и нередко служит причиной программных ошибок. Предупреждение Всякий раз, когда вам кажется, что есть необходимость применить явное преобразование типов, не торопитесь и хорошенько всё осмыслите. Вы обнаружите, что в подавляющем большинстве ситуаций этого вообще можно избежать. В других случаях преобразование можно локализовать для ряда функций внутри программы. Помните, когда вы задействуете приведение типов, вы нарушаете систему типов. И это чревато опасными последствиями. Указатели на члены классов Мы знаем, что для доступа к элементу структуры мы используем оператор '.' или '->'. Кроме того, для разыменования указателя мы используем оператор *. Давайте посмотрим, как это работает на практике: int i ; int *ptr ; struct Emp { char name ; int age ; } ; Emp e, *eptr ; ptr = &i ; cout << *ptr ; // разыменование cout << e.name ; // доступ cout << eptr -> name ; // доступ При необходимости указатели могут адресовать определенные члены структуры. Коль скоро элементы структуры размещаются в смежных участках памяти, адрес любого элемента структуры на самом деле представляет собой смещение от начального адреса структуры. Для доступа к элементу структуры через этот указатель требуется оператор '.' или '->', а также оператор '*' для разыменования указателя. Для одновременного доступа и разыменования C++ располагает двумя новыми операторами: '.*' и '->*'. Они известны как операторы указателей на члены класса (pointer to member operator). Следующая программа иллюстрирует их применение: #include <iostream> using namespace std ; struct Sample
250 Глава 9 { int a ; float b ; } ; int main( ) { int Sample::*p1 = &Sample::a ; float Sample::*p2 = &Sample::b ; Sample so = { 10, 3.14f } ; cout << so.*p1 << endl << so.*p2 << endl ; Sample *sp ;sp = &so ; cout << sp->*p1 << endl << sp->*p2 << endl ; // можно даже присваивать новые значения so.*p1 = 20 ; sp->*p2 = 6.28f ; cout << so.*p1 << endl << so.*p2 << endl ; cout << sp->*p1 << endl << sp->*p2 << endl ; return 0 ; } Рассмотрим определение указателей p1 и p2: int Sample::*p1 = &Sample::a ; float Sample::*p2 = &Sample::b Обратите внимание на ту часть выражения, что расположена перед оператором присваивания. Звездочки указывают на то, что p1 и p2 являются указателями. Sample:: говорит о том, что они адресуют переменные целого и вещественного типа в Sample. Мы инициализировали эти указатели при их объявлении с адресами a и b соответственно. На самом деле нет «адреса» Sample::a, поскольку мы ссылаемся на сам класс, а не на объект этого класса. Выражение &Sample::a просто создает смещение в классе. Фактический адрес будет получен, когда мы объединим это смещение с начальным адресом определенного объекта. Таким образом, &Sample::a есть не что иное, как синтаксис указателя на член класса. Если мы используем p1 и p2 с одним объектом, мы получим один набор значений, если используем его с другим, то получим другой набор значений. Это наглядно продемонстрировано ближе к концу программы, где мы построили массив объектов и получили доступ ко всем полям объектов при помощи p1 и p2. Суть в том, что указатели на члены не привязаны ни к какому конкретному объекту.
Расширенные возможности C++ 251 Слева от '.*' всегда должна быть структурная переменная (объект) или ссылка, а слева от '->*'’ всегда должен быть указатель на структуру. Это подводит нас к важному вопросу: можно ли считать неудачной идею придать public-статус данным класса, как это сделано в приведенной выше программе? Да, можно. Именно по этой причине указатели на члены классов чаще используются с функциями-членами (которые обычно открыты), а не с членами данных класса. При помощи указателей на члены классов можно гибко выбирать функцию-член для вызова во время выполнения. Это позволяет направлять или изменять поведение программы во время выполнения. Звучит не слишком конкретно? Что ж, скоро всё прояснится. Для этого на первых порах нам потребуется разобраться в механизме работы указателя на функцию, что и демонстрирует следующая программа: #include <iostream> using namespace std ; int main( ) { void fun ( int, float ) ; void ( *p ) ( int, float ) ; p = fun ; ( *p ) ( 10, 3.14f ) ; return 0 ; } void fun ( int a, float b ) { cout << a << endl << b << endl ; } Здесь p — указатель на функцию, которая получает int и float и возвращает void. Заметьте, что *p нужно обязательно заключить в круглые скобки. Перед нами объявление прототипа функции p, которая получает int и float и возвращает void *. Мы инициализировали p с адресом функции fun( ). Упоминание имени функции без пары круглых скобок возвращает ее адрес в памяти. При желании мы все еще можем воспользоваться оператором & для получения адреса, как в p = &fun ; Синтаксис обращения к fun( ) посредством p следующий: ( *p )( 10, 3.14 ) ; При желании можно даже создать массив указателей на функции, а затем вызывать каждую функцию по очереди, как показано в следующем фрагменте кода: void ( *p[ 3 ] ) ( int, float ) = { fun1, fun2, fun3 } ; for ( int i = 0 ;i <= 2 ;i++ ) ( *p[ i ] ) ( 14 + i, 5.54 + i ) ; Одним из основных условий работы этого кода является совпадение прототипов функций fun1( ), fun2( ) и fun3( ); иначе мы не сможем собрать их адреса в массиве p[ ].
252 Глава 9 Давайте продвинемся еще на шаг и сделаем fun1( ), fun2( ) и fun3( ) открытыми членами класса Sample, а затем попробуем вызвать их способом, подобным тому, что мы применили выше: #include <iostream> using namespace std ; class Sample { public : void fun1( ) { cout << this << "В теле fun1" << endl ; } void fun2( ) { cout << this << "В теле fun2" << endl ; } void fun3( ) { cout << this << "В теле fun3" << endl ; } } ; int main( ) { Sample so[ 4 ] ; void ( Sample::*p[ 3 ] )( ) = { &Sample::fun1, &Sample::fun2, &Sample::fun3 } ; for ( int j = 0 ;j <= 3 ;j++ ) { for ( int i = 0 ;i <= 2 ;i++ ) ( so[ j ].*p[ i ] )( ) ; } return 0 ; } При помощи оператора '.*' мы можем вызывать различные функции-члены для разных объектов. При этом мы также отобразили адрес каждого объекта, для которого вызывается функция-член. Так выглядит результат работы программы: 002BF994 002BF994 002BF994 002BF995 В В В В теле теле теле теле fun1 fun2 fun3 fun1
Расширенные возможности C++ 002BF995 002BF995 002BF996 002BF996 002BF996 002BF997 002BF997 002BF997 В В В В В В В В теле теле теле теле теле теле теле теле 253 fun2 fun3 fun1 fun2 fun3 fun1 fun2 fun3 1. Укажите, верны ли следующие утверждения:  Такое явление, как создание класса внутри класса, называется композицией.  «Композиция» и «отношения включения» — это одно и то же.  Как отношения включения, так и наследование являются средствами многократ- ного использования программного кода.  Дружественная функция может получить доступ к закрытым членам данных класса, но не может их модифицировать.  Если весь класс становится другом другого класса, то все функции-члены этого класса могут получить доступ к закрытым членам данных дружественного класса.  Не имея исходного кода класса, нельзя объявить функцию как дружественную этому классу.  При помощи интеллектуального указателя (smart pointer) можно выполнять итерации по всем элементам объекта-контейнера.  Посредством интеллектуального указателя можно пользоваться объектом как указателем.  Перегруженный оператор -> называется оператором интеллектуального указа- теля.  Оператором указателя на член класса можно воспользоваться для доступа к оп- ределенным членам данных внутри класса.  Указатели на члены классов не привязаны ни к какому конкретному объекту.  Определение пространства имен может быть разбито на несколько заголовоч- ных файлов. 2. Ответьте на вопросы:  Должны ли данные класса иметь public-статус при использовании указателя на член класса?  В каких случаях в конструкторе следует использовать ключевое слово explicit? Может ли оно использоваться с какой-либо другой функцией?
254 Глава 9  Каким образом можно изменить член данных const-объекта?  Если два заголовочных файла содержат одинаковые имена, как избежать кон- фликта имен при подключении обоих файлов?  Как присвоить альтернативное имя пространству имен? 3. Решите следующие задачи:  Составьте программу, в которой имеется класс с именем base. Выведите из него два класса, derived1 и derived2. Занесите адреса объектов производных классов в указатели базового класса. Задействовав typeid( ) и dynamic_cast, установите тип объекта во время выполнения программы.  Напишите программу, преобразующую целочисленный указатель в целое число и наоборот.  «Отношения включения» или «композиция» — это средство многократного ис- пользования объектного кода. Оно позволяет создать подобъект внутри объекта.  Функция может быть объявлена «другом» класса.  Дружественная функция может получить доступ к закрытым данным класса, «другом» которого она является.  Несмотря на то что объявление дружественной функции выполняется внутри класса, ее определение должно быть сделано за его пределами.  Объявление дружественной функции может быть сделано в закрытой или от- крытой части класса.  Указатель this не передается дружественной функции.  Функцию можно сделать «другом» нескольких классов.  Если функция является «другом» нескольких классов, то ее объявление необхо- димо поместить во всех этих классах.  Если класс объявлен «другом» другого класса, то все функции-члены этого класса могут получить доступ к закрытым членам класса, содержащего объявление дружественного класса.  Перегруженная операторная функция может быть объявлена дружественной для повышения ее универсальности.  Дружественные функции нарушают систему типов C++, поэтому использовать их следует как можно реже и с осторожностью.  Если конструктор объявлен с ключевым словом explicit, компилятор не может использовать его для выполнения неявных преобразований.  Если член данных const-объекта должен оставаться изменяемым, включите в его определение ключевое слово mutable.
Расширенные возможности C++ 255  Пространство имен действует как контейнер для связанных типов namespace { class A { } ; class B { } ; }  Пространство имен можно разбить на несколько файлов, поскольку оно является логическим контейнером.  Допустимы вложенные пространства имен.  Типы данных могут иметь одинаковые имена, если они принадлежат разным пространствам имен.  Пространства имен могут иметь псевдонимы, к примеру: namespace s = std ;  Существуют два способа доступа к имени в пространстве имен: • через оператор ::; • при помощи ключевого слова using.  Можно получить тип объекта во время выполнения программы, используя опе- ратор typeid.  Оператор typeid может использоваться с объектом, указателем, переменной или выражением, например typeid ( 45 ), typeid ( ptr ), typeid ( *ptr ).  Тип, возвращаемый typeid, можно сравнить с другим возвращаемым типом, ис- пользуя операторы == и !=: if ( typid ( 45 ) == typeid ( i ) )  Следующие преобразования типов происходят неявным образом: • Расширяющие преобразования, в частности присвоение целого типа (int) двойному (double). • Присвоение адреса объекта производного класса указателю на базовый класс.  Эксплицитное (явное) преобразование можно выполнять при помощи четырех операторов приведения типов: • static_cast; • dynamic_cast; • const_cast; • reinterpret_cast.  static_cast применяется для: • преобразований ограничивающего типа; • преобразований указателя к пустому типу (void*) для отображения адреса массива.
256 Глава 9  dynamic_cast применяется для преобразования указателей с приведением вниз.  При использовании dynamic_cast во время работы программы можно прове- рить, является ли преобразование безопасным.  const_cast применяется для преобразования константных объектов в некон- стантные и наоборот.  reinterpret_cast применяется для: • преобразования указателя в целое число и наоборот; • преобразования между несвязанными типами указателей. И этот вид приведения небезопасен.  '.* ' и '->*' называются операторами указателей на члены класса.  '.* ' и '->*' используются для одновременного доступа и разыменования.  '.* ' и '->*' могут применяться для доступа к открытым функциям-членам класса.
Шаблоны Вместо того, чтобы переписывать и сопровождать набор похожих функций или классов, работающих с различными типами данных, можно создать обобщенные функции/классы и позволить компилятору заниматься выбором конкретных версий. В этой главе мы рассмотрим, как это реализуется
258 Глава 10  Шаблоны функций • Что происходит во время компиляции?  Шаблоны функций для пользовательских типов  Еще одна шаблонная функция • Явная специализация обобщенной функции  Функция с набором обобщенных типов  Шаблоны и макросы  Сортировка на основе шаблона  Шаблоны классов  Шаблон класса связного списка  Полезные советы по шаблонам  Вариативные шаблоны  Области применения шаблонов  Упражнения  Важное
259 Шаблоны Ш аблоны представляют собой механизм, позволяющий применять одну функцию или класс для обработки широкого диапазона различных типов данных. При помощи шаблонов мы можем создать единый общий класс или функцию, которые работают с данными многих типов, вместо того, чтобы создавать отдельный класс/функцию для каждого из этих типов. Применительно к функциям они называются шаблонами функций (обобщенными функциями), а применительно к классам — шаблонами классов (обобщенными классами). Сначала мы рассмотрим шаблоны функций, а затем перейдем к шаблонам классов. Шаблоны функций Допустим, мы хотим написать функцию, возвращающую наименьшее значение двух переданных ей чисел. Это будет очень простая функция: если числовые параметры имеют разные типы в разных вызовах, нам придется написать несколько перегруженных версий: // int-специализация min int myMin ( int a, int b ) { return ( a < b ) ? a : b } // long-специализация min long myMin ( float a, float { return ( a < b ) ? a : b } // char-специализация min char myMin ( char a, char b { return ( a < b ) ? a : b } // etc... ; b ) ; ) ; У такого подхода выявляется три недостатка:  Приходится многократно повторять один и тот же код в разных функциях.  Набор версий одной функции занимает больше дискового пространства.  Если мы решим внести изменение в одну функцию, мы должны помнить о том, чтобы сделать аналогичные изменения во всех остальных версиях этой функции. Таким образом, сопровождение программы становится утомительным делом. Вот бы написать одну такую функцию, работающую с различными типами данных! Именно для этого и предназначены шаблоны функций. В следующей программе показано, как написать обобщенную функцию myMin( ), принимающую в качестве аргументов любой встроенный тип данных. Мы обращаемся к этой функции из main( ), передавая ей различные типы данных.
260 Глава 10 #include <iostream> using namespace std ; template < class T > T myMin ( T a, T b ) { return ( a < b ) ? a : b ; } int main( ) { int i = 10, j = 20 ; cout << myMin ( i, j ) << endl ; float a = 3.14f, b = -6.28f ; cout << myMin ( a, b ) << endl ; char ch = 'A', dh = 'Z' ; cout << myMin ( ch, dh ) << endl ; double d = 1.1, e = 1.11 ; cout << myMin ( d, e ) << endl << endl ; return 0 ; } И вот какие результаты генерирует эта программа: 10 -6.28 A 1.1 Как видите, функция myMin( ) теперь работает с разными типами данных, которые мы передаем ей в качестве аргументов. Разве это не многократное использование программного кода? Да, но иного типа. Наследование и отношения включения позволяют повторно использовать объектный код, а шаблоны — исходный код. Шаблоны могут значительно уменьшить размер исходного кода и повысить его гибкость без снижения безопасности. Давайте теперь разберемся, что придает обобщенной функции гибкость для работы с различными типами данных. Перед нами определение функции myMin( ): template < class T > T mymin ( T a, T b ) { return ( a < b ) ? a : b ; } Данная синтаксическая конструкция называется шаблоном функции. В шаблоне функции тип данных может быть представлен «заполнителем» (в нашем случае T), могущим обозначать любой тип. У имени T нет никакого особенного значения.
Шаблоны 261 Вместо него мы можем воспользоваться любым другим именем, будь то type, mytype и т. п. Такой элемент, как T, называют обобщенным параметром. Везде, где в определении функции обычно записывается конкретный тип данных, такой как int, мы подставляем обобщенный параметр шаблона, т. е. T. Что происходит во время компиляции? Сам по себе просмотр шаблона функции не побуждает компилятор к каким-либо конкретным действиям, помимо его запоминания для применения в будущем. Компилятор не может сгенерировать какой-либо код, поскольку пока что неизвестно, с каким типом данных будет работать функция. Генерация кода происходит, когда функция фактически вызывается из программы с помощью таких операторов, как cout << endl << myMin ( i, j ) ; Встречая такой вызов функции, компилятор понимает, что задействуется тип int, поскольку это тип аргументов переменных i и j. Тогда он генерирует специализацию (конкретную версию) myMin( ) для типа int, заменяя каждый T на int. Эту процедуру нередко называют реализацией (instantiating) шаблонной функции. Компилятор также заменяет вызов myMin(i, j) вызовом только что порожденной функции. Аналогичным образом выражение myMin(a, b) побуждает компилятор сгенерировать специализацию myMin( ) для типа float, а затем к ней обратиться. Точно так же вызов myMin(d, e) генерирует конкретную версию функции, обрабатывающую тип double. Заметьте, что компилятор генерирует только одну версию myMin( ) для каждого типа данных независимо от количества вызовов, сделанных для этого типа. Помогают ли шаблоны экономить память? Не совсем, поскольку даже когда мы используем шаблоны, генерируются четыре функции (для int, float, char и double). Преимущество лишь в том, что нам не нужно эти функции создавать и сопровождать. Компилятор создает их из нашей обобщённой версии. Это сокращает и упрощает текст программы. Еще одно преимущество заключается в том, что, если мы хотим модифицировать нашу функцию, нам потребуется внести изменения лишь в одной части программы, а не в четырех разных частях. Шаблоны функций для пользовательских типов Функция myMin( ) может работать даже с пользовательскими типами данных, такими как Date, при условии, что в классе Date перегружен оператор «меньше» (<) для сравнения двух объектов Date. Нам также потребуется предоставить функцию перегруженного оператора >> для вывода дат (устройство и работа такой функции уже обсуждались в главе 8). Проиллюстрируем вышесказанное: #include <iostream> using namespace std ;
262 Глава 10 class Date { private : int day, mon, year ; public : Date ( int d, int m, int y ) { day = d ;mon = m ;year = y ; } int operator < ( Date dt ) { if ( year < dt.year ) return 1 ; if ( year == dt.year && mon < dt.mon ) return 1 ; if ( year == dt.year && mon == dt.mon && day < dt.day ) return 1 ; return 0 ; } friend ostream& operator << ( ostream &o, Date &dt ) ; } ; ostream& operator << ( ostream &o, Date &dt ) { o << dt.day << "/" << dt.mon << "/" << dt.year ; return o ; } template < class T > T myMin ( T a, T b ) { return ( a < b ) ? a : b ; } int main( ) { int i = 10, j = 20 ; cout << myMin ( i, j ) << endl ; Date dt1 ( 17, 11, 62 ), dt2 ( 23, 12, 65 ) ; cout << myMin ( dt1, dt2 ) << endl << endl ; return 0 ; } Еще одна шаблонная функция Для закрепления материала спроектируем еще одну обобщенную функцию, в которой производится изменение значения (это еще называется свопингом) двух переменных:
Шаблоны 263 #include <iostream> using namespace std ; class Date { private : int day, mon, year ; public : Date ( int d = 0, int m = 0, int y = 0 ) { day = d ;mon = m ;year = y ; } friend ostream& operator << ( ostream &o, Date &dt ) ; } ; ostream& operator << ( ostream &o, Date &dt ) { o << dt.day << "/" << dt.mon << "/" << dt.year ; return o ; } template < class T > void mySwap ( T &a, T &b ) { T c ; c = a ; a = b ; b = c ; } int main( ) { int i = 10, j = 20 ; mySwap ( i, j ) ; cout << i << "\t" << j << endl ; char ch = 'A', dh = 'Z' ; mySwap ( ch, dh ) ; cout << ch << "\t" << dh << endl ; Date dt1 ( 17, 11, 62 ), dt2 ( 23, 12, 65 ) ; mySwap ( dt1, dt2 ) ; cout << dt1 << "\t" << dt2 << endl ; return 0 ; } В программе определяется шаблон функции с именем mySwap( ). Из этой обобщенной функции компилятор генерирует специализации, меняющие местами зна-
264 Глава 10 чения переменных типа int, char и Date. В конструкторе класса Date для аргументов мы задействовали значения по умолчанию. Это требуется для создания объектов класса Date в функции mySwap( ), когда она вызывается для свопинга значений двух таких объектов. Заметьте, что стандартные преобразования типов не применяются к шаблонам функций. Когда встречается вызов обобщенной функции, компилятор сначала просматривает существующие экземпляры в поиске «точного совпадения» предоставленных параметров. В случае неудачи он пытается сгенерировать новый экземпляр, соответствующий критериям «точного совпадения». Если и это не удается, то компилятор выдает ошибку. Явная специализация обобщенной функции Что если мы хотим, чтобы функция вела себя одинаково для всех типов данных, кроме одного? В таком случае мы можем переопределить шаблон функции для данного конкретного типа. Нам просто потребуется предоставить конкретную версию функции для этого типа: void mySwap ( double a, double b ) { // место для кода } Такое объявление позволяет определить отдельную функцию для переменных типа double. Как и к прочим нешаблонным функциям, здесь применимы стандартные средства преобразования типов (к примеру, преобразование переменной типа float в double). Функция с набором обобщенных типов Возможно создать шаблонную функцию, принимающую во время вызова несколько обобщенных типов аргументов, что проиллюстрировано в следующей короткой программе: #include <iostream> using namespace std ; template < class T, class S, class Z > void fun ( T a, S b, Z c ) { cout << a << endl << b << endl << c << endl ; } int main( ) { int i = 10 ;
Шаблоны 265 float j = 3.14f ; char ch = 'A' ; fun ( i, j, ch ) ; return 0 ; } Вы, должно быть, заметили, что в этой программе синтаксис шаблонной функции чуть-чуть отличается от предыдущих примеров. Мы поместили ключевое слово template и собственно объявление функции в одну строку: template < class T, class S, class Z > void fun ( T a, S b, Z c ) Это никак не связано с тем, что обобщенной функции передается несколько аргументов. С тем же успехом мы могли бы применить многострочную запись в программах, приведенных выше: template < class T, class S, class Z > void fun ( T a, S b, Z c ) Шаблоны и макросы Во многих отношениях шаблоны действуют как макросы препроцессора, заменяя аргумент шаблона заданным типом. Тем не менее существует множество различий между макросом, таким как # define min( i, j ) ( ( i ) < ( j ) ? ( i ) : ( j ) ) и схожей обобщенной функцией template < class T > T min ( T i, T j ) { return ( i < j ) ? i : j ) ; } Приведенный выше макрос также выполняет простую замену текста и, следовательно, может обрабатывать любой тип данных. Однако у него есть ряд недостатков:  Макрос развертывается без проверки типов.  При этом не указывается тип возвращаемого значения, и потому компилятор не может определить, присваиваем ли мы его несовместимой переменной.  В макросе параметры i и j оцениваются дважды. Если у некоторого параметра имеется постинкрементная переменная, приращение значения будет произведено два раза.  При развертывании макросов препроцессором сообщения об ошибках компиля- тора будут относиться к развернутому макросу, а не к самому определению макроса, что затрудняет поиск ошибок. Одним словом, макросы сильно уступают шаблонам.
266 Глава 10 Сортировка на основе шаблона Давайте теперь разработаем функцию сортировки выборкой (selection sort) на основе шаблона, которая сможет сортировать массивы как стандартных, так и пользовательских типов. Для этого мы определили класс Date, содержащий функцию перегруженного оператора для сравнения двух объектов Date с целью установить, какой из них больше другого. И реализуем дружественную функцию для отображения объекта Date: #include <iostream> using namespace std ; class Date { private : int day, mon, year ; public : Date ( int d = 0, int m = 0, { day = d ; mon = m ; year = y ; } int operator > ( Date dt ) { if ( year > dt.year ) return 1 ; if ( year == dt.year && mon return 1 ; if ( year == dt.year && mon return 1 ; return 0 ; } friend ostream& operator << ( } ; int y = 0 ) > dt.mon ) == dt.mon && day > dt.day ) ostream &o, Date &dt ) ; ostream& operator << ( ostream &o, Date &dt ) { o << dt.day << "/" << dt.mon << "/" << dt.year ; return o ; } template < class T > void selectionSort ( T a[ ], int sz ) { T temp ;
Шаблоны 267 for ( int i = 0 ;i < sz - 1 ;i++ ) { for ( int j = i + 1 ;j < sz ;j++ ) { if ( a[ i ] > a[ j ] ) { temp = a[ i ] ; a[ i ] = a[ j ] ; a[ j ] = temp ; } } } } int main( ) { int arr[ ] = { -12, 23, 14, 0, 245, 78 , 66, -9 } ; Date dtarr[ ] = { Date ( 17, 11, 62 ), Date ( 23, 12, 65 ), Date ( 12, 12, 78 ), Date ( 23, 10, 69 ) } ; int i ; selectionSort ( arr, 8 ) ; for ( i = 0 ;i < 8 ;i++ ) cout << arr[ i ] << endl ; cout << endl << endl ; selectionSort ( dtarr, 4 ) ; for ( i = 0 ;i < 4 ;i++ ) cout << dtarr[ i ] << endl ; return 0 ; } Мы не станем здесь обсуждать работу алгоритма сортировки выборкой. Эта тема достаточно подробно рассматривается в учебных курсах по структурам данных. Наша с вами цель — понять, как написать шаблон функции, способный работать как со встроенными, так и с пользовательскими типами данных. Шаблоны классов Концепция шаблонов распространяется и на классы. Шаблоны классов (или обобщенные классы) обычно используются для классов хранения данных. Такие классы также называют контейнерными классами. Вспомните, как в главе 6 мы разработали класс Stack для ведения LIFO-списка. Однако он мог хранить данные лишь одного встроенного типа, например целые
268 Глава 10 числа. Для хранения в стеке данных типа float нам пришлось бы определить совершенно новый класс. Из этого следует, что для каждого нового типа данных, подлежащих сохранению, должен быть создан новый класс стека. Не лучше ли написать единую спецификацию класса, которая работала бы для всех типов? Лучше, конечно. Следующая программа продемонстрирует такой обобщенный класс в действии: #include <iostream> using namespace std ; const int MAX = 10 ; template < class T > class Stack { private : T stk[ MAX ] ; int top ; public : Stack( ) { top = -1 ; } void push ( T data ) { if ( top == MAX - 1 ) cout << "Стек переполнен" << endl ; else { top++ ; stk[ top ] = data ; } } T pop( ) { if ( top == -1 ) { cout << "Стек пуст" << endl ; return NULL ; } else { T data = stk[ top ] ; top-- ; return data ; } } } ;
269 Шаблоны class Complex { private : float real, imag ; public : Complex ( float r = 0.0, float i = 0.0 ) { real = r ; imag = i ; } friend ostream& operator << ( ostream &o, Complex &c ) ; } ; ostream& operator << ( ostream &o, Complex &c ) { o << "( " << c.real << ", " << c.imag << " )" ; return o ; } int main( ) { Stack < int > s1 ; s1.push ( 10 ) ; s1.push ( 20 ) ; s1.push ( 30 ) ; cout << s1.pop( ) << endl ; cout << s1.pop( ) << endl ; cout << s1.pop( ) << endl ; Stack < s2.push s2.push s2.push cout << cout << cout << float > ( 3.14f ( 6.28f ( 8.98f s2.pop( s2.pop( s2.pop( Complex Stack < s3.push s3.push s3.push cout << cout << cout << c1 ( 1.5f, 2.5f ), c2 ( 3.5f, 4.5f ), c3 ( 1.2f, 0.6f ) ; Complex > s3 ; ( c1 ) ; ( c2 ) ; ( c3 ) ; s3.pop( ) << endl ; s3.pop( ) << endl ; s3.pop( ) << endl ; return 0 ; } s2 ; ) ; ) ; ) ; ) << endl ; ) << endl ; ) << endl ;
270 Глава 10 Здесь мы создали три стека — s1, s2 и s3 — и поместили в каждый из них по три значения. Затем мы извлекли значения из трех стеков и отобразили их на экране. В результате выполнения этой программы получим: 30 20 10 8.98 6.28 3.14 ( 1.2, 0.6 ) ( 3.5, 4.5 ) ( 1.5, 2.5 ) Можно заметить, что порядок, в котором элементы извлекаются из стека, полностью противоположен порядку, в котором они были в него помещены. Способ создания обобщенного класса аналогичен тому, что применяется для создания обобщенной функции. Ключевое слово template и < class T > свидетельствуют о том, что весь класс будет шаблоном. template < class T > class stack { // данные и функции-члены с использованием аргумента шаблона T } ; Затем «заполнитель» шаблона T используется в каждой части спецификации класса, где есть ссылка на тип массива stk. Таких частей три:  Определение stk.  Тип аргумента функции push( ).  Тип возвращаемого значения функции pop( ). Мы также объявили класс с именем Complex, а затем работали с объектами этого класса, помещая их в стек и извлекая их из него. Это доказывает, что мы также можем создавать стеки пользовательских объектов из обобщенного класса. Чтобы иметь возможность отображать объекты класса Complex при помощи cout, мы перегрузили оператор <<. Как мы уже знаем, создание экземпляра обобщенной функции происходит при ее вызове. В отличие от этого, классы создаются путем определения объекта с использованием аргументов шаблона. К примеру Stack < int > s1 ; создает объект s1 — стек, предназначенный для хранения чисел целого типа. Компилятор резервирует область памяти для данных этого объекта, используя тип int везде, где в спецификации класса появляется аргумент шаблона T. Он также резервирует место для компонентных функций (если они еще не были помещены в па-
Шаблоны 271 мять другим объектом типа Stack < int >). Эти функции-члены также работают исключительно с типом int. Когда мы создаем объект Stack, в котором хранятся единицы другого типа, скажем, вещественного, то создается пространство для данных, а также новый набор функций-членов, которые работают с этим типом. Как и в случае с обычными классами, можно ли не определять функции-члены обобщенного класса за его пределами? Да, это возможно, но для этого, как показано ниже, требуется другая форма записи: template < class T > void Stack < T > :: push ( T data ) { if ( top == MAX - 1 ) cout << endl << "Стек переполнен" ; else { top++ ; stk[ top ] = data ; } } Обратите внимание на тот факт, что выражение template < class T > должно предшествовать не только определению класса, но и каждой компонентной функции, определенной за пределами класса. Имя Stack < T > используется для обозначения класса, членом которого является функция push( ). Шаблон класса связного списка Давайте теперь при помощи шаблонов спроектируем класс связного списка (linked list class) общего назначения. При помощи такого обобщенного класса можно легко сопровождать связный список чисел целого или вещественного типа, или же связный список пользовательского типа данных с именем Employee (Сотрудник). Допустим, что пользовательский тип данных хранит сведения об имени (name), возрасте (age) и зарплате (salary) сотрудника. Классу Employee потребуются две компонентные функции — конструктор и перегруженный оператор <<. Ниже я привожу набросок программы, реализующей обобщенный класс для поддержки двух связных списков — одного для целых чисел и другого для данных о сотрудниках. Вы можете попытаться самостоятельно разработать функции класса LinkedList и класса Employee. #include <string> #include <iostream> using namespace std ; class Employee {
272 private : string name ; int age ; float sal ; public : Employee ( string n = "", int a = 0, float s = 0.0 ) ; friend ostream& operator << ( ostream& s, emp& e ) ; } ; template < class T > class LinkList { private : struct node { T data ; node *link ; } *p ; public : LinkList ( ) ; ~ LinkList ( ) ; void append ( T ) ; void addAtBeg ( T ) ; void addAfter ( int, T ) ; void del ( int ) ; void display( ) ; int count( ) ; } ; int main( ) { LinkList < int > l1 ; cout << "Число элементов в связном списке = " << l1.count( ) << endl ; l1.append ( 11 ) ; l1.append ( 22 ) ; l1.append ( 33 ) ; l1.append ( 44 ) ; l1.append ( 55 ) ; l1.append ( 66 ) ; l1.addAtBeg ( 100 ) ; l1.addAtBeg ( 200 ) ; l1.addAfter ( 3, 333 ) ; l1.addAfter ( 6, 444 ) ; l1.display( ) ; cout << "Число элементов в связном списке = " << l1.count( ) << endl ; l1.del ( 200 ) ; l1.del ( 66 ) ; l1.del ( 0 ) ; l1.del ( 333 ) ; Глава 10
273 Шаблоны l1.display( ) ; cout << "Число элементов в связном списке = " << l1.count( ) << endl ; LinkList < Employee > l2 ; cout << "Число элементов в связном списке = " << l2.count( ) << endl ; Employee e1 ( "Sanjay", 23, 1100.00 ) ; Employee e2 ( "Rahul", 33, 3500.00 ) ; Employee e3 ( "Rakesh", 24, 2400.00 ) ; Employee e4 ( "Sanket", 25, 2500.00 ) ; Employee e5 ( "Sandeep", 26, 2600.00 ) ; l2.append ( e1 ) ; l2.append ( e2 ) ; l2.append ( e3 ) ;l2.append ( e4 ) ; l2.append ( e5 ) ;l2.display( ) ; l2.del ( 3 ) ; l2.display( ) ; cout << "Число элементов в связном списке = " << l2.count( ) << endl ; l2.addAtBeg ( e5 ) ; l2.display( ) ; l2.addAfter ( 3, e1 ) ; l2.display( ) ; cout << "Число элементов в связном списке = " << l2.count( ) << endl ; return 0 ; } Полезные советы по шаблонам Позвольте мне теперь дать несколько практических советов.  При объявлении обобщенной функции или класса мы можем заменить ключевое слово class на ключевое слово typename, как показано ниже. template < typename T > void selectionSort ( T a[ ], int sz ) { // ваш код } template < typename T > class Stack { // ваш код } ;  Имя обобщенного класса (к примеру, Stack) в разных контекстах оформляется по-разному. Так, в спецификации класса это просто имя, как в class Stack { } ;
274 Глава 10 Для функций-членов, определенных вне класса, это имя класса плюс имя аргумента шаблона, как в void Stack < T > :: push ( T data ) { } Ну и наконец, когда вы определяете фактические объекты для хранения определенного типа данных, это имя класса плюс этот конкретный тип, как в Stack < float > s1 ; // объект типа Stack <float> Применение нужной формы записи в нужном контексте требует постоянного внимания. Легко забыть добавить < T > или < float > к элементу Stack. Компилятор не терпит таких ошибок.  Будьте аккуратны с синтаксисом, когда компонентная функция возвращает зна- чение собственного класса. Допустим, мы определили обобщенный класс с именем Sample. Если функция-член fun( ) этого класса возвращает тип Sample, и мы должны определить эту функцию за пределами обобщенного класса, нам нужно использовать Sample < T > для возвращаемого типа, а перед ним оператор разрешения контекста, как показано ниже: Sample < T > Sample < T > :: fun ( Sample s ) { } С другой стороны, имя класса, применяемое в качестве типа аргумента функции, не должно включать элемент < T >.  Аргументы шаблона могут принимать значения по умолчанию. Затем значения этих аргументов становятся константами времени компиляции для этого конкретного экземпляра шаблона. Приведем пример: template < class T, int max = 50 > class Stack { private : T arr[ max ] ; } ; Этот обобщенный класс можно задействовать при помощи таких операторов, как Stack < int, 10 > s1 ; // может хранить 10 целых чисел в стеке Stack < float > s2 ; // может хранить 50 чисел вещественного типа в стеке Как и в случае функций с исходными значениями для аргументов, здесь также значения по умолчанию могут быть присвоены конечным аргументам списка аргументов шаблона.  Мы можем наследовать новый шаблон от существующего. Например, template < class T > class NewSample : public Sample < T > { } ;
Шаблоны 275  Всякий раз, когда создается экземпляр шаблона, код в теле шаблона генерирует- ся заново. Если некоторая часть функционала шаблона не зависит от типа данных, ее можно переместить в общий базовый класс, чтобы не воспроизводить этот код лишний раз.  Шаблоны следует применять при создании типобезопасного класса коллекции, способного обрабатывать данные любого типа. Вариативные шаблоны Вариативный шаблон — это обобщенная функция или класс, поддерживающий произвольное количество аргументов. В следующей программе продемонстрировано, как при помощи обобщенной функции с переменным числом аргументов получить сумму переданных ей аргументов: #include <iostream> using namespace std ; template < class T > double getSum ( T t ) { return t ; } template < class T, class... S > double getSum ( T first, S ... rest ) { return first + getSum ( rest... ) ; } int main( ) { double s1 = getSum ( 10, 20, 30 ) ; double s2 = getSum ( 1.5, 2.5, 3.5, 4.5 ) ; double s3 = getSum ( 10, 2.5, 20, 3.5 ) ; cout << s1 << "\t" << s2 << "\t" << s3 << endl ; } В теле основной функции мы обращаемся к getSum( ) с произвольным количеством аргументов. При каждом вызове ожидается, что функция getSum( ) вернет значение типа double, представляющее сумму переданных ей аргументов. Интересно, что у нас есть две реализации обобщенной функции getSum( ). Первая получает лишь один аргумент, тогда как второй передается переменное число аргументов. Обратите внимание на использование многоточий ( ... ) и их положение во второй реализации. template < class T, class... S > double getSum ( T first, S... rest ) { return first + getSum ( rest... ) ; }
276 Глава 10 Многоточие используется вариативной функцией в различных формах записи:  Выражение class... S представляет несколько типов, которые можно обозначить как S1, S2, S3 и т. д. S часто называют пакетом параметров, подразумевая под этим набор типов.  Выражение S... rest указывает на то, что функция getSum( ) рассчитана на получение нескольких аргументов разных типов, кроме первого аргумента типа T.  Выражение rest... указывает на то, что функция getSum( ) будет вызываться рекурсивно, принимая значения, содержащиеся в rest. Посмотрим теперь, как будут работать рекурсивные вызовы. Первая версия getSum( ) является основной, а вторая — рекурсивной. Первая версия принимает один аргумент T, тогда как вторая — один или несколько аргументов T и S. Вызов getSum ( 10, 20, 30 ) развертывается следующим образом: getSum ( 10, 20, 30 ) ; 10 + getSum ( 20, 30 ) ; 10 + ( 20 + getSum (30 ) ) ; 10 + ( 20 + getSum ( 30 ) ) ; В первых трех обращениях вызывается рекурсивная версия getSum( ), тогда как при последнем вызове задействуется основная версия. Области применения шаблонов Теперь мы знаем, что обобщенная функция/класс представляет собой семейство функций/классов. Полезность шаблонов для программистов на C++ трудно переоценить. Они часто применяются в крупных базах исходного кода с целью многократного использования и повышения гибкости программ. Стандартная библиотека C++ предоставляет множество ценных классов шаблонов. Среди них:  Классы «умных указателей», способствующих предотвращению утечек памяти и недействительных ссылок.  Классы библиотеки iostream, позволяющие осуществлять ввод-вывод на C++. В дополнение к этому стандартная библиотека шаблонов (STL) содержит множество классов для эффективного и элегантного управления коллекциями различных типов. Библиотека STL подробно обсуждается в главе 12. 1. Укажите, верны ли следующие утверждения:  От обобщенного класса можно наследовать новый класс.  Если есть обобщенная функция с именем max( ), то конкретная ее версия будет создана при вызове max( ) с новым типом.
Шаблоны 277  Компилятор генерирует лишь одну версию обобщенной функции для каждого типа данных, независимо от количества вызовов, сделанных для этого типа.  Применение шаблонов экономит память.  Мы можем задать явную специализацию обобщенной функции для определен- ного типа данных.  Обобщенная функция может принимать несколько типов аргументов.  Шаблоны типобезопасны, а макросы нет.  Обобщенные классы, как правило, применяются для контейнерных классов.  Функция-член обобщенного класса может быть определена за его пределами.  Аргументы шаблона могут принимать значения по умолчанию.  При определении обобщенной функции следует использовать форму записи template < class T >, а при определении обобщенного класса — template < typename T >.  Параметр обобщенной функции (обычно обозначаемый как T, S, Z и т. п.) может использоваться для указания обобщенных типов функции, для указания типа возвращаемого значения и для объявления переменных в теле функции.  Имена параметров шаблона в определениях шаблона должны быть уникаль- ными.  Посредством одного сегмента кода шаблоны позволяют обозначить весь диапа- зон вызываемых родственных перегруженных функций или весь диапазон родственных классов.  Все конкретные версии, сгенерированные из обобщенной функции, имеют оди- наковое имя. 2. Решите следующие задачи:  Разработайте обобщенную функцию для проверки обобщенного класса LinkedList, рассмотренного в этой главе. Вызовите эту обобщенную функцию из main( ).  Составьте программу, реализующую бинарное дерево в качестве обобщенного класса.  Составьте программу для реализации двусвязного списка в качестве обобщенно- го класса.  Напишите обобщенную функцию с переменным числом аргументов, отобра- жающую переменное количество переданных ей параметров.  Напишите вариативную обобщенную функцию с переменным количеством аргументов, вычисляющую выражение x + x2 + ( x2 )2 + ( ( x2 )2 ) 2 + ...
278 Глава 10  Шаблоны являются средством многократного использования на уровне исход- ного кода, тогда как наследование и отношения включения — средствами многократного использования на уровне объектного кода.  Можно создавать обобщенные функции, а также обобщенные классы.  Шаблон функции/класса можно использовать с любым встроенным или пользо- вательским типом данных.  Форма записи определения и вызова обобщенной функции: // определение обобщенной функции template < class T > // либо template < typename T > void printArray ( T[ ] arr ) { .. } // вызов обобщенной функции int intarr[ ] = { 10, -2, 37, 42, 15 } ; printArray ( intarr ) ;  Обобщенная функция может принимать несколько типов, к примеру template < class T, class S, class Z > void printTypes ( T a, S b, Z c ) { .. }  Форма записи для применения и определения обобщенного класса: // применение обобщенного класса stack < int > s1 ; s1.push ( 10 ) ; // определение обобщенного класса template < class T > class Stack { .. }  Можно разработать вариативный шаблон функции или класса, поддерживаю- щий произвольное количество аргументов.
Обработка исключений Недостаточно проектировать программы, работающие с идеальными входными данными и идеальной средой. Следует предвидеть все ситуации, в которых что-то может пойти не так, и включить в программу набор мер по их преодолению. В этой главе объясняется, как создавать такие надежные программы при помощи механизма обработки исключений C++
280 Глава 11  Обработка исключений в C++  Работа с библиотечными классами исключений • Библиотечные исключения при создании очереди • Еще один пример  Полезные советы  Спецификация исключений  Необработанные исключения  Интеллектуальные указатели и динамические контейнеры  Упражнения  Важное
Обработка исключений П 281 рограммирование — не зря называют искусством. Независимо от того, насколько вы уверены в своих способностях и знаниях, некоторые вещи в ходе разработки программы могут пойти совершенно не так, как вы предполагали. Не обходится без опечаток, ошибок компиляции, ошибок компоновки, ошибок времени выполнения. С первыми тремя типами ошибок справиться относительно легко. Но когда ошибки возникают во время выполнения программы, она должна уметь адекватно на них реагировать. В настоящей главе обсуждается, как бороться с такими типами ошибок. Ошибки, возникающие во время выполнения программы, называются исключениями (или исключительными ситуациями). Причины возникновения исключений весьма многочисленны. Вот лишь некоторые из них, наиболее распространенные:  Нехватка памяти.  Невозможность открыть файл.  Выход за границы массива.  Попытка инициализировать объект недопустимым значением.  Деление на ноль.  Переполнение стека.  Арифметическое переполнение или исчезновение порядка.  Попытка использовать неназначенную ссылку.  Невозможность подключения к серверу. Когда возникают такие исключительные ситуации, программист должен выбрать стратегию, согласно которой он будет их обрабатывать. Стратегии могут заключаться в отображении сообщений об ошибках на экране, или отображении диалогового окна в случае среды с графическим пользовательским интерфейсом, или запросе у пользователя предоставления более качественных данных, или журналировании сообщения об ошибке в файле, или просто в завершении выполнения программы. Обработка исключений в C++ C++ обеспечивает системный, объектно-ориентированный подход к обработке ошибок во время выполнения. Механизм обработки исключений C++ зиждется на трех ключевых словах — throw, catch и try. Разберемся в их назначении. Допустим, что во время выполнения функции (глобальной или компонентной) возникает ошибка. Нам нужно сообщить об этом, выполнив следующие два шага:  Создание объекта-исключения (exception object) и сохранение в нем информа- ции об исключительной ситуации.  Генерирование объекта-исключения посредством ключевого слова throw. Место, откуда вызывается объект-исключение, называется точкой выброса. Объект-исключение можно создать из готовых классов исключений, предоставляемых
282 Глава 11 стандартной библиотекой C++. Эти классы исключений представляют собой различные исключительные ситуации. В частности, один из классов исключений представляет ситуацию арифметического переполнения, другой — результат арифметического выхода за пределы допустимого диапазона и т. п. Все эти классы являются производными от базового класса с именем exception. На рис. 11.1 представлена иерархия классов исключений, доступных в стандартной библиотеке. Рис. 11.1. Иерархия классов исключений стандартной библиотеки C++ Если мы допускаем возможность возникновения исключительной ситуации, отличной от тех, что представлены в этих классах исключений, то мы можем определить свой собственный класс исключений, создать объект этого класса, а затем вызвать этот объект, чтобы сообщить об ошибке. Давайте для начала сфокусируемся на библиотечных классах исключений, а затем перейдем к пользовательским исключениям. Код в приложении, ожидающий возникновения исключения во время выполнения, заключен в try-блок. Когда возникает исключение и создается объект-исключение, управление перехватывается другим разделом кода в приложении, именуемым обработчиком исключений или catch-блоком. Таким образом, ошибки времени выполнения, сгенерированные в try-блоке, перехватываются catch-блоком. Передача управления (часто называемая распространением) из места возникновения исключения в try-блоке в место его обработки (т. е. в catch-блок) выполняется средой выполнения C++. Заметьте, что код, не ожидающий возникновения исключения, не обязательно размещать в try-блоке. В следующем фрагменте программного кода показана организация try- и catchблоков. Это не рабочая программа, но она наглядно демонстрирует, где и как размещаются различные элементы механизма обработки исключений. class Sample { public : void fun( )
Обработка исключений 283 { // ваш код if ( some error occurs ) // если возникает некая ошибка { // из этой точки выброса выдается исключение // оно будет перехвачено catch-блоком throw overflow_error ( .. ) ; } } } ; int main( ) { // try-блок try { Sample s ; s.fun( ) ; } catch ( overflow_error &e ) // обработчик исключений или catch-блок { // код, обрабатывающий перехваченное исключение } return 0 ; } Здесь Sample::fun( ) является функцией, в которой может возникнуть ошибка времени выполнения. По этой причине вызов fun( ) помещен в try-блок. Если ошибки переполнения не возникнет, функция fun( ) отработает в штатном режиме и вернет управление. Если же во время выполнения функции fun( ) действительно возникает исключение, то будет сгенерирован объект-исключение класса overflow_error. Если ниже точки выброса или ниже вызова fun( ) в теле main( ) размещены другие инструкции, они не будут выполнены, поскольку после создания объекта-исключения try-блок перестает действовать. Выброшенный объект-исключение перехватывается в catch-блоке, следующим непосредственно за try-блоком. При перехвате выброшенного исключения программный код может сделать две вещи:  Выполнить поэтапное завершение работы программы.  Исправить условия, по причине которых возникла исключительная ситуация, и продолжить выполнение. Эти два варианта проиллюстрированы на рис. 11.2. После выполнения catch-блока управление переходит к операторам, расположенным ниже этого блока, при условии что в catch-блоке отсутствуют инструкции return или exit( ).
284 Глава 11 Рис. 11.2. Механизм обработки исключений С++ Работа с библиотечными классами исключений Как показано на рис. 11.1, все классы исключений стандартной библиотеки порождены классом exception. Класс этот был объявлен в заголовочном файле ′exception′, тогда как logic_error и runtime_error — в файле ′ stdexcept′. Список классов, представленный на рис. 11.1, далеко не полный — существуют и другие классы исключений, связанные с ошибками в распределении памяти, использовании интеллектуальных указателей, файловой системы и т. п. Давайте составим небольшую программу, использующую некоторые из этих классов исключений: #include #include #include #include <iostream> <fstream> <string> <exception> using namespace std ; int main( ) { try
Обработка исключений 285 { ifstream infile ( "Sample.cpp" ) ; if ( !infile ) throw runtime_error ( "Не удалось открыть файл" ) ; string str1 ( "Yankee" ) ; string str2 ( "Doodle" ) ; str1.append ( str2, 7, 3 ) ; cout << str1 << endl ; } catch ( { cout << } catch ( { cout << } } logic_error &le ) le.what( ) ; runtime_error &rte ) rte.what( ) ; В try-блоке программа пытается совершить два действия — открыть файл для чтения и дописать часть строки str2 в конец строки str1. Если файл успешно открыт, программа продолжает добавлять строки. В противном случае создается и выбрасывается объект-исключение runtime_error. Как только объект выброшен, try-блок прекращает свое действие. Это исключение перехватывается вторым catch-блоком, и на экране отображается сообщение «Не удалось открыть файл», хранящееся в объекте runtime_error. Поскольку выброшенный объект перехватывается по ссылке, копия этого объекта не создается. Если управление достигает процедуры добавления, программа пытается извлечь из «Doodle» три символа, начиная с седьмого символа, и добавить их к «Yankee». Поскольку в слове «Doodle» всего шесть букв, не может быть и речи об извлечении 7, 8 и 9-го символов. Поэтому функция append( ) генерирует исключение out_of_range. Это исключение перехватывается первым catch-блоком. Заметьте, что мы перехватили его при помощи ссылки logic_error, а не ссылки out_of_range. Это стало возможным благодаря тому, что logic_error является родительским классом для out_of_range. Таким образом, logic_error — это ссылка с приведением вверх. С ее помощью мы обращаемся к методу what( ) для вывода сообщения об ошибке «недопустимая позиция в строке». Почему вознкает это сообщение? Дело в том, что когда процедура добавления символов генерирует объект out_of_range, это сообщение сохраняется в объекте-исключении, который затем извлекается с помощью функции what( ). Теперь разберем, в чем разница между logic_error и runtime_error. Ряд ошибок обнаруживается до выполнения программы. К примеру, операция деления на ноль. При возникновении таких исключений должен быть вызван объект logic_error. И напротив, существуют ошибки, которые обнаруживаются лишь во время выполнения программы, такие как открытие файла для чтения. В таких случаях должны выдаваться исключения класса runtime_error.
286 Глава 11 Библиотечные исключения при создании очереди А теперь давайте воплотим механизм обработки исключений в программе, которая поддерживает структуру данных очереди (или FIFO-списка). Мы задействуем обработку исключений для сообщения об ошибках в двух следующих ситуациях:  При попытке сохранить в очереди больше объектов, чем она может вместить.  При попытке удалить объект из пустой очереди. Ниже приведен листинг программы: #include <iostream> #include <exception> using namespace std ; #define MAX 4 class Queue { private : int arr[ MAX ] ; int front, rear ; public : Queue( ) { front = -1 ; rear = -1 ; } void addQ ( int item ) { if ( rear == MAX - 1 ) throw exception ( "Очередь переполнена" ) ; rear++ ; arr[ rear ] = item ; if ( front == -1 ) front = 0 ; } int delQ( ) { int data ; if ( front == -1 ) throw exception ( "Очередь пуста" ) ; data = arr[ front ] ;if ( front == rear ) front = rear = -1 ;else front++ ;
287 Обработка исключений return data ; } } ; int main( ) { Queue q ; try { q.addQ ( 11 ) ; q.addQ ( 12 ) ; q.addQ ( 13 ) ; q.addQ ( 14 ) ; q.addQ ( 15 ) ; // очередь переполнена i = q.delQ( ) ; cout << "Удаленный элемент = " << i << i = q.delQ( ) ; cout << "Удаленный элемент = " << i << i = q.delQ( ) ; cout << "Удаленный элемент = " << i << i = q.delQ( ) ; cout << "Удаленный элемент = " << i << i = q.delQ( ) ; //очередь пуста ; cout << "Удаленный элемент = " << i << } catch ( exception &err ) { cout << endl << err.what( ) << endl ; } return 0 ; } ; endl ; endl ; endl ; endl ; endl ; В нашей программе имеется класс Queue, содержащий методы addQ( ) и delQ( ) для добавления элементов в очередь и их удаления. Члены данных front и rear служат для отслеживания начала и конца очереди. При выполнении addQ( ) существует вероятность того, что массив, в котором хранятся элементы очереди, окажется заполнен и не сможет принимать новые элементы. В то же время при вызове delQ( ) не исключено, что мы попытаемся удалить элемент из уже пустой очереди. И в том и в другом случае возникнет исключительная ситуация. Поскольку при обращениях к addQ( ) и delQ( ) могут возникать исключения, их вызовы заключены в try-блок в основной функции main( ). Мы специально сделали вместимость очереди небольшой, чтобы было проще вызвать исключение при добавлении/удалении ее элементов. При возникновении исключительной ситуации создается новый объект-исключение, при этом его конструктору передается соответствующее сообщение. Чтобы
288 Глава 11 иметь возможность использовать класс исключений из стандартной библиотеки, мы подключили заголовочный файл ′exception′. Создав объект-исключение, мы используем ключевое слово throw, чтобы его выбросить. Этот выброшенный объект-исключение перехватывается в catch-блоке по ссылке, благодаря чему не создается лишняя копия объекта, что позволяет избежать дублирования и сэкономить память. Теперь, когда управление перешло к catch-блоку (срок действия try-блока истекает при возникновении первого исключения), выполняются уже его инструкции. В нашей программе мы просто отображаем сообщение, хранящееся в объекте-исключении, обратившись к его методу what( ). Если мы попытаемся удалить элемент из пустой очереди, будет выполнен аналогичный набор действий, в чем можно убедиться, закомментировав серию вызовов addq( ). Подытожим вышесказанное:  Код выполняется в штатном режиме за пределами try-блока.  Управление переходит к try-блоку.  Инструкция в try-блоке вызывает ошибку в функции-члене.  Функция-член создает объект-исключение.  Функция-член выбрасывает объект-исключение.  Управление передается обработчику исключения (catch-блоку), следующему за try-блоком.  Исключение обрабатывается в catch-последовательности. Оцените чистоту этой процедуры — она позволяет отделить код, обнаруживающий исключения, от кода, который их обрабатывает. Как следствие, исключение, вызванное одним и тем же фрагментом кода, может обрабатываться разными клиентами по-разному, путем написания соответствующего кода в соответствующих catch-последовательностях. Еще один пример Когда мы выделяем память при помощи оператора new, существует риск того, что объем памяти, который мы пытаемся выделить, недоступен. В таком случае функция оператора new выдает исключение с именем bad_alloc. В следующей небольшой программе показано, как перехватить это исключение: #include <iostream> #include <new> using namespace std ; int main( ) { long *p[ 100 ] ;
Обработка исключений 289 try { for ( int i = 0 ; i < 100 ;i++ ) { p[ i ] = new long[ 50000000 ] ; cout << i << "Размещен массив типа long" << endl ; } } catch ( bad_alloc &e ) { cout << " Ошибка выделения памяти: " << e.what( ) << endl ; } } Здесь мы пытаемся выделить 100 массивов, каждый из которых содержит 50 000 000 длинных целых чисел. Когда я запустил эту программу, то исключение возникло после выделения шести таких массивов, после шестого цикла. В зависимости от объема оперативной памяти на вашем компьютере и доступного дискового пространства для виртуальной памяти у вас может получиться другой результат. Мы подключили заголовочный файл ′new′, т. к. он содержит объявление класса исключений bad_alloc. Выброшенное исключение было перехвачено, а затем сгенерировано сообщение с типом исключения, возвращаемым методом what( ). Вместо этого при желании мы можем настроить собственный обработчик, которому будет передано управление при возникновении ошибки выделения памяти. Как это сделать, показано в следующей программе: #include <iostream> #include <new> using namespace std ; void my_allochandler( ) { cout << "Ошибка выделения памяти" << endl ; abort( ) ; } int main( ) { long *p[ 100 ] ; set_new_handler ( my_allochandler ) ; for ( int i = 0 ; i < 100 ;i++ ) { p[ i ] = new long[ 50000000 ] ; cout << i << "Выделен массив длинных чисел" << endl ; } }
290 Глава 11 При помощи метода set_new_handler( ), объявленного в заголовочном файле ′new′, можно настроить my_allochandler( ) в качестве функции, которая будет вызываться при сбое выделения памяти. Эта пользовательская функция-обработчик не должна ни получать, ни возвращать никаких значений. В ней мы просто отображаем сообщение об ошибке и завершаем программу, вызвав функцию abort( ). Работа с пользовательскими классами исключений В программе из предыдущего раздела мы просто отображаем на экране сообщение о возникшем исключении. Временами этого недостаточно. В частности, нам могут потребоваться дополнительные сведения о контексте, в котором произошла ошибка. Для этого необходимо включить больше информации в объект-исключение. Сделать это можно, создав собственный класс исключений, что продемонстрировано в следующей программе: #include <iostream> #include <exception> using namespace std ; #define MAX 4 class Queue { private : int arr[ MAX ] ; int front, rear ; public : Queue( ) { front = -1 ; rear = -1 ; } void addQ ( int item ) { if ( rear == MAX - 1 ) throw exception ( "Очередь переполнена" ) ; rear++ ; arr[ rear ] = item ; if ( front == -1 ) front = 0 ; } int delQ( ) { int data ;
291 Обработка исключений if ( front == -1 ) throw exception ( "Очередь пуста" ) ; data = arr[ front ] ; if ( front == rear ) front = rear = -1 ; else front++ ; return data ; } } ; int main( ) { Queue q ; try { q.addQ ( 11 ) ; q.addQ ( 12 ) ; q.addQ ( 13 ) ; q.addQ ( 14 ) ; ddQ ( 15 ) ; // очередь переполнена int i ; i = q.delQ( ) ; cout << "Удаленный элемент = " << i << i = q.delQ( ) ; cout << "Удаленный элемент = " << i << i = q.delQ( ) ; cout << "Удаленный элемент = " << i << i = q.delQ( ) ; cout << "Удаленный элемент = " << i << i = q.delQ( ) ; //очередь пуста cout << "Удаленный элемент = " << i << catch ( exception &err ) } { cout << endl << err.what( ) << endl ; } return 0 ; } endl ; endl ; endl ; endl ; endl ; Здесь мы создали собственный класс исключений QueueExcetion, произведя его от класса exception стандартной библиотеки. Когда возникает исключение, и программа готова сгенерировать объект-исключение, мы вызываем конструктор класса QueueException и передаем ему строку с ошибкой, front (начало) и rear (конец). Чтобы соединить эти данные в одну строку, конструктор выводит их в объект
292 Глава 11 ostrstream. Для завершения строки используется не endl, как обычно, а ends. При вызове метода what( ) из catch-блока он извлекает строку из объекта ostrstream и возвращает ее. Затем эта строка отправляется в cerr вместо cout. Это рекомендуемый метод, позволяющий при необходимости перенаправить поток cerr (стандартная ошибка) в файл. Полезные советы Теперь, когда мы разобрали несколько программ, использующих обработку исключений, пришло время практических рекомендаций:  Не обязательно размещать инструкцию, вызывающую исключение, непосредст- венно в try-блоке. Она может также располагаться в функции, вызываемой из try-блока.  Try-блоки могут быть вложенными. Если при выбрасывании исключения внут- ренний try-блок не имеет соответствующего catch-блока, то catch-инструкции внешнего try-блока проверяются на предмет совпадения. Этот прием известен как раскрутка стека.  У одного try-блока может быть более одного обработчика исключений, как по- казано ниже. try { // код, потенциально генерирующий исключения } catch ( exceptiontype1 id1 ) { // обработка исключений типа type1 } catch ( exceptiontype2 id2 ) { // обработка исключений типа type2 } Такие структуры позволяют дифференцировать исключения по типам. При выдаче исключения catch-выражения проверяются в порядке их следования. Как только обнаруживается совпадение, то исключение считается обработанным и дальнейший поиск прекращается.  Не перехватывайте все исключения в объекте класса exception. Безусловно, это сработает, поскольку exception является базовым классом, из которого порождены практически все остальные классы исключений. Чем дискретнее catchблок, тем точнее обработка исключений.  Не размещайте catch-инструкцию, перехватывающую объект базового класса, перед catch-инструкцией, перехватывающей объект производного класса. Иначе catch-инструкция базового класса перехватит все объекты классов, производных
Обработка исключений 293 от этого базового класса, а catch-инструкция производного класса никогда не будет выполняться.  Можно написать универсальный catch-блок, используя следующий синтаксис: catch ( ... ) { } Такую конструкцию нередко называют catch-блоком по умолчанию. Его следует применять в качестве замыкающей catch-инструкции в catch-последовательности.  Не рекомендуется прописывать универсальный catch-блок и не совершать в нем никаких действий. Это все равно что перехватить исключение и проигнорировать его.  Не используйте обработку исключений в чисто косметических целях. Она долж- на служить для исправления исключительной ситуации или выполнения поэтапного завершения программы.  Составляя библиотеку классов для сторонних пользователей, следует предвос- хитить потенциальные источники проблем для программ, в которых она будет задействована. На всех соответствующих участках кода следует генерировать исключения.  При написании программы, использующей библиотеку классов, необходимо предусмотреть try- и catch-блоки для всех исключений, выдаваемых данной библиотекой.  Исключения создают дополнительную нагрузку с точки зрения размера про- граммы и (при возникновении исключения) скорости ее исполнения. Поэтому обработкой исключений не следует злоупотреблять. Сделайте ее максимально продуманной — не слишком громоздкой, но и не слишком краткой. Спецификация исключений Функция может явным образом обозначить, какие исключения она может генерировать. Это делается с помощью спецификации исключений или списка типов генерируемых исключений (throw-list). Указав такой список, пользователь этой функции может перехватывать их при помощи одного или нескольких catch-блоков. После указания функция не может генерировать никаких других исключений, кроме перечисленных в списке. Вот несколько примеров: // может генерировать любое исключение void closeAccount ( ) ; // не может генерировать никаких исключений void openAccount ( ) throw ( ) ; void openAccount( ) noexcept ; // применимо к C++ 17
294 Глава 11 // может генерировать стандартное исключение void fun( ) throw ( overflow_error ) ; // может генерировать пользовательские исключения bool withdraw ( int no ) throw ( InsufficientFundsExc, InvalidAccnoExc ) ; Заметьте, что функция может генерировать либо объекты-исключения из классов исключений, упомянутых в списке типов исключений, либо объекты, созданные из производных от них классов с открытым наследованием. Необработанные исключения Если функция генерирует исключение, отличное от упомянутых в списке исключений, то вызывается предопределенная функция с именем unexpected( ). Эта функция вызывает функцию terminate( ), которая, в свою очередь, вызывает функцию abort( ) для аварийного завершения программы. Если мы хотим, чтобы вместо предопределенных вызывалась наша версия функций unexpected( ) или terminate( ), то необходимо настроить их, как показано в следующей программе: #include <iostream> #include <exception> using namespace std ; class Exception1 { } ; class Exception2 { } ; class Exception3 { } ; void my_terminate( ) { cout << "Вызов моей версии terminate( )" << endl ; abort( ) ; } void my_unexpected( ) { cout << "Вызов моей версии unexpected( )" << endl ; throw ; // повторное генерирование Exception3 } void fun( ) throw ( Exception1, Exception2 ) { throw Exception3( ) ; } int main( ) { set_unexpected ( my_unexpected ) ; set_terminate ( my_terminate ) ;
Обработка исключений 295 try { cout << "В try-блоке" << endl ; fun( ) ; } catch ( Exception1 e ) { cout << "Перехвачено Exception1" << endl ; } catch ( Exception2 e ) { cout << "Перехвачено Exception2" << endl ; } } Когда функция fun( ) генерирует Exception3, это исключение не может быть перехвачено в основной функции main( ), поскольку в ее теле отсутствует соответствующий catch-блок. В итоге вызывается функция unexpected( ). Однако при помощи set_unexpceted( ) мы снабдили программу нашей версией этой функции. Поэтому управление переходит к my_unexpected( ). Здесь мы повторно генерируем Exception3, используя пустой оператор throw. На этот раз вызывается функция my_terminate( ), т. к. мы настроили ее при помощи set_terminate( ). Эта функция просто отображает сообщение и завершает выполнение программы вызовом abort( ). Обратите внимание, что прототипы нашей функции-обработчика должны выглядеть следующим образом: void my_unexpected( ) ; // ничего не принимает, ничего не возвращает void my_terminate( ) ; // ничего не принимает, ничего не возвращает Интеллектуальные указатели и динамические контейнеры При динамическом создании и разрушении объектов регулярно возникает общий набор проблем, которые мы и обсудим ниже. Утечка памяти. Происходит, когда мы выделяем память, но забываем ее освободить. К примеру, когда в приведенном ниже фрагменте кода управление возвращается из функции fun( ), происходит утечка памяти, поскольку p разрушается, но объект, на который он указывает, остается в памяти, и мы не можем до него добраться: void fun( ) { Sample *p ; p = new Sample ; // создаем объект p -> show( ) ; // используем объект }
296 Глава 11 Висячий указатель (или же указатель на несуществующий объект). Если два указателя адресуют один и тот же объект и один из указателей используется для удаления объекта, то этот объект будет разрушен, но второй указатель продолжит на него указывать. Так, в следующем фрагменте кода p2 становится висячим указателем, когда управление возвращается из функции fun( ): void fun( ) { Sample *p1, *p2 ; p1 = new Sample ; // создаем объект p2 = p1 ; // копируем указатель p1 -> show( ) ; // используем объект delete p1 ; // удаляем объект при помощи указателя p1 } Повторное удаление объекта. Если дважды удалить динамически размещенный объект, второе удаление приведет к неопределенному поведению. Может произойти что угодно, вплоть до сбоя программы. void fun( ) { Sample *p ; p = new Sample ; // создаем объект p -> show( ) ; // используем объект delete p ; // удаляем объект delete p ; // пытаемся повторно удалить объект – неопределенное поведение } Некорректное удаление массива. Массив, размещенный при помощи оператора new[ ], следует удалять при помощи оператора delete[ ]. В следующем фрагменте кода мы воспользовались delete вместо delete[ ]. В результате деструктор вызывается лишь для первого объекта в массиве, что приводит к утечке памяти. void fun( ) { Sample *p ; p = new Sample[ 10 ] ; // создаем 10 объектов, вызываем конструктор 10 раз p[ 0 ] -> show( ) ; // используем объект с индексом 0 p[ 1 ] -> show( ) ; // используем объект с индексом 1 delete p ; // удаляем только первый объект, вызываем деструктор лишь один раз } Во избежание этих проблем, вызванных обычными (не интеллектуальными) указателями, было предложено вообще отказаться от использования операторов new, new[ ], delete и delete[ ]. Вместо них рекомендуется использовать одно из следующих средств:  Интеллектуальные указатели, такие как unique_ptr< >, shared_ptr< > и weak_ptr< >.  Динамические контейнеры, такие как vector<>, set< > и пр. Все они доступны в виде обобщенных классов стандартной библиотеки C++.
Обработка исключений 297 Интеллектуальный указатель, получаемый путем перегрузки операторов * и ->, ведет себя как простой указатель, но при этом выполняет некоторую обработку. Объект, созданный с его помощью, всегда будет разрушен надлежащим образом, не вызывая утечки памяти. При использовании динамических контейнеров не требуется явным образом задействовать интеллектуальные указатели unique_ptr, shared_ptr, weak_ptr. Вполне естественно, что использование динамических контейнеров является предпочтительным способом выделения и удаления ресурсов в современном программировании на C++. Поэтому мы не станем углубляться в тему интеллектуальных указателей, а перейдем в следующей главе к подробному рассмотрению динамических контейнеров. 1. Укажите, верны ли следующие утверждения:  Механизм обработки исключений предназначен для работы с ошибками време- ни компиляции.  Необходимо объявить класс исключения в теле класса, в котором будет генери- роваться исключение.  Необходимо перехватывать все сгенерированные исключения.  На один try-блок может приходиться несколько catch-блоков.  Обработчик исключений и catch-блок — это одно и то же.  При выбрасывании исключения вызывается конструктор класса исключения.  Try-блоки не могут быть вложенными.  Механизм обработки исключений гарантирует корректное разрушение объекта. 2. Решите следующие задачи:  Реализуйте механизм обработки исключений для класса Stack, сообщающий об ошибках, связанных с переполненным и пустым стеком.  Разработайте класс Customer, содержащий имя (name), номер счета (accountno) и текущий баланс (balance) в качестве членов данных. Создайте 5 объектов этого класса. Реализуйте функцию withdraw( ), осуществляющую снятие суммы с определенного счета. Эта функция должна генерировать исключение с именем BankException, если после снятия суммы баланс на счете становится ниже 1000 рублей. В catch-блоке укажите сведения о клиенте, инициировавшем неудачную транзакцию.
298 Глава 11  При создании и выполнении программы на C++ ошибки могут возникать на трех разных этапах: • В ходе компиляции: сообщается компилятором, действие — исправление программного кода. • В ходе компоновки: сообщается компилятором, действие — исправление #include-инструкций. • В ходе выполнения (программы): сообщается средой выполнения С++ (C++ Runtime), действие — перехват и обработка на лету.  Примеры ошибок времени выполнения: • Связанные с памятью — переполнение стека / динамической памяти, выход за границы массива. • Связанные с арифметическими вычислениями — деление на ноль, переполнение или исчезновение порядка. • Прочие — попытка использовать неприсвоенную ссылку, открыть ненайденный файл.  При выполнении метода, вызванного из клиентского кода, может возникнуть исключительная ситуация. В таком случае следует: • Поместить информацию об исключении в объект. • Сгенерировать объект-исключение.  Два варианта действия при генерировании объекта-исключения: • Перехватить объект в клиентском коде. • Передать его дальше.  Если объект-исключение передается дальше, то его перехватывает обработчик исключений по умолчанию и выполняет аварийное завершение программы.  Перехватив объект-исключение в клиентском коде, мы можем либо осуществить поэтапное завершение программы, либо исправить исключительную ситуацию и продолжить ее выполнение.  Два способа создания объектов-исключений: • Из классов исключений стандартной библиотеки C++. • Из пользовательских классов исключений.  Преимущества обработки исключений объектно-ориентированным способом: • В объекты-исключения можно поместить больше сведений. • Передача объектов-исключений вызывающей стороне управляется средой выполнения C++.
Обработка исключений 299  Каким образом C++ упрощает обработку объектно-ориентированных исключе- ний: • Предоставляя ключевые слова — try, catch, finally, throw, throws. • Предоставляя библиотечные классы исключений. • Позволяя методам анонсировать возможность исключения.  Как пользоваться try- и catch-блоками: • try-блок — поместите в него код, в котором, по вашим предположениям, может возникнуть исключение. • catch-блок — перехватывайте в нем выброшенное исключение. Этот блок должен следовать непосредственно за try-блоком.  При возникновении исключения управление переходит к catch-блоку. После выполнения catch-инструкций управление переходит к следующей строке после catch-блока(ов), при отсутствии в catch-последовательности команд return или throw.  try-блок: • Может быть вложен в другой try-блок. • Если у вложенного try-блока нет своего обработчика исключений, то проверяются на совпадение catch-инструкции внешнего try-блока.  catch-блок: • Для одного try-блока допускается наличие нескольких catch-блоков. • На каждое выданное исключение срабатывает лишь один catch-блок. • Важен порядок catch-блоков — от производных к базовым.  Советы по обработке исключений: • Не перехватывайте исключение лишь для того, чтобы проигнорировать его. • Не перехватывайте все исключения при помощи класса ′exception′, старайтесь дифференцировать исключения по типам. • Сделайте обработку исключений в вашей программе максимально продуманной — не слишком громоздкой и не слишком краткой.  Используйте интеллектуальные указатели или динамические контейнеры вместо операторов new, new[ ], delete и delete[ ].

Стандартная библиотека шаблонов Помимо средств объектно-ориентированного программирования, именно мощная стандартная библиотека шаблонов (Standard Template Library, STL) делает C++ таким популярным среди программистов, стремящихся создавать надежные и производительные программы. Материал этой главы составляют ключевые компоненты библиотеки STL
302 Глава 12  Стандартная библиотека шаблонов  Компоненты библиотеки STL • Контейнеры • Итераторы • Алгоритмы  Вектор (vector) • Другие операции  Вектор объектов класса Point  Список (list)  Множество (set) и мультимножество (multi-set)  Отображение (map) и мультиотображение (multi-map)  Стек (stack)  Очередь (queue) • Объект-функция  Упражнения  Важное
Стандартная библиотека шаблонов Б 303 иблиотека STL предоставляет шаблонные классы и функции общего назначения, реализующие множество популярных и широко распространенных алгоритмов и структур данных. Первоначально STL была отдельной библиотекой, однако теперь она объединена со стандартной библиотекой C++. И хотя в отрасли по-прежнему используется аббревиатура STL, в стандартной документации к C++ она упоминается как стандартная библиотека. Поскольку теперь это часть стандартной библиотеки, для ее использования не требуется скачивать ее отдельно. В этой главе обсуждаются различные компоненты STL и демонстрируется их применение в программах на C++. Стандартная библиотека шаблонов Архитектура STL была разработана Александром Степановым и Менг Ли в лабораториях Hewlett Packard в Пало-Альто (Калифорния). При создании программ на C++ нередко нужна та или иная структура данных для их обработки. Вместо того, чтобы создавать и поддерживать эти структуры данных самостоятельно, мы можем воспользоваться готовыми классами библиотеки STL. Данный подход имеет следующие преимущества:  Реализация каждой структуры данных, такой как связный список, очередь с приоритетом, ассоциативный массив и т. п., требует значительных трудозатрат.  Многие структуры данных требуют нетривиального использования указателей. Небольшое упущение или недосмотр может привести к нарушениям доступа к памяти и ее утечкам.  Используя стандартную библиотеку шаблонов, можно сконцентрироваться на проблеме, которую требуется решить, и предоставить STL обработку данных.  Допустим, что в крупном проекте программистам, работающим над разными задачами, необходима единая структура данных. Если они решат создать классы для индивидуальной обработки этих структур данных, код станет трудно изменять, поддерживать и отлаживать. Более того, такое решение стимулирует скорее изобретение давно изобретенного, а не многократное использование. Таким образом, использование библиотеки STL способствует многократному использованию кода и может значительно сэкономить время разработки, трудозатраты и бюджет, а также сократить сроки тестирования и отладки. Стандартная библиотека шаблонов была задумана и разработана для повышения производительности и гибкости. Кроме того, все классы в STL основаны на шаблонах. Следовательно, их можно повторно использовать для работы как со стандартными, так и с пользовательскими типами данных. Такая расширяемость библиотеки STL является еще одной причиной ее популярности среди программистов.
304 Глава 12 Компоненты STL Ядро стандартной библиотеки шаблонов включает три основные составляющие — контейнеры, итераторы и алгоритмы.  Так, контейнеры STL представляют собой структурные объекты, способные хранить другие объекты практически любого типа данных.  Итераторы STL применяются для работы с элементами STL-контейнера.  И наконец, алгоритмы библиотеки STL используются для обработки структур данных. К примеру, контейнер STL, именуемый vector, можно использовать для хранения ряда целых чисел в виде динамического массива. Итератор можно использовать для обхода этого массива в прямом или обратном направлении; а алгоритм STL, называемый sort( ), можно задействовать для сортировки элементов объекта vector. Обратите внимание на тот факт, что управление контейнерами при помощи итераторов не только обеспечивает удобство, но и в сочетании с алгоритмами делает программу выразительной, гибкой и производительной. Давайте теперь подробно рассмотрим различные компоненты библиотеки STL. Контейнеры Классы контейнеров подразделяются на три категории в зависимости от способа расположения элементов:  Последовательные контейнеры: представляют собой линейные структуры данных, такие как vector (вектор, динамический массив), deque (двусторонняя очередь, дек) и list (список).  Ассоциативные контейнеры: позволяют эффективно находить нужные значения на основе заданных ключей. Такие контейнеры могут хранить наборы значений или пары ключ-значение. В частности, к ним относятся set (множество), multiset (мультимножество), map (отображение) и multimap (мультиотображение).  Производные контейнеры: Это ограниченные версии последовательных контей- неров. Их часто называют контейнеры-адаптеры. К ним относятся stack (стек), queue (очередь) и priority_queue (очередь с приоритетом). В табл. 12.1 приведены отличительные особенности каждого из классов контейнеров. Таблица 12.1. Различные классы контейнеров Контейнер Описание vector Вставка/удаление в конце массива. Прямой доступ к любому элементу deque Вставка/удаление в начале/конце. Прямой доступ к любому элементу list Двусвязный список. Вставка/удаление в любом месте
305 Стандартная библиотека шаблонов Таблица 12.1 (окончание) Контейнер Описание set Уникальные элементы. Используется сбалансированное дерево двоичного поиска (BST). Быстрый поиск multiset Допускаются дубликаты. Быстрый поиск map Уникальные пары «ключ-значение». Используется сбалансированный BST. Быстрый поиск по ключам multumap Допускаются дубликаты. Пары «ключ-значение». Быстрый поиск по ключам stack LIFO-список queue FIFO-список priority_queue Элемент с наивысшим приоритетом становится первым элементом Кроме контейнеров, указанных в табл. 12.1, библиотека STL также предоставляет следующие специализированные контейнеры:  Массив в виде связанных списков.  bitset (битовое множество) для хранения наборов значений флагов.  valarray (числовой массив) для выполнения математических векторных операций. Итераторы Как следует из самого названия, итераторы позволяют циклически опрашивать все элементы контейнера и выполнять над ним операции ввода-вывода. Итераторы инкапсулируют механизм, применяемый для доступа к элементам контейнера. В различных итераторах могут быть реализованы одна или несколько из этих возможностей. Ниже приведен список итераторов и их поведение.  Входной итератор: • Считывает элемент из контейнера. • Опрашивает элементы от начала до конца, по одному элементу за раз. • Поддерживает только однопроходные алгоритмы.  Выходной итератор: • Записывает элемент в контейнер. • Опрашивает элементы только от начала до конца, по элементу за раз. • Поддерживает только однопроходные алгоритмы.  Однонаправленный итератор: • Считывает/записывает элемент из/в контейнер. • Опрашивает элементы только от начала до конца, по элементу за раз. • Поддерживает только однопроходные алгоритмы.
306 Глава 12  Двунаправленный итератор: • Считывает/записывает элемент из/в контейнер. • Опрашивает элементы в обоих направлениях, по элементу за раз. • Поддерживает многопроходные алгоритмы.  Итератор прямого доступа: • Считывает/записывает элемент из/в контейнер. • Опрашивает элементы в обоих направлениях, с пропуском многих элементов одновременно. • Поддерживает многопроходные алгоритмы. Обратите внимание, что среди всех перечисленных видов итератор прямого доступа выглядит самым функциональным. В разных контейнерах реализованы разные виды итераторов. На основе итератора, поддерживаемого контейнером, определяются его возможности чтения/записи/перемещения по элементам. Ниже приведен список, в котором указаны итераторы, поддерживаемые различными контейнерами:  Вектор, дек — прямой доступ.  Список, множество, мультимножество, отображение, мультиотображение — двунаправленные.  Стек, очередь, очередь с приоритетом — без поддержки итераторов. Помимо разновидностей, указанных выше, итераторы могут быть константными (например, const_iterator) или неконстантными. Константные итераторы могут опрашивать элементы контейнера, но не могут их изменять. Кроме того, неконстантные итераторы нельзя использовать с константными объектами-контейнерами. Алгоритмы Алгоритмы библиотеки STL — это шаблонные функции, которые выполняют общие операции, такие как вставка, удаление, поиск, сортировка и сравнение элементов или целых контейнеров. Некоторые алгоритмы могут изменять содержимое контейнера (например, delete( )), а некоторые — нет (к примеру, find( )). Стандартная библиотека шаблонов предоставляет множество алгоритмов. Их применение может сэкономить массу времени и усилий. Алгоритмы задействуют итераторы для доступа к элементам контейнера. Алгоритмы нередко возвращают итераторы, указывающие на результаты алгоритмов. Каждый алгоритм имеет минимальные требования к типам итераторов, которые можно с ним использовать. Возможность применения контейнера с конкретным алгоритмом зависит от типа итератора, поддерживаемого контейнером. Контейнеры, поддерживающие итераторы прямого доступа, можно использовать со всеми алгоритмами библиотеки STL.
Стандартная библиотека шаблонов 307 Поскольку алгоритмы предоставляются как автономные функции, а не как часть классов контейнеров, можно также создавать собственные алгоритмы и научить их работать с различными типами контейнеров. Разобравшись с различными составляющими библиотеки STL, приступим к написанию программ, использующих эти компоненты. Вектор (vector) Контейнер vector (вектор) напоминает массив C++ в том смысле, что он содержит объекты одного и того же типа, и к каждому из этих объектов можно получить доступ по отдельности. Вектор определен как обобщенный класс, поэтому его можно использовать для хранения объектов любого типа. К примеру: vector <int> vec_int ; // может хранить целые числа vector <Sample> vec_obj ; // может хранить объекты Sample Vector-контейнеры «умнее» массивов C++, т. к. они могут динамически увеличиваться по мере добавления к ним элементов. Вектор всегда занимает непрерывные области памяти; для него предоставляется итератор прямого доступа. Рассмотрим программу, которая выполняет различные операции над vector-контейнером. #include <vector> #include <iostream> using namespace std ; int main( ) { vector <int> v1 ; vector <int>::iterator itr1 ; for ( int i = 0 ; i <= 5 ; ++i ) v1.push_back ( 5 * i ) ; cout << "Vector v1:" << endl ; for ( itr1 = v1.begin( ) ; itr1 != v1.end( ) ; ++itr1 ) cout << *itr1 << endl ; vector <int> v2 { 10, 20, 30, 40, 50 } ; cout << "Vector v2:" << endl ; for ( auto itr2 = v2.begin( ) ; itr2 != v2.end( ) ; ++itr2 ) cout << *itr2 << endl ; vector <int> v3 { 10, 20, 30, 40, 50 } ; cout << "Vector v3:" << endl ; for ( auto num : v3 ) cout << num << endl ;
308 Глава 12 return 0 ; } Наша программа создает три вектора целочисленного типа. Так, мы сохраняем пять целых чисел в v1, вызвав функцию push_back( ), а v2 и v3 инициализируются на месте. Затем мы производим циклический опрос этих векторов, используя три цикла for, каждый из которых становится проще, чем предыдущий. В первом for-цикле мы вызываем метод begin( ) для получения объекта итератора. Цикл продолжается до тех пор, пока itr1 не достигает конца vector-контейнера, который определяется путем сравнения itr1 с результатом v1.end( ), возвращающего итератор, указывающий позицию за последним элементом вектора. Если itr1 равен этому значению, значит, достигнут конец вектора. В теле цикла мы воспользовались *itr1 для получения значения текущего элемента вектора. Команда itr1++ перемещает итератор на следующий элемент. Во втором for-цикле мы используем ключевое слово auto. Поэтому нам не нужно определять тип itr2. Он выводится компилятором из контекста, в котором он задействован. Третий for-цикл — это цикл нового типа. При каждой итерации цикла num принимает следующее значение в vector-контейнере. Другие операции Кроме сохранения элементов и их опроса, над контейнером vector можно выполнять множество других операций. Они продемонстрированы в следующем фрагменте кода и снабжены пояснительными комментариями. vector<int> v ; // store elements v.push_back ( 18 ) ;v.push_back ( 29 ) ; v.push_back ( -4 ) ;v.push_back ( 12 ) ;v.push_back ( 44 ) ; // доступ к элементам cout << "Начальный элемент: " << v.front( ) << endl ; cout << "Элемент во второй позиции: " << v.at ( 2 ) << endl ; cout << "Элемент в конце: " << v.back( ) << endl ; // замена элементов vector <int>::iterator itr ;itr = v.begin( ) ; *itr = 35 ; vec [ 2 ] = 20 ;itr += 4 ; *itr = 99 ; // вставка элемента itr = v.begin( ) ;v.insert ( itr, 25 ) ; // удаление элемента itr = v.begin( ) ; itr += 2 ;v.erase ( vitr ) ;
Стандартная библиотека шаблонов 309 // удаление элементов из вектора v.pop_back( ) ; v.pop_back( ) ; // размер и емкость cout << v.size( ) << endl ; // текущее кол-во элементов в векторе cout << v.max_size( ) << endl ; // макс. кол-во элементов, которое может содержать вектор cout << v.capacity( ) << endl ; // кол-во элементов до необходимости увеличения емкости вектора v.resize (100 ) ; // увеличение размера до 100 // очистка элементов вектора v.clear( ) ; // проверка, является ли вектор пустым bool b = v.empty( ) ; Вектор объектов класса Point При помощи vector-контейнера мы также можем управлять массивом пользовательских объектов Point (двухмерные координаты точки), что продемонстрировано в следующем листинге: #include <vector> #include <iostream> using namespace std ; class Point { private : int x, y ; public : Point ( int xx = 0 , int yy = 0 ) { x = xx ; y = yy ; } friend ostream & operator << ( ostream &o, Point &p ) ; } ; ostream & operator << ( ostream &o, Point &p ) { o << "(" << p.x << ", " << p.y << ")" << endl ; return o ; } int main( ) { vector <Point> vp1 ;
310 Глава 12 for ( int i = 0 ;i < 5 ;i ++ ) vp1.push_back ( Point ( i + 1, i + 1 ) ) ; cout << "Вектор vp1:" << endl ;for ( auto itr : vp1 ) cout << itr ; cout << "Начало: " ;cout << vp1.front( ) ; cout << "Конец: " ;cout << vp1.back( ) ; vector <Point>::reverse_iterator ritr ; cout << "Обратный вектор vp1:" << endl ; for ( ritr = vp1.rbegin( ) ;ritr != vp1.rend( ) ;ritr++ ) cout << *ritr ; cout << "Размер vp1: " << vp1.size( ) << endl ; vector <Point> vp2 ; vp2.assign ( vp1.begin( ) , vp1.begin( ) + 3 ) ; cout << "Вектор vp2:" << endl ;for ( auto itr : vp2 ) cout << itr ; } Программа генерирует следующие результаты: Вектор vp1: (1, 1) (2, 2) (3, 3) (4, 4) (5, 5) Начало: (1, 1) Конец: (5, 5) Обратный вектор vp1: (5, 5) (4, 4) (3, 3) (2, 2) (1, 1) Размер vp1: 5 Вектор vp2: (1, 1) (2, 2) В этой программе мы сначала объявили класс Point, содержащий конструктор и дружественную функцию, отображающую объект Point. Затем в for-цикле в теле основной функции мы создали пять объектов Point и добавили их в вектор vp, вызвав функцию push_back( ) класса vector. Затем при помощи итератора мы опросили вектор, отображая значения всех объектов класса Point, в нем присутствующих.
Стандартная библиотека шаблонов 311 Мы также получили доступ к элементам в начале и в конце вектора, используя методы front( ) и back( ). Обратный обход был выполнен при помощи reverse_ iterator. Размер контейнера возвращается функцией size( ). В заключение мы создали еще один вектор vp2 и скопировали в него первые три элемента vp1, а затем опросили вектор vp2 от начала до конца. Список (list) Контейнер list (список) воплощает классическую структуру данных с двунаправленным последовательным доступом. Так, в отличие от массива C++ или вектора библиотеки STL, элементы списка не поддерживают прямой доступ. List-контейнер реализован как двусвязный список для поддержки двунаправленных итераторов. Каждый элемент списка содержит указатель на предыдущий и последующий элементы. Списки следует применять в тех случаях, когда необходимо часто вставлять или удалять элементы из середины списка. Рассмотрим программу, выполняющую различные действия с контейнером list. #include <iostream> #include <list> using namespace std ; int main( ) { list <int> ls ; // добавление элементов в список ls.push_back ( 12 ) ; ls.push_front ( 34 ) ; ls.push_back ( 19 ) ; ls.push_front ( 44 ) ; ls.push_back ( 31 ) ; ls.push_front ( 2 ) ; ls.push_back ( 29 ) ; ls.push_front ( -8 ) ; // отображение элементов списка cout << "Элементы списка: " << endl; for ( auto litr : ls ) cout << litr << " " ; cout << endl ; cout << "Начальный элемент: " << ls.front( ) << endl ; cout << "Конечный элемент: " << ls.back( ) << endl ; // удаление двух элементов из начала и конца ls.pop_back( ) ;
312 ls.pop_back( ) ; ls.pop_front( ) ; ls.pop_front( ) ; // отображение элементов списка cout << "Список после удаления: " << endl ;for ( auto litr : ls ) cout << litr << " " ; cout << endl ; // вставка элементов list<int>::iterator litr ;litr = ls.end( ) ; litr-- ; ls.insert ( litr, -20 ) ; litr-- ; litr-- ; ls.insert ( litr, 67 ) ; litr++ ; ls.insert ( litr, 33 ) ; cout << "Список после вставки: " << endl ; for ( auto litr : ls ) cout << litr << " " ; cout << endl ; // очистка элемента litr = ls.begin( ) ; ls.erase ( litr ) ; cout << "Список после очистки первого элемента: " << endl ; for ( auto litr : ls ) cout << litr << " " ; cout << endl ; // перестановка элементов в обратном порядке ls.reverse( ) ; cout << "Реверсированный список: " << endl ; for ( auto litr : ls ) cout << litr << " " ; cout << endl ; // сортировка элементов списка ls.sort( ) ; cout << "Список, отсортированный по возрастанию: " << endl ; for ( auto litr : ls ) cout << litr << " " ; cout << endl ; Глава 12
Стандартная библиотека шаблонов 313 // Сортировка списка по убыванию ls.sort ( greater <int> ( ) ) ; cout << "Список, отсортированный по убыванию: " << endl ; for ( auto litr : ls ) cout << litr << " " ; cout << endl ; ls.clear( ) ; } В этой программе мы создаем связный список целых чисел. Для вставки элементов в конец/начало списка мы вызываем методы push_back( ) и push_front( ). После вставок методы front( ) и back( ) отображают элемент на первой и последней позиции списка соответственно. Вызываемые затем методы pop_back( ) и pop_front( ) удаляют элемент в конце и начале списка. У списка есть двунаправленный итератор. Прямой доступ к элементам списка не поддерживается. По этой причине список не поддерживает операцию индексирования [ ]. Так, чтобы вставить элемент на 6-ю позицию (в список из семи элементов), мы использовали команду litr = ls.end( ), которая возвращает итератор, ссылающийся на элемент в 7-й позиции. Затем мы декрементировали значение итератора на единицу, чтобы litr ссылался на элемент на 6-й позиции. Далее метод insert( ) добавляет в эту позицию элемент -20. Аналогичные шаги выполняются для вставки элемента в 3-ю и 5-ю позиции. Метод erase( ) очищает элемент, на который ссылается итератор litr, тогда как метод clear( ) удаляет все элементы из списка. Все элементы списка можно перестроить в обратном порядке при помощи метода reverse( ). Функция алгоритма сортировки не поддерживается списком. В то же время для контейнера list предусмотрена функция-член sort( ), сортирующая список по возрастанию/убыванию. Множество (set) и мультимножество (multi-set) Это ассоциативные контейнеры, элементы которых содержат уникальные значения, при этом мультимножество допускает дублирование элементов. Размер этих контейнеров может меняться во время выполнения. Они всегда упорядочены в порядке возрастания или убывания. Обе разновидности поддерживают двунаправленные итераторы. Давайте посмотрим, как множество и мультимножество можно задействовать в коде: #include <iostream> #include <algorithm> #include <set> using namespace std ;
314 int main( ) { set<int> set<int, set<int, set<int, Глава 12 s1 { 1, 12, -13, 44, 15, 6 } ; less<int> > s2 { 11, 32, -3, 14, 5, 12 } ; greater<int> > s3 { -3, 2, 4, 19, 11, 30 } ; less<int> > s4, s5, s6, s7 ; cout <<"s1: " ; for ( auto sitr : s1 ) cout << sitr << " " ; cout << endl ; cout <<"s2: " ; for ( auto sitr : s2 ) cout << sitr << " " ; cout << endl ; cout <<"s3: " ; for ( auto sitr : s3 ) cout << sitr << " " ; cout << endl ; set<int>::iterator sitr ; // поиск элемента int num ; cout << "Введите число для поиска: " ; cin >> num ; sitr = find ( s1.begin( ), s1.end( ), num ) ; if ( sitr != s1.end( ) ) cout << num << " найдено в s1" << endl ; else cout << num << " не найдено в s1" << endl ; // проверка на идентичность данных в set1 и set2 bool b = includes ( s1.begin( ), s1.end( ), s2.begin( ), s2.end( ) ) ; if ( b ) cout << "s1 и s2 идентичны" << endl ; else cout << "s1 и s2 не идентичны" << endl ; // объединение двух множеств set_union ( s1.begin( ), s1.end( ), s2.begin( ), s2.end( ), inserter ( s4, s4.begin( ) ) ) ; cout << "Объединение s1 и s2: " << endl ; for ( auto sitr : s4 ) cout << sitr << " " ; cout << endl ;
Стандартная библиотека шаблонов // получение общих элементов двух множеств set_intersection ( s1.begin( ), s1.end( ), s2.begin( ), s2.end( ), inserter ( s5, s5.begin( ) ) ) ; cout << "Общее у s1 и s2: " << endl ;for ( auto sitr : s5 ) cout << sitr << " " ; cout << endl ; // получение элементов s1, которых нет в s2 set_difference ( s1.begin( ), s1.end( ), s2.begin( ), s2.end( ), inserter ( s6, s6.begin( ) ) ) ; cout << "Разное у s1 и s2: " << endl ; for ( auto sitr : s6 ) cout << sitr << " " ; cout << endl ; // получение симметрической разности для s1 и s2 set_symmetric_difference ( s1.begin( ), s1.end( ), s2.begin( ), s2.end( ), inserter ( s7, s7.begin( ) ) ) ; cout << "Симметрическая разность между s1 и s2: " << endl ; for ( auto sitr : s7 ) cout << sitr << " " ; cout << endl ; multiset multiset multiset multiset <int, greater<int> > ms1 { 2, 3, 11, -6, 11, 2 } ; <int, greater<int> > ms2 { 8, 10, 11, 8, 3, 12 } ; <int, greater<int> > ms3 ; <int>::iterator msitr ; // отображение элементов мультимножеств cout << "ms1: " ; for ( auto msitr : ms1 ) cout << msitr << " " ; cout << endl ; cout << "ms2: " ; for ( auto msitr : ms2 ) cout << msitr << " " ; cout << endl ; // объединение двух мультимножеств set_union ( ms1.begin( ), ms1.end( ), ms2.begin( ), ms2.end( ), inserter ( ms3, ms3.begin( ) ) ) ; cout << "Объединение ms1 и ms2: " << endl ; for ( auto msitr : ms3 ) cout << msitr << " " ; cout << endl ; } 315
316 Глава 12 Программа генерирует следующие результаты: s1: -13 1 6 12 15 44 s2: -3 5 11 12 14 32 s3: 30 19 11 4 2 -3 Введите число для поиска: 67 67 не найдено в s1 s1 и s2 не идентичны Объединение s1 и s2: -13 -3 1 5 6 11 12 14 15 32 44 Общее у s1 и s2: 12 Разное у s1 и s2: -13 1 6 15 44 Симметрическая разность между s1 и s2: -13 -3 1 5 6 11 14 15 32 44 ms1: 11 11 3 2 2 -6 ms2: 12 11 10 8 8 3 Объединение ms1 и ms2: 12 11 11 11 10 8 8 3 3 2 2 -6 Для начала мы создали множества s1, s2 и s3 для хранения целых чисел. Методы less<int> или greater<int>, участвующие в определении s2 и s3, являются предопределенными функциями алгоритма, которые гарантируют, что элементы во множестве представлены в порядке возрастания/убывания. По умолчанию задан порядок по возрастанию, как и в случае с s1. Множества опрашиваются при помощи неявного итератора sitr. Его тип выводится из контекста. Метод find( ) применяется для поиска элемента во множестве, а метод include( ) — для проверки тождественности двух множеств. Множества s4, s5, s6 и s7 остаются пустыми во время объявления. Позднее в них сохраняются результаты различных действий над множествами s1 и s2. Так, осуществляются следующие операции: set_union( ) : Объединяет элементы в s1 и s2 set_intersection( ) : Выдает различающиеся элементы в s1 и s2 set_difference( ) : Выдает элементы s1, отсутствующие в s2 set_symmetric_difference( ): Выдает элементы s1, отсутствующие в s2, и наоборот Шаблонная функция inserter( ) копирует результат операций, выполненных вышеуказанными функциями, во множество, переданное ей как аргумент. В этой программе мы также задействовали мультимножество целых чисел, которые могут содержать дубликаты или несколько значений одного ключа. Методы find( ), empty( ), set_union( ) и другие работают с мультимножеством точно таким же образом, как они работают со множеством.
Стандартная библиотека шаблонов 317 Отображение (map) и мультиотображение (multi-map) Отображение (map) хранит пары значений, где каждая пара состоит из объектаключа и объекта-значения. Объектом-ключом могут быть данные встроенных типов, таких как string, int, float, или любой другой объект пользовательского класса. Этот объект содержит ключ, по которому осуществляется поиск. Объект-значение хранит дополнительные данные. Объект-значение обычно хранит числа или строки, однако может хранить и объекты других классов. К примеру, если ключ отображения содержит слово, то значением может быть длина слова, количество его повторов или даже его значение. Данные отображения всегда организованы в порядке сортировки по ключам, который определяется соответствующим методом при создании отображения. Элементами отображения всегда являются уникальные пары «ключ-значение». Мультиотображение (multimap), с другой стороны, позволяет хранить неуникальные пары «ключ-значение». Теперь посмотрим, как воплотить отображение и мультиотображение в программном коде. #include <iostream> #include <map> #include <string> using namespace std ; int main( ) { map <string, int> m1 { { "Rahul", 645 }, { "Aditi", 555 }, { "Salil", 455 }, { "Vibha", 470 }, { "Beena", 378 } } ; // отображение данных cout << "Всего элементов в m1: " << m1.size( ) << endl ; cout << "Элементов в отображении m1: " << endl ; for ( auto mitr : m1 ) cout << "Имя: " << mitr.first << " Баллы: " << mitr.second << endl ; // добавление новой пары «ключ-значение» m1 [ "Dinesh" ] = 333 ; // изменение значения существующего ключа m1 [ "Beena" ] = 555 ;
318 Глава 12 // еще один способ добавить пару «ключ-значение» pair<string, int> p ; p.first = "Shailesh" ; p.second = 665 ; m1.insert ( p ) ; cout << "Новое отображение" << endl ; for ( auto mitr : m1 ) cout << "Имя: " << mitr.first << " Баллы: " << mitr.second << endl ; // поиск отображения для заданного студента string str ; cout << "Введите имя студента для поиска: " ; cin >> str ; map <string, int>::iterator mitr ; mitr = m1.find ( str ) ; if ( mitr != m1.end( ) ) cout << mitr->first << " набрал " << mitr->second << " баллов" << endl ; else cout << "Студент с таким именем не найден!" << endl ; multimap <string, int> m2 ; copy ( m1.begin( ), m1.end( ), inserter ( m2, m2.begin( ) ) ) ; p.first = "Dinesh" ; p.second = 665 ;m2.insert ( p ) ; cout << "Мультиотображение:" << endl ; for ( auto mitr : m2 ) cout << "Имя: " << mitr.first << " Баллы: " << mitr.second << endl ; } В этой программе мы создали отображение для хранения имен студентов (ключ) и баллов (значение). Элементы отображения упорядочены в алфавитном порядке по именам. Мы отобразили элементы, хранящиеся в m1, при помощи цикла с использованием неявного итератора. Инструкция для вывода данных имеет несколько отличный характер. Нам необходимо использовать mitr.first для вывода значения ключа и mitr.second для вывода связанного с ним значения. Существуют два способа вставить в отображение новые пары «ключ-значение» — при помощи оператора [ ] или путем вызова метода insert( ) с передачей ему пары «ключ-значение» в качестве аргумента. Оператором [ ] также можно воспользоваться для изменения существующего значения.
Стандартная библиотека шаблонов 319 Метод find( ) используется для поиска указанного ключа. Если ключ найден, то отображаются оценки, полученные студентом, в противном случае выводится соответствующее сообщение. В этой же программе мы создали мультиотображение m2. Затем, воспользовавшись методом copy( ), мы скопировали все элементы m1 в m2. Мультиотображение не поддерживает оператор [ ]. Поэтому для добавления элементов в мультиотображение мы создали объект p шаблонного класса pair. Затем мы обратились к методу insert( ) для вставки пары p в мультиотображение m2. Заметьте, что добавленная пара дублирует существующую запись, что поддерживается мультиотображением. И наконец, при помощи for-цикла мы вывели элементы, хранящиеся в m2. Стек (stack) Стек (stack) представляет собой последовательный контейнер, обеспечивающий вставку и удаление элементов лишь с одного конца. Так, элементы стека добавляются и извлекаются по принципу LIFO (Last In First Out) «последним пришел — первым ушел». Следующая программа демонстрирует функции управления стеком целых чисел: #include <iostream> #include <stack> using namespace std ; void main( ) { stack<int> stk ; // вставка элементов в стек stk.push ( 16 ) ; stk.push ( 10 ) ; stk.push ( 19 ) ; stk.push ( -3 ) ; stk.push ( 22 ) ; stk.push ( 18 ) ; int sz = stk.size( ) ; cout << "Стек содержит " << sz << " элементов" << endl ; // удаление элементов из стека while ( !stk.empty( ) ) { int i = stk.top( ) ; cout << i << " " ; stk.pop( ) ; } }
320 Глава 12 В этой программе мы создали стек целых чисел. После создания объекта stk мы добавляем элементы в стек посредством метода push( ). Функция size( ) возвращает общее количество элементов в стеке. Метод top( ) возвращает элемент, находящийся на вершине стека. Мы вызываем этот метод в while-цикле, который выполняется до тех пор, пока стек не окажется пустым. Мы проверяем это условие при помощи функции empty( ). После отображения элемента на вершине стека для его удаления вызывается метод pop( ). Стек может быть реализован при помощи вектора, списка или двусторонней очереди (deque). Можно указать тип базового контейнера в качестве второго параметра в конструкторе стека, как показано ниже: stack<int, vector<int>> stk ; По умолчанию в качестве базового контейнера используется класс deque. Очередь (queue) Очередь (queue) реализуется в программном коде так же, как и стек. В частности, если мы хотим создать очередь целых чисел в виде связного списка, тогда инструкция для объявления такой очереди будет следующей: queue <int, list<int>> q ; где q — это контейнер типа queue, задействованный в качестве связного списка целых чисел. Такие методы, как size( ), front( ), empty( ), действуют таким же образом, как и в случае со стеком. Поэтому я предложил бы вам самим написать программу для управления очередью целых чисел. Допустим, есть ряд заданий, которые должен обрабатывать процессор. У каждого задания есть имя и номер приоритета. Чем меньше номер приоритета, тем выше приоритет задания. Задания эти должны быть переданы процессору в порядке их приоритета. Достичь этого можно при помощи контейнера priority_queue (очередь с приоритетом), как показано ниже: #include <iostream> #include <queue> #include <string> #include <vector> using namespace std ; class Task { private : string pname ; int ppri ; public : Task ( string n, int pr ) { pname = n ;ppri = pr ; }
321 Стандартная библиотека шаблонов friend class PrioritizeTasks ; friend ostream& operator << ( ostream &s, Task &t ) ; } ; ostream& operator << ( ostream &o, Task &t ) { o << "Процесс: " << t.pname << " Приоритет: " << t.ppri << endl ; return ( o ) ; } class PrioritizeTasks { public : int operator( ) ( const Task &t1, const Task &t2 ) { return t1.ppri > t2.ppri ; } } ; int main( ) { priority_queue<Task, vector<Task>, PrioritizeTasks> Task tarr[ ] = { Task ( "SWAP", 4 ), Task ( Task ( "WORD", 5 ), Task ( Task ( "RENM", 6 ), Task ( Task ( "CRET", 1 ), Task ( } ; for ( auto t : tarr ) pq.push ( t ) ; pq ; "PRNT", "COPY", "DELT", "DUMP", 17 ), 3 ), 18 ), 9 ) Task tk ; while ( !pq.empty( ) ) { tk = pq.top( ) ; cout << tk ; pq.pop( ) ; } } В этой программе мы сконструировали объект pq класса priority_queue, применив следующую инструкцию: priority_queue <Task, vector<Task>, PrioritizeTasks> pq ; Vector-контейнер pq хранит объекты Task класса Task. Помимо этого, в объявлении указывается, что класс PrioritizeTasks предоставляет функцию для определения порядка, в котором задачи должны помещаться в очередь с приоритетом. После создания массива tarr, содержащего объекты класса Task, при помощи forцикла мы добавляем эти объекты в очередь с приоритетом pq, обращаясь к методу
322 Глава 12 push( ). При добавлении объектов в pq вызывается функция перегруженного оператора ( ) (которая является функцией-членом класса PrioritizeTasks). Эта функция сравнивает приоритеты двух объектов t1 и t2, возвращая 0 или 1. Заметьте, что PrioritizeClass объявлен как друг класса Task, поскольку функция перегруженного оператора ( ) должна обращаться к его закрытым членам. После создания очереди с приоритетом мы отображаем ее содержимое в цикле, вызывая top( ) для доступа к отдельному элементу и pop( ) для его удаления из очереди. Объект-функция Объект-функция, или функциональный объект — это класс, реализующий перегруженный оператор ( ). В нашей программе таким объектом-функцией является PrioritizeTask. Он также известен как оператор обращения (call operator). Функциональный объект предпочтительнее, чем вызов функции, в двух отношениях, поскольку он:  Может содержать члены данных.  Может использоваться как обобщенный параметр. В частности, объекты-функции применяются в качестве критерия сортировки контейнеров. К примеру, в следующем объявлении multiset <int, less<int> > ms1 { 2, 3, 11, -6, 11, 2 } ; less является функциональным объектом. Он возвращает «истинно» (true), если первый параметр меньше второго параметра. Поскольку контейнер set хранит свои элементы в отсортированном порядке, ему нужен способ сравнения двух элементов. Сравнение осуществляется при помощи объекта-функции less. При желании можно задать собственные критерии сортировки, создав функциональный объект и указав его в списке шаблонов для контейнера, как показано ниже: multiset <int, myfunctor<int> > ms1 { 2, 3, 11, -6, 11, 2 } ; Кроме того, объекты-функции широко применяются в алгоритмах. Например, рассмотрим следующий алгоритм с использованием remove_if( ): remove_if ( v.begin( ), v.end( ) , IsOdd ) ; Здесь IsOdd является функциональным объектом. Если элемент в векторе v окажется нечетным, он будет удален из вектора. Ниже приведена полная реализация такого решения: #include <iostream> #include <vector> #include <algorithm> using namespace std ;
Стандартная библиотека шаблонов 323 class IsOdd { public : bool operator ( ) ( int num ) { return ( ( num % 2 ) == 1 ) ; } } ; int main( { vector vector vector ) <int> v { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 } ; <int>::iterator pend ; <int>::iterator q ; pend = remove_if ( v.begin( ), v.end( ), IsOdd( ) ) ; for ( q = v.begin( ) ; q != pend ;++q ) cout << *q << endl ; } 1. Укажите, верны ли следующие утверждения:  STL означает стандартный язык программирования.  Алгоритмы библиотеки STL используются для создания наборов стандартных и пользовательских типов данных.  Функция push_back( ) применяется для удаления элемента в конце вектора.  Стек и очередь являются адаптерами контейнеров.  Множество и мультимножество — это разновидности ассоциативных контейне- ров. 2. Решите следующие задачи:  Составьте программу для подсчета количества вхождений слова в текстовом файле.  Составьте программу для управления стеком строк.  Составьте программу для ведения телефонной книги, содержащей имена або- нентов и номера телефонов. Предусмотрите возможность поиска по имени абонента либо по номеру телефона.  Составьте программу, применяющую алгоритм сортировки к набору строк, хра- нящихся в векторе.
324 Глава 12  Для хранения, извлечения и управления несколькими числами/строками можно использовать массивы.  У массивов есть два существенных недостатка: • У них отсутствует механизм организации данных различными способами, такими как пары «ключ-значение», упорядоченные множества и т. п. • У них нет средств доступа к данным в порядке очередности, сортировки и т. п.  Вместо массивов следует использовать стандартную библиотеку шаблонов (STL).  Преимущества применения библиотеки STL: • Высокоэффективная, созданная экспертами, проверенная временем. • Содержит готовые классы для большинства структур данных, что позволяет сосредоточиться не на их построении, а на самой программе. • Позволяет расширять классы в соответствии с нашими потребностями.  Три ключевых компонента библиотеки STL: • Контейнеры — хранение данных. • Итераторы — обход элементов контейнера. • Алгоритмы — выполнение различных операций над элементами контейнера.  Типы контейнеров: • Последовательные — vector (вектор), deque (двусторонняя очередь, дек), list (список). • Ассоциативные — set (множество), multiset (мультимножество), map (отображение) и multimap (мультиотображение). • Контейнеры-адаптеры — stack (стек), queue (очередь) и priority_queue (очередь с приоритетом). • Прочие — bitset (битовое множество), valarray (числовой массив).  Типы итераторов: • Входной итератор. • Выходной итератор. • Однонаправленный итератор. • Двунаправленный итератор. • Итератор прямого доступа.
Стандартная библиотека шаблонов 325  Разные контейнеры поддерживают различные типы итераторов.  Константные итераторы могут изменять элементы контейнера, неконстант- ные — нет.  Алгоритмы — обобщенные функции, выполняющие стандартные операции, та- кие как вставка, удаление, поиск, сортировка и сравнение элементов или целых контейнеров.  Объект-функция или функциональный объект — это класс, реализующий пере- груженный оператор ( ).
326 Глава 12
Предметный указатель Кому-то нравится читать справочную информацию, кому-то нет. Но все любят «быструю помощь». Этот указатель поможет вам ее получить

329 Предметный указатель B bad( ) 216 basic_fstream 202 basic_ifstream 202 basic_istream 192 basic_ofstream 202 basic_ostream 192 basic_streambuf 192 C catch 32, 39, 281 ◊ catch-блок 282, 292 cin 40, 202 const ◊ const_iterator 306 ◊ возврат значений 57 ◊ задать данные 75 ◊ переменные 52 ◊ спецификатор 52, 55, 165 ◊ ссылки 54 ◊ указатели 53 ◊ универсальный спецификатор 124 ◊ функции-члены 57 const_cast 39, 248 cout 40, 189, 191, 194, 202, 204, 212 D delete 39, 117, 121, 180, 296, 306 dynamic_cast 246 E endl 41 eof( ) 216 explicit 39 F fail( ) 216 failbit 216 G get( ) 193 getline( ) 193 good( ) 216 I ifstream ◊ get( ) 204 ◊ eof( ) 204 iostream 189, 190, 191 ◊ istream 192 ◊ ostream 194 ◊ класс 196 istrstream 211 ◊ ostrstream 211 M main( ) 102, 166 N new 117, 120, 121 O ofstream 208 ◊ write( ) 208 ostream 209 ostrstream 211 P private 145, 146 R reinterpret_cast 248 S strstream 211 ◊ istrstream 211 strstreams 211 W write( ) 208
330 А Абстракция 25 ◊ операций ввода-вывода 190 Архитектура STL 303 ◊ алгоритмы 306 ◊ итераторы 305 ◊ компоненты STL 304 ◊ контейнеры, итераторы и алгоритмы 304 В Ввод-вывод 189 ◊ Unicode 195 ◊ библиотека iostream 190 ◊ в файл 202 ◊ записей 206 ◊ манипуляторы потоков 196 ◊ обработка ошибок ввода-вывода 215 ◊ общепринятые требования к системе ввода/вывода 189 ◊ объектов 213 ◊ пользовательский манипулятор 199 ◊ потоки 190 ◊ работа с файлом 203 ◊ символьный 203 ◊ строк 205 ◊ строковые потоки 211 Вектор-контейнер (vector) 307 ◊ класс point 309 Взаимодействие с файловой системой 217 Выделение данных ◊ стек 149 Выделение памяти 117 ◊ VPTR 171 ◊ VTABLE 171 ◊ динамическое выделение памяти 117 ◊ для массивов и структур 118 ◊ для объектов 120 ◊ статическое выделение памяти 117 ◊ стек 117 Д Данные ◊ пользовательский тип 130 ◊ простой (встроенный) типа 130 ◊ скрытие данных 90 ◊ тип bool 58 ◊ тип complex 78 Предметный указатель ◊ тип float 34 ◊ тип int 34 Деструктор 94, 121 ◊ виртуальные деструкторы 179 Динамическая идентификация типа 242 Динамические контейнеры 296 И Инкапсуляция 29, 33 Инстант См. Объект класса Использование оператора разрешения контекста 240 К Класс 29, 34, 89 ◊ complex 95, 214 ◊ index 98, 143 ◊ абстрактный 168 ◊ базовый (родительский) 30, 151 ◊ интерфейс класса 90 ◊ клиентский код 102 ◊ код реализации класса 103 ◊ объект класса 122 ◊ объявление класса 102 ◊ отношения принадлежности 227 ◊ пользовательский 30 ◊ порядок перечисления классов 157 ◊ производный (дочерний) 30, 151 Ключевые слова ◊ auto 43 ◊ explicit 234 ◊ mutable 236 ◊ private 90, 102 ◊ public 90, 102 ◊ using 241 Комментарии 38 Компилятор 71, 80, 131, 171 Конструктор 92, 121, 131, 136, 179 ◊ с одним аргументом 136 ◊ копирования 126, 129 М Многократное использование 32 Множество (set) и мультимножество (multi-set) 313 Мультиотображение (multimap) 317
331 Предметный указатель Н Нарезка объекта См. Срезание объекта Наследование ◊ private (закрытое) 154 ◊ protected (защищенное) 154 ◊ public (открытое) 154 Наследование 30, 34, 35, 143 ◊ базовый и производный классы 143 ◊ виды 154 ◊ конструкторы 151 ◊ многоуровневое 155 ◊ множественное 156 ◊ одиночное 154 ◊ предупреждение 157 ◊ применение 149 Неструктурные языки программирования 23 ◊ недостатки 24 Новые ключевые слова 39 О Обобщенная функция ◊ специализация 264 Обработка исключений 32, 281 ◊ библиотечные классы исключений 284 ◊ висячий указатель 296 ◊ иерархия классов исключений 282 ◊ некорректное удаление массива 296 ◊ необработанные исключения 294 ◊ обработчик исключений 282 ◊ объект-исключение 281 ◊ повторное удаление объекта 296 ◊ пользовательские классы исключений 290 ◊ спецификация исключений 293 ◊ точка выброса 281 ◊ утечка памяти 295 Объект 27, 28, 34 ◊ память 100 ◊ характеристики 33 ООП 23, 26, 34, 35, 189 ◊ истоки 23 ◊ концепции 28 ◊ характеристики 26 Оператор ◊ (.) 91 ◊ ++ 98 ◊ :: 46 ◊ доступа к члену класса 91 ◊ разрешения контекста 240 Организация программы 102 ◊ компиляция и сборка 106 ◊ поэтапная разработка 158 Особенности С++ ◊ ввод и вывод 40 ◊ возвращение значения по ссылке 51 ◊ вывод типов 43 ◊ динамическая инициализация 43 ◊ динамическое объявление переменных 42 ◊ комментарии 39 ◊ неименованные объединения и перечисления 44 ◊ приведение типов 45 ◊ пустой указатель (void) 46 ◊ синтаксис 43 ◊ ссылки 47 ◊ типы обращений к функциям 48 Отношения включения 31, 34, 35, 143, 227 Отображение (map) 317 Очередь-контейнер (queue) 320 ◊ объект-функция 322 П Полиморфизм 31, 35, 165, 167 Преобразование данных 129 ◊ в исходном объекте 132 ◊ в целевом объекте 134 ◊ между встроенными типами 129 ◊ между встроенными и пользовательскими типами 130 ◊ между различными пользовательскими типами данных 132 Приведение типов 244 Пространство имен 237 ◊ namespace 237 ◊ глобальное 237 ◊ псевдоним пространства имен 238 ◊ способы применения 240 Прямой доступ 208 Р Режимы открытия файла 210
332 С Связывание ◊ динамическое 169 ◊ позднее 168 ◊ статистическое 169 ◊ функций 168 Сериализация 214 Скрытие данных 29, 35 Список-контейнер (list) 311 Срезание объекта 177 Стек-контейнер (stack) 319 Структура 89, 101 ◊ разница между структурой и классом 102 Структурное программирование 24 ◊ характеристики 24 Счетчик count 123 У Указатель ◊ const 53 ◊ this 97, 100, 230 ◊ интеллектуальный 295 ◊ на члены классов 171, 249 Унарный оператор ◊ перегрузка 98 Ф Функции ◊ встраиваемые 79, 86 ◊ перегрузка операторов 165 ◊ связывание функций 168 ◊ функции-члены (компонентные функции, методы) 27, 91 Функция ◊ виртуальная (virtual) 81, 165, 169, 176 ◊ вызовы перегруженной операторной функции 100 Предметный указатель ◊ ◊ ◊ ◊ ◊ ◊ ◊ ◊ ◊ ◊ ◊ ◊ ◊ ◊ деструктор 94 дружественная (friend) 81, 229 исходные значения для аргументов 72 конструктор 93 неявный конструктор 94 особенности перегрузки функций 74 перегрузка операторов 76, 86 перегрузка функций 73, 165 прототип 71, 72 синтаксис возвращаемого типа 81 статическая (static) 81, 123 строгая проверка типов 71 чистая виртуальная функция 167 экземплярная (instance) 81 Ш Шаблоны 32, 34, 35, 143 ◊ вариативные 275 ◊ для пользовательских типов 261 ◊ классов 267 ◊ макросы 265 ◊ области применения 276 ◊ полезные советы 273 ◊ связного списка 271 ◊ сортировка 266 ◊ сортировка на основе шаблона 266 ◊ стандартная библиотека 276 ◊ шаблонов 303 ◊ функций 259 ◊ функция с набором типов 264 Э Экземпляр 91