/
Автор: Солтер Н.А. Клепер С.Дж.
Теги: компьютерные технологии программирование программное обеспечение язык программирования c++
ISBN: 5-8459-1065-X
Год: 2006
Текст
Программистам от программистов
■л
>
для профессионалов
Николас А. Солтер, Скотт Дж. Клепер
1^^ >^^ Обновления, исходный код и техническая поддержка на сайте
^^ www.wrox.com
А
wrox
www.dialektika.com
для профессионалов
Николас А. Солтер
Скотт Дж. Клепер
Н
"Диалектика"
Москва • Санкт-Петербург • Киев
2006
Научно-популярное издание
Николас А. Солтер, Скотт Длс. Клепер
C++ для профессионалов
Литературный редактор О.Ю. Белозовская
Верстка В.И. Бордюк
Художественный редактор В.Г. Павлютин
Корректоры Л.А. Гордиенко, Т.А. Корзун,
О.В. Мишутина, В. В. Смоляр,
Л. В. Чернокозинская
Издательский дом "Вильяме"
101509, г. Москва, ул. Лесная, д. 43, стр. 1
Подписано в печать 11.07.2006. Формат 70x100/16.
Гарнитура Times. Печать офсетная.
Усл. печ. л. 73,53. Уч.-изд. л. 54,62.
Тираж 3 000 экз. Заказ № 1966.
Отпечатано по технологии CtP
в ОАО "Печатный двор" им. А. М. Горького
197110, Санкт-Петербург, Чкаловский пр., 15
ББК 32.973.26-018.2.75
С60
УДК 681.3.07
Компьютерное издательство "Диалектика"
Зав. редакцией СМ. Тригуб
Перевод с английского и редакция Н.М. Ручко
По общим вопросам обращайтесь в издательство "Диалектика" по адресу:
info@dialektika.com, http://www.dialektika.com
115419, Москва, а/я 783; 031150, Киев, а/я 152
Солтер, Николас А., Клепер, Скотт Дж.
С60 C++ для профессионалов. : Пер. с англ. — М. : ООО "И.Д. Вильяме", 2006. —
912 с.: ил. — Парал. тит. англ.
ISBN 5-8459-1065-Х (рус.)
В этом практическом руководстве с большим количеством примеров
представлены все грани разработки приложений на C++, включая этапы проектирования,
тестирования и отладки. Здесь описаны простые, но мощные методы,
используемые профессионалами, малознакомые, но весьма полезные средства и многократно
применяемые шаблоны проектирования. В книге демонстрируются различные
методики и хороший стиль программирования, а также предлагаются пути повышения
качества кода и эффективности программирования в целом. Вы узнаете, как написать
межплатформенный и межъязыковый код, выполнить поэлементное тестирование,
а также использовать стандартную библиотеку C++.
Книга предназначена для программистов и разработчиков, которые хотят поднять
свои навыки программирования на C++ на профессиональный уровень. Поэтому
читатель должен владеть базовыми знаниями C++ или существенным опытом
программирования на С и/или Java, а также иметь представление об основах программирования.
ББК 32.973.26-018.2.75
Все названия программных продуктов являются зарегистрированными торговыми марками
соответствующих фирм.
Никакая часть настоящего издания ни в каких целях не может быть воспроизведена в какой бы
то ни было форме и какими бы то ни было средствами, будь то электронные или механические,
включая фотокопирование и запись на магнитный носитель, если на это нет письменного
разрешения издательства JOHN WILEY&Sons, Inc.
Copyright © 2006 by Dialektika Computer Publishing.
Original English language edition Copyright © 2005 by Wiley Publishing, Inc.
All rights reserved including the right of reproduction in whole or in part in any form. This translation
published by arrangement with Wiley Publishing, Inc.
Wiley, the Wiley Publishing logo, Wrox, the Wrox logo, and Programmer to Programmer are
trademarks or registered trademarks of John Wiley & Sons, Inc. and/ or its affiliates.'All other trademarks
are the property of their respective owners. Wiley Publishing, Inc., is not associated with any product or
vendor mentioned in this book.
ISBN 5-8459-1065-Х (рус.) © Компьютерное издво "Диалектика", 2006
перевод, оформление, макетирование
ISBN 0-7645-7484-1 (англ.) © by Wiley Publishing, Inc.,2005
Оглавление
Введение 20
Часть I. Введение в профессиональное С++-проектирование 27
Глава 1. Краткий курс C++ 28
Глава 2. Разработка профессиональных С++-программ 69
Глава 3. Проектирование с использованием объектов 85
Глава 4. Проектирование с использованием библиотек и шаблонов 107
Глава 5. Проектирование с целью многократного использования кода 138
Глава 6. Использование эффективных методов разработки программного
обеспечения 156
Часть II. Пишем С++-код профессионально m
Глава 7. Кодируем стильно 174
Глава 8. Оттачиваем мастерство в использовании классов и объектов 195
Глава 9. Освоение классов и объектов 222
Глава 10. Осваиваем механизм наследования 264
Глава 11. Пишем обобщенный код с помощью шаблонов 315
Глава 12. Причуды и странности C++ 371
Часть III. Освоение суперсредств C++ 399
Глава 13. Эффективное управление памятью 400
Глава 14. Использование С++-потоков ввода-вывода 433
Глава 15. Обработка ошибок 457
Часть IV. Как создать код без ошибок 491
Глава 16. Перегрузка С++-операторов 492
Глава 17. Создание эффективных С++-программ 528
Глава 18. Разработка межплатформенных приложений 554
Глава 19. Становимся экспертами в области тестирования программ 573
Глава 20. Что нужно знать об отладке 597
Часть V. Использование библиотек и шаблонов бзз
Глава 21. Библиотека STL: контейнеры и итераторы 634
Глава 22. Освоение STL-алгоритмов и функциональных объектов 694
Глава 23. Использование и расширение возможностей STL 731
Глава 24. Исследование распределенных объектов 772
Глава 25. Объединим возможности технологий и оболочек 809
Глава 26. Применение шаблонов проектирования 833
Часть VI. Приложения вез
1 Приложение А. Готовимся к С++-интервью 864
Приложение Б. Аннотированная библиография 885
Предметный указатель ■ 894
Содержание
Введение - 20
Для кого написана эта книга 20
Что можно найти в этой книге 21
Структура книги 22
Что нужно для работы с этой книгой 22
Соглашения, используемые в книге 22
Исходный код 23
Сигнализируйте об ошибках 23
Форумы 24
Посвящение 25
Благодарности 25
Об авторах 25
Список участников проекта 26
Часть I. Введение в профессиональное С++-проектирование 27
Глава 1. Краткий курс C++ 28
Основы C++ 29
Ну, как же обойтись без "Привет, мир!" 29
Пространства имен 32
Переменные 34
Операторы 35
Типы 37
Инструкции и операторы условного выполнения 39
Циклы 42
Массивы 43
Функции 44
Разминка окончена 45
Приготовимся к погружению в C++ 45
Указатели и динамическое распределение памяти , 46
Строки в C++ 49
Ссылки 51
Исключения 51
Использование модификатора const 53
Использование C++ как объектно-ориентированного языка
программирования 54
Объявление класса 54
Первая реальная С++-программа 57
Система управления кадрами 57
Класс Employee 57
Содержание
7
Класс Database 61
Интерфейс пользователя 65
Оценка программы 68
Резюме с перспективой 68
Глава 2. Разработка профессиональных С++-программ 69
Что такое проектирование программ? 70
Значимость проектирования программ 71
Что особенного в проектировании С++-программ? 73
Два правила С++-проектирования 74
Абстракция 74
Многократное использование кода 76
Проектирование шахматной программы 78
Требования к проекту 78
Этапы проектирования 79
Резюме 84
Глава 3. Проектирование с использованием объектов 85
Объектно-ориентированный взгляд на мир 86
О процедурном мышлении 86
Объектно-ориентированный подход к проектированию 87
Жизнь в мире объектов 89
Отношения между объектами 92
Абстракция 102
Резюме 106
Глава 4. Проектирование с использованием библиотек и шаблонов 107
Многократное использование кода 108
Быть или не быть многократно используемому коду 109
Стратегии использования готового кода 112
Использование приложений от сторонних организаций 117
Открытые библиотеки 118
Стандартная библиотека C++ 120
Проектирование с использованием шаблонов и методов 133
Методы проектирования 134
Шаблоны проектов 135
Резюме 137
Глава 5. Проектирование с целью многократного использования кода 138
Принцип многократности использования кода 139
Как спроектировать многократно используемый код 140
Использование абстракций 141
Структурирование кода в расчете на его неоднократное
использование 142
Проектирование удобных интерфейсов 147
Поддерживайте баланс между общностью и простотой
применения 153
Резюме 154
8 Содержание
Глава 6. Использование эффективных методов разработки программного
обеспечения 156
Необходимость подчиняться технологическому процессу 157
Модели жизненных циклов разработки ПО 158
Ступенчатая и водопадная модели 158
Спиральный метод 161
Рациональный унифицированный процесс 163
Методологии разработки программного обеспечения 165
Экстремальное программирование (ЭП) 165
Сортировка ПО 170
Построение собственного процесса и методологии 171
Будьте готовы к восприятию новых идей 171
Перенесите новые идеи на бумагу 171
Разберитесь, что работает, а что — нет 171
Не сдавайтесь 172
Резюме 172
Часть II. Пишем С++-код профессионально 173
Глава 7. Кодируем стильно 174
Красота — страшная сила 174
Думая о будущем... 175
... и настоящем 175
Элементы хорошего стиля 175
Документируйте свой код 175
Зачем писать комментарии 175
Стили комментариев 179
Комментарии, применяемые в этой книге 184
Декомпозиция 184
Декомпозиция посредством переделки 184
Декомпозиция посредством нисходящего проектирования 185
О декомпозиции в этой книге 186
Присвоение имен 186
Выбор хорошего имени 186 '
Соглашение о присвоении имен 187
Использование языковых средств 190
Применяйте константы 190
Используйте преимущество const-переменных 190
Замените указатели ссылками 190
Используйте собственные исключения 191
Форматирование • 191
Договоритесь о размещении фигурных скобок 192
Договоритесь об использовании пробелов и круглых скобок 193
Пробелы и табуляция 193
Проблемы стилистической слаженности 194
Резюме 194
Содержание 9
Глава 8. Оттачиваем мастерство в использовании классов и объектов 195
Начнем с примера 196
Создание классов . 196
Определение класса 196
Определение методов 199
Использование объектов 203
Жизненные циклы объектов 204
Создание объектов 204
Итак, что мы знаем о генерируемых компилятором
конструкторах 215
Разрушение объектов 215
Присваивание объектов 217
Отличие копирования от присваивания 220
Резюме 221
Глава 9. Освоение классов и объектов 222
Динамическое выделение памяти в объектах 222
Класс Spreadsheet 228
Освобождение памяти с помощью деструкторов 224
Обработка операций копирования и присваивания 225
Различные виды членов данных 233
Статические члены данных 233
Константные члены данных 235
Ссылочные члены данных 236
Ссылочные const-члены данных 238
Подробнее о методах 238
Статические методы 238
Константные методы 239
Перегрузка методов 242
Параметры по умолчанию 242
Встраиваемые методы 244
Вложенные классы 245
"Друзья" 247
Перегрузка операторов 248
Реализация оператора сложения 249
Второй вариант: перегруженный метод operator+ 250
Перегрузка арифметических операторов 253
Перегрузка операторов сравнения 256
Построение типов с помощью перегрузки операторов 257
Указатели на методы и члены классов 258
Построение абстрактных классов 259
Использование классов интерфейса и реализации 259
Резюме 262
Глава 10. Осваиваем механизм наследования 264
Построение классов с использованием наследования 265
Расширение классов 265
Переопределение методов 269
10 Содержание
Наследование как средство многократного использования кода 272
Класс WeatherPrediction 272
Добавление в подкласс функций 273
Уважайте своих родителей 275
Конструкторы родительских классов 276
Деструкторы родительских классов 277
Обращение к данным родительского класса 279
Наследование ради полиморфизма 282
Возвращаясь к электронным таблицам 282
Проектирование полиморфного класса ячейки
электронной таблицы 283
Базовый класс ячеек электронной таблицы 284
Подклассы 286
Усиление полиморфизма 288
Размышления о будущем 289
Множественное наследование 291
Наследование нескольких классов 291
Коллизии имен и неоднозначные базовые классы 292
Неоднозначные базовые классы 294
"Подводные рифы" наследования 296
Изменение характеристик переопределенного метода 296
Специальные случаи в переопределении методов 300
Конструкторы копии и оператор равенства 306
Правда о ключевом слове virtual 307
Динамические возможности преобразования типов и получения
информации о типе 311
Неоткрытое наследование 312
Виртуальные базовые классы 313
Резюме 314
Глава 11. Пишем обобщенный код с помощью шаблонов 315
Представление о шаблонах 316
Шаблоны классов 317
Построение шаблона класса 317
Как компилятор обрабатывает шаблоны 325
Распределение кода шаблона между файлами 326
Шаблонные параметры 327
Шаблоны методов 330
Специализация шаблонных классов 336
Построение подклассов шаблонных классов 340
Наследование в сравнении со специализацией 341
Шаблоны функций 341
Специализация шаблонов функций 342
Перегрузка шаблонов функций 343
Шаблоны функций-"друзей" в шаблонах классов 344
Расширенные шаблоны 345
Подробнее о шаблонных параметрах 346
Частичная специализация шаблонного класса 355
Содержание 11
Эмуляция частичной специализации функций
механизмом перегрузки 860
Применение рекурсии к шаблонам 362
Резюме 370
Глава 12. Причуды и странности C++ 371
Ссылки 372
Ссылочные переменные 372
Ссылочные члены данных 374
Параметры-ссылки 874
Использование ссылок в качестве значений, возвращаемых
функциями или методами 376
Выбор между ссылками и указателями 376
Некоторые особенности ключевых слов 379
Ключевое слово const 379
Ключевое слово static 382
Порядок инициализации нелокальных переменных 385
Типы и использование операций приведения к типу 386
Использование ключевого слова typedef 386
Использование операций приведения к типу 388
Разрешение контекста 892
Заголовочные файлы 393
Утилиты языка С 394
Списки аргументов переменной длины 394
Макроопределения препроцессора 396
Резюме 397
Часть III. Освоение суперсредств C++ 399
Глава 13. Эффективное управление памятью 400
Работа с динамической памятью 401
Как представить себе память 401
Выделение памяти и ее освобождение 403
Массивы 405
Работа с указателями 412
Дуализм массивов и указателей 414
Массивы — это те же указатели! 415
Не все указатели являются массивами! 416
Динамическая обработка строк 417
Строки в стиле языка С 417
Строковые литералы 418
С++-класс string 419
Низкоуровневые операции по управлению памятью 421
Арифметика указателей 422
Индивидуальное управление памятью 422
"Сбор мусора" 423
Накопители объектов 424
, Указатели на функции 424
12 Содержание
Распространенные ошибки при управлении памятью 426
Выделение недостаточного объема памяти для строк 426
Утечка памяти 427
Двойное удаление и использование некорректных указателей 431
Доступ к "заграничной" памяти 431
Резюме 432
Глава 14. Использование С++-потоков ввода-вывода 433
Откуда взялись эти потоки 434
Что такое поток 434
Входные и выходные потоки 434
Вывод данных с помощью потоков 435
Ввод данных с использованием потоков 439
Ввод и вывод объектов 444
Строковые потоки 446
Файловые потоки 447
Использование методов seek() и tell() 448
Связывание потоков 450
Двунаправленные потоки ввода-вывода 451
Локализация 452
"Широкие" символы 453
Использование стандарта Unicode • 453
Местная специфика и аспекты локализации 454
Резюме 456
Глава 15. Обработка ошибок 457
Ошибки и исключения 458
Что такое исключения 458
Почему поддержка исключений в C++ — это "плюс" 459
Почему поддержка исключений в C++ — это "минус" 460
Наши рекомендации 461
Механизм исключений 461
Генерирование и перехват исключений 462
Типы исключений 463
Генерирование и перехват множественных исключений 465
Неперехваченные исключения 468
Списки типов генерируемых исключений 469
Исключения и полиморфизм 474
Иерархия стандартных классов исключений 474
Перехват исключений в иерархии классов 475
Создание собственных классов исключений 477
"Раскручивание" и очистка стека 480
Перехват, очистка и повторное генерирование исключений 482
Использование интеллектуальных указателей 482
Распространенные проблемы обработки ошибок 483
Ошибки, связанные с распределением памяти 483
Ошибки в конструкторах 486
Ошибки в деструкторах 487
Содержание 13
Теперь соберем все в одну кучу 488
Резюме 490
Часть IV. Как создать код без ошибок 491
Глава 16. Перегрузка С++-операторов 492
Понятие о перегрузке операторов 493
Зачем перегружать операторы 493
Ограничения для перегрузки операторов 494
Рассмотрение альтернатив при перегрузке операторов 494
Операторы, не подлежащие перегрузке 497
Резюме о перегружаемых операторах 497
Перегрузка арифметических операторов 500
Перегрузка унарных операторов "минус" и "плюс" 500
Перегрузка операторов инкремента и декремента 501
Перегрузка поразрядных и бинарных логических операторов 503
Перегрузка операторов ввода-вывода данных 503
Перегрузка оператора индексации 505
Обеспечение с помощью оператора operator [] доступа "только
для чтения" 508
Использование для массивов нецелочисленных индексов 510
Перегрузка оператора вызова функций 510
Перегрузка операторов разыменования 512
Реализация оператора operator* 514
Реализация оператора operator-> 514
Что это за operators*? 515
Создание операторов преобразования 516
Проблемы неоднозначности при использовании операторов
преобразования 517
Преобразования для булевых выражений 518
Перегрузка операторов выделения и освобождения памяти 520
Как в действительности работают операторы new и delete 521
Перегрузка операторов new и delete 522
Перегрузка операторов operator new и operator delete
с дополнительными параметрами 525
Резюме 527
Глава 17. Создание эффективных С++-программ 528
Немного о производительности и эффективности 529
Два способа достижения эффективности 529
Два вида программ 529
Разве C++ — не эффективный язык программирования? 530
Эффективность на уровне языка 531
Эффективная обработка объектов 531
Не злоупотребляйте дорогостоящими языковыми средствами 535
Использование встраиваемых методов и функций 536
Как позаботиться об эффективности на уровне проектирования 536
Кеш как самое эффективное средство 537
14 Содержание
Использование пула (накопителя) объектов 588
Использование пула потоков 543
Протоколирование программ 543
Пример протоколирования программы с помощью
средства gprof 544
Резюме 553
Глава 18. Разработка межплатформенных приложений 554
Межплатформенная разработка 555
Проблемы архитектуры 555
Проблемы реализации 558
Средства языка, зависящие от платформы 559
Использование в разработке нескольких языков программирования 560
Смешанное использование языков С и C++ 561
Смещение парадигм 561
Компоновка с С-кодом 564
Смешанное выполнение Java- и С++-кода с помощью
JNI-интерфейса 565
Объединение C++ с языком Perl и сценариями для оболочки 568
Совместное выполнение C++ и языка ассемблера 571
Резюме 572
Глава 19. Становимся экспертами в области тестирования программ 573
Контроль качества ' , 574
Кто отвечает за тестирование 574
Жизненный цикл ошибок 574
Средства отслеживания ошибок 575
Блочное тестирование 577
Методы поэлементного тестирования 578
Процесс поэлементного тестирования 579
Поэлементное тестирование в действии 583
Тестирование более высокого уровня 592
Комплексные испытания 592
Системные тесты 594
Регрессивные тесты 594
Рекомендации по успешному тестированию 595
Резюме 596
Глава 20. Что нужно знать об отладке 597
Основной Закон отладки 598
Систематика ошибок 598
Как избежать попадания ошибок в код 598
Планирование работы над ошибками 599
Регистрация ошибок 599
Трассировка программы 600
Использование макросов assert 612
Методы отладки 613
Репродуцирование ошибок , 613
Отладка воспроизводимых ошибок 614
Содержание 15
Отладка невоспроизводимых ошибок 615
Отладка ошибок, связанных с управлением памятью 616
Отладка многопоточных программ 620
Пример отладки: поиск цитат 620
Делаем выводы 631
Резюме 632
Часть V. Использование библиотек и шаблонов 633
Глава 21. Библиотека STL: контейнеры и итераторы 634
Обзор контейнеров 635
Исключения и контроль за ошибками 637
Итераторы 637
Последовательные контейнеры 639
Вектор 640
Специализация vector<bool> 658
Очередь с двусторонним доступом (дек) 659
Список 659
Контейнеры-адаптеры 663
Очередь 663
Очередь по приоритету 666
Стек 669
Ассоциативные контейнеры 670
Вспомогательный класс pair 670
Отображение 672
Мультиотображение 680
Множество 683
Мультимножество 686
Другие контейнеры 686
Массивы как STL-контейнеры 686
Строки как STL-контейнеры 687
Потоки как STL-контейнеры 688
Битовое множество 688
Резюме 693
Глава 22. Освоение STL-алгоритмов и функциональных объектов 694
Обзор алгоритмов 695
Алгоритмы find() и find_if() 696,
Алгоритм accumulate() 698
Функциональные объекты 699
Функциональные объекты арифметических операторов 699
Функциональные объекты операторов сравнения 700
Логические функциональные объекты 702
Адаптеры функциональных объектов 702
Создание собственных функциональных объектов 706
Алгоритмы в деталях 707
Вспомогательные алгоритмы 708
Немодифицирующие алгоритмы 709
16 Содержание
Модифицирующие алгоритмы 714
Алгоритмы сортировки 719
Алгоритмы выполнения операций над множествами 722
Пример использования алгоритмов и функциональных объектов:
проверка регистрации участников голосования 724
Постановка задачи проверки регистрации участников
голосования 724
Функция auditVoterRolls() 724
Функция getDuplicates() 725
Функтор RerrioveNames 726
Функтор NamelnList 727
Тестирование функции auditVoterRolls() 728
Резюме 729
Глава 23. Использование и расширение возможностей STL 731
Распределители памяти 732
Итераторные адаптеры 732
Реверсивные итераторы 733
Потоковые итераторы 734
Итераторы вставки 735
Расширение библиотеки STL 737
Зачем расширять библиотеку STL 737
Написание STL-алгоритма 737
Написание STL-контейнера 739
Резюме 770
Глава 24. Исследование распределенных объектов 772
В чем притягательность распределенных вычислений 772
Распределение ради расширяемости 773
Распределение ради надежности 774
Распределение ради центрированности 774
Распределенное содержимое 774
Сравнение распределенного приложения с сетевым 775
Распределенные объекты 776
Сериализация и маршалинг 776
Удаленные вызовы процедур 780
Архитектура CORBA 782
Язык определения интерфейсов (IDL) 782
Реализация класса 785
ЯзыкХМЬ 789
Ускоренный курс по XML 789
Использование языка XML в качестве технологии
распределенных объектов 792
Формирование и анализ XML-кода в C++ 793
Аттестация языка XML 801
Построение распределенных объектов с помощью языка XML 803
Протокол SOAP (Simple Object Access Protocol) 806
Резюме 808
Содержание 17
Глава 25. Объединим возможности технологий и оболочек 809
"Я все время забываю, как..." 810
.. создать класс 810
.. вывести подкласс из существующего класса 811
,. сгенерировать и перехватить исключения 812
.. считывать данные из файла 813
... записать данные в файл 813
... создать шаблонный класс 814
Должно быть, есть способ получше 815
Интеллектуальные указатели, выполняющие подсчет ссылок 816
Метод двойной диспетчеризации 821
Смешанные классы 827
Объектно-ориентированные оболочки 830
Работа с оболочками 830
Парадигма "модель-представление-контроллер" 831
Резюме 832
Глава 26. Применение шаблонов проектирования 833
Одноэлементное множество 834
Пример: механизм регистрации 834
Реализация одноэлементного множества 835
Использование одноэлементного множества 839
Фабрика объектов 840
Пример: имитация автомобильного производства 840
Реализация фабрики объектов 842
Использование фабрики объектов 845
Другие ситуации для использования фабрики объектов 846
Шаблон посредника 846
Пример: сокрытие проблем сетевого подключения 847
Реализация шаблона посредника 847
Использование proxy-шаблона 848
Шаблон адаптера 848
Пример: адаптация библиотеки XML 849
Реализация адаптера 849
Использование адаптера 853
Шаблон дизайнера 853
Пример: определение стилей в Web-страницах 854
Реализация шаблона дизайнера 854
Использование шаблона дизайнера 856
Шаблон цепочки ответственности 857
Пример: обработка событий 857
Реализация шаблона цепочки ответственности 858
Использование цепочки ответственности 859
Шаблон наблюдателя 859
Пример: обработка событий 859
Реализация наблюдателя 859
Использование наблюдателя 861
Резюме 862
18 Содержание
Часть VI. Приложения 863
Приложение А. Готовимся к С++-интервью 864
Глава 1: краткий курс C++ 865
О чем не следует забывать 865
Типы вопросов 865
Глава 2: разработка профессиональных С++-программ 866
О чем не следует забывать 866
Типы вопросов 866
Глава 3: проектирование с использованием объектов 866
О чем не следует забывать 866
Типы вопросов 867
Глава 4: проектирование с использованием библиотек и шаблонов 867
О чем не следует забывать 868
Типы вопросов 868
Глава 5: проектирование с целью многократного использования кода 868
О чем не следует забывать 869
Типы вопросов 869
Глава 6: использование эффективных методов разработки
программного обеспечения 869
О чем не следует забывать 869
Типы вопросов 869
Глава 7: кодируем стильно 870
О чем не следует забывать 870
Типы вопросов 870
Главы 8 и 9: классы и объекты 871
О чем не следует забывать 871
Типы вопросов 871
Глава 10: осваиваем механизм наследования 874
О чем не следует забывать 874
Типы вопросов 874
Глава 11: пишем обобщенный код с помощью шаблонов 875
О чем не следует забывать 875
Типы вопросов 875
Глава 12: причуды и странности C++ 876
О чем не следует забывать 876
Типы вопросов 876
Глава 13: эффективное управление памятью 877
О чем не следует забывать 877
Типы вопросов 877
Глава 14: использование С++-потоков ввода-вывода 877
О чем не следует забывать 878
Типы вопросов 878
Глава 15: обработка ошибок 878
О чем не следует забывать 878
Типы вопросов 879
Глава 16: перегрузка С++-операторов 879
О чем не следует забывать 879
Содержание 19
Типы вопросов 879
Глава 17: создание эффективных С++-программ 880
О чем не следует забывать 880
Типы вопросов 880
Глава 18: разработка межплатформенных приложений 881
О чем не следует забывать 881
Типы вопросов 881
Глава 19: становимся экспертами в области тестирования программ 881
О чем не следует забывать 881
Типы вопросов 882
Глава 20: что нужно знать об отладке 882
О чем не следует забывать 882
Типы вопросов 882
Главы 21, 22 и 23: стандартная библиотека шаблонов 882
О чем не следует забывать 883
Типы вопросов 883
Глава 24: исследование распределенных объектов 883
О чем не следует забывать 883
Типы вопросов 883
Глава 25: объединим возможности технологий и оболочек 884
Глава 26: применение шаблонов проектирования 884
О чем не следует забывать 884
Типы вопросов 884
Приложение Б. Аннотированная библиография 885
C++ 885
Начальный курс по C++ 885
Общий курс по C++ 886
Потоки ввода-вывода 887
Стандартная библиотека C++ 888
С++-шаблоны 888
Язык С 888
Интеграция C++ и других языков программирования 889
Алгоритмы и структуры данных 889
Открытые программные средства 889
Методология разработки программного обеспечения 890
Стиль программирования 891
Архитектура вычислительных систем 891
Эффективность 891
Тестирование 892
Отладка 892
Распределенные объекты 892
CORBA 892
XMLhSOAP ' 893
Шаблоны проектирования 893
Предметный указатель
894
Введение
В течение многих лет язык C++ использовался для написания быстродействующих
и эффективных объектно-ориентированных программ профессионального уровня.
Удивительно, но при всей своей популярности C++ довольно сложен для понимания
в полном объеме. В учебниках традиционного плана, как правило, не описываются
относительно простые и в то же время мощные методы, используемые
профессионалами, и поэтому многие полезные разделы C++ остаются-"тайной за семью печатями"
даже для С++-программистов с опытом.
Зачастую в учебных пособиях по программированию основное внимание
уделяется синтаксису языка, а не его применению для решения реальных задач. Нередко
глава типичного самоучителя по C++ содержит пространное описание некоторого
раздела синтаксиса и небольшой иллюстративный пример. Книга C++ для профессионалов
построена по-другому. Ее авторы отказались от простого изложения основных
элементов языка, "приправленного" вырванными из реального контекста
демонстрационными "опусами". Цель этой книги — показать читателю по-настоящему "классный"
уровень использования C++ в реальном мире и познакомить его с малоизвестными
средствами, которые действительно могут облегчить его жизнь как программиста.
С помощью этой книги вы научитесь создавать многократно используемые
компоненты, а ведь это как раз то, что отличает новичка от профессионала.
Для кого написана эта книга
Даже если вы пользуетесь языком C++ в течение многих лет, не исключено, что за
это время вам так и не удалось познакомиться с некоторыми его возможностями.
Вероятно, вам уже приходилось писать компонентный код, но вы бы хотели больше
узнать о С++-проектах и улучшить свой стиль программирования. Если вы
относительно недавно в С++-программировании, то вам будет весьма полезно научиться
"правильно" программировать с самого начала. Эта книга поможет поднять ваши С++-
навыки на профессиональный уровень.
Поскольку в этой книге основное внимание уделяется переводу программиста
базового или среднего звена на уровень профессионала, она предполагает определенные
знания языка C++. Главу 1 можно рассматривать как памятку, поскольку она содержит
описание основных элементов C++. Их знание абсолютно необходимо для тех, кто в C++
делает пока только первые шаги, но имеет значительный опыт написания программ на
С. В любом случае вы должнвд опираться на твердый фундамент, состоящий из
основных понятий программирования. Это означает, что вам необходимо свободно
оперировать циклами, функциями и переменными. Вы должны знать, как структурировать
программу, и уметь пользоваться такими средствами, как рекурсия. Вам следует обладать
определенными знаниями в области структурирования данных (т.е. иметь представление
Введение 21
о хеш-таблицах и очередях) и некоторым опытом в использовании таких алгоритмов,
как сортировка и поиск. Не беда, если вы пока не знаете, в чем заключается суть объектно-
ориентированного программирования, — об этом вам поведает глава 3.
Вам также нужно быть на "ты" с компилятором C++. Эта книга не содержит
инструкций по использованию отдельных компиляторов. В случае затруднений вам
придется обратиться к соответствующей документации.
Что можно найти в этой книге
Книга C++ для профессионалов описывает такой подход к С++-программированию,
который позволит повысить качество ваших программ в частности и эффективность
программирования в целом. Здесь делается акцент не просто на возможностях языка
C++, а на методологии программирования, образцах проектирования многократно
используемого кода и хорошем стиле программирования. Под методологией, которая
предлагается в книге C++ для профессионалов, понимается полный процесс разработки
программного обеспечения (ПО) — от проектирования и написания кода до
тестирования, отладки и работы в группах. Этот подход позволит досконально изучить язык
C++, а также воспользоваться преимуществами его мощных средств разработки
крупномасштабных проектов ПО.
Представьте такого себе теоретика, который изучил все элементы синтаксиса C++, не
рассмотрев при этом ни единого примера их использования. О таком "программисте"
можно сказать, что он знает вполне достаточно, чтобы быть опасным для окружающих!
Не изучив практических примеров, он может предположить, что весь программный код
должен содержаться в функции main (), или что все переменные должны быть
глобальными — такая практика не считается признаком хорошего программирования.
Профессионал знает не только синтаксис C++, но и наилучший способ его
использования. Он признает важность качественного проектирования и соблюдения
принципов объектно-ориентированного программирования, а также знает, как лучше
всего использовать существующие библиотеки. У него всегда есть "за душой"
собственный арсенал программных заготовок "особой полезности" и идей создания
многократно используемого кода.
Освоив эту книгу, вы станете профессиональным С++-программистом. Вы
непременно расширите свои знания C++, поскольку в ваш актив добавятся недооцененные
или непонятые прежде средства. По достоинству оценив полученные с нашей помощью
навыки объектно-ориентированного проектирования, вы овладеете первоклассными
методами отладки программ. Но главное то, что к концу этой книги вы будете
вооружены богатым арсеналом идей, которые пригодятся вам в реальном "ежедневном"
программировании.
Существует множество причин, по которым имеет смысл приложить все усилия к тому,
чтобы стать профессионалом, а не оставаться программистом, который "знает C++".
Понимание истинных возможностей языка заметно улучшит качество вашего кода. Знание
различных методологий и процессов программирования поможет вам эффективнее
работать в команде. Применение библиотек многократно используемых функций и
шаблонов повысит эффективность повседневного программирования и позволит избежать
изобретения колеса. Все эти уроки сделают вас программистом более высокого уровня
и более ценным специалистом. Несмотря на то что эта книга не гарантирует вам
продвижения по службе, но если оно произойдет, вы ведь не будете на нас в обиде?
22 Введение
Структура книги
Эта книга состоит из шести частей.
Часть I, "Введение в профессиональное С++-проектирование", начинается с
краткого обзора основных элементов C++. Затем читателю предлагается рассмотреть
методологии проектирования С++-программ. Прочитав о методологии объектно-
ориентированного программирования, использовании библиотек и шаблонов,
преимуществах создания многократно используемого кода, а также о технологиях,
применяемых организациями, занимающимися программированием, вы поймете, насколько
важен этап проектирования.
В части П, "Пишем С++-код профессионально", читателю предлагается посмотреть
на язык C++ с точки зрения профессионала. Здесь вы узнаете о том, как писать
удобочитаемый С++-код, создавать многократно используемые классы и с максимальной
эффективностью применять такие важные средства языка, как наследование и шаблоны.
В части III, "Освоение суперсредств C++", показано, как "выжать" из C++ "все соки".
Здесь вы узнаете многие тайны C++ и научитесь использовать наиболее интересные
средства этого языка (речь идет об эффективных способах управления памятью,
методах ввода-вывода данных, обработке ошибок, перегрузке операторов, возможности
написания эффективного С++-кода и так называемого кросс-платформенного кода,
который будет работать в среде разнородных процессоров и разных операционных систем).
В части IV, "Как создать код без ошибок", основное внимание уделяется созданию
высококачественного программного обеспечения. Здесь вы прочитаете о видах
тестирования ПО: помодульном, или блочном, регрессивном (с возвратом от более
сложных тестов к простым), а также о методах отладки С++-программ.
Часть V, "Использование библиотек и шаблонов", содержит информацию,
которая позволит писать более эффективный код при меньших затратах труда. Вы
прочитаете о применении стандартной библиотеки C++ и о ее расширении, а также о
распределенных объектах, методах разработки многократно используемых компонентов
C++ и концептуальных объектно-ориентированных шаблонах.
Книгу завершает краткий путеводитель по главам, который поможет вам успешно
пройти С++-интервью с потенциальным работодателем (часть VI), а на сайте
издательства (его адрес: www.wrox.com) вы найдете практический справочник по
стандартной библиотеке C++.
Что нужно для работы с этой книгой
Для работы с этой книгой достаточно иметь компьютер с установленным
компилятором C++. Несмотря на то что компиляторы могут по-разному интерпретировать
язык, в этой книге в основном используются стандартизованные части C++.
Приведенные здесь программы были протестированы под управлением таких
операционных систем, как Windows, Solaris и Linux.
Соглашения, используемые в книге
Для того чтобы вы могли получить максимальную пользу от этой книги, мы
используем здесь следующие элементы оформления.
€
Введение 23
i ~, ~:l '• И
■ ■ ■ « н ■■ „: ; и
Советы, рекомендации, приемы программирования и примечания выделяются курсивом.
В тексте мы используем также следующие стилевые изменения:
Q для выделения важных слов применяется курсив;
Q сочетания клавиш отображаются так: <Ctrl+A>;
Q имена файлов, URL-адреса и фрагменты кода в тексте отмечены таким стилем:
monkey. срр;
□ программный код в этой книге представлен двумя различными способами:
На сером фоне мы выделяем код, на который следует обратить внимание в силу его
новизны.
Без фона представляется код, который не столь важен в данном контексте или уже
демонстрировался ранее.
Исходный код
Работая с примерами, приведенными в этой книге, вы можете либо вводить их
вручную, либо использовать исходный код, который можно загрузить с сайта по
адресу www. wrox. com. Для загрузки кода с сайта достаточно найти на нем название этой
книги (либо с помощью поискового средства (Search), либо в одном из списков книг)
и щелкнуть на ссылке Download Code на странице, посвященной данной книге.
Поскольку многие книги имеют похожие названия, проще всего их найти по ISBN-коду;
данной книге (в оригинальном издании) присвоен код 0-7645-7484-1.
Загрузив код, распакуйте его любым средством распаковки. Можно также перейти на
основную страницу Wrox (ее адрес: www.wrox.com/dynamic/books/download.aspx),
которая позволяет загрузить код, приведенный в этой книге (и других книгах
издательства Wrox).
Сигнализируйте об ошибках
Мы приложили все усилия, чтобы текст этой книги (равно как и исходный код
примеров) не содержал ошибок. Но если вы найдете ошибку (ведь никто в этом мире
не совершенен), пожалуйста, сообщите нам о ней. Мы будем вам за это весьма
признательны. Тем самым вы избавите других читателей от разочарования и поможете
нам повысить качество работы.
Для сообщения о найденной ошибке зайдите на сайт (www.wrox.com), найдите на
нем название этой книги (либо с помощью поискового средства (Search), либо в одном
из списков книг) и щелкните на ссылке Book Errata. На этой странице можно
просмотреть все сообщения от редакторов Wrox относительно этой книги. Полный список книг
также доступен по адресу: www. wrox. com/misc-pages/booklist. shtml.
24 Введение
Не обнаружив "свою" ошибку на странице Book Errata, перейдите, пожалуйста, на
страницу с адресом www.wrox.com/contact/techsupport.shtml и заполните
предложенную там форму. Мы обязательно проверим эту информацию, в случае
подтверждения вашей правоты поместим соответствующее сообщение на "страницу
ошибок" и исправим свой недочет в последующих изданиях этой книги.
Форумы
Для участия в дискуссиях заходите на Р2Р-форумы по адресу: р2р. wrox. com. Эти
форумы представляют собой Web-ориентированные системы для пересылки
сообщений относительно книг издательства Wrox и рассматриваемых в них технологий,
а также для общения с другими читателями и пользователями компьютерных
технологий. На этих форумах предлагается средство подписки, которое позволит сообщать
вам по электронной почте темы дискуссий. Участниками форумов являются авторы
книг, редакторы издательства Wrox, эксперты, и вы, наши дорогие читатели.
По адресу http://p2p.wrox.com вы найдете ряд различных форумов, которые
помогут вам не только при чтении этой книги, но и во время разработки
собственного приложения. Для участия в форумах достаточно выполнить следующие действия.
1. Зайти на сайт по адресу: р2р. wrox. com и щелкнуть на ссылке Register.
2. Ознакомиться с условиями и щелкнуть на ссылке Agree.
3. Ввести требуемую (при желании и дополнительную) информацию и щелкнуть
на ссылке Submit.
4. По электронной почте вы должны получить сообщение, в котором описано, как
удостоверить свою учетную запись и завершить процесс присоединения к форуму.
Читать сообщения с форумов можно и без присоединения к Р2Р, но для отправки
собственных сообщений присоединение необходимо.
Присоединившись, вы можете отсылать на форумы новые сообщения и отвечать
на сообщения других участников. Чтение Web-информации возможно в любое время.
Если бы вы хотели с конкретного форума получать новые сообщения по электронной
почте, щелкните на пиктограмме Subscribe to this Forum, выбрав название форума
в соответствующем списке.
Дополнительную информацию об использовании форумов Wrox P2P можно
получить в разделе вопросов и ответов (Р2Р FAQ). Здесь также можно узнать о том, как
работает программное обеспечение форумов, и получить ответы на вопросы о книгах
издательства Wrox. Чтобы попасть в раздел FAQ, щелкните на ссылке FAQ любой
страницы Р2Р-сайта.
1
Посвящение
Соне за ее безоговорочную любовь и поддержку и моему сыну Каю, который часто
отрывал меня от работы, напоминая, что в жизни важнее.
— Николас А. Солтер
Марни, чья непредсказуемая притягательность наполняла радостью каждый мой день.
— Скотт Дж. Клепер
Благодарности
Мы в долгу перед многими людьми, кто сделал эту книгу реальностью. Хотим
поблагодарить Дэвида Фагейта (David Fugate) из компании Waterside Productions за все
его советы и рекомендации и Роберта Эллиота (Robert Elliot) из издательства Wiley за
то, что он дал двум неизвестным авторам возможность рассказать повесть о C++ на
новый лад. Эта книга еще долго бы не увидела свет без помощи редактора проекта Адаоби
Оби Тултон (Adaobi Obi Tulton). Выражаем благодарность также Катрин Малм Бургон
(Kathryn Malm Bourgoine) за ее редакторскую работу. Фотография на обложке, на
которой искусно скрыта наша нефотогеничность, была сделана Адамом Toy (Adam Tow).
Мы также хотим выразить признательность всем коллегам и учителям, которые
благоприятствовали нашему развитию в правильном направлении в течение многих лет.
В частности, говорим огромное спасибо Майку Хэнсону (Mike Hanson), Мэгги Джонсон
(Maggie Johnson), Адаму Нэшу (Adam Nash), Нику Паленту (Nick Parlante), Бобу Плам-
меру (Bob Plummer), Эрику Робертсу (Eric Roberts), Мехрану Сахами (Mehran Sahami),
Биллу Уолкеру (Bill Walker), Дэну Волковски (Dan Walkowski), Патрику Янгу (Patrick
Young) и Джулии Зеленски (Julie Zelenski). Мы будем вечно благодарны Джерри Кейну
(Jerry Cain), который не только научил нас в свое время программировать на C++, но
также выполнял роль технического редактора, скрупулезно анализирующего код,
приведенный в этой книге, как если бы это был один из наших последних экзаменов.
Сердечно благодарим также всех, кто рецензировал одну или несколько глав: Роба
Бесмана (Rob Baesman), Аарона Брэдли (Aaron Bradley), Элейн Чонг (Elaine Cheung),
Марни Клепер (Marni Kleper), Толи Кузнец (Toli Kuznets), Акшея Рангнекара (Akshay
Rangnekar), Элтефаата Шокри (Eltefaat Shokri), Алета Солтера (Aletha Solter), Кена Со-
лтера (Ken Solter) и Соню Солтер (Sonja Solter). Все оставшиеся в книге ошибки,
конечно же, на нашей совести. Мы признательны нашим семьям за их терпение и поддержку.
Наконец, мы бы хотели поблагодарить наших читателей за проверку нашего
подхода к профессиональной С++-разработке.
Об авторах
Николас А Солтер (Nicholas A Solter) изучал теорию вычислительных систем в Стэн-
фордском университете (Stanford University), где получил дипломы бакалавра и
магистра наук. Будучи студентом, он уже работал ассистентом преподавателя по
нескольким предметам (от вводного в теорию вычислительных систем для первокурсников до
разработки программного обеспечения для студентов старших курсов).
Ныне как специалист по программному обеспечению компании Sun Microsystems Ник
программирует (в основном, на С и C++) системы с высоким коэффициентом готовно-
сти. До этого он получил опыт работы в индустрии компьютерных игр. В компании
Digital Media International он занимал должность ведущего программиста по
мультимедийной образовательной игре "The Land Before Time Math Adventure". Во время
учебы в интернатуре (Electronic Arts) он участвовал в разработке средства
редактирования компьютерного поля для игры в гольф Course Architect 2000, используемого для
игры Tiger Woods PGA Tour 2000.
Помимо опыта работы на производстве, Ник в течение одного года преподавал
программирование на C++ в качестве адъюнкт-профессора на кафедре теории
вычислительных систем в Фаллертонском колледже (Fullerton College). В свободное от
работы время Ник любит читать, играть в баскетбол, проводить время в кругу семьи.
Скотт Дж. Клепер (Scott J. Kleper) начал свою карьеру программиста еще в школе,
создавая приключенческие игры на языке BASIC для компьютера Tandy TRS-80. В
средней школе Скотт перешел к языкам более высокого уровня и выпустил несколько
приложений в виде испытательных версий, которые удостоились специальных наград.
Окончив Стэнфордский университет, Скотт получил дипломы бакалавра и
магистра наук в области теории вычислительных машин и систем, специализируясь на теме
человеко-машинного взаимодействия. Еще учась в колледже, Скотт работал
ассистентом преподавателя в классах, где читались такие предметы, как введение в
программирование, объектно-ориентированное проектирование, структуры данных, GUI-
оболочки, групповые проекты и Internet-программирование.
По окончании университета Скотт работал ведущим инженером в нескольких
компаниях, а в настоящее время занимает должность старшего специалиста по
программному обеспечению в компании Reactivity, Inc. Во внерабочее время Скотт замечен в
роли маниакального Internet-покупателя, страстного читателя и фанатичного гитариста.
Ждем ваших отзывов!
Вы, уважаемый читатель, и есть главный критик этой книги. Мы ценим ваше
мнение и хотим знать, что было сделано нами правильно, что можно было сделать лучше
и что еще вы хотели бы увидеть изданным нами. Нам интересно услышать и любые
другие замечания, которые вам хотелось бы высказать в наш адрес.
Мы ждем ваших комментариев и надеемся на них. Вы можете прислать нам
бумажное или электронное письмо либо посетить наш Web-сервер и оставить там свои
замечания. Одним словом, любым удобным для вас способом дайте нам знать, нравится ли
вам эта книга, а также выскажите свое мнение о том, как сделать наши книги более
интересными для вас.
Посылая письмо или сообщение, не забудьте указать название книги и ее авторов,
а также ваш обратный адрес. Мы внимательно ознакомимся с вашим мнением и
обязательно учтем его при отборе и подготовке к изданию последующих книг. Наши
координаты:
E-mail: inf o@dialek.tika. com
WWW: http://www.dialektika.com
Адреса для писем:
из России: 115419, Москва, а/я 783
из Украины: 03150, Киев, а/я 152
Часть I
Введение
в профессиональное С++-
проектирование
в этой части...
Глава 1. Краткий курс C++
Глава 2. Разработка профессиональных С++-программ
Глава 3. Проектирование с использованием объектов
Глава 4. Проектирование с использованием библиотек
и шаблонов
Глава 5. Проектирование с целью многократного
использования кода
Глава 6. Использование эффективных методов разработки
программного обеспечения
Краткий курс C++
Эта глава содержит краткий обзор самых важных разделов C++, знание которых
позволит читателю освоить остальную часть книги. Однако не следует рассматривать
содержимое этой главы как исчерпывающий урок по языку программирования C++.
Здесь вы не найдете ответов на такие элементарные вопросы, как "Что представляет
собой С++-программа?" или "В чем заключается разница между операторами "="
и "=="?". Мы не посчитали нужным включать сюда понятные лишь "посвященным"
описания назначения таких ключевых слов, как union или volatile. He нашли здесь
также своего отражения те разделы языка С, которые не существенны для C++, при
этом некоторые темы C++ подробно рассматриваются в других главах книги.
Таким образом, в эту главу вошли лишь те разделы C++, с которыми
программисты встречаются практически ежедневно. Если вы временно не имели "близких
отношений" с C++ и, к примеру, подзабыли синтаксис записи цикла for, то с помощью
этой главы вы легко восполните такой пробел. Если вы только начинаете переходить
от С к C++ и не понимаете, что такое ссылочная переменная, эта глава вам также
покажется весьма полезной.
Если вы уже приобрели значительный опыт в C++, вам все же стоит бегло
просмотреть содержимое этой главы, чтобы убедиться в том, что не существует таких
разделов языка, которые нужно было бы освежить в памяти. Если вы лишь приступаете
к изучению C++, то вам просто необходимо внимательнейшим образом прочитать эту
главу и приобрести уверенность в том, что вы хорошо понимаете приведенные здесь
примеры. Если вы почувствуете необходимость в дополнительной информации
вводного характера, обратитесь к списку литературы, приведенному в приложении Б.
Глава 1. Краткий курс C++ 29
Основы C++
Язык C++ часто называют "улучшенным С" или "супермножеством С". При
разработке C++ были "сглажены" многие "острые углы" языка С. Поскольку C++ построен
на фундаменте С, то, если вы — опытный С-программист, большинство
синтаксических записей этого раздела вам покажутся очень знакомыми. Тем не менее эти два
языка имеют существенные различия. В подтверждение этого достаточно сказать, что
труд создателя C++ Бьерна Страуструпа (Bjarne Stroustrup) The C++ Programming
Language занимает 911 страницы, в то время как книга Кернигана (Kernighan) и Ритчи
(Ritchie) The С Programming Language— только 274. Поэтому, если вы — С-программист,
особенно внимательно отнеситесь к новому или незнакомому для вас синтаксису!
Ну, как же обойтись без "Привет, мир!"
Для начала мы не можем не включить, несмотря на всю его популярность,
следующий код, который представляет собой простейшую С++-программу.
// helloworld.cpp
#include <iostream>
iat «aintint argc, char** argv)
{
etd::Cout « "Привет, мир!" << std::endl,-
return 0,-
J
Нетрудно предположить, что при выполнении этого кода на экран выводится
сообщение Привет, мир!. Это — очень простая программа, которая вряд ли
претендует на определение "интересная", тем не менее в ней представлены важные элементы,
по которым можно судить о формате С++-программы.
Комментарии
Первая строка программы представляет собой комментарий, т.е. сообщение,
которое существует только для программиста и игнорируется компилятором. В C++
предусмотрено два способа определить комментарий. В предыдущем примере две косые
черты означают, что весь следующий за ними остаток строки является комментарием.
// helloworld.cpp
Аналогичного поведения (вернее сказать, отсутствия какого-либо поведения)
можно было бы достичь с использованием С-комментария (т.е. комментария в стиле
языка С), который также допустим в C++. С-комментарии начинаются с символов "/*"
и заканчиваются символами "*/". С-комментарии могут занимать несколько строк.
Приведенный ниже код демонстрирует С-комментарий в действии (точнее, в его
полном бездействии).
/* Это,, нногостровдый
. * гомшентарий в стиле языка С.
'.-* Компилятор полностью
* игнорирует его.
Более подробно комментарии описаны в главе 7.
30 Часть I. Введение в профессиональное С++-проектирование
Директивы препроцессора
Построение С++-программы представляет собой трехступенчатый процесс.
Сначала код "пропускается" через препроцессор, который распознает метаинформацию об
этом коде. Затем код компилируется, т.е. переводится в машиночитаемые объектные
файлы. Наконец, отдельные объектные файлы связываются, или компонуются, в
единое приложение. Директивы, предназначенные для препроцессора, начинаются
с символа "#", как в строке #include <iostream> из предыдущего примера. В
данном случае директива #include предписывает препроцессору взять все содержимое
заголовочного файла iostream и сделать его доступным для текущего файла. Самое
распространенное применение заголовочных файлов заключается в объявлении
функций, которые будут определены где-то в другом месте. Помните: объявление
сообщает компилятору, как называется функция, а определение содержит ее реальный код.
Заголовок iostream объявляет встроенные С++-средства ввода-вывода данных. Если
бы в рассмотренную выше программу он не был включен, программа была бы
неспособной выполнить единственную "порученную" ей задачу по выводу текста на экран.
В языке С включаемые в программу файлы обычно имеют
расширение .h (например, <stdio.h>). В C++ для таких стандартных
библиотечных заголовков, как <iostream>, этот суффикс опускается. Столь
"дорогие вашему сердцу" стандартные библиотечные заголовки из С по-
прежнему "живут и здравствуют" в C++, но под новыми именами.
Например, чтобы получить доступ к содержимому заголовка <stdlo.h>,
достаточно включить в свою программу заголовок <cstdio>.
Некоторые самые распространенные директивы препроцессора приведены в
следующей таблице.
Директива
препроцессора
Выполняемое
действие
Применение
^include [файл]
ftdefine [ключ]
[значение]
#ifdef [ключ]
#ifndef [ключ]
ftendif
Указанный файл
вставляется в код
вместо директивы
Каждое вхождение
заданного элемента
ключ заменяется
заданным элементом
значение
Код if def- или
if ndef -блоков условно
включается или
опускается
в зависимости от того,
определен ли заданный
элемент ключ
с помощью директивы
Udefine
Почти всегда используется для включения
заголовочных файлов, чтобы данный код мог
задействовать функциональность,
определенную где-то в другом месте
Часто используется в С для определения
константного значения или макроопределения.
В C++ предусмотрен более совершенный
механизм определения констант.
Макроопределения же содержат потенциальную опасность,
поэтому директива #def ine редко используется
в C++. (Подробности — в главе 12)
Используется, в основном, для защиты
многократных включений. Каждый
включаемый файл сначала определяет
некоторое значение и заключает остальной
код в #ifndef-flendif-блок, чтобы не
допустить его повторных включений
Глава 1. Краткий курс C++ 31
Окончание таблицы
Директива Выполняемое
препроцессора действие
Применение
#pragma Его действие отличается Поскольку применение этой директивы
при переходе от компилятора не стандартизировано, мы рекомендуем
к компилятору. Часто ее не использовать
позволяет программисту
отобразить предупреждение
или сообщение об ошибке
при обработке этой
директивы препроцессором
Функция main ()
Выполнение программы начинается с функции main (). Она возвращает значение
типа int, которое является индикатором результата выполнения программы.
Функция main () принимает два параметра: параметр argc содержит количество
аргументов, переданных программе, а параметр argv — сами эти аргументы. Обратите
внимание на то, что первым аргументом всегда является имя самой программы.
Потоки ввода-вывода
Если вы — новичок в C++, но имеете опыт работы на С, то вам, вероятно, будет
интересно узнать, что означает запись std: : cout и что "произошло" со старой
проверенной "в боях" функцией printf О .И хотя функцию printf () еще можно
вызывать в C++, гораздо лучше использовать средства ввода-вывода из арсенала
библиотеки потоков.
Потоки ввода-вывода подробно описаны в главе 14, но принципы механизма вывода
понять несложно. Представьте выходной поток в виде "путепровода" для данных. Все,
что вы "бросите" в него, будет доставлено по назначению. Запись std: : cout означает
вывод данных на пользовательскую консоль, или стандартное устройство вывода.
Существуют и другие "путепроводы", например применение записи std: :cerr
обеспечит вывод данных на консоль ошибок. Оператор "<<" загружает данные в
"путепровод". В предыдущем примере на стандартное устройство вывода отсылалась одна
заключенная в кавычки текстовая строка. Однако выходные потоки с помощью лишь
одной строки кода позволяют последовательно отправлять на выходное устройство
несколько данных различных типов. Например, при выполнении следующей строки
кода будет выведен текст, число и снова текст.
etd::cout « "Кажется, " << 219 << " я тебя люблю." « etd::endl;
Запись std: :endl представляет символ конца строки. Если выходной поток
встречает "на своем пути" символ std: : endl, он выведет все, что в него "попало" до
этого символа, и обеспечит переход на следующую строку. В качестве
альтернативного способа представления конца строки используется символ ' \п'. Этот символ
представляет собой одну из управляющих последовательностей, которая служит в качестве
символа новой строки. Управляющие символьные последовательности можно
применять в любом месте строки текста, заключенной в кавычки. Ниже перечислены самые
распространенные управляющие символы.
Q \п новая строка
Q \г возврат каретки в исходное положение
32 Часть I. Введение в профессиональное С++-проектирование
О \t табуляция
Q \ \ обратная косая черта
Q \" кавычка
Потоки можно также использовать для приема входных данных от пользователя.
Проще всего это сделать с помощью оператора ">>" и обозначения входного потока.
Для приема входных данных с клавиатуры используется запись std:: cin. Сложность
приема данных от пользователя состоит в том, что программа может "не знать", данные
какого типа он вводит. О том, как работать с входными потоками, читайте в главе 14.
Пространства имен
Пространства имен позволяют снять проблему конфликтов имен, которая может
возникнуть между различными частями кода. Например, ваша программа может
содержать функцию с именем f оо (). Но в один прекрасный день вы решаете
использовать библиотеку стороннего производителя, в которой также есть функция f оо ().
В этом случае компилятор не будет "знать", какую версию функции f оо () вы имеете
в виду, вызывая ее в своей программе. Имя библиотечной функции вы изменить не
можете, а посему вам придется (испытывая неимоверные страдания) переименовать
собственную функцию.
Пространства имен могут избавить вас от подобных ситуаций, поскольку они
позволяют определить контекст существования имен. Чтобы поместить код в некоторое
пространство имен, достаточно заключить его в соответствующий namespace-блок.
// nameepacee.h
namespace mycode {
void fоо();
}
Реализацию любого метода или функции также можно обрабатывать в рамках
пространства имен.
// namespaces.срр
#include <iostream>
#include "namespaces.h"
namespace mycode {
void foo() {
std::cout «. "Функция foo() определена "
« "в пространстве имен mycode."
« std::endl;
Поместив свою версию функции f оо () в пространство имен mycode, мы тем
самым изолируем ее от библиотечной функции foo(). Чтобы вызвать namespace-
версию функции f оо (), присоедините спереди к ее имени название этого
пространства имен (в данном случае mycode).
Mycode::fоо(); // Вызов функции fоо() в пространстве
// имен "mycode".
Глава 1. Краткий курс C++ 33
Любой код из mycode-блока может вызывать другой код из того же
"пространственного" блока без явного использования названия пространства имен в качестве
приставки. Отсутствие таких приставок делает код более четким и читабельным.
Избежать необходимости в приставках можно также с помощью директивы using. Эта
директива сообщает компилятору, что последующий код использует имена в заданном
пространстве имен. Другими словами, для последующего кода указанное
пространство имен подразумевается.
// usingnamespaces.cpp
ttinclude "namespaces. h"
using namespace mycode;
int maindnt argc» char** argv)
{
foo(); // подразумевается mycode; :foo0 ;
}
Один исходный файл может содержать несколько директив using, однако
злоупотреблять такими using-сокращениями не следует. Используя (чего только не бывает!) все
известные человечеству пространства имен, вы полностью их аннулируете! Ведь если
вы "заведете" у себя в программе два пространства имен, которые содержат одинаковые
имена, у вас снова возникнет конфликт имен. Кроме того, важно знать, в каком
пространстве имен выполняется ваш код, чтобы случайно не вызвать "не ту" версию функции.
Вы уже видели синтаксис пространств имен выше в этой главе (мы использовали его
в программе helloworld.cpp). Имена cout и endl в действительности определены
в пространстве имен std. Теперь мы можем переписать программу helloworld. cpp
с использованием директивы using.
// helloworld.cpp
iinclude <iostream>
using namespace std; ''
int maindnt argc, char** argv)
{
cout « "Привет, мир!" <« endl;
return 0;
}
Директиву using также можно использовать для обращения к отдельному имени
в пространстве имен. Например, если единственным элементом, который мы хотим
использовать в пространстве имен std, является имя cout, то обозначить его
принадлежность к std можно таким образом.
Ueing std;;cout;
В последующем коде при обращении к имени cout можно исключить приставку std,
но другие имена из пространства имен std по-прежнему должны иметь явно
заданный "ярлык".
34 Часть I. Введение в профессиональное С++-проектирование
Using std::cout;
eout « "Привез?, мир!" « etdizendl;
Переменные
В C++ переменные можно объявлять практически в любом месте программы и
использовать в текущем блоке (ниже строки, в которой они были объявлены). В рамках своей
рабочей группы вы сами должны договориться, где объявлять переменные: в начале каждой
функции или по мере необходимости. Переменные можно объявлять, не присваивая им
значения. Такие неинициализированные переменные обычно получают
"полупроизвольные" значения, зависящие от состояния памяти на тот момент времени, и зачастую
являются источником бесчисленных ошибок. И, наоборот, переменным в C++ можно
присваивать начальные значения во время их объявления. На примере следующего кода
демонстрируется как сам процесс объявления переменных, так и использование
переменных типа int, которые служат для представления целочисленных значений.
// hellovariablee.cpp
#iaelude <iostream>
using namespace std;
int maindnt argc, char** argv)
{
int uninitializedlnt,-
int initializedlnt - It
cout « uninitializedlnt « * - произвольное значение'
« endl;
cdut « initializedlnt « "' - начальное значение"
« endl,-
return (0),-
)
При выполнении этого кода в первой строке будет выведено произвольное
значение из памяти, а во второй — число 7. Этот код также демонстрирует, как можно
использовать переменные при взаимодействии с выходными потоками.
В следующей таблице перечислены наиболее распространенные типы
переменных, используемые в C++.
Тип Описание Применение
int Положительные и отрицательные int i = i ■,
целочисленные значения (диапазон
зависит от параметров компилятора)
short Короткие целочисленные значения short в = 13,-
(обычно занимают 2 байт)
long Длинные целочисленные значения long l = -7,-
(обычно занимают 4 байт) ■
unsigned int Ограничивают диапазон предыдущих типов unsigned int i =2,-
unsigned short использованием неотрицательных unsigned short s = 23,- •■
unsigned long значений (>= 0) unsigned long 1 = 5400,-
Глава 1. Краткий курс C++ 35
Окончание та&шцы
Тип Описание Применение
float Значения с плавающей точкой одинарной float f = 7.2,-
double и двойной точности double d = 7.2
char Единичный символ char ch = ' m ■ ,-
bool Значения true или false (то же самое, bool b = true,-
что не нуль или О)
В C++ строковый тип не определен в качестве базового. Но
стандартная реализация строки обеспечивается библиотечными средствами
(подробное описание см. ниже в этой главе и в главе 13).
Переменные одного типа можно приводить к другому путем выполнения специальной
операции. Например, переменную типа int можно привести к типу bool. В C++
предусмотрено три способа явного изменения типа переменной. Первый способ
унаследован из языка С и до сих пор остается самым распространенным. Второй (несмотря на
то, что он, на первый взгляд, кажется более естественным) используется довольно
редко. Третий способ при всей своей "многословности" считается наиболее ясным.
bool someBool = (bool)somelnt; // способ 1
bool someBool = bool(somelnt); // способ 2
bool someBool = static_cast<bool>(somelnt); // способ 3
При таком преобразовании типов результат примет значение false, если int-nepe-
менная равна 0, и значение true в противном случае. Иногда (все зависит от контекста)
переменные могут автоматически приводиться к другому типу. Например,
short-переменная может быть автоматически преобразована в long-переменную, поскольку тип
long представляет, по сути, тот же тип данных, но с дополнительной точностью.
long someLong = someShort; // Здесь не нужна явная
// операции приведения типов.
Подвергая переменные автоматическому приведению типов, необходимо
учитывать возможность потенциальной потери данных. Например, при преобразовании
float- в int-значение теряется дробная часть числа. Многие компиляторы, если
обнаружат в программе присвоение float-значения int-переменной без явно заданной
операции приведения типа, реагируют предупреждением. Если же вы уверены, что
тип переменной, стоящей слева от оператора присваивания, полностью совместим
с типом "правостороннего" значения, то можете настаивать на выполнении неявного
приведения типов.
Операторы
Какая польза была бы от переменных, если бы не существовало способа изменять их
значения? Конечно же, такой способ есть, и он состоит в использовании операторов.
В приведенной ниже таблице перечислены самые популярные в C++ операторы: бинарные
(выполняемые над двумя операндами), унарные (выполняемые над одним операндом)
и даже тернарные (выполняемые над тремя операндами). По правде говоря, в C++
определен только один тернарный оператор (он рассматривается в следующем разделе).
36 Часть I. Введение в профессиональное С++-проектирование
Оператор Описание
Применение
int ;
i = 3;
int j ;
D = i»
bool b = Itrue;
bool b2 = !b;
int i = 3 + 2,-
int j = i + 5,-
int k = i + j ;
int i = 5-1,-
int j = 5*2;
int k = j / i;
int remainder =
i++;
++l;
5 % 2;
Бинарный оператор, который присваивает
значение, указанное справа от оператора,
переменной, указанной слева от него
Унарный оператор НЕ, меняющий логическое
значение переменной
(true/false, или не нуль/нуль) на противоположное
Бинарный оператор сложения
Бинарные операторы вычитания, умножения
* и деления
% Бинарный оператор получения остатка от деления.
Именуется также оператором деления по модулю
++ Унарный оператор инкремента (увеличения
значения на 1). Если оператор стоит перед
переменной, результатом выражения является
неинкрементированное значение. Если оператор
стоит после переменной, результатом выражения
является новое (инкрементированное) значение
Унарный оператор декремента i - - ;
(уменьшения значения на 1) - - i <
+= Сокращенный синтаксис для i = i + j i+=j,-
Сокращенный синтаксис для i -= j;
*= ... i *= j;
\Лч\\ i%=j;
i = i % j,-
& Бинарный оператор поразрядного умножения (И) i = j & k,-
&= j &= k;
I Бинарный оператор поразрядного i = j | k,-
l= следующожения (ИЛИ) j |= k,-
« Оператор сдвига влево («) или вправо (») на i = i « 1,-
» заданное количество разрядов i = i » 4,-
<<= i <<= 1;
»= i »= 4;
Бинарный оператор "исключающее ИЛИ" i = i * j;
ж- i ж= j;
В следующей программе демонстрируется использование самых
распространенных типов переменных и операторов. Если вы не уверены в том, как они работают,
попытайтесь предсказать результат выполнения этой программы, а затем запустите
ее, чтобы проверить свои "предсказания".
-if fcypeteet-:сн? - '
^include <iostream>
Глава 1. Краткий курс C++ 37
UBiag namespace std,- <
int wainiint argc, chair** argv)
Г
int somejnteger = 256;
short someShort,-
long someLong;
float eomeFloat;
double someDouble;
! someiiiteger++;
somelnteger *« 2j
eomeShort = (short) somelnteger;
someLong = someShort * 10000;
someFloat = someLong + 0.785;
someDouble « (double) someFloat / 100000;
cout « someDouble « ertdl;
Компилятор C++ вычисляет выражения по собственному "рецепту". Если вы
записали строку кода с множеством операторов, не следует ожидать, что порядок их
выполнения будет очевидным. Поэтому лучше разбивать сложные операторы на
несколько простых или группировать отдельные выражения с помощью круглых скобок.
Например, результат выполнения .следующей строки кода может оказаться неожи-
' данным для тех, кто не знает "назубок" таблицу C++ приоритетов операторов.
'i~ inti = 34 + 8*2 + 21/7%2;
t'-
После добавления круглых скобок последовательность выполнения операций станет
!■ понятнее.
;■ int i = 34 + (8 * 2) + ( (21 / 7) % 2 ) ;
А после разбиения исходной строки кода на несколько более коротких строк вся-
|; кие сомнения в порядке выполнения операторов исчезнут совсем.
(i int i = 8 * 2;
I int j = 21 / 7;
£ j *= 2;
5 i = 34 + i + j ; ,
I В любом из трех вариантов представления этого кода результат будет одинаков
»:. и составит 51 (т.е. переменная i станет равной 51). Если предположить, что C++ вы-
j числяет выражения слева направо, то в ответе вы получили бы 1. В действительности
£++ сначала выполняет такие операторы, как "/", "*" и "%" (в порядке слева
направо), затем переходит к сложению и вычитанию, а уж потом принимается за поразряд-
■ ные операции. Круглые скобки позволяют явно указать компилятору, что определен-
4; _ные операции должны быть выполнены в первую очередь.
| Типы
'f )'.'. В C++ базовые типы (например, int или bool) могут служить для построение бо-
\ 'лее сложных типов собственного "фасона". Опытный С++-программист редко ис-
/ пользует С-средства создания типов, поскольку С++-классы предоставляют более
широкие возможности в этой области. Тем не менее важно знать и другие способы
* создания типов (хотя бы на уровне распознавания синтаксиса).
38 Часть I. Введение в профессиональное С++-проектирование
Перечислимые типы
Любое целочисленное значение в действительности представляет элемент,
принадлежащий некоторой последовательности — последовательности целых чисел.
Перечислимые типы позволяют определить собственную последовательность, чтобы
иметь возможность объявлять переменные со значениями именно в этой
последовательности. Например, в "шахматной" программе каждую фигуру можно было бы
представить как int-значение, используя при этом константы для типов фигур
(см. следующий код). Целочисленные значения, представляющие эти типы, удобно
отметить спецификатором const, чтобы показать, что они не подлежат изменению.
const int kPieceTypeKing « О;
const int kPieceTypeQueen « 1;
const int kPieceTypeRopk « 2;
const int kPieceTypePawn « 3;
//...
int myPiece = kPieceTypeKing;
Такое представление выглядит прекрасно, но таит в себе потенциальную
опасность. Поскольку в нашем коде фигура представлена просто int-значением,
возникает вопрос: что произойдет, если, к примеру, ваш коллега программным путем инкре-
ментирует значение какой-нибудь фигуры? В результате простого увеличения
значения на единицу король мигом бы превратился в ферзя, что не имеет никакого
смысла. Хуже того, какой-нибудь третий программист (из вредности) мог присвоить
фигуре значение —1, которое не соответствует ни одной из шахматных фигур.
Перечислимые типы позволяют решить проблемы такого рода путем жесткого
определения диапазона значений для каждой переменной. В следующем коде
объявляется новый тип PieceT, который имеет четыре возможных значения,
представляющих четыре шахматные фигуры.
typedef enum { kPieceTypeKing, kPieeeTypeQueen,
kPieceTypeRook, kPieceTypePawn
} PieceT;
Конечно, любой перечислимый тип служит всего-навсего для представления
целочисленных значений. Реальное значение константы kPieceTypeKing равно нулю.
Однако, определив возможные значения для переменных типа PieceT, ваш
компилятор "просигналит" предупреждением или даже ошибкой, если вы попытаетесь
выполнить арифметические действия над переменными типа PieceT или посмеете
обращаться с ними, как с обычными int-переменными. Большинство компиляторов
при выполнении следующего кода, который сначала объявляет переменную типа
PieceT, а затем пытается использовать ее как обычную int-переменную, отреагирует
по меньшей мере предупреждающим сообщением.
PieceT «vyPiece,-
myPiece » 0,-
Структуры
Структуры позволяют инкапсулировать в некотором новом типе один или несколькЬ
существующих. Классический пример структуры — запись базы данных. Если вы
занимаетесь построением системы управления кадрами, предназначенной для ведения
Глава 1. Краткий курс C++ 39
информации о служащих, вам обязательно придется для каждого из них хранить такие
данные, как имя, отчество, фамилия, идентификационный код и размер оклада. В
следующем заголовочном файле используется структура, которая содержит все эти данные.
// employeestruct.h ~
typedef struct {
char firstlnitial;
char middlelnitial;
char lastlnitial;
int employeeNumber;
int salary,-
} EmployeeT;
Переменная, объявленная с типом EmployeeT, будет иметь все эти поля, в качестве
"встроенных". К отдельным полям структуры можно получить доступ с помощью
символа "точка" (.). В следующем примере программы показано создание и вывод
записи для одного служащего.
// structtest.cpp
iinclude <iostream>
finclude "snployeeBtruct.h"
using namespace std;
iht main (int argc, char** argv)
i
II Создание записи служащего.
",. EmployeeT anEmployee;
* anEmployee.firstlnitial * 'M';
anEmployee,middlelnitial * 'R',-
anEmployee.lastlnitial « 'G';
anEmployee.employeeNumber « 42;
anEmployee. salary « 80000;
• // Вывод данных о служащем.
pout « "Служащий; " «< anBniployee. firstlnitial «
anEmployee.wi-ddlelnitial «
-. anEmployee.lastlnitial «« endl;
:■:, cout « "Номер; " « anEmployee.employeeNumber «c endl;
cout « "Оклад: $" « anEmployee.salary <«.endl;
return 0;
*' Инструкции и операторы условного выполнения
»' Средства условного выполнения позволяют выполнять код в зависимости от того,
;., истинно ли некоторое условие. В C++ существует три основных вида таких средств.
V
:■ Инструкции i f -el в е
Чаще всего в качестве средства условного выполнения используется инструкция if,
^ фрторая может быть дополнена ветвью else. Если условие, заданное в инструкции if, ис-
k тданно, выполнится соответствующая строка или блок кода. В противном случае выполне-
- ние программы продолжит ветвь else (если таковая имеется) или код, расположенный
40 Часть I. Введение в профессиональное С++-проектирование
за инструкцией if. Следующий псевдокод демонстрирует каскадное построение
инструкции if, несколько причудливая форма которой позволяет выразить следующий
смысл: "Данная if-инструкция содержит else-ветвь, которая в свою очередь
содержит другую if-инструкцию и т.д.".
iS (i > 4) {
// Какие-то действия.
} else if ft > 2) {
// Какие-*© другие действия»
} лХае {
// Какие-то еще действия.
}
Выражение, стоящее между круглыми скобками инструкции if, должно быть
значением булева (логического) типа или приводиться к таковому. Условные операторы (они
рассматриваются ниже) обеспечивают возможность вычислять выражения, результат
которых может интерпретироваться как значение булева типа (true или false).
Инструкция switch
Инструкция switch — это способ альтернативного выполнения действий на
основе значения некоторой переменной. В switch-инструкциях переменная
поочередно сравнивается с константами из предложенного набора, поэтому далеко не все if-
инструкции (в том числе и приведенная выше) могут быть преобразованы в switch-
конструкции. Каждое значение константы представляет свою case-ветвь. Если
значение переменной совпадает со значением константы, выполняются последующие
строки кода до тех пор, пока не будет достигнута инструкция break. В switch-
конструкции возможно применение ветви default, которая выполнится при
несовпадении заданной переменной ни с одной из предложенных констант.
Инструкции switch обычно используются при желании выполнить определенные
действия при конкретном значении переменной. Характерное применение
инструкций switch показано на примере следующего псевдокода.
i
switch {menultem) {
case kOpenMenuItem:
// Код открытия файла.
break;
case kSaveMenuItera:
// Код сохранения файла,
break;
default:
// Код выдачи сообщения об ошибке.
break;
}
Если опустить инструкцию break, код последующей case-ветви будет выполнен
независимо от результата сравнения переменной и соответствующей константы.
Иногда такое поведение полезно (и потому используется намеренно), но чаще служит
источником ошибок.
Тернарный оператор
В C++ определен только один оператор, который принимает три аргумента, и
поэтому он называется тернарным. Он используется в качестве условного выражения,
имеющего "сокращенный" формат, который можно выразить так: "Если [что-то], то
Глава 1. Краткий курс C++ 41
[выполнить одно действие], в противном случае [выполнить другое действие]".
Тернарный оператор представляется вопросительным знаком (?) и двоеточием (:).
Например, при выполнении следующего кода будет выведено слово "да", если
переменная i больше 2, и "нет" в противном случае.
Std::COut « ( (i > 2) ? "да" : "нет");
Преимущество тернарного оператора состоит в том, что его можно применить
практически в любом контексте. В предыдущем примере тернарный оператор
используется в коде вывода данных. Синтаксис этого оператора легко запомнить: знак
вопроса помогает расценить инструкцию, которая ему предшествует, как реальный
вопрос. Например: "Значение переменной i больше 2? Если да, то в результате
получим "да", (:) если нет, результатом станет "нет"".
В отличие от if- или switch-инструкций, тернарный оператор в
действительности не выполняет код в зависимости от результата. Он используется внутри кода, как
показано в предыдущем примере. Он является именно оператором (подобно
операторам "+" и "- "), а не инструкцией, как if- или switch-инструкции.
Условные операторы
Вы уже встречали условные операторы без их формального определения.
Оператор ">" сравнивает два значения. Результат будет истинным, если значение слева от
него больше значения справа. Все остальные условные операторы соответствуют
этому образцу, т.е. результат их применения всегда равен значению true или false.
Условные операторы описаны в следующей таблице.
Оператор Описание Применение
< Определяет, действительно ли if (i <= о) {
<= левый операнд меньше (меньше std::cout « "i отрицательно" ,-
■* или равен, больше, больше или }
>= равен) правого операнда
Определяет, равен ли левый if (i == з) {
операнд правому. Не путайте с std::cout « "i равно 3",-
оператором присваивания (=) }
!= Оператор "НЕ РАВНО". if (i != 3) {
Его результат равен значению std::cout « "i не равно з»;
true, если левый операнд }
не равен правому
! Унарный оператор "логическое НЕ". if (isomeBooiean) {
Изменяет логическое значение etd::cout « "someBoolean равно false",-
выражения (true/false) }
на противоположное
&& Оператор "логическое И". if (someBoolean && someOtherBoolean) {
Результат равен значению true, std: :cout « "Оба операнда равны true";
если оба операнда имеют }
значение true
11 Оператор "логическое ИЛИ". if (someBoolean 11 someOtherBoolean) {'
Результат равен значению true, std::cout « "Хотя. бы. один из операндов
если хотя бы один из операндов равен true" ;
имеет значение true }
■о ■
.к
42 Часть I. Введение в профессиональное С++-проектирование
В C++ используется "сокращенная" логика вычисления выражений. Это означает,
что если после вычисления одной части выражения ясен конечный результат, то
остальная часть выражения не вычисляется. Рассмотрим такой пример.
bool result = booll || bool2 || (i > 7) || (27 / 13 % i + 1) < 2;
Если booll окажется равным значению true, все выражение будет равно true,
поэтому другие его части не вычисляются. Таким образом, сами языковые средства
позволяют избежать выполнения ненужной работы. В следующей инструкции,
выполняющей логическое связывание по "И" четырех элементов, механизм сокращенного
вычисления стопроцентно вступает в силу после анализа второго элемента, поскольку
О всегда интерпретируется как false (независимо от значений других элементов,
результат всего выражения в этом случае также будет равен false),
bool result = booll && 0 && (i > 7) && !done;
В то же время метод сокращенного вычисления может послужить источником
труднообнаруживаемых ошибок, если одно из невычисляемых выражений каким-то
образом могло повлиять на состояние программы (например, путем вызова
некоторой функции).
Циклы
Компьютеры, как известно, прекрасно справляются с выполнением многократно
повторяющихся действий. В C++ предусмотрено три типа циклических конструкций.
Цикл while
Цикл while позволяет повторять выполнение блока кода до тех пор, пока
вычисление заданного выражения дает в результате значение true. Например, следующий
код должен вывести на экран текст "Это глупо." пять раз.
lut i = 0;
«Kile ii « s) {
Std::COUt <■* "ЭТО ГЛУПО. " « Std: ;6ndlj
i++;
}
Для немедленного выхода из цикла и продолжения программы можно
использовать ключевое слово break. Для возврата к началу цикла и очередного вычисления
while-выражения используется ключевое слово continue. Применение обоих
ключевых слов считается плохим стилем программирования, поскольку они вносят
некоторую беспорядочность.
Цикл do-while
В C++ также существует вариация "на тему" цикла while, именуемая циклом do-
while. Этот цикл работает подобно циклу while, за исключением того, что сначала
он выполняет код тела цикла, а затем уж проверяет условное выражение,
определяющее дальнейшую "судьбу" повторяемого кода. Цикл do-while используется в случае,
если необходимо, чтобы тело цикла выполнилось хотя бы один раз. В следующем
примере текст "Это глупо." будет обязательно выведен на экран один раз, несмотря
на то, что вычисление управляющего условия дает в результате значение f al se.
int i * 100;
do {
Глава 1. Краткий курс C++ 43
std::cout « "Зто глупо. " « std: :endl(-
} while (i < 5) ;
Цикл for
Несмотря на то что для цикла for используется несколько другой синтаксис,
любой цикл for можно преобразовать в цикл while и наоборот. Однако синтаксис
цикла for многие программисты считают более удобным, поскольку он содержит явно
заданные выражения начала, конца и инструкцию, которая должна выполняться
в конце каждой итерации. В следующем коде переменная i инициализируется
нулевым значением, затем цикл выполняется до тех пор, пока i остается меньше 5,
причем в конце каждой итерации значение переменной i увеличивается на 1. Этот код
выполняет те же действия, что и предыдущий пример цикла while, но многим он
кажется проще для понимания, поскольку выражение начала, выражение конца и
"поститерационная" инструкция располагаются в одной строке.
for (int i « 0,- 1 «с 5; i++) {
std: :cout « "Это глупо. " << std::endl;
}
Массивы
Массивы предназначены для хранения ряда значений одного типа, причем доступ
к каждому из них возможен по его позиции в массиве. В C++ при объявлении массива
необходимо указывать его размер. В качестве размера нельзя использовать
переменную— это должна быть константа.-При выполнении следующего фрагмента кода
объявляется массив для хранения 10 целочисленных значений и используется цикл
for для инициализации всех его элементов нулями.
tnt-myftrrayflO] ;
for (int i - 0; i < 10; i++) {
tttyArrayti] = 0;
В предыдущем примере показано использование одномерного массива, который
можно представить в виде цепочки целочисленных элементов, причем каждый из них
находится в собственной нумерованной ячейке. C++ позволяет определять и
многомерные массивы. Двумерный массив легко представить себе в виде шахматной доски,
каждая клеточка которой имеет номер позиции по оси хну. Трехмерные массивы
и массивы более высокой размерности труднее поддаются визуальной интерпретации
и используются гораздо реже. На примере следующего кода демонстрируется
синтаксис объявления двумерного массива символов (для представления доски для игры в
крестики и нолики) и записи "нолика" в центральную клетку.
■ char ticTacToeBoard[3j [3J t
ticTacToeBoardUHU » 'о';
Визуальное представление этой доски с указанием позиции каждой ее клеточки
показано на рис. 1.1.
44 Часть I. Введение в профессиональное С++-проектирование
ticTacToeBoard[0][0]
BcTacToeBoard[1][0]
ticTacToeBoard[2][0]
ticTacToeBoard[0][1]
BcTacToeBoard[1][1]
ticTacToeBoard[2][1]
ticTacToeBoard[0][2]
ticTacToeBoard[1][2]
ticTacToeBoard[2][2]
Рис. 1.1
В C++ позиция первого элемента всегда равна 0, а не 1! Последняя
позиция массива всегда равна размеру массива, уменьшенному на единицу!
Функции
Программа большого размера, в которой весь код находится в функции main (),
может стать со временем практически неуправляемой. Чтобы сделать программу
более читабельной, необходимо разбить ее код на более мелкие функции.
Для того чтобы функция в C++ была доступной для вызова другим кодом
программы, ее нужно сначала объявить. Если функция используется в конкретном файле, она,
как правило, объявляется и определяется в нем же. Если же функция предназначена
для использования другими модулями или файлами, ее объявление обычно
помещается в заголовочный файл, а определение — в исходный.
Объявления функций часто называют "прототипами функций" или
"сигнатурами", чтобы подчеркнуть, что они представляют способ
доступа к ним, а не их код.
Рассмотрим следующий пример объявления функции. В данном случае функция
возвращает результат типа void, что означает, что она не возвращает никакого
результата инициатору вызова. При вызове этой функции необходимо передать два
аргумента — целочисленное значение и символ.
void myFunctionfint i, char с);
Без реального определения этой функции, которое должно соответствовать ее
объявлению, стадия компоновки процесса компиляции не пройдет успешно, поскольку
в этом случае код вызова функции myFunction () обратится к несуществующему коду.
Согласно следующему определению эта функция просто выводит на экран значения
своих двух параметров.
Глава 1. Краткий курс C++ 45
void myFunction(int i, char с)
{
std::cout « "Значение i равно " << i << std::endl;
8td::cout « "Значение с равно " « с « std::endl;
}
В каком-нибудь другом месте программы можно обратиться к функции myFunction ()
и передать для двух ее параметров константы или переменные. Приведем примеры
возможных вызовов этой функции.
myFunction(8, 'а•);
myFunction(somelnt, ' b') ;
myFunction(5, someChar);
В C++, в отличие от С, функция, которая ие принимает параметров,
просто имеет пустой список параметров. В этом случае не обязательно
использовать ключевое слово void для обозначения отсутствия
принимаемых параметров. Однако тип void непременно должен быть
указан для обозначения отсутствия значения, возвращаемого функцией.
В C++ функции могут возвращать значения. В следующем фрагменте кода
демонстрируется объявление и определение функции, которая суммирует два числа и
возвращает результат сложения.
int addNumbers(int numberl, int number2);
int addNumbers(int numberl, int number2)
{
int result = numberl + number2;
return (result);
}
Разминка окончена
Итак, в этом разделе мы сделали краткий обзор основных элементов C++. Если
ничего нового вы в нем не обнаружили, мы рекомендуем бегло просмотреть следующий
раздел, чтобы убедиться в своей готовности осваивать более сложный материал. Если
какая-то из тем вызывает у вас затруднения, то, возможно (прежде чем продолжать
чтение этой книги), вам стоит обратиться к одной из книг по введению в C++,
перечисленных в приложении Б.
Приготовимся к погружению в C++
Циклы, переменные и средства условного выполнения кода можно назвать
"кирпичиками", из которых строится здание программы, но всякому ясно, что одних
"кирпичиков" недостаточно. Программисту необходимо владеть множеством инструментов,
которые позволят ему возвести грандиозное программное сооружение, причем
следует иметь в виду, что, как и на реальной стройплощадке, неосторожное
обращение с техникой (в данном случае с языковым инструментарием) может привести
к ужасным последствиям. Если вы считаете себя С-программистом и имеете пока
небольшой опыт программирования на C++, вам рекомендуется прочитать этот
раздел особенно внимательно.
46 Часть I. Введение в профессиональное С++-проектирование
Указатели и динамическое распределение памяти
Средства динамического распределения памяти позволяют создавать программы,
используя данные, которые на момент компиляции не имеют фиксированного
размера. Большинство нетривиальных программ используют динамически распределяемую
память в той или иной форме.
Стек и "куча"
Память любого С++-приложения делится на две части, которые называют стеком
и "кучей". Стек можно представить себе в виде колоды карт. Верхняя карта
символизирует текущую область видимости программы (это может быть область видимости
функции, которая выполняется в данный момент). Все переменные, объявленные
в этой функции, занимают память в области старших адресов стекового фрейма. Если
текущая функция, назовем ее f оо (), вызовет другую функцию, скажем, bar (), то
поверх нашей колоды карт будет положена новая карта, чтобы новая текущая функция
bar () имела собственный стековый фрейм. Любые параметры, переданные из
функции f оо () для функции bar (), копируются из стекового фрейма функции f оо ()
в стековый фрейм функции bar (). Механизмы передачи параметров и
функционирования стековых фреймов рассматриваются в главе 13. Схематичное изображение
стека во время выполнения гипотетической функции f оо (), объявленной с двумя
целочисленными параметрами, показано на рис. 1.2.
Стековые фреймы прекрасно справляются
с обеспечением изолированной рабочей области
памяти для каждой функции. Если в стековом
фрейме функции foo() объявляется некоторая
переменная, то вызов функции bar () не изменит ее
(если, конечно, вы не сделаете это специально).
Кроме того, при завершении функции foo() ее
стековый фрейм ликвидируется, и все объявленные
в ней переменные память больше не занимают.
Под "кучей" понимают область памяти,
которая совершенно не зависит от текущей функции
* или стекового фрейма. В "кучу" можно
поместить переменные, если нужно, чтобы они существовали даже после завершения
функции, в которой они были объявлены. "Куча" менее структурирована, чем стек. Ее
можно представить себе просто как множество битов. Ваша программа в любое время
может добавить несколько новых битов в это множество или модифицировать
некоторые биты из уже существующих.
Динамическое выделение памяти для массивов
"Зная", как организовано функционирование стека, компилятор должен иметь все
необходимые данные для определения во время компиляции размера каждого
стекового фрейма. А поскольку размер стекового фрейма определяется заранее, размер
массива нельзя объявить с помощью переменной. Следующий код не скомпилируется,
поскольку arraySize — переменная, а не константа.
int arraySize = 8;
inc myVariableSizedArray[arraySize] ,- // не скомпилируется!
mai
foo()
n()
inti
intj
7
11
Рис. 1.2
Глава 1. Краткий курс C++ 47
Поскольку весь массив должен поместиться в стеке, компилятору необходимо
точно знать, какой объем памяти он займет. Однако, используя средства динамического
распределения памяти, массив можно разместить не в стеке, а в "куче", и тогда его
размер может быть задан во время выполнения программы.
Некоторые С++-компиляторы могут "согласиться" с приведенным
выше объявлением массива, но такой синтаксис не является частью
С++-спецификации. Большинство компиляторов предпочитают
более "строгие правила игры", которые исключают подобные
нестандартные расширения языка.
Чтобы динамически выделить для массива память, сначала необходимо объявить
указатель,
int* myVariableSizedArray;
Символ " * " после типа int означает, что объявляемая здесь переменная указывает на
некоторую область памяти в "куче", предназначенную для хранения целочисленного
значения. Представьте себе указатель в виде гипотетической стрелки, которая
указывает на динамически выделяемую область памяти "кучи". При этом наша "стрелка"
пока не указывает ни на что конкретное, поскольку эта область памяти еще ничем не
заполнена, т.е. пока мы имеем дело с неинициализированной переменной.
Чтобы инициализировать указатель на область памяти в "куче", используйте
команду new.
myVariableSizedArray = new int[arraySize] ;
При выполнении этой команды будет выделена область памяти, размер которой
вполне удовлетворит потребности целочисленного массива, объявленнпого с
использованием переменной arraySize. На рис. 1.3 показано схематическое изображение
стека и "кучи" после выполнения этого кода. Как видите, переменная-указатель по-
прежнему размещается в стеке, а динамически созданный массив — в "куче".
Стек
"Куча"
Рис. 1.3
myVariableSizedArray[0]
myVariableSizedArray[1]
myVariableSizedArray[2]
myVariableSizedArray[3]
myVariableSizedArray[4]
myVariableSizedArray[5]
myVariableSizedArray[6]
myVariableSizedArray[7]
После выделения памяти для массива с переменной myVariableSizedArray
можно работать так, как если бы это был обычный "стековый" массив.
myVariableSizedArray [3] = 2;
48 Часть I. Введение в профессиональное С++-проектирование
Когда этот динамический массив станет больше не нужным, его необходимо
удалить из "кучи", освободив тем самым ее память для других переменных. В C++ для
этого существует команда delete,
delete [] myVariableSizedArray;
Квадратные скобки после оператора delete означают, что удаляется массив.
Операторы new и delete выполняют действия, подобные действиям
С-функций mallocO и free О. Однако синтаксис С++-операторов
new и delete проще, поскольку вам не нужно задумываться над тем,
сколько байтов памяти требовать для выделения.
Работа с указателями
Существует множество причин для использования памяти "кучи" помимо
динамического создания массивов. Используя аналогичный синтаксис, в "куче" можно
разместить любую переменную,
int* mylntegerPointer = new int;
В этом случае указатель ссылается на одно-единственное целочисленное значение.
Чтобы получить доступ к этому значению, необходимо разыменовать указатель.
Процесс разыменования можно представить себе как успешное путешествие "по
стрелке", приведшей к реальному значению в "куче". Чтобы "записать" значение в
динамически выделенную область памяти, используйте код, подобный следующему.
♦mylntegerPointer = 8;
Обратите внимание на то, что выполняемое здесь действие не эквивалентно
установке переменной mylntegerPointer равной значению 8. С помощью приведенного
выше кода вы изменяете не указатель, а область памяти, на которую он указывает.
Если бы вы выполнили предыдущее присваивание без использования символа "*", то
переменная mylntegerPointer стала бы указывать на область памяти с адресом 8,
в которой, скорее всего, содержится случайный "мусор".
Указатели не всегда ссылаются на область памяти "кучи". Можно объявить
указатель на переменную, расположенную в стеке, или даже на другой указатель. Для
получения указателя на переменную используйте оператор "взятия адреса" (&).
int i = 8;
int* mylntegerPointer = &i; // Указывает на переменную
// со значением 8.
В C++ предусмотрен специальный синтаксис для работы с указателями на структуры.
С формальной точки зрения, если у вас определен указатель на структуру, то для
получения доступа к ее полям нужно сначала разыменовать указатель с помощью символа
"*", а затем использовать обычный синтаксис оператора "точка" (.), как показано
в следующем примере (здесь предполагается существование функции getEmployee ()).
EmployeeT* anEmployee = getEmployee () ,-
cout << (*anEmployee).salary << endl;
Рассмотрим синтаксис этого кода подробнее. Оператор "стрелка" (->) позволяет
выполнить разыменование и доступ к полю "в один присест". Следующий код
эквивалентен предыдущему, но проще для восприятия.
Глава 1. Краткий курс C++ 49
EmployeeT* anEmployee = getEmployee();
cout « anEmployee->salary << endl;
Обычно передача параметров функции реализуется по значению. Если функция
принимает целочисленный аргумент, то при его передаче происходит копирование
передаваемого значения в параметр. В языке С, чтобы позволить функциям
модифицировать переменные, расположенные в чужих стековых фреймах, для передачи
аргументов часто используются указатели на стековые переменные (этот метод
называется передачей по ссылке). Разыменовав указатель, функция может изменить
содержимое области памяти, которая представляет переменную, несмотря на то, что
эта переменная находится не в текущем стековом фрейме. Такой подход не слишком
популярен в C++, поскольку в C++ реализован более совершенный механизм,
основанный на применении ссылок, о которых речь впереди.
Строки в C++
В C++ существует три способа обработки строк: во-первых, с использованием С-
стиля, согласно которому строки представляются как массивы символов; во-вторых,
с использованием С++-стиля, что означает применение типа string, -и в третьих, на
основе общего класса нестандартных подходов.
Строки в стиле языка С
Строка текста вида "Привет, мир! " внутренне представляется как массив
символов, завершающийся символом ' \ 0 ', который является признаком конца строки. Как
вы уже поняли, массивы и указатели связаны между собой "тесными узами". Поэтому
для представления строки можно использовать любой вариант.
char arrayString[20] = ".Привет, мир!";
char* pointerString = "Привет, мир!";
Для массива arrayString компилятор выделяет в стеке область, достаточную для
хранения 20 символов. Первые 13 элементов массива заполнены символами ' П', ' р',
..., ' \0'. Элементы, соответствующие позициям 13—19, содержат случайные значения
("мусор"). Символ ' \ 0' служит признаком конца содержимого строки, поэтому,
несмотря на то, что длина массива равна 20, функции, которые используют это строку,
должны игнорировать содержимое массива после символа ' \ 0 '.
В случае использования указателя pointerString компилятор выделяет в стеке
объем памяти, достаточный для хранения лишь самого указателя. Этот указатель
ссылается на область памяти, которую компилятор выделил для хранения строки
" Привет, мир!". В этой строке также существует символ ' \ 0 ' после символа ' ! '.
В языке С для работы со строками предусмотрено множество стандартных
функций, которые описаны в заголовочном файле <cstring>. В этой книге мы не
углубляемся в детали использования стандартной библиотеки, поскольку C++ предлагает
более ясный и простой способ работы со строками.
С++-строки
Со строками, созданными в С-стиле, важно уметь работать, поскольку они все еще
часто используются С++-программистами. Однако в C++ для обработки строк можно
использовать специальный тип string. Тип string, описанный в заголовочном
50 Часть I. Введение в профессиональное С++-проектирование
файле <string>, действует подобно любому базовому типу. Как и потоки ввода-
вывода, тип string определен в пространстве имен std. Следующий пример
показывает, что С++-строки можно использовать аналогично символьным массивам.
// stringtest.срр
#include <string>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
string myString » "Привез?, мирт";
cout << "Значение myString равно " << myString « endl;
return 0;
}
Преимущество С++-строк состоит в том, что для работы с ними можно
использовать стандартные операторы. Это значит, что вместо функции strcat () для
конкатенации двух строк можно использовать просто оператор сложения (+). Если для
сравнения двух С-строк вы попытаетесь применить оператор "==", у вас ничего не
получится. Оператор "==" в этом случае будет сравнивать адреса символьных
массивов, а не их содержимое. Но со сравнением С++-строк оператор "==" прекрасно
справится. Использование некоторых стандартных операторов применительно к С++-
строкам показано в следующей программе.
// stringtest2.cpp
#include <string>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
string strl = "Привет,";
string str2 «. "мир!"?
string str3 ж strl + " " + 8tr2;
cout << "Строка strl: " « strl « endl;
cout « "Строка Str2: " « Str2 « endl;
cout << "Строка str3: " « 6tr3 << endl;
if (str3 «™ "Привет, мир!") { l
cout « "Строка str3 такой и должна быть." « endl;
} else {
cout « "Гммм . . . строка str3 не должна быть такой. "
« endl;
}
return (0);
}
В этом примере продемонстрированы только некоторые возможности С++-строк.
В главе 13 они рассматриваются более подробно.
Нестандартные строки
Существует ряд причин того, почему многие С++-программисты не используют
С++-строки. Некоторые из них просто не имеют опыта работы с типом string,
поскольку он не всегда был частью спецификации C++. Другие считают, что С++-строки
Глава 1. Краткий курс C++ 51
не обеспечивают нужное им поведение, и поэтому разрабатывают собственный
строковый тип. Но самой распространенной причиной является то, что разные среды
разработки и операционные системы часто имеют собственные способы
представления строк, как, например, класс CString в библиотеке базовых классов,
поддерживающей разработку приложений для Microsoft Windows (Microsoft Foundation
Classes — MFC). Нередко отказ от использования С++-строк объясняется намерением
реализации обратной совместимости. Поэтому, приступая к новому С++-проекту,
важно заранее решить, как ваша группа разработчиков будет представлять строки.
Ссылки
Большинство функций работают по такой схеме: они принимают некоторое
количество параметров (оно может быть нулевым), выполняют какие-то вычисления
и возвращают результат. Но иногда эта схема нарушается. Ведь возможны ситуации,
когда необходимо, чтобы функция возвращала, например, два значения или была
способна изменить значение переменной, которое ей было передано.
В языке С для реализации такого поведения функции вместо переменной
передается указатель на нее. Единственным недостатком такого подхода (несмотря на
простоту задачи) является внесение путаницы в синтаксис применения указателей. В C++
предусмотрен явный механизм реализации "передачи по ссылке". Присоединение
символа "&" к типу означает, что данная переменная является ссылкой. При этом она
по-прежнему используется как обычная переменная, но в действительности является
указателем на исходную переменную. Ниже демонстрируются два варианта
реализации функции addOne (). В первом варианте выполнение функции не оказывает
никакого воздействия на переменную, которая ей передается, поскольку здесь имеет место
передача параметра по значению. Во втором варианте используется ссылка, и
поэтому функция изменяет значение исходной переменной.
void addOne(int i)
{
i++; // На аргумент функции это не влияет, поскольку
// в функции используется копия, а не оригинал...
void addOne(int& i)
{
i++; // Здесь изменяется значение оригинальной переменной.
}
Синтаксис вызова функции addOne (), которой передается ссылка на
целочисленную переменную, не отличается от синтаксиса вызова функции-, которая принимает
обычную переменную типа int.
int mylnt e 7;
addOne (mylnt);
Исключения
Язык C++ отличается гибкостью и широкими возможностями, но отнюдь не
безопасностью. Компилятор легко "пропустит" код, который способен "достать1' случайные
адреса памяти или попытаться выполнить деление на нуль (при том, что компьютерам
не ведомо понятие бесконечности). И все же в языке C++ есть средства, которые
позволяют повысить уровень безопасности программ. Речь идет об исключениях.
52 Часть I. Введение в профессиональное С++-проектирование
Под исключением понимается исключительная ситуация. Например, если вы
пишете функцию, которая обращается к Web-странице, при ее выполнении возможно
возникновение ряда проблем. Internet-сайт, который содержит эту страницу, может
находиться в нерабочем состоянии, запрошенная страница может оказаться пустой, или
во время работы может оборваться связь. Во многих языках программирования
подобные ситуации обрабатываются путем возврата функцией специального значения,
например, NULL-указателя. Исключения предоставляют гораздо более совершенный
механизм обработки таких проблем.
При рассмотрении темы обработки исключений применяется специальная
терминология. Если в некотором разделе кода обнаруживается исключительная ситуация,
генерируется исключение. В другом разделе кода это исключение перехватывается,
и выполняются соответствующие действия. В следующем примере демонстрируется
функция divideNumbers (), которая генерирует исключение, если окажется, что
инициатор ее вызова передал в качестве параметра нуль для знаменателя.
#include <stdexcept>
double divideNumbers(double inNumerator, double inDenominator)
{
if (inDenominator == 0) {
throw std::exception(),-
return (inNumerator / inDenominator);
} t
При выполнении инструкции throw функция будет немедленно завершена без
возврата какого бы то ни было значения. Если вызов функции заключить в try-catch
блок, как показано в следующем коде, то инициатор вызова получит исключение
и сможет его обработать.
#include <iostream>
#include <stdexcept>
int main(int argc, char** argv)
try {
std::cout « divideNumbers(2.5, 0.5) « std::endl;
std::cout << divideNumbers(2.3, 0) << std::endl;
} catch (std::exception exception) {
std::cout « "Исключение перехвачено!" « std::endl;
) '
Первое обращение к функции divideNumbers () завершается успешно, и
пользователь видит на экране результат деления чисел. Второе обращение приводит к
генерированию исключения. В этом случае функция не возвращает значения, но при
перехвате исключения на экран выводится сообщение об ошибке. Вот как выглядят
результаты выполнения предыдущей программы.
5
Исключение перехвачено!
Применяя механизм исключений в C++, необходимо понимать, что происходит со
стековыми переменными при генерировании исключения, и обеспечить перехват
и надлежащую его обработку. В предыдущем примере был использован встроенный
тип исключения std: :exception, но предпочтительнее создавать собственные ти-
Глава 1. Краткий курс C++ 53
пы, соответствующие конкретным ошибочным ситуациям. В отличие от языка Java
С++-компилятор не обязывает программиста перехватывать все возможные
исключения. Если произойдет исключение, не предусмотренное в программе, оно будет
перехвачено самой программой, которая при этом завершится. Более подробно
исключения рассматриваются в главе 15.
Использование модификатора const
Ключевое слово const в C++ используется по-разному. Способы его применения
имеют как сходства, так и различия, которые подробно описаны в главе 12. Здесь же
мы рассмотрим только самые распространенные варианты использования
модификатора const.
Создание констант
Предположив, что ключевое слово const имеет прямое отношение к созданию
констант, вы будете совершенно правы. В языке С программисты часто использовали
директиву препроцессора #def ine для объявления символических имен для значений,
которые не должны изменяться во время выполнения программы (например, номер
версии программы). В C++ программисты для определения констант вместо директивы
#def ine используют модификатор const. Определение константы с помощью
ключевого слова const подобно определению переменной, за исключением того, что
компилятор будет гарантировать, что const-значение не будет изменено программой.
const float kVersionNumber = "2.О";
const string kProductName = "Super Hyper Net Modulator";
Защита переменных
В C++ обычные (не const) переменные можно преобразовать в const-переменные.
Спросите, а зачем это нужно? Такое преобразование обеспечивает определенную
степень защиты переменных от изменения их значений со стороны других частей
программного приложения. Если вы используете функцию, которую пишет ваш сотрудник,
и хотите быть уверены в том, что она не изменяет значение передаваемого вами
параметра, вы можете предложить своему сотруднику позаботиться о том, чтобы эта
функция принимала const-параметр. И если после этого в коде функции будет реализована
попытка изменить значение параметра, она (функция) не скомпилируется.
В следующем коде при обращении к функции mysteryFunction () переменная
типа char* автоматически приводится к типу const char*. Если автор функции
mysteryFunction () попытается изменить значения элементов этого символьного
массива, то его код не скомпилируется. В действительности можно найти способ
обойти это ограничение, но для этого придется приложить особые усилия. Язык C++
посредством модификатора const обеспечивает защиту переменных только от
случайных, (ненамеренных) изменений.
// consttest.срр
void mysteryFunction (const char* myString); ...
int main(int argc, char** argv) •
char* myString » new char[21;
'■'myStririgtO] '* 'a';'- ' < ' '''■■
54 Часть I. Введение в профессиональное С++-проектирование
myString[l] > ' \0';
tnysteryFunctiondnyString) ;
return (О) ;
}
Использование const-ссылок
Ссылочные const-параметры используются в С++-программах довольно часто.
В этом, на первый взгляд, можно увидеть некое противоречие. Ведь ссылочные
параметры позволяют изменять значение переменной из другого (т.е. "не ее") контекста,
а модификатор const, казалось бы, должен не допустить таких изменений.
Основная ценность ссылочного const-параметра — его эффективность. При
передаче функции переменной в качестве параметра создается ее копия. А при передаче
ссылки в действительности передается лишь указатель на оригинальную переменную,
поэтому необходимость в создании копии здесь отпадает. Передавая функции const-
ссылку, вы одним "выстрелом" убиваете сразу двух "зайцев": и копия не создается,
и исходная переменная не будет подвергнута никаким случайным изменениям.
Еще большую важность const-ссылки приобретают при работе с объектами, поскольку
в результате создания копий объектов (из-за их внушительных размеров) могут
возникнуть нежелательные побочные эффекты. Подобные тонкости нашли свое
отражение в главе 12.
Использование C++ как
объектно-ориентированного языка
программирования
Если вы — С-программист, то, возможно, вы расценили описанные в этой главе С++-
средства как удобные дополнения к языку С. Исходя из названия, можно предположить,
что C++ — это "улучшенный язык С". С этим можно согласиться лишь с большой
натяжкой. C++ (в отличие от языка С) — объектно-ориентированный язык. Объектно-
ориентированное программирование (ООП) представляет собой совсем иной,
возможно, более естественный способ написания программ. Если вам до сих пор приходилось
использовать только такие процедурные языки, как С или Pascal, не волнуйтесь. В
главе 3 вы найдете всю информацию, которую вам необходимо знать, чтобы направить
свой образ мышления в сторону объектно-ориентированной парадигмы. Если вы уже
знакомы с теорией ООП, то оставшуюся часть этого раздела, посвященную синтаксису
объектов C++, можете "пройти" в ускоренном темпе (чтобы просто освежить память).
Объявление класса
Класс определяет характеристики объекта. Класс имеет много общего со
структурой, за исключением того, что он (помимо свойств) определяет и поведение объектов.
В C++ классы объявляются в заголовочном файле, а полное их определение
содержится в соответствующем исходном файле.
Ниже приведено определение базового класса для описания характеристик
авиабилетов. Этот класс позволяет вычислить стоимость билета на основе дальности
перелета и в зависимости от того, является ли потенциальный пассажир участником про-
Глава 1. Краткий курс C++ 55
граммы "Elite Super Rewards Program". Определение начинается с объявления имени
класса. Все остальное содержимое класса (члены данных, или свойства, и его методы, или
функции) заключается в фигурные скобки. Каждый член данных и метод имеет
соответствующий уровень доступа: public, protected или private. Эти спецификаторы
могут располагаться в любом порядке, при этом возможно их повторение.
// AirlineTicket.h
♦include <string>
class AirlineTicket
{
public:
AirlineTicket();
-AirlineTicket (> ,•
int calculatePricelnDollars{);
std::string getPassengerNameО;
void setPassengerName(std::string inName);
int getNumberOfMiles();
void setNumberOfMiles(int inMiles);
bool getHaeEliteSuperRewardsStatus();
void setHasEliteSuperRewardsStatus(bool inStatus) ,-
private:
std::string mPassengerName,-
int mNumberOfMiles;
bool fHasEliteSuperRewardsStatus;
};
Метод, имя которого совпадает с именем класса, называется конструктором класса.
Перед именем этого метода тип возвращаемого значения не указывается, поскольку
он не возвращает никакого результата. Конструктор вызывается автоматически при
создании объекта. А при разрушении объекта автоматически вызывается другой
метод, именуемый деструктором класса. Имя деструктора также совпадает с именем
класса, но ему предшествует символ "тильда" (~).
Ниже приводится пример программы, в которой демонстрируется использование
класса, объявленнного в предыдущем примере. При выполнении этой программы
создается объект Airl ineTicket как в стековой области, так и в памяти "кучи".
// AirlineTicketTest.срр
♦include <iostream>
♦include "AirlineTicket.h"
using namespace std;
int main(int argc, char** argv)
{
' AirlineTicket myTicket,- // Стековый объект
// класса AirlineTicket.
myTicket.setPassengerName("Sherman T. Socketwrench"};
myTicket.setNumberOfMiles(700);
int coet m myTicket. calculatePricelnDollars О ,-
rout « ".Этот билет Судет стоить §" « cost « endl,- ,
AirlineTl'tiket* myTick«t2> // "Кулевой" объект
56 Часть I. Введение в профессиональное С++-проектирование
// класса AirlineTicket.
myTicket2 » new AirlineTicket(); // Создаем новый объект
myTicket2->setPassengerName(nLaudimore M. Hallidue");
myTicket2->setNumberOfMiles(2000};
myTicket2->setHasEliteSuperRewardsStatus(true);
int cost2 » ntyTicket2->calculatePricelnDollars{);
cout « "А этот билет Судет стоить $" « cost2 « endl;
delete myTicket2;
return 0; '
}
Ниже приводится определение методов класса AirlineTicket.
// AirlineTicket.cpp
ttinclude <ioetream>
#include "AirlineTicket.h"
using namespace std;
AirlineTicket: .-AirlineTicket ()
{
// Инициализируем члены данных.
fHasEliteSuperRewardsStatus « false;
mPassengerName = "Неизвестный пассажир";
mNutriberOfMiles « 0,-
}
AirlineTicket::^-AirlineTicket()
{
// Никакие ""уборочные" действия не выполняются.
}
int AirlineTicket::calculatePriceInDollars()
{ * ,
if (getHasEliteSuperRewardsStatus()) {
// Участники "элитной" программы летают бесплатно}
return 0;
}
// Стоимость билета вычисляется путем умножения расстояния
//на коэффициент 0,1. В действительности для расчета
// может использоваться более сложная" формула!
return static_cast<int>((getNumberOfMiles() * 0.1));
string AirlineTicket::getPassengerName()
return mPassengerName;
void AirlineTicket::setPassengerName(string iriName)
mPassengerName = inName;
int AirlineTicket::getNumberOfMiles()
return mNumberOf Miles,-
void AirlineTicket: .-setNumberOfMiles (int inMiles)
Глава 1. Краткий курс C++ 57
iriNumberOfMiles = inMiles,-
bool AirlineTicket::getHasEliteSuperRewardsStatus()
return (fHasEliteSuperRewardsStatus);
void AirlineTicket: :setHasEliteSuperRewardsStatus(
bool inStatus)
fHasEliteSuperRewardsStatus = inStatus;
На примере предыдущей программы демонстрируется общий синтаксис создания
и использования классов. Более подробно механизмы определения С++-классов
рассматриваются в главах 8 и 9.
Первая реальная С++-программа
В следующей программе используется пример построения базы данных служащих,
приведенный выше при рассмотрении структур. На этот раз мы напишем
полнофункциональную С++-программу и применим в ней средства, упомянутые в этой главе.
Наша программа будет содержать классы, исключения, потоки, массивы,
пространства имен, ссылки и пр.
Система управления кадрами
Программа, предназначенаая для управления базой данных служащих, должна
быть гибкой и позволять:
Q добавлять запись о новом служащем;
□ фиксировать факт увольнения служащего или перевода его на другую должность;
Q просматривать записи всех служащих (в прошлом и настоящем);
□ просматривать записи всех работающих и работавших ранее служащих.
Эта программа разделена на три части. В классе Employee инкапсулирована
информация, описывающая одного служащего. Класс Database предназначен для
управления всеми служащими компании. Кроме того, здесь создан отдельный файл
Userlnterf асе, который обеспечивает интерактивность программы.
Класс Employee
Класс Employee позволяет поддерживать всю информацию об одном служащем.
Его методы предоставляют возможность формировать запросы к базе данных и
изменять хранимую там информацию. Объекты класса Employee также способны
отображать содержимое своих членов данных на экране. Кроме того, здесь предусмотрены
методы регулировки ставок заработной платы служащих и их статуса занятости.
Файл Employee. h
В файле Employee. h описывается поведение класса Employee. Отдельные
разделы этого файла приводятся ниже по мере необходимости.
58 Часть I. Введение в профессиональное С++-проектирование
// Employee.h
#include <iostream>
namespace Records {
В первой строке файла Employee. h содержится комментарий, идентифицирующий
имя файла. При выполнении следующей строки кода в программу включается
заголовочный файл, содержимое которого обеспечивает функционирование потоков ввода-вывода.
Третья строка кода содержит объявление, означающее, что последующий код
(заключенный в фигурные скобки) относится к пространству имен Records. Это
пространство имен используется для кода всей этой программы.
const int kDefaultStartingSalary * 30000;
Эта константа, которая по умолчанию представляет стартовое значение оклада для
новых служащих, определена в пространстве имен Records. Код, который определен
в том же пространстве имен, может получать доступ к этой константе без
уточняющего префикса, используя только имя kDefaultStartingSalary. Вне этого
пространства имен к ней пришлось бы обращаться не просто по имени, но и по "фамилии":
Records::kDefaultStartingSalary.
Ниже показана первая часть объявления класса Employee (его public-методы).
Методы promote () и demote () принимают целочисленные параметры, для которых задано
значение по умолчанию. Поэтому при вызове любого из этих методов аргумент можно
опустить, и тогда в качестве параметра автоматически будет использовано значение,
равное числу 1000. Для изменения данных, хранимых в объектах класса Employee,
а также для их считывания используется ряд методов доступа к членам данных класса.
clase Employee
{
public:
Employee () ;
void promote{int inRaiseAmount « 1000)t
void demote(int inDemeritAmount * 1000);
void fciret); // прием служащего на работу
void fire О; // увольнение служащего
void displayО; // отображение информации о служащем
// Методы доступа к членам данных,
void setFirstName(std::string inFirstName);
std: : string getFirstNarae () ,-
void setLastName (std:: string xnLastName) ,-
std;:string getLastName<);
void setEmployeeNumber (int inEmployeeNumber) ,-
int getEmployeeNumber{);
void -*etSaiary(int inNewSalary);
int getSalaryO ;
bool getlsHiredO;
Во второй части класса Employee объявлены его закрытые члены данных.
Благодаря использованию спецификатора private код за пределами класса не сможет
модифицировать их напрямую. Получить их значения или установить новые можно
только с помощью открытых (public) методов доступа.
Глава 1. Краткий курс C++ 59
private:
std::string
std::string
int
int
bool
mFirstName;
mLastName,-
mEmployeeNumber
mSalary;
fHired;
}
Файл Employee. cpp .
Рассмотрим реализацию методов класса Employee.
// Employee.cpp
^include <iostream>
#include "Employee.h"
using namespace std;
namespace Records {
Employee::Employee(>
mFirstName = "",-
mLastName = "" ;
mEmployeeNumber ■= -1;
mSalary ■= kDefaultStartingSalary;
fHired = false;
}
Конструктор класса Employee устанавливает начальные значения для членов
данных этого класса. По умолчанию информация о новых служащих, заносимая в базу
данных, не содержит имени, номер устанавливается равным —1, размер зарплаты —
стартовому окладу, а статус занятости — значению false.
void Employee::promote(int inRaiseAmount)
{
setSalary(getSalaryf) + inRaiseAmount);
}
void Employee::demote(int inDemeritAmount)
setSalary(getSalary() - inDemeritAmount);
Методы promote () и demote () всего лишь вызывают метод setSalary (),
передавая ему в качестве параметра новое значение. Обратите внимание на то, что в
файле исходного кода для целочисленных параметров значения по умолчанию не
указаны. Они представлены только в заголовочном файле.
void Employee::hire()
{
fHired = true;
}
void Employee::fire О
fHired = false,-
}
60 Часть I. Введение в профессиональное С++-проектирование
Назначение методов hire () и f ire () состоит в соответствующей установке
члена данных f Hi red.
void Employee::display()
{
cout « "Служащий: " « getLastName() « ", "
« getFirstName.O << endl;
cout « " - " « endl;
cout « (fHired ? ''штатный" : "бывший")
« endl;
cout « "Номер служащего: " « getEmployeeNumber()
« endl;
cout « "Оклад: $" << getSalaryO << endl;
cout « endl;
}
В методе display () для отображения информации о штатном служащем
используется поток вывода данных на консоль. Поскольку этот код является частью класса
Employee, он имеет право непосредственного доступа к таким членам данных, как
mSalary, т.е. он мог бы обойтись без метода getSalary (). Однако использование
методов доступа к закрытым членам класса считается хорошим стилем
программирования даже для методов-членов того же класса.
// Методы доступа к закрытым членам данных.
void Ещ»1оуее::setFirstName(string inFirstName)
mFirstSaae * InFirstName,-
string Bu^iloyee: :getFirstName ()
return mFirstName,-
void Employee::setLastName(string inLastName)
mLastName = inLastName;
string Employee::getLastName()
return mLastName;
void Employee: :setEmployeeNumber(int inEmployeeNutnber)
mEmployeeNumber = inEmployeeNumber,-
int Employee::getEmployeeNumber()
return mEmployeeNumber,-
void Employee::setSalary(int inSalary)
mSalary ,= inSalary;.
int Employee::getSalary()
Глава 1. Краткий курс C++ 61
return mSalary;
}
bool Employee::getIsHired()
{
return fHired;
}
}
Приведенные выше методы доступа к закрытым (private) членам данных класса
выполняют простую задачу считывания и установки значений. Несмотря на то что
эти методы кажутся тривиальными, все же лучше иметь их такими, чем оставить
члены данных открытыми (public). В дальнейшем, возможно, имеет смысл
дополнить некоторые методы доступа (например, метод setSalary ()) граничной
проверкой (т.е. проверкой на отсутствие нарушения границ).
Файл EmployeeTest.cpp
Создав классы, часто имеет смысл их протестировать в отдельности. Следующий
код включает функцию main (), предназначенную для выполнения некоторых простых
операций, в которых участвует класс Employee. Убедившись, что класс Employee
функционирует ожидаемым образом, файл EmployeeTest. cpp следует удалить или
превратить в комментарий, чтобы не скомпилировать код с двумя функциями main ().
// EmployeeTest.cpp
#include <iostream>
finclude "Employee.h"
using namespace std,-
using namespace Records;
int main (int argc, char** ergv)
cout « "Тестирование класса Employee." « endl;
Employee emp;
emp.setFirstName("Marni");
emp. settiastttame (" Kleper *} ;
emp. setEmployeeNuraber (71);
emp.setSalary (50000);
emp. promote () ,-
emp .promote(50);
emp.hireO ;
emp. display О ,-
return 0;
}
Класс Database
Класс Database обеспечивает хранение объектов типа Employee в виде массива.
Для указания очередного свободного элемента используется целочисленная
перемещая mNextSlpt. Такой способ хранения элементов нельзя назвать идеальным,
повдкцьку этот массив имеет фиксированный размер. В главах 4 и 21 вы узнаете
О структурах данных, предлагаемых стандартной библиотекой C++, которые можно
использовать вместо массива.
62 Часть I. Введение в профессиональное С++-проектирование
Класс Database. h
// Database.h
# include <iostream3»
#include "Employee.h"
namespace Records {
const int kMaxEmployess - 100;
const Int kFirstEmployeeNumber « 1000,-
С этой базой данных связаны две константы. Одна из них (определяющая
максимальное количество служащих), используется для создания массива фиксированного размера,
в котором хранится информация о служащих. Поскольку в механизме
функционирования базы данных предусмотрено автоматическое присвоение новому служащему
индивидуального номера, то вторая константа определяет начальный номер служащего.
class Database
{
public;
Database () ,-
-Database();
Employees; addEmployee(std: :string inFirstName,
std::string inLastName);
Employees getEmployee (int inEmployeeNumber) ;
Employees getEmployee(std::string inFirstName,
std::string inLastName);
Для добавления в базу данных новой записи предусмотрен простой метод addEm-
ployee (), при вызове которого в качестве параметров достаточно передать имя и
фамилию нового служащего. Для удобства этот метод возвращает ссылку на новую запись,
т.е. на объект класса Employee. Внешний (по отношению к классу Database) код также
может получить ссылку на интересующую его запись, вызвав метод getEmployee (). Как
видите, в приведенном выше коде объявлены две версии этого метода. Одна
позволяет получить ссылку по номеру служащего, а вторая — по имени и фамилии.
void displayAlU);
void displayCurrentО ;
void display Former О ,-
Поскольку в базе данных хранятся записи обо всех служащих, в ней
предусмотрены методы вывода данных для разных групп служащих: штатных, уволившихся
(бывших) и вообще всех.
protected:
Employee mEmployees[kMaxEmployees];
int mNextSlot;
int mNextEmployeeNumber,-
Массив mEmployees, который содержит объекты класса Employee, имеет
фиксированный размер. При создании базы данных этот массив заполняется информацией о
безымянных служащих с номерами (для всех "подряд") —1. При вызове метода addEm-
ployee () одна из таких "безликих" записей заполняется реальными данными. Член
mNextSlot содержит номер очередной записи, которая "готова" принять информацию
о новом служащем. Член данных mNextEmployeeNumber содержит номер, который
будет присвоен новому служащему при формировании его записи в базе данных.
Глава 1. Краткий курс C++ 63
Файл Database. срр
// Database.срр
♦include <iostream>
#include <stdexcept>
iinclude "Database.h"
using namespace std;
namespace Records {
Database::Database()
{
triNextSlot - Of
niNextEmployeeNumber = kFirstEmployeeNumber;
Database::-Database()
Конструктор класса Database инициализирует переменные, предназначенные для
хранения номера следующей записи и индивидуального номера служащего. Член
iiiNextSlot инициализируется нулем, поэтому при внесении в базу данных записи о
первом служащем будет заполнен элемент массива mEmployees, индекс которого равен нулю.
Employees Database::addEmployee(string inFirstHame,
string inLastName)
{
if (mlJextSlot >■ kMaxEmployees) {
csrr « "Для записи о новом служащем места нет!"
« endl;
throw exception(>;
}
Employees: theEmployee m mEmployees[mNextSlot++] ,-
theEmployee, setFirstName (inFirstName) ;
theEmployee.BetLastName (inLastName) ;
theEmployee. setEmployeeNumber (mNextEmployeeNumber++) ;
theEmployee.hire();
return theEmployee,-
Метод addEmployee () позволяет заполнить очередную "пустую" запись реальными
данными. Выполнив начальную проверку, мы либо убеждаемся, что массив mEmployees
1 еще не заполнен "до отказа", либо генерируем исключение. Обратите внимание на
то, что после использования членов данных mNextSlot и mNextEmployeeNumber
выполняется их инкрементирование, чтобы база данных подготовилась к "приему"
-,- нового служащего с очередным номером.
..®5>1оуе'еЬ Database: ;getEmployee(int inEmployeeNumber)
-if f;
, for (int i к Oi i < mNextSlc-t; i++) { ,,..,-
J .s:, ,, if (mEmployees[i],getEmployeeNumber() == InEmployeeNumber) {
■ ■}. ■?' return mEmployees[i] ;
.: ;#«.■ }, ~ .
:& '" "
64 Часть I. Введение в профессиональное С++-проектирование
сегг « "В базе данных нет служащего с номером "
« inEmployeeNumber « endl;
throw exception();
}
Employee^ Database::getEmployee(string inFirstName,
string inLastName)
for (int i = 0; i < mNextSlot; i++> {
if (mEtnployees [i] .getFirstNameO == inFirstName &&
mEtnployees [i] .getLastName () == inLastName) {
return mEmployeestil ;
cerr « "В базе данных нет служащего с именем "
« inFirstName << " " « inLastName « endl,-
throw exception();
}
Обе версии метода getEmployee () работают аналогичным образом. В обоих
случаях в цикле проверяются все непустые элементы массива mEtnployees на предмет
совпадения данных в каждом из объектов класса Employee с информацией,
переданной методу. Если совпадение не обнаружено, выводится соответствующее сообщение
об ошибке и генерируется исключение.
void Database::displayAll()
for (int i e 0; i < mNextSlot,- i++> {
mEmployees [i] . display (> ,-
void Database!:displayCurrent(>
for (int i = 0; i < mNextSlot; i++) {
if (mEmployeestil.getlsHiredO) {
mEmployees[i].display0;
,'*
void Database::displayFormer(>
for (int i » 0; i < mNextSlot; i++) f
if ('mEmployees[i] .getlsHiredO) {
mEmployees [ij . display ();
i ' '
}
Все методы отображения информации о служащих используют сходный алгоритм.
В цикле просматриваются все непустые записи, и при условии совпадения с заданным
критерием их содержимое выводится на экран.
Файл DatabaseTest.cpp
Ниже приводится простой тест выполнения базой данных ее основных функций.
// DatabaseTest.cpp
Глава 1. Краткий курс C++ 65
#include <iOstreara>
#include "Database.h"
using namespace std;
using namespace Records,-
int main(int argc, char** argv)
{
Database myDB;
Employees empl = myDB.addEmployee("Greg", "Wallis");
empl.fireO ;
Employees emp2 = myDB.addEmployee("Scott", "Kleper");
' emp2.setSalary(100000) ;
Employees emp3 = myDB.addEmployee("Hick", "Solter");
emp3,setSalary(10000);
emp3.promote();
cout « "Все служащие: " « endl;
cout « endl,-
myDB. displayAll () ;
cout « endl;
cout « "Штатные служащие: " « endl;
cout << endl;
myDB.displayCurrent 0;
cout « endl;
cout « "Бывшие служащие: " « endl;
cout « endl;
myDB.displayForraer();
Интерфейс пользователя
Финальная часть программы представляет собой пользовательский менютоггерфейс,
который делает взаимодействие с базой данных служащих удобным и простым.
Файл Userlnterfасе.ерр
// Userlnterf асе. ерр
#include <i0stream>
♦include <stdexcept>
^include "Database.h"
using namespace std;
using namespace Records;
int displayMenuO ;
void doHire (Databases inDB) s
void doFire^Databaseb inDB) ;
void doPromote(Database& inDB);
void doDemote(Databases inDB);
int main(int argc, char** argv)
{
Database employeeDB;
66 Часть I. Введение в профессиональное С++-проектирование
bool done * false,-
while {tdone) {
int selection'к display-Menu О ;
switch (selection) .{
case l:
doHire(employeeDB);
break;
case 2:
* doiM-re (employee!»); у
break;
case 3s
dopromote(employeeDB);
break;
case 4:
employeeDB.displayAll(};
break;
case 5:
en^iloyeeDB,jdi6playCurrent();
break;
case 6:'
en$loyeeDB,displayFbrmer();
break;
case Os ,
done = true;
break;
default:
cerr << ^Неизвестная команда." « endl;
}
}
return 0;
}
Функция main () содержит цикл, на каждой итерации которого отображается
меню и обеспечивается выполнение выбранной пользователем команды. Для
реализации большинства команд меню определены отдельные функции, остальные же (более
простые) реализованы кодом, вставленным в соответствующие case-ветви.
int displayMenu()
i
i,«jt «election;
cout << endl;
cout « "Ваза данных служащих" « endl;
cout « и *■*■ -^—.—.-.-в « endl;
cout « "1) Прием на работу нового служащеяо" «, endl;
cout « и2) Увольнение служащего* « endl;
cout « и3) Повышение служащего" << endl;
cout_ <;< «4) Список всех служащих." « endl;
cout « "5) Список всех штатных служащих* « endl;
cout << "€) Список всех бывших служащих'! « endl;
cout << "0) Быходв « endl;
cout « endl;
cout « и -> ";
Gin » selection; '
return selection;
Глава 1. Краткий курс C++ 67
Функция displayMenu О предназначена для вывода меню на экран и приема
команды от пользователя. Здесь важно отметить, что данный код предполагает
"хорошее поведение" пользователя, т.е. ввод пользователем именно цифр (а не букв,
например). Прочитав главу 14, вы узнаете, как защититься от ввода "плохих" данных.
void doHire(Databases inDB)
{
string firetName;
string laetName;
«out.« "Имя? ";
cin » CirstHame;
cput « "Фамилия? "; >'
cin » lastName,-
try {
inDB. addEmployee (f irstName, laetName);
} catch (std::exception ex) {
cerr « "He удается добавить новую запись I » '« endl,-
}
}
Назначение функции doHire () — принять от пользователя имя и фамилию
нового служащего и обеспечить внесение в базу данных новой записи. В случае
возникновения ошибки выводится соответствующее сообщение, после чего работа базы
данных продолжается "как ни в чем не бывало".
void doFire (Databases; inDB)
{
Int entployeeNumber;
cout « "Номер служащего? ";
cin >> employeeNumber; л*
Employees emp « inDB.getEmployee(employeeNumber) ,-
emp.fireO ;
cout « "Служащий с номером " ••« employeeNumber
<< " уволен." « endl;
} catch (std:: exception ex)
cerr <s< "He удается реалиэовавь увольнение служащего!" " ,__
.«. endl;
• }
}
void doPromote(Database^ inDB)
{
int employeeNumber;
int raiseAmount;
cout « "Номер служащего? ";
cin » employeeNumber;
cout « "Размер повышения оклада? ";
cin >> raieeAmount ;
try {
Employees emp * inDB.getEmployee(employeeNumber);
emp,promote(raiseAmount);
} catch (std::exception ex) {
68 Часть I. Введение в профессиональное С++-проектирование
сегг « "Не удается реализовать повышение служащего!"
« еп<31;
}
}
Обе функции doFire () и doPromote () делают запрос в базу данных, чтобы найти
в ней запись по заданному номеру служащего, а затем используют public-методы
класса Employee для внесения изменений в найденный объект этого класса.
Оценка программы
В предыдущей программе нашли свое отражение темы разного уровня сложности:
от самых простых до более трудных. При этом наш вариант программы оставляет
массу возможностей для расширения. Например, пользовательский интерфейс не
охватывает весь спектр функций, реализованных в классах Database или Employee.
Поэтому вы могли бы модифицировать его, включив в меню работы с базой данных
новые команды. Можно также изменить класс Database, обеспечив удаление из базы
данных (из массива mEmployees) записей с данными об уволенных служащих. Такой
подход потенциально сэкономит пространство базы данных.
Если некоторые части этой программы вам кажутся неясными, попробуйте
перечитать соответствующие теоретические разделы и поиграть с кодом, чтобы на
практике проверить свои предположения и убедиться в их правильности или открыть для
себя что-то новое в очевидных, на первый взгляд, вещах. Например, если вы не
уверены в своих знаниях об использовании тернарного оператора, напишите короткую
функцию main () и реализуйте в ней свое намерение ликвидировать все белые пятна
по данной теме программирования.
Резюме с перспективой
Теперь, когда вы вооружены знанием основных элементов языка C++, у вас есть
все предпосылки, чтобы стать профессиональным С++-программистом. В следующих
пяти главах вы получите (на идейном уровне) представление о некоторых важных
принципах разработки С++-пррграмм, не увязая в деталях синтаксиса.
Чтобы восстановить в памяти подзабытые мелочи, возможно, вам будет
достаточно просмотреть приведенные здесь фрагменты кода. Поэтому при дальнейшей работе
с данной книгой не забывайте об этой главе и возвращайтесь к ее разделам по мере
необходимости.
Разработка
профессиональных
С++-программ
Прежде чем написать хотя бы одну строку кода, необходимо сначала спроектировать
программу. Для этого нужно ответить на следующие вопросы. Какие структуры данных
вы собираетесь использовать? Какие классы потребуются программе? Такой план
приобретает особую важность при работе в группах. Только представьте себе: вы пишете
программу и не имеете ни малейшего представления о намерениях своего коллеги,
который работает над той же программой! Ужасно, не правда ли? Поэтому мы покажем,
как использовать профессиональный С++-подход к С++-проектированию.
Несмотря на значимость этапа проектирования, он, вероятно, является самым
недооцененным и не до конца используемым аспектом процесса разработки
программного обеспечения. Слишком уж часто программисты "с наскоку" берутся за создание
приложений, не имея четкого плана действий, т.е. они "проектируют" программу по
ходу создания кода. Такой подход неминуемо приводит к появлению "закрученных"
и чрезмерно усложненных проектов, а также затрудняет задачи разработки, отладки
и поддержки приложений. Опыт показывает, что дополнительное время, затраченное
на проектирование в начале работы над приложением, существенно экономит общее
время создания проекта.
70 Часть I. Введение в профессиональное С++-проектирование
Глава 1 содержит обзорный курс синтаксиса и средств C++. В главе 7 мы вернемся к
деталям С++-синтаксиса, посвятив все остальные главы части I вопросам
проектирования программных продуктов.
Завершив чтение этой главы, вы должны знать следующее:
□ что такое проектирование программ;
□ в чем состоит значимость проектирования программ;
□ аспекты проектирования, уникальные для C++;
□ два фундаментальных принципа эффективного С++-проектирования:
абстракция и многократное использование;
□ специфические компоненты, составляющие проект программы в C++.
Что такое проектирование программ?
Проектирование программ (programm design) — это спецификация (т.е. подробное
описание) архитектуры, которую вы собираетесь реализовать для удовлетворения
требований к функциональным и рабочим характеристикам программы. Неформально под
проектированием понимается просто план написания программы. Этот план может
быть создан в форме проектного документа. Несмотря на то что каждая компания или
проект имеет собственный формат проектной документации, большинство таких
документов строятся по одинаковой схеме и включают следующие две основные части.
1. Подразделение программы на подсистемы с описанием интерфейсов и
зависимостей между подсистемами, потоков данных между подсистемами, схем ввода-
вывода данных для каждой подсистемы и общей модели организации поточной
обработки (сообщений или данных).
2. Детали каждой подсистемы, включающие разделение на классы, описание
иерархии классов, структур данных, алгоритмов, конкретной модели поточной
обработки и особенностей обработки ошибок.
Проектные документы обычно включают диаграммы и таблицы, отображающие
взаимодействие подсистем и иерархий классов. Точный формат проектной
документации гораздо менее важен, чем процесс обдумывания проекта программы.
Суть проектирования состоит в необходимости подумать о программе,
прежде чем приступать к ее написанию.
До начала написания программы ее проектирование должно быть завершено хотя
бы в общих чертах. В результате проектирования мы получаем "карту", следуя
которой, любой здравомыслящий программист сможет реализовать приложение.
Безусловно, с момента начала кодирования проект программы неизбежно потребует
внесения изменений, и вы обязательно столкнетесь с проблемами, о существовании
которых ранее и не думали. Поэтому процесс проектирования программы должен
предполагать определенную гибкость для реализации таких изменений. Более
детально различные модели процесса проектирования программ описаны в главе 6.
Глава 2. Разработка профессиональных С++-программ 71
Значимость проектирования программ
Да, нам, программистам, вполне понятно стремление опустить этап
проектирования или выполнить его "по-быстрому", чтобы как можно скорее начать
программировать. Ведь кажется, что только компиляция и запуск программы создает
впечатление прогресса в вашей работе. И как жалко тратить время на формализацию проекта,
когда уже и так знаешь (более или менее), как структурировать программу! И, потом,
составлять проектные документы не так интересно, как кодировать. Если бы целью вашей
жизни было "писать бумаги" с утра до вечера, вы бы никогда не стали программистом,
черт подери! Мы, как программисты, хорошо понимаем соблазн немедленно начать
программировать и (не скроем) иногда поддавались ему. Но это неизменно приводило
к разнообразным проблемам, но только не к созданию хороших проектов.
Чтобы понять важность проектирования программ, рассмотрим аналогию из ре
альной жизни. Представьте себе, что вы владеете участком земли, на котором хотите
построить дом. Когда строитель приступает к работе, с вашей стороны вполне
естественно попросить его показать вам чертежи. Но он удивленно говорит: "Какие
чертежи? Я знаю что делать. Мне не нужен план. Я все держу в голове. Двухэтажный дом?
Нет проблем — месяц назад я построил одноэтажный дом — ту модель я просто
возьму за основу и сделаю необходимые дополнения".
Предположим, вы подавили в себе недоверие к такому положению вещей и
позволили строителю приступить к работе. Несколько месяцев спустя вы замечаете, что
водопроводно-канализационная сеть оказалась снаружи, а не внутри стен дома. На
вопрос о такой, с вашей точки зрения, аномалии строитель ответил, что для
прокладки труб он забыл оставить место в стенах: "Я был в таком восторге от этой новой
технологии сухой кладки стен, что начисто забыл обо всем остальном. Ведь неважно, где
проходят трубы, главное, чтобы все работало!". У вас начинают зарождаться
сомнения в профессионализме этого строителя, но вы (из чувства такта, может быть)
позволяете ему продолжить работу.
Предприняв через некоторое время первый осмотр законченного дома, вы
замечаете, что в кухне не хватает раковины. Строитель же, немного конфузясь, говорит:
"Мы сделали уже 2/3 работы в кухне, когда стало ясно, что для раковины нет места.
Поэтому, чтобы не переделывать кухню сначала, мы просто добавили отдельное
помещение с раковиной рядом с кухней. Вполне нормальное решение, не правда ли?".
Написание программы без проектирования подобно строительству
дома без чертежей.
Не покажутся ли вам знакомыми "извинения" строителя, если "перевести" их в
область программного обеспечения? Приходилось ли вам когда-либо заниматься
реализацией "скверного" решения проблемы, подобной прокладке труб снаружи дома?
Возможно, вы забыли предусмотреть установку блокировки в структуре данных
очереди, которая совместно используется несколькими потоками. К тому моменту, когда
вы поняли причину проблемы, вам показалось для ее решения проще всего выдвинуть
требование, чтобы все потоки не забывали сами устанавливать блокировку. Да,
сказали вы, пусть и коряво, но работает же! Однако все дело в том, что такая
программа будет работать только до тех пор, пока к приложению не присоединится новый
пользователь, полагающий, что механизм блокировки встроен в структуру данных,
и которому не удастся обеспечить взаимное исключение доступа к общим данным.
72 Часть I. Введение в профессиональное С++-проектирование
Хуже того, в результате такой непродуманности может возникнуть ошибка состояния
гонок, на устранение которой может уйти недели три.
Формализация проекта до начала кодирования поможет вам определить,
насколько хорошо все части совмещаются друг с другом. Подобно тому, как чертежи для дома
показывают, как комнаты связаны одна с другой и как работают все коммуникации,
так и проект программы иллюстрирует, как связаны между собой отдельные ее
подсистемы и как они взаимодействуют друг с другом. Без плана существует большая
вероятность пропустить установку связей между подсистемами, не позаботиться о
возможности многократного использования информации или обеспечении совместного
доступа к данным. Без плана, наконец, можно не увидеть самые простые способы
выполнения поставленных задач. Не имея "всей картины", которую можно получить при
проектировании, вы рискуете увязнуть в отдельных деталях реализации, потеряв
основную идею архитектуры и цель всей разработки. Более того, процесс проектирования
сопряжен с составлением документации, к которой смогут обращаться все члены проекта.
Если рассмотренная выше аналогия пока не убедила вас в необходимости
проектирования приложения до его кодирования, приведем еще один пример, в котором
кодирование "с наскока" не позволяет принять оптимальное архитектурное решение.
Предположим, вы задумали написать шахматную программу. Вместо проектирования
всей программы до начала программирования вы решили перво-наперво написать
самые простые части программы, а затем постепенно перейти к более трудным
участкам работы. В соответствии с объектно-ориентированной перспективой
программирования, частично описанной в главе 1 и более детально изложенной в главе 3, вы
принимаете решение смоделировать свои шахматные фигуры с помощью классов.
Самая простая шахматная фигура — пешка, поэтому вы предпочитаете начать с нее.
Рассмотрев свойства и особенности поведения пешки, вы пишете класс,
представленный в следующей таблице.
Класс
Пешка
Свойства
Расположение на доске
Цвет (черный или белый)
Состояние потери
Поведение
Переместить ("пойти")
Проверить легальность перемещения
Взять фигуру
Превратиться в фигуру более высокого уровня
(по достижении противоположного края доски)
Конечно, в действительности вы не составляете такую таблиц)', а прямиком
переходите к реализации. Создав класс пешки и неимоверно радуясь своему прогрессу, вы
бодро переходите к следующей простой фигуре: слону. Вспомнив его атрибуты и
поведенческие функции, вы пишете класс слона, рассмотренный в следующей таблице.
Класс Свойства Поведение
Слон Расположение на доске Переместить ("пойти")
Цвет (черный или белый) Проверить легальность перемещения
Состояние потери Взять фигуру
И снова^гаки вы не составляете никакой таблицы, поскольку вам не терпится
погрузиться в процесс кодирования. Но тут у вас появляется ощущение, что вы делаете что-то
не так. Слон и пешка выглядят как-то подозрительно похожими. И в самом деле, их
свойства идентичны, да и составляющие поведения в большинстве своем совпадают.
Глава 2. Разработка профессиональных С++-программ 73
Несмотря на то что в реализации перемещения этих фигур есть серьезные различия,
обеим фигурам присуща способность перемещаться по шахматной доске. Если бы вы
спроектировали свою программу еще до начала программирования класса пешки, вы
бы поняли, что различные фигуры в действительности очень похожи, и что вам бы
следовало найти некоторый способ описания их общей функциональности только
один раз. Методы объектно-ориентированного проектирования для реализации этой
идеи разъясняются в главе 3.
Более того, некоторые аспекты шахматных фигур зависят от других подсистем
вашей программы. Например, вы не можете точно представить положение на доске
в классе любой шахматной фигуры, не зная, как будет смоделирована сама доска. С
другой стороны, возможно, вы спроектируете свою программу так, что управлять
размещением фигур на доске будет сама доска, и поэтому фигурам и не нужно будет знать о своем
положении на ней. В любом случае кодирование местоположения в классах фигур до
проектирования программного образа доски приведет к проблемам. Или, скажем, как
можно написать метод взятия фигуры, не определив сначала интерфейс пользователя
вашей программы? Каким он будет: графическим или текстовым? Как должна выглядеть
доска на экране? Ведь все дело в том, что подсистемы программы существуют не
изолированно друг от друга — они взаимосвязаны с другими подсистемами. Большая часть
работы по проектированию как раз и направлена на определение этих взаимоотношений.
Что особенного в проектировании
С++-программ?
Язык C++ обладает рядом особенностей, которые отличают проектирование С++-
программ от проектирования программ, ориентированных на другие языки
программирования.
□ Во-первых, C++ имеет огромный набор инструментов. Он почти полностью
включает множество средств языка С, дополняя его классами и объектами,
перегрузкой операторов, исключениями, шаблонами и многими другими средствами.
Только один размер языка делает задачу проектирования устрашающе сложной.
О Во-вторых, C++— объектно-ориентированный язык программирования. Это
означает, что проект должен включать иерархии классов, интерфейсы классов
и взаимодействие объектов. Этот вид проектирования довольно сложен с точки
зрения "традиционного" проектирования в расчете на язык С или другие
процедурные языки. Об особенностях объектно-ориентированного
проектирования речь пойдет в главе 3.
О В-третьих, уникальность C++ состоит в возможности проектирования
обобщенного и многократно используемого кода. Кроме базовых классов и
механизма наследования, вы можете использовать такие средства, как шаблоны
и возможность перегрузки операторов. Методы создания многократно
используемого кода описаны в главе 5.
□ В-четвертых, C++ позволяет использовать стандартную библиотеку,
включающую класс string, средства ввода-вывода, а также множество полезных
структур данных и алгоритмов. Проектированию программ с использованием
стандартной библиотеки C++ и шаблонному проектированию, означающему
применение общих методов решения проблем, посвящена глава 4.
74 Часть I. Введение в профессиональное С++-проектирование
С учетом перечисленных выше особенностей языка C++ создание проекта для С++-
прш рам мы может быть сопряжено с большими трудностями. Одному из авторов этой
книги потребовалось несколько дней на то, чтобы сначала изложить идеи по
проектированию на бумаге, затем перечеркнуть все, написать новые, снова перечеркнуть
их и повторить этот процесс еще раз. Тем не менее такие многодневные муки
творчества часто приводят к ясному пониманию того, как должен выглядеть проект
эффективной программы. Не исключено также, что ваши страдания не увенчаются успехом.
В любом случае важно понять, достигли вы реального прогресса или нет. Если у вас
появилось устойчивое ощущение, что вы застряли, и теперь не знаете, что делать
дальше, попробуйте выполнить одно из следующих действий.
□ Попросите о помощи. Проконсультируйтесь с коллегами, руководителем,
обратитесь к литературе, сетевым конференциям или Web-источникам информации.
□ Переключитесь временно на какую-нибудь другую работу и вернитесь к выбору
архитектурного варианта чуть позже.
Q Примите некоторое решение и переходите к следующему этапу. Даже если это
решение не идеально, примите его как некоторый исходный вариант и
попробуйте с ним поработать. Совершенно неправильный выбор вскоре обязательно
станет очевидным. Но, возможно, что этот "неказистый" вариант окажется вполне
приемлемым. Ведь может быть так, что для достижения желаемого результата не
существует прямого и четкого пути, а ваш "гадкий утенок" может предоставить
единственную реальную стратегию для удовлетворения нужных требований.
Имейте в виду, что для построения хорошего проекта необходимо
затратить силы и время. Опыт и практика— также немаловажные
факторы успеха. Специалистами становятся не вдруг, и поэтому не стоит
удивляться, если вы обнаружите, что гораздо труднее овладеть
мастерством С++-проектирования, чем мастерством С++-кодирования.
Два правила С++-проектирования
В C++ существует два правила проектирования программирования: абстракция
и многократное использование кода. Эти принципы очень важны, и поэтому в этой
книге им уделяется много внимания. Мы неоднократно будем подчеркивать их
значимость для достижения эффективности проектов С++-программ в самых разных
предметных областях.
Абстракция
Принцип абстракции—самый простой для понимания. Рассмотрим такой пример.
Телевидение— это простой образец технологии, которая пришла практически в
каждый дом. Вы можете включить или выключить телевизор, сменить канал, настроить
громкость или подключить такие дополнительные устройства, как звуковые колонки,
видеомагнитофон или DVD-проигрыватель. Но можете ли вы объяснить, как работает
эта техника? Другими словами, знаете ли вы, как принимаются сигналы (по воздух)
или кабелю), как они преобразуются, а затем отображаются на экране? Даже не зная,
как работает телевизор, мы, тем не менее, можем его успешно эксплуатировать. О
телевизоре можно сказать, что его внутренняя реализация надежно изолирована от
Глава 2. Разработка профессиональных С++-программ 75
внешнего интерфейса. Мы взаимодействуем с телевизором через его интерфейс:
кнопку включения питания, панель переключения каналов и регулятор уровня громкости.
Мы не знаем (да нас, честно говоря, это и не очень волнует), как работает телевизор;
нас мало интересует, что в нем используется для генерирования изображения на
экране: электронно-лучевая трубка или какое-нибудь другое устройство. Это не имеет
значения, поскольку не влияет на интерфейс.
Достоинства абстракции
Аналогично дело обстоит с принципом абстракции и в программном обеспечении.
Можно написать программный код, ничего не зная о базовой реализации
используемого средства. Приведем простой пример. Программист может использовать в своей
программе обращение к функции sqrt (), объявленной в заголовочном файле <cmath>, не
зная, какой именно алгоритм реализован в ней для вычисления квадратного корня.
На самом деле базовая реализация вычисления квадратного корня может меняться
с изменением версии библиотеки, но до тех пор, пока ее интерфейс остается
прежним, ваш вызов функции будет работоспособным. Точно так же принцип абстракции
распространяется и на классы. Как упоминалось в главе 1, для вывода данных в
стандартный выходной поток можно использовать объект cout класса ostream.
cout « "При выполнении данной инструкции отображается "
« "эта строка текста.\п";
В данном случае используется легальный интерфейс объекта выходного потока cout
с символьным массивом. При этом вам необязательно понимать, как объект cout
реализует отображение текста на экране пользователя. Вам достаточно знать лишь открытый
(для всех) интерфейс. Базовая реализация объекта cout может изменяться без каких бы
то ни было последствий для вас до тех пор, пока его открытое поведение и интерфейс
остаются прежними. Более подробно потоки ввода-вывода описаны в главе 14.
Включение абстракции в проект программы
Вам, возможно, придется проектировать функции и классы в расчете на то, что их
будут использовать другие программисты, не знающие их базовой реализации. Чтобы
понять разницу между проектом с открытой и скрытой (за интерфейсом)
реализацией, снова обратимся к примеру шахматной программы. Для представления шахматной
доски можно взять двумерный массив указателей на объекты класса ChessPiece.
CheBBPiece* chessBoard[10] [10];
ChessBoard[0] [0] = new RookO;
Однако такой подход не соответствует принципу абстракции. Каждый
программист, использующий шахматную доску, будет знать, что она реализована в виде
двумерного массива. В этом случае изменение реализации шахматной доски (например,
мы могли бы заменить массив указателей массивом векторов) было бы сопряжено
с определенными трудностями, поскольку нам пришлось бы вносить изменения в
каждое обращение к образу шахматной доски по всей программе. Как видите,
интерфейс здесь не отделен от реализации.
Гораздо лучше было бы смоделировать шахматную доску в виде класса. В этом
случае детали базовой реализации мы могли бы скрыть за открытым интерфейсом.
Рассмотрим пример класса ChessBoard.
76 Часть I. Введение в профессиональное С++-проектирование
class ChessBoard {
public:
// В этом примере опущены конструкторы, деструкторы
// и оператор присваивания,
void setPieceAt (ChessPiece* piece, int x, int y) ,-
ChessPieceb getPieceAt(int x, int y);
bool isEmptyfint x, int y);
protected:
// Здесь опущены члены данных.
};
Обратите внимание на то, что этот интерфейс не опирается на какую-то
определенную базовую реализацию. В классе ChessBoard вполне можно было бы
использовать двумерный массив, но это не является требованием интерфейса. Главное то, что
изменение реализации не требует изменения интерфейса. Более того, при таком
подходе мы можем обеспечить для шахматной доски дополнительные функции
(например, проверку на отсутствие нарушения границ), что было бы невозможно
сделать в первом (открытом) варианте ее реализации.
Будем надеяться, что этот пример убедил вас в том, что абстракция — один из
важнейших принципов С++-программирования. В главах 3 и 5 абстракция и объектно-
ориентированное проектирование рассматриваются более детально, а в главах 8 и 9 вы
найдете подробное описание процесса создания собственных классов.
Многократное использование кода
Второе фундаментальное правило проектирования в C++ — обеспечение
многократного использования кода. Для понимания этого принципа полезно рассмотреть
аналогию из реального мира. Предположим, что вы отказались от карьеры
программиста и выбрали профессию булочника. В первый же рабочий день ваш начальник
велит вам испечь булочки. Для выполнения приказа вы находите в поваренной книге
рецепт булочек с шоколадной начинкой, смешиваете ингредиенты, выкладывайте
сформированные заготовки булочек на противень и ставите его в духовой шкаф. Ваш
начальник доволен результатом.
Теперь мы сообщим вам нечто тривиальное, что, тем не менее, удивит вас: духовой
шкаф, в котором выпекались булочки, был сделан не вами. И точно так же вы не сами
приготовили масло, не сами смололи муку. "Это же само собой разумеется", — скажете
вы. И будете правы, если речь идет о реальном булочнике. А теперь представим себе,
что вы остались программистом и хотите написать игру, в которой имитируется
процесс выпечки булок. В этом случае вы должны подумать о создании всех необходимых
программных компонентов: от шоколадных булочек до духового шкафа. Однако вы можете
сэкономить время, воспользовавшись чужими заготовками. Возможно, ваш коллега когда-
либо уже писал подобную имитационную игру, и у него "завалялся" неплохой код духового
шкафа. Может быть, его духовой шкаф не полностью отвечает вашим требованиям, но вы
могли бы подправить все, что вам нужно, или что-то добавить к тому, что есть.
Помните, находясь в роли "реального" булочника, для приготовления теста вы
использовали рецепт? Ведь это же был не ваш рецепт булочек? "Само собой", — опять
скажете вы. Однако в С++-программировании это само собой не разумеется. Несмотря
на существование стандартных способов решения проблем, которые вновь и вновь
возникают в C++, многие программисты со странной настойчивостью заново
изобретают эти стратегии в каждом очередном проекте.
Глава 2. Разработка профессиональных С++-программ 77
Повторное использование кода
Мы уверены, что идея использования существующего кода не нова для вас. Вы
этим занимались с первого дня, когда попытались вывести что-либо на экран с
помощью объекта cout. Ведь вы не писали код реального вывода данных на экран, а лишь
заставили сделать это существующую реализацию класса ostream.
К сожалению, программисты часто не используют преимущества доступного им
кода. Этому надо положить конец. Ваши проекты должны рассчитывать на код, уже
созданный кем-то, и использовать его с максимальной эффективностью.
Предположим, например, что вы хотите написать планировщик операционной
системы. Планировщик— это компонент операционной системы, который принимает
решение о том, какой процесс выполнять и в течение какого времени. Поскольку вы
хотите реализовать приоритетный планировщик, то вы понимаете, что вам потребуется
действующая по приоритету очередь, в которой будут храниться процессы, ожидающие
запуска. Самый прямолинейный подход к решению этой задачи состоит в
написании собственной приоритетной очереди. Однако вам следует знать, что в
стандартной С++-библиотеке шаблонов (standard template library — STL) предусмотрен
контейнер priority_queue, который можно использовать для хранения объектов любого
типа. Таким образом, вам не нужно тратить время на создание собственной
приоритетной очереди, а достаточно вставить уже имеющийся контейнер priority_queue из
библиотеки STL в свой проект планировщика. Более детально тема многократного
использования кода (и стандартной С++-библиотеки шаблонов) рассматривается в главе 4.
Создание повторно используемого кода
О повторно используемом коде можно говорить применительно как к коду,
который вы пишете сами, так и к коду, уже написанному кем-то другим. Следует
проектировать свои программы так, чтобы вы сами могли впоследствии снова использовать
свои классы, алгоритмы и структуры данных. Вы и ваши коллеги должны научиться
применять свои программные компоненты как в текущем, так и в будущих проектах.
Лучше не проектировать слишком уж узкоспециальный код, который может работать
только в данном конкретном случае.
Одним из способов написания кода общего назначения в C++ является шаблон.
Продемонстрируем создание шаблонной структуры данных на следующем примере.
Если вам не приходилось видеть подобный синтаксис ранее, не переживайте! Его
подробное разъяснение вы найдете в главе 11.
Итак, вместо рассмотренного выше конкретного класса ChessBoard,
предназначенного для хранения объектов типа ChessPieces, напишем обобщенный шаблон
GameBoard, который можно применить для любой игры, использующей двумерную
доску (например, игры в шахматы или шашки). В этом случае вам достаточно
изменить объявление класса, чтобы оно имело шаблонный параметр вместо жесткого
кодирования фигур в интерфейсе. Этот шаблон может иметь такой вид.
template «typename PieceType>
class GameBoard {
public:
// В этом примере опущены конструкторы, деструкторы
// и оператор присваивания.
void setPieceAt(PieceType* piece, int x, int y);
PieceTypeb getPieceAt (int x, int y) ,-
bool isEmptyfint x, int y);
protected:
// Здесь опущены члены данных.
};
78 Часть I. Введение в профессиональное С++-проектирование
Благодаря такому простому изменению в интерфейсе мы получаем обобщенный
класс игровой доски, который можно применить для любой игры, использующей
двумерную доску. Несмотря на то что показанное здесь изменение кажется очень
простым, важно внести его на стадии проектирования, чтобы затем эффективно
реализовать соответствующий код.
Повторное использование идей
Как показано на примере с булочником, было бы нелепо снова изобретать рецепты
для каждого блюда. Однако программисты часто допускают такие досадные ошибки
в своих проектах. Вместо применения уже проверенных "рецептов" (в данном случае —
шаблонов) при проектировании программ они изобретают "велосипеды" в каждом
своем новом проекте. Да, таких шаблонов уже создано великое множество, поэтому вам, как
С++-программисту, следует ознакомиться с ними, чтобы найти подходящий.
Например, вы решили спроектировать шахматную программу так, чтобы в ней
использовался только один объект типа ErrorLogger, который бы фиксировал все
ошибки различных компонентов в системном журнале. При попытке разработать класс
ErrorLogger вы понимаете, что создание нескольких объектов класса ErrorLogger
было бы пагубным для вашей программы. Вы также хотели бы обеспечить
возможность доступа к этому объекту типа ErrorLogger из любого места программы. Дело
в том, что требование создания единственного, глобально доступного экземпляра
класса часто предъявляется к С++-программам, и даже уже существует стандартная
стратегия создания одноэлементного множества (singleton). Таким образом, можно
подытожить, что на данном этапе в качестве приемлемого проекта подошел бы шаблон
одноэлементного множества. Более детально о методах шаблонного проектирования
мы поговорим в главах 5, 25 и 26.
Проектирование шахматной программы
В этом разделе мы представляем систематический метод проектирования С++-
программ в контексте простого приложения игры в шахматы. Несмотря на то что
в процессе построения законченного примера мы будем использовать принципы,
описанные в последующих главах, мы считаем, что вам не следует откладывать
рассмотрение этого примера "на потом". Наша цель сейчас — дать вам общее
представление о процессе проектирования. При желании вы сможете перечитать этот раздел
по завершении части I.
Требования к проекту
Прежде чем приступать к проектированию, важно четко определить требования
к функциональности и эффективности будущей программы. В идеале эти требования
должны быть задокументированы в форме технического задания. Требования к программе
игры в шахматы должны содержать по крайней мере следующие виды спецификаций.
О Программа должна поддерживать стандартные правила игры в шахматы.
О Программа должна быть рассчитана на двух игроков— пользователей
программы. (Здесь не предусматривается компьютерный игрок с искусственным
интеллектом.)
О Программа должна обеспечивать текстовый интерфейс:
Глава 2. Разработка профессиональных С++-программ 79
О состояние шахматной доски и фигур должно выражаться в ASCII-тексте:
О ход игрока должен обозначаться вводом чисел, представляющих позиции
фигуры на шахматной доске.
Выполнение этих требований гарантирует, что вы спроектируете программу,
которая будет работать в соответствии с ожиданиями игроков. Если пользователей
программы игры в шахматы удовлетворяет текстовый интерфейс, не стоит тратить время
на проектирование и кодирование графического. И наоборот, если вам известно, что
пользователю важно иметь графический интерфейс, ваша программа должна
предоставить ему такую возможность.
Этапы проектирования
Мы рекомендуем вам взять на вооружение систематический метод
проектирования программ и в своей работе идти от общего к частному. Описанные ниже действия
применимы не ко всем программам, но они обозначают общее направление. Ваш
проект может включать схемы и таблицы (в этом примере они весьма полезны). Вы
можете следовать предлагаемому здесь формату представления проектных
документов или разработать собственный.
Не существует "правильного" способа включения графических элементов в проект
программы. Главное, чтобы ваши схемы и рисунки были ясны и понятны вам самим и
вашим коллегам.
Разбивка программы на подсистемы
Прежде всего, необходимо разбить программу на общие функциональные
подсистемы и определить интерфейсы и формат взаимодействия между ними. На этом этане
не стоит тратить время на обдумывание конкретных структур данных и алгоритмов
или даже классов. Стремитесь получить только общее представление о различных
частях программы и характере взаимодействия между ними. Было бы нелишне
перечислить подсистемы в виде таблицы и описать их поведение на макроуровне, т.е.
указать функции подсистем, интерфейсы, экспортируемые из одной подсистемы в
другие, а также интерфейсы, используемые данной подсистемой для связи с другими.
Таблица подсистем компьютерной игры в шахматы могла бы иметь такой вид.
Имя
подсистемы
Количество
Функции
Экспортируемые
интерфейсы
Используемые
интерфейсы
GamePlay
ChessBoard
Начало игры
Управление процессом игры
Управление взятием фигуры
Объявление победителя
Конец игры
Хранение шахматных фигур
Проверка ходов и мата
Взятие фигуры
Конец игры
(GameOver)
Получение фигуры
(GetPieceAt)
Установка фигуры
(SetPieceAt)
Взятие фигуры
(Draw)
Смена очереди
(TakeTurn)
Взятие фигуры
(на доске) (Draw)
Конец партии.
(GameOver)
Взятие фигуры
(Draw)
80 Часть I. Введение в профессиональное С++-проектирование
Окончание таблицы
Имя
подсистемы
Количество
Функции
Экспортируемые Используемые
интерфейсы интерфейсы
ChessPiece
Player
ErrorLogger
32 Взятие фигуры
Ход
Проверка легальности хода
Взаимодействие
с пользователем:
напоминание пользователю
о ходе, получение хода
от пользователя
Перемещение фигур
Запись сообщений об ошибках
в системный журнал
Взятие фигуры
(Draw)
Ход (Move)
Проверка хода
(CheckMove)
Смена очереди
(TakeTurn)
Регистрация
ошибки
Получение фигуры
(на доске)
(GetPieceAt)
Установка фигуры
(на доске)
(SetPieceAt)
Получение фигуры
(на доске)
(GetPieceAt)
Ход (Move)
Проверка хода
(CheckMove)
Нет
Как видно из таблицы, эта компьютерная игра в шахматы включает такие
функциональные подсистемы: GamePlay, ChessBoard, ChessPieces (в количестве 32),
две подсистемы Player и одну ErrorLogger. Однако нельзя утверждать, что наш
подход к представлению игры в шахматы — единственно правильный. В
проектировании программных продуктов, как и в самом программировании, обычно существует
множество различных способов достижения одной и той же цели. Конечно, они не
всегда равноценны: одни оказываются в чем-то лучше других. При этом возможно
существование и равноценных методов.
При удачном разбиении проекта на подсистемы будущая программа будет состоять
из базовых функциональных частей. Например, в нашем примере подсистема Player
отделена от подсистем ChessBoard, ChessPieces и GamePlay. He имеет смысла
смешивать объекты игроков (Player) с объектом партии (GamePlay), поскольку это
логически самостоятельные подсистемы. Другие варианты не столь очевидны.
Может, было бы разумно добавить в систему отдельную подсистему пользовательского
интерфейса? Если вы собираетесь реализовать различные виды пользовательских
интерфейсов или планируете модифицировать интерфейс в будущем, вам стоило бы
выделить этот аспект в отдельную подсистему. Поэтому, принимая решение, учитывайте
не только текущие, но и будущие цели и задачи.
Поскольку по таблице зачастую трудно наглядно представить взаимоотношения
между подсистемами, то для этого используют такие схемы, как на рис. 2.1. Стрелки на
этом рисунке представляют обращения одной подсистемы к другой (для простоты
подсистема ErrorLogger опущена).
Выбор потоковых моделей
На этом этапе для своей программы следует выбрать количество потоков и
определить взаимодействия между ними. Необходимо также задать все возможные
блокировки при доступе к совместно используемым данным. Если ранее вам не
приходилось иметь дело с многопоточными программами, или ваша платформа не
поддерживает многопоточную обработку, то, возможно, вам стоит ориентироваться
на организацию однопоточного управления (т.е. на модель обработки данных, при
Глава 2. Разработка профессиональных С++-программ 81
которой все объекты выполняются в едином процессе). Но если ваша программа
должна выполнять несколько различных задач, причем параллельно одна другой, то,
скорее всего, вашей программе необходимо иметь несколько потоков выполнения.
Например, в приложениях, использующих графический интерфейс, первый поток
зачастую выполняет основную работу приложения, а второй ожидает от пользователя
щелчка мышью или выбора команды меню.
Game
Over
Draw
ChessBoard
TakeTurn
*^^ Draw
>
GetPieceAt
SetPieceAt
Move
CheckMove
Chess
Piece
Рис. 2.1
Поскольку организация поточной обработки зависит от конкретной платформы,
в этой книге проблемы многопоточного программирования не рассматриваются.
Вопросы отношений платформы и языковых средств C++ затронуты в главе 18.
Программа компьютерной игры в шахматы для управления ходом игры требует
только одного потока.
Определение иерархий классов для каждой подсистемы
Следующий этап— определение иерархий классов. В нашей программе игры
в шахматы достаточно организовать только одну иерархию классов, представляющую
шахматные фигуры. Эту иерархию можно изобразить так, как показано на рис. 2.2.
Рис. 2.2
В этой иерархии обощенный класс ChessPiece выполняет роль суперкласса. Для
того чтобы показать, что такая фигура, как ферзь (класс Queen), по своим
поведенческим характеристикам представляет собой объединение ладьи (класс Rook) и слона
(класс Bishop), в этой иерархии используется множественное наследование (форма
наследования функций и свойств классами, при которой производный класс может
иметь любое число базовых).
Детали проектирования классов и иерархий классов описаны п главе 3.
82 Часть I. Введение в профессиональное С++-проектирование
Определение классов, структур данных, алгоритмов и шаблонов
для каждой подсистемы
На этом этапе обычно переходят к более высокой степени детализации и
определяют частности каждой подсистемы, т.е. конкретные классы. Не исключено, что
каждая подсистема сама будет смоделирована как класс. Эту информацию можно свести
в таблицу, подобную следующей.
Подсистема
Классы
Структуры данных Алгоритмы
Шаблоны
GamePlay Класс GamePlay
ChessBoard
ChessPiece
Player
Класс
ChessBoard
Абстрактный
суперкласс
ChessPiece
Классы Rook,
Bishop,
Knight, King,
Pawn И Queen
Один класс
Player
ErrorLogger Один класс
ErrorLogger
Объект GamePlay
включает один объект
типа ChessBoard
и два объекта типа
Player
Объект ChessBoard
представляет собой
двумерный массив
для хранения
32 фигур на
шахматной доске
Каждая фигура
хранит свою
позицию на
шахматной доске
Два объекта игроков
(черный и белый)
Очередь сообщений
на регистрацию
Простой цикл, Нет
который позволяет
игрокам играть
по очереди
Проверка выигрыша Нет
или взятия фигуры
после каждого хода
Проверка Нет
легальности хода
путем запроса
к шахматной доске
о расположении
фигур
Алгоритм перехода
хода (TakeTurn):
цикл с напоминанием
игроку о его очереди
ходить, проверка
легальности хода и
перемещение фигуры
Буферизация
сообщений
и периодическая
их запись
в системный журнал
Нет
Шаблон
Singleton,
который
гарантирует
создание
только одного
объекта типа
ErrorLogger
В этом разделе проектного документа обычно представляются реальные
интерфейсы для каждого класса, но здесь мы ограничимся таким уровнем детализации.
Проектирование классов, выбор структур данных, алгоритмов и шаблонов — не
самая простая задача. Решая ее, всегда следует помнить о принципах абстракции и
многократного использования кода, рассмотренных выше в этой главе. В абстракции
главное — грамотно отделить интерфейс от реализации. Сначала определите интерфейс
с точки зрения пользователя. Уточните, какие действия, по-вашему, должен выполнять
Глава 2. Разработка профессиональных С++-программ 83
данный компонент. Затем решите, как он будет это делать, выбрав соответствующие
структуры данных и алгоритмы. Для обеспечения принципа многократного
использования кода обязательно ознакомьтесь со стандартными структурами данных,
алгоритмами и шаблонами. Кроме того, не забудьте о стандартной библиотеке функций C++,
а также любых оригинальных разработках кода, доступных на вашем рабочем месте.
Более подробно эти вопросы рассматриваются в главах 3, 4 и 5.
Определение механизма обработки ошибок для каждой системы
На этом этапе проектирования вы намечаете схему обработки ошибок в каждой
подсистеме. При этом необходимо учитывать как системные ошибки (например, сбой
при распределении памяти), так и ошибки пользователей (например, ввод
некорректных данных). Вам следует здесь определиться, каждая ли подсистема будет
использовать исключения. Эту информацию также оформите в виде таблицы.
Подсистема Обработка системных ошибок
Обработка ошибок
пользователей
GamePlay
ChessBoard
ChessPiece
Player
Регистрирует ошибку с помощью
подсистемы Error-Logger
и завершает программу, если
не удается выделить память для
подсистем ChessBoard или Player
Регистрирует ошибку с помощью
подсистемы ErrorLogger
и генерирует исключение,
если не удается выделить память
для свой подсистемы или
подсистемы ChessPiece
Регистрирует ошибку с помощью
подсистемы ErrorLogger
и генерирует исключение, если
не удается выделить память
Регистрирует ошибку с помощью
подсистемы ErrorLogger
и генерирует исключение, если
не удается выделить память
ErrorLogger Пытается зарегистрировать ошибку
и завершает программу, если не
удается выделить память
Неприменимо (нет прямого
пользовательского интерфейса)
Неприменимо (нет прямого
пользовательского интерфейса)
Неприменимо (нет прямого
пользовательского интерфейса)
"Санитарная" проверка данных,
введенных пользователем, на предмет
выхода за пределы доски; при
необходимости напоминание
пользователю о вводе других данных.
Проверка легальности хода
до перемещения фигуры на доске;
при нелегальности напоминание
пользователю о вводе других данных
Неприменимо (нет прямого
пользовательского интерфейса)
При построении подсистемы обработки ошибок обычно пытаются обрабатывать
любые ошибки и рассматривают все возможные ошибочные ситуации. Не следует
никакие из ошибок относить к категории "неожидаемых". Предусмотрите все
возможные неприятности: отказ при выделении памяти, ввод пользователем некорректных
данных, отказы дисководов и сетевые отказы. Но, как показывает таблица, построенная
84 Часть I. Введение в профессиональное С++-проектирование
для данного примера, ошибки пользователей необходимо обрабатывать не так, как
системные. Например, выполнение пользователем некорректного хода не должно
привести к завершению вашей игровой программы.
Более подробно особенности обработки ошибок рассматриваются в главе 15.
Резюме
В этой главе вы узнали о том, что понимается под профессиональным подходом
к проектированию. Надеемся, вы убедились в значимости этого этапа. Здесь вы
познакомились с некоторыми особенностями С++-проектирования, например, с его
объектной ориентированностью, большим набором средств и размером стандартной
библиотеки, а также с его возможностями создания обобщенного кода. Эта
информация поможет вам лучше подготовиться к С++-проектированию.
В этой главе ваше внимание было обращено на два принципа проектирования.
Принцип абстракции, или отделения интерфейса от реализации, пронизывает всю
эту книг)' и должен быть определяющим во всей вашей работе по проектированию.
Принцип многократного использования как кода, так и идей, также часто
воплощается в реальных приложениях. Не изобретайте велосипед, используйте уже
существующий код и выраженные кем-то идеи, а также пишите новый код так, чтобы он мог по-
ч служить вам еще не раз.
Теперь, когда вы понимаете значимость проектирования, можно двигаться
дальше. В главе 3 описаны стратегии применения в программных проектах объектно-
ориентированных аспектов C++. Главы 4 и 5 посвящены возможностям
многократного использования уже существующего кода и идей, а также способам написания кода,
который можно использовать в будущем. Глава 6 завершает часть I рассмотрением
моделей проектирования программ и процессов.
Проектирование
с использованием
объектов
Теперь, когда (на основе содержимого главы 2) вы воспитали в себе "вкус" к
хорошему проекту, пора объединить понятие объекта с идеей "хорошего проекта".
Различие между программистами, которые применяют объекты в своих программах,
и теми, кто в действительности понимает суть объектно-ориентированного
программирования, можно оценить на основании их отношения к связыванию объектов друг
с другом и к общему проекту программы.
Начнем с перехода от процедурного программирования к
объектно-ориентированному. Даже если вы использовали объекты в течение ряда лет, вам все же стоит
прочитать эту главу, чтобы уловить несколько новых идей в отношении применения
объектов. При рассмотрении различных видов взаимоотношений между объектами особое
место занимают ловушки, жертвами которых часто становятся программисты при
построении объектно-ориентированных программ. Вы также узнаете о том, как связан
с объектами принцип абстракции.
86 Часть I. Введение в профессиональное С++-проектирование
Объектно-ориентированный взгляд на мир
Переходя от процедурного кодирования (в С-стиле) к
объектно-ориентированному, важно помнить, что в основе объектно-ориентированного программирования
(ООП) лежит другой способ представления о том, что происходит в программе.
Слишком часто программисты увязают в новом синтаксисе и терминологии ООП,
прежде чем начнут в достаточной мере понимать, что представляет собой объект.
В этой главе, не углубляясь в детали кодирования, мы делаем основной акцент на
принципах и идеях. Об особенностях же синтаксиса С++-объектов читайте в главах 8—10.
О процедурном мышлении
При использовании такого процедурного языка, как С, код делится на маленькие
участки, каждый из которых, как правило, выполняет одну-единственную задачу. Беэ
этих С-процедур весь код программы был бы сосредоточен в функции main (). В
таком коде было бы трудно разобраться, и если бы этим пришлось заниматься вашим
коллегам, они, мягко говоря, были бы не в восторге.
Компьютеру безразлично, как организована ваша программа: то ли целиком
включена в функцию main (), то ли разделена на "микроскопические" кусочки с
описательными именами и комментариями. Процедуры представляют собой абстракцию,
которая существует, чтобы помочь программисту, а также тем, кто вынужден
разбираться в его программе и поддерживать ее. Все зависит от того, как вы отвечаете на
основной вопрос: "Что делает эта программа?'. Отвечая на своем родном языке, вы
мыслите процедурно. Например, вы могли бы начать проектирование программы
выбора акций в следующей последовательности. Сначала программа получает
информацию об акциях из Internet, затем сортирует принятые данные по специальным
атрибутам, после чего анализирует отсортированные данные и, наконец, выводит список
рекомендаций по покупке и продаже. Приступая к кодированию, эту мысленную
модель можно напрямую превратить в С-функции: retrieveQuotes (), sortQuotes (),
analyzeQuotes() и outputRecommendations().
Несмотря на то что С-процедуры называются "функциями", С не
является функциональным языком. Термин "функциональный"
кардинально отличается от термина "процедурный" и относится к таким
языкам, как Lisp (язык обработки списков Лисп), который
использует совершенно другой вид абстракции.
Процедурный подход оправдывает себя, если программа выполняет действия по
заданному списку. Однако современные крупные приложения зачастую опираются не
на линейную последовательность событий. Ведь пользователь может выполнить
любую команду в любой момент времени. Кроме того, процедурное мышление не
охватывает представления данных. В предыдущем примере и речи не было о том, как
должна быть организована информация об акциях.
Если процедурный способ мышления напоминает ваше нынешнее отношение
к написанию программ, не расстраивайтесь. Сейчас просто важно понять, что ООП —
это просто альтернативный и более гибкий способ мышления о программе.
Глава 3. Проектирование с использованием объектов 87
Объектно-ориентированный подход к проектированию
В отличие от процедурного подхода, который опирается на вопрос: "Что
программа делает?", объектно-ориентированный подход требует ответа на другой
вопрос: "Какие объекты реального мира я моделирую?". В основе ООП лежит мнение,
что программист должен делить свою программу не на задачи, а на модели
физических объектов. Пусть на первый взгляд это утверждение кажется абстрактным, все
прояснится после рассмотрения физических объектов в терминах классов,
компонентов, свойств и поведенческих характеристик.
Классы
Класс позволяет охарактеризовать объект с помощью некоторой формулировки и
одновременно выделить его определенным образом. Возьмем апельсин. Подумайте,
существует ли разница между апельсинами вообще, если иметь в виду вкусные фрукты,
растущие на деревьях, и конкретным экземпляром, лежащим сейчас передо мной на столе.
Отвечая на вопрос: "Что такое апельсин?", мы будем говорить о классе предметов,
именуемых апельсинами. Все апельсины — фрукты. Все апельсины растут на
деревьях. Все апельсины оранжевого цвета (возможно, отличаются в оттенках). Все
апельсины имеют специфический вкус. Класс — это просто инкапсуляция всех признаков,
определяющих некоторую категорию объектов.
Описывая конкретный апельсин, мы говорим об объекте. Все объекты принадлежат
конкретному классу. Поскольку тот объект на моем столе является апельсином,
я знаю, что он принадлежит к классу апельсинов. Следовательно, я могу утверждать,
что это фрукт, который растет на деревьях. Я могу добавить, что мой апельсин
обычного (средне-) оранжевого цвета и чрезвычайно приятен на вкус. Объект — это
экземпляр класса, т.е. конкретный элемент с характеристиками, которые отличают его от
других экземпляров того же класса.
В качестве более конкретного примера рассмотрим упоминаемое выше
приложение выбора акций- В ООП понятие "котировка акций" можно выразить в виде класса,
поскольку оно определяет абстрактное представление о том, что составляет любую
котировку. Конкретная же котировка, например "текущая котировка акций компании
Microsoft", послужила бы примером объекта, так как она является конкретным
экземпляром класса котировок.
Если вы — ^программист, то представьте себе классы и объекты в виде аналогов
типов и переменных. В главе 8 вы убедитесь в том, что синтаксис для классов подобен
синтаксису для С-структур. Объекты синтаксически очень сходны с С-переменными.
Компоненты
Если взять такой сложный реальный объект, как самолет, то нетрудно убедиться
втом, что он состоит из более мелких компонентов: фюзеляжа, рычага управления,
шасси, моторов и многих других частей. Если для ООП вполне естественно
рассматривать объекты в предположении, что они состоят из более мелких компонентов, то
для процедурного программирования характерно разбиение сложных задач на более
простые процедуры.
х Компонент, по сути, можно сравнить с классом. Он отличается от последнего
только более мелкими размерами и более специфичными характеристиками.
Например, объектно-ориентированная программа включает класс Airplane. Вы только
представьте себе, каким огромным был бы этот класс, содержащий полное описание
88 Часть I. Введение в профессиональное С++-проектирование
самолета. Но, к счастью, класс Airplane составлен из более мелких и более
управляемых компонентов. Каждый из таких компонентов в свою очередь может иметь еще
более мелкие компоненты. Например, шасси, являясь компонентом самолета,
содержит в качестве своего компонента колесо.
Свойства
Свойства представляют собой характеристики, которыми один объект отличается
от другого. Давайте вернемся к упомянутому выше "апельсиновому" классу Orange
и вспомним, что все апельсины имеют характерный вкус и один из оттенков
оранжевого цвета. Эти две характеристики и являются свойствами. Все апельсины имеют
одинаковые свойства, но с различными значениями. Помните, я уверял вас, что мой
апельсин чрезвычайно приятен на вкус, но ваш ведь может быть "ужасно кислым".
Свойства можно рассматривать на уровне класса. Например, мы уже отмечали, что
все апельсины — фрукты и растут на деревьях. Эти характеристики присущи классу
фруктов, в то время как специфический оттенок оранжевого цвета характерен для
конкретного "фруктового" объекта. Свойства класса присущи всем членам класса,
в то время как свойства объекта могут иметь для разных объектов разные значения.
В примере с выбором акций класс котировок акций может иметь такие свойства,
как имя компании, аббревиатура ценной бумаги, текущая цена и другие
статистические данные.
Свойства — это характеристики, которые описывают объект. Они отвечают на
вопрос: "Что отличает этот объект от других?".
Поведенческие характеристики
Поведенческие характеристики отвечают на один из двух вопросов: "Что делает
этот объект?" или "Что я могу сделать с этим объектом?". В примере с апельсином
легче ответить на второй вопрос: например, мы можем его съесть. Как и о свойствах,
мы можем думать о поведенческих характеристиках на уровне класса или на уровне
объекта. Все апельсины можно съесть примерно одинаковым способом. Но в какой-
нибудь другой сфере их "деятельности" можно зафиксировать определенные
различия. Например, при скатывании их вниз по наклонной плоскости поведение
совершенно круглых апельсинов будет отличаться от поведения более сплющенных.
В примере с выбором акций попробуем найти более практичные поведенческие
характеристики. Рассуждая процедурно, мы решили, что наша программа должна
анализировать информацию о котировках акций в форме одной из ее функций. Если
же мыслить категориями ООП, то напрашивается решение, согласно которому объект
котировок акций вполне может заняться "самоанализом"! Другими словами, анализ
может стать одной из главных характеристик поведения объекта котировок акций.
В объектно-ориентированном программировании большая часть
функционального кода выведена из процедур и внесена в объекты. Создавая объекты, которым
присущи определенные поведенческие характеристики, и определяя характер их
взаимодействия, ООП предлагает более богатый механизм для связывания кода с данными,
которые он обрабатывает.
Итак, если собрать все в кучу...
Теперь, учитывая все выше сказанное, вы можете по-другому взглянуть на программу
выбора акций и перепроектировать ее на объектно-ориентированный "манер".
Глава 3. Проектирование с использованием объектов 89
Как мы говорили выше, все, что входит в понятие "котировки акций", можно
определить в виде класса. Для получения списка котировок программа должна оперировать
понятием группы классов, которую часто называют коллекцией. Поэтому неплохо бы в
нашем проекте определить класс для представления "коллекции котировок акций", который
должен состоять из более мелких компонентов, т.е. одиночных "котировок акций".
Теперь о свойствах. Класс коллекции должен обладать по крайней мере одним
свойством: реальным списком полученных из Internet котировок. Он может иметь
и другие свойства, например, точную дату и время самой последней выборки данных
и количество принятых котировок. Что касается поведенческих характеристик, то
"коллекция котировок акций" должна обладать способностью обращаться к серверу
за получением новой информации и составлять отсортированный список котировок.
Такую поведенческую характеристику можно назвать "получением котировок".
Класс котировок акций должен иметь свойства, о которых упоминалось выше,
т.е. имя, символ, текущую цену и пр. Его поведение, как минимум, должно
характеризоваться способностью анализировать собственные данные. Можно рассмотреть включение
в класс и других поведенческих характеристик, например, покупку и продажу акции.
Неплохо бы бегло набросать схемы, отображающие отношения между
компонентами. Использование нескольких линий (рис. 3.1) означает, что одна "коллекция
котировок акций" содержит много объектов "котировок акций".
Коллекция
котировок
акций
Котировка
акций
Рис. 3.1
Существует еще один способ отображения классов. Он состоит в перечислении их
свойств и поведенческих характеристик (см. следующие две таблицы).
Класс
Orange
Компоненты
Нет
Свойства
Цвет
Вкус
Поведение
Употреблять в пищу
Катить
Подбрасывать
Класс
Компоненты
Свойства
Поведение
"Коллекция Составлена из Отдельные котировки
котировок отдельных объектов Время создания
акций" "Котировка акций" Количество котировок
Имя компании
Аббревиатура ценной бумаги
Текущая цена
"Котировка Нет (пока)
акций"
Считывать котировки
Сортировать котировки
по различным критериям
Анализировать
Покупать акции
Продавать акции
Жизнь в мире объектов
При переходе от процедурного мышления к объектно-ориентированной
парадигме программисты по-разному относятся к формированию свойств и поведенческих
характеристик объектов. Одни при этом частично пересматривают проекты своих
90 Часть I. Введение в профессиональное С++-проектирование
программ и переписывают некоторые фрагменты кода объектов. Другие готовы
перечеркнуть свой предыдущий опыт и начать проект как совершенное новое объектно-
ориентированное приложение.
Существует два основных подхода к разработке программ с использованием
объектов. Некоторые программисты считают объекты просто прекрасным средством
инкапсуляции данных и действий над ними. В результате ради улучшения читабельности
кода и упрощения его поддержки они стараются как можно гуще "заселить"
объектами свои приложения. Для этого они берут отдельные фрагменты кода и заменяют их
объектами подобно тому, как хирург вживляет больному кардиостимулятор. В этом,
по сути, нет ничего неправильного. Такие программисты видят в объектах
инструмент, который обладает во многих ситуациях большими достоинствами. Если
определенные части программы "ведут себя подобно объектам" (например, как в случае
с котировками акций), то их можно изолировать и описать "человеческим языком".
Другие программисты полностью принимают парадигму ООП и все превращают
в объекты. По их мнению, некоторые объекты вполне соответствуют реальным вещам
(например, апельсин или котировка акций), в то время как другие инкапсулируют
более абстрактные идеи (например, механизм сортировки или отмена предыдущего
действия). Идеальный подход, как и следует ожидать, лежит где-то посередине. Ваша
первая объектно-ориентированная программа может в действительности быть
традиционной процедурной программой с некоторыми "объектными вкраплениями".
Но, возможно, ваша натура не приемлет половинчатых решений, и вы (гулять так
гулять!) превратите в объект все: от класса, представляющего int-значения, до класса
главного приложения. Со временем вы обязательно найдете свою золотую середину.
Избыточность объектов
При создании объектно-ориентированной системы всегда стоит хорошо подумать,
прежде чем превращать мельчайшую деталь в объект. Перефразируя Фрейда, можно
сказать, что иногда переменная — это только переменная.
Предположим, вы проектируете очередной бестселлер — новую версию игры в
крестики-нолики и собираетесь сделать ее полностью объектно-ориентированной. Итак,
вы сидите с чашечкой кофе и блокнотом и описываете классы и объекты. В подобных
играх зачастую используется объект, который отслеживает ход партии и определяет
победителя. Для представления игровой доски вы предполагаете, что объект Grid
будет отслеживать символьные знаки и их позиции. Компонентом доски (сетки) может
быть объект Piece, который представляет "крестик" (X) либо "нолик" (О).
Подождите, уж больно резво вы тронулись с места! Для представления "крестика"
и "нолика" в таком проекте должен быть специальный класс. Это значит, что у нас
уже налицо избыточность объектов. И потом, нельзя ли для представления знаков
"X" и "О" использовать просто char-переменную? Кроме того, почему бы объекту
Grid не использовать просто двумерный массив перечислимого типа? А что, объект
Piece так усложнит код? Рассмотрим следующую таблицу, представляющую
предложенный вами (нами)класс Piece.
Класс
Piece
Компоненты
Нет
Свойства
"X" или "О"
Поведенческие характеристики
Нет
Эта таблица выглядит скудно, но именно ее лаконичность дает нам понять, что вряд
. ш здесь стоит создавать полноценный объект.
Глава 3. Проектирование с использованием объектов 91
Однако дальновидный программист может поспорить с нами: несмотря на то, что
класс Piece на данный момент довольно беден по содержанию, преобразование его
в объект без особых проблем позволит в будущем расширить программу. Возможно,
когда-нибудь это будет графическое приложение, и для поддержки визуального
отображения перечеркивания трех одинаковых знаков появится смысл и в создании
класса Piece. Дополнительным свойством в этом случае мог бы быть цвет фигуры или
признак последнего (по времени использования) хода.
Очевидно, правильно ответа не существует вовсе. Главное, чтобы вы при
проектировании своего приложения рассмотрели все аргументы "за" и "против". Помните,
что объекты призваны помогать программистам справляться с поставленной перед
ними задачей. Если объекты используются без веского на то основания, а лишь для
того, чтобы сделать код "более объектно-ориентированным", значит, не все
благополучно в "королевстве датском".
Слишком общие объекты
Возможно, еще большей неприятностью, чем объекты, которым не стоит быть
таковыми, являются слишком общие объекты. Программисты, приступающие к
освоению ООП, начинают с примеров, подобных "апельсину", т.е. с реальных объектов.
Но в программировании мы сталкиваемся с более абстрактными вещами. Во многих
ООП-программах существует так называемый "объект приложения", и это несмотря
на то, что приложение в действительности не является тем, что мы можем
представить в реальном мире. Тем не менее представление приложения в качестве объекта
может оказаться весьма полезным, поскольку приложение само по себе обладает
определенными свойствами и поведенческими характеристиками.
Слишком общий объект — это объект, который вообще не представляет никакого
конкретного предмета. Программист (из самых лучших побуждений) пытается создать
объект, чтобы он был гибким и многократно используемым, но иногда такое намерение
может закончиться плачевно. Например, представим себе программу, которая
организует и отображает средства аудиовизуальной информации. Она может
каталогизировать ваши фотографии, формировать музыкальную коллекцию и служить в качестве
дневника. Слишком общий подход — рассматривать все эти вещи объектами и
строить один класс, который подойдет под все форматы. Этот класс может иметь
свойство, именуемое "данные", которое будет содержать биты изображений, песен или
записей дневника (формат зависит от типа средства аудиовизуальной информации).
Класс может обладать поведенческой функцией, назовем ее "выполнить" (при ее
вызове она отобразит фотографию, воспроизведет песню или выведет на экран запись
дневника для редактирования).
Доказательство того, что этот класс слишком общий,
содержится в самих именах свойств и поведенческих характеристик. Слово
"данные" несет в себе мало информации: мы вынуждены использовать
общий термин, поскольку этот класс перенапряжен тремя различными
способами применения. Аналогично слово "выполнить" в каждом из
трех наших случаев имеет уж слишком различные значения.
Наконец, этот проект слишком общий еще и потому, что "средства
аудиовизуальной информации" не подразумевают конкретный объект (ни
в интерфейсе пользователя, ни в реальной жизни, ни даже в
сознании программиста). Основная причина перебора в обобщении
заключается в том, что наш гипотетический программист попытался
объединить в одном объекте совершенно разные идеи (рис. 3.2).
Средства
аудиовизуальной
информации
92 Часть I. Введение в профессиональное С++-проектирование
Отношения между объектами
Как программисту, вам наверняка придется встретиться с ситуациями, когда
различные классы будут иметь общие характеристики или по крайней мере каким-то
образом связаны друг с другом. Например, хотя создание "аудиовизуального" объекта
для представления изображений, песен и текста в программе "цифрового каталога"
можно обвинить в преувеличенном обобщении, эти объекты действительно обладают
общими характеристиками. Для них всех существует дата и время последней
модификации, и все они могут поддерживать функцию удаления.
Объектно-ориентированные языки программирования предоствляют ряд
механизмов, в которых учитываются такие взаимоотношения между объектами. Труднее
всего убедиться в реальности взаимоотношений. Различают два основных типа
отношений между объектами: HAS-A и IS-A.
Отношения типа HAS-A
Объекты, связанные отношением типа has-a, или агрегированием, соответствуют
следующему шаблону: объект А имеет (по-английски has а) объект В, или объект А содержит
(или включает) объект В. При таком типе отношений один объект можно
рассматривать как часть другого. Компоненты, как упоминалось выше, представляют отношение
типа has-a, поскольку они описывают объекты, которые состоят из других объектов.
Примером такого типа отношений может служить отношение между зоопарком
и обезьяной. Вы могли бы сказать, что в зоопарке есть обезьяна или, что то же самое,
зоопарк включает обезьяну. При моделировании зоопарка с помощью кода мы
создали бы объект "зоопарк", который бы включал компонент "обезьяна".
Зачастую при обдумывании сценариев пользовательского интерфейса полезно
понимать, какие отношения существуют между объектами. Дело в том, что даже если не
все пользовательские интерфейсы реализованы с использованием ООП (хотя,
вероятно, таки большинство из них), визуальные элементы на экране нетрудно
преобразовать в объекты. В качестве примера отношения агрегирования (типа has-a) для
пользовательского интерфейса может служить окно, которое содержит кнопку.
Кнопка и окно — это два совершенно различных объекта, но вполне очевидно, что
они каким-то образом связаны между собой. Поскольку кнопка находится внутри
окна, мы можем сказать, что окно включает кнопку.
Примеры отношений типа has-a показаны на рис. 3.3.
Отношение типа IS-A (наследование)
Отношение типа is-a представляет фундаментальный принцип объектно-
ориентированного программирования, который обычно называется наследованием
(inheriting). Классы позволяют смоделировать тот факт, что реальный мир содержит
объекты, которые характеризуются свойствами и поведением. С помощью же
наследования можно смоделировать тот факт, что эти объекты организованы
иерархически. Сущность иерархий как раз и выражается в отношениях типа is-a .
По существу, наследование можно описать таким шаблоном: объект А — это вид
объекта В, или объект А практически подобен объекту В. Для примера вернемся в наш
"зоопарк", но предположим, что в нем, помимо обезьян, находятся и другие животные.
Обозначение is-a происходит от английской фразы is a kind of (это вид), конкретизирующей
категорию отношений между двумя предметами. Например, бабочка — это вид насекомого, а автомобиль —
это вид транспортного средства. — При меч. ред.
Глава 3. Проектирование с использованием объектов 93
Уже одно только это предложение выражает смысл отношения: обезьяна — это вид
животного, или даже проще: обезьяна — это животное. Аналогично жираф — это
животное, кенгуру— это животное и пингвин— это животное. Ну и что из этого? Так
вот, магия наследования проявляется как раз в тот момент, когда вы понимаете, что
обезьян, жирафов, кенгуру и пингвинов объединяет нечно общее. Этим общим фактом
для них являются характеристики животных в целом.
- - — ■■ - >
Microsoft Office Word Ш
? \ Do you want to save the changes to Document4?
Окно содержит кнопку
Самолет имеет крыло (вероятно, два!)
Рис. 3.3
Для программиста вышесказанное означает, что он может определить класс
животных (Animal), который инкапсулирует все свойства (размер, обитание, питание и т.д.)
и поведенческие характеристики (двигаться, есть, спать), присущие каждому
животному. Конкретные животные, например обезьяны, образуют подклассы класса Animal,
поскольку обезьяне свойственны все характеристики животного (вспомните:
обезьяна— это животное с дополнительными характеристиками, которые выделяют ее из
животных вообще). Схема наследования для класса животных показана на рис. 3.4.
Стрелки обозначают направление отношения типа is-a.
Животное
Обезьяна Жираф Кенгуру Пингвин
Рис. 3.4
Подобно тому как обезьяны и жирафы — различные виды животных,
пользовательский интерфейс тоже может иметь различные типы кнопок. Например, кнопка-
флажок (checkbox) — это вид кнопки (button). Предположим, что кнопка — это
элемент пользовательского интерфейса, на котором можно щелкнуть и тем самым
выполнить некоторое действие, тогда можно сказать, что класс Checkbox расширяет
класс Button путем добавления в него состояния кнопки (с пометкой или нет).
Если предполагается, что между классами существует отношение наследования
(типа is-a), то необходимо выявить "общую функциональность" суперкласса, т.е класса,
94 Часть I. Введение в профессиональное С++-проектирование
который позже планируется расширить за счет других классов (подклассов). Если вы
считаете, что все ваши подклассы имеют код, подобный или в точности совпадающий с
кодом суперкласса, подумайте, как с максимальной эффективностью использовать код
суперкласса. В этом случае любые изменения придется вносить только в одном месте,
и будущие подклассы "общую функциональность" получат "совершенно бесплатно".
Методы наследования
В предыдущих примерах мы продемонстрировали ряд методов наследования, но
не формализовали их. Чтобы при создании подклассов определить, чем объект
отличается от родительского объекта (или суперкласса), программист может использовать
разные способы. Иногда, например, для этого достаточно завершить предложение:
"А — это (вид) В. который...".
Расширение функциональных возможностей
Подкласс может "превосходить" своего родителя по дополнительным
функциональным возможностям. Например, обезьяна — это животное, которое может
качаться на ветвях деревьев. Помимо того, что класс Monkey обладает всеми
функциональными возможностями класса Animal, классу Monkey также присуща такая
поведенческая характеристика, как "умение качаться на ветвях деревьев".
Замена функций
В любом подклассе можно заменить одну из функций или полностью
переопределить поведение его родителя. Например, большинство животных перемещаются
в пространстве посредством ходьбы, поэтому вы могли бы внести в класс Animal
поведенческую функцию движения и определить ее как ходьбу. Но как в таком случае
быть с кенгуру? Ведь кенгуру — это животное, которое передвигается в пространстве
не ходьбой, а "вприпрыжку". Все другие свойства и поведенческие характеристики
суперкласса Animal применимы к подклассу Kangaroo. Поэтому для соответствия
истине в подклассе иногда достаточно изменить одну из функций суперкласса (в данном
случае функцию движения). Конечно же, если при создании подкласса окажется, что
в нем необходимо заменить все функции суперкласса, то это, скорей всего, верный
признак того, что вы создаете подкласс не от того суперкласса.
Добавление свойств
В подкласс также можно добавлять новые свойства (к тем, которые определены
в суперклассе и унаследованы от него). Пингвин имеет все свойства животного, но
для него также характерно такое свойство, как размер клюва.
Замена свойств
В C++ предусмотрена возможность переопределения свойств, подобная
возможности переопределения поведенческих характеристик. Однако это следует делать только
в редких случаях. Важно не путать понятие замены свойства с понятием присваивания
свойствам подклассов различных значений. Например, все животные обладают
свойством "питание", по которому можно судить о том, что они едят. Обезьяны едят бананы,
пингвины — рыбу, однако здесь не идет речь о замене свойства "питание", а лишь
о различных значениях, присваиваемых одному и тому же свойству в разных подклассах.
Полиморфизм в сравнении с многократным использованием кода
Полиморфизм означает, что объекты, которые имеют стандартный набор свойств
и поведенческих характеристик, можно использовать взаимозаменяемо. Определение
класса можно сравнить с контрактом между объектами и кодом, который взаимодействует
Глава 3. Проектирование с использованием объектов 95
с ними. Так, по определению любой объект класса Monkey должен поддерживать
свойства и поведенческие характеристики класса Monkey.
Понятие полиморфизма распространяется и на суперклассы. Поскольку все
обезьяны — животные, то все объекты класса Monkey поддерживают свойства и функции
класса Animal.
Полиморфизм — довольно привлекательная часть объектно-ориентированного
профаммирования, поскольку здесь в действительности используются преимущества
механизма наследования. При моделировании зоопарка мы могли бы программным
путем "обойти" в цикле всех животных и каждого "привести в движение". Так как все
животные являются членами класса Animal, то они знают, как им двигаться. У
некоторых животных функция движения переопределена, но главное здесь то, что наша
программа просто дает каждому животному команду двигаться, не заботясь о его типе,
и каждое животное двигается известным ему одному способом.
Помимо полиморфизма, есть еще одна причина для создания подклассов. Речь идет
об эффективном использовании существующего кода. Например, если вам нужен класс,
который воспроизводит музыкальные произведения с эхо-эффектом, а ват коллега уже
написал класс для воспроизводения музыки, но без каких-либо эффектов, вы могли бы
расширить возможности уже существующего класса, добавив в него новую функцию.
Здесь по-прежнему применяется отношение типа is-a (ведь музыкальный проигрыватель
с эхо-эффектом— это проигрыватель, оснащенный дополнительной функцией эхо-
эффекта), однако эти классы не предназначены для взаимозаменяемого использования.
В итоге вы получите два отдельных класса, которые могут использовать совершенно
различные части программы (или даже совершенно различные программы), и тем
самым счастливо избежите очередного изобретения колеса.
Как различить HAS-A- и /S-4-отношения
В реальном мире довольно просто классифицировать has-a- и м-а-отношения между
объектами. Ведь никто не скажет, что апельсин включает фрукт, поскольку апельсин —
это фрукт. Однако в программировании иногда нет такой ясности.
Рассмотрим гипотетический класс, который представляет некоторую хеш-таблицу.
Хеш-таблица— это такая структура данных, которая позволяет преобразовать ключ
в значение. Например, страховая компания могла бы использовать класс Hashtable
для преобразования ID-номеров в имена своих клиентов, т.е. по заданному
идентификационному номеру (ID) можно найти соответствующее имя. Таким образом, в
терминах хеш-таблицы ID является ключом, а имя — значением.
В стандартной реализации хеш-таблицы каждому ключу соответствует
единственное значение. Если ID-номер 14534 соответствует имени "Kleper, Scott", он не может
также соответствовать имени "Kleper, Marni". Если вы попытаетесь для ключа,
который уже имеет одно значение, добавить второе, то первое значение будет "стерто"
(так реализовано по крайней мере в большинстве хеш-таблиц). Другими словами, если
ГОномер 14534 преобразуется в имя "Kleper, Scott", а затем вы присваиваете ID-номеру
14534 имя "Kleper, Marni", то мистер Скотт (Scott) окажется незастрахованным
(см. следующую последовательность инструкций, состоящую из двух обращений к
функции гипотетической хеш-таблицы enter (), с отображением результата их
выполнения в виде содержимого хеш-таблицы). Если обозначение hash.enter вызывает
вопросы, то, не забегая вперед, т.е. в объектный синтаксис C++, скажем пока, что эту
запись можно прочитать как "использование функции enter объекта hash".
hash.enter(14534, "Kleper, Scott");
96 Часть I. Введение в профессиональное С++-проектирование
Ключ
14534
hash.enter(14534,
Ключ
14534
"Kleper,
Marni");
Значение
"Kleper, Scotf [строка]
Значение
"Kleper, Marni" [строка]
Нетрудно представить использование структур данных, подобных хеш-таблице, но
разрешающих существование нескольких значений для каждого ключа. В примере со
страховой компанией некоторая семья могла бы иметь несколько имен,
соответствующих одному и тому же ID-номеру. Поскольку такая структура данных очень напоминает
хеш-таблицу, неплохо было бы воспользоваться этим фактом. Хеш-таблица может иметь
только одно значение для ключа, но это значение может быть любым. Вместо строки
значение могло бы представлять собой коллекцию (например, массив или список),
содержащую несколько (!) значений для ключа. При каждом добавлении нового клиента
для существующего ID-номера новое имя просто добавляется в такую коллекцию.
Такое поведение демонстрируется на примере следующей последовательности.
Collection collection; // Создаем новую коллекцию,
collection.insert("Kleper, Scott"); // Добавляем
// в коллекцию новый элемент,
hash.enter(14534, collection); // Вводим коллекцию в таблицу.
Ключ Значение
14534 {"Kleper, Scott"} [коллекция]
Collection collection = hash.get(14534);// Считываем
// существующую коллекцию,
collection.insert("Kleper, Marni"); // Добавляем
// в коллекцию новый элемент,
hash.enter(14534, collection); // Заменяем коллекцию
// ее обновленной версией.
Ключ Значение
14534 {"Kleper, Scott", "Kleper, Marni"} [коллекция]
Использование коллекции вместо строки для программиста довольно утомительно
и требует включения повторяющихся фрагментов кода. Было бы лучше оформить
поддержку ввода нескольких значений для одного ключа в отдельный класс, скажем,
MultiHash. Класс MultiHash должен работать подобно классу Hashtable, за
исключением того, что он мог бы негласно сохранять каждое значение как коллекцию строк, а не
как одну строку. Очевидно, класс MultiHash каким-то образом связан с классом Hashtable,
поскольку он по-прежнему использует для хранения данных хеш-таблицу. Неясным
остается лишь то, отношением какого рода будут связаны эти классы: м-аили has-a?
Рассмотрим сначала отношение типа is-a и представим, что класс MultiHash
является подклассом класса Hashtable. В нем, по-видимому, необходимо переопределить
функцию, которая ''отвечает" за добавление элементов в таблицу, чтобы она могла
либо создать коллекцию и добавить новый элемент, либо извлечь существующую
коллекцию и затем добавить в нее новый элемент. Потребуется также переопределить
функцию, которая считывает значение по ключу. Она могла бы, например, сцепить все
Глава 3. Проектирование с использованием объектов 97
значения для данного ключа в одну строку. Такой вариант проекта кажется вполне
приемлемым. Несмотря на то что здесь налицо переопределение всех функций
суперкласса, подкласс по-прежнему использует оригиналы функций своего родителя.
Этот подход схематически показан на рис. 3.5.
А теперь рассмотрим отношение типа has-a. Класс MultiHash — это отдельный
класс, но он содержит объект класса Hashtable. Вероятно, его интерфейс очень
похож на интерфейс класса Hashtable, но он не должен быть одинаковым. Когда
пользователь добавляет что-либо в класс MultiHash, это что-то в действительности
попадает в коллекцию и помещается в объект класса Hashtable. Такой вариант проекта
тоже кажется вполне приемлемым. Данный подход схематически показан на рис. 3.6.
Hashtable
enter(wiK>4, значение)
де1АН(ключ)
I MultiHash
MultiHash
Модифицирует enter() enter(wiK>4. значение)
Модифицирует get() де1А||(ключ)
Рис. 3.5 Рис. 3.6
Итак, какое же решение правильно? Здесь не существует однозначного ответа,
хотя один из авторов этой книги (который написал класс MultiHash для коммерческого
применения) видит в этой ситуации отношение типа has-a. Основной аргумент—
позволить модификациям доступ к открытым интерфейсам, не беспокоясь о поддержке
функционирования хеш-таблиц. Например, функция get была заменена функцией
getAll, и теперь стало ясно, что она должна считывать все значения для
конкретного ключа в классе MultiHash. Кроме того, при использовании отношения типа has-a вы
вообще не должны волноваться о функционировании каких бы то ни было хеш-
таблиц. Например, допустим, что класс хеш-таблицы поддерживает поведение, которое
соответствует считыванию общего количества значений. В этом случае, если в классе
MultiHash нет переопределения функций, он просто сообщит размер коллекций.
При этом можно найти убедительный аргумент в пользу того, что класс MultiHash
в действительности является классом Hashtable с некоторыми новыми функциями, и,
следовательно, должен находиться с ним в отношении типа is-a. Дело в том. что грань
между двумя этими типами отношений иногда едва заметна, и вам придется хорошо
подумать о том, как вы собираетесь использовать тот или иной класс, а также решить: то,
построением чего вы занимаетесь, является простым использованием функций другого
класса или классом с модифицированными или даже совсем новыми функциями.
В следующей таблице представлены аргументы "за" и "против" выбора каждого из
этих подходов для класса MultiHash.
Отношение типа NOT-A
Обсуждая тип отношений между классами, подумайте о том, а существуют ли
вообще между ними какие-либо отношения. Создание объектно-ориентированного
проекта еще не означает "притягивания за уши" не нужных, по сути, отношений между
классом и его под классами.
Hashtable
включает (has-a)
98 Часть I. Введение в профессиональное С++-проектирование
Is-A
Has-A
Причины "за" •
Причины
"против"
По существу, эти два класса
представляют собой одну и ту же
абстракцию с различными
характеристиками.
В классе MultiHash определяются
(почти) те же функции, что и в классе
Hashtable
Хеш-таблица (класс Hashtable)
по определению имеет однозначное
соответствие между ключом
и значением. Поэтому назвать класс
MultiHash хеш-таблицей — значит
грешить против истины. Класс
MultiHash переопределяет
как функции класса Hashtable, так
и сам признак, по которому можно
судить о некорректности проекта.
Неизвестные или неподходящие
свойства либо функции класса
Hashtable могут "обескровить"
класс MultiHash
Класс MultiHash может иметь
любые поведенческие
характеристики, не заботясь
о том, какие функции
определены в классе Hashtable.
Их реализ2ацию можно было
бы сделать отличной от
функций класса Hashtable,
не изменяя открытых
интерфейсов
В известном смысле, класс
MultiHash (со своими новыми
функциями) служит примером
еще одного изобретения колеса.
Некоторые дополнительные
свойства2 и функции класса
Hashtable могут оказаться
полезными
Иногда может ввести в заблуждение очевидная связь, существующая между
некоторыми вещами в реальном мире. Тот факт, что в реальной жи.шп "М\станг" являег-
ся моделью автомобильной марки "Форд" (компании Ford Motors), еще не означает,
что при программном моделировании автомобиля класс Mustang обязательно
должен быть подклассом класса Ford. При построении объектно-ориентированных
иерархий необходимо моделировать функциональные отношения, а не искусственные. На
рис. 3.7 показаны отношения, которые представляют интерес с точки зрения
онтологии или иерархии, но вряд ли заслуживают выражения в коде.
Лучший способ не допустить бесполезного образования подклассов —
схематически изобразить проект. Для каждого класса и подкласса следует в этом
случае указать, какие свойства и поведенческие характеристики вы планируете в них
определить. Если вы увидите, что класс не имеет собственных конкретных свойств или
функций, или все эти свойства и функции полностью переопределяются его
подклассами, велика вероятность того, что в таком классе нет необходимости вообще.
Иерархии
Объектно-ориентированные иерархии позволяют моделировать многоуровневые
отношения: если класс А может быть суперклассом класса В, то класс В при этом может
быть суперклассом для класса С. Например, при моделировании зоопарка каждое
животное можно представить как подкласс общего класса животных Animal (рис. 3.8).
При кодировании каждого из этих подклассов можно заметить, что многие из них
чем-то сходны. В этом случае следует рассмотреть их "вхождение" в общего родителя.
Если лев и пантера питаются и двигаются одинаково, это может означать, что для них
Глава 3. Проектирование с использованием объектов 99
имеет смысл создать общий класс "больших кошек" BigCat (к нему можно отнести
и других диких животных семейства кошачьих). При дальнейшем подразделении класса
Animal можно создать такие подклассы, как WaterAnimal (морские животные) и
Marsupial (сумчатые). Более развернутый иерархический проект модели зоопарка
с учетом замеченной общности показан на рис. 3.9.
Блюз-рок Поп-музыка Фолк-рок
Бибоп
Вице-президент
по техническому
обслуживанию
Заместитель
Руководитель
предпродажного
сектора
Руководитель проекта
Инженер
Рис. 3.7
Рис. 3.8
Биолог, глядя на эту иерархию, может испытать чувство разочарования,
поскольку пингвин в действительности не относится к тому же семейству, что и
дельфин. Но это лишь подчеркивает тот факт, что в коде необходимо найти баланс
100 Часть I. Введение в профессиональное С++-проектирование
между реальными и общими функциональными отношениями. Даже если две вещи
тесно связаны в реальном мире, они могут не иметь никаких "классовых" отношений
в коде программы, поскольку в действительности они не обнаруживают общих
поведенческих черт. Вы могли бы просто разделить животных на млекопитающихся
и рыб, но такой подход не позволил бы выделить общие характеристики суперкласса
Животное
Обезьяна
Пингвин
Рис. 3.9
Важно отметить, что существуют и другие способы организации иерархии.
Предыдущий проект организован по большей части на основании характера движения
животных. Если бы в основу иерархии положить способ питания животных или их
рост, то иерархия выглядела бы по-другому. В конце концов важно то, как будут
использоваться наши классы. Другими словами, способ построения объектной
иерархии продиктован нашими потребностями.
Корректно построенная объектно-ориентированная иерархия позволяет достичь
следующих результатов:
□ классы организованы на основании существенных функциональных отношений;
□ многократное использование кода достигается посредством выделения общих
функций в суперкласс;
□ при создании подклассов удается избежать переопределения многих
родительских функций.
Множественное наследование
Во всех приводимых до сих пор примерах мы имели дело с единичным
наследованием. Это означает, что класс имел, самое большее, один непосредственный
родительский класс. Но благодаря множественному наследованию класс может иметь не
один, а несколько суперклассов.
Если вы считаете, что в мире животных не существует надлежащей
объектной иерархии, поскольку виды животных отличаются
слишком по многим параметрам, можно обратиться к механизму
множественного наследования. При множественном наследовании возможно
создание нескольких отдельных иерархий. Для мира животных
создайте три иерархии: размера, питания и движения, а затем для
каждого животного определите его место в этой иерархической системе.
Глава 3. Проектирование с использованием объектов 101
На рис. 3.10 показан проект иерархии с использованием множественного
наследования. В нем по-прежнему существует суперкласс животных Animal, который затем
подвергается делению по размеру. Другие две отдельные иерархии основаны на
характере питания и движения животных. Каждый вид животного затем образует свой
подкласс на базе всех этих трех суперклассов.
Движение
Прыгун
Питание
Животное
Кенгуру
Коала
Рис. 3.10
В контексте пользовательского интерфейса представьте себе изображение, на
котором пользователь может щелкнуть мышью. В этом случае данный объект должен
быть одновременно как кнопкой, так и рисунком, чтобы при его реализации как
подкласса у него было два родителя: класс Image и класс Button (рис. 3.11).
Рис. 3.11
Недостатки множественного наследования
Многие программисты не долюбливают множественное наследование, несмотря
на то, что в C++ предусмотрена явная поддержка отношений такого вида (чего не
скажешь о языке Java). И на это есть ряд причин.
Во-первых, возникают проблемы с визуализацией множественного наследования.
Как показано на рис. 3.10, даже схема простого класса может оказаться очень сложной
при наличии нескольких иерархий и пересекающихся линий, обозначающих
отношения. Иерархии классов призваны помочь программисту понять отношения между
различными частями кода. При использовании же множественного наследования
класс может иметь нескольких родителей, которые никак не связаны друг с другом.
И такое положение вещей может значительно усложнить представление
программиста о том, что происходит с его объектом. В реальном мире мы, как правило, не
рассматриваем существование между объектами множественных отношений типа is-a.
Во-вторых, множественное наследование может разрушить другие "четкие"
иерархии. В примере с миром животных переход к использованию множественного
наследования означает, что суперкласс Animal стал менее значительным, поскольку код,
который описывает животных, теперь разделен на три отдельные иерархии. Несмотря на то
что проект, показанный на рис. 3.10, демонстрирует три отчетливые иерархии, нетрудно
102 Часть I. Введение в профессиональное С++-проектирование
представить себе, как они смешаются в своих потомках. Например, как поступить, если
окажется, что все прыгуны не только двигаются одинаково, но и употребляют одинаковую
пищу? Поскольку мы имеем дело с тремя отдельными иерархиями, то не существует
способа объединить принципы движения и питания. не,добаши£Д1еодин-ш№.кдасг.
В-третьих, сама реализация множественного наследования довольно сложна. А что
если два из ваших суперклассов реализуют одинаковое поведение различными
способами? Можно ли использовать два суперкласса, которые сами являются подклассами
некоторого общего суперкласса? Эти ситуации усложняют реализацию, поскольку
структурирование таких запутанных отношении в коде довольно затруднительно как
для автора, так и для читателя.
Отсутствие встроенного механизма для реализации множественного наследования
в других языках программирования объясняется тем, что программисты обычно избегают
иметь с ним дело. Если вы решите пересмотреть нашу зоологическую иерархию или
воспользоваться шаблонами проектирования, описанными в главе 26, вы сможете
избежать применения множественного наследования при создании проекта программы.
"Смешанные" классы
"Смешанные" классы представляют еще один тип отношений. В C++ смешанный
класс реализуется синтаксически подобно множественному наследованию, но с иной
семантикой. Смешанный класс отвечает на вопрос: "Что еще данный класс способен
сделать?". Смешанные классы представляют собой способ добавления функций в класс,
не затрагивая отношение тина is-a.
Вернемся к примеру с зоопарком. Для некоторых животных мы можем ввести
понятие "любимчик". Таких животных посетители могут баловать без вреда для себя.
Функцию поведения для всех животных-любимчиков можно обозначить как "позволяет
ласку". Поскольку "ласковые" животные не имеют общих черт, и вы не хотите
разрушить уже созданную вами иерархию, то этот класс "любимчиков" (Pettable) можно
отнести к категории смешанных.
Смешанные классы часто становятся членами пользовательских интерфейсов.
Вместо применения множественного наследования к классу PictureButton (кнопка-
изображение), чтобы наделить его признаками и кнопки (Button), и простого
изображения (Image), можно заявить, что мы имеем дело с изображением, па котором можно
щелкнуть (Clickable). А пиктограмма панки па вашем экране будет соответствовать
изображению (Image), которое можно перетаскивать (Draggable). При разработке
программных продуктов мы используем множество забавных прилагательных.
Разница между смешанным и суперклассом больше относи гея к тому, как мы
думаем о классе, а не к различию в коде. В общем случае смешанные классы проще
систематизировать, чем классы, созданные с использованием множественного
наследования, поскольку их области видимости довольно ограничены. Создавая смешанный
класс Pettable, к существующему классу мы просто добавили одну поведенческую
функцию. Смешанный класс Clickable отличается от существующего только
добавлением функций перетаскивания изображения "вниз" и "вверх". Кроме того,
смешанные классы редко могут "похвастаться" развернутой иерархией, поэтому здесь
вряд ли стоит опасаться перекрестного функционального "опыления".
Абстракция
В главе 2 вы узнали о принципе абстракции, т.е. понятии отделения реализации от
средств, используемых для доступа к ней. Абстракция также является основной
частью объектно-ориентированного проектирования.
Глава 3. Проектирование с использованием объектов 103
Сравнение интерфейса с реализацией
Основное для абстракции — эффективно отделить интерфейс от реализации.
Реализация— это код, который пишет программист для решения поставленной перед ним
задачи. Интерфейс— это средство использования вашего кода другими людьми.
В языке С в качестве примера интерфейса можно привести заголовочный файл,
который описывает функции в созданной вами библиотеке. В объектно-
ориентированном программировании интерфейс класса может иметь вид коллекции
доступных для всех (т.е. открытых) свойств и функций.
Принятие решения по открытому интерфейсу
Как другие программисты будут взаимодействовать с вашими объектами — эта тема
подлежит обсуждению при проектировании класса. В C++ свойства и функции (методы)
класса могут быть открытыми (public), защищенными (protected) или закрытыми
(private). Спецификатор public означает, что доступ к свойству или функции может
получить любой другой (внешний для данной иерархии классов) код программы.
Спецификатор protected означает невозможность получения доступа со стороны
внешнего (для данной иерархии классов) кода. Спецификатор private обеспечивает еще
более строгую защиту, чем спецификатор protected, поскольку в этом случае доступ
к свойствам и функциям класса не могут получить даже подклассы данной иерархии.
Проектирование открытого интерфейса означает выбор спецификаторов для
свойств и функций класса, точнее, какие члены класса сделать public-членами.
Работая над большим проектом совместно с другими программистами, следует
рассматривать проектирование открытого интерфейса как процесс.
Кто конечный пользователь интерфейса
Первый шаг в процессе проектирования открытого интерфейса— обсудить
целевою аудиторию. Кто конечный пользователь интерфейса? Другой член пашей
команды? Вы сами? Программист не из вашей компании? А, может быгь, массовый
потребитель или оффшорная фирма-исполнитель? Ответ на вопрос о конечном
пользователе интерфейса позволит определить не только того, кто поможет вам в его
проектировании, но также прольет свет на некоторые цели его разработки.
Если вы сами собираетесь использовать свой интерфейс, у вас есть полная свобода
в работе над ним. Это значит, что вы вольны многократно изменять его до тех пор,
пока он не станет отвечать вашим требованиям. И если вы — не кустарь-одиночка,
а работаете в коллективе, то вполне вероятно, что в один прекрасный день вашим
интерфейсом начнут пользоваться и другие члены вашей команды.
Проектирование интерфейса для других "внутренних" программистов несколько
отлично от первого варианта. В известном смысле ваш интерфейс становится
контрактом, "подписанным" между вами и ними. Например, если вы реализуете
компонент хранения данных, остальные находятся в зависимости от того, как ваш
интерфейс поддерживает определенные операции. Вам придется выяснить все аспекты
использования вашего класса остальными членами вашей команды. Нужно ли при
этом организовать управление версиями? Хранение данных какого типа должен
обеспечить ваш компонент? В соответствии с контрактом вам не стоит предполагать
слишком большую потенциальную гибкость для своего интерфейса. Если проект
согласован до начала кодирования, но вы (после написания кода) вдруг решите внести
в него изменения, вам не миновать жалоб от других программистов.
104 Часть I. Введение в профессиональное С++-проектирование
Если заказчиком интерфейса является внешний потребитель, вам придется его
проектировать с учетом большого пакета различных требований. Привлечь
конечного пользователя к выяснению деталей интерфейса было бы идеальным вариантом.
При этом желательно рассмотреть конкретные потребности как сегодняшнего, так
и завтрашнего дня. Терминология, используемая в интерфейсе, должна
соответствовать терминам, с которыми знаком потребитель, да и документацию необходимо
писать в расчете на конечного пользователя. А вот шуточные названия и
программистский сленг в проекте использовать не следует.
Назначение интерфейса
Для написания интерфейса есть множество причин. Прежде чем доверить код
бумаге или хотя бы решить, какие функции сделать открытыми, необходимо понять
назначение интерфейса.
Программный интерфейс приложения (API)
Программный интерфейс приложения (application programming interface — API) это
внешне видимый механизм, который позволяет распространить продукт или
использовать его функции в другом контексте. Если внутренний интерфейс можно сравнить
с контрактом, то API-интерфейс ближе к "высеченному из камня" закону. Если кто-то уже
использует ваш API-интерфейс (пусть даже не работая в вашей компании), его не
обрадуют изменения, если, конечно, вы не добавите новые средства, которые смогут ему
быть полезны. Поэтому тщательно распланируйте API-характеристики и обсудите их
с потенциальными пользователями прежде, чем делать интерфейс доступным для них.
При проектировании API-интерфейса обычно пытаются найти компромисс между
простотой применения и гибкостью. Поскольку конечные пользователи интерфейса не
знакомы с внутренней работой вашего продукта, то нужно учитывать трудности его
освоения. Прежде всего, ваша компания создает этот API-интерфейс для потребителей,
поскольку желает, чтобы он был востребован. И если с ним будет трудно работать, то он
обречен на неудачу. Гибкость же часто находится в противоречии с требованием
простоты применения. Ваш продукт может иметь множество различных режимов работы,
и вполне закономерно, что вы захотите, чтобы потребитель мог использовать все
заложенные в нем функции. Но API-интерфейс, который позволяет потребителю делать все,
на что способен ваш продукт, будет довольно сложным в обращении.
У программистов есть такая поговорка: "Хороший интерфейс делает простой
случай простым, а трудный— возможным". Это значит, что API-интерфейсы, прежде
всего, должны быть простыми в освоении. Все варианты использования продукта
должны быть доступными для пользователя. При этом любой API должен быть
рассчитан и на более интеллектуальное использование, поэтому всегда нужно стремиться
к компромиссу между сложностью его применения для редко используемых режимов
работы и простотой для обычных и частых.
Вспомогательный класс или библиотека
Нередко ваша задача заключается в разработке некоторых конкретных функций
приложения, например, библиотеки случайных чисел или класса регистрации. В
таких случаях с интерфейсом вопросы решаются проще, поскольку вы стремитесь
сделать открытыми для пользователей большинство или даже все функции, не раскрывая
деталей реализации. Здесь важно обсудить вопрос уровня обобщения. Поскольку
класс или библиотека имеют уровень общего назначения, то при создании проекта
приложения вам придется рассмотреть весь возможный набор вариантов
применения вашего продукта.
Глава 3. Проектирование с использованием объектов 105
Интерфейс между подсистемами
Возможно, вам придется проектировать интерфейс между двумя основными
подсистемами приложения, например механизм доступа к базе данных. В подобных
случаях отделение интерфейса от реализации первостепенно, поскольку, вполне
вероятно, что другие программисты начнут заниматься реализацией их части еще до того,
как вы "разделаетесь" со своей. Работая над подсистемой, прежде всего, необходимо
думать о том, каково ее основное назначение. Идентифицировав главную задачу
подсистемы, подумайте о конкретных вариантах ее применения, а также о том, как она
должна быть представлена другим частям кода. Попытайтесь взглянуть на нее
"глазами" других задач, не углубляясь в детали реализации.
Интерфейс между компонентами
Большинство интерфейсов по своим функциональным возможностям не
превышают подсистемный или API-интерфейс. Другими словами, большинство
интерфейсов представляют собой объекты, которые используются внутри вашей же
программы. В таких случаях основную опасность таит в себе тот момент, когда ваш
интерфейс, развиваясь постепенно, вдруг становится неуправляемым. Несмотря на то
что подобные интерфейсы предназначены для "внутреннего употребления", лучше
думать о них как об "экспортном продукте". Другими словами, рассмотрите (как и при
использовании подсистемного интерфейса) по одной основной цели существования
каждого класса и осторожно отнеситесь к тому, чтобы функции, которые не связаны
с этой целью, делать открытыми.
Думая о будущем
Проектируя интерфейс, не нужно забывать о том, что "день грядущий нам
готовит", т.е. о будущих потребностях. Будет ли этот проект годами оставаться
неприкосновенным? А, может быть, стоит предусмотреть возможность расширения,
ориентируясь на сменную архитектуру? Возможно, вы уверены в том, что ваш интерфейс
можно использовать не только по нл-шачснию, но и в других целях? Если существует
вероятность его альтернативной переделки впоследствии или, что еще хуже,
добавления новых функций вразбивку, то из вашего интерфейса может получиться
чудовищный коктейль! В этом случае будьте особенно бдительны! Предполагаемое
обобщение— это еще одна ловушка. Не стоит проектировать "всеядный"' класс токмо для
пущей важности, если его будущее назначение пока не ясно.
Проектирование абстракции
Настоящий успех в проектировании хороших во всех отношениях интерфейсов
приходит лишь после нескольких лет получения опыта в написании своих и
использовании чужих абстракций. Изучая абстракции, написанные другими
программистами, старайтесь запомнить, что в них было работоспособным, а что — нет. Чего вам
недоставало в API файловой системы Windows, которую вы использовали на прошлой
неделе? Что бы вы сделали по-другому, если бы не вашему коллеге, а именно вам
пришлось писать сетевой упаковщик? С первого раза интерфейс вряд ли получится
превосходным, поэтому дерзайте еще и еще раз. Не бойтесь получить отзыв о своей
работе у более опытных коллег. Не бойтесь также что-либо изменить в своей
абстракции после начала кодирования, даже если другим программистам придется
адаптироваться к новому варианту. В конце концов они поймут, что хорошая
абстракция выгодна не только для вас, но и для них.
Порой не обойдется без нервов, когда вы будете связывать свою программу с кодом
других программистов. Возможно, ваши коллеги по команде не понимают проблемы,
106 Часть I. Введение в профессиональное С++-проектирование
которая обнаружилась в предыдущем варианте интерфейса, или не осознают, что
ваш подход требует дополнительных усилий. В подобных ситуациях будьте готовы
как защищать свою работу, так и воспользоваться их идеями (если они того
заслуживают). В критических ситуациях может помочь хорошо составленная
документация и примеры программ.
Остерегайтесь использования абстракций, состоящих из одного класса. Если
глубина создаваемого вами кода довольно значительна, рассмотрите возможность
создания классов-попутчиков, которые могут составить "компанию" основному
интерфейсу. Например, если ваш интерфейс ориентирован на некоторую обработку данных, то
почему бы вам не предусмотреть также существование результирующего объекта,
который бы позволял просматривать и интерпретировать полученные результаты?
Там, где это возможно, преобразуйте свойства в функции. Другими словами, не
позволяйте внешнему коду напрямую манипулировать данными "за спиной" вашего
класса. Вы ведь не хотели бы, чтобы кто-нибудь из программистов (по неаккуратности
или в силу своей испорченности) установил высоту объекта визуализации равной
отрицательном)' числу? Лучше для установки подобных значений использовать
функции, которые выполняют граничную проверку (на отсутствие нарушения границ).
Не бойтесь возвращаться к "пройденному". Стремитесь получить отзывы на свой
проект, учитывайте замечания и учитесь на ошибках (необязательно на своих).
Более конкретные рекомендации по проектированию интерфейсов и
возможностям многократного использования кода можно найти в главе 5.
Резюме
В этой главе вы получили представление о проектировании
объектно-ориентированных программ (хотя и без рассмотрения соответсгвующнх примеров). Изложен
ные здесь принципы реализуемы практически во всех объектно-ориентированных
языках программирования. Надеемся, вы узнали для себя новые способы
формализации уже известных идей и познакомились с новыми подходами к решению старых
проблем или с новыми аргументами в пользу некоторых концепций командной
работы. Даже если вам никогда не приходилось прежде использовать объекты в своих
программах или вы делали это фрагментарно, то теперь о том, как спроектировать
объектно-ориентированную программу, вы знаете больше, чем многие уже опытные
С++-программнсты.
Очень важно досконально изучить отношения между объектами, причем не только
потому, что хороню связанные объекты позволяют использовать код .многократно и
"навести порядок" в коде, но и потому, что вам обязательно придется работать в
команде. Опыт показывает, что связанные объекты проще поддерживать и
модифицировать. Поэтому, приступая к проектированию своих программ, используйте раздел
"Отношения между обьектами" в качестве руководства к действию.
Наконец, вы познакомились с теоретическими основами успешного построения
абстракций и поняли, что, принимая решение по созданию открытого интерфейса,
необходимо до конца уяснить его назначение и определиться с тем, кто будет его
конечным пользователем. В главе 4 вы получите более расширенное представление
о разработке абстракций, а именно о создании многократно используемого кода, о
повторном использовании идей, а также о применении некоторых библиотек.
Проектирование
с использованием
библиотек
и шаблонов
Опытные С++-программисты никогда не начинают проект "с пуля". Как правило,
они используют код из различных источников, например, из стандартной библиотеки
шаблонов, библиотек с открытым исходным текстом, оригинальных разработок
своей компании и собственных наработок (из предыдущих проектов). Кроме того,
многие С++-программисты применяют уже проверенные методы и стратегии различного
назначения. В этой главе вы узнаете, как при проектировании программ с
максимальной эффективностью воспользоваться уже существующим кодом и стратегиями.
В главе 2 при введении в тему многократного использования кода вы узнали, что
включение в новые проекты уже существующего кода и реализация в них уже готовых
идей позволяет значительно увеличить эффективность работы программиста. В
настоящей главе эта тема получает развитие за счет описания конкретных деталей
и стратегий, которые вы можете воплотить в своих проектах. К концу чтения этой
главы вы узнаете о:
108 Часть I. Введение в профессиональное С++-проектирование
Q существовании различных типов кода, подлежащих многократному
использованию;
Q достоинствах и недостатках многократного использования кода;
Q общих стратегиях и принципах многократного использования кода;
Q библиотеках с открытым исходным текстом;
Q стандартной библиотеке C++;
Q методах и шаблонах проектирования.
Многократное использование кода
Чтобы получить максимум эффективности от многократного использования кода,
необходимо понимать типы кода, который можно использовать повторно, и суть
компромиссов, на которые стоит пойти ради этого. Прежде чем анализировать
достоинства и недостатки этого метода программирования, следует определиться с
соответствующей терминологией. Итак, различают три категории кода, подходящего для
повторного использования:
Q код, разработанный вами в прошлом;
Q код, написанный вашими коллегами;
Q код, созданный в сторонних организациях или компаниях.
С> шествуют также следующие способы структуризации кода.
Q Автономные функции или классы (код, написанный вами или вашими
коллегами).
Q Библиотеки. Библиотека— это коллекция процедур, используемых для
выполнения конкретных задач, например, XML-анализ (Extensible Markup
Language— язык, предназначенный для создания страниц WWW). Код,
созданный сторонними организациями, обычно имеет формат библиотеки.
Вероятно, вы уже работали с библиотекой математических функций в С или
C++. В виде библиотечных функций представлены и средства поддержки
потоков ввода-вывода информации и их синхронизации, а также средства сетевой
и графической обработки данных.
Q Оболочки. Оболочка— это коллекция функций, под управлением которой вы
проектируете свою программу. Например, библиотека базовых классов Microsoft,
поддерживающая разработку приложений для Microsoft Windows (Microsoft Foundation
Classes— MFC), обеспечивает условия для создания графических приложений
(т.е. приложений, использующих графический интерфейс пользователя) для
Microsoft Windows. Оболочки обычно и диктуют структуру вашей программы.
Более подробно об оболочках можно прочитать в главе 25.
Программа использует библиотеку, подчиняясь требованиям оболочки.
Библиотеки обеспечивают программу конкретными функциями, в то
время как оболочки устанавливают основной принцип
проектирования вашей программы и ее структуру.
Глава 4. Проектирование с использованием библиотек и шаблонов 109
Кроме того, при проектировании программ часто используется такой термин, как
программный интерфейс приложения (application programming interface— API). API —
это интерфейс с библиотекой или телом кода, созданный с определенной целью.
Например, программисты часто используют API-сокеты, означающие открытый
интерфейс с сокетами сетевой библиотеки, а не с самой библиотекой.
Несмотря на то что термины "API"и "библиотека"часто используются
взаимозаменяемо, они не являются эквивалентными. Термин "библиотека" относится к реализации,
а термин "API"— к открытому интерфейсу с библиотекой.
В остальной части этой главы мы будем употреблять термин "библиотека"
применительно к любому многократно используемому коду, будь то действительно
библиотека, оболочка или коллекция функций.
Быть или не быть многократно используемому коду
Принцип многократно используемого кода, казалось бы, легко понять
теоретически. Однако, когда дело доходит до деталей, ясность куда-то девается. Как узнать,
когда имеет смысл использовать существующий код, и какой именно? Выбор, конечно
же, будет зависеть от конкретной ситуации. Поэтому сначала нужно рассмотреть все
известные вам "за" и "против", а лишь потом принимать окончательное решение.
Преимущества использования уже существующего кода
Использование уже существующего кода может значительно облегчить вам жизнь
и сделать работу над проектом гораздо эффективнее.
□ Допустим, у вас нет возможности или желания писать код, который уже был
кем-то написан. И в самом деле, имеет ли смысл тратить силы на написание,
например, средств обработки форматированного ввода-вывода данных?
Конечно же, нет, и поэтому вы спокойно используете при необходимости
стандартные С++-потоки ввода-вывода.
□ Использование уже существующего кода экономит время. Если вы нашли код в
готовом виде, то вам не нужно писать его самостоятельно. Ведь наличие готовых
компонентов, помимо экономии времени, значительно упрощает структуру
вашего приложения, поскольку отпадает необходимость в разработке этих компонентов.
□ Готовый код теоретически требует меньших затрат на отладку, чем "сырой".
Ведь библиотечный код обычно уже протестирован и проверен "в деле", но,
конечно же, ошибки могут быть в любом коде, даже в библиотечном.
□ При использовании библиотек можно предположить, что они были
протестированы и уже использовались многими программистами. Поэтому есть все
основания надеяться на то, что большинство ошибочных ситуаций в них учтено,
и предусмотрена надлежащая их обработка. Этого, к сожалению, нельзя сказать
о "новоиспеченном" коде. В начале работы над проектом можно упустить
какие-нибудь граничные случаи, которые "выплывут" гораздо позже. Ваше
счастье, если это "позже" наступит еще до предъявления вашего приложения
пользователям. В противном случае все неучтенные вами ситуации будут
расценены ими как ошибки, что гораздо хуже.
□ Использовать готовый код, написанный специалистами в определенной
области знаний, гораздо безопаснее, чем программировать самому в неизвестной
(или малоизвестной) вам предметной области. Например, не стоит пытаться
110 Часть I. Введение в профессиональное С++-проектирование
писать собственный код по системе защиты, если вы не являетесь
специалистом в области обеспечения безопасности. Если вам необходимо выполнить
шифрование в своих программах, используйте библиотеку. Если же некоторым
деталям, которые покажутся вам маловажными, не будет уделено должное
внимание, они могут скомпрометировать безопасность всей программы.
Q Наконец, код .библиотеки постоянно совершенствуется. Если вы пользуетесь
библиотечными функциями, то будете, безусловно, в выигрыше, не прилагая
к этому дополнительных усилий со своей стороны! И в самом деле, если
создатели библиотеки надлежащим образом отделили интерфейс от реализации, ны
сможете получить новые версии своей библиотеки, не меняя характер
взаимодействия с ней. Корректное усовершенствование реализации библиотеки не
требует изменения интерфейса.
Недостатки использования уже существующего кода
К сожалению, у использования готового кода есть и слабые стороны.
Q Применяя только свои прошлые наработки, вы точно знаете их особенности
и тонкости реализации. Включая в свое приложение "чужие" библиотеки, вам
придется затратить время, чтобы разобраться в работе интерфейса.
Дополнительные временное затраты на начальном этапе проектирования могут
увеличить время создания проекта и отодвинуть момент начала кодирования.
□ Создавая код самостоятельно, вы точно знаете цель своей работы.
Библиотечный код может не обеспечить нужные вам функции в полном объеме.
Например, один из авторов этой книги однажды не обратил внимание на кричащий
недостаток библиотеки языка XML (extensible Markup language),
предназначенного для создания Web-страниц, и начал активно его использовать. На
первый взгляд, библиотека казалась замечательной: она поддерживала как модель
анализа DOM (Document Object Model — стандарт консорциума YVYVW,
определяющий способы манипулирования объектами и изображениями па одной YVeb-
странице). так и упрощенную модель API (Simple API for XML — SAX), работала
довольно эффективно и не требовала платного лицензирования. И нее было
хорошо до тех пор, пока во время кодирования он не понял, что эта библиотека
не поддерживает подтверждения правильности с точки зрения шаблона DTI)
(Document Type Definition — определение типа документа в языке Standard
Generalized Markup Language, или SGML, который представляет гобой
стандарт описания офисных документов, утвержденный ISO).
Q Даже если библиотечный код предоставляет нужные вам функции, его
производительность может оказаться недостаточно высокой. Производительность
может быть никуда не годной вообще, неприемлемой в вашем конкретном
случае или совершенно недокументированной. Кроме того, тот, кто написал эту
библиотеку или документацию на нее, мог не использовать те же стандарты
производительности, которые применяете вы.
Q Использование библиотечного кода можно сравнить с ящиком Пандоры (из
этого ящика, подаренного Зевсом Пандоре, по Земле распространились беды и
болезни). Что вы станете делать, если обнаружите в библиотеке ошибку? Ведь
чаще всего пользователь библиотеки не имеет доступа к ее исходному коду,
поэтому ваше желание исправить ошибку может не совпасть с возможностью это
сделать. И если вы уже затратили много времени на изучение библиотечного
Глава 4. Проектирование с использованием библиотек и шаблонов 111
интерфейса, то вам захочется не бросать его, а таки исправить ошибку. Но нет
никакой гарантии, что вам удастся убедить разработчиков библиотеки сделать
это. Кроме того, если вы используете библиотеку, созданную сторонним
производителем, то как вам быть в случае, если они решат прекратить ее поддержку,
а ваше приложение все еще зависит от нее?
□ Помимо проблем поддержки, использование библиотек сопряжено с
вопросами лицензирования. Использование открытых программных средств (т.е.
лицензионных программ вместе с их исходными текстами, не связанных
ограничениями на дальнейшую модификацию и распространение с сохранением
информации о первичном авторстве и внесенных изменениях) часто
предполагает, чтобы и вы оставили свой код открытым. Иногда использование
библиотек также связано с необходимостью оплаты лицензии, и тогда, возможно,
окажется, что дешевле самому написать нужный код.
□ Немаловажной остается проблема межплатформенной совместимости с
несколькими операционными средами или переносимости программы с очной машины
на другую. Большинство библиотек и оболочек разработаны в расчете на
конкретную платформу. Совершенно неудивительно, например, что MFC-обо ючка
(библиотека базовых классов Microsoft) работает главным образом под
управлением Microsoft Windows. Даже код, созданный с учетом требований межплат-
форменненности (совместимости с несколькими операционными средами),
будет, вероятно, на разных платформах работать по-разному. Если вам нужно
написать межплатформенное приложение, то для различных пчатформ вам
придется использовать различные библиотеки.
□ Для открытых приложений важной проблемой остается безопасность.
Некоторые программисты к использованию открытых программных продуктов
относятся довольно осторожно именно по причинам безопасности. Имея
исходный код программы, хакеры с целью дискредитации конкурента, например,
могут намеренно внести ошибки, которые до поры до времени будут
оставаться необнаруженными.
□ Наконец, в использовании готового кода большое значение имеет фактор
доверия. Было бы хорошо, если бы вы доверяли автору используемого вами кода.
Некоторые программисты до тошно проверяют каждую строку исходного кода,
попадающего в их проект. Один из авторов этой книги также не считает разумным
доверять библиотечному коду, написанном)- кем-то другим. Но такое огульное
недоверие не всегда полезно для разрабо тки в целом, да и в общем-то нереально.
Как принять решение
Теперь, когда вы познакомились с терминологией, достоинствами и недостатками
использования готового кода, вам будет легче принять решение о том, использовать
уже существующий код в своем новом проекте или пет. Часто решение приходит
быстро. Например, сети вы хотите написать на языке C++ графический интерфейс
пользователя (graphical user interface— GUI) для Windows-приложения, вы должны
использовать такую оболочку, как MFC. Если вы не знаете, как написать код GUI
в Windows, и, что важнее, не хотите тратить время на изучение теории, то, используя
оболочку, вы тем самым сэкономите приличный объем человеко-лет.
Но порой сделать выбор не так просто. Например, если вы не знакомы с
библиотекой или обочочкой, а вам нужна лишь простая структл-ра данных, то, возможно, не
112 Часть I. Введение в профессиональное С++-проектирование
стоит тратить время на изучение всей оболочки ради использования только одного
компонента, который можно написать за несколько дней.
Разумеется, решение всегда субъективно, и его необходимо принять в конкретной
ситуации. Зачастую приходится выбирать между временем, которое потребуется на
самостоятельное написание кода, и временем, которое может уйти на поиски и
изучение библиотеки, способной решить ваши проблемы. Тщательно оцените
перечисленные выше достоинства и недостатки и решите, какие факторы являются для вас
самыми важными. В конце концов, вы всегда можете передумать!
Стратегии использования готового кода
Используя библиотеки, оболочки, код своих коллег или собственный, необходимо
помнить о следующем.
Возможности и ограничения
Обязательно познакомьтесь с кодом, который собираетесь использовать. Важно
понимать как его возможности, так и ограничения. Начните с документации и
опубликованных интерфейсов или API. В идеальном случае достаточно понимать, как его
применять. Но если библиотека не обеспечивает четкого разделения между
интерфейсом и реализацией, то вам, возможно, придется разбираться и в исходном коде. Имеет
смысл также поговорить с программистами, которым уже приходилось использовать
этот код, и которые могут подробно рассказать вам о его особенностях. Лучше всего
начать с изучения базовых функций. Если речь идет о библиотеке, то какие она имеет
поведенческие характеристики? Если вас интересует оболочка, то насколько хорошо ваш
код впишется в нее? Какие классы следует вам создать? Какой код придется написать
самим? И это только часть вопросов, на которые вам предстоит дать ответ.
Применяя библиотеку или оболочку, обсудите следующие вопросы.
□ Будет ли этот код безопасным для многопоточных программ?
□ Какие действия по инициализации и очистительно-восстановительного
характера потребуются для библиотеки или оболочки?
□ От каких других библиотек зависит работа выбранной вами библиотеки или
оболочки?
Относительно любой библиотеки следует иметь в виду следующее.
□ Если библиотечная функция возвращает указатель, го кто несет
ответственность за освобождение памяти: инициатор вызова или библиотека? Если
библиотека, то когда будет освобождена занимаемая память?
□ Какие сбойные ситуации библиотечные функции способны выявить и какие
таковыми не считаются? Как происходит обработка ошибок?
□ Каким образом библиотечные функции возвращают результаты: по значению
или по ссылке? Какие возможные исключения могут они сгенерировать?
Используя оболочку, ответьте на следующие вопросы.
□ Если вы выводите класс из существующего, то какой конструктор вам следует
использовать? Какие виртуальные методы необходимо переопределить?
□ За освобождение какой области памяти несете ответственность вы и за
освобождение какой — оболочка?
Глава 4. Проектирование с использованием библиотек и шаблонов 113
П роиз вод ите л ьность
Важно знать, какую производительность гарантирует выбранная вами библиотека
или другой код. Даже если для конкретной программы это не критично, вы должны
быть уверены в том, что используемый вами код не снизит быстродействие
программы до непозволительного уровня. Например, от библиотеки для XML-анализа может
потребоваться довольно высокая скорость обработки, хотя в действительности она
сохраняет временные данные в файле, выполняя при этом дисковые операции ввода-
вывода, которые значительно снижают общее быстродействие.
Нотация "большого О"
Программисты, как правило, обсуждают производительность алгоритмов и
библиотек с помощью так называемой нотации "большого О" (служащей для
определения времени выполнения алгоритма; например, О(п) обозначает время,
пропорциональное числу и обрабатываемых элементов, a O(l) — время, не зависящее от числа
элементов). В этом разделе рассматриваются общие принципы анализа сложности
алгоритмов и нотации "большого О" без углубления в математические формулы. Если
вы уже знакомы с этими принципами, можете пропустить данный раздел.
Нотация "большого О" определяет не абсолютную, а относительную
производительность. Например, по результатам этой нотации устанавливается не отрезок
времени, скажем, 300 миллисекунд, в течение которого выполняется некоторый
алгоритм, а характер работы этого алгоритма как показатель увеличения его "входного
размера". "Входной размер" может включать количество элементов,
отсортированных алгоритмом сортировки, количество элементов, содержащихся в хеш-таблице во
время поиска ключа, и размер файла, копируемого с одного диска на другой.
Обратите внимание на то, что нотация "большого О" применяется только к
алгоритмам, скорость которых зависит от входных данных. Она не применяется к
алгоритмам, которые не принимают входные данные, или время их выполнения случайно. На
практике замечено, что время выполнения большинства алгоритмов таки зависит от
входных данных, поэтому это ограничение не очень существенно.
Теперь возьмем более формальный тон. Нотация "большого О" определяет время
выполнения алгоритма как функцию от его входного размера, именуемого также
сложностью алгоритма. На самом деле все не так сложно, как кажется на первый
взгляд. Например, предположим, что алгоритму сортировки требуется 50
миллисекунд, чтобы отсортировать 500 элементов, и 100 миллисекунд, чтобы отсортировать
1 000 элементов. Поскольку оказывается, что этому алгоритму требуется вдвое больше
времени на сортировку вдвое большего числа элементов, можно сделать вывод о том,
что его производительность является линейной функцией от объема его входных
данных. Таким образом, зависимость производительности от входных данных можно
было бы отобразить графически в виде прямой линии. Нотация "большого О"
обобщает производительность алгоритма сортировки с помощью записи 0(п). Буква "О"
просто обозначает, что мы используем нотацию "большого О", а п представляет
входной размер алгоритма. Обозначение 0(п) говорит о том, что скорость алгоритма
сортировки можно выразить в виде линейной функции от размера входных данных.
К сожалению, не у всех алгоритмов производительность является линейной ф)нк-
цией от объема входных данных. Если бы это было справедливо для всех алгоритмов,
компьютерные программы работали бы гораздо быстрее! В следующей таблице
подытожены общие категории функций (в порядке ухудшения их производительности).
114 Насть I. Введение в профессиональное С++-проектирование
Тип сложности
алгоритма
Нотация
'большого О'
Разъяснение
Пример алгоритмов
Постоянный
Логарифмический
Линейный
Линейно-
логарифмический
Квадратичный
0(1)
0(log п)
О(п)
0(n log n)
0(п2)
Время выполнения не зависит Доступ к одному элементу
от входного размера массива
Время выполнения является
функцией логарифма
по основанию 2 от входного
размера
Время выполнения прямо
пропорционально входному
размеру алгоритма
Время выполнения является
функцией от линейного
коэффициента, умноженного
на логарифмическую
функцию от входного размера
Время выполнения является
функцией от квадрата
входного размера
Поиск элемента
в отсортированном списке
методом двоичного поиска
Поиск элемента
в неотсортированном
списке
Алгоритм сортировки
слиянием
Более медленный алгоритм
сортировки методом
выбора (наименьшего или
наибольшего элемента)
У представления производительности в виде функции от размера входных данных
(а не в виде абсолютных чисел) есть два достоинства.
1. Такое представление не зависит от платформы. Тот факт, что некоторый
фрагмент кода выполняется за 200 миллисекунд на одном компьютере, ничего
не говорит о скорости его выполнения на другом. Так же трудно сравнить два
различных алгоритма, если не выполнить их на одном компьютере при
абсолютно одинаковой загрузке. Однако производительность, заданная как
функция от размера входных данных, применима к любой платформе.
2. Производительность, заданная как функция от размера входных данных,
охватывает все возможные входные условия работы алгоритмов с помощью одной
спецификации. Конкретное время в секундах, которое требуется алгоритму для
выполнения, относится только к одному конкретному варианту входных
данных и ничего не говорит о любом другом.
Советы по оценке производительности
Теперь, познакомившись с нотацией "большого О", вы сможете понять
большинство документов, посвященных анализу производительности. Стандартная С++-библиоте-
ка шаблонов описывает производительность своих алгоритмов и структур данных как
раз с помощью этой нотации. Однако и это средство описания не всегда бывает
эффективным. Рассмотрим следующие случаи.
Q Если алгоритм работает вдвое дольше при обработке вдвое большего объема
входных данных, то это ничего не говорит о том, насколько долго он
выполнялся в первом случае! Если алгоритм написан плохо, но показывает хорошие
"O''-результаты, вам все равно не стоит его использовать. Например,
предположим, что алгоритм выполняет ненужное обращение к диску. Этот факт не
повлияет на результаты оценки с использованием нотации "большого О", но
вряд ли производительность этого алгоритма можно назвать высокой.
Глава 4. Проектирование с использованием библиотек и шаблонов 115
□ Иногда трудно сравнить два алгоритма, которые по оценке нотации
"большого О" показывают одинаковые результаты. Например, если сложность двух
различных алгоритмов сортировки оценивается формулой 0(п log n), то, не
проведя собственных тестов, трудно сказать, какой из них действительно
будет работать быстрее.
□ Для небольших объемов входных данных время согласно нотации "большого О"
может быть очень обманчивым. При маленьких наборах данных 0(п ^-алгоритм
в действительности выполняется быстрее, чем 0(log п)-алгоритм. Поэтому,
прежде чем принимать решение, подумайте, какие у вас будут наиболее
вероятные размеры входных данных.
Кроме рассмотрения характеристик нотации "большого О", вам следует обратить
внимание и на другие аспекты производительности алгоритмов.
□ Подумайте, насколько часто вы собираетесь использовать конкретный фрагмент
библиотечного кода. На практике очень полезным считается правило "90/10",
которое гласит: 90% времени выполнения большинства программ тратится на
выполнение лишь 10% кода [54]. Если библиотечный код, который вы
собираетесь использовать, попадает в категорию часто выполняемых 10% кода, вам
следует тщательно проанализировать характеристики его производительности.
Но если он попадает в категорию часто "игнорируемых" 90% кода, то вам нет
смысла тратить время на такой анализ, поскольку общая производительность
программы от этого все равно не выиграет.
□ Не слишком доверяйте документации. Всегда сами тестируйте
производительность, чтобы понять, обеспечивает ли библиотечный код приемлемые рабочие
характеристики.
Ограничения, связанные с платформой
Прежде чем использовать библиотечный код, убедитесь в том, что вы знаете, для
какой платформы он предназначен. Ведь вы не будете пытаться использовать MFC-
библиотеку (библиотеку базовых классов Microsoft) в приложении, которое должно
выполняться под управлением операционной системы Linux. Кроме того, следует
иметь в виду, что и межплатформенные библиотеки могут по-разному работать на
разных платформах.
Кроме того, под различными платформами понимаются не только различные
операционные системы, но и различные версии одной и той же операционной
системы. Если вы пишете приложение, которое предназначено для работы под
управлением Solaris 8, Solaris 9 и Solaris 10, убедитесь, что все используемые вами библиотеки
также поддерживают все эти Solaris-версии. Нельзя полагаться на совместимость
снизу вверх или вверху вниз (т.е. обратную) в отношении версий операционных систем.
Например, тот факт, что данная библиотека работает под управлением Solaris 9,
совершенно не означает, что она будет работать под управлением Solaris 10, и наоборот.
Библиотека, ориентированная на применение в среде Solaris 10, может использовать
такие системные средства или другие библиотеки, которые не известны версии
системы с меньшим номером. С другой стороны, библиотека, предназначенная для
работы в среде Solaris 9, может использовать системные средства, которые были удалены в
Solaris 10, или быть ориентирована на устаревший двоичный формат.
116 Часть I. Введение в профессиональное С++-проектирование
Лицензирование и поддержка
Использование библиотек, созданных сторонними организациями, часто
сопряжено с трудностями лицензирования. Попросту говоря, если вы решили использовагь
чужие библиотеки, то необходимо купить право на это. Помимо вопросов, связанных
с лицензионным правом, вы можете столкнуться и с другими, например, с
ограничениями экспорта. Кроме того, библиотеки с открытым исходным кодом часто
распространяются по лицензии, которая требует, чтобы любой код, который связывается
с этими библиотеками, также был открытым.
Если вы планируете распространять или продавать свой
программный продукт, убедитесь, что хорошо осведомлены о лицензионных
ограничениях на использование библиотек, созданных сторонними
организациями. В случае сомнений лучше заранее
проконсультироваться у юристов.
Использование библиотек, созданных сторонними организациями, также
сопряжено с вопросами поддержки. Прежде чем включать такую библиотеку в свой
программный продукт, убедитесь, что вы хорошо понимаете процесс выявления ошибок,
и уточните, какое время займет их исправление. По возможности выясните, как долго
эта библиотека будет поддерживаться производителем, чтобы соответствующим
образом скоординировать и свои планы.
Интересно отметить, что даже использование "своих" библиотек (т.е. библиотек,
созданных в вашей же организации) может повлечь за собой проблемы поддержки.
Убедить сотрудника вашей компании (но, скажем, из другого отдела) исправить
ошибку в его библиотеке может оказаться таким же трудным делом, как и подобные
уговоры работника другой компании. В действительности это может оказаться даже
еще труднее, поскольку вы официально не являетесь платежеспособным клиентом.
Поэтому, прежде чем использовать "внутренние" библиотеки, убедитесь, что вы
понимаете политическую и организационную обстановку в своей компании.
Куда обратиться за помощью
Поначалу использование библиотек и оболочек может вызвать опасения. К счастью,
существует множество возможностей получить поддержку. Прежде всего, изучите
документацию, поставляемую с библиотекой. Если речь идет о таких широко
распространенных продуктах, как стандартная библиотека шаблонов (standard template library —
STL) или библиотека базовых классов Microsoft (Microsoft Foundation Classes— MFC),
подберите хорошую литературу по нужной теме. В данной книге информация по STL
содержится в главах 21—23. Если у вас есть конкретный вопрос, который не освещен
в этой книге и опущен в документации на библиотеку, попробуйте найти ответ в Web.
Адресуйте свой вопрос какому-нибудь поисковому средству, например Google (адрес:
www.google.com), и обратитесь к Web-страницам, посвященным обсуждению этой
библиотеки. Так, введя фразу "introduction to C++ STL" ("введение в C++ STL"), я
нашел сотни Web-сайтов, посвященных C++ и библиотеке STL.
Будьте осторожны: не верьте всему, что прочитаете в Web!
Web-страницы не так проверяются на достоверность, как книги или печатная
документация, и могут содержать неточности.
Глава 4. Проектирование с использованием библиотек и шаблонов 117
Также можно поучаствовать в сетевых конференциях (обратившись к сетевой
службе, рассылающей информацию по определенной теме) и подписаться на рассылку
новостей. Чтобы получить информацию об интересующей вас библиотеке или оболочке,
найдите группу новостей Usenet по адресу: http://groups.google.com. Например,
предположим, что вы не знаете, включает ли стандарт C++ хеш-таблицу из STL. Введя
в поисковое средство Google фразу "hashtable in C++ STL" ("хеш-таблица в C++ STL"), вы
получите несколько вариантов объяснения того, что в стандарт хеш-таблица не
включена, но многие производители компиляторов все же поддерживают ее реализацию.
В сетевых конференциях могут использоваться довольно резкие,
агрессивные выражения. Учтите это при поиске информации в Web.
Наконец, многие Web-сайты проводят собственные конференции по заданной
теме, участником которых можете стать и вы. Для этого необходимо зарегистрироваться.
Прототип
Для первого "подхода" к новой библиотеке или оболочке часто хорошо "помогает"
написание "на скорую руку" прототипа будущего приложения. Это — лучший способ
познакомиться с возможностями и ограничениями библиотеки. Причем это всегда
нужно делать со всей дотошностью, на которую вы способны, и к тому же заранее, т.е.
до введения ее в свой проект. Такая эмпирическая проверка позволит точнее
определить характеристики производительности библиотеки-кандидата.
Даже если ваш опытный экземпляр не будет иметь ничего общего с
окончательным приложением, время, затраченное на создание прототипа, не "убито" напрасно.
При этом не заставляйте себя писать прототип реального приложения. Напишите
макет программы, тестирующий библиотечные средства, которые вы предполагаете
использовать. Главное — близко познакомиться с библиотекой.
Из-за временных ограничений программисты иногда
трансформируют свои прототипы в конечный продукт. Если вы приметесь
наспех сколачивать прототип, который окажется поэтому
неприемлемым в качестве основы для конечного продукта,- знайте, что такой
подход здесь очень вреден.
Использование приложений от сторонних организаций
Ваш проект может включать несколько приложений. Возможно, для поддержки
инфраструктуры вашего нового электронного бизнеса вам потребуется интерфейсная
часть Web-сервера. Для этого вам, вероятно, придется связать некоторое "стороннее"
приложение (например, Web-сервер) с вашим программным продуктом. Такой подход
доводит идею использования готового кода до крайности, поскольку вы собираетесь
использовать целое приложение! Однако большинство предостережений и
рекомендаций, которые мы давали в отношении библиотек, применимы и к привязке
"сторонних" приложений. Но в этом случае нужно обратить особое внимание на
вопросы легальности и лицензирования.
Прежде чем связывать "сторонние" приложения со своими программными средствами,
обязательно проконсультируйтесь с юристами.
118 Часть I. Введение в профессиональное С++-проектирование
Кроме того, помните, что в этом случае еще более усложняется решение вопросов
поддержки. Например, если потребители столкнутся с проблемой привязки вашего
продукта к Web-серверу, то с кем им тогда вступать в контакт: с вами или
производителем этого Web-сервера? Непременно решите эти вопросы до выпуска своего продукта.
Открытые библиотеки
Открытые библиотеки становятся все более и более популярной категорией
многократно используемого кода. Общая значимость их "открытости" состоит в том, что
исходный код доступен для каждого желающего. Относительно включения открытого
кода в собственные приложения существует ряд формальных определений и правовых
норм, но здесь важно помнить, что открытые программные продукты позволяют
любому читать их исходный код. Открытыми бывают не только библиотеки. Например,
самым известным открытым продуктом, пожалуй, является операционная система Linux.
Об открытых программных продуктах
К сожалению, в терминологии, связанной с открытыми программными продуктами,
есть некоторая путаница. Прежде всего, необходимо сказать о существовании двух
конкурирующих названий для этого движения "открытости". Ричард Столлмен (Richard
Stallman) и сторонники GNU-нроекта (iVot t/nix-проект по свободному
распространению программного обеспечения) используют термин "free software" (т.е.
лицензионные программы вместе с их исходными текстами, не связанные ограничениями на
дальнейшую модификацию и распространение с сохранением информации о
первичном авторстве и внесенных изменениях). Обратите внимание на то, что термин free
не подразумевает, что законченный продукт должен распространяться бесплатно,
т.е. разработчики вольны сложить цену, которую считают нужной. Термин free
относится к свободному отношению к исходному коду со стороны тех, кто желает изучить
его, модифицировать и перераспределить. Чтобы лучше понять, что здесь
подразумевается в качестве свободы, то ближе всего это слово соответствует его применению
в словосочетании "свобода слова". Подробнее о Ричарде Столлмене и GNU-проекте
можно прочитать на сайте по адресу: www. gnu. org.
Не путайте термины free software и freeware. Термином freeware
обозначают программные продукты, которые можно свободно, т.е. бесплатно
распространять, но их исходный код является частной
собственностью. А термин free software относится к продуктам, использование
которых может требовать оплаты, но их исходный код должен быть
доступным для каждого желающего.
Объединение Open Source Initiative использует термин открытого программного
продукта для описания программ, исходный код которых должен быть доступным для
всех. Подобно продуктам категории free software, открытые программные продукты не
подразумевают бесплатного распространения. Подробнее об объединении Open
Source Initiative можно прочитать на сайте по адресу: www. opensource. org.
Поскольку название "открытый исходный код" менее двусмысленно, чем "free
software", в этой книге к продуктам и библиотекам с доступным исходным кодом мы
будем применять только термин "открытые". Выбор термина не подразумевает
поглощение принципами "открытости" принципов, которые присущи категории "free
software": это всего лишь способ упростить понимание сути проблемы.
Глава 4. Проектирование с использованием библиотек и шаблонов 119
Поиск и использование открытых библиотек
Независимо от терминологии, вы можете получить ошеломляющий выигрыш от
использования открытых программных продуктов. Главный выигрыш —
функциональные возможности. Можно говорить об огромном количестве открытых С++-
библиотек, созданных для решения различных задач: от XML-анализа до
межплатформенного механизма регистрации ошибок.
Несмотря на то что использование открытых библиотек не предполагает
бесплатного распространения и лицензирования, многие открытые библиотеки все же
доступны совершенно бесплатно. Это значит, что, используя такие библиотеки, вы
можете сэкономить деньги.
Наконец, при необходимости вы можете свободно модифицировать открытые
библиотеки. Большинство таких библиотек можно взять из Web-пространства.
Например, если обратиться опять-таки к "поисковику" Google и предложить ему
поработать над таким вариантом, как "open-source C++ library XML parsing" (открытые
С++-библиотеки по XML-анализу), то вы получите список ссылок на XML-библиотеки
па С и C++, включая libxml и Xerces C++ Parser.
Существует также ряд открытых порталов (общедоступных региональных узлов
компьютерной сети), которые вполне можно использовать для поиска нужных
библиотек:
□ www.opensource.org;
□ www. gnu. org;
□ www.sourceforge.net.
Ваши собственные способы поиска, несомненно, откроют вам и много других Web-
ресурсов.
Рекомендации по использованию открытого исходного кода
Использование библиотек с открытым исходным кодом сопряжено с рядом
особых проблем и требует новых стратегий.
Во-первых, открытые библиотеки обычно пишутся программистами в их
"свободное" время. Исходный код таких библиотек, как правило, доступен для всех, у кого
есть желание внести свой вклад в развитие данного продукта или исправить
замеченные ошибки. Если вы воспользовались результатами открытого продукта, то, как
порядочному человеку, вам следует самому посодействовать развитию подобных
проектов. Если вы работаете на какую-нибудь компанию, то можете, безусловно,
натолкнуться на сопротивление этой идее со стороны руководства, поскольку такой
подход не способствует прямому росту доходов компании. Однако вы могли бы
убедить свое руководство в том, что это может принести и косвенные выгоды, например,
приумножить доброе имя фирмы, а осознанная поддержка движения создания
открытых продуктов должна позволить вам открыто участвовать в этом движении.
Во-вторых, из-за распределенной природы разработки открытых продуктов и, по
сути, отсутствия единоличного права собственности использование таких библиотек
часто сопряжено с проблемами поддержки. Если вам непременно нужно исправить
ошибки в библиотеке, то лучше всего это сделать самому, а не ожидать "милостей" от
кого-то другого. Если вам удалось исправить ошибки, внесите исправления в
распространяемую версию этой библиотеки. Но даже если вам не удалось это сделать,
потрудитесь сообщить об обнаруженных вами проблемах, чтобы другие программисты не
тратили время на приобретение того же опыта.
120 Часть I. Введение в профессиональное С++-проектирование
Используя библиотеки с открытым исходным кодом, уважительно
относитесь к принципу "свободы" этого движения. Не стоит
порочить эту свободу или наживаться на чьих-то результатах труда, в
которые вы не внесли собственный вклад.
Стандартная библиотека C++
Самой важной для С++-программиста является, конечно же, стандартная С++-биб-
лиотека. Судя по названию, эта библиотека включена в состав стандарта C++, поэтому
любой "уважающий" стандарт компилятор должен ее содержать. Стандартная библиотека
не является монолитной: она включает несколько отдельных компонентов, которые вам,
вероятно, уже приходилось использовать и которые вы могли принять за ядро языка.
В этом разделе мы сделаем обзор компонентов стандартной библиотеки с точки зрения
проектирования программ. Не углубляясь в детали кодирования, вы узнаете, какими
библиотечными средствами вы можете располагать. Интересующие вас подробности
использования библиотечных средств рассматриваются в других главах этой книги.
Необходимо отметить, что мы не ставили своей целью сделать следующий обзор
библиотеки исчерпывающим. Одни детали будут рассмотрены позже, а другие
опущены вовсе. Обширный объем стандартной библиотеки не позволяет полностью
описать ее возможности в одной книге по C++ (нам известны книги объемом не менее
800 страниц, целиком посвященные одной лишь стандартной библиотеке!).
Стандартная библиотека языка С
Поскольку C++ является супермножеством языка С, С-библиотека доступна С++-
программисту в полном объеме. Она включает такие математические функции, как
abs (), sqrt () и pow (), генераторы случайных чисел (srand () и rand ()), а также
средства обработки ошибок (assert () и errno). Кроме того, библиотека языка С
позволяет обрабатывать символьные массивы как строки с помощью таких функций, как
strlen () и strcpy (), и использовать С-функции ввода-вывода (printf () и scanf ()).
В C++ предусмотрена более эффективная (по сравнению с языком С) поддержка строк
и средств ввода-вывода. Поэтому, несмотря на возможность использования в C++
функций обработки строк и средств ввода-вывода, лучше все же отказаться от этого
в пользу применения С++-строк и С++-потоков ввода-вывода.
Авторы этой книги полагают, что читатель знаком с библиотекой языка С Если
наше предположение неверно, обратитесь к любому из справочников по С,
перечисленных в приложении Б. Обратите также внимание на то, что в C++ используются
не такие же имена заголовочных С-файлов, как в языке С. Подробнее об этом
можно узнать на Web-сайте, посвященном стандартной С++-библиотеке (см. Internet-
ресурс Standard Library Reference).
Строки
В C++ определен встроенный класс string. И хотя вы по-прежнему можете
использовать С-строки (в виде символьных массивов), практически во всех случаях
лучше отдавать предпочтение С++-классу string. Он обеспечивает надлежащее
распределение памяти, граничную проверку (на отсутствие нарушения границ), семантику
присваивания и сравнения строк, а также поддерживает такие операции, как
конкатенация (сцепление), выделение подстроки и замена символа в строке.
Глава 4. Проектирование с использованием библиотек и шаблонов 121
С++-имя string является в действительности typedef-именем для
char-реализации шаблона basic string. Но об этих подробностях
беспокоиться не стоит; используйте класс string как если бы он был
обычным, а не шаблонным.
Напомним, что в главе 1 кратко рассмотрены возможности класса string.
Подробнее о них можно узнать на Web-сайте, посвященном стандартной С++-библиотеке
(см. Internet-ресурс Standard Library Reference).
Потоки ввода-вывода
В C++ реализована новая модель системы ввода-вывода, ориентированная на
использование потоков. Библиотека C++ содержит функции чтения и записи данных
встроенных типов в файлы, ввода с клавиатуры, вывода на экран, а также функции
обработки строк. Кроме того, в C++ предусмотрены средства создания функций
считывания и записи собственных объектов.
Основные принципы использования потоков ввода-вывода описаны в главе 1.
Подробнее об этом можно прочитать в главе 14.
Локализация
Язык C++ предоставляет поддержку средств локализации, которые позволяют
писать программы с использованием различных языков, символьных и числовых
форматов (подробности — в главе 14).
Интеллектуальные указатели
В C++ определен шаблон интеллектуального указателя "ограниченного действия",
именуемый auto_jptr. Этот шаблонный класс позволяет поместить указатель любого
типа в оболочку, которая обеспечивает автоматический вызов оператора delete при его
выходе за пределы области видимости. Однако этот класс не поддерживает подсчет
ссылок, поэтому у указателя такого типа не может быть одновременно несколько владельцев.
Подробнее об интеллектуальных указателях читайте в главах 13, 15, 16 и 25.
Исключения
Язык C++ поддерживает механизм обработки исключительных ситуаций
(исключений), который позволяет функциям или методам передавать информацию об
ошибках различных типов инициаторам их вызова. Стандартная библиотека C++
использует иерархию классов исключений, которую вы можете применять в своих программах
"как есть" или создавать на их основе собственные типы исключений. Подробнее об
исключениях и стандартных классах исключений читайте в главе 15.
Математические утилиты
Библиотека C++ содержит ряд классов математической направленности.
Несмотря на то что они "шаблонизированы" (а значит, их можно использовать с
любым типом данных), их полезность смогут оценить по достоинству, скорее, только
те программисты, которые занимаются численными расчетами, остальным же вряд
ли понадобятся эти утилиты.
Стандартная библиотека позволяет использовать класс комплексных чисел,
именуемый complex, который предоставляет возможность работать с комплексными
числами, содержащими действительную и мнимую части.
Стандартная библиотека также включает класс valarray, который "в
математическом смысле" представляет собой вектор. В библиотеке определены связанные классы
122 Часть I. Введение в профессиональное С++-проектирование
для представления концепции векторных "срезов". Вот из этих "строительных блоков"
вы можете построить классы, которые позволят выполнять операции над матрицами.
Однако встроенные классы матриц в библиотеке отсутствуют.
В C++ также предусмотрен новый способ получения информации о таких
числовых пределах, как максимально возможное значение для целочисленной переменной
в данной платформе. В языке С с этой целью можно использовать такую директиву,
как #def ine, и опросить идентификатор INT_MAX. Несмотря на то что все эти
средства по-прежнему доступны в C++, все же лучше использовать новое семейство
шаблонных классов numeric_limits.
Стандартная библиотека шаблонов
Ядро стандартной библиотеки C++ составляют обобщенные контейнеры и
алгоритмы. Эту часть библиотеки часто называют стандартной библиотекой шаблонов (standard
template library— STL), поскольку она изобилует всевозможными шаблонами.
Прелесть библиотеки STL в том, что она предоставляет возможность использовать
обобщенные контейнеры и алгоритмы, причем таким образом, что большинство
алгоритмов успешно работает на большинстве контейнеров независимо от типа данных,
хранимых в контейнере. В этом разделе мы кратко рассмотрим различные контейнеры
и алгоритмы STL, а более детально их использование описано в главах 21—23.
Контейнеры STL
Библиотека STL обеспечивает реализацию большинства стандартных структур
данных. Используя C++, вам не нужно самим писать такие структуры данных, как связные
списки или очереди. Структуры данных, или контейнеры, позволяют хранить элементы
информации, обеспечивая при этом соответствующий к ним доступ. Различные
структуры данных характеризуются различными способами вставки, удаления и доступа
к своим элементам. Важно хорошо знать поведенческие характеристики различных
структур данных, чтобы сделать правильный выбор для решения конкретной задачи.
Все контейнеры в библиотеке STL являются шаблонами, поэтому их можно
использовать для хранения элементов любого типа, от встроенного (например, int или
double) до объектов собственных классов. При этом следует иметь в виду, что в
любом отдельно взятом контейнере можно хранить элементы одинакового типа. Это
означает, что вы не можете хранить элементы разного типа в одном и том же
контейнере, например, очереди. Но вы запросто можете создать две отдельные очереди: одну,
например, для int, а другую — для double-значений.
В C++ STL-контейнеры гомогенные: в каждом контейнере могут
храниться элементы только одного типа.
Обратите внимание на то, что С++-стандарт определяет интерфейс, а не
реализацию каждого контейнера или алгоритма. Таким образом, различные производители
вольны предоставлять собственные реализации. Но стандарт также определяет
требования к функционированию, которым должны отвечать эти реализации.
Ниже приводится краткий обзор различных контейнеров библиотеки STL.
Вектор
Вектор обеспечивает хранение последовательности элементов и последовательный
доступ к ним. Вектор можно представить в виде массива элементов, который при
вставке в него элементов динамически увеличивается и выполняет проверку на "нерушимость
границ". Как и в массиве, элементы вектора хранятся в смежных ячейках памяти.
Глава 4. Проектирование с использованием библиотек и шаблонов 123
Вектор в C++ считается синонимом динамического массива — массива, который
увеличивается и сокращается динамически в соответствии с количеством хранимых в нем
элементов. Концепция С++-вектора (класса vector) не совпадает с математической.
Математические векторы моделируются в C++ посредством контейнера val array.
Векторы позволяют быстро выполнить (в течение некоторого постоянного
интервала времени) вставку элементов в конец вектора и их удаление, но довольно
медленно реализуют произвольный доступ при выполнении тех же операций (в этом
случае имеет место линейная зависимость). Дело в том, что при выполнении операций
вставки и удаления все элементы сдвигаются по очереди "вниз" или "вверх", чтобы
освободить место для нового элемента или заполнить пространство, освободившееся в
результате удаления элемента. Подобно массивам, векторы обеспечивают быстрый доступ
(в течение некоторого постоянного интервала времени) к любому из своих элементов.
Векторы стоит использовать в программах в случае, когда необходимо
организовать быстрый доступ к элементам, но когда добавление или удаление элементов не
ожидается слишком частым. Основное правило здесь — использовать вектор "в роли"
массива. Например, средство системного контроля может хранить список
компьютерных систем, подлежащих мониторингу, именно в векторе. Трудно предположить,
что новые компьютеры будут часто добавляться в этот список или уже существующие
удаляться из него. Однако вполне вероятно, что пользователи будут часто
интересоваться информацией о том или ином компьютере, поэтому время поиска нужного
элемента должно быть небольшим.
По возможности используйте вектор вместо массива.
Список
Список, являясь STL-контейнером, представляет собой стандартную структур)
связного списка. Подобно массиву или вектору, он предназначен для хранения
последовательности элементов. Но в отличие от массива или вектора элементы связного
списка необязательно хранятся в смежных ячейках памяти. В каждом элементе списка
указано, где найти следующий или предыдущий элементы (обычно с помощью
указателей). Обратите внимание на то, что список, в котором элементы указывают как на
следующий, так и на предыдущий элементы, называется дважды связанным списком.
Рабочие характеристики списка— прямая противоположность характеристикам
вектора. Для списков свойствен медленный (с линейной зависимостью) поиск
элементов и доступ к ним, но довольно быстрое выполнение (в течение некоторого
постоянного интервала времени) операций вставки и удаления элементов после
обнаружения соответствующей позиции. Таким образом, если вы планируете вставлять
или удалять множество элементов, а время поиска для вас не является критичным,
стоит остановить свой выбор на списках. Например, при реализации интерактивной
переписки необходимо где-то хранить имена всех текущих участников "разговора",
и для этого вполне подойдет список. Состав участников интерактивной переписки
часто меняется (одни уходят, другие — приходят), поэтому нужно быстро выполнять
операции вставки и удаления. С другой стороны, поиск участников разговора в списке
требуется проводить не очень часто, поэтому время его выполнения — не слишком
важная характеристика в данном случае.
Очередь с двусторонним доступом
Очередь с двусторонним доступом часто сокращенно называют деком (deque —
double-mded queae). Дек можно рассматривать как некоторый промежуточный
вариант между вектором и списком, который все же ближе к вектору. Подобно вектору, он
124 Часть I. Введение в профессиональное С++-проектирование
обеспечивает быстрый (с постоянной функцией времени) доступ к элементам. В то же
время, подобно списку, он позволяет быстро вставлять и удалять элементы с обоих
концов последовательности (функция времени— "амортизированная" константа).
Но так же, как список, дек обеспечивает медленное (с линейной зависимостью)
выполнение вставки и удаления в средней части последовательности.
Дек рекомендуется использовать вместо вектора, когда нужно вставлять или
удалять элементы с любого конца последовательности с сохранением быстрого доступа
ко всем элементам. Однако такое требование неприменимо к решению многих задач
программирования, поэтому в большинстве случаев используют вектор или очередь.
Такие контейнеры, как вектор, список и дек, называются
последовательными, поскольку они сохраняют последовательность элементов.
Очередь
Термин очередь соответствует своему "обычному" определению, означающему
очередность людей или объектов. Контейнерная очередь обеспечивает семантику стандартной
дисциплины обслуживания: первым прибыл, первым обслужен (first in, first out— FIFO).
Очередь представляет собой контейнер, вставка элементов в который происходит на
одном конце последовательности, а "обслуживание" — на другом. Как вставка
элементов, так и их удаление выполняется быстро (с постоянной функцией времени).
Структуру очереди рекомендуется использовать в случае, когда нужно смоделировать
реальный механизм "первым прибыл, первым обслужен". Рассмотрим, например,
работу банка. Клиенты заходят в банк и становятся в очередь. Один из банковских служащих,
отпустив текущего клиента, принимается обслуживать клиента из очереди, реализуя
таким образом FIFO-поведение. Вы могли бы смоделировать ситуацию в банке, сохраняя
объекты класса Customer в очереди. По прибытии клиента в банк мы помещаем его в
конец очереди. После того как банковский служащий, освободившись, будет готов к
обслуживанию, к нему пригласят клиента, стоящего в очереди первым. Таким образом,
клиенты обслуживаются в порядке, в котором они пришли в банк.
Очередь по приоритету
Очередь по приоритету обеспечивает функционирование очереди, в которой
каждый элемент имеет некоторый приоритет. Из такой очереди элементы удаляются
согласно приоритету. Вставка и удаление из очереди по приоритету обычно
происходит медленнее, чем в обычной очереди, поскольку для поддержки системы
упорядочения по приоритетам элементы необходимо перестраивать.
Очередь по приоритету можно использовать для моделирования "очередей с
исключениями". Например, вернемся к нашему банку и предположим, что клиенты,
у которых в этом банке открыты счета предприятий, имеют приоритет перед
обычными клиентами. Во многих реальных банках такое поведение реализовано с
использованием двух отдельных очередей: одна для VIP-клиентов, а другая — для всех
остальных. Любой клиент из VIP-очереди обслуживается раньше клиентов из обычной
очереди. Однако банки могли бы реализовать такое поведение и с одной очередью,
в которой VIP-клиенты просто бы перемещались к началу очереди, опережая любых
обычных клиентов. В программах мы рекомендуем использовать очередь по
приоритету в случае, если клиенты имеют один из двух уровней приоритета: VIP- или
обычный. Все VIP-клиенты должны обслуживаться перед всеми обычными клиентами, но
каждая из этих групп — в порядке "первым прибыл, первым обслужен".
Глава 4. Проектирование с использованием библиотек и шаблонов 125
Стек
Стек как член STL-библиотеки обеспечивает стандартную семантику дисциплины
обслуживания "первым прибыл, последним обслужен" (first-гп, feist-out— FILO).
Подобно очереди, стек организует вставку элементов в контейнер и удаление из него. Но
в стеке первым обслуживается (т.е. удаляется) элемент, который был вставлен
последним. Название стек присваивается такой организации объектов, при которой
видимым остается только верхний объект. При добавлении объекта в стек все
остальные (вставленные до него) объекты "скрываются" под ним.
С помощью стека моделируют реальное поведение "первым прибыл, последним
обслужен". В качестве примера представьте автостоянку в большом городе, на которой
автомобиль, прибывший первым, "запирается" автомобилями, прибывшими позже.
В этом случае первыми выехать со стоянки смогут автомобили, прибывшие последними.
Стек, являясь STL-контейнером, обеспечивает быструю (с постоянной функцией
времени) вставку и удаление элементов. Структуру стека рекомендуется использовать
в случае, когда необходимо реализовать FILO-семантику. Например, системный
администратор хотел иметь такое средство обработки ошибок, которое бы
обеспечивало хранение данных об ошибках в стеке, чтобы самая последняя ошибка была
доступной для изучения в первую очередь. Процесс обработки ошибок в FILO-порядке
полезен потому, что "свежие" ошибки часто помогают устранить более давние.
Формально такие контейнеры, как очередь, очередь по приоритету
и стек, выполняют роль контейнерных адаптеров. Они, по сути,
представляют собой интерфейсы, встроенные в вершину одного из трех
стандартных последовательных контейнеров (вектора, дека и списка).
Множество и мультимножество
Под множеством в библиотеке STL понимается коллекция элементов. Несмотря на
то что математическое определение множества предполагает использование
неупорядоченной коллекции, STL-множество позволяет хранить элементы в некотором
порядке. Это дает возможность довольно быстро выполнять операции поиска,
вставки и удаления. В действительности множество выполняет поиск, вставку и удаление
быстрее, чем вектор (по вставке и удалению) и список (по поиску). Однако операции
вставки и удаления выполняются медленнее, чем в списке, а поиск — медленнее, чем
в векторе. В качестве базовой реализации множества обычно используется
сбалансированное двоичное дерево, поэтому множество следует использовать в тех случаях,
когда обычно используют сбалансированное двоичное дерево. Использование
множества особенно "показано" при наличии примерно одинакового количества
выполняемых операций вставки-удаления и поиска и при желании оптимизировать
программу. Например, в программе ведения склада для книжного магазина для хранения
информации о книгах имеет смысл использовать именно множество. Список книг на
складе должен обновляться при каждом поступлении новых книг или в результате их
продажи, поэтому операции вставки и удаления должны выполняться быстро.
Клиентам также необходимо предоставить возможность без проблем находить нужную
книгу, поэтому программа должна быстро справляться и с поиском.
Используйте множество вместо вектора или списка в случае, если
хотите обеспечить одинаковое быстродействие при выполнении
операций вставки, удаления и поиска.
126 Часть I. Введение в профессиональное С++-проектирование
Обратите внимание на то, что множество не позволяет хранить дубликаты
элементов. Другими словами, каждый элемент в множестве должен быть уникальным.
Если же вам нужно обеспечить хранение дубликатов, используйте такой контейнер, как
мультимножество.
Мультимножество —-
дубликаты элементов.
это
множество,
которое
позволяет
хранить
Отображение и мультиотображение
Отображение предназначено для хранения пар ключ-значение. Элементы здесь
сортируются в соответствии с ключами. Во всех других отношениях отображение
идентично множеству. Отображение следует использовать в случаях, когда нужно
связать ключи и значения. Например, в сетевой игре, предполагающей участие
нескольких игроков, нужно хранить информацию о каждом игроке (зарегистрированное имя,
настоящее имя, IP-адрес и другие характеристики). Эту информацию можно хранить
в отображении, используя в качестве ключа зарегистрированное (пользовательское) имя.
Мультиотображение имеет такое же отношение к отображению, как и
мультимножество к множеству. В частности, мультиотображение можно определить как
отображение, которое позволяет хранить дубликаты ключей.
Обратите внимание на то, что отображение можно использовать в качестве
ассоциативной матрицы, т.е. массива, в котором индекс может быть выражен любым
типом данных, например строкой.
Такие контейнеры, как множество и отображение, называются
ассоциативными, поскольку они ассоциируют, или связывают, ключи
и значения. Этот термин применительно к множествам может ввести
в заблуждение, поскольку в множествах ключи сами представляют
собой значения. Поскольку эти контейнеры выполняют сортировку
своих элементов, они также называются отсортированными
ассоциативными контейнерами.
Битовое множество
С- и С++-программисты часто хранят наборы флагов (признаков) в int- или long-
значениях, в которых один бит соответствует одному флагу. Они устанавливают или
считывают эти биты с помощью побитовых (поразрядных) операторов: &, |, л, ~, <<
и >>. Стандартная библиотека C++ включает класс bitset, который абстрагирует
манипуляции над битовыми полями, поэтому вам не стоит больше использовать эти
поразрядные операторы.
Контейнер bitset — это не контейнер в обычном смысле, т.е. он не реализует
конкретную структуру данных, позволяющую вам вставлять или удалять элементы. Но
вы можете представить ее в виде последовательности булевых значений, которые
можно считывать и устанавливать.
Резюме по STL-контейнерам
В следующей таблице мы подытоживаем сведения по всем контейнерам из
библиотеки STL. Для представления рабочих характеристик контейнера (быстродействия),
содержащего N элементов, здесь используется нотация "большого О".
Обозначение "—" (прочерк) говорит о том, что данная операция не является частью
семантики контейнера.
Глава 4. Проектирование с использованием библиотек и шаблонов 127
Имя класса Тип Быстродействие
контейнера контейнера вставки удаления поиска
Применение
vector Последова- 0(1) в конце; 0(1) в конце; 0(1)
тельный O(N) в O(N) в
других других
случаях случаях
Последова- 0(1) 0(1) O(N)
тельный
list
deque
queue
priority^
queue
stack
set /
multiset
map /
multimap
bitset
Последова- 0(1) в начале 0(1) в начале 0(1)
тельный или в конце; или в конце;
0(N) в других 0(N) в других
случаях случаях
Адаптер 0(1) 0(1) —
Адаптер OflogfN)) 0(log(N)) —
Когда нужен быстрый поиск; не
имеет значения скорость
операций вставки-удаления;
везде, где используется массив
Когда требуется быстрое
выполнение операций вставки-
удаления; не имеет значения
скорость поиска
Обычно вместо типа deque
используется тип vector или
list
Адаптер
Отсортированный
ассоциативный
0(1)
OflogfN))
Отсортиро- 0(log(N))
ванный
ассоциативный
Специаль- —
ный
— При реализации FIFO-структуры
— При реализации FIFO-структуры
по приоритету
0(1) — При реализации FILO-структуры
0(log(N)) Oflog(N)) Когда нужна коллекция
элементов с одинаковым
временем выполнения операций
вставки, удаления и поиска
Oflog(N)) Oflog(N)) Когда необходимо связать
пары ключей и значений
— 0(1) Когда нужна коллекция флагов
(признаков)
Обратите внимание на то, что формально объекты класса string также являются
контейнерами. Их можно считать векторами символов. Поэтому многие описанные
ниже алгоритмы работают и на string-объектах.
Алгоритмы STL
Помимо контейнеров, библиотека STL содержит реализации обобщенных
алгоритмов. Алгоритм— это стратегия выполнения конкретной задачи, например,
задачи сортировки или поиска. Алгоритмы также реализованы как шаблоны, поэтому
они способны работать с большинством различных типов контейнеров. Обратите
внимание на то, что алгоритмы не являются частью контейнеров. В библиотеке STL
принято отделять данные (контейнеры) от выполнения действий над ними
(алгоритмы). Хотя может показаться, что такой подход противоречит духу
объектно-ориентированного программирования, без него не обойтись при поддержке
обобщенного программирования с использованием библиотеки STL. Основной
принцип ортогональности гласит, что алгоритмы и контейнеры должны быть
независимы, а это значит, что (практически) любой алгоритм должен работать с
(практически) любым контейнером.
128 Часть I. Введение в профессиональное С++-проектирование
Несмотря на то что алгоритмы и контейнеры теоретически независимы, некоторые
контейнеры предусматривают использование определенных алгоритмов в форме
методов классов, поскольку обобщенные алгоритмы не очень эффективно работают с этими
конкретными контейнерами. Например, для множеств рекомендуется использовать
собственный алгоритм find (), который быстрее обобщенного алгоритма find ().
Обратите внимание на то, что обобщенные алгоритмы не работают с
контейнерами напрямую. Они используют интерфейсное средство-посредник, именуемое
итератором (iterator). Каждому контейнеру в библиотеке STL соответствует итератор,
который поддерживает обход элементов контейнера в некоторой последовательности.
Итераторы временно преобразуют ("выстраивают") элементы, содержащиеся в
множествах и отображениях, в последовательность. Различные итераторы для различных
контейнеров используют стандартные интерфейсы, поэтому алгоритмы могут
выполнять свою работу с помощью итераторов независимо от особенностей реализации
контейнера. Более детально об итераторах, алгоритмах и контейнерах можно
прочитать в главах 21—23.
Итераторы служат связующим звеном между алгоритмами и контейнерами. Они
предоставляют стандартный интерфейс для обхода элементов контейнера в некоторой
последовательности, чтобы любой алгоритм мог работать с любым контейнером.
Шаблоны проектирования итераторов рассматриваются ниже.
Библиотека STL содержит приблизительно 60 алгоритмов (все зависит от того, как
их считать), разделяемых на несколько различных категорий. Несмотря на то что
многие авторы книг по программированию на C++ расходятся в видении этих
категорий, в данной книге используются следующие пять: утилиты (вспомогательные), не
модифицирующие, модифицирующие, алгоритмы сортировки и выполнения
операций над множествами. Некоторые из перечисленных категорий имеют подкатегории.
Обратите внимание на то, что если алгоритмы определены как работающие на
"последовательности" элементов, это значит, что последовательность элементов
передается им в качестве итератора.
Рассматривая список алгоритмов, следует иметь в виду, что
библиотека STL была спроектирована комитетом специалистов. Здесь уместно
вспомнить старую шутку: "зебра— это лошадь, спроектированная
комитетом". Другими словами, деятельность комитетов часто
завершается результатами, которые характеризуются наличием лишних или
ненужных элементов (как, например, полоски у зебры). Таким образом,
и среди алгоритмов STL можно найти в равной степени как стран*
ные, так и излишние. Но в этом нет ничего страшного. Вы не
обязаны использовать все алгоритмы. Но важно знать, что у вас есть
возможность применить их в случае, если вы посчитаете их полезными.
Вспомогательные алгоритмы (утилиты)
В отличие от других алгоритмов утилиты не работают с последовательностями
данных. Мы рассматриваем их как часть библиотеки STL только потому, что они
"шаблонизированы".
Глава 4. Проектирование с использованием библиотек и шаблонов 129
Название алгоритма
Описание
min(), max()
swap()
Возвращает минимум или максимум из двух значений
Меняет местами два значения
Не модифицирующие алгоритмы
Не модифицирующие алгоритмы "просматривают" последовательность
элементов и возвращают некоторую информацию о них или выполняют некоторую функцию
для каждого элемента. Поскольку эти алгоритмы немодифицирующие, они не могут
изменять значения элементов или их порядок в пределах последовательности. Данная
категория содержит четыре типа алгоритмов. В следующих таблицах перечислены
различные немодифицирующие алгоритмы, используя которые, вам не придется
писать цикл для опроса последовательности значений.
Алгоритмы поиска
Название
алгоритма
Описание
Требование
отсортированной
последовательности
find (),
find if ()
find first of()
adjacent_find()
search(),
find end()
searchjn ()
lower_bound(),
upper_bound(),
equal_range()
binary_search()
min_element(),
max element()
Находит первый элемент, который совпадает
с заданным значением, или первый элемент,
для которого заданный предикат возвращает
значение true
Аналогичен алгоритму find (), за исключением
того, что поиск выполняется внутри заданной
последовательности элементов
Находит первый из двух совпадающих смежных
элементов /
Находит первую (search ()) или последнюю
(f indend ()) заданную последовательность
внутри другой заданной последовательности
Находит первый элемент из п смежных
элементов, равных заданному значению
Находит начальный элемент (lower_bound ()),
конечный (upperbound ()) или оба граничных
элемента (equal_range ()) диапазона,
включающего заданный элемент
Находит значение в отсортированной
последовательности
Находит минимальный или максимальный
элемент в последовательности
Нет
Нет
Нет
Нет
Нет
Да
Да
Нет
Алгоритмы числовой обработки
Название алгоритма
Описание
count (), count_if () Подсчитывает количество элементов, совпадающих с заданным
значением или удовлетворяющих заданному предикату
130 Часть I. Введение в профессиональное С++-проектирование
Окончание таблицы
Название алгоритма
Описание
accumulate()
inner_product()
partial_sum()
adj acent_difference(]
По умолчанию "накапливает" сумму значений всех элементов
последовательности. Инициатор вызова (вместо суммирования)
может задать и другую бинарную функцию
Аналогичен алгоритму accumulate (), но работает с двумя
последовательностями, аккумулирующими результат.
По умолчанию выполняется умножение. Если
последовательности представляют собой математические
векторы, алгоритм вычисляет скалярное произведение векторов
Генерирует новую последовательность, в которой каждый
элемент является результатом сложения (или другой бинарной
операции) параллельного и всех предыдущих элементов
исходной последовательности
Генерирует новую последовательность, в которой каждый
элемент является результатом вычитания (или другой бинарной
операции) параллельного элемента и его предшественника
в исходной последовательности
Алгоритмы сравнения
Название алгоритма
Описание
equal()
mismatch()
lexicographical_compare()
Определяет равенство двух последовательностей на основе
одинакового порядка следования элементов
Возвращает первый элемент в последовательности, который
не совпадает с соответствующим ему элементом из другой
последовательности
Сравнивает две последовательности в "лексикографическом"
порядке: сравниваются два соответствующих элемента; если
один элемент меньше другого, эта последовательность
считается "лексикографически" первой; если элементы
равны, сравниваются следующие по порядку элементы
Операционные алгоритмы
Название алгоритма
Описание
for each()
Выполняет функцию для каждого элемента последовательности. Этот
алгоритм полезно использовать для вывода всех элементов контейнера
Модифицирующие алгоритмы
Модифицирующие алгоритмы (как нетрудно догадаться по названию) изменяют
некоторые или все элементы в последовательности. Одни алгоритмы модифицируют
элементы "по месту", изменяя таким образом исходную последовательность. Другие
копируют результаты в отдельную последовательность, чтобы исходную
последовательность оставить без изменения. Краткое описание модифицирующих алгоритмов
приведено в следующей таблице.
Глава 4. Проектирование с использованием библиотек и шаблонов 131
Название алгоритма
Описание
transform()
сору (), copy_backward ()
iter_swap(),
swap_ranges()
replace(), replace_if (),
replace_copy(),
replace_copy_if()
fill(), fill_n()
generate(),
generate_n()
remove(), remove_if ()
remove_copy() ,
remove_copy_if()
unique(),
unique_copy()
reverse(),
reverse_copy()
rotate(),
rotate_copy ()
next_permutation (),
prev_permutation()
Вызывает функцию для каждого элемента или для каждой пары
элементов
Копирует элементы из одной последовательности в другую
Меняет местами два элемента или две последовательности
элементов
Заменяет новым элементом все элементы, значения которых
совпадают с заданным, или элементы, для которых заданный
предикат возвращает значение true, причем реализация замены
возможна как "по месту", так и путем копирования в новую
последовательность
Устанавливает все элементы последовательности равными
заданному значению
Аналогичны алгоритмам f ill () и f illn (), за исключением
того, что для заполнения последовательности значениями
используется заданная функция-генератор
Удаляет из последовательности все элементы, значения которых
совпадают с заданным, или элементы, для которых заданный
предикат возвращает значение true, причем удаление возможно
как "по месту", так и путем копирования в новую последовательность
Удаляет из последовательности смежные дубликаты, причем
удаление возможно как "по месту", так и путем копирования
в новую последовательность
Обращает порядок следования элементов в последовательности
"по месту" или путем копирования в новую последовательность
Меняет местами первую и вторую "половины" последовательности
"по месту" или путем копирования в новую последовательность.
Обмениваемые последовательности необязательно должны
совпадать по размеру
Модифицирует последовательность путем создания "следующей"
или "предыдущей" перестановки. Последующие вызовы этих
алгоритмов позволяют создать все возможные перестановки
элементов исходной последовательности
Алгоритмы сортировки
Алгоритмы сортировки образуют специальную категорию модифицирующих
алгоритмов, которые сортируют элементы последовательности. Библиотека STL
включает несколько различных алгоритмов сортировки, которые различаются
гарантированными характеристиками.
Название алгоритма
Описание
sort О, stable sort ()
Сортирует элементы "по месту", либо сохраняя порядок
следования элементов-дубликатов, либо нет. Быстродействие
алгоритма sort () близко к быстродействию алгоритма
"быстрой сортировки" (quicksort), а алгоритм stable_sort О
по своим характеристикам близок алгоритму сортировки
слиянием (merge-sort)
132 Часть I. Введение в профессиональное С++-проектирование
Окончание таблицы
Название алгоритма
Описание
partial_sort(),
partial_sort_copy()
nth_element()
merge(),
inplace_merge()
make_heap(), push_heap(),
pop_heap(), sort_heap()
partition(),
stable_j?artition ()
random shuffle()
Сортирует последовательность частично: сортируются только
первые п элементов либо "по месту", либо путем копирования
в новую последовательность
Перемещает п-й элемент последовательности так, как если
бы вся последовательность была отсортирована
Объединяет две отсортированные последовательности либо
"по месту", либо путем копирования в новую последовательность
"Куча" (heap — здесь частично упорядоченное полное бинарное
дерево) — это стандартная структура данных, в которой элементы
массива или последовательности упорядочиваются "наполовину",
чтобы обеспечить быстрый поиск "вершины". Эти четыре
алгоритма позволяют выполнять в последовательностях леар-сортировку
Так сортирует последовательность, что все элементы, для которых
заданный предикат возвращает значение true, размещаются
перед элементами, для которых этот предикат возвращает
значение false. При этом либо сохраняется исходный порядок
следования элементов в пределах каждого сегмента, либо нет
Придает случайный характер последовательности элементов
Алгоритмы выполнения операций над множествами
Алгоритмы выполнения операций над множествами составляют отдельную
категорию модифицирующих алгоритмов. Больше всего они подходят для
последовательностей, хранимых в set-контейнерах (множествах), но их можно применять и для
отсортированных последовательностей, хранимых во многих других контейнерах.
Название алгоритма
Описание
includes()
set_union(), set_difference()
set_intersection(),
set_symmetric_difference()
Определяет, является ли одна последовательность
подмножеством другой
Выполняет операции, определенные для множеств,
над двумя отсортированными последовательностями.
Результаты заносятся в третью отсортированную
последовательность. Для получения информации
об операциях над множествами см. главу 22
Выбор алгоритма
От количества и возможностей алгоритмов можно поначалу растеряться. Как
разобраться в таком разнообразии? Как сделать правильный выбор? Об эффективном
использовании алгоритмов можно прочитать в главах 21—23.
Чего не хватает в библиотеке STL
В мире нет ничего совершенного. И библиотека STL не исключение. Составим
список ее пробелов и упущений.
□ В библиотеке STL не предусмотрено средств синхронизации для безопасного
функционирования в многопоточной среде. Стандарт STL не обеспечивает
поддержку многопоточной синхронизации, поскольку организация поточной
Глава 4. Проектирование с использованием библиотек и шаблонов 133
обработки (сообщений или данных) зависит от платформы. Таким образом,
если вы пишете многопоточную программу, то вам придется самим реализовать
средства синхронизации для контейнеров.
О Библиотека STL не поддерживает функционирование хеш-таблиц. Другими
словами, здесь не предусмотрено никаких хешированных ассоциативных
контейнеров (ассоциативных контейнеров, в которых элементы не отсортированы, но
хранятся в виде хеш-таблиц). Обратите внимание на то, что во многих
реализациях библиотеки STL яеш-таблица или хеш-отображение таки присутствуют, но
пока это не является частью стандарта, использование этих средств не
позволит вашей программе быть переносимой. Пример реализации хеш-отображения
представлен в главе 23.
□ В библиотеке STL не предусмотрено средств для представления обобщенного
дерева или дерева графа. Несмотря на то что отображения и множества
реализованы как сбалансированные двоичные деревья, библиотека STL не открывает
эту реализацию в интерфейсе. Для того чтобы реализовать дерево или граф
(например, при написании синтаксического анализатора), вам придется создать
собственную структуру или найти подходящий вариант в другой библиотеке.
□ Библиотека STL не включает никаких табличных абстракций. Если вы
собираетесь реализовать что-нибудь наподобие шахматной доски, используйте
двумерный массив.
Однако важно иметь в виду, что библиотека STL открыта для новых элементов. Вы
можете создать собственные контейнеры или алгоритмы, которые будут работать
с существующими алгоритмами или контейнерами. Поэтому, если библиотека STL не
отвечает в полной мере вашим потребностям, напишите собственный код, который
бы работал в согласии с STL.
Использовать или не использовать библиотеку STL
При проектировании библиотеки STL в качестве основных ставились цели добиться
нужной функциональности, быстродействия и ортогональности, а не простоты
применения. В начале этой главы мы упомянули о сложностях, которые ожидают
программиста на пути освоения библиотеки STL. Но, преодолев трудности ее изучения, вы
останетесь в выигрыше, причем в существенном. Подумайте о времени, которое уйдет на
вылавливание неправильно использованных указателей при организации связного
списка или сбалансированного двоичного дерева либо на отладку алгоритма сортировки,
"не желающего" правильно сортировать данные. Если же вы научитесь корректно
применять библиотеку STL, то вам вряд ли понадобится заниматься кодированием.
Если вы решите использовать библиотеку STL в своих программах, внимательно
изучите главы 21—23. Они представляют собой углубленный самоучитель по
использованию контейнеров и алгоритмов STL.
Проектирование с использованием
шаблонов и методов
Знать язык C++ и быть хорошим С++-программистом — это две разные вещи.
Прочитав стандарт C++ и запомнив каждую деталь, вы будете знать язык
программирования C++ как никто другой. Но если вы не приобретете собственного опыта написания
134 Часть I. Введение в профессиональное С++-проектирование
программ и изучения кода, написанного профессионалами, вам никогда не стать
хорошим программистом. Дело в том, что синтаксис C++, определяя, что может язык,
ничего не говорит о том. как следует использовать его возможности.
Приобретя некоторый опыт в использовании языка C++, программисты находят
и развивают собственные пути использования языковых средств. Сообщество C++
создало некоторые стандартные пути применения языка: как формальные, так и
неформальные. Авторы этой книги акцентируют внимание на использовании уже
готового кода, а именно шаблонов и методов проектирования. Главы 25 и 26 почти
целиком посвящены этой теме. Одни из описанных там шаблонов и методов могут
показаться вам очевидными, поскольку они представляют простую формализацию
очевидного решения. Другие же описывают свежие подходы к решению проблем,
с которыми вам, скорей всего, уже приходилось сталкиваться. Третьи, с нашей точки
зрения, выражают совершенно новые идеи в отношении организации программ.
Читателю важно познакомиться с этими шаблонами и методами проектирования,
чтобы знать, когда с помощью уже существующих заготовок можно решить
конкретные проблемы проектирования. Следует понимать, что в этой книге описана лишь
малая часть методов и шаблонов, которые можно применить к С++-задачам. И хотя
авторы постарались описать наиболее полезные из них, вы должны иметь
возможность узнать о существовании других. В этом вам поможет приложение Б.
Методы проектирования
Метод проектирования представляет собой стандартный подход к решению
конкретной задачи в C++. Часто целью метода проектирования является замена
неудобного средства или восполнение "пробела" в языке C++. По сути, метод
проектирования — это просто фрагмент кода, который можно использовать во многих различных
программах для решения распространенных проблем.
Пример метода проектирования: интеллектуальные указатели
Управление памятью в C++ можно определить как "не пересыхающий" источник
ошибок. Многие ошибки возникают в результате использования динамического
выделения памяти и применения указателей. При частом употреблении этих средств
в своих программах (особенно при передаче указателей между объектами) можно
легко забыть о необходимости однократного выполнения оператора delete для каждого
указателя. Последствия же неосторожного обращения со "спичками" (т.е.
указателями) могут быть очень тяжелыми: освобождение динамически выделенной памяти
более одного раза может стать причиной разрушения данных, а если вы забудете
освободить динамически выделенную память, это может привести "утечке памяти".
Интеллектуальные указатели позволяют управлять динамически выделяемой
памятью. Интеллектуальный указатель— это указатель на динамически выделенную
память, который "помнит" о необходимости освободить ее при выходе из области
видимости. В программах интеллектуальный указатель часто представляется объектом,
который содержит обычный (т.е. неинтеллектуальный) указатель. Этот объект
размещается в стеке. При выходе из области видимости его деструктор для
"внутреннего" указателя выполняет оператор delete.
Обратите внимание на то, что в некоторых языках программирования
обеспечивается "сбор мусора" (процесс утилизации памяти, освобождаемой во время работы
программы или системы), чтобы программисты не несли ответственность за
освобождение какой бы то ни было памяти. В таких языках все указатели можно считать
Глава 4. Проектирование с использованием библиотек и шаблонов 135
интеллектуальными, поскольку вам не нужно помнить об освобождении памяти, на
которую они указывают. Хотя такие языки, как Java, считают и реализуют процесс
"сбора мусора" как нечто само собой разумеющееся, такой "сборщик мусора" для C++
реализовать очень трудно. Таким образом, интеллектуальные указатели — это просто
способ компенсировать недостатки существования "открытых дверей" к С++-управлению
памятью без автоматического "сбора мусора".
Управление указателями создает больше проблем, чем опасность не удалить их
при выходе из области видимости. Иногда несколько объектов содержат копии
одного и того же указателя. Эта проблема называется совмещением имен (aliasing). Чтобы
корректно освободить всю память, в участке кода, который последним использует эту
область памяти, должно быть предусмотрено выполнение операции delete для этого
указателя. Но в том-то все и дело, что зачастую трудно узнать, какой фрагмент кода
будет использовать эту- память последним. Иногда при написании программы это
сделать даже невозможно, поскольку порядок выполнения участков кода может зависеть
от данных, вводимых при выполнении программы. Поэтом)' в указателе с более
высоким уровнем "интеллекта" реализуется подсчет ссылок, который позволяет отследить
их владельцев. Когда все владельцы завершат использование указателя, количество
ссылок упадет до нуля, и интеллектуальный указатель выполнит операцию delete для
"внутреннего" обычного указателя. Многие С++-оболочки (например, Object Linking
and Embedding (OLE) компании Microsoft и Component Object Model (COM))
используют подсчет ссылок очень интенсивно. Если вы не собираетесь реализовать подсчет
ссылок самостоятельно, важно понимать суть этой идеи.
В C++ предусмотрено несколько языковых средств, которые делают
использование интеллектуальных указателей чрезвычайно привлекательным. Во-первых, можно
написать класс указателей, обеспечивающих типовую безопасность с помощью
шаблонов. Во-вторых, можно создать интерфейс с объектами интеллектуальных
указателей, используя перегрузку операторов, благодаря которой объекты интеллектуальных
указателей можно использовать так, как если бы они были обычными. В частности, вы
могли бы перегрузить операторы "*" и "->", чтобы код клиента мог разыменовывать
объект интеллектуального указателя точно так же, как разыменовываются обычные
указатели. В главе 25 приведена реализация интеллектуального- указателя, который
"умеет" подсчитывать ссылки. Этот код вы можете непосредственно вставить в свою
программу. Стандартная библиотека C++ также содержит простой вариант
интеллектуального указателя, именуемого auto_jotr.
Шаблоны проектов
Шаблон проектов представляет собой стандартный подход к организации
программы, который позволяет решить общую задачу. C++ — объектно-ориентированный язык,
поэтом)' С++-программистов больше всего интересуют объектно-ориентированные
шаблоны, которые описывают стратегию организации объектов в программе и
отношений между ними. Такие шаблоны обычно применяются к любому объектно-
ориентированному языку (C++, Java или Smalltalk). Если вы имеете опыт
программирования на языке Java, то вам будут знакомы многие из рассматриваемых здесь шаблонов.
Шаблоны проектов менее привязаны к конкретному языку, чем методы
проектирования. Различие между шаблоном и методом проектирования, по общему
признанию, не имеет четкого выражения, и в различных книгах приводятся различные
определения. Здесь мы определяем метод как стратегию, применяемую конкретно
к языку C++, которая позволяет восполнить пробелы в средствах языка, в то время как
136 Часть I. Введение в профессиональное С++-проектирование
о шаблоне мы говорим как о более общей стратегии для объектно-ориентированного
проектирования, применимой для любого объектно-ориентированного языка.
Обратите внимание на то, что многие шаблоны имеют различные названия.
Различия между самими шаблонами часто трудно уловить, поскольку в различных
литературных источниках они описываются по-разному и относятся к разным категориям.
Другими словами, некоторые различные шаблоны носят одинаковые имена. Более
того, у авторов книг существуют разногласия по поводу того, какие стратегии
проектирования квалифицировать как шаблоны. За небольшим исключением, в этой книге
используется терминология, которая согласуется с терминологией книги [74].
Каталог различных шаблонов проектирования (с примерами реализации на C++)
приведен в главе 26.
Пример шаблона проекта: итератор
Шаблон итератора предоставляет механизм отделения алгоритмов или операций
от обрабатываемых данных. На первый взгляд может показаться, что такой шаблон
противоречит основному принципу объектно-ориентированного программирования,
который состоит в объединении объектных данных с функциями, выполняющими
манипуляции над этими данными. Несмотря на справедливость (в некоторой степени)
этого замечания, рассматриваемый здесь шаблон совершенно не означает удаление
поведенческих характеристик из объектов. Наоборот, он решает сразу две проблемы,
которые обычно возникают в результате тесного связывания данных с функциями.
Первая проблема заключается в том, что при таком тесном связывании данных
с функциями затрудняется работа обобщенных алгоритмов, предназначенных для
выполнения на различных объектах, которые могут и не принадлежать одной и той
же иерархии классов. Для написания обобщенных алгоритмов необходимо иметь
некоторый стандартный механизм для доступа к содержимому этих объектов.
Вторая проблема состоит в том, что иногда тесное связывание данных с
функциями является преградой для добавления новых функций. В самом крайнем случае для
преодоления этой преграды вам понадобится доступ к исходному коду объектов. Но
что делать в случае, если интересующая вас иерархия объектов является частью
оболочки, созданной сторонней организацией, или библиотекой, которую вы не можете
изменить? Было бы здорово иметь возможность добавлять алгоритм или операцию,
которые бы работали с данными без модификации исходной иерархии объектов.
Вы уже видели пример шаблона итератора в библиотеке STL. По определению
итераторы (iterators) призваны обеспечить механизм доступа алгоритма или
операции к контейнеру, в котором (в некоторой последовательности) хранятся элементы
данных. Термин iterator происходит от английского слова iterate, означающего
"повторять." Основное назначение итераторов— повторять действие поочередного
перехода от одного элемента последовательности к другому. В библиотеке STL
обобщенные алгоритмы используют итераторы для доступа к элементам контейнеров. По
определению стандартного интерфейса итератора библиотека STL позволяет
программисту писать алгоритмы, которые могут работать на любом контейнере,
поддерживающем итератор с соответствующим интерфейсом. Таким образом, итераторы
дают нам возможность создавать обобщенные алгоритмы, не модифицируя данные.
На рис. 4.1 показан итератор в виде сборочного конвейера, который "подает" элементы
объекта данных на "операцию".
Глава 4. Проектирование с использованием библиотек и шаблонов 137
Объект Итератор Операция
дшДЬда
Рис. 4.1
Резюме
Эта глава посвящена теме использования готового кода при проектировании
программ. Вы узнали, что ваш С++-проект может включать как фрагменты уже
существующего кода (библиотеки и оболочки), так и готовые идеи (методы и шаблоны).
Несмотря на то что основная цель эффективного проектирования —
использование уже существующих идей и фрагментов кода, у такого подхода есть как
достоинства, так и недостатки. Здесь вы узнали о достижении возможных компромиссов и
получили рекомендации по использованию готового кода, включающие понимание
возможностей и ограничений, предварительное изучение рабочих характеристик,
лицензирование, осознание ограничений, связанных с поддержкой версий и
переносом на другую платформу и пр. Кроме того, вы узнали об анализе производительности
и применении нотации "большого О", а также познакомились с особенностями
использования библиотек с открытым исходным кодом.
В этой главе приведен обзор стандартной библиотеки C++. Она включает С-биб-
лиотеку, а также дополнительные средства обработки строк, ввода-вывода данных,
ошибок и пр. С++-библиотека содержит обобщенные алгоритмы и контейнеры,
которые относятся к стандартной библиотеке шаблонов. Более подробно стандартная
библиотека шаблонов описана в главах 21—23.
Проектируя программы, помните, что использование уже готовых шаблонов и
методов также важно, как и использование фрагментов готового кода. Не стоит
изобретать "велосипед", а также перестраивать его! Глава завершается введением в понятие
шаблонов и методов проектирования. Примеры использования готового кода,
шаблонов и методов проектирования приведены в главах 25—26.
Однако следует понимать, что использование библиотек и шаблонов — только
половина пути к реализации стратегии многократности использования кода и идей.
Кроме этого, вам необходимо так спроектировать свою программу, чтобы вы сами
и другие программисты могли еще не раз воспользоваться своими достижениями на
ниве программирования. Стратегиям проектирования кода, который можно
многократно использовать впоследствии, посвящена глава 5.
Проектирование
с целью многократного
использования кода
Использование существующих библиотек или других фрагментов готового кода в
программах— важная стратегия проектирования. Но она составляет только половину от
общей стратегии многократности использования кода. Вторая ее половина заключается
в разработке и написании кода, который, возможно, будет использован в будущих
программах. Вероятно, вам уже приходилось почувствовать существенную разницу между
хорошо и плохо спроектированными библиотеками. Применять хорошо
спроектированные библиотеки — одно удовольствие, в то время как плохо спроектированные
могут вызвать у вас чувство отвращения и заставить писать нужный код самостоятельно. Если
вы пишете библиотеку в расчете на ее использование другими программистами или
просто продумываете конкретную иерархию классов, вам следует держать в уме идею
многократности применения создаваемого вами кода. Наперед нельзя сказать, когда и в каком
из будущих проектов вам понадобится, например, функция, аналогичная данной.
В главе 2 мы впервые подняли тему многократного использования кода. В главе 4
рассказано, как включить в свои программы существующие библиотеки и другие
фрагменты готового кода. В настоящей главе мы рассмотрим другую сторону той же
"медали": проектирование кода, претендующего на многократное использование
в дальнейшем. Эта стратегия строится па принципах объектно-ориентированного
Глава 5 . Проектирование с целью многократного использования кода 139
проектирования, описанных в главе 3. Здесь вы познакомитесь с некоторыми новыми
направлениями и получите ряд рекомендаций.
Прочитав эту главу, вы должны понимать:
□ принцип многократности использования кода, т.е. почему так важно писать код
в расчете на его использование в будущем (а не только в настоящем);
□ как спроектировать многократно используемый код:
□ особенности использования абстракций:
□ три стратегии структурирования кода для его повторного использования;
□ шесть стратегий проектирования интерфейсов;
□ как согласовать универсальность (общность) с простотой применения.
Принцип многократности использования кода
Программный код следует проектировать так, чтобы как вы сами, так и другие
программисты могли им воспользоваться снова. Это правило применимо не только к
библиотекам и оболочкам, которые специально и создаются для применения другими
программистами, но также к любому классу, подсистеме или компоненту, который вы
проекгируете для программы. Хорошо бы всегда держать в уме такой девиз: "написавши
однажды, используй не раз". Для такой стратегии проектирования есть ряд причин.
□ Код редко используется в одной программе. Во время написания кода вы, как
правило, не планируете его использовать еще хотя бы раз, но несколько
месяцев или лет спустя вас (или ваших коллег) вдруг озаряет, что тот фрагмент
кода можно включить в качестве компонента в аналогичный проект. Теперь вы
понимаете, что он будет полезен и в других проектах, поэтому его стоило
проектировать соответствующим образом с самого начала.
□ Проектирование "на будущее" экономит время и деньги. Если вы
проектируете код в стиле, который не предусматривает его использования в будущем, то вы
тем самым создаете условия, при которых, когда возникнет необходимость в
аналогичном коде, вы или ваши партнеры будете вынуждены тратить время на еще
одно "изобретение колеса". Даже если вы не возражаете против повторного
использования своего кода, но если его интерфейс не окажется достаточно
продуманным или обнаружатся большие упущения, то для эффективного
использования этого кода в будущем потребуются дополнительные затраты времени и сил.
□ Другие программисты из вашей рабочей группы должны также иметь
возможность использовать код, написанный вами. Даже если кажется, что ваш
код полезен только для данной конкретной программы, этот труд, вероятно,
смогут но достоинству оценить ваши же коллеги, если предложить им хорошо
спроектированные, компактно организованные библиотеки или фрагменты
кода. Вы ведь знаете, что значит использовать чей-то плохой интерфейс или
плохо продуманный класс. Проектирование с расчетом на будущее также
можно назвать кооперативным кодированием. Программный код следует писать
с мыслями о будущих проектах, а не только о текущем.
□ Вы должны быть заинтересованы в "многовекторности" собственной
работы. Опытные программисты никогда не выбрасывают свои предыдущие
140 Часть I. Введение в профессиональное С++-проектирование
наработки. Со временем они создают персональную библиотеку инструментов
программирования. Ведь никогда не знаешь, где потом может пригодиться тот
или иной фрагмент кода. Например, когда один из авторов этой книги проходил
первый курс программирования сетевых задач, он написал несколько
обобщенных утилит сетевого обмена для реализации соединений, отправки и получения
сообщений. Он обращался к этому коду во время работы над каждым своим
последующим проектом, в котором решались сетевые задачи, и таким образом
многократно использовал свои утилиты в нескольких различных программах.
Если вы, являясь служащим некоторой компании, разрабатываете
или пишете программный код, то обычно не вы, а ваша компания
получает права на интеллектуальную собственность. При уходе из
этой компании считается незаконным делать копии "своих"
проектов или программ.
Как спроектировать многократно
используемый код
Создавая код, который можно использовать неоднократно, мы должны
обеспечить достижение двух основных целей. Во-первых, такой код должен иметь
достаточно общее назначение, и тогда его можно использовать даже в различных предметных
областях. Программные компоненты узкой специфической направленности трудно
еще раз задействовать в других программах. Во-вторых, код, который
предусматривает повторное использование, должен характеризоваться простотой применения. Он
не должен требовать больших затрат времени, чтобы понять его интерфейс или
изучить рабочие характеристики. Программисты должны иметь возможность включать
его в готовом виде в свои приложения.
Многократно используемый код должен быть общего назначения
и простым в применении.
Коллекция фрагментов кода, претендующих на неоднократное использование,
необязательно должна иметь формальный библиотечный формат. Это может быть
класс, коллекция функций или подсистема. Но, как отмечалось в главе 4, термин
"библиотека" используется в этой книге везде, где имеется в виду любая создаваемая
коллекция фрагментов кода.
Обратите внимание на то, что в этой главе термин "клиент"служит для обозначения
программиста, который использует ваши интерфейсы. Не путайте клиентов с
"пользователями", которые применяют ваши готовые программы. Кроме того, фраза "код
клиента" здесь используется для обозначения кода, который написан с целью
применения ваших интерфейсов.
Самой важной стратегией разработки повторно используемого кода является
абстракция. В главе 2 была приведена аналогия из реального мира в виде телевизора,
который можно использовать посредством его интерфейса, не имея ни малейшего понятия
о его внутренней работе. Поэтому при проектировании кода следует четко отделять
Глава 5 . Проектирование с целью многократного использования кода 141
интерфейс от реализации. Это разделение упрощает применение кода, главным
образом благодаря тому, что клиентам, чтобы воспользоваться функциональными
возможностями вашей библиотеки, и не нужно понимать детали внутренней реализации.
Абстракция разделяет код на интерфейс и реализацию, поэтому, проектируя
часто используемый код, необходимо сосредоточиться на двух основных областях. Во-
первых, вы должны соответствующим образом структурировать свой код, ответив на
такие вопросы. Какие иерархии классов вы будете использовать? Нужно ли вам
задействовать шаблоны? Как лучше разделить код по подсистемам? Во-вторых, необходимо
спроектировать интерфейсы, которые должны стать "элементами" вашей
библиотеки или фрагментами кода, которые программисты смогут использовать для доступа
к реализуемым вами функциям. Обратите внимание на то, что интерфейс
необязательно должен иметь формат API. Главное, чтобы вы обеспечили четкий барьер
между функциональным кодом и кодом, который позволяет использовать эти функции.
Вполне допустимыми интерфейсами такого плана могут быть открытые (public)
методы класса или заголовочные файлы, содержащие прототипы функций.
Термин "интерфейс" может относиться к одному месту доступа (т.е. это может быть
отдельная функция или метод класса) или к целой коллекции (например, API, объявле- ■
ние класса или заголовочный файл).
Использование абстракций
Впервые вы познакомились с принципом абстракции в главе 2, а более подробно
о его приложении к объектно-ориентированному проектированию вы узнали в
главе 3. Чтобы придерживаться принципа абстракции, следует предоставить интерфейс
с кодом, за которым скрываются подробности реализации. Между интерфейсом и
реализацией должна пролегать четкая граница.
Использование абстракции выгодно не только вам, но и клиентам, которые
используют ваш код. Выгода клиентов заключается в том, что им не нужно беспокоиться
о деталях реализации; они используют лишь преимущества функционирования
предлагаемого кода без необходимости разбираться, как в действительности он работает.
А ваша выгода состоит в том, что вы можете модифицировать код реализации, не
изменяя интерфейса. Таким образом, вы можете усовершенствовать свой продукт и
исправлять недочеты, не требуя от клиента что-то менять в характере использования
этого продукта. С помощью динамически связываемых библиотек клиентам не нужно
даже перестраивать (перекомпоновывать) свои исполняемые файлы! Наконец,
выигрывают все. Вы, как автор библиотеки, можете определить в интерфейсе только те
действия, которые находите нужными поддерживать. Четкое разделение интерфейса
и реализации не позволит клиенту использовать библиотеку способом, который вы не
планировали обнародовать или который не является корректным, ведь в противном
случае возможна демонстрация неожиданного поведения или возникновение ошибок.
Предположим, вы проектируете библиотеку генераторов случайных чисел и
хотите предоставить возможность пользователю задавать диапазон генерируемых
случайных чисел. При плохом проектировании программист сделал бы открытыми
глобальные переменные или члены класса, которые влияют на диапазон и в реализации
генератора случайных чисел используются внутренне. Такая плохо спроектированная
библиотека потребовала бы, чтобы в коде клиента эти переменные устанавливались
напрямую. В хорошем проекте переменные, используемые внутренней реализацией,
были бы скрыты, а вместо них для установки нужного диапазона клиенту предлагалось
142 Часть I. Введение в профессиональное С++-проектирование
бы вызывать независимую от реализации функцию или метод класса. В этом случае
клиенту не нужно было бы понимать внутренний алгоритм. Кроме того, поскольку
детали реализации скрыты, вы могли бы изменять алгоритм, не оказывая никакого
влияния на взаимодействие кода клиента с библиотекой.
Иногда для использования библиотек требуется, чтобы код клиента хранил
информацию, возвращаемую одним интерфейсом для передачи ее другому. Эта информация
иногда называется дескриптором (handle) и часто используется для отслеживания
конкретных экземпляров, которые требуют запоминания состояния между вызовами
функций. Если ваша библиотека требует дескриптор, не открывайте ее внутренних деталей.
Включите дескриптор в "непрозрачный" класс, к внутренним членам данных
которого программист не может получить доступ. Не требуйте от кода клиента считывания
этих переменных внутри "дескрипторного" класса. У одного из авторов этой книги
был печальный опыт работы с примером плохого проекта библиотеки, которая
требовала от него установить конкретный член структуры в предположительно
"непрозрачном" дескрипторе, чтобы перейти в режим регистрации ошибок.
В C++ не предусмотрены механизмы создания эффективной
абстракции при написании классов. Программист вынужден размещать
объявления закрытых (private) членов данных и методов в одном и том лее
заголовочном файле вместе с объявлениями открытых (public)
методов. Некоторые способы обхода этого ограничения с целью
представления четких интерфейсов описаны в главе 9.
Абстракция настолько важна, что она определяет успех всего проекта. Принимая
решение но тому или иному поводу, задайте себе вопрос, согласуется ли ваш выбор
с принципом абстракции. Поставьте себя на место клиента и определите, не требуются
ли вам знания внутренней реализации в интерфейсе. Это правило действует безотказно.
Структурирование кода в расчете на его неоднократное
использование
Возможность повторного использования своего кода в будущем следует
предусматривать с самого начала работы над проектом программы. Правильно
организовать свой код с этой точки зрения вам помогут следующие стратегии. Причем во всех
этих стратегиях основное внимание уделяется созданию кода общего назначения.
Второй аспект разработки многократно используемого кода, обеспечивающий
простоту его применения, в большей степени относится к проектированию интерфейса
и рассматривается ниже в этой главе.
Избегайте объединения несвязанных или логически изолированных идей
Проектируя библиотеку или оболочку, сосредоточьтесь на решении одной или
группы связанных задач. Не смешивайте в одном компоненте совершенно разные
идеи (например, генератор случайных чисел и XML-анализатор).
Даже если вы не планируете использовать свой код повторно, держите эту стратегию
в уме. Безусловно, программам целиком редко можно найти повторное применение. Но
их части или подсистемы зачастую можно включать в другие приложения напрямую или
путем внесения небольших изменений. Таким образом, проектировать программы
следует так, чтобы можно было выделить логически несвязанные функции в отдельные
компоненты, которые можно использовать в различных программах.
Глава 5 . Проектирование с целью многократного использования кода 143
В этой программной стратегии моделируется принцип реального проектирования
отдельных сменных частей. Например, вы могли бы снять шины со старого
автомобиля и использовать их в новом, причем совершенно новой модели. Шины в данном
случае представляют собой отдельные компоненты, которые не связаны с другими
аспектами автомобиля. Ведь вам не надо при смене шин изменять двигатель автомобиля!
Стратегию логического разделения можно применять в проекте программы как на
макроуровне подсистем, так и на микроуровне иерархий классов.
Разбейте программу на логические подсистемы
Проектируйте подсистемы как отдельные компоненты, которые можно
использовать независимо друг от друга. Например, если вы проектируете сетевую игру,
поместите аспекты решения сетевых задач и графический интерфейс пользователя в
отдельные подсистемы. В этом случае в будущем вы сможете использовать каждый из
этих компонентов безотносительно к другому. А затем при написании, например,
какой-нибудь локальной игры вы сможете воспользоваться уже готовой подсистемой
графического интерфейса, не трогая сетевой аспект из прошлого проекта.
Аналогично вы могли бы заняться, скажем, проектированием программы организации сети
с равноправными узлами (peer-to-peer), опираясь на совместное использование
файлов, и в этом проекте вы могли бы успешно воспользоваться подсистемой сетевого
обмена, не касаясь функций подсистемы графического интерфейса пользователя.
Старайтесь всегда следовать принципу абстракции для каждой подсистемы, четко
отделяя ее интерфейс от базовой реализации. Рассматривайте каждую подсистему как
миниатюрную библиотеку, для которой вы должны обеспечить согласованный и
простой в применении интерфейс. Даже если вы являетесь единственным
программистом, который будет когда-либо использовать эти миниатюрные библиотеки, вы сами
только выиграете от хорошо спроектированных по отдельности интерфейсов и
реализаций логически несвязанных функций.
Используйте иерархии классов для выделения логических концепций
Помимо разбиения программы на логические подсистемы, следует избегать
объединения не связанных между собой идей на уровне классов. Например, предположим,
что вы решили написать для многопоточной программы структуру
сбалансированного двоичного дерева. Вы пришли к выводу, что такая древовидная структура данных
должна позволить доступ или модификацию данных в любой момент времени только
одному потоку, поэтому вы включаете блокировку в саму структуру данных. Но как
быть, если вы чуть позже захотите использовать это двоичное дерево в другой
программе, которая проектируется как одноиоточная? В этом случае блокировка будет
излишней и потребует от вашей программы связывания с библиотеками, которые
здесь ни к чему. Хуже того, ваша древовидная структура может не скомпилироваться
на другой платформе, поскольку код блокировки не является межплатформенным.
Решение этой проблемы — в создании иерархии классов (см. главу 3), в которой
двоичное дерево с многопоточной поддержкой является подклассом обобщенного
двоичного дерева. При таком подходе вы сможете использовать подкласс двоичного
дерева в однопоточных программах без неоправданных затрат, связанных с
поддержкой блокировки, а в случае переноса на другую платформу — без переписывания кода
блокировки. Такая иерархия классов показана на рис. 5.1.
Эта стратегия работает достаточно хорошо, когда существуют две логические идеи
(например, многопоточная поддержка и двоичное дерево). При увеличении числа идей
ситуация усложняется. Например, предположим, что вы хотите поддерживать как
144 Часть I. Введение в профессиональное С++-проектирование
гс-арное, так и двоичное дерево, причем в каждом случае как с поддержкой
многопоточности, так и без нее. Логически рассуждая, двоичное дерево является специальным
случаем парного, и поэтому должно быть его подклассом. Аналогично структуры с
поддержкой многопоточности должны быть подклассами структур без таковой. Подобные
разделения нельзя обеспечить с помощью линейной иерархии. Один из возможных
вариантов — сделать аспект поддержки многопоточности смешанным классом (рис. 5.2).
Двоичное дерево
Двоичное дерево с многопоточной поддержкой
Рис. 5.1
..
N-арное дерево
,
i
Двоичное дерево
Двоичное дерево
с многопоточной поддержкой
*
N-арное дерево
с многопоточной поддержкой
Рис. 5.2
Такая иерархия потребует, чтобы вы написали пять различных классов, однако
четкое разделение функций стоит немалых затрат.
Иерархии классов можно использовать для отделения обобщенной
функциональности от более конкретной. Например, предположим, что вы проектируете
операционную систему, которая поддерживает многопоточность на пользовательском уровне.
Вы могли бы попытаться написать класс процесса, который включает поддержку
многопоточности. Однако как быть с пользовательскими процессами, которым не нужна
многопоточность? Поэтому лучше спроектировать обобщенный класс процесса и
сделать многопоточный процесс его подклассом.
Используйте для разделения логических идей механизм агрегирования
Механизм агрегирования (см. главу 3) позволяет смоделировать отношения типа
has-a: объекты содержат другие объекты, позволяющие реализовать ряд аспектов их
поведения. Механизм агрегирования можно использовать для не связанных между
собой или связанных, но обособленных функций в случае, если наследование не
подходит для применения.
Продолжая рассмотрение примера разработки операционной системы, можно
предположить, что готовые процессы будут храниться в очереди по приоритету. Вместо
того, чтобы объединять структуру очереди по приоритету с классом ReadyQueue, лучше
Глава 5 . Проектирование с целью многократного использования кода 145
написать отдельный класс такой очереди. Тогда класс ReadyQueue вполне сможет
содержать и использовать очередь по приоритету. Употребляя объектно-ориентированную
терминологию, класс ReadyQueue будет содержать очередь по приоритету (т.е.
реализует отношение типа has-a). При таком подходе подобную приоритетную очередь можно
успешно применить еще и еще раз в какой-нибудь другой программе.
Для построения обобщенных структур данных и алгоритмов
используйте шаблоны
По возможности вместо кодирования специфичных деталей конкретных
программ используйте обобщенные шаблоны для структур данных и алгоритмов. Не
стоит создавать структуру сбалансированного двоичного дерева, которая хранит только
объекты книг. Сделайте ее обобщенной, чтобы она могла хранить объекты любого
типа. В этом случае вы сможете использовать ее и в книжном, и в нотном магазине,
и в операционной системе, т.е. везде, где будет потребность в сбалансированном
двоичном дереве. Эта стратегия подчеркивает значимость стандартной библиотеки
шаблонов (STL), о которой шла речь в главе 4. Библиотека STL предоставляет
обобщенные структуры данных и алгоритмы, которые работают с любыми типами данных.
С помощью библиотеки STL язык C++ позволяет использовать замечательные
средства обобщенного программирования — шаблоны. Как упоминалось в главах 2 и 4,
используя шаблоны, вы сможете писать структуры данных и алгоритмы для обработки
любых типов данных. В главе 11 приведены некоторые подробности кодирования
шаблонов, а в этом разделе мы рассмотрим особо важные аспекты их проектирования.
Почему шаблоны лучше других методов обобщенного программирования
Шаблоны — это не только механизм для написания обобщенных структур данных.
Вы можете писать обобщенные структуры на С и C++, сохраняя вместо данных
конкретного типа void*-yкaзaтeли. Клиенты затем смогут использовать эту структуру для
хранения чего угодно, выполнив предварительно операцию преобразования в тип
void*. Но основная проблема такого подхода состоит в том, что он не обеспечивает
типовую безопасность: контейнеры не способны выполнять проверку типов или
навязывать определенный тип хранимым элементам. Да, чтобы сохранить данные
в структуре, вы можете привести любой тип к типу void*, но при удалении указателей
из этой структуры данных вы должны выполнить обратное преобразование типов.
Поскольку проверка типов отсутствует, последствия реализации этого подхода могут
быть весьма плачевными. Представьте себе такой сценарий: один программист
сохраняет указатели на int-значения в структуре данных, приведя их сначала к типу
void*, а другой программист думает, что они указывают на объекты типа Process.
Второй программист беспечно преобразует void*-yкaзaтeли в указатели на тип Process*
и попытается использовать их в качестве таковых (типа Process*). Вряд ли нужно
говорить, что после этого программа не будет работать ожидаемым образом.
Второй подход состоит в написании структуры данных для конкретного класса.
Благодаря полиморфизму в такой структуре можно сохранить любой подкласс этого
класса. В языке Java этот подход доведен до крайности: там по определению каждый
класс прямо или косвенно выведен из класса Object. Если Java-контейнеры хранят
объекты класса Object, значит, они хранят объекты любого типа. Однако этот
подход тоже по-настоящему не обеспечивает типовую безопасность. При удалении объекта
из контейнера вы должны помнить его "истинное лицо", т.е. его тип, и выполнить
обратное преобразование к нужному типу.
146 Часть I. Введение в профессиональное С++-проектирование
Шаблоны же при корректном использовании как раз обеспечивают типовую
безопасность. Каждая реализация шаблона хранит только один тип. Если вы попытаетесь
сохранить в одинаковых экземплярах шаблона данные различного типа, ваша
программа не скомпилируется.
Проблемы, связанные с использованием шаблонов
Шаблоны отнюдь не совершенны. Во-первых, их синтаксис может сбить с толку,
особенно тех, кто никогда не имел с ними дела раньше. Во-вторых, их анализ
довольно сложен, и не все компиляторы полностью поддерживают С++-стандарт.
Более того, шаблоны требуют организации гомогенных (однородных) структур
данных, в каждой из которых вы могли бы сохранять объекты одинакового типа.
Таким образом, написав шаблонное сбалансированное двоичное дерево, вы сможете
создать один древовидный объект для хранения объектов типа Process, а другой — для
хранения, скажем, значений типа int. В одной и той же древовидной структуре нельзя
хранить int-значения и объекты типа Process. Это ограничение— прямое следствие
природы шаблонов, которые "в ответе" за типовую безопасность "порученных" им
объектов. Хотя типовая безопасность сама по себе очень важна, некоторые
программисты считают требование гомогенности существенным ограничением.
Еще одна проблема использования шаблонов связана с "разбуханием" программ.
При создании древовидного объекта для хранения int-значений компилятор в
действительности "раскрывает" шаблон, чтобы сгенерировать код так, как если бы вы
написали древовидную структуру только для int-значений. Аналогично, если вы
создадите древовидный объект для хранения объектов типа Process, компилятор
сгенерирует такой код, как если бы вы написали древовидную структуру только для
объектов типа Process. Если вы реализуете шаблоны для многих различных типов,
ваш исполняемый файл достигнет огромных размеров, поскольку компилятор
сгенерирует все фрагменты кода для заданных типов данных.
Шаблоны в сравнении с механизмом наследования
Программистам иногда трудно определиться, использовать им шаблоны или
механизм наследования. Вот несколько советов, которые помогут вам принять правильное
решение.
Используйте шаблоны, если хотите обеспечить идентичные функции для
различных типов. Например, если вы собираетесь написать обобщенный алгоритм
сортировки, который будет работать с данными любого типа, используйте шаблоны. Если
вам нужен контейнер для хранения данных любого типа, также используйте шаблоны.
Основная идея состоит в том, что шаблонная структура (или алгоритм) обрабатывает
данные всех типов одинаково.
Если вы хотите реализовать различное поведение для связанных типов,
используйте механизм наследования. Например, применяйте наследование, чтобы
реализовать два разных, но похожих контейнера (например, очередь и очередь по
приоритету). Обратите внимание на то, что ничто не мешает вам сочетать наследование
с шаблонами. Ведь вы могли бы написать класс шаблонной очереди для хранения
данных любого типа, а затем создать его подкласс, который служил бы в качестве
шаблонной очереди по приоритету. Детали синтаксиса шаблонов вы найдете в главе 11.
Предусмотрите соответствующие проверки и меры безопасности
Программы (насколько это возможно) следует проектировать безопасными с точки
зрения их использования другими программистами. Самое важное — не забывать о
контроле за ошибками. Например, если ваш генератор случайных чисел требует в качестве
Глава 5 . Проектирование с целью многократного использования кода 147
начального неотрицательное целое число, не следует полагаться на то, что
пользователь корректно введет именно неотрицательное целое число. Обязательно проверьте
вводимое значение и "забракуйте" его, если оно не соответствует требованиям.
В качестве примера возьмем бухгалтера, который готовит декларацию о доходах.
Нанимая на работу бухгалтера, вы предоставляете ему всю свою финансовую
информацию за год. Бухгалтер использует эту информацию, чтобы заполнить бланки для
налоговой инспекции. Однако бухгалтер вносит информацию в бланк не вслепую, а проверяет,
чтобы каждый пункт согласовывался с другими. Например, если вы — владелец дома, но
забыли указать выплачиваемый налог на доход с недвижимого имущества, бухгалтер
напомнит вам об упущенной информации. Аналогично, если вы сообщили, что заплатили
12 тыс. долл. в качестве процентов по закладной, но имели при этом только 15 тыс. долл.
общего дохода, бухгалтер может мягко попросить вас предоставить более корректные
числовые данные (или по крайней мере порекомендовать жить по средствам).
Вместо бухгалтера-человека представьте себе бухгалтера-программу. Входом для
этой программы будет служить ваша финансовая информация, а выходом —
декларация о доходах. Подумайте о ценности участия в этом процессе бухгалтера-человека:
оно состоит не просто в его собственноручном заполнении бланков. Ведь вы
прибегаете к помощи бухгалтера еще и потому, что он проверяет все ваши данные и
обеспечивает меры безопасности. Точно так же и в программировании вы должны
позаботиться о наличии в своих реализациях всевозможных проверок на ошибки
и соответствующих мерах безопасности.
Существует ряд методов и языковых средств, которые помогут вам включить
средства проверки и меры безопасности в свои программы. Во-первых, для уведомления
кода клиента об ошибках используйте исключения. Подробно исключения
рассматриваются в главе 15. Во-вторых, применяйте интеллектуальные указатели (см. главу 4)
и другие методы безопасного использования памяти, описанные в главе 13.
Проектирование удобных интерфейсов
Помимо абстракции и соответствующего структурирования кода проектирование
повторно используемого кода потребует от вас состредоточиться на интерфейсе, с
которым будут взаимодействовать другие программисты. Если вы скрываете
отвратительную реализацию за премиленьким интерфейсом, об этом, возможно, никто и не
узнает. Но если вы скроете чудесную реализацию за никудышным интерфейсом, ваша
библиотека никогда не будет пользоваться хорошей репутацией.
Обратите внимание на то, что каждая подсистема и класс в вашей программе
должны иметь хорошие интерфейсы, даже если вы и не собирались их использовать
в будущем. Прежде всего, никто не знает, когда что-нибудь будет востребовано. Во-
вторых, хороший интерфейс важен даже для самого первого применения, особенно
в случае, если вы программируете в команде, и другие программисты должны
использовать код, который вы проектируете и пишете.
Основная цель интерфейсов— сделать код простым для применения, но
некоторые методы создания интерфейсов могут помочь в соблюдении и принципа общности.
Проектирование простых для применения интерфейсов
Ваши интерфейсы должны быть простыми в работе. Не стоит требовать от
пользователей вашей библиотеки, чтобы для применения простой структуры данных им
приходилось одолевать пачку страниц исходного кода или для получения нужного
характера функционирования — изгаляться над собственным кодом. Ниже описаны
четыре стратегии проектирования простых для применения интерфейсов.
148 Часть I. Введение в профессиональное С++-проектирование
Разрабатывайте интуитивно понятные интерфейсы
Программисты используют термин "интуитивный" для описания интерфейсов,
работать с которыми просто даже без долгого изучения инструкции. Значение слова
"интуитивный" аналогично его значению во фразе "интуитивно очевидный", т.е.
понятный без излишних раздумий или разъяснений. Интуитивные интерфейсы по
определению должны быть простыми для применения.
Наилучшая стратегия для разработки интуитивного интерфейса— "идти
проторенной дорогой". Это значит, что те, для кого он предназначен, не должны
столкнуться с чем-то абсолютно для себя незнакомым. Ведь когда люди встречают
интерфейс, подобный тому, с которым они работали раньше, они понимают его лучше,
с большей готовностью его принимают и меньше допускают ошибок.
Например, предположим, что вы проектируете механизм рулевого управления
автомобилем. Перед вами такой выбор: джойстик, две кнопки для поворота налево
и направо, скользящий горизонтальный координатный рычаг или старое доброе
рулевое колесо. Какой интерфейс с вашей точки зрения будет проще всего? Какой
интерфейс будет продаваться большинством автомобилей? Потребители прекрасно
знакомы с рулевым колесом, поэтому в качестве ответа на оба вопроса, конечно же,
выбираем рулевое колесо. Даже если вы разработали другой механизм, который
обеспечивает более высокую производительность и лучше с точки зрения
безопасности, вы должны подумать о критичном времени сбыта своего продукта, не говоря
уж о характере обучения тех, кто будет его использовать. Если вам придется
выбирать между следующими стандартными моделями интерфейса и расширением в
каком-то новом направлении, то лучше все-таки придерживаться интерфейса, к
которому люди уже привыкли.
Новаторство, конечно же, важная штука, но вы должны акцентировать внимание
на новаторстве в базовой реализации, а не в интерфейсе. Например, потребителям
в некоторых моделях автомобилей понравился новый гибридный бензоэлектриче-
ский привод. Эти автомобили продаются хорошо отчасти и оттого, что интерфейс
в новых моделях идентичен старым образцам со стандартными двигателями.
Применительно к C++ эта стратегия предполагает, что вы должны разрабатывать
интерфейсы, соответствующие стандартам, к которым привыкли С++-программисты.
Например, С++-программисты вправе ожидать, что конструктор и деструктор класса
инициализирует и очищает объект соответственно. Проектируя классы,
необходимо следовать этим стандартам. Если же вы потребуете, чтобы программисты сами
вызывали методы initialize () и cleanup () для инициализации и очистки, а не
внесете эти функции в конструктор и деструктор соответственно, вы запутаете
всякого, кто попытается использовать ваш класс. Поскольку в этом случае поведение
вашего класса будет отличаться от поведения других С++-классов, программистам
понадобится больше времени на его освоение, и увеличится вероятность его
некорректного использования (хотя бы потому, что можно просто забыть о вызове методов
initialize () или cleanup()).
Следует всегда думать об интерфейсах с точки зрения того, кто будет их использовать.
Всели в них логично1? Соответствуют ли они ожиданиям пользователей?
В C++ предусмотрено языковое средство, именуемое перегрузкой операторов,
которое может помочь в разработке интуитивных интерфейсов для объектов. Используя
перегрузку операторов, можно писать классы и определять для них стандартные one-
Глава 5. Проектирование с целью многократного использования кода 149
раторы, поведение которых будет подобно поведению с такими встроенными типами
данных, как int и double). Например, можно написать класс Fraction, которые
позволяет складывать, вычитать и выводить в поток дроби следующим образом.
Fraction fl(3,4), f2(l,2), sum, diff;
sum = fl + f2;
diff = fl - f2;
cout << fl << " " << f2 << endl;
Сравните то же поведение на основе вызова методов класса.
Fraction f1(3,4), f2(l,2), sum, diff;
sum = fl.add(f2);
diff = fl.subtract(f2);
fl.print(cout);
cout < < " " ;
f2.print(cout);
cout << endl;
Как видите, перегрузка операторов позволяет создавать интуитивные интерфейсы
для классов. Однако и здесь необходимо соблюдать осторожность. Теоретически
можно так перегрузить оператор "+", что он будет выполнять операцию вычитания,
а оператор "-"— умножения. Такие реализации заведомо алогичны. Всегда нужно
обеспечивать ожидаемое действие операторов. Более подробно о перегрузке
операторов см. в главах 9 и 16.
Не опускайте нужных функций
Эта стратегия имеет две части. Во-первых, необходимо включить интерфейсы для
всех функций, которые могут потребоваться клиенту. На первый взгляд это может
показаться очевидным. Вернемся к примеру с автомобилями. Никто не будет спорить,
что каждый построенный автомобиль должен иметь спидометр. Аналогично
нормальный программист не спроектирует класс Fraction, не обеспечив его
механизмом доступа со стороны кода клиента к реальному значению дроби.
Однако не всегда характеристики поведения столь очевидны. Разработчик класса
должен предугадать все возможные способы применения клиентом вашего кода.
Например, предположим, что вы решили спроектировать класс игровой доски. При этом
вы рассмотрели только такие типичные настольные игры, как шахматы и шашки, и
решили поддерживать максимум одну фигуру на одном игровом поле доски (клетке). А что
будет, если чуть позже вы надумаете разработать игру в нарды, которая позволяет на
одном игровом поле доски находиться нескольким фигурам? Предотвращая такую
возможность, вы исключаете возможность применения вашей игровой доски к игре в нарды.
Очевидно, довольно трудно (вернее сказать, нереально) предусмотреть все
возможные способы использования библиотеки. Не стоит мучиться над созданием идеального
интерфейса, но все же постарайтесь сделать в этом направлении все от вас зависящее.
Вторая часть этой стратегии подразумевает включение в реализацию максимально
возможного количества функций. Не стоит требовать от кода клиента, чтобы он
задавал информацию, которая уже известна в реализации или может быть известной.
Например, если для вашей библиотеки XML-анализатора требуется временный файл для
хранения результатов, не заставляйте пользователей вашей библиотеки указывать
путевое имя. Их не должно волновать, какой файл вы используете; найдите другой
способ определить соответствующий файловый путь.
150 Часть I. Введение в профессиональное С++-проектирование
Более того, не требуйте от пользователей вашей библиотеки, чтобы они
выполняли ненужную работу по объединению результатов. Если ваша библиотека
генератора случайных чисел использует алгоритм, который вычисляет младший и
старший байты случайного числа по отдельности, объедините эти значения до
предъявления их пользователю.
Не требуйте от пользователей вашей библиотеки выполнения задач,
которые они не обязаны решать.
Избегайте излишеств в интерфейсах
Желая не пропустить ничего важного в своих интерфейсах, некоторые
программисты впадают в другую крайность, включая в них каждую мелочь. Казалось бы,
пользователи таких интерфейсов смогут выполнить любую задачу. К сожалению, в
интерфейсе, загроможденном деталями, трудно разобраться, что к чему!
Не следует перенапрягать возможности интерфейсов; главное, чтобы они
оставались четкими и простыми в применении. На первый взгляд может показаться, что
такая рекомендация противоречит предыдущей стратегии, состоящей в совете не
упустить нужных функций. Всегда важно найти золотую середину. Включите в интерфейс
все необходимые функции и откажитесь от бесполезных или тех, которые приводят
к обратным результатам.
Снова обратимся к автомобильным аналогиям. Вы ведете автомобиль,
взаимодействуя только с несколькими компонентами: рулевым колесом, педалью
тормозов и газа, переключателем передачи, зеркалами, спидометром и еще двумя-тремя
приборами на приборной панели. А теперь представьте себе такую приборную
панель, которая выглядит как кабина самолета, оснащенная сотнями приборов,
рычагов, мониторов и кнопок. Водить автомобиль намного проще, чем управлять
самолетом, поэтому и его интерфейс должен быть гораздо проще: ведь вам не нужно
знать высоту полета, общаться с диспетчером или управлять множеством таких
компонентов, как крылья или шасси.
Кроме того, по опыту известно, что, чем меньше библиотеки, тем проще их
поддерживать. Стараясь сделать всех счастливыми, вы создаете благоприятную среду для
"размножения" ошибок, и если ваша реализация достаточно сложна и состоит из
множества взаимосвязанных деталей, то даже одна ошибка может сделать вашу
библиотеку бесполезной.
К сожалению, идея проектирования эффективных интерфейсов прекрасно
выглядит на бумаге, но на практике все оказывается гораздо сложнее. Общего правила нет,
вернее, оно чрезвычайно субъективно: только вам решать, что должен иметь
интерфейс, а что — нет. Безусловно, ваши клиенты (если им что-либо не понравится)
непременно выскажут вам все, что они думают! И все же попробуем дать несколько советов.
□ Избавляйтесь от интерфейсов-дубликатов. Если один метод класса возвращает
результат в футах, а другой — в метрах, объедините их в один метод,
возвращающий объект, который может представлять результат либо в футах, либо в метрах.
□ Определите самый простой способ реализации требуемого поведения. Уберите
ненужные парамегры и методы и по возможности объедините несколько методов
в один. Объедините, например, функцию инициализации библиотеки с методом,
который устанавливает начальные параметры, задаваемые пользователем.
Глава 5. Проектирование с целью многократного использования кода 151
□ Ограничьте количество применений библиотеки. Невозможно удовлетворить
все капризы и желания. Неизбежно кто-то попытается использовать
библиотеку непредусмотренным вами способом. Например, если ваша библиотека
предназначена для обеспечения XML-анализа, обязательно найдется кто-нибудь, кто
попробует использовать ее для SGML-анализа. Не стоит винить себя за такую
"непредусмотрительность" и пытаться поддерживать функции, которые вы не
собирались реализовывать.
Позаботьтесь о надлежащей документации и комментариях
Как бы просты и интуитивны в применении ни были ваши интерфейсы,
необходимо позаботиться о соответствующей документации. Не следует ожидать, что
программисты, для которых предназначены ваши библиотеки, будут использовать их
надлежащим образом, если не предоставить сопровождающую документацию.
Считайте свой код продуктом, который будут потреблять другие программисты. Любой
материальный продукт, который вы покупаете (например, DVD-проигрыватель),
снабжается набором инструкций, разъясняющих его интерфейс, функции и процесс поиска
неисправностей. Даже самые простые продукты (стулья, например) сопровождаются
инструкциями по надлежащему их использованию, пусть даже в таком виде: "Здесь
сидят. Использование этого продукта по другому назначению может повлечь за собой
смерть или серьезные увечья". Аналогично ваш программный продукт должен иметь
соответствующую документацию с пояснениями о его надлежащем применении.
Существует два способа предоставления документации для интерфейсов:
комментарии в самих интерфейсах и внешняя документация. Постарайтесь приложить усилия
в двух направлениях. Большинство открытых API-интерфейсов предоставляют только
внешнюю документацию: комментарии — большой дефицит во многих стандартных
заголовочных файлах Unix и Windows. В Unix документация обычно имеет форму
оперативного руководства (man pages), а в Windows — интегрированной среды разработки.
Несмотря на тот факт, что в большинстве API-интерфейсов и библиотек
интерфейсы не содержат комментарии, мы считаем эту форму документации наиболее
важной. Никогда не следует оставлять "голым" заголовочный файл, который
содержит только код. Даже если комментарии в точности повторяют содержимое внешней
документации, это не так страшно, как увидеть заголовочный файл без комментариев
вообще. Даже самые распрекрасные программисты очень скоро захотят найти
пояснения своим действиям на родном языке!
Некоторые программисты используют специальный инструментарий для
автоматического создания документации на основе комментариев. Эти инструменты
генерируют документацию посредством анализа комментариев с помощью ключевых слов
и средств форматирования, при этом часто используется формат языка
гипертекстовой разметки (Hypertext Markup Language— HTML). Язык программирования Java
популяризировал этот метод с помощью средства JavaDoc, но уже существует
множество подобных средств, доступных для программистов на C++. Более подробно этот
метод рассматривается в главе 7.
Внутренние комментарии или внешняя документация должны содержать
описание поведения библиотеки, а не саму реализацию. Под поведением понимаются
входные и выходные данные, сбойные ситуации и их обработка, предполагаемые способы
(режимы) использования и гарантированные рабочие характеристики. Например,
в документации, описывающей обращение к генератору одного случайного числа
должно быть указано, что он не принимает параметров, возвращает целочисленное
152 Часть I. Введение в профессиональное С++-проектирование
значение в ранее заданном диапазоне и генерирует исключение "нехватки памяти"
в случае невозможности выделить память. В этой документации не нужно разъяснять
детали алгоритма сравнения первой степени, который используется для
генерирования случайного числа. Клиента интерфейса не заботит алгоритм до тех пор, пока он
исправно получает случайные числа! Отягощение интерфейса слишком большим
количеством подробных комментариев, возможно, является самой распространенной
ошибкой в его разработке. Нам приходилось встречать достаточно качественно
отделенные от реализации интерфейсы, которые были вконец испорчены
комментариями, более подходящими для тех, кто будет поддерживать библиотеки, а не для клиентов.
В открытой документации должны быть описаны варианты
поведения, а не подробности базовой реализации.
Конечно же, вы обязательно должны документировать внутреннюю реализацию
своих программных продуктов, но не оставлять ее открытой и доступной для всех
желающих, подобно интерфейсу. Детали надлежащего составления комментариев
продемонстрированы в главе 7.
Проектирование интерфейсов общего назначения
' Интерфейсы должны быть достаточно универсальны, чтобы их можно было
адаптировать к широкому диапазону задач. Если вы закодируете специфические
особенности одного приложения в интерфейсе предположительно общего назначения,
такой интерфейс вряд ли подойдет для других целей. Вот некоторые рекомендации,
к которым стоит прислушаться.
Обеспечьте несколько способов выполнения одной и той же функции
Чтобы удовлетворить всех "клиентов", иногда полезно обеспечить несколько
способов выполнения одной и той же функции. Однако использовать этот метод следует
"с умом", поскольку перебор может легко привести к "замусориванию" интерфейсов.
Снова рассмотрим пример с автомобилями. Большинство новых автомобилей
оснащаются системами дистанционного управления замками, которые позволяют
запереть/отпереть автомобиль нажатием кнопки пульта. В то же время такие автомобили
всегда имеют и обычные ключи. Несмотря на избыточность этих двух методов,
большинство автолюбителей ценят наличие обоих вариантов.
Иногда в проектах программных интерфейсов возникают аналогичные ситуации.
Например, предположим, что один из методов в качестве параметра принимает
строку. Вероятно, в этом случае вам стоит предложить два интерфейса с этим методом:
один будет принимать С++-объект класса string, а другой — указатель на символьный
массив в стиле языка С. И хотя один вариант нетрудно преобразовать в другой,
разные программисты предпочитают использовать различные типы строк, поэтому
полезно обеспечить оба подхода.
Обратите внимание на то, что данную стратегию следует рассматривать как
исключение из правила создания "неперегруженного" проекта интерфейса. Как
известно, исключений много не бывает, поэтому обычно лучше следовать правилу
проектирования интерфейсов без излишеств.
Позаботьтесь о потенциальных нуждах заказчика
Если вы подумаете о возможных потребностях клиентов, то постараетесь сделать
интерфейсы более гибкими. Люди обычно очень высоко ценят наличие механизма
Глава 5 . Проектирование с целью многократного использования кода 153
настройки на нужды пользователя. Например, один из авторов этой книги недавно
приобрел новый автомобиль с противоугонным устройством. Это устройство
аварийной сигнализации автоматически дезактивизируется при разблокировке дверей с
помощью пульта дистанционного управления. К сожалению, если двери не открыть
в течение 30 секунд после разблокировки, устройство аварийной сигнализации
включается в работу. Такое поведение просто-таки раздражает, когда нужно
воспользоваться багажником автомобиля. Согласитесь: ведь неудобно открывать дверцы только
для того, чтобы взять какую-то вещь из багажника. Но если сигнальное устройство не
дезактивизируется, захлопывание багажника инициирует срабатывание сигнализации.
Больше всего в этом раздражает тот факт, что противоугонное устройство отключить
невозможно! Разработчики автомобилей должны были предположить, что каждому
автолюбителю может понадобиться такая функция в их противоугонных устройствах
и предоставить возможность ее реализации.
В программировании механизм настройки на нужды пользователя может быть
самым простым, например, разрешение клиенту включать или отключать процесс
регистрации ошибок. Здесь важно понимать, что благодаря механизму настройки (при
обеспеченности всех клиентов одинаковыми базовыми функциями) они получают
возможность вносить небольшие изменения в поведение продукта.
Расширить диапазон настраиваемых вариантов поведения можно с помощью
указателей на функции и шаблонных параметров. Например, вы могли бы позволить
клиентам своих библиотек устанавливать собственные процедуры обработки ошибок.
Этот метод продемонстрирован в главе 26.
В библиотеке STL стратегия предоставления механизма настройки доведена до
крайности и действительно позволяет клиентам задавать собственные средства выделения
памяти для контейнеров. При желании вы можете сами написать объект распределителя
памяти в соответствии с рекомендациями STL и присоединить его к соответствующим
интерфейсам. Каждый контейнер в библиотеке STL принимает распределитель памяти
в качестве одного из шаблонных параметров (подробности — в главе 23).
Поддерживайте баланс между общностью и простотой
применения
Стремление добиться одновременно как простоты применения, так и
универсальности часто оказывается довольно трудной задачей из-за противоречивости этих целей.
Зачастую введение универсальности повышает сложность интерфейсов. Например,
предположим, что в программе построения карты для хранения данных о городах вам
нужно использовать некоторую графическую структуру. В интересах общности вам
следует применить шаблоны и написать обобщенную структуру отображения для любого
типа данных, а не только для городов. И тогда, если вам в будущем понадобится
написать сетевой имитатор, то для хранения маршрутизаторов в сети вы сможете
воспользоваться уже готовой графической структурой. К сожалению, используя шаблоны, вы
делаете интерфейс более громоздким и сложным для применения, особенно это будет
заметно в случае, если потенциальный клиент не знаком с шаблонами.
Однако нельзя утверждать категорически, что универсальность и простота
использования являются взаимно исключающими целями. Хотя в некоторых случаях
повышенная степень обобщения может негативно сказаться на простоте
применения, возможно проектировать интерфейсы, которые будут обладать как
универсальностью, так и относительной простотой в эксплуатации. Вот некоторые
рекомендации на этот счет.
154 Часть I. Введение в профессиональное С++-проектирование
Предложите клиенту несколько интерфейсов
Чтобы понизить уровень сложности интерфейсов при сохранении прежнего
диапазона функций, можно предоставить два отдельных интерфейса. Например, вы
могли бы написать обобщенную библиотеку сетевого обмена с двумя отдельными
аспектами проектного решения: один будет представлять сетевые интерфейсы,
предназначенные для компьютерных игр, а другой — для HTTP-протокола (Internet
Hypertext Transport Protocol— протокол передачи гипертекстовых файлов),
используемого при просмотре Web-страниц в сети Internet.
В библиотеке STL этот подход реализован в виде класса string. Как отмечалось
в главе 4, класс string в действительности представляет собой char-реализацию
шаблона basic_stream. Поэтому класс string можно считать интерфейсом, за
которым скрывается сложность шаблона basicstream.
Оптимизируйте часто используемые функции
Как правило, некоторые функции интерфейса общего назначения используются
чаще других. Наиболее популярные функции следует сделать простыми в
применении, оставив при этом возможность для осуществления более
высокоорганизованного поведения. Возвращаясь к программе построения карты, программисту, вероятно,
стоит предусмотреть возможность задавать названия городов на разных языках.
Родной язык, что вполне резонно, следует сделать доминирующим (т.е установить по
умолчанию), но при этом можно позволить клиенту изменить язык. Таким образом,
большинству клиентов не придется утруждать себя установкой языка, но желающие
всегда смогут это сделать.
Эта стратегия перекликается с принципом наибольшей производительности (см.
главу 4), согласно которому следует оптимизировать те части кода, которые выполняются
чаще других. Если вы хотите, чтобы ваш проект был самым удобным для большинства
пользователей, имеет смысл обратить внимание на оптимизацию этих аспектов.
Резюме
Прочитав эту главу, вы узнали, почему важно проектировать код многоразового
использования и как это сделать. Теперь, вероятно, вы убедились в необходимости
следовать принципу многократности, который словами можно выразить так: "написавши
однажды, используй не раз". Вы поняли, что программный код будет использован
повторно в случае, если он характеризуется универсальностью и простотой
применения. Для успешного создания такого кода мы рекомендуем применять абстракции,
соответствующее структурирование и проектировать удобные интерфейсы.
В этой главе мы дали вам три совета по структурированию кода: избегать
объединения несвязанных или логически изолированных идей, использовать шаблоны для
создания обобщенных структур данных и алгоритмов и предусматривать
соответствующие проверки и меры безопасности.
Здесь мы также очертили шесть стратегий проектирования интерфейсов:
разработка интуитивных интерфейсов, реализация всех требуемых функций, недопущение
излишеств в интерфейсах, предоставление надлежащей документации и
комментариев, обеспечение нескольких способов выполнения одной и той же функции и забота
о потенциальных нуждах заказчика. Эти стратегии дополняются еще двумя советами
по поддержке баланса между часто конфликтующими требованиями: общностью и про-
Глава 5 . Проектирование с целью многократного использования кода 155
стотой применения: предоставление нескольких интерфейсов и оптимизация часто
используемых функций.
Этой главой завершается обсуждение тем проектирования, которые мы начали
рассматривать в главе 2. Глава 6 посвящена обзору методик программно-технического
проектирования. Главы 7—11 переведут вас на этап реализации процесса разработки
программного обеспечения с погружением в детали С++-программирования.
Использование
эффективных
методов
разработки
программного
обеспечения
Делая первые шаги в программировании, вы, вероятно, еще не научились
планировать свои действия. Вы были вольны делать все что угодно и могли радикально
изменить свой проект в последнюю минуту работы над реализацией. Однако
программисты-профессионалы редко могут позволить себе такие вольности. Даже самые
либеральные технические руководители соглашаются с тем, что в деле
программирования без технологического процесса не обойтись. Знание технологии настолько же
важно, насколько важно знать, как программировать.
В этой главе рассматриваются различные подходы к разработке программного
обеспечения. Мы не собираемся подробно останавливаться ни на одном из них — на
это есть отдельные книги. Наша задача— обрисовать различные типы процессов,
чтобы вы могли сравнить их. Мы не ставим своей целью пропагандировать отдельные
Глава 6. Использование эффективных методов разработки программного... 157
методики или подчеркивать их слабые стороны. Наоборот, мы считаем, что, узнав
обо всех "за" и "против" различных подходов, вы сможете построить процесс,
который наилучшим образом подойдет вам и вашей команде.
Неважно, какой у вас статус (независимый программист или член команды,
состоящей из сотен специалистов с различных континентов), понимание различных
подходов к разработке программного обеспечения существенно поможет в вашей
ежедневной работе.
Необходимость подчиняться
технологическому процессу
История разработки программного обеспечения заполнена легендами о
провалившихся проектах. В этих страшных сказаниях, казалось бы, есть все: от
превышения бюджета и плохо продаваемых приложений до громко разрекламированных, но
не оправдавших себя операционных систем.
Даже в случае, когда ПО благополучно доходит до потребителя, всякого рода
ошибки становятся обычным явлением, из-за которого конечные пользователи
вынуждены терпеть постоянные обновления и "заплаты", т.е. вставки в программу с целью
исправления или изменения. Иногда программное обеспечение не решает
обещанные задачи или не работает ожидаемым образом. Все это говорит о том, что написать
хороший программный продукт довольно трудно.
Удивительно, почему многим кажется, что разработка программного обеспечения
так отличается от других форм инженерного искусства по частоте сбоев. Несмотря на
то что автомобили не лишены недостатков, вы редко увидите их резко
остановившимися и требующими перезагрузки из-за переполнения буфера (хотя, если судить о том,
что все больше компонентов автомобиля управляются бортовыми компьютерами, то,
наверное, скоро может быть и такое!). Ваш телевизор— тоже не совершенство, но
ведь вы не должны заменять его ПО на новую версию (например, 2.3), чтобы,
наконец, нормально заработал, скажем, 5 канал.
Значит ли это, что другие технические дисциплины более развиты по сравнению
с программированием? Способен ли инженер-строитель сконструировать надежный
мост, изучив долгую историю мостостроения? А смогут ли инженеры-химики успешно
создать соединение только потому, что большинство ошибок было исправлено
предыдущими поколениями ученых?
Неужели программирование — просто слишком молодая отрасль или
действительно уж очень отличается от других дисциплин своими
"врожденными" качествами, существенно влияющими на
возникновение ошибок, неожиданных результатов и "обреченных" проектов?
И вправду, кажется, что создание ПО — это нечто совсем иное, чем
конструирование мостов или автомобилей. Технология создания ПО меняется очень быстро, внося
в процесс его разработки некоторую долю неопределенности. Скорость развития
этой индустрии может кого угодно свести с ума, причем быстрота разработки ПО
часто обусловлена жестокой конкуренцией.
Разработка ПО иногда может не поддаваться прогнозированию. Точное планирование
здесь практически невозможно, ведь на исправление одной маленькой (но "удаленькой")
158 Часть I. Введение в профессиональное С++-проектирование
ошибочки могут уйти дни или даже недели. Даже когда вам кажется, что работа
продвигается в соответствии с графиком, широко распространенная тенденция изменять
формулировки технико-экономических показателей может свести на "нет" гладкость
всего процесса.
Программный продукт всегда характеризуется высокой сложностью. Не существует
простого, и точного способа доказать, что в программе нет ошибок. Код с ошибками,
если он поддерживается на протяжении нескольких версий, может оказывать влияние на
ПО в течение многих лет. Система программного обеспечения часто настолько сложна,
что при высокой текучести персонала никто не желает возиться с "проблемным" кодом,
оставленным уволившимися программистами. Это приводит к бесконечному "латанию"
и "вылизыванию" программы с целью максимального устранения недоделок.
Безусловно, стандартные деловые риски применимы и к ПО. Никуда не деться от
давления маркетинга и расхождений во взглядах. Многие программисты стараются
находиться подальше от выразителей корпоративной политики, но, к сожалению, между
отделами разработки и сбыта довольно часто возникают серьезные недоразумения.
Все эти факторы, направленные не на повышение качества программных
продуктов, означают насущную необходимость определенного технологического процесса.
Проекты создания ПО отличаются большими размерами, сложностью и быстрым
темпом. Чтобы избежать провала, группы программистов должны принять систему,
которая бы позволяла управлять этим процессом.
Модели жизненных циклов разработки ПО
Сложность разработки ПО— не новость. Необходимость в формализованном
процессе осознана десятки лет назад. Было сделано несколько попыток
смоделировать жизненные циклы ПО, чтобы как-то упорядочить хаос в его разработке путем
определения процесса его создания в виде последовательных этапов: от начальной
идеи до конечного продукта. Такие модели, усовершенствованные за долгие годы,
эффективно используются при разработке современного ПО.
Ступенчатая и водопадная модели
Классической моделью жизненного цикла ПО часто называют ступенчатый процесс
(Stagewise Model). Эта модель опирается на идею того, что ПО можно построить по
определенному рецепту. Существует набор действий, при корректном выполнении
которых можно получить прекрасный шоколадный торт (или программу). Каждое действие
(этап) должно быть завершено до начала следующего, как показано на рис. 6.1.
Процесс начинается с формального планирования, включающего составление
исчерпывающего списка требований. Этот список должен определять полноту свойств
и качеств продукта. Чем конкретнее будут выражены эти требования, тем более
вероятно, что проект завершится успешно. Затем намечается и полностью определяется
проект ПО. Этап проектирования, как и этап составления требований, должен быть
максимально конкретизирован— только тогда можно рассчитывать на успех. Все
проектные решения готовятся именно в это время, зачастую они содержат псевдокод
и определение отдельных подсистем, подлежащих кодированию. Те, кому поручается
разработка подсистем, договариваются между собой о том, как будут
взаимодействовать подсистемы, а также оговаривают конкретные детали архитектуры. Следующий
этап — реализация проекта. Поскольку проект считается полностью определенным,
Глава 6. Использование эффективных методов разработки программного... 159
код должен строго соответствовать проекту, в противном
случае отдельные его части не совместятся. Последние
четыре этапа посвящены блочному тестированию,
тестированию подсистем, проверке взаимодействия и
функционирования компонентов системы и оценке качества.
Основная проблема ступенчатой модели состоит в том,
что на практике практически невозможно завершить один
этап, не "прощупав" следующий. На этапе проектирования
нельзя поставить окончательную точку, не написав по
крайней мере нескольких фрагментов кода. И вообще, как
можно переходить к тестированию, если модель не
обеспечивает возможность возврата к этапу кодирования?
В результате усовершенствования ступенчатой модели
вначале 1970-х была сформулирована так называемая
водопадная модель (Waterfall Model). Эта модель остается одной
из основных, если не доминирующей, в современных
организациях, занимающихся разработкой программного
обеспечения. Основное достоинство водопадной модели
состоит в том, что она внесла понятие обратной связи между
этапами. Несмотря на то что эта модель по-прежнему
настаивает на строгом следовании друг за другом этапов
планирования, проектирования, кодирования и тестирования,
эти последовательные этапы могут частично
перекрываться. На рис. 6.2 показан пример водопадной модели,
иллюстрирующий обратные связи и перекрытия. Обратная связь
позволяет выводам, сделанным на одном этапе, повлиять на
результаты предыдущего (в форме внесения в него
изменений), а перекрытия делают возможным одновременное
выполнение двух смежных этапов.
Водопадная модель получила развитие в различных модификациях. Например,
в некоторых случаях этап планирования включает подэтап "осуществимости", на
котором до составления списка формальных требований проводятся эксперименты.
Достоинства водопадной модели
Ценность водопадной модели— в ее простоте. Вы, возможно, уже использовали
этот подход применительно к одному из своих прошлых проектов, не занимаясь
формализацией или не зная, как называется этот процесс. Главное в нем то, что, пока
каждый этап выполняется максимально аккуратно и полностью, последующие этапы
проходят достаточно гладко. Если все требования тщательно описаны на первом этапе,
а затем все проектные решения и задачи выяснены на втором, то третий этап—
реализация — должна выглядеть как простой перевод отдельных частей проекта в код.
Простота водопадной модели позволяет сделать план проекта, основанный на этой
• системе, организованным и легко поддающимся управлению. Каждый проект
начинается одинаково— с составления подробного списка всех свойств и качеств, которыми
должна обладать программа. Руководители, использующие этот подход, могут
потребовать, чтобы, например, к концу этапа проектирования все инженеры, ответственные за
подсистемы, представили на рассмотрение свои проекты в виде формальной проектной
Планирование
>'
Проектирование
V
Реализация
"
Блочное
тестирование
"
Тестирование
подсистем
>'
Проверка взаимодействия
и функционирования
компонентов системы
1
Оценка качества
Рис. 6.1
160 Часть I. Введение в профессиональное С++-проектирование
документации или официальных технических требований к подсистемам
(спецификаций). Это позволяет руководителю надеяться, что он минимизирует риски.
Проектирование
1
1
Реализация
к
Блочное
тестирование
к
Тестирование
подсистем
1
''
Проверка
взаимодействия
''
Оценка качества
Рис. 6.2
С инженерной точки зрения водопадная модель заставляет решать большинство
проблем заранее. Все инженеры должны уметь спроектировать свою подсистему до
написания существенной части кода. В идеале это означает, что код будет написан только
однажды, а не переписываться много раз в попытке "склеить" нестыкующиеся куски кода.
Для небольших проектов со специальными требованиями водопадная модель
зарекомендовала себя очень хорошо. В частности, в области создания
консультирующих средств она хорошо проявила себя в самом начале проекта при определении
системы конкретных показателей успеха. Формализация требований помогает
консультанту синтезировать в точности то, что хочет получить клиент, и заставляет клиента
конкретно называть цели проекта.
Недостатки водопадной модели
Судя почти по всей современной литературе, посвященной разработке ПО,
водопадная модель нынче попала в немилость (сотрудники многих организаций согласны
с этим). Критики слишком низко оценивают ее основные допущения о том, что
задачи разработки программного обеспечения должны быть выстроены по отдельным
линейным пунктам. Несмотря на возможность перекрытия этапов, водопадная
модель "по-хорошему" не предусматривает обратного движения. А ведь во многих
современных проектах требования поступают на всем протяжении разработки
продукта. Часто потенциальный потребитель требует использовать свойство, которое вдруг
стало необходимым для успешной продажи, или срочно обеспечить паритет с
продуктом конкурента, который, как оказалось, будет обладать новым качеством.
Детализация авансом всех требований делает водопадную модель
неприменимой для многих организаций, поскольку она не является
достаточно динамичной.
Глава 6. Использование эффективных методов разработки программного... 161
Еще один недостаток водопадной модели состоит в том, что в стремлении
минимизировать риски путем формализации решений на раннем этапе она может скрыть
реальный риск провала. Например, главная проблема проекта может оказаться невыяв-
ленной, "затушеванной", забытой или умышленно не замечаемой на этапе проекта.
К началу этапа проверки взаимодействия и функционирования компонентов системы
проявляется определенное несоответствие, но времени для спасения проекта уже
может не хватить. Да, главный изъян проекта таки обнаружен, но в соответствии с
водопадной моделью продукт находится практически "на выходе"! Таким образом, ошибка
в любом месте водопадного процесса с большой вероятностью приводит к провалу в конце
пути. Ранняя диагностика здесь оказывается весьма затруднительной и довольно редкой.
Несмотря на то что водопадная модель довольно широко распространена и может
быть эффективно использована для визуализации процесса, ее часто приходится
делать более гибкой, применяя, по сути, другие методы.
Спиральный метод
Спиральный метод был предложен Барри Бемом (Barry W. Boehm) в 1988 году в
качестве признания возможности возникновения непредвиденных проблем и
изменения требований в процессе разработки программного обеспечения. Этот метод —
один из членов семейства методов, именуемых итеративными процессами (iterative
processes). Основная идея этих методов состоит в том, что ничего нет страшного,
если что-то не ладится, потому что все еще можно исправить на следующем витке
процесса. Один "виток" спирального метода показан на рис. 6.3.
Открытие
Оценка
Анализ
Разработка
Рис. 6.3
Фазы спирального метода подобны этапам водопадной модели. Фаза открытия
включает составление требований и определение целей. В продолжение фазы оценки
рассматриваются альтернативы реализации и возможно построение прототипов.
Фазе оценки рисков и принятию решения по их минимизации в спиральном методе
уделяется особое внимание. Самыми рискованными считаются задачи, которые реали-
162 Часть I. Введение в профессиональное С++-проектирование
зуются в текущем витке спирали. Задачи в фазе разработки определяются на
основании рисков, вычисленных в фазе оценки. Например, если результаты оценки
выявляют рискованный алгоритм, который невозможно реализовать, то основной задачей
фазы разработки в текущем цикле будет моделирование, построение и тестирование
этого алгоритма. Четвертая фаза резервируется для анализа и планирования. На
основе результатов текущего цикла формируется план следующего. Предполагается, что
каждая итерация должна быть относительно недолгой по продолжительности и
посвящаться рассмотрению только нескольких ключевых факторов и видов риска.
На рис. 6.4 показан пример трех витков спирали процесса разработки
операционной системы. Первый цикл (виток) завершается созданием плана, содержащего
основные требования к продукту. К концу второго цикла создается прототип,
отображающий опыт пользователя. В результате третьего цикла строится компонент, для
которого определен высокий уровень риска.
Открытие
Оценка
Требования
к аспекту А
3
Анализ рисков
по аспекту А
,., , Устранение
Построение прототипа рисков
по аспекту А
Новый план поаспектуА
Анализ
Разработка
Рис. 6.4
Достоинства спирального метода
Спиральный метод можно рассматривать как итеративный подход к лучшим
результатам, которые способна предложить водопадная модель. На рис. 6.5 спиральный
метод показан в виде водопадного процесса, который модифицирован таким образом,
чтобы позволить выполнение итераций. Основные недостатки водопадного метода —
скрытые риски и линейный путь разработки — устранены здесь посредством
коротких итеративных циклов.
Глава 6. Использование эффективных методов разработки программного... 163
Планирование
ч
Проектирование
Планирование
Реализация
Блочное
тестирование
О.
Проектирование
Планирование
□_
Реализация
Тестирование
подсистем
Ч
Проверка
взаимодействия
El
Блочное
тестирование
Проектирование
Реализация
Тестирование
подсистем
Оценка
качества
ч
Проверка
взаимодействия
Блочное
тестирование | т.
1_] Тестирование
подсистем | !■
Оценка
качества
t_J Проверка
| взаимодействия | I
t_| Оценка
качества
Рис. 6.5
Еще одно достоинство спирального метода — выполнение в первую очередь самых
рискованных задач. Выдвигая риск на передний план действий и признавая тот факт,
что в любой момент могут возникнуть новые условия, спиральный метод позволяет
избежать "встреч" со скрытыми бомбами замедленного действия, которые возможны при
использовании водопадной модели. Возникшие проблемы можно решить с помощью
того же четырехэтапного подхода, который используется для остальной части процесса.
Наконец, неоднократно анализируя состояние проекта после каждого цикла и
создавая новые проекты, вы фактически устраняете практические трудности, которые
неизбежны при отделении по времени этапа проектирования от этапа реализации.
В данном случае с каждым циклом возрастает объем знаний о системе, что, конечно
же, положительно влияет на качество проекта.
Недостатки спирального метода
Основной недостаток спирального метода заключается в том, что для получения
реального выигрыша довольно трудно определить небольшие по объему итерации.
По самому худшему сценарию (при очень длительных итерациях) спиральный метод
может выродиться в водопадную модель. К сожалению, спиральный метод лишь
моделирует жизненный цикл ПО. Он не в состоянии предписать конкретный способ
разбиения проекта на "однотактные" итерации, поскольку при переходе от
проекта к проекту это происходит по-разному.
В числе других возможных недостатков можно назвать расходы, связанные с
повторением всех четырех фаз для каждого цикла, и трудности координации циклов.
Кроме того, иногда нелегко собрать в нужное время всех членов группы для
обсуждения вопросов проектирования. Если различные команды одновременно работают над
разными частями продукта, то они могут находиться в параллельных циклах, что
существенно затрудняет синхронизацию всей работы. Например, группа, которая
занимается разработкой пользовательского интерфейса, уже готова к фазе открытия
цикла "Менеджер окон", а основная группа, ответственная за операционную систему,
все еще находится в фазе разработки подсистемы управления памятью.
Рациональный унифицированный процесс
Рациональный унифицированный процесс (Rational Unified Process— RUP), или
РУП, представляет собой дисциплинированный и формальный подход к управлению
процессом разработки ПО. В отличие от спиральной или водопадной моделей,
рациональный унифицированный процесс (что очень важно) является не просто
теоретической моделью процесса, а программным продуктом, продаваемым под
торговой маркой Rational Software (отдел компании IBM). Для отношения к процессу как
к ПО есть ряд причин.
164 Часть I. Введение в профессиональное С++-проектирование
□ Сам процесс можно обновлять и совершенствовать подобно тому, как
периодически обновляются программные продукты.
□ Вместо простого предложения среды разработки РУП включает набор
программных средств для работы с такой средой.
□ Как продукт, РУП может быть развернут для целой команды, чтобы все ее
члены использовали одинаковые процессы и инструменты.
□ Подобно многим программным продуктам, РУП можно настраивать под нужды
пользователей.
РУП как продукт
Как продукт РУП принимает форму набора программных приложений, которые
направляют специалистов во время процесса разработки ПО. Существуют такие
специальные модификации рационального продукта, как Rational Rose (инструментальное
средство моделирования с использованием видеоданных) и Rational ClearCase
(инструментальное средство конфигурационного управления). Эти распространенные
средства коллективного пользования составляют часть "рынка идей", которые
предлагают разработчикам совместно использовать "общие" знания.
Один из основных принципов РУП состоит в том, что каждая итерация цикла
разработки должна иметь осязаемый результат. Ориентируясь на рациональный
унифицированный процесс, пользователи "обречены" на создание множества проектов,
документов, отчетов и планов. Для создания этих артефактов в инструментальном
комплексе РУП предусмотрены средства визуализации и разработки.
РУП как процесс
Определение точной модели — основополагающий принцип РУП. Модели в
соответствии с РУП помогают объяснить замысловатые структуры и отношения,
образующиеся в процессе разработки ПО. Обычно РУП-модели выражаются в формате
универсального языка моделирования (Unified Modeling Language — UML).
РУП определяет каждую часть процесса как отдельную последовательность
выполняемых действий. Такие последовательности представляют каждый этап процесса
в виде ответов на следующие вопросы: на кого возложить ответственность за каждую
задачу, какие задачи решаются в данный момент, какие должны быть получены
результаты (артефакты) и какова последовательность событий, которая должна
привести к решению этих задач. Практически все аспекты РУП-процесса поддаются
настройке, за исключением некоторых, именуемых базовыми последовательностями.
Базовые последовательности выполняемых действий имеют некоторое сходство
с этапами водопадной модели, но каждая из них итеративна и более конкретна по
определению. Целью бизнес-моделирования обычно является определение предварительных
требований к ПО. Уточнение требований предполагает определение требований на
основе анализа проблем в системе и проведение итераций при определенных
допущениях. Действия, связанные с анализом и проектированием, направлены па создание
архитектуры системы и проектирование подсистем. Реализация охватывает
моделирование, кодирование и интегрирование подсистем ПО. Тестирование включает
планирование, реализацию и проведение испытаний для подтверждения качества.
Последовательность действий, именуемых передачей в эксплуатацию, предполагает
высокоуровневый взгляд на генеральное планирование, сдачу продукта
пользователю, поддержку и проведение тестов. Конфигурационное управление продолжается от
Глава 6. Использование эффективных методов разработки программного... 165
концепции создания новых проектов до выпуска конечного продукта. Наконец,
создание рабочей среды призвано поддерживать организацию разработки ПО путем создания
и совершенствования инструментальных средств разработки.
Практическое применение РУП
РУП ориентирован, в основном, на большие организации и предлагает ряд
преимуществ по сравнению с традиционными моделями жизненных циклов. Если
команда способна освоить принципы РУП, все ее члены будут использовать общую
платформу для проектирования, взаимодействия и реализации своих идей. Этот процесс
может быть настроен под конкретные требования команды, при этом на каждой
стадии предполагается создание множества ценных артефактов, которые
документируют все фазы разработки ПО.
Для некоторых организаций такой продукт, как РУП, может быть слишком "тяжелым".
Команды с различными средами разработки или со стесненным бюджетом вряд ли
захотят или смогут позволить себе стандартизацию на основе готовой системы
разработки с использованием программных методов. Вопросы освоения новой технологии
тоже могут стать проблемой для отдельных коллективов — тому, кто не знаком с
упомянутыми выше продуктами, придется приложить определенные усилия для их освоения.
Методологии разработки программного
обеспечения
Модели жизненных циклов ПО обеспечивают формальный способ ответа на вопрос:
"Что делать дальше?", но редко (исключением являются такие формализованные
системы, как RUP) способны дать ответ на вопрос, логически вытекающий из
предыдущего: "Как это сделать?". Чтобы найти ответ на вопрос "как", были разработаны
методологии, предоставляющие практические правила профессиональной разработки ПО.
Выпущено уже немало книг и статей, посвященных методологиям разработки
программного обеспечения, но особого внимания заслуживают две новинки, Extreme
Programming (Экстремальное программирование) и Software Triage (Сортировка ПО).
Экстремальное программирование (ЭП)
Когда несколько лет назад один из авторов пришел после работы домой и рассказал
своей жене, что его компания приняла на вооружение ряд принципов экстремального
программирования (Extreme Programming), она пошутила: "Я надеюсь, ты пристегнешь
ремни безопасности". Несмотря на несколько вызывающее название, экстремальное
программирование эффективно объединяет лучшие из существующих принципов
разработки ПО и некоторые новшества во все более и более популярную методологию.
Принципы экстремального программирования изложены в общедоступной форме
Кентом Беком (Kent Beck) в книге eXtreme Programming explained (Addison-Wesley, 1999).
Бек утверждает, что эта методология показывает хорошие результаты разработки ПО.
Например, большинство программистов не станут возражать против необходимости
тестирования. В ЭП тестированию придается настолько большое значение, что вам
предлагается написать тесты до самого кода!
166 Часть I. Введение в профессиональное С++-проектирование
ЭП в теории
Методология экстремального программирования состоит из 12 основных
принципов, которые проявляются на протяжении всех фаз процесса разработки ПО и
оказывают непосредственное влияние на ежедневные задачи программистов.
Планируйте по ходу дела
В водопадной модели планирование выполняется один раз, в начале процесса.
В спиральном методе планирование было первой фазой каждой итерации. В РУП-
процессе планирование является неотъемлемой частью большинства
последовательностей выполняемых действий. В экстремальном программировании
планирование — не просто этап, а никогда не завершающаяся задача. Команды программистов,
следующие принципам ЭП, начинают с приблизительного плана, который
охватывает основные позиции разрабатываемого продукта. На протяжении всего процесса
разработки план совершенствуется и модифицируется по мере необходимости.
Теоретически условия постоянно меняются, и без конца поступает новая информация.
В рамках ЭП оценка той или иной характеристики продукта всегда выполняется
человеком, который будет заниматься ее реализацией. Это позволяет избежать ситуаций,
когда исполнителя вынуждают придерживаться нереального графика. В начальной
стадии оценки довольно приблизительны и выражаются в неделях. По мере сокращения
временного горизонта оценки становятся точнее. В конце концов характеристики
разбиваются на задачи, выполнение которых требует уже не более пяти дней.
Хорошего понемножку
Один из постулатов ЭП гласит, что программные проекты при попытке
одновременного выполнения слишком больших задач становятся более рискованными и
громоздкими. Вместо крупных версий ПО, которые характеризуются кардинальными
изменениями и многостраничными описаниями, ЭП рекомендует
совершенствоваться небольшими порциями с интервалами времени, близкими к двум (а не
восемнадцати) месяцам. При таких коротких циклах выпусков новых версий только самые
важные свойства могут удостоиться чести воплотиться в продукте. Это заставит
программистов и сотрудников отдела маркетинга договориться о том, какие свойства
считать наиболее важными.
Используйте общепринятое модельное представление
В экстремальном программировании используется термин "метафора" (в значении
модельное представление) подобно тому, как в других методологиях используется понятие
архитектуры. Дело в том, что все члены команды должны совместно использовать
общепринятое высокоуровневое представление о системе. Оно не включает такие
подробности, как способ взаимодействия объектов или точная конфигурация API-интерфейсов.
Под метафорой понимается мысленная модель компонентов системы. Члены команды
должны использовать понятие метафоры при обсуждении проекта системы.
Не усложняйте проект
Сторонники ЭП как молитву повторяют наказ "избегать гипотетического
обобщения". Это идет вразрез с естественными предпочтениями многих программистов.
Если перед вами стоит задача проектирования файлового хранилища объектов, не
стоит начинать с создания универсального решения всех проблем хранения объектов.
В противном случае ваш проект может быстро эволюционировать в суперпроект,
покрывающий несколько языков и рассчитанный на объекты любого типа. ЭП
рекомендует склоняться к другому концу континуума всеобщности. Вместо создания идеально-
Глава 6. Использование эффективных методов разработки программного... 167
го хранилища объектов, которое, по вашему мнению, потенциально принесет вам
всемирную славу, спроектируйте простейшее, но вполне работоспособное хранилище
объектов, что позволит считать вашу задачу решенной. Чтобы избежать
неоправданного усложнения кода, следует четко разобраться в текущих требованиях и написать
код для конкретных спецификаций.
Вероятно, нелегко привыкнуть к простоте в проекте. Ваш код (все зависит от типа
работы) может оказаться полезным для других частей программы и быть
востребованным в течение нескольких лет. Как упоминалось в главе 5, проблема создания
функций, которые могут быть полезными в будущем, состоит в том, что вы пока не
знаете, в чем состоит их гипотетическое предназначение, и поэтом)' невозможно
создать хороший проект только на основании предполоясении. ЭП предлагает строить
то, что нужно сегодня, и оставлять возможность для модификации в будущем.
Тестируйте постоянно
Приведем одну цитату из книги eXtreme Programming explained: "Любое свойство
(функция) программы без автоматически проведенного тестирования попросту не
существует". Экстремальное программирование "поведено" на тестировании. Часть
вашей ответственности как ЭП-программиста состоит в написании тестов элементов,
составляющих код. Локальный поэлементный тест представляет собой небольшой
фрагмент кода, который позволяет убедиться в работоспособности отдельной
функции программы. Например, блочные тесты подсистемы файлового хранения
объектов могут включать тесты хранения (testSaveObject), загрузки (testLoadObject)
иудаления (testDeleteObject) объектов.
"Поведенность" сторонников экстремального программирования на блочном
тестировании выражается в том, что они предлагают писать локальные тесты до
написания кода, подлежащего тестированию! Безусловно, некоторое время тесты "полежат
на полке". Но если тесты будут тщательно продуманы, то теоретически они помогут вам
понять, когда завершится создание кода. Признаком конца послужит успешное
прохождение всех тестов. Мы ведь не зря говорили об экстремальном программировании!
Переделывайте при необходимости
Большинство программистов время от времени переделывают свой код.
Переделка — это процесс перепроектирования уже работающего кода с учетом новых знаний
или альтернативных вариантов решения задачи, найденных уже после написания кода.
Переделку трудно "уложить" в традиционный график разработки ПО, поскольку ее
результаты не так ощутимы, как при реализации совершенно новой функции. Однако
опытные менеджеры признают важность долговременного сопровождения программы.
Экстремальный способ переделки состоит в распознании возникающих во время
разработки ситуаций, когда переделка действительно полезна. ЭП-программисты
считают необходимым выявлять признаки, по которым можно судить, что код
"созрел" для переделки, а не решать в начале периода подготовки очередной версии,
какая из существующих частей кода нуждается в перепроектировании. Несмотря на то
что такая практика практически всегда приводит к возникновению неожиданных
и незапланированных проблем, реструктурирование кода по необходимости, как
правило, упрощает разработку функций программы.
Кодируйте по парам
Понятие парного программирования можно считать отличительным признаком
экстремального программирования, который позволяет сразу находить "своих". И в
самом деле, мотивация для парного программирования лежит в практической плоско-
168 Часть I. Введение в профессиональное С++-проектирование
сти, причем даже в большей степени, чем можно было ожидать. Сторонники ЭП
считают, что весь код продукта должен быть написан одновременно двумя людьми,
работающими бок о бок. Очевидно, только один из них может реально сидеть за
клавиатурой. Второй же осуществляет высокоуровневый подход к задаче, думая над такими
вопросами, как тестирование, необходимые переделки и общая модель проекта.
Например, если вы ответственны за написание пользовательского интерфейса для
конкретной функции вашего приложения, то вам имеет смысл попросить автора этой
функции посидеть с вами и ввести вас в курс дела со всеми подробностями. Автор может
дать ряд советов в отношении корректного использования этой функции, предупредить
обо всех "подводных камнях" и помочь вам, так сказать, на более высоком уровне. Если
вы не имеете возможности попросить помощи у настоящего автора функции,
попробуйте привлечь к сотрудничеству другого члена команды. Теория гласит, что парная
работа создает общие знания, обеспечивает надлежащий дизайн и правильно расставляет
сдерживающие и уравновешивающие силы, причем в непринужденной обстановке.
Разделяйте код
Во многих традиционных средах разработки право собственности на код строго
оговорено. Один из авторов этой книги ранее работал в среде, где менеджер
запрещал регистрацию изменений, вносимых в код, написанный любым другим членом
команды! ЭП предусматривает противоположный подход, при котором код является
коллективной собственностью всей команды. Это еще один признак ЭП, поначалу
вызывающий ассоциацию с программистами, которые, поднявши руки вверх, плавно
покачиваются в такт музыки, которая понятна лишь посвященным. На самом деле это
не такая уж знаковая деталь, как "зацикливание" на тестировании.
Коллективное владение кодом практично по ряду причин. С точки зрения
руководства, если один программист неожиданно уволится из компании, то работе в этом
случае будет нанесен меньший ущерб, поскольку есть другие специалисты, которые
в курсе "брошенной" части программы. С точки зрения программиста, при
коллективном владении создается общее видение того, как работает вся система. Это
помогает находить более удачные решения задач и позволяет отдельному программисту
вносить любые изменения, которые способны улучшить весь проект в целом.
И еще. При коллективном владении каждому программисту необязательно
знакомиться со всеми строками кода. Здесь речь идет о том, что проект — это
совместный труд, и поэтому нет причин для того, чтобы кто-то из команды скрывал от
коллег свои наработки.
Постоянно интегрируйте код
Все программисты не понаслышке знакомы с такой мукой мученической, как
объединение отдельных частей кода. В работе наступает своего рода "момент истины",
когда обнаруживается, что ваше видение того, как, например, должны храниться объекты,
совершенно не совпадает с тем, что уже написано вашими коллегами. При стыковке
подсистем зачастую вскрывается множество проблем. Сторонникам ЭП это явление
также хорошо известно, и поэтому они рекомендуют выполнять операции по
объединению программных частей с частотой их приведения в состояние готовности (пусть
предварительной).
Методология ЭП предлагает особый метод интеграции кода. Два программиста
(пара, которая разрабатывала код) усаживаются за выделенную "станцию
интеграции" и вместе объединяют код. Этот код не должен регистрироваться до тех пор, пока
он не пройдет 100% тестов. Конфликтов можно избежать путем организации единст-
Глава 6. Использование эффективных методов разработки программного... 169
венной станции интеграции, а процесс интеграции необходимо четко определить как
этап, который должен состояться до регистрации кода.
Авторы пришли к заключению, что аналогичный подход вполне работоспособен
и на индивидуальном уровне. Программисты проводят тестирование индивидуально
или по парам до передачи кода в репозиторий. На выделенном компьютере постоянно
прогоняются автоматические тесты. При обнаружении факта сбоя в проведении
автоматического тестирования члены команды получают электронное сообщение,
уведомляющее их о возникшей проблеме, и листинги, зарегистрированные в последнее время.
Работайте в рабочие часы
Методология ЭП придает большое значение тому, как работают программисты. Не
секрет, что хорошо отдохнувший программист — удачливый и продуктивный
программист. Приверженцы методологии ЭП выступают за 40-часовую рабочую неделю и
предостерегают против работы в сверхурочные часы в течение более двух недель подряд.
Безусловно, разным людям нужно различное количество часов для отдыха. Но
главная идея состоит в том, что если вы пытаетесь программировать на "несвежую"
голову, вам вряд ли удастся написать хороший код, что послужит отрицанием многих
принципов ЭМ.
Вовлекайте конечного пользователя в рабочий процесс через Internet
Поскольку ЭП-команда программистов постоянно совершенствует свой план
работы над программным продуктом и занимается делами, которые необходимы в данный
момент, вовлечение потребителя в процесс может стать очень ценным фактором.
Хотя не всегда можно и нужно обеспечивать физическое присутствие потребителя во
время разработки, идея контакта между программистом и конечным пользователем
может принести очень хорошие результаты. Помимо оказания помощи в
проектировании отдельных функций пользователь, сообщив о своих конкретных нуждах, может
подсказать, какие задачи для него важнее всего.
Пользуйтесь общепринятыми стандартами кодирования
Благодаря принципам коллективной собственности и практике попарного
программирования кодирование в экстремальной среде может быть затруднено, если
каждый программист использует собственные соглашения о присвоении имен и
выделении частей текста программы различными отступами. ЭП не ратует за какой-то
определенный стиль оформления программ, но если вы, взглянув на фрагмент кода,
сразу идентифицируете его автора, то это значит, что вашей команде стоит лучше
определить стандарты кодирования.
Более подробно о различных подходах к стилю программирования рассказывается
в главе 7.
ЭП на практике
Сторонники ЭП утверждают, что 12 принципов экстремального
программирования настолько переплетены, что принятие лишь некоторых из них почти
совершенно разрушит всю методологию. Например, попарное программирование жизненно
важно для тестирования, поскольку, если вы не сможете понять, как протестировать
отдельный фрагмент кода, то, возможно, ваш партнер сможет вам в этом помочь.
Кроме того, если вы устали и решили опустить этап тестирования, ваш партнер будет
рядом и, вероятно, сможет вызвать у вас чувство вины.
170 Часть I. Введение в профессиональное С++-проектирование
Некоторые принципы ЭП трудно реализовать. Для многих программистов идея
написания тестов раньше кода кажется слишком абстрактной. В этом случае можно
ограничиться проектированием тестов (без их кодирования), а реальное их
написание отложить до появления кода, подлежащего тестированию. Многие принципы ЭП
звучат как строго определенные, но если вы понимаете их теоретическую подоплеку,
вам будет нетрудно найти способы для их адаптации к нуждам конкретного проекта.
Принцип совместной работы также может быть сложным для реализации. Хотя
попарное программирование обладает известными достоинствами, в
действительности этот принцип может оказаться трудным для осуществления, поскольку это
означает, что реально каждый день написанием кода будет заниматься вдвое меньшее
количество людей. Некоторые члены команды могут вообще испытывать дискомфорт
от такого тесного сотрудничества, находя для себя невозможным под посторонним
взглядом даже вводить код в компьютер. Кроме того, попарное программирование
может оказаться невозможным для реализации, если команда физически не
находится в одном помещении, или если ее члены привыкли к удаленной работе.
Для некоторых организаций экстремальное программирование может оказаться
слишком радикальным. Крупные компании, в которых принимается та или иная
политика программирования, могут не торопиться с принятием таких новых подходов,
как ЭП. Но даже если в вашей компании ощущается явное сопротивление реализации ЭП,
вы можете повысить собственную производительность, уяснив для себя
теоретические основы этой методологии.
Сортировка ПО
В книге с фаталистическим названием Death March [47] автор описывает часто
встречающиеся и жуткие по своим последствиям условия, когда разработка ПО
отстает от графика, не хватает специалистов, перерасходуется бюджет или
проектирование оказывается неудачным. Теория Йордона состоит в том, что при попадании
процесса разработки ПО в одно из таких состояний ни одна из самых лучших
методологий разработки ПО уже не в силах помочь. Как вы узнали в этой главе,
многие подходы к разработке ПО опираются на формализованные документы или ставят
во главу угла нужды и желания пользователя. Для проекта, который уже готов
"умереть в марте", т.е. попал в критическое состояние, просто нет времени для
применения каких бы то ни было методологий.
Идея сортировки ПО состоит в следующем. Если проект уже находится в
плачевном состоянии, это значит, что ресурсы пракгически исчерпаны: время на исходе,
программистов не хватает и деньги истрачены. Основной моральный барьер, который
должны преодолеть менеджеры и программисты при выходе проекта из графика,
заключается в том, что исходные требования в назначенное время удовлетворить
при всем желании невозможно. После осознания этого факта следует поставить
задачу так, чтобы перечислить оставшиеся нереализованными функции в виде списка
элементов, приоритет которых определялся бы такими словами: "необходимо",
"желательно" и "хорошо бы".
Сортировка ПО — довольно трудный и деликатный процесс. Зачастую он требует,
чтобы руководство принял на себя работник, способный принимать жесткие решения
и обладающий опытом спасения проектов, которым грозит неминуемая "смерть
Название этой книги можно перевести как Умереть в марте. — Примеч. ред.
Глава 6. Использование эффективных методов разработки программного... 171
в марте". Для программиста главное— понять, что для того, чтобы все-таки
закончить проект вовремя, иногда необходимо расстаться со знакомыми процессами (и, как
это ни прискорбно, с уже написанным кодом).
Построение собственного процесса
и методологии
Существует одна методология разработки ПО, которую мы горячо поддерживаем
(это не значит, что мы имеем в виду какую-то из выше перечисленных). Маловероятно,
чтобы какая-то книга или теория абсолютно подходила под нужды вашего проекта или
организации. Мы рекомендуем взять самое ценное из разных подходов и разработать
собственный процесс. Объединить принципы из различных методологий — это не так
трудно, как может показаться. Например, РУП-процесс выборочно поддерживает ЭП-
методологию. Предлагаем вашему вниманию несколько советов по построению
процесса разработки ПО, который бы соответствовал вашим представлениям об идеале.
Будьте готовы к восприятию новых идей
Некоторые технологии программирования кажутся поначалу бредовыми или
малоперспективными. Взгляните на инновационные технологии в области разработки
ПО как на способ усовершенствовать существующий процесс. Попробуйте что-нибудь
из новенького, примерьте это на себя. Если название ЭПзвучит интригующе, но вы не
уверены, подойдет ли этот метод для вашей организации, возьмите для начала
несколько принципов ЭП и примените их к небольшому экспериментальному проекту.
Перенесите новые идеи на бумагу
Весьма вероятно, что ваша команда состоит из людей, которые отличаются друг от
друга образованием, опытом и стилем работы. У вас могут работать
профессиональные программисты, консультанты с многолетним опытом, свежеиспеченные
выпускники учебных заведений и специалисты с учеными степенями. У всех членов команды
различный опыт, и каждый может иметь собственные идеи по поводу того, как
следует организовать процесс разработки ПО. Иногда лучшим вариантом оказывается тот,
который составляется путем комбинации различных методов.
Разберитесь, что работает, а что — нет
В конце проекта (или лучше во время работы над ним) соберите всех членов
команды, чтобы оценить ход процесса. Иногда есть такие проблемы, которых никто не
замечает до тех пор, пока не остановится работа всей команды. Возможно, есть и такие
проблемы, о которых все знают, но никто не говорит вслух! Обсудите, что не
работает до сих пор, и договоритесь, как эти вещи можно сдвинуть с мертвой точки. В
некоторых организациях требуется формальная экспертиза программы до регистрации ее
исходного кода. Если проведение такой экспертизы — настолько длительная и
утомительная процедура, что никто ею по-хорошему не занимается, рассмотрите изменение
методов ее проведения. Обсудите также, в каких направлениях работа продвигается
успешно, и подумайте, как можно закрепить этот успех. Например, если такая задача,
172 Часть I. Введение в профессиональное С++-проектирование
как поддержка и редактирование Web-сайта успешно решается, посвятите некоторое
время тому, чтобы рассмотреть возможности улучшения внешнего вида и
содержимого этого Web-сайта.
Не сдавайтесь
Если процесс включает написание большого объема проектной документации,
позаботьтесь о том, чтобы она была в порядке. Если вам кажется, что процесс нарушен
или слишком сложен, попробуйте поговорить об этом со своим менеджером.
Причина может крыться и в том, как организовано управление процессом (может быть, все
дело в отсутствии руководства, и работа пущена на самотек?). Не отворачивайтесь от
проблем, они все равно не дадут вам покоя.
Резюме
В этой главе вы познакомились с несколькими моделями и методологиями,
применяемыми для процессов разработки ПО. Безусловно, известны и другие способы
построения ПО, как формализованные, так и неформальные. Вероятно, не существует
единственно верного метода разработки ПО, за исключением метода, наиболее
подходящего вашей команде. Лучший способ найти такой метод — провести собственные
исследования, узнать, что можно позаимствовать из разных моделей, пообщаться с
коллегами, имеющими подобный опыт, и проверить теорию на практике. Помните, только
один показатель имеет значение при рассмотрении методологии процесса разработки
ПО, а именно то, насколько хорошо он помогает вашей команде писать код.
Этой главой завершается первая часть книги, которая позволила читателю
сформировать панорамный взгляд на процесс проектирования ПО. Вы узнали, как можно
проектировать программу, как организовать взаимоотношения между объектами, как
использовать существующие шаблоны и библиотеки, как сделать свой код
эффективным и как управлять процессом разработки ПО. В остальной части книги принципы
проектирования, с которыми вы здесь познакомились, будут применены
непосредственно к C++. Следующая часть этой книги вводит читателя в будничные, но жизненно
необходимые детали написания профессионального кода на C++. Постарайтесь не
забыть уроки по проектированию ПО при погружении в листинги, приведенные в этой
книге— ведь, поместив главы, посвященные проектированию, в начало книги, мы
тем самым хотели подчеркнуть их значимость для работы программиста.
Часть II
Пишем С++-код
профессионально
в этой части... .
Глава 7. Кодируем стильно
Глава 8. Оттачиваем мастерство в использовании классов
и объектов
Глава 9. Освоение классов и объектов
Глава 10. Осваиваем механизм наследования
Глава 11. Пишем обобщенный код с помощью шаблонов
Глава 12. Причуды и странности C++
Кодируем стильно
Если вы каждый день проводите за клавиатурой несколько часов, то вы
непременно должны получать удовлетворение от своей работы. Написание работающего
кода — только часть работы программиста. Научить основам программирования можно
любого. Стиль кодирования — вот что отличает рутину от истинного мастерства.
В этой главе мы попробуем разобраться в том, что означает "хороший код". Здесь
вы увидите, что в результате простого изменения стиля кодирования внешний вид
программы может стать совсем другим. Например, С++-код, написанный Windows-
программистами, часто отличается от С++-кода, написанного программистами Mac OS.
Более того, при сравнении их программ может показаться, что они используют
совершенно разные языки программирования. Знакомство с различными стилями поможет
вам избежать неприятного чувства, возникающего при просмотре исходного С++-файла,
что это очень мало напоминает язык C++, который, как вам казалось, вы знаете.
Красота — страшная сила
На написание стилистически "хорошего" кода требуется время. Вы могли бы
написать программу для анализа XML-файла за пару часов. Но, чтобы написать такую же
программу с применением функциональной декомпозиции, комментариев и четкой
структуризации, потребовалось бы несколько дней. Так стоит ли тратить драгоценное время?
Глава 7. Кодируем стильно 175
Думая о будущем...
Насколько вы были бы уверены в своем коде, если бы год спустя с ним пришлось
работать новому программисту? Один из авторов этой книги столкнулся с
неразберихой в коде Web-приложения, что заставило его команду представить муки
гипотетического молодого специалиста, который бы пришел к ним на работу через год. Как бы
этот бедный парень мучился с кодом, состоящим из жутких многостраничных
функций при отсутствии хоть какой-нибудь документации? Когда вы пишете код,
представьте себе несчастного новичка, которому придется поддерживать его в будущем.
Помните ли вы хотя бы то, как работает ваш код? А что будет, если вы уже уволитесь
к тому времени и ие сможете помочь бедняге? Хорошо написанный код лишен таких
проблемы, поскольку его легко читать и понимать.
... и настоящем
Если вы напишете хороший код, вам скажут спасибо не только потомки, но и
современники. И даже вы сами. Если вы работаете над проектом в коллективе, другим
программистам придется время от времени вникать в ваш код и даже модифицировать
его. Написав код, который будет понятен для других членов вашей команды, вы тем
самым освободите в первую очередь себя и других от постоянных вопросов и жалоб.
Элементы хорошего стиля
Трудно перечислить характеристики кода, которые делают его "стилистически
хорошим". Со временем вы отметите понравившиеся вам стили в чужих программах
и выберите для себя подходящий. Что, возможно, более важно, вы обязательно
встретите код, который научит вас, как не надо делать. И все же есть ряд
универсальных признаков "хорошего стиля", которые мы обсудим в этой главе:
□ документация;
□ декомпозиция;
□ присвоение имен;
□ корректное использование языка C++;
□ форматирование.
Документируйте свой код
В контексте программирования под документацией обычно понимаются
комментарии, содержащиеся в исходных файлах. С помощью комментариев вы можете
увековечить свои мысли, которые посетили вас во время написания программы.
Комментарии позволяют разъяснить все, что не очевидно при чтении "голого" кода.
Зачем писать комментарии
Возможно, вы не станете возражать против полезности комментариев, но вы
когда-нибудь задумывались над тем, зачем они нужны? Иногда программисты признают
важность комментирования кода, но не до конца понимают, почему это так важно.
Попробуем в этом разобраться.
176 Часть II. Пишем С++-код профессионально
Комментарии, разъясняющие способ применения программы
Одна из причин для вставки комментариев в код программы— объяснить, как
клиент должен взаимодействовать с программой. Как упоминалось в главе 5, каждая
публично доступная функция (или метод) должна иметь в заголовочном файле
комментарий, описывающий, что она (или он) делает. В некоторых организациях
предпочитают формализовать эти комментарии, обязывая указывать назначение каждого
метода, приводить описание всех аргументов и возвращаемого методом значения,
а также исключений, если таковые он может генерировать.
Комментарий, включаемый в открытые методы, позволяет сделать две вещи. Во-
первых, вы имеете возможность выразить на родном языке все, что вам не удалось
высказать на языке программирования. Например, на языке C++ невозможно
пояснить, что метод adjustVolume () объекта медиаплейера можно вызывать только
после вызова метода initializeO.A комментарий — это самое подходящее место для
упоминания о таком ограничении.
/*
* adjustVolume()
*
* Устанавливает уровень громкости проигрывателя на
* основе предпочтений пользователя.
*
* Этот метод генерирует исключение
* "UninitializedPlayerException", если еще не был вызван
* метод initialize().
*/
Во-вторых, с помощью комментария в текст открытого метода можно включить
информацию о том, как его применять. Язык C++ заставляет программиста указывать
тип значения, возвращаемого методом, но не предусматривает способ, как сообщить,
что это значение в действительности представляет. Например, объявление метода
adjustVolume () может означать, что этот метод возвращает значение типа int, но
клиент, прочитав это объявление, может не знать, что означает тип int. В состав
комментария могут входить и другие данные.
/*
* adjustVolume()
*
* Устанавливает уровень громкости проигрывателя на
* основе предпочтений пользователя.
*
* Параметры:
* нет
* Возвращает:
* int-значение, которое представляет новый уровень
* громкости.
*
* Генерирует:
* исключение UninitializedPlayerException, если еще
* не был вызван метод initialize().
*
Глава 7. Кодируем стильно 177
Комментарии, разъясняющие трудный для понимания код
Хорошие комментарии также важно включать в сам исходный код. В простой
программе, которая обрабатывает входные данные, принимаемые от пользователя, и
записывает результат на консоль, довольно просто разобраться и понять весь ее код.
Однако программистам часто приходится писать код, отличающийся
алгоритмической сложностью или понятный лишь посвященным.
Рассмотрим следующий код. Он написан хорошо, но не каждый сразу поймет, что
он делает. Если вы раньше видели этот алгоритм, то сможете его узнать, однако
новичок вряд ли поймет, как работает этот код.
void sort(int inArray[], int inSize)
for (int i = 1; i < inSize; i++) {
int element = inArrayfi];
int j = i - 1;
while {j >= 0 && inArrayfj] > element) {
inArray[j+l] = inArrayfj] ;
j--;
}
inArrayfj+1] = element;
Конечно, для описания этого алгоритма стоило бы включить в код комментарий.
Поэтому следующий вариант данной функции предваряет подробный многострочный
комментарий, разъясняющий суть алгоритма, а "внутренние" построчные
комментарии описывают отдельные строки кода, которые могут показаться непонятными.
/*
* Реализация алгоритма "сортировка методом вставок".
* Этот алгоритм разбивает массив на две части:
* отсортированную и неотсортированную. Начиная с позиции 1,
* оценивается каждый элемент массива. Все предыдущие
* элементы массива относятся к отсортированной части,
* поэтому алгоритм сдвигает каждый элемент до тех пор, пока
* для текущего элемента не будет найдена корректная позиция.
* Когда алгоритм завершит обработку последнего элемента,
* весь массив окажется отсортированным.
*/
void sort(int inArray[], int inSize)
{
// Начинаем с позиции 1 и оцениваем каждый элемент.
for (int i = 1; i < inSize; i++) {
int element = inArrayfi];
// Переменная j отмечает позицию в отсортированной
// части массива.
int j = i - 1 ,-
// До тех пор пока текущий слот в отсортированном
// массиве больше значения element, сдвигаем слот еще
// раз и возвращаемся назад.
178 Часть II. Пишем С++-код профессионально
while (j >= 0 && inArraytj] > element) {
inArray[j+l] = inArraytj] ;
j--;
}
// В данный момент текущая позиция в отсортированном
// массиве *не* больше значения переменной element,
// поэтому она становится его новой позицией.
inArrayfj+1] = element;
}
Как видите, код "вырос" в объеме, но теперь читателю, который не знаком с
алгоритмами сортировки, гораздо легче разобраться в нем благодаря наличию комментариев.
В некоторых организациях внутритекстовые комментарии не приветствуются. В таких
случаях код оставляют "голым" и включают подробные комментарии в начале функции.
Комментарии для выражения метаинформации
Еще одна причина для использования комментариев — предоставление
информации на более высоком уровне, чем та, которую несет в себе сам код. Эта метаинфор-
мация обычно включает сведения о создании кода, но не детализирует особенности
его поведения. Например, в вашей организации принято указывать автора каждого
метода. Метаинформацию можно также использовать для ссылки на внешние
документы или на другой код.
В следующем примере показано несколько составляющих раздела
метаинформации — имя автора файла, дата создания файла и номер версии. Этот код также
включает внутритекстовые комментарии, содержащие такие метаданные, как номер
ошибки," который соответствует строке кода, и напоминание пересмотреть возможную
проблему в последующем коде.
/*
* Автор: klep
* Дата: 040324
* Функция: PRD version 3, Feature 5.10
*/
int adjustVolume()
{
if (fUninitialized) {
throw UninitializedPlayerExceptionO ;
int newVol = getPlayer()->getOwner()
->getPreferredVolume();
if (newVol == -1) return -1; // Добавлено для устранения
// ошибки #142 - jsmith 040330.
setVolutne (newVol) ;
// ДОДЕЛАТЬ: а что, если функция setVolumeO генерирует
// исключение? - akshayr 040401
return newVol;
}
Какие комментарии применять— тоже не самый последний вопрос. Лучше
всего — обсудить в своей команде, комментарии какого типа будут более всего полезны
Глава 7. Кодируем стильно 179
для вашей группы и соответствовать внутренней политике компании. Например, если
один из членов группы для обозначения кода, который требует доработки, использует
комментарий "ДОДЕЛАТЬ", но никто больше не знает о таком соглашении, данный
код может остаться незамеченным.
Если в вашем коллективе решено использовать в качестве
комментариев метаинформацию, позаботьтесь о том, чтобы все программисты
включали в свои программы однотипную информацию, в противном
случае ваши файлы будут несовместимыми!
Стили комментариев
В различных организациях по-разному относятся к комментированию кода. В
некоторых фирмах "спускается сверху" определенный стиль оформления кода с тем,
чтобы все программы соответствовали общему стандарту, принятому для
документации. В других случаях решение о количестве и стиле комментариев каждый
программист принимает самостоятельно. Рассмотрим примеры, отображающие различные
подходы к комментированию кода.
Комментирование каждой строки
Чтобы вас не обвинили в недостатке документации, попробуйте вставлять
комментарий для каждой строки кода (чтоб мало не показалось). В этом случае вы
демонстрируете — для всего, что вы пишете, есть веская причина. В действительности
перебор с комментариями малоприятен, утомителен и по большому счету никому не
нужен. Рассмотрим, например, следующий код.
int result; // Объявляем целочисленную переменную для
// хранения результата.
result = doodad.getResult(); // Получаем результат для
// объекта doodad,
if (result % 2 == 0) { // Если результат делится на 2 без
// остатка ...,
logError(); // то регистрируем ошибку,
} else { //в противном случае ...
logSuccess(); // регистрируем успешное выполнение.
} // Конец блока if/else.
return (result); // Возвращаем результат.
Комментарии в этом коде описывают каждую строку как часть легко читаемой
повести. Это совершенно бесполезно, если учесть, что читатель имеет хотя бы базовые
знания C++. Такие комментарии не привносят никакой дополнительной информации
о коде. В частности, рассмотрим эту строку.
if (result % 2 == 0) { // Если результат делится на 2 без
// остатка ...,
Комментарий в этом случае представляет собой всего лишь перевод на русский язык
действия, выраженного кодом. Здесь ничего не говорится о том, почему программист
использовал оператор деления результата по модулю два. Было бы лучше
прокомментировать эту строку таким образом.
j if (result % 2 == 0) { // Если результат - четное число ...,
180 Часть П. Пишем С++-код профессионально
Новый комментарий, пусть даже очевидный для большинства программистов,
предоставляет все же дополнительную информацию о коде. Результат подвергается
делению по модулю 2, поскольку здесь необходимо проверить, является ли значение
переменной result четным.
Несмотря на раздражающую многословность и избыточность, перенасыщение
комментариями может оказаться полезным в случаях, если код очень труден для
понимания. В следующем примере также комментируется каждая строка, но эти
комментарии действительно полезны.
// Вызываем метод calculate() со значениями, действующими
// по умолчанию.
result = doodad.calculate(getDefaultStart(),
getDefaultEndO ,
getDef aultOff set () ) ,-
// Чтобы определить результат вызова метода (успех или
// неудача), необходимо к результату применить операцию
// поразрядного "И" с использованием специальной (для
// данного процессора) маски (см. документацию, стр. 201).
result = result & getProcessorMask();
// Устанавливаем значение поля пользователя на основе
// формулы Меригольда (Marigold).
setUserField( (result + kMarigoldOffset) / MarigoldConstant
+ MarigoldConstant );
Приведенный выше код вырван из контекста, но комментарии позволяют понять,
что делается в каждой строке. Без них было бы трудно расшифровать вычисления,
включающие оператор "&" и загадочную "формулу Меригольда".
Обычно комментирование каждой строки излишне, но, если код
действительно сложен для понимания, не стоит "переводить" его на
родной язык, а лучше объясните, что на самом деле там происходит
и почему вы используете те или иные операторы или формулы.
Префиксные комментарии
Иногда в организациях принято начинать все исходные файлы со стандартного
комментария. Это — прекрасная возможность предоставить потенциальному
читателю важную информацию о программе и о конкретном файле. Сведения, включаемые
в начало каждого файла, могут состоять из таких разделов:
□ имя файла/класса;
□ дата последней модификации;
□ автор исходной версии;
□ идентификационный номер (ID) функции, представленной в файле;
□ информация об авторском праве;
□ краткое описание файла/класса;
□ незавершенные элементы;
□ выявленные ошибки.
Глава 7. Кодируем стильно 181
Возможно, ваша среда разработки позволяет создать шаблон, с помощью которого
новые файлы будут автоматически содержать заготовки префиксных комментариев.
Некоторые системы управления исходным кодом (например, Concurrent Versions
System— CVS) оказывают даже помощь в заполнении метаданных. Например, если
ваши комментарии содержат строку $Id$, система CVS автоматически включит в
качестве комментария информацию об авторе, имени файла, редакции (версии) и дате.
Пример префиксного комментария.
/*
* Watermelon.срр
*
* $Id: Watermelon.срр,v 1.6 2004/03/10 12:52:33 klep Exp $
*
* Реализует основные функции класса Watermelon. Все единицы
* выражены в семенах на кубический сантиметр. Теория арбуза
* основана на материалах докладов "Алгоритмы арбузной
* обработки" .
*
* The following code is (c)opyright 2004, FruitSoft, Inc.
* ALL RIGHTS RESERVED
Комментарии фиксированного формата
Представление комментариев в стандартном формате, который может быть
обработан внешними генераторами документов, — практика, которая становится все
более и более популярной. В языке Java программисты могут писать комментарии в
стандартном формате, который позволяет такому инструментальному средству, как
JavaDoc, автоматически создавать документацию в гипертекстовой системе. Для C++
существует бесплатно распространяемое средство Doxygen (оно доступно по адресу:
www.doxygen.org), которое преобразует комментарии в автоматически генерируемую
HTML-документацию, диаграммы классов, оперативные UNIX-страницы руководства
(гипертекстовые страницы консультативной информации, поясняющие действие
конкретных команд) и другие полезные документы. Doxygen распознает и
обрабатывает даже JavaDoc-комментарии в С++-программах. В следующем примере
используются JavaDoc-комментарии, распознаваемые средством Doxygen.
/**
* Реализует основные функции класса Watermelon.
*
* ДОДЕЛАТЬ: Реализовать усовершенствованные алгоритмы!
*/
class Watermelon
{
public:
* ©param initialSeeds Начальное количество семян
Watermelon (mt initialSeeds) ,- r
/**
* Вычисляет соотношение семян на основе алгоритма
* Меригольда (Marigold).
*
* ©param slowCalc Признак полных (сокращенных) вычислений.
* ©return Коэффициент Меригольда
*/
double calcSeedRatio(bool slowCalc);
};
182 Часть II. Пишем С++-код профессионально
Средство Doxygen распознает С++-синтаксис и такие специальные директивы, как
©param и ©return, которые использует для генерирования текста документа. Пример
созданного средством Doxygen HTML-описания класса показан на рис. 7.1.
vlfJ The Watermelon Project: Watermelon class Reference - Moziita Fiteiox ШШШ
File Edit View Go Bookmarks Tools Help
j Main Page | Class List | File List | Class Members
Watermelon Class Reference
Implements the basic functionality of a watermelon. More...
# iixc 1 ude <Wa.t e rnxel otl . li>
List of all members.
Public Member Functions
Water melon (int initiaiSeedf)
double cal с Seed Rati о (boot slowCalc)
Computes the seed ratio, using the Marigold algorithm.
Detailed Description
TODO: Implement updated algorithms!
Definition at line 6 of file Watemteloii.lt.
Parameters:
irritiaiSeeds The starting number of seeds
Member Function Documentation
double Watermelen::caIcSeedRatio (hoolslowCalc )
Parameters:
slowCalc Whether or not to use long (slow) calculations
4
ttz
Constructor & Destructor Documentation
Watermelon::Watermeloit ( int initialSeeds )
Рис. 7.1
Автоматически сгенерированная документация (подобная той, что показана на рис. 7.1),
может оказаться полезной во время разработки, поскольку она позволяет
программистам быстро просмотреть описание классов и их взаимоотношений. Вы можете легко
настроить средство Doxygen для работы с таким стилем комментариев, который
принят в вашей организации.
Глава 7. Кодируем стильно 183
Специальные комментарии
Конечно же, чаще всего к комментариям прибегают по мере необходимости. Вот
некоторые рекомендации по применению комментариев к отдельным строкам кода.
□ Избегайте употреблять оскорбительные выражения или пренебрежительные
замечания. Ведь вы не знаете, кто в один прекрасный день заглянет в ваш код.
□ Шутливый тон, как правило, приемлем, но не мешает проконсультироваться
у менеджера.
□ По возможности указывайте номера (коды) ошибок или идентификационные
номера (ID) элементов программы.
□ Включите свою фамилию (и инициалы) и дату создания кода, если
предполагаете, что в будущем кто-нибудь захочет разобраться с вашей помощью в коде.
□ Не поддавайтесь искушению включить в комментарий чужие координаты,
чтобы избежать ответственности за код.
□ Не забывайте обновлять комментарии при модификации кода. Ничего нет хуже
кода, который сопровождается некорректными комментариями!
□ Если вы используете комментарии, чтобы разбить функцию на разделы,
подумайте, а не стоит ли эту функцию раздробить на более мелкие функции.
Самодокументирование
Хорошо написанный код не всегда нуждается в пространных комментариях.
Хорошим считается код, который легко читается. Если вам кажется, что необходимо
прокомментировать каждую строку, то, возможно, нужно переписать сам код, чтобы он больше
соответствовал тому, что вы хотите выразить в комментариях. Помните, что C++ — это
язык. Его основное назначение — сообщить компьютеру, что сделать, при этом семантику
языка также можно использовать для разъяснения читателю значения его элементов.
В качестве примера возьмем реализацию функции копирования С-строки.
Следующий код не имеет комментариев, но и не нуждается в них.
void copyString(const char* inSource, char *outDest)
int position =0;
while (inSource[position] != '\0') {
outDest[position] = inSource[position];
position++;
}
outDest[position] = '\0';
}
Следующая реализация работает аналогичным образом, но стиль этого кода
слишком лаконичен, чтобы его смысл немедленно "дошел" до читателя. Несмотря на то
что эта реализация абсолютно корректна, очевидно, что она требует разъяснений.
void copyString(const char* inSource, char* outDest)
{
int i = 0;
while (outDest[i] = inSource [i++]);
outDest[i] = ' \0' ;
}
184 Часть II. Пишем С++-код профессионально
Есть еще один способ написания самодокументируемого кода. Речь идет о
разбиении, или декомпозиции, кода на более мелкие части. Декомпозиция подробно
описана в следующем разделе.
Хорошо написанный код должен читаться естественным образом,
а комментарии должны нести только полезную дополнительную
информацию.
Комментарии, применяемые в этой книге
Код, приведенный в этой книге, часто содержит комментарии, разъясняющие
сложные ситуации или неочевидные действия. Ради экономии места мы, как правило,
опускаем префиксные (начальные) комментарии и комментарии в фиксированном
формате, но искренне приветствуем их использование в профессиональных С++-проектах.
Декомпозиция
Декомпозиция — это практика разбиения кода на более мелкие части. В
программировании ничто не приводит так в уныние, как файл, состоящий из объемистых
функций (длиной эдак под 300 строк) и "монументальных" вложенных блоков кода.
В идеале каждая функция или метод должен выполнять одну задачу. Любые подзадачи
следует разбивать на отдельные функции или методы. Например, если кто-то вас
спросит о том, что делает данный метод, и вы ответите примерно так: "Сначала он
делает действие А, потом — В; затем при условии С он выполнит D; в противном
случае — Е", то вам, по всей видимости, нужно создать вспомогательные методы для
выполнения действий А, В, С, D и Е.
Декомпозиция— это не точная наука. Некоторые программисты считают, что
размер функции не должен превышать страницы кода в распечатанном виде. С таким
правилом, наверное, можно согласиться, но всегда найдется фрагмент кода в
четверть страницы, который обязательно потребует декомпозиции. Поэтому существует
и другое правило представления кода: если приходится, напрягая зрение,
вглядываться в код и разбираться в его формате, не понимая его содержимого, значит, плотность
его размещения на бумаге (экране) слишком велика (независимо от размера кода).
Например, на рис. 7.2 и 7.3 показан код, который намеренно подан в расплывчатом
виде, чтобы вы не могли прочитать его содержимое. Но очевидно то, что код,
представленный на рис. 7.3, лучше декомпозирован, чем код на рис. 7.2.
Декомпозиция посредством переделки
Бывают ситуации, когда нужно запрограммировать задачу, которая кажется
пустяковой, и вы действительно быстро пишете программу, которая работает, "как доктор
прописал". Вам не хочется сбавлять темп, "отвлекаясь" на вопросы эстетического
свойства. У всех программистов время от времени бывают такие "полеты" мысли.
Короткие периоды "решительного" программирования оказываются порой самыми
продуктивными в процессе создания проекта.
"Густой" код также возникает при модификации программ. По мере того как
возникают новые требования, выясняются недоразумения и исправляются ошибки, код
совершенствуется, "обрастая" небольшими вставками и изменениями. Компьютерный
Глава 7. Кодируем стильно 185
■в J**
Рис. 7.2 Рис. 7.3
термин "хлам" (cruft) означает постепенное накопление коротких фрагментов кода,
которые в конце концов превращают когда-то элегантную программу в хитромудрое
сплетение заплат и "специальных случаев".
Если ваш код сразу начинает свою жизнь как "густой" блок нечитабельного "хлама"
или он таким образом эволюционирует, то для очищения кода от накопившихся
"шлаков" необходимо периодически проводить сеансы переделок. Решившись на
переделку, вы оцениваете существующий код и переписываете его так, чтобы он стал
читабельным и поддерживаемым, т.е. делаете из него "конфетку". Переделка— это шанс
пересмотреть код и выполнить его декомпозицию. Если назначение программы
немного изменилось или если она еще ни разу не подвергалась декомпозиции, то при
переделке необходимо определить, нуждается ли код в разбиении на более мелкие части.
Декомпозиция посредством нисходящего проектирования
Декомпозиция — это удобство "отсроченных дел". Если вы пишете код, выполняя
декомпозицию своего кода с самого начала, то трудные части можно отложить на
"потом". Такой стиль кодирования, часто называемый нисходящим проектированием,
требует высокоуровневого взгляда на программу и последующего перехода к более
конкретным ее частям.
Например, используя нисходящее проектирование, вы могли бы немедленно
создать скелет программы, которая имитирует тропический циклон или ураган. В
следующем коде показана возможная реализация функции main () такой программы.
186 Часть II. Пишем С++-код профессионально
int main(int argc, char** argv)
{
cout << "Добро пожаловать в имитатор урагана!" << endl;
getUserlnputs();
performCalculations();
outputResults();
}
Применяя нисходящий подход, вы убиваете сразу двух "зайцев". Во-первых, вы
можете немедленно приступить к кодированию. Даже если данная программа не
заставляет вас исключить этап первоначального ее продумывания на высоком уровне,
написание нескольких фрагментов кода лишь поможет организовать ваши мысли. Во-
вторых, программа будет эволюционировать естественным образом в направлении
хорошо организованного процесса декомпозиции. Если вы считаете, что при
написании каждого метода или функции необходимо продумать, какие ее части можно
отложить на "потом", ваши программы будут выглядеть менее "густыми" и более
организованными, чем если бы вы в процессе кодирования сразу пытались реализовать
каждый элемент программы во всей его полноте.
Безусловно, мы по-прежнему придерживаемся стратегии первоначального
проектирования программы до ее кодирования. Однако нисходящий подход может
оказаться полезным при принятии решения о специальной реализации некоторой части
программы или при работе над небольшими проектами.
О декомпозиции в этой книге
Вы увидите декомпозицию во многих примерах этой книги. Довольно часто мы
ссылаемся на методы, реализация которых не показана, поскольку эти методы
несущественны для рассматриваемого примера и только "зря" бы занимали место в книге.
Присвоение имен
Вашему компьютеру безразлично, какие имена вы будете присваивать своим
переменным или функциям до тех пор, пока какое-нибудь имя не вызовет конфликт с
другой переменной или функцией. Имена существуют лишь для того, чтобы помочь
программистам использовать отдельные элементы программы. При этом иногда только
диву даешься, насколько часто программисты наделяют переменные безликими,
ничего не говорящими или несоответствующими их реальному назначению именами.
Выбор хорошего имени
Если имя переменной, метода, функции или класса точно описывает назначение
этого программного элемента, значит, вы выбрали для него подходящее имя. Имена
также могут нести дополнительную информацию, например, о типе или специальном
способе применения. Но реально проверку того, насколько удачно вы выбрали имя
для того или иного элемента программы, пройдут только те из них, которые позволят
другим программистам понять, что вы хотели выразить с их помощью.
Не существует общих для всех правил для присваивания имен — в разных
организациях они устанавливаются по-разному. И все же можно говорить о некоторой
практике, которая выработалась с годами и которая рекомендуется к применению и в
дальнейшем. В следующей таблице показаны некоторые имена, которые характеризуют
две крайности в области присваивания имен.
Глава 7. Кодируем стильно 187
Удачные имена Неудачные имена
srcName, dstName (показано различие между thingi, thing2 (слишком общие имена)
двумя объектами)
gSettings (выражен глобальный статус) globalUserSpecif icSettingsAndPref
erences (слишком длинное имя)
mNameCounter (выражен статус члена данных) mNC (слишком неясное и сокращенное имя)
perf ormcalculations () (простое и точное имя) doAct ion () (слишком общее
и неопределенное имя)
mTypeString (вполне читабельное имя) _typeSTR256 (такое имя может
понравиться только компьютеру)
mWelshRarebit (милая шутка с намеком miHateLarry (неуместная шутка
на гренки с сыром) с выражением неприязни)
Соглашение о присвоении имен
Выбор имени не всегда требует долгих раздумий и творческих мук. Во многих
случаях используется стандартный способ, который прекрасно подходит для описанных
ниже типов данных.
Счетчики
Возможно, вам приходилось рассматривать программы, в которых в качестве
счетчика использовалась переменная с более чем лаконичным именем "i". Очень
часто можно увидеть, что роль счетчиков внешнего и внутреннего циклов доверена
переменным i и j соответственно. Однако с вложенными циклами нужно обращаться
очень осторожно. Распространенная ошибка таких программ заключается в ссылке на
i-й элемент, в то время как имеется в виду j-й. Поэтому опытные программисты
предпочитают вместо просто имен i и j использовать описательные имена outer-
Looplndex и irmerLoopIndex.
Средства доступа к членам данных s
Если ваш класс содержит член данных (например, mStatus), то доступ к нему
обычно обеспечивается с помощью специальных методов, которые для данного
примера удобно назвать getStatus () и setStatus (). В языке C++ нет правил,
предписывающих, как следует называть такие методы, но во многих организациях
существуют подобные правила или рекомендации.
Префиксы
Многие программисты начинают имена переменных с буквы, которая несет в себе
некоторую информацию о типе переменной или ее характере применения.
Распространенные префиксы описаны в следующей таблице.
Префикс Пример Значение Применение
m mData "member" Член данных класса. Одни
_ data программисты в качестве индикатора
членства используют символ
подчеркивания (_), другие — символ
т, считая его более читабельным
188 Часть II. Пишем С++-код профессионально
Окончание таблицы
Префикс
Пример
Значение
Применение
sLookupTable "static"
n
mNum
kMaximumLength
fCompleted
nLines
mNumLines
tmp
tmpName
"konstant" (решайте сами,
что здесь: либо ужасная
орфографическая
ошибка, либо немецкий
вариант слова "constant")
"flag"
"number"
"temporary"
Статическая переменная или
статический член данных.
Используется для переменных,
существующих "в единственном
экземпляре"
Означает константу. Некоторые
программисты для констант
используют имена, полностью
состоящие из прописных букв
Булево значение. Используется
в основном для индикации
"да"/"нет"-свойства класса, которое
в зависимости от этого значения
модифицирует поведение объекта
Член данных, который служит
в качестве счетчика. Поскольку
буква "п" внешне похожа на букву
"т", некоторые программисты
в качестве префикса вместо "п"
используют более определенный
"mNum", как в имени mNumLines
Означает, что данная переменная
используется только для временного
хранения значения, на которое
предположительно не должен
опираться последующий код
Применение прописных букв
Существует множество различных способов применения прописных букв в именах
программы. Важно, чтобы в вашей организации (команде) был принят единый
подход, обязательный для применения всеми членами вашей группы. Ведь нет ничего
хорошего в том, если одни программисты будут использовать в именах классов только
строчные буквы и символ подчеркивания в качестве разделителя слов (например,
priorityqueue), а другие станут начинать составные части имени с прописных
букв (например, PriorityQueue). Переменные и члены данных почти всегда
начинаются со строчной буквы, а в остальном чаще всего используются два варианта: либо
с символом подчеркивания в качестве разделителя слов (myqueue), либо с
обозначением составных частей имени прописными буквами (myQueue). Имена функций
и методов часто начинаются в C++ с прописных букв, но, как вы видели, в этой книге мы
предпочитаем для них использовать только строчные буквы, чтобы отличить имена
функций и методов от имен классов. Для обозначения границ слов в именах классов
и их членов данных мы приняли аналогичный стиль применения прописных букв.
Глава 7. Кодируем стильно 189
Интеллектуальные константы
Представьте, что вы пишете программу с использованием графического
интерфейса пользователя. Программа отображает несколько меню: File (Файл), Edit (Правка)
и Help (Справка). Чтобы представить идентификационный номер (ID) каждого меню,
молено использовать константу. Тогда вполне подходящим именем для константы,
соответствующей ID меню Help, будет kHelp.
Имя kHelp будет исправно работать до тех пор, пока в один прекрасный день вы
не добавите в главное окно кнопку Help. Вам также понадобится ввести константу для
ссылки на ID кнопки, но имя kHelp уже окажется занятым.
Эту проблему можно решить несколькими способами. Один из них— поместить
две константы в разные пространства имен, которые рассмотрены в главе 1. Однако
пространства имен — слишком серьезное средство для решения небольшой
проблемы конфликта одного имени между двумя константами. Конфликт имен можно легко
решить путем переименования обеих констант, назвав их kHelpMenu и kHelpButton.
Но разумнее было бы присвоить им имена, состоящие из тех же частей, но
составленные в обратном порядке: kMenuHelp и kButtonHelp.
"Инвертированные" имена поначалу кажутся неудобными для произношения.
Однако такой вариант содержит ряд достоинств. Во-первых, в списке всех констант,
который строится в алфавитном порядке, константы, относящиеся к меню, будут
собраны вместе. А если в вашей среде разработки есть средство автоматического
завершения вводимых слов, то оно в этом случае также позволит сэкономить время.
Во-вторых, "инвертированный" вариант дает возможность создать простую
иерархию имен. Вместо пространств имен как именованных областей видимости,
использование которых может оказаться довольно обременительным, их роль (в более
локальном масштабе) может сыграть часть имени. При этом можно даже расширять
иерархию, ссылаясь по такой системе на отдельные элементы меню, например,
в рамках меню Help на элемент kMenuFileSave.
Венгерская нотация
Под венгерской нотацией понимается соглашение о присваивании имен
переменным и членам данных, которое популярно среди программистов Microsoft
Windows. Основная идея этого соглашения состоит в том, что вместо однобуквен-
ного префикса (например, т) вы должны использовать более расширенные
приставки, несущие в себе дополнительную информацию. Рассмотрим пример кода
с использованием венгерской нотации.
char* pszName,- // psz означает "pointer to a null-terminated
// string" (т.е. указатель на строку с
// завершающим нулем)
Происхождение термина "венгерская нотация" связано с тем, что его
изобретатель, Карл Симони (Charles Simonyi), по национальности венгр. Некоторые
программисты утверждают, что этот термин в точности отражает впечатление, которое
производят на них программы, использующие венгерскую нотацию. Им кажется что код
в этом случае выглядит так, будто он написан на иностранном языке. Именно поэтому
многие программисты не любят венгерскую нотацию. В данной книге мы используем
префиксы, которые не подчиняются венгерской нотации. Мы считаем, что адекватно
названные переменные не требуют дополнительной контекстной информации. По
нашему мнению, таким именем члена данных, как mName, сказано все, что нужно..
190 Часть II. Пишем С++-код профессионально
Удачные имена выражают смысл назначения неременных, не делая
код программы в целом нечитабельным.
Использование языковых средств
Язык C++ позволяет программисту вытворять бог знает что. Рассмотрим,
например, такой странный код.
i+ч- + ++i;
При всем могуществе языка C++ важно все-таки стремиться к тому, чтобы
языковые средства использовались не во вред, а во благо стилистике.
Применяйте константы
Плохо написанный код часто изобилует "магическими числами". Например, в
некоторой функции выполняется деление на 24. Почему на 24? Не потому ли, что в
сутках 24 часа? А, может быть, все дело в том, что средняя цена на сыр в городе Ныо-
Брансуик (США, штат Нью-Джерси) составляет 24 долл.? Чтобы исключить
впоследствии построение подобных гипотез, воспользуйтесь константами и присваивайте
значениям, которые не меняются в программе, символические имена.
const int kAveragePriceOfCheeselnNewBrunswick = 24;
Используйте преимущество const-переменных
Ключевое слово const в C++ программисты называют синтаксическим "сахаром"
(неформальный термин для элементов синтаксиса, которые более полезны для
программиста, чем для самой программы) для "неизменяемых переменных". Надлежащее
использование ключевого слова const больше относится к стилю, чем корректности
программирования. Некоторые (довольно опытные) С++-программисты утверждают,
что у них никогда не было веского основания для использования слова const, и это не
отразилось негативным образом на их карьере. Подобно многим элементам языка C++,
ключевое слово const призвано в большей степени помочь программисту, чем
программе. Вся ответственность за его использование лежит на плечах программиста.
Детали корректного применения слова const описаны в главе 12. Пока приведем лишь
прототип для функции, который сообщает инициатору вызова о том, что он не должен
изменять содержимое С-строки, передаваемой этой функции в качестве параметра.
void wontChangeString(const char* inString);
Замените указатели ссылками
Традиционно С++-программисты сначала изучают язык С. Если вы также пошли по
такому пути, то, вероятно, поняли, что ссылки в действительности не добавляют
в язык ничего нового. Они просто вводят новый синтаксис в функциональную нишу,
уже занимаемую указателями. В языке С указатели были единственным средством
обеспечения механизма передачи параметра по ссылке и прекрасно работали в течение
многих лет. В некоторых случаях указатели востребованы и сейчас, но в большинстве
ситуаций их можно заменить ссылками.
Глава 7. Кодируем стильно 191
У ссылок есть ряд преимуществ перед указателями. Во-первых, ссылки безопаснее
указателей, поскольку они не контактируют непосредственно с адресами памяти и не
могут принимать значение NULL. Во-вторых, ссылки стилистически более приятны, чем
указатели, так как они используют такой же синтаксис, как и стековые переменные,
в котором не принимают участие символы "*" и "&". Кроме того, они просты в
применении, поэтому вы не должны сомневаться в добавлении ссылок на вашу стилевую палитру.
Еще одно достоинство ссылок заключается в том, что они проясняют вопросы,
связанные с правами собственности на память. Если вы пишете метод, а другой
программист передает вам ссылку на объект, то ясно, что вы можете читать и
модифицировать этот объект, но это не позволяет вам так просто освободить занимаемую им
память. Если бы вам передавался указатель, этот вопрос был бы менее ясным. Нужно
ли вам удалять объект, чтобы очистить память? Или это должен сделать инициатор
вызова метода? В своей рабочей группе вам необходимо договориться, как способ
передачи переменных методам и функциям будет связан с правами собственности на
память. Один из простых вариантов может состоять в следующем. Если функции
передается указатель, то именно она будет владеть памятью и поэтому должна
предпринять все необходимые действия по. ее очистке. Во всех остальных случаях
переменные должны передаваться как ссылки или копии.
Исходя из вышесказанного, по виду следующего прототипа функции нам ясно, что
ее параметр подлежит изменению, но, поскольку он является ссылкой, занимаемая
объектом память освобождаться не будет.
void changeMe(ChessBoard& outBoard);
Используйте собственные исключения
Язык C++ позволяет легко игнорировать исключительные ситуации. В синтаксисе
C++ нет средств, которые бы заставляли программиста иметь дело с исключениями,
и поэтому вполне можно писать программы, которые бы сохраняли
работоспособность при отказе отдельных ее элементов, используя такие традиционные
механизмы, как возврат функциями значений NULL или установка флага ошибки.
Исключения обеспечивают более совершенный механизм обработки ошибок, а
возможность создания собственных типов исключений позволяет приспособить этот
механизм к конкретным условиям. Например, тип исключения для Web-браузера мог бы
включать поля, которые определяют страницу, содержащую ошибку, состояние сети
в момент ее возникновения и дополнительную контекстную информацию.
Подробно С++-исключения описаны в главе 15.
В языке C++ предусмотрено множество средств, которые способны
помочь программисту. Главное — научиться использовать их во
благо, а не во вред стилю программирования.
Форматирование
Трудно поверить, но порой рушится дружба и рвутся отношения между
программистами из-за разных взглядов на вопросы форматирования. Когда-то в колледже один из
авторов этой книги так горячо спорил с сокурсниками по поводу использования
пробелов в инструкции if, что на шум сбежались люди в готовности утихомирить спорщиков.
192 Часть II. Пишем С++-код профессионально
Если в вашей организации приняты стандарты в отношении форматирования
кода, считайте, что вам повезло. Вам могут не нравиться эти стандарты, но по крайней
мере вы не полезете в драку с друзьями из-за какого-то несчастного пробела. Если
в вашей команде каждый оформляет свой код как ему вздумается, постарайтесь
относится к этому с пониманием. Ведь иногда это просто дело вгсуса, а иногда людям
вообще очень трудно работать в команде.
Договоритесь о размещении фигурных скобок
Возможно, чаще всего спорят о том, где размещать фигурные скобки,
ограничивающие блок кода. Обычно используется несколько вариантов. В этой книге мы
помещаем фигурные скобки на одной строке с предыдущей инструкцией, за
исключением имени функции, класса или метода. Приведем пример такого стиля.
void someFunction()
{
if (condition()) {
cout << "Условие было истинным." << endl;
} else {
cout << "Условие было ложным." << endl;
Этот стиль позволяет экономить "вертикальное пространство", показывая блоки
кода посредством отступов. Некоторые программисты возражают, что такая экономия
не отвечает требованиям реального программирования (особенно, если вам платят по
количеству строк кода!) Вот как выглядит более растянутый по вертикали стиль.
void someFunct ion()
{
if (condition())
{
cout << "Условие было истинным." << endl;
}
else
{
cout << "Условие было ложным." << endl;
Есть программисты, которые не считают нужным экономить пространство по
горизонтали. В этом случае код выглядит следующим образом.
void someFunction()
{
if (condition() )
{
cout << "Условие было истинным." << endl;
}
else
{
cout << "Условие было ложным." << endl;
Не желая получать гневные послания в свой адрес, мы не рекомендуем читателю
какой-то конкретный из приведенных стилей.
Глава 7. Кодируем стильно 193
Выбирая стиль для обозначения блоков кода, важно учесть, насколько
быстро при взгляде на код вы понимаете соответствие условий и блоков.
Договоритесь об использовании пробелов и круглых скобок
Форматирование отдельных строк кода также может стать яблоком раздора. Мы
не ратуем за применение какого-то определенного подхода, а просто хотим показать
стили, которые используются чаще всего.
В этой книге мы используем пробел после каждого ключевого слова и круглые
скобки для четкого понимания порядка выполнения операций.
if (i == 2) {
j = i + (k / tn) ;
}
В альтернативном варианте инструкция if стилистически оформляется подобно
имени функции, т.е. без пробела между ключевым словом и левой (открывающей)
круглой скобкой. Кроме того, круглые скобки часто опускаются, если они не имеют
семантической значимости.
if( i == 2 ) {
j = i + k / m,-
}
I
Различие между этими двумя вариантами незначительно, и поэтому читателю
решать, какой из них лучше, но мы не можем не обратить ваше внимание на то, что
if — ведь не функция!
Пробелы и табуляция
Использование пробелов и табуляции — не просто стилистическое предпочтение.
Если в вашей рабочей группе нет договоренности об использовании пробелов и
табуляции, то при совместной работе программистов возможны большие проблемы.
Наиболее очевидная из них может возникнуть в случае, когда, скажем, Алиса использует
четыре пробела для отступа кода, а Боб с той же целью — символ табуляции размером
в пять пробелов; и, если они работают с одним и тем же файлом, их совместный труд
никогда не будет выглядеть достойно. Положение ухудшится, когда Боб решит
переформатировать код с использованием символа табуляции одновременно с Алисой,
которая редактирует ту же программу; в этом случае многие системы управления
исходным кодом не смогут включить изменения, сделанные Алисой.
Большинство редакторов (но не все) позволяют настраивать параметры в
отношении пробелов и символов табуляции. Некоторые среды адаптируются к параметрам
форматирования считанного кода или сохраняют пробелы, даже если была
использована клавиша табуляции. Если вы работаете в гибкой среде разработки, у вас более
высокие шансы сработаться с другими программистами. Просто помните, что
пробелы и табуляция воспринимаются по-разному, поскольку применение табуляции в
разных средах может быть выражено различной длиной отступа, а пробел — это всегда
пробел. Поэтому мы рекомендуем использовать редактор, который всегда переводит
символ табуляции в четыре пробела.
194 Часть II. Пишем С++-код профессионально
Проблемы стилистической слаженности
Многие программисты начинают новый проект с заверения, что на этот раз они
сделают все правильно. Если переменная или параметр не подлежат изменению, они
непременно отметят их ключевым словом const. Всем переменным обязательно дадут
ясные, лаконичные и читабельные имена. Каждый разработчик будет помещать левую
фигурную скобку на следующей после инструкции строке и использовать стандартный
текстовый редактор с принятыми для всех соглашениями о пробелах и табуляции.
По ряду причин такой уровень стилистической согласованности выдержать
трудно. Например, программисты порой недостаточно осведомлены насчет применения
ключевого слова const. Зачастую вы заглядываете в старые программы или код
библиотечных функций, в которых модификатор const не использовался вообще.
Хороший программист при необходимости с помощью оператора constcast
временно отключает свойство const для переменных, а неопытный станет "раскручивать"
последовательность применения const-параметров и в конце концов ликвидирует
все const-предупреждения.
Иной раз стандартизация стиля направлена против индивидуальных вкусов и
наклонностей программиста. Возможно, корпоративная культура в вашей организации
не требует строгого подчинения стилевым правилам программирования. В таких
ситуациях необходимо все же решить, какие элементы кодирования действительно
нужно стандартизировать (например, имена переменных и параметры табуляции),
а какие можно оставить на усмотрение индивидуума (это может касаться
использования пробелов и стиля комментариев). Можно даже написать программы, которые
автоматически скорректируют стилевые "ошибки" или отметят стилистические
проблемы вместе с ошибками в коде.
Резюме
В языке C++ стилистические средства позволяют довольно широко трактовать
возможности их использования. В конечном счете любые стилевые соглашения
работают тогда, когда существуют требования по их соблюдению и когда они действительно
способствуют читабельности кода. Если вы работаете в команде, то стилевые вопросы
необходимо обсуждать в самом начале процесса наряду с вопросами выбора языка
программирования и инструментария.
Здесь необходимо понять, что стиль — очень важный аспект программирования.
Прежде чем делать свои программы доступными для других программистов, приучите
себя проверять свой код на предмет соблюдения стилевых моментов. Отдайте
должное хорошему стилю в коде, с которым вы взаимодействуете, и примите соглашения,
которые найдете полезными для себя и для своей организации.
Оттачиваем
мастерство
в использовании
классов и объектов
Являясь объектно-ориентированным языком, C++ предоставляет возможности для
использования объектов, а также для написания определений объектов, именуемых
классами. Конечно, вы можете писать программы на C++ без классов и объектов, но
в этом случае вы не воспользуетесь преимуществами самого фундаментального и
самого полезного аспекта языка. Писать С++-программы без классов — это все равно,
что посещать Париж и обедать в McDonald's! Чтобы эффективно применять классы
и объекты, необходимо понимать их синтаксис и возможности.
В главе 1 приведен базовый синтаксис определения класса. В главе 3 описан объектно-
ориентированный подход к программированию на C++, представлены специальные
стратегии проектирования классов и объектов. В этой главе мы рассмотрим основные
принципы использования классов и объектов, касающиеся написания классов,
определения методов, создания объектов с использованием памяти стека и "кучи", написания
конструкторов (в том числе конструкторов по умолчанию), списков инициализаторов
196 Часть II. Пишем C++—код профессионально
в конструкторах, конструкторов копии, деструкторов и операторов присваивания. Даже
если вы уже "на ты" с классами и объектами, все же бегло просмотрите материал этой
главы, чтобы не пропустить "изюминки", вкус которых вам, возможно, еще незнаком.
Начнем с примера
В этой и следующей главах мы будем использовать пример приложения,
работающего с электронными таблицами. Электронная таблица— это двумерная сетка
"ячеек", в каждой из которых может содержаться число или строка. Такие
профессиональные электронные таблицы, как Microsoft Excel, позволяют выполнять
различные математические операции, например, вычислить сумму значений, содержащихся
в заданном наборе ячеек. Своим примером в этих главах мы не пытаемся бросать
вызов компании Microsoft на мировом рынке, просто этот пример весьма полезен для
демонстрации средств создания и использования классов и объектов.
В нашем приложении табличных вычислений используется два основных класса:
Spreadsheet и SpreadsheetCell. Каждый объект класса Spreadsheet содержит
объекты класса SpreadsheetCell. Кроме того, для управления различными Spreadsheet-
объектами используется класс SpreadsheetApplication. В этой главе основное
внимание уделяется классу SpreadsheetCell, в главе 9— классам Spreadsheet
и SpreadsheetApplication.
В этой главе описано несколько различных версий класса SpreadsheetCell, которые не
всегда иллюстрируют "лучший" способ реализации некоторого аспекта в написании
класса. В частности, в первых примерах опущены важные средства, поскольку они на тот
момент еще не были описаны. Финальный же вариант полностью готов к применению.
Создание классов
В процессе написания класса программист определяет поведение в виде методов
(функций-членов), которые будут применяться к объектам данного класса, и свойства,
или члены данных, которые будут содержаться в каждом объекте.
Написание класса состоит из двух частей: определения самого класса и его методов.
Определение класса
Перед вами— первый вариант простого класса SpreadsheetCell, в котором
каждая ячейка может хранить только одно число.
// SpreadsheetCell.h
class SpreadsheetCell
{
public:
void setValue (double inValue) ,-
double getValue();
protected:
double mValue,-
};
Глава 8. Оттачиваем мастерство в использовании классов и объектов 197
Как упоминалось в главе 1, определение каждого класса начинается с ключевого
слова class и его имени. Определение класса представляет собой инструкцию C++,
поэтому оно должно оканчиваться точкой с запятой. Если вы забудете завершить
определение своего класса точкой с запятой, компилятор отреагирует на это
сообщением об ошибках, большинство из которых совершенно не будет иметь отношения к
реальной причине недоразумения.
Определения классов обычно помещаются в файл с именем ClassName . h, где
элемент ClassName означает имя класса.
Методы и члены данных класса
Следующие две строки, которые напоминают прототипы функций, объявляют
методы, обеспечивающие работу с этим классом.
void setValue(double inValue);
double getValue();
Строка, которая выглядит как объявление переменной, используется для
объявления члена данных для этого класса.
double mValue;
Каждый созданный объект будет содержать собственную переменную mValue.
Однако реализация методов общая для всех объектов. Классы могут содержать любое
количество методов и членов данных. При этом никакое имя члена данных не может
совпасть с именем метода.
Управление доступом к членам данных
Каждый метод или член данных класса является субъектом для одного из трех
спецификаторов доступа: public, protected или private. Спецификатор доступа
применяется ко всем следующим за ним объявлениям методов и членов данных до тех пор,
пока не встретится следующий спецификатор доступа. В классе Spreadsheet Се 11
к методам setValue () и getValue () применен спецификатор public, а к члену
данных mValue — спецификатор protected.
public:
void setValue(double inValue);
double getValue();
protected:
double mValue;
Для классов по умолчанию действует спецификатор доступа private: все
объявления методов и членов данных до первого спецификатора доступа имеют private-
спецификацию. Например, поставив спецификатор доступа public ниже объявления
метода setValue (), мы тем самым обеспечили для метода setValue ()
спецификацию private, а не public.
class SpreadsheetCe11
{
void setValue(double inValue); // здесь private-доступ
public:
double getValue();
protected:
double mValue,-
};
198 Часть II. Пишем C++—код профессионально
В C++ структуры (struct), подобно классам (class), могут иметь
методы. Единственное отличие между структурой и классом
заключается в том, что для структуры по умолчанию действует спецификатор
доступа public, а для класса — private.
Значения трех спецификаторов доступа резюмируются в следующей таблице.
Спецификатор
доступа
Значение
Применение
public
protected
private
К public-методу или public-члену
данных может обратиться (получить
доступ) любой код
К protected-методу или
protected-члену данных может
обратиться любой метод того же
класса или его подкласса (см. главу 10)
К private-методу или private-члену
данных могут обращаться только
методы того же класса.
Методы подкласса не могут получить
доступ к private-методам или
private-членам класса
Для методов, которые будут
использовать клиенты.
Для методов, которые обеспечивают
доступ к private- ИЛИ
protected- членам данных
Для "вспомогательных" методов,
которые не подлежат
использованию клиентами.
Для большинства членов данных
Только в случае, если
необходимо ограничить доступ
со стороны подклассов
Спецификаторы доступа имеют уровень класса, а не уровень
объектов, поэтому методы класса могут получать доступ к protected- или
private-методам и членам данных, принадлежащих любому объекту
этого класса.
Порядок следования объявлений
Методы, члены данных и спецификаторы управления доступом можно объявлять
в любом порядке: C++ не налагает в этом плане никаких ограничений. Более того,
спецификаторы доступа могут повторяться. Например, определение класса Spread-
sheetCell могло бы выглядеть таким образом.
class SpreadsheetСе11
{
public:
void setValue(double inValue)
protected:
double mValue;
public:
double getValue();
};
Глава 8. Оттачиваем мастерство в использовании классов и объектов 199
Но для ясности все же лучше группировать public-, protected- и private-
объявления, а в рамках этих групп объединять между собой методы и члены данных.
В этой книге для определений и спецификаторов доступа в классах мы
придерживаемся такого порядка.
class ClassName
{
public:
// объявления методов
// объявления членов
protected:
// объявления методов
// объявления членов
private:
// объявления методов
// объявления членов
};
Определение методов
Предыдущего определения класса Spreadsheet Се 11 вполне достаточно для
создания объектов класса. Но при попытке вызвать метод setValue () или getValue ()
компоновщик "пожалуется", что эти методы не определены. Дело в том, что наше
определение класса содержит не реализации методов, а лишь их прототипы. Для использования
автономной функции (как вы помните) необходимо написать как прототип, так и
определение, точно так же нужно поступить и с методом класса. При этом заметьте, что
определение класса должно предшествовать определениям его методов. Обычно определение
класса помещается в заголовочный файл, а определения методов — в исходный файл,
который включает этот заголовок с помощью директивы #include. Вот как могут
выглядеть определения этих двух методов класса SpreadsheetCell.
// SpreadsheetCell.epp
ttinclude "SpreadsheetCell.h"
void SpreadsheetCell::setValue(double inValue)
{
mValue = inValue;
}
double SpreadsheetCell::getValue0
{
return (mValue);
}
Обратите внимание на то, что имя каждого метода предваряется именем класса
и двумя знаками двоеточия.
void SpreadsheetCell::setValue(double value)
Символ ": :" называется оператором разрешения области видимости или оператором
разрешения контекста. Этим синтаксисом мы сообщаем компилятору о том, что данное
определение метода setValue () является частью класса SpreadsheetCell.
Обратите также внимание на то, что при определении метода спецификация доступа
повторно не указывается.
200 Часть II. Пишем C++—код профессионально
Доступ к членам данных
Большинство методов класса (например, setValue () и getValue ()) всегда
выполняются "от имени" конкретного объекта этого класса (исключение составляют
статические методы, о которых речь впереди). В теле метода можно получить доступ
ко всем членам данных класса, определенных для этого объекта. В предыдущем
определении метода setValue () следующая строка кода изменяет значение переменной
mValue для объекта, который вызывает этот метод.
mValue = inValue,-
Если метод setValue () вызывается для двух различных объектов, одна и та же
строка (выполняемая по одному разу для каждого объекта) изменяет переменную
mValue в двух различных объектах.
Вызов методов
Методы класса можно вызывать из любого метода того же класса. Рассмотрим,
например, расширение класса SpreadsheetCell. Реальные приложения табличных
вычислений позволяют хранить в ячейках текстовые данные и числа. При попытке
интерпретировать текстовое содержимое ячейки как число приложение попробует
преобразовать этот текст в численное значение. Если текст не представляет
действительное число, значение этой ячейки игнорируется. В этой программе строки, которые не
являются числами, генерируют нулевые значения ячеек. Приведем первый вариант
определения класса SpreadsheetCell, предназначенного для поддержки текстовых данных.
#include <string>
using std: :string,-
class SpreadsheetCell
{
public:
void setValue(double inValue);
double getValue();
void setString(string inString);
string getStringO ;
protected:
string doubleToString(double inValue);
double stringToDouble(string inString);
double mValue;
string mString;
};
Эта версия класса позволяет хранить как текстовое, так и числовое представление
данных. Если клиент устанавливает элемент данных как string-значение, оно
преобразуется в double-, a double- преобразуется в string-значение. Если текст не
является допустимым числом, его double-значение станет равным 0. Это определение
класса содержит два новых метода, предназначенных для установки и считывания
Глава 8. Оттачиваем мастерство в использовании классов и объектов 201
текстового представления ячейки таблицы, и два новых вспомогательных protected-
метода преобразования double- в string-значение и наоборот. Эти вспомогательные
методы используют строковые потоки, которые подробно описаны в главе 14. Теперь
приведем реализацию всех методов класса SpreadsheetCell.
#include "SpreadsheetCell.h"
#include <iostream>
#include <sstream>
using namespace std;
*
void SpreadsheetCell::setValue(double inValue)
{
mValue = inValue;
mString = doubleToString(mValue);
}
double SpreadsheetCell::getValue()
{
return (mValue)';
}
void SpreadsheetCell::setString(string inString)
{
mString = inString;
mValue = stringToDouble(mString);
}
string SpreadsheetCell::getString()
{
return (mString) ,-
}
string SpreadsheetCell::doubleToString(double inValue)
{
ostringstream ostr,-
ostr << inValue;
return (ostr.str());
}
double SpreadsheetCell::stringToDouble(string inString)
{
double temp;
istringstream istr(inString);
istr >> temp;
if (istr.fail() || !istr.eof()) {
return (0);
}
return (temp);
}
Обратите внимание на то, что каждый из методов установки вызывает для
выполнения преобразования соответствующий вспомогательный метод. Поэтому значения
переменных mValue и mString всегда допустимы.
202 Часть II. Пишем C++—код профессионально
Указатель this
При каждом вызове обычного метода в качестве "скрытого" первого параметра
с именем this передается указатель на объект, для которого он вызывается. Этот
указатель можно использовать для доступа к членам данных или для вызова меюдов. Ei о
также можно передавать другим методам или функциям. Кроме того, указатель this
иногда полезен для устранения неоднозначности имен. Например, вы могли бы
определить класс SpreadsheetCell таким образом, чтобы метод setValue() принимая
параметр с именем mValue, а не inValue. В этом случае определение метода setValue ()
выглядело бы так.
void SpreadsheetCell::setValue(double mValue)
{
mValue = mValue,- // неоднозначность!
mString = doubleToString (mValue) ;
}
Выделенная строка может лишь сбить с толку. Какая именно переменная mValue
имеется здесь в виду: та, что передана как параметр, или член данных объекта? Чтобы
устранить неоднозначность имен, можно использовать указатель this.
void SpreadsheetCell::setValue(double mValue)
{
this->mValue = mValue;
mString = doubleToString(this->mValue);
}
Но если бы вы следовали соглашению о присваивании имен, описанному в главе 7,
вам никогда бы не пришлось столкнуться с конфликтом такого рода.
Указатель this можно использовать также для вызова из некоторого метода (для
заданного объекта) функции, которая принимает указатель на этот объект.
Предположим, например, что вы пишете не метод, а автономную функцию print Cell ().
void printCell(SpreadsheetCell* inCellp)
{
cout << inCellp->getString() << endl;
}
Если вы хотите вызвать функцию printCell () из метода setValue (), то для того,
чтобы передать функции printCell () указатель на объект класса SpreadsheetCell,
для которого вызывается метод setValue (), необходимо передать функции
printCell () в качестве аргумента указатель this,
void SpreadsheetCell::setValue(double mValue)
{
this->mValue = mValue;
mString = doubleToString(this->mValue);
printCell (this) ,*
}
Глава 8. Оттачиваем мастерство в использовании классов и объектов 203
Использование объектов
Согласно предыдущему определению класс SpreadsheetCell состоит из двух
переменных (членов данных), четырех открытых (public) и двух защищенных (protected)
методов. Однако этот класс не создает никаких объектов; он просто задает их формат.
В известном смысле такой класс можно сравнить с архитектурными чертежами. Из
чертежа понятно, как будет выглядеть будущий дом, но создание чертежей не означает
строительство дома. Дома будут построены позже на основе готовых чертежей.
Аналогично в C++ на основе определения класса SpreadsheetCell можно
сконструировать SpreadsheetCell-объект, объявив переменную типа SpreadsheetCell.
Подобно тому как строитель по одному чертежу может построить не один, а несколько
зданий, так и программист может создать несколько Spreadsheet Cell-объектов,
опираясь на одно определение класса SpreadsheetCell. Существует два способа создания
и использования объектов: в стековой памяти и в области "кучи".
Объекты, создаваемые в стековой памяти
Рассмотрим код, при выполнении которого создаются и используются объекты
класса SpreadsheetCell в стековой памяти.
SpreadsheetCell myCell, anotherCell;
myCell.setValue(6) ;
anotherCell. setValue (myCell .getValue () ) ,-
cout << "ячейка 1: " « myCell.getValue() << endl;
cout << "ячейка 2: " << anotherCell .getValue () << endl,-
Создание объектов в этом случае подобно объявлению простых переменных, но
в качестве типа объекта используется имя класса. Символ " . " в таких выражениях, как
myCell. setValue (6), называется оператором "точка"; он позволяет вызывать методы
для объекта. Если бы в этом объекте были какие-нибудь открытые (public) члены
данных, к ним также можно было бы получить доступ с помощью оператора "точка".
Результаты выполнения этой программы таковы.
ячейка 1: 6
ячейка 2: б
Объекты, создаваемые в области "кучи"
Используя оператор new, можно также динамически выделять память для объектов.
SpreadsheetCell* myCellp = new SpreadsheetCell();
myCellp->setValue(3.7);
cout << "ячейка 1: " << myCellp->getValue() <<
" " << myCellp->getString() << endl;
delete myCellp;
При создании объекта в области "кучи" вызов его методов и доступ к его членам
данных осуществляется с помощью оператора "стрелка" (->). По своему действию
оператор "стрелка" равнозначен применению оператора разыменования (*) и
оператора "точка" (.). Другими словами, эти два оператора можно использовать вместо
одного оператора "стрелка", но это выглядит стилистически 1ромоздко.
SpreadsheetCell* myCellp = new SpreadsheetCell () ;
204 Часть II. Пишем C++—код профессионально
(*myCellp).setValue(3.7);
cout << "ячейка 1: " << (*myCellp).getValue() <<
" " « (*myCellp) .getStringO « endl;
delete myCellp;
Как вы помните, память, выделяемую для переменных в области "кучи",
необходимо освобождать. И объекты не составляют в этом смысле исключение. Поэтом)',
если объекты создавались в области "кучи", вьщеленную для них память нужно
освободить с помощью оператора delete.
Если объект создавался с использованием оператора new, то по
завершении работы с этим объектом занимаемую им память
необходимо освободить с помощью оператора delete.
Жизненные циклы объектов
Жизненный цикл объекта включает три события: создание, разрушение и
присваивание. Каждый объект создается, но не каждому из них "везет" на другие два
события. Важно понимать, как и когда объекты создаются, разрушаются и
присваиваются, а также, как можно управлять их поведением.
Создание объектов
Объекты создаются в момент их объявления (если это происходит в стековой
памяти) или при явном выделении памяти с использованием оператора new или new [ ].
Часто имеет смысл присваивать переменным некоторые начальные значения
в момент их объявления. Например, мы можем инициализировать целочисленные
переменные нулевыми значениями таким образом.
int х = 0, у = 0;
Аналогично можно инициализировать и объекты. Но для этого необходимо
объявить и написать специальный метод, именуемый конструктором, в котором можно
подготовить действия по инициализации объекта. И тогда при создании объекта
будет выполняться один из его конструкторов.
С++-программисты часто называют конструктор "ктором" (ctor).
Написание конструкторов
Вот как выглядит наша первая попытка добавить конструктор в класс Spread-
sheetCell.
class SpreadsheetCell
{
public:
SpreadsheetCell(double initialValue);
Глава 8. Оттачиваем мастерство в использовании классов и объектов 205
void set Value (double inValue);
double getValue();
void setString(string inString);
string getString();
protected:
string doubleToString(double inValue);
double stringToDouble(string inString);
double mValue;
string mString;
};
Обратите внимание на то, что имя конструктора совпадает с именем класса и ему
не предшествует тип возвращаемого значения. Это всегда справедливо для любых
конструкторов. Однако в необходимости предоставить реализацию конструкторы не
отличаются от обычных методов.
SpreadsheetCell::SpreadsheetCell(double initialValue)
{
setValue(initialValue);
}
Конструктор SpreadsheetCell () представляет собой метод класса SpreadsheetCell,
а в C++ перед именем метода часто требуется оператор разрешения контекста с
именем класса (SpreadsheetCell: :). Имя самого конструктора в данном случае также
SpreadsheetCell, поэтому получаем довольно забавное выражение: Spreadsheet-
Cell: : SpreadsheetCell. В самой же реализации конструктора здесь просто
вызывается метод setValue (), чтобы установить как числовое, так и текстовое
представление содержимого ячейки таблицы.
Использование конструкторов
При вызове конструктора создается объект, который инициализируется заданным
значением (значениями). Конструкторы могут использовать как стековую память, так
и область "кучи".
Конструкторы, использующие стековую память
При размещении объекта класса SpreadsheetCell в стековой памяти
используется конструктор, подобный следующему.
SpreadsheetCell myCell(5), anotherCell(4);
cout << "ячейка 1: " « myCell.getValue() << endl;
cout << "ячейка 2: " << anotherCell.getValue() << endl;
Обратите внимание на то, что конструктор SpreadsheetCell HE вызывается
явным образом. Например, не используйте такой синтаксис.
SpreadsheetCell myCell.SpreadsheetCell(5); // НЕ СКОМПИЛИРУЕТСЯ!
Аналогично конструктор нельзя вызывать и после создания объекта. Следующий
код также некорректен.
SpreadsheetCell myCell;
myCell.SpreadsheetCell(5); //HE СКОМПИЛИРУЕТСЯ!
206 Часть II. Пишем C++—код профессионально
Единственно корректный способ использования конструктора, создающего объект
в стековой памяти, выглядит так.
SpreadsheetCell myCell(5);
Конструкторы, использующие область памяти "кучи"
При динамическом выделении памяти для объекта класса SpreadsheetCell
конструктор используется таким образом.
SpreadsheetCell *myCellp = new SpreadsheetCell(5) ;
SpreadsheetCell *anotherCellp;
anotherCellp = new SpreadsheetCell(4);
delete anotherCellp;
Обратите внимание на то, что можно объявить указатель на Spreadsheet Cell-
объект, не вызывая сразу же конструктор. Этим динамическое создание объектов
отличается от создания объектов в стековой памяти, для которых конструктор
вызывается в момент объявления.
Не забывайте с помощью оператора delete удалять объекты, созданные
динамически с помощью оператора new!
Создание нескольких конструкторов
Класс может содержать несколько конструкторов. Все конструкторы имеют
одинаковые имена (совпадающие с именем класса), но аргументы различных
конструкторов должны отличаться количеством или типом.
В класс SpreadsheetCell имеет смысл включить два конструктора: один будет
принимать начальное double-значение, а второй — начальное string-значение. Вот
как выглядит определение этого класса с добавленным вторым конструктором.
class SpreadsheetCell
{
public:
SpreadsheetCell (double initialValue) ,-
SpreadsheetCell(string initialValue);
void setValue(double inValue);
double getValue();
void setstring(string inString);
string getStringO;
protected:
string doubleToString(double inValue);
double stringToDouble(string inString);
double mValue;
string mString,-
b
Теперь приведем реализацию второго конструктора.
SpreadsheetCell::SpreadsheetCell(string initialValue)
{
setstring(initialValue);
}
Глава 8. Оттачиваем мастерство в использовании классов и объектов 207
Рассмотрим код использования двух различных конструкторов класса.
SpreadsheetCell aThirdCell("test"); //Используется конструктор
// со string-аргументом.
SpreadsheetCell aFourthCell(4.4); // Используется конструктор
// с double-аргументом.
SpreadsheetCell* aThirdCellp =
new SpreadsheetCell("4.4"); // Используется
// конструктор со string-аргументом,
cout << "aThirdCell: " << aThirdCell.getValue() << endl;
cout << "aFourthCell: " << aFourthCell.getValue() << endl;
cout << "aThirdCellp: " << aThirdCellp->getValue() << endl;
delete aThirdCellp;
При создании нескольких конструкторов есть соблазн определить один
конструктор на основе другого. Например, вы могли бы попытаться организовать вызов double-
конструктора из тела string-конструктора.
SpreadsheetCell::SpreadsheetCell(string initialValue)
{
SpreadsheetCell(stringToDouble(initialValue));
}
Казалось бы, такой подход имеет смысл. Ведь не запрещено же вызывать обычные
методы класса из других методов. Этот код скомпилируется, скомпонуется и даже
заработает, но не так, как вы ожидали. При явном обращении к конструктору (в данном
случае класса SpreadsheetCell) в действительности будет создан временный
безымянный объект (типа SpreadsheetCell). Другими словами, при выполнении такого кода
не вызывается конструктор для объекта, который вы собирались инициализировать.
Не пытайтесь вызывать один конструктор класса из другого.
Конструкторы по умолчанию
Конструктор, действующий по умолчанию, — это конструктор, который не принимает
аргументы. Он также называется конструктором с нулевым чиаюм аргументов. Используя
такое языковое средство, как конструктор по умолчанию, членам данных объекта
можно присвоить подходящие начальные значения, не задавая их в явном виде.
Рассмотрим часть определения класса SpreadsheetCell с конструктором по
умолчанию.
class SpreadsheetCell
{
public:
SpreadsheetCell();
SpreadsheetCell(double initialValue);
SpreadsheetCell(string initialValue);
// Остальная часть определения класса опущена.
};
Вот один из возможных вариантов реализации конструктора по умолчанию.
SpreadsheetCell::SpreadsheetCell ()
{
208 Часть II. Пишем C++—код профессионально
mValue = 0;
mString = "";
}
Для создания объектов в стековой памяти конструктор по умолчанию можно
использовать таким образом.
SpreadsheetCell myCell;
myCell.setValue(6);
cout << "ячейка 1: " << myCell.getValue() « endl;
При выполнении этого кода создается объект класса SpreadsheetCell с именем
myCell, устанавливается его значение, которое затем выводится на экран. Синтаксис
вызова конструкторов по умолчанию отличается от синтаксиса вызова обычных
функций (чего не скажешь о других конструкторах, использующих для создания
объектов стековую память). Опираясь на синтаксис вызова других конструкторов, вы могли
бы попытаться вызвать конструктор по умолчанию таким образом.
SpreadsheetCell myCell(); // НЕ ВЕРНО, однако скомпилируетея.
myCell.setValue(6); // Но эта строка не скомпилируется.
cout « "ячейка 1: " << myCell.getValue() << endl;
К сожалению, строка, в которой делается попытка вызвать конструктор по
умолчанию, скомпилируется. Зато не скомпилируется следующая строка кода. Дело в том,
что компилятор воспримет первую строку как объявление функции с именем myCell,
которая не принимает аргументы и возвращает объект типа SpreadsheetCell. При
обработке второй строки компилятор "подумает", что вы пытаетесь использовать
имя функции в качестве объекта!
При создании объекта в стековой памяти круглые скобки после
имени конструктора по умолчанию не используются.
Но при использовании конструктора по умолчанию для размещения объекта в
области "кучи" синтаксис, подобный синтаксису вызова функций (с круглыми
скобками), просто необходим.
SpreadsheetCell* myCellp = new SpreadsheetCell(); // Обратите
// внимание на наличие круглых скобок.
Не стоит тратить много времени на размышления о том, почему в C++ требуется
различный синтаксис для создания объектов конструкторами по умолчанию в
стековой памяти и в области "кучи". Это просто один из аспектов C++, которые делают
этот язык таким увлекательным и полезным для развития памяти программиста.
Конструктор по умолчанию, генерируемый компилятором
Если в вашем классе не определен конструктор по умолчанию, вы не сможете
создавать объекты этого класса, не задавая аргументы. Например, предположим, что
у вас есть следующее определение класса SpreadsheetCell.
Глава 8. Оттачиваем мастерство в использовании классов и объектов 209
class SpreadsheetCell
{
public:
SpreadsheetCell(double initialValue); // Нет конструктора
SpreadsheetCell(string initialValue); // по умолчанию.
void setValue(double inValue);
double getValue();
void setString(string inString);
string getString();
protected:
string doubleToString(double inValue);
double stringToDouble(string inString);
double mValue;
string mString;
};
С учетом предыдущего определения следующий код не скомпилируется.
SpreadsheetCell myCell;
rayCell.setValue(б) ;
/ Но ведь обычно такой код работает! Что же здесь не так? Все верно: посколысу
конструктор по умолчанию не был объявлен, нельзя создавать объект, не задавая аргументы.
Вопрос в том, почему такой код обычно работает. Оказывается, если в классе не
определен ни один конструктор, компилятор за вас сам "напишет" конструктор,
который не принимает аргументы. Этот генерируемый компилятором конструктор по
умолчанию вызывается для всех объектных членов класса, но не инициализирует
такие языковые примитивы, как int- и double-переменные. И все же он позволяет
создавать объекты класса.
Но если вы объявите конструктор по умолчанию или любой другой конструктор,
компилятор не станет ничего генерировать за вас.
Конструктор по умолчанию — это конструктор, который не принимает
аргументы. Термин "конструктор по умолчанию" относится не только
к конструктору, который автоматически генерируется
компилятором в случае, если в классе не определено ни одного конструктора.
Когда нужен конструктор по умолчанию
Рассмотрим массив объектов. При создании массива объектов выполняется две
задачи: для всех объектов выделяется непрерывная область памяти, а затем для каждого
из них вызывается конструктор по умолчанию. В C++ не предусмотрен синтаксис,
который позволял бы коду создания массива объектов напрямую сообщить о вызове
какого-нибудь иного конструктора. Например, если не определить конструктор по
умолчанию для класса SpreadsheetCell, следующий код не скомпилируется.
SpreadsheetCell cells [3] ; // НЕ СКОМПИЛИРУЕТСЯ без
// конструктора по умолчанию.
SpreadsheetCell* myCellp = new SpreadsheetCell[10]; // Также
//HE СКОМПИЛИРУЕТСЯ без
// конструктора по умолчанию.
210 Часть II. Пишем C++—код профессионально
Это ограничение можно обойти для массивов, создаваемых в стековой памяти,
путем использования инициализаторов.
SpreadsheetCell cells [3] = {SpreadsheetCell(0),
SpreadsheetCell(23) ,
SpreadsheetCell(41)};
Но если вы собираетесь создавать массивы объектов некоторого класса, проще
определить в нем конструктор по умолчанию.
Конструкторы по умолчанию также требуются при создании объектов внутри
других классов (см. следующий раздел, "Списки инициализаторов").
Наконец, конструкторы по умолчанию удобно использовать, когда класс в
некоторой иерархии наследования является базовым. В этом случае (с точки зрения
подклассов) удобно инициализировать суперклассы через их конструкторы по
умолчанию. Более подробно об этом написано в главе 10.
Списки инициализаторов
В C++ предусмотрен альтернативный способ инициализации членов данных в
конструкторе — с помощью списка инициализаторов. Вот как выглядит не принимающий
аргументов конструктор класса SpreadsheetCell, переписанный с использованием
синтаксиса списка инициализаторов.
SpreadsheetCell::SpreadsheetCell() : mValue(0), mString("")
Как видите, список инициализаторов размещается между списком аргументов
и открывающей фигурной скобкой, означающей начало тела конструктора.
Признаком начала списка служит двоеточие, а в качестве разделителя инициализаторов
используется запятая. Каждый элемент списка предназначен для инициализации
соответствующего члена данных и представлен в форме вызова функции или
конструктора суперкласса (см. главу 10).
Процесс инициализации членов данных с помощью списка инициализаторов
отличается от инициализации, задаваемой в самом теле конструктора. При создании
объекта в C++ должны быть созданы все члены данных объекта до вызова
конструктора. В процессе создания для членов данных, которые сами являются объектами,
должны быть вызваны конструкторы. В течение времени, когда происходит присвое- /
ние значения объекту в Чгеле конструктора, объект не создается, а лишь
модифицируется. Список инициализаторов позволяет установить членам данных начальные
значения в момент создания, что эффективнее, чем присвоение значений после
создания. Интересно отметить, что при инициализации по умолчанию string-
объектам присваиваются пустые строки; поэтому инициализация члена mString
пустой строкой (как показано в предыдущем примере) несет в себе избыточность.
Списки инициализаторов позволяют выполнить инициализацию
членов данных в момент их создания. ■
Даже если вы не озабочены вопросами эффективности, используйте списки
инициализаторов, если находите их запись для себя более ясной. Некоторые
программисты предпочитают использовать более привычный синтаксис присвоения начальных
Глава 8. Оттачиваем мастерство в использовании классов и объектов 211
значений в теле конструктора. Но все же для некоторых типов данных (см.
следующую таблицу) инициализация членов класса должна быть организована именно с
помощью списка инициализаторов.
Тип данных Разъяснение
const-члены данных Нельзя законно присвоить значение const-переменной
после ее создания. Она должна быть инициализирована
в момент создания
Ссылочные члены данных Ссылки не могут существовать, не ссылаясь на что-либо
Объектные члены данных, C++ попытается инициализировать объекты-члены данных,
для которых не существует используя конструктор по умолчанию. Если таковой
конструктора по умолчанию отсутствует, инициализация объекта невозможна
Суперклассы без См. главу 10
конструкторов по умолчанию
При использовании списков инициализаторов важно иметь в виду следующее: они
инициализируют члены данных в порядке их следования в определении класса, а не
в порядке, заданном самим списком инициализаторов. Предположим, например, что
вы переписали string-конструктор класса SpreadsheetCell, использующий список
инициализаторов, таким образом.
SpreadsheetCell::SpreadsheetCell(string initialValue) :
mString(initialValue), // НЕКОРРЕКТНЫЙ
mValue(stringToDouble(mString)) // ПОРЯДОК!
Этот код скомпилируется (хотя некоторые компиляторы могут выдать
предупреждение), но программа не будет работать корректно. Вы предположили, что член
данных raString будет инициализирован раньше члена mValue, поскольку в списке
инициализаторов raString стоит первым. Но C++ работает по-другому. В классе
SpreadsheetCell член данных mValue объявляется перед членом raString.
class SpreadsheetCell
{
public:
// Код опущен из экономии места.
protected:
// Код опущен из экономии места.
double mValue;
string mString;
};
Таким образом, согласно определению класса SpreadsheetCell будет сделана
попытка инициализировать член данных mValue до члена raString. Однако в коде
реализации string-конструктора для инициализации значения mValue C++
попытается использовать значение переменной mString, которое еще не
инициализировано! Решению этой проблемы в данном случае поможет использование вместо
аргумента raString значения initialValue. Кроме того, во избежание путаницы здесь
следует изменить порядок следования элементов в списке инициализаторов.
212 Часть II. Пишем C++—код профессионально
SpreadsheetCell::SpreadsheetCell(string initialValue) :
mValue(stringToDouble(initialValue)), mString(initialValue)
При использовании списка инициализаторов члены данных
инициализируются в порядке, зафиксированном в объявлении класса, а не
в порядке следования, указанном в списке инициализаторов.
Конструкторы копии
В C++ существует специальное средство, именуемое конструктором копии (сору
constructor), которое позволяет создавать объект, являющийся точной копией
другого объекта. Если вы не напишете конструктор копии сами, C++ сгенерирует его за вас.
Этот сгенерированный компилятором конструктор копии проинициализирует
каждый член данных в новом объекте, взяв за основу соответствующий член данных
исходного объекта. Для объектных членов данных эта инициализация означает, что
будут вызваны их конструкторы копии.
Рассмотрим объявление конструктора копии в классе SpreadsheetCell.
class SpreadsheetCell
{
public:
SpreadsheetCell() ;
SpreadsheetCell(double initialValue);
SpreadsheetCell(string initialValue);
SpreadsheetCell(const SpreadsheetCell& src);
void setValue(double inValue);
double getValue();
void setString(string inString);
string getString();
protected:
string doubleToString(double inValue);
double StringToDouble(string inString);
double mValue;
string mString,-
};
Конструктор копии принимает в качестве аргумента const-ссылку на исходный
объект. Подобно другим конструкторам конструктор копии не возвращает значение.
В его теле необходимо скопировать все поля данных из исходного объекта. Конечно,
формально в конструкторе копии можно организовать выполнение любых операций, но
все же лучше обеспечить поведение, которое ожидается от конструктора копии, и
инициализировать новый объект так, чтобы он стал безукоризненной копией старого.
Рассмотрим пример реализации конструктора копии для класса SpreadsheetCell.
SpreadsheetCell::SpreadsheetCell(const SpreadsheetCell& src) :
mValue(src.mValue), mString(src.mString)
{
}
Глава 8. Оттачиваем мастерство в использовании классов и объектов 213
Обратите внимание на использование списка инициализаторов. Различие в
способе установки значений с помощью списка инициализаторов и в теле конструктора
копии рассматривается ниже в разделе "Конструкторы копии и объектные члены".
Сгенерированный компилятором конструктор копии класса Spread-
sheetCell идентичен представленному выше. Поэтому для
простоты вы могли бы опустить явно заданный конструктор копии и
довериться сгенерированному автоматически. В главе 10 описаны некоторые
типы классов, для которых сгенерированный компилятором
конструктор копии неприемлем.
Когда вызывается конструктор копии
По умолчанию в C++ действует семантика передачи аргументов функциям по
значению (pass-by-value). Это означает, что функция или метод класса получает копию
переменной, а не саму переменную. Таким образом, при передаче объекта функции
или методу компилятор вызывает конструктор копии, чтобы инициализировать
новый объект. Например, вспомним, что определение метода setStringO ,
объявленного в классе SpreadsheetCell, выглядит так.
void SpreadsheetCell::setString(string inString)
{
mString = inString;
mValue = stringToDouble(mString);
}
Вспомните также, что в C++ string в действительности не встроенный тип, а класс.
При вызове методу setString () передается string-аргумент, после чего string-
параметр inString инициализируется путем обращения к конструктору копии класса
string. Аргументом, передаваемым этому конструктору копии, послужит строка,
переданная методу setStringO. В следующем примере string-конструктор копии
выполняется для объекта inString при вызове метода setStringO , а в качестве
параметра конструктора копии используется объект name.
SpreadsheetCell myCell;
string name = "Первый заголовок";
myCell.setString(name); // копирует значение объекта name
По завершении метода setString () объект inString разрушается. Поскольку он
был всего лишь копией объекта name, то объект name остается целым и невредимым.
Конструктор копии также вызывается, когда функция или метод класса возвращает
объект. И в этом случае компилятор с помощью конструктора копии создает временный
безымянный объект. Подробнее о "жизни" временных объектов повествует глава 17.
Явный вызов конструктора копии
Конструктор копии можно вызывать в явном виде. Зачастую валено иметь
возможность построения объекта как точной копии другого. Например, вам может
понадобиться копия уже существующего объекта класса SpreadsheetCell. В этом случае
используйте следующий код.
SpreadsheetCell myCell2 (4) ;
SpreadsheetCell anotherCell(myCell2); // У объекта anotherCell
// теперь значения членов данных
// такие же, как у объекта myCell2.
214 Часть П. Пишем C++—код профессионально
Передача объектов по ссылке
Чтобы избежать копирования объектов при передаче их функциям и методам,
можно объявить, что функция или метод принимает ссылку на объект. Передача объектов
по ссылке обычно более эффективна по сравнению с передачей объектов по значению,
поскольку в этом случае копируется только адрес объекта, а не все его содержимое.
Кроме того, передача объектов по ссылке позволяет избежать проблем с
динамическим выделением памяти для объектов, о чем мы подробнее поговорим в главе 9.
Передавайте объекты по const-ссылке, а не по значению.
Функция или метод, принимающий в качестве параметра ссылку на объект, может
изменить исходный объект. Если вы используете передачу по ссылке только из
соображений эффективности, эту ненужную вам возможность следует предотвратить
путем объявления ссылочного параметра-объекта с использованием модификатора
const. Вот как выглядит определение класса SpreadsheetCell, в котором string-
объекты передаются по const-ссылке.
class SpreadsheetCell
{
public:
SpreadsheetCell() ;
SpreadsheetCell(double initialValue);
SpreadsheetCell(const string& initialValue);
SpreadsheetCell(const SpreadsheetCell& src);
void setValue(double inValue);
double getValueO;
void setstring(const string& inString);
string getString();
protected:
string doubleToString(double inValue);
double stringToDouble(const string& inString);
double mValue;
string mString;
};
Рассмотрим реализацию метода set St ring (). Обратите внимание на то, что тело
метода осталось прежним; изменился лишь тип параметра.
void SpreadsheetCell::setstring(const strings inString)
{
mString = inString,-
mValue = stringToDouble(mString);
}
Методы класса SpreadsheetCell, которые возвращают объект типа string,
возвращают его по значению. Возвращать ссылку на член данных довольно рискованно,
Глава 8. Оттачиваем мастерство в использовании классов и объектов 215
поскольку ссылка действительна только до тех пор, пока "жив" сам объект. При
разрушении объекта ссылка становится недействительной. Но иногда, как будет
показано ниже в этой и последующих главах, возникают законные причины для возврата
ссылок на члены данных.
Итак, что мы знаем о генерируемых компилятором
конструкторах
Компилятор автоматически генерирует конструктор без аргументов и конструктор
копии для каждого класса. Однако конструкторы, которые программист определяет
самостоятельно, заменят "автоматические" в соответствии со следующими правилами.
Если определить..
..то компилятор
сгенерирует...
..и можно создать
объект-
Пример
[класс
без конструкторов]
Только конструктор
без аргументов
Только конструктор
копии
Конструктор
без аргументов.
Конструктор копии
Конструктор
копии
Ни одного
конструктора
Только конструктор
с одним или несколькими копии
аргументами
(но не конструктор копии)
Конструктор без
аргументов, а также
конструктор с одним или
несколькими аргументами
(но не конструктор копии)
Конструктор
Конструктор
копии
Без аргументов.
Как копию другого
объекта
Без аргументов.
Как копию другого
объекта
Теоретически как копию
другого объекта.
Практически объект
создать нельзя
С аргументами.
Как копию другого
объекта
Без аргументов.
С аргументами.
Как копию другого
объекта
SpreadsheetCell
cell,
SpreadsheetCell
myCell(cell) ,
SpreadsheetCell
cell,
SpreadsheetCe11
myCell(cell) ,
Без примера
SpreadsheetCell
cell (6) ;
SpreadsheetCell
myCell(cell);
SpreadsheetCell
cell;
SpreadsheetCell
myCell(5);
SpreadsheetCell
anCell(cell) ;
Обратите внимание на отсутствие "симметрии" между конструктором по
умолчанию и конструктором копии. Если явным образом не определить конструктор копии,
компилятор создаст его за вас. Но если определить хоть какой-нибудь конструктор,
компилятор не станет генерировать конструктор по умолчанию.
Разрушение объектов
При разрушении объекта происходят два события: вызывается метод деструктора
объекта и освобождается занимаемая им память. Деструктор предоставляет
программисту возможность выполнить такие очистительно-восстановительные работы для объекта,
как освобождение динамически выделенной памяти или закрытие файловых
дескрипторов. Если не объявить деструктор, компилятор "напишет" его за вас, что обеспечит
216 Часть II. Пишем С+н—код профессионально
выполнение рекурсивного процесса разрушения соответствующих членов объекта и
позволит корректно удалить объект. О том, как написать деструктор, читайте в главе 9.
Объекты, созданные в стековой памяти, разрушаются при выходе из области
видимости, т.е. по завершении текущей функции, метода или другого блока кода. Другими
словами, когда встречается закрывающая фигурная скобка, любые "стековые"
объекты, созданные в рамках этих фигурных скобок, разрушаются. Это поведение
демонстрируется на примере следующей программы.
int main(int argc, char** argv)
{
SpreadsheetCell myCell(5);
if (myCell.getValueO ==5) {
SpreadsheetCell anotherCell(6);
} //По завершении этого блока разрушается
// объект anotherCell.
cout << "Объект myCell: " << myCell.getValueO << endl;
return (0);
} // По завершении этого блока разрушается объект myCell.
Объекты, созданные в стековой памяти, разрушаются в порядке, обратном порядку,
в котором они были объявлены (и созданы). Например, в следующем фрагменте кода
объект myCell2 создается до объекта anotherCell2, поэтому объект anotherCell2
разрушается до объекта туСе112 (обратите внимание на то, что с помощью
открывающей фигурной скобки молено начать новый блок кода в любом месте программы).
{
SpreadsheetCell myCell2(4);
SpreadsheetCell anotherCell2(5); // Объект myCell2 создан
// до объекта anotherCell2.
} // Объект anotherCell2 разрушается до объекта myCell2.
Такой порядок применяется к объектам, которые являются членами данных
других объектов. Вспомните, что члены данных инициализируются в порядке их
объявления в классе. Таким образом, следуя правилу разрушения объектов в порядке,
обратном порядку их создания, объектные члены данных также разрушаются в порядке,
обратном порядку их объявления в классе.
Объекты, создаваемые в области "кучи", не разрушаются автоматически.
Программист должен сам позаботиться об удалении указателя на объект, чтобы вызвать его
деструктор и освободить память. Это поведение демонстрируется следующей программой.
int main(int argc, char** argv)
{
SpreadsheetCell* cellPtrl = new SpreadsheetCell(5);
SpreadsheetCell* cellPtr2 = new SpreadsheetCell(6);
cout << "cellPtrl: " << cellPtrl->getValue() << endl,-
delete cellPtrl; // Разрушается объект cellPtrl.
return (0);
} // Объект cellPtr2 HE разрушается, поскольку для него
// не был вызван оператор delete.
Глава 8. Оттачиваем мастерство в использовании классов и объектов 217
Присваивание объектов
Подобно тому как в C++ мы присваиваем значение одной int-переменной другой,
мы можем присвоить значение одного объекта другому. Например, при выполнении
следующего кода значение объекта туСе 11 присваивается объекту anotherCell:
SpreadsheetCell myCell(5), anotherCell;
anotherCell = myCell;
Если вы скажете, что мы "скопировали" объект myCell в объект anotherCell, то
это не вполне будет соответствовать истине. В C++ "копирование" происходит лишь
при инициализации объекта. Если объект уже имеет некоторое значение, которое мы
хотим перезаписать, то уместнее здесь употребить термин "присвоить". Обратите
внимание на то, что для копирования в C++ предусморено такое средство, как
конструктор копии. А поскольку речь идет о конструкторе, то его можно использовать
только для создания объектов, а не для последущих присваиваний.
Поэтому C++ позволяет в каждый класс включить еще один метод именно для
выполнения операций присваивания. Этот метод называется оператором присваивания.
Он имеет имя operator=, поскольку в действительности он перегружает оператор
"-" для класса. В приведенном выше примере оператор присваивания вызывается для
объекта another Се 11, а объект туСе 11 передается ему в качестве аргумента.
Если вы не напишете собственный оператор присваивания, C++ "напишет" его
за вас, чтобы вы могли выполнять присваивание объектов класса. Поведение С++-
оператора присваивания по умолчанию идентично поведению конструктора копии:
он рекурсивно присваивает каждому члену данных объекта-приемника значение
соответствующего члена данных объекта-источника. Однако синтаксис присваивания
не так уж прост.
Объявление оператора присваивания
Рассмотрим еще один вариант определения класса SpreadsheetCell, которое на
этот раз включает оператор присваивания.
class SpreadsheetCell
{
public:
SpreadsheetCell();
SpreadsheetCell(double initialValue);
SpreadsheetCell (const string& initialValue) ,-
SpreadsheetCell(const SpreadsheetCell &src);
SpreadsheetCell& operator=(const SpreadsheetCell& rhs);
void setValue(double inValue);
double getValue () ,-
void setString(const strings inString);
string getString();
protected:
string doubleToString(double inValue);
double stringToDouble(const string& inString);
double mValue;
string mString;
};
218 Часть II. Пишем C++—код профессионально
Оператор присваивания, подобно конструктору копии, принимает const-ссылку
на исходный объект (объект-источник). Исходным мы называем объект (в данном
случае это параметр rhs), который располагается с правой стороны (right-hand side) от
знака равенства. Объект, для которого вызывается оператор присваивания,
указывается слева (left-hand side) от знака равенства.
В отличие от конструктора копии, оператор присваивания возвращает ссылку на
объект класса SpreadsheetCell. Это вызвано необходимостью того, что
присваивания, как показано в следующем примере, могут быть объеденены в цепочку.
myCell = anotherCell = aThirdCell;
При выполнении этой строки кода сначала вызывается оператор присваивания
(вы помните, что оператор присваивания для класса— это метод, который может
вызываться.— Примеч. ред.) для объекта anotherCell, а в качестве параметра rhs ем)
передается ссылка на "правосторонний" объект aThirdCell. Затем вызьшается оператор
присваивания для объекта myCell. Однако параметром в этом случае будет не объект
anotherCell, а результат присваивания объекта aThirdCell объект)' anotherCell.
И если бы это (первое) присваивание не возвращало результат, то нам нечего было
бы передать для присвоения объект)' myCell!
Вам, возможно, интересно узнать, почему оператор присваивания для объекта myCell
не может просто принять объект anotherCell. Дело в том, что здесь знак равенства
в действительности представляет собой сокращенную запись реального вызова
метода operator = (). Если взглянуть на строку "во всей красе", т.е. с
полнофункциональным синтаксисом, то упомянутая проблема станет понятнее.
, myCell.operator=(anotherCell.operator=(aThirdCell));
Теперь вы видите, что метод operator= (), вызванный для объекта anotherCell,
должен возвращать значение, которое будет передано при вызове метода operator= ()
для объекта myCell. Вполне корректным возвращаемым значением мог бы быть сам
объект anotherCell, поэтому он может послужить источником для присваивания
объект)' myCell. Но возврат объекта anotherCell напрямую был бы
неэффективным, поэтому имеет смысл организовать возврат ссылки на объект anotherCell.
В действительности молено было бы объявить оператор
присваивания таким образом, чтобы он возвращал значение любого
подходящего для программиста типа данных, включая тип void. Однако в
таких случаях следует всегда обеспечивать возврат ссылки на объект,
для которого он вызывается, поскольку это и есть тот результат,
которого ожидают клиенты.
Определение оператора присваивания
Реализация оператора присваивания подобна реализации конструктора копии, но
с несколькими важными отличиями. Во-первых, конструктор копии вызывается
только при инициализации, поэтому объект приемника еще не имеет никаких
действительных значений. Оператор присваивания может перезаписать текущие значения
в объекте. Это предположение не реализуется до тех пор, пока вы не станете
динамически выделять память в своих объектах. Подробности — в главе 10.
Глава 8. Оттачиваем мастерство в использовании классов и объектов 219
Во-вторых, в C++ вполне законно присваивать объект самому себе. Например,
следующий код скомпилируется и выполнится.
SpreadsheetCell cell(4);
cell = cell; // самоприсваивание
Ваш оператор присваивания не должен запрещать самоприсваивание, но также и не
должен выполнять присваивание в полном объеме. Таким образом, в самом начале теле
методов, реализующих операторы присваивания, следует проверять наличие ситуации
самоприсваивания и немедленно обеспечивать возврат соответствующего значения.
Рассмотрим определение оператора присваивания для класса SpreadsheetCell.
SpreadsheetCell& SpreadsheetCell::operator=(
const SpreadsheetCell& rhs)
{
if (this == &rhs) {
При выполнении этой строки кода проверяется ситуация самоприсваивания, но
несколько в неявном виде. Самоприсваивание имеет место, когда объекты справа
и слева от знака равенства совпадают. Два объекта можно считать одинаковыми, если
они занимают одну и ту же область памяти, или, выражаясь точнее, если указатели на
них равны. Вспомните, что this — это указатель на объект, доступный из любого
метода, вызванного для этого объекта. Таким образом, this — это указатель на объект,
расположенный слева от знака операции. Аналогично &rhs — указатель на объект,
расположенный справа от знака операции. Если эти указатели равны, то
присваивание является самоприсваиванием.
return (*this);
}
Если this — это указатель на объект, для которого выполняется метод, то *this —
это сам объект. Компилятор, чтобы удовлетворить объявленный тип значения,
возвращаемого методом, обеспечит возврат ссылки на этот метод.
При выполнении этих строк кода метод копирует значения членов данных.
mValue = rhs.mValue;
mString = rhs.mString;
Наконец, как разъяснялось выше, он возвращает значение *this.
return (*this);
}
Синтаксис перегрузки метода operator= () может на первый взгляд показаться
странным. У вас, возможно, подобное ощущение неправильности уже возникало при
первом знакомстве с другими элементами синтаксиса C++. Определяя метод operator^ (),
вы в действительности изменяете значение оператора "=". К сожалению, мощные
средства требуют несколько необычного синтаксиса. Однако волноваться не стоит:
к этому довольно быстро привыкают!
220 Часть II. Пишем C++—код профессионально
Отличие копирования от присваивания
Иногда трудно сказать, когда выполняется операция инициализации объектов
с использованием конструктора копии, а когда — присваивание посредством
оператора присваивания. Рассмотрим следующий код.
SpreadsheetCell myCell(5);
SpreadsheetCell anotherCell(myCell);
Здесь объект AnotherCell создается с помощью конструктора копии.
SpreadsheetCell aThirdCell = myCell;
И здесь объект aThirdCell формируется с использованием конструктора копии,
не посредством вызова метода operator= (), как можно было бы предположить. Этот
синтаксис— просто еще один вариант записи такой инструкции: SpreadsheetCell
aThirdCell(myCell);.
anotherCell = myCell; // Здесь для объекта anotherCell
// вызывается метод operator=().
Если объект anotherCell уже создан, то компилятор вызывает метод operator = ().
Оператор "=" не всегда означает присваивание! Он также может быть
частью конструкции копирования при инициализации объекта
одновременно с его объявлением (т.е. в одной инструкции кода).
Объекты в качестве возвращаемых значений
Если функция или метод возвращает объект, то порой трудно точно определить,
что происходит: копирование или присваивание. Вспомните, как выглядит
реализация метода getString().
string SpreadsheetCell::getString()
{
return (mString);
}
Теперь рассмотрим следующий код.
SpreadsheetCell myCell2(5);
string si;
si = myCell2.getString();
Если метод getString () возвращает значение mString, компилятор в
действительности создает безымянный временный string-объект путем вызова string-
конструктора копии. При присваивании этого результата объекту si, вызывается
оператор присваивания для объекта si, а в качестве аргумента ему передается этот
временный string-объект, который затем разрушается. Таким образом, при
выполнении одной-единственной строки кода вызывается как конструктор копии, так
и оператор присваивания (для двух различных объектов).
Если вы еще не запутались окончательно, рассмотрим такой код.
SpreadsheetCell myCell3(5);
string s2 = myCell3.getString();
Глава 8. Оттачиваем мастерство в использовании классов и объектов 221
В этом случае метод getString (), возвращая объект mString, по-прежнему
создает безымянный временный string-объект. Но теперь для объекта s2 вызывается
только конструктор копии, а не оператор присваивания.
Если вы забудете порядок, в котором происходят эти вызовы, или забудете, что
именно вызывается (конструктор копии или оператор присваивания), это можно
легко узнать, временно включив в свой код индикаторные инструкции вывода данных
или использовав пошаговый режим отладчика.
Конструкторы копии и объектные члены класса
Необходимо также различать присваивание и копирование в конструкторах. Если
объект содержит другие объекты, то генерируемый компилятором конструктор копии
рукурсивно вызывает конструкторы копии для каждого из "внутренних" объектов. При
написании собственного конструктора копии аналогичную семантику можно
обеспечить, как показано выше, посредством списка инициализаторов. Если в списке
инициализаторов опустить некоторый член данных, до выполнения кода в теле конструктора
компилятор выполнит для него инициализацию по умолчанию (вызовет конструктор
без аргументов, если этот член данных— объект). Таким образом, к моменту
выполнения тела конструктора все объектные члены данных будут уже инициализированы.
Собственный конструктор копии можно написать, не используя список
инициализации.
SpreadsheetCell::SpreadsheetCell(const SpreadsheetCell& src)
{
mValue = src.mValue;
mString = src.mString;
}
Однако при присваивании значений членам данных в теле конструктора вы будете
использовать оператор присваивания, а не конструктор копии, поскольку, как
описано выше, все члены данных уже инициализированы.
Резюме
В этой главе рассмотрены основные аспекты С++-средств
объектно-ориентированного программирования: классы и объекты. Сначала мы познакомились с базовым
синтаксисом написания классов и использования объектов, включая управление
доступом к членам класса. А затем подробно остановились на этапах жизненного цикла
объектов (создании, разрушении и присваивании) и выяснили, какие методы
вызывают эти действия. В этой главе детально описан синтаксис конструкторов, включая
списки инициализаторов. Особое внимание здесь уделено тому, какие конструкторы
(и при каких условиях) компилятор "пишет" за программиста.
Для одних из вас эта глава была, вероятно, скорее обзорной, чем познавательной.
Другим, возможно, открыла глаза на мир объектно-ориентированного С++-програм-
мирования. В любом случае теперь, когда вы ближе познакомились с классами и
объектами, смело переходите к главе 9, чтобы больше узнать о тонкостях работы с ними.
Освоение классов
и объектов
Получив в главе 8 основные сведения о работе с классами и объектами, приступим
к освоению некоторых тонкостей, которые позволят использовать их возможности
во всей полноте. Прочитав эту главу, вы узнаете, как управлять самыми сложными
аспектами языка C++, чтобы создавать безопасные, эффективные и полезные классы.
Здесь мы подробно рассмотрим такие темы, как динамическое выделение памяти
в объектах, статические (static) методы и члены, const-методы и члены,
ссылочные члены класса, перегрузка методов, inline-методы, вложенные классы, "друзья",
перегрузка операторов, указатели на методы и члены класса, а также отделение ин-.
терфейса от реализации классов.
Многие темы этой главы относятся к более высокому уровню С++-программирования
и особенно касаются использования стандартной библиотеки шаблонов.
Динамическое выделение памяти в объектах
Иногда невозможно сказать, какой объем памяти понадобится вашей программе
до того, как она реально заработает. Как вы знаете, в этом случае следует обеспечить
динамическое выделение памяти необходимого объема уже во время работы
программы. Классы также не являются исключением. При их написании порой
неизвестно, как много памяти понадобится объектам. Нетрудно догадаться, что решение
и здесь лежит в динамическом выделении памяти.
Глава 9. Освоение классов и объектов 223
С динамическим выделением памяти в объектах сопряжено ряд проблем:
освобождение памяти, выполнение копирования объектов и обработка процесса
присваивания объектов.
Класс Spreadsheet
В главе 8 мы написали определение класса SpreadsheetCell, а здесь мы
переходим к поэтапному созданию класса Spreadsheet. Для начала определим класс
Spreadsheet как двумерный массив объектов типа SpreadsheetCells, оснащенный
методами установки и считывания содержимого ячеек в заданной позиции
Spreadsheet-таблицы. Несмотря на то что в большинстве программ составления
динамических электронных таблиц для ссылки на ячейку в одном направлении используются
буквы, а для в другом — числа, в нашей программе будут применяться числа в обоих
направлениях. Вот как выглядит первый вариант определения класса Spreadsheet.
// Spreadsheet.h
ttinclude "SpreadsheetCell.h"
class Spreadsheet
{
public:
Spreadsheet(int inWidth, int inHeight);
void setCellAt(int x, int y,
const SpreadsheetCells cell);
SpreadsheetCell getCellAt(int x, int y) ;
protected:
bool inRange(int val, int upper);
int mWidth, mHeight;
SpreadsheetCell** mCells,-
};
Обратите внимание на то, что класс Spreadsheet содержит не стандартное
объявление двумерного массива элементов типа SpreadsheetCells, а объявление
указателя SpreadsheetCell**. Дело в том, что каждый объект класса Spreadsheet может
иметь различные размерности, поэтому конструктор этого класса должен
динамически выделять память для двумерного массива на основе задаваемых клиентом
значений высоты и ширины (таблицы). Для того чтобы динамически выделить память для
двумерного массива, необходимо написать следующий код.
#include "Spreadsheet .h"
Spreadsheet::Spreadsheet(int inWidth, int inHeight) :
mWidth(inWidth), mHeight(inHeight)
{
mCells = new SpreadsheetCell* [mWidth];
for (int i = 0; i < mWidth; i++) {
mCells[i] = new SpreadsheetCell[mHeight];
Массив с именем si, созданный в стековой памяти для таблицы (объекта класса
Spreadsheet) размером 3x4, показан на рис. 9.1.
224 Часть II. Пишем С++-код профессионально
Стековая память
int mWidth
int mHeight
SpreadsheetCell** mCells
Spreadsheet s1
V
Куча"
\
\^
Каждый элемент —
неименованный указатель
SpreadsheetCell*
Каждый элемент —
неименованный объект
SpreadsheetCell
Рис. 9.1
Если в этом коде что-то непонятно для вас, обратитесь к главе 13, в которой дана
подробная информация по управлению памятью.
Реализация методов установки и считывания содержимого ячеек довольно проста.
void Spreadsheet::setCellAt(int x, int y,
const SpreadsheetCell& cell)
}
if (!inRange(x, mWidth)
return;
}
mCells[x][y] = cell;
| !inRange(y, mHeight)) {
SpreadsheetCell Spreadsheet::getCellAt(int x,' int y)
{
SpreadsheetCell empty;
if (!inRange(x, mWidth) || !inRange(y, mHeight)) {
return (empty);
}
}
return (mCells[x][y]);
Обратите внимание на то, что в этих двух методах используется вспомогательный
метод inRange (), который позволяет проверить, что значения х и у представляют
допустимые координаты таблицы. Попытка получить доступ к несуществующему
полю массива приведет к сбою программы. Эту проблему можно решить путем
механизма исключений, описанном в главе 15.
Освобождение памяти с помощью деструкторов
Поработав с динамически выделенной памятью, ее необходимо освободить. Если
память выделяется в объекте, то освобождать ее следует в деструкторе. Компилятор
гарантирует, что при разрушении объекта обязательно будет вызван деструктор. Вот
как выглядит определение класса Spreadsheet с деструктором.
Глава 9. Освоение классов и объектов 225
class Spreadsheet
{
public:
Spreadsheet(int inWidth, int inHeight);
-Spreadsheet();
void setCellAt(int x, int y,
const SpreadsheetCell& inCell);
SpreadsheetCell getCellAt(int x, int y);
protected:
bool inRange(int val, int upper);
int mWidth, mHeight;
SpreadsheetCell** mCells;
};
Имя деструктора совпадает с именем класса (и его конструкторов), но в этом
случае ему предшествует символ "тильда" (-). Деструктор не принимает аргументов, и (в
отличие от конструкторов) может быть только в единственном числе.
Рассмотрим реализацию деструктора класса Spreadsheet.
Spreadsheet::-Spreadsheet()
{
for (int i = 0; i < mWidth; i++) {
delete [] mCells [i] ;
}
delete [] mCells;
}
Этот деструктор освобождает память, которая была выделена в конструкторе. Не
существует правил, которые бы предписывали вам использовать деструктор лишь для
освобождения памяти. Вы можете включить в деструктор любой код, но все же лучше
использовать его по назначению, т.е. для освобождения памяти и других системных ресурсов.
Обработка операций копирования и присваивания
Вспомните (см. главу 8), что, если не создать конструктор копии и оператор
присваивания самостоятельно, С++-компилятор "напишет" их за вас. Эти сгенерированные
компилятором методы рекурсивно вызывают конструкторы копии и операторы
присваивания для объектных членов данных. Однако для таких примитивов, как int-, double-
члены и указатели, они обеспечивают побитовое копирование или присваивание, т.е.
напрямую копируют (или присваивают) члены данных исходного объекта в члены объекта-
приемника. Но если в объекте динамически выделяется память, такое поведение
проблематично. Например, следующий код копирует табличный объект si, чтобы
инициализировать параметр s при передаче аргумента si функции printSpreadsheet ().
#include "Spreadsheet.h"
void printSpreadsheet(Spreadsheet s)
{
// Код опущен ради экономии места.
}
int main(int argc, char** argv)
226 Часть П. Пишем С++-код профессионально
}
Spreadsheet si (4, 3)
printSpreadsheet(si)
return (0);
Класс Spreadsheet содержит одну переменную-указатель mCells. При побитовом
копировании Spreadsheet-объекта в объекте-приемнике появится копия указателя
mCells, а не копия данных, на которые ссылается этот указатель. Таким образом,
возникает ситуация, когда оба объекта s и si будут содержать указатель на одни и те
же данные (рис. 9.2).
Стековая память
int mWidth
int mHeight
SpreadsheetCell** mCells
Spreadsheet s1
"Куча"
ir
\ V"
Каждый элемент —
неименованный указатель
SpreadsheetCell*
Каждый элемент— неименованный
объект типа SpreadsheetCell
int mWidth
int mHeight
SpreadsheetCell** mCells
Spreadsheet s
Рис. 9.2
Если объект s изменил бы что-либо в данных, на которые указывает переменная
mCells, это изменение коснулось бы и объекта si. Хуже того, при выходе из функции
printSpreadsheet () вызывается деструктор объекта s, который освобождает память,
адресуемую указателем mCells. Это оканчивается ситуацией, показанной на рис. 9.3.
Глава 9. Освоение классов и объектов 227
Теперь объект si содержит "висячий" указатель!
Стековая память
int mWidth
int mHeight
SpreadsheetCell** mCells
Spreadsheet s1
"Куча"
Освобожденная память
Рис. 9.3
Невероятно, но с присваиванием дело обстоит еще хуже. Предположим, что вы
написали такой код.
Spreadsheet sl(2, 2), s2(4, 3) ;
Sl = S2;
После создания двух объектов вы должны получить распределение памяти в
соответствии со схемой, показанной на рис. 9.4.
После выполнения инструкции присваивания вы получите ситуацию,
отображенную на рис. 9.5.
Теперь мы имеем две проблемы: помимо того, что указатели mCells в объектах sl
и s2 ссылаются на одну и ту же область памяти, но мы вдобавок получили "осиротелую"
область памяти, на которую ранее (до присваивания) ссылался указатель mCells
в объекте sl. Вот почему в операторах присваивания необходимо сначала
освобождать старую память, а затем создавать детальную копию (с воспроизведением всех
элементов структуры).
Как видите, не всегда стоит полагаться на средства языка, действующие по
умолчанию (конструктор копии и оператор присваивания, создаваемые компилятором).
Если в классе у вас предусмотрено динамическое выделение памяти, то вам
необходимо написать собственный конструктор, чтобы обеспечить создание детальной копии.
Конструктор копии класса Spreadsheet
Обратите внимание на объявление конструктора копии для класса Spreadsheet.
class Spreadsheet
{
public:
Jl* Spreadsheet (int inWidth, int inHeight);
Mt
Spreadsheet(const Spreadsheets src);
~Spreadsheet();
228 Часть II. Пишем С++-код профессионально
};
void setCellAt(int x, int у,
const SpreadsheetCell& cell);
SpreadsheetCell getCellAt(int x, int y);
protected:
bool inRange(int val, int upper);
int mWidth, mHeight;
SpreadsheetCell** mCells;
Стековая память
int mWidth
int mHeight
SpreadsheetCell** mCells
Spreadsheet s2
"Куча"
<r
\ V"
int mWidth
int mHeight
SpreadsheetCell** mCells
Spreadsheet s1
Рис. 9.4
А вот как выглядит определение этого конструктора копии.
Spreadsheet::Spreadsheet(const Spreadsheets src)
{
int i, j ;
mWidth = src.mWidth;
mHeight = src. mHeight,-
mCells = new SpreadsheetCell* [mWidth];
Глава 9. Освоение классов и объектов 229
for (i = 0; i < mWidth; i++) {
mCells [i] = new SpreadsheetCell [niHeight]
}
for (i = 0; i < mWidth; i++) {
for (j = 0; j < mHeight; j++) {
mCells[i] [j] = src.mCells [i] [j] ;
}
}
Стековая память
int mWidth int mHeight
SpreadsheetCell** mCells
Spreadsheet s2
"Куча"
V
\ Y^
int mWidth
int mHeight
SpreadsheetCell** mCells
I
Orphaned
memory!
Spreadsheet s1
Рис. 9.5
Обратите внимание на то, что конструктор копии копирует все члены данных,
включая mWidth и mHeight, а не только члены-указатели. Основная часть тела
конструктора копии обеспечивает создание детальной копии члена данных mCells в виде
двумерного массива, динамически размещаемого в области "кучи".
В конструкторе копии необходимо обеспечить копирование всех
членов данных, а не только членов-указателей.
230 Часть II. Пишем C++—код профессионально
Оператор присваивания класса Spreadsheet
Рассмотрим определение класса Spreadsheet, включающее оператор присваивания.
class Spreadsheet
{
public:
Spreadsheet(int inWidth, int inHeight);
Spreadsheet(const Spreadsheets src);
-Spreadsheet();
Spreadsheets operator=(const Spreadsheets rhs) ;
void setCellAt(int x, int y,
const SpreadsheetCellS cell);
SpreadsheetCell getCellAt (int x, int y) ,-
protected:
bool inRange(int val, int upper);
int mWidth, mHeight;
SpreadsheetCell** mCells,-
};
Теперь приведем реализацию оператора присваивания для класса Spreadsheet
(в виде четырех фрагментов, сопровождаемых пояснениями). Обратите внимание на
то, что при выполнении этого оператора присваиваемый объект уже
инициализирован. Таким образом, прежде чем занимать новые области памяти, необходимо
освободить ранее выделенные (имеется в виду динамически). Оператор присваивания
можно представить в виде объединения деструктора с конструктором копии.
Присваивая один объект другому, мы, по сути, совершаем "сеанс реинкарнации" объекта,
"вдыхая" в него "новую жизнь" (т.е. данные).
Spreadsheets Spreadsheet::operator=(const Spreadsheets rhs)
{
int i, j;
// Проверяем на наличие ситуации самоприсваивания.
if (this == Srhs) {
return (*this) ,-
}
В приведенном выше фрагменте кода выполняется проверка факта самоприсваивания.
// Освобождаем старую память.
for (i = 0; i < mWidth,- i++) {
delete [] mCells[i];
}
deleted mCells,-
Этот фрагмент кода идентичен коду деструктора. Перед динамическим
размещением объектов в новых областях памяти необходимо освободить все занимаемые
ранее, в противном случае можно получить ситуацию "утечки" памяти.
// Копируем данные во вновь выделенные области памяти.
mWidth = rhs.mWidth,-
mHeight = rhs.mHeight;
Глава 9. Освоение классов и объектов 231
mCells = new SpreadsheetCell* [mWidth];
for (i = 0; i < mWidth; i++) {
mCells[i] = new SpreadsheetCell[mHeight];
}
for (i = 0; i < mWidth; i++) {
for (j =0; j < mHeight; j++) {
mCells [i] [j] = rhs.mCells[i] [j];
Этот фрагмент кода идентичен конструктору копии.
return (*this);
}
Оператор присваивания завершает "большую тройку" методов управления
динамически распределяемой памятью в объекте: деструктор, конструктор копии и
оператор присваивания. Если вы почувствуете необходимость в написании одного из этих
методов, вам придется написать и все остальные элементы "большой тройки".
Если в классе динамически выделяется память, напишите
деструктор, конструктор копии и оператор присваивания, t *
Общие вспомогательные методы для конструктора копии и оператора
присваивания
Конструктор копии и оператор присваивания очень похожи. Поэтому удобно
выделить общие для них задачи в отдельный вспомогательный метод. Например, можно
было бы добавить в класс Spreadsheet метод copyFrom (), а затем переписать
конструктор копии и оператор присваивания с использованием этого метода.
void Spreadsheet:: copyFrom (const Spreadsheets;; src)
{
int i, j ;
mWidth = src.mWidth;
mHeight = src.mHeight;
mCells = new SpreadsheetCell* [mWidth] ,-
for (i = 0; i < mWidth; i++) {
mCells[i] = new SpreadsheetCell[mHeight];
}
for (i = 0; i < mWidht; i++) {
for (j =0; j < mHeight; j++) {
mCells[i] [j] = src .mCells [i] [j] ;
Spreadsheet::Spreadsheet(const Spreadsheet &src)
{
copyFrom(src);
232 Часть П. Пишем C++—код профессионально
}
Spreadsheets: Spreadsheet: :operator= (const Spreadsheets: rhs)
{
int i ;
// Проверяем на наличие ситуации самоприсваивания.
if (this == &rhs) {
return (*this);
}
// Освобождаем старую память.
for (i = 0; i < mWidth; i++) {
delete [] mCells [i] ;
}
delete [] mCells;
// Копируем данные во вновь выделенные области памяти.
copyFrom(rhs);
return (*this) ,-
}
Запрещение присваивания и передачи параметров по значению
Иногда при динамическом выделении памяти в классе проще всего не разрешить
копирование объектов или их присваивание вообще. Это можно сделать, объявив
конструктор копии и метод operator= () с использованием спецификатора private.
Если в этом случае кто-либо попытается передать объект по значению, организовать
его возврат из функции или метода либо присвоить его другому объекту, то
компилятор выразит свое "недоумение" соответствующим сообщением. Рассмотрим
определение класса Spreadsheet, в котором запрещается присваивание и передача
параметров по значению.
class Spreadsheet
{
public:
Spreadsheet(int inWidth, int inHeight);
-Spreadsheet();
void setCellAt(int x, int y, const SpreadsheetCellS: cell);
SpreadsheetCell getCellAt(int x, int y);
protected:
bool inRange(int val, int upper);
int mWidth, mHeight;
SpreadsheetCell** mCells;
private:
Spreadsheet (const Spreadsheets: src) ;
Spreadsheets: operator= (const Spreadsheets: rhs);
b
Если после этого написать код копирования или присваивания для объектов
класса Spreadsheet, компилятор "пожалуется на несправедливость" сообщением такого
типа: * = ' : cannot access private member declared in class 'Spreadsheet'
(невозможно получить доступ к private-члену, объявленному в классе Spreadsheet).
Глава 9. Освоение классов и объектов 233
Нет никакой необходимости предоставлять реализации для
закрытых (private) конструкторов копии и операторов присваивания.
Компоновщик никогда ие станет искать их, поскольку компилятор не
"согласится" с их вызовом. ■ <ыЖ
Различные виды членов данных
В C++ у программиста есть большой выбор членов данных. Помимо объявления
в классах простых членов данных можно создавать члены, совместно используемые
всеми объектами класса, const-члены, ссылки-члены, const-ссылки-члены и пр.
О них и пойдет речь в этом разделе.
Статические члены данных
Иногда предоставление каждому объекту класса собственной копии некоторой
переменной оказывается избыточным или вообще не соответствует вашим намерениям.
Оказывается, в C++ можно создать такой член данных, который будет принадлежать
конкретному классу и оставаться при этом в единственном экземпляре (т.е. при
создании объект не получит собственной копии этого члена данных, а будет
"довольствоваться" его статусом общей собственности). Например, вы могли бы
присваивать каждой таблице уникальный числовой идентификатор. В этом случае вам
понадобится счетчик (считающий с 0), из которого каждый новый объект смог бы
считывать значение своего ID. Такой счетчик таблиц действительно будет
принадлежать классу Spreadsheet, но нет никакого смысла каждый объект класса Spreadsheet
наделять собственной его копией, поскольку вам нужно каким-то образом обеспечить
синхронизацию всех счетчиков. В C++ предлагается решение подобных проблем
путем использования статических членов данных. Статический член данных — это член,
связанный с классом, а не с объектом. Статические члены данных играют роль
глобальных переменных в рамках класса. Рассмотрим определение класса Spreadsheet,
включающее новый static-член, который исполняет роль счетчика.
class Spreadsheet
{
public:
// Код опущен из экономии места.
protected:
bool inRangednt val, int upper);
void copyFrom( const Spreadsheets; src) ;
int mWidth, mHeight,-
SpreadsheetCell** mCells;
static int sCounter;
b
Помимо перечисления статических членов в определении класса необходимо
выделить им память в исходном файле, и обычно для этого используется исходный
файл, в котором содержатся определения методов класса. Статические члены данных
можно инициализировать одновременно с определением, но следует иметь в виду,
234 Часть II. Пишем C++—код профессионально
что, в отличие от обычных переменных и членов данных, они инициализируются по
умолчанию нулевыми значениями. Вот как можно выделить память для статического
члена данных sCounter и инициализировать его.
int Spreadsheet::sCounter = 0;
Этот код не принадлежит телу ни одной из функций или метода. Он практически
подобен объявлению глобальной переменной с одним исключением: оператор
разрешения контекста Spreadsheet: : говорит о том, что этот код является частью
класса Spreadsheet.
Получение доступа к статическим членам данных в методах класса
Использование статических членов данных в методах класса не отличается от
использования обычных. Например, вам нужно создать член mid класса Spreadsheet
и инициализировать его на основе члена sCounter в конструкторе этого класса.
Рассмотрим определение класса Spreadsheet, включающее член mid.
class Spreadsheet
{
public:
Spreadsheet(int inWidth, int inHeight);
Spreadsheet(const Spreadsheets src);
-Spreadsheet();
Spreadsheets operator=(const Spreadsheets rhs) ;
void setCellAt(int x, int y,
const SpreadsheetCellS cell);
SpreadsheetCell getCellAt(int x, int y);
int getld();
protected:
bool inRange(int val, int upper);
void copyFrom(const Spreadsheets src);
int mWidth, mHeight,-
int mid;
SpreadsheetCell** mCells,-
static int sCounter;
};
А вот как выглядит реализация конструктора класса Spreadsheet, в котором
выполняется присваивание начального значения ID:
Spreadsheet:Spreadsheet(int inWidth, int inHeight) :
mWidth(inWidth), mHeight(inHeight)
{
mid = sCounter++;
mCells = new SpreadsheetCell* [mWidth];
for (int i = 0; i < mWidth; i++) {
mCellsti] = new SpreadsheetCell[mHeight];
Глава 9. Освоение классов и объектов 235
Как видите, конструктор получает доступ к статической переменной sCounter как
к обычному члену данных. Не забудьте присвоить значение Шив конструкторе копии.
Spreadsheet::Spreadsheet(const Spreadsheets: src)
{
mid = sCounter++,-
copyFrom(src);
}
He нужно копировать значение ID в операторе присваивания. Однажды
назначенный объекту идентификационный номер (ID) никогда не должен меняться.
Получение доступа к статическим членам данных вне методов класса
Спецификаторы управления доступом применяются и к статическим членам
данных: если счетчик sCounter является protected-членом класса, то к нему
невозможно получить доступ вне методов класса.
Однако, несмотря на защищенность статического члена данных, разрешается '
присвоить ему значение при объявлении в исходном файле, хотя этот код (приводим
его снова) не принадлежит ни одному из методов класса Spreadsheet,
int Spreadsheet: : sCounter = 0 ,-
Константные члены данных
Члены данных класса можно объявить с использованием модификатора const,
и тогда их нельзя будет изменить после создания и инициализации. В константах
почти никогда нет большого смысла на уровне объектов, поэтому константные (const)
члены данных обычно объявляются также статическими. Используйте статические
const-члены данных вместо глобальных констант, если эти константы применяются
только к классу. Например, для высоты и ширины таблиц вполне логично задать
максимальные значения (аппетиты пользователей иногда просто безграничны!). Если
пользователь попытается построить таблицу с параметрами, превышающими
максимальные значения, то вместо заданных пользователем "неправильных размеров"
будут установлены жестко определенные максимумы. Максимальные значения для
высоты и ширины можно объявить статическими const-членами класса Spreadsheet.
class Spreadsheet
{
public:
// Код опущен ради экономии места.
static const int kMaxHeight;
static const int kMaxWidth;
protected:
// Код опущен ради экономии места.
};
Поскольку эти члены являются статическими, вы должны объявить их в исходном
файле. Поскольку они являются константными, это ваш последний шанс присвоить
им значения.
const int Spreadsheet::kMaxHeight = 100;
const int Spreadsheet::kMaxWidth = 100;
236 Часть II. Пишем C++—код профессионально
В действительности стандарт C++ позволяет присваивать статическим const-членам
данных значения при объявлении их в файле класса (т.е. в файле, содержащем
объявление класса), если они имеют целочисленный тип данных (например, int или char).
class Spreadsheet
{
public:
// Код опущен ради экономии места.
static const int kMaxHeight « 100;
static const int kMaxWidth = 100;
protected:
// Код опущен ради экономии места.
};
Этой возможностью не стоит пренебрегать в случае, если нужно использовать
константу позже в определении класса. И хотя некоторые старые компиляторы
отказываются поддерживать такой синтаксис, большинство современных относятся
к нему "с пониманием". И в самом деле, многие компиляторы позволяют опустить
дополнительное определение статического const-члена в исходном файле, если вы
инициализируете этот член в определении класса и если вы не будете выполнять с ним
операций, которые потребуют физической памяти, например получения адреса.
Эти новые константы можно использовать в конструкторе, как показано в
следующем разделе кода (обратите внимание на использование тернарного оператора).
Spreadsheet::Spreadsheet(int inWidth, int inHeight) :
mWidth(inWidth < kMaxWidth ? inWidth : kMaxWidth),
mHeight(inHeight < kMaxHeight ? inHeight : kMaxHeight)
{
mid = sCounter++;
mCells = new SpreadsheetCell* [mWidth];
for (int i = 0; i < mWidth,- i++) {
mCells [i] = new SpreadsheetCell[mHeight];
}
}
Члены kMaxHeight и kMaxWidth объявлены открытыми (public), поэтому вы
можете получить доступ к ним из любого места программы (как к глобальным переменным), но
с применением несколько отличного синтаксиса: вы должны указать, что переменная
является частью класса Spreadsheet, с помощью оператора разрешения контекста (: :).
cout << "Максимальная высота таблицы: "
<< Spreadsheet::kMaxHeight << endl;
Ссылочные члены данных
Созданные нами классы Spreadsheet и SpreadsheetCell получились
неплохими, но сами по себе они малополезны. Нужна программа, которая бы осуществляла
управление всем механизмом поддержки таблиц. Для этого создадим класс Spread-
sheetApplication.
В данными момент реализация этого класса не столь существенна. Пока важнее
рассмотреть задачу из области архитектуры: как объекты таблиц смогут взаимодействовать
Глава 9. Освоение классов и объектов 237
с приложением? Если приложение хранит список созданных таблиц, то у него будет
способ связи с таблицами. Аналогично каждый объект таблицы должен хранить
ссылку на объект приложения. Класс Spreadsheet должен "знать" о существовании
класса SpreadsheetApplication, но вместо директивы #include можно использовать
лишь опережающую ссылку (т.е. ссылку на элемент, который объявлен, но еще не
определен) на имя класса (подробнее см. главу 12). Итак, рассмотрим новое определение
класса Spreadsheet.
class SpreadsheetApplication,- // опережающее объявление
class Spreadsheet
{
public:
Spreadsheet(int inWidth, int inHeight,
SpreadsheetApplication& theApp);
// Код опущен из соображений экономии места.
protected:
// Код опущен из соображений экономии места.
SpreadsheetApplication& mTheApp;
static int sCounter,-
};
Обратите внимание на то, что ссылка на приложение передается каждому объекту
класса Spreadsheet в его конструкторе. Ссылка не может существовать сама по себе:
она обязательно должна ссылаться на что-нибудь, поэтому ссылочной переменной
mTheApp необходимо присвоить соответствующее значение в списке
инициализаторов конструктора.
Spreadsheet::Spreadsheet(int inWidth, int inHeight,
SpreadsheetApplicationb theApp)
: mWidth(inWidth < kMaxWidth ? inWidth : kMaxWidth),
mHeight(inHeight < kMaxHeight ? inHeight : kMaxHeight),
mTheApp(theApp)
{
// Код опущен из соображений экономии места.
}
Необходимо также инициализировать ссылочный член данных в конструкторе копии.
Spreadsheet:Spreadsheet(const Spreadsheet& src) :
mTheApp(src.mTheApp)
{
mid = sCounter++,-
copyFrom(src);
}
238 Часть II. Пишем C++—код профессионально
Помните, что после инициализации ссылки объект, на который она указывает,
изменять нельзя. Поэтому присваивать значения ссылкам в операторе присваивания
нет никакой необходимости.
Ссылочные const-члены данных
Подобно обычным ссылкам ссылочные члены класса могут указывать на const-
объекты. Например, из соображений целесообразности вы решили, что объекты
класса Spreadsheet должны иметь исключительно const-ссылку на объект
приложения. В этом случае для объявления члена mTheApp const-ссылкой достаточно
слегка изменить определение класса.
class Spreadsheet
{
public:
Spreadsheet(int inWidth, int inHeight,
const SpreadsheetApplication& theApp) ,-
// Код опущен из соображений экономии места.
protected:
// Код опущен из соображений экономии места.
const SpreadsheetApplicationb mTheApp;
static int sCounter,-
};
Можно также объявить статический ссылочный член или статический
константный ссылочный член данных, но такая необходимость возникает нечасто.
Подробнее о методах
В C++ методы можно объявлять по-разному. У программиста здесь большой выбор.
Статические методы
Методы, как члены класса, иногда применяются к классу в целом, а не к
отдельному объекту. Другими словами, методы могут быть статическими. В качестве примера
рассмотрим класс SpreadsheetCell, созданный в главе 8. Он содержит два
вспомогательных метода: stringToDouble () и doubleToStringO . Они не получают
информацию о конкретных объектах, поэтому они могут быть static-методами.
Рассмотрим определение класса со статическими методами.
class SpreadsheetCell
{
public:
// Опущено ради экономии места.
protected:
static string doubleToString(double val);
static double stringToDouble (const stringb str) ,-
// Опущено ради экономии места.
};
Глава 9. Освоение классов и объектов 239
Реализации этих двух методов идентичны предыдущим! Перед определениями
этих методов излишне даже повторять ключевое слово static. Однако обратите
внимание на то, что статические методы не вызываются для конкретного объекта,
поэтому они не имеют указателя this и не выполняются для конкретного объекта
с доступом к его нестатическим членам. И в самом деле, static-метод можно
сравнить с обычной функцией. Единственное различие между ними в том, что метод
может обращаться к статическим private- и protected-членам данных класса и
нестатическим private- и protected-членам данных других объектов того же типа.
Из статического метода нельзя обращаться к нестатическим членам
данных..
Статический метод можно вызывать (подобно обычной функции) из любого
метода класса. Таким образом, реализация всех методов класса SpreadsheetCell может
остаться прежней. Вне класса необходимо указывать имя метода вместе с именем
класса, используя оператор разрешения контекста (как для статических членов). При
этом управление доступом происходит, как обычно.
Вероятно, имеет смысл сделать методы stringToDouble () и doubleToString ()
открытыми (public), чтобы они стали доступными коду вне класса. В этом случае вы
могли бы вызывать их из любого места программы.
string str = SpreadsheetCell::doubleToString(5);
Константные методы
Константный объект — это объект, значение которого невозможно изменить.
Если у вас есть const-объект или ссылка на const-объект, то компилятор не позволит
вам вызывать методы для этого объекта, если они не гарантируют неизменность всех
их членов данных. Таким средством гарантии, т.е. гарантии того, что метод не
изменит члены данных объекта, является ключевое слово const, сопровождающее
объявление метода. Рассмотрим модифицированное определение класса Spreadsheet-
Cell, содержащее const-методы.
class SpreadsheetCell
{
public:
SpreadsheetCell();
SpreadsheetCell(double initialValue);
SpreadsheetCell (const stringb initialValue) ,-
SpreadsheetCell(const SpreadsheetCell& src);
SpreadsheetCell& operator=(const SpreadsheetCell& rhs);
void setValue (double inValue) ,-
double getValue() const;
void setString(const strings inString);
t string getString() const;
static string doubleToString(double inValue) ,-
static double stringToDouble(const stringb inString);
240 Часть II. Пишем C++—код профессионально
protected:
double mValue,-
string mString;
};
Спецификатор const является частью прототипа метода и должен присутствовать
и в его определении.
double SpreadsheetCell::getValue() const
{
return (mValue);
}
string SpreadsheetCell::getstring() const
{
return (mString);
}
Использование для метода спецификатора const — своего рода заключение с
кодом клиента контракта, который гарантирует, что клиент не будет пытаться изменить
внутренние значения объекта в теле метода. Если вы объявите метод с использованием
спецификатора const, а этот метод в действительности модифицирует некоторый член
данных, то компилятор выдаст соответствующее сообщение. Кроме того, static-метод
нельзя объявить константным, поскольку это было бы избыточным объявлением. Ведь
по определению статические методы не принадлежат отдельным экземплярам класса,
поэтому и нет смысла "подозревать" их в изменении внутренних значений объектов.
Действие спецификатора const для метода в целом можно сравнить с включением
const-ссылки на каждый член данных. Таким образом, если вы попытаетесь изменить
какой-нибудь член данных, компилятор просигналит сообщением об ошибке.
Любой не const-объект может вызывать как const-, так и не const-методы. Однако
const-объект может вызывать только const-методы. Рассмотрим несколько примеров.
SpreadsheetCell myCell(5);
cout << myCe11.getValue() << endl; // OK
myCell.setString("6") ,- // OK
const SpreadsheetCellb anotherCell = myCell,-
cout << anotherCell.getValue() << endl; // OK
anotherCell.setString("6"); // Ошибка компиляции!
Вам следует взять за правило объявлять константными все методы, которые не
модифицируют объект, чтобы вы могли использовать в своих программах ссылки на
cons t-объекты.
Обратите внимание на то, что const-объекты можно разрушать, т.е. ничто не
мешает вызвать их деструктор. Поэтому не стоит пытаться объявлять деструктор со
спецификатором const.
Изменяемые члены данных
Иногда возникает необходимость написать метод, константный по основной
логике, но изменяющий определенный член данных объекта. Эта модификация не
оказывает никакого влияния на какие бы то ни было данные, доступные для пользователя,
Глава 9. Освоение классов и объектов 241
но формально изменение все же имеет место, поэтому компилятор не позволит
объявить такой метод с использованием спецификатора const. Например, предположим,
что вам нужно проанализировать работу вашего приложения табличных вычислений,
чтобы получить информацию о частоте считывания данных. Для этого можно было
бы добавить в класс SpreadsheetCell счетчик, подсчитывающий вызовы методов
getValue () или getString {). К сожалению, с "точки зрения" компилятора, эти
методы сразу станут неконстантными, что не отвечает вашим намерениям. Для
решения подобных проблем в C++ предусмотрен модификатор mutable, который
позволяет изменять переменную в const-методе. Таким образом, сделав наш новый
счетчик изменяемым, т.е реализовав его с помощью mutable-переменной, мы сообщаем
компилятору о своем сознательном намерении модифицировать ее в теле const-
метода. Теперь определение класса SpreadsheetCell выглядит так.
class SpreadsheetCell
{
public:
SpreadsheetCell () ,-
SpreadsheetCell(double initialValue);
SpreadsheetCell(const strings initialValue);
SpreadsheetCell(const SpreadsheetCell& src) ;
SpreadsheetCell& operator=(const SpreadsheetCell& rhs) ;
void setValue(double inValue);
double getValue() const;
void setString(const strings inString);
string getString() const;
static string doubleToString(double inValue);
static double stringToDouble(const strings inString);
protected:
double mValue;
string mString,-
mutable int mNumAccesses,-
ь
Приведем определения методов getValue {) и getString ().
double SpreadsheetCell::getValue() const
{
mNumAccesses++;
return (mValue);
}
string SpreadsheetCell::getString() const
{
mNumAccesses++;
return (mString);
}
He забывайте об инициализации члена mNumAccesses во всех конструкторах!
242 Часть II. Пишем C++—код профессионально
Перегрузка методов
Вы уже, наверняка, обратили внимание на то, что класс может содержать
несколько конструкторов (и все они имеют одинаковые имена). Эти конструкторы
отличаются только количеством или типом параметров. В C++ аналогично можно поступить
с любым методом или функцией. В частности, можно перегрузить функцию (или
метод), используя ее (его) имя для нескольких функций (или методов), отличающихся
друг от друга количеством или типом параметров. Например, в классе SpreadsheetCell
мы могли переименовать методы setStringO и setValue (), присвоив им обоим
имя set (). Тогда определение класса будет выглядеть так.
class SpreadsheetCell
{
public:
SpreadsheetCell();
SpreadsheetCell(double initialValue);
SpreadsheetCell(const stringb initialValue);
SpreadsheetCell(const SpreadsheetCell& src);
SpreadsheetCell& operator=(const SpreadsheetCellb rhs);
void set(double inValue);
void set(const strings inString);
double getValue() const;
string getStringO const;
// Оставшаяся часть класса опущена ради экономии места.
};
Реализации методов set () остаются прежними. Итак, мы помним, что метод,
принимающий параметр типа double (он раньше назывался setValue ()), теперь
называется set (). При вызове метода set () компилятор определит нужную версию
на основе переданного ему параметра: если ему был передан string-параметр,
компилятор вызовет string-версию, а если double-параметр, то — double-версию.
Вы могли бы попытаться проделать аналогичную операцию по перегрузке с
методами getValue () и getString (), переименовав каждый из них в метод get ().
Однако такая программа не скомпилируется. C++ не позволяет перегружать метод
только на основе возвращаемого им типа, поскольку во многих случаях компилятор не
сможет определить, какую версию метода ему необходимо вызвать. Например, если
значение, возвращаемое методом, нигде не перехватывается, компилятор не сможет
"понять", к какой версии метода вы обратились.
Обратите также внимание на то, что метод можно перегрузить на основе
спецификатора const. Другими словами, можно написать два метода с одним именем
и одинаковыми параметрами, но один из них объявить const-методом, а другой— без
этого спецификатора. Компилятор будет вызывать const-метод для const-объекта
и не const-метод для не const-объекта.
Параметры по умолчанию
Подобно перегрузке методов в C++ предусмотрена и "перегрузка" параметров,
которая заключается в возможности их передачи по умолчанию. Это значит, что в
прототипе функции или метода можно передать значения, которые будут действовать в
определенной ситуации. Если при вызове функции или метода пользователь задает
Глава 9. Освоение классов и объектов 243
соответствующие аргументы, значения, указанные в прототипе, игнорируются. В
противном случае они применяются по назначению. В области передачи параметров по
умолчанию существует одно ограничение: действующие по умолчанию значения
необходимо указывать в виде непрерывного списка параметров, начиная с крайнего
справа. Другими словами, компилятор не "умеет" сопоставлять пропущенные аргументы
с параметрами по умолчанию. Больше всего пользы от использования параметров по
умолчанию в конструкторах. Например, мы можем присвоить значения, действующие
по умолчанию, членам класса Spreadsheet, которые "отвечают" за ширину и высоту
таблицы, в конструкторе этого класса.
class Spreadsheet
{
public:
Spreadsheet(const SpreadsheetApplications theApp,
int inWidth = kMaxWidth,
int inHeight = kMaxHeight) ,-
Spreadsheet (const Spreadsheets src) ,-
-Spreadsheet();
Spreadsheets operator= (const Spreadsheets rhs) ,-
void setCellAt(int x, int y,
const SpreadsheetCelIs inCell) ,-
Spreadsheet Cell getCellAt (int x, int y) ,-
int getld() ;
static const int kMaxHeight = 100;
static const int kMaxWidth = 100;
protected:
// Опущено ради экономии места.
};
Реализация конструктора класса Spreadsheet остается прежней. Обратите
внимание на то, что параметры по умолчанию необходимо задавать только в объявлении
метода, а не в его определении.
Теперь можно вызывать конструктор класса Spreadsheet с одним, двумя или
тремя аргументами, несмотря на то, что определен только один конструктор (имеется
в виду не конструктор копии).
SpreadsheetApplication theApp,-
Spreadsheet si(theApp);
Spreadsheet s2(theApp, 5);
Spreadsheet s3(theApp, 5, 6);
Конструктор с заданными по умолчанию значениями для всех параметров может
функционировать как конструктор по умолчанию. Другими словами, такой
конструктор позволяет построить объект класса, не задавая ни одного аргумента. Если
попытаться объявить как конструктор по умолчанию, так и конструктор с несколькими
аргументами и заданными для всех его параметров значениями по умолчанию, то
компилятор выдаст "тревожное" сообщение, поскольку ему в этом случае будет
непонятно, какой конструктор нужно реально вызвать, если в запросе на создание объекта
не задано ни одного аргумента.
Обратите внимание на следующее. Все, что можно достичь с параметрами по
умолчанию, можно добиться за счет перегрузки методов. Можно было бы написать
244 Часть II. Пишем C++—код профессионально
три различных конструктора, каждый из которых будет принимать различное
количество параметров. Однако параметры по умолчанию позволяют написать всего один
конструктор, принимающий три различных набора (по количеству) аргументов.
Используйте тот механизм, который кажется вам более удобным.
Встраиваемые методы
В C++ существует возможность иногда обойтись без реального обращения к функции
или методу. Вместо реализации механизма вызова компилятор вставляет тело метода
или функции непосредственно в код, в котором сделан вызов. Этот процесс называется
встраиванием (inlining), а методы или функции, которые мы хотим наделить таким
поведением, называются встраиваемыми, или подставляемыми. Этот процесс можно считать
всего лишь более экономной (в смысле ресурсов) версией макроса #def ine.
Встраиваемый метод (или функция) определяется с помощью ключевого слова
inline, располагаемого перед именем метода (или функции) в его (или ее)
определении. Например, методы доступа к членам данных класса SpreadsheetCell вполне
можно сделать встраиваемыми, и тогда их следует определить так.
inline double SpreadsheetCell::getValue() const
{
mNumAccesses++;
return (mValue);
}
inline string SpreadsheetCell::getString() const
{
mNumAccesses++;
return (mString);
}
Теперь компилятор должен заменить обращения к методам getValue () и get-
String () реальными их телами, а не генерировать код для реализации вызова этих
методов.
Однако здесь необходимо учесть следующее. Определения встраиваемых методов
и функций должны быть доступны в каждом исходном файле, в котором они
вызываются. И в этом есть логика, иначе как компилятор заменит тело функции, если не
будет иметь ее определения? Таким образом, если вы пишете inline-функции или
методы, их определения необходимо разместить в заголовочом файле вместе с их
прототипами. В отношении методов это означает размещение определений в .h-
файле, который включает определение класса. Такое размещение совершенно
безопасно: компоновщик не станет "возражать" против нескольких определений одного
и того же метода. Оно будет рассматриваться как макрос #def ine.
В C++ предусмотрен альтернативный синтаксис для объявления inline-методов,
которые не используют ключевое слово inline вообще. Для этого достаточно
разместить определение метода непосредственно в определении класса. Применим
вышесказанное к классу SpreadsheetCell.
class SpreadsheetCell
{
public:
SpreadsheetCell () ,-
Глава 9. Освоение классов и объектов 245
SpreadsheetCell (double initialValue) ,-
SpreadsheetCell(const strings initialValue);
SpreadsheetCell(const SpreadsheetCell& src) ;
SpreadsheetCellS operator= (const SpreadsheetCellfc rhs) ,-
void set(double inValue);
void set(const strings inString);
double getValueO const {mNumAccesses++ ,-
return (mValue);}
string getStringO const {mNumAccesses++;
return (mString);}
static string doubleToString(double inValue);
static double stringToDouble(const string& inString);
protected:
double mValue;
string mString;
mutable int mNumAccesses;
};
Многие С++-программисты открывают для себя синтаксис inline-методов и
применяют его, не до конца понимая последствий такого решения. Во-первых,
существует множество ограничений для того, чтобы сделать метод встраиваемым.
Компиляторы реально встраивают только простейшие методы или функции. Если вы укажете
ключевое слово inline для метода, который компилятор не "захочет" встраивать,
эта директива будет попросту проигнорирована. Во-вторых, inline-методы могут
привести к "разбуханию" кода. Ведь тело inline-метода воспроизводится везде, где
присутствует его вызов, что значительно увеличивает общий размер программы.
Таким образом, inline-методы и функции следует применять довольно расчетливо.
Вложенные классы
Определения классов могут содержать нечто большее, чем просто методы и
классы. Можно также написать вложенные классы и структуры, объявить typedef-
определения или создать перечислимые типы. Все, что объявлено в теле класса,
находится в области видимости этого класса. Если элемент класса объявлен открытым
(public), к нему можно получить доступ вне класса, используя синтаксис разрешения
контекста ClassName: :.
Определение класса можно включить в определение другого класса. Например, вы
могли бы прийти к выводу, что класс SpreadsheetCell в действительности является
частью класса Spreadsheet. И тогда их общее определение выглядело бы так.
class Spreadsheet
{
public:
class SpreadsheetCell
{
public:
SpreadsheetCell();
SpreadsheetCell(double initialValue);
SpreadsheetCell(const strings initialValue);
246 Часть II. Пишем C++—код профессионально
SpreadsheetCell(const SpreadsheetCell& src) ;
SpreadsheetCell& operator=(
const SpreadsheetCells rhs);
void set(double inValue);
void set(const strings inString);
double getValueO const {mNumAccesses.++;
return (mValue); }
string getStringO const {mNumAccesses++;
return (mString); }
static string doubleToString(double inValue);
static double stringToDouble(
const strings inString);
protected:
double mValue;
string mString;
mutable int mNumAccesses;
};
Spreadsheet(const SpreadsheetApplication& theApp,
int inWdith = kMaxWidth,
int inHeight = kMaxHeight);
Spreadsheet(const Spreadsheets src);
-Spreadsheet();
Spreadsheets operator= (const Spreadsheets rhs) ;
// Остальная часть объявлений класса Spreadsheet опущена
// ради экономии места.
};
Теперь класс SpreadsheetCell определяется в теле класса Spreadsheet,
поэтому все обращения к классу SpreadsheetCell вне класса Spreadsheet необходимо
сопровождать "приставкой" Spreadsheet: :. Это касается даже определений
методов. Например, конструктор по умолчанию теперь должен выглядеть так.
Spreadsheet::SpreadsheetCell::SpreadsheetCell() : mValue(0),
mNumAccesses(0)
Синтаксис в этом случае может быстро потерять привлекательность. Например,
определение оператора присваивания для класса SpreadsheetCell теперь примет
такой вид.
Spreadsheet::SpreadsheetCellS
Spreadsheet::SpreadsheetCell::operator=(
const SpreadsheetCellS rhs)
{
if (this == Srhs) {
return (*this);
}
mValue = rhs.mValue;
mString = rhs.mString;
mNumAccesses = rhs .mNumAccesses,-
return (*this);
}
Глава 9. Освоение классов и объектов 247
Подобный синтаксис необходимо теперь использовать даже для указания типов
значений, возвращаемых методами (но не для параметров), в самом классе Spreadsheet.
Spreadsheet::SpreadsheetCell Spreadsheet::getCellAt(int x,
int y)
{
SpreadsheetCell empty;
if (!inRange(x, mWidth) || !inRange(y, mHeight)) {
return (empty);
}
return (mCells[x][y]);
}
Избежать неудобного из-за громоздкости синтаксиса можно с помощью
ключевого слова typedef, которое позволит переименовать выражение Spreadsheet: :
SpreadsheetCell в нечто более лаконичное, например Scell.
typedef Spreadsheet::SpreadsheetCell SCell;
Создание этого typedef-имени следует вынести за рамки определения класса
Spreadsheet, в противном случае вам придется уточнять (квалифицировать) само
typedef-имя с помощью приставки Spreadsheet: :, чтобы получить выражение
Spreadsheet: : SCell. А это уже половинчатое решение, которое вряд ли можно
назвать удачным!
Теперь перепишем наш конструктор в таком виде.
SCell: :SpreadsheetCell () : mValue(-0), mNumAccesses(0)
К определениям вложенных классов применяется обычный механизм управления
доступом. Если вложенный класс объявить с использованием спецификатора private
или protected, то его можно будет использовать только в рамках внешнего класса.
Обычно вложенные определения классов используются только для тривиальных
классов. Для такого класса, как SpreadsheetCell, мы получаем уже слишком
громоздкий синтаксис.
"Друзья"
В С++-определении класса можно объявить, что некоторые другие классы или
функции являются "друзьями" для данного класса, и поэтому они имеют право доступа к его
protected- и private-членам данных и методам. Например, в определении класса
SpreadsheetCell можно определить, что класс Spreadsheet является его "другом".
class SpreadsheetCell
{
public:
friend class Spreadsheet;
// Остальная часть класса опущена из экономии места.
};
248 Часть П. Пишем C++—код профессионально
Теперь все методы класса Spreadsheet могут получить доступ к закрытым и
защищенным членам данных и методам класса SpreadsheetCell.
Аналогично можно указать, что "друзьями" данного класса являются одна или
несколько независимых функций или членов другого класса. Например, полезно было
бы написать функцию для подтверждения того, что числовое и строковое значения
объекта класса SpreadsheetCell в действительности синхронизированы. Эту
функцию верификации было бы разумно объявить вне класса SpreadsheetCell и тем
самым сделать возможным моделирование внешнего аудита, но эта функция (для
выполнения надлежащей проверки) должна иметь доступ к внутренним членам данных
объекта. Вот как выглядит определение класса SpreadsheetCell с "дружеской"
функцией checkSpreadsheetCell ().
class SpreadsheetCell
{
public:
// Опущено ради экономии места.
friend bool checkSpreadsheetCell(
const SpreadsheetCell &cell);
// Опущено ради экономии места.
};
Это friend-объявление в классе выполняет роль прототипа функции. Поэтому
нет необходимости писать прототип еще где-нибудь в другом месте (хотя вреда от
этого не будет).
Итак, рассмотрим определение анонсированной функции.
bool checkSpreadsheetCell(const SpreadsheetCell &cell)
{
return (SpreadsheetCell::stringToDouble(cell.mString) ==
cell.mValue);
}
Эта функция подобна любой другой, за исключением того, что она может
напрямую обращаться к private- и protected-членам данных класса SpreadsheetCell.
В определении этой функции не нужно повторять ключевое слово friend.
Возможность создания friend-классов и методов прокладывает простой путь
к злоупотреблению "дружбой". Ведь это средство позволяет нарушить принципы
абстракции, "открывая" содержимое одного класса для других классов и функций.
Поэтому это средство следует использовать только в редких ситуациях, например, для
реализации перегрузки операторов.
Перегрузка операторов
С объектами часто приходится выполнять такие операции, как сложение,
сравнение или вывод в файл. Например, наши табличные объекты будут полезны только
в том случае, если с ними можно выполнить некоторые арифметические действия -
(допустим, суммирование ряда ячеек).
Глава 9. Освоение классов и объектов 249
Реализация оператора сложения
В объектно-ориентированной среде объекты класса Spreadsheet Се 11 должны
позволять сложение с другими SpreadsheetCell-объектами. При суммировании
содержимого одной ячейки с содержимым другой мы должны получить результат,
который необходимо поместить в третью ячейку. При этом содержимое ни одной из
исходных ячеек не должно измениться. Смысл сложения для объектов типа
SpreadsheetCell состоит в сложении значений соответствующих ячеек. Строковые
представления в этом случае игнорируются.
Первый вариант: метод add ()
Мы можем объявить и определить метод add () для класса SpreadsheetCell
таким образом.
cllass SpreadsheetCell
{
public:
// Опущено ради экономии места.
const SpreadsheetCell add(
const SpreadsheetCell& cell) const;
// Опущено ради экономии места.
};
Этот метод выполняет сложение содержимого двух ячеек, возвращая в качестве
результата объект, соответствующий третьей ячейке, содержащей сумму первых двух.
Мы объявляем этот метод константным (const) и позаботимся о том, чтобы он
принимал ссылку на const-объект класса SpreadsheetCell, поскольку метод add() не
должен изменить ни одну из исходных ячеек. Кроме того, этот метод будет
возвращать const-объект класса SpreadsheetCell, так как мы не хотим, чтобы
пользователи могли изменить значение, возвращаемое методом. Им следует лишь присвоить
его другому объекту. Поскольку add () — метод, то он вызывается для одного объекта,
а в качестве параметра ему передается другой. Вот как выглядит его реализация.
const SpreadsheetCell SpreadsheetCell::add(
const SpreadsheetCellfc cell) const
{
SpreadsheetCell newCell,-
newCell.set(mValue + cell.mValue); // Вызов метода set()
// для обновления значений
// mValue и mString.
return (newCell) ,-
}
Обратите внимание на то, что в реализации этого метода создается новый объект
класса SpreadsheetCell с именем newCell, а возвращается копия "новоиспеченной"
ячейки. Такой подход возможен только благодаря тому, что мы написали конструктор
копии для этого класса. Если бы вы попытались возвратить из метода ссылку на ячейку,
то такой вариант метода сложения объектов не был бы работоспособным, поскольку
при завершении метода add () объект newCell выходит из области видимости и
разрушается. И тогда ссылка, которую вернул метод, оказалась бы висячей.
250 Часть II. Пишем C++—код профессионально
Использовать метод add () можно таким образом.
SpreadsheetСе11 myCell(4), anotherCell(5);
SpreadsheetCell aThirdCell = myCell.add(anotherCell);
Этот вариант вполне рабочий, но громоздкий. Стоит поискать что-нибудь получше.
Второй вариант: перегруженный метод operator+
Было бы удобно, если бы можно было складывать две ячейки с помощью знака "плюс"
подобно тому, как мы складываем два int- или два double-значения. Например, так.
SpreadsheetCell myCell(4), anotherCell(5);
SpreadsheetCell aThirdCell = myCell + anotherCell;
К счастью, для того, чтобы мы могли корректно работать с классами, C++ позволяет
нам создать собственную версию знака "плюс", именуемого оператором сложения. Для
этого мы разработаем метод с именем operator + (), который выглядит таким образом.
class SpreadsheetCell
{
public:
// Опущено ради экономии места.
const SpreadsheetCell operator+(
const SpreadsheetCell& cell) const;
// Опущено ради экономии места.
};
Определение этого метода идентично реализации метода add ().
const SpreadsheetCell SpreadsheetCell::operator+(
const SpreadsheetCellfc cell) const
{
SpreadsheetCell newCell;
newCell.set(mValue + cell.mValue); // Вызов метода set О
// для обновления значений
// mValue и mString.
return (newCell);
}
Теперь мы действительно можем складывать содержимое двух ячеек с помощью
знака "плюс"!
Такой синтаксис выглядит вполне привычно. Пусть вас не беспокоит немного
странное имя метода operator+ — это просто имя, подобное именам f оо или add.
Когда С++-компилятор анализирует программу и встречает такой оператор, как +, -, =
или <<, он пытается найти функцию (или метод) с именем operator+, operator-,
operator = или operator<< соответственно, которая принимает соответствующие
параметры. Например, если компилятор встречает следующую строку, он старается
отыскать либо метод operator+ (в классе SpreadsheetCell), который принимает
в качестве параметра другой объект класса SpreadsheetCell, либо глобальную
функцию с именем operator+, которая принимает два SpreadsheetCell-объекта.
SpreadsheetCell aThirdCell = myCell + anotherCell;
Обратите внимание на то, что совсем необязательно, чтобы метод operator+
принимал в качестве параметра объект, тип которого совпадает с классом, для которого
Глава 9. Освоение классов и объектов 251
написан этот метод. Вы могли бы написать для класса SpreadsheetCell метод
operator+, который принимает объект типа Spreadsheet для сложения с объектом
типа SpreadsheetCell. Такое сложение для программиста было бы бессмысленным,
но компилятору оно бы не показалось странным, и он его спокойно бы "пропустил".
Обратите также внимание на то, что метод operator+ мог бы возвращать значение
любого другого типа. Ведь перегрузка операторов — это разновидность перегрузки
функций, а при перегрузке функций тип возвращаемого значения для компилятора не важен.
Неявные преобразования
Удивительно, но после написания приведенного выше метода operator+ вы
сможете не только выполнять сложение двух ячеек, но и складывать содержимое ячейки
с string-, double- или int-значением!
SpreadsheetCell myCell(4), aThirdCell;
string str = "Привет!';
aThirdCell = myCell + str,-
aThirdCell = myCell + 5.6;
aThirdCell = tnyCell + 4;
Причина работоспособности этого кода состоит в следующем. Компилятор
больше "печется" о том, чтобы найти метод operator+, чем найти этот метод с точным
совпадением заданных типов. Компилятор также попытается найти соответствующее
преобразование для типов, чтобы можно было выполнить метод operator+.
Конструкторы, которые принимают рассматриваемый тип, и являются соответствующими
преобразователями. В предыдущем примере компилятор, "видя", что объект типа
SpreadsheetCell пытается "сложить" себя с double-значением, находит
конструктор класса SpreadsheetCell, который принимает double-параметр, и создает
временный объект типа SpreadsheetCell, чтобы передать его методу operator+.
Аналогично, если компилятор встречает строку кода, в которой выражена попытка
сложить SpreadsheetCell-объект со значением типа string, он вызывает string-
конструктор класса SpreadsheetCell, чтобы создать временный SpreadsheetCell-
объект для передачи его методу operator+.
Обычно такое неявное преобразование удобно. Но в предыдущем примере нет
никакого смысла выполнять сложение SpreadsheetCell-объекта со строкой. Подобное
неявное преобразование для объекта класса SpreadsheetCell можно предотвратить
с помощью ключевого слова expl ici t.
(Class SpreadsheetCell
{
public:
SpreadsheetCell();
SpreadsheetCell (double initialValue) ,-
explicit SpreadsheetCell(const strings initialValue);
SpreadsheetCell(const SpreadsheetCell& src);
SpreadsheetCell& operator=(const SpreadsheetCellfc rhs);
// Опущено ради экономии места.
};
Ключевое слово explicit используется только в определении класса, и имеет
смысл только применительно к конструкторам, принимающим ровно один аргумент.
252 Часть II. Пишем C++—код профессионально
Третий вариант: глобальная функция operator+ ()
Неявные преобразования позволяют использовать метод operator+ для
сложения объектов класса SpreadsheetCell с int- и double-значениями. Однако
оператор сложения, как показано в следующем коде, не коммутативный.
aThirdCell = myCell + 4; // Прекрасно работает.
aThirdCell = myCell + 5.6; // Прекрасно работает.
aThirdCell = 4 + myCell; // НЕ СКОМПИЛИРУЕТСЯ!
aThirdCell = 5.6 + myCell; //HE СКОМПИЛИРУЕТСЯ!
Неявное преобразование прекрасно работает в случае, если объект класса
SpreadsheetCell расположен слева от оператора, и совсем не работает, когда он
стоит справа. Но ведь всем известно, что сложение должно быть коммутативной
операцией, значит, что-то здесь не так. В данном случае проблема состоит в том, что
метод operator+ должен вызываться для Spreadsheet Cell-объекта, и этот объект
должен располагаться с левой стороны от оператора "+". Так определен язык C++,
и ничего с этим поделать нельзя. Другими словами, с помощью метода operator+ мы
не можем заставить приведенный выше код заработать.
Но мы можем это сделать, заменив "внутриклассовый" метод operator+
глобальной функцией operator+ (), которая не будет связана ни с каким конкретным
объектом. Вот как может выглядеть определение такой функции.
const SpreadsheetCell operator+(const SpreadsheetCellfc lhs,
const SpreadsheetCell& rhs)
{
SpreadsheetCell newCell;
newCell.set(lhs.mValue + rhs.mValue); // Вызов метода set()
// для обновления значений
// mValue и mString.
return (newCell);
}
Теперь все четыре строки с операцией сложения работают ожидаемым образом.
aThirdCell = myCell +4; // Прекрасно работает.
aThirdCell = myCell + 5.6; // Прекрасно работает.
aThirdCell = 4 + myCell; // Прекрасно работает.
aThirdCell = 5.6 + myCell; // Прекрасно работает.
Обратите внимание на то, что реализация глобальной функции operator+ () получает
доступ к protected-членам данных объектов класса SpreadsheetCell. Следовательно,
она должна быть "дружеской" (friend) функцией для класса SpreadsheetCell.
class SpreadsheetCell
{
public:
// Опущено ради экономии места.
friend const SpreadsheetCell operator+(
const SpreadsheetCell& lhs,
const SpreadsheetCellfc rhs);
// Опущено ради экономии места.
};
Глава 9. Освоение классов и объектов 253
Вас, должно быть, интересует, что произойдет, если написать следующий код.
aThirdCell = 4.5 + 5.5;
Он скомпилируется и выполнится, но при этом написанная нами функция operator+ ()
вызвана не будет. Здесь выполнится обычное сложение double-значений 4,5 и 5,5,
после чего будет создан временный объект класса SpreadsheetCell (с использованием
double-конструктора), который и присвоится объекту aThirdCell.
К третьему варианту придраться трудно, поскольку глобальная функция opera-
tor+ () — это лучшее, что мы можем сделать в C++ для решения описанных выше задач.
Перегрузка арифметических операторов
Теперь, когда вы понимаете, как написать функцию operator+ (), с остальными
арифметическими операторами проблем не должно быть. Рассмотрим объявления
функций для перегрузки операторов "-, "*" и "/" (можно также перегрузить и
оператор "%", но для double-значений, хранимых в классе SpreadsheetCell, он не актуален).
class SpreadsheetCell
{
public:
// Опущено ради экономии места.
friend const SpreadsheetCell operator+(
const SpreadsheetCell^ lhs,
const SpreadsheetCell^ rhs);
friend const SpreadsheetCell operator-(
const SpreadsheetCellfc lhs,
const SpreadsheetCell& rhs);
friend const SpreadsheetCell operator*(
const SpreadsheetCellfc lhs,
const SpreadsheetCell& rhs) ,-
friend const SpreadsheetCell operator/(
const SpreadsheetCellfc lhs,
const SpreadsheetCellfc rhs);
// Опущено ради экономии места.
};
А вот как выглядит реализация этих функций. Что касается операции деления, то,
как всегда, не забудьте проверить, не делите ли вы на нуль. Если такая ситуация
обнаружится, то в этой реализации результат устанавливается равным нулю, хотя с точки
зрения математики это некорректно.
const SpreadsheetCell operator-(const SpreadsheetCellfc lhs,
const SpreadsheetCellfc rhs)
{
SpreadsheetCell newCell;
newCell.set(lhs.mValue - rhs.mValue); // Вызов метода set()
// для обновления значений
// mValue и mString.
return (newCell) ,-
}
const SpreadsheetCell operator*(const SpreadsheetCellfc lhs,
const SpreadsheetCellfc rhs)
{
254 Часть II. Пишем C++—код профессионально
SpreadsheetCell newCell;
newCell.set(lhs.mValue * rhs.mValue); // Вызов метода set()
// для обновления значений
// mValue и mString.
return (newCell);
}
const SpreadsheetCell operator/(const SpreadsheetCell& lhs',
const SpreadsheetCell& rhs)
{
SpreadsheetCell newCell;
if (rhs.mValue ==0) {
newCell.set(0); // Вызов метода set()
// для обновления значений
// mValue и mString.
} else {
newCell.set(lhs.mValue / rhs.mValue); // Вызов метода
// set() для обновления значений
// mValue и mString.
}
return (newCell);
}
С точки зрения C++ от вас не требуется, чтобы в функции operator* () вы
действительно реализовали операцию умножения, а в функции operator/ () — операцию
деления и т.д. В функции operator/ () вы могли бы реализовать, например,
операцию умножения, а в функции operator+ () — операцию деления. Но, согласитесь,
это было бы очень неудобно (если не сказать, чрезвычайно неудобно) и вряд ли
разумно (ну, разве что из вредности или ради шутки). Поэтому в своих реализациях
операторов лучше придерживаться общепринятых значений.
Перегрузка арифметических операторов, использующих
сокращенный синтаксис
Помимо основных арифметических операторов в C++ можно использовать такие
"сокращенные" операторы, как "+=" и "-=". Вы могли бы предположить, что
реализацию оператора "+=" также можно обеспечить для своего класса с помощью функции
operator+ (). Но не тут-то было. Арифметические операторы, использующие
сокращенный синтаксис, необходимо перегружать в явном виде. Эти операторы отличаются
от базовых тем, что они, во-первых, не создают новый объект, а изменяют тот, который
расположен слева от оператора. Во-вторых, подобно оператору присваивания, они
генерируют результат, который представляет собой ссылку на модифицированный объект.
Если вы хотите реализовать арифметические операторы в виде методов класса,
а не глобальных функций, то помните, что в этом случае необходимо, чтобы объект
находился слева от оператора. Рассмотрим объявления новых методов для класса
SpreadsheetCell.
class SpreadsheetCell
{
public:
// Опущено ради экономии места. "Л
friend const SpreadsheetCell operator+(
const SpreadsheetCell^ lhs,
const SpreadsheetCell^ rhs);
friend const SpreadsheetCell operator-(
const SpreadsheetCell^ lhs,
const SpreadsheetCell^ rhs);
Глава 9. Освоение классов и объектов 255
friend const SpreadsheetCell operator*(
const SpreadsheetCell& lhs,
const SpreadsheetCell& rhs);
friend const SpreadsheetCell operator/(
const SpreadsheetCell& lhs,
const SpreadsheetCell& rhs);
SpreadsheetCell& operator+=(const SpreadsheetCell& rhs) ;
SpreadsheetCellfc operator-=(const SpreadsheetCell& rhs);
SpreadsheetCell & operator*» (const SpreadsheetCellfc rhs) ,-
SpreadsheetCell& operator/=(const SpreadsheetCellfc rhs);
// Опущено ради экономии места.
};
Вот как выглядят их реализации.
SpreadsheetCell& SpreadsheetCell::operator+=(
const SpreadsheetCell& rhs)
{
set(mValue + rhs.mValue); // Вызов метода set()
// для обновления значений
// mValue и mString.
return (*this);
}
SpreadsheetCell& SpreadsheetCell::operator-=(
const SpreadsheetCellfc rhs)
{
set(mValue - rhs.mValue); // Вызов метода set()
// для обновления значений
// mValue и mString.
return (*this);
}
SpreadsheetCell& SpreadsheetCell::operator*=(
const SpreadsheetCellfc rhs)
{
set(mValue * rhs.mValue); // Вызов метода set()
// для обновления значений
// mValue и mString.
return (*this);
}
SpreadsheetCellfc SpreadsheetCell::operator/=(
const SpreadsheetCellfc rhs)
{
set(mValue / rhs.mValue); // Вызов метода set()
// для обновления значений
// mValue и mString.
return (*this);
}
"Сокращенные" арифметические операторы представляют собой объединения
базовых арифметических и операторов присваивания. С учетом предыдущих
определений мы можем теперь написать такой код.
SpreadsheetCell myCell(4), aThirdCell(2);
aThirdCell -= myCell;
aThirdCell += 5.4;
256 Часть II. Пишем C++—код профессионально
Однако нельзя скомпилировать такую строку кода (а жаль!):
5.4 += aThirdCell;
Перегрузка операторов сравнения
Для классов очень полезно определить и операторы сравнения: ">", "<" и "==".
Подобно основным арифметическим операторам это следует сделать с
использованием глобальных friend-функций, чтобы можно было использовать неявное
преобразование для операнда, расположенного как слева, так и справа от оператора. Все
операторы сравнения возвращают значение типа bool. Конечно, можно изменить тип
возвращаемого значения, но мы не рекомендуем вам этого делать. Вот как выглядят
объявления и определения новых "друзей" класса SpreadsheetCell.
class SpreadsheetCell
{
public:
// Опущено ради экономии места.
friend const SpreadsheetCell operator+(
const SpreadsheetCell^ lhs,
const SpreadsheetCell^ rhs),-
friend const SpreadsheetCell operator-(
const SpreadsheetCellfc lhs,
const SpreadsheetCellk rhs);
friend const SpreadsheetCell operator*(
const SpreadsheetCell& lhs,
const SpreadsheetCellfc rhs) ,-
friend const SpreadsheetCell operator/(
const SpreadsheetCellfc lhs,
const SpreadsheetCellk rhs);
SpreadsheetCell& operator+=(const SpreadsheetCell& rhs);
SpreadsheetCell& operator-=(const SpreadsheetCell& rhs);
SpreadsheetCell& operator*= (const SpreadsheetCell& rhs) ,-
SpreadsheetCell& operator/=(const SpreadsheetCell& rhs);
friend bool operator==(const SpreadsheetCellfc lhs,
const SpreadsheetCell& rhs);
friend bool operator<(const SpreadsheetCellfc lhs,
const SpreadsheetCell& rhs);
friend bool operator>(const SpreadsheetCell& lhs,
const SpreadsheetCell& rhs);
friend bool operator!=(const SpreadsheetCell& lhs,
const SpreadsheetCell& rhs) ,-
friend bool operator<=(const SpreadsheetCell& lhs,
const SpreadsheetCellfc rhs) ,-
friend bool operator>=(const SpreadsheetCell& lhs,
const SpreadsheetCell& rhs);
// Опущено ради экономии места.
};
bool operator==(const SpreadsheetCellfc lhs,
const SpreadsheetCellfc rhs)
{
return (lhs.mValue == rhs.mValue);
}
bool operator<(const SpreadsheetCellfc lhs.
Глава 9. Освоение классов и объектов 257
f
const SpreadsheetCellfc rhs)
return (lhs.mValue < rhs.mValue);
bool operator>(const SpreadsheetCellfc lhs,
const SpreadsheetCell& rhs)
return (lhs.mValue > rhs.mValue),-
bool operator!=(const SpreadsheetCell& lhs,
const SpreadsheetCellk rhs)
return (lhs.mValue != rhs.mValue);
bool operator<=(const SpreadsheetCellfc lhs,
const SpreadsheetCell& rhs)
return (lhs.mValue <= rhs.mValue);
bool operator>=(const SpreadsheetCellfc lhs,
const SpreadsheetCellfc rhs)
return (lhs.mValue >= rhs.mValue);
В классах с большим количеством членов данных, вероятно, было бы труднее
сравнить все члены данных и сделать вывод о результате сравнения объектов. Но если вы
реализуете сравнение на основе операторов "==" и "<", то сможете написать и
остальные операторы сравнения, опираясь на первые два. Например, вот как может выглядеть
определение функции operator>= (), использующей функцию operator< ().
bool operator>=(const SpreadsheetCell& lhs,
const SpreadsheetCell& rhs)
{
return (!(lhs < rhs));
}
Эти операторы можно использовать для сравнения одних объектов типа Spread-
sheetCell с другими Spreadsheet Cell-объектами, а также с double- и int-
значениями.
if (myCell > aThirdCell || myCell < 10) {
cout << myCell.getValue() << endl;
}
Построение типов с помощью перегрузки операторов
Многие на первых порах находят синтаксис перегрузки операторов не очень
понятным или довольно сложным. Ирония судьбы здесь состоит в том, что назначение
этого синтаксиса как раз в том, чтобы упростить жизнь, но, как вы, наверное, уже
догадались, не для разработчика класса, а для его пользователя. Цель этого
синтаксиса— сделать новые классы как можно более похожими на такие встроенные типы, как
int и double: ведь выполнять сложение объектов легче с помощью оператора "+",
258 Часть II. Пишем C++—код профессионально
чем путем вызова метода, точное название которого нужно еще вспомнить: то ли
add (), то ли sum (), то ли еще как-то.
Рассматривайте реализацию перегрузки операторов как
предоставление определенного вида сервиса для клиентов ваших классов.
Возможно, вас интересует, какие операторы можно перегружать. Ответ прост:
практически все, даже те, о которых вам еще не приходилось слышать. Ведь мы, по
сути, "плаваем" на поверхности этой темы: пока мы использовали оператор
присваивания, арифметические операторы (базовые и с сокращенный синтаксисом) и
операторы сравнения. Полезно также рассмотреть перегрузку операторов вставки в поток
и извлечения из него (операторы ввода-вывода). С перегрузкой операторов связаны
довольно интересные вещи, о которых вы поначалу могли и не подозревать.
Достаточно активно перегрузка операторов используется в библиотеке STL. Как и когда
следует перегружать другие операторы, разъясняется в главе 16, а в главах 21—23 эта
тема рассматривается в связи с библиотекой STL.
Указатели на методы и члены классов
Вспомните, что мы можем создавать и использовать указатели как на переменные,
так и на функции (об указателях вообще и на функции в частности читайте в главе 13).
Теперь рассмотрим указатели на члены класса и методы. В C++ для получения
указателей на члены класса совершенно законно использование их адресов. Однако не
забывайте, что без объекта невозможно получить доступ к нестатическим членам
данных или нестатическим методам. Ведь существование членов класса и методов, по
сути, зависит от существования объектов. Поэтому, если вы хотите вызвать метод или
получить доступ к члену данных через указатель, его (этот указатель) необходимо
разыменовать в контексте объекта. Рассмотрим пример.
SpreadsheetCell myCell;
double (SpreadsheetCell::*methodPtr) () const =
&SpreadsheetCell::getValue;
cout << (myCell.*methodPtr)() << endl;
He стоит паниковать, взглянув на этот синтаксис. При выполнении второй строки
кода объявляется переменная-указатель methodPtr на const-метод, который не
принимает аргументов и возвращает значение типа double. В то же время этот код
инициализирует названную переменную указанием на метод getValue () класса
SpreadsheetCell. Этот синтаксис очень похож на объявление указателя на обычную
функцию за исключением добавления "префикса" SpreadsheetCell: : перед
выражением *methodPtr. Это всего лишь означает, что данный указатель на метод
ссылается на метод класса SpreadsheetCell.
При выполнении следующей строки кода вызывается метод getValue () (через
указатель methodPtr) для объекта myCell. Обратите внимание на круглые скобки,
в которые заключено выражение myCell. *methodPtr. Их необходимость
объясняется более высоким приоритетом по сравнению с оператором "*".
Вторую строку кода можно упростить с помощью typedef-определения.
SpreadsheetCell myCell;
Глава 9. Освоение классов и объектов 259
typedef double (SpreadsheetCell::*PtrToGet) () const;
PtrToGet methodPtr = &SpreadsheetCell::getValue;
cout << (myCell.*methodPtr)() << endl;
Указатели на методы не часто используются в программах. Но важно иметь в виду,
что нельзя разыменовать указатель на нестатический метод или член данных без
объекта. Вероятно, вам когда-нибудь захочется проверить возможность передачи
указателя на нестатический метод такой функции, как qsort (), которая принимает
указатель на функцию, но это попросту не будет работать.
Обратите внимание на то, что C++ позволяет разыменовать указатель на
статический член данных или метод без объекта.
В главе 22 рассматриваются указатели на методы в контексте библиотеки STL.
Построение абстрактных классов
Освоив синтаксис написания классов в C++, вам будет легче понять принципы
проектирования С++-приложений, изложенные в главах 3—5. Как вы знаете, классы
представляют собой основные единицы абстракции в C++. Для отделения интерфейса
от реализации следует активнее применять принципы абстракции. В частности,
имеет смысл сделать все члены данных защищенными (protected) или закрытыми
(private) и предоставить для них методы доступа (установки и считывания). Именно
гак мы и реализовали класс SpreadsheetCell. Переменные реализации mValue
и mString объявлены protected-членами, а для доступа к их значениям созданы
методы set (), getValue () и getString {). Эти меры позволяют сохранить
"внутреннюю" синхронность значений mValue и mString, а также дают основания не
беспокоиться о том, что клиенты несанкционированно их изменят.
Использование классов интерфейса и реализации
Даже с учетом принятия всех описанных выше мер и соблюдения наиважнейших
принципов проектирования язык C++ в своей основе "недружелюбен" к принципам
абстракции. Синтаксис требует объединения public-интерфейсов и private- или
protected-членов данных и методов в одном определении класса, и соблюдение
этого требования выставляет "на всеобщее обозрение" некоторые внутренние детали
'реализации класса.
Тем не менее существует возможность сделать интерфейсы яснее для клиентов
и скрыть от них детали реализации (это хорошая новость). Плохая же новость
состоит в том, что это потребует определенных усилий. Основной принцип — определить
два класса для каждого класса, который вы наметили написать: класса интерфейса
и класса реализации. Класс реализации идентичен классу, который вы бы написали, не
используя описываемый подход. Класс интерфейса представляет public-методы,
идентичные методам класса реализации, но с использованием только одного члена данных:
указателя на объект класса реализации. Реализации методов интерфейсного класса
просто вызывают эквивалентные методы для объекта класса реализации. Чтобы применить
этот подход к классу Spreadsheet, достаточно переименовать старый класс
Spreadsheet в класс Spreadsheetlmpl. Вот как выглядит новый класс SpreadsheetImpl
(который идентичен старому классу Spreadsheet, но носит другое имя).
260 Часть II. Пишем С+н—код профессионально
// Spreadsheetlmpl.h
#include "SpreadsheetCell.h"
class SpreadsheetApplication; // опережающая ссылка
class SpreadsheetImpl
{
public:
SpreadsheetImpl(const SpreadsheetApplication& theApp,
int inWidth = kMaxWidth,
int inHeight = kMaxHeight);
SpreadsheetImpl(const SpreadsheetImpl& src),-
-SpreadsheetImpl();
SpreadsheetImpl &operator=(const SpreadsheetImp1& rhs) ;
void setCellAt(int x, int y,
const SpreadsheetCellk inCell);
SpreadsheetCell getCellAt(int x, int y);
int getldO ;
static const int kMaxHeight = 100;
static const int kMaxWidth = 100;
protected:
bool inRange(int val, int upper);
void copyFrom(const Spreadsheetlmpl& src);
int mWidth, mHeight;
int mld;
SpreadsheetCell** mCells;
const SpreadsheetApplication& mTheApp;
}
static int sCounter;
Теперь определим новый класс Spreadsheet таким образом.
#include "SpreadsheetCell.h"
// Опережающие объявления.
class SpreadsheetImpl;
class SpreadsheetApplication;
class Spreadsheet
{
public:
Spreadsheet(const SpreadsheetApplication& theApp,
int inWidth, int inHeight);
Spreadsheet(const SpreadsheetApplication& theApp);
Spreadsheet(const Spreadsheet& src);
-Spreadsheet();
Spreadsheets operator=(const Spreadsheets rhs);
void setCellAt(int x, int y,
const SpreadsheetCell& inCell);
SpreadsheetCell getCellAt(int x, int y);
int getldO ;
protected:
SpreadsheetImpl* mlmpl;
Глава 9. Освоение классов и объектов 261
Этот класс теперь содержит только один член данных: указатель на объект класса
Spreadsheetlmpl. Открытые методы идентичны методам старого класса Spreadsheet
с одним исключением: конструктор класса Spreadsheet с аргументами по умолчанию
разбит на два конструктора, поскольку значения для аргументов по умолчанию были
const-членами, которых больше нет в классе Spreadsheet. Значения же по
умолчанию предоставляются классом Spreadsheetlmpl.
Реализации таких методов класса Spreadsheet, как setCellAt () и getCellAt {),
лишь передают соответствующее обращение к базовому объекту класса Spreadsheet Impl.
void Spreadsheet::setCellAt(int x, int y,
const SpreadsheetCellfc inCell)
mImpl->setCellAt(x, y, inCell);
SpreadsheetCell Spreadsheet: rgetCellAt (int x, int y)
return (mImpl->getCellAt(x, y) ) ;
int Spreadsheet::getId()
return (mlmpl->getld() ) ,-
Задача конструкторов класса Spreadsheet — построить новый SpreadsheetImpl-
объект, а деструктора— освободить динамически выделенную память. Обратите
внимание на то, что класс Spreadshetlmpl содержит только один конструктор с
заданными по умолчанию аргументами, который вызывается обоими обычными
конструкторами класса Spreadsheet.
Spreadsheet::Spreadsheet(const SpreadsheetApplication fctheApp,
int inWidth, int inHeight)
mlmpl = new Spreadsheetlmpl(theApp, inWidth, inHeight);
Spreadsheet::Spreadsheet(const SpreadsheetApplication& theApp)
mlmpl = new Spreadsheetlmpl(theApp);
Spreadsheet::Spreadsheet(const Spreadsheets src)
mlmpl = new Spreadsheetlmpl(*(src.mlmpl));
Spreadsheet::-Spreadsheet()
delete (mlmpl);
mlmpl = NULL;
Конструктор копии выглядит несколько странно, поскольку ему необходимо "снять
копию" базового объекта класса Spreadshetlmpl с исходного объекта таблицы
(объекта класса Spreadsheet). Поскольку конструктор копии принимает не
указатель, а ссылку на объект класса Spreadsheetlmpl, то для получения самого объекта
262 Часть II. Пишем C++—код профессионально
нужно разыменовать указатель mlmpl, чтобы при вызове конструктор мог взять
ссылку на этот объект.
Оператор присваивания класса Spreadsheet должен "распространить"
присваивание на базовый объект класса SpreadsheetImpl.
Spreadsheets Spreadsheet::operator=(const Spreadsheets rhs)
{
*mlmpl = *( rhs . mlmpl) ,-
return (*this);
}
Первая строка в теле оператора присваивания может показаться немного
странной, и у многих возникает желание ее "упростить", заменив ее таким вот вариантом.
mlmpl = rhs.mlmpl; // Некорректное присваивание!
Этот код скомпилируется и даже будет работать, но сделает не то, что вы хотели.
Он просто скопирует указатели таким образом, что левая и правая части операции
присваивания будут содержать указатели на один и тот же объект класса
Spreadsheet Impl. Если посредством одного из них объект изменится, то другой будет
указывать на тот же измененный объект. Если один из них послужит "орудием"
разрушения объекта, то другой останется "висячим" указателем. Следовательно, неправильно
просто выполнить присваивание указателей. Необходимо позаботиться о том, чтобы
выполнился оператор присваивания для класса Spreadsheet Impl, вызов которого
произойдет только в случае непосредственного копирования объектов. Путем
разыменования mlmpl-указателей мы и организуем прямое присваивание объектов,
которое обеспечивает вызов нужного оператора присваивания. Обратите внимание на то,
что это возможно только потому, что мы уже выделили память для mlmpl-объекта
в соответствующем конструкторе.
Мы показали, как в действительности можно отделить интерфейс от реализации.
И хотя на первый взгляд этот метод кажется не очень "изящным", хотим вас уверить,
что, немного привыкнув к нему, вы найдете его естественным и удобным в работе. Но
считаем своим долгом предупредить вас, что эта практика не очень распространена,
и при попытке внедрить ее вы можете встретить определенное сопротивление коллег.
Резюме
В этой главе (с учетом главы 8) описаны все средства, необходимые для граммот-
ного написания классов и эффективного использования их объектов.
Теперь вы знаете, что динамическое выделение памяти в объектах налагает на вас
как на программиста определенные обязательства: вы должны освободить в
деструкторе память, выделенную в конструкторе; скопировать память в конструкторе копии,
а в операторе присваивания — выполнить как копирование памяти, так и ее
освобождение. Вы также узнали, что предотвратить возможность присваивания и передачи
объекта по значению можно путем объявления конструктора копии и оператора
присваивания закрытыми (private).
Здесь вы расширили свои познания о различных видах членов данных,
создаваемых с помощью таких спецификаторов, как static, const и mutable, а также о
возможности включения в класс const-ссылок. Кроме того, вы получили представление
Глава 9. Освоение классов и объектов 263
о static-, inline- и const-методах, об их перегрузке и возможности передачи
параметров по умолчанию. Здесь также описано, как определить вложенные классы,
и представлено понятие "дружеских" (friend) классов и функций.
Особое внимание в этой главе уделено перегрузке операторов, в частности, вы
узнали, как перегрузить арифметические операторы и операторы сравнения, причем
как с помощью глобальных friend-функций, так и методов класса.
Наконец, вы получили представление о том, как "довести до крайности" понятие
абстракции и в действительности отделить интерфейс от реализации при
использовании классов, т.е. создавать отдельно классы интерфейса и классы реализации.
Теперь, когда вы уже свободно владеете языком объектно-ориентированного
программирования, можно перейти к рассмотрению механизмов наследования и
шаблонов, которым посвящены главы 10 и 11 соответственно.
Осваиваем механизм
наследования
Без механизма наследования классы были бы просто структурами данных,
объединенными с "описателями" их поведения. Одно только это можно было бы считать
огромным шагом вперед по сравнению с процедурными языками программирования,
однако наследование вводит совершенно новое измерение. Ведь с помощью
наследования мы можем строить новые классы, опираясь на уже существующие. А это значит,
что созданные однажды классы становятся многократно используемыми и
расширяемыми компонентами. В этой главе мы покажем, как можно усилить роль наследования
в создании классов и какой для этого использовать синтаксис, а также
продемонстрируем методы получения от наследования максимального эффекта.
Прочитав эту главу, вы будете понимать:
□ как расширить класс с помощью наследования;
□ как применить механизм наследования для создания многократно
используемого кода;
□ как обеспечить взаимодействие между суперклассами и подклассами;
□ как использовать наследование для достижения полиморфизма;
□ как обеспечить множественное наследование;
□ как решить нестандартные проблемы, связанные с наследованием.
Глава 10. Осваиваем механизм наследования 265
В этой главе (точнее, в той ее части, которая связана с полиморфизмом)
используется пример построения электронных таблиц, описанный в главах 8 и 9. Если вы не
читали указанные главы, вам все же стоит просмотреть приведенный в них код,
чтобы войти в курс и ориентироваться, на какой основе будут строиться примеры данной
главы. Здесь также используются принципы объектно-ориентированного подхода
к программированию, описанные в главе 3. Если вы почему-либо пропустили эту главу
и незнакомы с теорией наследования, то, прежде чем продолжить чтение, мы вам
настоятельно рекомендуем вернуться к главе 3.
Построение классов с использованием
наследования
В главе .3 вы узнали, что отношение типа is-a описывает модель, согласно которой
реальные объекты имеют тенденцию к существованию в иерархиях. В
программировании эта модель становится релевантной в том случае, если нам нужно создать класс,
который строится (возможно, с небольшими изменениями) на основе другого класса.
Один из возможных путей достижения этой цели таков: можно скопировать код
одного класса и вставить в "оболочку" другого. Изменяя затем релевантные части или
"исправляя" некоторые участки кода, можно добиться цели создания нового класса,
который будет немного отличаться от оригинала. Однако такой подход оставляет
у ООП-программиста чувство угнетения и даже раздражения (мол, боже, чем я
занимаюсь?!). И для такого, скажем прямо, неудовлетворения есть ряд причин.
□ Результат исправления ошибок в оригинальном классе не отразится на новом,
поскольку два этих класса содержат совершенно разный код.
□ Компилятор ничего "не знает" об отношениях между этими двумя классами,
поэтому они не являются полиморфными— это всего лишь различные
"вариации" на одну и ту же "тему".
□ Такой подход не позволяет построить истинное отношение типа is-a. Новый
класс очень похож на исходный по причине общности их кода, а не потому, что
в действительности здесь имеет место одинаковый тип объекта.
□ Нет возможности получить исходный код. Он может существовать только
в предварительно скомпилированном двоичном формате, поэтому
копирование и вставка могут оказаться под большим вопросом.
Не удивительно, что в C++ предусмотрена встроенная поддержка для определения
истинности отношения типа is-a. Характеристики С++-отношения типа is-a описаны
в следующем разделе.
Расширение классов
При написании определения класса в C++ можно сообщить компилятору о том, что
он является производным от некоторого существующего класса. После такого
"сообщения" ваш класс автоматически будет содержать члены данных и методы
оригинального класса, который называется родительским, или суперклассом. Расширение
существующего класса предоставляет вашему новому классу (который теперь называется
производным или подклассом) возможность описать только характеристики, которыми
он отличается от родительского.
266 Часть II. Пишем С++-код профессионально
Чтобы расширить класс в C++, при написании его определения нужно указать
расширяемый класс. Для демонстрации синтаксиса наследования мы используем два
класса: Super и Sub. (Более интересные примеры приводятся ниже.) ]\ля начала
рассмотрим следующее определение класса Super.
class Super
{
public:
Super() ;
void someMethod();
protected:
int mProtectedlnt;
private:
int mPrivatelnt;
b
Если вы решили построить новый класс Sub на базе класса Super, сообщите
компилятору о том, что класс Sub выводится из класса Super, используя следующий синтаксис.
class Sub
{
public:
Sub С
public Super
};
void someOtherMethod();
Сам по себе класс Sub— полноценный класс, которому "посчастливилось"
использовать характеристики класса Super. Пусть пока вас не беспокоит слово public
перед именем класса Super — его назначение мы поясним ниже в этой главе. На
рис. 10.1 схематично показано простое отношение между классами Sub и Super. Объекты
типа Sub можно объявить подобно любым другим объектам. Вы могли бы даже
определить третий класс, который наследует класс Sub, сформировав таким образом
цепочку классов (рис. 10.2).
Класс Sub необязательно должен быть единственным подклассом класса Super.
Класс Super, как показано на рис. 10.3, может образовывать множество "братьев" и
"сестер" для класса Sub.
Super
—*—
Super
—*—
Sub
Sub
SubSub
У
Super
/4
Sub
Foo
Рис. 10.1 Рис. 10.2 Рис. 10.3
Л
Глава 10. Осваиваем механизм наследования 267
Наследование с точки зрения клиентов
Для клиента или другой части вашего кода объект типа Sub также является
объектом типа Super, поскольку класс Sub выведен из класса Super. Это означает, что ко
всем открытым (public) методам и членам данных класса Super, а также ко всем
открытым методам и членам данных класса Sub можно свободно получить доступ.
Для вызова некоторого метода коду, который использует подкласс, необязательно
"знать", каким классом в цепочке наследования определен этот метод. Например,
следующий код вызывает два метода для объекта класса Sub, хотя один из этих
методов определен классом Super.
Sub mySub;
mySub. someMethod () ,-
mySub.someOtherMethod();
Важно понимать, что механизм наследования работает только в одном
направлении. Класс Sub связан с классом Super четко определенными отношениями, но класс
Super (после того как он был написан) и "знать ничего не знает" о классе Sub. Это
означает, что объекты типа Super не "поддерживают отношений" с public-
методами и членами данных класса Sub, поскольку на класс Super никак не может
повлиять то, что когда-то "потом" был создан класс Sub.
Следующий код не скомпилируется, поскольку класс Super не содержит public-
метода с именем someOtherMethod ().
Super mySuper;
mySuper.someOtherMethod(); // ОШИБКА! В классе Super нет
// метода someOtherMethod().
С точки зрения кода, который использует некоторый подкласс, его
(подкласса) объекты принадлежат не только этому классу, но и всем
суперклассам.
Указатель (или ссылка) на объект некоторого класса может указывать (или
ссылаться) на объект этого класса или любого из его подклассов. Этот непростой для
понимания момент подробнее разъясняется ниже в этой главе. Основная же мысль,
которую важно понять пока, состоит в том, что указатель на Super-объект можно
использовать для адресации Sub-объекта. Аналогичное утверждение справедливо и для
ссылок. Клиент по-прежнему может получить доступ только к методам и членам данных,
которые существуют в классе Super, но посредством описанного выше механизма
любой код, который использует класс Super, может также использовать класс Sub.
Например, следующий код компилируется и успешно выполняется, хотя на
первый взгляд может показаться, что здесь имеет место несоответствие типов.
Super* superPointer = new Sub(); // Создаем объект класса Sub
// и сохраняем его с помощью
// Super-указателя.
Наследование с точки зрения подкласса
Для самого подкласса практически не важно, как он написан или как он себя ведет.
Методы или члены данных подкласса можно определять так же, как если бы вы имели
дело с обычным классом. В приведенном выше определении класса Sub был объявлен
268 Часть II. Пишем C++—код профессионально
метод someOtherMethod (). Таким образом, можно сказать, что класс Sub расширяет
класс Super путем добавления еще одного метода.
Подкласс получает свободный доступ к public- и protected-методам и членам
данных, объявленным в его суперклассе (как если бы они были его собственными
членами, поскольку формально они таковыми и являются). Например, в реализации
метода someOtherMethod (), объявленного в классе Sub, можно было бы
использовать член данных mProtectedlnt, который объявлен как часть класса Super. Вот как
выглядит эта реализация. (Доступ к члену данных или методу суперкласса не
отличается от доступа к членам самого подкласса.)
void Sub::someOtherMethod()
{
cout << "Я могу обратиться к члену данных mProtectedlnt, "
<< "который является частью моего суперкласса."
<< endl;
cout << "Его значение равно " << mProtectedlnt << endl;
}
Когда в главе 8 вы знакомились со спецификаторами доступа (public, private
и protected), то, возможно, не до конца поняли, в чем состоит различие между
спецификаторами private и protected. Теперь, после того как вы узнали о подклассах
и суперклассах, это различие должно стать более ясным. Если в некотором
суперклассе методы или члены данных объявлены защищенными (protected), подклассы
получают к ним свободный доступ. Если же они объявлены закрытыми (private),
подклассы не получают к ним доступа совсем.
Следующая реализация метода someOtherMethod () не скомпилируется, поскольку
в ней выражена попытка подкласса получить доступ к private-члену данных суперкласса.
void Sub::someOtherMethod()
{
cout << "Я могу обратиться к члену данных mProtectedlnt, "
<< "который является частью моего суперкласса."
<< endl;
cout << "Его значение равно " << mProtectedlnt << endl,-
cout << "Значение члена mPrivatelnt равно "
<< mPrivatelnt « endl; // ОШИБКА!
}
Спецификатор доступа private позволяет управлять тем, как потенциальный
подкласс мог бы взаимодействовать с вашим классом. На практике большинство
членов данных объявляются защищенными, а большинство методов — либо открытыми,
либо защищенными. Дело в том, что в большинстве случаев вы или ваши коллеги
будут заниматься расширением уже созданных классов, поэтому вам нет смысла
закрывать доступ к потенциальным средствам, объявляя методы или члены закрытыми.
Иногда спецификатор private используется для "страхования" подклассов от
доступа к потенциально опасным методам. Он также может оказаться полезным при
написании классов, для которых предполагается расширение за счет внешних или
пока неизвестных частей, и поэтому вы можете заблокировать доступ к "святым"
местам, чтобы не допустить их неправильного использования.
С точки зрения подкласса все public- и protected-члены данных
и методы из суперкласса доступны для использования.
Глава 10. Осваиваем механизм наследования 269
Переопределение методов
Как упоминалось в главе 3, основная причина наследования классов состоит в
добавлении или изменении поведения. Определение класса Sub расширяет поведение
родительского класса за счет объявления дополнительного метода someOtherMethod ().
А метод someMethod (), унаследованный от класса Super, обеспечивает в подклассе
такое же поведение, как и в суперклассе. Во многих случаях поведение суперкласса
модифицируется путем замены, или переопределения, некоторого метода.
От "нервов" хорошо помогает виртуальность
В теме переопределения методов есть одна,маленькая деталь, связанная с
использованием ключевого слова virtual. Подклассами могут переопределяться
только те методы, которые объявлены в суперклассе как виртуальные. Ключевое
слово virtual размещается в начале объявления метода, как показано в
модифицированной версии класса Super.
class Super
{
public:
Super();
virtual void someMethod();
protected:
int mProtectedlnt;
private:
int mPrivatelnt;
}.-
Ключевое слово virtual имеет несколько "оттенков" и часто упоминается как
плохо разработанная часть языка. Чтобы не ошибиться, достаточно сделать все свои
методы виртуальными. В этом случае вам не придется беспокоиться, будут ли работать
их переопределенные версии. Единственный недостаток такого "беспроигрышного"
подхода— низкая производительность. "Оттенки" ключевого слова virtual мы
рассмотрим к концу этой главы, а вопросы производительности — в главе 17.
Несмотря на малую вероятность того, что наш класс Sub удостоится чести
расширения в будущем, все же имеет смысл сделать его методы виртуальными, ну так, на
всякий случай.
class Sub : public Super
public:
Sub();
virtual void someOtherMethod();
b
Как правило, все методы объявляются виртуальными (включая
деструктор, но не конструкторы). Это позволяет избежать проблем,
связанных с пропуском ключевого слова virtual.
270 Часть II. Пишем C++—код профессионально
Синтаксис переопределения метода
Чтобы переопределить метод, достаточно снова объявить его в определении
подкласса точно так, как он был объявлен в суперклассе. Новое определение этого метода
необходимо разместить в файле реализации подкласса.
Например, класс Super содержит метод someMethod (). Определение метода
someMethod (), которое включено в файл Super. cpp, выглядит так.
void Super::someMethod()
{
cout << "Это Super-версия метода someMethod()." << endl;
}
Обратите внимание на то, что здесь не повторяется ключевое слово virtual.
Если вам нужно представить новое определение метода someMethod () в классе Sub,
необходимо сначала добавить его в определение класса Sub.
class Sub : public Super
{
public:
Sub();
virtual void someMethod(); // Переопределяем метод
// someMethod(), определенный
// в классе Super.
virtual void someOtherMethod () ,-
};
Новое определение метода someMethod () размещается вместе с остальными
методами класса Sub.
void Sub::someMethod()
{
cout << "Это Sub-версия метода someMethod()." << endl;
}
Переопределенные методы с точки зрения клиентов
Даже с учетом предыдущих изменений метод someMethod () вызывается кодом
пользователя так же, как это делалось до сих пор. Как и раньше, этот метод можно
вызвать как для объекта класса Super, так и для объекта класса Sub. Но теперь
поведение метода someMethod () зависит от типа объекта, для которого был сделан вызов.
Например, следующий код работает как раньше, т.е. вызывает Super-версию
метода someMethod():
Super mySuper;
mySuper.someMethod(); // Вызывается Super-версия
// метода someMethod().
Результат выполнения этого кода таков.
Это Super-версия метода someMethod().
Если вместо объекта класса Super объявить объект класса Sub, то автоматически
будет вызвана другая версия того же метода.
Глава 10. Осваиваем механизм наследования 271
Sub mySub;
mySub. someMethod () ; // Вызывается Sub-версия
// метода someMethod().
На этот раз будет выведен другой текст.
Это Sub-версия метода someMethod () .
Больше ничего для объектов класса Sub не меняется. Другие методы, которые
унаследованы от класса Super, по-прежнему используют определение,
предоставляемое классом Super, если они не будут явным образом переопределены в классе Sub.
Как упоминалось выше, указатель (или ссылка) может указывать (или ссылаться)
на объект некоторого класса или объект любого из его подклассов. Сам объект
"знает" свой класс, и это "знание" создает основу для вызова надлежащего метода,
если он был объявлен виртуальным. Например, если у вас есть Super-ссылка, которая
указывает на объект, в действительности являющийся объектом класса Sub, то при
обращении к методу someMethod (), как показано ниже, реально будет вызвана
версия подкласса. Этот аспект переопределения не будет работать должным образом,
если в суперклассе опустить ключевое слово virtual.
Sub mySub;
Superb ref = mySub;
ref .someMethod(); //Вызывается Sub-версия метода someMethod().
Помните, что хотя ссылка (или указатель) на суперкласс "знает", что в
действительности имеет дело с подклассом, с ее помощью невозможно получить доступ к методам
подкласса, которые не определены в суперклассе. Следующий код не скомпилируется,
поскольку Super-ссылка "понятия не имеет" о методе someOtherMethod ().
Sub mySub;
Super& ref = mySub;
mySub.someOtherMethod(); // Все законно.
ref.someOtherMethod(); // ОШИБКА!
Атрибут "подклассового" сознания — не указ для нессылочных объектов. Да,
можно выполнить операцию приведения типов или присвоить Sub-объект Super-объекту
(поскольку объект типа Sub является одновременно объектом типа Super). Однако
после такой "безобидной" манипуляции объект потеряет свое "подклассовое" сознание.
Sub mySub;
Super assignedObject = mySub; // Присваивание Sub-объекта
// Super-объекту.
assignedObject.someMethod(); // Вызов Super-версии
// метода someMethod().
Чтобы понять это кажущееся странным поведение, представьте, как объекты
выглядят в памяти. Изобразите мысленно Super-объект в виде прямоугольника,
занимающего определенный объем памяти. Объект типа Sub должен выглядеть немного
больше, потому что он включает все содержимое Super-объекта плюс некоторое
"дополнение". Если вы определили ссылку (или указатель) на объект типа Sub, то его
"прямоугольник" от этого не изменится — просто вы получили новый способ доступа
к нему. Но если к объекту типа Sub применить операцию приведения типов и "пре-
272 Часть II. Пишем C++—код профессионально
вратить" его в Super-объект, то тем самым вы "выбросите" из него всю "уникальность"
класса Sub, чтобы "подогнать" его под меньший размер класса Super.
Подклассы сохраняют свои переопределенные методы при
обращении к ним посредством указателей или ссылок "суперклассового" типа.
Но они теряют свою уникальность при приведении объекта к типу
суперкласса. Потеря переопределенных методов и данных подкласса
называется расслоением (slicing).
Наследование как средство многократного
использования кода
Теперь, когда вы познакомились с базовым синтаксисом наследования, можно
попытаться понять, почему наследование является столь важной особенностью языка
C++. Как упоминалось в главе 3, наследование — это механизм, который позволяет
использовать существующий код. В этом разделе рассматривается реальное
приложение, в котором наследование применяется как средство получения максимальной
эффективности от уже созданного кода.
Класс WeatherPrediction
Представьте, что вас нагрузили задачей написания программы, которая должна
давать простые прогнозы погоды. Составление прогнозов погоды слегка выходит за
рамки сферы вашей (как программиста) деятельности, поэтому вы приобретаете
у стороннего производителя библиотеку классов, которая способна предсказывать
погоду на основе текущей температуры и расстояния между Юпитером и Марсом (ну,
что ж, вполне убедительно). Этот библиотечный пакет распространяется в виде
скомпилированной библиотеки (чтобы защитить интеллектуальную собственность
на алгоритмы предсказания), но вам удалось увидеть определение класса Weather-
Prediction. Вот как оно выглядит.
// WeatherPrediction.h
/**
* Предсказывает погоду с помощью проверенных технологий
* нового поколения на основе текущей температуры и
* расстояния между Юпитером и Марсом. Если эти
* значения отсутствуют, приблизительная оценка погоды
* будет все равно сделана, но с точностью лишь 99%.
*/
class WeatherPrediction
{
public:
virtual void setCurrentTempFahrenheit{int inTemp);
virtual void setPositionOfJupiter(
int inDistanceFrorriMars) ;
/**
* Получает прогноз температуры на завтра.
*/
virtual int getTomorrowTempFahrenheit () ;
/**
Глава 10. Осваиваем механизм наследования 273
* Получает вероятность дождя на завтра. 1 означает,
* что дождь будет наверняка. 0 означает отсутствие
* оснований для таких утверждений.
*/
virtual double getChanceOfRain();
/**
* Отображает результат в таком формате:
* Результат: вероятность х.хх. Темп, хх
*/
virtual void showResult();
protected:
int mCurrentTempFahrenhe it;
int mDistanceFromMars;
};
Этот класс решает большинство задач вашей программы. Однако, как это обычно
бывает, он не совсем отвечает вашим потребностям. Во-первых, все значения
температуры задаются по шкале Фаренгейта. Ваша программа должна работать и с
температурой по Цельсию. Кроме того, метод showResult () генерирует не очень уж
удобный для пользователя результат. Было бы неплохо, если бы этот метод давал более
понятную для пользователя информацию.
Добавление в подкласс функций
Как упоминалось в главе 3, наследование важно "с толком" использовать. Это,
прежде всего, можно сделать путем расширения функциональных возможностей
существующего кода. По существу, вашей программе очень подойдет вариант,
предлагаемый классом WeatherPrediction, но при условии, если к нему "привязать"
несколько дополнительных "бантиков". Итак, для начала определим новый класс
MyWeatherPrediction, который наследует класс WeatherPrediction.
// MyWeatherPrediction.h
class MyWeatherPrediction : public WeatherPrediction
Это определение класса «компилируется без проблем. Класс
MyWeatherPrediction уже сразу можно использовать вместо класса WeatherPrediction. Он в полном
объеме реализует функции оригинала, но пока без какой бы то ни было новизны.
В качестве первой модификации вам стоит вооружить свой класс знанием шкалы
Цельсия. Однако здесь есть небольшое затруднение, поскольку вы ничего не знаете
о внутренней работе этого класса. Если все внутренние вычисления выполняются
с использованием шкалы Фаренгейта, то как добавить средства обработки
температуры по Цельсию? Одно из решений этой проблемы — использовать подкласс, который
будет действовать как связующее звено между пользователем, который может
применять любую шкалу, и суперклассом, который "понимает" только шкалу Фаренгейта.
Итак, сделаем первый шаг на пути к поддержке температурных значений по
Цельсию и добавим в класс новые методы, которые позволяют клиентам устанавливать
значение текущей температуры и получать прогноз на завтра в градусах Цельсия, а не
Фаренгейта. Вам также нужны защищенные вспомогательные методы, которые
выполняют преобразования между значениями температуры по Цельсию и Фарен-
274 Часть II. Пишем C++—код профессионально
гейту. Эти методы могут быть статическими, поскольку они должны работать
одинаково для всех экземпляров класса.
// MyWeatherPrediction.h
class MyWeatherPrediction : public WeatherPrediction
{
public:
virtual void setCurrentTempCelsius(int inTemp);
virtual int getTomorrowTempCelsius();
protected:
static int convertCelsiusToFahrenheit(int inCelsius);
static int convertFahrenheitToCelsius(int inFahrenheit);
};
В новом классе соблюдается то же соглашение об именах, что и в родительском
классе. Не забывайте, что с точки зрения "постороннего" кода объект класса
MyWeatherPrediction будет обладать всеми поведенческими характеристиками,
определенными в обоих классах MyWeatherPrediction и WeatherPrediction. Принятие
соглашения об именах, действующего в родительском классе, обеспечивает потомкам
последовательный интерфейс.
Мы предлагаем читателю самому выполнить реализацию методов преобразования
Цельсий/Фаренгейт в качестве упражнения. Два других метода несколько интереснее.
Чтобы установить значение текущей температуры по Цельсию, нужно сначала
преобразовать температуру, а затем передать ее родительскому классу в "понятных" ему единицах.
void MyWeatherPrediction::setCurrentTempCelsius(int inTemp)
{
int fahrenheitTemp = convertCelsiusToFahrenheit(inTemp);
setCurrentTempFahrenheit(fahrenheitTemp);
}
Как видите, если температура уже преобразована, просто вызывается существующий
метод из суперкласса. Аналогично в реализации метода getTomorrowTempCelsius ()
для получения температуры по Фаренгейту используется существующий метод
родительского класса, но перед возвратом из метода результат преобразуется в
температуру по Цельсию.
int MyWeatherPrediction::getTomorrowTempCelsius()
{
int fahrenheitTemp = getTomorrowTempFahrenheit();
return convertFahrenheitToCelsius(fahrenheitTemp);
}
Эти два новых метода эффективно используют родительский класс, просто
"заворачивая" существующий код в "обертку", которая обеспечивает новый
интерфейс для доступа к "начинке".
Безусловно, можно также добавить и другие функции, которые совершенно не
связаны с существующими методами родительского класса. Например, неплохо было
бы добавить метод, который бы считывал альтернативные прогнозы погоды из
Internet, или метод, предлагающий рекомендации в зависимости от прогноза погоды.
Глава 10. Осваиваем механизм наследования 275
Замена функций в подклассе
В создаваемых подклассах можно не только добавлять новые функции, но и
заменять "старые". Метод showResult () в классе WeatherPrediction остро нуждается
в "косметическом ремонте". В классе MyWeatherPrediction можно переопределить
этот метод, чтобы заменить "старое" поведение собственной реализацией.
Вот как выглядит новое определение класса MyWeatherPrediction.
// MyWeatherPrediction.h
class MyWeatherPrediction : public WeatherPrediction
{
public:
virtual void setCurrentTempCelsius (int inTemp) ,-
virtual int getTomorrowTempCelsius();
virtual void showResult();
protected:
static int convertCelsiusToFahrenheit(int inCelsius);
static int convertFahrenheitToCelsius(int inFahrenheit);
};
Теперь представим новую удобную для пользователя реализацию.
void MyWeatherPrediction::showResult()
{
cout << "Завтра будет " <<
getTomorrowTempCelsius() << " градусов Цельсия (" <<
getTomorrowTempFahrenheit() << " градусов Фаренгейта)"
<< endl;
cout << "Вероятность дождя составляет "
<< (getChanceOfRainO * 100) << " процентов."
<< endl;
if (getChanceOfRainO > 0.5) {
cout << "Возьмите с собой зонтик!" << endl;
}
}
Для клиентов, использующих этот класс, старая версия метода showResult {) как
будто и не существует вовсе. Если объект имеет тип MyWeatherPrediction, то для
него будет вызываться новая версия этого метода.
В результате этих изменений класс MyWeatherPrediction можно охарактеризовать
как новый класс с новыми функциями, "приспособленными" к более узкому
назначению. Но при этом для его программирования не потребовалось писать много кода,
поскольку в нем эффективно использованы уже существующие функции его суперкласса.
Уважайте своих родителей
При написании подкласса необходимо учитывать характер взаимодействия между
родительскими и сыновними классами, поскольку потенциальными источниками
ошибок могут служить такие аспекты, как порядок создания классов, формирование
цепочки вызовов конструкторов и приведение типов.
276 Часть II. Пишем С+н—код профессионально
Конструкторы родительских классов
Объекты не создаются одновременно; они начинают свое существование в
соответствии с "генеалогией", т.е. в порядке создания своих предков и любых объектов,
содержащихся в них. В C++ определен следующий порядок создания объектов.
1. Создается базовый класс, если таковой указан в "свидетельстве о рождении".
2. Создаются нестатические члены данных в порядке их объявления.
3. Выполняется тело конструктора.
Эти правила могут выполняться рекурсивно. Если класс имеет "прародителя", то
прародительский объект инициализируется до родительского и т.д. Такой порядок
создания объектов демонстрируется в следующем примере. Отметим, что обычно мы
не рекомендуем "вытягивать" описание методов "по горизонтали", как это сделано
в приведенном ниже коде. Вероятно, вы поняли, что отступление от этого правила
продиктовано исключительно интересами получения читабельных и лаконичных
примеров. При выполнении этого кода будет выведено число 123.
#include.<iostream>
using namespace std,-
class Something
{
public:
Something() { cout << "2"; }
};
class Parent
{
public:
Parent() { cout < < "Iм; }
};
class Child -. public Parent
{
public:
Child() { cout << "3"; }
protected:
Something mDataMember;
};
int main(int argc, char** argv)
{
Child myChild;
}
При создании объекта класса myChi Id сначала вызывается конструктор класса Parent,
который выводит строку "1". Затем инициализируется член данных mDataMember,
вызывая конструктор класса Something, который выводит строку "2". Наконец,
вызывается конструктор класса Child, который выводит строку " 3 ".
Обратите внимание на то, что конструктор класса Parent был вызван
автоматически. C++ автоматически вызывает конструктор без аргументов родительского класса,
если таковой существует. Если действующий по умолчанию конструктор в
родительском классе не существует или если он существует, но вы хотите использовать
альтернативный конструктор, можно сформировать цепочку конструкторов подобно списку
инициализаторов при инициализации членов данных.
Глава 10. Осваиваем механизм наследования 277
В следующем коде показана версия класса Super, в которой отсутствует
конструктор по умолчанию. В соответствующей версии класса Sub необходимо явно уведомить
компилятор о том, как вызвать конструктор класса Super, в противном случае код не
скомпилируется.
// Super.h
class Super
{
public:
Super(int i);
};
// Sub.h
class Sub : public Super
{
public:
Sub();
};
// Sub.cpp
Sub::Sub() : Super(7)
{
// Здесь выполняется дополнительная инициализация членов
// объекта класса Sub.
}
В предыдущем коде конструктор класса Sub передает конструктору класса Super
фиксированное значение (7). Если конструктор класса Sub принимает аргумент, то
конструктору класса Super можно было бы передать переменную.
Sub::Sub(int i) : Super(i) {}
Передача аргументов конструкторов от подкласса к суперклассу— совершенно
нормальное явление, которое прекрасно работает. Однако здесь передача данных
работать не будет. Код-то скомпилируется, но вспомните, что члены данных остаются
неинициализированными до тех пор, пока не будет построен объект суперкласса. Это
значит, что если передать член данных в качестве аргумента родительскому
конструктору, то он получит неинициализированное значение этого члена.
Деструкторы родительских классов
Поскольку деструкторы не могут принимать аргументы, в C++ предусмотрено
средство автоматического вызова деструктора родительских классов. Легко запомнить, что
порядок деструкции является обратным по отношению к порядку конструкции.
1. Вызывается тело деструктора.
2. Все члены данных разрушаются в порядке, обратном порядку их создания.
3. Вызывается тело деструктора родительского класса.
И опять-таки, эти правила могут применяться рекурсивно. Самый младший член
иерархической цепочки всегда первым подвергается деструкции. Следующий пример
отличается от предыдущего наличием деструкторов. При выполнении этого кода
будет выведена строка "123321".
#include <iostream>
using namespace std;
278 Часть II. Пишем C++—код профессионально
class Something .
{
public:
Something() { cout << "2"; }
virtual -Something() { cout << "2"; }
};
class Parent
{
public:
Parent() { cout << "1"; }
virtual -Parent() { cout << "1"; }
ь-
class Child : public Parent
{
public:
Child() { cout << "3"; }
virtual -Child() { cout << "3"; }
protected:
Something mDataMember;
};
int main(int argc, char** argv)
{
Child myChild;
}
Обратите внимание на то, что все деструкторы объявлены виртуальными. Это
обычная практика для деструкторов. Если бы в предыдущем коде деструкторы не
были объявлены виртуальными, на работе данного кода это не отразилось. Но если в код
вставить команду удаления объекта с использованием указателя родительского типа,
который в действительности указывает на объект подкласса, то цепочка деструкции
началась бы не с того места. Например, рассмотрим следующий код, который
отличается от предыдущего лишь тем, что его деструкторы не виртуальные. Этот факт
превращается в целую проблему при создании объекта типа Child посредством указателя
на класс Parent с последующим удалением этого объекта.
#include <iostream>
using namespace std;
class Something
{
public:
Something() { cout << "2"; }
-Something() { cout << "2"; } // Лучше сделать его
// виртуальным, но работать будет.
};
class Parent
Глава 10. Осваиваем механизм наследования 279
{
public:
Parent О { cout << "1"; }
-Parent() { cout << "1"; } // ОШИБКА! Необходимо сделать
// деструктор виртуальным!
};
class Child : public Parent
{
public:
Child() { cout << "3"; }
-Child() { cout << "3"; } // Лучше сделать его
// виртуальным, но работать будет.
protected:
Something mDataMember,-
};
int main(int argc, char** argv)
{
Parent* ptr = new Child();
delete ptr;
}
При выполнении этого кода будет выведена более короткая (по сравнению с
предыдущим примером) строка "1231". При удалении переменной ptr вызывается
только деструктор класса Parent, поскольку деструктор класса Child не объявлен
виртуальным. В результате Child-деструктор не вызывается, как не вызываются и
деструкторы для его членов данных.
Формально эту проблему можно решить, просто сделав деструктор класса Parent
виртуальным. И тогда эта "виртуальность" автоматически была бы использована
сыновними классами Parent. Но мы рекомендуем объявлять все деструкторы виртуаль-
нами, чтобы больше не беспокоиться об этом.
Всегда объявляйте деструкторы своих классов виртуальными!
Обращение к данным родительского класса
В теле подкласса имена могут стать неоднозначными, особенно в случае, если в
игру вступает множественное наследование (подробности ниже). В C++ предусмотрен
механизм устранения неоднозначности имен между классами, который заключается
в использовании оператора разрешения контекста. Его синтаксис (два символа
двоеточия) такой же, как при обращении к статическим данным в классе.
При переопределении метода в подклассе оригинал эффективно заменяется
другим кодом. Однако родительская версия этого метода по-прежнему существует, и при
необходимости можно использовать и ее. Но если просто вызвать метод по имени,
компилятор "посчитает", что вы имеете в виду версию подкласса. Это, как показано
в следующем примере, может легко привести к возникновению бесконечного цикла.
280 Часть II. Пишем С++-код профессионально
Sub:
{
}
:doSomething()
cout << "В Sub-версии метода doSomethingО." << endl;
doSomething(); // ОШИБКА! Здесь рекурсивно вызывается
// тот же метод!
Чтобы явным образом вызвать родительскую версию этого метода, достаточно
перед его именем указать имя родительского класса, разделив эти имена двумя
символами двоеточия.
Sub:
{
}
:doSomething()
cout << "В Sub-версии метода doSomething()." << endl;
Super::doSomething(); // Вызов родительской версии.
Книга
В мягкой
обложке
Техническая
литература
Романтическая
литература
Вызов родительской версии текущего метода —
обычная практика в C++. Если у вас есть цепочка подклассов,
в каждом из них возможно выполнение операции, уже
определенной суперклассом, но с некоторыми
дополнительными функциями.
Рассмотрим, например, иерархию классов,
представляющих виды книг (рис. 10.4).
Поскольку каждый младший класс в иерархии
определяет более узкую специализацию литературы, метод,
который получает описание книги, в действительности должен
учесть все уровни иерархии. Это может быть реализовано
путем формирования цепочки обращений к родительским методам (как было показано
выше). Эта идея иллюстрируется в следующем коде.
#include <iostream>
#include <string>
using namespace std;
class Book
{
Рис. 10.4
public:
virtual string getDescription() { return "Книга"; }
class Paperback : public Book
{
public:
virtual string getDescription() {
return "В бумажной обложке " +
Book::getDescription();
}
};
class Romance : public Paperback
{
public:
virtual string getDescription() {
return "Романтическая литература "
Paperback::getDescription();
}
};
Глава 10. Осваиваем механизм наследования 281
class Technical : public Book
{
public:
virtual string getDescription() {
return "Техническая литература " +
Book::getDescription();
}
};
int main()
{
Romance novel;
Book book;
cout << novel.getDescription() << endl; // Выводится:
// "Романтическая литература
// В бумажной обложке
// Книга".
cout << book.getDescription() << endl,- // Выводится:
// "Книга".
}
Преобразование типа в восходящем и нисходящем направлениях
Как уже было показано, объект можно привести к типу (или присвоить другому
объекту) родительского класса. Если операция преобразования типа или
присваивания выполняется для простого объекта, то в результате получаем эффект расслоения.
Super mySuper = mySub; // РАССЛОЕНИЕ!
Расслоение возникает в ситуациях, подобных этой, поскольку в конечном
результате мы получаем объект типа Super (тип родительского класса), а Super-объекты
лишены дополнительной "оснастки", определенной в классе Sub. Но, если подкласс
присваивается указателю или ссылке на суперкласс, расслоения не происходит.
Superb mySuper = mySub; // Расслоения нет!
Данным способом обычно пользуются для корректной ссылки на подкласс на
основе его суперкласса, и в этом случае говорят о преобразовании типа в восходящем
направлении (upcasting). Вот почему всегда имеет смысл писать методы и функции так,
чтобы они принимали ссылки на классы, а не напрямую использовали объекты этих
классов. С помощью ссылок подклассы можно передавать в качестве параметров
методов или функций без эффекта расслоения.
Чтобы избежать расслоения, при преобразовании типа в восходящем
направлении используйте указатель или ссылку на суперкласс.
Преобразование объекта типа суперкласса в объект одного из его подклассов,
также называемое преобразованием типа в нисходящем направлении (downcasting), зачастую
неодобрительно оценивается профессиональными С++-программистами. Дело в том,
что не существует никакой гарантии, что объект действительно принадлежит
указанному подклассу. Рассмотрим, например, следующий код.
void presumptuous(Super* inSuper)
{
Sub* mySub = static_cast<Sub*>(inSuper);
// Действия для получения доступа к Sub-методам
// для обработки членов данных объекта mySub.
}
282 Часть II. Пишем C++—код профессионально
Если бы автор функции presumptuous () также написал код, который вызывает
функцию presumptuous (), то все, скорее всего, было бы в порядке, поскольку сам
автор знает, что эта функция "ожидает" приема аргумента типа Sub*. Но если эту
функцию будет использовать какой-нибудь другой программист, то при вызове он
может передать ей аргумент типа Super*. He существует языковых средств, которые бы
во время компиляции обеспечивали нужный тип аргумента, и поэтому функция
вслепую "предполагает", что параметр inSuper является указателем на Sub-объект.
Преобразование типа в нисходящем направлении иногда просто необходимо,
и его можно эффективно использовать в контролируемых условиях. Если вы таки
собираетесь прибегнуть к нисходящему преобразованию, то для того, чтобы "вовремя"
можно было отказаться от него (если оно не имеет смысла), вам следует применить
оператор dynamic_cast, который использует встроенные знания объекта о типе.
Учитывая вышесказанное, предыдущий пример следует переписать таким образом.
void lessPresumptuous(Super* inSuper)
{
Sub* mySub = dynamic_cast<Sub*>(inSuper);
if (mySub != NULL) {
// Действия для получения доступа к Sub-методам
// для обработки членов данных объекта mySub.
}
}
Если динамическое преобразование типа для указателя выполнить не удается, его
значение устанавливается равным NULL (согласитесь: это лучше, чем, если бы
указатель в случае неудачи ссылался на бессмысленные данные). Если оператор
преобразования dynamiccast окажется бессильным для ссылки на объект, будет
сгенерировано исключение типа std: : badcast. Подробнее о преобразованиях см. в главе 12, об
исключениях— в главе 15.
Прибегайте к преобразованию типа в нисходящем направлении
только по необходимости и с использованием оператора dynamiccast.
Наследование ради полиморфизма
Теперь, когда вам стали понятны отношения между подклассом и его родителем, вы
можете использовать наследование в его самом значительном сценарии—
полиморфизме. Как вы узнали в главе 3, полиморфизм позволяет использовать объекты с общим
родительским классом взаимозаменяемо, а также применять их вместо родителей.
Возвращаясь к электронным таблицам
В главах 8 и 9 в качестве примера приложения, которое само напрашивается на
объектно-ориентированное проектирование, использовалась программа составления
динамических электронных таблиц. Напомним, что объект класса Spreadsheet Се 11
представляет собой один элемент данных. Этот элемент может иметь тип double или
Глава 10. Осваиваем механизм наследования 283
string. Ниже приводится упрощенное определение класса Spreadsheet Cell.
Обратите внимание на то, что в любую ячейку таблицы можно записать либо число (типа
double), либо строку (типа string). Однако текущее значение ячейки всегда
возвращается в виде строки.
class SpreadsheetСе11
{
public:
SpreadsheetCell() ;
virtual void set(double inDouble);
virtual void set(const std::string& inString);
virtual std::string getString();
protected:
static std::string doubleToString(double inValue);
static double stringToDouble(
const std::strings inString);
};
double mValue;
std::string mString;
Казалось бы, класс SpreadsheetCell находится в состоянии "личностного
кризиса", который выражается в том, что иногда ячейка представляет double-, а
подчас— string-значение. Время от времени приходится выполнять преобразования
между этими двумя форматами. Для достижения такой двойственности в классе
необходимо хранить оба значения, несмотря на то, что данная ячейка должна содержать
только одно значение. А что если пользователям понадобятся дополнительные типы
ячеек, например, для хранения формулы или даты? В данном случае для поддержки
всех этих типов данных и преобразований между ними определение класса
SpreadsheetCell резко увеличится в объеме.
Проектирование полиморфного класса ячейки
электронной таблицы
Класс SpreadsheetCell просто "кричит", требуя переделки иерархической
системы. Самым разумным здесь было бы сузить "профиль" класса SpreadsheetCell до
хранения лишь string-значений, возможно, переименовав его в класс String-
SpreadsheetCell. А для обработки double-значений можно создать второй класс,
DoubleSpreadsheetCell, как производный от класса
StringSpreadsheetCell и "упаковать" его функциональ-
StringSpreadsheetCell
DoubleSpreadsheetCell
ными средствами, соответствующими его формату. Такой
подход к проектированию схематически показан на рис. 10.05.
В этом случае механизм наследования служит цели
повторного использования кода, поскольку класс
DoubleSpreadsheetCell моделируется как подкласс класса StringSpread- рис_ -jq 5
sheetCell только для того, чтобы воспользоваться некоторыми
его встроенными методами.
Если бы вы решили реализовать проект, показанный на рис. 10.05, то вскоре стало
бы ясно, что для реализации большей части подкласса (если не всего целиком)
необходимо переопределить поведенческие характеристики базового класса. Поскольку
double-значения обрабатываются в большинстве случаев не так, как строки, то от-
284 Часть II. Пишем С++-код профессионально
ношения между этими двумя классами могут оказаться не совсем такими, какими они
виделись вначале. Пока ясно одно: отношение между ячейкой, содержащей строку,
и ячейкой, содержащей double-значение, определенно существует. Однако вместо
того, чтобы использовать модель, показанную на рис. 10.05, которая предполагает,
что объект класса DoubleSpreadsheetCell является разновидностью более общего
класса StringSpreadsheetCell (т.е. имеем отношение "is-a"), следует сделать эти
классы одноранговыми "братьями" с общим родителем, а именно классом Spread-
sheetCell. Этот вариант проекта схематично показан на рис. 10.6.
Рис. 10.6
Второй проект (см. рис. 10.6) демонстрирует полиморфный подход к иерархии
класса Spreadsheet Cell. Поскольку оба класса DoubleSpreadsheetCell и
StringSpreadsheetCell являются производными от общего родителя, класса Spreadsheet-
Cell, то "глазами" другого кода они видятся взаимозаменяемыми. На деле это
означает следующее.
Q Оба подкласса поддерживают один и тот же интерфейс (набор методов),
определенный в базовом классе.
□ Код, который использует объекты класса SpreadsheetCell, может вызывать
любой метод в интерфейсе, не заботясь о том, объектом какого класса
(DoubleSpreadsheetCell или StringSpreadsheetCell) является данная ячейка.
Q Благодаря магии виртуальных методов вызов соответствующей версии каждого
метода в интерфейсе будет зависеть от типа объекта.
□ Другие структуры данных, например, класс Spreadsheet (см. главу 9), могут
содержать коллекцию ячеек с несколькими типами данных за счет ссылки на
родительский тип.
Базовый класс ячеек электронной таблицы
Поскольку все ячейки электронной таблицы являются подклассами базового
класса SpreadsheetCell, то имеет смысл написать сначала именно этот класс. При
проектировании базового класса необходимо учитывать, как подклассы связаны друг
с другом. На основе этой информации можно определить долю унифицированности
(общности), которая реализуется в теле родительского класса. Например, string-
и double-ячейки подобны в том, что они содержат одну "единицу" данных.
Поскольку данные исходят от пользователя и будут отображаться опять-таки для пользователя,
то значение, записанное в ячейку как строковое, должно и вернуться пользователю
как строковое. Такое поведение является общим для обоих подклассов, поэтому оно
и должно определить суть базового класса.
Глава 10. Осваиваем механизм наследования 285
Первая попытка реализации
Базовый класс SpreadsheetCell "отвечает" за определение поведения, которое
будут поддерживать все подклассы класса SpreadsheetCell. В нашем простом примере
для всех ячеек необходимо обеспечить возможность установки значения в виде строки.
Все ячейки также должны "уметь" возвращать свои текущие значения в виде строки.
Таким образом, в определение базового класса необходимо включить такие методы.
class SpreadsheetCell
{
public:
SpreadsheetCell();
virtual -SpreadsheetCell();
virtual void set(const std::strings inString);
virtual std::string getStringO const;
};
Приступив к написанию . срр-файла для этого класса, вы очень скоро столкнетесь
с проблемой. Поскольку базовый класс ячейки электронной таблицы не содержит ни
double-, ни string-членов данных, то как вы сможете реализовать его? Попробуем
поставить вопрос в более общем виде: как написать родительский класс, который
объявляет поведение, поддерживаемое подклассами, не реализуя в действительности
это поведение?
Один из возможных способов решения этой проблемы — реализация "холостого"
поведения для соответствующих методов. Например, вызов метода set (),
определенного в базовом классе SpreadsheetCell, не повлечет за собой никаких последствий,
поскольку базовому классу нечего устанавливать (он не содержит членов данных).
Однако в этом подходе чувствуется некоторая некорректность. В идеале никогда не
должно быть объекта реализации такого базового класса. Ведь вызов метода set ()
должен всегда иметь результат, поскольку он должен всегда вызываться для объекта
либо класса DoubleSpreadsheetCell, либо класса StringSpreadsheetCell. Этот
неудобный момент можно исключить с помощью специального решения.
Чисто виртуальные методы и абстрактные базовые классы
Чисто виртуальные методы — это методы, которые в определении класса явным
образом не определены. Делая метод чисто виртуальным, вы сообщаете компилятору
о том, что в текущем классе определения для данного метода не существует. О классе,
в котором объявлен хотя бы один чисто виртуальный метод, говорят, что он
абстрактный, поскольку реализовать его невозможно. Компилятор гарантирует, что, если
класс содержит один или несколько чисто виртуальных методов, он (этот класс)
никогда не будет использован для построения объекта.
Ниже приведен синтаксис объявления чисто виртуального метода. Он
чрезвычайно прост: в определении класса достаточно установить нужный метод "равным" нулю.
Наш . срр-файл теперь, по сути, не должен содержать никакого кода.
class SpreadsheetCell
{
public:
SpreadsheetCell();
virtual -SpreadsheetCell();
286 Часть II. Пишем C++—код профессионально
virtual void set(const std::strings inString) = 0;
virtual std::string getStringO const = 0;
};
Теперь базовый класс SpreadsheetCell стал абстрактным, а значит, создать его
объект невозможно. Следующий код не просто не скомпилируется, но и послужит
причиной выдачи компилятором такого сообщения: Cannot declare object of
type 'SpreadsheetCell' because one or more virtual functions are
abstract (Нельзя объявлять объект типа SpreadsheetCell, поскольку одна или
несколько его функций являются абстрактными).
int main(int argc, char** argv)
{
SpreadsheetCell cell; // ОШИБКА! Попытка создать экземпляр
// абстрактного класса.
}
Абстрактный класс обеспечивает способ предотвращения создания
его экземпляров другим кодом.
Исходный код базового класса
Итак, какой все же код необходимо включить в файл SpreadsheetCell. cpp?
Если, согласно определению класса SpreadsheetCell, большинство его методов
является чисто виртуальными, то их и не нужно определять. Нам остается позаботиться
о методе преобразования типов, конструкторе и деструкторе. Для данного примера
конструктор и деструктор реализуются лишь как "заполнители" на случай
выполнения в будущем задач инициализации и деструкции.
SpreadsheetCell::SpreadsheetCell()
SpreadsheetCell::-SpreadsheetCell()
Подклассы
Создание подклассов StringSpreadsheetCell и DoubleSpreadsheetCell сейчас
заключается в реализации поведения, которое было "намечено" в их родителе. Поскольку
мы хотим, чтобы клиенты могли создавать объекты этих классов и работать с ячейками
string- и double-типа, классы StringSpreadsheetCell и DoubleSpreadsheetCell
не должны быть абстрактными: в них необходимо реализовать все чисто виртуальные
методы, унаследованные от родителя.
Определение строкового класса ячейки электронной таблицы
В написании определения класса StringSpreadsheetCell прежде всего
необходимо указать его родителя (класс SpreadsheetCell).
class StringSpreadsheetCell : public SpreadsheetCell ' f
{
Глава 10. Осваиваем механизм наследования 287
В классе StringSpreadsheetCell необходимо объявить собственный
конструктор, который бы позволял инициализировать данные его объекта.
public:
StringSpreadsheetCell();
Затем нужно переопределить чисто виртуальные методы (на этот раз не должно
быть "приравнивания" к нулю).
virtual void set(const std::strings inString);
virtual std::string getString() const;
Наконец, позаботимся о том, чтобы строковая ячейка могла хранить реальные
данные, поэтому добавим в этот класс защищенный член данных mValue.
protected:
std::string mValue;
};
Реализация класса строковой ячейки
Нетрудно предположить, что .срр-файл для класса StringSpreadsheetCell
будет несколько интереснее, чем для базового класса. В его конструкторе string-член
инициализируется строкой, которая означает, что данная ячейка пуста, т.е. ее string-
значение (пока еще) не установлено.
StringSpreadsheetCell::StringSpreadsheetCell() :
mValue("#NOVALUE")
Метод set () довольно прост, поскольку его внутреннее представление и так
строковое. Аналогично метод getString () лишь возвращает хранимое в объекте
string-значение.
void StringSpreadsheetCell::set(const stringfc inString)
{
mValue = inString,-
}
string StringSpreadsheetCell::getString() const
{
return mValue,-
}
Определение и реализация double-класса ячейки электронной таблицы
По такому же образцу можно "скроить" и double-версию класса ячейки, но с
несколько другой логикой. Помимо метода set (), который принимает string-значение,
в этом классе также определяется и новый метод set (), который позволяет
клиенту установить числовое значение типа double. Для преобразования типов
значений (в обоих направлениях) здесь используется два новых protected-метода.
Подобно классу StringSpreadsheetCell, класс DoubleSpreadsheetCell содержит
член данных mValue, но не строкового типа, а типа double. Поскольку классы Dou-
I
288 Часть II. Пишем C++—код профессионально
bleSpreadsheetCell и StringSpreadsheetCell— "братья", никаких конфликтов
имен не возникнет.
class DoubleSpreadsheetCell : public SpreadsheetCell
{
public:
DoubleSpreadsheetCell (),-
virtual void set(double inDouble);
virtual void set(const std::string& inString);
virtual std: :string getStringO const;
protected:
static std::string doubleToString(double inValue);
static double stringToDouble(
const std::strings inValue);
double mValue,-
};
Вот как выглядит реализация конструктора класса DoubleSpreadsheetCell.
DoubleSpreadsheetCell::DoubleSpreadsheetCell() : mValue(-l)
Метод set (), который принимает double-параметр, предельно прост. В его
string-версии используется защищенный static-метод stringToDouble (). Метод
get String () обеспечивает преобразование хранимого double-значения в строку.
void DoubleSpreadsheetCell::set(double inDouble)
mValue = inDouble;
void DoubleSpreadsheetCell::set(const strings inString)
mValue = stringToDouble(inString);
string DoubleSpreadsheetCell::getString() const
return doubleToString(mValue);
Вы, вероятно, уже заметили одно главное преимущество иерархической реализации
ячеек электронной таблицы: код классов стал гораздо проще! Вам теперь не нужно
беспокоиться об использовании двух полей для представления данных двух типов. Каждый
объект теперь будет "самоцентрирующимся" и ему вполне хватит собственных "сил".
Обратите внимание на то, что реализации методов doubleToString () и
stringToDouble () здесь опущены, поскольку они совершенно совпадают с приведенными
в главе 8.
Усиление полиморфизма
Теперь созданная нами SpreadsheetCell-иерархия стала полиморфной, и код
клиента может воспользоваться преимуществами, которые предлагает полиморфизм.
Чтобы понять, о каких преимуществах идет речь, рассмотрим следующую тестовую
программу.
Глава 10. Осваиваем механизм наследования 289
int main(int argc, char** argv)
{
Во-первых, объявим трехэлементный массив указателей на класс Spreadsheet-
Cell. Поскольку класс SpreadsheetCell — абстрактный, объекты этого типа
создавать нельзя. Однако ничто не мешает нам создать указатель (или ссылку) на класс
SpreadsheetCell, так как он (или она) в действительности будет указывать на один
из его (класса SpreadsheetCell) подклассов. Этот массив использует преимущества
общего (для двух подклассов) типа. Каждый из элементов массива может указывать
либо на объект типа StringSpreadsheetCell, либо на объект типа DoubleSpread-
sheetCell. Поскольку эти два класса имеют общего родителя, их можно хранить
вместе (в одном массиве).
SpreadsheetCell* cellArray[3];
Установим нулевой элемент массива так, чтобы он указывал на новый объект типа
StringSpreadsheetCell. Аналогично поступим и с первым элементом, а второй
пусть указывает на новый объект типа DoubleSpreadsheetCel 1.
cellArray[0] = new StringSpreadsheetCell();
cellArray[l] = new StringSpreadsheetCell();
cellArray[2] = new DoubleSpreadsheetCell();
Теперь этот массив содержит данные разного типа, и любой из методов,
объявленный базовым классом, может быть применен к объектам, адресуемым элементами
этого массива. В коде можно просто использовать указатели на класс Spreadsheet-
Cell— ведь компилятор "не имеет понятия" о том, какой тип в действительности
имеют эти объекты. Но поскольку они являются подклассами класса Spreadsheet-
Cell, они должны поддерживать методы класса SpreadsheetCell.
cellArray[О]->set("Привет");
-cellArray[1]->set("10");
cellArray[2]->set("18");
При вызове метода getString () каждый объект должным образом возвращает
строковое представление своего значения. Важно понимать, что различные объекты
делают это по-разному. Объект класса StringSpreadsheetCell просто возвращает
хранимое в нем значение, а объект класса DoubleSpreadsheetCell сначала должен
выполнить соответствующее преобразование. Как программисту, вам не нужно знать,
как объект делает это. Главное, чтобы вы понимали, что, поскольку объект (по
"дедушке") имеет тип SpreadsheetCell, то он может реализовать это поведение.
cout << "Значения массива: [" <<
cellArray[0]->getstring() << "," <<
cellArray[1]->getString() << "," <<
cellArray[2]->getString() << "]" << endl;
}
Размышления о будущем
Безусловно, с точки зрения объектно-ориентированного проектирования новая
реализация Spreadsheet Cell-иерархии— это заметный шаг вперед. Но для
реальной программы табличных вычислений такой иерархии классов, скорей всего,
недостаточно. И на это есть ряд причин.
290 Часть II. Пишем C++—код профессионально
Прежде всего, несмотря на улучшенный "дизайн", мы все же упустили одно
свойство исходного варианта — возможность преобразования значения ячейки из
одного типа в другой. Разделив ячейки на два класса, мы тем самым ослабили интегриро-
ванность объектов ячеек. Чтобы обеспечить возможность преобразования объектов из
типа DoubleSpreadsheetCell в тип StringSpreadsheetCell, можно было бы
добавить специальный конструктор. Внешне он должен выглядеть подобно
конструктору копии, но вместо ссылки на объект того же класса он принимает ссылку на
объект "братского" класса.
class StringSpreadsheetCell
{
public:
StringSpreadsheetCell() ;
StringSpreadsheetCell(
const DoubleSpreadsheetCell& inDoubleCell);
С помощью конструктора преобразования типа можно легко создать объект типа
StringSpreadsheetCell из объекта типа DoubleSpreadsheetCell. Однако не
путайте это преобразование с операцией приведения типов. Приведение "братских"
типов не будет работать, если вы не перегрузите соответствующий оператор, как
описано в главе 16.
Приведение типов всегда возможно в восходящем направлении по
иерархии классов (и иногда в нисходящем направлении), но
никогда— по "горизонтали" (т.е. между классами одного уровня), если,
конечно, не изменить поведение оператора приведения типов.
Как реализовать перегруженные операторы для ячеек — довольно интересный
вопрос, для ответа на который существует несколько возможных решений. Можно, во-
первых, реализовать версию каждого оператора для каждого сочетания ячеек. При
наличии лишь двух подклассов это вполне осуществимо. Необходимо определить
функцию operator+О для сложения двух double-, двух string-ячеек и "разнополых"
(double- и string-типа) ячеек. Во-вторых, можно выбрать некое общее
представление. В предыдущей реализации в качестве такого общего представления был выбран
тип string. Тогда все возможные сочетания ячеек охватываются лишь одной
функцией operator+ () — в этом и состоит существенное преимущество общего
представления. В качестве примера приведем одну из возможных реализаций, в которой
предполагается, что результат сложения двух ячеек всегда имеет тип string.
const StringSpreadsheetCell operator+(
const StringSpreadsheetCell &lhs,
const StringSpreadsheetCell &rhs)
{
StringSpreadsheetCell newCell;
newCell.set(lhs.getString() + rhs.getString());
return (newCell);
}
Если компилятор "знает", как преобразовать конкретную ячейку таблицы в объект
типа StringSpreadsheetCell, этот оператор сложения будет прекрасно работать.
Если обеспечить предыдущий пример конструктором класса StringSpreadsheetCell,
который принимает в качестве аргумента объект типа DoubleSpreadsheetCell,
Глава 10. Осваиваем механизм наследования 291
компилятор автоматически выполнит нужное преобразование, но при условии, что
существует только один способ заставить работать функцию operator+ (). Это означает,
что следующий код будет работоспособным даже в случае, если функция operator+ ()
была явно написана для работы с объектами класса StringSpreadsheetCells.
DoubleSpreadsheetCell myDbl;
myDbl.set(8.4);
StringSpreadsheetCell result = myDbl + myDbl;
Конечно, в результате этого сложения мы не получим сумму чисел. Этот оператор
преобразует double- в string-ячейку и сложит строки, после чего
StringSpreadsheet Cell-объект будет содержать значение 8.48.4.
Если вы еще не почувствовали в себе уверенность в понимании сути
полиморфизма, тщательно изучите код последнего примера еще раз. Он послужит прекрасной
стартовой площадкой для экспериментального кодирования, которая позволит на
практике исследовать различные аспекты класса.
Множественное наследование
Как упоминалось в главе 3, множественное наследование часто воспринимается
как излишне сложная и даже ненужная часть объектно-ориентированного
программирования. Мы не станем вас убеждать в обратном и позволим вам самим сделать
выводы о степени полезности этого механизма. Наша задача— разъяснить суть
множественного наследования в C++.
Наследование нескольких классов
Определить класс с несколькими родителями синтаксически очень легко. Для
этого достаточно при объявлении имени класса перечислить все его суперклассы.
class Baz : public Foo, public Bar
{
//и так далее
};
После перечисления нескольких родителей объект класса Baz будет обладать
следующими характеристиками.
□ Объект класса Baz станет поддерживать public-методы и включать открытые
члены данных обоих классов Foo и Ваг.
□ Методы класса Baz будут иметь доступ к защищенным (protected) данным
и методам обоих классов Foo и Ваг.
□ Объект класса Baz можно привести либо к типу Foo, либо к типу Ваг.
□ При создании нового объекта класса Baz будут автоматически вызваны
конструкторы по умолчанию классов Foo и Ваг, причем в порядке, в котором эти
имена были перечислены в определении класса Baz.
□ При удалении объекта класса Baz будут автоматически вызваны деструкторы
классов Foo и Ваг, причем в порядке, обратном тому, в котором эти имена
были перечислены в определении класса Baz.
292 Часть II. Пишем С++-код профессионально
Dog
Bird
DogBird
В следующем простом примере демонстрируется класс DogBird, который имеет
два родительских класса: Dog и Bird. Тот факт, что "собакоптица" — в общем-то,
нелепый пример "скрещивания", не следует рассматривать как намек на то, что
множественное наследование само по себе абсурдно. Впрочем, мы предоставляем вам
возможность составить собственное мнение.
class Dog
{
public:
virtual void barkO { cout << "Гав!" << endl; }
};
class Bird
{
public:
virtual void chirp() { cout << "Чирик!" << endl; }
};
class DogBird : public Dog, public Bird
{
};
Иерархия классов для образования DogBird показана
на рис. 10.7.
Использование объектов классов с несколькими
родителями не отличается от использования объектов с одним
предком. В действительности коду клиента даже и знать не
Рис. 10.7 нужно, что класс имеет двух родителей. Важны лишь
свойства и поведение, поддерживаемое классом. В данном
случае объект класса DogBird поддерживает все public-методы классов Dog и Bird.
int main(int argc, char** argv)
{
DogBird myConfusedAnimal;
myConfusedAnimal.bark();
myConfusedAnimal.chirp();
}
Вот как выглядят результаты выполнения этой программы.
Гав!
Чирик!
Коллизии имен и неоднозначные базовые классы
Совсем нетрудно построить сценарий, в котором множественное наследование
может обнаружить свои слабые места. В следующих примерах мы продемонстрируем
некоторые крайние ситуации, которые следует учитывать при проектировании.
Неоднозначность имен
А что если как класс Dog, так и класс Bird будут включать метод с именем eat ()?
Поскольку классы Dog и Bird никак не связаны, одна версия этого метода не
переопределяет другую — это значит, что они обе продолжат свое существование в классе DogBird.
До тех пор пока код клиента не будет пытаться вызвать метод eat (), проблема
себя не проявит. Класс DogBird скомпилируется корректно даже несмотря на наличие
Глава 10. Осваиваем механизм наследования 293
двух версий метода eat (). Но если код клиента попытается вызвать метод eat (),
компилятор выдаст сообщение об ошибке, означающее, что обращение к методу eat ()
содержит неоднозначность. Компилятор просто не будет "знать", какую именно версию
ему нужно вызвать. Следующий код провоцирует возникновение такой ошибки.
class Dog
{
public:
virtual void bark() { cout << "Гав!" << endl; }
virtual void eat() { cout << "Собака сыта." << endl; }
};
class Bird
{
public:
virtual void chirp() { cout << "Чирик!" << endl; }
virtual void eat() { cout << "Птичка сыта." << endl; }
};
class DogBird : public Dog, public Bird
int main(int argc, char** argv)
{
DogBird myConfusedAnimal;
myConfusedAnimal.eat(); // ОШИБКАI Неоднозначное обращение
// к методу eat().
}
Чтобы решить проблему неоднозначности, можно либо явно выполнить для объекта
"восходящее" приведение типа, скрыв тем самым от компилятора нежелательную
версию метода, либо использовать синтаксис, устраняющий неоднозначность. Например,
следующий код демонстрирует два способа вызова Dog-версии метода eat ().
static_cast<Dog>(myConfusedAnimal).eat(); // Вызов метода
// Dog::eat() путем
// расслоения.
myConf usedAnimal.Dog::eat(); // Вызов метода Dog::eat().
Методы самого подкласса также могут явно решить проблему неоднозначности
среди различных одноименных методов путем использования для доступа к
родительским методам оператора ": :". Например, класс DogBird мог бы предотвратить
ошибки неоднозначности в некотором другом коде, определив собственный метод
eat (). В теле этого метода можно четко определить, какая именно родительская
версия должна быть вызвана.
void DogBird::eat()
{
Dog:: eat О; // Явный вызов Dog-версии метода eat().
} f
294 Часть II. Пишем С++-код профессионально
Спровоцировать появление неоднозначности можно и по-другому. Для этого
достаточно дважды унаследовать один и тот же класс. Например, если бы класс Bird был
выведен из класса Dog, то код класса DogBird не скомпилировал ся бы, поскольку
в этом случае класс Dog оказался бы в роли неоднозначного базового класса.
class Dog { } ;
class Bird : public Dog {};
class DogBird : public Bird, public Dog {}; // ОШИБКА! Класс
// Dog является неоднозначным
// базовым классом.
Возникновение неоднозначных базовых классов либо связано
с запутанными "что-если"-примерами (подобными предыдущему),
либо является следствием неаккуратно построенных иерархий
классов. Иллюстрирующая неоднозначность схема иерархии
классов для предыдущего примера показана на рис. 10.8.
Неоднозначность может также встречаться и с членами
данных. Если бы в обоих классах Dog и Bird были члены данных
с одинаковыми именами, то неоднозначность моментально
проявилась бы при попытке получить доступ к члену с этим именем.
Рис. 10.8
Неоднозначные базовые классы
Более вероятный сценарий состоит в наличии у нескольких родителей общих предков.
Например, пусть оба класса Bird и Dog являются подклассами класса Animal (рис. 10.9).
Этот тип иерархии классов разрешен в C++, хотя неоднозначность имен здесь все же
имеет место. Например, если класс Animal имеет public-метод sleep (), то его нельзя
вызывать для объекта класса DogBird, поскольку компилятор не будет "знать", какую
версию этого метода ему следует вызвать: унаследованную классом Dog или классом Bird.
Такие "ромбовидные" иерархии классов лучше всего
использовать следующим образом. "Вершину" иерархии
следует сделать абстрактным базовым классом, объявив
все его методы чисто виртуальными. А поскольку этот
класс лишь объявляет методы и не предоставляет их
определений, это означает, что реальных методов в базовом
классе не существует, и, следовательно, на этом уровне
неоднозначности нет.
В следующем примере реализуется "ромбовидная"
иерархия классов с чисто виртуальным методом eat (),
который должен быть переопределен каждым подклассом.
В класс DogBird по-прежнему нужно внести ясность насчет того, от какого родителя
в нем используется метод eat (), ведь любая неоднозначность будет вызвана тем
фактом, что классы Dog и Bird имеют один и тот же метод, а не тем, что они выведены из
одного и того же класса.
class Animal
Рис. 10.9
public:
virtual void eat()
0;
Глава 10. Осваиваем механизм наследования 295
};
class Dog : public Animal
{
public:
virtual void bark() { cout << "Гав!" << endl; }
virtual void eat() { cout << "Собака сыта." << endl; }
};
class Bird : public Animal
{
public:
virtual void chirp() { cout << "Чирик!" << endl; }
virtual void eat() { cout << "Птичка сыта." << endl; }
};
class DogBird : public Dog, public Bird
{
public:
virtual void eat() { Dog-.:eat(); }
b
Более тонкий механизм использования вершины в "ромбовидной" иерархии
классов разъясняется в конце этой главы (в разделе "Виртуальные базовые классы").
Применение множественного наследования
Возможно, теперь вы подумаете, что вряд ли кто-то вообще захочет иметь дело
с множественным наследованием. Самый распространенный случай применения
множественного наследования — определить класс, который одновременно является
разновидностью нескольких сущностей. Как упоминалось в главе 3, любые реальные
объекты, соответствующие этой схеме, вряд ли можно корректно перевести на язык
программного кода.
Один из самых простых вариантов применения множественного наследования
состоит в реализации "смешанных" классов (они рассматриваются в главе 3). Пример
реализации с использованием множественного наследования приведен в главе 25.
Кроме того, множественное наследование часто применяется для моделирования
классов, основанных на использовании компонентных объектов. В главе 3 приведен
пример тренажера управления самолетом. Класс Airplane включал мотор, фюзеляж,
рычаги управления, шасси и другие компоненты. Хотя в типичной реализации класса
Airplane каждый из этих компонентов принято делать отдельным членом данных,
вы могли бы попытаться использовать множественное наследование. Класс самолета
мог бы стать производным от классов мотора, фюзеляжа и рычагов управления,
чтобы получить в наследство поведение и свойства всех своих компонентов. Мы не
рекомендуем использовать такой вид проектирования, поскольку в этом случае
отношение типа has-a можно легко спутать с наследованием, которое следует применять
к описанию отношений типа is-a.
296 Часть II. Пишем С++-код профессионально
Подводные рифы" наследования
Расширение класса часто сопряжено с необходимостью искать ответы на самые
разные вопросы. Какие характеристики класса можно изменить, а какие— нельзя?
Что в действительности делает таинственное ключевое слово virtual? На эти и
многие другие вопросы мы ответим в следующих разделах.
Изменение характеристик переопределенного метода
В большинстве случаев метод переопределяется с целью изменения его
реализации. Но иногда требуется изменить другие характеристики метода.
Изменение типа значения, возвращаемого методом
Существует мнение, что переопределение метода должно точно соответствовать
его объявлению, или сигнатуре, которую использует его суперкласс. Реализация может
быть изменена, но сигнатура должна оставаться той же.
Однако в C++ при переопределении метода можно изменить тип возвращаемого им
значения, если исходный тип является указателем или ссылкой на некоторый класс, а
новый тип — указателем или ссылкой на производный. Такие типы называются ковариант-
ными. Упомянутая возможность оказывается весьма полезной в случае, если суперкласс
и подкласс работают с объектами в параллельной иерархии, т.е. в другой группе классов,
которая не имеет прямого отношения, но все же связана с первой иерархией классов.
Например, рассмотрим гипотетический имитатор вишневого сада. Для этого нам
понадобятся две иерархии классов, которые моделируют различные, но явно
связанные между собой реальные объекты. Первая иерархическая цепочка будет состоять из
базового класса Cherry и подкласса BingCherry, а вторая— из базового класса
CherryTree и подкласса BingCherryTree (рис. 10.10).
Cherry
i
k
BingCherry
CherryTree
i
k
BingCherryTree
Рис. 10.10
Теперь предположим, что класс CherryTree содержит метод pick (), который
будет "снимать" с дерева по одной вишенке.
Cherry* CherryTree::pick()
{
return new Cherry();
}
В подклассе BingCherryTree нам нужно переопределить этот метод. (Ведь
собранные вишни нужно перебрать.) Поскольку объект класса BingCherry— это, по
сути, объект типа Cherry, мы могли бы оставить сигнатуру метода как есть и
переопределить его, как показано в следующем примере. Указатель на тип BingCherry
автоматически приводится к указателю на тип Cherry.
Cherry* BingCherryTree::pick()
{
BingCherry* theCherry = new BingCherry();
Глава 10. Осваиваем механизм наследования 297
theCherry->polish();
return theCherry,-
}
Приведенная реализация совершенно корректна, но поскольку вы знаете, что
метод класса BingCherryTree должен всегда возвращать объекты типа BingCherry, то
для потенциальных пользователей этого класса сей факт можно было бы обозначить
путем изменения типа возвращаемого методом значения.
BingCherry* BingCherryTree::pick()
{
BingCherry* theCherry = new BingCherry();
theCherry->polish();
return theCherry,-
}
Чтобы узнать, можно ли изменить тип значения, возвращаемого
переопределенным методом, достаточно проверить, будет ли работать уже существующий (т.е.
работавший прежде) код. В предыдущем примере изменение типа значения,
возвращаемого методом, было приемлемым, поскольку любой код, который предполагает, что
метод pick () всегда будет возвращать объект типа Cherry*, прекрасно скомпили-
руется и корректно отработает. Поскольку объект типа BingCherry— это
одновременно объект и типа Cherry, то любые методы, которые вызывались для Cherry-
Tree-версии метода pick (), по-прежнему будут вызываться для BingCherryTree-
версии метода pick ().
Вы не могли бы заменить тип возвращаемого методом значения каким-нибудь
типом, совершенно не связанным с прежним, например, типом void*. Так, следующий
код не скомпилируется, поскольку компилятор "решит", что вы пытаетесь
перегрузить метод pick (), но не можете отличить BingCherryTree-версию метода pick ()
от его CherryTree-версии (ведь типы значений, возвращаемых методами, не
используются в устранении неоднозначности при перегрузке методов).
void* BingCherryTree::pick() // ОШИБКА!
{
BingCherry* theCherry = new BingCherry();
theCherry->polish();
return theCherry,-
}
Изменение параметров метода
В общем случае попытка изменить параметры переопределяемого метода означает
не переопределение "старого" метода, а создание нового. Возвращаясь к примеру
с классами Super и Sub (см. начало этой главы), вы могли бы попытаться
переопределить метод someMethod () в классе Sub, используя новый список аргументов.
class Super
{
public:
298 Часть II. Пишем C++—код профессионально
Super();
virtual void someMethod();
};
class Sub : public Super
{
public:
Sub();
virtual void someMethod(int i); // Компилируется, но не
// переопределяет.
virtual void someOtherMethod();
b
Реализация этого метода выглядит так.
void Sub::someMethod(int i)
{
cout << "Это Sub-версия метода someMethod() с аргументом "
<< i << "." << endl;
}
Предыдущее определение класса скомпилируется, но метод someMethod () не
будет переопределен. Поскольку аргументы метода отличаются от аргументов
одноименного метода в родительском классе, это значит, что мы создали новый метод,
который существует только в классе Sub. Если вам нужно, чтобы метод someMethod (),
который принимает int-аргумент, работал только для объектов класса Sub,
предыдущий код корректен. Однако следует признать, что стилистически весьма сомнительно
иметь метод, имя которого совпадает с именем метода суперкласса и при этом не
имеет к нему никакого отношения.
В действительности исходный метод теперь скрыт, т.е. он не попадает в зону
видимости класса Sub. Следующий пример не скомпилируется, поскольку с точки
зрения класса Sub больше не существует версии метода someMethod () без аргументов.
Sub mySub;
mySub.someMethod(); // ОШИБКА! Не скомпилируется, поскольку
// исходный метод скрыт.
Возможна ситуация, которая реально позволяет изменить список аргументов для
переопределенного метода. "Секрет" в том, что новый список аргументов должен
быть совместим со старым. Если бы мы модифицировали приведенный выше пример,
наделив параметре значением по умолчанию, Sub-версия метода someMethod()
реально переопределяла бы Super-версию метода someMethod ().
class Sub : public Super
{
public:
Sub () ;
virtual void someMethod(int i = 2); // переопределение
virtual void someOtherMethod();
\
Глава 10. Осваиваем механизм наследования 299
В чем же здесь разница? После корректного переопределения метода другой код
должен иметь возможность одинаковым способом вызывать обе версии (суперкласса
и подкласса). Как и в случае проверки возможности изменения типа значения,
возвращаемого методом, испытаем возможность изменения его аргументов с помощью
существующего кода и посмотрим, не потребует ли он модификации. При наличии аргумента
по умолчанию любой код, который вызывал Super-версию метода someMethod (),
сможет вызывать его Sub-версию без внесения каких-либо изменений.
Следующий пример показывает, что на этот раз класс Sub действительно
переопределяет Super-версию метода someMethod (). Более того, Sub-версия будет
корректно вызываться даже при использовании Super-ссылки.
Sub mySub;
Super& ref = mySub;
mySub.someMethod(); // Вызов Sub-версии метода
// аргументом по умолчанию
mySub.someMethod(l); // Вызов Sub-версии метода
// заданным аргументом.
ref.someMethod(); // Вызов Sub-версии метода
// аргументом по умолчанию
Результаты выполнения этого кода таковы.
Это Sub-версия метода someMethod() с аргументом 2.
Это Sub-версия метода someMethod() с аргументом 1.
Это Sub-версия метода someMethodО с аргументом 2.
Хотя "один пирог два раза не съешь", все же существует еще один способ, который
позволит это сделать, т.е. эффективно переопределить метод в подклассе с новой
сигнатурой, продолжая при этом наследовать версию суперкласса. Для этого
используется ключевое слово using, которое позволяет явно включить определение метода
из суперкласса в тело подкласса.
class Super
{
public:
Super();
virtual void someMethod();
};
class Sub : public Super
{
public:
Sub();
using Super:: someMethod,- // Явно "наследует"
// Super-версию.
virtual void someMethod(int i); // Добавляет новую
// версию метода someMethod().
virtual void someOtherMethod () ,-
ь
Переопределение метода с изменением списка параметров
встречается довольно редко.
someMethod() с
someMethod() с
someMethod() с
300 Часть П. Пишем C++—код профессионально
Специальные случаи в переопределении методов
При переопределении метода возможны особые ситуации, которые требуют
специального внимания. Их рассмотрению и посвящен этот раздел.
Если переопределяется статический метод суперкласса
В C++ нельзя переопределять статический метод. В большинстве случаев этого
знания вполне достаточно. Однако есть некоторые нюансы, которые вы должны
хорошо понимать.
Прежде всего, метод не может быть одновременно статическим и виртуальным.
Отсюда следует, что попытка переопределить static-метод приведет не к тем
результатам, которые вы ожидали получить. Если в своем подклассе вы определили static-
метод, имя которого совпадает с именем static-метода в суперклассе, то в
действительности вы имеете два отдельных метода. В следующем коде демонстрируются два класса,
в каждом из которых есть "свой" static-метод с именем beStatic (). Эти два метода
никак не связаны.
class SuperStatic
{
public:
static void beStatic() {
cout << "Вызов статического SuperStatic-метода."
< < endl;}
};
class SubStatic
{
public:
static void beStatic() {
cout << "Вызов статического SubStatic-метода."
<< endl;}
};
Поскольку static-метод принадлежит классу, то при обращении к одноименным
методам для двух различных классов будут вызваны соответствующие версии. При
выполнении этих строк кода
SuperStatic::beStatic{);
SubStatic::beStatic();
получим следующие результаты.
Вызов статического SuperStatic-метода.
Вызов статического SubStatic-метода.
Все предельно ясно, если доступ к методам реализуется через класс. Но если
вместо классов используются объекты, ясность несколько теряется. В C++ синтаксически
static-метод можно вызвать и для объекта, но в действительности такой метод
существует только для класса. Рассмотрим следующий код.
SubStatic mySubStatic,-
SuperStatic& ref = mySubStatic;
mySubStatic.beStaticO ;
ref.beStatic() ;
Глава 10. Осваиваем механизм наследования 301
При первом обращении к методу beStatic () будет, без сомнения, вызвана его
SubStatic-версия, поскольку она явно вызывается для объекта, объявленного с типом
SubStatic. Co вторым вызовом не все так ясно. Объект в момент вызова представляет
собой ссылку на тип SuperStatic, но она указывает на объект типа SubStatic. В этом
случае будет вызвана SuperStatic-версия метода beStatic (). И вот почему. Дело
в том, что в C++ при вызове статического метода "конечный" объект реального
значения не имеет. Во время компиляции важен лишь тип, указанный при вызове
статического метода. В данном случае таким типом является ссылка на класс SuperStatic.
Результаты выполнения предыдущего кода таковы.
Вызов статического SubStatic-метода.
Вызов статического SuperStatic-метода.
Статические методы связаны с классом, в котором они определены,
а не с объектом. Метод в классе, который обращается к статическому
методу, вызывает на самом деле версию, определенную в этом классе,
независимо от "динамического" типа объекта, для которого
вызывается оригинальный метод.
Если перегружается метод суперкласса
При переопределении метода неявным образом скрываются любые другие его
версии. И в этом есть логика. Давайте подумаем, почему мы хотим изменить
конкретные версии метода? Рассмотрим следующий подкласс, который переопределяет
метод, не переопределяя его перегруженных "братских" версий.
class Foo
{
public:
virtual void overload() {
cout << "Foo-версия метода overload О" << endl; }
virtual void overload(int i) {
cout << "Foo-версия метода overload(int i)" << endl;}
};
class Bar : public Foo
{
public:
virtual void overload() {
cout << "Bar-версия метода overloadO" << endl; }
При попытке вызвать версию метода overload (), которая принимает int-параметр
для объекта класса Ваг, код не скомпилируется, поскольку она не была явно
переопределена.
myBar.overload(2); // ОШИБКА! Формат вызова метода
// overload(int) не совпадает с его
// сигнатурой в определении класса.
Однако доступ к этой версии метода для объекта класса Ваг все же можно
получить. Для этого достаточно использовать указатель или ссылку на объект класса Foo.
Bar myBar;
Foo* ptr = &myBar;
ptr->overload(7);
302 Часть II. Пишем C++—код профессионально
Сокрытие нереализованных перегруженных методов в C++ носит лишь
поверхностный характер. Объекты, которые явно объявлены как экземпляры подтипа, не
сделают метод доступным, но путем простого приведения к типу суперкласса можно
эффективно восстановить доступ к родительским методам.
Ключевое слово using можно использовать для перегрузки всех версий метода
в случае, когда в действительности нужно изменить только одну. В следующем коде
в определении класса Ваг используется одна версия метода overload () из класса Foo
и явным образом перегружаются все остальные.
class Foo
{
public:
virtual void overload() {
cout << "Foo-версия метода overload()" << endl; }
virtual void overload(int i) {
cout << "Foo-версия метода overload(int i)" << endl;}
};
class Bar : public Foo
{
public:
using Foo::overload;
virtual void overload() {
cout << "Bar-версия метода overloadO" << endl; }
};
Чтобы избежать ошибок сокрытия отдельных версий
перегруженных методов, рекомендуется переопределять все версии. Это можно
сделать либо явным образом, либо с помощью ключевого слова using.
Если метод суперкласса объявлен закрытым или защищенным
В переопределении private- или protected-метода совершенно нет ничего
некорректного. Напомним, что спецификатор доступа для метода определяет, кто
сможет его вызвать. Тот факт, что подкласс не может вызывать private-методы
родительского класса, совсем не означает, что подкласс не может их переопределить.
И в самом деле, переопределение private- или protected-метода— обычная
практика в объектно-ориентированных языках программирования, которая позволяет
подклассам обозначить собственную "уникальность" на основе суперкласса.
Например, следующий класс является частью имитатора автомобиля и
предназначен для оценки расстояния в милях, которое может проехать автомобиль, на основе
запаса топлива и величины его расхода.
class MilesEstimator
{
public:
virtual int getMilesLeft() {
return (getMilesPerGallon() * getGallonsLeft () ) ,-
}
virtual void setGallonsLeft(int inValue) {
mGallonsLeft = inValue; }
virtual int getGallonsLeft() { return mGallonsLeft; }
private: ^
int mGallonsLeft;
virtual int getMilesPerGallon() { return 20; }
Глава 10. Осваиваем механизм наследования 303
Метод getMilesLef t () выполняет вычисления на основе результатов двух
методов. В следующем коде используется класс MilesEstimator для подсчета расстояния
в милях, которое еще может преодолеть автомобиль, имея 2 галлона бензина.
MilesEstimator myMilesEstimator,-
myMilesEstimator.setGallonsLeft(2);
cout << "Я могу проехать еще "
<< myMilesEstimator.getMilesLeftО << " миль." << endl;
Результаты выполнения этого кода таковы.
Я могу проехать еще 4 0 миль.
Чтобы сделать имитатор более интересным, можно ввести различные типы
транспортных средств. В нашем имитаторе (на основе класса MilesEstimator)
предполагается, что все автомобили на прохождение 20 миль расходуют один галлон
бензина, но это значение возвращается из отдельного метода, и поэтому подкласс мог
бы его переопределить.
class EfficientCarMilesEstimator : public MilesEstimator
{
private:
virtual int getMilesPerGallon () { return 35,- }
};
Переопределив один этот private-метод, новый класс совершенно изменил бы
поведение существующих немодифицированных public-методов. Метод getMilesLef t ()
в суперклассе будет автоматически вызывать переопределенную версию private-метода
getMilesPerGallon (). Приведем пример использования этого нового класса.
EfficientCarMilesEstimator myEstimator;
myEstimator.setGallonsLeft(2);
cout << "Я могу проехать еще " << myEstimator.getMilesLeft{)
<< " миль." << endl;
На этот раз результаты выполнения кода отражают новое (переопределенное)
поведение имитатора.
Я могу проехать еще 70 миль.
Переопределение private- и protected-методов — прекрасный
способ изменить нужные свойства класса без "капитального ремонта".
Если метод суперкласса имеет аргументы по умолчанию
Подклассы и суперклассы могут по-разному определять аргументы по умолчанию,
но используемый аргумент зависит от объявленного типа переменной, а не от
базового объекта. Рассмотрим простой пример подкласса, который в переопределяемом
методе предоставляет другой аргумент по умолчанию.
class Poo
{
public:
virtual void go(int i = 2) {
cout << "Foo-версия вызвана с параметром " << i
< < endl; }
Ь
304 Часть II. Пишем C++—код профессионально
class Bar : public Foo
{
public:
virtual void go(int i = 7) {
cout << "Bar-версия вызвана с параметром " << i
< < endl; }
};
Если метод go () вызывается для объекта класса Ваг, будет выполнена Bar-версия
метода до () с аргументом, равным 7 по умолчанию. Если метод до () вызывается для
объекта класса Foo, будет выполнена Foo-версия того же метода с аргументом,
равным 2 по умолчанию. Но если (это самый интересный момент) метод до ()
вызывается для указателя или ссылки на тип Foo, которая в действительности указывает на
объект типа Ваг, будет выполнена Bar-версия метода до (), использующая по
умолчанию аргумент 2. Продемонстрируем это поведение на примере.
Foo myFoo;
Bar myBar;
Foo& myFooReferenceToBar;
myFoo.go();
myBar.go();
myFooReferenceToBar.go();
При выполнении этого кода получим такие результаты.
Foo-версия вызвана с параметром 2
Bar-версия вызвана с параметром 7
Ваг-версия вызвана с параметром 2
Хитро, да? Дело в том, что C++ связывает аргументы по умолчанию с типом
переменной, обозначающей объект, а не с самим объектом. По той же причине аргументы
по умолчанию не "наследуются" в C++. Если бы класс Ваг не обеспечил действие
аргументов по умолчанию, как в родительском классе, он бы перегрузил метод до () его
новой версией с нулевым количеством аргументов.
При переопределении метода, в котором аргумент задан по
умолчанию, вам следует также позаботиться о задании аргумента по
умолчанию, причем желательно с использованием того лее значения.
Если метод суперкласса имеет другой уровень доступа
Есть два способа изменить уровень доступа к методу, т.е. вы могли бы попытаться
сделать доступ к нему более или менее ограниченным. Ни один из этих вариантов не
имеет большого смысла в C++, но есть ряд законных причин для подобных попыток.
Для того чтобы еще более ограничить метод (или член данных), существует два
пути. Первый состоит в изменении спецификатора доступа для всего базового класса.
Этот способ описан в конце данной главы. Второй подход заключается в простом
переопределении доступа в подклассе, как показано в следующем примере.
class Gregarious
{
public:
virtual void talk() {
cout << "Класс Gregarious приветствует вас!"
< < endl; }
};
class Shy : public Gregarious
Глава 10. Осваиваем механизм наследования 305
{
protected:
virtual void talk() {
cout << "Класс Shy лишь обозначает приветствие."
<< endl; }
}; \
Защищенная версия метода talk () в классе Shy соответствующим образом
переопределяет метод. Теперь любой код клиента, который попытается вызвать метод
talk () для объекта класса Shy, получит сообщение об ошибке.
myShy.talk(); // ОШИБКА! Попытка обратиться к protected-методу
И все же этот метод не полностью защищен. Чтобы получить доступ к методу,
который, как вам кажется, хорошо защищен, достаточно всего лишь использовать
ссылку или указатель на тип Gregarious.
Shy myShy;
Gregarious& ref = myShy;
ref.talk();
Результаты выполнения пре идущего кода таковы.
Класс Shy лишь обозначает приветствие.
Этот пример доказывает, что, сделав метод защищенным в подклассе, мы в
действительности переопределяем его (поскольку версия подкласса была корректно
вызвана), но он также доказывает, что protected-доступ не является полностью
обеспеченным, если в суперклассе метод был определен открытым (public).
Не существует надлежащего способа (или веской причины) для
ограничения доступа к public-методу родительского класса.
Намного проще (и в этом больше смысла) уменьшить ограничения в доступе к
методу в подклассе. Проще всего это сделать с помощью некоторого public-метода,
который вызывает protected-метод из суперкласса.
class Secret
{
protected:
virtual void dontTell() {
cout << "Я буду нем как рыба." << endl; }
};
class Blabber : public Secret
{
public:
virtual void tell() { dontTell(); }
Клиент, вызывающий public-метод tell() объекта класса Blabber,
эффективно получит доступ к protected-методу класса Secret. Конечно, реально это не
изменяет уровень доступа к методу dontTell (), но обеспечивает открытый способ
обращения к нему.
Можно было бы также явно переопределить метод dontTell () в подклассе Blabber
и с помощью открытого доступа наделить его новым поведением. Это имеет гораздо
больше смысла, чем понижение уровня доступа, поскольку совершенно ясно, что
происходит со ссылкой или указателем на базовый класс. Например, предположим,
что класс Blabber в действительности определил метод dontTell () открытым.
306 Часть II. Пишем C++—код профессионально
class Secret
{
protected:
virtual void dontTellO {
cout << "Я буду нем, как рыба." << endl; }
};
class Blabber : public Secret
{
public:
virtual void dontTellO {
cout << "Я все расскажу!" << endl; }
};
Если метод dontTellO вызывается для объекта класса Blabber, он выведет
строку Я все расскажу!.
myBlabber.dontTell(); // Выводит строку "Я все расскажу!"
Но в этом случае protected-метод в суперклассе остается защищенным, поскольку
любая попытка вызвать Secret-версию метода dontTellO через указатель или
ссылку не скомпилируется.
Blabber myBlabber;
Secret& ref = myBlabber;
Secret* ptr = &myBlabber;
ref.dontTell(); // ОШИБКА! Попытка доступа к protected-методу.
ptr->dontTell(); // ОШИБКА! Попытка доступа к protected-методу.
Единственный по-настоящему полезный способ изменить уровень
доступа к защищенному методу состоит в применении спецификатора
доступа, который уменьшает ограничение.
Конструкторы копии и оператор равенства
В главе 9 мы говорили о том, что определение конструктора копии и оператора
присваивания считается хорошей практикой программирования при динамическом
распределении памяти в классе. Создавая подкласс, необходимо с большим
вниманием отнестись к конструкторам копии и методу operator= ().
Если ваш подкласс не содержит никаких специальных данных (указателей,
например), для которых требовалось бы наличие нестандартного конструктора копии
или оператора присваивания (operator= ()), определять их необязательно,
причем независимо от того, имеются ли они в суперклассе. Если в подклассе
конструктор копии не определен, при копировании объекта будет вызван родительский
конструктор копии. Аналогично, если в подкласс явно не включить операторный метод
operator= (), будет использован вариант, действующий по умолчанию, и вызван
родительский метод operator^ ().
Но если в подклассе вы таки решили определить конструктор копии, вам придется
явно обозначить его связь с родительским конструктором копии, как показано в
следующем коде. Если этого не сделать, для родительской части объекта будет
использован конструктор по умолчанию (а не конструктор копии!).
Глава 10. Осваиваем механизм наследования 307
class Super
{
public:
Super();
Super(const Super& inSuper);
};
class Sub : public Super
{
public:
Sub();
Sub(const Sub& inSub);
};
Sub::Sub(const Sub& inSub) : Super(inSub)
Аналогично, если подкласс переопределяет метод operator= (), ему практически
всегда необходимо также вызывать родительскую версию оператора присваивания.
Нежелание это сделать может быть оправдано лишь какими-нибудь исключительными
обстоятельствами, создавшимися некоторой "экзотической" причиной, по которой
вам нужно при выполнении присваивания реализовать эту операцию лишь частично.
Как вызвать родительский оператор присваивания из подкласса, демонстрируется на
примере следующего кода.
Sub& Sub::operator=(const Sub& inSub)
{
if (ScinSub == this) {
return *this,-
}
Super::operator=(inSub) // Вызов родительского метода
// operator=.
// Выполнение необходимых присваиваний для подкласса.
return (*this);
}
Если в подклассе не определен собственный конструктор копии или
оператор operator* (), родительские методы будут продолжать
работать. Если же в подклассе определить собственный конструктор
копии или оператор operator» (), то необходимо явно вызвать
родительские версии этих методов. *
Правда о ключевом слове virtual
При первом знакомстве с темой переопределения методов мы утверждали, что
должным образом можно переопределить только virtual-методы. Мы не зря
употребили здесь словосочетание должным образом. Дело в том, что, если метод не
определен как виртуальный, его все равно можно попытаться переопределить, но это будет
не всегда корректно.
308 Часть II. Пишем C++—код профессионально
Сокрытие вместо переопределения
В следующем коде демонстрируется суперкласс и подкласс, каждый из которых
содержит по одному методу. В подклассе делается попытка переопределить метод,
объявленный в суперклассе без ключевого слова virtual.
class Super
{
public:
void go() {
cout << "Метод go() вызван для Super-объекта."
< < endl,- }
};
class Sub : public Super
{
public:
void go() { cout << "Метод go() вызван для Sub-объекта."
< < endl; }
};
Создается впечатление, что попытка вызвать метод до () для объекта класса Sub
вполне работоспособна.
Sub mySub;
mySub.go();
При выполнении этого кода, как и ожидалось, выведется сообщение Метод до ()
вызван для Sub-объекта.. Но, поскольку метод до () не виртуальный, он в
действительности не переопределяется. Вместо этого в классе Sub создан новый метод, также
именуемый до (), который совершенно не связан с методом до () класса Super. Чтобы
убедиться в этом, достаточно вызвать его в контексте указателя или ссылки на класс Super.
Sub mySub;
Superb ref = mySub;
ref.go();
Вместо ожидаемого сообщения Метод go() вызван для Sub-объекта., будет
выведена фраза Метод до () вызван для Super-объекта.. Причина в том, что
переменная ref является ссылкой на тип Super, и ключевое слово virtual опущено. При
вызове метода до () просто выполняется Super-метод. А поскольку он не виртуальный,
у компилятора нет необходимости интересоваться, переопределен ли он в подклассе.
При попытке переопределить невиртуальный метод определение
суперкласса "скрывается", а этот метод будет использован лишь
в контексте подкласса.
Реализация ключевого слова virtual
Чтобы понять, почему происходит сокрытие методов, нужно больше знать о том,
какую функцию реально выполняет ключевое слово virtual. При компиляции класса
в C++ создается двоичный объект, который содержит все члены данных и методы
Глава 10. Осваиваем механизм наследования 309
класса. Если не использовать ключевое слово virtual, переход на соответствующий
метод жестко кодируется непосредственно в месте его вызова в соответствии с типом,
известным во время компиляции.
Если же метод объявляется виртуальным, его реализация хранится в специальной
области памяти, именуемой vtctble (от "virtual table" — виртуальная таблица, или г>таблица).
Каждый класс, который содержит один или несколько virtual-методов,
включает v-таблицу с указателями на реализации этих virtual-методов. При вызове
метода для заданного объекта по указателю из этой таблицы выполняется
соответствующая версия метода с учетом типа этого объекта, а не типа переменной,
используемой для доступа к нему.
На рис. 10.11 схематично показано, как с помощью v-таблицы осуществляется
перегрузка методов. Здесь используются два класса, Super и Sub. В классе Super
объявлено два виртуальных метода, foo () и bar (). Из ^таблицы класса Super
видно, что каждый метод имеет собственную реализацию. Класс Sub не
переопределяет Super-версию метода foo (), поэтому ^таблица класса Sub указывает на ту же
самую реализацию метода foo (). Но метод bar () переопределяется в классе Sub,
поэтому ^таблица указывает на новую его версию.
Super
vtable
foo
bar
Реализация метода
Super: :foo()
Реализация метода
Super::bar()
Sub
vtable
foo
bar
Рис. 10.11
Реализация метода
Sub::bar()
Реабилитация ключевого слова virtual
Получив совет сделать все методы виртуальными, вы могли бы поинтересоваться,
почему вообще существует ключевое слово virtual. Почему бы компилятору самому
не делать все методы виртуальными автоматически? В принципе, это возможно.
Многие считают, что все должно быть виртуальным. В языке Java именно так и есть.
Аргумент против "виртуализации" всех методов (как, собственно, и причина
создания ключевого слова virtual) в первую очередь связан с необходимостью
системных затрат на обслуживание зу-таблицы. Для вызова virtual-метода программа должна
выполнить дополнительную операцию по разыменованию указателя па соответст-
310 Часть II. Пишем С+н—код профессионально
вующий код. В большинстве случаев это не очень сильно отражается на общей
производительности программ, но разработчики C++ считают, что было бы лучше (по
крайней мере, пока) предоставить право программисту самому решать, стоит ли понижать
быстродействие его программы. Если метод не предполагается переопределять,
значит, нет нужды делать его виртуальным и наносить ущерб производительности кода.
Виртуальность влияет также и на размер программного кода. Помимо затрат на
реализацию метода, каждому объекту также потребуется указатель, который будет
занимать хоть и небольшую, но вполне определенную область памяти.
Ужасные "новости" от невиртуальных деструкторов
Программисты, которые не согласны с мнением всеобщей "виртуализации методов",
по-прежнему придерживаются этого правила в отношении деструкторов. Дело в том, что
использование невиртуальных деструкторов может легко привести к утечке памяти.
Например, если некоторый подкласс использует память, которая динамически
выделяется в конструкторе и освобождается в деструкторе, то она в действительности
никогда не будет освобождена при отсутствии вызова деструктора. Как показано
в следующем коде, совсем нетрудно "обхитрить" компилятор, заставив его
проигнорировать обращение к деструктору, если он не является виртуальным.
class Super
{
public:
Super();
-Super () ,-
};
class Sub : public Super
{
public:
Sub() { mString = new char [30] ,- }
~Sub() { delete[] mString; }
protected:
char* mString,-
};
int main(int argc, char** argv)
{
Super* ptr = new Sub(); // Здесь выделяется mString-память.
delete ptr; // Вызывается деструктор -Super(), но не
// деструктор -Sub(), поскольку он
// не является виртуальным!
}
Мы настоятельно рекомендуем вам делать все методы (за исключе-
ни ^м конструкторов) виртуальными, если, конечно, у вас нет
серьезной причины так не поступить. Конструкторы нельзя делать
виртуальными (да в этом нет и необходимости), поскольку при создании
объекта всегда точно указывается его класс.
Глава 10. Осваиваем механизм наследования 311
Динамические возможности преобразования типов
и получения информации о типе
Считается, что по сравнению с другими объектно-ориентированными языками
программирования C++ больше нацелен на процесс компиляции. Переопределение
методов, как вы узнали выше, работает благодаря наличию определенного уровня
косвенности между методом и его реализацией, а не потому, что объект обладает
встроенными знаниями о собственном классе (типе).
Однако в C++ предусмотрены средства, которые позволяют в динамике получить
информацию о типе объекта. Эти средства обычно группируются под общим,
названием механизма RTTI (Runtime Type Identification— идентификация типов в процессе
выполнения программы). Механизм RTTI включает ряд средств, которые работают
с информацией о принадлежности объекта к тому или иному классу.
Оператор dynamiccast
В главе 1 вы прочитали об операторе static_cast, который позволяет
преобразовать тип объекта. Своим названием оператор static_cast обязан тому факту, что
результат преобразования встраивается в скомпилированный код. Нисходящее
статическое преобразование всегда успешно, независимо от динамического типа объекта.
Как упоминалось выше, оператор dynamic_cast обеспечивает более безопасный
механизм преобразования типов в рамках объектно-ориентированной иерархии
классов. Напомним, что синтаксис динамического преобразования типа объекта
аналогичен статическому. Но при использовании динамического преобразования
оператор может возвратить значение NULL (для указателя) или сгенерировать исключение
(для ссылки). В следующем примере показано, как можно корректно выполнить
динамическое преобразование для ссылки.
SomeObject myObject = getSomeObject();
try {
SomeOtherObjectSc myRef = dynamic_cast<SomeOtherObject&>(
myObject);
} catch (std::bad_cast) {
cerr << "He удалось преобразовать объект к желаемому типу."
< < endl;
}
Оператор type id
Оператор type id позволяет запросить информацию о типе объекта во время
выполнения программы. В большинстве случаев у программистов нет насущной
необходимости использовать оператор type id, поскольку любой код, который в условных
конструкциях определяет тип объекта, лучше обрабатывать с помощью виртуальных методов.
В следующем коде оператор type id используется для вывода сообщения на основе
типа объекта.
#include <typeinfo>
void speak(const Animal& inAnimal)
{
if (typeid(inAnimal) == typeid(Dogfc)) {
cout << "Гав!" << endl;
312 Часть II. Пишем C+-i—код профессионально
} else if (typeid(inAnimal) == typeid(Bird&) {
cout << "Чирик!" << endl;
Код, подобный представленному выше, имеет смысл тут же переделать, используя
виртуальные методы. В данном случае лучше всего в классе Animal объявить virtual-
метод speak (). Затем в классе Dog достаточно переопределить этот метод для вывода
"собачьего" голоса "Гав!", а в классе Bird— для вывода "птичьей" партии "Чирик!".
Такой способ лучше подходит к объектно-ориентированному программированию, когда
функциональность, связанная с объектами, определяется самими объектами.
Однако оператор type id удобно использовать при отладке. Полезно выводить
тип объекта и в целях регистрации событий. Рассмотрим следующий код. Функция
logOb j ect () принимает "регистрируемый" объект в качестве параметра. Любой объект,
который может быть зарегистрирован, является производным от класса Loggable
и поддерживает метод getLogMessage (). Таким образом, Loggable является
смешанным классом.
#include <typeinfo>
void logObject(Loggable& inLoggableOb j ect)
{
logfile << typeid(inLoggableObject).name() << " ";
logfile << inLoggableObject.getLogMessage() << endl;
}
Функция logOb j ect () сначала записывает в файл имя класса регистрируемого
объекта, а затем — его сообщение. Таким образом, при чтении впоследствии
регистрационного журнала молено понять, какой объект ответственен за каждую строку файла.
Неоткрытое наследование
Во всех приведенных выше примерах родительские классы перечислялись с
использованием ключевого слова public. Но вам, вероятно, интересно, можно ли
наследовать классы с помощью ключевого слова private или protected. Конечно,
можно, хотя такой вариант наследования не так популярен, как открытый.
Объявление защищенных отношений с родительским классом (т.е. с
использованием ключевого слова protected) означает, что все public- и protected-методы и
члены данных суперкласса становятся защищенными в контексте подкласса. Аналогично
использование спецификатора доступа private означает, что все public-, protected-
и private-методы и члены данных суперкласса становятся закрытыми в подклассе.
Существует немного причин, по которым стоило бы понизить уровень доступа
родительского класса, но чаще всего они означают изъяны в проектировании иерархии
классов. Некоторые программисты злоупотребляют этим языковым средством (часто
в сочетании с множественным наследованием) для реализации "компонентов" класса.
Вместо создания класса Airplane, который должен содержать члены данных engine
и fuselage, они создают класс Airplane, который защищенно наследует классы
engine и fuselage. В этом случае класс Airplane для кода клиента выглядит не как
"класс-мотор" и "класс-фюзеляж" (поскольку все защищено), но внутренне он может
использовать все "моторно-фюзеляжные" функции.
Неоткрытое наследование используется довольно редко, и мы
рекомендуем относиться к нему с осторожностью.
Глава 10. Осваиваем механизм наследования 313
Виртуальные базовые классы
Выше в этой главе вы узнали о неоднозначных базовых классах, о ситуации, которая
возникает в случае, если родительские классы имеют общего родителя (см. рис. 10.9).
Мы рекомендовали вам всегда убеждаться в том, что этот общий родитель не имеет
никаких собственных функциональных средств. В этом случае его методы никогда
нельзя будет вызвать, а посему вам не придется опасаться возникновения проблем
неоднозначности .
В C++ предусмотрен еще один механизм решения этой проблемы в случае, если вы
посчитаете нужным, чтобы общий родитель имел собственные методы. Если этот
общий родитель будет оформлен как виртуальный базовый класс, то и речи не будет ни
о какой неоднозначности. В следующем коде базовый класс Animal пополнился
методом sleep (), а также модифицированы классы Dog и Bird, чтобы обеспечить
наследование базового класса Animal в качестве виртуального. Без использования
ключевого слова virtual обращение к методу sleep () для объекта DogBird было бы
неоднозначным, поскольку оба класса Dog и Bird унаследовали бы в этом случае
версии метода sleep () из класса Animal. Но при виртуальном наследовании класса
Animal в его потомках существует только одна копия каждого метода или члена.
class Animal
{
public:
virtual void eat() = 0;
virtual void sleep () { cout << "ззззз...." << endl; }
};
class Dog : public virtual Animal
{
public:
virtual void bark() { cout << "Гав!" << endl,- }
virtual void eat() { cout << "Собака сыта." << endl; }
};
class Bird : public virtual Animal
{
public:
virtual void chirp() { cout << "Чирик!" << endl; }
virtual void eat() { cout << "Птичка сыта." << endl; }
};
class DogBird : public Dog, public Bird
{
public:
virtual void eat () { Dog: : eat () ,- }
};
int main(int argc, char** argv)
{
DogBird myConfusedAnimal;
myConfusedAnimal.sleep(); // Неоднозначности нет, поскольку
314 Часть II. Пишем C++—код профессионально
// класс Animal наследуется
// виртуально.
}
Виртуальные базовые классы представляют собой прекрасный
способ избежать неоднозначности в иерархиях классов. Единственный
недостаток этого механизма состоит в том, что многие С++-програм-
мисты не знают о такой возможности.
Резюме
В этой главе раскрыты многие "тайны" наследования. Вы узнали о таких
приложениях механизма наследования, как возможность многократного использования
кода и полиморфизм. Мы посоветовали вам не злоупотреблять схемами
множественного наследования, поскольку зачастую это приводит к неудачным проектам. Кроме
того, вашему вниманию были предоставлены некоторые из менее популярных
"крайних" случаев, с которыми вы вряд ли будете сталкиваться в повседневном
программировании, но которые способны внести в код отвратительнейшие ошибки.
Наследование — это мощное средство языка, на освоение которого требуется
определенное время. Изучив примеры этой главы и запрограммировав собственные, вы
обязательно подружитесь с наследованием и будете активно использовать этот
механизм в объектно-ориентированном проектировании.
Пишем
обобщенный код
с помощью шаблонов
В C++ предусмотрена языковая поддержка не только для
объектно-ориентированного, но и для обобщенного программирования. Как упоминалось в главе 5, цель
обобщенного программирования— создание кода, предназначенного для многократного
использования. Основным инструментом такого программирования в C++ являются
шаблоны. И хотя они не относятся к числу объектно-ориентированных средств, их
можно эффективно сочетать с объектно-ориентированным программированием. К
сожалению, многие программисты считают шаблоны самой трудной частью C++ и поэтому
стараются избегать их. Но даже если вы никогда не писали собственных шаблонов, для
успешного использования стандартной библиотеки C++ вам просто необходимо
понимать их синтаксис и возможности.
Эта глава содержит подробности написания кода, которые важны для реализации
в проектировании принципа обобщенности (упоминаемом в главе 5). Освоив
материал этой главы, вы сможете использовать стандартную библиотеку шаблонов, которая
рассматривается в главах 21—23. В первой части этой главы описаны следующие
наиболее употребительные шаблонные средства, которые позволяют понять:
□ как написать шаблонные классы;
□ как компилятор обрабатывает шаблоны;
316 Часть II. Пишем C+-I—код профессионально
□ как организовать исходный код шаблона;
□ как использовать шаблонные параметры, не являющиеся типами;
□ как создать шаблоны отдельных методов класса;
□ как написать варианты шаблонов класса, настроенные на заданные типы;
□ как сочетать шаблоны и наследование;
□ как разработать шаблоны функций;
□ как сделать шаблонные функции "друзьями" шаблонных классов.
Во второй половине этой главы исследуются более трудные для понимания темы
шаблонных средств:
□ три вида шаблонных параметров;
□ частичная специализация;
□ дедукция шаблонов функции;
□ использование шаблонной рекурсии.
Представление о шаблонах
Основной "единицей" программирования в процедурной парадигме является
процедура, или функция. Полезность функций, в основном, выражается в том, что они
позволяют писать алгоритмы, которые не зависят от конкретных величин и могут поэтому
многократно использоваться для множества различных значений. Например, функция
sqrt () в языках С и C++ вычисляет квадратный корень из значения, заданного
инициатором вызова. Понятно, что функция, которая вычисляет квадратный корень только из
одного числа, например числа 4, не будет особенно полезной! Функция sqrt ()
написана с использованием параметра, который представляет "заместителя" для любого
значения, потенциально передаваемого инициатором вызова этой функции.
Специалисты по вычислительной технике говорят, что функции параметризируют значения.
Парадигма объектно-ориентированного программирования добавляет концепцию
объектов, которая группирует связанные данные и поведение, но не изменяет способ,
с помощью которого функции и методы параметризируют значения.
Шаблоны позволяют параметризовать не только значения, но и типы. Вспомним,
что типы в C++ включают примитивы (например, int и double), а также
определяемые пользователем классы (например, Spreadsheet Се 11 и CherryTree). С
помощью шаблонов можно написать код, который не будет зависеть не только от задаваемых
значений, но и от типов этих значений! Например, вместо создания отдельных классов-
стеков для хранения int-значений, объектов типа Саг и типа Spreadsheet Cell,
можно написать одно определение стекового класса, которое будет использоваться для
любого из этих типов.
Хотя шаблоны представляют собой поразительно эффективное языковое
средство, они в C++ считаются не очень простым инструментом, причем как в идейном
плане, так и с точки зрения синтаксиса, и поэтому многие программисты игнорируют его
или остерегаются иметь с ним дело. И в самом деле, разработка поддержки шаблонов
в C++ напоминает порой подход "учесть всю кухонную мебель, кроме кухонной
мойки", т.е. назначение многих шаблонных средств неясно или не доработано. Хуже
того, компиляторная поддержка шаблонов исторически была и остается неполной.
Глава 11. Пишем обобщенный код с помощью шаблонов 317
Лишь небольшая часть коммерческих компиляторов обеспечивает полную поддержку
шаблонов в соответствии с С++-стандартом.
Поэтому в большинстве С++-книг тема шаблонов раскрывается весьма
поверхностно. Однако разбираться в С++-шаблонах чрезвычайно важно по одной основной
причине: стандартная С++-библиотека шаблонов, как и следует из ее названия,
построена с помощью шаблонов. Чтобы эффективно использовать эту библиотеку,
программист просто обязан понимать основы "шаблоностроения" в C++.
Итак, в этой главе вы получите представление о поддержке шаблонов в C++,
причем акцент будет сделан на аспектах, которые используются в стандартной
библиотеке шаблонов. Попутно (помимо использования стандартной библиотеки) вы узнаете
о некоторых стилевых особенностях шаблонов, которые затем сможете применить
в своих программах.
Шаблоны классов
Шаблоны классов используются, в основном, для контейнеров, или структур данных,
которые хранят объекты. В этом разделе рассматривается пример контейнера Grid.
Чтобы наши примеры сохраняли приемлемую длину и доходчиво иллюстрировали
конкретные аспекты, к коду контейнера Grid будут добавляться средства из
различных разделов главы без повторения их в последующих разделах.
Построение шаблона класса
Предположим, вам нужно построить обобщенный класс игровой доски, который
можно использовать для игры в шахматы, шашки, крестики и нолики или других,
т.е. класс для любой игры, в которой требуется двумерная доска. Чтобы придать
классу универсальность, вам следует уметь хранить шахматные фигуры, шашки, "крестики"
и "нолики" или фигуры другого типа.
Кодирование без шаблонов
Лучше всего для построения обобщенной игровой доски, если не прибегать к
шаблонам, применить механизм полиморфизма, который позволит хранить объекты
обобщенного класса GamePiece. Затем из класса GamePiece можно было бы создать
подклассы для фигур, участвующих в каждой игре. Например, для шахмат подошел бы класс
ChessPiece. Благодаря полиморфизму класс GameBoard, написанный для хранения
GamePiece-фигур, мог бы также хранить Chess Piece-фигуры. Сделаем определение
этого класса подобным классу Spreadsheet из главы 9, в котором в качестве базовой
структуры сетки будем использовать динамически создаваемый двумерный массив.
// GameBoard.h
class GameBoard
{
public:
// Класс общего назначения GameBoard разрешает
// пользователю задавать размерность.
GameBoard(int inWidth = kDefaultWidth,
int inHeight = kDefaultHeight);
GameBoard(const GameBoard& src); // конструктор копии
-GameBoard() ;
GameBoard &operator=(
const GameBoardt rhs) ,- // оператор присваивания
318 Часть II. Пишем C++—код профессионально
void setPieceAt{int х, int у, const GamePiece& inPiece);
GamePiece& getPieceAt {int x, int y) ,-
const GamePiece& getPieceAt{int x, int y) const;
int getHeight() const { return mHeight,- }
int getWidthO const { return mWidth; }
static const int kDefaultWidth = 10;
static const int kDefaultHeight = 10;
protected:
void copyFrom(const GameBoardfc src);
// Объекты динамически выделяют пространство для
// игровых фигур.
GamePiece** mCells;
int mWidth, mHeight,-
};
Метод getPieceAt () возвращает ссылку на фигуру в заданной позиции, а не
копию этой фигуры. Класс GameBoard служит в качестве абстракции двумерного
массива, поэтому он должен обеспечить соответствующую семантику доступа к массиву,
представляя доступ к реальному объекту по индексу, а не с помощью копии объекта.
Данная реализация класса предоставляет две версии метода get Pi ееeAt (),
одна из которых возвращает ссылку, а другая — сопвЪ-ссылку. Как работает такая
перегрузка —разъясняется в главе 16.
Рассмотрим определения этого метода и статических членов класса. Предлагаемая
вам реализация практически идентична реализации класса Spreadsheet из главы 9.
Будь это коммерческая программа, она должна бы, безусловно, в методах setPieceAt ()
и getPieceAt () выполнять граничную проверку (на отсутствие нарушения границ).
Но в нашем коде такая проверка опущена, поскольку она не является предметом
изучения этой главы.
// GameBoard.ерр
# include "GameBoard.h"
const int GameBoard::kDefaultWidth;
const int GameBoard::kDefaultHeight;
GameBoard::GameBoard{int inWidth, int inHeight) :
mWidth{inWidth), mHeight(inHeight)
{
mCells = new GamePiece* [mWidth];
for (int i = 0; i < mWidth; i++) {
mCells[i] = new GamePiece[mHeight];
}
}
GameBoard::GameBoard(const GameBoard& src)
{
copyFrom(src);
}
GameBoard::-GameBoard()
{
// Освобождаем старую память.
for {int i = 0; i < mWidth; i++) {
delete [] mCells [i] ;
}
delete [] mCells;
Глава 11. Пишем обобщенный код с помощью шаблонов 319
}
void GameBoard::copyFrom(const GameBoard& src)
{
int i, j ;
mWidth = src.mWidth;
mHeight = src.mHeight;
mCells = new GamePiece* [mWidth];
for (i = 0; i < mwidth,- i++) {
mCells[i] = new GamePiece[mHeight];
}
for (i = 0; i < mWidth; i++) {
for (j = 0; j < mHeight; j++) {
mCells[i] [j] = src.mCells[i] [j];
GameBoard& GameBoard::operator=(const GameBoard& rhs)
{
// Проверяем на ситуацию самоприсваивания.
if (this == &rhs) {
return (*this);
}
// Освобождаем старую память.
for (int i = 0; i < mWidth; i++) {
delete [] mCells [i] ;
}
delete[] mCells;
// Копируем в новую память.
copyFrom(rhs);
return (*this);
}
void GameBoard:rsetPieceAt(int x, int y,
const GamePiece& inElem)
mCells[x][y] = inElem;
GamePiece& GameBoard::getPieceAt(int x, int y)
return (mCells [x] [y] ) ,-
const GamePiece& GameBoard::getPieceAt(int x, int y) const
return (mCells[x][y]);
Этот класс GameBoard работает довольно сносно. Предположим, что вы
разработали класс ChessPiece и можете теперь создавать GameBoard-объекты и
использовать их следующим образом.
GameBoard che s sBoard(10, 10);
ChessPiece pawn;
chessBoard.setPieceAt(0, 0, pawn);
\
320 Часть II. Пишем C++—код профессионально
Шаблонный класс Grid
Класс GameBoard, представленный в предыдущем разделе, работоспособен, но не
эффективен. Например, он во многом подобен классу Spreadsheet из главы 9, но
единственный способ использования его в качестве электронной таблицы состоит
в решении сделать гсласс SpreadsheetCell подклассом класса GamePiece. Но в этом нет
никакого смысла, поскольку он не удовлетворяет "is-a''-принципу наследования: класс
SpreadsheetCell никак не может быть потомком класса GamePiece. Другое дело, если
бы мы могли написать обобщенный класс сетки, который можно было бы использовать
в разных целях, как, например, мы можем поступать с классами Spreadsheet или Chess-
Board. В C++ это можно сделать, написав шаблон класса, который в свою очередь
позволит написать класс, не определяя жестко один или несколько типов объектов. После
этого клиентам для реализации готового шаблона достаточно задать нужный им тип.
Определение класса Grid
Чтобы понять суть шаблонов классов, полезно обратиться к синтаксису. В
следующем примере показано, как можно слегка "подправить" класс GameBoard, чтобы
получить шаблонный класс Grid. He стоит бояться нового синтаксиса— все объяснит
следующий код. Обратите внимание на то, что имя класса GameBoard заменено именем
Grid, а имена методов setPieceAt () и getPieceAt () — именами setElementAt ()
и get El ementAt () соответственно — так они лучше отражают обобщенную суть класса.
// Grid.h
template <typename T>
class Grid
{
public:
Grid(int inWidth = kDefaultWidth,
int inHeight = kDefaultHeight);
Grid(const Grid<T>& src) ;
-Grid();
Grid<T>& operator=(const Grid<T>& rhs);
void setElementAt(int x, int y, const T& inElem);
T& getElementAt(int x, int y);
const T& getElementAt(int x, int y) const;
int getHeightO const { return mHeight,- }
int getWidthO const { return mWidth,- }
static const int kDefaultWidth = 10;
static const int kDefaultHeight = 10;
protected:
void copyFrom(const Grid<T>& src) ,-
T** mCells;
int mWidth, mHeight;
};
Теперь рассмотрим то же определение класса, но построчно.
template <typename T>
Глава 11. Пишем обобщенный код с помощью шаблонов 321
Первая строка означает, что следующее определение класса представляет собой
шаблон с одним типом. Как template, так и typename, являются ключевыми словами
в C++. Как упоминалось выше, шаблоны "параметризируют" типы точно так же, как
функции "параметризируют" значения. Подобно тому как в функциях имена
параметров используются для представления аргументов, передаваемых инициаторами
вызова функций, в шаблонах имена типов (например, Т) применяются для
представления реальных типов, которые укажет пользователь шаблона. И совсем
необязательно использовать именно имя Т — вы можете заменить его любым другим.
По историческим причинам для задания параметров-типов шаблона
вместо ключевого слова typename молено использовать ключевое
слово class. В результате во многих книгах и программах
используется такой синтаксис: template <class T>. Однако использование
слова "class" в этом контексте может кого-то ввести в заблуждение,
поскольку оно подразумевает, что задаваемым типом должен быть
класс, а это не соответствует истине. Поэтому в данной книге ис-
пользуется ключевое слово typename.
Спецификатор template действует на всю следующую инструкцию, которой в
данном случае является определение класса.
Несколькими строками ниже объявлен конструктор копии, который выглядит так.
Grid(const Grid<T>& sre);
Как видите, в качестве типа параметра sre здесь используется выражение const
Grid<T>& (а не const Grid&). При написании шаблона класса следует иметь в виду
следующее: то, что вы считали именем класса (Grid), в действительности будет
действовать как имя шаблона. Когда придет время говорить о реальных Grid-классах или
типах, вы будете рассматривать их как реализации шаблона класса Grid для опреде-
- ленного типа, например, int, SpreadsheetCell или ChessPiece. Пока не задан
реальный тип, мы используем "заменитель" параметра шаблона, Т, вместо которого
впоследствии будет использован нужный тип. Таким образом, когда вам понадобится
сослаться на тип для объекта класса Grid (в качестве параметра метода или
возвращаемого им значения), используйте обозначение Grid<T>. Описанное изменение,
связанное с параметром метода или возвращаемым им значением, коснулось, как
нетрудно заметить, оператора присваивания и метода copyFrom ().
Обрабатывая определение класса, компилятор при необходимости будет
интерпретировать имя Grid как Grid<T>. Однако стоит всегда задавать обозначение
Grid<T> в явном виде, поскольку это — синтаксис, который используется вне класса
для ссылки на типы, генерируемые на основе шаблона.
Последние изменения в нашем классе связаны с тем, что методы setElementAt ()
и getElementAt () теперь принимают и возвращают параметры и значения типа Т,
а не типа GamePiece.
void setElementAt(int x, int y, const T& inElem);
T& getElementAt(int x, int y);
const T& getElementAt(int x, int y) const;
Здесь тип Т является "заполнителем" для любого типа, который укажет пользователь.
Член mCells теперь имеет тип Т**, а не GameBoard**, поскольку он указывает на
динамически создаваемый двумерный массив элементов типа Т.
Шаблонные классы могут содержать встраиваемые (inline) методы, например,
getHeight() и getWidth ().
322 Часть II. Пишем C++—код профессионально
Определения методов класса Grid
Каждому определению метода для шаблона Grid должен предшествовать
спецификатор template < typename T>. И тогда конструктор будет выглядеть так.
template <typename T>
Grid<T>::Grid(int inWidth,
int inHeight) : mWidth(inWidth), mHeight(inHeight)
{
mCells = new T* [mWidth];
for (int i = 0; i < mWidth; i++) {
mCells [i] = new T[mHeight];
}
}
Обратите внимание на то, что в качестве имени класса, указанного перед
оператором " : : ", используется выражение Grid<T>, а не Grid. Выражение Grid<T>
необходимо задавать в роли имени класса во всех определениях методов и static-членов
данных. Тело конструктора идентично конструктору класса GaraeBoard за
исключением того, что вместо типа GamePiece используется заменитель типа Т.
Остальная часть определений методов и статических членов данных также
аналогична своим эквивалентам в классе GameBoard, за исключением соответствующих
изменений, связанных с шаблоном и синтаксисом Grid<T>.
template <typename T>
const int Grid<T>::kDefaultWidth;
template <typename T>
const int Grid<T>::kDefaultHeight;
template <typename T>
Grid<T>::Grid(const Grid<T>& src)
{
copyFrom(src);
}
template <typename T>
Grid<T>::~Grid()
{
// Освобождаем старую память.
for (int i = 0; i < mWidth; i++) {
delete [] mCells [i] ;
}
delete [] mCells,-
}
template <typename T>
void Grid<T>::copyFrom(const Grid<T>& src)
{
Глава 11. Пишем обобщенный код с помощью шаблонов 323
int i, j;
mWidth = src.mWidth;
mHeight = src.mHeight;
mCells = new T* [mWidth];
for (i = 0; i < mWidth; i++) {
mCells[i] = new T[mHeight];
}
for (i = 0; i < mWidth; i++) {
for (j = 0; j < mHeight; j++) {
mCells[i] [j] = src.mCells [i] [j] ;
}
}
}
template <typename T>
Grid<T>& Grid<T>::operator=(const Grid<T>& rhs)
{
// Проверяем на самоприсваивание.
if (this == &rhs) {
return (*this);
}
// Освобождаем старую память,
for (int i = 0; i < mWidth; i++) {
delete [] mCells[i] ;
}
delete [] mCells;
// Копируем в новую память.
copyFrom(rhs);
return (*this);
}
template <typename T>
void Grid<T>::setElementAt(int x, int y, const T& inElem)
mCells[x][y] = inElem;
template <typename T>
T& Grid<T>:rgetElementAt(int x, int y)
return (mCells[x][y]);
template <typename T>
const T& Grid<T>::getElementAt(int x, int y) const
return (mCells[x][y]);
324 Часть II. Пишем C++—код профессионально
Использование шаблона Grid
При создании объектов сетки нельзя использовать в качестве типа просто имя
Grid; необходимо задать и тип, который будет сохранен в контейнере Grid.
Создание объекта шаблонного класса для заданного типа называется реализацией шаблона.
Рассмотрим пример.
#include "Grid.h"
int main(int argc, char** argv)
{
Grid<int> mylntGrid; // Объявляет сетку для
// хранения int-значений.
mylntGrid.setElementAt(0, О, 10) ;
•int x = mylntGrid.getElementAt(0, 0);
Grid<int> grid2(mylntGrid);
Grid<int> anotherlntGrid = grid2;
return (0);
}
Обратите внимание на то, что типом объектов mlntGrid, grid2 и anotherlntGrid
является Grid<int>. В этих сетках невозможно сохранить объекты класса Spread-
sheetCell или ChessPiece, и если вы все-таки попытаетесь это сделать, компилятор
сгенерирует сообщение об ошибке.
Очень важно в точности соблюдать спецификацию типа: ни одна из следующих
двух строк не скомпилируется.
Grid test; // не скомпилируется
Grido test; // не скомпилируется
В первом случае компилятор "объяснит", что для использования шаблона класса
требуется список шаблонных аргументов. А вторая строка заставит его указать на
неверное количество аргументов шаблона. Если вы решите объявить функцию или
метод, который принимает объект типа Grid, вам придется задать хранимый в сетке тип
как часть типа Grid.
void processIntGrid(Grid<int>& inGrid)
{
// Тело метода опущено.
}
Шаблон Grid позволяет хранить не только значения типа int. Например, можно
реализовать шаблон Grid для хранения объектов класса SpreadsheetCell.
Grid<SpreadsheetCell> mySpreadsheet;
SpreadsheetCell myCell;
mySpreadsheet.setElementAt(3, 4, myCell);
Можно также хранить и указатели.
Grid<char*> myStringGrid;
myStringGrid.setElementAt(2, 2, "привет");
Глава 11. Пишем обобщенный код с помощью шаблонов 325
Задаваемый тип может быть даже другим шаблонным типом. В следующем
примере используется векторный шаблон из стандартной библиотеки шаблонов (она была
представлена в главе 4).
Grid<vector<int> > gridOfVectors,- // Обратите внимание на
// дополнительный пробел!
vector<int> myVector;
gridOfVectors.setElementAt(5, 6, myVector);
При использовании вложенных шаблонов необходимо оставлять пробел между двумя
закрывающими угловыми скобками. В противном случае компилятор интерпретирует
пару скобок >> (см. следующий пример) как оператор ввода.
Grid<vector<int>> gridOfVectors; // НЕКОРРЕКТНЫЙ СИНТАКСИС
Можно также для реализации Grid-шаблона динамически выделять память из "кучи".
Grid<int>* myGridp = new Grid<int>();
myGridp->setElementAt(0, 0, 10);
int x = myGridp->getElementAt(0, 0);
delete myGridp;
Как компилятор обрабатывает шаблоны
Чтобы понять сложности использования шаблонов, необходимо знать, как
компилятор обрабатывает шаблонный код. При обнаружении определений шаблонных
методов компилятор выполняет синтаксическую проверку, но реально не компилирует
шаблоны. Он не может это сделать, поскольку не знает, для каких типов они будут
использованы. Компилятор не имеет возможности сгенерировать код для выражения,
подобного х = у, если неизвестны типы переменных х и у.
При обнаружении реализации шаблона (например, Grid<int> mylntGrid)
компилятором будет создан код для int-версии шаблона Grid путем замены каждого
имени Т в определении шаблонного класса типом int. Если компилятор встретит
другую реализацию того же шаблона (например, Grid<SpreadsheetCell> myS-
preadsheet), он "напишет" еще одну версию класса Grid для типа Spreadsheet-
Cells. Компилятор просто создает код, который программист мог бы написать сам,
если бы в языке C++ не было поддержки шаблонов, но в этом случае ему пришлось бы
писать отдельные классы для каждого типа. Здесь нет никакой магии; шаблоны лишь
позволяют автоматизировать довольно занудный процесс кодирования. Если вы не
реализуете шаблон класса для конкретных типов в своей программе, то определения
методов класса никогда скомпилируются.
Теперь вам должно быть понятно, почему необходимо использовать синтаксис
Grid<T> в различных местах определения. При реализации компилятором шаблона
для конкретного типа, скажем, int, происходит замена обозначения Т типом int,
в результате чего получаем выражение Grid<int>.
Избирательная реализация
Реализация шаблона для множества различных типов может привести к "разбуханию"
кода, поскольку компилятор генерирует копии шаблонного кода для каждого типа.
Таким образом, при использовании шаблонов у вас могут получиться довольно
большие исполняемые файлы.
326 Часть II. Пишем C++—код профессионально
Однако эта проблема вполне решаема, поскольку компилятор генерирует код
только для тех методов класса, которые реально вызываются для определенного типа.
Например, с учетом приведенного выше шаблонного класса Grid предположим, что
для функции main () мы написали следующий код (без какого-либо дополнения).
Grid<int> mylntGrid;
mylntGrid.setElementAt(0, 0, 10);
Компьютер в этом случае сгенерирует только конструктор без аргументов,
деструктор и метод setElementAt () для int-версии шаблона Grid. Он не сгенерирует
никакие другие методы: ни конструктор копии, ни оператор присваивания, ни метод
getHeight ().
Требования к шаблонам
При написании кода, который не зависит от типов значений, необходимо сделать
определенные предположения в отношении этих типов. Например, в шаблоне Grid
(исходя из наличия строки mCells [х] [у] = inElem) мы предполагаем, что тип
(представленный параметром Т) будет иметь оператор присваивания, а также конструктор
по умолчанию (чтобы можно было создать массив элементов).
При попытке реализовать шаблон с типом, который не поддерживает все
операции, используемые этим шаблоном в конкретной программе, ее код не скомпилирует-
ся. Но даже если тип, который вы хотите применить, не поддерживает операции,
требуемые полным кодом шаблона, для нескольких (но не для всех) методов все же
можно использовать избирательную реализацию. Например, если вы попытаетесь
создать сетку (т.е. двумерную таблицу) для объекта, который не имеет оператора
присваивания, но никогда не вызывает метод setElementAt () для этой сетки, ваш код будет
прекрасно работать. Но как только вы попытаетесь вызвать метод setElementAt (),
сообщение об ошибке компиляции не заставит себя ждать.
Распределение кода шаблона между файлами
Обычно определения класса помещаются в заголовочный файл, а определения
методов— в исходный. Код, который создает или использует объекты класса, с
помощью директивы #include включает этот заголовочный файл и получает доступ к коду
метода посредством компоновщика. Шаблоны же не могут работать подобным
образом. Поскольку для компилятора они, по сути, являются "трафаретами",
показывающими, как ему генерировать реальные методы для заданных типов, определения
шаблонных классов и определения их методов должны быть доступны для компилятора
в любом исходном файле, который их использует. В этом смысле методы шаблонного
класса подобны встраиваемым (подставляемым), или inline-методам. Для
реализации этого включения существует ряд механизмов.
Определения шаблонов в заголовочных файлах
Определения методов можно поместить непосредственно в тот же заголовочный
файл, в котором определен и сам класс. При включении этого файла (с помощью
директивы #include) в исходный файл, в котором используется шаблон, компилятор
получит доступ ко всему необходимому для него коду.
В качестве альтернативного варианта определения шаблонных методов можно
поместить в отдельный заголовочный файл, который затем нужно включить (с помощью
Глава 11. Пишем обобщенный код с помощью шаблонов 327
директивы #include) в заголовочный файл, содержащий определение класса. При
этом следует убедиться в том, что директива #include для определений методов
расположена после определения класса; в противном случае код не скомпилируется!
// Grid.h
template <typename T>
class Grid
{
// Определение класса опущено из соображений экономии места.
};
#include "GridDefinitions.h"
Определения шаблонов в исходных файлах
Реализации методов в заголовочных файлах выглядят довольно странно. И если
такой синтаксис вас раздражает, существует возможность разместить определения
методов в исходном файле. Однако вам по-прежнему нужно сделать эти определения
доступными для кода, использующего шаблоны, путем включения (с помощью
директивы #include) исходного файла реализации методов в заголовочный файл,
содержащий определение шаблонного класса. Некоторым программистам такой вариант
может показаться странным, тем не менее в C++ это вполне допустимо. Ъ данном
случае заголовочный файл будет выглядеть так.
// Grid.h
template <typename T>
class Grid
{
// Определение класса опущено из соображений экономии места.
};
#include "Grid.cpp"
В действительности стандарт C++ определяет способ существования определений
шаблонных методов в исходном файле без использования директивы #include в
заголовочном файле. Чтобы указать, что шаблонные определения должны быть доступны во
всех "единицах трансляции" (т.е. исходных файлах), достаточно использовать ключевое
слово export. К сожалению, лишь немногие коммерческие компиляторы поддерживают
это средство, более того, создается такое впечатление, что большинство
производителей компиляторов и не собираются реализовывать стандарт в ближайшее время.
Шаблонные параметры
В примере с шаблоном Grid у нас был только один параметр — тип данных,
хранимых в сетке (т.е. двумерной таблице). При написании шаблона класса в общем
случае в угловых скобках задается список параметров.
template <typename T>
Этот список параметров подобен списку параметров в функции или методе. Другими
словами, как в функциях или методах, мы можем написать класс с нужным нам
количеством шаблонных параметров. Кроме того, эти параметры необязательно должны
быть типами, причем они могут иметь значения, действующие по умолчанию.
328 Часть П. Пишем C++—код профессионально
Шаблонные параметры, не являющиеся типами
Под параметрами, не являющимися типами, подразумеваются такие "обычные"
параметры, как int-значения или указатели: с ними вы познакомились во время
изучения функций или методов. Однако шаблоны позволяют в качестве "обычных"
использовать лишь "простые" типы: int-значения, перечисления, указатели и ссылки.
В шаблонном классе Grid для задания высоты и ширины сетки (таблицы) можно
использовать "обычные" параметры, и тогда их не нужно указывать в конструкторе.
Принципиальное преимущество задания "обычных" параметров в списке шаблона
(вместо списка параметров в конструкторе) состоит в том, что эти значения известны
до компиляции кода. Вспомните, что компилятор генерирует код для шаблонных
методов, предварительно их заменив конкретными значениями. Таким образом, в своей
реализации можно использовать обычный двумерный массив, а не динамически
выделять для него память. Вот как будет выглядеть новое определение класса.
template <typename T, int WIDTH, int HEIGHT>
class Grid
{
public:
void setElementAt(int x, int y, const T& inElem);
T& getElementAt(int x, int y);
const T& getElementAt(int x, int y) const;
int getHeight() const { return HEIGHT; }
int getWidth() const { return WIDTH; }
protected:
T mCells[WIDTH][HEIGHT];
};
Этот класс значительно проще предыдущей версии. Обратите внимание на то, что
согласно списку шаблонных параметров здесь требуется три параметра: тип объектов,
хранимых в сетке, ширина сетки и ее высота. Последние два параметра используются
для создания двумерного массива, в котором, собственного, и будут храниться
объекты. В этом классе нет динамического выделения памяти, поэтому нет необходимости
в конструкторе копии, деструкторе или операторе присваивания. Более того, вам
даже не нужно писать конструктор по умолчанию: сгенерированный компилятором
вполне пригоден. Вот как выглядят определения методов этого класса.
template <typename T, int WIDTH, int HEIGHT>
void Grid<T, WIDTH, HEIGHT>::setElementAt(int x, int y,
const T& inElem)
{
mCells [x] [y] = inElem,-
}
Глава 11. Пишем обобщенный код с помощью шаблонов 329
template <typename T, int WIDTH, int HEIGHT>
Tb Grid<T, WIDTH, HEIGHT>::getElementAt(int x, int y)
{
return (mCells[x][y]);
}
template <typename T, int WIDTH, int HEIGHT>
const T& Grid<T, WIDTH, HEIGHT>::getElementAt(int x,
int y) const
{
return (mCells[x][y]);
}
Обратите внимание на то, что там, где мы раньше использовали выражение Grid<T>
(для задания одного параметра), теперь необходимо писать Gr id<T, WIDTH, HEIGHT>
(для задания трех шаблонных параметров).
Этот шаблон мы можем реализовать и использовать следующим образом.
Grickint, 10, 10> myGrid;
Grid<int, 10, 10> anotherGrid;
myGrid.setElementAt(2, 3, 45);
anotherGrid = myGrid;
cout << anotherGrid.getElementAt(2, 3);
Этот код кажется просто прекрасным! Несмотря на немного путаный синтаксис
объявления шаблона Grid, реальный Grid-код стал намного проще. К сожалению, на
использование "обычных" параметров налагается больше ограничений, чем вы
могли того ожидать. Во-первых, для задания высоты или ширины сетки нельзя
использовать значение, которое не является целочисленной константой. Поэтому следующий
код не скомпилируется.
int height = 10;
Grid<int, 10, height> testGrid; // HE СКОМПИЛИРУЕТСЯ
Применение же модификатора const может делу помочь.
const int height = 10;
Grid<int, 10, height> testGrid; // компилируется и работает
Во-вторых, существует проблема посложнее. Тот факт, что ширина и высота —
шаблонные параметры, означает, что они являются частью типа каждой сетки.
Другими словами, теперь Grid<int, 10, 10> и Grid<int, 10, 11 > — это два
различных типа. Вспомним: объект одного типа нельзя присвоить объекту другого типа,
а переменные одного типа не могут быть переданы функциям или методам, которые
ожидают приема переменных другого типа.
Шаблонные параметры, не являющиеся типами, становятся частью
спецификации типа реализуемых объектов.
330 Часть II. Пишем C++—код профессионально '
Значения по умолчанию для целочисленных шаблонных параметров,
не являющихся типами
Если вам показалось правильным решение сделать высоту и ширину таблицы
шаблонными параметрами, то, идя в этом направлении, можно позаботиться о
предоставлении значений, действующих по умолчанию для этих параметров, подобно тому,
как мы это делали в конструкторе класса Grid<T>. C++ позволяет указывать значения
по умолчанию для шаблонных параметров с использованием аналогичного
синтаксиса. Вот как будет выглядеть определение нашего класса в этом случае.
template <typename T, int WIDTH = 10, int HEIGHT = 10>
class Grid
{
// Остальная часть реализации идентична предыдущей версии.
};
Нет необходимости указывать значения по умолчанию для параметров WIDTH
и HEIGHT в спецификации шаблона для определений методов. Рассмотрим, например,
реализацию метода setEleraentAt ().
template <typename T, int WIDTH, int HEIGHT>
void Grid<T, WIDTH, HEIGHT>::setElementAt(int x, int y,
const T& inElem)
{
mCells [x] [y] = inElem,-
}
Теперь мы можем реализовать объект класса Grid, используя лишь параметр-тип,
параметр-тип и один "обычный" параметр (ширина таблицы) или параметр-тип и два
"обычных" параметра (ширина и высота таблицы).
Grid<int> myGrid;
Grid<int, 10> anotherGrid;
Grid<int, 10, 10> aThirdGrid,-
Правила использования значений по умолчанию в списках шаблонных параметров
не отличаются от правил для функций и методов: все параметры, которые принимают
значения по умолчанию, должны быть расположены справа от остальных. Это значит,
что, если вы начали определять параметры по умолчанию, нельзя после них
указывать параметры, задаваемые только явным образом.
Шаблоны методов
В C++ предусмотрена возможность отдельные методы класса сделать шаблонными.
Такие методы могут принадлежать как шаблонному классу, так и "обычному", т.е. не
шаблонному. При создании шаблонного метода класса вы в действительности пишете
множество различных версий этого метода для разных типов данных. Шаблоны
методов оказываются весьма полезными для операторов присваивания и конструкторов
копии в шаблонах классов.
Виртуальные методы и деструкторы нельзя делать шаблонными.
Рассмотрим исходный шаблон класса Grid (с единственным параметром-типом).
Сего помощью мы можем реализовать сетки (таблицы), используя множество
различных типов, например, int и double.
Глава 11. Пишем обобщенный код с помощью шаблонов 331
Grid<int> mylntGrid;
Grid<double> myDoubleGrid;
Ho ведь теперь классы Grid<int> и Grid<double> представляют собой два
различных типа. Если вы напишете функцию, которая принимает объект тина Grid<double>,
то ей нельзя будет в качестве аргумента передать объект типа Grid<int>. Даже если
вы знаете, что int-таблицу можно было бы скопировать в double-таблицу (поскольку
int-значения можно привести к типу double), вам не удастся присвоить объект типа
Grid<int> объекту типа Grid<double> или построить объект типа Grid<double>
на основе объекта типа Grid<int>. Поэтому ни одна из следующих строк кода не
скомпилируется.
myDoubleGrid = mylntGrid; // HE СКОМПИЛИРУЕТСЯ
Grid<double> newDoubleGrid(mylntGrid); // НЕ СКОМПИЛИРУЕТСЯ
Проблема состоит в том, что сигнатуры конструктора копии и оператора
присваивания (opera tor - ()) шаблона Grid выглядят так.
Grid(const Grid<T>& src);
Grid<T>& operator=(const Grid<T>& rhs) ;
Конструктор копии и метод operator= () шаблона Grid принимают const-ссылку
на объект типа Grid<T>. При реализации класса Grid<double> и попытке вызвать
его конструктор копии и метод operator= () компилятор сгенерирует методы с
такими сигнатурами.
Grid(const Grid<double>& src);
Grid<double>& operator=(const Grid<double>& rhs);
Обратите внимание на то, что в сгенерированном компилятором классе
Grid<double> не нашлось места конструкторам или методу operator= (), которые
бы принимали в качестве параметра ссылку на объект типа Grid<int>. Но вы можете
исправить эту "оплошность", добавив в класс Grid шаблонные версии конструктора
копии и оператора присваивания, чтобы сформировать средства преобразования
сетки одного тина в сетку другого. Вот как выглядит повое определение класса Grid.
template <typename T>
class Grid
{
public:
Grid(int inWidth = kDefaultWidth,
int inHeight = kDefaultHeight);
Grid(const Grid<T>& src);
template <typename E>
Grid(const Grid<E>& src);
-Grid();
Grid<T>& operator=(const Grid<T>& rhs);
template <typename E>
Grid<T>& operator=(const Grid<E>& rhs);
332 Часть II. Пишем C++—код профессионально
void setElementAt(int x, int у, const T& inElem)
Т& getElementAt(int x, int y) ;
const T& getElementAt(int x, int y) const;
int getHeightO const { return mHeight; }
int getWidthO const { return mWidth; }
static const int kDefaultWidth = 10;
static const int kDefaultHeight = 10,-
protected:
void copyFrom(const Grid<T>& src);
template <typename E>
void copyFrom(const Grid<E>& src);
};
T** mCells;
int mWidth, mHeight;
Шаблонные члены не заменяют нешаблонные с такими же именами.
Это правило приводит к возникновению проблем с конструктором
копии и оператором присваивания, поскольку версии классов с
конкретными типами генерируются компилятором. Если вы напишете
шаблонные версии конструктора копии и метода operators (}, но
опустите нешаблонные, компилятор не будет вызывать эти новые
шаблонные версии для присваивания объектов (сеток в данном
случае) такого лее типа. Взамен он сгенерирует конструктор копии и
метод operator» () для создания и присваивания двух объектов (сеток)
одинакового типа, что не соответствует вашим ожиданиям! Таким
образом, в новом классе вам необходимо сохранить старые
нешаблонные версии конструктора копии и метода opera tor» ().
Рассмотрим сначала новую сигнатуру шаблонного конструктора копии.
template <typename E>
Grid (const Grid<E>& src) ,-
Здесь появилось еще одно шаблонное объявление с другим именем типа Е (сокращение
от слова "element" — элемент). Класс Grid сделан шаблонным для одного типа, Т, а
новый конструктор копии "шаблонизируется" также по другому типу, Е. Такая двойная
"шаблонизация" позволяет-таки копировать сетки одного типа в сетки другого.
Теперь рассмотрим определение нового конструктора копии.
template <typename T>
template <typename E>
Grid<T>::Grid(const Grid<E>& src)
{
copyFrom(src);
}
Как видите, вы должны поместить объявление шаблона класса (с параметром Т) до
объявления шаблона его члена (с параметром Е). Их нельзя комбинировать в одном
выражении.
Глава 11. Пишем обобщенный код с помощью шаблонов 333
template <typename T, typename E> // НЕКОРРЕКТНЫЙ СПИСОК
// ПАРАМЕТРОВ ШАБЛОНА!
Grid<T>::Grid(const Grid<E>& src)
Некоторые компиляторы требуют, чтобы определения шаблонных
методов помещались в определение класса встраиваемым образом,
но стандарт C++ позволяет располагать их и вне границ класса.
Конструктор копии использует защищенный метод copyFrom (), поэтому классу
также нужна шаблонизированная версия этого метода.
template <typename T>
template <typename E> '
void Grid<T>::copyFrom(const Grid<E>& src)
{
int i, j ;
mwidth = src.getwidth();
mHeight = src.getHeight();
mCells = new T* [mWidth];
for (i = 0; i < mWidth; i++) {
mCellsti] = new T[mHeight];
}
for (i = 0; i < mWidth; i++) {
for (j = 0; j < mHeight; j++) {
mCells[i][j] = src.getElementAt(i, j);
}
}
}
Помимо предшествующей определению метода copyFrom () дополнительной строки,
"возвещающей" о наличии шаблонного параметра, обратите внимание на
необходимость использования public-методов getWidth (), getHeight () и getElementAt (),
предназначенных для доступа к членам объекта src. Дело в том, что объект, в
который выполняется копирование, имеет тип Grid<T>, а объект, из которого мы
копируем, — тип Grid<E>. Из-за различия в типах мы и вынуждены прибегать к
использованию public-методов.
Теперь рассмотрим шаблонный оператор присваивания. Обратите внимание на то,
что он принимает объект типа const Grid<E>&, но возвращает объект типа Grid<T>&.
template <typename T>
template <typename E>
Grid<T>& Grid<T>::operator=(const Grid<E>& rhs)
{
// Освобождаем старую память.
for (int i = 0; i < mWidth; i++) {
delete [] mCells[i];
}
334 Часть II. Пишем C++—код профессионально
delete [] mCells,-
// Копируем в новую память.
copyFrom(rhs);
return (*this) ,-
}
В шаблонном операторе присваивания не нужно выполнять проверку на наличие
самоприсваивания, поскольку присваивание объектов одинаковых типов по-прежнему
происходит с использованием старой (нешаблонной) версии оператора operator= ().
Кроме не совсем простого синтаксиса шаблонов методов, существует еще одна
проблема: некоторые компиляторы не реализуют в полном объеме (или вообще) их
поддержку. Проверьте возможности своего компилятора на этом примере и узнайте,
можете ли вы использовать эти средства.
Шаблоны методов с параметрами, не являющимися типами
В примере с целочисленными шаблонными параметрами HEIGHT и WIDTH мы
обращали ваше внимание на основную проблему такого подхода: значения высоты и
ширины сетки (таблицы) становятся частью типов. Это ограничение не позволяет
присваивать объект сетки с одними "размерами" объекту сетки с другими. Но в некоторых
случаях очень даже желательно иметь возможность присваивания при разных
"размерах" его участников. Вместо того чтобы делать объект-приемник идеальным
клоном объекта-источника, мы могли бы копировать лишь те элементы исходного массива,
которые позиционно соответствуют приемному массиву, заполняя последний
значениями по умолчанию, если исходный массив окажется меньше по любой из
размерностей. С применением шаблонных методов для оператора присваивания и конструктора
копии мы вполне можем реализовать такой сценарий, тем самым позволив присваивать
и копировать сетки различных размеров. Рассмотрим следующее определение класса.
template <typename T, int WIDTH = 10, int HEIGHT = 10>
class Grid
{
public:
Grid () {}
template <typename E, int WIDTH2, int HEIGHT2>
Grid(const Grid<E, WIDTH2, HEIGHT2>& src) ;
template <typename E, int WIDTH2, int HEIGHT2>
Grid<T, WIDTH, HEIGHT>& operator=(const Grid<E,
WIDTH2, HEIGHT2>& rhs) ;
void setElementAt(int x, int y, const T& inElem);
T& getElementAt(int x, int y) ,-
const T& getElementAt(int x, int y) const;
int getHeightO const { return HEIGHT; }
int getWidthO const { return WIDTH; }
protected:
template <typename E, int WIDTH2, int HEIGHT2>
void copyFrom(const Grid<E, WIDTH2, HEIGHT2>& src);
Глава 11. Пишем обобщенный код с помощью шаблонов 335
Т mCells[WIDTH][HEIGHT];
};
Мы добавили шаблоны для конструктора копии и оператора присваивания, а
также вспомогательный метод copyFrom (). Как упоминалось в главе 8, при написании
конструктора копии компилятор автоматически не генерирует конструктор по
умолчанию, поэтому мы должны добавить его сами. При этом обратите внимание на то,
что нам не нужно писать нешаблонные версии конструктора копии и оператора
присваивания, поскольку вполне подойдут их автоматические эквиваленты,
сгенерированные компилятором. Эти методы просто копируют (или присваивают) исходный
массив mCells в приемный, что в точности соответствует семантике, которая нам
нужна для обработки двух сеток (таблиц) одинакового размера.
"Шаблонизируя" конструктор копии, оператор присваивания и метод copyFrom (),
мы должны задать три шаблонных параметра. Вот как выглядит шаблонный
конструктор копии.
template «ctypename T, int WIDTH, int HEIGHT>
template <typename E, int WIDTH2, int HEIGHT2>
Grid<T, WIDTH, HEIGHT>::Grid(const Grid<E,
WIDTH2, HEIGHT2>& src)
{
copyFrom (src) ,-
}
Теперь рассмотрим реализации методов copyFrom () и operator^ (). Обратите
внимание на то, что метод copyFrom () копирует из приемника src только WIDTH
и HEIGHT элементов по х и у размерностям соответственно даже в случае, если
исходный объект src больше приемного. Если же объект src меньше приемника по
какой-нибудь из размерностей, метод copyFrom () заполняег "лишние" позиции
нулевыми значениями. Выражение Т () означает вызов конструктора по умолчанию для
соответствующего объекта, если Т представляет собой тип класса, или генерирование
значения 0, если Т — простой тип. Такой синтаксис называется синтаксисом
инициализации нулевыми значениями. Он является прекрасным способом предоставить по
умолчанию подходящее значение для переменной, тип которой вы пока не знаете.
template <typename T, int WIDTH, int HEIGHT>
template <typename E, int WIDTH2, int HEIGHT2>
void Grid<T, WIDTH, HEIGHT>::copyFrom(const Grid<E,
WIDTH2, HEIGHT2>& src)
{
int i, j ,-
for (i = 0; i < WIDTH; i++) {
for (j = 0; j < HEIGHT; j++) {
if (i < WIDTH2- && j < HEIGHT2) {
mCells[i][j] = src.getElementAt(i, j);
} else {
mCells [i] [j] = T() ,-
}
template <typename T, int WIDTH, int HEIGHT>
template <typename E, int WIDTH2, int HEIGHT2>
Grid<T, WIDTH, HEIGHT>& Grid<T, WIDTH, HEIGHT>::operator=(
const Grid<E, WIDTH2, HEIGHT2>& rhs)
{
336 Часть II. Пишем C++—код профессионально
// Нет необходимости в проверке на самоприсваивание,
// поскольку эта версия присваивания никогда не вызывается,
// если типы Т и Е одинаковы.
// Нет необходимости в первоначальном освобождении памяти.
// Копируем в новую память.
copyFrom(rhs);
return (*this);
}
Специализация шаблонных классов
Для конкретных типов можно обеспечить альтернативные реализации шаблонов
классов. Например, вы могли бы решить, что поведение шаблона Grid для значений
типа char* (т.е. строк в стиле языка С) не имеет смысла. Наш класс сетки позволяет
хранить лишь поверхностные копии типов-указателей, а для значений типа char* было
бы логично делать детальные копии (с воспроизведением всех элементов) строки.
Альтернативные реализации шаблонов называются специализациями шаблонов
(template specializations). Сразу предупредим, что синтаксис этого средства обычно
заставляет программистовв несколько напрячься. При написании специализации
шаблонного класса необходимо указывать, что вы определяете шаблон и что пишете версию
шаблона для данного конкретного типа. Рассмотрим синтаксис специализации
исходной версии шаблонного класса Grid для типов char *.
// Директивы #include для работы с С-строками.
#include <cstdlib>
#include <cstring> ,
using namespace std;
// При использовании специализации шаблона его исходная
// реализация также должна быть видимой. Ее включение
// <#include) гарантирует ее постоянную "видимость" при
// "видимости" данной специализации.
#include "Grid.h"
template <>
class Grid<char*>
{
public:
Grid(int inWidth = kDefaultWidth,
int inHeight = kDefaultHeight);
Grid(const Grid<char*>& src);
-Grid();
Grid<char*>& operator=(const Grid<char*>& rhs);
void setElementAt(int x, int y, const char* inElem);
char* getElementAt(int x, int y) const;
int getHeight () const { return mHeight,- }
int getwidth() const { return mWidth; }
static const int kDefaultWidth = 10,-
static const int kDefaultHeight = 10;
Глава 11. Пишем обобщенный код с помощью шаблонов 337
protected:
void copyFrom(const Grid<char*>& src);
char*** mCells;
int mWidth, mHeight;
};
Обратите внимание на то, что в специализации нет ни одной ссылки на
переменную-тип, например Т: здесь используется непосредственно тип char*. Тогда
возникает вопрос: почему этот класс по-прежнему является шаблоном? Другими словами, что
хорошего (или какой смысл) в таком синтаксисе?
template <>
class Grid<char *>
Этот синтаксис сообщает компилятору, что данный класс представляет собой
char*-специализацию шаблонного класса Grid. Предположим, что вы не
использовали этот синтаксис и просто попытались написать следующую инструкцию.
class Grid
Компилятор не позволит это сделать, поскольку уже существует класс с именем Grid
(исходный шаблонный класс). И только благодаря специализации это имя можно
использовать повторно. Основное достоинство специализации заключается в том, что
она может быть невидимой для пользователя. Если пользователь создает Grid-сетку
для хранения int-значений или объектов типа SpreadsheetCell, компилятор
генерирует код на основе исходного шаблона Grid. А если пользователь создает Grid-сетку
для сЬаг*-значений, компилятор применит сЬаг*-специализацию. Но это все
происходит "за кулисами".
Grid<int> mylntGrid; // Используется исходный шаблон Grid.
Grid<char*> stringGridl(2, 2); // Используется
// char*-специализация.
char* dummy = new char[10];
strcpy(dummy, "Чайник");
stringGridl.setElementAt(0, 0, "привет");
stringGridl.setElementAt(0, 1, dummy);
stringGridl.setElementAt(1, 0, dummy);
stringGridl.setElementAt(1, 1, "там");
delete [] dummy;
Grid<char*> stringGrid2(stringGridl);
При создании специализации шаблона вы не "наследуете" код: специализации — это
не подклассы. Вы должны полностью переписать реализацию исходного класса. Не
существует никаких требований, которыми вы могли бы руководствоваться при
написании методов с одинаковыми именами или поведением. Вы даже могли бы написать
совершенно другой класс, никак не связанный с оригинальным! Конечно, в этом случае вы
злоупотребили бы возможностью специализации шаблонов, что без веских на то
причин делать не следует. Итак, рассмотрим реализации методов для сЬаг*-специализации.
В отличие от исходных шаблонных определений, здесь перед каждым определением
метода или static-члена синтаксис template< > повторять не нужно!
338 Часть II. Пишем C++—код профессионально
const int Grid<char*>::kDefaultWidth;
const int Grid<char*>::kDefaultHeight;
Grid<char*>::Grid(int inWidth, int inHeight) :
mWidth(inWidth), mHeight(inHeight)
{
mCells = new char** [mWidth];
for (int i = 0; i < mWidth; i++) {
mCells[i] = new char* [mHeight];
for (int j =0; j < mHeight; j++) {
mCells [i] [j] = NULL;
}
}
}
Grid<char*>::Grid(const Grid<char*>& src)
{
copyFrom(src);
}
Grid<char*>::~Grid()
{
// Освобождаем старую память.
for (int i = 0; i < mWidth; i++) {
for (int j =0; j < mHeight-; j++) {
delete [] mCells [i] [j ] ;
}
delete [] mCells [i] ;
}
delete [] mCells;
}
void Grid<char*>::copyFrom(const Grid<char*>& src)
int i, j;
mWidth = src.mWidth;
mHeight = src.mHeight;
mCells = new char** [mWidth];
for (i = 0; i < mWidth; i++) {
mCells [i] = new char* [mHeight]
}
for (i = 0; i < mWidth; i++) {
for (j = 0; j < mHeight; j++) {
if (src.mCells[i][j] ==NULL) {
mCells [i] [j] = NULL;
} else {
mCells [i] [j] = *
Глава 11. Пишем обобщенный код с помощью шаблонов 339
new char[strlen(src.mCells[i][j]) + 1]
strcpy(mCells[i][j], src.mCells[i][j]);
}
,-'
Grid<char*>& Grid<char*>::operator=(const Grid<char*>& rhs)
{
int i, j;
// Проверяем на самоприсваивание.
if (this == &rhs) {
return (*this);
}
// Освобождаем старую память,
for (i = 0; i < mWidth; i++) {
for (j = 0; j < mHeight; j++) {
delete [] mCells [i] [j] ;
}
delete [] mCells [i] ;
}
delete [] mCells;
// Копируем в новую память.
copyFrom(rhs);
return (*this) ;
void Grid<char*>::setElementAt(int x, int y,
const char* inElem)
{
delete [] mCells [x] [y] ;
if (inElem == NULL) {
mCells[x][y] = NULL;
} else {
mCells [x] [y] = new char [strlen (inElem) + 1] ,-
strcpy(mCells[x][y], inElem);
}
}
char* Grid<char*>::getElementAt(int x, int y) const
{
if (mCells[x][y] == NULL) {
return (NULL);
}
char* ret = new char[strlen(mCells[x][y]) + l];
strcpy(ret, mCells[x][y]);
return (ret);
}
Метод getElementAt () возвращает детальную копию строки, поэтому не нужно
создавать перегруженную версию, которая бы возвращала значение типа const char *.
340 Часть II. Пишем C++—код профессионально
Построение подклассов шаблонных классов
Из шаблонного класса можно выводить подклассы. Если подкласс создается из
самого шаблона, он также должен быть шаблоном. Но можно вывести подкласс из
конкретной реализации шаблонного класса, и в этом случае он необязательно должен
быть шаблоном. Предположим, вы решили, что обобщенный класс Grid не
полностью подходит для использования в качестве игровой доски. В частности, вы хотели
бы добавить в него метод move (), который позволяет перемещать фигуру из одной
позиции доски в другую. Рассмотрим определение класса для шаблона GameBoard.
#include "Grid.h"
template <typename T>
class GameBoard : public Grid<T>
{
public:
GameBoard(int inWidth = Grid<T>::kDefaultWidth,
int inHeight = Grid<T>::kDefaultHeight);
void move(int xSrc, int ySrc, int xDest, int yDest);
};
Шаблонный класс GameBoard является производным от шаблона Grid и поэтому
наследует его поведение. Это значит, что вам не нужно переписывать методы setEle-
mentAt (), get Element At () и пр. Не нужно также добавлять сюда конструктор копии,
оператор присваивания (operator= ()) и деструктор, поскольку в классе GameBoard
нет динамического выделения памяти. О динамическом выделении памяти в
суперклассе Grid позаботятся его конструктор копии, метод operator= () и деструктор.
Синтаксис наследования здесь выглядит вполне "буднично", за исключением того,
что в списке наследуемых классов указан суперкласс Grid<T>, а не просто Grid. Дело
в том, что шаблон GameBoard в действительности не наследует обобщенный шаблон
Grid. Вместо этого каждая реализация шаблона GameBoard для конкретного типа
выводится из реализации шаблона Grid для этого же типа. Например, если реализовать
шаблон GameBoard с использованием типа ChessPiece, компилятор также
сгенерирует код реализации Grid<ChessPiece>. Синтаксис " : public Grid<T>" означает,
что этот класс выводится из той реализации шаблона Grid, которая имеет смысл для
параметра-типа Т. Обратите внимание на то, что С++-правила поиска имен для
наследования шаблонов требуют, чтобы было указано, что параметры kDefaultWidth
и kDef aultHeight объявляются в суперклассе Grid<T> и поэтому зависят от него.
Рассмотрим реализации конструктора и метода move (). Обратите опять-таки
внимание на использование выражения Grid<T> в вызове конструктора суперкласса.
Отметим, что С++-правила поиска имен при обращении к членам данных и методам
в суперклассе требуют использования указателя this (хотя многие компиляторы в этом
отношении более "лояльны").
template <typename T>
GameBoard<T>::GameBoard(int inWidth, int inHeight) :
Grid<T>(inWidth, inHeight)
{
}
template <typename T>
void GameBoard<T>::move(int xSrc, int ySrc,
int xDest, int yDest)
{
Глава 11. Пишем обобщенный код с помощью шаблонов 341
this->mCells[xDest][yDest] = this->mCells[xSrc][ySrc];
this->mCells[xSrc][ySrc] = T(); // Инициализация нулевыми
// значениями ячейки
// объекта src.
}
Как видите, в методе move () используется синтаксис инициализации нулевыми
значениями Т (), описанный в разделе "Шаблоны методов с параметрами, не
являющимися типами".
Теперь можно использовать шаблон GameBoard таким образом.
GameBoard<ChessPiece> chessBoard;
ChessPiece pawn,-
chessBoard.setElementAt(О, 0, pawn);
'chessBoard.move (0, 0, 0, 1) ;
Наследование в сравнении со специализацией
Некоторые программисты находят различие между наследованием шаблонов
и специализацией шаблонов довольно "туманным". Чтобы рассеять "туман",
предлагаем рассмотреть следующую таблицу.
Наследование Специализация
Многократность Да, подклассы поддерживают все Нет, необходимо в специализацию
использования кода? члены и методы суперклассов шаблона переписать весь его код
Многократность Нет, имя подкласса должно Да, имя специализации должно
использования имени? отличаться от имени имени совпадать с именем исходного
суперкласса шаблона
Поддержка Да, объекты подкласса могут Нет, все реализации шаблона
полиморфизма? замещать объекты суперкласса для разных типов представляют
собой различные типы
Механизм наследования имеет смысл использовать для расширения
реализаций и полиморфизма, а специализацию— для создания
отдельных реализаций под конкретные типы данных.
Шаблоны функций
Шаблоны можно применять и к независимым (от классов) функциям. Например,
можно написать обобщенную функцию для поиска значения в массиве и возврата
его индекса.
template <typename T> *
int Find(T& value, T* arr, int size)
{
for (int i = 0; i < size,- i++) {
if (arr[i] == value) {
// Значение найдено; возвращаем его индекс,
return (i);
}
342 Часть II. Пишем C++—код профессионально
} /
// Значение не найдено; возвращаем -1.
return (-1);
}
Шаблон функции Find () может работать с массивами любого типа. Например, вы
могли бы использовать его для поиска индекса заданного int-значения в массиве
целочисленных элементов или объекта SpreadsheetCell в массиве, хранящем
элементы типа SpreadsheetCell.
Эту функцию можно вызывать двумя способами: явно указывая тип в угловых
скобках или опуская его и предоставляя компилятору па основе заданных аргументов самому-
"догадаться", какой тип данных здесь используется. Рассмотрим несколько примеров.
int х = 3, intArr[4] = {l, 2, 3, 4},-
double dl = 5.6, dArr[4] = {1.2, 3.4, 5.7, 7.5};
int res,-
res = Find(x, intArr, 4); // Вызов функции Find<int> no
// дедукции.
res = Find<int>(x, intArr, 4); // Явный вызов функции
// Find<int>.
res = Find(dl, dArr, 4); // Вызов функции Find<double>
// по дедукции.
res = Find<double>(dl, dArr, 4); // Вызов функции
// Find<double> явным образом.
res = Find(x, dArr, 4); // HE СКОМПИЛИРУЕТСЯ! Аргументы имеют
// различные типы.
SpreadsheetCell cl(10), c2 [2] = {SpreadsheetCell(4) ,
SpreadsheetCell(10) };
res = Find(cl, c2, 2); // Вызов функции Find<SpreadsheetCell>
// по дедукции.
res = Find<SpreadsheetCell>(cl, c2, 2); // Вызов функции
// Find<SpreadsheetCell> явным образом.
Подобно шаблонам классов шаблоны функций могут принимать параметры, не
являющиеся типами. Из соображений экономии места мы приведем пример шаблона
функции, которая принимает только параметр-тип.
Стандартная библиотека C++ содержит шаблонную функцию find (),
которая гораздо эффективнее приведенной выше. Подробности —
в главе 22.
Специализация шаблонов функций
Подобно шаблонам классов, мы можем специализировать и шаблоны функций.
Например, мы могли бы написать функцию Find () для типа char*, которая выполняег
сравнение С-строк с помощью функции strcmpO, а не оператора operator== ().
Итак, рассмотрим специализацию функции Find ().
templateo
int Find<char*>(char*& value, char** arr, int size)
{
Глава 11. Пишем обобщенный код с помощью шаблонов 343
for (int i = 0; i < size; i++) {
if (strcmp(arr[i], value) == 0) {
// Значение найдено; возвращаем его индекс.
return (i);
// Значение не найдено; возвращаем —1.
return (-1);
}
Мы можем опустить выражение <char*> в имени функции, если о типе параметра
нетрудно догадаться по виду аргумента, и тогда прототип этой функции будет
выглядеть так.
templateo
int Find(char*& value, char** arr, int size)
Однако при перегрузке шаблонных функций правила дедукции усложняются (см.
следующий раздел), поэтому, чтобы избежать ошибок, лучше указывать тип явным образом.
Несмотря на то что специализированная функция find() вместо типа char*&
может принимать в качестве первого параметра только тип char*, для должного
функционирования правил дедукции лучше всего поддерживать эти аргументы
параллельно ее неспециализированной версии.
Специализацию этой функции можно использовать так.
char* word = "two";
char* arr [4] = {"one", "two", "three", "four"},-
int res;
res = Find<char*>(word, arr, 4); // Вызов char*-специализации.
res = Find(word, arr, 4); // Вызов char*-специализации.
Перегрузка шаблонов функций
Шаблонные функции можно также перегружать их нешаблонными версиями.
Например, вместо специализации шаблона функции Find () для типа char* мы могли бы
написать нешаблонную функцию Find (), которая обрабатывает значения типа char*.
int Find(char*& value, char** arr, int size)
{
for (int i = 0; i < size; i++) {
if (strcmp(arr[i], value) == 0) {
// Значение найдено.; возвращаем его индекс,
return (i);
}
}
// Значение не найдено; возвращаем -1.
return (-1);
}
Эта функция по поведению идентична специализированной версии из
предыдущего раздела. Но правила ее вызова несколько другие.
char* word = "two";
char* arr[4] = {"one", "two", "three", "four"};
int res;
344 Часть II. Пишем С++-код профессионально
res = Find<char*>(word, arr, 4); // Вызов шаблона Find()
// для T=char*.
res = Find(word, arr, 4); // Вызов нешаблонной функции Find()!
Таким образом, если вам нужно, чтобы функция работала в двух случаях (если явно
задан тип char* и, если тип не задан, т.е. посредством дедукции), вам следует
написать вместо нешаблонной специализированную шаблонную (перегруженную) версию.
Подобно шаблонным определениям методов класса определения шаблонных
функций (а не просто их прототипы) должны быть доступны для всех исходных файлов,
которые их используют. Следовательно, эти определения следует помещать в
заголовочные файлы, если они используются не одним, а несколькими исходными файлами.
Перегрузка шаблонов функций и их специализация
Можно написать как специализированный шаблон функции Find () для значений
типа char*, так и независимую функцию Find () для значений того же типа.
Компилятор всегда отдаст предпочтение нешаблонной функции, а не шаблонизированной
версии. Но если задать реализацию шаблона в явном виде, компилятор будет
вынужден использовать шаблонную версию.
char* word = "two";
char* arr[4] = {"one", "two", "three", "four"};
int res,-
res = Find<char *>(word, arr, 4); // Вызов char*-специализации
// шаблона,
res = Find(word, arr, 4); // Вызов нешаблонной функции Find О .
Шаблоны функций-"друзей" в шаблонах классов
Шаблоны функций часто применяют при перегрузке операторов в шаблоне
класса. Например, вы решили перегрузить оператор вывода для шаблона класса Grid,
чтобы иметь возможность выводить в поток содержимое сетки (таблицы).
Если вы незнакомы с механизмом перегрузки оператора opera t or << (), обратитесь
за деталями к главе 16.
Как утверждается в главе 16, метод operator<< () нельзя сделать членом класса
Grid: это должна быть отдельная функция. Определение, которое следует поместить
непосредственно в файл Grid. h, выглядит так.
template <typename T>
ostream& operator<<(ostream& ostr, const Grid<T>& grid)
{
for (int i = 0; i < grid.mHeight; i++) {
for (int j =0; j < grid.mWidth; j++) {
// Добавляем символ табуляции после каждого
// элемента строки.
ostr << grid.mCellstj][i] << "\t";
}
ostr << std::endl; // После каждой строки добавляем
// символ новой строки.
}
return (ostr);
}
Глава 11. Пишем обобщенный код с помощью шаблонов 345
Этот шаблон функции будет работать для любой версии класса Grid, если в ней
существует оператор вывода для элементов, хранимых в сетке. Единственная
проблема здесь состоит в том, что оператору вывода (operator<< ()) необходим доступ
Kprotected-членам класса Grid. Следовательно, он должен быть "другом" класса
Grid. Но ведь и класс Grid, и функция operator<< () являются шаблонами! В
действительности нам нужно, чтобы каждая реализация функции operator<< (),
использующая конкретный тип Т, была "другом" шаблонной реализации класса Grid для
того же типа. Синтаксис в этом случае будет выглядеть так.
//Grid.h
#include <iostream>
using std: :ostream,-
// Опережающее объявление шаблона Grid,
template <typename T> class Grid;
// Прототип шаблонной функции operator<<().
template<typename T>
ostream& operator<<(ostream& ostr, const Grid<T>& grid);
template <typename T>
class Grid
{
public:
// Код опущен из экономии места.
friend ostream& operator<< <T>(ostream& ostr,
const Grid<T>& grid);
// Код опущен из экономии места.
ь
Это объявление функции-"друга" стоит рассмотреть подробнее. Для экземпляра
шаблона, работающего с типом Т, Т-реализация функции operator<< () является
"дружественной". Другими словами, существует однозначное соответствие "друзей"
между реализациями класса и реализациями функций. Обратите внимание на явно
заданную спецификацию шаблона <Т> в объявлении функции operator<< () (пробел
после слова operator<< необязателен). Этот синтаксис сообщает компилятору о том,
что функция operator<< () сама является шаблоном. Некоторые компиляторы не
поддерживают этот синтаксис, но он вполне легален в C++ и работает на большинстве
новых компиляторов.
Расширенные шаблоны
Первая часть этой главы посвящена одному из самых популярных средств C++:
шаблонам классов и функций. Если вас интересуют только базовые знания шаблонов,
позволяющие использовать библиотеку STL и писать собственные простые классы, можете
поставить здесь точку. Но если вы собираетесь использовать шаблоны
профессионально и желаете узнать их реальную мощь, прочитайте и вторую часть этой главы.
346 Часть II. Пишем С+н—код профессионально
Подробнее о шаблонных параметрах
Реально можно говорить о трех видах шаблонных параметров: тип, не тип и
шаблонный шаблон (нет, это не "масло масляное": это действительно такое название!). Выше
вы видели примеры использования параметров-типов и параметров, не являющихся
типами, но с шаблонным параметром-шаблоном мы еще не встречались. Кроме того,
мы рассмотрели еще не все аспекты шаблонов и "нетиповых" параметров. Поэтому
нам есть еще о чем поговорить.
Подробнее о шаблонных параметрах-типах
Параметры-типы, принимаемые шаблонами, составляют основной смысл
существования механизма шаблонов. Можно объявить любое количество параметров-типов.
Например, в шаблон сетки мы могли бы добавить второй параметр-тип,
определяющий еще один шаблонный класс-контейнер, который бы служил основой для
построения сетки. Как упоминалось в главе 4, в стандартной библиотеке шаблонов
определено несколько шаблонных контейнерных классов, включая вектор (vector)
и дек, или очередь с двусторонним доступом (deque). В исходный класс сетки имело бы
смысл включить массив векторов или массив деков (вместо простого массива
массивов). С помощью второго шаблонного параметра-типа мы можем разрешить
пользователю указывать, какой базовый контейнер он желает использовать: вектор или дек.
Вот как выглядит определение класса с дополнительным шаблонным параметром.
template <typename T, typename Containers
class Grid
{
public:
Grid(int inWidth = kDefaultWidth,
int inHeight = kDefaultHeight);
Grid(const Grid<T, Container>& src);
-Grid();
Grid<T, Container>& operator=(const Grid<T,
Container>& rhs);
void setElementAt(int x, int y, const T& inElem);
T& getElementAt(int x, int y);
const T& getElementAt(int x, int y) const;
int getHeight() const { return mHeight; }
int getWidthO const { return mWidth; }
static const int kDefaultWidth = 10;
static const int kDefaultHeight =
Unprotected:
void copyFrom(const Grid<T, Container>& src);
Container* mCells,-
int mWidth, mHeight,-
};
Этот шаблон теперь имеет два параметра: Т и Container. Следовательно, везде, где
мы раньше писали Grid<T>, для указания двух шаблонных параметров нужно писать
Глава 11. Пишем обобщенный код с помощью шаблонов 347
GrickT, Containers Есть еще одно важное изменение: член mCells теперь является
указателем на динамически создаваемый массив, элементы которого имеют тип Container
(вместо указателя на динамически создаваемый двумерный массив элементов типа Т).
Рассмотрим определение конструктора класса. В нем предполагается, что тип
Container имеет метод resize (). При попытке реализовать этот шаблон путем
задания типа, который не имеет метода resize (), компилятор, как показано ниже,
сгенерирует сообщение об ошибке.
template <typename Т, typename Container>
Grid<T, Container>::Grid(int inWidth, int inHeight) :
mWidth(inWidth), mHeight(inHeight)
{
// Динамически создается массив из mWidth контейнеров.
mCells = new Container[mWidth];
for (int i = 0; i < mWidth; i++) {
// Изменяем размер каждого контейнера так, чтобы он мог
// содержать mHeight элементов.
mCells[i].resize(mHeight);
}
}
Теперь приведем определение деструктора. В конструкторе есть только одно
обращение к оператору new, поэтому и в деструкторе должен быть один вызов
оператора delete.
template <typename T, typename Container>
Grid<T, Container>::~Grid()
{
delete [] mCells,-
}
Код метода copyFrom () написан в предположении, что инициатор его вызова
имеет доступ к элементам в контейнере посредством оператора " [ ] " (оператора
доступа к элементам массива). В главе 16 разъясняется, как можно перегрузить оператор
" [] " для реализации этого средства в контейнерных классах, создаваемых
программистами, но пока вам достаточно знагь, что вектор и дек (из библиотеки STL)
поддерживают этот синтаксис.
template <typename T, typename Container>
void Grid<T, Container?::copyFrom(const Grid<T,
Container>& src)
{
int i, j ;
mWidth = src.mWidth;
mHeight = src.mHeight;
mCells = new Container[mWidth];
for (i = 0; i < mWidth; i++) {
// Изменяем каждый элемент, как в конструкторе.
mCells[i].resize(mHeight);
}
for (i = 0; i < mWidth; i++) {
for (j =0; j < mHeight; j++) {
mCells[i] [j] = src.mCells [i] [j] ;
348 Часть II. Пишем C++—код профессионально
Вот как выглядят реализации остальных методов.
template <typename Т, typename Containers
Grid<T, Containers::Grid(const Grid<T, Container>& src)
{
copyFrom(src);
}
template <typename T, typename Container>
Grid<T, Container>& Grid<T, Container>::operator=(
const Grid<T,
Container>& rhs)
{
// Проверка на самоприсваивание,
if (this == &rhs) {
return (*this);
}
// Освобождение старой памяти.
delete [] mCells;
// Копирование в новую память.
copyFrom(rhs);
return (*this) ,-
}
template <typename T, typename Container>
void Grid<T, Containers:setElementAt(int x, int y,
const T& inElem)
mCells tx] ty] = inElem;
template <typename T, typename Container>
T& Grid<T, Containers:getElementAt(int x, int y)
return (mCells[x] ty]);
template <typename T, typename Container>
const T& Grid<T, Containers:getElementAt(int x, int y) const
return (mCells[x] ty] );
Теперь мы можем создать объекты сетки и использовать их следующим образом.
Grid<int, vector<int> > mylntGrid;
Grid<int, deque<int> > myIntGrid2;
mylntGrid.setElementAt(3, 4, 5);
cout << mylntGrid.getElementAt(3, 4);
Grid<int, vector<int> > grid2(mylntGrid);
grid2 = mylntGrid;
Глава 11. Пишем обобщенный код с помощью шаблонов 349
Использование слова Container в качестве имени параметра-типа напрямую не
означает, что указанный тип обязательно должен быть контейнером. Чтобы
проверить это, попробуем реализовать класс Grid с использованием типа int.
Grid<int, int> test; // HE СКОМПИЛИРУЕТСЯ
Эта строка не скомпилируется, но сообщение об ошибке, обнаруженной
компилятором, может показаться несколько неожиданным. Он не станет "жаловаться",
что в качестве второго аргумента-типа задан "простой" тип int, а не контейнер.
Вместо этого он сообщит, что левая часть выражения .resize должна иметь тип
class/struct/union. Дело в том, что компилятор попытается сгенерировать класс
Grid с типом int для параметра-типа Container. Все будет работать прекрасно до
тех пор, пока он не попытается скомпилировать следующую строку.
mCellsti].resize(mHeight);
В этом месте компилятор "понимает", что объект mCells [i] в действительности
представляет собой int-значение, поэтому для него попросту невозможно вызвать
метод resize ()!
Этот подход к объявлению параметров может показаться слишком "закрученным"
и бесполезным. Однако он используется в стандартной библиотеке шаблонов. Такие
шаблоны классов, как stack, queue и priority_queue, принимают шаблонный
параметр-тип, задающий базовый контейнер, который может быть вектором (vector),
деком (deque) или списком (list).
Значения по умолчанию для шаблонных параметров-типов
Шаблонным параметрам можно присваивать значения по умолчанию. Например,
вы могли бы указать, что в качестве контейнера по умолчанию для вашего класса Grid
должен служить вектор (vector). В этом случае определение шаблонного класса
будет выглядеть так.
#include <vector>
using std::vector;
template <typename T, typename Container = vector<T> >
class Grid
{
public:
// Весь остальной код остается прежним.
};
Тип Т (первый шаблонный параметр) можно использовать в качестве аргумента
для vector-шаблона в значении, действующем по умолчанию для второго
шаблонного параметра. Обратите также внимание на то, что во избежание проблем с
синтаксическим анализом (о которых упоминалось выше в этой главе) между двумя угловыми
закрывающимися скобками необходимо оставлять пробел.
Синтаксис C++ требует, чтобы в template-строке заголовка для определений
методов значение, действующее для параметров по умолчанию, не повторялось.
Используя параметр, указываемый по умолчанию, клиенты могут теперь создавать
объекты сетки с помощью базового контейнера или без него.
Grid<int, vector<int> > mylntGrid;
Grid<int> my!ntGrid2;
350 Часть II. Пишем C++—код профессионально
Использование шаблонных параметров-шаблонов
С параметром Container, описанным в предыдущем разделе, есть одна проблема.
При реализации шаблона класса мы пишем строку кода, подобную следующей.
GricUint, vector<int> > mylntGrid;
Обратите внимание на повторение типа int. Вы должны здесь указать, что это
элементарный тип, который используется как в шаблоне Grid, так и контейнере vector.
А что если мы напишем такую строку кода?
Grid<int, vector<SpreadsheetCell> > mylntGrid;
Этот код нельзя назвать безукоризненным. Чтобы исключить шансы для
внедрения в него ошибок, неплохо было бы иметь возможность написать следующее.
Grid<int, vector> mylntGrid;
Определение класса Grid должно быть построено так, чтобы компилятор
"понимал", что оно подразумевает использование вектора для хранения int-значений
(vector<int>). Кроме того, компилятор не должен позволить передавать шаблонный
аргумент обычному параметру-типу, поскольку вектор — не просто тип, а шаблон.
Если вам нужно в качестве шаблонного параметра обеспечить передачу не просто
типа, а шаблона, необходимо использовать специальный вид параметра, именуемый
шаблонным параметром-шаблоном (template template parameter). Безусловно, синтаксис
применения параметров такого вида довольно неординарен, и неудивительно, что
некоторые компиляторы его не поддерживают. Но если такая "завязка" вас
заинтриговала, дочитайте этот раздел до конца.
Задание шаблонных параметров-шаблонов можно сравнить с заданием для
обычных функций параметров-указателей на функции. Описания типов указателей на
функции включают тип значения, возвращаемого функцией, и типы параметров,
принимаемых ею. Аналогично при задании шаблонного параметра-шаблона его
полная спецификация включает параметры для этого шаблона.
Описание контейнеров в библиотеке STL содержит список шаблонных
параметров, который выглядит примерно так.
template <typename E, typename Allocator = allocator<E> >
class vector
{
// Определение вектора.
};
Параметр Е означает элементарный тип. Назначение слова Allocator мы
рассмотрим в главе 21.
С использованием приведенной выше спецификации шаблона определение
шаблонного класса Grid, который принимает в качестве второго шаблонного параметра
шаблон-контейнер, будет выглядеть таким образом.
template <typename T, template <typename E,
typename Allocator = allocator<E> >
class Container = vector >
class Grid
{
public:
Глава 11. Пишем обобщенный код с помощью шаблонов 351
// Опущенный код совпадает с прежним.
Container<T>* mCells;
// Опущенный код совпадает с прежним.
};
Итак, что здесь объявляется? Первый шаблонный параметр не изменился по
сравнению с прежним вариантом: это все тот же элементарный тип Т. Второй
шаблонный параметр теперь сам является шаблоном для таких контейнеров, как vector
или deque. Этот "шаблонный тип" должен принимать два параметра: элементарный
тип Е и распределитель памяти Allocator. Обратите внимание на повторение слова
class после списка вложенных шаблонных параметров. Этот (второй) параметр
в шаблоне Grid носит (как и прежде) имя Container. По умолчанию теперь он
принимает значение vector, а не vector<T>, поскольку параметр Container
представляет собой шаблон, а не "обычный" тип.
Синтаксическое правило для шаблонного параметра-шаблона в более общем виде
выглядит так.
template <какие-то параметры, ...,
template <TemplateTypeParams> class ParameterName,
какие-то параметры, ...>
Теперь, если для объявления нашего шаблона вы одолели "тяготы" приведенного
выше синтаксиса, остальное вам покажется легкой забавой. Для того чтобы указать, что
вы имеете в виду тип контейнера, вместо использования в коде "одинокого" имени
Container необходимо употреблять выражение Container<T>. Теперь конструктор
выглядит так (в спецификации шаблонов для определения этого метода программистам
не нужно повторять значение по умолчанию для шаблонного параметра-шаблона).
template <typename T,
template <typename E,
typename Allocator = allocator<E> >
class Container?
Grid<T, Container>::Grid{int inWidth, int inHeight) :
mWidth(inWidth), mHeight(inHeight)
{
mCells = new Container<T>[mWidth];
for (int i = 0; i < mWidth; i++) {
mCells [i] . resize (mHeight) ,-
После реализации всех методов можно использовать этот шаблон таким образом.
Grid<int, vector> myGrid;
myGrid.setElementAt(1, 2, 3);
myGrid.getElementAt(1,2);
Grid<int, vector> myGrid2(myGrid);
Если вы полностью пропустили данный раздел, то можете подумать, что C++
заслуживает тех слов критики, которые когда-либо звучали в адрес этого языка.
Старайтесь не увязнуть в синтаксисе и помните одно: таки существует возможность передачи
шаблонов в качестве параметров для других шаблонов.
352 Часть II. Пишем C++—код профессионально
Подробнее о шаблонных параметрах, которые не являются типами
Возможна ситуация, когда вам потребуется разрешить пользователю указывать
пустой (не в буквальном смысле) элемент, который будет использован для
инициализации каждой ячейки сетки (таблицы). Вот как может выглядеть вполне корректный
подход для реализации этой цели.
template <typename T, const T EMPTY>
class Grid
{
public:
Grid(int inWidth = kDefaultWidth,
int inHeight = kDefaultHeight);
Grid(const Grid<T, EMPTY>& src);
-GridO ;
Grid<T, EMPTY>& operator=(const Grid<T, EMPTY>& rhs);
// Опущено из соображений экономии места,
protected:
void copyFrom(const Grid<T, EMPTY>& src);
T** mCells;
int mWidth, mHeight;
};
Это определение совершенно законно. Тип Т (указанный для первого параметра)
можно использовать для второго параметра, а параметры, не являющиеся типами,
могут быть (подобно параметрам функций) объявлены с использованием
модификатора const. Для инициализации каждой ячейки сетки вы можете использовать
следующее начальное значение для типа Т.
template <typename T, const T EMPTY>
Grid<T, EMPTY>::Grid(int inWidth, int inHeight) :
mWidth(inWidth), mHeight(inHeight)
{
mCells = new T* [mWidth];
for (int i = 0; i < mWidth; i++) {
mCells[i] = new T[mHeight];
for (int j =0; j < mHeight; j++) {
mCells[i][j] = EMPTY;
}
Другие определения методов остаются прежними, за исключением того, что в
template-строки необходимо добавить второй параметр-тип, в результате чего все
экземпляры выражения Grid<T> должны принять вид Grid<T, EMPTY>. После внесения
Глава 11. Пишем обобщенный код с помощью шаблонов 353
описанных изменений можно затем создавать экземпляры int-сетки по Grid-шаблону
с любым начальным значением для всех табличных элементов.
Grickint, 0> mylntGrid,-
Grid<int, 10> mylntGrid2;
Начальное значение может быть любым целым числом. Однако предположим, что
вы попытались создать Grid-сетку элементов типа SpreasheetCell.
SpreadsheetCell emptyCell,-
Grid<SpreadsheetCell,
emptyCell> mySpreadsheet; // HE СКОМПИЛИРУЕТСЯ
Эта строка приведет к возникновению ошибки компиляции, поскольку в качестве
аргументов для параметров, которые не являются типами, нельзя передавать объекты.
Параметры, не являющиеся типами, не могут принимать значения
объектов, а также double- или float-значения. Они ограничены
приемом только int-значений, перечислений, указателей и ссылок.
Этот пример демонстрирует одну из причудливых форм шаблонных классов,
которые могут корректно работать с одним типом, но не компилируются для другого.
Использование ссылок и указателей в качестве шаблонных параметров,
не являющихся типами
Пользователь может задавать начальный пустой элемент для сетки и другим
способом, а именно, используя в качестве шаблонного параметра, не являющегося
типом, ссылку на тип Т. Рассмотрим новое определение класса.
template <typename T, const Т& EMPTY>
class Grid
{
// Весь остальной код совпадает с кодом предыдущего примера,
// за исключением того, что в template-строках определений
// методов вместо выражения const T EMPTY необходимо
// указывать ссылочное выражение const T& EMPTY.
};
Теперь мы можем реализовать этот шаблонный класс для любого типа. Однако
ссылка, которая передается в качестве второго шаблонного аргумента, должна
относиться к глобальной переменной, использующей внешнее связывание (external linkage).
Внешнее связывание, которое следует рассматривать как противоположность
статическому, попросту означает, что данная переменная доступна в исходных файлах, т.е.
эне файла, в котором она определена (подробности — в главе 12). Пока вам
достаточно знать, что для объявления переменной, которая обладает свойством внешнего
связывания, можно использовать ключевое слово extern.
extern const int x = 0,-
Обратите внимание на то, что эта строка кода должна находиться вне тела какой-
либо функции или метода. Рассмотрим полный код программы, в которой
объявляются int- и SpreadsheetCell-сетки с одновременной инициализацией параметров.
354 Часть П. Пишем C++—код профессионально
#include "GridRefNonType.h"
#include "SpreadsheetCell.h"
extern const int emptylnt = 0;
extern const SpreadsheetCell emptyCell(0);
int main(int argc, char** argv)
{
Grid<int, emptylnt> mylntGrid;
Grid<SpreadsheetCell, emptyCell> mySpreadsheet;
Grid<int, emptylnt> myIntGrid2(mylntGrid);
return (0);
}
Используемые в качестве шаблонных аргументов указатели или
ссылки должны указывать на глобальные переменные, которые доступны
из всех единиц трансляции. Для таких типов переменных
используется такой технический термин, как данные с внешним связыванием.
Использование нуль-инициализации для шаблонных типов
Ни один из вариантов, представленных до сих пор для обеспечения начального
"пустого" значения для ячеек сетки, не является очень уж привлекательным. Вместо
того, чтобы разрешать пользователю самому задавать для каждой ячейки сетки начальное
значение, можно предоставить возможность программисту, инициализировать ячейки
подходящим (с его точки зрения) значением по умолчанию. Безусловно, сразу
возникает вопрос: что это. за такое "подходящее" значение для любого возможного типа? Для
объектов таким значением может быть объект, созданный с помощью конструктора по
умолчанию. Это как раз тот результат, который вы достигаете при создании массива
объектов. Но для таких простых типов данных, как int и double, а также указателей,
"подходящим" начальным значением является нуль. Следовательно, наша задача —
обеспечить присваивание значения 0 всем необъектам и использовать конструктор по
умолчанию для объектов. Соответствующий синтаксис бьи представлен в разделе
"Шаблоны методов с параметрами, не являющимися типами". Вот как выглядит
реализация конструктора Grid-шаблона с использованием синтаксиса нуль-инициализации.
template <typename T>
Grid<T>::Grid(int inWidth,
int inHeight) : mWidth(inWidth), mHeight(inHeight)
{
mCells = new T* [mWidth];
for (int i = 0; i < mWidth; i++) {
mCells[i] = new T[mHeight];
for (int j =0; j < mHeight; j++) {
mCells[i] [j] = T() ;
}
Получив в руки такое средство, можно вернуться к исходному классу Grid (без
использования параметра EMPTY, который не является типом) и просто
инициализировать каждый элемент ячейки соответствующим нуль-значением.
Глава 11. Пишем обобщенный код с помощью шаблонов 355
Частичная специализация шаблонного класса
Специализация класса на основе типа char*, представленная в первой части этой
главы, называется полной специализацией шаблонного класса, поскольку она определяет
"спецшаблон" Grid для каждого шаблонного параметра. В этой специализации нет
других шаблонных параметров. Но это не единственный способ специализации
класса; можно также написать частичную специализацию класса, в которой будут
специализироваться только некотрые шаблонные параметры, но не все. Например,
вспомним базовую версию шаблона Grid, которая содержит "нетипичные" параметры,
принимающие значения ширины и высоты таблицы.
template <typename Т, int WIDTH, int HEIGHT>
class Grid
{
public:
void setElementAt(int x, int y, const T& inElem);
T& getElementAt(int x, int y) ;
const T& getElementAt(int x, int y) const;
int getHeightO const { return HEIGHT; }
int getWidthO const { return WIDTH; }
};
protected:
T mCells[WIDTH][HEIGHT]
Для этого шаблонного класса мы могли бы определить специализацию для типа char*,
т.е. для С-строк.
#include "Grid.h" // Этот файл содержит приведенное выше
// определение шаблона Grid.
ttinclude <cstdlib>
#include <cstring>
using namespace std,-
template <int WIDTH, int HEIGHT>
class Grid<char*, WIDTH, HEIGHT>
{
public:
Grid();
Grid(const Grid<char*, WIDTH, HEIGHT>& src);
-Grid();
Grid<char*,
WIDTH, HEIGHT>& Grid<char*,
WIDTH, HEIGHT>::operator=(
const Grid<char*, WIDTH, HEIGHT>& rhs) ;
void setElementAt(int x, int y, const char* inElem);
char* getElementAt(int x, int y) const;
int getHeightO const { return HEIGHT; }
int getWidthO const { return WIDTH; }
protected:
void copyFrom(const Grid<char*, WIDTH, HEIGHT>& src);
char* mCells[WIDTH] [HEIGHT] ;
};
В данном случае мы специализируем не все шаблонные параметры. Поэтому
template-строка должна выглядеть так.
template <int WIDTH, int HEIGHT>
class Grid<char*, WIDTH, HEIGHT>
356 Часть П. Пишем С++-код профессионально
Обратите внимание на то, что рассматриваемый шаблон содержит только два
параметра: WIDTH и HEIGHT. Но ведь мы пишем класс Grid для трех параметров: Т, WIDTH
и HEIGHT. Итак, выходит, что в нашем списке шаблонных параметров всего два члена,
в то время как в явно заданном заголовке класса Grid<char *, WIDTH, HEIGHT>
указано три. При реализации шаблона необходимо по-прежнему указывать три параметра.
Нельзя создавать экземпляр Grid-класса, задавая только высоту и ширину сетки.
Grid<int, 2, 2> mylntGrid; // Используется исходный класс Grid
Grid<char*, 2, 2> myStringGrid; // Используется частичная
// специализация для типа char*.
Grid<2, 3> test; // НЕ СКОМПИЛИРУЕТСЯ! Не задан тип.
Как видите, синтаксис не самый простой. Но приготовьтесь к худшему. В
частичных специализациях, в отличие от полных, template-строка должна быть включена
перед каждым определением метода.
template <int WIDTH, int HEIGHT>
Grid<char*, WIDTH, HEIGHT>::Grid()
for (int i = 0; i < WIDTH; i++) {
for (int j = 0; j < HEIGHT; j++) {
// Инициализируем каждый элемент NULL-значением.
mCellsfi] [j] = NULL;
Чтобы показать, что данный метод параметризуется по двум параметрам, здесь
используется template-строка, список параметров которой состоит из двух элементов.
Обратите внимание на то, что при ссылке на полное имя класса необходимо
использовать весь список параметров: Grid<char*, WIDTH, HEIGHT>.
Вот как выглядят определения остальных методов.
template <int WIDTH, int HEIGHT>
Grid<char*, WIDTH, HEIGHT>::Grid(const Grid<char*,
WIDTH, HEIGHT>& src)
{
copyFrom(src);
}
template <int WIDTH, int HEIGHT>
-Grid<char*, WIDTH, HEIGHT>::~Grid()
{
for (int i = 0; i < WIDTH; i++) {
for (int j = 0; j < HEIGHT; j++) {
delete [] mCells [i] [j] ;
template <int WIDTH, int HEIGHT>
void Grid<char*, WIDTH, HEIGHT>::copyFrom(const Grid<char*,
WIDTH, HEIGHT>& src)
{
int i, j;
for (i = 0; i < WIDTH; i++) {
for (j =0; j < HEIGHT; j++) {
if (src.mCells[i] [j] == NULL) {
mCells[i] [j] = NULL,-
Глава 11. Пишем обобщенный код с помощью шаблонов 357
} else {
mCells [i] [j] = new char[strlen(
src.mCells[i] [j] )+1] ,-
strcpy(mCells[i] [j] ,
src.mCells[i][j]);
}
}
}
}
template <int WIDTH, int HEIGHT>
Grid<char*,
WIDTH, HEIGHT>& Grid<char*,
WIDTH, HEIGHT>::operator=(
const Grid<char*,
WIDTH, HEIGHT>& rhs)
{
}
int i, j;
// Проверка на самоприсваивание.
if (this == &rhs) {
return (*this);
}
// Освобождение старой памяти.
for (i = 0; i < WIDTH; i++) {
for (j =0; j < HEIGHT; j++) {
delete [] mCells [i] [j] ;
}
}
// Копирование в новую память.
copyFrom(rhs);
return (*this);
template <int WIDTH, int HEIGHT>
void Grid<char*, WIDTH, HEIGHT>::setElementAt(
int x, int y,
const char* inElem)
{
}
delete [] mCells[x] [y];
if (inElem == NULL) {
mCells[x][y] = NULL;
} else {
mCells [x] [y] = new char [strlen(inElem) + 1] ,-
strcpy(mCells[x][y], inElem);
}
template <int WIDTH, int HEIGHT>
char* Grid<char*, WIDTH, HEIGHT>::getElementAt(int x,
int y) const
{
if (mCells[x][y] == NULL) {
return (NULL);
}
char* ret = new char[strlen(mCells[x] [y] ) + 1];
strcpy(ret, mCells[x][y]);
return (ret);
358 Часть П. Пишем С++-код профессионально
Еще одна форма частичной специализации
Предыдущий пример не демонстрирует истинную силу частичной специализации.
Но оказывается, не определяя специализацию для отдельных типов, можно создавать
специализированные реализации для некотрого подмножества возможных типов.
Например, можно написать специализацию класса Grid для всех типов-указателей.
Такая специализация вместо хранения поверхностных копий указателей в сетке могла
бы создавать детальные копии объектов, адресуемых указателями.
Рассмотрим определение класса, предполагая, что мы определяем специализацию
начальной версии класса Grid (в которой был только один параметр).
#include "Grid.h"
template <typename T>
class Grid<T*>
{
public:
Grid(int inWidth = kDefaultwidth,
int inHeight = kDefaultHeight);
Grid(const Grid<T*>& src);
-Grid();
Grid<T*>& operator=(const Grid<T*>& rhs) ;
void setElementAt(int x, int y, const T* inElem);
T* getElementAt(int x, int y) const;
int getHeightO const { return mHeight; }
int getwidth() const { return mWidth; }
static const int kDefaultwidth = 10;
static const int kDefaultHeight = 10;
protected:
void copyFrom(const Grid<T*>& src);
T** mCells;
int mWidth, mHeight;
};
Как обычно, главная "изюминка" здесь содержится в этих двух строках.
template <typename T>
class Grid<T*>
Этот синтаксис означает, что данный класс является специализацией шаблона
Grid для всех типов-указателей (по крайней мере именно об этом сообщается
компилятору). Нам же с вами ясно, что комитету по стандарту C++ следует поработать над
улучшением этого синтаксиса! Те, кому приходилось работать с ним в течение
долгого времени, хорошо помнят связанное с ним ощущение раздражения.
Глава 11. Пишем обобщенный код с помощью шаблонов 359
Эта реализация возможна только в случаях, когда Т является типом-указателем.
Обратите внимание на то, что, если реализовать сетку так: Grid<int*> mylntGrid, то Т
в действительности будет означать тип int, а не int *. Это кажется несколько
нелогичным, но, к сожалению, именно так эта штука работает. Рассмотрим пример программы.
Grid<int*> psGrid(2, 2); // Используется частичная
// специализация для типов-указателей,
int х = 3, у = 4 ;
psGrid.setElementAt(О, 0, &х);
psGrid.setElementAt(0, 1, &у);
psGrid.setElementAt(1, 0, &у);
psGrid.setElementAt(1, 1, &х);
Grid<int> mylntGrid; // Используется неспециализированная сетка.
Вероятно, вы засомневались, а работает ли такой код вообще? Мы разделяем ваш
скептицизм. Один из авторов этой книги не верил в возможность работы
рассматриваемого синтаксиса, пока не проверил его в действии. И вы можете проверить его
сами! Ниже приведены реализации методов. Обратите особое внимание на синтаксис
template-строки перед каждым методом.
template <typename T>
const int Grid<T*>:rkDefaultWidth;
template <typename T>
const int Grid<T*>::kDefaultHeight;
template <typename T>
Grid<T*>::Grid(int inWidth, int inHeight) : mWidth(inWidth),
mHexght(inHeight)
{
mCells = new T* [mWidth];
for (int i = 0; i < mWidth; i++) {
mCells[i] = new T[mHeight];
}
}
template <typename T>
Grid<T*>::Grid(const Grid<T*>& src)
{
copyFrom(src);
}
template <typename T>
Grid<T* >::-Grid()
{
// Освобождаем старую память.
for (int i = 0; i < mWidth; i++) {
delete [] mCells[i];
}
delete [] mCells;
}
template <typename T>
void Grid<T*>::copyFrom(const Grid<T*>& src)
{
int i, j;
mWidth = src.mWidth;
mHeight = src.mHeight;
mCells = new T* [mWidth];
360 Часть П. Пишем С++-код профессионально
for (i = 0; i < mWidth; i++) {
mCells[i] = new T[mHeight];
}
for (i = 0; i < mWidth; i++) {
for (j = 0; j < mHeight; j++) {
mCells[i] [j] = src.mCells[i] [j] ;
template <typename T>
Grid<T*>& Grid<T*>::operator=(const Grid<T*>& rhs)
{
// Проверка на самоприсваивание,
if (this == &rhs) {
return (*this);
}
// Освобождаем старую память,
for (int i = 0; i < mWidth; i++) {
delete [] mCells[i];
}
delete [] mCells;
// Копируем в новую память.
copyFrom(rhs);
return (*this);
}
template <typename T>
void Grid<T*>::setElementAt(int x, int y, const T* inElem)
{
mCells [x] [y] = *inElem,-
}
template <typename T>
T* Grid<T*>::getElementAt(int x, int y) const
{
T* newElem = new T(mCells[x][y] ) ;
return (newElem);
}
Эмуляция частичной специализации функций
механизмом перегрузки
Стандарт C++ не разрешает использовать частичную специализацию шаблона
функций. Вместо этого можно перегрузить функцию другим шаблоном. Разница лишь
в деталях. Предположим, вы хотели бы написать специализацию функции Find()
(представленную выше в этой главе), которая разыменовывает указатели, чтобы
использовать оператор operator== () непосредственно для сравнения адресуемых ими
объектов. Рассмотрим синтаксис частичной специализации шаблона класса, с
помощью которого вы собирались реализовать ваше намерение.
template <typename T>
int Find<T*>(T*& value, T** arr, int size)
{
for (int i = 0; i < size; i++) {
if (*arr[i] == *value) {
// Значение найдено; возвращаем его индекс.
return (i);
}
Глава 11. Пишем обобщенный код с помощью шаблонов 361
}
// Значение найти не удалось; возвращаем -1.
return (-1);
}
Однако с помощью такого синтаксиса объявляется частичная специализация
шаблона функции, которая запрещена стандартом C++ (хотя некоторые компиляторы все
же поддерживают ее). Стандартный же способ реализации нужного поведения
состоит в написании нового шаблона для функции Find ().
template <typename T>
int Find(T*& value, T** arr, int size)
for (int i = 0; i < size; i++) {
if (*arr[i] == *value) {
// Значение найдено; возвращаем его индекс,
return (i);
}
}
// Значение найти не удалось; возвращаем -1.
return (-1);
}
Различие между этими двумя вариантами может показаться тривиальным и чисто
теоретическим, но оно в итоге определяет различие между переносимым,
стандартным кодом и кодом, который, возможно, не скомпилируется.
Подробнее о дедукции
В одной программе можно определить исходный шаблон функции Find (),
перегруженный ее вариант частичной специализации для типов-указателей, версию
полной специализации для типа char* и перегруженную функцию FindO для типа
char*. При вызове функции FindO компилятор выберет соответствующую версию
на основе правил дедукции.
Компилятор всегда выбирает "самую конкретную" версию функции,
предпочитая нешаблонные версии шаблонным. .: Ь
При выполнении следующего кода будут вызываться различные версии функции
Find () (они отмечены в комментариях).
char* word = "two";
char* arr[4] = {"one", "two", "three", "four" },-
int res,-
int x = 3, intArr[4] = {l, 2, 3, 4},-
double dl = 5.6, dArr[4] = {1.2, 3.4, 5.7, 7.5};
res = Find(x, intArr, 4); // Вызывается Find<int> по дедукции,
res = Find<int>(x, intArr, 4); // Вызывается Find<int> явно.
res = Find(dl, dArr, 4) ,- // Вызывается Find<double> no
// дедукции,
res = Find<double>(dl, dArr, 4); // Версия Find<double>
// вызывается явно.
res = Find<char *>(word, arr, 4); // Вызывается специализация
362 Часть II. Пишем С++-код профессионально
// шаблона для типа char*.
res = Find(word, arr, 4); // Вызывается перегруженная
// функция Find() для типа char*.
int *px = &х, *рАгг[2] = (&х, &х};
res = Find(px, pArr, 2); // Вызывается перегруженная
// функция Find() для указателей.
SpreadsheetCell cl(10), с2 [2] = (SpreadsheetCell(4),
SpreadsheetCell(10) } ;
res = Find(cl, c2, 2); // Версия Find<SpreadsheetCell>
// вызывается по дедукции.
res = Find<SpreadsheetCell>(cl, c2, 2); // Версия
// Find<SpreadsheetCell> вызывается
// явно.
SpreadsheetCell *pcl = &cl,-
SpreadsheetCell *psa[2] = {&cl, &cl};
res = Find(pel, psa, 2); // Вызывается перегруженная
// функция Find() для указателей.
Применение рекурсии к шаблонам
Механизм шаблонов в C++ предоставляет программисту огромные возможности,
которые выходят далеко за пределы тех простых классов и функций, которые мы
рассматривали до сих пор в этой главе. Одна из таких возможностей — рекурсия
шаблонов. В данном разделе мы сначала поговорим о мотивации для использования этого
средства, а затем покажем, как его реализовать.
В настоящем разделе используется перегрузка некоторых операторов, синтаксис которой
рассматривается в главе 16. Если вы незнакомы с синтаксисом перегрузки оператора
operator [ ], обратитесь сначала к указанной главе.
N-мерная сетка: первый вариант
Рассмотренный выше пример с Grid-шаблоном поддерживает только две
размерности, что ограничивает область его применения. А что если бы мы решили
запрограммировать 3-мерную игру в "крестики-нолики" или написать математическую
программу с 4-мерными матрицами? Мы могли бы, конечно, создать шаблонный или
нешаблонный класс для каждой размерности. Но в этом случае пришлось бы
повторять большой объем кода. Но можно поступить иначе. Сначала написать вариант
только для одномерной сетки, а затем — создать шаблон Grid любой размерности,
реализовав его на основе другого класса Grid, используемого в качестве
элементарного типа. Сам этот элементарный тип Grid можно было бы в свою очередь
реализовать с помощью класса Grid, который используется в качестве его элементарного
типа, и т.д. Ниже приведена реализация шаблона класса OneDGrid. Это — не что
иное как одномерная версия шаблона Grid из самых первых примеров, но с
добавлением метода resize () и замены оператором operator [] методов set Element At ()
и getElementAt (). Коммерческий вариант этого кода должен, безусловно,
содержать граничную проверку (на отсутствие нарушения границ при доступе к массиву)
и генерировать исключение в случае нештатной ситуации.
Глава 11. Пишем обобщенный код с помощью шаблонов 363
template <typename T>
class OneDGrid
{
public:
OneDGrid(int inSize = kDefaultSize);
OneDGrid(const OneDGrid<T>& src);
-OneDGrid();
OneDGrid<T> &operator=(const OneDGrid<T>& rhs);
void resize(int newSize);
T& operator [] (int x) ;
const T& operator[](int x) const;
int getSizeO const { return mSize; }
static const int kDefaultSize = 10;
protected:
void copyFrom(const OneDGrid<T>& src);
T* mElems;
int mSize;
}.■
template <typename T>
const int OneDGrid<T>::kDefaultSize;
template <typename T>
OneDGrid<T>::OneDGrid(int inSize) : mSize(inSize)
{
mElems = new TfmSize];
}
template <typename T>
OneDGrid<T>::OneDGrid(const OneDGrid<T>& src)
{
copyFrom(src);
}
template <typename T>
OneDGrid<T>::-OneDGrid()
{
delete [] mElems;
}
template <typename T>
void OneDGrid<T>::copyFrom( const OneDGrid<T>& src)
{
mSize = src.mSize;
mElems = new T[mSize];
for (int i = 0; i < mSize; i++) {
mElems[i] = src.mElems[i];
template <typename T>
OneDGrid<T>& OneDGrid<T>::operator=(const OneDGrid<T>& rhs)
{
// Проверка на самоприсваивание.
if (this == &rhs) {
return (*this);
}
// Освобождение старой памяти.
delete [] mElems;
// Копирование в новую память.
copyFrom(rhs);
364 Часть II. Пишем C++—код профессионально
return (*this);
}
template <typename T>
void OneDGrid<T>::resize(int newSize)
{
T* newElems = new T[newSize]; // Создаем динамически новый
// массив нового размера.
// Выполняем действия, если новый размер меньше или больше
// старого.
for (int i = 0; i < newSize && i < mSize; i++) {
// Копируем элементы из старого массива в новый.
newElems[i] = mElems[i];
}
mSize = newSize; // Сохраняем новый'размер.
delete [] mElems; // Освобождаем память для старого
// массива.
mElems = newElems; // Сохраняем указатель на новый массив.
}
template <typename T>
Т& OneDGrid<T>::operator[](int x)
{
return (mElems [x] ) ;
}
template <typename T>
const T& OneDGrid<T>::operator[](int x) const
{
return (mElems[x]);
}
Используя эту реализацию шаблона класса OneDGrid, многомерные сетки можно
создавать следующим образом.
OneDGrid<int> singleDGrid;
OneDGrid<OneDGrid<int> > twoDGrid;
OneDGrid<OneDGrid<OneDGrid<int> > > threeDGrid;
singleDGrid[3] = 5;
twoDGrid[3] [3] = 5;
threeDGrid[3][3][3] = 5;
Этот код прекрасно работатет, но используемые здесь объявления оставляют
желать лучшего. Попробуем реализовать это желание.
Реальная N-мерная сетка
Рекурсию шаблонов можно использовать для написания "настоящей" Л^мерной
сетки, поскольку размерность сеток рекурсивна по своей сути. Это подтверждается
следующим объявлением.
OneDGrid<OneDGrid<OneDGrid<int> > > threeDGrid;
Каждое вложение шаблона OneDGrid можно рассматривать как рекурсивный шаг,
а реализацию шаблона OneDGrid для типа int— как базовый вариант (точку
"останова" рекурсии). Другими словами, трехмерная сетка— это одномерная сетка,
содержащая одномерные сетки, которые содержат int-значения. Вместо того, чтобы
создавать такую рекурсию, вы можете написать шаблонный класс, который сделает
это за вас. А затем уже можно создавать Л^мерные сетки таким вот образом.
Глава 11. Пишем обобщенный код с помощью шаблонов 365
NDGrid<int, 1> singleDGrid;
NDGrid<int, 2> twoDGrid; i
NDGrid<int, 3> threeDGrid; I
Шаблонный класс NDGrid принимает два параметра: тип и целочисленное
значение, которое определяет "размерность" сетки. Главное здесь то, что элементарным
типом шаблона NDGrid является не тот элементарный тип, который указывается
в списке шаблонных параметров, а другой шаблон NDGrid, размерность которого на
единицу меньше размерности текущего шаблона. Другими словами, трехмерная
сетка— это массив двухмерных сеток; а каждая двухмерная сетка представляет собой
массив одномерных сеток.
При использовании рекурсии необходимо позаботиться о базовом варианте, или
точке "останова" рекурсии. Для этого можно написать частичную специализацию
шаблона NDGrid для размерности, равной 1, в которой элементарным типом будет
служить не другой шаблон NDGrid, а тип, задаваемый шаблонным параметром.
Вот как выглядит общее определение шаблона NDGrid (более темным фоном
помечены строки, которыми данный шаблон отличается от приведенного выше).
template <typename T, int N>
class NDGrid
{
public:
NDGrid();
NDGrid(int inSize);
NDGrid(const NDGrid<T, N>& src);
-NDGrid();
NDGrid<T, N>& operator=(const NDGrid<T, N>& rhs);
void resize(int newSize);
NDGrid<T, N-l>& operator[](int x);
const NDGrid<T, N-l>& operator!](int x) const;
int getSizeO const { return mSize,- }
static const int kDefaultSize = 10;
protected:
void copyFrom(const NDGrid<T, N>& src);
NDGrid<T, N-l>* mElems;
int mSize;
};
Обратите внимание на то, что член mElems определен указателем на тип NDGrid<T,
N-l>: это как раз и есть рекурсивный шаг. Кроме того, метод operator [] ()
возвращает ссылку на элементарный тип, которым снова-таки является шаблон NDGrid<T#
N-l>, а не тип Т.
А вот как выглядит определение шаблона для базового варианта.
template <typename T>
class NDGrid<T, 1>
366 Часть II. Пишем C++—код профессионально
{
public:
NDGrid(int inSize = kDefaultSize);
NDGrid(const NDGrid<T, 1>& src) ;
-NDGrid() ;
NDGrid<T, 1>& operator=(const NDGrid<T, 1>& rhs) ;
void resize(int newSize);
T& operator [] (int x) ;
const T& operator[](int x) const;
int getSizeO const { return mSize; }
static const int kDefaultSize = 10;
protected:
void copyFrom(const NDGrid<T, 1>& src) ;
T* mElems;
int mSize;
};
На этом базовом варианте и завершается рекурсия: элементарным типом здесь
служит тип Т, а не еще одна реализация шаблона.
Помимо самой идеи рекурсии шаблона в этом примере необходимо понять, как
задается соответствующий размер для каждой размерности массива. Данная реализация
создает Л^мерный массив, все размерности которого имеют одинаковый размер.
Гораздо труднее для каждой из них определить "свой" размер. Но даже при таком
упрощении здесь все же остается одна проблема: пользователь должен иметь
возможность создавать массив заданного размера, например, 20 или 50. Следовательно, один
из конструкторов класса должен принимать целочисленный параметр, означающий
размер. Но при динамическом создании вложенного массива сеток это значение
размера передать невозможно, поскольку массивы создают объекты с помощью своих
конструкторов по умолчанию. Поэтому для каждого "сеточного" элемента массива
необходимо явно вызывать метод resize ().
Базовый вариант шаблона не нуждается в изменении размеров своих элементов,
поскольку его элементами являются значения типа Т, а не сетки.
Вот как выглядит реализация основного шаблона NDGrid (более темным фоном
помечены строки, которыми данный шаблон отличается от шаблона OneDGrid).
template <typename T, int N>
const int NDGrid<T, N>::kDefaultSize;
template <typename T, int N>
NDGrid<T, N>::NDGrid(int inSize) : mSize(inSize)
{
mElems = new NDGrid<T, N-l>[mSize];
// При динамическом создании массива вызывается конструктор
Глава 11. Пишем обобщенный код с помощью шаблонов 367
// без аргументов для шаблона NDGrid<T, N-l>. Размер этого
// массива определяется по умолчанию. Поэтому для каждого
// элемента массива мы должны явно вызвать метод resize().
for (int i = 0; i < mSize,- i++) {
mElems[i].resize(inSize);
template <typename T, int N>
NDGrid<T, N>::NDGrid() : mSize(kDefaultSize)
{
mElems = new NDGrid<T, N-l>[mSize];
}
template <typename T, int N>
NDGrid<T, N>::NDGrid(const NDGrid<T, N>& src)
{
copyFrom(src);
}
template <typename T, int N>
NDGrid<T, N>::-NDGrid()
{
delete [] mElems;
}
template <typename T, int N>
void NDGrid<T, N>::copyFrom(const NDGrid<T, N>& src)
{
mSize = src.mSize;
mElems = new NDGrid<T, N-l>[mSize];
for (int i = 0; i < mSize; i++) {
mElems[i] = src.mElems[i];
template <typename T, int N>
NDGrid<T, N>& NDGrid<T, N>::operator=(const NDGrid<T N>& rhs)
{
// Проверяем на самоприсваивание,
if (this == &rhs) {
return (*this);
}
// Освобождаем старую память.
delete [] mElems;
// Копируем в новую память.
copyFrom(rhs);
return (*this);
}
template <typename T, int N>
void NDGrid<T, N>::resize(int newSize)
368 Часть II. Пишем С++-код профессионально
{
// Динамически создаем новый массив нового размера.
NDGrid<T, N - 1>* newElems = new NDGrid<T, N - 1>[newSize] ;
// Копируем все элементы, обрабатывая ситуации, когда
// newSize больше mSize и меньше mSize.
for (int i = 0; i < newSize && i < mSize; i++) {
newElems[i] = mElems [i];
// Рекурсивно изменяем размер элементов вложенного
// Grid-шаблона.
newElems[i].resize(newSize);
}
// Сохраняем новый размер и указатель на новый массив.
// Сначала освобождаем память старого массива.
mSize = newSize;
delete [] mElems;
mElems = newElems;
}
template <typename T, int N>
NDGrid<T, N-l>& NDGrid<T, N>::operator[](int x)
{
return (mElems[x]);
}
template <typename T, int N>
const NDGrid<T, N-l>& NDGrid<T, N>::operator[](int x) const
{
return (mElems[x]);
}
Ниже приведена реализация частичной специализации (базовый вариант
шаблона). Обратите внимание на необходимость повторения большого объема кода,
поскольку при специализации шаблона никакие реализации не наследуются. Более
темным фоном здесь выделены отличия от неспециализированного шаблона NDGrid.
template <typename T>
const int NDGrid<T, 1>: :kDefaultSize,-
template <typename T>
NDGrid<T, 1>::NDGrid(int inSize) : mSize(inSize)
{
mElems = new T[mSize];
}
template <typename T>
NDGrid<T, 1>::NDGrid(const NDGrid<T, 1>& src)
{
copyFrom(src);
}
Глава 11. Пишем обобщенный код с помощью шаблонов 369
template <typename T>
NDGrid<T, 1>::-NDGrid()
{
delete [] mElems;
}
template <typename T>
void NDGrid<T, 1>::copyFrom(const NDGrid<T, 1>& src)
{
mSize = src.mSize;
mElems = new T[mSize];
for (int i = 0; i < mSize; i++) {
mElems[i] = src.mElems[i];
}
}
template <typename T>
NDGrid<T, 1>& NDGrid<T, 1>::operator=(const NDGrid<T, 1>& rhs)
{
// Проверка на самоприсваивание.
if (this == &rhs) {
return (*this);
}
// Освобождение строй памяти.
delete [] mElems;
// Копирование в новую память.
copyFrom(rhs);
return (*this);
}
template <typename T>
void NDGrid<T, 1>::resize(int newSize)
{
T* newElems = new T[newSize];
for (int i = 0; i < newSize && i < mSize; i++) {
newElems[i] = mElems[i];
// Здесь не нужно рекурсивно изменять размер, поскольку
// это базовый вариант.
}
mSize = newSize;
delete [] mElems;
mElems = newElems;
} •
template <typename T>
T& NDGrid<T, 1>::operator[](int x)
{
return (mElems[x]);
}
370 Часть II. Пишем С++-код профессионально
template <typename T>
const T& NDGrid<T, 1>::operator[](int x) const
{ '
return (mElems[x]);
}
Теперь мы можем написать код, подобный следующему.
NDGrid<int, 3> my3DGrid;
my3DGrid[2] [l] [2] = 5;
my3DGrid[l] [1] [1] = 5;
cout << my3DGrid[2][1][2] << endl;
Резюме
В этой главе вы узнали, как использовать шаблоны для обобщенного
программирования. Мы надеемся, что вы сумели по достоинству оценить силу и возможности
этих средств, а также поняли, как можно применить этот механизм для решения
прикладных задач. Не расстраивайтесь, если, прочитав один раз, вы не до конца освоили
весь представленный здесь синтаксис и примеры. Авторы признают, что к такому
синтаксису нужно немного привыкнуть, поскольку сами прошли в свое время через
этап непонимания, и поэтому часто консультировались у специалистов. Когда вы
возьметесь за написание реального шаблонного класса или функции, смело
обращайтесь к этой главе, чтобы найти соответствующий синтаксис.
Эту главу можно рассматривать как подготовку к освоению материала глав 21, 22
и 23, которые посвящены использованию стандартной библиотеки шаблонов. Если
вы хотите прямо сейчас получить информацию о библиотеке STL, можете сразу
перейти к главам 21—23, но мы все же рекомендуем сначала прочитать все остальные
главы частей II и III.
Причуды
и странности C++
Многие части языка C++ отличаются сложным синтаксисом или причудливой
семантикой. Трудясь на ниве С++-программирования, вы постепенно привыкаете к его
особенностям, которые со временем начинают казаться вам вполне естественными.
И все же некоторые аспекты C++ являются постоянным источником неразберихи.
Либо в прочитанных вами книгах им никогда не уделялось должного внимания, либо
вы забыли, как с ними нужно обращаться, и постоянно ищете ответы на свои "почему"
и "как", либо и то, и другое. В этой главе мы постараемся заполнить многие "белые
пятна" и дать ответы на наиболее популярные вопросы.
Многие трудные аспекты языка разъясняются в различных главах этой книги.
Здесь мы не собираемся повторять эти темы, ограничившись вопросами, которые
детально еще не затрагивались в книге. Возможно, определенная избыточность здесь
и присутствует, но для того, чтобы вы могли увидеть важные детали в новом ракурсе,
материал этой главы "подан" под другим утлом.
В этой главе получили отражение такие темы, как ссылки, приведение к типу,
оператор разрешения контекста, заголовочные файлы, списки аргументов переменной
длины, макроопределения препроцессора, а также описание назначения ключевых
слов const, static, extern и typedef. И хотя этот список тем может показаться
весьма эклектичным, он, тем не менее, представляет собой тщательно подобранную
коллекцию самых запутанных, но часто используемых аспектов языка C++.
372 Часть II. Пишем C++—код профессионально
Ссылки
В профессиональном С++-коде (как и в коде, представленном в этой книге) ссылки
используются довольно часто. Поэтому мы сделаем небольшое отступление и
рассмотрим, что в действительности они собой представляют и как себя ведут.
Ссылка (reference) в C++ — это своего рода псевдоним, предлагаемый для некоторой
другой переменной. Все модификации, которым подвергается ссылка, изменяют
значение переменной, на которую она ссылается. Вы можете представлять себе ссылки как
неявные указатели, которые избавляют вас от забот, связанных со "взятием" адреса
переменной и разыменованием указателя (т.е. с получением значения объекта, к которому
отсылает данный указатель). Ссылку также можно считать еще одним именем для
исходной переменной. Программист может создавать независимые ссылочные
переменные, использовать ссылочные члены данных в классах, принимать ссылки в качестве
параметров функций и методов, а также возвращать ссылки из функций и методов.
Ссылочные переменные
Ссылочные переменные должны быть инициализированы сразу же при создании,
например, так.
int X = 3;
int& xRef = х;
В результате этого присваивания xRef становится еще одним именем для
переменной х. Любое использование имени xRef означает использование текущего
значения переменной х. Любое присваивание переменной xRef повлечет за собой
изменение значения х. Например, при выполнении следующей строки кода
переменная х будет установлена равной числу 10 через ссылку xRef (т.е. без
использования самой переменной х).
xRef = 10;
Ссылочную переменную нельзя объявить вне класса без ее инициализации.
int& emptyRef; // НЕ скомпилируется!
Ссылки при создании необходимо всегда инициализировать.
Обычно это делается при их объявлении, но ссылочные члены данных
молено инициализировать в списке инициализаторов для
соответствующего класса. *
Нельзя создать ссылку на неименованное значение, например, на целочисленный
литерал, если при этом ее не объявить const-ссылкой.
int& unnamedRef = 5; // НЕ СКОМПИЛИРУЕТСЯ!
const int& unnamedRef = 5,- // Работает ожидаемым образом.
Модификация ссылок
Ссылка всегда указывает на переменную, для которой она была инициализирована;,
после создания ссылку изменить уже нельзя. Это правило приводит нас к несколько
запутанному синтаксису. Если вы "присваиваете" переменную некоторой ссылке
Глава 12- Причуды и странности C++ 373
в момент ее (ссылки) объявления, то она будет указывать на эту переменную. Но если
назначить переменную ссылке после ее объявления, переменная, на которую эта
ссылка указывала, изменится, приняв значение переменной, для которой
выполняется "присваивание". Ссылка не может "обновиться", чтобы указывать на новую
переменную. Рассмотрим следующий пример.
int х = 3, у = 4;
int& xRef = х;
xRef = у; // Значение переменной х изменяется и становится
// равным 4. Но ссылка xRef не указывает на
//.переменную у.
Попробуем обойти это ограничение "взятием" адреса переменной у при
выполнении присваивания.
int х = 3, у = 4 ;
int& xRef = х;
XRef = &у; // НЕ СКОМПИЛИРУЕТСЯ!
1 Адрес переменной у является указателем, а поскольку переменная xRef объявлена
ссылкой на int-переменную, а не на указатель, этот код не скомпилируется.
Некоторые программисты, пытаясь перехитрить "врожденную" семантику
ссылок, идут еще дальше. А что если присвоить одну ссылку другой? Не заставит ли это
первую ссылку указывать на переменную, на которую указывает вторая ссылка?
Попробуем выполнить такой код.
int х = 3, z = 5;
int& xRef = х;
int& zRef = z;
zRef = xRef; // Выполняется присваивание значений,
// а не ссылок.
При выполнении последней строки ссылка zRef не изменится. Вместо этого она
установит значение переменной z равным 3, поскольку xRef ссылается на
переменную х, которая равна числу 3.
Невозможно поменять (на другую) переменную» на которую
указывает ссылка после ее инициализации; можно изменить только значение
этой переменной.
Ссылки на указатели и указатели на ссылки
Можно создавать ссылки на переменные любого типа, включая тип-указатель.
Рассмотрим пример ссылки на int-указатель (т.е. указатель на int-значение).
int* intP;
int*& ptrRef = intP;
ptrRef = new int;
*ptrRef = 5;
Этот синтаксис выглядит немного странным: вам, должно быть, непривычно видеть
рядом операторы "*" и "&". Однако семантика такой записи довольно проста:
переменная ptrRef объявлена ссылкой на переменную intP, которая является указателем на
374 Часть II. Пишем С++-код профессионально
int-значение. При модификации ссылки ptrRef изменится и указатель intP. Ссылки
на указатели встречаются нечасто, но иногда могут оказаться очень даже полезными
(см. раздел "Параметры-ссылки" ниже в этой главе).
Обратите внимание на то, что взятие адреса ссылки дает такой же результат, что
и взятие адреса переменной, на которую эта ссылка указывает. Вот пример.
int х = 3;
int& xRef = х;
int* xPtr = &xRef; // Адрес ссылки является указателем
// на значение.
*xPtr = 100;
Этот код заставляет указатель xPtr ссылаться на переменную х путем взятия
адреса ссылки на х. После присваивания числа 100 выражению *xPtr значение
переменной х станет равным 100.
Наконец, обратите внимание на то, что нельзя объявить ссылку на ссылку или
указатель на ссылку.
int X = 3;
int& xRef = х;
int&& xDoubleRef = xRef; // HE СКОМПИЛИРУЕТСЯ!
int&* refPtr = &xRef; // HE СКОМПИЛИРУЕТСЯ!
Ссылочные члены данных
Как вы узнали в главе 9, члены данных в классах могут быть ссылками. Ссылка не
может существовать, не указывая на какую-либо другую переменную. Поэтому
ссылочные члены данных необходимо инициализировать в списке инициализации
конструктора класса, а не в теле конструктора. (За деталями обратитесь к главе 9.)
Параметры-ссылки
С++-программисты не часто используют независимые ссылочные переменные или
ссылочные члены данных. Чаще всего ссылки используются в качестве параметров
функций и методов. Вспомните, что по умолчанию параметры передаются по
значению: в этом случае функции принимают копии передаваемых аргументов. При
модификации таких параметров исходные аргументы не меняются. Ссылки позволяют
использовать альтернативную семантику для аргументов, передаваемых функции, и этот
способ передачи параметров так и называется: по ссылке. При использовании
ссылочных параметров функция принимает не копии аргументов, а ссылки на них. При
модификации ссылок изменения отражаются на оригинальных значениях аргументов.
Например, рассмотрим простую функцию, которая переставляет (меняет местами)
значения двух int-значений.
void swap(int& first, int& second)
{ *
int temp = first;
first = second;
second = temp;
}
Глава 12- Причуды и странности C++ 375
Эту функцию можно вызвать таким способом.
int х = 5, у = 6 ;
swap(x, у);
При вызове функции swap () с аргументами х и у параметр first
инициализируется ссылкой на аргумент х, а параметр second — ссылкой на аргумент у. При
модификации функцией swap () параметров first и second аргументы х и у
действительно изменяются.
Подобно тому как обычные ссылочные переменные нельзя инициализировать
константами, нельзя передавать константы в качестве аргументов функциям, в
которых используется передача параметров по ссылке.
swap(3, 4); // НЕ СКОМПИЛИРУЕТСЯ!
"Получение" ссылок из указателей
Если вы используете указатель на некоторое значение, подлежащее передаче
функции (или методу), которая (или который) принимает ссылку, то у вас могут
возникнуть определенные затруднения. В этом случае можно "преобразовать" указатель
в ссылку простым его разыменованием. В результате вы получите значение,
адресуемое этим указателем, которое компилятор затем использует для инициализации
ссылочного параметра. Например, функцию swap () можно вызвать так.
int х = 5, у = 6;
int *хр = &х, *ур = &у;
swap(*xp, *yp);
Сравнение передачи по ссылке с передачей по значению
Передачу по ссылке необходимо использовать в случае, если вам нужно, чтобы
результат модификации параметра отразился на аргументе, передаваемом функции или
методу. Применение передачи по ссылке позволяет избежать копирования api-умен-
тов для функции, обеспечивая два дополнительных преимущества, которыми имеет
смысл воспользоваться в таких ситуациях.
1. Эффективность: копирование больших объектов и структур связано с
значительными затратами времени. При передаче же по ссылке используется только
указатель на объект или структуру.
2. Корректность: передача по значению возможна не для всех объектов. Даже
если объекты позволяют такой способ передачи, они могут некорректно
поддерживать "глубокое", т.е. детальное копирование. Как упоминалось в главе 9,
объекты с динамическим выделением памяти должны включать специальный
конструктор копии, обеспечивающий детальное копирование.
Чтобы воспользоваться этими преимуществами, но при этом не позволить
модификацию исходных объектов, достаточно объявить передаваемые параметры с
модификатором const. Более детально данная тема раскрывается ниже в этой главе.
С учетом вышесказанного следует отметить, что если вам не нужно
модифицировать аргументы, то передачу параметров по значению необходимо обеспечивать
только для таких простых встроенных типов, как int и double. Во всех других
случаях применяйте передачу по ссылке.
376 Часть II. Пишем C++—код профессионально
Использование ссылок в качестве значений, возвращаемых
функциями или методами
Функция или метод может также возвращать ссылку. Эта возможность
используется обычно ради достижения эффективности работы кода. Возвращение ссылки
(вместо целого объекта) позволяет избежать ненужного копирования. Безусловно,
к такому средству следует прибегать только в случае, если рассматриваемый объект
будет продолжать существовать после завершения функции.
Никогда не обеспечивайте возврат функцией ссылки на переменную,
которая будет разрушена по завершении этой функции (речь идет,
например, о переменной, автоматически создаваемой в области стека).
Еще одна причина, по которой имеет смысл организовать возврат ссылки, состоит
в возможности выполнения непосредственного присваивания значению,
возвращаемому функцией, т.е. использованию его в качестве элемента, стоящего с левой
стороны от оператора присваивания.
Некоторые операторы присваивания возвращают ссылки. Вы уже видели
соответствующие примеры в главе 9, а о других примерах применения этого средства можно
прочитать в главе 16.
Выбор между ссылками и указателями
Ссылки в C++ несут в себе определенную избыточность: практически все, что
можно сделать с помощью ссылок, достижимо с применением указателей. Например,
приведенную выше функцию swap () вы могли бы написать так.
void swap(int* first, int* second)
{
int temp = *first;
*first = *second;
*second = temp;
}
Однако этот код выглядит более громоздким, чем версия со ссылками: ссылки
делают программы яснее и легче для понимания. Ссылки, кроме того, безопаснее
указателей: ссылка не может быть недействительной, а поскольку вы не разыменовываете
ссылки явным образом, то и не сделаете ошибку разыменования, которая возможна
при использовании указателей. В большинстве случаев ссылки можно использовать
вместо указателей. Ссылки на объекты даже поддерживают полиморфизм подобно
тому, как это происходит при использовании указателей на объекты. Единственный
случай, когда не обойтись без указателя, состоит в необходимости заменить область
памяти, на которую он указывает. Вспомните, что для ссылки это сделать
невозможно: нельзя заменить переменную, на которую указывает ссылка. Например, при
динамическом выделении памяти необходимо сохранить значение указателя на результат
в переменной-указателе, а не в переменной-ссылке.
По-разному используются указатели и ссылки в качестве параметров и значений,
возвращаемых функциями. Здесь все зависит от владельца памяти. Если код,
принимающий переменную, отвечает за освобождение памяти, связанной с объектом, то он
должен принимать указатель на этот объект. Если же код, принимающий переменную,
не должен освобождать память, ему следует принимать ссылку.
Глава 12. Причуды и странности C++ 377
Используйте ссылки вместо указателей, если вам не нужно
динамически выделять память.
Это правило применяется к независимым переменным, параметрам функций или
методов, а также к значениям, возвращаемым функциями или методами.
Строгое применение этого правила может привести к несколько непривычному
синтаксису. Рассмотрим функцию, которая делит массив int-значений на два массива:
один для четных чисел, а другой— для нечетных. Эта функция не "знает", сколько
чисел в исходном массиве окажутся четными или нечетными, поэтому она будет
динамически выделять память для результирующих массивов после просмотра элементов
исходного хранилища. Эта функция должна возвращать два размера новых массивов
и два указателя на два новых массива. Очевидно, здесь необходимо использовать
передачу параметров по ссылке. Канонический С-способ записи этой функции выглядит так.
void separateOddsAndEvens(const int arr[], int size,
int** odds, int* numOdds,
int** evens, int* numEvens)
{
int i ;
// Первый проход служит для определения размеров
// новых массивов.
*numOdds = *numEvens = 0;
for (i = 0; i < size; i++) {
if (arr[i] % 2 == 1) {
(*numOdds)++;
} else {
(*numEvens)++;
// Выделяем память для двух новых массивов
// соответствующего размера.
*odds = new int[*numOdds];
*evens = new int[*numEvens];
// Копируем нечетные и четные элементы в новые массивы
int oddsPos = 0, evensPos = 0;
for (i = 0; i < size; i++) {
if (arr[i] % 2 == 1) {
(*odds)[oddsPos++] = arr[i];
} else {
(*evens)[evensPos++] = arr[i];
Последние четыре параметра, принимаемые этой функцией, являются
ссылочными. Чтобы изменить значения, на которые они указывают, функция
separateOddsAndEvens () должна их разыменовать, что заставляет нас использовать довольно
устрашающий синтаксис в теле функции.
Кроме того, при вызове функции separateOddsAndEvens () необходимо
передать ей адреса двух указателей, чтобы она могла изменить эти реальные указатели,
и адреса двух int-значений для изменения реально существующих int-значений.
int unSplit[10] = {l, 2, 3, 4, 5, 6, 6, 8, 9, 10};
int *oddNums, *evenNums,-
int numOdds, numEvens;
378 Часть II. Пишем С+н—код профессионально
separateOddsAndEvens(unSplit, 10, &oddNums, &numOdds,
&evenNums, &numEvens);
Если такой синтаксис вас раздражает (что совсем неудивительно), вы могли бы
написать аналогичную функцию с использованием ссылок.
void separateOddsAndEvens(const int arr [], int size,
int*& odds, int& numOdds,
int*& evens, int& numEvens)
{
int i ;
numOdds = numEvens = 0;
for (i =0; i < size; i++) {
if (arr[i] % 2 == 1) {
numOdds++;
} else {
numEvens++;
odds = new int[numOdds];
evens = new int [numEvens] ,-
int oddsPos = 0, evensPos = 0;
for (i = 0; i < size,- i++) {
if (arrfi] % 2 == 1) {
odds[oddsPos++] = arr[i];
} else {
evens[evensPos++] = arrfi]; '
i
}
}
}
В данном случае параметры odds и evens являются ссылками на :1п1;*-значения.
Функция separateOddsAndEvents{) может модифицировать эти :1п1:*-значения,
которые используются как аргументы (посредством ссылок), не выполняя никакого
явного разыменования. Аналогичная логика применяется и к параметрам numOdds
и numEvens, которые являются ссылками на int-значения. Используя эту версию
функции, больше не нужно передавать адреса указателей или int-значений.
Ссылочные параметры выполняют все нужные действия автоматически.
Глава 12. Причуды и странности C++ 379
int unSplitflO] = {l, 2, 3, 4, 5, 6, 6, 8, 9, 10};
int *oddNums, *evenNums;
int numOdds, numEvens;
separateOddsAndEvens(unSplit, 10, oddNums, numOdds,
evenNums, numEvens);
Некоторые особенности ключевых слов
В C++ есть два ключевых слова, которые у программистов вызывают больше
смятения, чем другие. Речь идет о словах const и static. Оба они имеют несколько
различных способов применения, характеризуемых тонкостями, которые важно понимать.
Ключевое слово const
Ключевое слово const (сокращение от слова "constant") означает требование
неизменности. Как вы видели в примерах этой книги, существуют два различных, но
связанных между собой способа применения этого ключевого слова: для переменных и методов.
const-переменные
Ключевое слово const можно использовать для "защиты" переменных от
потенциальной модификации. Как упоминалось в главах 1 и 7, это ключевое слово может
служить для объявления констант (в качестве замены директивы #def ine). Это— прямое
назначение модификатора const. Например, мы могли бы объявить константу PI так.
const double PI = 3.14159;
Модификатором const можно "отмечать" любые переменные, включая
глобальные, и члены данных класса.
Модификатор const можно также использовать для уведомления компилятора
о том, что параметры, принимаемые функциями или методами, должны оставаться
неизменными. Соответствующие примеры вы могли видеть в главах 1 и 9, а также
в других местах этой книги.
cons t-указател и
Если переменная характеризуется одним или несколькими уровнями косвенности
(непрямого доступа) через указатель, смысл применения модификатора const
понять уже не так просто. Рассмотрим следующие строки кода.
int* ip;
ip = new int [10] ;
ip[4] = 5;
Предположим, вы решили применить модификатор const к указателю ip. Закроем
пока глаза на фактор полезности этого действия и рассмотрим его последствия. Чего вы
хотели этим добиться: защитить саму переменную ip от возможных изменений или
предотвратить таковые для значений, на которые она указывает? Другими словами, вы
хотели не допустить выполнения второй или третьей строки предыдущего примера?
380 Часть II. Пишем C++—код профессионально
Чтобы защитить от модификаций адресуемое указателем значение (как в
третьей строке), можно добавить ключевое слово const в объявление переменной ip
таким образом.
const int* ip,-
ip = new int [10] ,-
ip[4] =5; // HE СКОМПИЛИРУЕТСЯ!
Теперь уж нельзя изменить значения, на которые указывает переменная ip.
В качестве альтернативного варианта можно написать следующее.
int const* ip;
ip = new int[10];
ip[4] = 5; // HE СКОМПИЛИРУЕТСЯ!
Место модификатора const (перед или после типа int) никак не влияет на его
функционирование.
Если же вы хотите сделать константным сам указатель ip (а не значения, на
которые он ссылается), необходимо написать так.
int* const ip = NULL;
ip = new int[10]; // HE СКОМПИЛИРУЕТСЯ!
ip[4] = 5;
Теперь сам указатель ip не может быть изменен, и поэтому компилятор потребует
его инициализации в момент объявления.
Можно также отметить модификатором const как указатель, так и значения, на
которые он ссылается.
int const* const ip = NULL;
C той же целью можно применить и альтернативный синтаксис. -
const int* const ip = NULL;
J.
Хотя этот синтаксис может показаться несколько путанным, существует очень
простое правило, которое поможет разобраться в отношениях между словами:
ключевое слово const применяется к элементу, который стоит непосредственно слева от
него. Рассмотрим эту строку снова.
int const* const ip = NULL;
Итак, первое ключевое слово const (при просмотре слева направо)
непосредственно "примыкает" (в "левостороннем" направлении) к слову int. Следовательно,
модификатор const применяется к типу int, на который указывает переменная ip.
Таким образом, эта "связка" говорит о невозможности изменять значения, на
которые указывает ip. Второе ключевое слово const расположено справа от символа "*".
Это значит, что оно применяется к указателю на int-значение, т.е. к переменной ip.
Следовательно, оно заявляет о невозможности изменить сам указатель ip.
Глава 12- Причуды и странности C++ 381
Модификатор const в случае непрямого доступа применяется к
элементу, расположенному непосредственно слева от него.
Но даже это правило может не всегда "работать на понимание", например, в
случае, если первое слово const будет стоять перед переменной.
const int* const ip = NULL;
Надо сказать, что этот "исключительный" синтаксис используется гораздо чаще,
чем какой-либо другой.
Это правило можно расширить на любое количество уровней косвенности
(непрямого доступа). Вот пример.
const int * const * const * const ip = NULL;
const-ссылки
Применение модификатора const к ссылкам обычно воспринимается проще, чем
к указателям. И на это есть две причины. Во-первых, ссылки являются константными
по умолчанию, причем это их "врожденное" качество означает невозможность
изменить то, на что они ссылаются. Поэтому C++ не позволяет явным образом отмечать
ссылочную переменную ключевым словом const. Во-вторых, для ссылок обычно
существует только один уровень непрямого доступа. Как разъяснялось выше, нельзя
создать ссылку на ссылку. Единственный способ получить несколько уровней
косвенности — создать ссылку на указатель.
Таким образом, если С++-программист использует const-ссылку, он имеет в виду
следующее.
int Z;
const int& zRef = z;
zRef =4; // HE СКОМПИЛИРУЕТСЯ!
Применяя модификатор const к типу int, вы, как показано в этом примере,
предотвращаете присваивание ссылке zRef. Помните, что выражение const int& zRef
эквивалентно выражению int const& zRef. Однако обратите внимание на то, что
применение модификатора const к ссылке zRef никак не влияет на саму переменную z.
Вы по-прежнему можете модифицировать ее значение, действуя напрямую, а не по ссылке.
Константные ссылки используются чаще всего в качестве параметров, и здесь они
действительно полезны. Если вы (из соображений эффективности) хотите передать
функции что-либо по ссылке, но при этом не разрешаете модификацию передаваемых
объектов, используйте const-ссылку. Вот пример.
void doSomething(const BigClass& arg)
{
// Здесь должна быть реализация функции.
-1
Как правило, для передачи объектов в качестве параметров
используется const-ссылка. Опустить модификатор const можно только
в том случае, если вам нужно изменить передаваемый объект.
382 Часть II. Пишем C++—код профессионально
const-методы
Как упоминалось в главе 9, методы классов также можно отмечать модификатором
const. Такая спецификация защитит метод от модификации любых не mutable-членов
данных класса. Для рассмотрения примера обратитесь к главе 9.
Ключевое слово static
Несмотря на то что существует несколько способов использования ключевого
слова const в C++, все они связаны между собой и имеют значение "неизменности" чего-
либо. С ключевым словом static все обстоит иначе: его можно использовать тремя
способами, причем никакой внешней связи между ними нет.
Статические члены данных и методы
Как вы узнали в главе 9, в классе можно объявить статическими его члены данных
и методы. Основное отличие static-членов данных от нестатических состоит в том, что
они не являются частью каждого объекта. Это означает, что существует только одна копия
статического члена данных, которая физически расположена вне объектов этого класса.
Аналогично статические методы существуют на уровне класса, а не на уровне
объекта. Другими словами, static-метод не выполняется в контексте конкретного объекта.
Примеры использования статических членов данных и методов приведены в главе 9.
Статическое связывание
Прежде чем рассматривать использование ключевого слова static для связывания,
необходимо разобраться с понятием связывания в C++. Как вы узнали в главе 1,
исходные файлы C++ компилируются независимо друг от друга, а затем полученные в
результате объектные файлы компонуются (связываются) вместе. Каждое имя в исходном С++-
файле, включая имена функций и глобальных переменных, характеризуется либо
внутренним, либо внешним связыванием. Внутреннее связывание (которое также называется
статическим) означает, что объекты и функции могут ссылаться только на имена внутри
своей единицы трансляции и не разделяются другими единицами. Под внешним
связыванием подразумевается, что данное имя доступно из других исходных файлов. По
умолчанию функции и глобальные переменные характеризуются внешним связыванием.
Однако для отдельных имен можно установить внутреннее (или статическое) связывание,
предварив соответствующее объявление ключевым словом static. Например,
предположим, что у вас есть два исходных файла: First File. cpp и AnotherFile. cpp.
Рассмотрим исходный код файла FirstFile. cpp. t
// FirstFile.cpp
void f();
int main(int argc, char** argv)
{
f();
return (0) ; «л,
J ill
Обратите внимание на то, что этот файл содержит не определение функции f (),
а лишь ее прототип.
Глава 12. Причуды и странности C++ 383
А вот как выглядит содержимое файла AnotherFi le. срр.
// AnotherFile.cpp
#include <iostream>
using namespace std;
void f () ;
void f ()
{
cout « "f\n";
} •
Этот файл содержит как определение, так и прототип функции f (). Обратите
внимание на то, что вполне допустимо записывать прототип одной и той же функции
в двух различных файлах. Именно это и делает препроцессор за вас, если вы
помещаете прототип в заголовочный файл, который затем включаете с помощью
директивы #include в каждый из исходных файлов. Заголовочные файлы упрощают
поддержку (и синхронизацию) одной копии прототипа. Но в данном примере
заголовочный файл мы не используем.
Каждый из этих исходных файлов компилируется без ошибок, а затем и вся
программа прекрасно компонуется: поскольку функция f () имеет внешнее связывание,
функция main () может вызвать ее из другого файла.
Теперь к функции f () в файле AnotherFi le. срр применим ключевое слово static.
// AnotherFile.cpp
#include <iostream>
using namespace std;
static void f();
void f ()
{
cout << "f\n";
}
И в этом случае каждый из этих исходных файлов будет скомпилирован без ошибок,
но на этапе компоновки нас ждет неудача, поскольку функция f () теперь имеет
внутреннее (статическое) связывание, что делает ее недоступной.из файла FirstFile. срр.
Некоторые компиляторы выдают предупреждающее сообщение, если static-методы
определены, но не используются в данном исходном файле (предполагается, что эти
методы не должны быть статическими, раз они, по всей вероятности, используются
где-то в другом месте).
Обратите внимание на то, что в определении функции f () не нужно повторять
ключевое слово static. Вполне достаточно того, что оно предваряет первое
использование имени этой функции.
Теперь, когда вы узнали все об использовании ключевого слова static, вам,
вероятно, будет интересно узнать, что С++-комитет наконец убедился в том, что оно
слишком перегружено, и стал возражать против использования его в этом значении.
Другими словами, ключевое слово static пока остается частью стандарта, но нет
гарантии, что так будет и в будущем. При этом традиционный С++-код по-прежнему
использует ключевое слово static в этом значении.
Альтернативный путь (позволяющий достичь того же эффекта) заключается в
применении анонимных пространств имен. Вместо использования в объявлении переменных
384 Часть II. Пишем C++—код профессионально
или функций ключевого слова static, заключите их в оболочку неименованного
пространства имен.
// AnotherFile. срр
#include <iostream>
using namespace std;
namespace {
void f();
void f()
{
cout « "f\n";
}
}
К членам анонимного пространства имен можно получить доступ с любого места
кода после их объявления в том же исходном файле, но не из других исходных
файлов. Такая семантика аналогична результату применения ключевого слова static.
Ключевое слово extern
Казалось бы, ключевое слово extern должно иметь значение, противоположное
значению слова static, определяя внешнее связывание для имен, которым оно
предшествует. В определенных случаях его действительно можно использовать с этой
целью. Например, const- и typedef-имена имеют внутреннее связывание по
умолчанию. Поэтому для придания им свойства внешнего связывания можно употребить
ключевое слово extern.
Однако использование слова extern может быть сопряжено с некоторыми
трудностями. Если вы определяете имя как внешнее, компилятор обрабатывает этот код как
объявление, но не как определение. Для переменных это означает, что компилятор не
выделяет для них память. Поэтому для таких переменных вы должны предоставить
отдельное определение (без использования ключевого слова extern). Рассмотрим пример.
// AnotherFile.срр
extern int x;
int x = 3;
В качестве альтернативного варианта можно инициализировать переменную х
в extern-строке, которая в этом случае послужит одновременно объявлением и
определением.
// AnotherFile.срр
extern int x = 3;
От ключевого слова extern в этом файле не много толку, поскольку переменная х
обладает внешним связыванием по умолчанию. Реальную пользу от применения слова
extern можно получить при обращении к переменной х из другого исходного файла.
// FirstFile.cpp
#include <iostream>
using namespace std;
extern int x;
Глава 12. Причуды и странности C++ 385
int main(int argc, char** argv)
{
cout << x « endl,-
}
Используемое в файле FirstFile.cpp extern-объявление позволяет обращаться
к переменной х в функции main (). Но если бы мы объявили переменную х без ключевого
слова extern, компилятор принял это объявление за определение и выделил для
переменной х память, но этап компоновки наша программа не прошла (поскольку у нас
образовалось бы в глобальном пространстве две переменные х). С применением слова extern
мы можем делать переменные глобально доступными из нескольких исходных файлов.
И все же мы рекомендуем не использовать глобальные переменные вообще. Они
вызывают "предрасположенность" к ошибкам, особенно в больших программах. Для
обеспечения аналогичной функциональности на уровне классов используйте
статические члены данных и методы.
Статические переменные в функциях
Еще одно использование ключевого слова static в C++ состоит в создании
локальных переменных, которые возвращают свои значения между выходом из области
видимости и входом в нее. Статические переменные внутри функции можно сравнить
с глобальной переменной, которая доступна только из этой функции. Один из
распространенных способов использования static-переменных— "помнить" о гом, была
ли выполнена инициализация для определенной функции. Например, код, в котором
применяется это средство, может выглядеть примерно так.
void performTask{)
{
static bool inited = false;
if {!inited) {
cout << "инициализация\п";
// Выполнение инициализации.
inited = true;
}
// Выполнение нужной задачи.
}
Однако со статическими переменными возможна путаница, и поэтому лучше их
все же избегать. В данном случае имеет смысл создать класс, в котором выполнение
требуемой инициализации можно "поручить" конструктору.
Избегайте использования независимых static-переменных. Вместо
этого поддерживайте нужное состояние на уровне объекта.
Порядок инициализации нелокальных переменных
Завершая тему статических членов данных и глобальных переменных, рассмотрим
порядок инициализации этих переменных. Все глобальные переменные и
статические члены данных класса в программе инициализируются до начала выполнения
функции main (). Переменные в исходном файле инициализируются в порядке их
386 Часть II. Пишем C++—код профессионально
следования. Например, в следующем файле переменная Demo: : х гарантированно
инициализируется раньше переменной у.
// sourcel.cpp
class Demo
{
public:
static int x;
};
int Demo::x = 3;
int у = 4;
Однако в C++ не предполагается никаких спецификаций или гарантий в
отношении порядка инициализации нелокальных переменных в различных исходных
файлах. Если у вас есть глобальная переменная х в одном исходном файле и глобальная
переменная у в другом, вы не узнаете, какая из переменных будет инициализирована
первой. Обычно недостаток в такой информации не является причиной для
беспокойства. Но проблемы могут возникнуть в случае, если одна глобальная или
статическая переменная зависит от другой. Вспомните, что инициализация объектов
подразумевает выполнение их конструкторов. Конструктор одного глобального объекта
может получить доступ к другому глобальному объекту в "предположении", что тот
уже создан. Если эти два глобальных объекта объявлены в двух различных исходных
файлах, нельзя рассчитывать на то, что один объект будет построен раньше другого.
Порядок инициализации нелокальных переменных в различных
исходных файлах стандартом C++ не определен. :;
Типы и использование операций
приведения к типу
Базовые С++-типы рассмотрены в главе 1, а в главе 8 показано, как, используя
классы, создать собственные типы. Данный раздел посвящен более сложным аспектам
этой темы — применению ключевого слова typedef и использованию операций
приведения к типу. '
Использование ключевого слова typedef
Ключевое слово typedef позволяет создать новое имя для уже существующего
типа. Лучше всего воспринимать typedef-синтаксис как средство ввода синонима для
существующего имени типа. С помощью typedef-объявлений создаются не новые
типы, а предлагается новый способ для обращения к старым типам. Новые и старые
имена типов можно использовать взаимозаменяемо. Переменные, создаваемые с
помощью нового имени типа, полностью совместимы с теми, которые формируются
с использованием исходного имени.
Возможно, вам приходилось уже использовать typedef-объявления в своем коде
или встречать их в чужом — в любом случае важно понимать, что они просто
предоставляют альтернативные имена типов.
Глава 12. Причуды и странности C++ 387
Чаще всего typedef-объявления используются для ввода в программу более
удобных (коротких или более понятных) имен, если реальные имена оказываются
слишком громоздкими. Обычно такая ситуация возникает в шаблонах. Например, вы
хотите для создания электронной таблицы использовать шаблон Grid (из главы 11),
который представляет собой сетку элементов типа SpreadsheetCell. Если не
применять typedef-синтаксис, то при любом обращении к типу этого Grid-шаблона (при
объявлении переменных, задании параметров функций и пр.) вам пришлось бы
писать выражение Grid<SpreadsheetCell>.
int main(int argc, char** argv)
{
Grid<SpreadsheetCell> mySpreadsheet;
// Остальная часть программы. . .
}
void processSpreadsheet(const Grid<SpreadsheetCell>& spreadsheet)
{
// Тело опущено.
}
С помощью typedef-объявления можно создать более короткое и понятное имя.
typedef Grid<SpreadsheetCell> Spreadsheet;
int main(int argc, char** argv)
{
Spreadsheet mySpreadsheet;
// Остальная часть программы. . .
}
void processSpreadsheet(const Spreadsheetfc spreadsheet)
{
// Тело опущено.
}
Важно помнить, что typedef-имена могут включать спецификаторы области
видимости. Например, в главе 9 вы видели такую строку кода.
1 typedef Spreadsheet::SpreadsheetCell SCell;
Это typedef-объявление создает короткое имя SCell для обращения к типу
SpreadsheetCell в области видимости класса Spreadsheet.
i В библиотеке STL typedef-объявления широко используются для образования
более коротких имен типов. Например, тип string в действительности представляет
- собой typdef-имя, которое образовано таким образом.
typedef basic_string<char> string;
388 Часть II. Пишем C++—код профессионально
Использование операций приведения к типу
Как разъяснялось в главе 1, операции приведения к типу в стиле языка С по-
прежнему работают в C++. Но в C++ также предусмотрены четыре новые операции:
static_cast, dynamic_cast, const_cast и reinterpret_cast. Вместо С-стиля
в С++-программах следует все же использовать С++-стиль, поскольку С++-операторы
приведения к типу выполняют более серьезный контроль типов и синтаксически
лучше вписываются в С++-код.
В этом разделе описывается назначение каждого из операторов приведения к типу
и даются рекомендации по их применению.
Оператор constcast
Оператор constcast — самый простой для применения. Его используют для
удаления признака постоянства переменной. Причем удалить это свойство можно только
с помощью этого оператора. Казалось бы, не должно быть причин для применения
оператора constcast. Если уж переменная объявлена с помощью спецификатора const,
ей следует таковой и оставаться. Однако на практике возникают ситуации, когда одна
функция должна (по определению) принимать const-переменную, которую затем
необходимо передать функции, принимающей не const-переменную. "Корректное"
решение может быть следующим: создайте const-переменную для согласования с
остальной частью программы, но не "навсегда". Этот оператор особенно полезен в случаях,
если вы используете библиотеки, написанные сторонними производителями. Таким
образом, если иногда вам нужно ликвидировать для переменной признак
константности, смело используйте оператор constcast. Рассмотрим следующий пример.
void g(char* str)
{
// Тело функции опущено для экономии места.
}
void f(const char* str)
{
// Тело функции опущено для экономии места,
g(const_cast<char*>(str) ) ;
// Тело функции опущено для экономии места.
}
Оператор staticcast
Оператор staticcast можно использовать для выполнения явных преобразований,
которые напрямую поддерживаются языковыми средствами. Например, если вы написали
арифметическое выражение, в котором нужно преобразовать int- в double-значение,
то во избежание целочисленного деления используйте оператор static_cast.
int i = 3;
double result = static_cast<double>(i) / 10;
Оператор staticcast также можно использовать для выполнения явных
преобразований, которые разрешены благодаря существованию в программе
определенных пользователем конструкторов или функций преобразования. Например, если
класс А содержит конструктор, который принимает объект класса В, можно
преобразовать В-объект в тип А с помощью оператора static_cast. Однако в большинстве
Глава 12- Причуды и странности C++ 389
случаев (когда требуется обеспечить подобное поведение) компилятор выполняет
нужное преобразование автоматически.
Оператор static_cast можно использовать и в других целях, а именно для
выполнения нисходящего преобразования типов в иерархии наследования.
Рассмотрим пример.
class Base
public:
Base () { } ;
virtual -Base() {}
class Derived : public Base
public:
Derived() {}
virtual -Derived() {}
int main(int argc, char** argv)
Base* b;
Derived* d = new Derived();
b = d; // Для восходящего преобразование типов в иерархии
// наследования специальный оператор не нужен.
d = static_cast<Derived*>(b); // Он нужен для нисходящего
// преобразование типов в
// иерархии наследования.
Base base;
Derived derived;
Base& br = base;
Derivedfc dr = static_cast<Derived&> (br) ,-
return (0);
}
Эти операторы приведения типов можно применять как для указателей, так и для
ссылок. Они не работают с самими объектами.
Обратите внимание на то, что оператор static cast не выполняет
динамический контроль типов. Он позволяет преобразовать любой Base- в Derived-указатель
или Base- в Derived-ссылку, даже если Base-объект в действительности не является
одновременно Derived-объектом во время выполнения программы. Для обеспечения
безопасного приведения типов (т.е. с использованием динамического контроля
типов) используйте оператор dynamic_cast.
Оператор staticcast никак нельзя назвать "всемогущим". Невозможно с его
помощью преобразовать указатели одного типа в указатели другого типа (не
связанного с первым). Он не позволяет преобразовать указатели в int-значения. Используя
оператор static_cast, нельзя напрямую преобразовать объекты одного типа в
объекты другого, а также нельзя const-тип преобразовать в не const^ran. По существу, нельзя
делать ничего, что не имеет смысла согласно правилам использования типов в C++.
Оператор reinterpretcast
Оператор reinterpret_cast — более мощный, но менее безопасный, чем
оператор static__cast. Его можно использовать для выполнения преобразований
типа, которые формально не разрешены правилами C++, но которые в некоторых
390 Часть II. Пишем C++—код профессионально
обстоятельствах для программиста имеют смысл. Например, оператор reinterpret_
cast позволяет преобразовать указатель одного типа в указатель другого, даже если
они не связаны иерархией наследования. Аналогичное преобразование можно
выполнить и для ссылок. Кроме того, с помощью этого оператора становится
возможным преобразование указателей в int-значения и "обратно" (int-значений— в
указатели). Рассмотрим несколько примеров.
class X {};
class Y {};
int main(int argc, char** argv)
{
int i = 3;
X X;
Y y;
X* xp;
Y* yp;
// Для преобразования указателей из классов, не
// связанных "родственными" отношениями, необходимо
// использовать оператор reinterpret_cast, поскольку
// оператор static_cast здесь не работает.
xp = reinterpret_cast<X*>(yp);
// Для преобразования указателя в int-значение
// и int-значения в указатель необходимо
// использовать оператор reinterpret_cast
i = reinterpret_cast<int> (xp) ,-
xp = reinterpret_cast<X*>(i);
// Для преобразования ссылок из классов, не
// связанных "родственными" отношениями, необходимо
// использовать оператор reinterpret_cast, поскольку
// оператор static_cast здесь не работает.
Х& ХГ = X;
Y& yr = reinterpret_cast<Y&>(x);
return (0);
}
При использовании оператора reinterpret_cast следует проявлять большую
острожность, поскольку он "интерпретирует" исходные биты как биты другого типа
без какого бы то ни было контроля типов.
Оператор dynamiccast
Как упоминалось в разделе "Оператор static_cast", при преобразовании типов
в рамках иерархии наследования оператор dynamic_cast обеспечивает
динамический контроль типов. Этим, конечно же, стоит воспользоваться при выполнении
преобразования указателей или ссылок. Оператор dynamiccast проверяет
динамически получаемую информацию о типе базового объекта во время выполнения
программы. Если (с точки зрения оператора dynamic_cast) преобразование не имеет
смысла, он возвращает значение NULL (для указателей) или генерирует исключение
типа bad_cast (для ссылок).
Глава 12. Причуды и странности C++ 391
Обратите внимание на то, что динамическая информация о типе хранится в г>таблице
объекта. Поэтому, чтобы вы могли использовать оператор dynamic_cast, ваши
классы должны иметь по крайней мере одну виртуальную (virtual) функцию.
Вот несколько примеров.
#include <typeinfo>
#include <iostream>
using namespace std;
class Base
{
public:
Base() {};
virtual -Base() {}
class Derived : public Base
{
public:
Derived() {}
virtual -Derived() {}
b
int raain(int argc, char** argv)
{
Base* b;
Derived* d = new Derived();
b = d;
d = dynamic_cast<Derived*>(b);
Base base;
Derived derived;
Basefc br = base;
try {
Derivedb dr = dynamic_cast<Derived&;> (br) ;
} catch (bad_cast&) {
cout << "Некорректное преобразование!\n";
}
return (0);
}
В предыдущем примере первое преобразование типа должно выполниться
успешно, в то время как второе должно сгенерировать исключение. Детали обработки
исключений описаны в главе 15.
Обратите внимание на возможность выполнения одних и тех же нисходящих
преобразований типов в иерархии наследования с помощью операторов static_cast
и reinterpret_cast. Отличие оператора dynamic_cast (от упомянутых выше)
заключается в том, что он включает динамический контроль типов.
Резюме по использованию операторов преобразования типов
В следующей таблице сведены данные о применении различных операторов
приведения типов в различных ситуациях.
392 Часть II. Пишем C++—код профессионально
Ситуация
Оператор
Удаление признака константности
Явные преобразования, поддерживаемые языковыми средствами
(например, int- в double-значение или int- в bool-значение)
Явные преобразования, поддерживаемые определенными
пользователем конструкторами или функциями
Объект одного класса нужно преобразовать в объект другого
(не связанного с первым)
Указатель на объект одного класса нужно преобразовать в указатель
на объект другого класса в рамках одной и той же иерархии
наследования
Ссылку на объект одного класса нужно преобразовать в ссылку на
объект другого класса в рамках одной и той же иерархии наследования
Указатель на тип нужно преобразовать в указатель на другой тип
(не связанный с первым)
Ссылку на тип нужно преобразовать в ссылку на другой тип
(не связанный с первым)
Указатель нужно преобразовать в int-значение или int-значение —
в указатель
Указатель на функцию нужно преобразовать в указатель
на другую функцию
constcast
static_cast
static_cast
Невозможно
выполнить
static_cast ИЛИ
dynamic_cast
static_cast ИЛИ
dynami c_ca s t
reinterpret_cast
reinterpret_cast
reinterpret_cast
reinterpret_cast
Разрешение контекста
Программисту на C++ в своей работе важно хорошо понимать, что означает
понятие области видимости {контекста). Каждое имя в программе, включая имена
переменных, функций и классов, находится в определенной области видимости. Эти
области создаются с помощью пространств имен, определений функций и определений
классов. При получении доступа к переменной, функции или классу поиск
соответствующего имени сначала выполняется в ближайшей включающей (это имя) области
видимости, затем — в следующей и так вплоть до глобальной. Любое имя, которого
нет в пространстве имен, области видимости функции или класса, принадлежит
глобальной области видимости.
Иногда имена в одних областях видимости скрывают идентичные имена в
других. Возможны ситуации, когда в текущей строке кода нужная область видимости не
является частью контекста, действующего по умолчанию. В этом случае можно
квалифицировать (уточнить) имя с помощью оператора разрешения контекста (: :).
Например, для получения доступа к статическому методу конкретного класса
необходимо перед именем метода поставить имя этого класса (т.е. его контекст) и оператор
разрешения контекста.
class Demo
{
};
public:
static void method() {}
Глава 12- Причуды и странности C++ 393
int raain(int argc, char** argv)
{
Demo::method();
return (0);
}
В нашей книге есть другие примеры разрешения контекста. При этом следует
обратить внимание на доступ к глобальной области видимости. Глобальная область
видимости является неименованной, поэтому ее невозможно указать конкретно, т.е. по
имени. В этом случае можно использовать только оператор разрешения контекста
(без именного префикса): тем самым вы получите ссылку на глобальную область
видимости. Рассмотрим пример.
int name = 3;
int main(int argc, char** argv)
{
int name = 4;
cout << name << endl; // доступ к локальному имени name
cout << ::name << endl; // доступ к глобальному имени name
return (0);
}
Заголовочные файлы
Заголовочные файлы (заголовки) представляют собой механизм обеспечения
подсистем или частей программного кода абстрактным интерфейсом. Используя
заголовочные файлы, важно избежать циклических ссылок и многократных включений
одних и тех же заголовков. Например, предположим, вам поручено написать класс
Logger, который будет выполнять задачи регистрации всех сообщений об ошибках.
Вы могли бы использовать еще один класс, Preferences, который должен
отслеживать параметры, устанавливаемые пользователем. Класс Preferences может в свою
очередь использовать класс Logger косвенно, через еще один заголовок.
Как показано в следующем коде, во избежание циклических ссылок и
многократных включений файлов можно использовать механизм директивы #if ndef. В начале
каждого заголовочного файла директива #if ndef будет проверять факт определения
заданного ключа. Если ключ уже определен, компилятор опустит код вплоть до
строки, содержащей директиву #endif, которая обычно размещается в конце файла. Если
ключ еще не определен, это (его определение) будет реализовано соответствующим
кодом, после чего очередное включение того же файла будет корректно опущено.
// Logger.h
#ifndef LOGGER
#define LOGGER
#include "Preferences.h"
class Logger
{
394 Часть II. Пишем C++—код профессионально
public:
static void setPreferences(const Preferences& inPrefs);
static void logError(const char* inError);
};
#endif // LOGGER
Избежать проблем с заголовками можно путем применения другого средства, а именно
с помощью опережающих ссылок. Если вам нужно сослаться на некоторый класс, но вы
пока не можете включить его заголовочный файл (например, по причине использования
им класса, который вы пишете в данный момент), можно сообщить компилятору о том,
что такой класс существует, не предоставляя при этом формального определения
посредством механизма директивы #include. Безусловно, вы не сможете реально
использовать этот класс в своем коде, поскольку компилятор "ничего не знает" о нем, за
исключением того, что названный класс будет существовать после того, как все будет
скомпоновано. Тем не менее в определении своего класса вы можете использовать
указатели или ссылки на этот "потенциальный" класс. В следующем коде класс Logger
ссылается на класс Preferences без включения его заголовочного файла.
// Logger.h
#ifndef LOGGER
#define LOGGER
class Preferences;
class Logger
{
public:
static void setPreferences(const Preferences& inPrefs);
static void logError(const char* inError);
};
#endif // LOGGER
Утилиты языка С
Вспомните, что C++— это супермножество языка С, которое, следовательно,
обеспечивает все его функциональные возможности. Ряд С-средств, которые не
были заменены эквивалентными С++-средствами, могут иногда быть полезными в
своем, так сказать, первозданном виде. В этом разделе рассматриваются два таких
оригинальных средства— списки аргументов переменной длины и макросы
препроцессора.
Списки аргументов переменной длины
Рассмотрим С-функцию printf (), определенную в заголовке <cstdio>. Ее можно
вызвать с любым количеством аргументов.
#include <cstdio>
int main(int argc, char** argv)
{
printf("int-значение %d\n", 5);
Глава 12. Причуды и странности C++ 395
printf("Строка %s и int-значение %d\n", "Привет!", 5);
printf("Много int-значений: %d, %d, %d, %d, %d\n",
1, 2, 3, 4, 5);
}
В языке C++ предусмотрен синтаксис и некоторые вспомогательные макросы,
которые позволяют программистам писать собственные функции с переменным
количеством аргументов. Обычно эти функции похожи на функцию printf (). Скорее
всего, вы не будете пользоваться этим средством слишком часто, но возможны
ситуации, когда оно окажется весьма кстати. Например, предположим, что вы хотите "на
скорую руку" написать функцию отладки, которая бы выводила строки в поток
stderr, если признак отладки установлен, и ничего не делала бы в противном случае.
Эта функция должна была бы принимать произвольное количество аргументов
произвольных типов. Самая простая реализация данной функции может выглядеть так.
#include <cstdio>
#include <cstdarg>
bool debug = false,-
void debugOut(char* str, ...)
{
va_list ap,-
if (debug) {
va_start(ap, str);
vfprintf (stderr, str, ap) ,-
va_end(ap); -
}
}
i
Прежде всего, обратите внимание на то, что прототип функции debugOut ()
содержит один типизированный и именованный параметр str, за которым следует
многоточие (. . .). Многоточие означает наличие аргументов в любом количестве
и любого типа. Чтобы получить доступ к этим аргументам, необходимо использовать
макросы, определенные в заголовке <cstdarg>. Вам нужно объявить переменную
типа va_list и инициализировать ее вызовом функции va_start (). Второй
параметр функции vastart () должен представлять собой крайнюю справа
именованную переменную в списке параметров. Всем функциям требуется по крайней мере
один именованный параметр. Функция debugOut () просто передает этот список
функции vfprintf () (стандартной функции, определенной в заголовке <cstdio>).
После завершения эта функция вызывает функцию va_end (), чтобы ограничить
доступ к списку аргументов переменной длины. После вызова функции va_start ()
необходимо всегда обращаться к функции vaend (), которая гарантирует, что стек
будет оставлен в согласованном состоянии.
Эту функцию можно использовать таким образом.
int main(int argc, char** argv)
{
debug = true,-
debugOut(("int-значение %d\n", 5) ;
debugOut("Строка %s и int-значение %d\n", "Привет!", 5);
debugOut("Много int-значений: %d, %d, %d, %d, %d\n",
1, 2, 3, 4, 5) ;
}
return (0);
396 Часть П. Пишем C++—код профессионально
Доступ к аргументам
Если вам нужно самим получить доступ к реальным аргументам, используйте для
этого функцию va_arg (). Например, вот как выглядит функция, которая принимает
любое количество int-аргументов и выводит их на экран.
#include <iostream>
using namespace std;
void printInts(int nura, ...)
{
int temp;
va_list ap;
va_start (ap, nutn) ;
for (int i = 0; i < nura; i++) {
temp = va_arg(ap, int) ,-
cout « temp << " ";
}
va_end(ap);
cout << endl;
}
Функцию print Ints () можно вызвать так.
printlnts(5r 5, 4, 3, 2, 1);
Почему следует избегать использования списков аргументов
переменной длины
Доступ к списку аргументов переменной длины нельзя назвать безопасным. Как
видно на примере функции print Ints (), существуют следующие риски.
□ Неизвестно количество параметров. Например, для функции printlntsO
приходится положиться на то, что в первом ее аргументе инициатор вызова
правильно передаст количество аргументов списка. А для функции debugOut ()
инициатор вызова должен позаботиться (позаботится ли?) о том, чтобы
количество аргументов, следующих после символьного массива, совпадало с
количеством кодов форматирования, содержащихся в этом символьном массиве.
□ Неизвестны типы аргументов. Функция vaarg (), например, принимает тип,
который используется для интерпретации текущего значения. При этом
существует опасность того, что можно заставить функцию va_arg ()
интерпретировать это значение как любой тип. Проблема состоит в том, что
удостовериться в корректности типа нет никакой возможности.
Избегайте использования списков переменной длины.
Предпочтительнее передавать данные в массиве или векторе переменных.
Макроопределения препроцессора
Препроцессор C++ можно использовать для написания макроопределений,
которые похожи на небольшие функции. Рассмотрим пример.
#define SQUARE(x) ((х) * (х)) // После макроопределения
// точка с запятой не ставится!
Глава 12. Причуды и странности C++ 397
int main(int argc, char** argv)
{
cout « SQUARE(4) « endl;
return (0);
}
Макроопределения— это "пережиток прошлого", доставшийся в наследство от
языка С. Макроопределения очень похожи на встраиваемые функции (inline), за
исключением того, что они не обеспечивают контроль типов, и препроцессор
"молча" заменяет любые к ним обращения соответствующими расширениями.
Препроцессор не применяет к ним настоящей семантики вызова функций. Такое
поведение может дать неожиданные результаты. Например, посмотрим, что произойдет,
если вызвать макроопределение SQUARE не с аргументом 4, а с аргументом 2 + 2.
cout « SQUARE(2 + 2) « endl;
Ожидаемого результата (16) от такого вызова макроопределения SQUARE вы не
получите. А что если из него убрать часть круглых скобок, чтобы оно выглядело так?
#define SQUARE (х) (х * х)
Теперь вызов SQUARE (2 + 2) сгенерирует число 8, а не 16! Вспомните, что
макроопределение "тупо" расширяется, не принимая во внимание семантику вызова
функций. Это означает, что любая переменная х в теле макроопределения будет
заменена значением 2 + 2, в результате чего мы получим следующее.
cout « 2+2*2+2 << endl;
Согласно порядку выполнения операций при обработке этой строки сначала будут
перемножены "центральные двойки", а затем полученное произведение станет
участником операций сложения, в результате чего мы получим 8, а не 16!
Макроопределения также вызывают проблемы при отладке кода, поскольку
написанный вами код (благодаря "стараниям" препроцессора) не соответствует коду,
который "видит" компилятор, или коду, который отображается в окне отладчика.
Поэтому мы рекомендуем совершенно избегать макроопределений и отдавать
предпочтение inline-функциям. Мы уделили этой теме внимание лишь потому, что
некоторые программисты на C++ все же используют макроопределения (или использовали
их раньше), и поэтому вы можете с ними встретиться. Чтобы разобраться в таком
коде и поддерживать его, необходимо понимать суть макроопределений.
Резюме
В этой главе были рассмотрены те аспекты C++, которые часто вызывают
определенное недопонимание. Прочитав эту главу, вы, возможно, по-новому взглянули на
многие детали синтаксиса C++. Такие средства C++, как ссылки, модификатор const,
оператор разрешения контекста, С++-операторы приведения типов, и особенности
использования заголовочных файлов, вам придется часто применять в своих
программах. Здесь вы также узнали некоторые подробности использования ключевых
398 Часть II. Пишем C++—код профессионально
слов static и extern, научились составлять списки аргументов переменной длины
и макроопределения препроцессора. И хотя последние из перечисленных средств
вряд ли можно отнести к разряду "повседневных", все же вы должны хорошо
понимать их назначение и особенности применения. В любом случае теперь вы вооружены
новыми знаниями и, мы надеемся, готовы к освоению более сложных тем C++.
Часть III
Освоение суперсредств C++
1 в этой части...
Глава 13. Эффективное управление памятью
Глава 14. Использование С++-потоков ввода-вывода
Глава 15. Обработка ошибок
I
I
I
Эффективное
управление памятью
Зачастую программирование на C++ напоминает езду по бездорожью. Да, вы
можете ехать в любом направлении, но без линий разметки и светофора такая езда
очень опасна. В C++, как и в языке С, предусмотрены автоматические методы
программирования. Разработчики языка исходили из предположения, что программист
знает, что делает. Поэтому язык позволяет делать вещи, которые могут вызвать
серьезные проблемы, поскольку C++ невероятно гибкий и жертвует безопасностью
в пользу эффективности.
Распределение памяти и управление ею относится к одной из тех областей С++-
программирования, которая чрезвычайно "уязвима". Чтобы писать
высококачественные С++-программы, профессиональные С++-программисты должны понимать,
как происходит неявное управление памятью. Эта тема подробно исследуется в
данной главе. Вы узнаете о "ловушках" динамической памяти и некоторых методах,
которые позволяют в них не попасть либо их "нейтрализовать".
Мы начнем с обзора различных способов использования памяти и управления ею.
Вы прочитаете о трудных для многих отношениях, которые "сложились" между
массивами и указателями. Затем вы узнаете о создании и обработке С-строк. Наконец, мы
разберемся в причинах некоторых специфичных проблем, с которыми вы можете
столкнуться в процессе управления памятью, и попытаемся их решить.
Глава 13. Эффективное управление памятью 401
Работа с динамической памятью
Учась программировать, начинающие программисты часто "спотыкаются", и
одним из "камней преткновения" как раз и является тема динамической памяти. Память
представляет собой низкоуровневый компонент компьютера, который, к сожалению,
"дотягивается" аж до такого языка программирования высокого уровня, как C++.
Многие программисты вникают в тему управления динамической памятью лишь до
некоторой степени. Они стараются избегать структур данных, которые используют
динамическую память, или совершенствуют свои программы методом проб и ошибок.
Можно говорить о двух основных преимуществах использования динамической
памяти в программах.
□ Динамическая память может быть разделена между различными объектами
и функциями.
□ Размер динамически выделяемой памяти можно определить во время
выполнения программы.
Ясное представление о том, как в действительности работает динамическая
память в C++ — необходимое условие становления профессионального С++-программиста.
Как представить себе память
Понять, как работает динамическая память, гораздо легче, если мысленно
построить модель того, как объекты выглядят в памяти. В этой книге блок памяти
изображается в виде прямоугольника с текстом (меткой) вблизи него. Текст означает имя
переменной, которая соответствует этой области памяти. Данные, указанные внутри
прямоугольника, отображают текущее значение соответствующей области памяти.
Например, на рис. 13.1 показано состояние памяти после выполнения следующей
строки кода.
int i = 7;
Как упоминалось в главе 1, переменная i размещается в области стека, поскольку
она объявляется с использованием простого типа, а не в расчете на средства
динамического выделения памяти (с помощью ключевого слова new).
Стек
"Куча-
Рис. 13.1
При использовании ключевого слова new запрашиваемая программой память
выделяется в области "кучи". При выполнении следующего кода в стеке создается
переменная ptr, а затем в области "кучи" выделяется память, на которую указывает
переменная ptr.
int* ptr,-
ptr = new int;
402 Часть III. Освоение суперсредств C++
Состояние памяти после выполнения этого фрагмента кода показано на рис. 13.2.
Обратите внимание на то, что переменная ptr расположена в стеке, несмотря на то, что
она указывает на область памяти в "куче". Указатель— это всего лишь переменная,
которая может размещаться как в стеке, так и в области "кучи" (хотя этот факт можно легко
забыть). Однако динамическое выделение памяти происходит только в области "кучи".
Стек
"Куча"
Ptr
> ?
*ptr
Рис. 13.2
Следующий пример демонстрирует, что указатели могут существовать как в
стековой памяти, так и в области "кучи".
int** handle ,-
handle = new int*;
♦handle = new int;
При выполнении этого кода сначала объявляется переменная handle как
указатель, который ссылается на указатель на целочисленное значение. Затем
динамически выделяется память, достаточная для хранения указателя на целочисленное
значение, а сам этот указатель запоминается в переменной handle. Затем в выделенную
память (*handle) записывается указатель на другую область динамической памяти,
объем которой достаточен для хранения целочисленного значения. На рис. 13.3
показаны два уровня указателей, один из которых размещается в стеке (handle), а
другой — в области "кучи" (*handle).
Стек
"Куча"
handle
"handle
'handle
Рис. 13.3
Термин "handle" (дескриптор) иногда используется для описания указателя на
указатель на некоторую область памяти. В некоторых приложениях дескрипторы находят
широкое применение, поскольку они при необходимости позволяют смещать память,
занимаемую объектами. Данный термин имеет здесь более узкое значение, чем описанное
в главе 5, но соответствует тому же принципу доступа к чему-то посредством
непрямой адресации.
Глава 13. Эффективное управление памятью 403
Выделение памяти и ее освобождение
В предыдущих главах этой книги были рассмотрены основы функционирования
динамически распределяемой памяти. Чтобы создать область памяти для переменной,
используется ключевое слово new. Чтобы освободить это пространство в пользу других частей
программы, применяется ключевое слово delete. Но, что совсем не удивительно для
языка C++, такие, казалось бы, простые средства, как new и delete, характеризуются
несколькими вариациями применения и соответствующими сложностями для понимания.
Использование операторов new и delete
В этой книге вы уже видели множество примеров использования оператора new.
Для того чтобы выделить в "куче" блок памяти, достаточно вызвать оператор new,
указав тип переменной, для которой выделяется память. Оператор new возвращает
указатель на выделенную память, и, казалось бы, совсем необязательно сохранять
этот указатель в некоторой переменной. Но если вы проигнорируете значение,
возвращаемое оператором new, или если эта переменная-указатель выйдет за пределы
области видимости, выделенная память "осиротеет" (программисты говорят—
"повиснет"), поскольку у вас больше не будет возможности получить к ней доступ.
Например, при выполнении следующего кода память, выделенная для хранения int-
значения, "повиснет". Состояние памяти после выполнения этого фрагмента кода
показано на рис. 13.4. Если в "куче" существуют блоки данных, к которым нет средств
доступа (прямого или косвенного) из стека, такая память становится "повисшей".
void leaky()
{
new int; // ОШИБКА! Область памяти "повисла"!
cout << "Произошла утечка памяти для int-значения!" << endl;
Стек
"Куча"
["Повисшее"
int-значение]
Рис. 13.4
До тех пор пока не появятся компьютеры с бесконечной памятью, нам
(программистам) придется сообщать компилятору, когда можно освободить память, связанную
с некоторым объектом, чтобы использовать ее для других целей. Чтобы освободить
память, выделенную в "куче", достаточно применить ключевое слово delete с
указателем на освобождаемую память. Вот пример.
int* ptr;
ptr = new int;
delete ptr;
Каждое строке кода, в которой выделяется память с помощью
оператора new, должна соответствовать другая строка кода, которая
освобождает ту же область памяти с помощью оператора delete.
404 Часть III. Освоение суперсредств C++
А как там поживает наша старая "знакомая" функция malloc () ?
Если вы раньше программировали на С, то вас должно интересовать, что
случилось (плохого) с функцией malloc (). В языке С функция malloc () используется для
выделения заданного количества байт памяти. Как правило, применение функции
malloc () не вызывает проблем, и ее по-прежнему можно вызывать в C++, но мы не
рекомендуем вам это делать. Основное преимущество оператора new перед функцией
malloc () состоит в том, что оператор new не просто выделяет память, но и создает
объекты. Например, рассмотрим следующие две строки кода, в которых используется
гипотетический класс Foo.
Foo* myFoo = (Foo*)malloc(sizeof(Foo));
Foo* myOtherFoo = new Foo();
После выполнения этих строк кода как переменная myFoo, так и переменная
myOtherFoo, будут указывать на области памяти в "куче", которые имеют достаточный размер
для хранения объекта Foo. К членам данных и методам объекта Foo можно получить
доступ с помощью обоих указателей. Различие между ними состоит в том, что объект
Foo, адресуемый указателем myFoo, не является, по сути, объектом, поскольку он не был
построен. Функция malloc () лишь "резервирует" область памяти определенного
размера. Она "ничего не знает" ни о каких объектах. В отличие от нее при обращении
к оператору new не только выделяется надлежащий объем памяти, но и создается сам
объект. Более детально эти две роли оператора new описаны в главе 16.
Подобное различие существует между функцией free () и оператором delete.
При обращении к функции free () деструктор объекта не вызывается. И, наоборот,
при использовании оператора delete вызывается деструктор объекта, в результате
чего выполняются все надлежащие очистительно-восстановительные операции.
Никогда не смешивайте в одной программе функции malloc О и free ()
с операторами new и delete. Мы рекомендуем использовать только
операторы new и delete.
Когда не удается выделить память
Многие программисты, если не большинство из них, пишут код, предполагая, что
оператор new всегда выполняется успешно. В отношении подобного поведения
обычно можно услышать примерно такие объяснения. Если оператор new
срабатывает неудачно, это означает, что память имеет очень маленький объем, из чего следует
вывод о том, что, мол, "жизнь не удалась". Часто такое непонятное состояние
объясняется недоумением: что могла бы сделать программа в подобной ситуации.
В случае неуспешного выполнения оператора new программа по умолчанию будет
завершена. Для многих программ такое поведение вполне приемлемо. Если оператор
new не может выполнить запрос из-за недостатка свободной памяти, он генерирует
исключение, в результате которого программа и завершается. Красивые решения для
выхода из такой ситуации описаны в главе 15.
Существует также альтернативная версия использования оператора new, которая
не генерирует исключение, а возвращает значение NULL, что соответствует поведению
функции malloc () в языке С. Синтаксис использования этой версии выглядит так.
int* ptr = new(nothrow) int;
Глава 13. Эффективное управление памятью 405
Безусловно, альтернативная версия не избавляет вас от проблемы, которая
присуща версии, генерирующей исключение: что делать, если в результате вызова
оператора new (nothrow) получено значение NULL? Компилятор не требует от вас проверки
результата, поэтому nothrow-версия оператора new даже более чревата появлением
других ошибок, чем версия, которая генерирует исключение. Поэтому мы
рекомендуем вам использовать стандартную версию оператора new. Если восстановление после
попадания в ситуацию нехватки памяти существенно для вашей программы, методы,
описанные в главе 15, дадут вам в руки все необходимые для этого инструменты.
Массивы
Массивы упаковывают несколько переменных одинакового типа в одну-единствен-
ную переменную с индексами. Работа с массивами даже для начинающего
программиста быстро приобретает естественный характер, поскольку совсем нетрудно
вообразить значения в пронумерованных ячейках. Представление массива в памяти не
слишком отличается от такой интуитивной модели.
Массивы базового типа
При выделении программой памяти для массива происходит "резервирование"
смежных ячеек памяти, причем каждая такая ячейка имеет размер, достаточный для
хранения одного элемента массива. Например, массив из пяти int-значений можно
объявить в области стека таким образом.
int myArray[5];
Состояние памяти после объявления этого массива показано на рис. 13.5.
Стек
"Куча"
myArray[0]
туАггау[1]
туАггау[2]
туАггау[3]
туАггау[4]
Рис. 13.5
Отличительной особенностью объявления массива в "куче" является
использование переменной для указания на область, занимаемую массивом. При выполнении
следующего кода выделяется память для массива, предназначенного для хранения
пяти int-значений, а указатель на выделенную для массива память сохраняется в
переменной myArrayPtr.
int* myArrayPtr = new int [5];
Как показано на рис. 13.6, массив, созданный в области "кучи", аналогичен
"стековому". Различаются они лишь расположением. Переменная myArrayPtr указывает на
нулевой элемент массива.
406 Часть III. Освоение суперсредств C++
Стек
"Куча"
myArrayPtr
myArrayPtr[0]
myArrayPtr[1]
myArrayPtr[2]
myArrayPtr[3]
myArrayPtr[4]
Рис. 13.6
Преимущество создания массива в области "кучи" состоит в том, что в этом случае
можно использовать механизм динамического вьщеления памяти для определения его
размера во время выполнения программы. Например, следующая функция получает
нужное количество документов из гипотетической функции askUserForNumberOf-
Documents () и использует этот результат для создания массива Document объектов.
Document* createDocArray()
{
int numDocs = askUserForNumberOfDocuments();
Document* docArray = new Document[numDocs];
return docArray;
Некоторые компиляторы, как будто под воздействием загадочного колдовства,
позволяют создавать массивы переменной длины и в стековой памяти. Поскольку это
не стандартное средство C++, мы не рекомендуем пользоваться им.
В предыдущей функции динамически созданный массив носит имя docArray. He
стоит путать его с понятием динамического массива. Массив docArray не является
динамическим, поскольку его размер не изменяется после создания. Механизм
динамического распределения памяти позволяет задать размер для массива во время выполнения
программы, но это не означает возможность автоматической настройки размера в
соответствии с хранимыми в нем данными. Однако в C++ существуют и структуры
данных, которые таки выполняют динамическую подгонку размера под конкретные
данные, например, встроенный STL-класс vector.
В C++ есть функция realloc (), которая "перешла" сюда из языка С. Не
используйте ее! В языке С функция realloc () применяется для эффективного изменения
размера массива путем выделения нового блока памяти (нового размера) и
перемещения в него всех старых данных. Такой подход чрезвычайно опасен в C++, поскольку для
определяемых пользователем объектов вряд ли подойдет поразрядное копирование.
Не используйте в C++ функцию realloc (). Она не принадлежит к числу
ваших друзей! „к
Глава 13. Эффективное управление памятью 407
Массивы объектов
Массивы объектов практически ничем не отличаются от массивов простых типов.
При использовании оператора new для создания массива, содержащего N объектов,
выделяется память в расчете на N смежных блоков, причем размер каждого блока
должен быть достаточен для хранения одного объекта. При вызове оператора new для
каждого из объектов массива автоматически вызывается конструктор без аргументов. Таким
образом, при создании массива объектов с помощью оператора new возвращается
указатель на массив полностью сформатированных и инициализированных объектов.
Например, рассмотрим следующий класс.
class Simple
{
};
public:
Simple() { cout << "Вызван конструктор класса Simple!"
<< endl; }
Если бы вам нужно было создать массив, содержащий четыре объекта типа Simple,
конструктор Simple () был бы вызван четыре раза.
int main(int argc, char** argv)
{
Simple* mySimpleArray = new Simple[4];
}
Результаты выполнения этого кода таковы.
Вызван конструктор класса Simple
Вызван конструктор класса Simple
Вызван конструктор класса Simple
Вызван конструктор класса Simple
Расположение этого массива в памяти схематично показано на рис. 13.7. Как
видите, оно ничем не отличается от расположения массива элементов базовых типов.
Стек
"Куча"
mySimpleArray
Рис. 13.7
mySimpleArrayfO]
mySimpleArray[1]
mySimpleArray[2]
mySimpleArray[3]
Удаление массивов
При динамическом способе выделения памяти с помощью "массивной" версии
оператора new (new []) необходимо освобождать эту память также с помощью
"массивной" версии оператора delete (delete []). Эта версия, помимо освобождения
памяти, занимаемой объектами массива, автоматически разрушает эти объекты. Если
408 Часть III. Освоение суперсредств C++
не использовать "массивную" версию оператора delete, ваша программа может
повести себя странным образом. Одни компиляторы обеспечат в этом случае вызов
деструктора только для нулевого элемента массива, поскольку им известно только то,
что вы заказали удаление указателя на объект. Другие компиляторы могут
"организовать" искажение данных в памяти, поскольку в работе операторов new и new [] могут
быть использованы совершенно различные схемы распределения памяти.
int main(int argc, char** argv)
{
Simple* mySimpleArray = new Simple[4];
// Используем указатель mySimpleArray.
de1ete [ ] myS imp1eArray;
}
Безусловно, деструкторы вызываются только в случае, если элементами массива
являются простые объекты. Если же ваш массив содержит указатели, то, как показано
в следующем коде, вам по-прежнему придется удалять каждый элемент по отдельности
(точно так же, как вы по отдельности создавали каждый элемент).
int main(int argc, char** argv)
{
Simple** mySimplePtrArray = new Simple*[4];
// Создаем объект для каждого указателя,
for (int i = 0; i < 4; i++) {
mySimplePtrArray[i] = new Simple();
}
// Используем указатель mySimplePtrArray.
// Удаляем каждый динамически созданный объект,
for (int i = 0; i < 4; i++) {
delete mySimplePtrArray[i] ;
}
// Удаляем сам массив.
delete[] mySimplePtrArray;
}
He путайте операторы new и delete с их "массивными" версиями
newt] и deleted.
Многомерные массивы
Многомерные массивы расширяют понятие индексированных значений до
использования нескольких индексов. Например, для игры в крестики-нолики можно
было бы взять двумерный массив, который бы позволил представить сетку размером
3x3. Применение такого массива (объявленного в стековой памяти) показан в
следующем примере.
int main(int argc, char** argv)
{
char board [3] [3] ;
// Тестовый код.
Глава 13. Эффективное управление памятью 409
}
board[0][0] = 'X1; // "Крестик" (X) помещается в
// позицию (0,0).
board[2][1] = 'О'; // "Нолик" (О) помещается в позицию(2,1)
Вас, вероятно, интересует, какой координате соответствует первый индекс в
двумерном массиве: х или у. По правде говоря, в действительности это не имеет значения
(т.е. решение принимаете только вы сами). Сетку размером 4x7 можно было бы
объявить как char board [4] [7] или как char board [7] [4]. В большинстве
приложений все же принято считать первый индекс соответствующим оси х, а второй — оси у.
Многомерные стековые массивы
Размещение в памяти двумерного стекового массива показано на рис. 13.8.
Поскольку память не имеет двух осей (мы имеем дело просто с последовательными адресами), то
в компьютере двумерный массив представляется точно так же, как одномерный.
Различие состоит лишь в размере массива и методах, используемых для доступа к нему.
Стек
"Куча"
board[0][0]
board[0][1]
board[0][2]
board[1][0]
board[1][1]
board[1][2]
board[2][0]
board[2][1]
board[2][2]
board[0]
board[1]
У board[2]
Рис. 13.8
Размер многомерного массива вычисляется как произведение всех его
размерностей, умноженное затем на размер его одного элемента. Размер массива, показанного на
рис. 13.8 (как вы помните, служащего для представления игровой доски размером 3x3),
равен 3*3*1 = 9 байт, если предположить, что один символ ("крестик" или "нолик")
занимает один байт. Для "символьной" доски размером 4x7 (клеток) массив бы занял
4*7*1 = 28 байт.
Чтобы получить доступ к значению многомерного массива, компьютер
обрабатывает каждый индекс так, как будто бы обращается к одному из его подмассивов.
Например, в сетке размером 3x3 выражение board [0] означает обращение к подмасси-
ву, выделенному темным тоном на рис. 13.9. При добавлении второго индекса
(например, board [0] [2]) компьютер находит нужный элемент в этом подмассиве по
второму индексу (искомый элемент выделен на рис. 13.10).
410 Часть III. Освоение суперсредств C++
Стек
"Куча"
board[0][0]
board[0][1]
board[0][2]
board[1][0]
board[1][1]
board[1][2]
board[2][0]
board[2][1]
board[2][2]
V board[0]
board [1]
> board[2]
Рис. 13.9
Стек
"Куча"
board[0][0]
board[0][1]
board[0][2]
board[1][0]
board[1][1]
board[1][2]
board[2][0]
board [2][1]
board[2][2]
board[0]
V board[1]
board[2]
Рис. 13.10
Эти методы доступа можно применить и к Л^мерным массивам, хотя при
увеличении размерности выше трех их осмысление становится более трудным, и такие
"экземпляры" редко используются в повседневном программировании.
Многомерные массивы в области "кучи"
Если определение размерностей многомерного массива возможно только во время
выполнения программы, создайте такой массив в области "кучи". Как упоминалось
выше, доступ к одномерным динамически создаваемым массивам осуществляется
Глава 13. Эффективное управление памятью 411
через указатель. Точно так же можно обращаться и к многомерным массивам.
Единственное различие состоит в том, что при адресации двумерного массива необходимо
использовать указатель на указатель, а при адресации Л^мерного — N уровней
указателей. Новичку может показаться, что следующий способ объявления многомерного
массива и динамического выделения для него памяти вполне корректен.
char** board = new char[i][j]; // ОШИБКА! Не скомпилируется!
Этот код не скомпилируется, поскольку обработка массивов "родом" из "кучи"
отличается от обработки стековых массивов. Массивы размещаются в памяти "кучи" не
в смежных ячейках, поэтому такой способ их создания некорректен. Вместо того,
чтобы следовать аналогии создания стековых массивов, необходимо начать с
размещения в "куче" одного массива (в непрерывной области памяти), соответствующего
первому индексу (первой размерности). Каждый элемент этого массива в
действительности является указателем на другой массив, в котором будут храниться
элементы, соответствующие второму индексу (второй размерности). Размещение в памяти
динамически создаваемой игровой доски размером 2x2 показано на рис. 13.11.
Стек
oard
"Куча"
1
board[0]
board[1]
board[0][0]
board[0][1]
board[1][0]
board[1][1]
Рис. 13.11
К сожалению, компилятор от вашего имени не выделяет память для подмассивов.
Вы можете создать массив "первой размерности" подобно тому, как создается любой
одномерный массив в области "кучи", но отдельные подмассивы должны быть
созданы явным образом. Как корректно выделить память для двумерного массива,
показано на примере следующей функции.
char** allocateCharacterBoard(int xDimension, int yDimension)
{
char** myArray = new char*[xDimension]; // Выделяем память
// для первой размерности
// массива,
for (int i = 0; i < xDimension; i++) {
myArray[i] = new char[yDimension]; // Выделяем память
// для i-го подмассива.
}
412 Часть III. Освоение суперсредств C++
return myArray;
}
При освобождении памяти, занимаемой в "куче" многомерным массивом,
простого применения синтаксиса delete [] недостаточно для возвращения в систему
памяти, связанной с подмассивами. Как показано в следующей функции, код, реализующий
освобождение занимаемой массивом памяти, должен "зеркально отражать" код,
реализующий выделение этой памяти.
void releaseCharacterBoard(char** myArray, int xDimension)
for (int i = 0; i < xDimension; i++) {
delete[] myArray[i]; // Удаление i-го подмассива.
}
delete[] myArray; // Удаление массива первой размерности.
}
Работа с указателями
Указатели не зря пользуются "дурной славой". Хотя, как это часто бывает, не в самих
указателях дело, а в злоупотреблении ими. Поскольку указатель — это всего лишь адрес
памяти, теоретически его можно изменить вручную, даже таким простым способом.
char* scaryPointer = 7;
При выполнении этой строки кода указателю на тип char присваивается адрес 7,
по которому, вероятнее всего, содержится "мусор", или же эта область памяти
используется где-то в другом месте программы. Если в таком стиле "трогать" области
памяти, которые могут быть задействованы оператором new, то в конце концов мы
разрушим память, связанную с каким-нибудь объектом, и наша программа неизбежно
попадет в аварийную ситуацию.
Построение интуитивной модели для указателей
Как упоминалось в главе 1, об указателях можно думать двояко. Читатели с
математическим складом ума могут представлять себе указатели просто как адреса. Такой
взгляд на них напрямую ведет к арифметике указателей, которая рассматривается
ниже в этой главе. Указатели — это не таинственные тропинки в памяти компьютера;
это просто числа, которые связаны с ячейками. На рис. 13.12 схематически
изображена сетка размером 2x2 с точки зрения адресной организации нашего мира.
Читатели, которым не свойственно пространственное мышление, могут при слове
"указатель" представлять себе стрелку. Указатель в этом случае может служить неким
"графическим" посредником, который как будто "говорит" программе: "Эй! Смотри
туда!". При таком взгляде на указатели их многоуровневую организацию нетрудно
представить в виде последовательности отдельных этапов на пути к данным. Такое
графическое представление указателей в памяти показано на рис. 13.11.
При разыменовании указателя с помощью оператора "*" мы велим программе
"заглянуть" в память на один уровень глубже. При адресном подходе к указателям
разыменование можно представить себе как переход к содержимому ячейки памяти но
адресу, обозначенному указателем. С точки зрения графического представления каждое
разыменование соответствует следованию по стрелке (от ее основания до острия).
Глава 13. Эффективное управление памятью 413
1000
1001
2000.
2001
5000
5001
Рис. 13.12
При взятии адреса переменной (с помощью оператора "&") мы наращиваем
уровень косвенности в памяти. Используя адресный подход, можно сказать, что
программа просто "отмечает" числовой адрес переменной, который можно сохранить
в какой-нибудь другой переменной-указателе. С точки зрения графического
представления оператор "&" создает новую стрелку, острие которой касается переменной.
Основание же стрелки находится у переменной-указателя.
Приведение типов с помощью указателей
Поскольку указатели — это просто адреса памяти (или "стрелки"), то говорят, что
они слабо типизированы. Указатель на XML-документ имеет такой же размер, что
и указатель на целочисленное значение. При использовании операции приведения
в стиле языка С компилятор без проблем позволит преобразовать указатель одного
типа в указатель другого.
Document* documentPtr = getDocument () ;
char* myCharPtr = (char*)documentPtr;
Оператор staticcast обеспечивает более высокий уровень безопасности.
Компилятор "откажется" выполнить операцию статического приведения типов для
указателей на данные различных типов.
Document* documentPtr = getDocument();
static_cast<char*> (documentPtr); //ОШИБКА! Не скомпилируется!
Если два указателя (участники операции преобразования) ссылаются на объекты,
которые связаны наследованием, компилятор разрешит выполнить операцию
статического приведения типов. Однако, как упоминалось в главе 10, существует метод
динамического приведения типов, который еще безопаснее для преобразования типов
внутри иерархии наследования.
Стек
board
1000
"Куча"
2000
5000
414 Часть III. Освоение суперсредств C++
Использование модификатора const с указателями
Применение ключевого слова const к указателям вносит некоторую неясность,
поскольку непонятно, на что именно действует модификатор const. Если
динамически создать массив целочисленных значений и применить к нему модификатор
const, то возникает вопрос, что будет защищено от изменений: адрес массива или
отдельные его (массива) значения? Ответ, оказывается, зависит от синтаксиса.
Если модификатор const стоит перед типом, это говорит о том, что защищается
адресуемое указателем значение, а в случае использования массива — его отдельные
элементы. Следующая функция принимает указатель на const-значение типа int.
Первая строка функции не скомпилируется, поскольку реальное значение защищено
от изменений. Вторая строка скомпилируется, поскольку сам указатель не защищен.
void test(const int* inProtectedlnt, int* anotherPtr)
{
♦inProtectedlnt = 7; // ОШИБКА! Попытка модификации
// значения, предназначенного
// для чтения.
inProtectedlnt = anotherPtr; // Прекрасно работает!
}
Как показано в следующем коде, чтобы защитить сам указатель, ключевое слово
const должно стоять непосредственно перед именем переменной. На этот раз
и указатель, и адресуемое им значение защищены, поэтому не скомпилируется ни
одна из строк кода функции.
void test(const int* const inProtectedlnt, int* anotherPtr)
{
♦inProtectedlnt = 7; // ОШИБКА! Попытка модификации
// значения, предназначенного
// для чтения.
inProtectedlnt = anotherPtr; // ОШИБКА! Попытка
// модификации значения,
// предназначенного для чтения.
}
На практике в защите указателей редко возникает необходимость. Если функция
может изменить значение переданного ей указателя, это мало что меняет. Другими
словами, это повлияет только локально, т.е. в пределах функции, а по выходу из нее
указатель снова будет содержать исходный адрес. Защита указателя с помощью
ключевого слова const больше полезна с точки зрения документирования кода, чем для
реальной защиты. Однако защита адресуемого указателем значения (значений)
действительно работает, не допуская перезаписи соответствующих данных.
Дуализм массивов и указателей
Вы уже наблюдали определенные "перекрытия" в функционировании указателей
и массивов. Имена массивов, созданных в "куче", можно использовать в качестве
указателей на их первый элемент. Доступ к массивам, созданным в стековой памяти,
осуществляется с помощью синтаксиса " [] ". Но на этом "перекрытия" не
исчерпываются. Между указателями и массивами существуют более сложные отношения.
Глава 13. Эффективное управление памятью 415
Массивы — это те же указатели!
Массив, созданный в "куче", — не единственное место приложения указателя
в качестве средства доступа к массиву. "Указательный" синтаксис можно также
использовать для доступа к элементам массива, размещенного в стековой памяти. Адрес
любого массива — это в действительности адрес его нулевого элемента. Компилятор
"знает", что при обращении к массиву по имени на самом деле происходит
обращение к адресу его нулевого элемента. Таким образом, указатель выполняет роль
массива, образованного в области "кучи". При выполнении следующего кода создается
массив в стековой памяти, но для доступа к нему используется указатель.
int main(int argc, char** argv)
{
int myIntArray[10];
int* mylntPtr = mylntArray;
// Доступ к массиву через указатель.
myIntPtr[4] = 5;
}
Возможность доступа к массиву, образованному в стековой памяти, через указатель
часто используется при передаче массивов функциям. Так следующая функция
принимает массив целочисленных значений в качестве указателя. Обратите внимание на
то, что инициатору вызова этой функции придется явным образом передать и размер
массива, поскольку указатель не несет в себе никакой информации о нем. Фактически
С++-массивы (в каком бы виде они ни были представлены — указателями или нет) не
имеют встроенного понятия о размере.
void doublelnts(int* theArray, int inSize)
{
for (int i = 0; i < inSize; i++) {
theArray[i] *= 2;
}
}
Инициатор вызова этой функции может передавать как "стековый", так и "кучевой"
массив. В последнем случае указатель уже существует ("от рождения") и просто
передается функции по значению. В случае использования "стекового" массива
инициатор вызова может передать переменную массива, а компилятор автоматически
интерпретирует ее как указатель на массив. Продемонстрируем оба варианта на
следующем примере.
int main(int argc, char** argv)
{
int* heapArray = new int[4];
heapArray[0] = 1;
heapArray[1] = 5;
heapArray[2] = 3;
heapArray[3] = 4;
doublelnts(heapArray, 4);
-5 int stackArray[4] = {5, 7, 9, 11} ,-
doublelnts(stackArray, 4);
}
416 Часть III. Освоение суперсредств C++
Даже если функция явно принимает в качестве параметра указатель, семантика
передачи параметров для массивов угрожающе напоминает "указательную"
семантику, поскольку компилятор при передаче массива функции обрабатывает его как
указатель. Функция, которая принимает массив в качестве аргумента и изменяет значения
элементов массива, в действительности изменяет исходный массив, а не копию. При
передаче массива функции реализуется передача по ссылке, поскольку на самом деле
передается не копия исходного значения, а его адрес. Следующая реализация
функции doublelnts () изменяет содержимое исходного массива, несмотря на то, что
параметром функции является не указатель, а массив.
void doublelnts(int theArrayt], int inSize)
{
for (int i = 0; i < inSize; i++) {
theArray[i] *= 2;
}
}
Вам, вероятно, хотелось бы знать, почему передача массивов организована
именно так. Почему бы компилятору при использовании в определении функции такого
([ ]) синтаксиса не копировать массив? Один из возможных вариантов ответа на этот
вопрос — эффективность: на копирование элементов массива необходимо затратить
время и память. Передавая же всегда указатель, компилятору не нужно включать код
копирования массива.
Итак, к массивам, объявленным с использованием "родного" синтаксиса ([]),
доступ можно получить с помощью указателя. При передаче массива функции реально
передается указатель.
Не все указатели являются массивами!
Тот факт, что компилятор позволяет передавать функции массив даже в том
случае, если она ожидает приема указателя (как показано в приведенной выше функции
doublelnts ()), может навести вас на мысль, что указатели и массивы— одно и то
же. В действительности это не так. Хотя указатели и массивы характеризуются
общими свойствами и иногда могут использоваться взаимозаменяемо (как показано выше),
это не эквивалентные понятия.
Указатель сам по себе не имеет смысла. Он может указывать на случайную область
памяти, некоторый объект или массив. Синтаксис доступа к массиву ([ ]) можно всегда
заменить указателем, но обратное утверждение в общем случае несправедливо,
поскольку указатели не всегда являются массивами. Рассмотрим, например, следующий код.
int* ptr = new int;
Переменная ptr имеет тип указателя, но о массиве здесь нет и речи. Да, можно
получить доступ к значению, адресуемому этим указателем, с помощью синтаксиса
доступа к массиву (ptr [0]), но такой стиль программирования стилистически
сомнителен и не дает никакого выигрыша в эффективности. В действительности
применение синтаксиса доступа к массиву относительно указателей, никак не связанных с
массивами, это своего рода "день открытых дверей" для ошибок. Ведь область памяти по
адресу ptr [ 1 ] может содержать все что угодно!
Массивы всегда автоматически адресуются через указатели, но не все
указатели являются массивами.
Глава 13. Эффективное управление памятью 417
Динамическая обработка строк
У разработчиков языков программирования строки часто вызывают затруднения,
поскольку они, казалось бы, должны быть представлены стандартным типом данных,
но при этом их невозможно описать фиксированными размерами. Однако строки
столь употребимы, что в большинстве языков программирования предусмотрены их
встроенные модели. В языке С строкам никогда не уделялось должного внимания,
хотя они, безусловно, заслуживают большего. Язык C++ характеризуется гораздо более
гибким и эффективным представлением строк.
Строки в стиле языка С
В языке С строки представляются в виде массивов символов. Для того чтобы при
обработке строк можно было определить их окончание, в качестве последнего
символа каждой строки используется нуль-символ (' \0 '). Несмотря на то что в языке C++
предусмотрена более удачная абстракция строки, важно понимать С-способ
представления строк, поскольку они по-прежнему используются в С++-программировании.
Самая распространенная ошибка, которую программисты допускают при работе
с С-строками, состоит в том, что они забывают оставить место для нуль-символа.
Например, строка "hello" содержит пять символов, но для ее хранения в памяти
необходимо "зарезервировать" шесть (рис. 13.13).
Стек
myString
■h"
•е'
Т
Т
'о'
'Ю'
"Куча"
Рис. 13.13
Язык C++ содержит ряд функций обработки строк, унаследованных от С. В
отношении этих функций необходимо помнить, что они не "утруждают себя" проблемами
выделения памяти. Например, функция strcpy () принимает две строки в качестве
параметров. Она копирует вторую строку в первую, ничуть не "беспокоясь" о том,
поместится ли источник в приемнике. При выполнении следующего кода делается попытка
построить вокруг функции strcpy () оболочку, которая бы выделяла нужный объем
памяти и возвращала результат, а не просто принимала источник в неизвестно какой
приемник. Для получения длины строки здесь используется функция strlen ().
418 Часть III. Освоение суперсредств C++
char* copyString(const char* inString)
{
char* result = new char[strlen(inString)]; // ОШИБКА! Не
// хватает памяти для одного символа!
strcpy(result, inString);
return result;
}
Функция copyString () написана некорректно. Дело в том, что функция strlen ()
возвращает длину строки, а не объем памяти, необходимый для ее хранения.
Например, для строки "hello" функция strlen () возвратит число 5, а не 6! Поэтому,
чтобы правильно выделить память для строки, необходимо увеличить количество
реальных символов на единицу. К такому положению вещей нетрудно привыкнуть, и очень
скоро этот нехитрый прием покажется вам вполне естественным.
char* copyString(const char* inString)
{
char* result = new char [strlen (inString) + 1] ,-
strcpy(result, inString);
return result;
}
Для того чтобы запомнить, что функция strlen {) возвращает только количество
реальных символов в строке, достаточно представить, что было бы, если бы нам нужно
было выделить место для строки, составленной из нескольких других. Например, если
бы ваша функция принимала в качестве параметров три строки и возвращала строку,
которая бы являлась результатом конкатенации всех исходных строк, то каким бы был
ее размер? Чтобы правильно рассчитать нужное пространство, достаточно сложить
длины всех исходных строк, а затем полученный результат увеличить на единицу (в
расчете на замыкающий нуль-символ). Если бы функция strlen () включала в длину строки
нуль-символ, то выделенная память была бы слишком большой. Для выполнения
описанной операции в следующем коде используются функции strcpy () и streat ().
char* appendstrings(const char* inStrl,
const char* inStr2,
const char* inStr3)
{
char* result = new char[strlen(inStrl) + strlen(inStr2) +
strlen (inStr3) + 1] ,-
strcpy(result, inStrl);
strcat(result, inStr2);
strcat(result, inStr3);
return result;
}
Полный список С-функций обработки строк находится в заголовочном файле
<cstring>.
Строковые литералы
Вероятно, вам уже приходилось видеть в С++-программе строки, заключенные
в кавычки. Например, при выполнении следующей строки кода будет выведена
текстовая строка hello (обратите внимание на то, что эта строка используется
"самостоятельно", т.е. без переменной.
cout « "hello" << endl;
Глава 13. Эффективное управление памятью 419
Здесь строка "hello" представляет собой строковый литерал, поскольку она
написана как значение, а не как переменная. Несмотря на то что строковые литералы не
имеют связанных с ними переменных, они интерпретируются как значения типа const
char* (массивы константных символов).
Строковые литералы допускается присваивать переменным, однако это действие
может быть сопряжено с определенным риском. Реальная память, связанная со
строковым литералом, принадлежит той части памяти компьютера, которая предназначена
только для чтения, потому строковые литералы и представляются массивами
константных символов. Это позволяет компилятору оптимизировать управление памятью путем
повторного использования ссылок на эквивалентные строковые литералы (это значит,
что, если ваша программа использует строковый литерал "hello" 500 раз, компилятор
может создать только один его экземпляр в памяти). Однако компилятор отнюдь не
заставляет вас присваивать строковый литерал переменной только типа const char*
или const char []. Можно присвоить строку переменной типа char* (без
модификатора const), после чего ваша программа будет прекрасно работать до тех пор, пока
вы не попытаетесь ее изменить. И вообще, как показано в следующем фрагменте кода,
попытка изменить строку немедленно приведет программу к аварийному отказу.
char* ptr = "hello"; // Присваиваем строковый литерал
// переменной.
ptr[l] = "а"; // ОТКАЗ! Попытка записи в область памяти,
// предназначенную только для чтения.
Гораздо безопаснее при использовании строковых литералов применять указатель
на const-символы. Следующий код содержит ту же ошибку, что и предыдущий, но,
поскольку литерал здесь присваивается const-массиву символов, попытка записи в
область памяти, предназначенную только для чтения, будет перехвачена компилятором.
const char* ptr = "hello"; // Присваиваем строковый литерал
// переменной.
ptr[l] = 'а'; // ОШИБКА! Попытка записи в область памяти,
// предназначенную только для чтения.
Строковый литерал можно также использовать в качестве начального значения
для символьного массива, создаваемого в области стека. Поскольку стековая
переменная не может ссылаться на какую-либо другую память, компилятор позаботится о
копировании строкового литерала в стековую область памяти.
char stackArrayt] = "hello"; // Компилятор позаботится о
// копировании литерала и
// создании стекового массива
// соответствующего размера.
stackArray[1] = 'а1; // Эту копию можно модифицировать.
С+ч-класс string
Как упоминалось выше, C++ предлагает значительно усовершенствованную
реализацию строки в виде части стандартной библиотеки. В C++ строковый тип представлен
классом string (в действительности это реализация шаблонного класса basic_string),
который поддерживает многие популярные операции обработки строк (из обеспечи-
420 Часть III. Освоение суперсредств C++
ваемых функциями, определенными в заголовке <cstring>). Основное
преимущество класса string перед С-представлением строк заключается в том, что
библиотечный класс заботится о распределении памяти, избавляя вас от описанных выше
проблем, если, конечно, вы будете корректно его использовать.
Что плохого в использовании С-строк
Прежде чем погружаться в новый мир С++-класса string, рассмотрим
достоинства и недостатки представления строк в С-стиле.
Достоинства
□ Простота использования на основе базового символьного типа и понятия массива.
□ Экономичность (при надлежащем использовании не занимают лишнего объема
памяти).
□ Средства низкого уровня (ими можно легко манипулировать и копировать как
необработанную область памяти).
□ Привычка (если С-программист научился с ними работать, то, зачем,
спрашивается, осваивать что-то новое?).
Недостатки
□ "Не прощают" ошибок.
□ Не используют объектно-ориентированный характер C++.
□ "Подаются" в одном пакете с не всегда удачно названными и порой
сбивающими с толку вспомогательными функциями.
□ Требуют от программиста знаний базового представления.
Эти списки достоинств и недостатков были построены так, чтобы вы захотели
освоить более эффективный вариант представления строк. Как будет показано ниже,
С++-строки устраняют все перечисленные недостатки С-строк и оспаривают
указанные достоинства.
Использование класса string
Несмотря на то что тип string — это класс, с ним практически всегда можно
обращаться как со встроенным типом (подобно типу int). И в самом деле, чем больше о
нем думать как о простом типе, тем лучше будет для вас самих (и вашего кода).
Программисты обычно меньше испытывают проблем со string-строками, когда
забывают, что имеют дело со string-объектами.
Благодаря магической перегрузке операторов С++-строки поддерживают
конкатенацию на основе оператора "+", присваивание— на базе оператора "=", сравнение —
с помощью оператора "==", а доступ к отдельным символам— за счет оператора " [] ".
Эти операторы позволяют программисту обращаться со string-объектами как с
переменными встроенного типа. Как показано в следующем коде, эти операции над
строками можно выполнять, совершенно не беспокоясь о проблемах распределения памяти.
int main(int argc, char** argv)
{
string myString = "привет";
myString += ", люди!";
Глава 13. Эффективное управление памятью 421
string myOtherString = myString;
if (myString == myOtherString) {
myOtherString[0] = 'П';
}
cout << myString « endl;
cout << myOtherString « endl;
}
Результаты выполнения этого кода таковы.
привет, люди!
Привет, люди!
В этом примере следует отметить следующие моменты. Во-первых, никаких
проблем с нехваткой памяти у вас не будет даже в случае, если строки должны
увеличиться в размере (вправо или влево— не важно). Все эти string-объекты были созданы
как стековые переменные. Несмотря на то что класс string выполняет ряд операций
по выделению памяти и изменению размеров строковых объектов, эти объекты при
выходе из области видимости сами освобождают "после себя" память.
Кроме того, необходимо отметить, что используемые в предыдущем коде
операторы работают вполне ожидаемым образом. Оператор "=" копирует строки, оператор
"==" действительно сравнивает реальное содержимое двух строк, а не области
памяти, занимаемые string-объектами. Если вы устали или привыкли работать со
строками как с символьными массивами, то новое средство вам покажется либо глотком
свободы, либо новой морокой на вашу голову. Доверьтесь классу string, и ваша жизнь
станет намного проще!
В целях совместимости вы можете преобразовать string-объект в С-
строку. Для этого используйте метод c_str {). Чтобы результат
преобразования точно отражал текущее содержимое строки, метод
est г () следует вызывать непосредственно перед использованием.
Все операции, которые можно выполнять со string-объектами, перечислены
в оперативном справочнике (on-line reference).
Низкоуровневые операции по управлению
памятью
Одно из самых больших преимуществ C++ перед языком С состоит в том, что
программисту не нужно беспокоиться о проблемах памяти. Если в вашем коде
используются объекты, вам просто необходимо убедиться в том, что каждый отдельный класс
надлежащим образом управляет собственной памятью. Посредством действий,
выполняемых конструктором и деструктором, компилятор помогает управлять памятью.
Как вы увидели на примере класса string, сокрытие процесса управления памятью
внутри классов создает для программиста значительные удобства по сравнению с
обработкой строк в языке С.
Однако при разработке некоторых приложений программист может столкнуться
с необходимостью управлять памятью на низком уровне. Знание методов обработки
байтов данных (ради эффективности, для отладки или из нездорового любопытства)
может оказаться порой весьма полезным.
422 Часть III. Освоение суперсредств C++
Арифметика указателей
Компилятор языка C++ использует объявленные типы указателей, чтобы позволить
программисту выполнять арифметические действия над указателями. Если объявить
указатель на тип int, а затем увеличить его на 1, то этот указатель изменится на размер
int-значения, а не на один байт. Такой вид операции больше всего полезен для работы
с массивами, поскольку они содержат однородные данные, расположенные в памяти
последовательно. Предположим, вы объявляете массив int-значений в области "кучи".
int* myArray = new int [8] ,-
Вы ведь уже знакомы со следующим синтаксисом установки значения (в данном
случае устанавливается значение, расположенное в позиции 2).
myArray[2] = 33;
Используя арифметические действия над указателями, можно выполнить ту же
операцию, но другим способом (получить указатель, соответствующий int-значению,
расположенному на 2 "шага" дальше, чем значение с адресом myArray, а затем
разыменовать его, чтобы сделать возможной установку нового значения).
*(myArray + 2) = 33;
В качестве альтернативного синтаксиса для доступа к отдельным элементам
арифметика указателей не кажется слишком уж привлекательной. Ее реальная сила состоит
в том, что выражение, подобное myArray + 2, по-прежнему является указателем на
int-значение и, таким образом, может представлять int-массив меньшего (по
сравнению с исходным) размера. Предположим, вы создали следующую С-строку.
const char* myString = "Привет, мир!";
Допустим, у вас есть функция, которая принимает строку и возвращает новую, в
которой все буквы исходной строки стали прописными.
char* toCaps(const char* inString);
Вы могли бы сделать прописными все буквы строки myString, передав ее
функции toCaps (). Но если бы вам нужно было сделать прописными буквы не всей строки
myString, а только ее части, то здесь как раз нам бы и пригодилась арифметика
указателей: ведь с помощью смещения можно легко обозначить нужную (для
преобразования) часть строки. При выполнении следующей строки кода функции toCaps ()
будет передана только часть исходной строки (мир).
toCaps(myString + 8);
Помимо сложения также полезным бывает применение к указателям операции
вычитания. При вычитании одного указателя из другого (того же типа) вы получите
не абсолютное количество байтов между этими двумя указателями, а количество
элементов (адресуемого типа) между ними.
Индивидуальное управление памятью
В 99% случаев (для некоторых программистов — даже все 100%) встроенные
средства управления памятью в C++ вполне адекватны. Операторы new и delete
Глава 13. Эффективное управление памятью 423
негласно выполняют всю работу по выделению порций памяти нужного объема,
поддерживая соответствующим образом список доступных областей памяти и возвращая
в этот список освобожденные порции памяти после удаления объектов.
При жестких ограничениях на количество ресурсов "ручное" управление памятью
может быть довольно рискованным. Хотя на самом деле не все так ужасно, как может
показаться. В основном самостоятельное управление памятью, как правило, означает,
что классы требуют выделения большого объема памяти, а затем "раздают" эту память
маленькими порциями по мере необходимости.
Как же усовершенствовать этот метод? Эффективное управление собственной
памятью может потенциально сократить уровень затрат системных ресурсов. При
использовании оператора new для выделения памяти программе необходимо также
зарезервировать небольшой объем пространства, чтобы зафиксировать, какой именно
размер памяти был выделен. Благодаря этим сведениям при вызове оператора delete
освобождается соответствующий объем памяти. Для больших объектов затраты
системных ресурсов гораздо меньше выделенной памяти, и поэтому они "не делают
погоды". Но для небольших объектов или программ с огромным количеством объектов
системные расходы могут иметь существенное значение.
При "ручном" управлении памятью программист знает размер каждого объекта
априори, что позволяет ему избежать затрат системных ресурсов вообще. Для
большого количества небольших объектов экономия может быть весьма существенной.
Синтаксис "ручного" управления памятью описан в главе 16.
"Сбор мусора"
На другом конце всего спектра "гигиены" памяти лежит процесс, именуемый "сбором
мусора"» В средах, которые поддерживают этот процесс, программист редко (если вообще
когда-либо) в явном виде освобождает память, связанную с объектом. Этим занимается
низкоприоритетная фоновая задача, которая следит за состоянием памяти и "подчищает"
ее отдельные области, которые, по ее мнению, больше не используются.
"Сбор мусора" не является встроенным средством языка C++, как это реализовано
в языке Java. В большинстве С++-программ управление памятью происходит на уровне
объектов посредством операторов new и delete. Да, "сбор мусора" можно
реализовать и в C++, но освобождение от задачи "зачистки" памяти, по всей вероятности,
принесет вам новую головную боль.
Один из алгоритмов сбора мусора называется "отметить и подмести" (mark and
sweep). В этом случае сборщик мусора периодически просматривает все одиночные
указатели в вашей программе и проверяет факт использования адресуемой ими
памяти. В конце цикла просмотра память, которая осталась неотмеченной, считается
неиспользуемой и освобождается.
Этот алгоритм можно было бы реализовать в C++, если бы вы были готовы к
выполнению следующих действий. '
1. Зарегистрируйте все указатели так, чтобы сборщик мусора мог легко
просматривать этот список.
2. Сгруппируйте все объекты из смешанного класса, например, в подкласс Garba-
geCollectible, чтобы это позволило сборщику мусора отмечать объекты как
используемые.
3. Не допускайте одновременного доступа к объектам, чтобы гарантировать, что
во время работы сборщика мусора указатели не могут подвергнуться никаким
изменениям.
424 Часть III. Освоение суперсредств C++
Как видите, даже этот простой алгоритм сбора мусора требует определенных
усилий от программиста. При этом он может оказаться даже более подверженным
ошибкам, чем использование оператора delete! Делались попытки внедрения в C++ и
других механизмов сбора мусора, но даже если и был бы изобретен идеальный алгоритм,
нет никакой гарантии, что он подойдет для всех С++-приложений. Теперь
рассмотрим недостатки процесса сбора мусора.
□ Во время сбора мусора, по всей вероятности, работа программы замедлится.
□ Если ваша программа интенсивно использует память, сборщик мусора не
сможет функционировать.
□ Если сборщик мусора ошибочно "подумает", что "покинутый" объект все еще
используется, он может создать ситуацию непоправимых "утечек памяти".
Накопители объектов
Индивидуальное управление памятью, как упоминалось выше, можно сравнить с
походом в супермаркет при подготовке к пикнику, когда вы намеренно берете больше
одноразовых тарелок, чтобы не тратить время на их покупку при подготовке к следующему
пикнику. Сбор мусора напоминает ситуацию, когда оставленные во дворе вашего дома
одноразовые тарелки легко сдуваются ветром на территорию соседского дворика.
Безусловно, должен существовать экологически более чистый подход к управлению памятью.
Аналогом средства утилизации отходов могут служить накопители объектов.
Представьте себе следующее. Вы покупаете разумное количество тарелок, но не
выбрасываете их, а моете, чтобы снова использовать их в следующий раз. Накопители
объектов идеально подходят к ситуации, когда вам нужно неоднократно (время от времени)
использовать множество объектов одного и того же типа, а создание каждого объекта
требует затрат.
Детали использования накопителей объектов для повышения относительного
уровня производительности описаны в главе 17.
Указатели на функции
Обычно никто не задумывается о том, где расположены функции в памяти
компьютера, но в действительности каждая функция "прописана" по вполне
определенному адресу. В C++ функции можно использовать просто как данные. Другими словами,
можно взять адрес любой функции и использовать его как переменную.
Указатели на функции типизированы в соответствии с типами параметров и
значений, возвращаемых этими функциями. Самый простой способ использовать указатели
на функции состоит в следующем. С помощью typedef-механизма можно присвоить
некоторое имя типа семейству функций, которые имеют заданные характеристики.
Например, следующая строка объявляет тип YesNoFcn, который представляет указатель
на любую функцию, имеющую два int-параметра и возвращающую значение типа bool.
typedef bool(*YesNoFcn){int, int);
После создания этого нового типа вы могли бы написать функцию, которая
принимает значение типа YesNoFcn в качестве параметра. Например, следующая
функция принимает два int-массива и их размер, а также значение типа YesNoFcn. Она
опрашивает параллельно оба массива и вызывает YesNoFcn-функцию для соответст-
Глава 13. Эффективное управление памятью 425
вующих элементов обоих массивов, выводя сообщение, если YesNoFcn-функция
возвращает значение true. Несмотря на то что параметр типа YesNoFcn передается как
переменная, он может быть вызван подобно обычной функции.
void findMatches(int valuesl[], int values2[],
int numValues, YesNoFcn inFunction)
{
for (int i = 0; i < numValues; i++) {
if (inFunction(valuesl[i], values2[i])) {
cout << "Совпадение обнаружено в позиции " << i
<< " (" << valuesl[i] << ", " << values2[i]
« ")" << endl;
Чтобы вызвать функцию f indMatches (), необходимо, чтобы у вас была функция,
которая соответствует определенному выше типу YesNoFcn. Другими словами,
подойдет любая функция, которая принимает два int-параметра и возвращает значение
типа bool. Рассмотрим, например, следующую функцию, которая возвращает
значение true, если два параметра равны.
bool intEqual(int inlteml, int inltem2)
{
return (inlteml == inltem2);
}
Поскольку функция intEqual () соответствует типу YesNoFcn, ее можно
передать, как показано в следующей программе, в качестве последнего аргумента
функции findMatches().
int main(int argc, char** argv)
{
int arrl[7] = {2, 5, 6, 9, 10, 1, l};
int arr2 [7] = {4, 4, 2, 9, 0, 3, 4};
cout << "Вызов функции findMatches() с параметром
*Ъ intEqual (): " « endl,-
findMatches(arrl, arr2, 7, &intEqual);
return 0;
}
Обратите внимание на то, что функция intEqual {) передается в функцию find-
Matches () путем взятия ее адреса. Формально символ "&" использовать
необязательно — даже если просто поставить имя функции, компилятор будет "знать", что вы имеете
в виду взятие адреса. Вот как выглядят результаты выполнения этой программы.
Вызов функции findMatches() с параметром intEqualО:
Совпадение обнаружено в позиции 3 (9, 9)
Магия указателей на функции заключается в том факте, что функция f indMatches ()
представляет собой обобщенную функцию, которая параллельно сравнивает
значения в двух массивах. (Как было показано выше, она выполняет сравнение на основе
равенства значений.) Но, поскольку функция f indMatches () принимает указатель
на функцию, она могла бы сравнивать значения, используя совсем другой критерий.
Например, следующая функция также подходит под определение типа YesNoFcn.
426 Часть III. Освоение суперсредств C++
bool bothOdd(int inlteml, int inltem2)
{
return (inlteml % 2 == 1 && inltem2 % 2 == 1);
}
При выполнении следующей программы функция f indMatches () вызывается
с двумя разными параметрами типа YesNoFcn.
int main(int argc, char** argv)
{
int arrl[7] = {2, 5, 6, 9, 10, 1, l};
int arr2[7] = (4, 4, 2, 9, 0, 3, 4};
cout << "Вызов функции findMatches() с параметром
•Ь int Equal (): " << endl;
findMatches(arrl, arr2, 7, fcintEqual);
cout << endl;
cout << "Вызов функции findMatches() с параметром
•Ь bothOddO:" « endl;
findMatches(arrl, arr2, 7, &bothOdd);
return 0;
}
Результаты выполнения этой программы таковы.
Вызов функции f indMatches () с параметром intEqualO:
Совпадение обнаружено в позиции 3 (9, 9)
Вызов функции findMatches() с параметром bothOdd ():
Совпадение обнаружено в позиции 3 (9, 9)
Совпадение обнаружено в позиции 5 (1, 3)
С помощью указателей на функции в нашем примере одна и та же функция, find-
Matches (), "настраивалась" на различные "режимы" (в данном случае "режимы"
сравнения значений). И этой гибкостью мы обязаны параметру inFunction.
Распространенные ошибки при управлении
памятью
Трудно точно определить ситуации, которые могут привести к ошибкам,
связанным с памятью. Каждый случай "утечки памяти" или некорректного использования
указателя имеет свои нюансы. Не существует некого общего магического средства для
решения проблем с памятью, однако есть ряд категорий, на которые можно разделить
эти проблемы, и ряд средств, позволяющих обнаруживать и решать их.
Выделение недостаточного объема памяти для строк
Как упоминалось выше, наиболее распространенной проблемой использования С-
строк является выделение недостаточного объема памяти. В большинстве случаев это
связано с тем, что программист забывает о необходимости выделить место для
замыкающего нуль-символа (' \ 0 '), играющего роль сигнальной метки. Нехватка памяти
для строк также встречается в случаях, когда программисты предполагают, каким
Глава 13. Эффективное управление памятью 427
будет некоторый фиксированный максимальный размер. Основные встроенные
функции обработки строк никак не учитывают фиксированный размер — они с
легкостью могут "перешагнуть" ничем не отмеченный для них конец строки и "не глядя"
посягнут на смежные ячейки памяти.
При выполнении следующего кода из сети будут считаны данные и помещены в С-
строку. Этот процесс выполняется в цикле, поскольку при сетевом соединении
компьютер получает данные только небольшими порциями. Когда функция getMoreData ()
возвратит значение NULL, это будет означать, что все данные получены.
char buffer[1024]; // Выделяем целый блок памяти.
bool done = false;
while ('done) {
char* nextChunk = getMoreData();
if (nextChunk == NULL) {
done = true ,-
} else {
strcat(buffer, nextChunk); // ОШИБКА! Нет гарантии, что
// не будет переполнения буфера!
delete[] nextChunk;
}
}
Есть три способа для решения этой проблемы. Перечислим их в порядке
возрастания степени предпочтения.
1. Найдите версию функции getMoreData (), которая принимает максимальный
размер в качестве параметра. При каждом вызове функции getMoreData () ей
необходимо передавать только "оставшийся максимальным" объем пространства.
2. Отслеживайте, какой объем пространства остался в буфере. Если его
недостаточно для текущей порции данных, выделите новый буфер вдвое большего
размера и скопируйте в него содержимое исходного буфера.
3. Используйте С++-строки, которые от вашего имени прекрасно "справляются"
с вопросами памяти при конкатенации строк. Раз так — им и знамя в руки!
Утечка памяти
Обнаружение причин утечки памяти и их ликвидация — одна из самых печальных
страниц "книги" программирования на C++. Представьте: ваша программа, наконец,
работает и, казалось бы, дает правильные результаты. И вдруг вы начинаете замечать,
что во время работы она поглощает все больше и больше памяти. Это значит, что
ваша программа создает утечку памяти.
Утечка памяти возникает в случае, если вы выделяете память и не даете себе труд ее
освободить. Вначале это звучит как результат небрежного программирования, которого
можно было бы избежать. Прежде всего, если каждый оператор new имеет
соответствующий оператор delete в каждом написанном вами классе, то никакой утечки памяти
и быть не должно, не так ли? В действительности это не всегда так. Рассмотрим
следующий код: класс Simple написан аккуратно (в нем освобождается любая ранее
выделенная им память). Но при вызове функции doSome thing () значение указателя
изменяется, поскольку он связывается с другим (новым) объектом класса Simple без удаления
старого. Потеряв указатель на объект, его (объект) удалить практически невозможно.
428 Часть III. Освоение суперсредств C++
#include <iostream>
using namespace std;
class Simple
{
public:
Simple() { mlntPtr = new int(); }
-Simple() { delete mlntPtr; }
void setlntPtr(int inlnt) { *mIntPtr = inlnt; }
void go() { cout << "Привет всем!" << endl; }
protected:
int* mlntPtr;
};
void doSomething(Simple*& outSimplePtr)
{
outSimplePtr = new Simple О; // ОШИБКА! Не был удален
// оригинал.
}
int main(int argc, char** argv)
{
Simple* simplePtr = new Simple(); // Выделяем память для
// Simple-объекта.
doSomething(simplePtr);
delete simplePtr; // Освобождается память только
// от второго объекта.
}
В случаях, подобных продемонстрированному в предыдущем примере, утечка памяти
возникла, вероятно, из-за недостаточного общения между программистами или
некачественно подготовленной документации на код. Пользователь функции doSomething (),
возможно, не понял, что переменная ей передается по ссылке, и поэтому не ожидал,
что указателю будет присвоено новое значение. Если бы пользователь обратил
внимание на то, что параметр является не const-ссылкой на указатель, он бы, возможно,
и заподозрил неладное, но отсутствие соответствующих комментариев для функции
doSomething (), объясняющих ее странное поведение, усыпило его бдительность.
Обнаружение причин утечки памяти и их ликвидация
Причины утечки памяти трудно поддаются обнаружению, поскольку не так-то
просто "просмотреть" содержимое памяти и понять, какие объекты не используются
и где они были изначально созданы. К счастью, есть программы, которые могут
сделать это за вас. Существуют самые разные средства обнаружения причин утечки
памяти: от профессиональных пакетов до свободно загружаемых утилит из всемирной
сети. Одним таким бесплатным средством является valgrind, программный продукт
с открытым исходным текстом для ОС Linux, который помимо прочих вещей, точно
указывает строку в коде, где выделяет память для "пожирающего" память объекта.
Ниже приводятся результаты va/grmrf-расследования "деятельности" предыдущей
программы, в которых точно указаны строки кода, где память была выделена, но не
освобождена. В данном случае выявлены две причины утечки памяти: неудаленный
первый объект типа Simple и созданная им (в области "кучи") переменная типа int.
Глава 13. Эффективное управление памятью 429
==15606== Memcheck, a.k.a. Valgrind, a memory error detector for x86-linux.
==15606== Copyright (C) 2002-2003, and GNU GPL'd, by Julian Seward.
==15606== Using valgrind-2.0.0, a program supervision framework for x86-linux
==15606== Copyright (C) 2000-2003, and GNU GPL'd, by Julian Seward.
==15606== Estimated CPU clock rate is 1136 MHz
==15606== For more details, rerun with: -v
==15606==
==15606==
==15606== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
==15606== malloc/free: in use at exit: 8 bytes in 2 blocks.
==15606== malloc/free: 4 allocs, 2 frees, 16 bytes allocated.
==15606== For counts of detected errors, rerun with: -v
==15606== searching for pointers to 2 not-freed blocks.
==15606== checked 4455600 bytes.
==15606==
з
==15606== 4 bytes in 1 blocks are still reachable in loss record 1 of 2
==15606== at Ox4002978F: builtin_new (vg_replace_malloc.c:172)
==15606== by 0x400297E6: operator new(unsigned) (vgreplacemalloc.c:185)
==15606== by 0x804875B: Simple::Simple() (leaky.cpp:8)
==15606== by 0x8048648: main (leaky.cpp:24)
==15606==
==15606==
4
==15606== 4 bytes in 1 blocks are definitely lost in loss record 2 of 2
==15606== at 0x4002978F: builtin_new (vg_replace_malloc.c:172)
==15606== by Ox400297E6: operator new(unsigned) (vg_replace_malloc.c:185)
==15606== by 0x8048633: main (leaky.cpp:24)
==15606== by Ox4031FA46: libc_start_main (in /lib/libc-2.3.2.so)
==15606==
==15606== LEAK SUMMARY:
==15606== definitely lost: 4 bytes in 1 blocks.
==15606== possibly lost: 0 bytes in 0 blocks.
==15606== still reachable: 4 bytes in 1 blocks.
==15606== suppressed: 0 bytes in 0 blocks.
Конечно же, программы, подобные valgrind, не могут в действительности
исправить места утечки памяти за вас — и что же тогда делать? Такие средства
предоставляют информацию, которую можно использовать для выявления истинной проблемы.
Обычно это означает выполнение кода в пошаговом режиме с целью выяснения, где
именно был перезаписан указатель на объект без предварительного удаления
исходного объекта. Некоторые отладчики при обнаружении такой ситуации позволяют
останавливать выполнение программы.
4 байт в блоке еще достижимы (запись 1 из 2). — Примеч. ред.
4 байт в блоке окончательно утеряны (запись 2 из 2). — Примеч. ред.
430 Часть III. Освоение суперсредств C++
Интеллектуальные указатели
Довольно популярный метод избежать утечек памяти состоит в использовании
интеллектуальных указателей (smart pointers). Интеллектуальные указатели— это
понятие, которое возникло благодаря тому факту, что большинство проблем, связанных
с памятью, можно избежать, если все данные помещать в стек. Область стека гораздо
безопаснее "кучи", поскольку стековые переменные автоматически разрушаются
и очищаются при выходе из области видимости. Интеллектуальные указатели сочетают
в себе безопасность стековых переменных и гибкость переменных, создаваемых в "куче".
Теория, положенная в основу интеллектуальных указателей, достаточно проста:
это объект со связанным указателем. При выходе интеллектуального указателя из
области видимости соответствующий указатель удаляется. По сути, он заключает объект,
созданный в "куче", в оболочку стекового объекта.
Стандартная С++-библиотека шаблонов включает базовую реализацию
интеллектуального указателя, именуемую auto_jptr. Вместо хранения динамически создаваемых
объектов, с помощью указателей можно сохранять их в стековых экземплярах типа
auto_j?tr. При этом вам не нужно явным образом освобождать память, связанную
с аи1:о_з)Ъг-указателем, — она будет освобождена автоматически при его выходе из
области видимости.
В качестве примера рассмотрим следующую функцию, которая "нагло ворует"
память, выделяя в области "кучи" память для объекта типа Simple и даже "не думает" ее
освобождать.
void leaky()
{
Simple* mySimplePtr = new Simple(); // ОШИБКА! Память
// никогда не освобождается!
mySimplePtr->go();
}
При использовании класса auto_jptr этот объект тоже не будет удален явным
образом. Но при выходе аиЬо_зэЪг-объекта из области видимости (в конце метода) он
освободит память, занимаемую Simple-объектом, в своем деструкторе.
void notLeaky()
{
auto_ptr<Simple> mySimpleSmartPtr(new Simple());
mySimpleSmartPtr->go();
}
Одна из наиболее значительных характеристик интеллектуальных указателей
состоит в том, что они позволяют программисту извлечь огромную пользу, практически
не требуя от него освоения нового синтаксиса. Как было показано в предыдущем
коде, интеллектуальный указатель по-прежнему можно разыменовывать подобно
обычным указателям (с помощью операторов "*" или "->").
Практическая реализация шаблонного класса интеллектуального указателя на основе
перегрузки операторов продемонстрирована в главе 16. А в главе 25 описана
расширенная реализация интеллектуальных указателей, которая включает подсчет ссылок.
Глава 13. Эффективное управление памятью 431
Двойное удаление и использование некорректных указателей
После того как вы освободите память, связанную с некоторым указателем, с
помощью оператора delete, эта память становится доступной для других частей
программы. Однако вас ничего, по сути, не удерживает от попытки и дальше использовать
этот указатель. Двойное удаление также создает проблему. Если использовать
оператор delete во второй раз (для того же указателя), программа может освободить
память, которая (после первого удаления) была выделена другому объекту.
Двойное удаление и использование уже освобожденной памяти — проблемы,
которые трудно отследить, поскольку их симптомы могут проявиться не сразу. Если два
удаления происходят в пределах относительно короткого промежутка времени,
программа может работать бесконечно долго, поскольку память не так уж быстро задей-
ствуется вторично. Аналогично, если удаленный объект используется сразу же после
удаления, весьма вероятно, что он будет еще целым и невредимым.
Безусловно, нет никакой гарантии, что такое поведение будет означать полную
работоспособность программы. Распределитель памяти не обязан сохранять объект,
коль он уже удален. Даже если ваша программа с подобными "фокусами" и будет
работать, такой стиль программирования (использовать объекты после удаления) можно
определить как чрезвычайно скверный.
Многие программы обнаружения утечек памяти (подобно valgrind) способны
также выявить двойное удаление и использование освобожденных объектов.
Доступ к "заграничной" памяти
Выше в этой главе вы прочитали 6 том, что, поскольку указатель— это просто
адрес памяти, то можно получить указатель, который будет ссылаться на случайную
область в памяти. Такую "неуправляемую" ситуацию очень легко создать. Рассмотрим,
например, С-строку, которая каким-то образом "потеряла" свой завершающий нуль-
символ (' \0 '). Следующая функция, которая должна заполнить всю эту строку
символами ' m', будет продолжать заполнение символом ' m' содержимого памяти
компьютера и за "границами" этой строки.
void fillWithM(char* inStr)
{
int i = 0;
while (inStr[i] != '\0') {
inStr[i] = 'm' ;
i++,-
}
}
Если этой функции "подать" на обработку неправильно завершенную строку, то
через некоторое время соответствующая часть памяти будет перезаписана, и программу
постигнет аварийное завершение. А что произойдет, если в вашей программе память,
связанная с объектами, будет вдруг перезаписана символами ' m' ? Ничего хорошего!
Ошибки, в результате которых происходит перезапись памяти за конечной
границей массива, часто называют отгибками переполнения буфера. Такие "ошибки" зачастую
лежат в основе компьютерных вирусов и "червей" (программ, самостоятельно
распространяющих свои копии по сети). Хакер может использовать способность
перезаписи областей памяти, чтобы "впрыснуть" код в выполняющуюся программу.
432 Часть III. Освоение суперсредств C++
К счастью, многие средства контроля позволяют обнаружить и ошибки
переполнения буфера. Кроме того, использование программных конструкций более высокого
уровня (например, С++-строк и векторов) помогает предотвратить многочисленные
ошибки, характерные для С-строк и массивов.
Резюме
В этой главе вы узнали подробности использования динамической памяти: от
базового синтаксиса до низкоуровневых средств управления ею, а также о
существовании программ контроля за использованием памяти. Чтобы избежать проблем,
связанных с динамическим распределением памяти, следует (помимо внимательного
отношения к кодированию) учитывать следующие ключевые моменты. Во-первых,
нужно понимать, как работают указатели (как компилятор распределяет память). Во-
вторых, проблем с использованием динамической памяти можно избежать путем
"сокрытия" указателей с помощью стековых объектов (за счет использования С++-
класса string и интеллектуальных указателей).
Использование
С++-ПОТОКОВ
ввода-вывода
Основное назначение практически любой программы — принимать входные
данные и генерировать результат (выходные данные). Программа, которая не
генерирует результат (в каком бы то ни было виде), вряд ли будет полезной. Во всех языках
программирования предусмотрен механизм ввода-вывода данных либо в виде
встроенной части языка, либо с помощью специальных системных средств. Хорошая
система ввода-вывода должна одновременно быть гибкой и простой для применения.
Частично гибкость обеспечивается полиморфизмом: гибкие системы ввода-вывода
поддерживают выполнение операций через такие устройства, как файлы и
пользовательская консоль. Они также поддерживают чтение и запись различных типов
данных. Код ввода-вывода обычно характеризуется низким "иммунитетом" к ошибкам
разного рода, поскольку данные, приходящие от пользователя, могут быть
некорректными, либо базовая файловая система или другой источник данных могут
оказаться недоступными. Следовательно, хорошая система ввода-вывода также должна
обладать способностью обрабатывать сбойные ситуации.
Если вы знакомы с языком С, то вам, без сомнения, приходилось использовать
функции printf () и scanf (). Эти функции обеспечивают довольно гибкий механизм
434 Часть III. Освоение суперсредств C++
ввода-вывода данных. С помощью специальных кодов и элементов формата их можно
настраивать на считывание данных, отформатированных специальным образом, или на
вывод данных любого типа (от целочисленных значений до строковых). Однако
функции print f () и scanf () не могли удовлетворить многим другим требованиям, часто
предъявляемым к системам ввода-вывода. Они недостаточно хорошо обрабатывают
ошибки, их трудно применить к обработке типов данных, определенных
пользователем, и, что хуже всего, они не являются объектно-ориентированными средствами!
В C++ заложен улучшенный метод ввода-вывода данных, который реализован через
механизм потоков. Потоки позволяют применить к операциям ввода-вывода гибкий
и объектнсюриентированный подход. В этой главе вы узнаете, как использовать потоки
для ввода и вывода данных, а также как применить механизм потоков к считыванию
данных из различных источников и к записи на разные приемники информации (например,
пользовательскую консоль, файлы и даже строки). Здесь вы найдете описание самых
употребительных средств ввода-вывода. В этой главе также нашла отражение такая важная
тема, как написание программ с возможностью адаптации к различным регионам мира.
Откуда взялись эти потоки
К понятию "потоки" новичкам приходится некоторое время привыкать. Поначалу
потоки кажутся чем-то более сложным, чем такие традиционные С-средства ввода-
вывода, как функции printf () и scanf (). В действительности эта кажущаяся
трудность объясняется лишь новизной. Не стоит волноваться раньше времени: не так
страшен поток... . Рассмотрим пару примеров — и вы больше никогда не захотите
возвращаться к функциям printf () и scanf ().
Что такое поток
Как упоминалось в главе 1, поток cout сравнивается с "путепроводом" для данных.
Вы "бросаете" несколько переменных "вниз по течению", и они записываются на экран
пользователя, или консоль. Другими словами, все потоки можно рассматривать как
"путепроводы" для данных. Потоки различаются направлением и связью с источником
или приемником. Например, поток cout предназначен для вывода данных, поэтому его
направление можно описать словами "на выход". Он записывает данные на консоль,
поэтому соответствующим ему приемником является консоль. Существует еще один
стандартный поток, именуемый с in, который принимает входные данные от
пользователя. Его направление работы можно охарактеризовать словом "внутрь", и
соответствующим ему источником является консоль. Потоки cout и с in— это встроенные
экземпляры потоковых классов, которые определены в С++-пространстве имен std.
Каждый входной поток имеет соответствующий источник данных.
Каждый выходной поток имеет соответствующий приемник данных.
Входные и выходные потоки
Потоки как понятие могут быть применены к любому объекту, который принимает
данные или порождает их. С их помощью можно было бы написать потоковый класс
подключения к сети или реализовать потоковый доступ к MIDI-устройству (Musical
Instrument Digital Interface— цифровой интерфейс музыкальных инструментов).
В C++ существует три распространенных источника и приемника данных для потоков.
Глава 14. Использование С++-потоков ввода-вывода 435
Мы уже привели ряд примеров использования потоков с консолью. Связанные
с консолью входные потоки делают программы интерактивными, разрешая
пользователю вводить данные во время работы программы. Связанные с консолью выходные
потоки обеспечивают обратную связь с пользователем и вывод результатов.
Файловые потоки, как нетрудно догадаться, считывают данные из файловой
системы и записывают данные в нее. Файловые входные потоки используются,
например, для считывания информации в файлы конфигуарации или для пакетной
обработки данных на базе файлов. Файловые выходные потоки используются для
сохранения состояния системы и обеспечения вывода результатов.
Строковые потоки можно определить как приложение метафоры потока к типу
string. С помощью строковых потоков символьные данные можно обрабатывать
подобно любым другим потокам. Обычно под этим понимается просто удобный
синтаксис для обработки данных методами класса string. Вместе с тем использование
потокового синтаксиса предоставляет возможности для оптимизации, что более удобно,
чем непосредственное использование класса string.
В остальной части этого раздела рассматриваются потоки, связанные с консолью
(с in и с out). Примеры файловых и строковых потоков приводятся ниже в этой
главе. Другие типы потоков, используемые, например, при выводе данных на принтер
или сетевом обмене информацией, предоставляются операционной системой и не
встроены в язык программирования.
Вывод данных с помощью потоков
Выводом данных с помощью потоков мы пользуемся практически во всех главах
этой книги, начиная с первой. В данном разделе мы кратко повторим основы этих
программных средств и рассмотрим более сложный материал.
Основные понятия
Выходные потоки определены в заголовочном файле <ostream>. Большинство
программистов включают в свои программы заголовок <iostream>, который в свою
очередь включает заголовки как для входных, так и для выходных потоков. В этом
заголовке также объявлен стандартный поток вывода данных на консоль cout.
Выходные потоки проще всего использовать с помощью оператора "<<". Он без
проблем позволяет выводить данные таких базовых типов данных, как int, double,
а также указатели и символы. Этот оператор также совместим с С++-классом string,
да и с выводом С-строк он справляется должным образом. Приведем некоторые
примеры использования оператора "<<" (с соответствующими результатами).
int i = 7;
COUt < < i;
7
char ch = 'a\';
cout < < ch;
a
string myString = "Жан такой милый!";
cout << myString;
Жан такой милый!
436 Часть III. Освоение суперсредств C++
Поток cout представляет собой встроенное средство записи данных на консоль
(его называют стандартным выходным потоком). Вспомните: для вывода нескольких
данных можно образовывать "цепочку" операторов "<<". Такая "цепочка" работает
благодаря тому, что оператор "<<" возвращает в качестве результата поток, который
можно снова использовать по назначению. Вот пример.
int i = 11;
cout << "В списке самых интересных ребят Жан занимает "
<< i << " место!";
В списке самых интересных ребят Жан занимает 11 место!
С++-потоки корректно анализируют управляющие С-коды, например, строки,
которые содержат код \п, однако для этой цели лучше использовать endl-механизм.
Применение символа конца строки endl, который определен в пространстве имен
std, показано в следующем примере (при выполнении этой команды также
сбрасывается на диск содержимое файловых буферов). Как видите, несколько строк текста
можно вывести, используя лишь одну строку кода.
cout << "Строка 1" << endl << "Строка 2" << endl
<< "Строка 3" << endl;
Строка 1
Строка 2
Строка 3
Методы выходных потоков
Оператор "<<" — самая полезная часть выходных потоков, но можно использовать
и другие средства. В заголовочном файле <ostream> есть множество строк с
перегруженными определениями оператора "<<", а также ряд полезных public-методов.
Методы put () и write ()
Методы put () и write () работают на символьном уровне. Они принимают не
объект и не переменную с обусловленным заранее поведением, а символ или
символьный массив соответственно. Данные, переданные этим методам, выводятся как
есть, без какого-либо специального форматирования или обработки. Такие
управляющие символы, как \п, выводятся в соответствующей форме (т.е. они обеспечивают
возврат каретки в исходное положение), но никакого полиморфного эффекта при этом
не наблюдается. Следующая функция принимает С-строку и выводит ее на консоль без
помощи оператора "<<".
void rawWrite(const char* data, int dataSize)
{
cout.write(data, dataSize);
}.
Следующая функция с помощью метода put () выводит на консоль значение,
соответствующее принятому индексу С-строки.
void rawPutChar(const char* data, int charlndex)
{
cout.put(data[charlndex]);
}
Глава 14. Использование С++-потоков ввода-вывода 437
Метод flush ()
При выводе данных в выходной поток их запись в приемник необязательно
происходит тотчас же. В большинстве случаев выходные потоки сначала буферизуют,
т.е. накапливают, данные. Вывод накопленных данных из буфера происходит при
возникновении одного из следующих условий.
□ Достигнута сигнальная метка (например, endl — признак конца строки).
□ Поток вышел из области видимости и будет разрушен.
□ Поступил запрос на ввод данных из соответствующего входного потока (т.е. при
использовании потока с in для ввода поток с out должен вывести содержимое
буфера). Подробности приведены в разделе, посвященном файловым потокам.
□ Буфер потока заполнился.
□ Программист явно дал команду освободить содержимое буфера.
Как показано в следующем коде, одним из способов явного указания на
освобождение буфера является вызов метода flush ().
cout << "abc";
cout. flush () ,- // Текст "abc" немедленно выводится на консоль .
cout << "def";
cout << endl; // Текст "def" немедленно выводится на консоль.
Не все выходные потоки буферизованы. Например, поток с err не
буферизует выводимые данные.
Обработка ошибок при выводе данных
Ошибки при выводе данных могут возникать в различных ситуациях. Возможно,
вы попытались записать данные в файл, который не существует или имеет атрибут
"только для чтения". Вероятно, выполнить операцию записи помешала ошибка
работы с диском, либо консоль каким-то образом была переведена в заблокированное
состояние. Короче говоря, до тех пор, пока не будет устранена подобная ситуация, вы
не сможете прочитать или записать данные в поток. Однако в профессиональных
С++-программах необходимо предусмотреть все возможные ошибочные ситуации.
Если поток находится в нормальном рабочем состоянии, о нем говорят, что он
исправный (по англ. — "good"). Чтобы определить, в каком состоянии (исправном или
нет) пребывает поток в данный момент, для него можно вызвать метод good ().
if (cout.good()) {
cout << "Все нормально!" << endl;
}
Метод good () позволяет простым способом получить базовую информацию о
работоспособности потока, но не сообщает причину его непригодности. С помощью метода
bad () можно узнать чуть больше. Если метод bad () возвращает значение true, это
говорит о том, что встретилась неисправимая ошибка (в противоположность любому
"нефатальному" варианту, например, обнаружению конца файла). А метод fail ()
возвращает значение true в случае, если большая часть недавно выполненных операций
оказалось неудачной, т.е. можно предположить, что следующая операция также не
увенчается успехом. Например, чтобы удостовериться в работоспособности выходного
потока после вызова метода flush (), можно обратиться к методу f ail ().
438 Часть III. Освоение суперсредств C++
cout .f lush () ;
if (cout.fail()) {
cerr << "He удается освободить буфер выходного потока."
<< endl;
}
Чтобы привести к исходному состояние ошибки для потока, используйте
метод clear().
cout.clear() ;
Контроль ошибок при выводе данных на консоль выполняется реже, чем для
файловых потоков ввода-вывода. Рассмотренные здесь методы применяются и для других
видов потоков и более конкретно описываются в соответствующих разделах.
Манипуляторы вывода данных
Одно из замечательных свойств потоков состоит в том, что через потоковый
"путепровод" можно передавать не только данные. С++-потоки способны также
распознавать манипуляторы, т.е. объекты, которые заставляют несколько изменить
поведение потока.
Вы уже видели один из манипуляторов: endl. Манипулятор endl инкапсулирует
данные и поведение. Он предписывает потоку выполнить возврат каретки и вывести
все содержимое буфера. Ниже описаны некоторые другие манипуляторы (они
определены в стандартных заголовочных файлах <ios> и <iomanip>).
□ Манипуляторы boolalpha и noboolalpha. Предписывают потоку выводить
bool-значения в виде true и false (boolalpha) или 1 и 0 (noboolalpha). По
умолчанию действует манипулятор noboolalpha.
□ Манипуляторы hex, oct и dec. Выводят числа в шестнадцатеричной,
восьмеричной и десятичной системах счисления соответственно.
□ Манипулятор setprecision. Устанавливает количество десятичных разрядов
при выводе дробных чисел. Это параметризованный манипулятор (т.е. он
принимает аргумент).
□ Манипулятор setw. Устанавливает ширину поля для выводимых числовых
данных. Это параметризованный манипулятор.
□ Манипулятор set fill. Задает символ, который используется для
дополнения чисел, ширина поля которых меньше заданной. Это параметризованный
манипулятор.
□ Манипуляторы showpoint и noshowpoint. Предписывают потоку всегда (или
никогда не) показывать десятичную точку (точка в десятичной дроби,
отделяющая целое от дроби) для float- и double-значений без дробной части.
Использование некоторых из описанных манипуляторов демонстрируется в
следующей программе.
#include <iostream>
#include <iomanip>
using namespace std;
int main(int argc, char** argv)
{
1
Глава 14. Использование С++-потоков ввода-вывода 439
bool myBool = true,-
cout << "Здесь должно быть значение true: " << boolalpha
<< myBool << endl;
cout << "Здесь должно быть значение 1: " << noboolalpha
<< myBool << endl;
double dbl = 1.452;
cout << "Здесь должно быть значение @@1.452: " << setw(7)
<< setfilK1®1) << dbl « endl;
}
Можно обходиться и без манипуляторов. Эквивалентные функции можно
реализовать с помощью соответствующих методов (например, set Precis ion ()).
Подробности можно узнать из приложения Б.
Ввод данных с использованием потоков
Входные потоки обеспечивают простой способ считывания структурированных
и неструктурированных данных. В этом разделе методы ввода рассматриваются в
контексте использования стандартного потока ввода данных с консоли с in.
Основные понятия
С помощью входных потоков данные можно считывать двумя способами. Первый
аналогичен оператору "<<", который выводит данные в выходной поток.
Соответствующим средством для считывания данных является оператор ">>". При
использовании оператора ">>" переменная, которая участвует в операции считывания данных
из входного потока, служит для сохранения принятого значения. Например,
следующая программа считывает строку входных данных, предоставляемых пользователем,
и помещает ее в строку. Затем эта строка выводится на консоль.
#include <iostream>
#include <string>
using namespace std;
int main(int argc, char** argv)
{
string userlnput,-
cin >> userlnput;
cout << "Пользователь ввел значение " << userlnput << endl;
}
По умолчанию входной поток "помечает" значения в соответствии с расположением
пробельных символов (пробелы, символы табуляции и пустой строки). Например, если
пользователь запустит предыдущую программу на выполнение и введет строку "Привет
всем", в переменной userlnput запомнятся только символы, расположенные до
первого пробела (в данном случае). Результат выполнения этого кода выглядит так.
Пользователь ввел значение Привет
Оператор ">>", как и оператор "<<", работает с переменными различных типов.
Например, для считывания целочисленного значения в предыдущем коде нужно
изменить только тип переменной, участвующей в операции приема данных.
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
440 Часть III. Освоение суперсредств C++
int userInput;
cin >> userlnput;
cout << "Пользователь ввел значение " << userlnput << endl;
}
Входные потоки можно использовать для считывания сразу нескольких значений,
причем смешанного типа. Например, следующая функция, которая представляет
собой фрагмент системы резервирования мест в ресторане, предлагает пользователю
указать фамилию и количество приглашенных на ужин.
void getReservationDataO
{
string guestName;
int partySize;
cout << "Фамилия и количество гостей: ";
cin >> guestName >> partySize,-
cout << "Спасибо, " << guestName << "." << endl;
if (partySize > 10) {
cout << "Вам полагается скидка." << endl;
}
}
Обратите внимание на следующее. Несмотря на то что использование входного
потока cout не сопровождалось явным предписанием освободить буфер (с помощью
манипулятора endl или метода flush () ), текст тем не менее сразу был записан на
консоль, поскольку обращение к потоку cin немедленно заставляет извлечь всю
информацию из cout-буфера и вывести ее на консоль. В этом смысле потоки cin и cout
тесно связаны между собой.
Если вы путаетесь между операторами "<< " и ">> " представьте, что угловые скобки
указывают направление "пункта" назначения. В выходном потоке оператор "<<
"указывает в направлении самого потока, поскольку данные посылаются в него. А во
входном потоке оператор "> > "указывает в направлении переменной, в которой эти данные
запоминаются.
Методы входных потоков
Как и выходные, входные потоки имеют в своем арсенале ряд методов, которые
позволяют получить доступ более низкого уровня, чем с помощью общего оператора " >>".
Метод get ()
С помощью метода get () можно вводить неформатированные (программисты
говорят "сырые") данные. Самая простая версия метода get () возвращает в поток
следующий символ, в то время как другие позволяют считывать несколько символов
одновременно. Метод get () чаще всего используют, чтобы избежать автоматической
"фрагментации", которая имеет место при использовании оператора ">>".
Например, следующая функция считывает из входного потока имя, которое может состоять
из нескольких слов, до тех пор, пока не будет достигнут конец потока.
string readName (istreamSc inStream)
{
string name;
while (inStream.good()) {
int next = inStream.get{);
Глава 14. Использование С++-потоков ввода-вывода 441
if (next == EOF) break;
name += next; // Неявное преобразование в тип char и
// присоединение к концу.
}
return name;
}
Рассмотрим функцию readName () подробнее. Во-первых, ее параметр является
ссылкой на объект класса istream, а не const-ссылкой. Методы, которые считывают
данные из потока, изменяют реальный поток (в частности его позиционную
характеристику), поэтому они не могут быть const-методами. Следовательно, их нельзя
вызывать для const-ссылки. Во-вторых, значение, возвращаемое методом get (),
сохраняется не в char-, а в int-переменной. Поскольку метод get () может возвращать
такие специальные несимвольные значения, как EOF (конец файла), для
возвращаемого значения используется тип int. При присоединении значения переменной
next к концу строки эта int-переменная неявно преобразуется в значение типа char.
Особенность предыдущей функции состоит в реализации двоякой возможности
выйти из цикла: либо поток перейдет в "неисправное" состояние, либо будет
достигнут конец потока. Чаще для считывания данных из потока используется другая версия
метода get (), которая принимает ссылку на символ и возвращает ссылку на поток.
Эта версия использует тот факт, что входной поток в рамках условного контекста
будет оценен с результатом true только в том случае, если этот поток доступен для
дальнейшего считывания. При возникновении ошибки или достижении конца файла
поток будет оценен с результатом false. Подробности операций преобразования,
необходимых для реализации этого средства, разъясняются в главе 16. Следующий
вариант той же функции выглядит более лаконично.
string readName (istreamSc inStream)
{
string name;
char next; while (inStream.get(next)) {
name += next;
}
return name;
}
Метод unget ()
В большинстве случаев входной поток можно представлять в виде
однонаправленного путепровода, через который данные "прямиком" попадают в переменные. Эту модель
несколько "портит" метод unget (), позволяя возвращать данные назад в путепровод.
Один вызов метода unget () заставляет поток "вернуться" на одну позицию, по
сути, "задвигая" символ, который был считан последним, назад в поток.
char chl, ch2, ch3;
in >> chl >> ch2 >> ch3;
in.unget{);
char testChar;
in >> testChar;
// Здесь testChar == ch3.
442 Часть III. Освоение суперсредств C++
Метод putback ()
Метод putback (), подобно методу unget (), позволяет "прокрутить" входной
поток назад на один символ. Различие между ними состоит в том, что метод putback ()
принимает возвращаемый назад символ в качестве параметра.
char chl;
in >> chl;
in.putback(chl);
// Символ chl теперь станет следующим, который будет
// считан из потока.
Метод peek ()
Метод peek () позволяет предварительно "просмотреть" следующий символ,
который будет считан из потока, если вызвать метод get ().
Метод peek () идеально подходит для ситуации, когда перед считыванием
значения желательно его узнать. Например, ваша программа может выполнять различные
действия в зависимости от того, значение какого типа (число или текст) будет
считано следующим.
int next = cin.peekO;
if (isdigit(next)) {
processNumber();
} else {
processText();
}
Метод get line ()
Большой популярностью пользуется метод получения из входного потока одной
строки данных. Этот метод (getline () ), как показано в следующем коде, заполняет
символьный буфер строкой данных, не превышая заданного размера.
char buffer[kBufferSize + 1];
cin.getline(buffer, kBufferSize);
Обратите внимание на то, что метод get line () удаляет из потока символ новой
строки, который не включается в результирующую строку. Существует еще одна
форма метода get (), которая выполняет ту же операцию, что и метод get line (), за
исключением того, что символ новой строки остается в этом случае во входном потоке.
Существует также функция с именем getlineO, которую можно использовать
с С++-строками. Она определена в пространстве имен std и принимает в качестве
параметров ссылку на поток, ссылку на объект типа string и (как необязательный
параметр) разделитель.
string myString;
std::getline(cin, myString);
Обработка ошибок при вводе данных
Входные потоки имеют ряд методов, которые позволяют обнаружить
экстраординарные обстоятельства. Большинство сбойных ситуаций при использовании входных
потоков связано с отсутствием данных, доступных для считывания. Например, может
Глава 14. Использование С++-потоков ввода-вывода 443
быть достигнут конец потока (который можно интерпретировать как конец файла
даже для нефайловых потоков). Чаще всего состояние входного потока анализируется
в контексте некоторого условного выражения. Можно также вызвать метод good (), как
мы делали это для выходных потоков, или метод eof (), который при достижении
конца потока возвращает значение true.
Следует всегда проверять состояние потока после считывания данных, чтобы
можно было восстановить работоспособность потока после потенциально
некорректной входной информации.
На примере следующей программы демонстрируется образец считывания данных
из потока и обработки ошибок. Программа считывает числа из стандартного
входного потока и сразу же по достижении конца файла отображает их сумму. Обратите
внимание на то, что в большинстве сред (в режиме приема командной строки) конец
файла обозначается на консоли как control-D.
#include <iostream>
#include <fstream>
#include <string^
using namespace std;
int mainO
{
int sum = 0;
if (!cin.good() ) {
cout << "Стандартный входной поток в неисправном
'Ъ состоянии!" << endl;
exit(l);
}
int number;
while (true) {
cin >> number;
if (cin.good()) {
sum += number,-
} else if (cin.eofO) {
break; // Достигнут конец файла.
} else {
// Ошибка!
cin.clear(); // Очищаем признак состояния ошибки,
string badToken;
cin >> badToken; // Принимает некорректные данные.
cerr « "ПРЕДУПРЕЖДЕНИЕ: Приняты некорректные
Жданные: " << badToken << endl;
cout << "Сумма равна " << sum << endl;
return 0;
}
Манипуляторы ввода данных
Следующий список содержит встроенные манипуляторы ввода данных, которые
можно посылать во входной поток для "настройки" характера считывания данных.
□ Манипуляторы boolalpha и noboolalpha. Если используется манипулятор
boolalpha, строка true интерпретируется как булево значение true, а строка
444 Часть III. Освоение суперсредств C++
false— как булево значение false. Если же установлен манипулятор по-
boolalpha, эта интепретация не работает. По умолчанию действует
манипулятор noboolalpha.
□ Манипуляторы hex, oct и dec. Считываются числа в шестнадцатеричной,
восьмеричной и десятичной системах счисления соответственно.
□ Манипуляторы skipws и noskipws. Предписывают либо опускать пробельные
символы в потоке, либо считывать их как отдельные лексемы соответственно.
□ Манипулятор ws. Удобный манипулятор, который в текущей позиции потока
просто опускает последовательность пробельных символов.
Ввод и вывод объектов
Как было показано выше, оператор "<<" можно использовать для вывода С++-
строк, хотя string и не является базовым типом. В C++ объекты могут предписывать,
как их нужно выводить и вводить. Это реализуется путем перегрузки оператора "<<"
при создании нового класса.
Зачем нужно перегружать оператор "<<"? Если вы знакомы с функцией printf ()
в языке С, то вам известно, что она не отличается гибкостью по этой части. Функция
printf () способна выводить данные нескольких типов, но ее невозможно нагрузить
дополнительными знаниями. Например, рассмотрим следующий простой класс.
class Muffin
{
public:
string getDescription () const ,-
void setDescription(const string& inDescription);
int getSize() const;
void setSize(int inSize);
bool getHasChocolateChips() const;
void setHasChocolateChips(bool inChips);
protected:
string mDescription;
int mSize;
bool mHasChocolateChips;
};
string Muffin::getDescription() const { return mDescription; }
void Muffin::setDescription(const strings inDescription)
{
mDescription = inDescription;
}
int Muffin::getSize() const { return mSize; }
void Muffin::setSize(int inSize) { mSize = inSize; }
bool Muffin::getHasChocolateChips() const
{
return mHasChocolateChips;
}
void Muffin::setHasChocolateChips(bool inChips)
{
mHasChocolateChips = inChips;
}
Глава 14. Использование С++-потоков ввода-вывода 445
Как было бы здорово, если бы для того, чтобы вывести объект класса Muffin с
помощью функции print f (), мы могли просто указать его как аргумент, используя
специальное обозначение %т!
printf("Выводим объект класса Muffin: %m\n", myMuffin); //
// ОШИБКА! Функция printf() не "знает" тип Muffin.
К сожалению, функция printf () ничего не знает о классе Muffin и поэтому не
сможет вывести объект этого типа. Хуже того, благодаря способу объявления
функции printf () на эту строку кода будет реакция лишь во время выполнения
программы, а не во время компиляции (хотя нормальные компиляторы все же обозначат свое
"недоумение" предупреждающим сообщением).
Если вы никак не можете обойтись без функции printf (), попробуйте добавить
в класс Muffin новый метод output ().
class Muffin
{
public:
String getDescription() const;
void setDescription(const string& inDescription);
int getSizeO const;
void setSize(int inSize);
bool getHasChocolateChips{) const;
void setHasChocolateChips(bool inChips);
void output();
protected:
string mDescription;
int mSize;
bool mHasChocolateChips,-
};
string Muffin::getDescription{) const { return mDescription; }
void Muffin::setDescription(const string& inDescription)
{
inDescription = inDescription;
}
int Muf f in: :getSize () const { return mSize,- }
void Muffin::setSize(int inSize) { mSize = inSize; }
bool Muffin::getHasChocolateChips() const
{
return mHasChocolateChips;
}
void Muffin::setHasChocolateChips(bool inChips)
{
mHasChocolateChips = inChips;
}
void Muffin::output()
{
printf("%s, размером %d, %s\n", getDescription().c_str(),
getSizeO, (getHasChocolateChips () ? "с чипсами"
: "нет"));
}
Однако использование такого громоздкого механизма вывода данных просто
наводит тоску. Чтобы вывести объект типа Muffin в середине какой-нибудь строки
текста, вам пришлось бы, как показано ниже, разбить эту строку на две, а между ними
вставить обращение к методу Muffin: : output ().
446 Часть III. Освоение суперсредств C++
printf ("Этот маффин "),-
myMuffin.output();
printf(" -- такой аппетитный!\п");
Перегрузка же оператора "<<" позволит выводить объекты класса Muffin так же,
как выводится любая строка: для этого достаточно передать ее как аргумент
оператора "<<". Детали перегрузки операторов "<<" и ">>" приведены в главе 16.
Строковые потоки
Строковые потоки предполагают применение потоковой семантики к объектам типа
string. В этом случае мы имеем дело с потоками, которые представляют текстовые
данные. Такой подход может быть полезен в приложениях, в которых несколько
потоков вносят данные в одну и ту же строку, или в ситуации, когда необходимо передать
строку различным функциям, сохраняя при считывании текущую позицию. Строковые
потоки также полезны для анализа текста, поскольку они характеризуются встроенной
способностью к разметке текста, т.е. к восприятию его в виде отдельных лексем.
Классы ostringstream и istringstream используются для записи данных в string-
объект и считывания из него. Эти классы определены в заголовочном файле <sstreara>.
Поскольку классы ostringstream и istringstream наследуют такое же поведение,
как и классы ostream и istream, работа с ними не вызывает особых трудностей.
Следующая простая программа запрашивает у пользователя слова и выводит их в
объект типа ostringstream, разделяя их символами табуляции. В конце программы весь
поток превращается в string-объект с помощью метода str () и выводится на консоль.
#include <iostream>
#include <sstream>
using namespace std;
int main(int argc, char** argv)
{
ostringstream outstream;
while (cin.goodO) {
string nextToken;
cout << "Следующая лексема: ";
cin >> nextToken,-
if (nextToken == "выполнено") break;
outstream << nextToken << "\t";
}
cout << "Конечный результат равен: " << outstream.str()
<< endl;
}
В чтении данных из строкового потока также нет ничего нового. Следующая
функция создает объект класса Muffin (см. примеры выше в этой главе) и "наполняет"
его содержимым строкового потока входных данных. Потоковые данные
представлены в фиксированном формате, поэтому функция может легко превращать эти
значения в вызовы методов установки членов класса Muffin.
Маффин — оладья из пористого дрожжевого теста; подается горячей с маслом. — Примеч. ред.
Глава 14. Использование С++-потоков ввода-вывода 447
Muffin createMuffin(istringstream& inStream)
{
Muffin muffin;
// Предполагаем, что данные сформатированы таким, образом:
// "Описание" "размер" "чипсы"
string description;
int size;
bool hasChips;
// Считываем все три значения. Обратите внимание на то, что
// член "чипсы" представлен строковыми значениями "true" и
// "false".
inStream >> description » size >> boolalpha >> hasChips;
muffin.setSize(size);
muffin.setDescription(description);
muffin.setHasChocolateChips(hasChips);
return muffin;
}
Превращение объекта в такой "плоский" тип, как string, часто используется при
сохранении объектов на диске или пересылке их по сети (см. также главу 24).
Основное преимущество строкового потока перед стандартным С++-объектом
класса string состоит в том, что, помимо данных, поток "знает" свою текущую
позицию. Конкретная реализация строковых потоков также может давать выигрыш в
производительности.
Файловые потоки
Файлы очень хорошо подходят под абстракцию потоков, поскольку считывание
и запись файлов всегда включает (помимо данных) понятие текущей позиции.
Операции ввода-вывода для файлов в C++ позволяют выполнять классы of stream и if stream.
Они определены в заголовочном файле <f stream>.
Работая с файловой системой, особенно важно уметь обнаруживать и
обрабатывать ошибочные ситуации. Файл, с которым вы работаете, может являться частью
сетевой файловой системы, с которой в данный момент может быть разорвана связь.
Вы можете попытаться выполнить запись в файл, на редактирование которого
текущий пользователь не дает разрешение. Эти условия могут быть выявлены с помощью
стандартных механизмов обработки ошибок, описанных выше.
Единственное различие между файловыми и другими выходными потоками состоит
в том, что конструктор файлового потока принимает в качестве параметров имя файла
и режим, в котором вы хотите его открыть. По умолчанию устанавливается режим
записи, который обеспечивает запись данных с самого начала файла и предусматривает
перезапись (стирание) существующих данных. Открыть выходной файловый поток можно
также в режиме дозаписи (для этого используется константа iosbase: : арр).
В следующей простой программе открывается файл test и в него записываются
аргументы, с которыми вызывается эта программа.
#include <iostream>
#include <fstream>
using namespace std;
448 Часть III. Освоение суперсредств C++
int main(int argc, char** argv)
{
ofstream outFile("test");
if (!outFile.good()) {
cerr << "Ошибка при открытии выходного файла!" << endl;
return -1;
}
outFile << "Программа была вызвана с " << argc
<< " аргументами." << endl;
outFile << "Вот они: " << endl;
for (int i = О; i < argc; i++) {
outFile << argv[i] << endl;
}
}
Использование методов seek О и tell ()
Методы seek () и tell () являются составной частью всех входных и выходных
потоков, но они редко имеют смысл вне контекста файловых потоков.
Метод seek () позволяет перейти на произвольно заданную позицию входного или
выходного потока. Такое действие нарушает стройность потоковой модели, поэтому эти
методы лучше использовать "с оглядкой". Существует несколько версий метода seek ().
Во входном потоке они носят имя seekg () (буква g— от слова "get", т.е. получать,
считывать), а в выходном — имя seekp () (буква р— от слова "put", т.е. помещать,
записывать). В потоке каждого типа реализовано два способа поиска. Можно загсазать поиск
абсолютной позиции в потоке, например, начальной или 17-й, либо поиск по
смещению, например, 3-й позиции от текущей отметки. Позиции "измеряются" в символах.
Чтобы отыскать абсолютную позицию в выходном потоке, можно использовать
версию метода seekp () с одним параметром, которая, как показано ниже, для перемещения
в начало потока использует константу ios_base: :beg. Существуют также константы,
означающие конец потока (ios base: : end) и его текущую позицию (iosbase: : cur).
outstream.seekp(iosbase::beg);
Поиск заданной позиции во входном потоке выполняется с использованием тех же
констант.
inStream.seekg(iosbase::beg);
Версии метода seek () с двумя параметрами используются для перехода на
"относительную" позицию потока. Первый аргумент означает количество позиций
для перемещения, а второй— стартовую точку. Для перемещения относительно
начала файла используется константа ios_base: :beg, а относительно конца—
константа ios_base: : end. Для перемещения относительно текущей позиции нужно
выбрать константу ios_base: : cur. Например, при выполнении следующей строки
кода маркер будет перемещен на второй символ от начала потока.
outstream.seekp(2, iosbase::beg);
В следующем примере задано перемещение на третью с конца позицию входного
потока.
inStream. seekg (- 3 , ios_base : : end) ,-
Глава 14. Использование С++-потоков ввода-вывода 449
С помощью метода tell () можно узнать текущую позицию потока. Этот метод
возвращает значение типа ios_base: :pos_type, которое можно запомнить, а позже,
использовать при обращении к методу seek () или при уточнении нужной позиции.
Как и для метода seek (), существует несколько отдельных версий метода tell () для
входных и выходных потоков. Входные потоки используют метод tellg (), а
выходные — метод tellp ().
При выполнении следующего кода выполняется сравнение текущей позиции
входного потока с позицией, соответствующей его началу.
ios_base::pos_type curPos = inStream.tellg{);
if (curPos == ios_base::beg) {
cout << "Мы находимся в начальной позиции." << endl;
}
Ниже приводится программа, в которой используются многие средства,
предоставляемые файловыми входными и выходными потоками. Программа работает с
файлом test. out, выполняя следующие действия.
1. Записывает в файл строку 12345.
2. Проверяет, установлен ли маркер выходного потока в позиции 5.
3. Перемещает маркер в позицию 2.
4. Записывает значение 0 в позицию 2, выводит на консоль содержимое
выходного потока.
5. Открывает входной поток для файла test, out.
6. Считывает первую лексему как целочисленное значение.
7. Убеждается, что считанное значение равно числу 12045.
#include <iostream>
#include <fstream>
using namespace std;
int main(int argc, char** argv)
{
ofstream fout("test.out");
if (Ifout) {
cerr << "Ошибка при открытии файла test.out для
*b записи. \п"; ч
exit(l);
}
// 1. Выводим строку "12345".
fout « "12345";
// 2. Проверяем, установлен ли маркер в конце выходного
// потока.
iosbase::pos_type curPos = fout.tellp(); if (curPos == 5){
cout << "Тест пройден: мы находимся в позиции 5."
<< endl;
} else {
cout << "Тест не пройден: мы не находимся в позиции 5."
< < endl,-
}
// 3. Переходим в позицию 2.
450 Часть III. Освоение суперсредств С++
fout.seekp(2, ios_base::beg);
// 4. Выводим О в позицию 2 и освобождаем потоковый буфер.
fout « 0;
fout.flush();
// 5. Открываем входной поток для файла test.out.
ifstream fin("test.out");
if (!fin) {
cerr << "Ошибка при открытии файла test.out для
Ч> чтения.\n";
exit(1);
}
// 6. Считывание первой лексемы как целочисленного
// значения.
int testVal;
fin >> testVal;
// 7. Убеждаемся, что считанное значение равно числу 12045.
if (testVal == 12045) {
cout « "Тест пройден: значение равно 12045." << endl;
} else {
cout « "Тест не пройден:: значение не равно 12045.";
Связывание потоков
Между любыми входными и выходными потоками можно установить связь, которая
позволит реализовать им следующее поведение: при запросе на получение данных из
входного потока связанный с ним выходной поток будет автоматически выводить
содержимое своего буфера. Такое поведение доступно для всех потоков, но особенно
эффективно оно применяется для файловых потоков, которые могут зависеть друг от друга.
Связывание потоков реализуется с помощью метода tie (). Чтобы связать
выходной поток со входным, вызовите метод tie () для входного потока и передайте ему
адрес выходного потока. Чтобы разорвать установленную ранее связь, достаточно
передать этому методу значение NULL.
При выполнении следующей программы входной поток на основе одного файла
связывается с выходным потоком, определенным на основе совершенно другого
файла. Можно было бы также установить связь с выходным потоком на основе того же
самого файла, но для этого (т.е. для одновременного чтения и записи одного и того
же файла) предусмотрен более элегантный способ, который состоит в использовании
двунаправленных потоков ввода-вывода (см. следующий раздел).
#iriclude <iostream>
#include <fstream>
#include <string>
using namespace std;
int main(int argc, char** argv)
{
ifstream inFile("input.txt");
ofstream outFile("output.txt");
// Устанавливаем связь между потоками inFile и outFile.
inFile.tie(&outFile);
Глава 14. Использование С++-потоков ввода-вывода 451
// Выводим текст в поток outFile. Обычно это не означает
// немедленный вывод, поскольку не был послан манипулятор
// Std::endl.
outFile « "Привет всем!";
// Поток outFile HE вывел содержимое своего буфера.
// Считываем текст из потока inFile. Это послужит причиной
// вызова метода flush() для потока outFile.
string nextToken;
inFile » nextToken;
// Теперь поток outFile выведет содержимое своего буфера.
}
Метод flush () определен в базовом классе ostream, поэтому любой выходной
поток можно связать с любым другим выходным потоком.
outFile.tie(&anotherOutputFile);
Такие отношения будут означать, что при выполнении каждой записи в один файл
буферизованные данные, которые были посланы в другой файл, будут выведены из
буфера. Этот механизм можно успешно использовать для синхронизации двух
связанных файлов.
Двунаправленные потоки ввода-вывода
До сих пор мы рассматривали входные и выходные потоки как два отдельных,
хотя и связанных класса. Но существует поток, который может выполнять как операции
ввода, так и операции вывода. Речь идет о двунаправленном потоке, который может
действовать как входной, так и как выходной поток.
Двунаправленные потоки — это объекты класса iostream, который "произошел"
от классов istream и ostream, — наглядный пример эффективного использования
множественного наследования. Как и следовало ожидать, двунаправленные
потоки поддерживают как оператор ">>", так и оператор "<<", а также методы входных
и выходных потоков.
Класс f stream реализует двунаправленный файловый поток. Он идеально подходит
для приложений, которые выполняют замену данных в файле, поскольку можно
организовать считывание данных до тех пор, пока не будет найдена нужная позиция, а затем
тут же переключиться на запись. Например, представьте программу, которая сохраняет
список парных значений: идентификационных номеров (ГО) и телефонных номеров.
В этом случае можно было бы использовать файл данных следующего формата.
123 408-555-0394
124 415-555-3422
164 585-555-3490
100 650-555-3434
Возможно, было бы разумным в этой программе организовать считывание всего
файла данных с последующей его перезаписью (со всеми модификациями) при
завершении программы. Но если набор данных будет очень большим, все содержимое файла
может не поместиться в памяти. Используя поток iostream, можно поступить по-
другому, а именно просмотреть содержимое всего файла, найти нужную запись или
452 Часть III. Освоение суперсредств C++
добавить новые записи, открыв файл для вывода в режиме дозаписи. На примере
следующей функции, которая изменяет телефонный номер по заданному значению ID, показано,
как можно использовать двунаправленный поток для модификации су1цествующей записи.
void changeNumberForlD(const strings inFileName, int inID,
const strings inNewNumber)
{
fstream ioData(inFileName.c_str());
if (!ioData) {
cerr « "Ошибка при открытии файла " « inFileName
« endl;
exit(1);
}
// Циклический просмотр записей до тех пор, пока не будет
// достигнут конец файла,
while (ioData.goodO ) {
int id;
string number;
// Считываем следующее ID-значение.
ioData >> id;
// Проверяем, нужно ли изменять текущую запись.
if (id == inID) {
// Перемещаем маркер в текущую позицию для считывания.
ioData. seekp (ioData. tellg () ) ,-
// Выводим пробел, а за ним новый номер.
ioData « " " << inNewNumber;
break;
}
// Считываем текущий номер, чтобы "продвинуть" поток.
ioData >> number;
Безусловно, такой подход хорошо работает только в том случае, если данные
имеют фиксированный размер.
Строковые потоки также можно использовать в двунаправленном режиме. Для
этого существует класс stringstream.
Двунаправленные потоки имеют отдельные указатели для
считывания и записи. При переключении между режимами считывания и
записи необходимо устанавливать соответствующую позицию.
Локализация
Когда вы только учитесь программировать на С или C++, имеет смысл считать
символы эквивалентами байтов и обрабатывать их как члены символьного набора ASCII
(U.S.). В действительности профессиональные С++-программисты понимают, что
наиболее успешные программы находят широкое распространение по всему миру.
Несмотря на то что на начальном этапе программирования вы не думаете о
потенциальных заморских пользователях, может настать время, когда это все же придется
сделать, т.е. заняться локализацией своего программного продукта.
Глава 14. Использование С++-потоков ввода-вывода 453
"Широкие" символы
Не все языки мира или символьные наборы могут быть полностью представлены
восемью битами, или одним байтом. К счастью, в C++ для "широких" символов
предусмотрен встроенный тип wchar_t размером два байта. Поэтому для таких языков, как
японский или арабский, можно в C++ использовать тип wchart.
Если существует хотя бы один шанс, что ваша программа будет использоваться не
в контексте символьного набора ASCII (U.S.) (подсказка: существует!), вам следует
использовать "широкие" символы с самого начала разработки. Использование типа
wchar_t не вызывает особых проблем, поскольку он работает подобно типу char.
Единственное отличие состоит в том, что строковые или символьные литералы для
"широких" символов предваряются буквой L. Например, чтобы инициализировать
символ типа wchart буквой т, следует написать такую инструкцию.
wchar_t myWideCharacter = L'm';
Существуют "широкосимвольные" версии всех популярных типов и классов. Так,
"широкосимвольную" роль string-класса "играет" класс wstring. Префикс в виде
буквы w также применяется к потокам. "Широкосимвольные" файловые выходные потоки
работают на основе класса wof stream, а входные — на основе класса wif stream.
В дополнение к cout, cin и cerr существуют "широкосимвольные" версии
встроенных потоков ввода-вывода на консоль и потока ошибок, именуемые wcout, wcin и wcerr.
Как показано в следующей простой программе, использование "широкосимвольных"
потоковых классов и типов не отличается от использования "нешироких" версий.
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
wcout « Ь"Я занимаюсь локализацией." << endl;
}
Использование стандарта Unicode
"Широкие" символы— это огромный шаг вперед, поскольку они увеличивают
пространство, доступное для определения одного символа. Следующий шаг — понять,
как это пространство используется. В традиционных ASCII-символах каждой букве
соответствует конкретное число (код), которое помещается в одном байте.
Современное представление символов не сильно отличается от традиционного.
Просто таблица соответствия (символов своим кодам) стала гораздо больше,
поскольку она включает, помимо английского алфавита, множество других наборов
символов. Преобразование символа в код в этом случае определяется стандартом
Unicode. Например, символу "N" соответствует Unicode-значение 05D0. Никакой
другой символ из других символьных наборов не будет иметь этого кода.
Чтобы корректно работать с Unicode-текстом, необходимо знать применяемый
в этом случае принцип кодирования. Различные приложения могут хранить Unicode-
символы различными способами. В C++ для стандартного кодирования "широких"
символов служит таблица UTF-16, поскольку для каждого символа используется 16 бит.
454 Часть III. Освоение суперсредств C++
Местная специфика и аспекты локализации
Символьные наборы — это только один из аспектов, которыми различаются
страны в представлении данных. Даже страны, которые используют похожие символьные
наборы (например, Великобритания и США), по-разному представляют такие
данные, как даты и денежные единицы.
Стандартная библиотека C++ содержит встроенный механизм группирования
специальных данных о конкретной стране в раздел, именуемый местной спецификой
(locale). Под местной спецификой подразумевается коллекция параметров,
соответствующих конкретному региону. Отдельный параметр называется аспектом
локализации (facet). Примером местной специфики может послужить американский вариант
английского языка (U.S. English), а примером одного аспекта локализации — формат,
используемый для отображения даты. В C++ содержатся средства настройки или
добавления аспектов локализации.
Использование местной специфики
С точки зрения программиста, различные варианты локализации должны быть
автоматическим средством языка программирования. При использовании потоков
ввода-вывода данные форматируются в соответствии с конкретным значением,
установленным для местной специфики. Местная специфика реализуется в виде объектов,
которые можно присоединить к потоку. Например, в следующей строке кода
используется метод выходного потока imbue () для установки параметра локализации,
означающего использование американского варианта английского языка (обычно он
называется "en_U") для "широкосимвольного" вывода данных на консоль.
wcout. imbue (locale ("en_US") ) ,- // Методы локализации определены
// в пространстве имен std.
Американский английский обычно не устанавливается по умолчанию. По
умолчанию, как правило, действует классический вариант локализации, соответствующий
использованию ANSI С-соглашений. Классический вариант во многом совпадает
с установками американского варианта английского языка, но небольшие различия
между ними все же имеются. Например, если совсем не устанавливать вариант
локализации или принять действующий по умолчанию, выведенное вами число будет
представлено без знаков препинания.
wcout.imbue(locale("С") )
wcout << 32767 << endl;
Результат выполнения этого кода будет выглядеть так.
32767
Но, если установить в качестве местной специфики американский вариант
английского языка, число будет сформатировано по правилам использования знаков
препинания, принятым в США. При выполнении следующего кода перед выводом
числа устанавливается "американский" вариант локализации.
wcout.imbue(locale("en_US"));
wcout « 32767 « endl;
В этом случае результат будет таким.
32,767
Глава 14. Использование С++-потоков ввода-вывода 455
Нетрудно предположить, что в различных регионах мира числовые данные
форматируются по-разному, т.е. используются различные знаки препинания (или не
используются вообще) для разделения групп разрядов и для представления разделителя
между целой и дробной частями числа.
Названия вариантов локализации зависят от реализации, хотя в большинстве
случаев реализации стандартизованы по принципу используемого языка общения и
региона. Например, местную специфику для французского языка, используемого во
Франции, можно указать с помощью значения f r_FR. Для настройки на японский
язык для Японии с указанием конкретного стандарта (например, Japanese Industrial
Standard) необходимо установить значение j a_JP. j is.
В большинстве операционных систем предусмотрен механизм установки варианта
локализации по желанию пользователя. В C++ для установки нужного варианта
местной специфики из среды пользователя можно передать пустую строку конструктору
объекта локализации. После создания такой объект можно использовать для подачи
запросов по теме локализации и в зависимости от ответов на эти запросы принимать
затем соответствующие программные решения.
Например, следующая программа создает стандартный вариант локализации.
Метод name () используется для получения С++-строки, которая описывает выбранный
вариант местной специфики. При выполнении этой программы выводится одно из
двух возможных сообщений.
#include <iostream>
#include <string>
using namespace std,-
int main(int argc, char** argv)
{
locale loc("");
if (loc.name().find("en_US") == string::npos &&
loc.name().find("United States") == string::npos){
wcout << L"Добро пожаловать, пользователь не из США!"
<< endl;
} else {
wcout << L"Welcome U.S. user!" << endl;
}
)
Определение варианта локализации на основе его названия не всегда точно дает
ответ на вопрос, где физически находится пользователь, но все же может дать
некоторую подсказку.
Использование отдельных аспектов локализации
Функцию std: : use_f acet () можно использовать для получения конкретного
аспекта локализации в конкретном варианте местной специфики. Например, при
использовании следующего шаблонного выражения будет получено значение
стандартного аспекта локализации, связанного с представлением денежной единицы в
регионе, использующем британский вариант английского языка.
use_facet<moneypunct<wchar_t> >(locale("en_GB"));
Обратите внимание на то, что внутренний шаблонный тип определяет тип
используемых символов. Обычно это тип wchar_t или char. Хотя использование вложенных
456 Часть III. Освоение суперсредств C++
шаблонных классов не приветствуется, применение приведенного выше синтаксиса
даст в результате объект, который содержит всю информацию, которую вам
необходимо знать о представлении "британской" денежной единицы. Данные, доступные
в стандартных аспектах локализации, определяются в заголовке <locale> и
соответствующих файлах.
На примере следующей программы демонстрируется использование средств по
установке вариантов локализации и считывания их аспектов путем вывода валютных
символов как в "американском" (U.S. English), так и "британском" (British English)
представлении. Обратите внимание на то, что в зависимости от используемой вами
рабочей среды "британский" вариант отображения валютных символов может быть
выражен знаком вопроса, прямоугольником или вообще никак. Кроме того, в
зависимости от платформы могут варьироваться названия вариантов локализации. При
надлежащей настройке вашей среды выполнения вы можете действительно получить
знак "решетки", обозначающий британский фунт.
#include <iostream>
#include <locale>
using namespace std;
int main(int argc, char** argv)
{
locale locUSEng ("en_US");
locale locBritishEng ("en_GB");
wstring dollars = use_facet<moneypunct<wchar_t> >
(locUSEng).curr_symbol();
wstring pounds = use_facet<moneypunct<wchar__t> >
(locBritishEng).curr_symbol();
wcout « L"B США валютным символом служит " << dollars
« endl;
wcout << L"B Великобритании валютным символом служит "
« pounds « endl;
} '
Резюме
В этой главе вы узнали, что потоки представляют собой гибкое
объектно-ориентированное средство, предназначенное для выполнения операций ввода-вывода данных.
Главное здесь, чтобы вы поняли концепцию потока. В некоторых операционных
системах могут быть реализованы собственные средства организации ввода-вывода и доступа
к файлам, но знание принципа работы потоков и потоковых библиотек позволит вам
успешно работать с современными системами ввода-вывода любого типа.
Мы надеемся, что вы по достоинству оценили возможности адаптации своих программ
для применения в различных регионах мира. Всякий, кому уже приходилось заниматься
локализацией своего кода, скажет вам, что гораздо проще это делать с момента
проектирования приложения, чем добавлять соответствующие возможности потом, поэтому
заранее продумайте вариант использования Unicode-символов в своих программах.
Обработка ошибок
В каждой С++-программе могут обнаружиться ошибки. Вдруг окажется, что
программе не удалось открыть файл, разорвалась связь с сетью или пользователь ввел
некорректное значение. Профессиональные С++-программы распознают эти ситуации
как исключительные, но вполне ожидаемые, и поэтому обрабатывают их
соответствующим образом. В языке C++ для обработки ошибок в программах предусмотрены
исключения (exceptions).
В приведенных в этой книге примерах кода ошибочные ситуации практически
игнорировались. В данной главе мы исправим это "упущение" и научимся включать
в программы код обработки ошибок. Здесь мы рассмотрим детали синтаксиса С++-
исключений и узнаем, как их использовать с максимальной эффективностью. В этой
главе раскрываются следующие темы.
□ Обзор С++-средств обработки ошибок, а также аргументов "за и против" С++-
исключений
□ Синтаксис исключений
□ Генерирование и перехват исключений
□ Неперехваченные исключения
□ Списки генерируемых исключений
* □ Иерархия классов исключений и полиморфизм
□ Иерархия С++-классов исключений
□ Создание собственных классов исключений
458 Часть III. Освоение суперсредств С++
□ Процесс возврата содержимого стека и его очистка
□ Самые распространенные проблемы обработки ошибок
□ Ошибки распределения памяти
□ Ошибки в конструкторах и деструкторах.
Ошибки и исключения
Даже в идеально написанной программе могут возникнуть ошибки и
исключительные ситуации. Ни одна программа не существует в изоляции от остального мира;
все они зависят от работоспособности сетей и файловых систем, от внешнего кода
(например, библиотек, созданных сторонними организациями) или от данных,
введенных пользователем. Каждый из этих факторов может создавать исключительные
ситуации. Таким образом, программист при написании компьютерной программы должен
включать в нее средства обработки ошибок. В некоторых языках (например, С) такие
специальные языковые средства не предусмотрены. Программисты, которые пишут
программы на этих языках, в основном, полагаются на возможности анализа значений,
возвращаемых функциями, и другие специальные методы. Однако в таких языках
программирования, как Java, программист просто не может обойтись без использования
механизма обработки ошибок, именуемого исключением. Между этими двумя
крайностями язык C++ занимает промежуточное место. Он предоставляет средства поддержки
исключений, но не требует их обязательного включения в программу. И все же вам не
удастся полностью проигнорировать исключения в C++, поскольку некоторые базовые
средства (используемые, например, при распределении памяти) их активно применяют.
Что такое исключения
Исключения представляют собой механизм, действие которого заключается в том, что
один фрагмент кода уведомляет другой фрагмент кода о наличии "исключительной"
ситуации или ошибки. Код, обнаруживший ошибку, генерирует исключение, а код, который
обрабатывает его, сначала должен его перехватить. Исключения не подчиняются
основному правилу пошагового выполнения инструкций,
к которому вы привыкли. Если некоторый фрагмент
кода сгенерирует исключение, программа немедленно
прекращает пошаговое выполнение и передает это
исключение обработчику. Если вы любите аналогии
со спортом, то можете представить себе код,
генерирующий исключение, в образе игрока, находящегося
в дальней части поля и выбрасывающего
бейсбольный мяч на площадку, где ближайший полевой игрок
(по аналогии с ближайшим обработчиком
исключения) захватывает его. На рис. 15.1 показан
гипотетический стек, в котором хранятся данные от трех
вызовов функций. Функция А () содержит обработчик
исключения. Она вызывает функцию В (), которая
обращается к функции С (), генерирующей исключе-
Рис 15 1 ние, которое будет перехвачено функцией А ().
Стековый
фрейм
функции А()
Стековый
фрейм
функции В()
Стековый
фрейм
функции С()
Глава 15. Обработка ошибок 459
На рис. 15.2 показан обработчик, перехватывающий
исключение. Стековые фреймы, выделяемые для функций С ()
и В (), будут удалены, а фрейм функции А () останется до тех
пор, пока не будет обработано исключение.
Некоторые С++-программисты со стажем удивляются,
узнав, что в C++ реализована поддержка исключений.
Программисты привыкли связывать исключения с такими
языками программирования, как Java, в которых этот механизм
гораздо более заметен. Тем не менее C++ имеет полностью
отработанный механизм поддержки исключений.
Почему поддержка исключений в C++ — это "плюс"
Как упоминалось выше, ошибки времени выполнения в С++-программах попросту
неизбежны. Несмотря на это, обработка ошибок в большинстве С- и С++-программ не
носит системного характера и реализуется специально созданными для данного
конкретного случая средствами. Фактически стандартный С-подход к обработке ошибок,
который был перенесен во многие С++-программы, заключается в анализе
целочисленных значений, возвращаемых функциями, и использовании макроопределения
errno для извещения об ошибках. Макроопределение errno действует как
глобальная целочисленная переменная, которая позволяет функциям уведомить инициатора
их вызова о возникновении ошибки.
К сожалению, коды результатов выполнения функций и макроопределение errno
используются по-разному. Некоторые функции возвращают значение 0 в качестве
признака успешного завершения и значение —1 в качестве признака ошибки. В случае
возврата значения —1 они также устанавливают переменную errno равной коду
ошибки. Другие функции при успешном завершении возвращают значение 0, а в
случае ошибки— ненулевое значение. Эти функции не используют макроопределение
errno. Существует также группа функций, для которых возврат числа 0 означает не
успешное завершение, а неудачный исход (вероятно, потому, что в языках С и C++
число 0 всегда соответствовало логическому значению "ложь").
Эти несообразности могут вызывать существенные проблемы, поскольку
программисты, используя новую функцию, часто предполагают, что ее коды
возвращаемых значений такие же, как у других функций. А это, как видите, не всегда так.
Операционная система Solaris 9 содержит две различные библиотеки объектов
синхронизации: POSIX- и Solaris-версии. Функция, предназначенная для
инициализации программного семафора в POSIX-версии, называется seminit (), а функция
с таким же назначением в Solaris-версии — sema_init (). Более того, эти функции по-
разному обрабатывают коды ошибок! Функция sem_init () в случае ошибки
возвращает значение —1 и устанавливает соответствующим образом переменную errno, в то
время как функция sema_init () возвращает код ошибки в виде положительного
значения и не устанавливает переменную errno.
Еще одна проблема связана с тем, что функции в C++ могут возвращать только
одно значение, поэтому в случае, если вам необходимо вернуть из функции как код
ошибки, так и конкретное значение, вы должны находить альтернативное решение.
Например, можно использовать для этого ссылочный параметр. Или же можно
сделать код ошибки одним из возможных значений, возвращаемых функцией, например,
NULL-указателем.
Стековый
фрейм
функции А()
Рис. 15.2
460 Часть III. Освоение суперсредств C++
Исключения предлагают более простой, более последовательный и в то же время
более безопасный механизм обработки ошибок. Перечислим ряд преимуществ, которые
имеют исключения перед специальными подходами, применяемыми в языках С и C++.
□ Коды значений, возвращаемые функциями, могут быть проигнорированы.
Исключения проигнорировать нельзя: если программе не удастся перехватить
исключение, она будет завершена.
□ Целочисленные коды значений, возвращаемые функциями, не содержат
никакой семантической информации. Различные числа для разных программистов
могут означать различные результаты. Исключения же могут содержать
семантическую информацию как в именах типов, так и в самих данных (если для
представления исключений используются объекты).
□ Целочисленные коды значений, возвращаемые функциями, не имеют
сопровождающей информации. Исключения же можно использовать для передачи
любого объема информации из кода, обнаружившего ошибку, в код, который ее
должен обработать. Исключения можно также использовать для обмена
информацией "неошибочного" характера, хотя многие программисты считают
это злоупотреблением механизмом исключений.
□ Обработка исключений позволяет пропустить ряд уровней в стеке вызовов.
Другими словами, функция может обработать ошибку, которая возникла (и
зафиксирована) на более низком уровне в стеке вызовов без передачи кода
обработки ошибки в "промежуточные" функции. В то же время коды значений,
возвращаемые функциями, требуют, чтобы каждый уровень стека вызовов
очищался явным образом после обработки каждого предыдущего уровня.
Почему поддержка исключений в C++ — это "минус"
Несмотря на наличие у исключений перечисленных выше достоинств, их
конкретная реализация в C++ делает их не слишком привлекательным средством для
некоторых программистов. Первая проблема связана с производительностью: языковые
средства, добавленные в программу для поддержки исключений, замедляют
выполнение всех программ, причем даже тех, которые ими не пользуются. Но если вы не
занимаетесь разработкой высокоэффективного программного обеспечения или
программ системного уровня, то вас это не должно беспокоить. Более детально эта
проблема рассматривается в главе 17.
Вторая проблема состоит в том, что поддержка исключений в C++ не является
интегральной частью языка, как это реализовано в других языках. Например, в Java
функции, которая не определяет список возможных исключений, не разрешается
генерировать ни одно исключение. И это вполне логично. В C++ все как раз наоборот:
функция, которая не определяет список возможных исключений, может
сгенерировать любое исключение! Кроме того, в C++ список исключений необязателен при
компиляции программы, а это значит, что во время ее выполнения его можно
игнорировать. Эти и другие несообразности затрудняют использование исключений
в C++, наводя страх на некоторых программистов.
Наконец, механизм исключений создает проблемы, связанные с динамическим
распределением памяти и освобождением системных ресурсов. Добавляя в программу
средства обработки исключительных ситуаций, трудно гарантировать, что
освобождение системных ресурсов будет выполнено должным образом. Об этом пойдет речь ниже.
Глава 15. Обработка ошибок 461
Наши рекомендации
Несмотря на указанные недостатки, мы все же рекомендуем для обработки ошибок
использовать механизм исключений. Мы считаем, что формализация процесса
обработки исключительных ситуаций обеспечивает значительный перевес над его менее
привлекательными аспектами. Поэтому остальную часть этой главы мы посвящаем
исключительно исключениям. Даже если в ваши планы пока не входит использование этих
средств, вы должны хотя бы поверхностно ознакомиться с материалом этой главы,
чтобы быть в курсе довольно распространенных средств обработки ошибок в С++-
программировании.
Механизм исключений
Исключительные ситуации часто возникают в сфере файловых операций ввода-
вывода. Ниже приведена простая функция, которая позволяет открыть файлг считать
из него список целочисленных значений, а затем сохранить их в структуре данных
типа vector. Как упоминалось в главе 4, вектор — это динамический массив. В него
можно добавлять элементы с помощью метода push_back (), причем доступ к
элементам вектора осуществляется так же, как к элементам массива.
#include <fstream>
#include <iostream>
#include <vector>
#include <string>
using namespace std;
void readlntegerFile(const strings fileName,
vector<int>& dest)
{
if stream istr,-
int temp;
istr.open(fileName.c_str());
// Считываем целочисленные значения одно за другим и
// добавляем их в вектор,
while (istr >> temp) {
dest.push_back(temp);
Функцию readlntegerFile () можно использовать таким образом.
int main(int argc, char** argv)
{
vector<int> mylnts;
const string fileName = "IntegerFile.txt";
readlntegerFile(fileName, mylnts);
for (size_t i = 0; i < mylnts.size(); i++) {
cout << mylnts[i] << " ";
}
cout << endl;
return (0);
}
462 Часть III. Освоение суперсредств C++
Отсутствие обработки ошибок в этих функциях должно вас по крайней мере
насторожить. В этом разделе мы покажем, как добавить в программу обработку ошибок
на базе механизма исключений.
Генерирование и перехват исключений
Наиболее вероятная проблема, которая может возникнуть в функции readlnteger-
File (), связана с ситуацией, когда файл не удается открыть. Это идеальная ситуация
для генерирования исключения. Теперь рассмотрим синтаксис.
#include <fstream>
#include <iostream>
#include <vector>
#include <string>
#include <exception>
using namespace std;
void readlntegerFile(const strings fileName, vector<int>& dest)
{
ifstream istr;
int temp;
istr.open(fileName.c_str());
if (istr.faiK) ) {
// He удалось открыть файл: генерируем исключение.
throw exception();
}
// Считываем целочисленные значения одно за другим и
// добавляем их в вектор.
while (istr >> temp) {
dest.push back(temp);
}
}
Ключевое слово throw — единственный способ в C++ сгенерировать исключение.
В C++ существует класс exception, объявленный в заголовочном файле <exception>.
Часть throw-строки exception () означает создание нового объекта типа exception,
который позволяет сгенерировать исключение.
Если эта функция не сможет открыть файл, будет выполнена строка throw
exception () ;, но остальной код функции опускается, и управление передается
ближайшему обработчику исключений.
Чаще всего генерирование исключений в коде имеет положительные эффект, если
вы сами также пишете код по их обработке. Рассмотрим функцию main (), в которой
выполняется обработка исключения, сгенерированного в функции readlntegerFile ().
int main(int argc, char** argv)
{
vector<int> mylnts;
const string fileName = "IntegerFile.txt";
try {
readlntegerFile(fileName, mylnts);
Глава 15. Обработка ошибок 463
} catch (const exceptions^ e) {
cerr << "Не удается открыть файл " << fileName << endl;
exit (1);
}
for (size_t i = 0; i < mylnts.size () ; i++) {
cout << mylnts[i] << " ";
}
cout << endl;
return (0);
}
Обработка исключений заключается в "пробном" выполнении блока кода (try-блока)
"в сотрудничестве" с другим блоком (catch-блоком), который предназначен для
реагирования на любые проблемы, которые могут возникнуть в try-блоке. В данном
конкретном случае инструкция catch реагирует на любое исключение типа exception,
которое генерируется в try-блоке, путем вывода сообщения об ошибке с
последующим выходом из функции. Если бы try-блок завершался без генерирования
исключения, catch-блок был бы опущен. Блоки try-catch можно представить себе в виде
"приукрашенных" (или модернизированных) инструкций if. Если в блоке try генерируется
исключение, выполняется блок catch. В противном случае он опускается.
Несмотря на то что по умолчанию потоки не генерируют исключения, программист
может обязать потоки генерировать исключения при возникновении ошибочных
условий путем вызова их методов exceptions (). Однако создатель C++ Бьерп Страуст-
руп (Bjarne Stroustrup) не рекомендует это делать. В своей книгеТЪс C++ Programming
Language (третье издание) он пишет: ". . . Я предпочитаю непосредственно
контактировать с состоянием потока. Если ошибочную ситуацию можно обработать с помощью
локальных управляющих структур в рамках функции, то вряд ли использование
исключений даст более эффективный результат ". В этой книге мы следуем его рекомендациям.
Типы исключений
Можно сгенерировать исключение любого типа. В предыдущем примере был
сгенерирован объект типа exception, но исключения необязательно должны быть
объектами. Можно было бы сгенерировать простое исключение типа int.
void readlntegerFile(const strings fileName, vector<int>& dest)
{
// Код опущен.
istr.open(fileName.c_str() ) ;
if (istr.fail()) {
// He удалось открыть файл: генерируем исключение.
throw 5;
}
// Код опущен.
}
Теперь нужно изменить и инструкцию catch.
int main(int argc, char** argv)
{
// Код опущен.
464 Часть III. Освоение суперсредств C++
try {
readlntegerFile(fileName, mylnts);
} catch (int e) {
cerr << "He удается открыть файл " << fileName << endl,-
exit (1);
}
// Код опущен.
}
В качестве альтернативного варианта мы могли бы сгенерировать С-строку типа
char*. Этот метод иногда оказывается весьма полезным, поскольку строка может
содержать информацию о возникшем исключении.
void readlntegerFile(const strings fileName,
vector<int>& dest)
{
Код опущен.
istr.open(fileName.c_str() ) ;
if (istr.fail О) {
// He удалось открыть файл: генерируем исключение.
throw "He удается открыть файл.";
}
// Код опущен.
}
При перехвате сЬаг*-исключения мы можем вывести результат.
int main(int argc, char** argv)
{
// Код опущен,
try {
readlntegerFile(fileName, mylnts);
} catch (const char* e) {
cerr << e << endl,-
exit (1);
}
// Код опущен.
}
Но в большинстве случаев в качестве исключений следует генерировать объекты,
и на это есть две причины:
□ объекты передают информацию просто по имени класса;
□ объекты могут хранить информацию в виде строк, которые позволяют описать
исключения.
В стандартной библиотеке C++ определено восемь классов исключений,
подробное описание которых приведено ниже. Программист может таюке написать
собственные классы исключений. Об этом речь впереди.
Глава 15. Обработка ошибок 465
Перехват объектов исключений с использованием модификатора
const и ссылок
В приведенном выше примере, где функция readlntegerFile () генерирует объект
типа exception, catch-строка выглядит так.
} catch (const exceptions: e) {
Однако нет необходимости, чтобы объекты перехватывались по const-ссылке.
Мы могли бы организовать перехват объектов по значению.
} catch (exception e) {
В качестве альтернативного варианта мы могли бы перехватывать объект по
ссылке (не используя модификатор const):
} catch (exceptions e) {
Кроме того, как было показано в примере генерирования строки типа char*, мы
можем перехватывать указатели на исключения, если, конечно, таковые будут
сгенерированы.
В программах можно организовать перехват исключений по
значению, ссылке, const-ссылке или указателю.
Генерирование и перехват множественных исключений
Неудача при попытке открыть файл— не единственная проблема, которая может
возникнуть в функции readlntegerFile (). Если данные, считываемые из файла,
сформатированы некорректно, то процесс считывания таких данных может
сформировать ошибку. Рассмотрим реализацию функции readlntegerFile (), которая генерирует
исключение, если ей не удается открыть файл или корректно считать из него данные.
void readlntegerFile(const strings fileName,
vector<int>& dest)
{
ifstream istr;
int temp;
istr.open(fileName.c_str());
if (istr.faiK) ) {
// He удалось открыть файл: генерируем исключение.
throw exception();
}
// Считываем целочисленные значения одно за другим и
// добавляем их в вектор,
while (istr » temp) {
dest.push_back(temp);
}
if (istr.eofO) {
// Мы достигли конца файла,
istr.close();
} else {
// Какая-то иная ошибка. Генерируем исключение,
istr.close();
throw exception();
466 Часть III. Освоение суперсредств C++
Код в функции main (■) необязательно изменять, поскольку он уже и так
перехватывает исключение типа exception. Но теперь исключение может быть
сгенерировано в двух различных ситуациях, поэтому имеет смысл соответствующим образом
модифицировать сообщение об ошибке.
int main(int argc, char** argv)
{
// Код опущен,
try {
readlntegerFile(fileName, mylnts);
} catch (const exceptions: e) {
cerr << "He удается либо открыть, либо прочитать файл "
« fileName << endl;
exit (1);
}
// Код опущен.
}
В качестве альтернативного варианта мы могли бы сгенерировать в функции
readlntegerFile () два исключения различного типа, чтобы инициатор вызова
этой функции мог понять, что именно произошло во время ее выполнения.
Рассмотрим реализацию функции readlntegerFile (), которая генерирует объект
исключения класса invalid_argument, если файл не удается открыть, и объект класса
runt imeexcept ion, если из этого файла невозможно считать целочисленные
значения. Оба класса, как invalid_argument, так и runt ime_except ion, определены
в заголовочном файле <stdexcept> и являются частью стандартной библиотеки C++.
#include <fstream>
#include <iostream>
#include <vector>
#include <string>
#include <stdexcept>
using namespace std;
void readlntegerFile(const strings fileName,
vector<int>& dest)
{
ifstream istr,-
int temp;
istr.open(fileName.c_str());
if (istr.failO) {
// He удалось открыть файл: генерируем исключение.
throw invalid_argument("");
}
// Считываем целочисленные значения одно за другим и
// добавляем их в вектор,
while (istr >> temp) {
dest ,push_back( temp) ,-
}
Глава 15. Обработка ошибок 467
if (istr.eof()) {
// Мы достигли конца файла.
istr. close () ,-
} else {
// Какая-то иная ошибка. Генерируем исключение.
istr.close();
throw runtime_error("");
Для классов invalid argument и runtime_error не существует открытых
конструкторов по умолчанию — только string-конструкторы.
Теперь функция main (), используя две catch-инструкции, может перехватывать
объекты исключений как типа invalid_argument, так и типа runtime_error
int main(int argc, char** argv)
{
// Код опущен,
try {
readlntegerFile(fileName, mylnts);
} catch (const invalid_argument& e) {
cerr << "He удается открыть файл " « fileName << endl;
exit (1);
} catch (const runtime_error& e) {
cerr << "Ошибка при чтении файла " << fileName << endl;
exit (1);
}
// Код опущен.
}
Если исключение генерируется в блоке try, компилятор поочередно сопоставит
тип этого исключения с типами исключений, "заявленных" обработчиками. Если
функция readlntegerFile () не сможет по какой-либо причине открыть файл и
сгенерирует объект типа invalid_argument, исключение будет перехвачено первой
catch-инструкцией. Если же функции readlntegerFile () не удастся должным
образом считать данные из файла и она сгенерирует объект типа runtime_error,
исключение перехватит вторая catch-инструкция.
Влияние модификатора const на перехват исключений
Использование модификатора const при задании типа исключения, которое вы
хотите перехватывать, никак не влияет на результат сопоставления. Например, эта
строка кода обеспечит перехват любого исключения типа runt ime_error.
} catch (const runtime_error& e) {
Эта строка кода также перехватит любое исключение типа runt ime_error.
} catch (runtime_error& e) {
В общем случае модификатор const при перехвате исключений
следует использовать только для того, чтобы обозначить, что вы не
модифицируете их.
468 Часть III. Освоение суперсредств С++
Перехват любых исключений
Можно написать catch-строку, которая будет перехватывать исключение любого
возможного типа. Для этого используется специальный синтаксис.
int main(int argc, char** argv)
{
// Код опущен.
try {
readlntegerFile(fileName, mylnts);
} catch (...) {
cerr << "Ошибка при чтении или открытии файла "
<< fileName << endl;
exit (1);
}
// Код опущен.
}
Три точки в catch-строке — отнюдь не опечатка. Они представляют собой
специальный (или групповой) символ, который означает совпадение с исключением
любого типа. Этот метод может оказаться полезным при вызове непроверенного и плохо
документированного кода, чтобы гарантировать перехват всех возможных
исключений. Однако в ситуациях, когда нужно иметь полную информацию о генерируемых
исключениях, этот метод считается субоптимальным (условно оптимальным),
поскольку он одинаково обрабатывает исключения любого типа. Все же лучше при
перехвате исключений явным образом указывать их тип и предпринимать в каждом
случае соответствующие действия.
Неперехваченные исключения
Если программа генерирует исключение, которое нигде не перехватывается,
выполнение такой программы будет завершено. Вряд ли вас устроит такое поведение.
Назначение исключений — дать программе шанс обработать и скорректировать
нежелательные или неожидаемые ситуации. Если программа не перехватила
исключение, значит, не было большого смысла в его генерировании или вы просто
недоработали код по перехвату всех исключительных ситуаций.
Перехватывайте и обрабатывайте все возможные исключения,
сгенерированные в ваших программах.
Даже если вы не можете обработать конкретное исключение, вам все равно
следует до выхода из программы написать код по его перехвату и вывести соответствующее
сообщение об ошибке.
Если существует неперехваченное исключение, можно также попробовать
изменить поведение программы. Если возникает такое исключение, программа может
вызвать встроенную функцию terminate (), которая просто обратится к функции
abort () (из заголовочного файла <cstdlib>), чтобы прекратить выполнение
программы. Имеет смысл установить собственный обработчик terminatehandler,
вызвав функцию set_terminate () с указателем на функцию обратного вызова, которая
не принимает аргументов и не возвращает никакого значения. Функции
terminate (), set_terminate () и обработчик terminate_handler объявлены
в заголовочном файле <except ion>. Прежде чем вы заинтересуетесь этим
средством, вам следует знать, что ваша функция обратного вызова должна завершить про-
Глава 15. Обработка ошибок 469
грамму (в противном случае все равно будет вызвана функция abort ()). Она не
может просто игнорировать ошибку. Но вам стоит использовать ее для вывода
информативного сообщения об ошибке. Рассмотрим пример функции main (), которая не
перехватывает исключения, сгенерированные функцией readlntegerFile (). Но
она устанавливает обработчик terminate_handler, используя функцию обратного
вызова, которая перед завершением выводит сообщение об ошибке.
void myTerminate()
{
cout << "Неперехваченное исключение!\n";
exit(l);
}
int main(int argc, char** argv)
{
vector<int> mylnts;
const string fileName = "IntegerFile.txt";
set_terminate(myTerminate);
readlntegerFile(fileName, mylnts);
for (sizet i = 0; i < mylnts. size () ,- i++) {
cerr << mylnts[i] << " ";
}
cout << endl;
return (0);
}
Функция setterminate () при установке нового обработчика, хотя это и не
показано в данном примере, возвращает старый обработчик terminate_handler.
Обработчик terminate_handler часто используется во всей программе, поэтому
считается хорошим стилем программирования освободиться от старого обработчика
terminate_handler при выполнении кода, в котором используется новый. В данном
случае всей этой программе нужен новый обработчик terminate_handler, поэтому
нет смысла в освобождении от старого.
Хотя важно знать о возможности применения функции setterminate (), она
все же не считается очень эффективным средством обработки исключений. Чтобы
обеспечить более точную обработку ошибок, мы рекомендуем реализовать попытки
перехвата и обработки каждого исключения в отдельности.
Списки типов генерируемых исключений
Язык C++ позволяет указывать исключения, которые предполагают сгенерировать
функция или метод. В этом случае составляются списки типов генерируемых исключений
(throw-списки), или спецификация исключений. Рассмотрим функцию
readlntegerFile () из предыдущего примера с заданным throw-списоком.
void readlntegerFile(const strings fileName,
vector<int>& dest)
throw (invalid_argument, runtime error)
{
// Остальная часть кода совпадает с предыдущим вариантом.
}
470 Часть III. Освоение суперсредств C++
В throw-списке просто перечисляются типы исключений, которые могут быть
сгенерированы из этой функции. Обратите внимание на то, что throw-список должен
быть включен и в прототип этой функции.
void readlntegerFile(const stringfc fileName,
vector<int>& dest)
throw (invalidargument, runtime_error);
В отличие от модификатора const спецификация исключений не является частью
сигнатуры функции или метода. Невозможно перегрузить функцию на основе
отличий в списке типов исключений (throw-списке).
Если в определении функции или метода не задан throw-список, она (он) может
генерировать любое исключение. Вы имели возможность увидеть такое поведение в
предыдущей реализации функции readlntegerFile (). Если же вам нужно указать, что
функция или метод не генерирует исключений вообще, напишите пустой throw-список.
void readlntegerFile(const strings fileName,
vector<int>& dest) throw ();
Если такое поведение вам кажется странным, вы не одиноки. Однако лучше
принять его таким как есть и двинуться дальше.
Функция без throw-списка может генерировать исключения любого
типа. Функция с пустым throw-списком не должна генерировать
исключения вообще. ' w *
Неожидаемые исключения
К сожалению, throw-список в C++ необязателен во время компиляции. Коду,
который вызывает функцию readlntegerFile (), не нужно перехватывать
исключения, перечисленные в throw-списке. Такой подход отличается от поведения,
реализованного в других языках (например, Java), которое требует, чтобы функция или
метод перехватывали исключения или объявляли их в собственных throw-списках.
Кроме того, мы могли бы реализовать функцию readlntegerFile () таким образом.
void readlntegerFile(const strings fileName,
vector<int>& dest)
throw (invalid_argument, runt ime_error)
{
throw (5);
}
В данном throw-списке не указано, что функция readlntegerFile () генерирует
исключение типа int, однако этот код, в котором оно таки генерируется явным
образом, компилируется и выполняется. Тем не менее результат его выполнения не будет
соответствовать вашим ожиданиям. Предположим, что вы написали следующую
функцию main (), полагая, что можете перехватить это int-исключение.
int main(int argc, char** argv)
{
vector<int> mylnts,-
const string fileName = "IntegerFile.txt";
try {
Глава 15. Обработка ошибок 471
readlntegerFile(fileName, mylnts);
} catch (int x) {
cerr << "Перехват int-исключения.\n";
При выполнении этой программы, когда функция readlntegerFile ()
сгенерирует исключение типа int, программа завершится. Функции main () попросту не
будет позволено перехватить int-исключение. Но мы можем изменить такое поведение.
Хотя throw-списки не мешают функциям генерировать исключения
не указанных в них типов, они не позволяют таким исключениям
выйти заграницы функций.
Если функция генерирует исключение, которое отсутствует в ее throw-списке, C++
вызывает специальную функцию unexpected (). Встроенная реализация функции
unexpected () просто содержит обращение к функции terminate (). Но точно так
же, как вы устанавливали собственный обработчик типа terminate_handler, вы
сможете установить собственный обработчик типа unexpected_handler. В отличие
от обработчика terminate_handler, в обработчик unexpected_handler вы
можете действительно заложить выполнение действий, отличных от простого завершения
программы. Ваша версия этой функции должна либо сгенерировать новое
исключение, либо завершить программу— она не может обеспечить нормальный выход из
функции. Если будет сгенерировано новое исключение, оно заменит неожидаемое,
причем так, как если бы новое исключение было сгенерировано изначально. Если это
замещенное исключение также отсутствует в throw-списке, программа выполнит
одно из двух действий. Если в throw-списке для этой функции задан тип исключения
badexception, то будет сгенерировано исключение этого типа. В противном случае
программа тут же завершится. В пользовательских реализациях функции unexpected ()
обычно практикуется преобразование неожидаемых исключений в ожидаемые.
Например, мы могли бы записать версию функции unexpected () в таком виде.
void myUnexpected()
{
cout << "Неожидаемое исключение! \п" ,- *
throw runtime_error("");
}
Этот код преобразует неожидаемое исключение в исключение типа runtime_error,
которое содержится в throw-списке функции readlntegerFile ().
Можно было бы установить обработчик этого неожидаемого исключения в функции
main () с помощью функции set_unexpected (). Подобно функции setterminate (),
функция setunexpected () возвращает текущий обработчик. Функция unexpected ()
применяется ко всей программе, а не только к этой функции, поэтому вам следует
привести обработчик в исходное состояние после того, как будет выполнен код, в
котором использовался ваш специальный обработчик.
int main(int argc, char** argv)
{
vector<int> mylnts,-
const string fileName = "IntegerFile.txt",-
unexpectedhandler old_handler = set_unexpected(
myUnexpected);
472 Часть III. Освоение суперсредств C++
try {
readlntegerFile(fileName, mylnts);
} catch (const invalid_argument& e) {
cerr << "He удалось открыть файл " << fileName << endl;
exit (1);
} catch (const runtimeerrorSt e) {
cerr << "Ошибка при чтении файла " << fileName << endl;
exit (1) ;
} catch (int x) {
cout << "Перехват исключения типа int.\n";
}
set_unexpected(old_handler);
// Остальная часть кода функции опущена.
}
Теперь функция main () обрабатывает любое исключение, сгенерированное
из функции readlntegerFile (), путем преобразования его в исключение типа
runtime_error. Но, как и с функцией set_terminate (), мы не рекомендуем
злоупотреблять этим средством.
Функции unexpected (), set_unexpected () и тип bad_exception объявлены
в заголовочном файле <exception>.
Изменение throw-списка в переопределенных методах
При переопределении виртуального метода в подклассе можно изменить throw-
список, если вы сделаете его более ограничительным, чем throw-список в
суперклассе. Более ограничительными считаются следующие изменения.
□ Удаление исключений из списка.
□ Добавление подклассов исключений, которые имеются в throw-списке
суперкласса.
Следующие изменения не считаются более ограничительными.
□ Добавление в список исключений, которые не являются подклассами
исключений, указанных в throw-списке суперкласса.
□ Полное удаление throw-списка.
Если вы изменяете throw-списки при переопределении методов,
помните, что любой код, который вызывается версией метода,
определенной суперклассом, должен быть способен вызвать версию
метода подкласса. Это означает, что исключения добавлять нельзя.
Например, предположим, что у нас есть следующий суперкласс.
class Base
{
public:
virtual void func() throw(exception)
{
cout << "Класс Base!\n";
Глава 15. Обработка ошибок 473
Мы могли бы написать подкласс, который переопределяет функцию f unc () и
уточняет, что она не генерирует ни одного исключения.
class Derived : public Base
{
public:
virtual void func() throw() { cout << "Derived!\n"; }
};
Мы могли бы также переопределить функцию f unc () таким образом, чтобы она
генерировала исключение типа runtirae_error, а также типа exception, поскольку
класс runtiraeerror является подклассом класса exception.
class Derived : public Base
{
public:
virtual void func() throw(exception, runtimeerror)
{ cout << "Класс Derived!\n"; }
};
Однако мы не можем удалить throw-список полностью, поскольку это будет
означать, что функция f unc () не может генерировать ни одного исключения.
Предположим, класс Base выглядит теперь так.
class Base
{
public:
virtual void func() throw(runtime_error)
{ cout << "Класс Base!\n"; }
b
И теперь мы не можем переопределить функцию f unc () в классе Derived с таким
throw-списком.
class Derived : public Base
{
public:
virtual void func() throw(exception)
{ cout << "Класс Derived!\n"; } // ОШИБКА!
b
Класс exception является суперклассом для класса runtime_error, поэтому мы
не можем заменить тип исключения для типа runt irae_error.
Есть ли польза от throw-списков
Иметь возможность задавать характер поведения функции в ее сигнатуре и не
воспользоваться этим — не кажется ли такой подход слишком расточительным?
Исключения, сгенерированные в конкретной функции, составляют важную часть ее
интерфейса и должны быть документированы с максимальной полнотой.
К сожалению, С++-код (в большинстве своем), включая стандартную
библиотеку, свидетельствует о том, что его создатели не следовали этому совету. Поэтому
474 Часть III. Освоение суперсредств C++
программистам трудно определить, какие исключения могут быть сгенерированы при
использовании того или иного кода. Кроме того, невозможно определить
характеристики исключений для шаблонных функций и методов. Если вам неизвестно, какие
типы объектов будут использованы при реализации шаблона, вы не сможете
определить, исключения каких типов могут сгенерировать эти методы. Наконец, сам
синтаксис throw-списка с точки зрения программиста малоубедителен.
Поэтому решение о применении throw-списков мы оставляем за вами.
Исключения и полиморфизм
Как описано выше, вы можете реально сгенерировать исключение любого типа.
Однако чаще всего в качестве типов исключений используются классы. Классы
исключений, как правило, составляют иерархию, поэтому при перехвате исключений
можно воспользоваться механизмом полиморфизма.
Иерархия стандартных классов исключений
Вы уже видели несколько примеров классов исключений из стандартной С++-
иерархии: exception, runtirae_error и invalid_arguraent. Полностью эта
иерархия показана на рис. 15.3.
exception
bad alloc
badexception
logic_error
domain error
bad_typeid
overflow error
out_of_range
underflow error
Рис. 15.3
*
Все исключения, сгенерированные стандартной С++-библиотекой, являются
объектами классов из этой иерархии. Каждый класс в ней поддерживает метод what (),
который возвращает строку типа char* с описанием этого исключения. Эту строку
можно использовать в сообщении об ошибке.
Все классы исключений (кроме базового класса exception) требуют, чтобы в
конструкторе устанавливалась строка, которую будет возвращать метод what (). Поэтому
вы и должны задавать строку в конструкторах классов runtirae_error и invalid_
argument. Теперь, зная, для чего используются эти строки, можно сделать их более
полезными. Рассмотрим пример, в котором строка используется для передачи
полного сообщения об ошибке.
Глава 15. Обработка ошибок 475
void readlntegerFile(const strings fileName,
vector<int>& dest)
throw (invalid_argument, runtime_error)
{
ifstream istr;
int temp;
istr.open(fileName.c_str());
if (istr.failO) {
// He удалось открыть файл: генерируем исключение.
string error = "Не удалось открыть файл " + fileName;
throw invalid_argument(error);
}
// Считываем целочисленные значения одно за другим и
// добавляем их в вектор.
while (istr >> temp) {
dest.push back(temp);
}
if (istr.eofO) {
// Достигнут конец файла.
istr.close();
} else {
// Какая-то иная ошибка. Генерируем исключение.
istr.close();
string error = "He удалось прочитать файл " + fileName;
throw runtime_error(error);
}
}
int main(int argc, char** argv)
{
// Код опущен.
try {
readlntegerFile(fileName, mylnts);
} catch (const invalid_argument& e) {
cerr << e.what() << endl;
exit (1);
} catch (const runtimeerrorb e) {
cerr << e.whatO << endl;
exit (1) ;
}
}
// Код опущен,
Перехват исключений в иерархии классов
Иерархия классов исключений позволяет перехватывать исключения
полиморфически. Например, если рассмотреть две catch-инструкции в приведенной выше
функции main (), следующие за обращением к функции readlntegerFile (), то
476 Часть III. Освоение суперсредств C++
нетрудно понять, что они идентичны (различие состоит лишь в классе исключения,
которое они обрабатывают). Классы inval idarguraent и runtiraeerror являются
подклассами класса exception, поэтому можно заменить эти две catch-инструкции
одной, но обрабатывающей класс exception.
int main(int argc, char** argv)
{
// Код опущен.
try {
readlntegerFile(fileName, mylnts);
} catch (const exceptions e) {
cerr << e.what() << endl;
exit (1);
}
// Код опущен.
}
Инструкция catch, в которой используется ссылка на класс exception,
перехватит исключение любого класса, являющегося exception-подклассом, включая классы
inval idargument и runtiraeerror. Обратите внимание на то, что чем выше в
иерархии исключений стоит исключение, которое вы перехватываете, тем менее
специфической должна быть обработка ошибок. Лучше перехватывать исключения на
как можно более конкретизированном уровне.
Если вы организовываете перехват исключений полиморфически,
обеспечьте передачу класса исключения по ссылке. При перехвате
исключений по значению может возникнуть эффект расслоения, в
результате которого вы неизбежно потеряете информацию от объекта.
Подробнее об эффекте расслоения читайте в главе 10.
Правила полиморфного совпадения (типов) работают по принципу "первым
пришел — первым обслужен". В C++ реализован механизм поиска совпадения типа
сгенерированного исключения с типом, заданным в catch-инструкциях в порядке их
следования. Совпадение происходит в случае, если в текущей catch-инструкции
обнаружен объект искомого класса или объект подкласса искомого класса, даже если
более точное совпадение произойдет в одной из нижестоящих catch-инструкций.
Например, предположим, что мы хотим явным образом перехватить исключение
типа invalid_argument из функции readlntegerFile (), но оставить при этом
общий тип exception для совпадения с любыми другими исключениями. Корректный
способ реализации такого намерения выглядит так.
try {
readlntegerFile(fileName, mylnts);
} catch (const invalid_argument& e) { // Сначала берем
// подкласс класса exception. Предпринимаем
// специальные действия в случае задания
// неверного имени.
} catch (const exceptions e) { // Теперь берем
// суперкласс exception,
cerr << е.what() << endl;
exit (1) ;
}
Первая catch-инструкция предназначена для перехвата исключений типа inva-
lidarguraent, а вторая — для перехвата исключений любого другого типа. Но если
Глава 15. Обработка ошибок 477
поменять порядок следования этих catch-инструкций на противоположный,
результат будет другим.
try {
readlntegerFile(fileName, mylnts);
} catch (const exceptions e) { // ОШИБКА: перехват объекта
// суперкласса стоит первым!
сегг << е.what() << endl;
exit (l);
} catch (const invalid_argument& e) {
// Предпринимаем специальные действия в случае задания
// неверного имени.
}
При таком порядке следования catch-инструкций любое исключение класса,
который является производным от класса exception, будет перехвачено первой catch-
инструкцией; вторая же никогда не будет выполнена. Некоторые компиляторы в
таких случаях выдают предупреждающее сообщение, но вам не стоит рассчитывать на
такую предупредительность.
Создание собственных классов исключений
В создании собственных классов исключений есть два положительных момента.
1. Количество исключений в стандартной библиотеке C++ ограничено. Вместо
использования класса исключений с таким общим именем, как runt ime_except ion,
можно создавать классы с именами, которые более "красноречиво" выражают
суть конкретных ошибок в программах.
2. В такие классы исключений можно добавлять собственную информацию.
Исключения, относящиеся к стандартной иерархии, позволяют устанавливать
только строку с уведомлением об ошибке. "Играя по собственным правилам",
вы могли бы передавать в исключение самую разную информацию.
Мы рекомендуем, чтобы все классы исключений, которые вы будете создавать,
были прямыми или косвенными потомками стандартного класса exception. Если все
участники вашего проекта будут следовать этому правилу, вы будете знать, что каждое
исключение в вашей программе является производным от класса exception (если,
конечно, вы не используете библиотеки, созданные сторонними организациями,
которые не подчиняются этому правилу). Такой подход значительно упрощает
обработку исключений благодаря использованию полиморфизма.
Например, классы invalid_argument и runtime_error не очень хорошо
справляются с обработкой ошибок, связанных с открытием файла и считыванием из него данных,
в функции readlntegerFile (). Вы можете определить собственную иерархию
классов для обработки ошибок при работе с файлами, начиная с общего класса FileError.
class FileError : public runtime_error
{
public:
FileError(const strings fileln) : runtime_error(""),
mFile(fileln) {}
virtual -FileError() throw() {}
virtual const char* what() const throw()
{ return mMsg.cstr(); }
string getFileName() { return mFile; }
478 Часть III. Освоение суперсредств C++
protected:
string mFile, mMsg,-
};
Вам следует сделать класс FileError частью стандартной иерархии классов
исключений. Вероятно, будет вполне уместно интегрировать его в качестве прямого
потомка класса runtirae_error. При написании подкласса класса runtirae_error (или
любого другого класса исключений из стандартной иерархии) необходимо
переопределить два метода: what () и деструктор.
По сигнатуре метода what () видно, что он должен возвращать строку типа char*,
которая действительна до тех пор, пока объект не разрушен. В случае использования
класса FileError эта строка связана с членом данных mMsg, который в конструкторе
устанавливается равным пустой строке (" ")• Если вы хотите иметь другое сообщение,
в подклассах класса FileError необходимо по-другому устанавливать строку mMsg.
Чтобы задать пустой throw-список, необходимо переопределить деструктор.
Деструктор, генерируемый компилятором, не будет иметь throw-списка вообще, но
такой вариант не скомпилируется, поскольку в классе runtime_error throw-список
определен пустым.
Общий класс FileError также содержит имя файла и метод доступа,
позволяющий узнать это имя файла.
Первая исключительная ситуация в функции readlntegerFile () возникает
в случае, если файл не удается открыть. Таким образом, имеет смысл написать класс
FileOpenError как производный от класса FileError.
class FileOpenError : public FileError
{
public:
FileOpenError(const strings fileNameln);
virtual «FileOpenError() throw() {}
b
FileOpenError::FileOpenError(const strings fileNameln) :
FileError(fileNameln)
{
mMsg = "He удалось открыть файл " + fileNameln;
}
Конструктор класса FileOpenError изменяет строку mMsg, чтобы представить
ошибку при открытии файла.
Вторая исключительная ситуация в функции readlntegerFile () возникает в
случае, если не удается должным образом считать содержимое файла. Здесь было бы
полезно, чтобы исключение содержало номер строки, при считывании которой
обнаружилась ошибка в файле, а также имя файла и строку с сообщением об ошибке,
возвращаемую методом what (). Вот как может выглядеть подкласс FileReadError
класса FileError.
class FileReadError : public FileError
{
public:
FileReadError(const strings fileNameln, int lineNumln); *
virtual -FileReadError() throw() {}
int getLineNum() { return mLineNum,- }
protected:
Глава 15. Обработка ошибок 479
int mLineNum;
};
FileReadError::FileReadError(const strings fileNameln,
int lineNumln) :
FileError(fileNameln), mLineNum(lineNumln)
{
ostringstream ostr;
ostr << "Ошибка при чтении файла " << fileNameln
<< " в строке " << lineNumln;
mMsg = ostr.str();
}
Чтобы установить соответствующим образом номер строки, необходимо
модифицировать функцию readlntegerFile (), чтобы она не просто считывала целочисленные
значения, а могла отслеживать количество считанных строк. Рассмотрим новый
вариант функции readlntegerFile (), которая использует новые типы исключений.
void readlntegerFile(const strings fileName,
vector<int>& dest)
throw (FileOpenError, FileReadError)
{
ifstream istr;
int temp;
char line[1024]; // Предположим, что длина каждой строки
// не превышает 1024 символа.
int lineNumber = 0;
istr.open(fileName.c_str());
if (istr.failO) {
// He удалось открыть файл: генерируем исключение.
throw FileOpenError(fileName);
}
while (listr.eof()) {
// Считываем из файла одну строку.
istr.getline(line, 1024);
lineNumber++;
// Создаем строковый поток,
istringstream lineStream(line);
// Считываем целочисленные значения одно за другим
// и добавляем их в вектор.
while (lineStream >> temp) {
dest.push_back(temp);
}
if (!lineStream.eof())/{
// Какая-то иная ошибка. Закрываем файл
// и генерируем исключение.
istr.close();
throw FileReadError (fileName, lineNumber) ,-
istr.close ();
}
Теперь код, который вызывает функцию readlntegerFile (), может
использовать полиморфизм для перехвата исключений типа FileError таким образом.
try {
readlntegerFile(fileName, mylnts);
} catch (const FileErrorfc e) {
480 Часть III. Освоение суперсредств C++
cerr << е.what() << endl;
exit (1);
}
При написании классов, объекты которых будут служить в качестве исключений,
можно использовать следующий прием. Если некоторый код генерирует исключение,
создаваемый при этом объект (или значение) подлежит копированию. Другими
словами, с помощью конструктора копии на базе старого объекта создается новый. Без
копирования не обойтись, поскольку исходный объект выйдет за рамки области видимости
(и будет разрушен, а занимаемая им память — освобождена) еще до перехвата
исключения. Таким образом, если вы будете создавать класс, объекты которого должны
генерироваться как исключения, вам необходимо сделать эти объекты копируемыми. Это
означает, что при использовании средств динамического выделения памяти вы должны
написать деструктор, конструктор копии и оператор присваивания, как описано в главе 9.
Объекты, генерируемые как исключения, всегда копируются по
значению (по крайней мере один раз).
Объекты исключений можно копировать более одного раза, но только в том
случае, если исключение перехватывается по значению, а не по ссылке.
Чтобы избежать ненужного копирования, перехватывайте объекты
исключений по ссылке.
"Раскручивание"и очистка стека
При генерировании некоторым кодом исключения управление программой
немедленно передается обработчику, который перехватывает это исключение. Вполне
вероятна ситуация, когда обработчик исключений мог поместить в стек один или
несколько вызовов функций. Если управление передается в стек в процессе, именуемом
"раскручиванием" стека (stack unwinding), весь код, оставшийся в каждой функции
после текущей инструкции, опускается. Однако локальные объекты и переменные
в каждой функции, которая подверглась "раскручиванию", разрушаются, как это
происходит при нормальном завершении выполнения кода функции.
Однако в процессе "раскручивания" стека не освобождаются переменные-
указатели и не выполняются другие "очистительно-восстановительные" операции. Такое
поведение, как показано на примере следующего кода, может вызывать
определенные проблемы.
#include <fstream>
#include <iostream>
#include <stdexcept>
using namespace std,-
void funcOneO throw(exception) ;
void funcTwoO throw (exception) ;
int main(int argc, char** argv)
{
try {
funcOne();
} catch (exceptions e) {
Глава 15. Обработка ошибок 481
сегг << "Исключение перехвачено!\п";
exit(1);
}
return (0);
}
void funcOneO throw (exception)
{
string strl;
string* str2 = new string();
funcTwo();
delete str2;
}
void funcTwoO throw (exception)
{
ifstream istr;
istr.open("f ilename");
throw exceptionO;
istr. close () ,-
}
Предположим, что функция f uncTwo () генерирует исключение, ближайший
обработчик которого находится в функции main (). В результате управление будет
немедленно передано от инструкции
throw exception () ,-
в функции f uncTwo () к этой инструкции (расположенной в функции main ()).
сегг << "Исключение перехвачено!\п";
После того как в функции f uncTwo () было сгенерировано исключение, все
инструкции кода, расположенные после строки, в которой это произошло, никогда не
будут выполнены. Другими словами, следующая строка после генерирования
исключения потеряла все шансы на выполнение.
istr.close() ;
Но, к счастью, при этом вызывается деструктор класса if stream, поскольку
переменная istr является локальной и хранится в памяти стека. Деструктор класса
if stream закроет за нас файл, поэтому утечки ресурсов здесь не будет. Если бы мы
динамически создали объект istr, он бы не был разрушен, и файл остался открытым.
В функции f uncOne () был выполнен код до обращения к функции f uncTwo (),
поэтому следующая строка навсегда осталась невыполненной.
delete str2;
В данном случае таки имеет место утечка памяти. В процессе "раскручивания"
стека автоматически не будет выполнен оператор delete для переменной str2. При
этом переменная strl разрушается без осложнений, поскольку она является
локальной переменной, созданной в области стека. В процессе "раскручивания" стека
разрушение всех локальных переменных происходит корректно.
Неосторожное обращение с механизмом обработки исключений
может привести к утечке памяти и других ресурсов.
В следующих разделах рассматриваются возможные методы решения описанной
проблемы.
482 Часть III. Освоение суперсредств C++
Перехват, очистка и повторное генерирование исключений
Первый и самый распространенный способ избежать утечек памяти и других
ресурсов состоит в следующем. Для перехвата в каждой функции любых возможных
исключений выполняются необходимые действия "очистительно-восстановительного"
характера, а затем повторно генерируется исключение для функции, которая в
стековой очереди на обработку занимает более "высокое" положение. Вот как выглядит
исправленный вариант функции f uncOne ().
void funcOneO throw (exception)
{
string strl;
string* str2 = new string();
try {
funcTwo();
} catch (...) {
delete str2;
throw) // Повторное генерирование исключения.
}
delete str2,-
}
Эта функция заключает обращение к функции f uncTwo () в рамки блока try-
catch, т.е. сопровождает вызов этой функции обработчиком исключения, который
выполняет необходимые очистительные операции (вызывает оператор delete для
динамически созданной переменной str2), а затем повторно генерирует
исключение. Ключевое слово throw (одно, без дополнительных выражений) обеспечивает
повторное генерирование исключения того типа, которое было перехвачено
последним по времени. Обратите внимание на то, что в инструкции catch используется
синтаксис (...), означающий перехват исключения любого типа.
Этот метод прекрасно работает, но может показаться несколько беспорядочным.
В частности, обратите внимание на то, что в этом варианте мы имеем две идентичные
строки кода, которые вызывают оператор delete для переменной str2: одна для
обработки исключения и еще одна в той части тела функции, которая выполнится, если
функция завершится нормально (без "выбросов" исключений).
Использование интеллектуальных указателей
Интеллектуальные указатели позволяют писать код, который автоматически
предотвращает возможность утечки памяти при обработке исключений. Как
упоминалось в главе 13, объекты интеллектуальных указателей размещаются в стековой
области памяти, поэтому при их разрушении вызывается оператор delete для
базовых переменных-указателей. Рассмотрим пример функции f uncTwo (), в которой
используется шаблонный класс интеллектуального указателя auto_ptr,
определенного в стандартной библиотеке.
#include <memory>
using namespace std,-
void funcOneO throw (exception)
{
string strl,-
auto_ptr<string> str2(new string("Привет"));
funcTwo();
}
Глава 15. Обработка ошибок 483
Используя интеллектуальные указатели, вы не сможете забыть о применении
оператора delete к базовой переменной-указателю: деструктор интеллектуального
указателя сделает это за вас в любом случае: при нормальном завершении функции или
в ситуации ее "досрочного" выполнения в результате генерирования исключения.
Распространенные проблемы обработки
ошибок
Использовать исключения в программах или нет — решать вам или вашим
коллегам. Однако мы настоятельно рекомендуем вам формализовать план обработки
ошибок применительно к своим программам, независимо от того, используете вы
механизм исключений или нет. Если вы их сторонник, то вам в общем случае будет легче
применить некую унифицированную схему обработки ошибок, которая без
исключений попросту невозможна. Самый важный аспект эффективного планирования
состоит в единообразии подходов ко всем модулям приложения. Это важно довести до
сведения каждого участника проекта и убедиться в том, что все программисты
понимают эти правила и следуют им.
В данном разделе рассматриваются самые распространенные проблемы обработки
ошибок в контексте исключений, хотя описываемые здесь проблемы также
релевантны для программ, в которых исключения не используются.
Ошибки, связанные с распределением памяти
Несмотря на тот факт, что во всех примерах программ, приведенных до сих пор
в данной книге, возможность неуспешного распределения памяти игнорировалась,
это, к сожалению, вполне реально. Поэтому в профессиональном коде просто-таки
необходимо учитывать не всегда успешный поворот событий. Для обработки ошибок,
связанных с распределением памяти, в C++ предусмотрено несколько различных способов.
Если операторам new и new [ ] не удается выделить затребованную память, то по
умолчанию они генерируют исключение типа badalloc, определенное в
заголовочном файле <new>. Ваш код должен перехватывать эти исключения и обрабатывать их
соответствующим образом. Смысл слов "соответствующим образом" зависит от
конкретного приложения. В некоторых случаях для корректной работы программ фактор памяти
может быть критичным, и тогда лучше всего вывести сообщение об ошибке и обеспечить
"достойный" выход из программы. В других случаях память, возможно, нужна только для
выполнения отдельной операции или задачи, и тогда можно ограничиться лишь выводом
сообщения об ошибке, запретив, безусловно, выполнение "крамольной" операции,
а затем "спокойно" продолжить выполнение остальной программы.
Таким образом, все ваши new-инструкции должны выглядеть так.
try {
ptr = new int[numlnts];
} catch (bad_alloc& e) {
cerr << "He удается выделить память!\n";
// Обрабатываем ситуацию невозможности выделить память.
return;
}
// Продолжаем работу функции, предполагая,
// что затребованная память выделена.
484 Часть III. Освоение суперсредств C++
Вы могли бы, если это, конечно, подходит для вашей программы, обрабатывать
множество возможных "сбоев" в работе оператора new с помощью одного-единствен-
ного блока try/catch где-нибудь в начальной части программы.
При этом необходимо иметь в виду, что сама процедура регистрации ошибки
может требовать выделения памяти. Если оператору new не удалось выполнить свою
работу для целей программы, то не исключено, что оставшейся в системе памяти
недостаточно даже для записи сообщения об ошибке.
Использование по throw-версии оператора new
Как упоминалось в главе 13, если вы предпочитаете не иметь дела с
исключениями, то можете вернуться к старой С-модели, в которой функции распределения
памяти в случае неудачи возвращают NULL-указатель. В C++ также предусмотрены nothrow-
версии операторов new и new [ ] , которые при неуспешной попытке выделить память
не генерируют исключение, а возвращают значение NULL.
ptr = new(nothrow) int[numlnts];
if (ptr == NULL) {
cerr << "He удается выделить память!\п";
// Обрабатываем ситуацию невозможности выделить память.
return;
}
// Продолжаем работу функции, предполагая,
// что затребованная память выделена.
Используемый здесь синтаксис выглядит несколько "странновато": ключевое
слово no throw (означающее "не генерировать исключение") выступает в роли аргумента
оператора new (и, надо сказать, успешно ее "играет").
Формирование поведения в случае неудачного выделения памяти
Язык C++ позволяет задавать в качестве нов.ого обработчика ошибок функцию
обратного вызова. По умолчанию никакого нового обработчика не существует, поэтому
операторы new и new [] попросту генерируют исключения типа bad_alloc. Но в
случае существования такого нового обработчика функция выделения памяти при
неудачной попытке выполнить свою задачу (вместо генерирования исключения)
вызовет именно его. Если новый обработчик успешно завершится, функция выделения
памяти сделает очередную попытку и при повторной неудаче опять вызовет новый
обработчик. Этот цикл станет бесконечным, если ваш новый обработчик не изменит
ситуацию с помощью одной из следующих четырех альтернатив.
□ Сделайте так, чтобы доступной памяти стало больше. Один из "хитроумных"
способов добиться этого— выделить большую область памяти на начальном
этапе выполнения программы, а затем освободить ее с помощью оператора
delete в вашем новом обработчике. Если текущий запрос на получение памяти
не превышает по размеру область, которая освобождается в новом
обработчике, функция выделения памяти теперь сможет реализовать свой потенциал.
Однако этот метод не дает большого выигрыша. Если бы вы не "зарезервировали"
заранее этот объем памяти, то при достаточных ресурсах памяти в системе ваш
запрос был бы "и так" удовлетворен в первую очередь (и вам бы не пришлось
обращаться к новому обработчику). Польза же от этого варианта, скорее,
состоит лишь в возможности зарегистрировать в новом обработчике
предупреждающее сообщение о недостатке памяти.
Глава 15. Обработка ошибок 485
□ Сгенерируйте исключение. Операторы new и new [ ] оснащены throw-списками,
которые означают возможность генерирования исключений только типа
badalloc. Поэтому, если в ваши планы не входит обращение к функции
unexpected () и если уж вы собрались генерировать исключение из нового
обработчика, генерируйте исключение типа badalloc или его подкласса.
Однако для генерирования этого исключения новый обработчик вам не нужен,
поскольку в реализуемом по умолчанию поведении это будет сделано за вас.
Следовательно, если вам больше нечем "занять" ваш новый обработчик, то нет
смысла его писать вообще.
□ Установите другой новый обработчик. Теоретически вы могли бы иметь серию
новых обработчиков, каждый из которых пытался бы "выкроить" память, а в
случае неудачной попытки устанавливал бы другой новый обработчик. Но обычно
такой сценарий скорее усложняет ситуацию, чем позволяет ее "разрегулировать".
□ Завершите программу. Этот вариант самый реальный и полезнее всех
остальных. Ваш новый обработчик может просто зарегистрировать сообщение об
ошибке и завершить программу. Преимущество использования нового
обработчика перед перехватом исключения типа bad_alloc и завершением
программы в обработчике этого исключения состоит в том, что вы
сосредоточиваете обработку отказов в одной функции, и вам не нужно будет "засорять"
свой код блоками try-catch. Если нее в вашей программе возможны ситуации,
когда неудачная попытка выделить память не помешает продолжить
выполнение кода, вы можете в этих случаях до вызова оператора new временно
возвращать ваш новый обработчик к действующему по умолчанию значению NULL.
Если вы в своем новом обработчике не реализуеге один из описанных вариантов,
любая неудачная попытка выделить память станет причиной создания бесконечного цикла.
Новый обработчик можно установить путем обращения к функции set_new_
handler (), объявленной в заголовочном файле <new>. Функция set_new_handler () —
последняя из "трио" С++-функций, предназначенных для установки функций обратного
вызова. Две другие (setterminate () и setunexpected ()) описаны выше в этой
главе. Рассмотрим пример нового обработчика, который регистрирует сообщение об
ошибке и завершает выполнение программы.
void myNewHandler()
{
cerr << "Не удается выделить память. Завершение программы!\п";
abort();
}
Новый обработчик не должен принимать аргументы и возвращать какое-либо
значение. В данном случае новый обработчик для завершения программы вызывает
функцию abort (), объявленную в заголовочном файле <cstdlib>.
Установить новый обработчик можно таким образом.
#include <new>
#include <cstdlib>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
// Код опущен.
486 Часть III. Освоение суперсредств C++
// Устанавливаем новый объект типа new_handler
// и сохраняем старый.
new_handler oldHandler = set_new_handler(myNewHandler);
// Код вызова оператора new.
// Восстанавливаем старый объект типа new_handler.
set_new_handler(oldHandler);
// Код опущен,
return (0) ;
}
Обратите внимание на то, что тип newhandler представляет собой результат
typedef-операции для типа указателя на функцию, который как раз подходит для
функции set_new_handler ().
Ошибки в конструкторах
Еще до открытия С++-программистами механизма исключений им часто
приходилось сталкиваться с проблемой обработки ошибок в конструкторах. А что, если
конструктору не удастся построить объект надлежащим образом? Конструкторы ведь не
возвращают никакого значения, поэтому стандартный "доисторический" (т.е. до
"эры" исключений) механизм обработки ошибок здесь не работает. Не применяя
исключений, самое лучшее, что вы могли бы сделать в этой ситуации, — при
необходимости устанавливать флаг в объекте, означающий, что он создан некорректно. Можно
при этом использовать метод (скажем, с именем checkConstructionStatus ()),
который возвращает значение этого флага, и очень надеяться на то, что клиенты не
забудут вызвать этот метод для только что созданного объекта.
В "эпоху" исключений можно применить гораздо более эффективное решение.
Несмотря на невозможность конструктора возвращать значение, вы можете из него
сгенерировать исключение. С помощью исключения нетрудно уведомить клиента о
результате выполнения конструктора: успешном или нет. Здесь, однако, есть одна
существенная проблема: если исключение "покидает" пределы конструктора, то деструктор
для созданного объекта никогда не будет вызван. Следовательно, вы должны еще в
конструкторе тщательно "подчистить" любые ресурсы и освободить всю выделенную
память до момента выхода исключения за рамки конструктора. Эта проблема неуникальна
(она присуща и другим функциям), но в конструкторах она стоит более остро, поскольку
программисты привыкли "сваливать" всю работу очистительно-восстановительного
характера (освобождение памяти и других ресурсов) на деструкторы.
Рассмотрим пример конструктора класса GameBoard из главы 11,
модифицированного с использованием обработки исключения.
GameBoard::GameBoard(int inWidth,
int inHeight) throw (bad_aHoc) :
mWidth(inWidth), mHeight(inHeight)
{
int i, j;
mCells = new GamePiece* [mWidth] ,-
try {
for (i = 0; i < mWidth,- i++) {
mCells[i] = new GamePiece[mHeight] ;
}
} catch (...) {
II
Глава 15. Обработка ошибок 487
// Освобождаем память, которую мы уже выделили, поскольку
// деструктор для объекта никогда не будет вызван. Верхняя
// граница for-цикла соответствует индексу последнего
// элемента в массиве mCells, который мы пытались создать
// динамически (но попытка "проваливается"). Мы должны
// освободить память с помощью указателей, которые мы
// успели сохранить в массиве до возникновения исключения,
// используя все "занятые" индексы этого массива.
//
for (j = 0; j < i; j++) {
delete [] mCells[j],-
}
delete [] mCells;
// Любое исключение "приводим" к типу bad_alloc.
throw bad_alloc();
Этот способ останется работоспособным даже тогда, когда исключение будет
сгенерировано первым же оператором new, поскольку в этом случае конструктор еще не
успеет вьщелить память, которую придется освобождать. Если же исключение будет
сгенерировано любым из последующих операторов new, конструктор обязан
освободить всю уже выделенную на тот момент память. Здесь перехватывается любое
исключение, поскольку неизвестно, исключения какого типа конструкторы класса
GamePiece могут сами генерировать.
Вам, вероятно, интересно, что произойдет при добавлении наследственных
факторов. Известно, что конструкторы суперкласса выполняются до конструкторов
подклассов. Если конструктор подкласса сгенерирует исключение, как тогда будут
освобождены ресурсы, которые выделил конструктор суперкласса? Ответ таков: C++
гарантирует, что он выполнит деструктор для любых полностью построенных
"нодобъектов". Следовательно, любой конструктор, который выполнился нормально
(без исключения), заставит "отработать" соответствующий деструктор.
Ошибки в деструкторах
Необходимо обрабатывать все ошибочные ситуации, возникающие в самих
деструкторах. Вы не должны позволить, чтобы из деструктора генерировалось
исключение по трем следующим причинам.
1. Деструкторы могут работать, пока обрабатывается другое исключение, т.е. в
процессе "раскручивания" стека. Если сгенерировать исключение из деструктора в то
время, пока еще активно другое исключение, программа будет завершена. Для
смелых и пытливых добавим, что в C++ предусмотрена возможность определить в
деструкторе текущую ситуацию: то ли данное выполнение деструктора является
результатом нормального завершения функции либо вызова оператора delete, то ли
происходит "раскручивание" стека. Функция uncaught_exception(),
объявленная в заголовочном файле <exception>, возвращает значение true, если в данный
момент существует неперхваченное исключение, и вы "находитесь" в середине
"раскручивания" стека. В противном случае она возвращает значение false.
Однако такой подход может "спутать карты", и поэтому его следует избегать.
2. Подумайте о своих клиентах. Клиенты не вызывают деструкторы явно: они вы-
. зывают оператор delete, который вызывает деструктор. Если вы сгенерируете
исключение из деструктора, то какие действия должен, по вашему, предпринять
488 Часть III. Освоение суперсредств C++
клиент? Он не сможет снова вызвать оператор delete для того же объекта, да он
и не должен вызывать деструктор явным образом. Невозможно представить себе
никаких разумных действий, которые мог бы предпринять клиент, поэтому не
существует веской причины перегружать код деструктора обработкой исключений.
3. Деструктор — это ваш шанс освободить память и другие ресурсы, используемые
объектом. Если вы утратите этот шанс в результате преждевременного выхода
из функции деструктора из-за исключения, вы никогда не сможете вернуться
назад и освободить ресурсы.
Таким образом, осторожней отнеситесь к перехвату в деструкторе любых
исключений, которые могут быть сгенерированы вызовами каких-либо функций или
методов из деструктора. Если ваши деструкторы будут вызывать лишь операторы delete
и delete [], которые не могут генерировать исключения (что является обычной
практикой), у вас проблем не будет.
Теперь соберем все в одну кучу
Теперь, когда вы больше узнали об обработке ошибок и исключений, рассмотрим
полный вариант класса GameBoard из главы 11с применением исключений.
Итак, вот определение этого класса.
#include <stdexcept>
# inc1ude <new>
using std::bad_alloc;
using std::out_of_range;
class GameBoard
{
public:
GameBoard(int inWidth = kDefaultWidth,
int inHeight = kDefaultHeight)
throw(bad_alloc);
GameBoard (const GameBoardu src) throw(bad_a11oc);
-GameBoard() throw();
GameBoard& operator=(const GameBoard& rhs)
throw(bad_alloc);
void setPieceAt(int x, int y, const GamePiece& inPiece)
throw(out_of_range);
GamePiece& getPieceAt(int x, int y) throw(out_of range);
const GamePiece& getPieceAt(int x, int y) const
throw(out_of_range);
int getHeight() const throw() { return mHeight; }
int getWidthO const throw () { return mWidth; }
static const int kDefaultWidth = 100;
static const int kDefaultHeight = 100;
protected:
void copyFrom(const GameBoardu src) throw(bad_alloc);
Глава 15. Обработка ошибок 489
GamePiece** mCells;
int mWidth, mHeight;
};
Конструкторы и операторный метод operator= () генерируют исключения типа
badalloc, поскольку все они занимаются выделением памяти. Деструктор, а также
методы getHeight () и getWidth () не генерируют исключений вообще. Методы
setPeiceAt () и getPieceAt () генерируют исключения типа out_of_range, если
инициатор их вызова передаст некорректные значения ширины или высоты игровой доски.
В предыдущем разделе вы уже видели реализацию конструктора класса GameBoard.
Теперь рассмотрим реализации методов copyFrom (), setPieceAt () и getPieceAt ()
с применением обработки исключений. Реализации конструктора копии и метода
operator= () не изменились, за исключением их throw-списков, поскольку вся их
основная работа заключена в теле метода copyFrom (), поэтому их реализации здесь
не приведены. Деструктор также не изменился, поэтому и его реализация опущена.
Необходимые детали можно найти в главе 11.
void GameBoard::copyFrom(const GameBoard& src) throw(bad_alloc)
{
int i, j ;
mWidth = src. mWidth,-
mHeight = src.mHeight;
mCells = new GamePiece *[mWidth];
try {
for (i = 0; i < mWidth; i++) {
mCells[i] = new GamePiece[mHeight];
}
} catch (...) {
// Освобождаем любую память, которую мы уже выделили.
// Если эта функция вызывается из конструктора копии,
// деструктор никогда не будет вызван.
// Используем тот же самый верхний индекс для цикла,
// как описано в конструкторе,
for (j = 0; j < i; j++) {
delete [] mCells [j];
}
delete [] mCells;
// Устанавливаем переменные mCells и mWidth равными
// значениям, которые позволят деструктору выполняться
// без вреда для "окружающей среды".
// Если эта функция вызывается из метода operator=(),
// то, значит, объект уже был создан, поэтому будет
// вызван деструктор.
mCells = NULL;
mWidth = 0;
throw bad_alloc();
}
for (i = 0; i < mWidth; i++) {
for (j = 0; j < mHeight; j++) {
mCells[i] [j] = src.mCells [i] [j];
}
490 Часть III. Освоение суперсредств C++
}
}
void GameBoard::setPieceAt(int x, int y,
const GamePiece& inElem)
throw(out_of_range)
{
// Проверяем, чтобы аргументы не оказались вне досягаемости.
if (х < 0 || х >= mwidth || у < 0 || у >= mHeight) {
throw out_of_range(
"Некорректные значения ширины или высоты.");
}
tnCells [x] [у] = inElem,-
}
GamePiece& GameBoard::getPieceAt(int x, int y)
throw(out_of_range)
{
// Проверяем, чтобы аргументы не оказались вне досягаемости,
if (х < 0 || х >= mWidth || у < 0 || у >= mHeight) {
throw out_of_range(
"Некорректные значения ширины или высоты.");
}
return (mCells Гх] [у]);
}
const GameP.iece& GameBoard: :getPieceAt (int x, int y)
const throw(out_of_range)
{
// Проверяем, чтобы аргументы не оказались вне досягаемости.
if (х < 0 || х >= mWidth || у < 0 || у >= mHeight) {
throw out_of_range(
"Некорректные значения ширины или высоты.");
}
return (mCells[х][у]);
}
Резюме
Эта глава посвящена вопросам, связанным с обработкой ошибок в С++-
программах, причем акцент был сделан на необходимости учета этого аспекта с
самого начала проектирования и кодирования программ. Здесь вы изучили детали С++-
синтаксиса исключений и особенностей поведения. Кроме того, вы узнали о том,
в каких областях обработка ошибок играет особо важную роль (потоки ввода-вывода,
распределение памяти, создание конструкторов и деструкторов). Наконец, вы
рассмотрели пример обработки ошибок в классе GameBoard.
В следующих нескольких главах мы продолжим рассмотрение наиболее трудных
тем языка C++. В главе 16 описывается перегрузка операторов, глава 17 посвящена
вопросам производительности в C++, а из главы 18 вы узнаете о том, как можно
сочетать C++ с другими языками программирования и выполнять программы на
различных платформах.
Часть IV
Как создать код без ошибок
В ЭТОЙ ЧАСТИ...
Глава 16. Перегрузка С++-операторов
Глава 17. Создание эффективных С++-программ
Глава 18. Разработка межплатформенных приложений
Глава 19. Становимся экспертами в области тестирования
программ
Глава 20. Что нужно знать об отладке
Перегрузка С++-
операторов
В C++ для классов (создаваемых программистом) разрешено переопределять
смысловое значение таких операторов, как "+","-" и "=". Многие
объектно-ориентированные языки программирования такой возможности не предоставляют, но вам вряд ли
стоит пренебрегать этим полезным механизмом в C++. Только представьте себе, как
будет удобно, если поведение ваших классов будет сравнимо с поведением переменных
таких встроенных типов, как int и double. Используя это средство, можно написать
классы, которые будут похожи на массивы, функции или даже указатели!
В главах 3 и 5 вы получили представление об объектно-ориентированном
проектировании и перегрузке операторов соответственно. В главах 8 и 9 рассматривались
детали синтаксиса применительно к объектам и перегрузке базовых операторов.
В этой же главе мы уделим внимание тем вопросам перегрузки операторов, которые не
были затронуты в главе 9. Необходимо отметить, что в библиотеке STL, с которой вы
познакомились в главе 4 (и которая более подробно описана в главах 21—23),
механизм перегрузки операторов используется весьма широко. Поэтому, прежде чем
переходить к главам 21—23, вам следует хорошо разобраться в материале данной главы.
В этой главе акцент ставится на синтаксисе и базовой семантике перегрузки
операторов. Здесь представлены практические примеры для большинства операторов,
но не для всех. Использование механизма перегрузки для остальных операторов
продемонстрировано в последующих главах этой книги.
Глава 16. Перегрузка С++-операторов 493
Эта глава включает такие темы.
□ Понятие о перегрузке операторов.
□ Логическое обоснование для применения перегрузки операторов.
□ Ограничения, предостережения и рассмотрение альтернатив при
перегрузке операторов.
□ Таблица операторов, которые вы можете, не можете и не должны перегружать.
□ Как перегрузить унарный "плюс", унарный "минус", инкремент и декремент.
Q Как перегрузить потоковые операторы ввода-вывода (operator<< и
operators).
□ Как перегрузить оператор доступа к массиву (оператор индексации массива).
Q Как перегрузить оператор вызова функции.
□ Как перегрузить операторы разыменования (" * " и " - > ").
□ Как написать операторы преобразования.
□ Как перегрузить операторы выделения и освобождения памяти.
Понятие о перегрузке операторов
Как отмечалось в главе 1, операторы в C++ представляются символами, например
"+", "<", "*" и "<<". Они работают со значениями таких встроенных типов, как int
и double, и позволяют выполнять арифметические, логические и другие операции.
Есть и другие операторы (например, "->" и "&"), которые предназначены для
разыменования указателей. Понятие оператора в C++ трактуется довольно широко и даже
включает средство доступа к элементам массива " [] ", или оператор индексации
массива, оператор вызова функций " () " и средства выделения и освобождения памяти.
Перегрузка операторов позволяет изменять поведение языковых операторов для
классов, создаваемых программистом. Но эта возможность сопряжена с
необходимостью соблюдения правил, ограничений и принятия решений при наличии нескольких
альтернатив.
Зачем перегружать операторы
Прежде чем разбираться в том, как перегружать операторы, стоит понять, зачем
это может понадобиться. Для разных операторов эти причины различны, но
основной мотив — заставить свои классы вести себя подобно встроенным типам. Чем
ближе ваши классы будут к встроенным типам, тем проще их использовать. Например,
если вы хотите написать класс для представления дробей, будет весьма полезным
иметь возможность определить, что означают операторы "+", "<", "*" и "/"
применительно к объектам этого класса.
Еще одна причина для перегрузки операторов — получить больше степеней
свободы в управлении поведением своей программы. Например, вы можете перегрузить
средства выделения и освобождения памяти для своих классов, чтобы точно указать,
как должна распределяться и восстанавливаться память для каждого вашего объекта.
Важно подчеркнуть, что перегрузка операторов необязательно облегчит жизнь
вам как разработчику классов; ваша основная цель — облегчить жизнь пользователям
ваших классов.
494 Часть IV. Как создать код без ошибок
Ограничения для перегрузки операторов
Теперь обсудим, чего нельзя делать при перегрузке операторов.
□ Нельзя добавлять новый символ оператора. Вы можете переопределить только
значения операторов, уже имеющихся в языке программирования. В таблице,
приведенной в разделе "Резюме о перегружаемых операторах", перечислены
все операторы, которые можно перегружать.
□ Существует несколько операторов, которые перегружать нельзя. Это, например,
оператор "точка" (оператор доступа к членам объекта), оператор разрешения
контекста ": :", sizeof, тернарный оператор "? :" и ряд других. В только что
упомянутой таблице перечислены все операторы, которые можно перегружать.
С операторами, которые перегружать нельзя, обычно и не имеет смысл это делать,
поэтому мы не думаем, что вы сочтете это ограничение нарушением ваших прав.
Q При перегрузке нельзя изменять арность оператора. Арность описывает
количество аргументов, или операндов, связанных с оператором. Унарные
операторы, (например, "++") работают только с одним операндом, а бинарные
(например, "+") — только с двумя. Существует лишь один тернарный оператор:
"? : ". Источником беспокойства это ограничение может послужить лишь при
перегрузке оператора доступа к элементам массива " [] ", но об этом чуть ниже.
□ Нельзя менять приоритет выполнения операторов. Эти правила определяют,
в каком порядке должны вычисляться операторы в заданном выражении или
инструкции. И снова-таки, это ограничение не должно вас волновать,
поскольку редко можно получить выгоду от изменения существующего порядка
вычисления операторов.
□ Нельзя переопределять операторы для встроенных типов. Оператор должен
представлять собой метод вашего класса, или по крайней мере один из
аргументов глобальной перегружаемой операторной функции должен иметь тип,
определенный пользователем (например, класс). Это означает, что вы не можете сделать
что-либо "эдакое", например, переопределить оператор "+" для int-значений так,
чтобы он выполнял вычитание (хотя подобную глупость вы успешно можете
сотворить для своих классов). Из этого правила есть одно исключение, и оно связано со
средствами выделения и освобождения памяти: вы можете изменить глобальные
процедуры для всех операций по распределению памяти в вашей программе.
Некоторые операторы уже имеют разные значения. Например, оператор "-"
может использоваться как бинарный (например, в инструкции х = у - z;) или как
унарный (как в инструкции х = -у;). Оператор "*" может служить для выполнения
операции умножения или для разыменования указателя. Оператор "<<", в
зависимости от контекста, может действовать как оператор вывода или как оператор сдвига
влево. Вы же можете перегружать оба значения таких операторов "с двойным дном".
Рассмотрение альтернатив при перегрузке операторов
Для того чтобы перегрузить оператор, необходимо написать функцию или метод
с именем operators, где X— символ, обозначающий этот оператор. Например, в
главе 9 было приведено такое объявление оператора "+" (operator+) для объектов типа
SpreadsheetCell.
Глава 16. Перегрузка С++-операторов 495
friend const SpreadsheetCell operator+(
const SpreadsheetCell& lhs,
const SpreadsheetCell& rhs) ,-
Перегрузку операторов можно реализовать различными способами. Ваша
задача — выбрать оптимальный.
Метод или глобальная функция
Прежде всего, вы должны решить, как будет реализован ваш оператор: методом
вашего класса или глобальной функцией (обычно friend-функцией класса). Как
сделать правильный выбор? Для начала необходимо понять различие между этими двумя
вариантами. Если оператор реализован как метод класса, то выражение, стоящее
слева от оператора, должно всегда быть объектом этого класса. Если же вы пишете
глобальную функцию, левый операнд может быть объектом другого типа.
Существует три различных типа операторов.
□ Операторы, которые должны быть методами. Язык C++ требует, чтобы
некоторые операторы были методами класса, поскольку они не имеют смысла вне
класса. Например, оператор operator= настолько тесно связан с классом, что
не может существовать в каком-либо другом контексте. В таблице, приведенной
в разделе "Резюме о перегружаемых операторах", перечислены операторы,
которые должны быть представлены методами. При реализации таких
операторов выбор между методом и глобальной функцией делается очень просто!
Однако большинство операторов не соответствует этому требованию.
□ Операторы, которые должны быть глобальными функциями. Если вам нужно
разрешить, чтобы слева от оператора могла находиться переменная, тип которой
не совпадает с типом вашего класса, вы должны реализовать этот оператор в виде
глобальной функции. Это правило особенно касается операторов operator«
Hoperator>>, поскольку слева от них находится объект типа iostream, а не
объект вашего класса. Такие коммутативные операторы, как бинарные "+" и "-",
также должны позволять, чтобы слева от них могла стоять переменная, которая
не является объектом вашего класса. Эта проблема разъяснялась выше в главе 9.
□ Операторы, которые могут быть либо методами, либо глобальными
функциями. Существует некоторое расхождение во мнениях в С++-сообществе в
отношении того, как лучше реализовать перегрузку операторов: в виде методов
или глобальных функций. Мы же рекомендуем использовать следующее
правило: делать каждый оператор методом, если вы не должны его сделать
глобальной функцией в соответствии с приведенным выше описанием. В пользу этого
правила есть весомый аргумент: методы могут быть виртуальными, а friend-
функции — нет. Следовательно, если в иерархическом дереве наследования вы
планируете написать перегруженные операторы, их (где это возможно) лучше
реализовать в виде методов.
При реализации перегруженного оператора в виде метода вам следует отметить
весь этот метод модификатором const, если, конечно, он не изменяет объект. В этом
случае он будет вызываться для const-объектов.
Выбор типа аргумента
В выборе типа аргумента вы несколько ограничены, поскольку обычно не в вашей
власти изменить количество аргументов (хотя все же существуют исключения, о
которых речь пойдет ниже в этой главе). Например, оператор operator+ должен
496 Часть IV. Как создать код без ошибок
всегда иметь два аргумента, если он представляется глобальной функцией, и один
аргумент, если — методом. При обнаружении отличий от стандарта компилятор
непременно выдаст сообщение об ошибке. В этом смысле операторные функции
отличаются от обычных, которые можно перегружать с любым количеством параметров.
И потом, хотя теоретически можно написать оператор для любых типов, ваш выбор
обычно ограничен классом, для которого вы пишете этот оператор. Например, если
вам нужно реализовать операцию сложения для класса Т, вы ведь не будете писать
оператор operator+, который принимает две строки! Реально момент выбора
наступает тогда, когда вы задумаетесь над тем, как организовать передачу параметров: по
значению или по ссылке, и стоит ли делать их const-параметрами.
Выбрать между передачей параметров по ссылке и по значению как раз нетрудно:
любой параметр лучше передавать по ссылке. Как разъяснялось в главах 9 и 12,
никогда не передавайте объекты по значению, если это можно сделать по ссылке!
Принятие решения в отношении модификатора const также не требует
гамлетовских мучений: отмечайте каждый параметр модификатором const, если вы не
собираетесь его модифицировать. В таблице, приведенной в разделе "Резюме о
перегружаемых операторах", показаны примеры прототипов для каждого оператора, причем
все аргументы отмечены модификатором const и передаются по ссылке.
Выбор типа значения, возвращаемого оператором
Вспомните, что в C++ при наличии перегруженных функций решение о вызове
конкретного варианта не принимается на основе типа значения, возвращаемого этой
функцией. Таким образом, при написании перегруженных операторов вы можете
указать для возвращаемых ими значений любой тип. Но одна лишь возможность
сделать что-либо не означает, что это следует сделать. Эта гибкость предполагает, что вы
могли бы (ну, может быть, ради смеха) написать сбивающий всех с толку код, в
котором операторы сравнения возвращают указатели, а арифметические операторы —
значения типа bool! Конечно же, это не стоит делать: как правило, поведение
перегруженных операторов при возвращении значений должно быть аналогичным
поведению операторов, обрабатывающих значения встроенных типов. Поэтому оператор
сравнения должен возвращать значение типа bool, а арифметический оператор —
объект, представляющий результат данной операции. Иногда тип возвращаемого
оператором значения на первый взгляд не очевиден. Например, как упоминалось
в главе 8, для поддержки вложенного присваивания оператор operator= должен
возвращать ссылку на объект, для которого он был вызван. Другие "трудные" случаи
описаны в таблице, приведенной в разделе "Резюме о перегружаемых операторах".
При определении типов значений, возвращаемых операторами, также актуальны
выбор способа передачи (по ссылке или по значению) и принятие решения
относительно модификатора const. Но в этом случае сделать такой выбор гораздо труднее.
Общее правило предлагает по возможности использовать передачу по ссылке (в
противном случае— по значению). Но как узнать эти свои "возможности"? Такое
решение применимо лишь к тем операторам, которые возвращают объекты. Во многих
других случаях результат далеко не однозначен: например, операторы сравнения
должны возвращать bool-значения, операторы преобразования не возвращают
ничего (и потому разговор о типе беспредметен), а оператор вызова функций может
возвращать значения любого типа. Если ваш оператор создает новый объект, вы должны
возвращать этот объект по значению. Если он не создает новый объект, вы можете
обеспечить возврат ссылки на объект, для которого вызывается данный оператор,
Глава 16. Перегрузка С++-операторов 497
или на один из его аргументов. Примеры приведены в разделе "Резюме о
перегружаемых операторах".
Возвращаемое оператором значение, которое может быть модифицировано как
/-значение (т.е. то, которое может находиться в левой части оператора присваивания),
не должно определяться с модификатором const. В противном случае оно должно быть
const-значением. Большинство операторов, включая все операторы присваивания
(operator=, operator+=, operator-= и т.д.), возвращают именно Означения.
Если у вас возникают сомнения в отношении типа значения, возвращаемого
оператором, обратитесь к таблице, приведенной в разделе "Резюме о
перегружаемых операторах".
Выбор поведения
В перегружаемом операторе теоретически вы можете реализовать любое
поведение. Например, вы могли бы написать оператор operator+, который запускает игру
"Эрудит". Но, как описано в главе 5, вам следует ограничить свою реализацию
ожидаемым для клиента поведением. Напишите оператор operator+ так, чтобы он
выполнял сложение или нечто, подобное сложению, например, конкатенацию строк.
В этой главе вы узнаете, как следует реализовать перегруженные операторы.
Только исключительные обстоятельства могут оправдать отступление от этих
рекомендаций, но в общем случае вы должны следовать стандартному образцу.
Операторы, не подлежащие перегрузке
Есть операторы, которые вряд ли стоит перегружать даже несмотря на то, что это
дозволено. К таковым, в частности, относится оператор взятия адреса (operator&).
Изменение его фундаментального поведения не только вряд ли окажется полезным,
но попросту может привести к неразберихе, если ожидаемое поведение (взятие
адреса) будет заменено чем-то потенциально неизвестным.
Кроме того, вам следует избегать перегрузки бинарных булевых операторов
operator&& и operator | |, поскольку при этом будут утрачены С++-правила
вычисления результата по "сокращенной схеме".
Наконец, не рекомендуется перегружать оператор "запятая" (operator,). Здесь
нет никакой опечатки: в C++ действительно существует оператор "запятая". Он также
называется оператором выполнения последовательности действий и используется для
разделения двух выражений в одной инструкции при наличии гарантии, что они
вычисляются слева направо. Трудно себе представить, что для перегрузки этого оператора
найдется весомая причина.
Резюме о перегружаемых операторах
В следующей таблице перечислены все операторы, которые можно перегружать,
указан возможный способ их реализации (в виде метода класса или глобальной
функции), отмечено, в каких случаях их стоит (или не стоит) перегружать, а также
приведены примеры прототипов с надлежащими возвращаемыми значениями.
Если вам когда-либо придется писать перегруженный оператор, используйте эту
таблицу в качестве справочника. Ведь со временем можно легко забыть, какой тип
должно иметь значение, возвращаемое оператором, и как сделать наилучший выбор
между функцией и методом. Мы сами время от времени заглядываем в эту таблицу!
В данной таблице символ Т означает имя класса, для которого пишется
перегруженный оператор, а символ Е — некоторый другой тип (не совпадающий с именем
этого класса).
498 Часть IV. Как создать код без ошибок
Оператор
operator+
operator-
operator*
operator/
operator!
operator-
operatort
operator-
operator++
operator--
operator=
Имя или
категория
Бинарные
арифметические
операторы
Унарные
арифметические
и поразрядные
операторы
Инкремент
и декремент
Оператор
присваивания
Метод или
глобальная
friend-функция
Рекомендована
глобальная
friend-
функция
Рекомендован
метод
Рекомендован
метод
Необходимо
использовать
метод
При каких
условиях
перегружать
При необходимости
реализации этих
операций для
класса
При необходимости
реализации этих
операций для
класса
Всегда при
перегрузке унарных
операторов "+" и"-"
Если динамически
выделяется память
или нужно
Пример прототипа
friend const T
operator+(const
T&, Const T&);
const T operator-()
const ,-
T& operator++() ;
const T
operator++(int);
T& operator=
(const T&);
предотвратить
присваивание,как
описано в главе 9
operator+=
operator-=
operator*=
operator/=
operator%=
operator<<
operator»
operators
operator |
operator^
operator<<=
operator>>=
operator&=
operator|=
operatorA=
operator<
operator>
operator<=
operator>=
operator==
operator<<
operator»
operator!
Сокращенные
арифметические
операторы
присваивания
Бинарные
поразрядные
операторы
Сокращенные
поразрядные
операторы
присваивания
Бинарные
операторы
сравнения
Потоковые
операторы
ввода-вывода
Оператор
логического
отрицания
Рекомендован
метод
Всегда при
перегрузке
бинарных
арифметических
операторов
Рекомендована При реализации
глобальная этих операций для
friend- класса
функция
Рекомендован
метод
Рекомендована
глобальная
friend-
функция
Всегда при
перегрузке
бинарных
поразрядных
операторов
При необходимости
реализации этих
операций для
класса
Т& operator+=
(const T&);
friend const T
operator<<(const
T&, const T&);
T& operator<<=
(const T&) ,-
friend bool
operator<(const T&,
const T&);
Рекомендована При необходимости
глобальная реализации этих
friend- операций для
функция класса
Рекомендован
метод
(член класса)
friend ostream
&operator<<
(ostream&,
const T&);
friend istream
&operator>>
(istream&, T&) ;
Редко. Вместо пере- bool operator! ()
грузки используйте const ,-
bool- ИЛИ void*-
преобразования
Глава 16. Перегрузка С++-операторов 499
Продолжение таблицы
Оператор
operator&&
operator||
operator []
operator()
operator
new
operator
newt]
operator
delete
operator
delete []
operator*
operator->
operators
operator->*
Имя или
категория
Бинарные
логические
операторы
Оператор
индексации
массива
(оператор доступа
к элементам
массива)
Оператор
вызова функции
Операторы
выделения
памяти
Операторы
освобождения
памяти
Операторы
разыменования
Оператор взятия
адреса
Оператор
разыменования
указателя на
член класса
Метод или
глобальная
friend-функция
Рекомендована
глобальная
friend-
функция
Необходимо
использовать
метод
Необходимо
использовать
метод
Рекомендован
метод
Рекомендован
метод
Для оператора
operator->
необходимо
использовать
метод
Для оператора
operator*
рекомендован
метод
Нет
рекомендаций
Нет
рекомендаций
При каких
условиях
перегружать
Редко
При необходимости
поддержки
индексированных
элементов (в классах,
действующих
подобно массивам)
Если поведение
объектов должно
быть подобно
указателям на
функции
Если в классах
нужно обеспечить
выделение памяти
(редко)
При перегрузке
операторов
распределения
памяти
Для реализации
интеллектуальных
указателей
Никогда
Никогда
Пример прототипа
friend bool
operator&&(const T&
lhs, const T& rhs);
friend bool
operator||(const
T& lhs, const T&
rhs) ;
E& operator []
(int);
const E&
operator [] (int)
const;
Аргументы и тип
значения,
возвращаемого
оператором, могут
быть различными (см.
примеры в этой главе)
void* operator
new(size t size)
throw(bad alloc);
void* operator
new[] (size t size)
throw(bad_alloc);
void operator
delete(void* ptr)
throw();
void operator
delete [] (void*
ptr) throw () ;
E& operator*()
const;
E* operator-:» ()
const;
500 Часть IV. Как создать код без ошибок
Окончание таблицы
Оператор
Имя или
категория
Метод или
глобальная
friend-функция
При каких
условиях
перегружать
Пример прототипа
operator, Оператор
operator
type ()
запятая
Нет
рекомендаций
Операторы Необходимо
преобразования использовать
или приведения метод
типа (отдельные
операторы для
каждого типа)
Никогда
При реализации
преобразований
объектов данного
класса в объекты
других типов
operator type()
const;
Перегрузка арифметических операторов
В главе 9 вы узнали, как писать бинарные арифметические операторы и
"сокращенные" арифметические операторы присваивания. Но вы еще не научились
перегружать все эти арифметические операторы.
Перегрузка унарных операторов "минус" и "плюс"
В C++ определено несколько унарных арифметических операторов. В их число
входят унарные операторы "минус" и "плюс". Вероятно, вам уже приходилось
использовать унарный "минус", а теперь вам предстоит познакомиться с унарным
оператором "плюс". Рассмотрим пример использования этих операторов
применительно к значениям типа int.
int i, j = 4;
i = -j; // унарный "минус"
i = +i; // унарный "плюс"
j = +(-i); // Применяем унарный "плюс" к результату
// применения унарного "минуса" к переменной i.
j = -(-i); // Применяем унарный "минус" к результату
// применения унарного "минуса" к переменной i.
Унарный оператор "минус" выполняет над операндом операцию отрицания, в то
время как унарный "плюс" непосредственно возвращает значение операнда. Результат
применения унарного "плюса" или "минуса" не является Означением: ему нельзя
присвоить что-то еще. Это означает, что при перегрузке унарных операторов "+" и "-"
соответствующие методы должны возвращать const-объект. Однако заметьте, что мы
можем применять унарные операторы "+" или "-" к результату применения унарных
"+" или "-". Поскольку мы выполняем эти операции над временным const-объектом,
мы должны сами операторы operator- и operator+ сделать const-методами, в
противном случае компилятор не позволит вызвать их для временного const-объекта.
Рассмотрим пример определения класса SpreadsheetCell с перегруженным
оператором operator-. Унарный оператор "+" обычно является "no-op"-командой
(сокр. от "no-operation"), т.е. пустой командой, поэтому этот класс не "утруждает себя"
заботами по его перегрузке.
Глава 16. Перегрузка С++-операторов 501
class SpreadsheetCell
{
public:
// Опущено из экономии места. Подробнее см. главу 9.
friend const SpreadsheetCell operator+(
const SpreadsheetCell& lhs,
const SpreadsheetCell& rhs);
friend const SpreadsheetCell operator-(
const SpreadsheetCell& lhs,
const SpreadsheetCell& rhs);
friend const SpreadsheetCell operator*(
const SpreadsheetCell& lhs,
const SpreadsheetCell& rhs);
friend const SpreadsheetCell operator/(
const SpreadsheetCell& lhs,
const SpreadsheetCell& rhs);
SpreadsheetCell& operator+=(const SpreadsheetCell& rhs) ;
SpreadsheetCell & operator-=(const SpreadsheetCellk rhs) ,-
SpreadsheetCell& operator*=(const SpreadsheetCell& rhs);
SpreadsheetCell& operator/=(const SpreadsheetCellb rhs) ;
const SpreadsheetCell operator-() const;
protected:
// Опущено из экономии места. Подробнее см. главу 9.
};
Вот как выглядит определение унарного оператора operator-.
const SpreadsheetCell SpreadsheetCell::operator-() const
{
SpreadsheetCell newCell(*this);
newCell.set(-mValue); // Вызываем set-метод для обновления
// значений mValue и mStr.
return (newCell);
}
Оператор "-" не изменяет операнд, поэтому метод operator- () должен создать
новый объект типа SpreadsheetCell и установить его равным значению операнда,
но с противоположным знаком, а затем вернуть его копию. Таким образом, он не
может вернуть ссылку.
Перегрузка операторов инкремента и декремента
Вспомним, что сложить переменную с единицей можно четырьмя способами.
i = i + 1;
i += 1;
++i; '
i++;
Два последних способа реализовано с помощью операторов инкремента. Первая
форма представляет собой префиксный оператор инкремента, который выполняет
сложение переменной с единицей, а затем возвращает новое (инкрементированное)
значение для использования в остальной части выражения. Вторая форма означает
применение постфиксного оператора инкремента, который для использования в
остальной части выражения возвращает старое (неинкрементированное) значение.
Операторы декремента имеют аналогичные формы.
502 Часть IV. Как создать код без ошибок
Существование двух возможных вариантов для методов operator++ и operator- -
(префиксная и постфиксная формы) представляет определенную проблему для
программиста, который впервые собрался их перегрузить. Как при написании
перегруженного оператора operator++, например, указать, что вы имеете в виду именно
префиксную или постфиксную версию? К счастью, в языке C++ предусмотрена
возможность отличить эти варианты: префиксные версии операторов operator++
и operator— не принимают аргументов, в то время как постфиксные версии
принимают один неиспользуемый аргумент типа int.
Если бы вы решили перегрузить эти операторы для класса SpreadsheetCell, их
прототипы выглядели бы так.
class SpreadsheetCell
{
public:
// Опущено из экономии места. Подробнее см. главу 9.
' SpreadsheetCellb operator++(); // Префиксная форма,
const SpreadsheetCell operator++(int); // Постфиксная
// форма.
SpreadsheetCellb operator--() ,- // Префиксная форма.
const SpreadsheetCell operator--(int); // Постфиксная
// форма.
protected:
// Опущено из экономии места. Подробнее см. главу 9.
};
В стандарте C++ определено, что префиксные версии операторов инкремента
и декремента возвращают /-значение, поэтому оно не может быть объявлено с
модификатором const. Значение, возвращаемое в префиксных формах, совпадает с
конечным значением операнда, поэтому префиксные версии операторов инкремента
и декремента могут возвращать ссылку на объект, для которого они были вызваны.
Однако постфиксные версии операторов инкремента и декремента возвращают
значения, которые отличаются от конечных значений их операндов, и поэтому они не
могут возвращать ссылки.
Вот как выглядят реализации этих операторов.
SpreadsheetCellb SpreadsheetCell::operator++()
set(mValue + 1);
return (*this);
const SpreadsheetCell SpreadsheetCell::operator++(int)
SpreadsheetCell oldCell(*this); // Сохраняем текущее
// значение до инкрементирования.
set(mValue + 1); // Инкрементирование.
return (oldCell); // Возвращаем старое значение.
SpreadsheetCell& SpreadsheetCell:toperator--()
set(mValue - 1);
return (*this);
const SpreadsheetCell SpreadsheetCell::operator--(int)
Глава 16. Перегрузка С++-операторов 503
{
SpreadsheetCell oldCell(*this); // Сохраняем текущее
// значение до декрементирования.
set(mValue - 1); // Декрементирование.
return (oldCell); // Возвращаем старое значение.
}
Теперь мы можем инрементировать и декрементировать объекты класса
SpreadsheetCell!
SpreadsheetCell cl, c2;
cl.set(4) ;
с2.set (4) ;
cl++;
++cl ,-
Вспомните, что операторы инкремента и декремента также работают с
указателями. Когда вы будете писать классы для реализации интеллектуальных указателей или
итераторов, вы можете перегрузить операторы operator++ и operator--, чтобы
обеспечить инкрементирование и декрементирование указателей. Прочитав главу 23,
вы узнаете, как написать собственные STL-итераторы.
Перегрузка поразрядных и бинарных
логических операторов
Поразрядные операторы подобны арифметическим, а "сокращенные"
поразрядные операторы присваивания — "сокращенным" арифметическим операторам
присваивания. Однако они используются гораздо реже, поэтому мы не считаем нужным
подробно останавливаться на них. Таблица, приведенная в разделе "Резюме о
перегружаемых операторах", содержит несколько прототипов, поэтому при
необходимости вы сможете без труда их реализовать.
С логическими операторами дело обстоит несколько сложнее. Мы не рекомендуем
перегружать операторы "&&" и " | | ". Эти операторы реально не применяются к
отдельным типам: они агрегируют результаты булевых выражений. Кроме того, при
перегрузке вы потеряете возможность вычисления по сокращенной схеме. Поэтому
вряд ли имеет смысл перегружать их для конкретных типов.
Перегрузка операторов ввода-вывода данных
В C++ операторы используются не только для выполнения арифметических
операций, но также для считывания данных из потока и записи их в поток. Например, при
записи int- и string-значений в поток cout мы используем оператор вывода "<<".
int number = 10;
cout << "Число равно " << number << endl;
При считывании данных из потока мы используем оператор ввода ">>".
int number;
string str;
cin >> number >> str;
504 Часть IV. Как создать код без ошибок
Вы можете написать операторы ввода-вывода применительно к собственным
классам, чтобы можно было считывать и записывать объекты таким образом.
SpreadsheetCell myCell, anotherCell, aThirdCell;
cin >> myCell >> anotherCell >> aThirdCell;
cout << myCell << " " << anotherCell << " " << aThirdCell
<< endl;
Прежде чем писать операторы ввода-вывода, вы должны решить, какие именно
данные своего класса вы хотите выводить в поток и какие — считывать из потока. Для
класса SpreadsheetCells имеет смысл считывать и записывать string-значения,
поскольку все double-значения могут быть считаны как строки (и преобразованы
назад в double-значения), но не наоборот.
Объект, стоящий слева от оператора ввода или вывода, является объектом класса
istream или ostream (как, например, объекты cin или cout), а не объектом класса
SpreadsheetCell. Поскольку вы не можете добавить новый метод в класс istream
или ostream, вы должны реализовать операторы ввода и вывода как глобальные
friend-функции класса SpreadsheetCell. Объявление этих функций в классе
SpreadsheetCell может иметь такой вид.
class SpreadsheetCell
{
public:
// Опущено из экономии места.
friend ostream& operator<<(ostream& ostr,
const SpreadsheetCell& cell);
friend istream& operator>>(istream& istr,
SpreadsheetCell& cell);
// Опущено из экономии места.
};
Заставляя оператор вывода принимать в качестве первого параметра ссылку на
объект типа ostream, вы тем самым позволяете использовать его для операций
с файловыми и строковыми выходными потоками, а также стандартными потоками
cout и сегг (подробнее см. главу 14). Аналогично, заставляя оператор ввода
принимать в качестве первого параметра ссылку на объект типа istream, вы обеспечиваете
себя средствами обработки файловых входных потоков, строковых входных потоков
и стандартного потока cin.
Вторым параметром операторных функций operator<< () и operator>> ()
является ссылка на объект типа SpreadsheetCell, который вы хотите записать или
считать. Оператор вывода не изменяет объект типа SpreadsheetCell, который он
записывает, поэтому ссылка должна быть объявлена с модификатором const. Однако
оператор ввода модифицирует SpreadsheetCell-объект, следовательно, этот
аргумент не должен быть const-ссылкой.
Оба оператора возвращают ссылку на поток, тип которого совпадает с типом их
первого аргумента. Это позволяет организовать вложенные обращения к этим
операторам. Вспомните, что синтаксис этих операторов может иметь сокращенную форму
для явных вызовов глобальных функций operator<< () и operator>> ().
Рассмотрим следующую строку кода.
cin >> myCell >> anotherCell >> aThirdCell;
Глава 16. Перегрузка С++-операторов 505
В действительности она представляет собой сокращенную запись такой строки.
operator>>(operator>>(operator>>(cin, myCell), anotherCell),
aThirdCell);
Как видите, значение, возвращаемое первым вызовом оператора operators > (),
используется в качестве обращения к следующему. Таким образом, оператор ввода должен
возвращать ссылку на поток, чтобы ее можно было использовать в следующем вложенном
вызове оператора ввода. В противном случае такое вложение не скомпилировалось бы.
Рассмотрим реализации операторов operator<<() и operator>>() для класса
SpreadsheetCell.
ostream& operator<<(ostream& ostr,
const SpreadsheetCell& cell)
{
ostr << cell.mString;
return (ostr);
}
istream& operator>>(istream& istr, SpreadsheetCell& cell)
{
string temp;
istr >> temp;
cell.set(temp);
return (istr);
}
При реализации оператора ввода (operator>>{)) необходимо помнить
следующее. Для того чтобы корректно установить значение члена mValue, необходимо
сделать обращение к методу set () класса SpreadsheetCell, а не устанавливать член
mString напрямую.
Перегрузка оператора индексации
Предположим, что вы никогда не слышали о векторном шаблонном классе из
библиотеки STL, и поэтому решили самостоятельно написать класс динамически
создаваемого массива. Этот класс позволит вам устанавливать и считывать элементы по
заданным индексам, но всю заботу о выделении памяти для этих элементов возьмет на
себя. Первый вариант определения класса для динамически создаваемого
целочисленного массива может выглядеть так.
class Array
{
public:
// Создаем массив стандартного размера, который при
// необходимости может изменяться.
Array();
-Array();
// Возвращаем значение по индексу х. Если заданный
// индекс не существует в массиве, генерируем
// исключение типа out_of range.
int getElementAt (int x) const;
// Устанавливаем элемент по индексу х равным значению
// val. Если индекс х вне досягаемости, выделяем еще
506 Часть IV. Как создать код без ошибок
// память, чтобы он попадал в область досягаемости.
void setElementAt(int x, int val);
protected:
static const int kAllocSize = 4;
void resize(int newSize);
int* mElems;
int mSize;
private:
// Запрещаем выполнение оператора присваивания и
// передачу по значению.
Array(const Array& src) ;
Array& operator=(const Array& rhs);
};
Здесь мы опустили throw-списки типов исключений и не сделали этот класс
шаблоном. Данный интерфейс поддерживает установку и считывание элементов массива. При
этом он предоставляет гарантии произвольного доступа: клиент может создать массив
и установить элементы с индексами 1, 100 и 1000, не беспокоясь об управлении памятью.
Приведем реализации методов класса Array.
#include "Array.h"
const int Array:: kAllocSize,-
Array::Array()
{
mSize = kAllocSize;
mElems = new int[mSize];
}
Array::-Array()
{
delete [] mElems;
}
int Array::getElementAt(int x) const
{
if (x < 0 || x >=mSize) {
throw out_of_range("");
}
return (mElems[x]);
}
void Array::setElementAt(int x, int val)
{
if (x < 0) {
throw out_of_range("");
if (x >= mSize) {
// Выделяем память для kAllocSize элементов после
// элемента, заданного клиентом.
resize (x + kAllocSize);
}
mElems[x] = val;
}
void Array::resize(int newSize)
{
int* newElems = new int[newSize]; // Создаем новый массив
// нового размера.
// Новый размер всегда больше старого.
Глава 16. Перегрузка С++-операторов 507
for (int i = 0; i < newSize; i++) {
// Копируем элементы из старого массива в новый.
newElems [i] = ttiElems ti] ;
}
mSize = newSize; // Сохраняем новый размер.
delete [] mElems; // Освобождаем память для старого массива.
mElems = newElems; // Сохраняем указатель на новый массив.
}
Вот небольшой пример использования этого класса.
Array arr;
int i ;
for (i = 0; i < 10; i++) {
arr.setElementAt(i, 100);
}
for (i = 0; i < 10; i++) {
cout << arr.getElementAt(i) << " ";
}
cout << endl;
Как видите, вам не нужно "подсказывать" массиву, сколько дополнительной
памяти вам требуется. Он "сам" выделит память, которая необходима для хранения ваших
элементов. Однако использовать функции setElementAt () и getElementAt ()
очень неудобно. Было бы здорово, если бы мы могли применять привычный нам
синтаксис индексации массивов.
Array arr;
int i ;
for (i = 0; i < 10; i++) {
arr[i] = 100;
}
for (i = 0; i < 10; i++) {
cout << arrti] << " ";
}
cout < < endl ,-
Вот когда пришло время вступать в действие перегруженному оператору
индексации. Теперь мы можем заменить функции getElementAt () и setElementAt ()
оператором operator [] ().
class Array
{
public:
Array();
-Array () ;
int& operator[](int x)
protected:
static const int kAllocSize = 4,-
508 Часть IV. Как создать код без ошибок
void resize (int newSize) ,-
int* mElems;
int mSize;
private:
// Запрещаем выполнение оператора присваивания и
// передачу по значению.
Array(const Array& src);
Array& operator=(const Array& rhs);
b
Приведенный выше код С* использованием синтаксиса индексации массива теперь
благополучно скомпилируется. Оператор operator [] может заменить обе функции
setElementAt () и getElementAt (), поскольку он возвращает ссылку на элемент,
расположенный по индексу х. Эта ссылка может быть /-значением, поэтому ее можно
использовать для присваивания. Рассмотрим реализацию этого оператора.
int& Array::operator[](int x)
{
if (x < 0) {
throw out_of _range (" ") ,-
}
if (x >= mSize) {
// Выделяем память для kAllocSize элементов после
// элемента, заданного клиентом.
resize (х + kAllocSize);
}
return (mElems[x]);
}
Если оператор operator [] используется с левой стороны от оператора
присваивания, последний действительно изменяет значение, расположенное в массиве mElems
по индексу х.
Обеспечение с помощью оператора operator [] доступа
"только для чтения"
Хотя иногда довольно удобно иметь метод operator [], возвращающий элемент,
который может служить в качестве /-значения, такое поведение не всегда желаемо.
При необходимости было бы здорово обеспечить к элементам массива доступ "только
для чтения". Это можно сделать, если операторный метод operator [] будет
возвращать const-значение или const-ссылку. В идеальном случае имело бы смысл
реализовать два метода operator []: один будет возвращать ссылку, а другой — const-ссылку.
class Array
{
public:
Array();
-Array();
int& operator [] (int x) ;
const int& operator[](int x); // ОШИБКА! Нельзя
// реализовать перегрузку на основе
// типа возвращаемого значения.
protected:
static const int kAllocSize = 4;
void resize(int newSize);
int* mElems;
Глава 16. Перегрузка С++-операторов 509
int mSize;
private:
// Запрещаем присваивание и передачу по значению.
Array(const Array& src);
Array& operator=(const Array& rhs);
b
Однако здесь есть одна небольшая проблема: мы не можем перегружать метод (или
оператор) на основе одного только типа возвращаемого значения. Приведенный
выше код поэтому не скомпилируется. К счастью, в C++ предусмотрен способ обойти это
ограничение: если отметить второй метод operator [] модификатором const,
компилятор сможет различать наши два варианта. Если вызывать метод operator [ ] для
const-объекта, компилятор будет использовать версию const operator [] , а если —
для не const-объекта, то— не const-версию метода operator []. Вот как выглядят
эти два оператора с корректно написанными сигнатурами.
int& operator[](int х) ;
const int& operator[](int x) const;
Рассмотрим реализацию метода const operator []. Эта версия генерирует
исключение, если индекс находится вне досягаемости, и не делает никакой попытки
выделить новую область памяти. В этом есть своя логика: не имеет смысла выделять
новую память в случае, если вы хотите лишь считать значение элемента массива.
const int& Array::operator[](int x) const
{
if (x < 0 || x >=mSize) {
throw out_of range("");
}
return (mElems[x]);
}
В следующем коде демонстрируется использование этих двух версий метода
■operator [].
#include "Array.h"
void printArray(const Array& arr, int size);
int main(int argc, char** argv)
{
Array arr;
int i ;
for (i = 0; i < 10; i++) {
arr[i] = 100; // Вызывается не const-версия operator [],
// поскольку массив arr не const-объект.
}
printArray(arr, 10);
return (0);
}
void printArray(const Array& arr, int size)
{
for (int i = 0; i < size; i++) {
cout << arrti] << " "; // Вызывается версия
// const operator[], поскольку
// arr передан как const-объект.
510 Часть IV. Как создать код без ошибок
}
сout << endl;
}
Обратите внимание на то, что версия const operator [] вызывается в функции
printArray () только по той причине, что массив агг передан как const-объект.
Если бы объект агг не был отмечен модификатором const, была бы вызвана не const-
версия метода operator [], даже несмотря на то, что результат не модифицируется.
Использование для массивов нецелочисленных индексов
Можно также написать метод operator [], который для индексов массивов
использует не целочисленный, а какой-нибудь иной тип. Например, вы могли бы
создать матрицу ассоциативных элементов (associative array), в которой вместо
целочисленных значений используются string-ключи. Рассмотрим определение класса
ассоциативной матрицы, предназначенной для хранения int-значений.
class AssociativeArray
{
public:
AssociativeArray();
-AssociativeArray();
int& operator[](const strings key);
const int& operator[](const string& key) const;
private:
// Реализация деталей опущена.
};
Реализацию этого класса мы предлагаем выполнить читателю самостоятельно
в качестве упражнения. Вероятно, вам будет интересно узнать, что библиотечное STL-
отображение имеет функции, подобные ассоциативной матрице, включая
использование оператора operator [ ] с ключом любого возможного типа данных.
Оператор индексации нельзя перегружать за счет приема более
одного параметра. Если же вы хотите реализовать доступ к элементам
массива по нескольким индексам, вам необходимо для этого
использовать оператор вызова функции.
Перегрузка оператора вызова функций
Язык C++ позволяет перегрузить оператор вызова функций, который
записывается как operator (). Если вы напишете метод operator () для своего класса, то
сможете так использовать объекты этого класса, как если бы они были указателями на
функции. Этот оператор можно перегрузить только как нестатический метод класса.
Рассмотрим пример простого класса с перегруженным оператором operator () и
методом класса с таким же поведением.
class FunctionObject
{
public:
int operator() (int inParam); // Оператор вызова функций
Глава 16. Перегрузка С++-операторов 511
int aMethod(int inParam); // Обычный метод
};
//Реализация перегруженного оператора вызова функций.
int FunctionObj ect::operator() (int inParam)
{
return (inParam * inParam) ,-
}
// Реализация обычного метода.
int FunctionObject::aMethod(int inParam)
{
return (inParam * inParam);
}
Перед вами пример кода, в котором использование оператора вызова функций
можно сравнить с вызовом обычного метода класса.
int main(int argc, char** argv)
{
int x = 3, xSquared, xSquaredAgain,-
Funct ionObj ect square;
xSquared = square(x); // Вызываем оператор вызова функций.
xSquaredAgain = square.aMethod(x); // Вызываем обычный
// метод.
}
Объект класса с оператором вызова функций называется функциональным объектом
(function object) или функтором.
На первый взгляд оператор вызова функций может показаться немного странным.
Зачем нам может понадобиться в классе специальный метод, который бы позволял
объектам этого класса выглядеть подобно указателям на функции? Почему бы просто
не написать функцию или стандартный метод класса? Преимущество
функциональных объектов над стандартными методами объектов очевидно: эти объекты могут
иногда "маскироваться" под указатели на функции. Вы можете передавать
функциональные объекты как функции обратного вызова (callback functions) тем процедурам,
которые принимают указатели на функции, если, конечно, типы указателей на
функции шаблонизированы. (Подробнее см. главу 22.)
Объяснить преимущества функциональных объектов над глобальными функциями
несколько сложнее. Назовем два основных.
□ Между повторными обращениями к операторам вызова функций объекты могут
сохранять информацию о своих членах данных. Например, функциональный
объект можно было бы использовать для хранения текущего значения суммы
чисел, обновляемого при каждом обращении к оператору вызова функций.
□ Поведение функционального объекта можно было бы "настраивать" путем
установки членов данных. Например, мы могли бы написать функциональный объект,
который бы сравнивал аргумент, передаваемый функции, с некоторым членом
данных. Этот член данных мог бы иметь "перестраиваемую конфигурацию",
чтобы объект класса можно было бы "готовить" под любое нужное нам сравнение.
Безусловно, мы могли бы реализовать описанные преимущества с помощью
глобальных или статических переменных. Однако функциональные объекты позволяют
это сделать более ясным способом. Настоящие преимущества функциональных
объектов станут очевидными, когда вы больше узнаете о библиотеке STL (см. главы 21 и 23).
512 Часть IV. Как создать код без ошибок
Следуя правилам перегрузки обычных методов, вы можете написать для своих
классов любое количество операторов operator (). В частности, различные
операторы operator () должны иметь разное количество параметров. Например, мы
могли бы добавить в класс Funct ionObj ect оператор operator (), который принимает
s t r i ng-ссылку.
class FunctionObject
{
public:
int operator() (int inParam);
void operator() (string& str);
int aMethod(int inParam);
};
Оператор вызова функций можно также использовать для доступа к массиву с
несколькими индексами. Для этого достаточно написать оператор operator (),
который ведет себя подобно оператору operator [], но позволяет иметь не один, а
несколько параметров. Единственная проблема при таком подходе состоит в том, что
в этом случае для индексации массива вместо квадратных скобок [ ] мы должны
использовать круглые (), например, так: myArray (3, 4) = 6;.
Перегрузка операторов разыменования
Разрешается перегружать три оператора разыменования: "*","->" и "->*". Для
начала рассмотрим встроенные значения операторов "*" и "->" (а к оператору "->*"
вернемся позже). Оператор "*" разыменовывает указатель, чтобы предоставить прямой
доступ к его значению, в то время как оператор "->" представляет собой сокращенную
запись для оператора разыменования "*", за которым (через символ "точка") следует
имя члена класса. Эквивалентные инструкции демонстрируются в следующем коде.
SpreadsheetCell* celll = new SpreadsheetCell;
(*celll).set(5); // Разыменование с указанием члена класса.
celll->set(5); // Сокращенная запись предыдущей инструкции.
Перегрузка операторов разыменования для конкретного класса позволяет сделать
так, что объекты этого класса будут вести себя подобно указателям. В основном, такие
"метаморфозы" применяются для реализации интеллектуальных указателей, с
которыми вы познакомились в главах 4, 13 и 15. Подобный вид перегрузки также полезен
для итераторов, которые активно используются в библиотеке STL и которые можно
представить себе как "элитные" интеллектуальные указатели. Более детально
итераторы рассматриваются в главах 21—23, а в главе 25 приведен пример реализации
класса интеллектуальных указателей. В этой же главе мы займемся базовой механикой
перегрузки релевантных операторов в контексте простого шаблонного класса
интеллектуальных указателей.
Итак, рассмотрим определение шаблонного класса интеллектуальных указателей,
но пока без операторов разыменования.
template <typename T>
class Pointer
{
Глава 16. Перегрузка С++-операторов 513
public:
Pointer(T* inPtr);
-Pointer();
// Здесь оставим место для операторов разыменования.
protected:
Т* mPtr;
private:
// Предотвращаем возможность присваивания
//.и передачи по ссылке.
Pointer(const Pointer<T>& src);
Pointer<T>& operator= (const Pointer<T>& rhs) ,-
};
Этот интеллектуальный указатель — сама простота. Его "интеллект" заключается
лишь в возможности сохранить "глупый", т.е. обычный, указатель и удалить его при
разрушении объекта. Реализация методов этого класса также крайне проста:
конструктор принимает реальный ("тупой") указатель, который затем сохраняется в
качестве единственного члена данных этого класса. Задача деструктора —
"добросовестно" освободить хранимый указатель.
template <typename T>
Pointer<T>::Pointer(T* inPtr)
{
mPtr = inPtr;
}
template <typename T>
Pointer<T>::-Pointer()
{
delete mPtr;
}
Вероятно, нам бы хотелось использовать этот шаблон интеллектуального
указателя таким образом.
#include "Pointer.h"
#include "SpreadsheetCell.h"
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
Pointer<int> smartlnt(new int) ,-
*smartlnt = 5; // Выполняем разыменование
// интеллектуального указателя,
cout << *smartInt << endl;
Pointer<SpreadsheetCell> smartCell (new SpreadsheetCell) ,-
smartCell->set(5); // Разыменование с вызовом метода set () .
cout << smartCell->getValue() << endl;
return (0);
}
Как видите, теперь для этого класса нам "позарез" нужно предоставить
реализации операторов " * " и " - >".
514 Часть IV. Как создать код без ошибок
Операторы ***" (operator*) и **->" (operator*>) редко используются
поодиночке. Если они имеют соответствующую семантику для
вашего класса, их всегда стоит реализовывать вместе. Было бы глупо для
объектов, выступающих в роли интеллектуальных указателей, под*
держивать оператор "->" и при этом не поддерживать оператор "*",
или наоборот.
Реализация оператора operator*
Выполняя разыменование указателя, вы стремитесь получить возможность
доступа к памяти, на которую он указывает. Если эта память содержит значение такого
простого типа, как int, вы должны уметь изменять это значение напрямую. Если же
память содержит значение более сложного типа, например, объект, вы должны знать,
как получить доступ к его членам данных или методам с помощью оператора " . ".
Чтобы обеспечить такую семантику, необходимо позаботиться о том, чтобы ваш
operator* возвращал ссылку на переменную или объект. В классе Pointer его
объявление и определение имеют следующий вид.
template <typename T>
class Pointer
{
public:
Pointer(T* inPtr);
-Pointer();
T& operator*();
const T& operator*() const;
protected:
T* mPtr;
private:
Pointer(const Pointer<T>& src);
Pointer<T>& operator=(const Pointer<T>& rhs);
};
template <typename T>
T& Pointer<T>::operator*()
{
return (*mPtr);
}
Как видите, метод operator* возвращает ссылку на объект или переменную, на
которую указывает базовый "неинтеллектуальный" указатель. Как и при перегрузке
операторов индексации массивов, здесь полезно предоставить как const-, так и не
const-версии метода, которые возвращают const-ссылку и "просто" ссылку
соответственно. Поскольку const-версия практически идентична не const-версии, ее
реализация здесь не представлена.
Реализация оператора operator->
С оператором "стрелка" дело обстоит несколько сложнее. Результат применения
этого оператора должен быть членом объекта или его методом. Однако, чтобы
получить такой результат, вам придется реализовать эквивалент метода operator*, за
Глава 16. Перегрузка С++-операторов 515
которым следует метод operator.. В C++ не разрешено перегружать operator, по
одной веской причине: невозможно написать один прототип, который бы позволял
определить любой возможный член данных или метод. Аналогично нельзя написать
и метод operator- > с такой семантикой.
Поэтому в C++ оператор operator-> рассматривается как специальный случай.
Например, эту строку
smartCell->set (5) ,-
C++ транслирует в следующую.
(smartCell.operator->())->set(5);
Как видите, C++ применяет еще один оператор operator- > к тому, что будет
возвращено перегруженным нами методом operator->. Следовательно, мы должны
обеспечить возврат указателя на объект таким вот образом.
template <typename T>
class Pointer
{
public:
Pointer(T* inPtr);
-Pointer();
T& operator*();
const T& operator* () const ,-
I
T* operator->();
const T* operator->() const;
protected:
T* mPtr;
private:
Pointer(const Pointer<T>& src);
Pointer<T>& operator=(const Pointer<T>& rhs);
b
template <typename T>
T* Pointer<T>::operator->()
{
return (mPtr);
}
И снова-таки, нам необходимо написать как const-, так и не const-формы этого
оператора. Поскольку const-версия практически идентична не const-версии, ее
реализация здесь не представлена.
К сожалению, методы operator* и operator-> — асимметричны, но это не
помешает вам быстро привыкнуть к такой их особенности.
Что это за operator->*?
Как упоминалось в главе 9, мы можем использовать указатели на члены и методы
класса. Попытка разыменовать такой указатель должна совершаться в контексте
объекта этого класса. Рассмотрим пример из главы 9.
SpreadsheetCell myCell;
double (SpreadsheetCell::*methodPtr) () const =
&SpreadsheetCell::getValue;
cout << (myCell. *methodPtr) () << endl,-
1
516 Часть IV. Как создать код без ошибок
Обратите внимание на использование оператора " . *" для разыменования
указателя на метод и вызова этого метода. Существует также эквивалентный оператор
operator->* для вызова методов через указатели при наличии указателя на объект,
а не самого объекта. Этот оператор имеет такой вид.
SpreadsheetCell* myCell = new SpreadsheetСе11();
double (SpreadsheetCell::*methodPtr) () const =
&SpreadsheetCell: rgetValue,-
cout << (myCell->*methodPtr)() << endl;
Язык C++ не позволяет перегружать оператор operator.* (только потому, что
нельзя перегружать оператор operator.), но мы вполне можем выполнить перегрузку
оператора operator- >*. Однако эта задачка не из простых, а тот факт, что
большинство С++-программистов даже не знают, что можно получить доступ к методам и членам
класса через указатели, делает ее еще более сложной. Здесь осталось заметить, что
шаблон auto_jptr из стандартной библиотеки не перегружает оператор operator- >*.
Создание операторов преобразования
Вернемся назад к примеру с классом SpreadsheetCell и рассмотрим следующие
две строки кода.
SpreadsheetCell celll;
string si = celll; // HE СКОМПИЛИРУЕТСЯ!
Класс SpreadsheetCell содержит строковое представление, поэтому нам может
показаться вполне логичной возможность присваивания его string-переменной.
Увы, это предположение неверно. В этом случае компилятор уведомит нас, что он не
"знает", как преобразовать SpreadsheetCell-объект в объект типа string. Тогда
мы можем попытаться явно сообщить компилятору, что именно мы ожидаем от него.
string si = (string) celll; // ВСЕ РАВНО НЕ СКОМПИЛИРУЕТСЯ!
Во-первых, предыдущая строка кода не скомпилируется, поскольку компилятор по-
прежнему не знает, как преобразовать SpreadsheetCell-объект в объект типа
string. Он уже знает, чего мы от него хотим, и он "с радостью" сделал бы это, если
мог. Во-вторых, это вообще плохая идея — добавлять ничем не мотивированные
преобразования типов в свои программы. Даже если бы компилятор позволил
скомпилировать это преобразование, то вряд ли бы были совершены правильные действия при
выполнении программы. Например, могла быть предпринята попытка битовое
представление объекта интерпретировать как строковое.
Если вам нужно сделать допустимым присваивание такого типа, необходимо
сообщить компилятору о том, как это делается. В частности, можно написать оператор
преобразования для перевода SpreadsheetCell-объектов в строки (strings-объекты).
Прототип такого оператора преобразования может иметь такой вид.
class SpreadsheetCell
{
public:
// Опущено из экономии места.
operator string() const;
Глава 16. Перегрузка С++-операторов 517
// Опущено из экономии места.
};
Имя самой функции-оператора состоит из двух слов: operator string. Она не
имеет при себе типа возвращаемого значения, поскольку этот тип указывается в
имени оператора: string. Объявление прототипа сопровождается модификатором
const, поскольку эта функция не изменяет объект, для которого она будет вызвана.
Да, на первый взгляд ее вид может показаться несколько странным, но к этому
довольно быстро привыкают. Реализация же этой операторной функции выглядит так.
SpreadsheetCell::operator string() const
{
return (mString);
}
Теперь нам осталось написать инструкцию, при выполнении которой объект типа
Spreadsheet Cell будет преобразован в string-объект. На этот раз компилятор
примет эту строку и во время выполнения программы правильно ее обработает.
SpreadsheetCell celll;
string si = celll; // Работает ожидаемым образом.
Используя тот же синтаксис, можно написать операторы преобразования для
любого типа данных. Вот, например, как выглядит прототип оператора преобразования
объекта типа SpreadsheetCell в значение типа double.
class SpreadsheetCell
{
public:
// Опущено из экономии места.
operator string() const;
operator doublet) const;
// Опущено из экономии места.
};
Вот как выглядит его реализация.
SpreadsheetCell::operator double() const
{
return (mValue);
}
А теперь напишем код использования наших "заготовок".
SpreadsheetCell celll;
double d2 = celll;
Проблемы неоднозначности при использовании операторов
преобразования
К сожалению, написание оператора преобразования в тип double для объектов
класса SpreadsheetCell сопряжено с проблемой неоднозначности. Рассмотрим
следующий код.
518 Часть IV. Как создать код без ошибок
SpreadsheetСе11 celll;
double dl = celll + 3.3; // HE СКОМПИЛИРУЕТСЯ, ЕСЛИ
// ОПРЕДЕЛИТЬ operator doublet).
Последняя строка теперь не скомпилируется. Что же произошло? Ведь она
прекрасно работала до того, как мы написали operator double () ? Дело в том, что
компилятор теперь не "знает", должен ли он преобразовать объект celll в значение
типа double с помощью оператора operator double (), а затем выполнить сложение
double-значений или преобразовать число 3,3 в объект типа SpreadsheetCell с
помощью double-конструктора, а затем выполнить сложение объектов типа
SpreadsheetCell. До появления в классе SpreadsheetCell оператора operator double ()
у компилятора был только один вариант: преобразовать число 3,3 в Spreadsheet -
Cell-объект "руками" double-конструктора и выполнить SpreadsheetCell-
сложение. Но теперь у компилятора появилось две альтернативы. И он не желает за
нас делать выбор, который потенциально может нам не понравиться, поэтому он
отказывается делать выбор вообще.
Обычно эта головоломка решается путем применения к рассматриваемому
конструктору спецификатора explicit, чтобы предотвратить автоматическое
преобразование с участием этого конструктора. К сожалению, не желательно иметь explicit-
конструктор, поскольку чаще всего, как разъяснялось в главе 9, нам нужна
возможность применения автоматических преобразований double-значений в Spread-
sheetCells-объекты. Поэтому в данном случае, вероятно, попросту лучше не писать
оператор double-преобразования для класса SpreadsheetCell.
Преобразования для булевых выражений
Иногда полезно иметь возможность использовать объекты в булевых выражениях.
Например, часто в условных инструкциях программисты используют указатели.
if (ptr != NULL) {
// Выполняем действие по разыменованию указателя.
}
Иногда условное выражение применяется в сокращенном виде.
if (ptr) {
// Выполняем действие по разыменованию указателя.
}
Можно также встретить код, подобный следующему.
if (!ptr) {
// Какие-то действия.
}
Если бы мы вместо обычного указателя использовали определенный выше класс
интеллектуальных указателей Pointer, то ни один из предыдущих фрагментов кода
не скомпилировался бы. Но мы можем добавить в этот класс оператор
преобразования его объекта в тип указателя. И тогда сравнение Pointer-объекта со значением
NULL, а также наличие одного объекта в if-выражении запустит преобразование
в тип обычного указателя. Для оператора преобразования тип обычного указателя
задается типом void*. Итак, рассмотрим модифицированный класс Pointer.
Глава 16. Перегрузка С++-операторов 519
template <typename T>
class Pointer
{
public:
Pointer(T* inPtr);
-Pointer();
T& operator*();
const T& operator*() const;
T* operator->();
const T* operator->() const;
operator void*() const { return mPtr; }
protected:
T* mPtr;
private:
Pointer(const Pointer<T>& src) ;
Pointer<T>& operator= (const Pointer<T>& rhs) ,-
};
Теперь все следующие инструкции скомпилируются, и результаты их выполнения
будут вполне ожидаемыми.
Pointer<SpreadsheetCell> smartCell(new SpreadsheetCell);
smartCell->set(5);
if (smartCell != NULL) {
cout « "не NULL!\n";
}
if (smartCell) {
cout « "не NULL!\n";
}
if (!smartCell) {
cout « "NULL\n";
}
Возможен и другой подход. Можно вместо оператора operator void*
перегрузить operator bool. Ведь если мы используем объект в булевом выражении, то
почему бы нам не преобразовать его непосредственно в значение типа bool? Итак, мы
могли бы написать наш класс Pointer так.
template <typename T>
class Pointer
{
public:
Pointer(T* inPtr);
-Pointer();
T& operator*();
const T& operator* () const ,-
T* operator-> () ;
const T* operator-:» () const;
operator bool() const { return (mPtr != NULL); }
protected:
T* mPtr;
private:
Pointer(const Pointer<T>& src) ;
Pointer<T>& operator=(const Pointer<T>& rhs);
520 Часть IV. Как создать код без ошибок
Все три предыдущих теста останутся работоспособными, хотя явное сравнение со
значением NULL может вызвать у компилятора "замешательство", выраженное в виде
предупреждающего сообщения. Этот метод кажется особенно подходящим для
объектов, которые не представляют указатели, а также в случае, если преобразование объекта
в тип указателя в действительности не имеет смысла. К сожалению, добавление
оператора преобразования в bool-значение чревато определенными непредвиденными
последствиями. В C++ ко всем (по возможности) случаям преобразования bool- в int-
значения применяются правила "продвижения". Следовательно, с учетом предыдущего
оператора преобразования следующий код скомпилируется и успешно выполнится.
Pointer<SpreadsheetCell> smartCell(new SpreadsheetCell);
int i = smartCell; // Преобразует объект smartCell типа
// Pointer в значение типа bool, а затем
// в значение типа int.
Как правило, мы хотим иметь несколько другой результат. Поэтому многие
программисты предпочитают использовать оператор operator void* вместо
оператора operator bool. А теперь вспомним, как мы работали с потоками (см. главу 14).
ifstream istr;
int temp;
// Открываем поток istr.
while (istr >> temp) {
// Обрабатываем переменную temp.
}
Чтобы позволить использование потоковых объектов в булевых выражениях, но
запретить их нежелаемое для нас "продвижение" к типу int, в классе basicios
определен оператор operator void* (а не оператор operator bool).
Существует и третья альтернатива: реализовать оператор operator! и
потребовать от клиентов класса использовать условные выражения только с "отрицанием",
подобные следующему.
if (!smartCell) {
cout « "NULL\n";
}
Как видите, в перегрузке операторов присутствует элемент проектирования.
Решение о том, какие операторы стоит перегружать, напрямую влияет на характер
использования ваших классов клиентами.
Перегрузка операторов выделения
и освобождения памяти
В C++ предусмотрена возможность переопределить характер работы механизма
выделения и освобождения памяти в своих программах. Эту "адаптацию" можно провести
как на глобальном уровне, так и уровне класса. Такое переопределение особенно
полезно в случае, если вас беспокоит тема производительности и вы хотели бы обеспечить
более эффективное (по сравнению с действующим по умолчанию) управление памятью.
Например, вместо того, чтобы каждый раз обращаться к стандартным С++-средствам
выделения памяти, вы могли бы написать программный блок распределения пула памяти,
который обеспечивает многократное использование участков памяти фиксированного
Глава 16. Перегрузка С++-операторов 521
размера. В данном разделе разъясняются тонкости работы средств выделения и
освобождения памяти и демонстрируются возможности их настройки.
Если вы практически ничего не знаете о стратегиях распределения
памяти, вряд ли стоит пытаться перегружать эти операторы. Не
занимайтесь их перегрузкой лишь потому» что эта мысль не дает вам покоя.
Это имеет смысл делать только в том случае, если вы обладаете
соответствующими знаниями и к вам действительно предъявляются
высокие требования но производительности или распределению памяти.
Как в действительности работают операторы new и delete
Подробности работы операторов new и delete— один из самых интригующих
аспектов C++. Рассмотрим следующую строку кода.
SpreadsheetCell* cell = new SpreadsheetCell () ,-
Часть строки new SpreadsheetCell () называется т\еы-выражением. При его
выполнении происходят две вещи. Во-первых, выделяется пространство для объекта
класса SpreadsheetCell путем обращения к оператору new. Во-вторых, вызывается
конструктор для создания этого объекта. Только после полного завершения работы этого
конструктора оператор new возвратит указатель на выделенную им область памяти.
Оператор delete функционирует подобным образом, но в "обратном
направлении". Рассмотрим следующую строку кода.
delete cell;
Эта строка называется delete-выражением. При его выполнении сначала
вызывается деструктор для указателя cell, а затем оператор delete, собственно,
освобождает память.
При использовании ключевого слова new для выделения памяти мы
не напрямую вызываем оператор new. И точно так же при
использовании ключевого слова delete для освобождения памяти мы не
напрямую вызываем оператор delete.
Вы можете перегружать операторы new и delete для управления процессами
выделения и освобождения памяти, однако вы не можете перегрузить new- или delete-
выражение. Таким образом, у вас есть возможность модифицировать способ
выделения и освобождения памяти, но не обращения к конструктору и деструктору.
Оператор new и new-выражение
Существует шесть различных форм new-выражений, каждое из которых имеет
соответствующий оператор new. В главах 13 и 15 вы уже видели первые четыре: new,
new [], nothrow new и nothrow new []. Операторы operator new для каждого из
них определены в заголовочном файле <new>. Для удобства воспроизведем их здесь.
void* operator new(size_t size) throw(badallос); // Для new
void* operator new[](size_t size) throw(
bad_alloc) ; // Для new[]
522 Часть IV. Как создать код без ошибок
void* operator new(
size_t size,
const nothrow_t&) throw(); // Для nothrow new
void* operator new[] (
sizet size,
const nothrow_t&) throw () ,- // Для nothrow new[]
Пятая и шестая формы new-выражений называются формами размещения (и
предназначены как для одной переменной, так и для целого массива). Они позволяют
создавать объект в уже существующей области памяти.
void* ptr = allocateMemorySomehow() ,-
SpreadsheetCell* cell = new (ptr) SpreadsheetCell();
Это средство требует дополнительных разъяснений, но пока вам важно знать, что
оно существует. Оно может пригодиться в случае, если вы хотите реализовать пулы
памяти, которые можно многократно использовать без промежуточного
освобождения. Соответствующие операторы operator new имеют следующий вид.
void* operator new(size_t size, void* p) throw();
void* operator new[](size_t size, void* p) throw();
Оператор delete и delete-выражение
Существует только две различные формы delete-выражений: delete и delete []
(никаких nothrow-форм или форм, связанных с размещением в существующих областях
памяти, здесь нет). При этом существуют все шесть форм оператора operator delete.
Почему вдруг такая асимметрия? Четыре дополнительные формы (nothrow и "с
размещением") используются только в случае, если из конструктора генерируется
исключение. В этом случае вызывается оператор operator delete, соответствующий
оператору operator new, который был использован для выделения памяти до вызова
конструктора. Но если вы удаляете указатель обычным способом, в любом случае будет
вызван оператор delete (operator delete или operator delete []), но никогда не
будет задействована форма nothrow или "с размещением". Реально это не имеет
практического значения: оператор delete никогда не генерирует исключения, поэтому
nothrow-версия оператора operator delete несет в себе избыточность, а удаление
"с размещением" должно быть представлено пустой командой (no-op). (Поскольку в
операторе operator new "с размещением" память не выделялась, то нечего и
освобождать.) Вот как выглядят прототипы методов для форм оператора operator delete.
void operator delete(void* ptr) throw();
void operator delete [] (void* ptr) throw (),-
void operator delete(void* ptr, const nothrow_t&) throw();
void operator delete[](void* ptr, const nothrow_t&) throw();
void operator delete (void* p, void*) throwO,-
void operator delete[](void* p, void*) throw();
Перегрузка операторов new и delete
При необходимости можно заменить глобальные операторы operator new и
operator delete. Эти функции будут вызваны для каждого new- и delete-выражения
в программе, если в конкретных классах не определены более конкретные методы.
Глава 16. Перегрузка С++-операторов 523
По этому поводу уместно процитировать Бьерна Страуструпа: "...замена глобальных
операторов operator new() и operator delete () не для слабонервных" {Язык
программирования C++, третье издание). И мы не рекомендуем вам это делать!
Если вы не примете во внимание наш совет и все-таки решите
заменить глобальный оператор operator new, имейте в виду, что в
оператор, который осуществляет обращение к оператору new, нельзя
помещать никакой код: в результате может получиться бесконечный
цикл. Например, нельзя выводить на консоль сообщение с помощью
объекта cout*
Может оказаться полезной перегрузка операторов operator new и operator
delete для конкретного класса. Эти перегруженные операторы будут вызываться
только при создании и разрушении объектов этого класса. Рассмотрим пример класса,
который перегружает четыре формы операторов operator new и operator delete
"без размещения".
#include <new>
using namespace std;
class MemoryDemo
{
public:
MemoryDemo() {}
~MemoryDemo() {}
void* operator new(size_t size)' throw(bad_alloc);
void operator delete (void* ptr) throw () ,-
void* operator newt](size_t size) throw(badalloc);
void operator delete[](void* ptr) throwO;
void* operator new(size_t size,
const nothrow_t&) throwO;
void operator delete(void* ptr,
const nothrow_t&) throw();
void* operator new[](size_t size,
const nothrow_t&) throwO;
void operator delete[](void* ptr,
const nothrow_t&) throwO,-
};
Вот как выглядит простая реализация этих операторов, которые передают
аргументы через вызовы глобальных версий операторов. Обратите внимание на то, что
элемент nothrow в действительности представляет собой переменную типа nothrow_t.
# include "MemoryDemo.h"
#include <iostream>
using namespace std;
void* MemoryDemo::operator new(size_t size) throw(badalloc)
{
cout « "operator new\n";
return (::operator new(size));
}
void MemoryDemo::operator delete(void* ptr) throwO
524 Часть IV. Как создать код без ошибок
cout << "operator delete\n";
::operator delete(ptr);
void* MemoryDemo::operator new[](size_t size) throw(badalloc)
cout << "operator new[]\n";
return (::operator new[](size));
void MemoryDemo::operator delete[](void* ptr) throw()
cout « "operator delete []\n";
: : operator delete [] (ptr) ;
void* MemoryDemo::operator new(size_t size,
const nothrow_t&) throw()
cout << "operator new nothrow\n";
return (::operator new(size, nothrow));
void MemoryDemo::operator delete(void* ptr,
const nothrow_t&) throw()
cout « "operator delete nothrow\n";
::operator delete[](ptr, nothrow);
void* MemoryDemo::operator new[](size_t size,
const nothrowtb) throw()
cout << "operator new[] nothrow\n" ,-
return (::operator new[](size, nothrow));
void MemoryDemo::operator delete[](void* ptr,
const nothrow_t&) throw()
cout « "operator delete [] nothrow\n" ,-
::operator delete[](ptr, nothrow);
Ниже приведен код, при выполнении которого несколькими способами создаются
и разрушаются объекты этого класса.
# include "MemoryDemo.h"
int main(int argc, char** argv)
{
MemoryDemo* mem = new MemoryDemo();
delete mem;
mem = new MemoryDemo[10];
delete [] mem;
mem = new (nothrow) MemoryDemo();
delete mem,-
mem = new (nothrow) MemoryDemo [10] ,-
delete [] mem,-
Глава 16. Перегрузка С++-операторов 525
return (0);
}
Результаты выполнения этой программы таковы.
operator new
operator delete
operator new[]
operator delete[]
operator new nothrow
operator delete
operator new[] nothrow
operator delete[]
Приведенные здесь реализации операторов operator new и operator delete
тривиальны и не представляют особой ценности с точки зрения полезности. Их
назначение — продемонстрировать возможность применения соответствующего синтаксиса
в случае, если вам когда-нибудь придется реализовывать их нетривиальные версии.
Если вы решитесь перегрузить оператор operator new, обязательно
перегрузите и соответствующую форму оператора operator delete.
В противном случае память будет выделяться по вашему особому
"рецепту", а освобождаться — в соответствии со встроенной
семантикой, которая может оказаться и несовместимой с семантикой ее
выделения.
Может показаться избыточным перегружать все различные формы оператора
operator new. Но в общем случае это — совсем неплохая идея, поскольку ее
реализация позволяет предотвратить возможные несообразности в средствах
распределения памяти. Если в ваши планы не входит написание всех форм, можно объявить эту
операторную функцию защищенной (protected) или закрытой (private), и тогда
никто не сможет ее использовать.
Перегружайте все формы оператора operator new или, чтобы
предотвратить их использование, предоставьте их private-объявления
без реализаций.
Перегрузка операторов operator new и operator delete
с дополнительными параметрами
Помимо перегрузки стандартных форм оператора operator new, можно
написать собственные версии с дополнительными параметрами. Например, рассмотрим
класс MemoryDemo с добавочным оператором operator new, принимающим
дополнительный целочисленный параметр.
#include <new>
using namespace std;
class MemoryDemo
{
public:
MemoryDemo();
-MemoryDemo();
526 Часть IV. Как создать код без ошибок
void* operator new(size_t size) throw(bad_alloc);
void operator delete(void* ptr) throw();
void* operator newt](size_t size) throw(bad_alloc);
void operator delete[](void* ptr) throw();
void* operator new(size_t size,
const nothrow_t&) throw();
void operator delete(void* ptr,
const nothrow_t&) throw();
void* operator new[](size_t size,
const nothrow_t&) throw 0;
void operator delete[](void* ptr,
const nothrow_t&) throw();
void* operator new(size_t size,
int extra) throw(bad_alloc);
};
void* MemoryDemo::operator new(size_t size,
int extra) throw(bad alloc)
{
cout « "Это operator new с дополнительным
"Ь int - аргументом. \п" ;
return {::operator new(size));
}
При написании перегруженного оператора operator new с дополнительными
параметрами компилятор будет автоматически использовать соответствующее new-
выражение. Итак, мы можем сейчас написать код, подобный такому.
int x = 5;
MemoryDemo* memp = new(5) MemoryDemo();
delete memp;
Дополнительные аргументы для оператора new передаются с помощью синтаксиса
вызова функции (как в версии nothrow new). Эти дополнительные аргументы могут
оказаться полезными для передачи в ваши средства управления памятью различных
признаков (флагов) или счетчиков.
В оператор operator delete нельзя добавлять произвольные дополнительные
аргументы. Однако альтернативная форма оператора operator delete позволяет
(помимо указателя) задать размер памяти, подлежащей освобождению. Для этого
достаточно объявить прототип оператора operator delete с дополнительным
параметром размера.
Если в вашем классе объявлены две идентичные версии оператора
operator delete, отличающиеся тем, что одна принимает параметр
размера, а другая-— нет, всегда будет вызываться версия без
параметра размера. Если вам нужно использовать версию с размером,
напишите только эту версию.
"Безразмерный" оператор operator delete можно заменить версией, которая
принимает размер, отдельно для любой из разновидностей оператора operator delete.
Вот как выглядит определение класса MemoryDemo с первым оператором operator
delete, модифицированным для принятия размера памяти, подлежащей удалению.
#include <new>
using namespace std,-
Глава 16. Перегрузка С++-операторов 527
class MemoryDemo
{
public:
MemoryDemo();
-MemoryDemo();
void* operator new(size_t size) throw(bad_alloc);
void operator delete(void* ptr, size_t size) throw ();
void* operator new[] (size_t size) throw (bad_alloc) ,-
void operator delete [] (void* ptr) throw (),-
void* operator new(size_t size,
const nothrow_t&) throw();
void operator delete(void*ptr,
const nothrow_t&) throw{);
void* operator newt](sizet size,
const nothrow__t&) throw();
void operator delete[](void* ptr,
const nothrow_t&) throw();
void* operator new(size_t size,
int extra) throw(badalloc) ,-
};
Реализация этого оператора operator delete вызывает глобальный оператор
operator delete без параметра размера, поскольку не существует глобального
оператора operator delete, который бы принимал значение размера.
void MemoryDemo::operator delete(void* ptr,
size_t size) throw()
{
cout << "Это operator delete с параметром size.\n",-
::operator delete(ptr);
}
Это средство полезно только в том случае, если вы реализуете для своих классов
специальную схему механизма выделения и освобождения памяти.
Резюме
В этой главе представлено логическое обоснование для перегрузки операторов,
а также приведены примеры с соответствующими разъяснениями по поводу
особенностей перегрузки операторов различных категорий. Мы надеемся, что сложности
синтаксиса механизма перегрузки не помешали вам оценить его силу.
Прочитав следующие главы этой книги, вы убедитесь, насколько эффективно
перегрузка операторов используется для реализации таких абстракций, как итераторы
(глава 23) и интеллектуальные указатели (глава 25).
Создание
эффективных С++-
программ
Эффективность программ — очень важный показатель, независимо от их области
применения. Если ваши программные продукты конкурируют с другими на мировом
рынке, то быстродействие может оказаться решающим фактором: если бы вам самим
пришлось выбирать между быстрой и медленной программами, то какой бы вы
отдали предпочтение? Никто бы не купил операционную систему, для загрузки которой
понадобилось бы две недели (конечно, при наличии альтернатив). Даже если вы пока
не собираетесь продавать свои программы, они все равно найдут своих
пользователей. И они не будут в восторге, если им придется томительно ожидать, пока ваша
программа выполнит задачу.
Теперь, когда вы понимаете принципы профессионального проектирования и
кодирования С++-программ и освоили ряд возможностей, предоставляемых
программисту языком, настало время уделить внимание быстродействию программ. При
создании эффективных программ об этом необходимо думать на всех уровнях
проектирования, а также на этапе детальной реализации. И хотя эта глава— далеко
не первая в книге, не забывайте брать в расчет фактор производительности с самого
начала жизненного цикла вашей программы.
Здесь мы впервые будем использовать рабочие термины "эффективность" и
"быстродействие" по отношению к программному коду, рассмотрим два уровня, на которых
Глава 17. Создание эффективных С++-программ 529
можно повысить эффективность выполнения ваших программ, и обсудим два
основных класса приложений. Мы поговорим о конкретных стратегиях, позволяющих
писать эффективные программы, а именно о методах оптимизации на уровне языковых
средств и принципах проектирования. Наконец, мы подробно рассмотрим методы
протоколирования (профилирования).
Немного о производительности
и эффективности
Прежде чем погружаться в конкретику, полезно определиться в терминах.
Производительность программы может относиться к таким областям, как скорость,
использование памяти, доступ к дискам и использование сети. В этой главе мы концентрируем
свое внимание на понятии скоростных характеристик (speed performance). Термин
эффективность применительно к программам означает их выполнение без "пустых
хлопот", т.е. зря потраченных усилий. Эффективная программа выполняет свои
задачи максимально быстро, точнее, настолько быстро, насколько это возможно при
данных обстоятельствах. Программа может быть эффективной и при этом не очень
быстродействующей, если данная предметная область в своей основе "имеет
противопоказания" для быстрого выполнения.
Эффективная, или высокопроизводительная, программа
выполняется настолько быстро, насколько это возможно при конкретных
обстоятельствах.
Обратите внимание на то, что название этой главы, "Создание эффективных С++-
программ", означает написание программ, которые эффективно выполняются, а не
эффективное написание программ. Другими словами, время, которое вы затратите на
изучение материала этой главы, сэкономит время пользователей ваших программ,
а не ваше личное время!
Два способа достижения эффективности
Традиционный подход к написанию эффективных программ— стремиться найти
оптимальное решение или улучшить производительность уже существующего кода. Этот
метод обычно включает только возможности самого языка программирования и
подразумевает конкретные автономные усовершенствования кода, например, замену способа
передачи объектов по значению передачей по ссылке. Этот подход может
удовлетворить вас лишь до некоторых пор. Но, если вы захотите писать действительно
высокопроизводительные приложения, вам придется думать об эффективности с первого
дня проектирования. Эффективность на уровне проектирования означает выбор
эффективных алгоритмов, избежание необязательных действий и вычислений, а также
использование соответствующих методов оптимального проектирования.
Два вида программ
Как было отмечено выше, эффективность важна для всех предметных областей.
Кроме того, существует небольшое подмножество таких программ, как системные
приложения, встроенные системы, приложения интенсивных вычислений и игры
530 Часть IV. Как создать код без ошибок
реального времени, которые требуют эффективности на чрезвычайно высоком
уровне. Большинство программ относятся к другой категории. Если вы пишете не такие
высокопроизводительные приложения, то вам, возможно, и не стоит беспокоиться
о том, как "выжать" из своего С++-кода еще "немного скорости". Попробуйте
подумать об этом в таком ракурсе. Представьте себе различие в создании обычных
семейных автомобилей и спортивных моделей. Каждый автомобиль должен быть
достаточно эффективным, однако спортивные машины должны иметь возможность развивать
чрезвычайно высокие скорости. Вы ведь посчитаете излишним тратить время на
оптимизацию семейных автомобилей ради получения высоких скоростных
характеристик, если они никогда не будут ездить быстрее 70 миль в час.
Разве C++ — не эффективный язык программирования?
С-программисты часто отказываются использовать C++ для создания
высокопроизводительных приложений. Они утверждают, что этот язык в своей основе менее
эффективен, чем С или какой-нибудь другой подобный ему процедурный язык. На
первый взгляд этот аргумент бесспорен, поскольку C++ включает такие конструкции
высокого уровня, как исключения и виртуальные методы, которые с принципиальной
точки зрения действуют медленно. Однако это только на первый взгляд.
Прежде всего, не следует игнорировать влияние компиляторов. Обсуждая
эффективность любого языка программирования, необходимо отделять рабочие
характеристики самого языка от возможностей компиляторов по оптимизации кода. Вспомните,
что С- или С++-код, написанный программистом, — это не тот код, который выполняет
компьютер. Компилятор сначала переводит этот код в машинный язык, применяя в
процессе компиляции средства оптимизации. Это означает, что нельзя просто аттестовать С-
и С++-программы и сравнить их результаты. В действительности сравниваются
компиляторные оптимизации языков, а не сами языки. С++-компиляторы могут оптимизировать
множество языковых конструкций высокого уровня, чтобы сгенерировать машинный
код, подобный тому, который создается на основе сопоставимой С-программы.
Однако критики по-прежнему настаивают на том, что некоторые средства языка
C++ невозможно оптимизировать вообще. Например, как разъяснялось в главе 10, для
использования виртуальных методов необходимо наличие г^габлицы и
дополнительного уровня косвенности во время выполнения программы, что, безусловно, означает
проигрыш во времени по сравнению с вызовами невиртуальных функций. Однако,
если более глубоко вникнуть в эту проблему, этот аргумент не является таким уж
убедительным. Вызовы виртуальных методов обеспечивают не просто вызовы функций:
они также позволяют динамически выбирать, какую из функций следует выполнять.
Для сопоставимых невиртуальных функций, чтобы решить, какую из них нужно таки
вызвать, пришлось бы использовать условную инструкцию (инструкции). Если вам не
нужна эта дополнительная семантика, можете использовать невиртуальную функцию
(хотя из стилевых соображений и аспектов безопасности мы не рекомендуем вам этот
вариант). Общее правило проектирования в C++ гласит: "если вы не используете что-то,
то вы и не должны за это платить". Если вы не используете виртуальные методы, вы и не
подвергаетесь "штрафным санкциям" (в смысле "ущерба" производительности) за то,
что могли бы их применять. Таким образом, с точки зрения производительности вызовы
невиртуальных функций в C++ идентичны вызовам обычных функций в языке С.
Однако наши критики могут быть правы в одном: некоторые аспекты C++
облегчают написание неэффективного кода на уровне языка. Использование без разбора
исключений и виртуальных функций может существенно замедлить ваши программы.
Глава 17. Создание эффективных С++-программ 531
Но этот аспект бледнеет в свете преимуществ, которые предлагает C++ для
построения алгоритмов и общего проектирования. Конструкции высокого уровня еще на
этапе создания проекта позволяют писать более ясные и эффективные программы,
которые гораздо легче поддерживаются и не создают условий для накопления
ненужного и "мертвого" кода (т.е. невыполняемых участков программы).
Наконец, оба автора этой книги использовали C++ для создания успешных
программных продуктов системного уровня, когда требовалась именно высокая
производительность кода. Мы верим, что, выбрав C++ вместо какого бы то ни было
процедурного языка программирования, вы будете довольны результатами своих разработок,
производительностью кода и возможностью его поддержки.
Эффективность на уровне языка
Зачастую в книгах, статьях, а также в дискуссиях между программистами много
внимания уделяется попыткам убедить читателей или оппонентов применять к коду
оптимизации на уровне языка. Эти тонкости очень существенны, поскольку они в некоторых
случаях способны ускорить выполнение программ. Однако важнее уделить внимание
общему проектированию и выбору алгоритмов для своих программ. Вы можете
передавать по ссылке все что угодно, но это не ускорит выполнение вашей программы, если
в ней обращение к диску для записи данных происходит в два раза чаще, чем это в
действительности нужно. Легко увязнуть в ссылках и указателях и забыть о программе в целом.
Более того, некоторые приемы языкового порядка могут быть реализованы
автоматически путем применения хороших оптимизирующих компиляторов. Поэтому,
прежде чем тратить драгоценное время на оптимизацию отдельных участков кода,
загляните в документацию на свой компилятор, и, возможно, некоторые этапы работы
отпадут сами собой.
В этой книге мы попытались соблюсти некоторый баланс стратегий. Поэтому мы
включили в нее самые полезные (с нашей точки зрения) средства оптимизации на
уровне языка. Этот список нельзя считать исчерпывающим, но он позволяет понять,
с чего следует начинать, если вы хотите оптимизировать свой код. Однако мы
надеемся, что вы не оставите без внимания и советы по обеспечению эффективности на
уровне проектирования, предлагаемые ниже в этой главе.
Средства оптимизации на уровне языка следует применять "с умом".
Эффективная обработка объектов
В C++ большой объем работы выполняется за программиста "закулисно", в
частности, это касается объектов. Вам следует всегда помнить о производительности кода,
который вы пишете, и, если вы будете следовать следующим простым
рекомендациям, ваш код станет значительно эффективнее.
Передача по ссылке
Это правило упоминается в нескольких местах этой книги, но оно заслуживает
того, чтобы здесь еще раз уделить ему внимание.
Объекты редко передаются в функцию или метод по значению.
532 Часть IV. Как создать код без ошибок
Передача по значению чревата затратами системных ресурсов на копирование,
которое отсутствует при передаче по ссылке. При передаче по значению внешне не
наблюдается никаких проблем, поэтому это правило часто подвергается забвению.
Рассмотрим класс, предназначенный для представления данных о человеке.
class Person
{
public:
Person();
Person(const string& inFirstName,
const string& inLastName, int inAge);
string getFirstName() { return firstName; }
string getLastName() { return lastName; }
int getAge() { return age; }
private:
string firstName, lastName;
int age;
};
Мы могли бы написать функцию, которая принимает объект класса Person.
void processPerson(Person p)
{
// Обработка информации о человеке.
}
Вызов этой функции может выглядеть так.
Person me("Nicholas", "Solter", 28);
processPerson(me);
Внешне этот вызов выглядит вполне невинно, т.е. ничто не подсказывает нам
о выполнении каких-то дополнительных действий, которых мы могли избежать,
написав нашу функцию в таком виде.
void processPerson(const Person& p)
{
// Обработка информации о человеке.
}
Обращение к функции остается прежним. Однако посмотрим, что происходит, когда
мы вызываем первую версию функции processPersonO, т.е. используем передачу
объекта по значению. Чтобы инициализировать параметр р функции processPerson (),
мы должны скопировать аргумент (объект класса Person) путем обращения к его
конструктору копии. Даже несмотря на то что мы не написали конструктор копии для
класса Person, компилятор сгенерирует его так, чтобы он копировал каждый член
данных. И пока это выглядит не так уж плохо: ведь наш класс содержит только три
члена данных. Но два из них являются строками, которые сами представляют собой
объекты класса string со своими конструкторами копии. Поэтому каждый из их
конструкторов копии также будет вызван. Версия функции processPersonO , которая
принимает параметр р по ссылке, не содержит в своем "балансовом отчете" статьи
расходов на копирование. Поэтому при использовании передачи по ссылке в этом
примере мы существенно экономим системные ресурсы.
Глава 17. Создание эффективных С++-программ 533
И это еще не все. Вспомните, что параметр р в первой версии функции process-
Person () является ее локальной переменной, и поэтому он должен быть разрушен по
ее завершении. Это разрушение требует обращения к деструктору класса Person.
Поскольку мы не написали деструктор сами, деструктор, действующий по умолчанию,
просто вызовет деструкторы всех членов данных. Объекты класса string имеют
деструкторы, поэтому завершение этой функции (при передаче ей параметра по
значению) сопровождается обращением к трем деструкторам. Но если объект класса Person
будет передан по ссылке, ни одно из подобных обращений не произойдет.
Итак, если функция должна модифицировать объект, вы можете просто передать
объект по ссылке. Если же функция не должна модифицировать объект, можно
передать его по const-ссылке, как в предыдущем примере. (Подробнее о передаче по ссылке
и применению модификатора const см. главу 12.)
Возвращение значения по ссылке
Подобно тому как вы должны передавать функциям объекты по ссылке, вам
следует также возвращать их по ссылке из функций, чтобы избежать ненужного их
копирования. К сожалению, иногда это попросту невозможно, например, в случае
перегруженного оператора operator+. Вы никогда не должны возвращать ссылку или
указатель на локальный объект, который будет разрушен по завершении функции!
Перехват исключений по ссылке
Как отмечалось в главе 15, чтобы избежать излишнего копирования, вы должны
перехватывать исключения по ссылке. Как описано выше в этом разделе, исключения
тяжело поддаются оптимизации с точки зрения производительности, поэтому будет
ценным любой малейший нюанс, который направлен на повышение
эффективности вашего кода.
Лучше избегать создания временных объектов
Компилятор в некоторых случаях временно создает неименованные объекты.
Вспомните (см. главу 9), что после написания глобального оператора operator+ для
класса вы можете выполнять сложение объектов этого класса с объектами других
типов, если последние можно преобразовать в объекты этого класса. Например,
частично определение класса SpreadsheetCell выглядит так.
class SpreadsheetCell
{
public:
// Другие конструкторы опущены ради экономии места.
SpreadsheetCell (double initialValue) ,-
friend const SpreadsheetCell operator+(
const SpreadsheetCell& lhs,
const SpreadsheetCell& rhs);
// Остальной код опущен ради экономии места.
};
Конструктор, который принимает double-параметр, позволяет написать код,
подобный следующему.
SpreadsheetCell myCell(4), aThirdCell;
aThirdCell = myCell + 5.6;
aThirdCell = myCell + 4;
534 Часть IV. Как создать код без ошибок
При выполнении первой строки сложения на базе аргумента 5.6 создается
временный объект класса SpreadsheetCell, а затем вызывается оператор operator+,
api-ументами которого служат объект myCell и этот временный объект. Результат
сложения сохраняется в объекте aThirdCell. При выполнении второй строки
сложения происходит то же самое, за исключением того, что целое число 4 необходимо
сначала привести к типу double, чтобы можно было вызвать double-конструктор
класса SpreadsheetCell.
Обратите внимание вот на что. В предыдущем примере компилятор генерирует код
для создания дополнительного неименованного объекта класса SpreadsheetCell при
выполнении каждой строки сложения. Этот объект должен создаваться и разрушаться
путем обращения к его конструктору и деструктору соответственно. Если вы
сомневаетесь в этом, попробуйте вставить cout-инструкции в свой конструктор (и
деструктор) и понаблюдайте за выводом данных.
В общем случае компилятор создает временный объект всегда, когда код
выполняет преобразование переменной одного типа в другой тип для использования в
большем выражении. Это правило применяется в основном к вызовам функций.
Например, предположим, что мы пишем функцию с такой сигнатурой.
void doSomething (const SpreadsheetCell& s) ,-
Мы можем ее вызвать так.
doSomething(5.56);
Компилятор из аргумента 5.56 создает временный объект класса Spreadsheet -
Cell с помощью double-конструктора, который передает результат функции
doSomething (). Обратите внимание на то, что, если удалить модификатор const из
объявления параметра s, вы не сможете больше вызывать функцию doSomething ()
с константой: вы должны будете передавать переменную. Временные объекты могут
служить только в качестве приемников const-ссылки.
В общем случае вам следует стараться избегать ситуаций, в которых компилятор
вынужден создавать временные объекты. Хотя этого невозможно избежать совсем,
вам следует по крайней мере знать о существовании этой "особенности", чтобы не
удивляться некоторому снижению производительности.
Оптимизация возврата данных по значению
Функция, которая возвращает объект по значению, может стать причиной
создания временного объекта. Вернемся к примеру создания класса Person,
предназначенного для представления данных о человеке. Рассмотрим следующую функцию.
Person createPerson()
{
Person newP;
return (newP);
}
Предположим, что ее можно вызвать так (будем исходить из допущения, что для
класса Person уже реализован оператор operator<<).
cout << createPerson () ,-
Несмотря на то что после этого вызова результат выполнения функции
createPerson () нигде явно не сохраняется, его все же необходимо где-то запомнить, чтобы
Глава 17. Создание эффективных С++-программ 535
передать при вызове оператора operators<. Чтобы сгенерировать код, реализующий
соответствующее поведение, компилятору разрешено создать временную переменную
для хранения объекта класса Person, возвращаемого из функции createPerson ().
Даже если результат этой функции нигде не использовался, компилятор все равно
может сгенерировать код для создания временного объекта. Например,
предположим, что программа выполняет такой код.
createPerson();
Компилятор может сгенерировать код для создания временного объекта, чтобы
возвратить из функции значение, хотя оно и не используется.
Однако обычно для беспокойства такого рода нет особых причин, поскольку
компилятор в большинстве случаев оптимизирует код, связанный с временными
переменными. Подобная оптимизация называется оптимизацией возвращаемых значений.
Не злоупотребляйте дорогостоящими языковыми средствами
Некоторые средства C++ с точки зрения скорости выполнения считаются дорогим
"удовольствием". "Горячую" десятку возглавляют исключения, виртуальные методы
и RTTI-cpeдетва. Если вас волнует вопрос эффективности, вам следует избегать их
применения. К сожалению, поддержка исключений и RTTI-средств влечет за собой затраты
системных ресурсов, связанные с потерями производительности даже в том случае, если
эти средства явно и не используются в программе. Другими словами, дополнительные
затраты обусловлены одной лишь возможностью использовать названные средства.
Поэтому многие компиляторы позволяют указать в программе, что ее необходимо
скомпилировать вообще без поддержки упомянутых средств. Например, рассмотрим
следующую простую программу, в которой используются как исключения, так и RTTI-средства.
// test.cpp
#include <iostream>
#include <exception>
using namespace std;
class base
{
public:
base О {}
virtual -base() {}
};
class derived : public base {};
int main(int argc, char** argv)
{
base* b = new derived();
derived* d = dynamic_cast<derived*>(b); // Используем RTTI.
if (d == NULL) {
throw exception(); // Используем исключения.
}
return (0);
}
Для Linux можно скомпилировать программу с помощью флага д++.
>д++ test.cpp
536 Часть IV. Как создать код без ошибок
Если флаг д++ задается для запрещения исключений, то попытка скомпилировать
эту программу будет выглядеть так.
>g++ -fno-exceptions test.cpp
test.cpp: In function ""int main (int, char**)':
test.cpp:20: exception handling disabled, use -fexceptions to enable
Удивительно, но если вы укажете флаг д++ для запрещения RTTI-средств,
компилятор успешно обработает вашу программу, несмотря на очевидное использование
оператора dynamic_cast.
>g++ -fno-rtti test.cpp
>
Однако использование RTTI-средств "застопорится" при выполнении программы,
что выразится в генерировании сообщения о нарушении сегментации.
Чтобы узнать, какие флаги можно использовать для запрещения "дорогостоящих"
средств, обратитесь к документации, прилагаемой к компилятору.
Запрещение поддержки языковых средств довольно рискованно. Ведь
вы никогда не знаете, в какой момент (а это всегда происходит
неожиданно) библиотека стороннего производителя сгенерирует
исключение или воспользуется RTTI-средствами. Поэтому вам следует
запрещать поддержку исключений и RTTI-средств только в том случае, если
вы пишете критичное к быстродействию приложение и совершенно
уверены, что ни ваш, ни библиотечный код не потребует этих средств.
Использование встраиваемых методов и функций
Как описано в главе 9, тело встраиваемых методов и функций вставляется
непосредственно в код программы, именно в место их вызова, позволяя тем самым избежать
затрат системных ресурсов на вызов метода или функции. Ключевым словом inline
следует отмечать все функции и методы, которые, с вашей точки зрения, могут быть
квалифицированы как подходящие для оптимизации такого рода. Однако не стоит
забывать, что запрос программиста на встраивание означает лишь рекомендацию для
компилятора. Он может и не встроить вашу inline-функцию.
Однако некоторые компиляторы в процессе оптимизации сами встраивают
подходящие функции или методы, даже если они и не отмечены ключевым словом inline. Таким
образом, прежде чем тратить время на то, чтобы решить, какие функции стоит сделать
встраиваемыми, имеет смысл почитать документацию, прилагаемую к компилятору.
Как позаботиться об эффективности
на уровне проектирования
Выбор тех или иных подходов к решению поставленной перед вами задачи на
этапе проектирования влияет на производительность гораздо в большей степени, чем
такие языковые средства, как передача аргументов по ссылке. Например, если вы для
фундаментальной задачи своего приложения выберете алгоритм, который
выполняется в течение 0(п ^-времени, вместо более простого, который занимает только 0(п)-
Глава 17. Создание эффективных С++-программ 537
времени, то вы потенциально выполните п вместо действительно необходимых п
операций. Это значит, что задачу, которая использует 0(п j-алгоритм и выполняет один
миллион операций, на самом деле можно решить с помощью лишь одной тысячи
операций, применяя при этом Ofnj-алгоритм. Даже если учесть возможность оптимизации на
языковом уровне, то один лишь простой факт, что вы выполняете миллион операций •
вто время, как более удачный алгоритм способен решить ту же задачу посредством
тысячи операций, уже делает вашу программу неэффективной. Однако справедливости
ради стоит напомнить, что нотация "большого О" игнорирует постоянные множители,
поэтому данным показателем стоит руководствоваться не во всех случаях. Тем не менее
вам следует очень тщательно подходить к выбору алгоритмов (см. главу 4).
Помимо выбора алгоритмов, эффективность на уровне проектирования включает
понятие специфических подходов. Поэтому в остальной части этого раздела мы
рассмотрим три метода проектирования для оптимизации программ: кеширование,
использование пулов объектов и пулов потоков.
Кеш как самое эффективное средство
Кеширование означает временное сохранение элементов для использования в
будущем, что позволяет избежать лишних операций по извлечению данных или их пересчету.
Возможно, вам уже знаком этот принцип, поскольку он широко применяется в
аппаратных средствах компьютера. Процессоры современных компьютеров строятся с
использованием кешей памяти, сохраняющих информацию, доступ к которой осуществляется чаще
всего в устройствах памяти с более быстрым доступом, чем может обеспечить основная
память. Большинство в принципе доступных ячеек памяти опрашиваются или
обновляются более одного раза за довольно короткий промежуток времени, поэтому
кеширование на аппаратном уровне может значительно ускорить процесс вычислений.
Для кеширования в программном обеспечении используется аналогичный подход.
Если код реализации некоторой задачи или вычисления отличается особенно
низкими скоростными характеристиками, вам следует убедиться, что вы не выполняете его
чаще, чем это действительно необходимо. Сохраните результаты в памяти при
первом выполнении этой задачи, чтобы они были доступны для последующих запросов.
Ниже представлен список задач, которые обычно решаются медленнее других.
□ Доступ к диску. В своей программе вам следует избегать открытия и чтения
одного и того же файла более одного раза. Если вам нужно часто обращаться к
этому файлу, сохраните его содержимое в ОЗУ (если, конечно, позволяет память).
□ Сетевая связь. Если вам нужно во время работы программы подключаться
к сети, то ее эффективность будет определяться нагрузкой данной сети.
Отнеситесь к сетевому доступу как к файловому и кешируйте столько статической
информации, сколько это возможно.
□ Математические вычисления. Если вам может понадобиться результат
некоторого вычисления в нескольких местах программы, выполните это
вычисление один раз и используйте полученный результат в других местах.
□ Создание объектов. Если вам нужно в своей программе создавать и
использовать большое количество короткоживущих объектов, подумайте о применении
пулов объектов (object pool), которые описаны в следующем разделе.
□ Создание потоков. Эта задача также может выполняться медленно. Можно
"кешировать" потоки в пуле потоков (thread-pool). См. раздел "Пулы потоков"
ниже в этой главе.
538 Часть IV. Как создать код без ошибок
Недействительность содержимого кеша
Одна из распространенных проблем, связанных с кешированием, состоит в том,
что сохраняемые в кеше данные часто представляют собой лишь копии базовой
информации. Исходные данные могут меняться в процессе жизненного цикла кеша.
Например, вы решили кешировать значения, составляющие файл конфигурации, чтобы
не нужно было повторно их считывать при необходимости. Однако пользователю
разрешается изменять этот файл во время работы рассматриваемой программы, что
может сделать кешированную версию данных конфигурации устаревшей. В подобных
случаях необходимо иметь механизм для признания недействительности кеша: при
изменении базовых данных вы должны запретить использование кешированной
информации или обновить содержимое кеша.
Один из методов признания недействительности кеша состоит в запросе на
уведомление вашей программы в случае, если базовые данные изменятся. Это можно
реализовать с помощью обратного вызова (callback), который ваша программа зарегистрирует
с помощью администратора. В качестве альтернативного варианта ваша программа
могла бы реагировать на определенные события, которые бы заставляли обновлять кеш
автоматически. Как бы вы ни реализовали недействительность кеша, главное—
продумать решение этих вопросов до использования кеша в своей программе.
Использование пула (накопителя) объектов
Как упоминалось в главе 13, пулы объектов — это возможность избежать создания
и удаления большого количества объектов во время выполнения программы. Если вы
знаете, что в программе придется использовать много короткоживущих объектов
одного и того же типа, можно создать пул, или кеш, этих объектов. А когда в коде
понадобится такой объект, вы возьмете его из пула. По завершении работы его (объект)
необходимо вернуть в пул. Пул объектов создает объекты только единожды, поэтому
и их конструктор вызывается только один раз, а не каждый раз, когда в них возникает
потребность. Таким образом, пулы объектов имеет смысл использовать в случае, если
конструктор выполняет некоторые установочные действия, которые применимы ко
многим объектам, и можно установить конкретные параметры для объекта, вызвав
методы, отличные от конструкторов.
Реализация пула объектов
В этом разделе представлена реализация шаблонного класса пула, который вы
можете использовать в своих программах. Этот пул позволяет создать набор объектов
заданного класса при его построении. "Получить" объект для работы можно с помощью метода
acquireOb j ect (), а "возвратить" его в пул — с помощью метода releaseObj ect ().
Если после вызова метода aquireObject () окажется, что в пуле больше нет
"свободных" объектов, будет создан другой их набор.
Самый трудный аспект реализации пула объектов — отследить, какие объекты
свободны, а какие — заняты. В этой реализации для хранения свободных объектов
применяется такой подход, как очередь. Каждый раз, когда клиент делает запрос на
получение объекта, пул предлагает ему взять "верхний" объект из очереди. Пул не
отслеживает занятые объекты явным образом. Он "доверяет" клиенту, полагаясь на -
то, что он корректно возвратит их в пул по завершении работы. Отдельно пул
отслеживает все созданные объекты в векторе, который используется только в случае, если
пул разрушается, чтобы освободить память для всех объектов, тем самым
предупреждая утечку памяти.
Глава 17. Создание эффективных С++-программ 539
В предлагаемом вам коде используются STL-реализации очереди и вектора,
которые были представлены в главе 4. Контейнер очереди позволяет клиентам добавлять
элементы с помощью метода push (), удалять — с помощью метода pop () и
воспользоваться ее верхним элементом — с помощью метода front (). Добавление элементов
к вектору реализуется методом push__back (). Более детально с этими двумя
контейнерами можно познакомиться в главах 21—23.
Ниже приведено определение этого класса с подробными комментариями.
Обратите внимание на то, что данный шаблон параметризован по типу класса, объекты
которого будут создаваться в этом пуле.
#include <queue>
#include <vector>
#include <stdexcept>
#include <memory>
using std::queue;
using std: : vector,-
//
// Шаблонный класс ObjectPool
//
// поддерживает пул объектов, которые можно использовать для
// любого класса, который предусматривает использование
// конструктора по умолчанию.
//
// Конструктор пула объектов создает пул объектов, которые он
// предлагает клиентам по запросу через метод acquireObject().
//По завершении работы с объектом клиент должен вызвать метод
// releaseObject{), чтобы вернуть объект в пул.
//
// Конструктор и деструктор для каждого объекта, хранимого в
// пуле, будет вызываться только один раз за все время
// выполнения программы, а не при каждом получении или
// освобождении объекта.
//
// Основное назначение пула объектов - избежать повторяющегося
// создания и удаления объектов. Пул объектов более всего
// подходит для приложений, в которых используется большое
// количество объектов в течение короткого периода времени.
//
// В целях эффективности этот пул объектов не выполняет
// санитарную проверку (общий контроль программы на отсутствие
// тривиальных ошибок). Предполагается, что пользователь
// аккуратно возвратит каждый полученный им объект, причем
// единожды, а также не станет использовать объекты, уже
// возвращенные в пул.
//
// Предполагается, что пользователь не будет удалять пул
// объектов до тех пор, пока не будет возвращен каждый
// "выданный" объект. Удаление пула объектов делает
// недействительными любые объекты, которые получил
// пользователь, даже если они еще не были освобождены.
//
template <typename T>
class ObjectPool
{
public:
//
// Создается пул, содержащий chunkSize объектов.
// Всякий раз, когда в пуле иссякают объекты, в него
// будет добавлено еще chunkSize объектов. Пул может
// только расти: объекты никогда из него не удаляются
540 Часть IV. Как создать код без ошибок
// (освобождаются). Это происходит до тех пор, пока пул
// не будет разрушен совсем.
//
// Генерируется исключение типа invalid_argument, если
// окажется, что chunkSize <= 0.
//
ObjectPool(int chunkSize = kDefaultChunkSize)
throw(std::invalidargument, std::bad_alloc);
//
// Освобождаются все созданные объекты. Любые объекты,
// которые были затребованы для работы, становятся
// недействительными.
//
-ObjectPool() ;
//
// Резервируем объект для использования. Ссылка на
// него недействительна, если пул объектов сам
// освобожден.
//
// Клиенты не должны освобождать этот объект!
//
Т& acquireObject();
//
// Возвращаем объект в пул. Клиенты не должны
// использовать этот объект после его возвращения в пул.
//
void releaseObject(T& obj);
protected:
//
// В очереди mFreeList хранятся объекты, которые не
// используются клиентами.
//
queue<T*> mFreeList;
//
// В векторе mAllObjects хранятся указатели на все
// объекты (используемые или нет). Этот вектор
// позволяет гарантировать, что все объекты
// освобождаются надлежащим образом в деструкторе.
//
vector<T*> mAllObjects,-
int mChunkSize,-
static const int kDefaultChunkSize = 10;
//
// Создается mChunkSize новых объектов, которые
// добавляются в очередь mFreeList.
//
void allocateChunkO ,-
static void arrayDeleteObject(T* obj);
private -.
// Предотвращается присваивание и передача по значению.
ObjectPool(const ObjectPool<T>& src);
ObjectPool<T>& operator= (const ObjectPool<T>& rhs) ,-
b
Ha этом определении класса стоит остановиться поподробнее. Прежде всего,
обратите внимание на то, что объекты "выдаются" из пула и возвращаются в него по
ссылке (а не по указателю), освобождая тем самым клиентов от использования
указателей. Кроме того, заметьте, что пользователь пула объектов задает через шаблонный
Глава 17. Создание эффективных С++-программ 541
параметр имя класса, из которого необходимо создавать объекты, а через
конструктор — "размер набора" создаваемых объектов. Этот размер соответствует количеству
объектов, создаваемых одновременно. Вот как выглядит код, который определяет
переменную размера kDefaultChunksizе.
template<typename T>
const int ObjectPool<T>:ikDefaultChunkSize;
По умолчанию создаегся 10 объектов (это число задано в определении класса).
Возможно, в большинстве случаев это число слишком мало. Если вашей программе
потребуются тысячи объектов сразу, вам следует использовать более подходящее значение.
Конструктор проверяет корректность параметра chunkSize и вызывает
вспомогательный метод allocateChunk () для получения стартового набора объектов.
template <typename T>
ObjectPool<T>::0bjectPool(
int chunks i z e) throw(
std::invalid_argument,
std::bad_alloc) : mChunkSize(chunkSize)
{
if (mChunkSize <= 0) {
throw std::invalid_argument(
"Размер должен быть положительным.");
}
// Создаем для начала mChunkSize объектов.
allocateChunk();
}
Метод allocateChunk () создает mChunkSize элементов в непрерывной области
памяти и сохраняет указатель на массив объектов в векторе mAllObjects, помещая
при этом каждый отдельный объект в очередь mFreeLlist.
//
// Создается массив mChunkSize объектов, поскольку это более
// эффективно, чем создание каждого из них в отдельности.
// Сохраняется указатель на первый элемент массива в векторе
// mAllObjects. Добавляется указатель на каждый новый объект
// в очередь mFreeList.
//
template <typename T>
void ObjectPool<T>::allocateChunk()
.{
T* newObjects = new T[mChunkSize];
mAllObjects.pushback(newObjects);
for (int i = 0; i < mChunkSize; i++) {
mFreeList.push(&newObjects[i]);
}
}
Деструктор просто освобождает все массивы объектов, которые были созданы при
выполнении метода allocateChunk (). Но для этого он использует STL-алгоритм
for_each(), передавая ему указатель на статический метод arrayDelete (),
который в свою очередь реально удаляет каждый массив объектов. За деталями
реализации STL-алгоритма обращайтесь к главам 21—23.
//
// Функция освобождения объектов, используемая алгоритмом
// for_each() в деструкторе.
//
542 Часть IV. Как создать код без ошибок
template<typename T>
void ObjectPool<T>::arrayDeleteObject(T* obj)
{
delete [] obj;
}
template <typename T>
Obj ectPool<T>::-Obj ectPool()
{
// Освобождается каждый набор объектов.
for_each(mAllObjects.begin() , mAllObjects.endO ,
arrayDeleteObject);
}
Метод acquireOb j ect () возвращает верхний объект из списка свободных
объектов, но если таковые отсутствуют, вызывается метод allocateChunk ().
template <typename T>
Т& ObjectPool<T>::acquireObject()
{
if (mFreeList.empty()) {
allocateChunk();
}
T* obj = mFreeList .front () ,-
mFreeList.pop();
return (*obj);
}
Наконец, метод releaseOb j ect () возвращает объект в "хвост" списка свободных
объектов.
template <typename T>
void ObjectPool<T>::releaseObject(T& obj)
{
mFreeList.push(&obj);
}
Использование пула объектов
Рассмотрим приложение, которое нацелено на получение запросов от
пользователей и обработку этих запросов. Вероятнее всего, это приложение будет своего рода
посредником между графическим внешним интерфейсом и внутренним интерфейсом
базы данных. Например, оно могло бы быть частью системы резервирования билетов
на авиарейсы или приложением, позволяющим выполнять банковские операции по
сети. Вы могли бы кодировать запрос каждого пользователя в объекте, используя
класс, который выглядит подобно следующему.
class UserRequest
{
public:
UserRequest() {}
-UserRequest() {}
// Методы заполнения запроса специальной информацией *
// Методы считывания данных запроса
// (не показаны)
protected:
// Члены данных (не показаны)
};
Глава 17. Создание эффективных С++-программ 543
Вместо создания и удаления большого количества запросов во время выполнения
программы вы могли бы использовать пул объектов. В этом случае структура
программы имела бы следующий вид.
UserRequest& obtainUserRequest(ObjectPool<UserRequest>& pool)
{
// Получаем объект класса UserRequest из пула.
UserRequest& request = pool.acquireObject();
// Заполняем этот запрос данными, введенными пользователем
// (не показано).
return (request);
}
void processUserRequest(ObjectPool<UserRequest>& pool,
UserRequest& req)
{
// Обрабатываем запрос
// (не показано).
// Возвращаем запрос (объект) в пул.
pool.releaseObject(req) ;
}
int main(int argc, char** arcjv)
ObjectPool<UserRequest> requestPool (1000) ,-
// Настройка программы
// (не показано).
while (/* программа выполняется */) {
UserRequest& req = obtainUserRequest (requestPool) ,-
processUserRequest(requestPool, req);
}
return (0);
}
Использование пула потоков
Пулы потоков во многом подобны пулам объектов. Вместо многократного
динамического создания и удаления потоков в течение всего периода выполнения
программы можно создать пул потоков, который бы позволял использовать их по
необходимости. Этот подход часто применяется в программах, которые обрабатывают
поступающие по сети запросы. Ваш Web-сервер мог бы хранить пул потоков, готовый
к поиску страниц в ответ на каждый поступающий от клиента запрос.
Особенности поддержки потоков определяются конкретной платформой (см.
главу 18), поэтому примеры пулов потоков здесь не рассматриваются. Но вы сами можете
написать свой вариант "по образу и подобию" пула объектов.
Протоколирование программ
Несмотря на то что мы советовали вам думать об эффективности еще на этапе
проектирования и кодирования, вам следует понимать, что не каждое
законченное приложение будет выполняться наилучшим образом. Например, эффективность
544 Часть IV. Как создать код без ошибок
обязательно пострадает при попытке сгенерировать многофункциональную
программу. На основании собственного опыта мы можем утверждать, что чаще всего
оптимизация по эффективности выполняется для уже работающих программ. Даже если
вы вовремя "заложили" фактор эффективности в свою разработку, вам вряд ли
удастся оптимизировать именно те части программы! Как упоминалось в главе 4, 90%
времени работы большинства программ тратится на выполнение лишь 10% кода. Это
означает, что, оптимизируя 90% своего кода, вы в действительности улучшили время
выполнения программы лишь на 10%. Очевидно, вам стоило бы оптимизировать те
части кода, которые выполняют максимум работы для ожидаемой нагрузки.
Поэтому имеет смысл протоколировать свою программу, чтобы определить, какие
части кода требуют оптимизации. Существует множество средств протоколирования
(profiling tools), которые анализируют программы во время их выполнения, чтобы
сгенерировать информацию об их производительности. Большинство средств
протоколирования обеспечивают анализ на функциональном уровне путем указания объема
времени (или в процентах от общего времени выполнения), затраченного в каждой
функции программы. Обычно после запуска профайлера (подпрограммы
протоколирования, позволяющей оценить время выполнения отдельных функций) можно сразу
сказать, какие части программы требуют оптимизации. Кроме того, чтобы убедиться,
что оптимизация была выполнена не зря, полезно обратиться к протоколированию
до процесса оптимизации и после него.
Наилучшим средством протоколирования программ мы считаем Rational Quantify
(от компании IBM). Оно небесплатно (причем плата за лицензию довольно
существенна) , но сначала вам не помешает узнать, имеет ли ваша компания или
академический институт лицензию на его использование. Если же вам запрещено покупать
лицензии, можете воспользоваться и бесплатными средствами. Одним из самых
известных средств такого рода считается программа gprof, которая подходит для
большинства систем Unix, включая Solaris и Linux.
Пример протоколирования программы с помощью
средства gprof
Эффект протоколирования лучше всего увидеть на реальном коде. Ошибки
производительности, продемонстрированные на примере первого его варианта, не очень
существенны. Реальные проблемы эффективности, с которыми вам предстоит
столкнуться, будут, вероятно, сложнее, но в этом случае нам пришлось бы приводить
слишком длинную программу, что не подходит для данной книги.
Предположим, что вы работаете в Управлении по надзору за социальным
обеспечением США (United States Social Security Administration). Каждый год Управление
обновляет Web-сайт, который дает возможность пользователям оценить популярность имен
для новорожденных за предыдущий год. Ваша задача — написать программу, которая
бы по заданному имени снабжала пользователей полезной информацией. В качестве
входных данных вы используете файл, содержащий имена детей. Например, в файле
для мальчиков за 2003 год самым популярным было имя Jacob (Джекоб), поскольку им
воспользовались молодые родители 29 195 раз. Ваша программа должна "просмотреть"
этот файл и создать базу данных "в памяти" компьютера. А затем пользователь мог бы
узнать абсолютное количество детей, получивших при рождении заданное имя, или
место, которое это имя занимает среди всех других детских имен (ранг).
Глава 17. Создание эффективных С++-программ 545
Первый вариант проектирования
Логическое проектирование этой программы включает класс NameDB со
следующими public-методами.
#include <string>
#include <stdexcept>
using std::string;
class NameDB
{
public:
// Считываем список детских имен, содержащийся в файле
// nameFile, чтобы заполнить базу данных.
// Генерируем исключение типа invalidargument, если
// файл nameFile не удается открыть или прочитать.
NameDB(const strings nameFile) throw (
std::invalid_argument);
// Метод возвращает ранг имени (1-е, 2-е и т.д.).
// Метод возвращает -1, если имя не найдено.
int getNameRank(const strings name) const;
// Метод возвращает количество детей с таким именем.
// Метод возвращает -1, если имя не найдено,
int getAbsoluteNumber(const string &name) const;
// Защищенные и закрытые методы не показаны.
};
Выбрать подходящую структуру для нашей базы данных довольно непросто. Для
первого варианта можно взять массив (или вектор из STL-библиотеки), содержащий пары
"имя-количество". Каждый элемент в этом векторе будет хранить одно из имен вместе
с числом, которое соответствует тому, сколько раз это имя встретилось в исходном
файле данных. Приведем полное определение класса для такого метода проектирования.
#include <string>
#include <stdexcept>
#include <vector>
using std: :string,-
class NameDB
{
public:
NameDB(const strings nameFile) throw (
std: .-invalidargument) ;
int getNameRank(const strings name) const;
int getAbsoluteNumber(const strings name) const;
protected:
std::vector<std::pair<string, int> > mNames;
// Вспомогательные методы.
bool nameExists(const strings name) const;
void incrementNameCount(const strings name);
void addNewName(const strings name);
546 Часть IV. Как создать код без ошибок
private:
// Предотвращаем присваивание и передачу по значению.
NameDB(const NameDBk src) ;
NameDBk operator=(const NameDB& rhs); *
};
Обратите внимание на использование STL-контейнера vector и типа pair. Тип
pair— это просто вспомогательный класс, который объединяет две переменные
разного типа. За деталями реализации STL-контейнера обращайтесь к главам 21—23.
Ниже приведена реализация конструктора и вспомогательных функций name-
Exists (), incrementNameCount () и addNewName (). Если вы не знакомы с
библиотекой STL, то вам может быть непонятна реализация циклов в функциях nameExists ()
и incrementNameCount (). Поэтому отметим, что в этих циклах выполняется обход
всех элементов вектора mNames.
//
// Считываем имена из файла и заполняем базу данных.
// База данных представляет собой вектор пар "имя/количество",
// в котором хранятся числа, означающие, сколько раз каждое
// имя встретилось в исходном файле данных.
//
NameDB::NameDB(const strings nameFile) throw (
invalid argument)
{
// Открываем файл и проверяем на ошибки,
if stream inFile (nameFile . c_str () ) ,-
if (!inFile) {
throw invalid_argument("Невозможно открыть файл \п");
}
// Считываем по одному имени.
string name,-
while (inFile >> name) {
// Ищем это имя в текущей базе данных.
if (nameExists(name)) {
// Если имя существует в базе данных, просто
// инкрементируем счетчик.
incrementNameCount(name);
} else {
// Если имя пока не существует в базе данных,
// добавляем его, указывая его счетчик равным 1.
addNewName(name);
}
}
inFile.close();
}
//
// Функция nameExists
//
// возвращает значение true, если имя существует в базе
// данных, и значение false в противном случае.
//
bool NameDB::nameExists(const string& name) const
{
// "Проходим" по вектору имен в поиске заданного имени.
for (vector<pair<string, int> >::const_iterator it =
mNames .begin () ,- it != mNames . end () ; ++it) {
if (it->first == name) {
return (true);
Глава 17. Создание эффективных С++-программ 547
}
}
return (false);
}
//
// Функция incrementNameCount.
//
// Предусловие: имя существует в векторе имен.
// Постусловие: счетчик, соответствующий этому имени,
// инкрементируется.
//
void NameDB::incrementNameCount(const stringk name)
{
for (vector<pair<string, int> >::iterator it =
mNames.begin();
it != mNames.end(); ++it) {
if (it->first == name) {
it->second++;
return;
//
// Функция addNewName
//
// добавляет в базу данных новое имя.
//
void NameDB::addNewName(const strings name)
{
mNames.push_back(make_pair<string, int>(name, 1) ) ,-
}
Обратите внимание на то, что в предыдущем примере для выполнения действий,
аналогичных циклам в функциях nameExists () и incrementNameCount (), можно
было бы использовать алгоритм, подобный f ind_if. Мы приводим эти циклы в
явном виде лишь для того, чтобы подчеркнуть проблемы производительности.
Сообразительный читатель мог бы уже заметить некоторые проблемы
производительности. При условии существования сотен тысяч имен многие линейные
алгоритмы поиска, включенные в процесс заполнения базы данных, обнаруживают
недопустимо низкие скоростные показатели.
Чтобы завершить этот пример, приведем реализацию двух public-методов.
//
// Метод getNameRank
//
// возвращает ранг имени.
// Сначала ищем имя, чтобы получить количество детей с таким
// именем.
// Затем "проходим" по всем именам, подсчитывая все имена с
// более высоким значением счетчика, чем у заданного имени.
// Метод возвращает в качестве ранга результат этого подсчета.
//
int NameDB::getNameRank(const string& name) const
{
// Используем метод getAbsoluteNumber().
int num = getAbsoluteNumber(name);
// Проверяем, нашли ли мы имя.
if (num == -1) {
return (-1);
548 Часть IV. Как создать код без ошибок
}
}
//
// Теперь подсчитаем все имена в векторе, у которых
// значение счетчика выше текущего. Если ни одно имя не
// имеет более высокого значения счетчика, это имя будет
// иметь ранг, равный числу 1. Каждое найденное имя с
// более высоким значением счетчика уменьшает ранг
// текущего имени на 1.
//
int rank = 1;
for (vector<pair<string, int> >::const_iterator it =
mNames.begin()
it != mNames.end(); ++it) {
if (it->second > num) {
rank++;
}
}
return (rank);
//
// Метод getAbsoluteNumber
//
// возвращает значение счетчика, связанное с данным именем.
//
int NameDB::getAbsoluteNumber(const strings name) const
{
for (vector<pair<string, int> >::const_iterator it =
mNames.begin()
it != mNames.end(); ++it) {
if (it->first == name) {
return(it->second);
}
}
return (-1) ,-
}
Протоколирование первого варианта
Чтобы протестировать программу, необходимо создать main-функцию.
#include "NameDB.h"
int main(int argc, char** argv)
{
NameDB boys("boys_long.txt");
cout << boys.getNameRank("Daniel") << endl;
cout << boys.getNameRank("Jacob") << endl;
cout << boys.getNameRank("William") << endl;
}
return (0);
При выполнении этой main-функции создается один экземпляр базы данных
NameDB с именем boys, который заполняется содержимым файла boys_long.txt.
Этот файл включает 500 500 имен.
Для использования программы протоколирования gprof необходимо выполнить
три действия.
Глава 17. Создание эффективных С++-программ 549
1. Скомпилируйте программу с применением специального флага, который при
ее очередном запуске предписывает регистрировать информацию о деталях
выполнения. В среде Solaris 9 при использовании компилятора SunOne Studio 8
C++ Compiler таким флагом является -хрд.
> СС -о namedb -xpg main.cpp NameDB.cpp
2. Затем запустите свою программу на выполнение. При выполнении приведенной
выше команды в рабочем каталоге должен быть сгенерирован файл gmon. out.
3. Заключительный этап — выполнение gprof-команды, позволяющей
проанализировать запротоколированную информацию из файла gmon.out, и создать
читабельный отчет. Опция -С обеспечивает читабельность имен функций C++.
Программа gprof выводит результаты своей работы на стандартное
устройство, поэтому имеет смысл перенаправить вывод данных в файл.
> gprof -С namedb gmon.out > gprof_analysis.out
Теперь можно проанализировать полученные данные. К сожалению, файл
результата нуждается в некоторой расшифровке. Средство gprof формирует два набора
данных. Второй набор указывает общее время, затраченное на выполнение каждой
функции тестируемой программы. А первый, он же и более полезный, указывает
общее время, затраченное на выполнение каждой функции программы и их потомков.
Приведем некоторые результаты из файла gprof_analysis .out,
отредактированные для получения более читабельного вида.
[2] 85.1 0.00 48.21 1 main [2]
Эта строка означает, что функция main() и ее потомки занимают 85,1% общего
времени выполнения программы, что составляет 48,21 секунды. Оставшиеся 14,9%
времени принадлежат другим задачам, например, работе динамически связываемых
библиотек и инициализации глобальных переменных. Следующий элемент означает,
что на выполнение конструктора класса NameDB и его потомков уходит
48,18 секунды, что практически составляет все время функции main (). Из данных
по вложенным элементам (под именем NameDB: : NameDB) можно понять, какой из
потомков имеет самые высокие временные показатели. Так, на каждую из функций
nameExistsO и incrementNameCount () приходится приблизительно 14 секунд.
Вспомните, что эти числа представляют собой просуммированное время по всем
обращениям к этим функциям. Третий столбец в следующих строках содержит
количество вызовов функций (500 500 для функции nameExists () и 499 500 для функции
incrementNameCont ()). Остальные функции не оказывают существенного влияния
на время выполнения программы использования базы данных NameDB.
[3] 85.1 0.03 48.18 1 NameDB::NameDB
9.60 14.04 500500/500500 bool NameDB::nameExists
8.36 14.00 499500/499500 void NameDB::incrementNanb meCount
Опуская менее существенные детали этого анализа, считаем нужным обратить
ваше внимание на следующие два момента.
550 Часть IV. Как создать код без ошибок
1. Оказывается, на заполнение базы данных приблизительно 500 000 именами
уходит 48 секунд, что означает довольно медленную работу. Возможно, вам
необходимо усовершенствовать используемую структуру данных.
2. Функции nameExistsO и incrementNameCount () занимают практически
одинаковое время и вызываются почти одинаковое количество раз.
Большинство имен во входных текстовых файлах дублируется, поэтому за вызовами
функции incrementNameCount () следует подавляющее большинство
обращений к функции nameExists (). Просмотрев внимательно код, вы заметите, что
эти функции практически идентичны; значит, имеет смысл их объединить.
Кроме того, львиную долю того, что они делают, составляет поиск нужных
данных в векторе. Поэтому, возможно, чтобы сократить время поиска, было бы
лучше использовать отсортированную структуру данных.
Второй вариант
С учетом двух выше приведенных выводов, сделанных на основании применения
средства gprof, попробуем переделать нашу программу. В новом проекте вместо
вектора (vector) возьмем отображение (тар). Как упоминалось в главе 4, ST1.-контейнер тар
в качестве базовой использует древовидную структуру, которая хранит свои элементы
в отсортированном виде и гарантирует, что время поиска элементов в ней
выражается логарифмической (0(log n)) зависимостью, а не линейной (О(и)), как в векторе.
Новая версия программы характеризуется также тем, что две бывшие отдельные
функции nameExists () и incrementNameCount () объединены нынче в одну
функцию nameExistsAndlncrement().
Вот как теперь выглядит определение класса.
#include <string>
#include <stdexcept>
#include <map>
using std::string;
class NameDB
{
public:
NameDB(const strings nameFile) throw (
std: : invalid_argument) ,-
int getNameRank(const strings name) const;
int getAbsoluteNumber(const strings name) const;
protected:
std: :map<string, int> mNames,-
bool nameExistsAndlncrement(const strings name);
void addNewName (const strings name) ,-
private:
// Запрещаем присваивание и передачу по значению.
NameDB (const NameDBS src) ,-
NameDBS operator=(const NameDBS rhs);
};
Глава 17. Создание эффективных С++-программ 551
Рассмотрим новые реализации методов.
//
// Метод считывает имена из файла и заполняет базу данных.
// Эта база данных представляет собой отображение (тар),
// связывающее имена с их частотой использования.
//
NameDB::NameDB(const string& nameFile) throw (
invalid_argument)
{
//
// Открываем файл и проверяем на наличие ошибок.
//
ifstream inFile(nameFile.c_str());
if (!inFile) {
throw invalid_argument("He удается открыть файл \п");
}
//
// Считывает имена по одному.
//
string name;
while (inFile >> name) {
//
// Выполняем поиск имени в базе данных.
//
if (InameExistsAndlncrement(name)) {
//
// Если имени name нет в базе данных, оно будет
// добавлено со счетчиком, равным 1.
//
addNewName(name);
}
}
inFile.close();
}
//
// Метод nameExistsAndlncrement ()
//
// возвращает значение true, если имя существует в базе
// данных, и значение false в противном случае. Если имя
// найдено, соответствующий счетчик инкрементируется.
bool NameDB:InameExistsAndlncrement(const strings name)
{
//
// Выполняем поиск имени name в отображении (map).
//
map<string, int>::iterator res = mNames.find(name);
if (res != mNames.end()) {
res->second++;
return (true);
}
return (false);
}
//
// Метод addNewName()
//
// добавляет новое имя в базу данных.
//
552 Часть IV. Как создать код без ошибок
void NameDB::addNewName(const strings name)
{
mNames.insert(make_pair<string, int>(name, 1));
}
//
// Метод getNameRank()
//
// возвращает ранг имени.
int NameDB::getNameRank(const strings name) const
{
int num = getAbsoluteNumber(name);
//
// Проверяет, обнаружили ли мы имя.
//
if (num == -1) {
return (-1) ;
}
//
// Теперь подсчитываем все имена в отображении, у
// которых значение счетчика больше данного. Если ни
// одно имя не имеет более высокого значения счетчика,
// то данное имя получает ранг, равный 1. Каждое
// найденное имя с более высоким значением счетчика
// уменьшает ранг данного имени на 1.
//
int rank = 1,-
for (map<string,
int>::const_iterator it = mNames.begin();
it != mNames.end(); ++it) {
if (it->second > num) {
rank++ ,-
return (rank);
}
//
// Метод getAbsoluteNumber()
//
// возвращает значение счетчика, соответствующее данному
// имени.
//
int NameDB::getAbsoluteNumber(const strings name) const
{
map<string, int>::const_iterator res ==
mNames. find (name) ,-
if (res != mNames.end()) {
return (res->second);
}
return (-1);
}
Глава 17. Создание эффективных С++-программ 553
Протоколируем второй вариант
Выполняя уже знакомую вам последовательность действий с помощью средства
gprof, можно получить данные по новой версии программы. Они выглядят вполне
обнадеживающе.
[2] 85.3 0.00 3.19 1 main [2]
Теперь функция main () выполняется лишь 3,19 секунды: налицо 15-кратное
улучшение! Безусловно, в этой программе есть еще что улучшать, но мы предлагаем это
сделать нашим читателям в качестве упражнения. Небольшая подсказка: для
определения ранга имен может пригодиться кеширование данных.
Резюме
В этой главе мы рассмотрели ключевые аспекты эффективности и
производительности С++-программ и описали ряд советов по проектированию и реализации более
эффективных приложений. Мы надеемся, вы по достоинству оцените возможности
средств протоколирования программ. Помните, что об эффективности и
производительности стоит думать с первых минут запуска жизненного цикла вашей программы:
эффективность, "заложенная" на этапе проектирования, гораздо важнее
эффективности, достигаемой на уровне средств языка.
Разработка
межплатформенных
приложений
С++-программы могут быть скомпилированы в расчете на выполнение под
управлением различных операционных систем. Язык программирования C++ определен
таким образом, чтобы программирование на нем для одной платформы не отличалось
от программирования для другой. Несмотря на стандартизацию языка, различия
в платформах начинают проявляться лишь при написании профессиональных
программ на C++. Но даже если разработка ограничена конкретной платформой,
маленькие различия в компиляторах могут стать причиной головной боли дли
программиста. В этой главе рассматриваются как раз те аспекты программирования, которые
связаны с созданием программ, предполагающих выполнение на различных
платформах, и использующих не один, а несколько языков программирования.
В первой части этой главы мы постараемся отследить проблемы, с которыми
сталкивается С++-программист при разработке межплатформенных приложений. Под
платформой понимается коллекция всех деталей, которые составляют систему
разработки приложений и их выполнения. Например, ваша платформа может включать
Microsoft-компилятор C++, запускаемый под управлением Windows XP на процессоре
Pentium (высокопроизводительный суперскалярный микропроцессор фирмы Intel).
Глава 18. Разработка межплатформенных приложений 555
В качестве альтернативного варианта платформа может характеризоваться
компилятором дсс, запускаемым в среде Linux на процессоре PowerPC (Power Performance
Chip — семейство микропроцессоров, разработанных альянсом IBM, Motorola и Apple
Computer). И хотя компиляцию и выполнение С++-программ позволяют обе эти
платформы, между ними существуют значительные различия.
Во второй части этой главы мы рассмотрим, как C++ может взаимодействовать
с другими языками программирования. И хотя C++ — язык общего назначения, он не
всегда может означать наилучший выбор для решения конкретной задачи.
Оказывается, C++ можно использовать совместно с другими языками программирования,
которые позволяют лучше реализовать некоторые нюансы.
Межплатформенная разработка
Существует ряд причин, почему для языка C++ актуальны проблемы, связанные
с платформой. Несмотря на то что C++ — язык высокого уровня, его определение
включает детали реализации низкого уровня. Например, С++-массивы определены в расчете
на использование последовательных блоков памяти. Такая специфическая деталь
реализации делает этот язык уязвимым в том смысле, что не во всех системах память
организована подобным образом. Для C++ также характерна проблема обеспечения
стандартного языка и стандартной библиотеки без стандартной реализации. Различие
интерпретаций конкретной спецификации среди производителей С++-компиляторов
и библиотек может вызвать большие проблемы при переходе от одной системы к
другой. Наконец, язык C++ избирателен в том, что обеспечивается им в качестве
стандарта. Несмотря на наличие стандартной библиотеки, сложные программы часто
нуждаются в функциях, которые не предлагаются средствами самого языка. Такие
функции обычно берутся из библиотек сторонних производителей или платформ.
Проблемы архитектуры
Термин архитектура обычно относят к процессору, или семейству процессоров, на
которых выполняется программа. Стандартный персональный компьютер, на котором
установлена операционная система Windows или Linux, обычно использует архитектуру
х86, а операционная система Mac OS, как правило, связана с архитектурой PowerPC. Как
язык высокого уровня C++ защищает вас от различий между этими архитектурами.
Например, одна инструкция процессора Pentium по своему функциональному содержанию
может быть эквивалентна шести PowerPC-инструкциям. Как С++-программисту, вам не
нужно знать, в чем состоят эти различия, или вообще об их существовании. Основное
достоинство языка высокого уровня как раз и состоит том, что компилятор заботится
о преобразовании вашего кода в формат кода, присущий данному процессору.
Однако различия между процессорами временами восходят к уровню С++-кода.
Вам не "грозит" столкнуться с большинством из этих проблем, если вы не будете
опускаться до низкого уровня программирования, но вы все же должны знать об их
потенциальном существовании.
Совместимость на уровне двоичных кодов
Вероятно, вам известно, что программу, написанную и скомпилированную для
Pentium-платформы, нельзя выполнить на Mac-компьютере. Эти две платформы не
совместимы на уровне двоичных кодов, поскольку их процессоры поддерживают разные наборы
556 Часть IV. Как создать код без ошибок
инструкций. Вспомните, что когда вы компилируете С++-программу, ваш исходный код
превращается в двоичные инструкции, которые и должен выполнить компьютер.
Конкретный двоичный формат определяется используемой платформой, а не языком C++.
Решение проблем в области совместимости на уровне двоичных кодов обычно
состоит в применении кросс-компиляции (cross-compiling). При таком подходе создается
отдельная версия программы для каждой архитектуры, на которой предполагается ее
выполнение. Одни компиляторы поддерживают кросс-компиляцию напрямую.
Другие требуют, чтобы каждая версия была отдельно построена с использованием
"своей" архитектуры.
Еще одно решение проблемы различного представления С++-программ в
двоичном формате — распространение открытых исходных текстов. Если исходный код будет
доступным для конечного пользователя, последний сможет скомпилировать его
в своей системе и построить версию программы, которая будет иметь корректный
(для его компьютера) двоичный формат. Как упоминалось в главе 4, в последние годы
открытые программные средства становятся все более популярными. Одна из
главных причин такой популярности состоит в том, что доступность к исходному коду
позволяет программистам совместными усилиями разрабатывать программы и
расширять диапазон платформ, на которых они смогут работать.
Машинное слово и размеры типов данных
Слово— основная единица "измерения" памяти для компьютерных архитектур.
В большинстве систем слово представляет собой размер адреса и/или одной
процессорной инструкции. Если некоторая архитектура описывается как 32-битовая, это, как
правило, означает, что размер слова составляет 32 бит, или 4 байт. В общем случае
система с большим размером слова может управлять большей памятью и быстрее
действовать при обработке сложных программ.
Поскольку указатели— это адреса памяти, они, по сути, связаны с размером слова.
Многие программисты считают, что размер указателя всегда равен 4 байт, но это не так.
Например, рассмотрим следующую программу, которая выводит размер указателя.
#include <iostream>
using namespace std,-
int mainfint argc, char** argv)
{
int *ptr;
cout << "Размер указателя ptr равен " << sizeof(ptr)
<< " байт." << endl;
}
Если эту программу выполнить на 32-разрядной Pentium-архитектуре, ее
результаты будут выглядеть так.
Размер указателя ptr равен 4 байт.
При выполнении этой программы в 64-разрядной системе Itanium будут получены
такие результаты.
Размер указателя ptr равен 8 байт.
Различие в результатах выполнения этой программы означает, что программист
никогда не должен связывать указатель с размером .4 байт. В более общем случае
Глава 18. Разработка межплатформенных приложений 557
необходимо понимать, что большинство "типоразмеров" не предопределено
стандартом C++. В стандарте лишь оговорено, что короткое целое значение должно
занимать такое же или меньшее пространство по сравнению с целочисленным значением,
которое, в свою очередь, должно занимать такое же или меньшее пространство по
сравнению с длинным целым. При этом предполагается, что целочисленному
значению должна быть выделена область, достаточная для хранения слова, но, как было
показано выше, это число не является постоянным.
Порядок следования слов
Все современные компьютеры хранят числа в двоичном представлении, но
представление одного и того же числа на двух платформах может не быть идентичным.
Как будет показано ниже, к считыванию чисел существует два подхода, причем оба
имеют право на существование.
В качестве единицы измерения памяти компьютера обычно используется байт,
поскольку адресация в большинстве компьютеров опирается именно на байт. Числовые
типы в C++ обычно занимают несколько байтов. Например, тип short может иметь
размер 2 байт. Предположим, что ваша программа содержит следующую строку кода.
short myShort = 513;
В двоичной системе счисления число 513 представляется как 0000001000000001. Это
двоичное число содержит 16 единиц и нулей, или 16 бит. Поскольку в байте 8 бит, то для
хранения этого числа компьютеру понадобится 2 байт. Поскольку каждый отдельный
адрес памяти относится к 1 байт, компьютер разбивает это число на несколько байтов.
Если тип short имеет размер 2 байт, это число подвергнется разбиению на две равные
части. Одна часть числа помещается в старший байт, а другая — в младший. В данном
случае старший байт будет содержать число 00000010, а младший — число 00000001.
После того как наше число было разделено на части (в соответствии с
используемой единицей измерения памяти), нам осталось решить, как эти части должны
храниться в памяти. Итак, мы знаем, что для хранения этого числа необходимо занять
два байта памяти, но в каком порядке — неясно. Этот нюанс зависит от архитектуры
рассматриваемой системы.
Первый способ представления числа состоит в следующем: сначала поместить
а память старший байт числа, а за ним — младший. Эта стратегия носит название
обратного порядка байтов (big-endian ordering). PowerPC- и Sparc-процессоры
используют именно этот подход. В таких процессорах, как х86, байты следуют в
противоположном порядке, т.е. первым в память помещается младший байт. Этот подход
называется прямым порядком байтов (little-endian ordering), поскольку ведущая роль
отводится меньшей части числа. При создании архитектуры может быть выбран тот
или иной подход к расположению байтов в памяти, и часто выбор делается из
соображений обратной совместимости. Для любознательных отметим, что термины "big-
endian" ("тупоконечник") и "little-endian" ("остроконечник") старше современных
■ компьютеров на несколько столетий. Эти слова выдумал Джонатан Свифт в своем
романе "Путешествия Гулливера" (созданном в восемнадцатом веке), чтобы описать
суть полемики о том, с какого конца следует разбивать яйцо: с тупого или острого,
и соответствующим образом определить противостоящие лагеря.
В своих программах вы можете и дальше использовать числовые значения
независимо от порядка следования слов, применяемого конкретной архитектурой, и даже не
задумываться о том, какой именно порядок реализован в вашей архитектуре: прямой
558 Часть ГУ. Как создать код без ошибок
или обратный. Это имеет значение только в случае пересылки данных между
архитектурами. Например, если вы посылаете двоичные данные по сети, то вам, возможно,
и стоит поинтересоваться порядком данных, используемым в другой системе.
Аналогично, записывая двоичные данные в файл, вы должны рассмотреть последствия
открытия этого файла в системе с противоположным порядком следования слов.
Проблемы реализации
Хочется верить, что любой разработчик С++-компилятора искренне пытается
придерживаться стандарта C++. К сожалению, стандарт C++ довольно велик
(несколько сот страниц) и представляет собой сочетание прозы, грамматики языка
и примеров. Вряд ли два разных программиста, работая над реализацией
компилятора в соответствии с таким стандартом и стараясь интерпретировать каждую запятую
предписанной информации, будут использовать одни и те же средства и пути
достижения цели или охватят все до единого предельные случаи. Более того (и в это трудно
поверить), даже компиляторы содержат ошибки.
Причуды компиляторов и их расширения
Первое, с чем вы встретитесь при общении с компилятором, — впечатление
сюрреализма. После нескольких лет поиска и корректировки собственных ошибок вы,
наконец, понимаете, что каждая программа, к которой вы имеете хоть какое-нибудь
отношение, содержит, мягко говоря, недочеты! Компиляторы C++ с момента
создания этого языка совершенствовались многократно, но по-прежнему остаются
несовершенными. В лучшем случае они просто "по-другому" интерпретируют
спецификацию или не включают реализацию всех языковых средств. Но рано или поздно
возникает ситуация, при которой компилятор действительно работает неправильно.
Как найти в компиляторе ошибки или обойти их — универсального средства не
существует. Самое лучшее, что можно сделать, — справиться насчет обновленной
версии этого компилятора и подписаться на получение новостей. Если вы
подозреваете, что обнаружили ошибку, простой Web-поиск среди сообщений об ошибках
поможет выявить какой-либо искусственный прием или "заплату".
Ни для кого не секрет, что компиляторы "славятся" своими проблемами в
реализации относительно свежих дополнений к языку. Например, механизм шаблонов
и средства определения типа во время выполнения С++-кода не входят в стандарт
языка. Как упоминалось в главе 11, некоторые компиляторы все еще не
поддерживают эти средства должным образом.
Необходимо также иметь в виду, что компиляторы зачастую включают
собственные расширения языка, о которых программисты могут попросту и не знать. Например,
массивы переменной длины, построенные на базе стека, не являются частью языка C++,
тем не менее следующая строка успешно скомпилируется компилятором д++.
int i = 4;
char myStackArray[i]; // Нестандартное средство языка C++!
Расширения языка, реализованные в некоторых компиляторах, могут быть весьма
полезными, но если существует хотя бы малая вероятность того, что вам придется
переключать компиляторы в другие режимы работы, поинтересуйтесь, предусмотрен
ли в вашем компиляторе "строгий" режим, в котором подобные расширения не
действуют. Например, при компиляции предыдущей строки кода с флагом pedantic
(переданным компилятору д++) вы получите следующее предупреждение (напоминающее
Глава 18. Разработка межплатформенных приложений 559
о том, что международная организация по стандартизации ISO запрещает
использование массивов переменной длины).
warning: ISO C++ forbids variable-size array 'myStackArray'
Спецификация C++ позволяет использовать расширение языка определенного
типа (если оно предусмотрено компилятором) через #ргадта-механизм (#ргадта —
это прекомпиляторная директива, поведение которой зависит от конкретной
реализации компилятора). Если данная реализация компилятора "не понимает" эту
директиву, последняя будет попросту проигнорирована. Например, некоторые
компиляторы с помощью директивы #pragma позволяют программисту временно отключать
предупреждения. Однако такое поведение зависит от конкретного компилятора,
и его не следует брать за основу в своей работе.
Реализации библиотек
Вероятнее всего, ваш компилятор включает реализацию стандартной С++-библио-
теки, в которую входит и стандартная библиотека шаблонов (STL). Но так как STL
написана на C++, вам можно не использовать вариант библиотеки, который был
поставлен в комплекте с вашим компилятором. Можно взять экземпляр STL стороннего
производителя, который был оптимизирован по быстродействию, или библиотеку
собственного изготовления.
Безусловно, создатели STL сталкиваются с той же проблемой, с которой не
понаслышке знакомы разработчики компиляторов: стандарт— это не догма, а объект,
подлежащий интерпретации. Кроме того, в некоторых реализациях заложены
компромиссы, которые не совместимы с вашими потребностями. Например, одни
реализации с помощью мультипроцессорной поддержки позволяют улучшить
характеристики быстродействия, рассчитанные на один процессор. Другие же можно
специально настроить на использование нескольких процессоров.
Работая с реализацией библиотеки STL или используя библиотеку стороннего
производителя, важно изучить компромиссы, на которые ее создатели пошли во
время разработки. Если используемая вами библиотека представляет собой программный
продукт с открытым исходным текстом, можно воспользоваться текущим списком
выявленных проблем или базой данных ошибок. Более детальное описание проблем,
возникающих при использовании библиотек, содержится в главе 4.
Средства языка, зависящие от платформы
C++ — мощный язык общего назначения. Вместе со стандартной библиотекой он
содержит такие широкие возможности, что средний программист мог бы годами
кодировать на C++, не ощущая никакой потребности в дополнительном арсенале.
Однако профессиональным программистам часто требуются механизмы, которые не
предусмотрены в C++. В этом разделе перечислены некоторые важные средства, которые
обеспечиваются платформой, а не языком C++.
□ Графические интерфейсы пользователя. Большинство современных
коммерческих программ работают под управлением операционной системы,
которая оснащена графическим интерфейсом пользователя, включающим такие
элементы, как выбираемые с помощью мыши кнопки, перемещаемые по экрану
окна и иерархические меню. Язык C++, как и его предшественник С, "не имеет
никакого понятия" об этих элементах. Чтобы написать графическое приложение
на C++, необходимо использовать библиотеки (написанные специально для данной
560 Часть IV. Как создать код без ошибок
платформы), которые позволяют перетаскивать окна, принимать входные
данные с помощью мыши и выполнять другие графические задачи. В главе 25
описаны объектно-ориентированные графические оболочки как средства, которые
используют платформы для предоставления пользователям своих функций.
□ Использование сетевых возможностей. Всемирная сеть Internet давно
изменила отношение программистов к написанию приложений. Сегодня стало
обычной практикой, если приложение обращается в Web за обновлениями или
если игра обеспечивает сетевой режим участия многих игроков. В C++ не
реализован механизм взаимодействия с сетью, хотя уже существуют стандартные
библиотеки соответствующей направленности. Чаще всего сетевые
возможности для приложений обеспечиваются посредством абстракции, именуемой
сонетами (socket). Реализацию библиотеки сокетов можно найти на большинстве
платформ, поскольку она предоставляет простой
процедурно-ориентированный способ передачи данных через сеть. Некоторые платформы
поддерживают систему сетевого обмена данными (основанную на концепции потоков),
которая действует подобно С++-потокам ввода-вывода.
□ Взаимодействие приложений и событий операционной системы. В языке C++
немного внимания уделено средствам взаимодействия программы с окружающей
средой (т.е. операционной) и другими приложениями. Аргументы командной
строки — вот почти и все, что можно найти в стандартной С++-программе, не
использующей расширения, связанные с платформой. Например, такие операции,
как копирование и вставка, не поддерживаются непосредственно в C++ и требуют
наличия библиотек, написанных для конкретной платформы.
□ Файлы низкого уровня. В главе 14 вы прочитали о реализованном в C++
стандартном механизме ввода-вывода, включающем чтение и запись в файлы.
Многие операционные системы обеспечивают собственные API-интерфейсы с
файлами, которые порой не совместимы со стандартными классами файлового
ввода-вывода в C++. Эти библиотеки часто предлагают использовать такие
специфические для данной операционной системы инструменты, как способ
получения начального (исходного) каталога текущего пользователя или доступ
к файлам конфигурации. В общем случае, если уж вы начали использовать API-
интерфейсы для конкретной платформы, то вам имеет смысл перейти от С++-
классов ввода-вывода к соответствующим классам, написанным для данной
платформы, если, безусловно, таковые существуют.
□ Потоки. Выполнение параллельных потоков в пределах одной программы не
обеспечено непосредственной С++-поддержкой. Их реализация в большой
степени зависит от внутренней работы операционной системы, поэтому потоки не
включены в стандарт языка C++. Самой популярной из библиотек потоков
считается pthreads. Многие операционные системы и объектно-ориентированные
оболочки также предлагают собственные модели использования потоков.
Использование в разработке нескольких
языков программирования
Для определенных типов программ C++ нельзя считать лучшим выбором.
Например, если ваша Unix-программа должна взаимодействовать со средой оболочки, то
вам имеет смысл написать не С++-программу, а сценарий (скрипт) для оболочки. А если
Глава 18. Разработка межплатформенных приложений 561
ваша программа выполняет серьезный анализ текста, то, возможно, вам стоит для
работы выбрать язык Perl. Иногда вы чувствуете, что вам нужен язык, в котором
основные средства C++ сочетались бы со специализированными возможностями другого
языка. К счастью, существуют технологии, которые можно использовать для
получения максимальной пользы от двух разных сред: гибкость C++ можно соединить с
уникальными особенностями другого языка программирования.
Смешанное использование языков С и C++
Как вы уже знаете, C++ — это супермножество языка С. Все С-программы скомпи-
лируются и будут готовы к выполнению в С++-среде при минимуме исключений. Эти
исключения обычно связаны с применением зарезервированных слов. В языке С,
например, термин class не имеет никакого специального значения (т.е. это слово не
является ключевым). Следовательно, его можно было бы использовать в качестве имени
переменной, как в следующей С-программе.
#include <stdio.h>
int main(int argc, char** argv)
{
int class = 1,- // Скомпилируется в С, но не в C++.
printf("Значение переменной class равно %d\n", class);
}
Эта программа скомпилируется и выполнится в среде С, но обнаружит ошибку при
компиляции как С++-код. При переводе этой программы на "рельсы" C++ именно
с ошибкой этого типа вы и столкнетесь. К счастью, подобные ошибки легко
исправить. В данном случае достаточно переименовать переменную class, использовав,
например, новое имя classID, и код тут же скомпилируется.
Возможность включения С-кода в С++-программу может пригодиться в случае, если у
вас возникнут проблемы с использованием очень нужной библиотеки или
унаследованного от коллег по цеху кода, написанного на языке С. Функции и классы, как вы
неоднократно могли убедиться в этой книге, прекрасно работают в тесном содружестве. Метод
класса может вызвать любую функцию, а функция может легко использовать объекты.
Смещение парадигм
Одно из опасных последствий смешения С и C++ состоит в том, что вашей
программе может грозить потеря ее объектно-ориентированных свойств. Например,
если ваш объектно-ориентированный Web-браузер реализован с использованием
процедурной библиотеки, то в прикладной программе следует объединить две эти
парадигмы. Если в вашем приложении решение сетевых задач занимает существенное
место (как по приоритету, так и по количеству), вам следуем рассмотреть написание
вокруг процедурной библиотеки объектно-ориентированной оболочки.
Например, предположим, что вы пишете Web-браузер на C++, но используете при
этом С-библиотеку сетевых средств, которая содержит функции, объявленные в
следующем коде. (Структуры данных HostRecord и Connection опущены для экономии места.)
// netwrklib.h
#include "hostrecord.h"
#include "connection.h"
562 Часть IV. Как создать код без ошибок
/**
* Функция получает запись для конкретного Internet-хоста,
* заданного его именем (т.е. VTww.host.com).
*/
HostRecord* lookupHostByName(char* inHostName);
/**
* Подключение к заданному хосту.
*/
Connection* connectToHost(HostRecord* inHost);
/**
* Получение Web-страницы от уже открытого соединения.
*/
char* retrieveWebPage (Connection* inConnection, char* page) ,-
Интерфейс netwrklib.h имеет довольно примитивную организацию, которая
далека от объектно-ориентированной. В то же время С++-программист, который
использует эту библиотеку, вероятно, связан очень сильными чувствами благодарности
к потомкам. Эта библиотека не организована в связанный класс и даже не вполне
корректна по отношению к const-элементам! Безусловно, талантливый С-програм-
мист мог бы написать более удачный интерфейс, но, будучи пользователем этой
библиотеки, вы поставлены в такие условия, что должны принять ее как данность. В
подобной ситуации вам остается лишь создать оболочку, в которую вы поместите этот
интерфейс, что сделает его более "удобоваримым".
Прежде чем мы возведем объектно-ориентированное "укрытие" для этой библиотеки,
необходимо получить представление о ее реальных возможностях, т.е. рассмотреть, как ее
можно использовать "в чистом виде". В следующей программе библиотека netwrklib
используется для получения Web-страницы по адресу: www. wrox. com/index. html.
#include <iostream>
#include "netwrklib.h"
using namespace std;
int mainfint argc, char** argv)
{
HostRecord* myHostRecord = lookupHostByName(
11 www. wrox. com" ) ;
Connection* myConnection = connectToHost(myHostRecord);
char* result = retrieveWebPage(myConnection,
"/index.html");
cout << "Результат таков: " << result << endl,-
}
Возможный способ сделать библиотеку более объектно-ориентированной —
обеспечить единую абстракцию, которая бы распознавала связи между поиском хоста,
подключением к нему и получением (считыванием) Web-страницы. Хорошо
написанная объектно-ориентированная оболочка должна скрыть ненужную сложность типов
HostRecord и Connection.
Основываясь на принципах проектирования, рассмотренных в главах 3 и 5, мы
должны позаботиться о том, чтобы новый класс обеспечивал основные варианты
использования этой библиотеки. Предыдущий пример показывает, что самый
популярный сценарий состоит в следующем: сначала происходит поиск хоста, затем с ним
устанавливается соединение, после чего считывается Web-страница. Также весьма
Глава 18. Разработка межплатформенных приложений 563
вероятно, что последующие страницы будут считываться с того же самого хоста,
поэтому было бы логично в проекте предусмотреть и этот режим работы.
Ниже приведен public-раздел определения класса WebHost. Этот класс упрощает
общий режим работы для программиста клиентской части приложения.
// WebHost.h
class WebHost {
public:
/**
* Создает объект класса WebHost для данного хоста.
*/
WebHost(const string& inHost);
/**
* Получает заданную Web-страницу с хоста.
*/
string getPage(const string& inPage);
};
Рассмотрим теперь, каким образом программист должен использовать этот класс.
Приведем еще раз пример, используемый для библиотеки netwrklib.
#include <iostream>
#include "WebHost.h"
int main(int argc, char** argv)
{
WebHost myHost"www.wrox.com");
string result = myHost.getPage("/index.html");
cout << "Результат таков: " << result << endl;
}
Класс WebHost эффективно инкапсулирует поведение хоста и обеспечивает
полезные функции без ненужных вызовов и структур данных. Этот класс даже
предоставляет новые функции: после создания объект класса WebHost можно использовать
дчя получения нескольких Web-страниц, что позволяет "сэкономить" код и повысить
быстродействие программы.
Реализация класса WebHost обеспечивает исчерпывающее использование
библиотеки netwrklib, не раскрывая ее секретов пользователю. Чтобы "оживить" эту
абстракцию, наш класс необходимо пополнить членом данных, показанным в следующем
(исправленном) файле заголовка.
// WebHost.h
#include "netwrklib.h"
class WebHost {
public:
/**
* Создает объект класса WebHost для данного хоста.
*/
WebHost(const string& inHost);
/**
564 Часть IV. Как создать код без ошибок
* Получает заданную Web-страницу с хоста.
*/
string getPage(const strings inPage);
protected:
Connection* mConnection;
};
Соответствующий исходный файл придает функциям, содержащимся в библиотеке
netwrklib, "новое лицо". Сначала конструктор создает объект класса HostRecord
для заданного хоста. Поскольку класс WebHost использует С++-строки, а не строки
в стиле языка С, то для получения данных типа const char* вызывается метод
c_str () для объекта inHost. Затем выполняется операция приведения const__cast,
которая позволяет компенсировать const-некорректность библиотеки netwrklib.
Полученный в результате HostRecord-объект применяется для создания объекта
класса Connection, который сохраняется в форме члена данных mConnection для
использования в будущем.
WebHost::WebHost(const strings inHost)
{
const char* host = inHost.c_str();
HostRecord* theHost = lookupHostByName(
const_cast<char*>(host));
mConnection = connectToHost(theHost);
}
При последующих обращениях к методу getPage () сохраненное соединение
передается функции retrieveWebPage (), входящей в состав библиотеки netwrklib,
а возвращаемое при этом значение представляет собой С++-строку.
string getPage(const strings inPage)
{
const char* page = inPage.c_str();
string result = retrieveWebPage(mConnection,
const_cast<char*>(page) ) ;
return result;
}
Необходимо отметить, что сохранение соединения с хостом, открытым в течение
неопределенного времени, считается плохой практикой и не приветствуется с точки зрения
HTTP-спецификации. Но в этом примере мы сделали выбор в пользу "элегантности",
которая возобладала над одним из принципов профессиональной этики.
Итак, с помощью класса WebHost мы создали объектно-ориентированную оболочку
вокруг С-библиотеки. Применяя абстракцию, вы можете изменить базовую реализацию,
не оказывая влияния на код клиента, или разработать дополнительные средства,
например, счетчик ссылок на соединения или синтаксический анализатор страниц.
Компоновка с С-кодом '
В предыдущем примере мы предположили, что вы вынуждены состыковаться с С-кодом.
Мы исходили из того факта, что большинство С-программ успешно компилируются С++-
компиляторами. Но если вам достался уже скомпилированный С-код (возможно, в форме
Глава 18. Разработка межплатформенных приложений 565
библиотеки), вы по-прежнему можете использовать его в своей С++-программе, но для
этого вам придется выполнить еще ряд дополнительных действий.
Скомпилированный С-код по своему формату отличается от скомпилированного
С++-кода, поэтому вам нужно сообщить компилятору, что определенные функции
написаны на языке С, чтобы компоновщик мог использовать их надлежащим образом.
Это реализуется с помощью ключевого слова extern.
В следующем коде прототип функции doCFunctionO определяется с указанием,
что это — внешняя С-функция.
extern "С" {
void doCFunction(int i);
}
int maindnt argc, char** argv)
{
// Вызов С-функции.
doCFunct ion(8);
}
Реальное определение функции doCFunct ion () содержится в скомпилированном
двоичном файле, который присоединяется на этапе компоновки. Ключевое слово
extern просто информирует компилятор о том, что присоединяемый код во время
компоновки код был скомпилирован в С-среде.
Чаще всего слово extern используется на уровне заголовка. Например, если вы
применяете графическую библиотеку, написанную на языке С, то, возможно, она
хранится у вас в виде заголовочного . h-файла. Можно написать еще один заголовочный
файл, который полностью заключит исходный код в extern-блок, означающий, что весь
заголовок определяет функции, написанные на языке С. Упаковочные .h-файлы часто
имеют расширение . hpp, которое позволяет отличить их от С-версии того же заголовка.
// graphics].ib.hpp
extern "С" {
#include "graphicslib.h"
}
Включая С-код непосредственно в свою С++-программу или объединяя ее с уже
скомпонованной С-библиотекой, помните, что, хотя C++ — супермножество языка С,
это — два разных языка с различными целевыми задачами. Адаптация С-кода для
работы в среде C++— довольно распространенная практика, однако гораздо лучше
"накрыть" процедурный С-код объектно-ориентированной С++-оболочкой.
Смешанное выполнение Java- и С++-кода с помощью
JNI-интерфейса
Несмотря на то что эта книга посвящена языку C++, мы не станем утверждать, что
еще не изобрели языков поновее и поэффектнее. Такой язык программирования, как
Java, штурмом взял компьютерный мир еще в середине 1990-х и его популярность
с тех пор лишь возросла. Java и C++ — имеют некоторое родство, но все же они
находятся в разных "весовых категориях". Из достоинств C++ чаще всего называют
скорость, а из достоинств Java — встроенные библиотеки для сетевого
программирования и графические интерфейсы.
566 Часть IV. Как создать код без ошибок
"Собственный" интерфейс Java (Java Native Interface —JNI), являясь частью языка
Java, позволяет программистам получать доступ к функциям, написанным на других
языках. Поскольку Java— межплатформенный язык, первоначальное намерение при
его создании состояло в том, чтобы обеспечить для Java-программ возможность
взаимодействия с операционной системой. Интерфейс JNI также позволяет
программистам использовать библиотеки, написанные на других языках, например на C++.
Доступ к С++-библиотекам может быть весьма полезным для Java-программиста, часть
приложения которого критично к скорости выполнения или которому необходимо
использовать уже существующий код.
Кроме того, интерфейс JNI можно использовать для выполнения Java-кода внутри
С++-программы, но такой способ его использования не слишком популярен. В
настоящее время на C++ написано гораздо больше программного кода, чем на Java, поэтому
большинство Java-приложений состоит исключительно из Java-кода. Поскольку это
книга о C++, мы не включили в нее введение в язык Java. Этот раздел предназначен для
читателей, которые уже знакомы с Java и хотели бы включить С++-код в Java-программу.
Итак, начнем с Java-кода. Для этого примера достаточно взять простейшую Java-
программу.
public class HelloCpp {
public static void main(String[] args)
{
System.out.printIn("Привет из Java!\n");
Следующий наш шаг может показаться несколько странным. Нужно объявить Java-
метод, который будет написан на другом языке. Для этого используем ключевое слово
native и обходимся без реализации этого метода.
public class HelloCpp {
// Этот метод будет реализован на языке C++.
public native void callCppO;
public static void main(String[] args)
{
System.out.printIn("Привет из Java!\n");
}
}
Код, написанный на C++, со временем будет скомпилирован в совместно
используемую библиотеку, которая динамически должна быть загружена в Java-программу.
Эту библиотеку необходимо поместить в статический Java-блок, чтобы он загружался
на начальном этапе выполнения Java-программы. Библиотека может иметь любое имя
(в этом примере используется имя hellocpp.so). Расширение файла .so означает,
что он представляет собой совместно используемую библиотеку для Unix-систем.
Windows-пользователи, как правило, используют .dll-файлы.
public class HelloCpp {
static {
System.load("hellocpp.so");
}
Глава 18. Разработка межплатформенных приложений 567
// Этот метод будет скомпилирован в С++-среде.
public native void callCppO;
public static void main(String [] args)
{
System.out.println("Привет из Java!\n");
}
}
Наконец, необходимо действительно обеспечить вызов С++-кода из Java-программы.
Java-метод callCppO служит в качестве "заполнителя" для еще ненаписанного С++-
кода. Поскольку callCppO — метод класса HelloCpp, достаточно создать новый
HelloCpp-объект и вызвать для него метод call Срр ().
public class HelloCpp {
static {
System.load("hellocpp.so") ;
}
// Этот метод будет реализован на языке C++,
public native void callCppO;
public static void main(String[] args)
{
System.out.println("Привет из Java!\n");
HelloCpp cpplnterface = new HelloCpp();
cpplnterface.callCpp();
Вот и все, что нужно было сделать со стороны Java-кода! А теперь скомпилируем
эту Java-программу обычным способом.
j avac He1loCpp.j ava
Затем воспользуемся программой javah (авторы предпочитают произносить ее
имя так: джав-АХХ!) и создадим заголовочный файл для native-метода,
javah HelloCpp
После запуска программы javah необходимо найти файл HelloCpp.h—
абсолютно работоспособный С/С++-файл заголовка. В этом заголовочном файле должно
бьпъ определение С-функции Java_HelloCpp_callCpp (). В вашей С++-программе
должна быть реализована эта функция, чтобы ее можно было вызвать из Java-
программы. Вот как выглядит ее полная сигнатура.
void Java__HelloCpp_callCpp (JNIEnv* env, j object javaobj);
В С++-реализации этой функции можно использовать все возможности языка
(в этом примере просто выводится некоторый текст). Прежде всего, включите в
программу заголовочные файлы jni.h и HelloCpp.h (последний был создан
программой javah). He забудьте также включить все С- или С++-заголовки, которые
собираетесь использовать.
#include <jni.h>
#include "HelloCpp.h"
#include <iostream>
568 Часть IV. Как создать код без ошибок
Используемая здесь С++-функция создается обычным способом. Имейте в виду, что
вы занимаетесь реализацией функции, а не написанием программы. Поэтому вам не
нужна функция main (). Параметры, передаваемые С++-функции, позволяют
взаимодействовать с Java-средой и объектом, для которого вызывается native-код (в данном
примере они не рассматриваются).
#include <jni.h>
ttinclude "HelloCpp.h"
#include <iostream>
void Java_HelloCpp_callCpp(JNIEnv* env, jobject javaobj)
{
std::cout << "Привет из C++!" << std::endl;
}
Компиляция этого кода зависит от используемой вами среды, но, вероятнее всего,
вам придется настроить параметры работы вашего компилятора, чтобы включить JNI-
заголовки и указать местоположение собственных библиотечных Java-файлов. При
использовании компилятора дсс в среде Linux ваша команда на компиляцию должна
выглядеть примерно так.
g++ -shared -1/usr/java/jdk/include/ \
-1/usr/java/jdk/include/linux HelloCpp.cpp \
-о hellocpp.so
Результатом работы компилятора должна быть библиотека, используемая
Java-программой. Если совместно используемая библиотека находится у вас по указанному для
Java-классов пути, вы выполните эту Java-программу без проблем.
Java HelloCpp
При этом вы должны увидеть следующие результаты.
Привет из Java!
Привет из C++!
Конечно, этот пример лишь касается поверхностности того пласта возможностей,
которые предоставляет интерфейс JNI. Этот интерфейс можно использовать для
взаимодействия со средствами конкретной операционной системы или с драйвером
какого-нибудь устройства. Для более близкого знакомства с JNI обратитесь к
литературе, посвященной языку Java.
Объединение C++ с языком Perl и сценариями для оболочки
В язык C++ встроен универсальный механизм для взаимодействия с другими
языками и средами. Скорее всего, вы уже пользовались им (и причем неоднократно), не
придавая этому большого значения. Речь идет об аргументах, передаваемых функции
main (), и значении, возвращаемом ею.
Языки С и C++ разрабатывались с учетом возможности использования интерфейса
командной строки. Функция main() получает аргументы (которые пользователь
вводит в командную строку) и возвращает код состояния, который может быть
интерпретирован инициатором ее вызова. Многие крупные графические приложения
игнорируют параметры, передаваемые функции main (), поскольку в графических
интерфейсах, как правило, передача аргументов не предусмотрена. Однако в среде
выполнения сценариев аргументы для вашей программы могут играть роль мощного
механизма, который позволяет вам взаимодействовать со средой.
Глава 18. Разработка межплатформенных приложений 569
Подготовка сценариев в сравнении с программированием
Прежде чем погружаться в детали смешивания языка C++ и скриптов, рассмотрим,
что представляет собой ваш проект: приложение или скрипт (сценарий). Различие
между ними состоит в тонкостях, на которых стоит остановиться. Многие так
называемые скрипты являются, по сути, полнофункциональными приложениями. Вопрос не
в том, можно ли нечто программное выполнить как скрипт, а скорее, в том, является
ли язык подготовки скриптов (сценариев) самым эффективным инструментом.
Приложение — это программа, которая выполняет определенную задачу.
Современные приложения обычно предусматривают в некотором роде взаимодействие
с пользователем. Другими словами, приложения предполагают активность
пользователя, который направляет приложение на выполнение определенных действий.
Приложения зачастую многофункциональны. Например, пользователь может
использовать редактор фотографий для изменения масштаба изображений, их раскраски или
печати. Большинство программных продуктов, которые вы покупаете, являются
приложениями. Можно сказать, приложения — это относительно большие и зачастую
довольно сложные программы.
Скрипт, как правило, выполняет одну задачу или набор взаимосвязанных задач. Вы
можете использовать скрипт для автоматической сортировки электронной почты или
резервирования важных файлов. Скрипты часто выполняются без участия
пользователя (например, в определенное время суток) или запускаются некоторым событием
(например, при получении нового почтового сообщения). Скрипты могут относиться
к системному уровню (например, каждую ночь может запускаться скрипт, который
упаковывает файлы) или к уровню приложений (например, скрипт, который
автоматизирует процесс сжатия и печати изображений). Автоматизация — важная часть
определения скрипта, поскольку скрипты обычно пишутся для того, чтобы
закодировать последовательность действий, которые пользователю (не будь этой
возможности) пришлось бы выполнять вручную.
А теперь попробуем описать различия между языком подготовки скриптов и языком
программирования. Не все скрипты обязательно создаются с использованием
специальных языков. Скрипт для сортировки электронной почты можно написать на языке
программирования С или на языке подготовки скриптов Perl. Аналогично не все
приложения пишутся на языках программирования. Даже Web-браузер (при надлежащей
мотивации) можно было бы написать на языке Perl. И в самом деле, язык Perl
настолько гибок, что многие программисты считают его одновременно и языком
программирования, и языком подготовки скриптов.
Самое главное, чтобы выбранный вами язык обладал средствами, которые вам
нужны. Если вы предполагаете активно взаимодействовать с операционной системой,
то вам, скорее всего, подойдет один из языков подготовки скриптов, поскольку
именно они позволяют получить наиболее эффективную поддержку для диалога
с операционной системой. Если же ваш проект обещает перерасти в нечто
крупномасштабное и должен обеспечить интенсивное взаимодействие с пользователем, то
вам, вероятно, будет проще реализовать свои планы на языке программирования.
Практический пример: шифрование паролей
Предположим, у вас есть система, которая с целью аудита записывает все, что
пользователь вводит в файл. Этот файл может быть прочитан только системным
администратором, чтобы он мог понять, кого винить в случае чего. Приведем выдержку
из такого файла.
570 Часть IV. Как создать код без ошибок
Login: bucky-bo
Password: feldspar
bucky-bo> mail
bucky-bo has no mail
bucky-bo> exit
Несмотря на то что системному администратору, возможно, и стоит хранить
журнал регистрации действий всех пользователей, он все же предпочитает не раскрывать
их пароли во избежание неприятностей в случае хакерского "налета". Жанр сценария
для реализации такого проекта, похоже, будет наиболее подходящим, поскольку он
должен срабатывать автоматически, возможно, в конце каждого рабочего дня.
Однако в этом проекте не все соответствует принципам языка подготовки скриптов.
Библиотеки шифрования, в основном, предназначены для таких языков высокого уровня,
как С и C++. Поэтому лучше было бы создать смешанный вариант, т.е. написать
скрипт, который вызывает С++-программу шифрования.
В следующем скрипте используется язык Perl, хотя для решения этой задачи
подошел бы практически любой язык подготовки сценариев. Мы выбрали Perl, поскольку
он — межплатформенный и позволяет упростить анализ текстов. Если вы не знаете
его, не беда, это не помешает дальнейшему чтению. Самый важный элемент
синтаксиса Perl для этого примера— символ "кавычка" ("). Этот символ служит
инструкцией для Perl-скрипта выполнить внешнюю команду. В данном случае наш скрипт
должен выполнить С++-программу encryptString.
Скрипт должен в цикле просмотреть каждую строку файла и отыскать строки,
содержащие приглашение ввести пароль. Скрипт создает новый файл, userlog.out,
который содержит тот же текст, что и исходный файл, за исключением того, все
пароли в нем будут зашифрованы. Сначала открывается входной файл для чтения и
выходной файл для записи. Затем организуется цикл для просмотра всех строк файла.
Каждая строка по очереди помещается в переменную $line.
open (INPUT, "userlog.txt") or
die "He удается открыть входной файл!";
open (OUTPUT, ">userlog.out") or
die "He удается открыть выходной файл!";
while ($line = <INPUT>) {
Затем проверяется, содержит ли текущая строка регулярное выражение,
означающее приглашение ввести пароль Password:. Если содержит, Perl сохранит
пароль в переменной $1.
if ($line =~ m/APassword: (.*)/) {
При обнаружении совпадения скрипт вызывает программу encryptString с
обнаруженным паролем, чтобы получить его зашифрованную версию. Результат
выполнения этой программы сохраняется в переменной $result, а результирующий код
состояния (индикатор успешности выполнения программы) — в переменной $?.
Затем скрипт проверяет содержимое переменной $? и при отсутствии проблем
немедленно завершается, а строка, содержащая пароль, записывается в выходной файл, но
уже с зашифрованным паролем.
$result = "encryptString $1";
if ($? != 0) { exit(-l) }
print OUTPUT "Password: $result\n";
Глава 18. Разработка межплатформенных приложений 571
Если текущая строка не содержала приглашение ввести пароль, скрипт просто
записывает ее в выходной файл. По окончании цикла оба файла закрываются.
} else {
print OUTPUT "$line",-
close (INPUT);
close (OUTPUT) ,-
Вот и все! Единственное, чего здесь не хватает — это реальной С++-программы, но
реализация шифровального алгоритма выходит за рамки нашей книги. Важно таклсе
обратить внимание на функцию main (), поскольку она в качестве аргумента
принимает строку, которая должна быть зашифрована.
Аргументы содержатся в массиве argv С-строк. До получения доступа к элементу
массива argv всегда следует проверять значение параметра argc. Помните: если
значение аргумента argc равно 1, то список аргументов состоит из одного элемента,
а именно argv[0]. Нулевой элемент массива argv обычно представляет собой имя
программы, поэтому реальные параметры начинаются с элемента argv [1].
Ниже приведена функция main () для С++-программы, которая зашифровывает
входную строку. Обратите внимание на то, что программа возвращает значение 0 при
успешном завершении и ненулевое значение в случае неудачи, что является
стандартом в среде Unix.
int main(int argc, char** argv)
{
if (argc < 2) {
cerr << "Применение: " << argv[0] <<
" строка, подлежащая шифрованию " << endl;
return -1;
}
cout << encrypt(argv[1]);
return 0;
}
С точки зрения обеспечения безопасности в этом коде допущен вопиющий промах. При
передаче строки, подлежащей шифрованию, в С++-программу в качестве аргумента
командной строки ее могут увидеть другие пользователи через таблицу процессов. Более
безопасный способ передачи информации в С++-программу — отправка данных через
стандартные средства ввода-вывода.
Теперь, убедившись в том, насколько просто С++-программы можно внедрить
в языки подготовки скриптов, вы можете в собственных проектах смело объединять
достоинства двух языков. Используйте язык подготовки скриптов для взаимодействия
с операционной системой и управления ходом выполнения скрипта, а традиционный
язык программирования — для решения более серьезных задач.
Совместное выполнение C++ и языка ассемблера
C++ считается языком написания быстродействующих программ, особенно по
сравнению с другими объектно-ориентированными языками. Но в случае если скорость
выполнения является совершенно критичным фактором, у вас просто нет другого
572 Часть IV. Как создать код без ошибок
выхода, кроме как использовать ассемблерный код. Вспомните, что при компиляции
С++-программа преобразуется из высокоуровневой в код низкого уровня. И для
большинства задач скорость выполнения автоматически генерируемого ассемблерного
кода вполне достаточна. Кроме того, оптимизаторы часто "пересматривают" уже
сгенерированный код, чтобы сделать его еще более быстродействующим. Однако, какие
бы вершины ни были достигнуты при создании компиляторов, талантливый
программист может запросто написать ассемблерный код, который "переплюнет" по
своим рабочим характеристикам скомпилированный С++-код.
Многие С++-компиляторы используют ключевое слово asm, чтобы позволить
программисту вставить в С++-программу ассемблерный код. Это ключевое слово является
частью С++-стандарта, но его реализация определяется конкретным компилятором.
В большинстве компиляторов ключевое слово asm означает возможность перехода
с более высокого уровня C++ на уровень ассемблера прямо в середине программы.
Встраивание ассемблера может быть весьма полезным в некоторых приложениях
(например, в программах создания трехмерных изображений), но мы все же не
рекомендуем злоупотреблять этой возможностью, поскольку есть ряд причин, по которым
следует избегать применения этого "сильнодействующего средства".
□ После включения ассемблерного кода (соответствующего используемой вами
платформе) ваша программа теряет свойство переносимости на другой процессор.
□ Большинство программистов не знают языков ассемблера и не смогут при
необходимости модифицировать или поддерживать ваш код.
□ Ассемблерный код, как известно, отличается своей нечитабельностыо. Это
может отрицательно отразиться на стилевых особенностях вашей программы.
□ В большинстве случаев в ассемблерном коде попросту нет необходимости. Если
ваша программа работает медленно, корень проблем, скорее всего, следует
искать в алгоритме (или перечитать предложения, описанные в главе 17).
Резюме
Главную мысль этой главы можно выразить несколькими словами: C++— очень
гибкий язык программирования. Он, так сказать, находится в "зоне наилучшего
восприятия" между языками, которые слишком привязаны к конкретным платформам,
и языками, которые уж очень высоко развиты и чрезмерно "общительны". Многие
уверены, что выбор в пользу C++ не означает "клятву верности" этому языку на веки
вечные. C++ открыт для других технологий, а его прочный фундамент позволяет
гарантировать релевантность и в далеком будущем.
>
Становимся
экспертами в области
тестирования программ
Когда программист начинает понимать, что тестирование — важная часть
процесса разработки программных продуктов, это значит, что он практически преодолел
одну из самых больших преград в своей карьере. Ошибки — это не случайное явление.
Они — обязательный спутник каждого большого проекта. Безусловно, иметь в штате
отдельную команду, занимающуюся обеспечением высокого качества (quality
assurance — QA), очень хорошо, но все бремя тестирования невозможно переложить
на плечи лишь нескольких людей. Вы, как программист, сами должны нести
ответственность как за выполнение своего кода, так и создание тестов, которые бы могли
доказать его корректность.
Часто различают тестирование методом прозрачного ящика (white box testing), в
котором испытатель имеет понятие о внутренней работе программы, и тестирование
с алгоритмом типа черного ящика (black box testing), когда программа тестируется без
использования информации о ее реализации. Обе формы тестирования имеют
важное значение для проектов, претендующих на попадание в категорию
высококачественных. Чаще всего используется тестирование по принципу черного ящика,
поскольку оно, как правило, моделирует типичное поведение пользователя. Например,
при "черном" тестировании компоненты интерфейса можно рассматривать как
574 Часть IV. Как создать код без ошибок
кнопки. Если испытатель, щелкнув на кнопке, не увидел никакой реакции на свое
действие, он делает вывод о том, что, очевидно, в программе есть ошибка.
Тестирование по принципу черного ящика не может охватить все аспекты
испытываемого продукта. Современные программы слишком велики, чтобы реализовать
имитацию щелчков на каждой кнопке, проверить все возможные варианты входных данных
и выполнить все комбинации команд. Необходимость "белого" тестирования
объясняется тем, что гораздо проще гарантировать тестовое покрытие множества
неисправностей, если тесты будут написаны на уровне объекта или подсистемы. К тому же, зачастую
"белые" тесты легче написать и автоматизировать, чем "черные". В этой главе делается
акцент на методах тестирования путем применения "прозрачного ящика", поскольку
программист может использовать эти методы еще во время разработки своей программы.
Мы начнем с понятия контроля качества, включающего несколько способов
обнаружения и отслеживания ошибок. Затем перейдем к рассмотрению блочного
тестирования, или тестирования элементов, — одного из самых простых и наиболее
полезных видов тестирования. Мы познакомим вас с теорией и практикой
поэлементного тестирования, а также предложим рассмотреть несколько практических
примеров. После этого перейдем к тестам более высокого уровня, включающим
проверку взаимодействия и функционирования отдельных компонентов системы, а затем
к системным и регрессивным тестам. Наконец, мы дадим вам ряд советов, которые
позволят сделать тестирование ваших программ вполне успешным.
Контроль качества
Момент завершения больших проектов редко совпадает с моментом достижения
цели, связанной с реализацией всех запланированных функций и свойств. Всегда
необходимо помнить о "борьбе" с ошибками: находить их и исправлять, причем как во
время, так и после основной фазы разработки. Для успешной работы в команде важно
правильно понимать, что означает коллективная ответственность за контроль
качества и жизненный цикл ошибок.
Кто отвечает за тестирование
В организациях разработки программных продуктов используются различные
подходы к тестированию. В небольших фирмах обычно нет отдельного подразделения,
которое бы отвечало за тестирование. В этом случае задачу тестирования обычно
возлагают на определенных разработчиков или перед выпуском очередной версии продукта
предлагают всем сотрудникам компании постараться вывести ее из строя. В
организациях покрупнее тестированием могут заниматься специально нанятые на полный
рабочий день специалисты, которые оценивают качество текущей версии в соответствии
с определенным набором критериев. Однако некоторые аспекты тестирования могут
по-прежнему рассматриваться как должностная обязанность разработчиков. Даже
в тех организациях, где разработчики формально не участвуют в тестировании, они все
же должны знать о своей доле ответственности в общем процессе гарантии качества.
Жизненный цикл ошибок
Все команды, занимающиеся созданием программного обеспечения, признают,
что ошибки— это до конца неистребимая "пошесть", которая, как ее ни трави,
остается (в том или ином количестве) как до, так и после выпуска продукта в свет. Сущест-
Глава 19Становимся экспертами в области тестирования программ 575
вует множество способов "частичного" решения этой проблемы. На рис. 19.1
показана схема формального процесса избавления от ошибок. В данном конкретном
процессе ошибка всегда регистрируется членом команды контроля качества (КК). С
помощью специальных программных средств менеджеру, отвечающему за общую
разработку проекта, отсылается уведомление об обнаружении ошибки, он
устанавливает ее приоритет и "передает" ее владельцу соответствующего модуля. "Получатель
ошибки" либо принимает ее в работу (т.е. для исправления), либо объясняет, почему
она в действительности принадлежит не его модулю, после чего менеджер должен
"передать ошибку" кому-то другому. Если ошибка "попадает" в правильные руки, ее
исправляют, и менеджер отмечает ее как "исправленную". Инженер по контролю
качества должен убедиться, что ошибки больше нет, и отметить ее как "закрытую" или
вновь открыть как все еще существующую.
Ошибка
регистрируется
отделом КК
Менеджер
получает
уведомление
Ошибке
назначается
приоритет
Ошибка
получена
владельцем
модуля
Принимается
отчет
КК-инженером
Отклоняется
Определяется
владелец
ошибки
Принимается
Ошибка
исправляется
Подтверждение
Ошибка
"закрывается"
Рис. 19.1
Менее формальный подход к процессу исправления ошибок показан на рис. 19.2.
В этом случае право на регистрацию ошибки имеет любой сотрудник фирмы. При этом
ошибке назначается начальный приоритет и любой модуль. Владелец модуля принимает
уведомление об ошибке и может либо принять упомянутую ошибку в работу, либо
переадресовать ее другому инженеру или модулю. После исправления эта ошибка отмечается как
"исправленная". В конце фазы тестирования все сотрудники отделов реализации и
контроля за качеством "делят между собой" исправленные ошибки и проверяют,
действительно ли они больше не напоминают о себе в текущем варианте продукта. Версия
продукта считается готовой к выпуску, когда все ошибки будут отмечены как "закрытые".
Средства отслеживания ошибок
Существует множество способов, позволяющих отслеживать ошибки в коде: от
информационных (с помощью электронной почты) или табличных (на основе
электронных таблиц) схем до дорогостоящих программных средств, поставляемых сторонними
производителями. Решение, подходящее для конкретной организации, зависит от
количества сотрудников, характера выпускаемого программного продукта и уровня
формальности, который вы хотите поддерживать в процессе исправления ошибок.
576 Часть IV. Как создать код без ошибок
Ошибка
получена
владельцем
модуля
Зарегистрирована
ошибка с
приоритетом
и модулем
Ошибка
исправляется
Рис. 19.2
Bugzilla — популярное средство отслеживания ошибок, написанное авторами Web-
браузера Mozilla. Являясь программным проектом с открытым исходным текстом,
Bugzilla постепенно оброс множеством полезных свойств, позволяющих ему
достойно конкурировать с дорогостоящими программными пакетами, предназначенными
для отслеживания ошибок. Среди этих свойств отметим следующие:
G возможность настройки параметров ошибки (приоритет, связанный с ней
компонент, статус и пр.);
□ возможность отправки по электронной почте уведомления о новых ошибках
или об изменениях в уже существующий отчет об ошибках;
□ отслеживание зависимостей между ошибками и решении об
ошибках-дубликатах;
□ средства создания отчетов и поиска ошибок;
□ Web-ориентированный интерфейс со средствами регистрации ошибок и
обновления информации о них.
, На рис. 19.3 показано описание ошибки, созданное с помощью средства Bugzilla,
примененного к этой книге. Каждая глава была введена как Bugzilla-компонент. Как
видите, это средство позволяет указать степень серьезности ошибки (Severity), а также ее
приоритет (Priority), означающий, насколько быстро ее необходимо устранить. Весьма
полезными являются поля Summary (Краткое изложение сути ошибки) и Description
(Описание), поскольку они позволяют находить ошибку и определять ее в формате отчета.
Такие средства отслеживания ошибок, как Bugzilla, стали неотъемлемыми
компонентами профессиональных сред разработки программных продуктов. Помимо
поддержки основного списка "открытых" ошибок, эти средства обеспечивают
сохранность архива ранее выявленных ошибок и историю их устранения. Программист из
отдела технической поддержки, например, может использовать средство Bugzilla для
поиска проблемы, аналогичной той, о которой ему сообщил клиент. Если подобная
ошибка однажды уже была устранена, клиент, возможно, будет удовлетворен, получив
информацию о том, какую версию продукта ему необходимо установить или как
обойти возникшую проблему.
Глава 19. Становимся экспертами в области тестирования программ 577
Enter Bu«} - Mozilla Fifefox
еав
File Edit View Go Bookmaiks Tools Help
Bugzilla Version 2.16.5
Enter Bug This page lets you enter a new bug into Bugzffla.
Reporter: user@some-project.com Product: Professional C++ Programming
Version: unspecified ^1
Component i 05 - Designing for Reuse
106 - Software Engineering Methods
m
! 07 - Coding with Style in C++
.08-Writing Classes'
! 09 - Inheritance Methods and Techniques K^
Priority:, P2_jv]
Assigned
To:'
Severity- normal
T^
i (Leave blank to assign to default component owner)
J
URL: |http*____ _ __
Summary: jKeyword "class" is misspelled as "glass"
_J
Description;'in the first example in Chapter 8, the keyword "class" is
jwritten as "glass". I'm pretty sure there's no "glass" keyword;
in C++. f
Commit J [ Remember values as bookmarkable template
Done
Рис. 19.3
Блочное тестирование
Единственный способ выявить ошибки в программе— провести ее тестирование.
Одним из самых важных видов тестирования с точки зрения разработчика является
блочное, или поэлементное. Поэлементные тесты представляют собой программы,
которые проверяют работоспособность класса или подсистемы. В идеальном варианте для
каждой задачи низкого уровня (для решения которой и предназначен ваш код) должен
существовать один или несколько тестов этого ранга. Например, предположим, что
вы пишете библиотеку математических функций, которая выполняет операции
сложения и умножения. Ваш комплект тестов может состоять из следующих элементов:
□ основной тест сложения;
□ тест сложения больших чисел;
□ тест сложения отрицательных чисел;
□ тест сложения нуля с числом;
578 Часть IV. Как создать код без ошибок
□ тест свойства коммутативности для операции сложения;
□ основной текст умножения;
□ тест умножения больших чисел;
□ тест умножения отрицательных чисел;
□ тест умножения на нуль;
□ тест свойства коммутативности для операции умножения.
Хорошо написанные тесты элементов служат для вас защитой во многих
отношениях. Во-первых, они доказывают, что данная часть программы действительно
работает должным образом. До тех пор, пока вы не получите код, который и в самом деле
оправдывает существование вашего класса, его поведение можно считать
неизвестным. Во-вторых, поэлементные тесты первыми подают сигнал тревоги, если после
недавнего изменения что-то "сломалось" (о регрессивных, или возвратных,
испытаниях мы поговорим ниже в этой главе). В-третьих, используемые как часть общего
процесса разработки, они заставляют разработчика устранять проблемы с самого начала. Если
вы вообще не контролируете свой код с помощью блочных тестов, то в случае
возникновения проблемы ее источник можно смело искать в вашем коде. В-четвертых,
поэлементные тесты позволяют испытать код до объединения с другим кодом. Когда вы
только начинали программировать, вы могли сами писать программу целиком, а затем
ее выполнять. Профессиональные программы слишком велики для такого подхода,
поэтому вам придется научиться тестировать программные компоненты по
отдельности. Наконец, поэлементные тесты показывают пример применения вашего кода.
Правда, не обходится и без побочного эффекта: поэлементные тесты могут открыть
ваши секреты программирования другим программистам. Если ваш сотрудник захочет
узнать, как выполнить перемножение матриц с помощью вашей библиотеки
математических функций, вы можете отослать его к соответствующему тесту.
Методы поэлементного тестирования
Чем больше вы напишете тестов, тем более обширным тестовым покрытием вы
будете обладать. А чем шире ваше тестовое покрытие, тем меньше вероятность, что
ошибки останутся невыявленными, и вам, пряча глаза, придется говорить
начальнику, или, хуже того, клиенту: "Вы знаете, а мы почему-то это никогда не тестировали".
Существуют разные методики написания поэлементных тестов. Методология
экстремального программирования (Extreme Programming methodology), упомянутая
в главе 6, предписывает своим сторонникам создавать тесты еще до написания кода.
Теоретически предварительное написание тестов помогает четче сформулировать
требования к компоненту и предложить систему показателей, которые могут быть
использованы для определения момента завершения вашего кода. Заблаговременное
написание тестов — задача не из простых, которая потребует от программиста
определенного усердия. Для многих программистов такой подход попросту не совместим
с их стилем работы. Менее жесткий вариант состоит в проектировании тестов до
кодирования, но в расчете на более позднюю их реализациею. В этом случае
программист по-прежнему вынужден четко понимать требования, предъявляемые к модулю,
но не обязан писать код, использующий еще несуществующие классы.
В некоторых организациях не заведено, чтобы автор отдельной подсистемы писал
для нее поэлементные тесты. Теоретически считается, что если писать тесты для
собственного кода, можно подсознательно обходить известные вам проблемы или проверять
Глава 19. Становимся экспертами в области тестирования программ 579
только определенные ситуации, в которых (вы уверены) ваш код выполняется
безукоризненно. Кроме того, порой трудно психологически настроить себя на поиск ошибок
в собственной работе, поэтому ваша деятельность в этом направлении может быть
лишена здорового энтузиазма. На практике же выходит, что привлечение программиста
для написания поэлементных тестов с целью проверки кода другого разработчика
оборачивается слишком большими накладными расходами и дополнительными усилиями
по координации работ. Однако такая организация работы позволяет получить более
эффективные тесты, а следовательно, и более качественный конечный продукт.
Еще один способ гарантировать, что поэлементные тесты действительно
проверяют нужные части кода, — писать их так, чтобы они создавали максимальное
тестовое покрытие кода. Для этого можно использовать такое средство, как gcov, или
написать Perl-скрипт, который бы позволил вам узнать, сколько public-методов
вызывается поэлементными тестами. Класс считается надлежаще протестированным
(теоретически), если он имеет поэлементные тесты для всех его открытых методов.
Процесс поэлементного тестирования
Процесс создания поэлементных тестов начинается до написания самого кода.
Даже если вы не согласны с методологией написания тестов до кода, вам следует
выбрать время, чтобы поразмыслить над тем, какого рода тесты вам нужны. Тем самым
вы разобьете задачу на хорошо определенные части, каждая из которых будет иметь
собственный критерий проверки эффективности теста. Например, если ваша задача
состоит в написании класса доступа к базе данных, то вы, скорее всего, напишете
сначала функции, которые позволяют вставлять данные в базу данных. Протестировав
полностью эту часть кода с помощью некоторого набора поэлементных тестов,
можете смело переходить к написанию кода поддержки операций обновления, удаления
и поиска данных, тестируя каждую часть по мере ее написания.
Теперь рассмотрим, какие действия необходимо предпринять для разработки и
реализации поэлементных тестов. Как и при использовании любой методологии
программирования, лучшим будем считать процесс, который даст наилучшие результаты. Чтобы
понять, какой из способов использования поэлементных тестов будет наиболее
подходящим для вас, вы должны провести эксперименты с различными вариантами.
Определение глубины детализации тестов
Прежде чем приступать к разработке отдельных тестов, необходимо провести
проверку кода на реальных данных. С учетом требований к компоненту, его
сложности и резерва времени подумайте, какой уровень поэлементного тестирования вы
сможете обеспечить? В идеале, чтобы подтвердить правильность функционирования
программы, вам следует написать как можно больше тестов (справедливости ради
отметим, что в идеальном мире тесты вообще были бы не нужны, поскольку идеальная
программа работала бы идеально!). В действительности, как правило, у вас туго со
временем, и ваша исходная задача — максимизировать эффективность поэлементных
тестов — должна быть в кратчайшие сроки решена вами же.
Глубина детализации тестов связана с их областью видимости. Как показано в
следующей таблице, класс базы данных можно поэлементно протестировать с помощью
всего лишь нескольких тестовых функций либо можно скрупулезно проверить все до
последней "гайки" и действительно гарантировать, что все работает "как часы".
580 Часть IV. Как создать код без ошибок
Тесты крупной
детализации
Тесты средней детализации
Тесты мелкой детализации
testConnection {) [Все тесты крупной детализации]
test Insert () testConnectionDroppedError()
testUpdate() testlnsertBadData()
testDeleteO testlnsertStrings ()
testSelect() testlnsertlntegers()
testUpdateStrings()
testUpdatelntegers()
testDeleteNonexistentRow()
testSelectComplicated()
testSelectMalformed()
[Все тесты крупной и средней
детализации]
testConnectionThroughHTTP()
testConnectionLocal()
testConnectionErrorBadHost()
testConnectionErrorServerBusy()
testlnsertWideCharacters()
testlnsertLargeData()
testlnsertMalformed{)
testUpdateWideCharacters()
testUpdateLargeData()
testUpdateMalformed()
DeleteWithoutPermissions()
testDeleteThenUpdate()
testSelectNested()
testSelectWideCharacters()
testSelectLargeData()
Как видите, каждый последующий столбец содержит больше конкретных тестов.
При переходе от тестов крупной детализации к более мелким тестам вы начинаете
учитывать сбойные ситуации, различные наборы входных данных, а также различные
режимы выполнения операций.
Безусловно, решение, принимаемое вами изначально при выборе степени
детализации ваших тестов, всегда можно изменить. Возможно, класс базы данных пишется
лишь для того, чтобы подтвердить правильность выбранной концепции, и его вовсе
не предполагают использовать в дальнейшем. И поэтому на данный момент вполне
достаточно проверить его работоспособность на нескольких простых тестах (при
необходимости недостающие тесты можно добавить и позже). Или же не исключено,
что во время работы претерпели изменения варианты использования вашей
программы. Ведь на начальном этапе разработки класса базы данных вы могли не
предусмотреть возможность использования международных символов. И поэтому после
добавления подобных свойств класс необходимо дополнительно протестировать
с помощью специальных тестов, учитывающих его расширенные свойства.
Если позже вы планируете вернуться к тестам или детализировать их, важно таки
реализовать свой план. Считайте поэлементные тесты частью действительной
реализации своей программы. При каждой модификации не просто модифицируйте тесты
(чтобы можно было продолжить работу), а напишите новые, после чего вы сможете
по достоинству оценить старые.
Поэлементные тесты являются частью подсистемы, для проверки
работоспособности которой они предназначены. По мере
усовершенствования и детализаций подсистемы совершенствуйте и
детализируйте тесты. "
Глава 19. Становимся экспертами в области тестирования программ 581
Применение метода мозгового штурма к отдельным тестам
По прошествии некоторого времени у вас может выработаться интуитивное
понимание того, для каких аспектов кода необходимо писать тесты. Иногда некоторые
методы просто сами напрашиваются на тестирование. Такая интуиция обычно
достигается методом проб и ошибок, а также в результате наблюдений за работой своих
сотрудников. Обычно нетрудно понять, какие программисты считаются лучшими в
написании поэлементных тестов. Нет ничего зазорного в том, чтобы поучиться на
тестах, написанных экспертами (конечно же, эти тесты придется адаптировать под
свои задачи и многократно изменять).
До тех пор пока создание поэлементных тестов не войдет в привычку, решение о том,
какие тесты нужно писать в первую очередь, можно выработать путем применения
метода мозгового штурма. Чтобы запустить процесс генерации идей, рассмотрим
следующие вопросы.
1. Для чего были написаны отдельные части кода?
2. Каковы типичны способы вызова каждого метода?
3. Каковы предусловия (т.е. входные условия) для того, чтобы методы были
вызваны?
4. Каким образом можно неправильно использовать метод?
5. Какие виды данных ожидаются в качестве входных?
6. Какие виды данных не ожидаются в качестве входных?
7. Каковы предельные случаи или условия для создания исключительных ситуаций?
Не нужно писать формальные ответы на эти вопросы (если, конечно, ваш менеджер
не входит в число ярых приверженцев того, что написано в этой книге), но они должны
помочь вам сгенерировать некоторые идеи в отношении поэлементных тестов.
Приведенная выше таблица тестов для класса базы данных содержит тестовые функции,
которые своим существованием обязаны одному из перечисленных здесь вопросов.
Если вы обогатились-таки идеями по составу поэлементных тестов, подумайте
о том, на какие категории их можно разделить. Используя пример с классом базы
данных, тесты можно было бы распределить по следующим категориям:
□ основные тесты;
□ тесты на обнаружение опечаток;
□ тесты на применение международных символов;
□ тесты на обнаружение некорректных входных данных;
□ усложненные тесты.
Разбиение тестов на категории упрощает их идентификацию и наращивание. При
таком подходе легче понять, какие аспекты кода тестируются довольно тщательно,
а какие требуют написания дополнительных тестов.
Нетрудно написать множество простых тестов, но не надо при этом
забывать и о более сложных случаях!
582 Часть IV. Как создать код без ошибок
Подготовка входных и выходных данных
Чаще всего создатели поэлементных- тестов попадают в ловушку, когда стараются
написать тест, соответствующий поведению кода, а не тест для подтверждения его
правильности. Предположим, вы пишете тест, который выполняет поиск информации
в базе данных, и этот тест терпит фиаско, тогда вы задаетесь вопросом: в чем
проблема — в коде или тесте? Обычно проще допустить, что код написан правильно, и
модифицировать "неправильный" тест. Но такой подход, как показывает практика, не верен.
Чтобы избежать подобных ловушек, нужно понимать, с какими входными
данными будет работать этот тест, и какие выходные данные должны быть получены в
результате его выполнения. Часто это проще сказать, чем сделать. Например, вы
написали код, позволяющий шифровать произвольный блок текста с помощью
конкретного ключа. Ваш тест берет фиксированную строку текста и передает ее в
модуль шифрования. Затем по результату выполнения этого модуля он должен
определить, корректно ли было проведено шифрование. Если вы напишете именно такой
тест, то это и будет в первую очередь попытка испытать поведение модуля
шифрования и получить какой-то результат. Если результат выглядит приемлемо, то вы,
возможно, написали тест, который находит именно это значение. Однако такой подход
абсолютно ничего не доказывает. В действительности вы не протестировали код— вы
просто написали тест, который гарантирует возвращение данного значения. Часто
написание тестов требует приложения ощутимых усилий— вам следует зашифровать
текст независимо от вашего шифровального модуля и получить точное совпадение.
Прежде чем выполнять тест» необходимо знать, какой результат вы
должны получить.
Написание тестов
Код теста во многом зависит от типа используемой вами тестовой оболочки. Ниже
рассматривается оболочка cppunit. Однако, независимо от реальной реализации,
следующие рекомендации помогут вам в создании действительно эффективных тестов.
□ Убедитесь, что в каждом тесте вы проверяете только один элемент. В этом
случае, если тест не достигает успеха, он укажет конкретный источник проблемы.
□ Будьте конкретны при тестировании. Причина "прокола" в том, что было
сгенерировано исключение или получено неверное значение?
□ Не скупитесь на средства регистрации в коде теста. Если тест не был пройден,
вы должны понять, в чем причина.
□ Избегайте тестов, которые зависят от ранее написанных или взаимосвязаны
с другими. Тесты должны быть элементарными и изолированными, насколько
это возможно.
□ Если тест требует использования других подсистем, рассмотрите написание
версий-заглушек этих подсистем, имитирующих поведение модулей так, чтобы
изменения, вносимые в зависимый код, не приводили бы к "провалу" теста.
□ Предложите рецензентам своего кода взять под контроль и поэлементные
тесты. Они могут дать дельный совет на предмет того, какие тесты стоит добавить.
Так же можете поступить и вы, рецензируя код своих коллег.
Глава 19. Становимся экспертами в области тестирования программ 583
Как будет показано в следующих примерах, поэлементные тесты обычно совсем
невелики и представляют собой простые программы. В большинстве случаев
написание одного такого теста занимает лишь несколько минут, что говорит о том, что порой
наш труд может быть высокоэффективным.
Выполнение тестов
Написав тест, тут же запускайте его на выполнение: не стоит испытывать свою
нервную систему, затягивая с получением результатов. Если вы увидите, что экран
заполнен сообщениями типа "Тест пройден!", не нужно скрывать свою радость. Для
большинства программистов это самый простой способ поведать миру о том, что ваш
код полезен и корректен.
Даже если вы используете методологию написания тестов до кода, вам все равно
следует запускать их сразу же после написания. В этом случае вы можете доказать
самим себе, что действительно тест сначала (т.е. без тестируемого кода) пройти нельзя.
Но после создания кода вы будете иметь вполне осязаемые подтверждения того, что
достигнутый результат совпал с ожидаемым.
Вряд ли стоит надеяться на то, что все написанные вами тесты с первого же раза
дадут желаемый результат. Теоретически, если вы пишете тесты до создания кода, все
они должны завершиться неудачей. Если же вы вдруг увидите сообщение "Тест
пройден!", это будет означать либо волшебное появление кода, либо наличие проблемы
в тесте. Если же провал будет зафиксирован при тестировании реального кода, то
здесь возможны два варианта. Либо неверно написан код, либо — тест. Можно,
конечно, быстренько "подкорректировать" пару логических переменных и сразу
получить нужный результат, но, если вы не найдете действительный источник проблемы,
вам от этого видимого благополучия лучше не станет, ведь так?
Поэлементное тестирование в действии
Теперь, после того как вы вооружились теорией поэлементного тестирования,
пора переходить к написанию реальных тестов. Возьмем в качестве примера пул объектов
из главы 17. Напомним, что пул объектов— это класс, который позволяет избежать
создания излишнего количества объектов. Отслеживая "судьбу" уже созданных
объектов, пул действует подобно посреднику (брокеру) между кодом, которому нужен объект
определенного типа, и "складом" таких объектов (уже существующих).
Приведем открытый интерфейс класса Ob j ееtPool.
//
// Шаблонный класс ObjectPool
//
// поддерживает пул объектов, которые можно использовать для
// любого класса, предусматривающего использование
// конструктора по умолчанию.
//
// Конструктор пула объектов создает пул объектов, которые он
// предлагает клиентам по запросу через метод acquireObjectО.
// По завершении работы с объектом клиент должен вызвать метод
// releaseObject(), чтобы вернуть объект назад в пул.
//
// Конструктор и деструктор для каждого объекта, хранимого в
// пуле, будет вызываться только один раз за все время
// выполнения программы, а не при каждом получении или
// освобождении объекта.
//
584 Часть IV. Как создать код без ошибок
// Основное назначение пула объектов - избежать повторяющегося
// создания и удаления объектов. Пул объектов более всего
// подходит для приложений, в которых используется большое
// количество объектов в течение короткого периода времени.
//
// В целях эффективности этот пул объектов не выполняет
// санитарную проверку (общий контроль программы на отсутствие
// тривиальных ошибок). Предполагается, что пользователь
// аккуратно возвратит каждый полученный им объект, причем
// однажды, а также не станет использовать объекты, уже
// возвращенные в пул.
//
// Предполагается, что пользователь не будет удалять пул
// объектов до тех пор, пока не будет возвращен каждый
// "выданный" объект. Удаление пула объектов делает
// недействительными любые объекты, которые получил
// пользователь, даже если они еще не были освобождены.
//
template <typename T>
class ObjectPool
{
public:
//
// Создается пул, содержащий chunkSize объектов.
// Всякий раз, когда в пуле иссякают объекты, в него
// будет добавлено еще chunkSize объектов. Пул может
// только расти:объекты никогда из него не удаляются
// (освобождаются). Это происходит до тех пор, пока пул
// не будет разрушен совсем.
//
// Генерируется исключение типа invalid_argument, если
// окажется, что chunkSize <= 0.
//
ObjectPool(int chunkSize = kDefaultChunkSize)
throw(std::invalid_argument, std::bad_alloc);
//
// Освобождаются все созданные объекты. Любые объекты,
// которые были затребованы для работы, становятся
// недействительными.
//
-ObjectPool () ,-
//
// Резервирируем объект для использования. Ссылка на
// него недействительна, если пул объектов сам
// освобожден.
//
// Клиенты не должны освобождать этот объект!
//
Т& acquireObj ect();
//
// Возвращаем объект в пул. Клиенты не должны
// использовать этот объект после его возвращения в пул.
//
void releaseObject (T& obj ) ,-
// [Закрытые (private) и защищенные (protected) методы и
// данные опущены.]
};
Если понятие пула объектов вызывает у вас чувство неуверенности в своих
знаниях, то прежде чем продолжать чтение этой главы, обратитесь еще раз к главе 17.
Глава 19. Становимся экспертами в области тестирования программ 585
Введение в оболочку cppunit
Созданная на основе Java-пакета junit оболочка cppunit (программный продукт
с открытым исходным текстом) предназначена для написания поэлементных С++-
тестов. Эта оболочка довольно проста, что особенно нравится начинающим
авторам тестов. Она позволяет разработчику сконцентрироваться на написании тестов,
а не на построении логики, связанной с тестами, или сборе выходных данных.
Оболочка cppunit включает ряд полезных утилит и средство автоматического вывода
результатов в различных форматах. Конечно, здесь мы не можем полностью
рассмотреть все возможности этой оболочки и поэтому предлагаем вам посетить сайт по
адресу: http://cppunit.sourceforge.net.
Главной "игровой фигурой" оболочки cppunit является класс CppUnit: :Test-
Fixture (CppUnit— это пространство имен), который представляет собой
логическую группу тестов. При создании соответствующего Test Fixture-подкласса можно
переопределить метод setUp (), позволяющий выполнить любую задачу, которая
должна завершиться до запуска тестов, и метод tearDown (), который можно
использовать для очистительно-восстановительных действий после окончания тестирования.
Кроме того, оболочка cppunit с помощью переменных экземпляра класса может
сохранять состояние среды. Вот как выглядит скелет реализации класса Obj ectPoolTest,
предназначенного для тестирования класса Obj ectPool.
// ObjectPoolTest.h
#include <cppunit/TestFixture.h>
class ObjectPoolTest : public CppUnit::TestFixture
{
public:
void setUp();
void tearDown();
};
Поскольку тесты для класса Obj ectPool относительно просты и имеют
автономный характер, для методов setUp () и tearDown () здесь достаточно привести пустые
определения. Начальный вариант исходного файла может выглядеть так.
// ObjectPoolTest.cpp
#include "ObjectPoolTest.h"
void Obj ectPoolTest::setUp()
{
}
void ObjectPoolTest::tearDown()
{
}
Теперь можно приступать к разработке поэлементных тестов!
Пишем первый тест
Начнем с очень простого теста. Давайте с помощью оболочки cppunit проверим
справедливость неравенства 0 < 1.
586 Часть IV. Как создать код без ошибок
Отдельный поэлементный тест в среде cppun.it — это просто метод класса Test-
Fixture. Поэтому, чтобы создать простой тест, достаточно добавить его объявление
в файл Obj ectPoolTest.h.
// ObjectPoolTest.h
#include <cppunit/TestFixture.h>
class ObjectPoolTest : public CppUnit::TestFixture
{
public:
void setup{);
void tearDown();
// Наш первый тест!
void testSimple();
}; +
В определении этого теста используется макрос CPPUNITASSERT, который
выполняет реальное тестирование. Этот макрос, в отличие от других ему подобных assert-
макросов, просто принимает выражение, которое (предположительно) должно быть
истинным (соответствующие разъяснения можно найти в главе 20). В данном случае
тест утверждает, что 0 меньше 1, поэтому выражение 0 < 1 передается макросу
CPPUNIT_ASSERT при вызове. Этот макрос определен в файле cppunit/TestAssert. h.
// ObjectPoolTest.срр
#include "ObjectPoolTest.h"
#include <cppunit/TestAssert.h>
void Obj ectPoolTest::setUp()
{
}
void ObjectPoolTest::tearDown{)
void ObjectPoolTest::testSimple()
{
CPPUNIT ASSERT(0 < 1);
}
Вот и все! Безусловно, в большинстве поэлементных тестов выполняются более
интересные действия. Как будет показано выше, обычно тест состоит из некоторых
вычислений и assert-макроса, проверяющего тот факт, что результат равен
ожидаемому вами значению. Используя оболочку cppunit, вы можете не беспокоиться об
исключениях, — она перехватит и обработает их сама и, конечно же, сообщит о
результате этой обработки.
Создание набора тестов
Но прежде чем запустить простейший тест, необходимо проделать небольшую
подготовительную работу. Оболочка cppunit выполняет группу тестов в виде
некоторого набора (тестового комплекта), который должен содержать информацию
Глава 19. Становимся экспертами в области тестирования программ 587
о том, какие тесты подлежат выполнению (в отличие от класса TestFixture,
который просто логически группирует тесты). Для того чтобы составить нужный набор
тестов, в ваш TestFixture-подкласс достаточно добавить один статический метод.
В обновленных версиях файлов ObjectPoolTest .h и ObjectPoolTest .cpp для
этой цели используется метод suite ().
// ObjectPoolTest.h
#include <cppunit/TestFixture.h>
#include <cppunit/TestSuite.h>
#include <cppunit/Test.h>
class ObjectPoolTest : public CppUnit::TestFixture
{
public:
void setup ();
void tearDovmO;
// Наш первый тест!
void testSimple{);
static CppUnit::Test* suite{);
};
// ObjectPoolTest.cpp
#include "ObjectPoolTest.h"
#include <cppunit/TestAssert.h>
void ObjectPoolTest::setUp()
void Obj ectPoolTest::tearDown()
{
}
void ObjectPoolTest::testSimple()
{
CPPUNIT_ASSERT(0 < 1);
}
CppUnit::Test* Obj ectPoolTest::suite()
{
CppUnit::TestSuite* suiteOfTests =
new CppUnit::TestSuite("ObjectPoolTest");
suiteOfTests->addTest(
new CppUnit::TestCaller<ObjectPoolTest>(
"testSimple",
&ObjectPoolTest::testSimple ));
return suiteOfTests; // Обратите внимание на то, что Test -
// это суперкласс класса TestSuite.
}
Синтаксис шаблона для создания объекта класса Test Caller несколько
тяжеловат, но практически каждый одиночный тест, который вам придется писать, должен
строиться по его "образу и подобию", поэтому в большинстве случаев вы можете
игнорировать реализацию классов TestSuite и TestCaller.
Чтобы выполнить набор тестов и увидеть результаты, вам понадобится прогонщик
тестов. В оболочке cppun.it содержится несколько различных прогонщиков,
которые действуют в различных средах (например, прогонщик MFC Runner предназначен
для выполнения в программе, написанной с использованием библиотеки базовых
588 Часть IV. Как создать код без ошибок
классов Microsoft (Microsoft Foundation Classes). Для текстовых сред следует использовать
прогонщик Text Runner, который определен в пространстве имен CppUnit: : TextUi.
Ниже приведен код запуска набора тестов, определенного классом Ob j ectPoolTest.
Он просто создает прогонщик, добавляет тесты, возвращаемые методом suite (),
и вызывает метод run ().
// main.cpp
#include "ObjectPoolTest.h"
#include <cppunit/ui/text/TestRunner.h>
int maindnt argc, char** argv)
{
CppUnit: :TextUi : :TestRunner runner,-
runner.addTest(Obj ectPoolTest::suite{));
runner.run();
}
После компиляции кода, компоновки и выполнения вы должны увидеть результат,
аналогичный следующему.
ОК (1 tests)
Если вы модифицируете код assert-макроса для утверждения того, что 1 < О,
тест даст отрицательный результат, а оболочка cppunit сообщит о неудаче
следующим образом.
!!!FAILURES!!!
Test Results:
Run: 1 Failures: 1 Errors: 0
1) test: testSimple (F) line: 21 ObjectPoolTest.cpp
assertion failed
- Expression: 1 < 0
Обратите внимание на то, что с помощью макроса CPPUNITASSERT оболочка
смогла точно указать строку, на которой произошел "отказ", а ведь это— очень
ценная информация для отладки!
>
Добавление реальных тестов
Теперь, когда оболочка cppunit полностью настроена и ее работоспособность
проверена на простом тесте, пора переключиться на класс ObjectPool и написать
код его реального тестирования. Все следующие тесты должны быть добавлены
в файлы Obj ectPoolTest. h и Obj ectPoolTest. cpp подобно тому, как мы это
сделали для простого теста.
Прежде чем мы сможем написать тесты, нам понадобится вспомогательный объект
для работы с классом ObjectPool. Как вы помните, класс ObjectPool создает
наборы объектов определенного типа и по запросу "снабжает" ими "заказчика". В
некоторых тестах необходимо проверять, не совпадает ли извлеченный объект с ранее
извлеченным (предыдущим). Это можно сделать путем создания пула последовательных
объектов, т.е. объектов, которые имеют монотонно возрастающий порядковый
номер. Вот как выглядит определение такого класса.
// Serial.h
class Serial
{
public:
Глава 19. Становимся экспертами в области тестирования программ 589
Serial{);
int getSerialNumber{) const;
protected:
static int sNextSerial;
int mSerialNumber;
};
// Serial.cpp
#include "Serial.h"
Serial::Serial()
{
// Новый объект получает следующий порядковый номер.
mSerialNumber = sNextSerial++;
}
int Serial::getSerialNumber() const
{
return mSerialNumber;
}
int Serial::sNextSerial = 0; // Первый порядковый номер = 0.
Теперь переходим к собственно тестам! В качестве начальной проверки
корректности кода вам стоит выполнить тест, который просто создает пул объектов. Если во
время создания будут сгенерированы какие-либо исключения, среда cppun.it
сообщит об ошибке.
void ObjectPoolTest::testCreation{)
{
ObjectPool< Serial > myPool,-
}
От следующего теста мы ожидаем получения отрицательного результата,
поскольку его действия не должны быть успешными. В данном случае тест попытается создать
пул объектов с некорректным (нулевым) размером. Конструктор пула объектов
должен сгенерировать исключение. Обычно перехватом исключений (и
соответствующими сообщениями об ошибках) занимается среда cppunit. Но так как в данной
ситуации исключение означает желаемое поведение, тест явным образом
перехватывает исключение и устанавливает флаг. Итоговое действие этого теста заключается
Bassert-уведомлении об установке флага. Таким образом, если конструктор не
сгенерирует исключение, это будет означать, что тест не пройден.
void ObjectPoolTest::testlnvalidChunkSize()
{
bool caughtException = false;
try {
ObjectPool<Serial> myPool(O);
} catch (const invalid_argument& ex) {
// ОК. Мы ожидали это исключение.
caughtException = true;
}
590 Часть IV. Как создать код без ошибок
CPPUNIT_ASSERT(сaughtException);
}
Метод testAcquire () предназначен для тестирования конкретного набора public-
действий пула объекта— способности ObjectPool-объекта выделять по запросу
объект. В данном случае, чтобы доказать достоверность результирующей ссылки Serial,
assert^recT утверждает, что ее порядковый номер больше нуля.
void Obj ectPoolTest::testAcquire{)
{
ObjectPool<Serial> myPool;
Serial& serial = myPool.acquireObject();
CPPUNIT_ASSERT(serial.getSerialNumber() >= 0);
}
Следующий тест поинтереснее. Как вам известно, объект класса ObjectPool не
должен выделять один и тот же Serial-объект дважды (если он еще не освобожден
явным образом). Этот тест проверяет свойство уникальности Obj ectPool-объекта путем
создания пула фиксированного размера и извлечения объектов в количестве, в
точности равном заданному размеру пула. Если пул исправно выдает уникальные объекты, то
ни один из порядковых номеров не должен дублироваться. Обратите внимание на то,
что данный тест охватывает лишь объекты, созданные как часть одного набора. Было
бы замечательно написать аналогичный тест для нескольких наборов объектов.
void ObjectPoolTest::testExclusivity{)
{
const int poolSize = 5;
ObjectPool<Serial> myPool(poolSize);
set<int> seenSerials;
for (int i = 0; i < poolSize,- i++) {
SerialSc nextSerial = myPool.acquireObject();
// Благодаря assert-макросу утверждаем, что этот номер
//до сих пор не использовался.
CPPUNIT_ASSERT(seenSerials.find(
nextSerial.getSerialNumber()) ==
seenSerials.end());
// Добавляем этот номер в контейнер (set) . _ ,,
seenSerials.insert(nextSerial.getSerialNumber());
}
}
В этой реализации используется STL-контейнер set (множество). Если вы не
знакомы с этим контейнером, за разъяснениями обратитесь к главе 21.
Рассмотрим еще один тест, который проверяет работоспособность функции
освобождения объектов. Если объект освобождается, Obj ect Pool-объект может
использовать его повторно. Пул не должен создавать дополнительные наборы объектов до
тех пор, пока он может запускать на "повторный цикл" все освобожденные объекты.
Этот тест сначала извлекает Serial-объект из пула и фиксирует его порядковый
номер. Затем полученный объект немедленно освобождается и возвращается назад
в пул. После этого из пула извлекаются объекты до тех пор, пока либо не запустится на
"повторный цикл" исходный объект (идентифицируемый своим порядковым
номером), либо не израсходуется весь набор. Если тест "пройдет" по всем объектам набора,
не получив объект, намеченный для "повторного круга", значит, тест потерпел фиаско.
Глава 19. Становимся экспертами в области тестирования программ 591
void ObjectPoolTest::testRelease()
{
const int poolSize = 5;
ObjectPool<Serial> myPool (poolSize) ,-
Serial& originalSerial = myPool.acquireObject();
int originalSerialNumber = originalSerial.getSerialNumber()■
// Возвращаем исходный объект в пул.
myPool.releaseObject(originalSerial);
// Теперь необходимо убедиться в том, что исходный объект
// "запускается" на повторное использование до создания
// нового набора объектов.
bool wasRecycled = false;
for (int i = 0; i < poolSize; i++) {
SerialSc nextSerial = myPool.acquireObject();
if (nextSerial.getSerialNumber() ==
originalSerialNumber) {
wasRecycled = true;
break;
}
}
CPPUNIT ASSERT(wasRecycled);
}
После добавления этих тестов в тестовый набор вам нужно суметь их выполнить и,
что самое важное, все они должны пройти успешно. Конечно, если один или
несколько из них завершатся неудачно, вы зададитесь самым типичным для деятельности
такого рода вопросом: "Где проблема: в тесте или коде?".
Греемся в лучах славы, или Да здравствуют результаты поэлементного
тестирования!
Будем надеяться, что после рассмотрения тестов, приведенных в предыдущем
разделе, вы получили представление о том, как приступить к написанию
профессиональных тестов для реального кода. Конечно, эта информация — лишь верхушка
айсберга. И все же показанные выше варианты проверки кода могут навести вас на мысль
о том, какие еще тесты следовало бы написать для класса Obj ectPool. Например, ни
один из рассмотренных тестов не касался вопросов выделения памяти для нескольких
наборов создаваемых в пуле объектов, а ведь этот аспект кода обязательно нужно
проверить. Кроме того, не помешало бы протестировать более сложную ситуацию, когда
один и тот же объект запрашивается и освобождается несколько раз.
Для данного кода тесты можно было бы писать бесконечно, и это прекрасно! Если
вам стало интересно, как именно ваш код отреагирует на определенную ситуацию,
значит, вы получили "пищу" для написания очередного поэлементного теста! Если
к вам в душу закралось подозрение, что некоторый аспект вашей подсистемы не
свободен от проблем, немедленно пишите тест, чтобы охватить тестовым покрытием
и эту область вашей программы. Даже если вы просто решили стать на место клиента
и посмотреть его глазами на то, удобно ли работать с вашим классом, напишите
поэлементный тест, который позволит взглянуть на ваш код с другой точки зрения.
592 Часть IV. Как создать код без ошибок
Тестирование более высокого уровня
Хотя поэлементные тесты можно назвать первой линией обороны от ошибок,
они— лишь часть гораздо более объемного процесса тестирования. Чтобы
проверить, насколько слаженно работают отдельные части продукта, используют тесты
более высокого уровня. В известном смысле высокоуровневые тесты писать сложнее,
поскольку не всегда ясно, какие именно тесты нужны. Однако на этот вопрос все же
нужно находить ответ, поскольку до тех пор, пока вы не протестируете программу
целиком, вы не имеете права утверждать, что она работает.
Комплексные испытания
Комплексный тест подразумевает проверку совместного выполнения отдельных
компонентов системы. В отличие от поэлементного теста, который обычно действует
на уровне одного класса, комплексный тест, как правило, включает два или больше
классов. Комплексные тесты проверяют характер взаимодействия между двумя
компонентами, которые могут быть написаны двумя различными программистами.
В процессе написания комплексного теста часто обнаруживаются серьезные
проблемы несовместимости, допущенные еще на этапе проектирования.
Примеры комплексных тестов
Поскольку не существует твердого правила, позволяющего определить, какие
комплексные тесты вам следует написать, то, возможно, в этом смогут вам помочь
несколько приведенных ниже примеров. В следующих сценариях отображены
ситуации, подчеркивающие полезность применения комплексных тестов. Как и в случае
поэлементного тестирования, после написания первой серии тестов ваша интуиция
подскажет, в каком направлении работать дальше, т.е. какие еще комплексные тесты
полезно было бы написать для проверки работоспособности вашей программы.
Параллельно-последовательный файловый XML-преобразователь
Предположим, что ваш проект включает перманентный уровень, который
используется для сохранения объектов определенного типа на диске и считывания их оттуда.
Для преобразования данных в последовательную форму можно использовать XML-
формат (Extensible Markup Language— расширяемая спецификация языка,
предназначенного для создания Web-страниц), поэтому в логическую структуру компонентов
имело бы смысл включить XML-преобразователь и файловый API-интерфейс. Оба эти
компонента можно тщательно проверить с помощью поэлементных тестов. XML-
преобразователь должен пройти тесты, которые бы гарантировали, что объекты
различных типов корректно преобразуются в XML-формат и обратно. Файловый API-
интерфейс, пройдя этап тестирования, должен доказать, что он позволяет корректно
считывать, записывать, обновлять и удалять файлы с диска. Когда эти модули будут
готовы к совместной работе, можно приступать к комплексным испытаниям. По
крайней мере вам следует написать тест, который сохраняет объект на диске с
помощью XML-преобразователя, а затем считывает его обратно и сравнивает полученный
объект с исходным. Поскольку этот тест использует оба модуля, его вполне можно
считать комплексным.
t
Глава 19. Становимся экспертами в области тестирования программ 593
Использование разделяемого ресурса для считывания и записи данных
Предположим, что вы написали программу, которая содержит пространство
данных, совместно используемое различными компонентами. Например, программа
обслуживания деятельности фондовой биржи могла бы создавать очередь запросов на
оформление покупки и продажи. Компоненты, связанные с получением запросов на
выполнение фондовой сделки, добавляли бы заказы в очередь, а компоненты,
связанные с выполнением операций, извлекали бы данные из очереди. В этом случае имело
бы смысл написать поэлементный тест, проверяющий функционирование класса
очереди, но до тех пор, пока он не протестируется с реальными компонентами,
которые будут пользоваться очередью, вы в действительности не будете знать, насколько
правильны ваши предположения. В комплексном тесте в качестве клиентов класса
очереди нужно было бы использовать компоненты запроса на проведение фондовой
сделки и компоненты выполнения соответствующих операций. Для этого вам
пришлось бы написать несколько вариантов заказов и убедиться в том, что они успешно
поступают в очередь и извлекаются из нее посредством компонентов клиента.
Построение оболочки вокруг библиотеки стороннего производителя
Комплексные тесты не всегда проводятся в точках стыковки различных
компонентов внутри одной программы. Во многих случаях их пишут, чтобы протестировать
взаимодействие кода с библиотекой стороннего производителя. Например, для
доступа к системе обслуживания реляционной базы данных вы могли бы использовать
специальную библиотеку. Возможно, эту библиотеку имеет смысл заключить в объектно-
ориентированную оболочку, которая бы обладала средствами поддержки кеширова-
ния при подключении или обеспечивала бы более дружественный интерфейс. Этот
аспект (точку стыковки) очень важно тщательно протестировать, поскольку, если даже
оболочка и обеспечит более полезный интерфейс с базой данных, не исключено, что
он может привнести элемент неверного использования исходной библиотеки.
Другими словами, создание оболочки — это прекрасно, но создание оболочки с ошибками
может обернуться полной катастрофой.
Методы комплексного тестирования
Приступая к реальному написанию комплексных тестов, вы можете заметить, что
от поэлементных их отделяет весьма тонкая граница. Если поэлементный тест
модифицировать так, чтобы он затрагивал другой компонент, то вдруг он превратится
в комплексный? В известном смысле на этот вопрос нельзя дать однозначный ответ,
поскольку хороший тест останется хорошим, несмотря на его тип. Мы рекомендуем,
чтобы вы использовали концепцию комплексного и поэлементного тестирования
в качестве двух подходов к тестированию и избегали пустых проблем, связанных с
наклеиванием ярлыков, т.е. присваивания категории каждому отдельному тесту.
Если говорить о реализации, то комплексные тесты часто пишутся с
использованием оболочек поэлементного тестирования, которые легко позволяют получить
ответы "нет" или "да" и на их основании сгенерировать полезные результаты. То, что
этот тест "заглядывает" в отдельный модуль или в точку пересечения двух
компонентов, вряд ли будет иметь большое значение с точки зрения оболочки.
Из соображений эффективности и организации труда можно попробовать
отделить поэлементные тесты от комплексных. Например, в группе может действовать
распоряжение выполнять комплексные тесты до регистрации нового кода, но в
отношении изолированных поэлементных тестов таких строгих правил нет. Кроме того,
594 Часть IV. Как создать код без ошибок
разделение тестов на две категории повышает значимость результатов. Если провал
обнаружится среди тестов XML-класса, то будет ясно, что ошибка содержится
в этом классе, а не в коде реализации взаимодействия этого класса и файлового API-
интерфейса.
Системные тесты
По сфере приложения системные тесты находятся на еще более высоком уровне,
чем комплексные. Эти тесты проверяют работоспособность программы в целом.
В системных тестах часто используется виртуальный пользователь, который
имитирует человека, работающего с программой. Безусловно, виртуальный пользователь
должен быть запрограммирован с помощью некоторого сценария действий,
подлежащих выполнению. В других случаях системные тесты опираются на скрипты или
фиксированные наборы входных данных и ожидаемых результатов.
Во многом подобно поэлементным и комплексным тестам, отдельный системный
тест выполняет конкретную проверку в ожидании получить конкретный результат.
Нет ничего необычного в использовании системных тестов для того, чтобы
убедиться, что различные элементы успешно взаимодействуют один с другим. Теоретически
программа тестирования системы должна содержать тест для всех возможных
вариантов перестановки каждого функционального элемента. При таком подходе тест
обещает приобрести невероятно большие размеры и громоздкость, но с этим
приходится мириться, если нужно протестировать комбинацию, состоящую из множества
элементов. Например, для графической программы стоило бы написать системный
тест, который импортирует изображение, поворачивает его, применяет фильтр
размытия, преобразует в черно-белую цветовую гамму, а затем сохраняет. Этот тест должен
сравнить сохраненное изображение с файлом, содержащим ожидаемый результат.
К сожалению, трудно говорить о каких-либо правилах написания системных
тестов, поскольку они в большой степени зависят от реального приложения. Для
приложений, которые обрабатывают файлы без какого-либо взаимодействия с
пользователем, системные тесты можно писать почти так же, как поэлементные и комплексные.
Для графических программ, пожалуй, лучше всего использовать подход с "участием"
виртуального пользователя. Для серверных приложений, возможно, придется
построить клиентские заглушки, которые бы моделировали сетевой трафик. Здесь
важно отметить, что в данном случае вы действительно будете тестировать реальное
применение программы, а не просто ее отдельную часть.
Регрессивные тесты
Регрессивное тестирование имеет смысл рассматривать не как отдельный вид
проверки работоспособности кода, а скорее как реализацию самой идеи
тестирования. Эта идея опирается на тот факт, что, если некоторый функциональный элемент
заработал, разработчики склонны думать, что он и дальше будет работать так же
успешно, и поэтому его можно отложить в сторону и на время забыть. К сожалению,
введение в программу новых элементов и изменения, вносимые в какой-нибудь другой
код, часто угрожают разрушить достигнутое накануне "благополучие" и превратить
работающий код в неработающий. Регрессивные тесты часто используют в качестве
санитарной проверки для элементов, работа над которыми уже практически
завершена. Если регрессивный тест написан удачно, то вы будете избавлены от ситуаций,
Глава 19. Становимся экспертами в области тестирования программ 595
когда какое-нибудь небольшое "улучшение" программы сорвет ваши планы на
ближайшие выходные.
Если в вашей компании есть целое подразделение, выполняющее проверку качества
программных продуктов, регрессивное тестирование может принять форму ручного
тестирования. Тестировщик в этом случае действует так, как бы это делал пользователь,
и проходит ряд этапов, постепенно проверяя каждый функциональный элемент,
который работал в предыдущей версии. Этот подход (при добросовестном отношении
сотрудников) можно считать вполне надежным, но не масштабируемым. С другой
стороны, вы могли бы построить автоматизированную систему, которая выполняет каждую
функцию как виртуальный пользователь. Это — довольно сложная задача, хотя уже есть
несколько коммерческих и некоммерческих пакетов, которые могут облегчить
написание скрипт-приложений различного типа. Есть и промежуточный вариант, который
называют испытанием "герметичности" с помощью дыма (smoke testing). В некоторых
тестах предусмотрена проверка подмножества только самых важных функций. Их идея
состоит в том, что о поврежденном участке продукта должно быть заявлено
немедленно. Если программа прошла тесты на "герметичность", ее можно подвергнуть более
строгому ручному или автоматизированному тестированию.
Некоторые ошибки можно сравнить с кошмарным сном, в котором вы
оказываетесь в общественном месте одетым в нижнее белье, причем хуже всего то, что этот
ужас взял моду еще и повторяться время от времени. Повторяющиеся ошибки
расстраивают всех неимоверно и являются свидетельством неправильного
использования технических ресурсов. Даже если (по некоторой причине) вы решили не писать
полный набор регрессивных тестов, вам все же следует написать отдельные
регрессивные тесты для проверки ошибок, которые вы исправляете. Написав такой тест, вы
тем самым докажете, что ошибка исправлена, и установите в состояние готовности
сигнализатор опасности, который "протрубит" тревогу, если ошибка "посмеет" вернуться
(например, если ваше изменение вы "откатили" назад или оно было аннулировано
каким-то другим способом). Если регрессивный тест на наличие ранее исправленных
ошибок провалился, причину неудачи "вычислить" нетрудно, поскольку
добросовестно сделанный регрессивный тест должен содержать ссылку на номер исходной
ошибки и включать описание того, как эта ошибка была исправлена в первый раз.
Рекомендации по успешному тестированию
Ваша роль в тестировании как разработчика программного обеспечения может
варьироваться от ответственности за основную поэлементную проверку кода до
полного управления системой автоматизированного тестирования. Поскольку роли
и стили их исполнения могут быть разными, мы решили поделиться некоторыми
советами ("созревшими" на основе нашего собственного опыта), которые могут помочь
вам в различных ситуациях тестирования.
' i
□ Затратьте некоторое время на разработку своей автоматизированной тестовой
системы. Система, постоянно работающая в течение всего дня, способна
быстро обнаружить дефекты в коде. Система, которая автоматически отправляет
электронные сообщения программистам или расположена в середине комнаты,
громко "напевает" характерные мелодии в случае обнаружения ошибки,
позволит оперативно обратить внимание на возникшие проблемы.
596 Часть IV. Как создать код без ошибок
□ Не забывайте о тестировании в предельных режимах. Несмотря на то что,
скажем, ваш класс доступа к базе данных прошел полный набор поэлементных
тестов, результат тестирования может оказаться отрицательным при
одновременном использовании нескольких десятков потоков. Вам следует протестировать
свой продукт в самых экстремальных условиях, которые могу создаться в
реальной жизни.
□ Протестируйте свою программу на разных платформах или на платформе,
наиболее приблеженной к системе пользователя. Чтобы выполнить тестирование
под управлением разных операционных систем, можно воспользоваться средой
виртуальной вычислительной машины (от стороннего производителя),
которая позволит "установить" несколько различных операционных систем на
одном и том же компьютере.
□ Можно написать некоторые тесты, чтобы намеренно "ввести дефекты" в
систему. Например, вы могли бы написать тест, который удаляет файл во время
считывания из него информации или имитирует выход сети из строя во время
выполнения сетевой операции.
□ Ошибки и тесты тесно связаны между собой. Факт исправления ошибки должен
быть доказан путем написания регрессивного теста. При этом не забывайте
приложить к этому тесту комментарий с указанием номера исходной ошибки.
□ Не скупитесь на комментарии для тестов, которые не были успешно
пройдены. Когда ваш коллега будет надрываться в поисках причины ошибки и
заглянет в ваши "куцые" комментарии, он не ограничится этим и обязательно
обратится к вам лично!
Главное —■ помнить, что тестирование — важная часть разработки программного
обеспечения. Если вы согласны с этим не просто теоретически, а готовы реализовать
эту мысль еще до начала кодирования, то для вас не будет неожиданной перспектива
выполнения огромного объема работы после завершения некоторого программного
элемента, работоспособность которого еще нужно доказать.
Резюме
Эта глава содержит основную информацию о тестировании, которую должны
знать все профессиональные программисты. В частности, здесь вы узнали, что
поэлементное тестирование — самый простой и самый эффективный способ повысить
качество своего кода. В тестах же более высокого уровня необходимо предусмотреть все
возможные случаи использования приложения, синхронизацию выполнения
различных модулей и защиту от регрессии. И не важно, какова ваша роль в тестировании, вы
уже сейчас должны уверенно проектировать, создавать и совершенствовать тесты
различного уровня.
Теперь, когда вы знаете, как находить в коде ошибки, пора научиться их исправлять.
Для этого достаточно перейти к главе 20, в которой пойдет речь о методах и стратегиях
эффективной отладки.
Что нужно знать
об отладке
Ваш код, как бы вы ни старались, будет содержать ошибки. Каждый
профессиональный программист мечтает писать код, свободный от ошибок, но реальность
такова, что лишь немногим это удавалось, и то далеко не всегда. Любому пользователю
компьютера хорошо известно, что наличие ошибок— это очень распространенное
свойство практически любого программного продукта. Вероятно, и ваша программа
не является исключением. Поэтому, не собираясь каким-то образом стимулировать
своих коллег, чтобы они исправляли все ваши ошибки, и не отказавшись от мысли
стать профессиональным С++-программистом, вы обязательно должны узнать, как
отлаживать С++-код. Именно навыки по отладке программ считаются одним из тех
факторов, которые отличают опытного программиста от новичка.
Несмотря на очевидную важность отладки, в книгах и учебных курсах, как
правило, этой теме редко уделяется достаточное внимание. Отладка считается мастерством,
которым каждый хочет овладеть, но не каждый знает, как этому научиться. В этой
главе мы попробуем дать вам конкретные рекомендации и показать методы отладки
при наличии в программе даже самых серьезных проблем. Описав основной закон
отладки, мы рассмотрим способы избегать попадания ошибок в код, а также плановую
работу над ошибками, которая включает их регистрацию, трассировку программы
и генерирование утверждений (с помощью assert-макроса). Глава завершается
специальными советами по применению таких методов, как воспроизведение ошибок,
отладка невоспроизводимых ошибок, отладка ошибок распределения памяти и
отладка многопоточных программ.
598 Часть IV. Как создать код без ошибок
Основной закон отладки
Первое правило отладки гласит: будьте честным с самим собой и признайте, что
ваша программа содержит ошибки. Эта реалистичная оценка позволит вам
приложить максимум усилий к изгнанию их из программы и при этом подключить
необходимые средства, чтобы максимально облегчить процесс отладки.
Основной закон отладки: избегайте попадания ошибок в программу
во время написания кода, но запланируйте этап исправления тех, что
в нем остались.
Систематика ошибок
Ошибку в компьютерной программе можно определить как ее (программы)
некорректное поведение при выполнении. Некорректное поведение вызывают как
катастрофические ошибки, которые разрушают программу, искажают данные, приводят к
"замешательству" операционной системы или некоторым другим ужасным результатам,
так и некатастрофические, которые вынуждают программу работать неверно, но не
с такими страшными последствиями. Например, Web-браузер может отобразить не ту
Web-страницу, а приложение, работающее с электронными таблицами, может
некорректно вычислять по содержимому столбца среднеквадратическое отклонение.
Процесс отладки программы включает как определение основной причины ошибки, так и
эффективное исправление кода, после которого ошибка не возникает снова.
Как избежать попадания ошибок в код
Мощные средства C++ делают его языком, особенно подверженным ошибкам,
поэтому навыки отладки С++-программ еще важнее, чем само кодирование (в сравнении
с большинством других языков программирования). Поэтому мы считаем, что следующие
рекомендации по избежанию проникновения ошибок в С++-код вам должны пригодиться.
□ Прочитайте эту книгу от корки до корки. Детально изучите синтаксис языка
C++, особенно тему указателей и средств управления памятью. Затем
порекомендуйте эту книгу своим друзьям и коллегам, чтобы они тоже знали, как можно
избежать ошибок в коде!
□ Прислушайтесь к рекомендациям по оформлению системной и программной
документации (см. главу 7). Следование этим рекомендациям позволит
сократить количество ошибок, поскольку вы и другие программисты смогут
понимать текст ваших программ.
□ Прежде чем приступать к кодированию, выполните этап проектирования
программы. Проектирование, выполняемое одновременно с кодированием, часто
приводит к появлению "спиралевидных" проектов, которые трудны для
понимания и очень уж "предрасположены" к ошибкам. При таком подходе также
велика вероятность упущения предельных случаев и сбойных ситуаций.
□ Старайтесь получить экспертную оценку своего кода: по крайней мере два
других человека должны просмотреть каждую программную строку, написанную
вами. Иногда "свежий взгляд" помогает быстрее заметить проблемы.
□ Тестируйте, тестируйте и снова тестируйте (см. главу 19).
Глава 20. Что нужно знать об отладке 599
□ Имитируйте возможные сбойные ситуации и обработайте их надлежащим
образом. В частности, предусмотрите возможность возникновения ситуации,
связанной с нехваткой памяти (см. главу 15).
□ Наконец, во избежание проблемы утечки памяти используйте
интеллектуальные указатели (см. главы 13, 15 и 25).
Планирование работы над ошибками
Ваши программы должны содержать средства, которые позволяют упростить
процесс отладки при возникновении неизбежных (к сожалению) ошибок. В этом разделе
как раз и описаны такие средства, а также представлены примеры, которые вы
можете включить в свои программы.
Регистрация ошибок
Представьте следующий сценарий. Вы только что выпустили новую версию своего
основного продукта, и вдруг один из первых ее пользователей сообщает, что ваша
программа "перестала работать". Вы пытаетесь выведать у пользователя побольше
информации, и в конце концов узнаете, что программа "погибла" в середине выполнения
операции. Пользователь не может даже вспомнить, что именно он делал в тот момент
и получал ли он какие-либо сообщения об ошибках. Как быть с такой проблемой?
Теперь представим такой же сценарий, но помимо весьма ограниченной
информации, полученной от пользователя, у вас также есть возможность просмотра
системного журнала на компьютере пользователя. И вот в этом журнале вы видите
сообщение от вашей программы, которое гласит следующее: "Ошибка: невозможно выделить
память". Просматривая код в том месте, где было сгенерировано сообщение об
ошибке, вы находите строку, в которой вы "собственноручно" разыменовали
указатель, не проведя проверки на значение NULL. Ура! Вы нашли причину ошибки!
Регистрация ошибок— это процесс формирования сообщений об ошибках для
постоянного хранения, чтобы они были доступны после разрушения приложения или
даже компьютера. Вы еще сомневаетесь в целесообразности такой стратегии? Вам
кажется, что вывод о причине ошибки можно сделать по поведению программы? Или
для вывода вполне достаточно наблюдений пользователя? Как показывает
предыдущий пример, пользователь описывает ситуацию не всегда полно и точно. Кроме того,
многие программы (например, ядра операционных систем и такие "долгоживущие"
Unix-демоны, как inetd или syslogd) не интерактивны и выполняются
автоматически. В таких случаях единственным способом, которым эти программы могли бы
общаться с пользователями, является регистрация ошибок в системном журнале.
Поэтому вашей программе весьма рекомендовано регистрировать ошибки при их
возникновении. В этом случае, если пользователь сообщит об ошибке, вы сможете
просмотреть системный журнал и узнать, уведомляла ли ваша программа о чем-либо, прежде чем
пользователь заметил ошибку. К сожалению, регистрация ошибок зависит от платформы:
C++ не содержит стандартного механизма регистрации ошибок. Если говорить о
конкретных платформах, то в качестве механизма регистрации в Unix используется средство
syslog, а в Windows — API-интерфейс сообщений о событиях. Нужную информацию по
своей платформе вы можете почерпнуть из соответствующей документации. Кроме того,
существует ряд реализаций (с открытым исходным текстом) межплатформенных
классов регистрации, например log4cpp (http: //sourcef orge .net).
600 Часть IV. Как создать код без ошибок
Теперь, когда вы убедились, что регистрация ошибок — прекрасное средство,
которое просто необходимо вставлять в свои программы, вы, возможно, захотите
сопровождать уведомительными сообщениями каждую логическую группу строк своего
кода, чтобы в случае возникновения подобной "нечисти" вы могли бы быстро
отследить ее истоки. Сообщения об ошибках такого типа и называются соответствующим
образом: следами. Однако вам не стоит записывать эти "следы" в системный журнал
ошибок, причем по двум причинам. Во-первых, процесс записи в постоянную память
довольно медленный. Даже в системах, которые выполняют подобную регистрацию
асинхронно, при больших объемах регистрируемой информации этот процесс
существенно замедляет выполнение программы. Во-вторых, и что самое важное, большая
часть информации, которую вы хотели бы поместить в "следы", не предназначена
для глаз конечного пользователя. Более того, она даже способна сбить его с толку,
и он (от ужаса) может тут же обратиться за технической помощью. Поэтому к такому
важному методу отладки, как трассировка программы, следует прибегать при
соответствующих обстоятельствах (см. следующий раздел).
Предлагаем вам список типов ошибок, которые следует регистрировать.
□ Неисправимые ошибки (например, невозможность выделить память или
неожиданный сбой при системном вызове). Эти ошибки, как правило,
предваряют завершение приложения.
□ Ошибки, относительно которых администратор может принимать меры
(например, нехватка памяти, некорректно сформатированный файл данных,
невозможность записать данные на диск или обрыв подключения к сети).
□ Неожидаемые ошибки (например, переменные с непредвиденными значениями).
Обратите внимание на то, что ваш код должен "ожидать", что пользователи могут
ввести некорректные данные, и обработать эту ситуацию надлежащим образом.
□ Брешь в системе защиты (например, была попытка выполнить подключение
к сети с несанкционированного адреса или было сделано слишком много
попыток подключиться к сети (отказ от обслуживания)).
Кроме того, множество API-интерфейсов позволяют указать уровень регистрации
или уровень ошибки. Вы можете зарегистрировать неошибочные ситуации под
уровнем, который можно определить как менее строгий, чем "ошибка". Например, можно
зарегистрировать значительные изменения состояния приложения, запуск и
завершение программы. Можно также предложить вашим пользователям возможность
настройки уровня регистрации программы во время выполнения, чтобы они могли
управлять объемом регистрируемых сообщений.
Трассировка программы
Если отладка связана с решением сложных проблем, открытые сообщения об
ошибках обычно не содержат достаточной информации. Зачастую для того, чтобы
понять суть проблемы, нужно зафиксировать полную траекторию выполнения кода
или знать значения переменных до того момента, как ошибка обнаружила себя.
Помимо основных сообщений, полезно иметь и такую информацию:
□ идентификационный номер (ID) потока, если у вас многопоточная программа;
□ имя функции, которая сгенерировала "след";
□ имя исходного файла, в котором записан код, генерирующий "след".
Глава 20. Что нужно знать об отладке 601
Добавить эту информацию в "задание" на отслеживание работы вашей программы
можно в специальном отладочном режиме или через кольцевой буфер. Подробнее
эти два метода рассматриваются в следующих разделах.
Режим отладки
Первый метод введения указаний на трассировку состоит в установке для программы
отладочного режима, в котором программа записывает результаты трассировки
в стандартный поток ошибок или файл и, возможно, осуществляет дополнительный
контроль во время выполнения. Установить режим отладки можно несколькими
способами.
Режим отладки во время компиляции
Можно использовать директиву препроцессора #if def для выборочной
компиляции кода отладки, вставляемого в текст программы. Достоинство данного метода
состоит в том, что код отладки в этом случае не компилируется в результирующий файл
(т.е. в конечный продукт) и поэтому не увеличивает его размер. Недостатки же
выражаются в отсутствии возможности отладки пользовательского варианта (на его
территории) или последующем получении информации об ошибках, а также в том, что
ваш код становится загроможденным и неразборчивым.
Остальная часть этого раздела содержит пример простой программы, оснащенной
средствами отладки времени компиляции. Эта программа не делает ничего
полезного: она предназначена лишь для демонстрации этого метода.
Чтобы сгенерировать отладочную версию этой программы, ее необходимо
скомпилировать при условии определения символа DEBUG_MODE. Это реализуемо, если ваш
компилятор позволяет задание символов, определяемых во время компиляции (за деталями
обратитесь к соответствующей документации, прилагаемой к компилятору). Например,
при использовании команды compile ключ д++ позволит задать символ -Dsymbol.
Обратите внимание на то, что в этом примере для объекта of stream используется
глобальная переменная. Это — один из тех немногих случаев, в которых
рекомендовано использование глобальных переменных! Здесь для этого создаются вполне
приемлемые условия, поскольку режим отладки не должен вредить остальному коду
программы. Если бы объект of stream не был глобальным, вам пришлось бы передавать
его каждой функции и вносить изменения во все их прототипы.
// CTDebug.cpp
#include <exception>
#include <fstream>
#include <iostream>
using namespace std;
#ifdef DEBUG_MODE
of stream debugOstr,-
const char* debugFileName = "debugfile.out";
#endif
class ComplicatedClass
{
public:
ComplicatedClass() {}
// Содержимое класса опущено ради экономии места.
};
602 Часть IV. Как создать код без ошибок
class UserCommand
{
public:
UserCommand() {}
// Содержимое класса опущено из экономии места.
};
ostreamb operator<<(ostream& ostr,
const ComplicatedClassb src);
ostreamb operator<<(ostreamb ostr, const UserCommand& src);
UserCommand getNextCommand(ComplicatedClass* obj) ;
void processUserCommand(UserCommand& cmd) ,-
void trickyFunction(ComplicatedClass* obj) throw(exception) ,
int main(int argc, char** argv)
{
#ifdef DEBUG_MODE
// Открываем выходной поток.
debugO s t r.open(debugFi1eName);
if (debugOstr.failО) {
cout << "He удается открыть файл отладки!\п";
return (1);
}
// Выводим аргументы командной строки в качестве "следа"
for (int i = 0; i < argc; i++) {
debugOstr << argv[i] << " ";
debugOstr << endl;
}
#endif
// Остальная часть этой функции здесь не показана,
return (0);
ostreamb operator<<(ostream& ostr,
const ComplicatedClass& src)
ostr << "ComplicatedClass";
return (ostr);
ostreamb operator<<(ostreamb ostr, const UserCommand& src)
ostr << "UserCommand";
return (ostr);
UserCommand getNextCommand(ComplicatedClass* obj )
UserCommand cmd,-
return (cmd) ,-
void processUserCommand(UserCommand& cmd)
// Код опущен для экономии места.
void trickyFunction(ComplicatedClass* obj) throw(exception)
#ifdef DEBUG MODE
Глава 20. Что нужно знать об отладке 603
// Если мы находимся в режиме отладки, выводим значения,
// которые были переданы этой функции при вызове.
debugOstr << "Функция trickyFunctionO: аргумент: "
<< *obj « endl;
#endif
while (true) {
UserCommand cmd = getNextCommand(obj);
#ifdef DEBUG_MODE
debugOstr << "Функция trickyFunctionO: полученное
Означение cmd " << cmd « endl;
#endif
try {
processUserCommand(cmd);
} catch (exceptions e) {
#ifdef DEBUG_MODE
debugOstr << "Функция trickyFunctionO: "
<< " исключение, принятое от функции
4>procesUserCommand () : "
<< е.what() << endl;
#endif
throw,-
}
}
}
Режим отладки, устанавливаемый в момент запуска
Режим отладки, устанавливаемый в момент запуска, — это альтернативный
вариант использованию директивы #if def, который довольно просто реализовать. С
помощью аргумента командной строки можно указать, должна ли программа
выполняться в режиме отладки. В отличие от режима отладки времени компиляции, при
использовании этой стратегии отладочный код включается в исполнительный файл
(выходной продукт), что позволяет установить режим отладки на компьютере
пользователя. Но такой вариант все же требует перезапуска программы (чтобы выполнить
ее в отладочном режиме), что не всегда, мягко говоря, нравится пользователям, и они
могут попросту помешать вам получить полезную информацию об ошибках.
В следующем примере используется та же программа, которая служила для
демонстрации режима отладки времени компиляции, поэтому вам будет нетрудно сравнить
эти варианты. В данной версии программы снова таки не обошлось без глобальных
переменных: на этот раз для объекта класса of stream и логического признака, по
которому можно определить, находится ли программа в режиме отладки. Использование
глобальных переменных здесь также вполне оправдано, поскольку они позволяют
избежать передачи дополнительных отладочных аргументов во все прототипы функций.
Обратите внимание вот на что: в C++ не предусмотрено стандартных средств для
анализа аргументов командной строки. В следующей программе используется простая
функция isDebugSet (), которая среди всех аргументов командной строки проверяет
лишь признак отладки; анализ же всех аргументов потребовал бы более сложной логики.
// STDebug.cpp
#include <exception>
ftinclude <fstream>
#include <iostream>
using namespace std;
604 Часть IV. Как создать код без ошибок
of stream debugOstr;
bool debug = false;
const char* debugFileName = "debugfile.out";
class ComplicatedClass
{
public:
ComplicatedClass() { }
-ComplicatedClass() {}
};
class UserCommand
{
public:
UserCommand() {}
};
bool isDebugSet(int argc, char** argv) ,•
ostream& operator<<(ostream& ostr,
const ComplicatedClass& src) ;
ostream& operator<<(ostream& ostr, const UserCommand& src);
UserCommand getNextCommand(ComplicatedClass* obj) ;
void processUserCommand(UserCommand& cmd);
void trickyFunction(ComplicatedClass* obj) throw(exception);
int main(int argc, char** argv)
{
debug = isDebugSet(argc, argv);
if (debug) {
// Открываем выходной поток.
debugOstr.open (debugFileName) ,-
if (debugOstr.fail()) {
cout << "He удается открыть файл отладки!\n";
return (1);
}
// Выводим аргументы командной строки,
for (int i = 0; i < argc,- i++) {
debugOstr << argvfi] << " ";
debugOstr << endl;
}
}
// Остальная часть кода этой функции не показана,
return (0);
}
bool isDebugSet(int argc, char** argv)
{
for (int i = 0; i < argc; i++) {
if (strcmp(argvfi], "-d") == 0) {
return (true);
}
}
return (false);
}
Глава 20. Что нужно знать об отладке 605
ostream& operator<<(ostream& ostr,
const ComplicatedClass& src)
ostr « "ComplicatedClass";
return (ostr);
ostream& operator<<(ostream& ostr, const UserCommand& src)
ostr « "UserCommand";
return (ostr);
UserCommand getNextCommand(ComplicatedClass* obj)
UserCommand cmd;
return (cmd);
void processUserCommand(UserCommand& cmd)
// Код опущен для экономии места.
void trickyFunction(ComplicatedClass* obj) throw(exception)
if (debug) {
// Если мы находимся в режиме отладки, выводим значения,
// которые были переданы этой функции при вызове.
debugOstr << "Функция trickyFunctionO: аргумент: "
<< *obj << endl;
}
while (true) {
UserCommand cmd = getNextCommand(obj);
if (debug) {
debugOstr << "Функция rickyFunctionO: полученное
^значение cmd " << cmd « endl;
}
try {
processUserCommand(cmd);
} catch (exception& e) {
if (debug) {
debugOstr « "trickyFunctionO: "
. << " исключение, принятое от функции
1>procesUserCommand() : "
« е.what() << endl;
}
606 Часть IV. Как создать код без ошибок
throw;
Режим отладки времени выполнения
Лучше всего реализовать такой режим отладки, который бы позволял при
необходимости его разрешать (включать) или запрещать (отключать) во время выполнения
программы. Это можно сделать путем использования асинхронного интерфейса,
который бы управлял режимом отладки прямо "в полете". В GUI-программах, т.е. программах,
использующих графический интерфейс пользователя, этот интерфейс мог бы иметь
форму команд меню. В CLI-программах (Call Level Interface — прикладной программный
интерфейс уровня вызовов) подобный интерфейс можно было бы реализовать в виде
асинхронной команды, которая осуществляет межпроцессное обращение к программе
(например, с помощью сокетов, сигналов или удаленных вызовов процедуры). В C++ не
предусмотрено никакого стандартного способа выполнить межпроцессное
взаимодействие или GUI-обмен, поэтому мы не приводим примеров реализации такого метода.
Кольцевые буферы
Режим отладки полезен для решения воспроизводимых проблем, а также для
выполнения тестов. Однако ошибки имеют свойство проявляться при выполнении
программы в неотладочном режиме, причем к тому времени, когда вы сами или ваш
клиент включит режим отладки, может быть уже слишком поздно получать информацию
об ошибке. Один из вариантов решения этой проблемы — позволить включать
трассировку программы в любое время. Обычно для отладки программы нужны лишь
самые последние данные о трассировке, поэтому только их и стоит сохранять.
Реализовать такое ограничение можно при осмотрительном подходе к периодическим
обновлениям системного журнала.
Но, дабы избежать проблем с регистрацией данных о выполнении программы,
описанных выше в разделе "Регистрация ошибок", следовало бы позаботиться о том,
чтобы программа не использовала журнал ошибок вообще, а сохраняла
регистрационную информацию в оперативной памяти. В этом случае механизм записи всех
сообщений трассировки в стандартный поток ошибок или файл системного журнала
заработал бы лишь при возникновении такой необходимости. А в обычном режиме
использовался бы кольцевой буфер, в котором можно было бы либо сохранять
фиксированное количество сообщений, либо сохранять сообщения в области памяти
фиксированного объема. При полном заполнении буфера запись сообщений
начиналась бы сначала, перезаписывая тем самым уже устаревшие сообщения. Этот цикл
может повторяться бесконечно. В следующих разделах представлена реализация
такого кольцевого буфера и показано, как его можно использовать в программах.
Интерфейс кольцевых буферов
#include <vector>
#include <string>
#include <fstream>
using std::string;
using std::vector;
using std::ostream;
//
Глава 20. Что нужно знать об отладке 607
// Класс RingBuffer
//
// обеспечивает функционирование простого буфера отладки.
// Клиент задает количество элементов (записей) в конструкторе
// и добавляет сообщения с помощью метода addEntryO.
// Если количество записей в буфере превысит допустимое
// значение, новые записи будут перезаписывать старые.
//
// Буфер также позволяет распечатывать записи по мере их
// поступления в буфер. Клиент может указать в конструкторе
// выходной поток и вернуть его в исходное положение с
// помощью метода setOutput().
//
// Наконец, буфер поддерживает потоковую передачу данных в
// любой выходной поток.
//
class RingBuffer
{
public:
//
// Конструктор создает кольцевой буфер размером,
// задаваемым параметром numEntries.
// Записи должны поступать по очереди в ♦ostr-буфер.
//
RingBuffer(int numEntries = kDefaultNumEntries,
ostream* ostr = NULL),-
-RingBuffer() ,-
//
// Метод добавляет строку в кольцевой буфер, возможно,
// перезаписывая самую старую строку в буфере (если
// буфер уже полон).
//
void addEntry (const strings entry) ,-
//
// Метод выводит в поток ostr буферизированные записи,
// разделенные символами новой строки.
//
friend ostream& operator<<(ostream& ostr,
const RingBuffer& rb) ,-
//
// Метод устанавливает выходной поток, в который
// направляются записи по мере их поступления.
// Метод возвращает прежний выходной поток.
//
ostream* setOutput(ostream* newOstr);
protected:
vector<string> mEntries,-
ostream* mOstr;
int mNumEntries, mNext;
bool mWrapped;
static const int kDefaultNumEntries = 500;
private:
// Предотвращает присваивание и передачу по значению.
RingBuffer (const RingBuffer& src) ,-
RingBuffer& operator= (const RingBuffer& rhs) ,-
};
608 Часть IV. Как создать код без ошибок
Реализация кольцевого буфера
В данной реализации кольцевого буфера сохраняется фиксированное количество
строк. Каждая из этих строк должна быть скопирована в кольцевой буфер, требующий
динамического выделения памяти. Такой подход вряд ли можно назвать самым
эффективным решением. Поэтому стоит рассмотреть и другие варианты, например,
связанный с поддержкой фиксированного размера (количество байтов памяти) буфера. Но
в этом случае придется "опускаться" до низкоуровневых С-строк и С-средств управления
памятью, чего по возможности следует избегать. Такая реализация имела бы право на
существование только в случае, если вы не пишете высокоэффективное приложение.
В предлагаемом здесь кольцевом буфере для хранения string-записей
используется STL-вектор, но можно было бы также взять и стандартный С-массив. Надо
сказать, использование STL-контейнера не вызывает никаких трудностей, за
исключением разве что реализации оператора operator<< для класса RingBuffer с его
довольно "заумными" итераторами. Чтобы детальнее ознакомиться с итераторами
и алгоритмом копирования, обратитесь к главам 21—23.
#include <algorithm>
#include <iterator>
#include <iostream>
#include "RingBuffer.h"
using namespace std;
const int RingBuffer::kDefaultNumEntries;
//
// Инициализируем вектор, определяя его размер точным
// значением numEntries, которое не должно изменяться
// на протяжении времени существования объекта.
//
// Инициализируем и другие члены класса RingBuffer.
//
RingBuffer::RingBuffer{int numEntries,
ostream* ostr) : mEntries(numEntries),
mOstr(ostr), mNumEntries(numEntries), mNext(0),
mWrapped(false)
{
}
RingBuffer::-RingBuffer()
//
// Этот алгоритм довольно прост: добавляем запись в следующую
// свободную область, затем устанавливаем значение mNext,
// чтобы указать новую границу свободного места. Если mNext
// достигнет конца вектора, его значение снова установится
// равным 0.
//
// Буфер должен "быть в курсе", "пошел" ли он на новое
-// "кольцо", чтобы "решить", выводить ли с помощью оператора
// operator<< записи, расположенные после указателя mNext.
//
void RingBuffer::addEntry(const string& entry)
{
// Добавляем запись в следующую свободную область и
// инкрементируем значение mNext, чтобы оно указывало
// на новую границу свободного места.
mEntries[mNext++] = entry;
Глава 20. Что нужно знать об отладке 609
// Проверяем, достигли лм мы конца буфера. Если да, то
// нужно идти на новое "кольцо".
if (mNext >= mNumEntries) {
mNext = 0;
mWrapped = true;
}
// Если существует действительный ostream-объект,
// выводим запись в него.
if (mOstr != NULL) {
*mOstr << entry << endl;
ostream* RingBuffer:rsetOutput(ostream* newOstr)
{
ostream* ret = mOstr,-
mOstr = newOstr,-
return (ret);
}
//
// Эта функция использует итератор ostream_iterator для
// "копирования" записей непосредственно из вектора в
// выходной поток.
//
// Функция должна выводить записи по порядку. Если буфер
// перезаписан, то самая ранняя запись находится после самой
// "свежей", на которую указывает значение mNext.
// Поэтому сначала выводим записи с позиции mNext до конца.
//
// Затем (даже если буфер еще не "пошел" на новое "кольцо")
// выводим записи с начала буфера до позиции mNext - 1.
//
ostream& operator«(ostreamS ostr, const RingBuffer& rb)
{
if (rb.mWrapped) {
//
// Если буфер перезаписан, то выводим элементы, начиная
// с самой ранней записи, до конца.
//
copy (rb.mEntries.beginO + rb.mNext, rb.mEntries.endO ,
ostream iterator<string>(ostr, "\n"));
}
//
// Теперь выводим записи до тех пор, пока не дойдем до
// самой "свежей".
// Продвигаемся до позиции begin() + mNext, поскольку
// этот диапазон не включает правую границу.
//
copy (rb.mEntries.beginO, rb.mEntries.beginO + rb.mNext,
ostream_iterator<string>(ostr, "\n"));
return (ostr) ,-
}
Использование кольцевого буфера
Чтобы заработал наш кольцевой буфер, достаточно просто объявить объект и
начать добавлять в него сообщения. Если вы захотите распечатать содержимое буфера,
то для вывода данных в соответствующий поток ostream используйте оператор ор-
610 Часть IV. Как создать код без ошибок
erator<<. Ниже приведена уже знакомая вам программа (послужившая для
демонстрации режима отладки, устанавливаемого в момент запуска), но модифицированная
для показа использования кольцевого буфера.
#include "RingBuffer.h"
#include <exception>
#include <fstream>
#include <iostream>
#include <cassert>
#include <sstream>
using namespace std;
RingBuffer debugBuf;
class ComplicatedClass
{
public:
ComplicatedClass() {}
-ComplicatedClass() {}
};
class UserCommand
{
public:
UserCommand 0 {}
};
ostream& operator<<(ostream& ostr,
const ComplicatedClassb src);
ostreamb operator<<(ostreamS ostr, const UserCommand& src);
UserCommand getNextCommand(ComplicatedClass* ob j) ;
void processUserCommand(UserCommand& cmd);
void trickyFunction (ComplicatedClass* ob j ) throw (exception) ,-
int main(int argc, char** argv)
{
// Выводим аргументы командной строки.
for (int i = 0; i < argc; i++) {
debugBuf.addEntry(argv[i]);
}
trickyFunction(new ComplicatedClass());
// Выводим текущее содержимое буфера отладки в cout.
cout << debugBuf;
return (0);
}
ostreamS operator<<(ostream& ostr,
const ComplicatedClass& src)
{
ostr << "ComplicatedClass";
return (ostr);
Глава 20. Что нужно знать об отладке 611
ostream& operator<<(ostream& ostr, const UserCommand& src)
ostr << "User-Command" ;
return (ostr);
UserCommand getNextCommand(ComplicatedClass* obj)
UserCommand cmd;
return (cmd);
void processUserCommand(UserCommand& cmd)
// Код опущен ради экономии места.
void trickyFunction(ComplicatedClass* obj) throw(exception)
assert(obj != NULL);
// Регистрируем значения, с которыми эта функция
// начинает работать.
ostringstream ostr,-
ostr << "trickyFunction(): given argument: " << *obj;
debugBuf.addEntry(ostr.str() ) ;
while (true) {
UserCommand cmd = getNextCommand(obj);
ostringstream ostr;
ostr << "Функция trickyFunctionO : получено
Ч> значение cmd " << cmd;
debugBuf.addEntry(ostr.str());
try {
processUserCommand(cmd) ,-
} catch (exceptions e) {
string msg = "trickyFunctionO: получено исключение
Ч> от функции procesUserCommand(): ";
msg += е. what О,-
debugBuf.addEntry(msg);
throw;
}
break;
}
}
Обратите внимание на то, что перед добавлением записей в буфер этот интерфейс
может выполнять создание строк с помощью методов конкатенации, определенных
в классах ostringstream или string.
Отображение содержимого кольцевого буфера
Сохранение трассировочных сообщений в памяти — это только полдела, но для
того, чтобы получить от них пользу, нужно найти способ доступа к ним с целью
отладки. Ваша программа должна каким-то образом "узнавать" о том, что ей нужно
612 Часть IV. Как создать код без ошибок
распечатать сообщения. Такую процедуру можно сравнить с интерфейсом, через
который вы могли бы включать режим отладки во время выполнения программы. Кроме
того, если в вашей программе возникнет неисправимая ошибка, которая непременно
приводит к прекращению программы, то, несмотря на скорый "конец", она должна
успеть перед "смертью" вывести содержимое кольцевого буфера в стандартный поток
ошибок или системный журнал.
Извлечь эти сообщения из "недр" памяти можно и по-другому. Для этого
достаточно получить дамп памяти для программы. В каждой платформе обработка дампов
памяти происходит по-разному, поэтому для уточнения информации вам следует
обратиться к соответствующей документации или получить консультацию у специалиста.
Использование макросов assert
Макрос assert, определенный в библиотеке <cassert>, представляет собой
довольно мощное средство. Он принимает булево выражение и, если оно равно
значению false, выводит сообщение об ошибке и завершает программу. Если
анализируемое выражение принимает значение true, макрос не выполняет никаких действий.
Хотя описанное поведение не производит впечатление особенно полезного, в
некоторых случаях оно оказывается весьма эффективным. С помощью макроса assert
можно "заставить" программу показать ошибку в конкретном месте кода, из которого
она "произрастает". Если не "заявить претензию" в этом месте кода, программа,
скорее всего, продолжит выполнение с некорректными значениями, и эта ошибка может
не проявиться еще в течение долгого времени. Используя assert-средство, вы
можете обнаруживать ошибки заблаговременно.
Поведение макроса assert зависит от символа препроцессора NDEBUG: если этот
символ не определен, анализ з.В5&ю\1-выражения выполняется, в противном случае оно
игнорируется. При компиляции Отладочных" конструкций этот символ зачастую
определяется. Если вы хотите оставить assert-еы^ажетшя в исполняемом коде,
установите для компилятора соответствующие параметры или напишите собственную
версию макроса assert, на которую бы не влияло знаг»шш£ NDEBUG.
Проверки с помощью assert-маросов следует использовать в том случае, если вы
"предполагаете", что интересующие вас переменные будут находиться в
определенном состоянии. Например, если вы вызываете библиотечную функцию, которая,
предположительно, возвращает указатель, но (якобы) никогда— значение NULL,
вставьте assert-макрос после вызова этой функции, чтобы убедиться, что этот
указатель и в самом деле не равен значению NULL.
При этом не следует злоупотреблять assert-средством. Например, если вы
пишете библиотечную функцию, не стоит делать assert-проверку на допустимость ее
параметров. Лучше проверить их обычным способом и в случае некорректности вернуть
соответствующий код ошибки или сгенерировать исключение. "Пожарные" assert-
средства желательно "сэкономить" для ситуаций, в которых у вас нет другого варианта.
Например, в примере демонстрации режима отладки, устанавливаемого в момент
запуска, функция trickyFunction () принимает параметр типа ComplicatedClass*.
Вместо того, чтобы предполагать, что передаваемый аргумент будет иметь допустимое
значение, имеет смысл выполнить такую assert-проверку.
#include <cassert>
Глава 20. Что нужно знать об отладке 613
void trickyFunction(ComplicatedClass* obj) throw(exception)
{
assert(obj != NULL);
// Остальной код опущен.
}
He помещайте в тело assert-макроса код, без которого не может
обойтись программа. Например, использование подобной строки —
только лишний повод для головной боли: assert (xnyFunctionCall ()
1» NULL). Если при выпуске исполняемой версии из программы
будут удалены assert-фрагменты, то и обращение к функции myFunc-
tionCall () тоже пропадет!
Методы отладки
Иногда отладка программ может быть невероятно трудной. Но, применяя
систематический метод, ее удается значительно упростить. Первый шаг в попытке отладить
программу всегда должен быть связан с воспроизведением конкретной ошибки. От того,
сможете ли вы это сделать, и будет зависеть ваш следующий шаг. О том, как репродуцировать
ошибки, как отладить воспроизводимые ошибки и как быть с невоспроизводимыми, вы
прочтете в следующих трех разделах. Кроме того, вы узнаете об особенностях отладки
ошибок, связанных с управлением памятью, и отладки многопоточных программ.
Репродуцирование ошибок
Если ошибку можно воспроизводить постоянно, то найти ее "корни" обычно не
представляет труда. Любая воспроизводимая ошибка поддается "вычислению" и
исправлению. С невоспроизводимыми ошибками все обстоит гораздо сложнее, и найти
их первопричину не всегда удается. Чтобы воспроизвести ошибку, запустите
программу с точно такими же входными данными, которые использовались тогда, когда
эта ошибка впервые обнаружила себя. Побеспокойтесь о соответствии всех входных
данных (от момента запуска программы до момента появления ошибки).
Распространенное заблуждение— пытаться воспроизвести ошибку путем выполнения только
пускового действия. Такой подход может не дать результата, поскольку ошибка может
быть вызвана целой последовательностью действий. Например, если после запроса
определенной Web-страницы ваш Web-браузер отказывается работать, демонстрируя
"симптомы" нарушения сегментации, это может быть вызвано разрушением данных
в памяти именно в связи с тем конкретным запросом, т.е. заданием того самого
сетевого адреса. Однако причина может быть в чем-то другом. Например, ваша программа
записывает все запросы в очередь (которая рассчитана на один миллион записей),
I а эта "роковая" запись оказалась миллион первой. В этом случае перезапуск
программы и отправка одного запроса, конечно же, не позволит выявить ошибку.
Иногда невозможно сымитировать полную последовательность действий, которые
приводят к ошибке. Возможно, ошибка была замечена пользователем, который не
может вспомнить все, что он делал тогда. Или же имитация всех входных данных
невозможна из-за слишком длительного периода эксплуатации программы. В этом случае
остается просто приложить максимум усилий, чтобы воспроизвести ошибку "своими
614 Часть IV. Как создать код без ошибок
силами". Вам придется строить и проверять свои догадки, что может отнять много
времени, однако ваши усилия в этом направлении могуг сэкономить время на более позднем
этапе отладки. Перечислим методы, которые могут помочь вам в подобных ситуациях.
□ Повторите пусковое действие в нужной среде с входными данными,
максимально приближенными к тем, что указаны в отчете об ошибке.
□ Запустите автоматические тесты, которые проверяют "подозреваемые"
функции. Именно автоматические тесты, которые выполняются без вашего участия,
могут помочь в воспроизведении ошибок, поскольку для выявления такой
пакости может понадобиться 24 часа в сутки.
□ Если в вашем распоряжении есть все необходимое оборудование, запуск
различных вариаций "на тему" основных тестов на различных компьютерах
параллельно может иногда существенно сэкономить время.
□ Выполните испытания в утяжеленном режиме (нагрузочные испытания).
Запустите тесты, которые "нагружают" те участки кода, которые,
предположительно, содержат ошибку. Если ваша программа — Web-сервер, который "падает"
на определенном запросе, попробуйте заставить миллионы браузеров
одновременно выполнить этот запрос.
После того как вам удастся воспроизводить ошибку неоднократно, вы должны
попытаться определить самую простую и наиболее эффективную совокупность тестовых
данных для ее воспроизведения. Это позволит быстрее найти ее причину, устранить
ее и удостовериться в том, что данная ошибка (раз и навсегда) исправлена.
Отладка воспроизводимых ошибок
. Если вам удается воспроизводить ошибку постоянно и эффективно, значит, пора
вычислить проблемное место в коде, которое и "генерирует" ошибку. Ваша цель —
найти конкретные строки кода, которые инициируют проблему. Для этого можно
использовать две различные стратегии.
1. Вывод отладочных сообщений. Добавляя в программу достаточно много
отладочных сообщений и анализируя выводимую ими информацию при
воспроизведении ошибки, вы должны суметь точно определить строки кода, в
которых эта ошибка "затаилась". Если в вашем распоряжении есть отладчик, этот
метод обычно применять не рекомендуется, поскольку он требует внесения
в программу изменений и может быть довольно долгим. Но если вы уже
оснастили свою программу отладочными сообщениями (как было описано выше), то,
вам, возможно, удастся выявить причину ошибки, запустив программу на
воспроизведение ошибки в режиме отладки. Этот метод может оказаться
результативнее и быстрее принести плоды, чем при "раскочегаривании" отладчика.
2. Использование отладчика. Мы надеемся, что вы знакомы с отладчиками,
которые позволяют в пошаговом режиме выполнять программу и при этом
просматривать состояние памяти и значения переменных в различных точках
кода. Если вам еще не приходилось применять отладчики, этому следует
научиться, и чем скорее — тем лучше. Чаще всего при выявлении причин
ошибок без отладчиков обойтись нельзя. Если у вас есть доступ к исходному
коду, вы сможете использовать символический отладчик, который работает
с входным языком высокого уровня, т.е. с именами переменных, классов и
другими символами кода. Чтобы использовать символический отладчик, необходимо
Глава 20. Что нужно знать об отладке 615
скомпилировать программу вместе с отладочной информацией. В противном
случае символьная информация будет удалена из исполняемого файла
программы и не будет доступна для отладчика.
Оба метода отладки продемонстрированы на примере в конце этой главы.
Отладка невоспроизводимых ошибок
Исправить ошибки, которые относятся к категории невоспроизводимых, гораздо
сложнее. Как правило, в таких случаях вы располагаете слишком небольшой
информацией и должны теряться в догадках. Тем не менее существует ряд стратегий,
которые способны помочь и в этой беде.
1. Попытайтесь превратить невоспроизводимую ошибку в воспроизводимую. Часто
можно хотя бы приблизительно определить, где "зарыта собака". Иногда все
же стоит затратить какое-то время на попытки репродуцировать ошибку. Если
они увенчаются успехом, и эта злополучная ошибка будет воспроизведена, то
вам останется лишь применить к ней методы "борьбы", описанные выше.
2. Проанализируйте журналы регистрации ошибок. Будем надеяться, что вы не
забыли оснастить свою программу средствами формирования сообщений об
ошибках, как было описано выше. Вам следует тщательно проанализировать эту
информацию, поскольку любые ошибки, зарегистрированные
непосредственно перед моментом появления "летучего голландца", по всей видимости внесли
свой "вклад" в общее "черное дело". Очень даже вероятно, что ваша
программа зафиксирует точную причину искомой ошибки!
3. Проанализируйте результаты трассировки программы. Надо полагать, вы
вооружили свою программу средствами трассировки на основе кольцевого буфера, как
было описано выше. На момент появления искомой ошибки вы должны получить
копию результатов трассировки программы. Анализ этой информации может
эффективно помочь в определении истоков зарождения злополучной ошибки.
4. Изучите содержимое файла, сохранившего "снимок" памяти, если, безусловно,
таковой существует. В некоторых платформах для приложений, которые
завершаются в аварийном порядке, файлы дампов памяти генерируются
автоматически. В среде Unix эти дампы называются базовыми файлами. Каждая платформа
предоставляет средства для анализа этих дампов памяти. Даже без символической
отладочной информации эти файлы обычно содержат внушительный объем
информации. Например, вы можете получить "снимок" стековой памяти для
приложения перед его "кончиной", поскольку такие глобальные символы, как имена
функций и методов обычно доступны в "голых" исполнительных файлах. Если
вы знакомы с ассемблером, используемым на вашей платформе, то сможете
выполнить обратное ассемблирование машинного кода, чтобы получить код на
соответствующем языке ассемблера. Кроме того, можно просмотреть
содержимое памяти, но без символов эта информация будет выглядеть очень уж
аскетически: никаких типов и имен вы не увидите.
5. Внимательно изучите код программы. К сожалению, зачастую это—
единственный способ определить причину невоспроизводимой ошибки. Но, что
удивительно, он во многих случаях оказывается эффективным. Просматривая код,
даже написанный вами только что, с целью найти ошибку, часто можно ее
действительно обнаружить. Это вовсе не означает, что мы советуем часами сверлить
616 Часть IV. Как создать код без ошибок
взглядом строчки кода, но все же попытка "пройти" программу вручную может
привести прямо к искомой проблеме.
6. Используйте любое средство просмотра содержимого памяти, например,
описанное в нижеследующем разделе "Отладка ошибок, связанных с управлением
памятью". Подобные средства могут помочь в поиске ошибок, которые не
всегда нарушают поведение программы, но могут оказаться потенциальной
причиной искомой ошибки.
7. Сохраняйте и обновляйте отчет об ошибках. Даже если сейчас вы не найдете
истинную причину ошибки, этот отчет может содержать полезную информацию
о ваших попытках ее обнаружить и пригодиться вам в случае, если эта проблема
возникнет снова. (Методы отслеживания ошибок описаны в главе 19.)
Если вам удастся найти причину невоспроизводимой ошибки, создайте контрольный
пример, включающий совокупность всех тестовых данных и позволяющий
воспроизвести эту ошибку, что даст вам право перевести ее в категорию воспроизводимых.
Прежде чем реально ликвидировать ошибку, очень важно уметь ее воспроизводить.
В противном случае как же вы докажете (с помощью теста), что эта ошибка
исправлена? Распространенный случай при отладке невоспроизводимых ошибок — исправлять
в коде "не ту" проблему. Коль вы не можете воспроизвести ошибку, вы и не будете
знать, исправили ли вы ее на самом деле, поэтому не стоит и удивляться, если месяц
спустя она "выплывет" снова.
Отладка ошибок, связанных с управлением памятью
Большинство таких катастрофических ошибок, как разрушение приложения,
вызваны ошибками в управлении памятью. Да и причины многих некатастрофических
тоже "родом" из этой области. Истоки некоторых ошибок довольно тривиальны:
если программа, например, попытается разыменовывать NULL-указатель, она будет
немедленно завершена. Но другие отличаются большим коварством. Например, если вы
попытаетесь помещать данные за пределами С++-массива, ваша программа, возможно,
и не "лопнет" прямо в этот момент (т.е. в момент выполнения этого некорректного
кода), но если этот массив расположен в стековой памяти, вы при записи элементов
в один массив "попадете" на самом деле в область, занимаемую другой переменной
или другим массивом, изменив "наглым образом" их значения, причем эти изменения
могут сказаться на поведении программы гораздо позднее. А если для массива была
выделена память в "куче", то вы могли своими неправомерными действиями вызвать
искажение содержимого памяти в области "кучи", и последствия этого тоже могут
быть замечены позже: при попытке динамически выделить или освободить память.
Некоторые распространенные случаи ошибок при управлении памятью описаны
в главе 13, но там делался акцент на том, как их избегать при написании
программного кода. В данном разделе мы рассмотрим ошибки этой категории с точки зрения
идентификации проблем в коде, который демонстрирует наличие ошибок. Прежде
чем читать этот раздел, вам необходимо освоить материал, изложенный в главе 13.
Категории ошибок в управлении памятью
Для того чтобы успешно вести борьбу с проблемами в управлении памятью, следует
знать, с ошибками каких типов вы можете столкнуться. В этом разделе описаны
основные категории ошибок этого класса. Для каждой описываемой здесь категории
Глава 20. Что нужно знать об отладке 617
приводится небольшой пример кода, демонстрирующий ошибку, и список возможных
симптомов, которыми она может выражаться. Как известно, симптом— это не сама
болезнь, а наблюдаемое вами (в данном случае) поведение программы, вызванное ошибкой.
Ошибки при освобождении памяти
В следующей таблице описаны пять основных типов ошибок, связанных с
освобождением памяти.
Тип ошибки
Симптомы
Пример
Утечка памяти
Использование
несоответству
ющих команд
выделения и
освобождения
памяти
Освобождение
памяти более
одного раза
Освобождение
стековой
памяти
Процесс со временем
усугубляется и замедляется.
В конце концов, команды
и системные вызовы перестают
выполняться из-за отсутствия
свободной памяти
Обычно не приводит к
немедленному краху программы.
На некоторых платформах может
вызвать искажение памяти, что
может выразиться в более
позднем аварийном завершении
программы (нарушении
сегментации)
Освобождение
невыделенной
памяти
Может вызвать аварийное
завершение программы
(нарушение сегментации), если
между двумя вызовами оператора
delete память в этой области
была выделена во второй раз
Обычно вызывает аварийное
завершение программы
(нарушение сегментации или
ошибку при обращении к шине
передачи данных)
Формально это — специальный
случай освобождения
невыделенной памяти. Обычно
вызывает аварийное завершение
программы
void memoryLeak()
{
int* ip = new int [1000],-
return; // Ошибка! Указатель
// ip не освобожден.
}
void mismatchedFree()
{
int* ipl = (int *)malloc(
sizeof(int));
int* ip2 = new int;
int* ip3 = new int[100 0];
delete ipl; // Ошибка! Нужно
// использовать free().
delete [] ip2; // Ошибка! Нужно
// использовать delete().
free (ip3); // Ошибка! Нужно
// использовать delete[].
}
void doubleFree()
{
int* ipl = new int[1000];
delete [] ipl;
int* ip2 = new int[1000];
delete[] ipl; // Ошибка!
// ipl-память
// освобождается дважды
}
void freeUnallocatedO
{
int* ipl =
reinterpret_cast<int*>(10000);
// Ошибка! ipl -
// недействительный указатель.
delete ipl;
}
void freeStack()
{
int X;
int* ip = &x;
delete ip; // Ошибка!
// Освобождение
// стековой памяти
}
618 Часть IV. Как создать код без ошибок
Как видите, многие ошибки, связанные с некорректным освобождением памяти,
не вызывают немедленного завершения программы. Эти ошибки, как правило, сразу
себя не выдают, а проявляются на более поздних этапах выполнения программы.
Ошибки, связанные с доступом к памяти
Во вторую категорию входят ошибки считывания данных из памяти и записи в нее.
Тип ошибки
Симптомы
Пример
Доступ Почти всегда вызывает аварийное
к неправильно завершение программы, причем
заданной немедленно
области памяти
Доступ к уже
освобожденной
памяти
Доступ к
"чужой"
области
памяти
Считывание
данных из
неинициализированной
памяти
Обычно не вызывает аварийного
завершения программы.
Если память в этой области была
еще раз выделена, в программе
могут неожиданно появиться
"странные" значения
Не вызывает аварийного
завершения программы.
В программе могут неожиданно
появиться "странные" значения
Не вызывает аварийного
завершения программы,
если не использовать
неинициализированное значение
в качестве указателя
и не разыменовывать его
(как в примере). Но даже и в этом
случае аварийное завершение
программы необязательно
void accesslnvalid{)
{
int* ipl =
reinterpret_cast<int*>(10000) ,-
// Ошибка! ipl -
// недействительный
// указатель.
*ipl = 5;
}
void accessFreed()
{
int* ipl = new int;
delete ipl;
int* ip2 = new int;
// Ошибка! Память, адресуемая
// указателем ipl, была
// освобождена.
*ipl = 5;
}
void accessElsewhere()
{
int x, у[10], z;
x = 0;
z = 0;
// Ошибка! 10-й элемент
// расположен за границей
//- массива.
for (int i = 0; i <= 10; i++) {
y[i] = 10;
}
}
void readUninitialized()
{
int* ip;
// Ошибка! Указатель ip - не
// инициализирован.
cout << *ip << endl;
Глава 20. Что нужно знать об отладке 619
У ошибок, связанных с доступом к памяти, больше шансов (чем у ошибок из
категории освобождения памяти) вызвать аварийное завершение программы. Но они не
всегда приводят к такому результату. Они могут проявиться в виде странностей,
определяемых как некатастрофические ошибки.
Советы по отладке программ при наличии в них ошибок
в управлении памятью
Ошибки, связанные с управлением памятью, часто обнаруживаются в разных местах
программы. Обычно так происходит в случае разрушения памяти "кучи". Эта ситуация
подобна бомбе замедленного действия, готовой взорваться при попытке выделить
память в области "кучи", освободить ее или использовать по назначению. Поэтому, если
вы заметили ошибку, которую можно назвать воспроизводимой, но воспроизводится
она каждый раз не совсем в том же месте, можно подозревать разрушение памяти.
Например, при одном выполнении программа может продемонстрировать нарушение
сегментации, а во второй раз — ошибочную ситуацию при обращении к шине.
Если вы заподозрили, что ошибка связана с памятью, то лучше всего использовать
специальное средство проверки памяти для C++. Отладчики часто позволяют
выполнять программу одновременно с проверкой на ошибки управления памятью. Кроме
того, существуют такие прекрасные средства сторонних производителей, как purify от
компании Rational Software (теперь принадлежащей компании IBM) или valgrind для
среды Linux. Эти средства работают путем использования собственных функций
выделения и освобождения памяти, чтобы выявить в исследуемой программе случаи
некорректного использования динамической памяти, например, освобождения
невыделенной памяти, разыменования указателя на невыделенную память или записи
данных за пределами массива.
Если в вашем распоряжении нет подобных средств проверки памяти, и обычные
стратегии отладки оказались бесполезными, вам, возможно, стоит провести
инспекцию кода (так называется методология формального анализа кода). Если вам удастся
сузить круг поиска ошибки до определенной части кода, то для дальнейшего
уточнения можно попробовать следующие методы.
Ошибки в объектах и коде, связанном с классами
□ Удостоверьтесь в том, что ваши классы, в которых происходит динамическое
выделение памяти, содержат деструкторы, освобождающие те же области
памяти, которые были выделены в объектах: ни больше и ни меньше.
□ Удостоверьтесь в том, что ваши классы корректно обрабатывают операции
копирования и присваивания объектов с помощью конструкторов копий и
операторов присваивания, как было описано в главе 9.
□ Проверьте подозрительные операции приведения типа. Если вы преобразуете
указатель на объект одного типа в указатель на объект другого типа, убедитесь,
что эта операция правомерна.
Распространенные ошибки, связанные с управлением памятью
□ Убедитесь, что каждому вызову оператора new соответствует ровно один вызов
оператора delete. Аналогично каждое обращение к функции malloc, alloc
или calloc должно сопровождаться соответствующим обращением к
функции free, а каждый вызов оператора new [] должен иметь ровно один вызов
оператора delete []. Хотя дублирование f ree-вызовов само по себе безвредно,
620 Часть IV. Как создать код без ошибок
проблемы все же могут возникнуть, если после первого вызова функции free
та же самая область памяти будет выделена вторично.
□ Проверьте возможность переполнения буфера. Всякий раз, когда обращаетесь
к массиву или С-строке, убедитесь, что вы не "задеваете" при этом область
памяти, расположенную за границей массива.
□ Удостоверьтесь в том, что вы не разыменовываете недействительные указатели.
Отладка многопоточных программ
В отличие от Java, язык C++ не обеспечивает никакого механизма для организация
поточной обработки и синхронизации между потоками. Однако многопоточные С++-
программы нынче весьма распространены, поэтому важно рассмотреть
специфические вопросы, связанные с отладкой программ этого типа. Ошибки в многопоточных
программах часто вызваны различиями в характере квантования времени в разных
операционных системах и считаются трудновоспроизводимыми. Поэтому отладка
многопоточных программ требует применения специального набора методов.
1. Используйте cout-отладку. При отладке многопоточных программ cout-инст-
рукции зачастую более эффективны, чем применение отладчика. В
большинстве отладчиков не предусмотрена обработка нескольких потоков выполнения.
Ведь довольно трудно пошагово проходить программу, если не знаешь, какой
поток будет выполняться в данный момент. Лучше всего до и после критических
разделов своей программы вставить отладочные инструкции; это также полезно
сделать до запроса блокировок и после их снятия. Затем, анализируя выводимые
программой сообщения, вы сможете обнаружить взаимоблокировки и состояния
гонок, если увидите, что два потока одновременно находятся в критическом
разделе или один поток "застрял" в ожидании блокировки.
2. Вставьте принудительные переходы в режим ожидания и контекстные
переключения. Если у вас проблемы с регулярным воспроизведением ошибок, или вы
выявили их причину, но хотите в этом убедиться, попробуйте заставить программу
продемонстрировать поведение в соответствии с определенной схемой
переключения между потоками, заставляя потоки "засыпать" в течение заданного
периода времени. Несмотря на то что в C++ нет стандартного способа "усыпления"
потоков, в большинстве платформ предусмотрен вызов, как правило,
именуемый sleep (). Устанавливая на несколько секунд режим ожидания
непосредственно перед снятием блокировки, сразу перед передачей сигнала (переменной
условия) или прямо перед доступом к общим данным, можно выявить
состояния гонок, которые в противном случае могли остаться необнаруженными.
Пример отладки: поиск цитат
В этом разделе представлена программа с ошибками и показано, какие действия
можно предпринять, чтобы отладить ее и исправить ошибки.
Предположим, что вы — член команды, которой поручено написать
Web-страницу, позволяющую пользователям находить научные труды, ссылающиеся на
конкретный доклад или статью. Этот тип сервиса полезен для авторов, которые пытаются
найти труды, близкие по тематике их собственным. Найдя одну статью по сходной
тематике, они могут поискать другие, в которых цитируется только что найденная,
и тем самым продолжить поиск связанных цитат из научных трудов.
Глава 20. Что нужно знать об отладке 621
В этом проекте вы отвечаете за код, который считывает еще не обработанные
данные (цитаты) из текстовых файлов. Для простоты предположим, что цитаты из
каждой статьи должны храниться в собственном файле. Более того, допустим, что
первая строка каждого такого файла содержит имя автора, название статьи и
информацию о публикации; вторая строка — всегда пуста, а все последующие содержат
цитаты из статьи (по одной на каждой строке). Приведем пример файла, созданного для
одной из знаковых статей по вычислительной технике.
Alan Turing,"On Computable Numbers with an Application to the \
Entscheidungsproblem", Proceedings of the London Mathematical \
Society, Series 2, Vol.42 (1936 - 37) pages 230 to 265.
Godel, wUber formal unentscheidbare Satze der Principia \
Mathernatica und verwant der Systeme, I", Monatshefte Math. \
Phys., 38 (1931). 173-198.
Alonzo Church. "An unsolvable problem of elementary number \
theory", American J of Math., 58(1936), 345 363.
Alonzo Church. "A note on the Entscheidungsproblem", J. of \
Symbolic logic, 1 (1930), 40 41.
Cf. Hobson, "Theory of functions of a real variable (2nd ed., \
1921)", 87, 88.
Proc. London Math. Soc (2) 42 (1936 7), 230 265.
Обратите внимание на то, что символ "\" означает продолжение строки,
благодаря которому компьютер должен обрабатывать несколько строк как одну целую.
Реализация класса Articledtations (с ошибками)
Итак, вы решили структурировать свою программу, написав класс ArticleCita-
tions, который считывает содержимое файла и сохраняет информацию о статье из
первой строки в одной string-переменной, а цитаты — в string-массиве. Обратите
внимание на то, что это решение— необязательно наилучшее из возможных. Но
в целях демонстрации приложения с ошибками — это как раз то, что нужно!
Определение этого класса выглядит так.
#include <string>
using std::String;
class ArticleCitations
{
public:
ArticleCitations(const strings fileName);
-ArticleCitations();
ArticleCitations(const ArticleCitations& src);
ArticleCitations& operator=(
const ArticleCitations& rhs);
string getArticle() const { return mArticle,- }
int getNumCitations () const { return mNumCi tat ions,- }
string getCitation(int i) const {return mCitations[i];}
protected:
void readFile(const strings fileName);
string mArticle,-
string* mCitations;
int mNumCi tat ions,-
};
622 Часть IV. Как создать код без ошибок
Ниже приводится реализация методов этого класса. Помните: эта программа
содержит ошибки! Не используйте ее в исходном варианте или даже в качестве модели.
#include "ArticleCitations.h"
#include <iostream>
#include <fstream>
#include <string>
#include <stdexcept>
using namespace std,-
ArticleCitations::ArticleCitations(const strings fileName)
{
// Считываем содержимое файла.
readFile(fileName);
}
ArticleCitations::ArticleCitations(
const ArticleCitationsS src)
{
// Копируем название статьи, имя автора и так далее.
mArticle = src.mArticle;
// Копируем количество цитат.
mNumCitations = src.mNumCitations,-
// Создаем массив нужного размера.
mCitations = new string[mNumCitations];
// Копируем все элементы массива.
for (int i = 0; i < mNumCitations; i++) {
mCitations[i] = src.mCitations[i];
}
}
ArticleCitationsS ArticleCitations::operator=(
const ArticleCitationsS rhs)
{
// Проверяем на самоприсваивание.
if (this == &rhs) {
return (*this);
}
// Освобождаем старую область памяти.
delete [] mCitations;
// Копируем название статьи, имя автора и так далее.
mArticle = rhs. mArticle ,-
// Копируем количество цитат.
mNumCitations = rhs. mNumCitations,-
// Создаем массив нужного размера.
mCitations = new string[mNumCitations],-
// Копируем все цитаты.
for (int i = 0; i < mNumCitations; i++) {
mCitations[i] = rhs.mCitations[i] ;
}
return (*this);
}
ArticleCitations::-ArticleCitations О
{
delete [] mCitations,-
}
void ArticleCitations::readFile(const strings fileName)
{
// Открываем файл и проверяем, удалось ли нам это.
ifstream istr(fileName.c_str() ) ;
if (istr.fail()) {
throw invalid_argument("He удалось открыть файл.\п");
Глава 20. Что нужно знать об отладке 623
}
// Считываем строку с именем автора статьи,
// ее названием и пр.
getline(istr, mArticle);
// Пропускаем пробельный символ до начала списка цитат.
istr >> ws;
int count = 0;
// Сохраняем текущую позицию, чтобы можно было к ней
// вернуться.
int citationsStart = istr.tellg();
// Сначала подсчитаем количество цитат.
while (!istr.eof()) {
string temp;
getline(istr, temp);
// Пропускаем пробельный символ до следующего элемента.
istr >> ws;
count++;
}
if (count != 0) {
// Создаем массив string-элементов для сохранения цитат.
mCitations = new string[count];
mNumCitations = count;
// Возвращаемся к началу цитат.
istr.seekg(citationsStart);
// Считываем все цитаты и сохраняем их в новом массиве.
for (count = 0; count < mNumCitations; count++) {
string temp;
getline(istr, temp);
mCitations[count] = temp;
}
}
Тестирование класса Articledtations
Итак, следуя рекомендациям главы 19, мы решили (прежде чем двигаться дальше)
протестировать класс Art icleCitations, хотя для простоты в этом поэлементном
тесте не используется какая бы то ни было специальная оболочка тестирования. При
выполнении следующей программы пользователю предлагается ввести имя файла, на
основе которого создается объект класса Art icleCitations, после чего этот объект
передается по значению функции processCi tat ions (), которая с помощью
public-методов доступа к объекту выводит соответствующую информацию.
#include "ArticleCitations.h"
#include <iostream>
using namespace std;
void processCitations(ArticleCitations cit);
int main(int argc, char** argv)
{
string fileName;
while (true) {
cout << "Введите имя файла (\"СТ0П\" для завершения): ";
cin >> fileName;
if (fileName == "СТОП") {
break;
}
// Тестируем конструктор.
624 Часть IV. Как создать код без ошибок
ArticleCitations cit(fileName);
processCitations(cit);
}
return (0);
}
void processCitations(ArticleCitations cit)
{
cout << cit.getArticle() << endl;
int num = cit.getNumCitations();
for (int i = 0; i < num,- i++) {
cout << cit.getCitation(i) << endl;
}
}
Использование cout-отладки
Предположим, мы решили протестировать свою программу на примере статьи
Алана Тьюринга (Alan Turing), сохраненной в файле paper 1. txt. Вот как выглядит
результат тестирования.
Введите имя файла ("СТОП" для завершения): paperl.txt
Alan Turing."On Computable Numbers with an Application to the
Entscheidungsproblem", Proceedings of the London Mathematical Society, Series
2,
Vol.42 (1936 - 37) pages 230 to 265.
Введите имя файла ("СТОП" для завершения): СТОП
Первый результат— настоящий "блин комом"! Ведь мы предполагали получить
пять цитат, а не пять пустых строк.
Чтобы ликвидировать "комья", мы решили применить "народное" с out-средство.
В данном случае имеет смысл начать с функции, которая считывает из файла цитаты.
Если она работает неправильно, то, очевидно, объект не будет иметь цитат. Поэтому
модифицируем функцию readFile () таким образом.
void ArticleCitations::readFile(const strings fileName)
{
// Открываем файл и проверяем, удалось ли нам это.
ifstream istr(fileName.c_str());
if (istr.failO) {
throw invalid argument("He удалось открыть файл.\п");
}
// Считываем строку с именем автора статьи,
// ее названием и пр.
getline(istr, mArticle);
// Пропускаем пробельный символ до начала списка цитат.
istr >> WS;
int count = 0;
// Сохраняем текущую позицию, чтобы можно было к ней
// вернуться.
int citationsStart = istr.tellg();
// Сначала подсчитаем количество цитат.
cout << "Функция readFile(): подсчет числа цитат\п";
Глава 20. Что нужно знать об отладке 625
while (!istr.eof()) {
string temp;
getline(istr, temp);
// Пропускаем пробельный символ до следующего элемента.
istr >> ws,-
cout << "Цитата " << count << ": " << temp << endl;
count++;
}
cout << "Найдено " << count << " цитат\п" << endl;
cout << "Функция readFile(): считывание цитат\п" ,-
if (count != 0) {
// Создаем массив string-элементов для сохранения цитат.
mCitations = new string[count];
mNumCitations = count;
// Возвращаемся к началу цитат.
istr.seekg(citationsStart) ,-
// Считываем все цитаты и сохраняем их в новом массиве.
for (count = 0; count < mNumCitations; count++) {
string temp;
getline(istr, temp);
cout << temp << endl;
mCitations[count] = temp;
Выполнив этот тест для того же варианта программы, получаем такой результат.
Введите имя файла ("СТОП" для завершения): paperl.txt
Функция readFile(): подсчет числа цитат
Цитата 0: Godel, "Uber formal unentscheidbare Satze der
Principia Mathernatica und verwant der Systeme, I",
Monatshefte Math. Phys., 38 (1931). 173-198.
Цитата 1: Alonzo Church. "An unsolvable problem of
elementary number theory", American J of Math., 58(1936),
345 363.
Цитата 2: Alonzo Church. "A note on the Entscheidungsproblem",
J. of Symbolic logic, 1 (1930), 40 41.
Цитата 3: Cf. Hobson, "Theory of functions of a real variable
(2nd ed., 1921)", 87, 88.
Цитата 4: Proc. London Math. Soc (2) 42 (1936 7), 230 265.
Найдено 5 цитат
Функция readFile(): считывание цитат
Alan Turing,"On Computable Numbers with an Application to the
Entscheidungsproblem", Proceedings of the London Mathematical
Society, Series 2, Vol.42 (1936 - 37) pages 230 to 265.
Введите имя файла ("СТОП" для завершения):
626 Часть IV. Как создать код без ошибок
Как можно судить по полученному результату, в первый раз, когда программа
считывает цитаты из файла (чтобы подсчитать их количество), они считываются корректно.
Но во второй раз это происходит уже неправильно. В чем же дело? Чтобы выяснить
причину некорректности, добавим в наше "блюдо" еще немного отладочной "приправы"
и проверим состояние файлового потока после каждой попытки прочитать цитату.
void printStreamState(const istream& istr)
{
if (istr.good()) {
cout << "Состояние потока: в норме.\п";
}
if (istr.badO) {
cout << "Состояние потока: неудовлетворительное.\п";
}
if (istr.failO) {
cout << "Состояние потока: отказ.\п";
}
if (istr.eofO) {
cout << "Состояние потока: конец файла.\п";
}
}
void ArticleCitations::readFile(const stringb fileName)
{
// Открываем файл и проверяем, удалось ли нам это.
ifstream istr(fileName.c_str());
if (istr.failO) {
throw invalid_argument("He удалось открыть файл.\п");
}
// Считываем строку с именем автора статьи,
// ее названием и пр.
getline(istr, mArticle);
// Пропускаем пробельный символ до начала списка цитат.
istr >> ws;
int count = 0;
// Сохраняем текущую позицию, чтобы можно было к ней
// вернуться.
int citationsStart = istr.tellg();
// Сначала подсчитаем количество цитат.
cout << "Функция readFileO: подсчет числа цитат\п";
while (!istr.eof()) {
string temp;
getline(istr, temp);
// Пропускаем пробельный символ до следующего элемента.
istr >> ws,-
printStreamState(istr) ;
cout << "Цитата " << count << ": " << temp << endl;
count++;
}
cout << "Найдено " << count << " цитат\п" << endl;
cout << "Функция readFileO: считывание цитат\п";
•if (count != 0) {
// Создаем массив string-элементов для сохранения цитат
mCitations = new string[count];
mNumCitations = count;
// Возвращаемся к началу цитат.
istr.seekg(citationsStart);
// Считываем все цитаты и сохраняем их в новом массиве.
Глава 20. Что нужно знать об отладке 627
for (count = 0; count < mNumCitations; count++) {
string temp;
getline(istr, temp);
printStreamState(istr);
cout << temp << endl;
mCitations [count] = temp,-
}
}
}
При выполнении программы на этот раз мы получаем весьма интересную
информацию.
Введите имя файла ("СТОП" для завершения): paperl.txt
Функция readFile(): подсчет числа цитат
Состояние потока: в норме.
Цитата 0: Godel, "Uber formal unentscheidbare Satze der
Principia Mathernatica und verwant der Systeme, I",
Monatshefte Math. Phys., 38 (1931). 173-198.
Состояние потока: в норме.
Цитата 1: Alonzo Church. "An unsolvable problem of
elementary number theory", American J of Math., 58(1936),
345 363.
Состояние потока: в норме.
Цитата 2: Alonzo Church. "A note on the Entscheidungsproblem",
J. of Symbolic logic, 1 (1930), 40 41.
Состояние потока: в норме.
Цитата 3: Cf. Hobson, "Theory of functions of a real variable
(2nd ed., 1921)", 87, 88.
Состояние потока: конец файла.
Цитата 4: Ргос. London Math. Soc (2) 42 (1936 7), 230 265.
Найдено 5 цитат
Функция readFile(): считывание цитат
Состояние потока: отказ.
Состояние потока: конец файла.
Состояние потока: отказ.
Состояние потока: конец файла.
Состояние потока: отказ.
Состояние потока: конец файла.
Состояние потока: отказ.
Состояние потока: конец файла.
Состояние потока: отказ.
Состояние потока: конец файла.
628 Часть IV. Как создать код без ошибок
Alan Turing,"On Computable Numbers with an Application to the
Entscheidungsproblem", Proceedings of the London Mathematical
Society, Series 2, Vol.42 (1936 - 37) pages 230 to 265.
Введите имя файла ("СТОП" для завершения):
Создается такое впечатление, что состояние потока остается в норме до тех пор,
пока не будет считана в первый раз последняя цитата. Затем состояние потока
обозначает достижение конца файла, что вполне ожидаемо. Неожиданной же
оказывается ситуация после всех попыток считать цитаты во второй раз: в этом случае мы
видим поток попеременно то неработоспособным ("отказ"), то в состоянии "конец
файла". На первый взгляд такое поведение кажется необъяснимым: ведь для возврата
к началу цитат (до начала их считывания во второй раз) мы использовали функцию
seekg (), поэтому никакого "конца файла" не должно было бы быть. Но, как
упоминалось в главе 13, потоки поддерживают свои состояния ошибок и признак "конца
файла" до тех пор, пока не будут явным образом "сброшены", т.е. установлены
в исходное состояние. Функция seekg () не очищает признак конца файла
автоматически. После перехода в это состояние (или состояние ошибки) потоки
"отказываются" считывать данные корректно, чем и объясняется демонстрация
потоком состояния "отказа" при попытке считать цитаты во второй раз. Проанализировав
результаты cout-тестирования, мы понимаем, что упустили вызов метода clear () для
потока после достижения конца файла. Добавив обращение к методу clear (), мы
должны прочитать цитаты надлежащим образом.
Приведем корректный код метода readFile () без отладочных cout-инструкций.
void ArticleCitations::readFile(const string& fileName)
{
// КОД ОПУЩЕН РАДИ ЭКОНОМИИ МЕСТА.
if (count != 0) {
// Создаем массив string-элементов для сохранения цитат.
mCitations = new string[count];
mNumCitations = count;
// Очищаем предыдущий признак конца файла.
istr.clear();
// Возвращаемся к началу цитат.
istr.seekg(citationsStart);
// Считываем все цитаты и сохраняем их в новом массиве.
for (count = 0; count < mNumCitations,- count++) {
string temp;
getline(istr, temp);
mCitations[count] = temp;
Использование отладчика
В следующем примере используется отладчик gdb, предназначенный для работы под
управлением операционной системы Linux.
Глава 20. Что нужно знать об отладке 629
Теперь, когда класс Articled tat ions стал "вести себя" прилично при
обработке одного файла с цитатами, стоит устроить ему проверку "покруче" и посмотреть,
как он поведет себя в некоторых специальных случаях, например, "отведав" файл без
единой цитаты. Такой файл (назовем его paper2 . txt) может иметь следующий вид.
Автор без цитат
При выполнении нашей программы и с этим файлом получаем следующие результаты.
Введите имя файла ("СТОП" для завершения): paperl.txt
Alan Turing."On Computable Numbers with an Application to the
Entscheidungsproblem", Proceedings of the London Mathematical
Society, Series 2, Vol.42 (1936 - 37) pages 230 to 265.
Godel, "Uber formal unentscheidbare Satze der Principia
Mathernatica und verwant der Systeme, I", Monatshefte Math.
Phys.,38 (1931). 173-198.
Alonzo Church. "An unsolvable problem of elementary number
theory", American J of Math., 58(1936), 345 363.
Alonzo Church. "A note on the Entscheidungsproblem", J. of
Symbolic logic, 1 (1930), 40 41.
Cf. Hobson, "Theory of functions of a real variable (2nd ed.,
1921)", 87, 88.
Proc. London Math. Soc (2) 42 (1936 7), 230 265.
Введите имя файла ("СТОП" для завершения): paper2.txt
Автор без цитат
Segmentation fault
Ну, вот! Теперь что-то не в порядке с памятью. На этот раз предоставим
возможность проявить себя отладчику. В средах Unix широко применяется такой отладчик, как
Gnu DeBugger (gdb), который хорошо зарекомендовал себя. Прежде всего, мы должны
скомпилировать программу, используя информацию об отладчике (-д с д++). Затем
можно запускать программу под его (gdb) управлением. Приведем пример сеанса отладки.
>gdb buggyprogram
GNU gdb Red Hat Linux (5.2-2)
Copyright 2002 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public
License, and you are welcome to change it and/or distribute
copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB. Type "show warranty"
for details.
This GDB was configured as "ia64-redhat-linux"...
(gdb) run
Starting program: buggyprogram
Введите имя файла ("СТОП" для завершения): paperl.txt
Alan Turing."On Computable Numbers with an Application to the
Entscheidungsproblem", Proceedings of the London Mathematical
Society, Series 2, Vol.42 (1936 - 37) pages 230 to 265.
Godel, "Uber formal unentscheidbare Satze der Principia
Mathernatica und verwant der Systeme, I", Monatshefte Math.
Phys., 38 (1931). 173-198.
Alonzo Church. "An unsolvable problem of elementary number
theory", American J of Math., 58(1936), 345 363.
Alonzo Church. "A note on the Entscheidungsproblem", J. of
Symbolic logic, 1 (1930), 40 41.
Cf. Hobson, "Theory of functions of a real variable (2nd ed.,
1921)", 87, 88.
Proc. London Math. Soc (2) 42 (1936 7), 230 265.
Введите имя файла ("СТОП" для завершения): paper2.txt
Автор без цитат
Program received signal SIGSEGV, Segmentation fault.
630 Часть IV. Как создать код без ошибок
libc_free (mem=0x6000000000010320) at malloc.c:3143
3143 malloc.c: No such file or directory.
in malloc.c
Current language: auto,- currently с
При наличии сигнала SEGV отладчик позволяет узнать состояние программы на
текущий момент. По команде bt можно получить "снимок" стека, а с помощью команд
up и down — просмотреть вызовы функций, помещенные в стек.
(gdb) bt
#0 libc_free (mem=0x6000000000010320) at malloc.с:3143
#1 0x2000000000089010 in builtindelete ()
from /usr/lib/libstdc++-libc6.2-2.so.3
#2 0x2000000000089050 in builtin_vec_delete ()
from /usr/lib/libstdc++-libc6.2-2.so.3
#3 0x400000000000a820 in ArticleCitations::-ArticleCitations
(this=0x80000fffffffb920, in_chrg=2) at ArticleCitations.
cpp:51
#4 0x4000000000004f40 in main (argc=l,
argv=0x80000fffffffb968) at BuggyProgram.cpp:20
На "снимке" видно, что оператор delete вызывает функцию free (). Нет ничего
необычного в том, что операторы new и delete реализованы на основе функций
malloc () и free (). Более важно то, что данный "снимок" позволяет заметить
проблему в деструкторе класса ArticleCitations. С помощью команды list можно
получить код, содержащийся в текущем стековом фрейме, т.е. области памяти,
выделяемой при каждом вызове функции.
(gdb) up 3
#3 0х400000000000а820 in ArticleCitations::-ArticleCitations (
this=0x80000fffffffb920, in_chrg=2) at ArticleCitations.cpp:51
51 delete [] mCitations;
Current language: auto; currently C++
(gdb) list
46 return (*this);
47 }
48
49 ArticleCitations::-ArticleCitations()
50 {
51 delete [] mCitations;
52 }
53
54 void ArticleCitations::readFile(const string& fileName)
{
Деструктор содержит один-единственный вызов оператора delete []. В
отладчике gdb с помощью команды print можно распечатать значения, которые доступны
в текущей области видимости. Чтобы понять причину проблемы, нужно взглянуть
на значения переменных, которые являются членами объекта. Вспомните, что тип
string в C++ на самом деле представляет собой typedef-реализацию шаблона
basic_string для типа char.
(gdb) print mCitations
$3 = (
basic_string<char,string_char_traits<char>,
default_alloc_template<true, 0> >
*) 0x6000000000010338
Так, указатель mCitations выглядит вполне "прилично", хотя...
(gdb) print mNumCitations
$2 = 5
Глава 20. Что нужно знать об отладке 631
Ага! Вот мы тебя и поймали! Ведь статья-то не содержит ни одной цитаты. Почему
же тогда значение mNumCitations равно 5? Еще раз просмотрим код метода read-
File () для ситуации, когда полностью отсутствуют цитаты. Что мы видим: никто не
позаботился об инициализации членов mNumCitations и mCitations! В результате
эти области памяти сохраняют "отпечатки прошлой жизни", а попросту говоря,
"мусор". В данном случае "прошлая жизнь" объекта класса ArticleCitations
оставила в переменной mNumCitations значение 5. Второй же ArticleCitations-
объект использует те же ячейки памяти и поэтому "наследует" сохранившиеся в них
значения. Но если указатель получает случайное значение, то, безусловно, его вряд ли
можно считать действительным! Поэтому, найдем мы в файле цитаты или нет,
необходимо прежде инициализировать члены mCitations и mNumCitations. Теперь нам
осталось исправить эту ошибку.
void ArticleCitations:zreadFile(const string& fileName)
{
// КОД ОПУЩЕН РАДИ ЭКОНОМИИ МЕСТА.
mCitations = NULL;
mNumCitations = 0;
if (count != 0) {
// Создаем массив string-элементов для сохранения цитат.
mCitations = new string[count];
mNumCitations = count;
// Очищаем предыдущий признак конца файла.
istr.clear();
// Возвращаемся к началу цитат.
istr.seekg(citationsStart);
// Считываем все цитаты и сохраняем их в новом массиве.
for (count = 0; count < mNumCitations; count++) {
string temp;
getline(istr, temp);
mCitations[count] = temp;
}
}
}
Этот пример показывает, что ошибки, связанные с памятью, как правило,
проявляются не сразу. Часто их удается "вычислить", лишь проявив определенную
настойчивость (причем не без помощи отладчика).
Если вы попытаетесь воспроизвести этот сеанс отладки на другой платформе, то
не исключено, что программа откажет в другом месте (а мы ведь вас предупреждали:
что касается ошибок в управлении памятью, то их "пути" уж точно "неисповедимы").
Делаем выводы
Возможно, этот пример вам покажется слишком незначительным и не
заслуживающим того, чтобы демонстрировать возможности реальной отладки. Несмотря на то что
код нашего демонстрационного класса не поражает размерами, надо сказать, что
реальные классы (даже в больших проектах) необязательно должны быть огромными.
Главное здесь не объем, а то, что данный пример подтверждает мысль из главы 19 о
важности поэлементного тестирования. Только представьте себе, что вы (по какой-то
причине) не протестировали этот класс со всей тщательностью (т.е. для всех
возможных случаев исходных данных) до объединения с остальной частью общего проекта.
632 Часть IV. Как создать код без ошибок
Если бы эти ошибки "вылезли" позже, вам и другим программистам пришлось бы
затратить гораздо больше времени на поиски причины проблемы. И потом, показанные
здесь методы применимы к отладке любых проектов: как больших, так и не очень.
Резюме
Основная идея этой главы выражается главным законом отладки: избегайте
ошибок при написании кода, но приготовьтесь к тому, что они будут в вашей программе.
Реальность программирования такова, что (хочешь того или нет) ошибки все равно
найдут пристанище в вашем коде. Если вы заранее подготовитесь к борьбе с
ошибками, т.е. оснастите свою программу средствами регистрации ошибок (в системном
журнале), трассировки (с помощью кольцевого буфера) и assert-макросами, то
процесс реальной отладки пройдет гораздо легче (чем без такой "артподготовки").
Кроме того, вы узнали, что (помимо традиционных) существуют и специальные
методы отладки программ. Самое основное правило в реальной отладке — уметь
воспроизводить ошибку. В этом случае для выяснения причины проблемы можно
использовать cout-инструкции или какой-нибудь символический отладчик. Ошибки,
связанные с управлением памятью, можно объединить в отдельный класс, причем
своим особым положением они обязаны как трудностям их отладки, так и
многочисленностью (именно ошибки этого класса составляют большинство ошибок в С++-
коде). И именно поэтому мы описали различные категории ошибок, относящихся
к управлению памятью, и их симптомы, а также показали несколько примеров отладки.
Часть V
Использование библиотек
и шаблонов
в этой части...
Глава 21. Библиотека STL: контейнеры и итераторы
Глава 22. Освоение STL-алгоритмов и функциональных
объектов
Глава 23. Использование и расширение возможностей STL
Глава 24. Исследование распределенных объектов
Глава 25. Объединим возможности технологий и оболочек
Глава 26. Применение шаблонов проектирования
Библиотека STL:
контейнеры
и итераторы
Представляете, есть программисты, утверждающие, что знают C++, но при этом
они никогда не слышали о стандартной библиотеке шаблонов (standard template
library — STL)! С++-программиста нельзя считать профессионалом, если он не владеет
таким мощным инструментарием. Ведь вы экономите много времени и энергии,
включая STL-контейнеры и алгоритмы в свои программы. Теперь, когда вы
прочитали главы 1—20 и можете считать себя экспертом в разработке, кодировании,
тестировании и отладке С++-программ, самое время освоить библиотеку STL.
В главе 4 вы познакомились с библиотекой STL, основными принципами ее
построения и составом. При необходимости перечитайте разделы, в которых кратко
описаны контейнеры и алгоритмы. Для успешного освоения материала этой главы
вам также понадобится информация, изложенная в главах 11 (использование
шаблонов) и 16 (перегрузка С++-операторов).
Эта глава представляет собой первую часть обзора STL-контейнеров, имеющую
следующую структуру:
□ обзор контейнеров: требования к элементам, общие принципы обработки
ошибок и итераторы;
□ последовательные контейнеры: vector, deque и list;
Глава 21. Библиотека STL: контейнеры и итераторы 635
□ контейнеры-адаптеры: queue, priority_queue и stack.
□ ассоциативные контейнеры: pair, map, multimap, set и multiset;
□ другие контейнеры: массивы, строки (string-контейнеры), потоки и класс bitset.
Глава 22 (вторая часть STL-обзора) содержит примеры обобщенных алгоритмов, для
которых можно использовать контейнерные элементы. Здесь также описаны
предопределенные STL-классы функциональных объектов и показано, как их можно эффективно
использовать с определенными алгоритмами в качестве функций обратного вызова.
Наконец, в главу 23 вошли некоторые наиболее интересные аспекты STL-
программирования с акцентом на модификации и расширении этой библиотеки. Здесь
вы узнаете о применении и написании распределителей, использовании итераторов-
адаптеров, а также создании собственных алгоритмов, контейнеров и итераторов.
Несмотря на всю глубину материала, представленного в этой и следующих двух
главах, стандартная библиотека шаблонов слишком велика, чтобы эту тему можно
было исчерпать в рамках данной книги. Дополнительную информацию можно
почерпнуть из Web-ресурсов. В таком документе, как Standard Library Header Files, описаны
все заголовочные файлы стандартной библиотеки, а документ Standard Library
Reference представляет собой справочник по различным классам и алгоритмам STL.
Помните, что при всем своем желании мы не смогли в трех главах упомянуть все
методы и члены многочисленных STL-классов, прототипы всех STL-алгоритмов. При
необходимости обратитесь к дополнительной литературе (см. приложение Б).
Обзор контейнеров
Как упоминалось в главе 4, STL-контейнеры — это обобщенные структуры данных,
предназначенные для хранения коллекций данных. Имея в руках такой инструмент,
вам нет смысла самим писать связанный список или проектировать стек. Все
библиотечные контейнеры реализованы как шаблоны, которые позволяют использовать их
для любого типа, отвечающего определенным условиям, описанным ниже.
Библиотека STL включает 11 контейнеров, разделяемых на четыре категории.
К последовательным контейнерам относятся vector (динамический массив), list (список)
и deque (очередь с двусторонним доступом). В группу ассоциативных контейнеров
входят тар (отображение), multimap (мультиотображение), set (множество) и multiset
(мультимножество). Категорию контейнеров-адаптеров составляют queue (очередь),
priority_queue (очередь по приоритету) и stack (стек). Наконец, в последнюю
группу входит класс bitset (битовое множество), С-массивы, С++-строки (класс
string) и потоки, которые в определенном смысле можно использовать в качестве
STL-контейнеров.
Нет однозначного мнения насчет того, какие контейнеры в языке
C++ следует квалифицировать как часть STL. Одни считают, что на
"звание" STL-контейнеров имеют право претендовать только
последовательные и ассоциативные, другие распространяют это право и
на С++-строки, но уж никак не на битовые множества. Мы же
придерживаемся более широких взглядов на этот счет.
636 Часть V. Использование библиотек и шаблонов
На собственном опыте мы убедились, что контейнеры — это самая ценная часть
библиотеки STL (хотя некоторые приверженцы С++-теории сочли бы подобное
заявление ересью). Если вы не располагаете свободным временем или желанием для
изучения библиотеки STL во всех ее деталях, то поближе познакомьтесь по крайней мере
с контейнерами. Справившись с некоторыми синтаксическими трудностями, вы
поймете, что контейнеры довольно просты для применения, более того, освоив их на
практике, вы сэкономите на отладке массу времени, получив в руки уже готовые средства.
Вся библиотека STL определена в пространстве имен std. Практически во всех
примерах этой книги используется инструкция using namespace std;, но в своих
программах вы вольны сами решать, какие символы выбирать из std.
Требования к элементной базе
В STL-контейнерах для хранения элементов используется семантика значений
(семантика передачи объектов по значению). Другими словами, при "сдаче на
хранение" контейнер получает копию элемента, и по запросу на извлечение также
возвращает копию элемента. Присвоение элементов реализуется с помощью оператора
присваивания, а их разрушение происходит с использованием деструктора. Поэтому при
создании классов, в которых предполагается использование STL-средств,
позаботьтесь о том, чтобы с наличием многочисленных копий объектов одновременно у вас
в программе проблем не было.
Если вы предпочитаете использовать семантику ссылок (семантику передачи
объектов по ссылке), то вам необходимо реализовать этот вариант самим, т.е. сохраняя
в STL-контейнерах не сами объекты, а указатели на них. В этом случае при
копировании контейнерами указателя результат будет ссылаться на тот же самый элемент.
Если в контейнерах вы хотите сохранять указатели, то для того,
чтобы корректно управлять памятью, мы рекомендуем использовать
интеллектуальные указатели, работающие на основе подсчета числа
ссылок. При этом мы не можем использовать в контейнерах С++-
класс autoptr, поскольку копирование объектов в нем реализовано
некорректно. В STL-контейнерах используйте с этой целью класс
SuperSmartPointer (см. главу 25).
Конкретные требования к элементам в контейнерах перечислены в следующей
таблице.
Метод Описание Примечание
Конструктор Создает новый элемент, "равный" Используется при каждой вставке
копии старому, но его разрушение не окажет элемента в контейнер
вредного влияния на старый элемент
Оператор Заменяет содержимое элемента Используется при каждой
присваивания копией исходного элемента модификации элемента
Деструктор Разрушает элемент Используется при каждом удалении
элемента
Конструктор Создает элемент без аргументов Требуется только для определенных
по умолчанию операций (например, при выполнении
vector-метода resize () и метода
map-доступа operator [] )
Глава 21. Библиотека STL: контейнеры и итераторы 637
Окончание таблицы
Метод Описание Примечание
operator== Сравнивает два элемента на Требуется только для определенных
равенство операций (например, при выполнении
метода operator== для двух
контейнеров)
operators Определяет, меньше ли один Требуется только для определенных
элемент другого операций (например, при выполнении
метода operators для двух
контейнеров). Метод operators
также используется по умолчанию
при сравнении ключей в
ассоциативных контейнерах
Подробнее об упомянутых в таблице методах можно прочитать в главах 9 и 16.
STL-контейнеры довольно часто вызывают конструктор копии и
оператор присваивания для своих элементов, поэтому эти операции
должны быть реализованы достаточно эффективно.
Исключения и контроль за ошибками
В STL-контейнерах реализован ограниченный контроль за ошибками. Так, методы
и функции некоторых контейнеров генерируют исключения при некоторых условиях,
например при выходе индексов за установленные границы. В таком Web-pecypce, как
Standard Library Reference, каталогизированы возможные исключения, генерируемые
каждым методом библиотеки. Однако этот список нельзя считать исчерпывающим,
поскольку методы могут выполнять операции над объектами, тип которых определяется
пользователем, а в этом случае характеристики исключений заранее не известны.
Итераторы
Как упоминалось в главе 4, в библиотеке STL для доступа к элементам контейнера
в качестве посредника используется обобщенная абстракция, именуемая итератором.
Каждый контейнер поддерживает "свой" вид итератора, который представляет собой
"модернизированный" интеллектуальный указатель, "знающий", как получить доступ
к элементам данного конкретного контейнера. Итераторы для различных
контейнеров используют интерфейс, определенный в стандарте C++. Таким образом, несмотря
на различие в назначении контейнеров, итераторы представляют общий интерфейс,
обеспечивающий возможность работы с элементами контейнеров.
Итератор можно представить себе как указатель на конкретный элемент контейнера.
Подобно указателям на элементы массива, итераторы могут перемещаться к следующему
элементу с помощью оператора operator++. Аналогично для доступа к реальному
элементу или его полю можно использовать операторы operator* и operator->.
Некоторые итераторы позволяют сравнивать элементы контейнера с помощью
операторов operator== и operator != и поддерживают оператор operator— для перехода
к предыдущему элементу. В разных контейнерах используются итераторы, которые
обладают различными свойствами. Стандарт C++ определяет пять категорий
итераторов, описанных в следующей таблице.
638 Часть V. Использование библиотек и шаблонов
Категория
итераторов
Поддерживаемые
операции
Комментарии
Входные
Выходные
operator++,
operator*,
operator->,
конструктор копии,
operator=,
operator==,
operator!=
operator++,
operator*,
конструктор копии
Однонаправленные operator++,
operator*,
operator->,
конструктор копии,
конструктор по умолчанию,
operator=,
operator==,
operator!=
Двунаправленные
Итераторы
произвольного
доступа
Операции,
перечисленные для
однонаправленных
итераторов, а также
operator--
Операции,
перечисленные для
двунаправленных
итераторов, а также
operator+,
operator-,
operator+=,
operator-=,
operator<,
operator>,
operator<=,
operator>=,
operator []
Обеспечивают доступ только для чтения
в одном направлении (оператор operator--
для перехода в обратном направлении
не определен).
Итераторы позволяют выполнять присваивание
и копирование с помощью оператора
присваивания и конструктора копии.
Их можно сравнивать на равенство
Обеспечивают доступ только для записи
в одном направлении.
Итераторы не позволяют выполнять
присваивание и копирование.
Их нельзя сравнивать на равенство.
Обратите внимание на отсутствие оператора
operator->
Обеспечивают доступ для чтения и записи
в одном направлении.
Итераторы позволяют выполнять присваивание
и копирование с помощью оператора
присваивания и конструктора копии.
Их можно сравнивать на равенство
Поддерживают все функции, описанные для
однонаправленных итераторов.
Кроме того, они позволяют переходить
к предыдущему элементу
Эквивалентны неинтеллектуальным
указателям: поддерживают арифметику
указателей, синтаксис индексации массивов
и все формы сравнения
Все стандартные контейнеры поддерживают либо двунаправленные итераторы,
либо итераторы произвольного доступа. Итераторы реализованы подобно классам
интеллектуальных указателей, в которых они перегружают нужные операторы.
(Перегрузка операторов подробно описана в главе 16.) Пример реализации
итератора приведен в главе 23.
Основные итераторные операции аналогичны операциям, поддерживаемым
неинтеллектуальными (обычными) указателями, поэтому можно сказать, что
неинтеллектуальный указатель — это легитимный итератор для определенных контейнеров.
Глава 21. Библиотека STL: контейнеры и итераторы 639
И в самом деле, итератор для контейнера vector часто реализуется как обычный
указатель. Но вам (как пользователям контейнеров) не нужно беспокоиться о деталях
реализации; используйте итераторную абстракцию как готовое средство.
Внутренне итераторы могут быть реализованы не как указатели, поэтому при
рассмотрении доступа к элементам контейнера через итераторы лучше употреблять
словосочетание "ссылаться на", а не "указывать на".
Более подробно об итераторах и STL-алгоритмах мы поговорим в главах 22 и 23,
а в этой главе мы продемонстрируем возможности использования итераторов для
разных контейнеров.
Итераторы поддерживаются только последовательными и
ассоциативными контейнерами. Для контейнерных адаптеров и побитового
отображения (bitmap) это неприемлемо.
Общие для контейнеров методы и typedef-определения
с участием итераторов
Каждый контейнерный STL-класс, который поддерживает итераторы, позволяет
использовать открытые typedef-определения для своих итераторных типов: iterator
и const_iterator. Поэтому пользователи этих контейнеров могут применять
контейнерные итераторы, не беспокоясь об их реальном типе.
Итераторы типа cons t_it era tor обеспечивают к элементам
контейнера доступ только для чтения.
Контейнеры также поддерживают метод begin (), который возвращает итератор,
ссылающийся на первый элемент контейнера. Метод end () возвращает ссылку на
значение, расположенное "за последней чертой" данной последовательности элементов
контейнера. Другими словами, метод end () возвращает итератор, который равен
результату применения оператора operator++ к итератору, ссылающемуся на последний
элемент в данной последовательности. Вместе методы begin () и end () образуют
полуоткрытый диапазон, который включает первый элемент последовательности, но не
последний. Причина такого нюанса — в поддержке пустых диапазонов (т.е. контейнеров,
не содержащих элементов), для которых результат вызова метода begin () совпадал бы
с результатом вызова метода end (). Полуоткрытый диапазон, ограниченный
итераторами start и end, математически записывается так: [start, end).
Принцип полуоткрытого диапазона также применяется к
совокупности итераторов, которые передаются таким контейнерным методам,
как insert О и erase О.
Последовательные контейнеры
Такие контейнеры, как vector, deque и list, называются последовательными
контейнерами, поскольку они сохраняют элементы в видимом для клиента порядке.
Лучший способ познакомиться с последовательными контейнерами— сразу же
перейти к примеру с вектором, который используется, пожалуй, чаще других. Мы также
уделим внимание очереди с двусторонним доступом (deque) и списку (list).
640 Часть V. Использование библиотек и шаблонов
Вектор
Как упоминалось в главе 4, STL-контейнер vector подобен массиву: его элементы
хранятся в смежных ячейках памяти. При доступе к элементам вектора можно
применять индексацию, новые элементы можно добавлять в конец вектора или вставлять
в любое место. Время, затрачиваемое на выполнение операций вставки и удаления
элементов из вектора, обычно находится в линейной зависимости от его длины, хотя
в действительности эти операции включают некоторую постоянную составляющую
(подробности приведены в разделе "Схема распределения памяти для вектора").
Произвольный доступ к отдельным элементам .сопряжен с определенной сложностью.
Описание контейнера vector
Вектор определен в заголовочном файле <vector> как шаблон класса с двумя
параметрами-типами: типом элемента, подлежащего хранению в векторе, и типом
распределителя памяти.
template <typename T, typename Allocator =
allocator<T> > class vector;
Параметром Allocator задается тип объекта распределителя памяти, который
клиент может установить, чтобы использовать желаемый способ выделения памяти.
Для этого шаблонного параметра предусмотрено значение по умолчанию, которое
использует параметр-тип (элемента) Т. (Подробнее о шаблонных параметрах
рассказывается в главе 11.)
Значение, действующее по умолчанию для параметра-типа Allocator,
вполне подходит для большинства приложений. Программисты
обычно не считают нужным изменять распределитель памяти, но
если вас это интересует, обратитесь к главе 23, в которой такая
возможность описана более подробно. В этой главе предполагается
использование распределителя, заданного по умолчанию.
Векторы фиксированной длины
Самый простой способ использования контейнера vector — в качестве массива
фиксированной длины. В классе vector определен конструктор, который позволяет
задавать количество элементов, а также перегруженный метод operator [], с
помощью которого можно получать доступ к элементам и модифицировать их.
Подобно индексированию "настоящего" массива, в векторном
методе operator [] также не предусмотрена проверка на отсутствие
нарушения границ контейнера.
Например, рассмотрим небольшую программу по "нормализации" тестовых
оценок таким образом, чтобы наивысшая оценка была приведена к числу 100, а все
остальные получили соответствующие значения. При выполнении программы
создается вектор для хранения 10 double-значений, считывается 10 значений (вводимых
пользователем), каждое значение делится на максимальную оценку (разделенную
предварительно на 100), после чего выводятся новые значения. Чтобы не усложнять
код, в программе не реализована проверка ошибок.
Глава 21. Библиотека STL: контейнеры и итераторы 641
#include <vector>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
vector<double> doubleVector(10); // Создаем вектор для
// хранения 10 double-значений,
double max;
int i ;
for (i = 0; i < 10; i++) {
doubleVector[i] =0;
}
// Считываем первую оценку до входа в цикл, чтобы
// инициализировать переменную max.
cout << "Введите оценку 1: ";
cin >> doubleVector[0] ;
max = doubleVector[0];
for (i = 1; i < 10; i++) {
cout << "Введите оценку " << i + 1 « " : ";
cin >> doubleVector[i];
if (doubleVector[i] > max) {
max = doubleVector[i];
max /= 100;
for (i = 0; i < 10; i++) {
doubleVector[i] /= max;
cout << doubleVector[i] << " ";
}
cout « endl;
return (0);
}
Как видно из этого примера, вектор можно использовать как обычный массив.
Операторный метод вектора operatorП обычно возвращает ссылку
на элемент, который можно использовать слева от оператора
присваивания. Бели operator [] вызывается для const-объекта вектора, то он
возвращает ссылку на const-элемент, который нельзя использовать
в качестве приемника присваивания* (Подробности описаны в главе 16.)
Задание начального значения для элементов вектора
При создании вектора можно задать начальное значение для его элементов.
#include <vector>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
vector<double> doubleVector(10, 0); // Создаем вектор для
// хранения 10 элементов, равных 0.
double max;
int i;
642 Часть V. Использование библиотек и шаблонов
// Теперь нам не нужно инициализировать каждый элемент:
// вектор сделал это за нас.
// Считываем первую оценку до входа в цикл, чтобы
// инициализировать переменную max.
cout << "Введите оценку 1: ";
cin >> doubleVector[0] ;
max = doubleVector[0] ;
for (i = 1; i < 10; i++) {
cout « "Введите оценку " << i + 1 << ": ";
cin >> doubleVector[i] ;
if (doubleVector[i] > max) {
max = doubleVector[i] ;
}
}
max /= 100;
for (i = 0; i < 10; i++) {
doubleVector[i] /= max;
cout << doubleVector[i] << " ";
}
cout « endl;
return (0);
}
Другие методы доступа к элементам вектора
Помимо использования метода operator [], доступ к элементам вектора можно
получить с помощью методов at (), front () и back (). Метод at () идентичен методу
operator [], за исключением того, что он выполняет граничную проверку и генерирует
исключение outof range, если индекс выходит за пределы вектора. Методы front ()
и back () возвращают ссылки на первый и последний элементы вектора соответственно.
Доступ к элементам вектора не зависит от их позиции.
Векторы с динамически изменяемой длиной
Настоящая сила контейнера vector скрыта в его способности динамически
увеличивать свою длину. Например, рассмотрим программу нормализации тестовых оценок
из предыдущего раздела с дополнительным требованием: она (программа) должна
обрабатывать любое количество тестовых оценок. Вот как выглядит ее новая версия.
int main(int argc, char** argv)
{
vector<double> doubleVector; // Создаем вектор с нулевым
// количеством элементов.
double max, temp;
size_t i;
t
// Считываем первую оценку до входа в цикл, чтобы
// инициализировать переменную max.
cout << "Введите оценку 1: ";
cm >> max;
doubleVector.push_back(max);
Глава 21. Библиотека STL: контейнеры и итераторы 643
for (i = 1; true; i++) {
cout « "Введите оценку " « i + 1
<< " (-1 для останова): ";
cin >> temp;
if (temp == -1) {
break,-
}
doubleVector.push_back(temp);
if (temp > max) {
max = temp;
}
■ }
max /= 100;
for (i = 0; i < doubleVector.size(); i++) {
doubleVector[i] /= max;
cout << doubleVector[i] << " ";
}
cout << endl;
return (0);
}
В этой версии программы для создания вектора с нулевым числом элементов
используется конструктор по умолчанию. По мере считывания каждая оценка
добавляется в вектор с помощью метода push_back (), который сам заботится о выделении
места для нового элемента. Обратите внимание на то, что в последнем цикле for
используется метод size (), который предназначен для определения количества
элементов в контейнере. Метод size () возвращает целочисленное значение без знака,
поэтому в целях совместимости тип int переменной i (в предыдущей версии) был
заменен типом size_t.
О векторе подробнее
Теперь, когда вы, надо полагать, почувствовали силу векторов, можно рассмотреть
некоторые детали.
Конструкторы и деструкторы
Конструктор по умолчанию создает вектор с нулевым количеством элементов.
#include <vector>
using namespace std;
int main(int argc, char** argv)
{
vector<int> intVector,- // Создается вектор нулевой длины
// для хранения int-значений.
return (0);
}
Как уже было показано, при создании вектора можно задать количество хранимых
в нем элементов и (необязательно) их "общее" значение.
#include <vector>
using namespace std;
644 Часть V. Использование библиотек и шаблонов
int main(int argc, char** argv)
{
, vector<int> intVector(10, 100); // Создается вектор для
// хранения 10 элементов типа int,
// значение которых равно 100.
return (0);
}
Если опустить значение, устанавливаемое для элементов вектора по умолчанию,
новые объекты будут подвергнуты 1гуль-инициализации. Как описано в главе 11, при нуль-
инициализации объекты создаются с помощью конструктора по умолчанию, при этом
элементы контейнера типа int и double инициализируются нулевыми значениями.
Без проблем можно создать вектор, элементами которого будут объекты
встроенных классов.
#include <vector>
#include <string>
using namespace std;
int main(int argc, char** argv)
{
vector<string> stringVector(10, "Привет!");
return (0);
}
Наконец, можно создать вектор, элементами которого будут объекты класса,
определенного пользователем.
#include <vector>
using namespace std;
class Element
{
public:
Element() {}
-Element() {}
};
int main(int argc, char** argv)
{
vector<Element> elementVector;
return (0);
}
В контейнере vector хранятся копии объектов, поэтому при вызове его
деструктора вызывается деструктор каждого из объектов вектора.
Векторы можно также размещать в области памяти "кучи".
#include <vector>
using namespace std;
class Element
Глава 21. Библиотека STL: контейнеры и итераторы 645
{
public:
Element() {}
-Element() { }
};
int main(int argc, char** argv)
{
vector<Element>* elementVector = new vector<Element>(10);
delete elementVector,-
return (0) ,-
}
Завершая работу с вектором, который создавался с использованием оператора
new, не забудьте вызвать оператор delete!
Для освобождения векторов используйте оператор delete, а не
delete []. Несмотря на то что вектор реализован как массив, в
действительности удаляется объект вектора. Свой базовый массив
вектор обработает сам.
Копирование и присваивание векторов
Конструктор копии и оператор присваивания, определенные в классе vector,
создают детальные копии всех элементов вектора. Поэтому, чтобы обеспечить
эффективность программирования, функциям и методам векторы следует передавать по
ссылке. За детальным описанием функций, которые принимают в качестве
параметров шаблонные реализации, обратитесь к главе 11.
Помимо обычных средств копирования и присваивания, в векторе предусмотрен
метод assign (), который позволяет удалить все текущие элементы и добавить любое
количество новых. Этот метод полезен при многократном использовании вектора.
Вот тривиальный пример.
vector<int> intVector(10, 0) ;
// Некоторый код. . .
intVector.assign(5, 100);
В классе vector также определен метод swap (), который позволяет двум
векторам обменяться содержимым. Рассмотрим простой пример.
vector<int> vectorOne(10, 0);
vector<int> vectorTwo(5, 100);
vectorOne.swap(vectorTwo);
// Вектор vectorOne теперь содержит 5 элементов со
// значением 100.
// Вектор vectorOne теперь содержит 10 элементов со
// значением 0.
Сравнение векторов
Библиотека STL определяет шесть перегруженных операторов сравнения для
векторов: ==, !=, <, >, <= и >=. Два вектора считаются равными, если они содержат
одинаковое количество элементов, и все соответствующие элементы в двух векторах
646 Часть V. Использование библиотек и шаблонов
равны между собой. Считается, что один вектор "меньше" другого, если все
элементы, расположенные на позициях 0-(г— 1) в первом векторе, равны элементам,
расположенным на позициях O-(ir-l) во втором, но г-й элемент первого вектора меньше ьто
элемента второго вектора.
Сравнение двух векторов с помощью операторов operators- или
operator != требует, чтобы отдельные элементы были сравнимы
с помощью оператора operator==. Для сравнения двух векторов
с помощью операторов operator<, operator>, operator<= или
operator;»» необходимо, чтобы отдельные элементы можно было
сравнивать с помощью оператора opera tor<. Если вы собираетесь
хранить в векторе объекты класса, определенного пользователем,
следует позаботиться о написании этих операторов.
Рассмотрим пример простой программы сравнения векторов, предназначенных
для хранения элементов типа int.
#include <vector>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
vector<int> vectorOne(10, 0) ;
vector<int> vectorTwo(10, 0) ;
if (vectorOne == vectorTwo) {
cout << "Векторы равны!\n";
} else {
cout « "Векторы не равны!\п";
}
vectorOne[3] = 50;
if (vectorOne < vectorTwo) {
cout << "Вектор vectorOne меньше вектора vectorTwo. \n" ,-
} else {
cout <<"Вектор vectorOne не меньше вектора vectorTwo.\n";
}
return (0);
} .
Вот как выглядят результаты выполнения этой программы. »
Векторы равны!
Вектор vectorOne не меньше вектора vectorTwo.
Итераторы для класса vector
В разделе "Итераторы" в начале этой главы разъяснялись основы контейнерных
итераторов. Теперь от теории можно перейти к практике и рассмотреть некоторые
полезные примеры с их использованием. Ниже представлена уже знакомая вам
программа нормализации тестовых оценок, в которой цикл for с методом size ()
заменен циклом for с итераторами.
#include <vector>
#include <iostream> '
using namespace std;
Глава 21* Библиотека STL: контейнеры и итераторы 647
int main(int argc, char** argv)
{
vector<double> doubleVector,-
double max, temp;
int i ;
// Считываем первую оценку до входа в цикл, чтобы
// инициализировать переменную max.
cout « "Введите оценку 1: ";
cin >> max;
doubleVector.push_back(max);
for (i = 1; true; i++) {
cout << "Введите оценку " << i + 1
<< " (-1 для останова) : ",-
cin >> temp,-
if (temp == -1) {
break;
}
doubleVector.pushback(temp);
if (temp > max) {
max = temp;
}
}
max /= 100;
for (vector<double>::iterator it = doubleVector.begin();
it != doubleVector.end(); ++it) {
*it /= max;
cout << *it << " ";
}
cout << endl;
return (0);
}
Циклы for с итераторами, подобные представленному в этом примере, очень
распространены в STL-коде. Прежде всего, рассмотрим инструкцию инициализации
этого f ог-цикла:
vector<double>::iterator it = doubleVector.begin();
Вспомните, что в каждом контейнере для представления итераторов,
соответствующих контейнерам данного типа, определяется "свой" тип iterator. Метод begin ()
возвращает итератор этого типа, ссылающийся на первый элемент в контейнере.
Таким образом, при выполнении этой инструкции инициализации объявляется
переменная it, которой тут же присваивается значение итератора, ссылающегося на
первый элемент вектора doubleVector. Теперь рассмотрим инструкцию сравнения,
выполняемую в этом цикле for.
it != doubleVector.end();
При выполнении этой инструкции просто проверяется, не находится ли итератор
за концом последовательности элементов, содержащихся в векторе. По достижении
итератором этой позиции цикл прекращается. При выполнении инструкции
инкремента, ++it, значение итератора изменяется так, чтобы он ссылался на следующий
элемент вектора.
648 Чаеть V. Использование библиотек и шаблонов
По возможности используйте префиксную, а не постфиксную форму
оператора инкремента итератора, поскольку префиксная форма
обычно более эффективна. Выражение it++ должно возвращать
новый итераторный объект, в то время как выражение ++it может
возвращать лишь ссылку на него. Детали реализации оператора
operator* + приведены в главе 16, а подробности написания
итераторов — в главе 23. ~
Тело цикла for состоит всего из этих двух строк:
*it /= max;
cout << *it << " ";
Как видите, используя итераторы, можно как получать доступ к элементам вектора,
так и модифицировать их. В первой строке кода с помощью оператора " *"
выполняется разыменование итератора it, чтобы получить сам элемент, на который он
ссылается, и присвоить этому элементу нужное значение. Во второй строке кода
итератор снова подвергается разыменованию, но на этот раз только для того, чтобы
направить элемент в выходной поток cout.
Доступ к полям элементов векторного объекта
Если элементами вашего контейнера являются объекты, то для вызова их методов
или доступа к их членам можно применять к итератору оператор "->". Например,
при выполнении следующей небольшой программы сначала создается вектор,
содержащий 10 string-элементов, а затем по очереди к каждому из этих строковых
элементов присоединяется новая строка.
#include <vector>
#include <string>
using namespace std;
int raain(int argc, char** argv)
{
vector<string> stringVector(10, "Привет");
for (vector<string>::iterator it = stringVector. begin () ,-
it != stringVector.end(); ++it) {
it->append(" всем");
Тип const_iterator
Обычно итераторы применяются для операций чтения и записи. Но если вызвать
методы begin () и end () для const-объекта, вы получите итератор типа
const_iterator. Итератор типа const_iterator используется только для чтения
и не позволяет модифицировать элементы контейнера. Любой итератор всегда
можно привести к типу const_iterator, поэтому инструкция, подобная следующей,
всегда безопасна для использования.
vector<type>::const_iterator it = myVector.begin();.
Однако обратное преобразование невозможно, т.е. итератор типа const_iterator
нельзя привести к типу iterator. Если вектор myVector является const-контейнером,
следующая строка не скомпилируется.
Глава 21. Библиотека STL: контейнеры и итераторы 649
vector<type>::iterator it = myVector.begin();
Поэтому, если вам не нужно модифицировать элементы вектора, используйте
итератор типа constiterator.
Безопасность итераторов
В общем случае использование итераторов по безопасности сравнимо с
использованием указателей: т.е. они крайне небезопасны. Например, можно написать такой код.
vector<int> intVector,-
vector<int>::iterator it = intVector.end();
*it = 10; // ОШИБКА! Итератор it не ссылается на
// действительный элемент вектора.
Вспомните, что итератор, возвращаемый методом end (), находится за концом
вектора. Попытка разыменовать его даст неопределенный результат, который
обычно означает аварийное завершение программы. При этом для самих итераторов не
требуется выполнения какой-либо проверки.
Не забывайте, что метод end () возвращает итератор, который
ссылается не на последний элемент контейнера, а на область,
расположенную за пределами контейнера.
Еще одна проблема может возникнуть при использовании несоответствующих
итераторов. Например, при выполнении следующего кода инициализируется
итератор из вектора vectorTwo и делается попытка сравнить его с "конечным"
итератором из вектора vectorOne. Излишне говорить, чго этот цикл не справится с
возложенной на него миссией и может никогда не завершиться.
vector<int> vectorOne(10);
vector<int> vectorTwo(10);
// Заполняем векторы.
// ОШИБКА! Бесконечный цикл.
for (vector<int>::iterator it = vectorTwo.begin();
, it != vectorOne.end(); ++it) {
// Тело цикла
}
Выполнение других операций с помощью итераторов
Векторный итератор относится к категории итераторов произвольного доступа
к элементам контейнера, т.е. его можно как перемещать последовательно вперед
и назад, так и переставлять в любую заданную позицию. Например, при выполнении
следующего кода значение пятого элемента вектора устанавливается равным числу 4.
vector<int> intVector(10, 0);
vector<int>::iterator it = intVector.begin();
it += 5;
--it;
*it = 4;
Больше об итераторах различных категорий можно прочитать в главе 23. *
650 Часть V. Использование библиотек и шаблонов
Сравнение использования итераторов с индексированием
Если для обхода элементов вектора мы можем написать цикл for, использующий
простую индексную переменную и метод size (), то зачем нам нужно еще морочиться с
какими-то итераторами? Это — вполне резонный вопрос, на который можно дать три ответа.
□ Итераторы позволяют вставлять и удалять элементы и последовательности
элементов в любое место контейнера (см. следующий раздел "Добавление и
удаление элементов").
□ Итераторы позволяют использовать STL-алгоритмы, описанные в главе 22.
□ Использование итератора для последовательного доступа к каждому элементу
контейнера зачастую более эффективно, чем индексирование. Это обобщение,
возможно, не вполне применимо к векторам, но абсолютно справедливо для
списков (list), отображений (тар) и множеств (set).
Добавление и удаление элементов
Как упоминалось выше, добавлять элементы в вектор можно с помощью метода
push__back (). В контейнере vector определен "парный" (по отношению к
упомянутому) метод удаления pop_back ().
Метод popback {) не возвращает элемент, подвергшийся операции
удаления. Если вам нужно перед удалением получить "на руки"
элемент из контейнера, воспользуйтесь сначала методом back ().
С помощью метода insert () можно вставлять элементы в любое место контейнера
vector. Метод insert () добавляет один или несколько элементов, начиная с позиции,
заданной итератором, сдвигая при этом все последующие элементы вниз, чтобы
освободить место для новых. Существуют три различные перегруженные формы метода
insert (): первая вставляет один элемент, вторая предназначена для вставки п копий
одного элемента, а третья — для вставки элементов из диапазона, заданного итераторами.
Вспомните, что итераторный диапазон является полуоткрытым, т.е. он включает элемент,
адресуемый начальным итератором, но не включает элемент, адресуемый конечным.
Методы pushback () и insert () принимают const-ссылки на
элементы, выделяют память, необходимую для размещения новых
элементов, и сохраняют копии элементов-аргументов*
Аналогично с помощью метода erase () можно удалять элементы из любого места
вектора. Существует две формы метода erase О: для удаления одного элемента
и диапазона, заданного итераторами. Все элементы вектора можно удалить, используя
метод clear ().
Рассмотрим небольшую программу, которая демонстрирует применение методов
добавления и удаления элементов из контейнера vector. В ней используется
вспомогательная функция printVector (), которая выводит содержимое вектора в
выходной поток с out, однако реализация этой функции здесь не приводится, поскольку она
построена на базе алгоритмов, описанных в следующих двух главах.
int main(int argc, char** argv)
{
vector<int> vectorOne, vectorTwo;
int i;
Глава 21. Библиотека STL: контейнеры и итераторы 651
vectorOne.pushback(1);
vector0ne.push_back(2);
vectorOne.push_back(3);
vectorOne.pushback(5);
// Мы забыли поместить в вектор vectorOne элемент 4.
// Вставляем его в нужное место.
vectorOne.insert(vectorOne.begin() + 3, 4);
// Добавляем в вектор vectorTwo элементы с 6 по 10.
for (i = 6; i <= 10; i++) {
vectorTwo.push back(i);
}
printVector(vectorOne);
printVector(vectorTwo);
// Добавляем все элементы из вектора vectorTwo в конец
// вектора vectorOne.
vectorOne.insert(vectorOne.end(), vectorTwo.begin(),
vectorTwo.end());
printVector(vectorOne);
// Очищаем полностью вектор vectorTwo.
vectorTwo.clear();
// Добавляем в вектор vectorTwo 10 копий значения 100.
vectorTwo.insert(vectorTwo.begin(), 10, 100);
// Спохватываемся: нам нужно было ограничиться 9 элементами.
vectorTwo.pop_back();
// Теперь удаляем в векторе vectorOne числа в диапазоне 2-5.
vectorOne.erase(vectorOne.begin() + 1, vectorOne.begin() + 5);
printVector(vectorOne);
printVector(vectorTwo);
return (0);
}
Выполнив эту программу, получаем такие результаты.
12 3 4 5
6 7 8 9 10
123456 7- 89 10
1 6 7 8 9 10
100 100 100 100 100 100 100 100 100
Вспомните, что пары итераторов представляют полуоткрытый диапазон, и метод
insert () вставляет элементы перед элементом, адресуемым заданной итераторнои
позицией. Поэтому все содержимое вектора vectorTwo можно вставить в конец
вектора vectorOne, используя такую инструкцию.
vectorOne.insert(vectorOne.end(), vectorTwo.begin(),
vectorTwo.end() ) ;
При выполнении таких методов, как insert О и erase О, которые
принимают в качестве аргументов векторный диапазон,
предполагается, что начальный и конечный итераторы ссылаются на элементы
одного и того же контейнера, и что конечный итератор ссылается на
элемент, расположенный либо в позиции, соответствующей
начальному итератору, либо после таковой. Если эти предусловия не
соблюдаются, указанные методы будут работать некорректно!
652 Часть V. Использование библиотек и шаблонов
Алгоритмическая сложность и недействительность итераторов
При вставке элементов в вектор все последующие элементы сдвигаются вниз,
чтобы освободить место для новоприбывших, а при удалении — вверх, чтобы заполнить
пустоты, которые остались после ликвидации. Таким образом, эти операции
характеризуются линейной зависимостью от длины диапазона. Более того, все итераторы,
ссылающиеся на позицию вставки или удаления (или последующие позиции),
недействительны для выполнения действий. Они не перемещаются "волшебным образом"
за элементами вектора, которые были сдвинуты вверх или вниз.
Следует также иметь в виду, что внутривекторное перевыделение памяти может
привести к недействительности всех итераторов, ссылающихся на элементы в
векторе, а не только тех, которые ссылаются на элементы, расположенные за местом
вставки или удаления. (Подробности приведены в следующем разделе.)
Процедура выделения памяти для вектора
В контейнере vector для сохранения вставляемых в него элементов выделение
памяти происходит автоматически. Вспомните, что законы, по которым "живут"
векторы, требуют, чтобы его элементы располагались в смежных областях памяти
подобно тому, как это реализовано в С-массивах. Поскольку невозможно требовать
добавления памяти в конце текущего участка памяти, каждый раз, когда вектор собирается
занять больше памяти, он должен выделить совершенно новый больший участок в
отдельной области и скопировать в него все свои элементы. Этот процесс занимает много
времени, поэтому в vector-реализациях делается попытка избежать частых
перераспределений памяти путем выделения гораздо большего (чем это нужно) пространства
в случае, когда необходимо выполнить перевыделение памяти. Такой подход позволяет
избежать "перекроя" памяти при каждой вставке элемента в вектор.
В этой связи возникает один закономерный вопрос: почему вас как клиента
вектора должно волновать, как происходит внутреннее управление памятью? Должно быть,
вы думаете, что принцип абстракции позволяет вам игнорировать внутренние
особенности процедуры выделения памяти для вектора. К сожалению, существуют
причины, по которым вам таки нужно понимать, как работает вектор.
1. Эффективность. Процедура выделения памяти для вектора гарантирует, что
вставка элементов включает постоянную временного составляющую: в
большинстве случаев эта операция занимает некоторое постоянное время, но изредка
(если требуется перераспределение памяти), она приобретает линейный
характер. Если вам важно обеспечить эффективность кода, можете управлять
процессом, когда вектор будет перераспределять память.
2. Недействительность итераторов. При перераспределении памяти все
итераторы, ссылающиеся на элементы вектора, становятся недействительными.
Таким образом, векторный интерфейс позволяет осведомляться о
перераспределении памяти в векторе и управлять этим процессом. Если вы не управляете
перераспределением памяти вектора явным образом, вы должны по крайней мере знать, что
операции вставки приводят к перераспределению памяти, в результате чего все его
итераторы становятся недействительными.
Размер и емкость
В контейнере vector определено два метода для получения информации о его
размере: size () и capacity (). Метод size () возвращает фактическое количество
элементов в векторе, а метод capacity () — количество элементов, которое он
Глава 21. Библиотека STL: контейнеры и итераторы 653
может содержать без перераспределения памяти. Таким образом, количество
элементов, которое можно поместить в вектор без довыделения памяти, определяется
разностью capacity () - size ().
С помощью метода empty ()
vector пустым.
левую емкость.
молено
Вектор может быть
узнать,
пустым,
является
но иметь
ли
при
контейнер
этом нену-
Резервирование емкости
Если вас не волнуют аспекты эффективности или недействительности итераторов,
то вам не понадобится и управлять распределением векторной памяти в явном виде.
Но если вам нужно сделать свою программу максимально эффективной или вы хотите
гарантировать, что итераторы не станут недействительными, можно заранее
выделить для вектора пространство, достаточное для хранения всех его элементов.
Конечно же, для этого необходимо знать, сколько элементов будет содержать ваш
вектор, что порой спрогнозировать невозможно.
Одна из возможностей заранее выделить пространство для вектора — вызвать
метод reserve (). Этот метод выделяет область памяти, достаточную для хранения
заданного количества элементов. Пример использования метода reserve () показан
в следующем разделе.
При резервировании пространства для элементов изменяется
емкость вектора, но не его размер. Элементы в этом случае не
создаются. Поэтому не пытайтесь получить доступ к элементам в области,
расположенной за пределами размера вектора.
Еще одна возможность заблаговременно выделить память — указать в
конструкторе, сколько элементов вы собираетесь хранить в векторе. Этот метод в
действительности создает вектор заданного размера (и такой же емкости).
Пример использования вектора: класс циклического планирования
В вычислительной технике существует распространенная проблема, связанная с
распределением запросов среди ограниченного списка ресурсов. Например, для
распределения поступающих сетевых соединений между различными хостами, которые
могут обслужить этот запрос, создается специальное средство выравнивания сетевой
нагрузки. В идеальном случае все хосты должны иметь равную занятость. Одно из
самых простых алгоритмических решений этой проблемы состоит в циклическом
планировании, или планировании по принципу кругового обслуживания (round-robin
scheduling), при котором ресурсы используются по порядку. При задействовании
последнего ресурса планировщик начинает весь процесс сначала. Например, для
выравнивания сетевой нагрузки с тремя хостами первый запрос направляется на обработку
к первому хосту, второй — ко второму, третий — к третьему, а четвертый — снова
к первому. Этот цикл может продолжаться бесконечно.
Предположим, мы решили написать обобщенный класс циклического
планирования, который можно использовать с ресурсом любого типа. Этот класс должен
поддерживать ресурсы добавления и удаления, необходимость в которых возникает не
слишком часто, тем не менее обеспечение ресурсами должно быть организовано по
циклу. Мы могли бы использовать здесь STL-вектор, но зачастую полезнее написать
654 Часть V. Использование библиотек и шаблонов
класс оболочки, который бы более непосредственно обеспечивал функции,
необходимые для конкретного приложения. В следующем примере показан шаблонный класс
RoundRobin, отвечающий этим требованиям. Итак, начнем с определения класса.
#include <stdexcept>
#include <vector>
using std::vector;
//
// Шаблонный класс RoundRobin
//
// обеспечивает простую циклическую семантику для списка
// элементов. Клиенты добавляют элементы в конец списка с
// помощью метода add().
//
// Метод getNextO возвращает следующий элемент в списке,
// начиная с первого, и по достижении конца списка вновь
// переходит к первому. ,
//
// Метод remove() удаляет элемент, соответствующий аргументу.
//
template <typename T>
class RoundRobin
{
public:
//
// Клиент для повышения эффективности может подсказать
// номер ожидаемого элемента.
//
RoundRobin(int numExpected = 0);
-RoundRobin();
//
// Метод добавляет элемент elem в конец списка. Может
// быть вызван между обращениями к методу getNextO.
//
void add (const T& elem) ,-
//
// Метод удаляет первый (и только первый) элемент
// в списке, который равен (равенство определяется
// оператором operator==) элементу elem. Может
// быть вызван между обращениями к методу getNextO.
//
void remove(const T& elem);
//
// Метод возвращает следующий элемент в списке, начиная
// с 0, постоянно обеспечивая круговой обход с учетом
// элементов, которые были добавлены или удалены.
//
Т& getNextO throw(std: :out_of range) ;
protected:
vector<T> mElems;
typename std::vector<T>::iterator mCurElem;
private:
// Предотвращаем присваивание и передачу по ссылке.
RoundRobin(const RoundRobin& src);
RoundRobin& operator=(const RoundRobin& rhs);
Глава 21. Библиотека STL: контейнеры и итераторы 655
1Сак видите, public-интерфейс довольно прост: помимо конструктора и
деструктора, он включает только три метода. Ресурсы хранятся в векторе mElems, a mCurElem —
это итератор, который всегда ссылается на следующий элемент в векторе mElems,
подлежащий участию в схеме циклического планирования. Обратите внимание на
использование ключевого слова typename в начале строки объявления итератора
mCurElem (в protected-разделе). До сих пор вы видели это ключевое слово только при
задании шаблонных параметров, но здесь другой случай его применения. При доступе
к типу на основе одного или нескольких шаблонных параметров typename-onpe-
деление должно быть задано в явном виде. В данном случае шаблонный параметр Т
используется для доступа к типу iterator. Поэтому без typename-определения здесь
не обойтись. Ну что ж, это еще один пример "теневого" синтаксиса C++.
Ниже приведена реализация класса RoundRobin. Обратите внимание на
использование метода reserve () в конструкторе и итератора в методах add(), remove ()
и getNext (). Самое главное здесь, что в результате вызовов методов add () и remove ()
итератор mCurElem остается действительным и ссылается на корректный элемент.
template <typename T>
RoundRobin<T>::RoundRobin(int numExpected)
{
// По желанию клиента резервируется заданное пространство.
mElems.reserve(numExpected); v
// Инициализируем итератор mCurElem, несмотря на то, что
// он не используется до тех пор, пока не появится хотя
// бы один элемент.
mCurElem = mElems.begin();
}
template <typename T>
RoundRobin<T>::-RoundRobin()
{
// Здесь и делать нечего - вектор удалит все элементы.
}
//
// Всегда новый элемент добавляем в конец вектора.
//
template <typename T>
void RoundRobin<T>: -.add(const T& elem)
{
//
// Хотя мы добавляем элемент в конец вектора, он мог
// выполнить перераспределение памяти и
// "дисквалифицировать" итератор.
// Воспользуемся преимуществами итераторов произвольного
// доступа, чтобы запомнить позицию.
//
int pos = mCurElem - mElems.begin();
// Добавляем элемент.
mElems.push_back(elem);
// Если это первый элемент, инициализируем итератор,
// чтобы он указывал на начало вектора,
if (mElems.size() ==1) {
mCurElem = mElems.begin();
} else {
// Восстанавливаем итератор, чтобы он ссылался на
// прежнюю позицию.
mCurElem = mElems.begin() + pos;
}
656 Часть V. Использование библиотек и шаблонов
}
template <typename T>
void RoundRobin<T>::remove(const T& elem)
{
for (typename std::vector<T>::iterator it = mElems.begin();
it != mElems.end(); ++it) {
if (*it == elem) {
//
// После удаления элемента наш итератор mCurElem
// станет недействительным, если он ссылается на
// элемент, расположенный за удаленным.
// Воспользуемся преимуществами итераторов
// произвольного доступа, чтобы отследить позицию
// текущего элемента после удаления.
//
int newPos ;
i 11 Если текущий итератор находится до удаляемого
// элемента или ссылается на него, его новая позиция
// остается прежней. '
if (mCurElem <:= it) {
newPos = mCurElem - mElems. begin () ,-
} else {
// В противном случае номер позиции будет меньше
// на единицу.
newPos = mCurElem - mElems.begin() - 1;
}
// Удаляем элемент (и игнорируем возвращаемое
// значение).
mElems.erase(it);
// Теперь восстанавливаем наш итератор.
mCurElem = mElems.begin() + newPos;
// Если итератор ссылался на последний элемент,
// который был удален, нам нужно вернуться назад
// к первому.
if (mCurElem == mElems.end()) {
mCurElem = mElems.begin();
}
return,-
}
}
}
template <typename T>
T& RoundRobin<T>::getNext()throw(std::out of range)
{
// Сначала убедимся в существовании каких-либо элементов.
if (mElems.empty()) {
throw std::out_of range("В списке элементов нет.");
}
// Получаем ссылку, чтобы обеспечить возвращаемое значение.
Т& retVal = *mCurElem;
// Инкрементируем итератор и преобразуем его значение в
// соответствии с правилом циклического перехода.
++mCurElem;
if (mCurElem == mElems.end()) {
mCurElem = mElems.begin();
}
// Возвращаем ссылку.
return (retVal);
Глава 21. Библиотека STL: контейнеры и итераторы 657
Рассмотрим простую реализацию выравнивателя нагрузки, в которой используется
шаблонный класс RoundRobin. Реальный код подключения к сети опущен, поскольку
особенности этой операции определяются конкретной операционной системой.
# include "RoundRobin.h"
//
// Упреждающее объявление для класса NetworkRequest.
// Детали реализации опущены.
//
class NetworkRequest;
//
// Простой класс Host, который служит в качестве модуля
// доступа для компьютера.
// Детали реализации опущены.
//
class Host
{
public:
//
// Реализация метода processRequest будет упреждать
// запрос к сетевому хосту, представленному объектом.
// Код опущен. ~
//
void processRequest(NetworkRequestb request) {}
};
"//
// Простой выравниватель нагрузки, который распределяет
// входящие запросы между хостами, используя круговую схему.
//
class LoadBalancer
{
public:
//
// Конструктор принимает вектор, элементами которого
// являются хосты.
//
LoadBalancer(const vector<Host>& hosts);
~LoadBalancer() {}
//
// отправляем поступающий запрос к следующему хосту,
// используя алгоритм циклического планирования.
//
void distributeRequest(NetworkRequest& request);
protected:
RoundRobin<Host> rr;
};
LoadBalancer::LoadBalancer(const vector<Host>& hosts)
{
// Добавляем хосты.
for (size_t i = 0; i < hosts.size(); ++i) {
rr.add(hosts[i]);
}
}
void LoadBalancer::distributeRequest(NetworkRequest& request)
{
try {
rr.getNext().processRequest(request) ;
658 Часть V. Использование библиотек и шаблонов
} catch (out_of_range& e) {
cerr << "Хостов больше нет.\п",-
}
}
Специализация vector<bool>
Стандарт требует определения частичной специализации шаблона vector для типа
bool с намерением оптимизировать процесс выделения памяти путем "упаковки" булевых
значений. Вспомните, что переменная типа bool может принимать одно из двух
значений: либо true, либо false, т.е. bool-значение может быть представлено одним битом.
Однако в большинстве С++-компиляторов bool-значения имеют такой же размер, как
и данные типа int. Специализация vector<bool> предполагает хранение "массива
bool-значений", занимающих именно один бит, т.е. в "режиме экономии памяти".
Специализацию vector<bool> можно представлять себе не как
вектор, а как контейнер для битовых полей. Контейнер bit set (его
описание приведено ниже) обеспечивает реализацию битовых полей
с более полным набором функций, чем контейнер vector<bool>.
Однако к преимуществам специализации vector<bool> можно
отнести его способность динамически изменять размер. .
Результатом, честно говоря, слабой попытки определить функции обработки
битовых полей для специализации vector<bool> явился только один дополнительный
метод: f lip (). Этот метод можно вызвать либо для контейнера (и тогда он
применяет операцию отрицания ко всем элементам контейнера), либо для одной ссылки,
возвращаемой такими методами, как operator [] или at () (и тогда он инвертирует
указанный элемент).
Возможно, вас интересует, как можно вызвать метод для ссылки на отдельный
bool-элемент. Ответ: никак. В действительности специализация vector<bool>
определяет класс reference, который служит "заместителем" для базового bool-
элемента. При вызове таких методов, как operator [] или at (), для
специализации vector<bool> возвращается объект типа reference, который является
"доверенным лицом" реального bool-элемента.
Тот факт, что ссылки, возвращаемые методами класса vector<bool>,
на самом деле являются "заместителями**, означает, что для
получения указателей на реальные элементы в контейнере нельзя
использовать их адреса. Пример разработки такого "заместителя" подробно
описан в главе 26.
На практике мы не раз убеждались, что тот небольшой объем памяти,
сэкономленный в результате упаковки bool-элементов, вряд ли стоит дополнительных усилий. Но
вам будет полезно ознакомиться с этой частичной реализацией, хотя бы ради метода
f lip (), а также ради понимания того факта, что ссылки на самом деле являются
объектами-заместителями. Многие опытные С+^-программисты рекомендуют отказаться от
специализации vector<bool> в пользу класса bit set, если, конечно, вам
действительно не нужен контейнер битовых полей с динамически изменяемым размером.
Глава 21. Библиотека STL: контейнеры и итераторы 659
Очередь с двусторонним доступом (дек)
Контейнер deque практически идентичен вектору, но используется гораздо реже.
Принципиальные различия между ними таковы:
Q реализация дека не требует хранения элементов в смежных областях памяти;
□ для вставки и удаления элементов дека с обоих концов требуется постоянное
время (для вектора постоянным временем характеризуются операции,
выполняемые только с конца);
Q в деке (в отличие от вектора) определены методы push_f ront () и pop_f ront ();
Q в деке не раскрыта схема управления памятью, как в векторе (через методы
reserve () или capacity ()).
Как правило, в приложениях декам предпочитают векторы или списки. Поэтому
мы не будем останавливаться на деталях реализации deque-методов, но при
необходимости их можно почерпнуть из Web-ресурса Standard Library Reference.
Список
STL-контейнер list представляет собой стандартный дважды связанный список.
Он поддерживает постоянное время для операций вставки и удаления элементов
в любом месте списка, но доступ к отдельным элементам характеризуется более
медленным выполнением (линейной зависимостью). Список не обеспечивает такие
операции произвольного доступа, как operator []. И поэтому к отдельным элементам
списка можно добраться только с помощью итераторов.
Большинство операций над списком идентично векторным, включая такие
обязательные составляющие, как конструкторы, деструктор, операции копирования,
присваивания и сравнения. В этом разделе мы уделим внимание тем методам контейнера
list, которые отличаются от определенных в векторе. Если вас интересуют методы,
которые здесь не описаны, обратитесь к Web-ресурсу Standard Library Reference.
Доступ к элементам списка
Для доступа к элементам списка предусмотрено только два метода: front () и back (),
причем оба они характеризуются постоянным временем выполнения. Доступ ко всем
остальным методам возможен только через итераторы.
Списки не обеспечивают произвольный доступ к своим элементам.
Итераторы
Итераторы, используемые для работы со списками, относятся к категории
двунаправленных, а не произвольного доступа, как итераторы, работающие с векторами.
Это означает, что list-итераторы нельзя подвергать сложению и вычитанию, а
также другим операциям, характерным для арифметики указателей.
Добавление и удаление элементов
Контейнер list поддерживает те же самые методы добавления и удаления
элементов, что и вектор, включая push_back (), popback (), три формы метода insert (),
две формы метода erase () и clear (). Как и в контейнере deque, в списке также
определены методы push_f ront () и pop_f ront (). Удивительно то, что в списке все эти
методы (за исключением метода clear ()) требуют постоянного времени выполнения
660 Часть V. Использование библиотек и шаблонов
(при условии предварительного перехода в корректную позицию). Таким образом,
список вполне подходит для приложений, которые выполняют множество вставок и
удалений, но не требуют быстрого индексированного доступа к элементам.
Размер списка
Подобно декам, но в отличие от векторов, списки не открывают базовую модель
управления памятью. Другими словами, списки поддерживают методы size () и empty (),
но не resize () или capacity ().
Специальные операции над списками
В контейнере list определен ряд специальных методов на основе
быстродействующих операций вставки и удаления. В этом разделе содержится обзор и примеры.
Исчерпывающую информацию по всем этим методам можно получить из Web-pecypca
Standard Library Reference.
Склеивание
Специфика класса list позволяет вставлять целый список в любую позицию
другого списка в течение некоторого постоянного времени. Самая простая версия
склеивания списков работает следующим образом.
#include <list>
#include <string>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
list<string> dictionary, bWords;
// Добавляем слова, начинающиеся с буквы "а".
dictionary.push_back("aardvark" ) ;
dictionary.push_back("ambulance");
dictionary .push_back ( "archive") ,-
// Добавляем слова, начинающиеся с буквы "с".
dictionary .pushback ( "canticle") ,-
dictionary.push_back("consumerism");
dictionary.push_back("czar");
// Создаем еще один список слов bWords, начинающихся
// с буквы "Ь".
bWords.pushback("bathos");
bWords.push_back("balderdash");
bWords.push_back("brazen");
// "Вклеиваем" Ь-слова в основной словарь.
list<string>::iterator it;
int i ;
// Доходим до места, куда будем вставлять Ь-слова.
// Тело цикла for намеренно оставлено пустым, поскольку
// мы просто смещаемся на три элемента.
for (it = dictionary.begin(), i = 0; i < 3; ++it, ++i);
// Вставляем в словарь список bWords. Это действие
// удаляет элементы из списка bWords.
dictionary.splice(it, bWords);
Глава 21. Библиотека STL: контейнеры и итераторы 661
// Выводим содержимое словаря dictionary.
for (it = dictionary.begin(); it != dictionary.end(); ++it) {
cout << *it << endl;
}
return (0);
}
При выполнении этой программы получаем такие результаты.
aardvark
ambulance
archive
bathos
balderdash
brazen
canticle
consumerism
czar
Существуют также две другие формы метода splice (): первая вставляет в список
один элемент, а вторая — диапазон из другого списка. За подробностями обратитесь
к Web-ресурсу Standard Library Reference.
Склеивание— деструктивная операция для списка, передаваемого
в качестве параметра: она удаляет вставляемые элементы из одного
списка, чтобы "вклеить" их в другой.
Более эффективные версии алгоритмов
Помимо метода splice О, в классе list предусмотрены специальные реализации
нескольких обобщенных STL-алгоритмов "(они описаны в главе 22). Здесь мы
рассмотрим конкретные версии, имеющие отношение только к списку.
Если в качестве контейнера
большей эффективности
именно list-методы.
вы
выбрали список,
используйте не
для
обобщенные
достижения
алгоритмы, а
В следующей таблице перечислены алгоритмы, для которых в классе list
предусмотрены специальные реализации, представленные в виде методов. Прототипы,
описание алгоритмов и временные характеристики можно узнать, обратившись
к Web-ресурсу Standard Library Reference и главе 22.
Метод Описание
remove () Удаляет определенные элементы из списка
remove_if ()
unique () Удаляет из списка соседние элементы-дубликаты
merge () Объединяет два списка. Оба списка должны быть отсортированы Подобно
методу splice (), метод merge () ведет себя деструктивно по отношению
к списку, передаваемому в качестве аргумента
sort () Выполняет устойчивую сортировку элементов списка
reverse () Изменяет порядок элементов списка на обратный
Использование большинства из этих методов демонстрируется в следующей
программе.
662 Часть V. Использование библиотек и шаблонов
Пример использования списка: регистрация студентов
Предположим, что вы создаете систему компьютерной регистрации для
университета. При этом вы могли бы реализовать возможность создания полного списка
зарегистрированных студентов университета на базе списков студентов каждого класса.
Для данного примера предположим, что вы должны написать только одну функцию,
которая принимает вектор (vector) списков (list), элементами которых являются
имена студентов (в виде string-значений), а также список студентов, отчисленных с курса
по причине неоплаты обучения. Этот метод должен сгенерировать полный список всех
студентов всех курсов, но без дублирования имен (студенты могут учиться на нескольких
курсах). В этот список не должны входить студенты, которые были отчислены.
Ниже приведен код этого метода. Благодаря возможностям STL-класса list код
этого метода короче его описания! Обратите внимание на то, что библиотека STL позволяет
использовать "вложенные" контейнеры: в данном случае мы используем вектор списков.
#include <list>
#include <vector>
#include <string>
using namespace std;
//
// Параметр classLists — это вектор списков, соответствующих
// каждому курсу. Эти списки содержат имена студентов,
// зарегистрированных на этих курсах. Списки не отсортированы.
//
// Параметр droppedStudents - это список студентов, которые
// не смогли оплатить свое обучение и поэтому были отчислены.
//
// Эта функция возвращает список всех зарегистрированных
// (не отчисленных) студентов по всем курсам.
//
list<string>
getTotalEnrollment(const vector<list<string> >& classLists,
const list<string>& droppedStudents)
{
list<string> allStudents;
// Соединим все курсовые списки в один сводный список.
for (size_t i = 0; i < classLists.size(); ++i) {
allstudents.insert(allStudents.end(),
classLists[i].begin(),
classLists[i].end());
}
// Отсортируем сводный список.
allStudents.sort();
// Удаляем дублирующиеся имена студентов (которые посещают
// несколько курсов).
allStudents.unique();
//
// Удаляем имена исключенных студентов.
// Для этого при обходе элементов dropped-списка будем
// вызывать метод remove() для удаления в сводном списке
// элемента, соответствующего текущему элементу
// dropped-списка.
//
for (list<string>: -. const_iterator it =
droppedStudents.begin();
it != droppedStudents.end(); ++it) {
allStudents.remove(*it);
Глава 21. Библиотека STL: контейнеры и итераторы 663
}
// Готово!
return (allStudents);
}
Контейнеры-адаптеры
Помимо трех стандартных последовательных контейнеров, библиотека STL
содержит три контейнера-адаптера: очередь (queue), очередь по приоритету
(priority_queue) и стек (stack). Каждый из этих адаптеров представляет собой
оболочку вокруг одного из последовательных контейнеров. Назначение оболочки —
упростить интерфейс и обеспечить только те функции, которые пригодны для
абстракции стека или очереди. Например, адаптеры не позволяют вставлять или удалять
несколько элементов одновременно.
Интерфейсы адаптеров с точки зрения пользователя могут оказаться
слитком ограниченными. В этом случае можно использовать
последовательные контейнеры или написать собственный адаптер с более
широким набором функций. Пример разработки такого адаптера
подробно описан в главе 26.
Очередь
Адаптер очереди, определенный в заголовочном файле < queue >, обеспечивает
стандартную семантику "первым прибыл— первым обслужен" (first-in, first-out —
FIFO). Как правило, этот адаптер записывается как шаблонный класс:
template <typename Т,
typename Container = deque<T> > class queue;
Шаблонный параметр Т задает тип элементов, которые вы планируете сохранять
в очереди. Второй шаблонный параметр позволяет оговорить базовый контейнер,
который эта очередь должна адаптировать. Поскольку очередь требует, чтобы базовый
последовательный контейнер поддерживал методы pushback () и pop_f ront (), то
у вас для выбора адаптируемого контейнера есть только два встроенных варианта:
deque и list. Для большинства случаев подходит действующий по умолчанию дек.
Операции над очередями
Интерфейс очереди чрезвычайно прост: в вашем распоряжении только шесть
методов, не считая конструктора, и обычные операторы сравнения. Метод push ()
предназначен для добавления нового элемента в конец очереди, а метод pop () — для удаления
элемента с ее начала. С помощью методов front () и back () можно получить ссылки на
первый и последний элементы соответственно. Обычно при вызове для const-объектов
методы front () и back () возвращают const-ссылки, а при вызове не const-объектов —
не const-ссылки (т.е. ссылки, позволяющие операции чтения/записи).
Очередь также поддерживает методы size () и empty (). Подробности
представлены на Web-ресурсе Standard Library Reference.
664 Часть V. Использование библиотек и шаблонов
Метод pop О не возвращает извлеченный из очереди элемент. Если
вам нужно сохранить копию, необходимо сначала извлечь элемент
с помощью метода front ().
Пример использования очереди: буфер сетевого пакета
Если два компьютера общаются через сеть, они посылают друг другу данные,
разделенные на отдельные порции, именуемые пакетами. Сетевой уровень
операционной системы компьютера должен принимать пакеты и сохранять их по мере
поступления. Однако компьютер может не обладать пропускной способностью, достаточной
для обработки всех пакетов сразу. Поэтому на сетевом уровне эти пакеты обычно бу-
феризируются, т.е. сохраняются, до тех пор, пока на более высоком уровне не
появится возможность уделить им внимание. Пакеты должны обрабатываться в порядке
их поступления, поэтому описанная задача идеально подходит для структуры очереди.
Ниже приведен класс PacketBuf f er, назначение которого— сохранять
поступающие пакеты в очереди до тех пор, пока они не будут обработаны. Этот класс
представляет собой шаблон, чтобы его можно было использовать для пакетов различных
типов, например IP или TCP. Он позволяет клиенту задавать максимальный размер,
поскольку операционные системы обычно ограничивают количество пакетов,
которое они могут сохранить, чтобы не занимать слишком много памяти. После
заполнения буфера вновь прибывающие пакеты игнорируются.
#include <queue>
#include <stdexcept>
using std: : queue ,-
template <typename T>
class PacketBuffer
{
public:
//
// Если значение maxSize неположительное, то размер
// не ограничен. В противном случае- разрешается иметь в
// буфере одновременно только maxSize пакетов.
//
PacketBuffer(int maxSize = -1);
//
// Метод сохраняет пакет в буфере. Генерирует
// исключение overflowerror, если буфер полон.
//
void bufferPacket(const T& packet);
1Г
II Метод возвращает следующий пакет. Генерирует
// исключение out_of range, если буфер пуст.
//
Т getNextPacket () throw (std: :'out_of_range) ;
protected:
queue<T> mPackets;
int mMaxSize;
private:
// Предотвращаем присваивание и передачу по значению.
PacketBuf fer (const PacketBuf fer& src) ,-
PacketBuffer& operator=(const PacketBuffer& rhs);
Глава 21. Библиотека STL: контейнеры и итераторы 665
};
template <typename T>
PacketBuffer<T>::PacketBuffer(int maxSize)
{
mMaxSize = maxSize;
}
template <typename T>
void PacketBuffer<T>::bufferPacket(const T& packet)
{
if (mMaxSize > 0 && mPackets.size() ==
static_cast<size_t>(mMaxSize)) {
// Больше нет места. Пакет просто "отбрасываем",
return;
}
. mPackets. push (packet) ,-
}
template <typename T>
T PacketBuf fer<T>:-.getNextPacket () throw (std::out_of_range)
{
if (mPackets.empty()) {
throw (std::outof range("Буфер пуст."));
}
// Извлекаем начальный элемент.
Т temp = mPackets.front О ;
// Выталкиваем начальный элемент из очереди.
mPackets.pop();
// Возвращаем начальный элемент,
return (temp);
}
Для практического приложения этого класса потребовалось бы несколько
потоков. Но здесь мы приведем простой пример его применения, который может сыграть
роль поэлементного теста.
#include "PacketBuffer.h"
#include <iostream>
using namespace std;
class IPPacket {} ;
int main(int argc, char** argv)
{
PacketBuffer<IPPacket> ipPackets(3);
ipPacket s. buff erPacket (IPPacket () ) ,-
ipPackets.bufferPacket(IPPacket() ) ;
ipPackets.bufferPacket(IPPacket());
ipPackets.bufferPacket(IPPacket());
while (true) {
try {
IPPacket packet = ipPackets.getNextPacket();
} catch (out_of_range&) {
cout << "Обработаны все пакеты!" << endl;
break ,-
return (0);
}
666 Часть V. Использование библиотек и шаблонов
Очередь по приоритету
Очередь по приоритету— это очередь, которая сохраняет элементы в
отсортированном виде. Вместо строгого соблюдения принципа FIFO здесь действует принцип
сортировки: в любой момент времени обладателем наивысшего приоритета является
элемент, расположенный в начале очереди (головной элемент). Этот элемент может
быть как "старожилом", так и "новичком" в данной очереди. Если же два элемента
(по результату сортировки) имеют равный приоритет, вот тогда их относительный
порядок в очереди определяется дисциплиной FIFO.
Контейнер-адаптер STL priority_queue также определен в заголовочном файле
<queue>. Его шаблонное определение (с некоторым упрощением) выглядит
следующим образом.
template <typename T, typename Container = vector<T>,
typename Compare = less<T> >;
He стоит пугаться: все гораздо проще, чем кажется на первый взгляд! Первые
два параметра вы уже видели раньше: Т — тип элемента, сохраняемого в адаптере
priority_queue, a Container задает базовый контейнер, адаптируемый очередью по
приоритету. По умолчанию адаптер priori tyqueue использует контейнер vector,
но вполне будет уместен и контейнер deque. Список здесь не подойдет, поскольку
адаптеру priority_queue, чтобы иметь возможность отсортировать свои элементы, нужен
произвольный доступ к ним. Третий параметр, Compare, позволяет управлять
характером сортировки. Как будет описано в главе 22, класс less представляет собой шаблон,
который поддерживает сравнение двух объектов типа Т с помощью оператора operators
Для нас это означает, что приоритет элементов в очереди определяется в соответствии
с оператором operators Используемый при сортировке тип сравнения элементов
можно изменить, но это — тема главы 22. А пока будем считать, что оператор сравнения
operator< подходит для типов элементов, сохраняемых в очереди по приоритету.
Головной элемент очереди по приоритету — это элемент с "наивысшим"
приоритетом, по умолчанию определяемым в соответствии с
оператором opera tor <. Это означает, что элементы, которые "меньше"
других, будут иметь более низкий приоритет.
Операции над очередями по приоритету
Адаптер priority_queue позволяет выполнять над своим содержимым даже еще
меньше операций, чем контейнер queue. С помощью методов push () и pop () можно
вставлять и удалять элементы соответственно, а метод top () возвращает const-
ссылку на головной элемент очереди по приоритету.
Метод top () возвращает const-ссылку даже в том случае, если он
был вызван для не const-объекта. Адаптер priority_queue не
обеспечивает механизма получения хвостового элемента.
Метод pop () не возвращает извлеченный из очереди элемент. Если
вам нужно сохранить копию, необходимо сначала "достать" элемент
с помощью метода top ().
Глава 21. Библиотека STL: контейнеры и итераторы 667
Подобно очереди (queue), очередь по приоритету (prior ityqueue)
поддерживает методы size () и emptyO, но не определяет никаких операторов сравнения.
Подробности приведены на Web-ресурсе Standard Library Reference.
Этот интерфейс обладает довольно ограниченными возможностями. В частности,
адаптер priorityqueue не предоставляет никакой итераторнои поддержки и не
позволяет объединять две очереди по приоритету в одну.
Пример использования очереди по приоритету: коррелятор ошибок
Отказ системы иногда может быть вызван несколькими ошибками,
сгенерированными в различных компонентах. Система обработки ошибок считается хорошей, если
в ней еще до обработки (во избежание процесса дублирования ошибок) определяется
их взаимозависимость. Для создания простого коррелятора ошибок можно
использовать очередь по приоритету. Этот класс должен просто отсортировать события в
соответствии с их приоритетом, чтобы ошибки с наивысшим приоритетом всегда
обрабатывались первыми. Итак, рассмотрим определение этого класса.
#include <ostream>
#include <string>
#include <queue>
#include <stdexcept>
// Пример класса Error, предназначенного для систематизации
// ошибок,
class Error
{
public:
Error(int priority, std::string errMsg) :
mPriority(priority), mError(errMsg) {}
int getPriority() const {return mPriority; }
std::string getErrorStringO const {return mError; }
friend bool operator<(const Error& lhs,
const Error& rhs);
friend std::ostream& operator<<(std::ostream& str,
const Errors err); '
protected:
int mPriority;
std::string mError;
};
// Простой класс ErrorCorrelator, который возвращает первыми
// ошибки с наивысшим приоритетом,
class ErrorCorrelator
{
public:
ErrorCorrelator() {}
//
// Добавляем новую ошибку.
//
void addError(const Error& error);
//
// Извлекаем следующую ошибку для обработки.
//
Error getError() throw (std::out_of_range);
protected:
668 Часть V. Использование библиотек и шаблонов
std: : priority_queue<Error> mErrors,-
private:
// Предотвращаем присваивание и передачу по ссылке.
ErrorCorrelator(const ErrorCorrelator& src) ;
ErrorCorrelator& operator=(const ErrorCorrelator& rhs);
b
Теперь рассмотрим определения функций и методов класса.
#include "ErrorCorrelator.h"
using namespace std;
bool operator<(const Errort lhs, const Errort rhs)
{
return (lhs.mPriority < rhs.mPriority);
}
ostream& operator«(ostrearat str, const Errort err)
{
str « err.raError « " (Приоритет " << err.mPriority
« " ) " ;
return (str);
}
void ErrorCorrelator::addError(const Error& error)
{
mErrors. push (error) ,-
}
Error ErrorCorrelator::getError() throw (out of range)
{
//
// Если ошибок больше нет, генерируем исключение.
//
if (mErrors.empty()) {
throw (out_of_range("Элементов больше нет!"));
}
// Сохраняем головной элемент.
Error top = mErrors.top();
// Удаляем головной элемент.
mErrors.pop();
// Возвращаем сохраненный элемент.
return (top);
}
Ниже приведен простой поэлементный тест, демонстрирующий, как можно
использовать класс ErrorCorrelator. Для реального его применения потребовалось
бы несколько потоков: один бы добавлял в очередь ошибки, а другой их обрабатывал.
#include "ErrorCorrelator.h"
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
ErrorCorrelator ec,-
ec.addError(Error(3, "He удается прочитать файл."));
ее.addError(Error(1,
"Некорректные данные от пользователя."));
ее.addError(Error(10, "Не удается выделить память!"));
Глава 21. Библиотека STL: контейнеры и итераторы 669
while (true) {
try {
* Error e = ее.getError();
cout << e << endl;
} catch (out_of_range& ) {
cout << "Обработка ошибок завершена.\п";
break;
}
}
return (0);
}
Стек
Контейнерный адаптер stack практически идентичен адаптеру queue, за
исключением того, что реализует другую семантику: не FIFO, a LIFO (last-in, first-out),
означающую функционирование по принципу "последним прибыл— первым обслужен".
Определение этого шаблона выглядит следующим образом.
template <typename T,
typename Container = deque<T> > class stack;
В качестве базового контейнера для стека можно выбрать любой из трех
стандартных (по умолчанию используется дек).
Операции над стеком
Как и в деке, в стеке определены методы push () и pop (). Различие состоит в том,
что метод push () помещает новый элемент в вершину стека, "опуская ниже" все
элементы, вставленные раньше, а метод pop () удаляет "верхний" элемент стека,
который был помещен в него позже других. Метод top () возвращает const-ссылку на
вершину стека, если он вызывается для const-объекта, и не const-ссылку, если он
вызывается для не const-объекта.
Метод pop О не возвращает извлеченный из стека элемент. Если вам
нужно сохранить его копию, необходимо сначала "достать" элемент
с помощью метода top ().
Стек поддерживает методы empty (), size () и стандартные операции сравнения.
(Подробнее — см. Web-ресурс Standard Library Reference.)
Пример использования стека: усовершенствованный коррелятор ошибок
Предположим, что мы решили переписать приведенный выше класс
ErrorCorrelator, чтобы он сортировал ошибки не в соответствии с приоритетом, а по дате
поступления. Для этого можно в определении класса ErrorCorrelator очередь по
приоритету заменить стеком. Теперь объекты типа Error будут размещаться по
стековому принципу (LIFO), а не по приоритету. Важно то, что в определения методов
нам не нужно вносить изменения, поскольку методы push (), pop (), top () и empty ()
поддерживаются в обоих адаптерах: и priority_queue, и stack.
#include <ostream>
#include <string>
670 Часть V. Использование библиотек и шаблонов
#include <stack>
#include <stdexcept>
// Детали определения класса Error опущены.
//
// Простой класс ErrorCorrelator, который первой возвращает
// самую "свежую" ошибку.
//
class ErrorCorrelator
{
public:
ErrorCorrelator() {}
//
// Добавляем новую ошибку.
//
void addError(const Error& error);
//
, // Извлекаем следующую ошибку для обработки.
//
Error getErrorO throw (std::out_of_range);
protected:
std::stack<Error> mErrors;
private:
// Предотвращаем присвоение и передачу по ссылке.
ErrorCorrelator(const ErrorCorrelator& src) ;
ErrorCorrelator& operator=(const ErrorCorrelator& rhs);
};
Ассоциативные контейнеры
В отличие от последовательных, ассоциативные контейнеры хранят элементы не
в линейной структуре, а в виде пар соответствия ключей и значений. Ассоциативные
контейнеры в общем случае поддерживают операции вставки, удаления и поиска,
эквивалентные между собой по времени выполнения.
Библиотека STL содержит четыре ассоциативных контейнера: отображение (тар),
мультиотображение (multimap), множество (set) и мультимножество (multiset).
Каждый из этих контейнеров сохраняет элементы в отсортированной древовидной
структуре данных.
Вспомогательный класс pair
Прежде чем рассматривать ассоциативные контейнеры, необходимо сначала
познакомиться с классом pair, который определен в заголовочном файле <utility>.
Класс pair представляет собой шаблон, который группирует два значения, тип
которых может быть разным. Доступ к этим значениям можно получить через public-
Глава 21. Библиотека STL: контейнеры и итераторы 671
члены данных first и second. В этом классе определены методы сравнения opera-
tor== и operators. Рассмотрим несколько примеров использования класса pair.
#include <utility>
#include <string>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
// Используем конструктор с двумя аргументами и
// конструктор по умолчанию.
pair<string, int> myPair("Привет", 5), myOtherPair,-
// Членам данных first и second можно присваивать
// значения напрямую.
myOtherPair.first = "Привет";
myOtherPair.second = 6;
// Используем конструктор копирования.
pair<string, int> myThirdPair(myOtherPair);
// Используем оператор сравнения operator<.
if (myPair < myOtherPair) {
cout « "Объект myPair меньше объекта myOtherPair.\n";
} else {
cout << "Объект myPair больше объекта myOtherPair
*5> или равен ему.\п";
}
// Используем оператор сравнения operator==.
if (myOtherPair == myThirdPair) {
cout « "Объект myOtherPair равен объекту myThirdPair\n";
} else {
cout « "Объект myOtherPair не равен объекту
^myThirdPairXn";
}
return (0);
}
/'
В библиотеке также предусмотрена вспомогательная шаблонная функция
make_pair (), которая создает пару из двух переменных. Например, вы могли бы
использовать ее так.
pair<int, int> aPair = make_pair(5, 10);
Безусловно, в данном случае можно было бы обойтись двухаргументным
конструктором. Однако функция make_pair () более полезна при передаче пары значений
(объекта класса pair) какой-нибудь другой функции. В отличие от шаблонных
классов, шаблонные функции могут "делать вывод" о типах данных на основе заданных
параметров, поэтому функцию make_pair () можно использовать для построения
pair-объекта без явного задания типов.
Использование типов указателей в объектах класса pair довольно
рискованно, поскольку конструктор копии и оператор присваивания
класса pair выполняют только "поверхностное" копирование и
присвоение указателей.
672 Часть V. Использование библиотек и шаблонов
Отображение
Отображение — один из самых полезных контейнеров. Он предназначен для
хранения не просто отдельных значений, а пар "ключ-значение". Такие операции, как
вставка, поиск и удаление, основаны на ключе; значение же играет роль "примкнувшего"
члена. Термин "отображение" происходит из концептуального понимания того, что
этот контейнер "отображает" ключи на значения. С этим принципом вы могли
познакомиться на примере хеш-таблиц. Отображение обеспечивает подобный интерфейс;
различие состоит в базовой структуре данных и алгоритмической сложности операций.
Отображение сохраняет элементы в отсортированном порядке, основанном на
ключах, поэтому время выполнения операций вставки, удаления и поиска находится
в логарифмической зависимости от объема контейнера. Обычно отображение
реализуется в виде сбалансированного дерева (например красно-черного графа). Однако
древовидная структура сокрыта от клиентов.
Отображение следует использовать в случае, если нужно сохранять и извлекать
элементы на основе некоторого "ключевого" значения.
Построение отображений
Шаблон тар принимает в качестве параметров четыре типа: тип ключа, тип
значения, тип сравнения и тип распределителя памяти. В этой главе мы не будем
останавливаться на распределителе (см. главу 23). Тип сравнения отображения подобен
типу сравнения, применяемому в описанном выше контейнере priority_queue. Он
позволяет задать класс сравнения, отличный от действующего по умолчанию. Как
правило, критерий сортировки не изменяют. В этой главе мы используем критерий,
построенный на действующем по умолчанию классе less. Даже если вы не
собираетесь менять параметры, действующие по умолчанию, убедитесь, что все ваши ключи
должным образом реагируют на применение оператора operators
Как писать собственные классы сравнения, вы узнаете ниже в этой главе.
Если не указывать параметры сравнения и распределителя (что мы и вам
настоятельно рекомендуем), построение отображения очень напоминает создание вектора
или списка, за исключением того, что типы ключа и значения задаются в шаблоне
раздельно. Например, при выполнении следующего кода создается отображение,
предназначенное для хранения ключей типа int и объектов класса Data (полное
определение которого не показано).
#include <map>
using namespace std;
class Data
{ *
public:
Data(int val = 0) { mVal = val; }
int getVal() const { return mVal; }
void setVal(int val) {mVal = val; }
// Остальная часть определения опущена.
protected:
int mVal;
};
int main(int argc, char** argv)
{
map<int, Data> dataMap,-
return (0) ,-
}
Глава 21. Библиотека STL: контейнеры и итераторы 673
Вставка элементов
Вставка элементов в такие последовательные контейнеры, как vector и list, всегда
требует задания позиции, в которую данный элемент должен быть вставлен. В
отображении, как и других ассоциативных контейнерах, все обстоит по-другому. Позиция,
в которой будет сохранен новый элемент, определяется внутренней реализацией
контейнера, а от вас требуется задать только ключ и соответствующее ему значение.
В отображении (тар) и других ассоциативных контейнерах определена
версия метода insert (), которая принимает в качестве параметра
позицию итератора. Но эта позиция служит только "рекомендацией"
контейнеру насчет правильного размещения вставляемой пары
элементов. Совсем не обязательно, что контейнер вставит элементы
в заданную позицию.
Вставляя элементы, важно иметь в виду, что отображение поддерживает так
называемые "уникальные ключи": каждый элемент в отображении должен иметь "свой" ключ,
отличный от других. Если вам нужно хранить несколько элементов с одинаковыми
ключами, вы должны использовать мультиотображения (multimap), описанные ниже.
Существует два способа вставки элементов в отображение: с помощью метода
insert () и посредством оператора operator [].
Использование метода insert ()
Метод insert () реализует довольно "неуклюжий механизм" вставки элемента
в отображение. Одна трудность состоит в том, что вы должны задавать пару "ключ-
значение" как объект класса pair. Другая проблема связана с тем, что значение,
возвращаемое базовой формой метода insert (), представляет собой пару, состоящую из
iterator-объекта и bool-значения. Дело в том, что метод insert () не
перезаписывает значение элемента, уже существующее для заданного ключа. По элементу типа bool
(присутствующему в возвращаемой методом паре) можно судить о том, действительно
ли метод insert () вставил в контейнер новую пару "ключ-значение". Итератор же
ссылается на элемент в отображении с заданным ключом (то, какому значению
соответствует ключ— новому или старому, зависит от того, была вставка успешной или нет).
Продолжим рассмотрение примера использования отображения, начатое в
предыдущем разделе. Теперь на повестке дня такой вопрос: как использовать метод insert {).
#include <map>
#include <iostream>
using namespace std;
class Data
{
public:
Data(int val =0) { mVal = val; }
int getVal() const { return mVal; }
. void setVal(int val) {mVal = val; }
// Остальная часть определения опущена.
protected:
int mVal;
};
int main(int argc, char** argv)
{
map<int, Data> dataMap;
674 Часть V. Использование библиотек и шаблонов
pair<map<int, Data>::iterator, bool> ret;
ret = dataMap.insert(make_pair(1, Data(4)));
if (ret.second) {
cout << "Вставка успешно выполнена!\п";
} else {
cout « "Вставка не выполнена!\п";
}
ret = dataMap.insert(make jpair(1, Data(6) ) ) ;
if (ret.second) {
cout « "Вставка успешно выполнена!\n";
} else {
cout « "Вставка не выполнена!\n";
}
return (0);
}
Обратите внимание на использование метода make_pair() для построения
pair-объекта, передаваемого методу insert (). Результаты выполнения этой
программы таковы.
Вставка успешно выполнена!
Вставка не выполнена!
Использование оператора operator []
Есть более удобный способ вставки элемента в отображение. Он реализуется с
помощью перегруженного оператора operator []. Различие между этими двумя
способами, в основном, лежит в синтаксисе: в этом случае вы должны задать ключ и
значение в отдельности. Кроме того, оператор operator [] всегда выполняется успешно.
Если для заданного ключа в отображении не существует заданного значения,
создается новый элемент с этими ключом и значением. Если элемент с заданным ключом уже
существует, метод operator [] заменяет его значение новым. Рассмотрим
предыдущий пример, заменив метод insert () оператором operator [].
#include <map>
#include <iostream>
using namespace std;
class Data
{
public:
Data(int val =0) { mVal = val; }
int getVal() const { return mVal; }
void setVal(int val) {mVal = val; }
// Остальная часть определения опущена.
protected:
int mVal;
};
int main(int argc, char** argv)
{
map<int, Data> dataMap;
dataMap[1] = Data(4);
dataMap[1] = Data(6); // Замена элемента с ключом 1.
return (0) ,-
}
Глава 21. Библиотека STL: контейнеры и итераторы 675
Здесь необходимо отметить, что оператор operator [] всегда создает объект
с новым значением, даже если в этом нет необходимости. Он требует определения
конструктора по умолчанию для значений, участвующих в парах, а его использование
может быть менее эффективным, чем использование метода insert ().
Итераторы, используемые в отображениях
Итераторы контейнера тар действуют подобно итераторам, используемым в
последовательных контейнерах. Главное различие между ними в том, что map-итераторы
ссылаются на пары "ключ-значение", а не просто на значения. Чтобы получить
доступ к значению пары, необходимо считать содержимое поля second объекта
класса pair. Посмотрите, как можно "пройти" в цикле отображение, "взятое" из
предыдущего примера.
#include <map>
#include <iostream>
using namespace std;
class Data
{
public:
Data(int val =0) { mVal = val; }
int getVal() const { return mVal; }
void setVal(int val) {mVal = val; }
// Остальная часть определения опущена.
protected:
int mVal;
};
int main(int argc, char** argv)
{
map<int# Data> dataMap;
dataMap[1] = Data(4);
dataMap[1] = Data(6); // Замена элемента с ключом 1.
for (map<int, Data>::iterator it = dataMap.beginO;
it != dataMap. end () ,- ++it) {
cout « it->second.getVal() « endl;
}
return (0);
}
Рассмотрим выражение, используемое при для доступа к значению.
it->second.getVal()
Итератор it ссылается на пару "ключ-значение", поэтому для доступа к полю second
эгой пары, которая представляет собой объект класса Data, можно использовать
оператор "- >". Затем для этого можно вызвать метод getVal (). Обратите внимание
на то, что следующий код функционально эквивалентен предыдущему.
(*it).second.getVal()
Поскольку оператор "->" не обязателен для определения в итераторах, то вам,
возможно, часто придется использовать эквивалентный ему механизм доступа к
членам данных объектов.
Итераторы, используемые в контейнерах тар, относятся к категории
двунаправленных, или реверсивных. ■
676 Часть V. Использование библиотек и шаблонов
Значения элементов, хранимых в ассоциативных контейнерах,
молено модифицировать с помощью не const-итераторов, однако
ключевую составляющую элемента модифицировать нельзя, даже
посредством не const-итератора, поскольку такая модификация способна
разрушить отсортированный порядок элементов в контейнере тар.
Поиск элементов
Если вы знаете, что в данном отображении содержится элемент с заданным ключом,
то найти его проще всего с помощью оператора operator []. В использовании
оператора operator [] хорошо то, что он возвращает ссылку на элемент, которую можно
использовать напрямую, не думая о механизме извлечения значения из pair-объекта.
Рассмотрим расширенную версию предыдущего примера, в которой для установки
значения объекта класса Data с ключом, равным 1, вызывается метод setVal ().
#include <map>
#include <iostream>
using namespace std;
class Data
{
public:
Data(int val =0) { mVal = val; }
int getVal() const { return mVal; }
void setVal(int val) {mVal = val; }
// Остальная часть определения опущена.
protected:
int mVal;
};
int main(int argc, char** argv)
{
map<int, Data> dataMap;
dataMaptl] = Data(4);
dataMap[1] = Data(6) ;
dataMaptl].setVal(100);
return (0);
}
Но если вы не знаете, существует ли в отображении нужный вам элемент, то вам,
возможно, и не стоит использовать оператор operator [] , поскольку в случае
отрицательного результата поиска он обязательно вставит новый элемент с заданным ключом.
В качестве альтернативного варианта в контейнере тар предусмотрен метод find(),
который возвращает итератор, ссылающийся на элемент с заданным ключом (если
таковой существует), или итератор "конца", возвращаемый методом end() (если
искомого элемента нет в отображении). Рассмотрим пример использования метода find () для
выполнения все той же модификации значения объекта класса Data с ключом, равным 1.
#include <map>
#include <iostream>
using namespace std;
class Data
{
public:
Глава 21. Библиотека STL: контейнеры и итераторы 677
Data(int val =0) { mVal = val; }
int getVal() const { return mVal; }
void setVal(int val) {mVal = val; }
// Остальная часть определения опущена.
protected:
int mVal;
};
int main(int argc, char** argv)
{
map<int, Data> dataMap;
dataMap[1] = Data(4) ;
dataMap[1] = Data(6) ;
map<int, Data>::iterator it = dataMap.find(1);
if (it != dataMap.end()) {
it->second.setVal(100) ;
}
return (0);
}
Как видите, использовать метод find () не очень удобно, но иногда без него
попросту не обойтись.
Если вам нужно лишь узнать, содержится ли в отображении определенный
элемент с заданным ключом, можно использовать функцию-член count (). Она
возвращает количество элементов в контейнере тар с заданным ключом. Для отображений
результат всегда будет равен 0 или 1, поскольку отображение по своей "природе" не
содержит даже двух элементов с одинаковыми ключами. Пример использования
функции count () показан в следующем разделе.
Удаление элементов
Контейнер тар позволяет удалить элемент, расположенный в конкретной итера-
торной позиции, или удалить все элементы в заданном итераторном диапазоне,
причем в первом случае время выполнения этой операции будет определяться
константой, а во втором — логарифимечески зависеть от длины диапазона. С точки зрения
клиента, эти два метода erase () эквивалентны соответствующим методам,
определенным для последовательных контейнеров. Но ценно то, что в отображении также
предусмотрена еще одна версия метода erase () , предназначенная для удаления
элемента с соответствующим ключом. Рассмотрим пример.
// Директивы #include, определение класса Data и начало
// функции main() опущены.
// За деталями обращайтесь к предыдущим примерам.
map<int, Data> dataMap;
dataMap[1] = Data(4);
cout << "Существует " << dataMap.count(1) « " элементов с ключом 1\п";
dataMap.erase(1) ;
cout << "Существует " « dataMap.count(1) « " элементов с ключом 1\п";
Пример использования отображения: банковский счет
С помощью отображения можно реализовать простую базу данных банковских
счетов. В качестве ключа может служить одно поле класса (class) или структуры (struct),
сохраняемой в отображении в качестве значения. А в данном случае ключом у нас будет
номер счета в банке. Рассмотрим определения классов BankAccount и BankDB.
678 Часть V. Использование библиотек и шаблонов
#include <map>
#include <string>
#include <stdexcept>
using std::map;
using std::string;
using std::outof range,-
class BankAccount
{
public:
BankAccount(int acctNum, const string& name) :
mAcctNum(acctNum), mClientName(name) {}
void setAcctNum(int acctNum) { mAcctNum = acctNum; }
int getAcctNum() const {return (mAcctNum); }
void setClientName(const string& name) {
mClientName = name; }
string getClientNameO const { return mClientName; }
// Другие public-методы опущены.
protected:
int mAcctNum;
string mClientName;
// Другие члены данных опущены.
};
class BankDB
{
public:
BankDB() {}
// Метод добавляет в банковскую базу данных счет acct.
// Если счет с таким номером уже существует, новый счет
// не добавляется. Метод возвращает значение true, если
// счет был добавлен, и false в противном случае.
bool addAccount(const BankAccountb acct);
// Метод удаляет счет acctNum из базы данных.
void deleteAccount(int acctNum);
// Метод возвращает ссылку на счет, представленный
// номером или именем клиента. Генерирует
// исключение outof range, если счет не найден.
BankAccount& findAccount(int acctNum) throw(
outof range) ,-
BankAccount& findAccount(const string& name)
throw(out_of_range);
// Добавляет все счета из базы данных db в эту базу
// данных. Удаляет все счета, хранимые в db.
void mergeDatabase(BankDBfc db);
};
protected:
map<int, BankAccount> mAccounts;
Теперь приведем реализации методов класса BankDB:
#include "BankDB.h"
#include <utility>
using namespace std;
bool BankDB::addAccount(const BankAccountfc acct)
{
Глава 21. Библиотека STL: контейнеры и итераторы 679
// Объявляем переменную для хранения значения,
// возвращаемого методом insert().
pair<map<int, BankAccount>: : iterator, bool> res,-
// Выполняем реальную вставку, используя в качестве
// ключа номер счета в банке.
res = mAccounts.insert(make_pair(acct.getAcctNumO,
acct));
// Возвращаем bool-поле пары, служащее индикатором
// результата выполнения вставки,
return (res.second);
}
void BankDB:rdeleteAccount(int acctNum)
{
mAccounts.erase(acctNum);
}
BankAccount& BankDB;:findAccount(int acctNum)
throw(out_of_range)
{
// Найти элемент по ключу можно с помощью метода find().
map<int, BankAccount>::iterator it = mAccounts.find(
acctNum);
if (it == mAccounts.end()) {
throw (out_of_range(
"Счета с таким номером не существует."));
}
// Помните, что итераторы в контейнерах тар ссылаются на
// пары "ключ-значение".
return (it->second) ,-
}
BankAccountb BankDB::findAccount(const string& name)
throw(out_of_range)
{
//
// Поиск элемента по неключевому атрибуту требует
// линейного просмотра элементов контейнера.
//
for (map<int,
BankAccount>::iterator it = mAccounts.begin();
it != mAccounts .end () ,- ++it) {
if (it->second.getClientName() == name) {
// Found it!
return (it->second);
}
}
throw (out_of range(
"Счета с таким именем не существует."));
}
void BankDB::mergeDatabase(BankDBfc db)
{
// Вставляем копии всех счетов старой базы данных db
//в новую.
mAccounts.insert(db.mAccounts.begin(), db.mAccounts.end());
// Теперь удаляем все счета в старой базе данных,
db.mAccounts.clear();
}
680 Часть V. Использование библиотек и шаблонов
Мультиотображение
Мультиотображение (multimap) — это отображение, которое позволяет
пребывать в контейнере нескольким элементам с одним и тем же ключом." Его интерфейс
практически идентичен интерфейсу отображения, но с небольшими отличиями.
□ В мультиотображении не определен метод operator []. Если контейнер может
содержать несколько элементов с одинаковым ключом, семантика этого
оператора не имеет смысла.
□ Операции вставки в мультиотображение всегда завершаются успешно. Поэтому
multimap-метод insert (), который добавляет один элемент, возвращает только
итератор (ему просто нет необходимости возвращать еще и bool-значение).
Мультиотображения позволяют вставлять идентичные пары "ключ-
значение". Если вы хотите избежать этой избыточности, то перед
вставкой нового элемента вам следует организовать явную проверку
его существования в контейнере.
Самый интересный аспект мультиотображении — поиск элементов. Метод
operator [] использовать нельзя по той простой причине, что он не определен.
Метод find () не назовешь полезным, поскольку он возвращает итератор, который
ссылается на любой из элементов, соответствующих заданному ключу (это необязательно
первый "попавшийся").
К счастью, в мультиотображениях все элементы с одинаковыми ключами хранятся
вместе (можно сказать, "одной колонией"), и для получения итераторов на этот
поддиапазон в multimap-контейнерах предусмотрены специальные методы. Метод
lowerbound () возвращает итератор, ссылающийся на первый из группы элементов,
у которых ключ совпадает с заданным, а метод upper_bound () — итератор,
ссылающийся на элемент, расположенный за последним элементом той же группы. Если
в мультиотображении нет элементов с заданным ключом, итераторы, возвращаемые
методами lower_bound () и upper_bound (), будут равны один другому.
В случае, если вам почему-либо неудобно вызывать два отдельных метода для
получения "граничных" итераторов поддиапазона элементов с заданным ключом,
можете воспользоваться методом equalrange (), который возвращает пару
(объект класса pair) итераторов, которые бы вы получили, обратившись к методам
lower_bound() и upper_bound().
Использование этих методов демонстрируется в следующем разделе.
Методы lower_bound (),
лены и для
характер.
отображений
upper
, но их
bound()
и equal
range()
опреде-
пригодность носит ограниченный
Пример использования мультиотображения: списки друзей
Большинство многочисленных программ обеспечения интерактивной переписки
(чат-программ), предназначенных для работы в сети Internet в реальном времени,
позволяют своим пользователям иметь так называемый "список друзей по чату" ("buddy
list"). Такие программы предоставляют пользователям, упомянутым в "дружеском"
списке, такие специальные привилегии, как разрешение отправлять инициативные,
или незатребованные, сообщения.
Один из способов реализации "дружеских" списков для чат-программ состоит в
сохранении информации именно в мультиотображениях. Причем для всех пользователей
Глава 21. Библиотека STL: контейнеры и итераторы 681
было бы достаточно одного контейнера multimap. Каждый элемент контейнера может
хранить информацию об одном чат-друте для некоторого пользователя. Ключом может
служить идентификатор (например имя) пользователя, а значением — идентификатор его
чат-друта. Например, если имена авторов этой книги входили бы в чат-списки друг друга,
то существовало бы две записи (два элемента) примерно такого вида: ключу "Николас Со-
лтер" соответствует значение "Скотт Клепер", а ключу "Скотт Клепер"— значение
"Николас Солтер". Поскольку контейнер multimap позволяет хранить несколько
значений с одинаковым ключом, то, значит, одному и тому же пользователю разрешается
иметь несколько друзей по чату. Итак, рассмотрим определение класса BuddyList.
#include <map>
#include <string>
#include <list>
using std
using std
using std
:multimap;
: string ,-
:list;
class BuddyList
{
public:
BuddyList();
//
// Метод добавляет значение buddy в качестве "друга"
// для ключа name.
//
void addBuddy(const string& name, const strings buddy);
//
// Метод удаляет значение buddy как "друга" ключа name.
//
void removeBuddy(const string& name,
const string& buddy);
//
// Метод возвращает true, если значение buddy является
' // "другом" для ключа name, в противном случае ^ false.
//
bool isBuddy(const strings name,
const strings buddy) const;
//
// Считывает список всех "друзей" ключа name.
//
list<string> getBuddies (const strings name) const ,-
protected:
multimap<string, string> mBuddies,-
private:
// Предотвращаем присваивание и передачу по значению.
BuddyList(const BuddyLists src);
BuddyListS operator=(const BuddyLists rhs);
};
Теперь рассмотрим реализацию методов этого класса, в которой
демонстрируется использование методов мультиотображения lower_bound (), upper_bound ()
и equal_range().
#include "BuddyList.h"
using namespace std;
BuddyList::BuddyList()
{
682 Часть V. Использование библиотек и шаблонов
void BuddyList::addBuddy(const strings name,
const strings buddy)
{
// Убеждаемся, что этого друга buddy еще нет в списке.
// Мы не хотим вставлять идентичную копию пары
// "ключ-значение".
if (!isBuddy(name, buddy)) {
mBuddies. insert (make_j?air (name, buddy) ) ,-
void BuddyList::removeBuddy(const string& name,
const strings buddy)
// Объявляем два итератора в мультиотображении.
multimap<string, string>::iterator start, end;
// Получаем начало и конец диапазона элементов с ключом
// name. В целях демонстрации используем методы
// lowerbound() и upper_bound(), хотя можно было бы
// обойтись вызовом метода equal_range(). -
start = mBuddies.lower_bound(name);
end = mBuddies.upper_bound(name);
// Опрашиваем элементы с ключом name, чтобы найти
// элемент с заданным значением buddy,
for (start; start != end; ++start) {
if (start->second == buddy) {
// Есть совпадение! Удаляем этот элемент из
// мультиотображения.
mBuddies.erase(start);
break;
}
bool BuddyList::isBuddy(const strings name,
const strings buddy) const
{
// Объявляем два итератора в мультиотображении.
multimap<string, string>: .-constiterator start, end;
// Получаем начало и конец диапазона элементов с
// ключом name. В целях демонстрации используем методы
// lower_bound() и upper_bound(), хотя можно было бы
// обойтись вызовом метода equal_range().
start = mBuddies.lowerbound(name);
end = mBuddies.upperbound(name);
// Опрашиваем элементы с ключом name, чтобы найти
// элемент с заданным значением buddy. Если элементов
// с ключом name нет, значение start будет равно
// значению end, и тогда тело цикла не выполнится.
for (start; start ! = end; ++start) {
if (start->second == buddy) {
// Сопадение обнаружено!
return (true);
}
}
// Совпадение не обнаружено.
return (false);
.}
list<string> BuddyList::getBuddies(
const strings name) const
{
// Создаем переменную для хранения пары итераторов.
pair<multimap<string, string>::const_iterator,
Глава 21. Библиотека STL: контейнеры и итераторы 683
}
multimap<string, string>::const_iterator> its;
// Получаем пару итераторов, которые отмечают диапазон,
// содержащий элементы с ключом name,
its = mBuddies.equal_range(name);
// Создаем список всех друзей, относящихся к этому
// диапазону (запоминаем все buddies-значения с ключом
// name).
list<string> buddies;
for (its.first; its.first != its.second; ++its.first) {
buddies.pushback((its.first)->second);
}
return (buddies);
Обратите внимание на то, что метод removeBuddy () не может просто
использовать версию метода erase (), которая удаляет все элементы с заданным ключом,
поскольку здесь нужно, чтобы она удалила только один элемент с этим ключом, а не все.
Заметьте также, что метод getBuddies () не может использовать метод insert ()
для вставки элементов в диапазон списка, возвращаемый методом equal_range (),
поскольку элементы, адресуемые гш1^та]>итератораторами, представляют собой
пары "ключ-значение", а не строки (string). В методе getBuddies () необходимо
явно обеспечить обход элементов списка, чтобы выделить string-значение из пары
"ключ-значение" и поместить его в новый список, возвращаемый этим методом.
Вот как выглядит простой тест класса Buddy List.
#include "BuddyList.h"
#include <iostream> t
using namespace std; J
int main(int argc, char** argv)
{
BuddyList buddies;
buddies.addBuddy("Harry Potter", "Ron Weasley");
buddies.addBuddy("Harry Potter", "Hermione Granger");
buddies.addBuddy("Harry Potter", "Hagrid");
buddies.addBuddy("Harry Potter", "Draco Malfoy");
// He правильно! Удаляем Draco,
buddies.removeBuddy("Harry Potter", "Draco Malfoy");
buddies.addBuddy("Hagrid", "Harry Potter");
buddies.addBuddy("Hagrid", "Ron Weasley");
buddies.addBuddy("Hagrid", "Hermione Granger");
list<string> harryBuds = buddies.getBuddies(
"Harry Potter");
cout << "Друзья Harry: \n";
for (list<string>::const_iterator it = harryBuds.begin();
it != harryBuds.end(); ++it) {
cout << "\t" << *it << endl;
}
return (0);
Множество
Контейнер set во многом напоминает отображение. Различие между ними
состоит в том, что в контейнере set хранятся не пары "ключ-значение", а значения,
которые сами являются ключами. Множества предназначены для хранения данных, не со-
684 Часть V. Использование библиотек и шаблонов
держащих явно определенных ключей, но которые могут быть отсортированы для
быстрого выполнения операций вставки, поиска и удаления.
Интерфейс множества практически идентичен интерфейсу отображения. Основное
различие между ними в том, что множество не поддерживает оператор operator [].
Кроме того, хотя в стандарте это явно не отмечено, в большинстве реализаций
поведение set-итератора идентично типу const_iterator, чтобы нельзя было
модифицировать элементы множества с помощью итераторов. Даже если ваша версия библиотеки
STL позволяет модифицировать элементы множества с помощью итераторов, вам
следует избегать этого, поскольку при модификации элементов множества во время их
пребывания в контейнере возможно разрушение отсортированного порядка.
Пример использования множества: список управления доступом
Один способ обеспечения базовой безопасности в компьютерной системе
реализуется через списки управления доступом. Каждый логический объект в такой системе
(файл или устройство) имеет список пользователей с разрешениями на получение
доступа к нему (этому логическому объекту). Имена пользователей можно добавлять
к такому списку и удалять из него, чтобы к соответствующему логическому объекту
могли получать доступ только пользователи со специальными привилегиями. Контейнер
set по своей природе прекрасно подходит для представления списка управления
доступом. Вы могли бы использовать для каждого логического объекта по одному
множеству, содержащему имена всех пользователей, которым разрешено обращаться к этому
объекту. Рассмотрим определение класса, реализующего список управления доступом.
#include <set>
#include <string>
#include <list>
using std::set;
using std::string;
using std::list;
class AccessList
{
public:
AccessList() {}
//
// Метод добавляет имя пользователя в список разрешений.
//
void addUser(const strings user);
//
// Метод удаляет имя пользователя из списка разрешений.
//
void removeUser(const strings user);
//
^ // Метод возвращает значение true, если имя пользователя
// находится в списке разрешений.
//
bool isAllowed(const strings user) const;
//
// Метод возвращает список всех пользователей, имеющих
// разрешения.
//
list<string> getAllUsers() const;
Глава 21. Библиотека STL: контейнеры и итераторы 685
protected:
set<string> mAllowed;
};
Вот как выглядят определения этих методов.
#include "AccessList.h"
using namespace std;
void AccessList::addUser(const string& user)
{
mAllowed.insert(user);
}
void AccessList::removeUser(const strings user)
{
mAllowed.erase(user);
}
bool AccessList::isAllowed(const string& user) const
{
return (mAllowed.count(user) == 1);
}
list<string> AccessList::getAllUsers() const
{
list<string> users;
users.insert(users.end(), mAllowed.begin(),
mAllowed.end());
return (users);
}
А теперь приведем простую тестовую программу.
#include "AccessList.h"
#include <iostream>
#include <iterator>
using namespace std;
int main(int argc, char** argv)
{
AccessList fileX;
fileX.addUser("nsolter");
fileX.addUser("klep");
fileX.addUser("baduser");
fileX.removeUser("baduser");
if (fileX.isAllowed("nsolter") ) {
cout << "Пользователь nsolter имеет разрешение.\n";
}
if (fileX.isAllowed("baduser")) {
cout << "Пользователь baduser имеет разрешение.\n";
}
list<string> users = fileX.getAllUsers();
for (list<string>::const_iterator it = users.begin();
it != users.endO; ++it) {
cout << *it << " ";
}
cout << endl;
return (0);
}
686 Часть V. Использование библиотек и шаблонов
Мультимножество
Мультимножество (multiset) и множество (set) находятся примерно в таких же
отношениях, как мультиотображение (multimap) и отображение (тар).
Мультимножество поддерживает все операции множества, но позволяет хранить одинаковые
элементы. Обратите внимание на то, что одинаковыми могут считаться элементы,
являющиеся объектами, которые признаны равными на основе применения оператора
operator== даже в том случае, если они не идентичны. Мы не приводим пример
использования мультимножества, поскольку оно подобно использованию множества
и мультиотображения.
Другие контейнеры
Как упоминалось выше, в языке C++ есть и другие средства, которые в той или
иной степени взаимодействуют с библиотекой STL, например массивы, string-
объекты, потоки и битовые множества (bitset).
Массивы как STL-контейнеры
Вспомните, что "обычные" указатели— это, по сути, те же итераторы, поскольку
они поддерживают требуемые операторы. Это означает, что с обычными С++-
массивами можно работать как с STL-контейнерами, если в качестве итераторов
использовать указатели на их элементы. Массивы, безусловно, не поддерживают такие
методы, как size (), empty () , insert () и erase (), и поэтому их нельзя считать
настоящими STL-контейнерами. Тем не менее, поскольку они обладают
итераторами "в виде" указателей, их можно использовать в алгоритмах, описанных в главе 22,
и в некоторых методах, представленных в этой главе.
Например, вы могли бы скопировать все элементы массива в вектор с помощью
векторного метода insert (), который принимает итераторный диапазон из любого
контейнера. Прототип метода insert () имеет следующий вид.
template <typename InputIterator> void insert(
iterator position,
Inputlterator first,
Inputlterator last);
i
Если в качестве источника вы хотите использовать int-массив, то шаблонным
типом входного итератора Inputlterator станет тип int*. Рассмотрим пример.
#include <vector>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
int arr[10]; // обычный С++-массив
vector<int> vec,- // STL-вектор
//
// Инициализируем каждый элемент массива значением
// его индекса.
//
Глава 21. Библиотека STL: контейнеры и итераторы 687
}
for (int i = 0; i < 10; i++) {
arr[i] = i;
}
//
// Вставляем содержимое массива в конец вектора.
//
vec.insert(vec.end(), arr, arr + 10);
// Выводим содержимое вектора.
for (i = 0; i < 10; i++) {
cout << vec[i] << " ";
}
return (0);
Обратите внимание на то, что итератор, ссылающийся на первый элемент
массива, попросту представляет собой адрес первого элемента. Как упоминалось в главе 13,
имя массива интерпретируется как адрес первого элемента. Итератор, ссылающийся
на конец массива, должен указывать не на последний его элемент, а на следующий за
ним, поэтому его адрес равен адресу первого элемента, увеличенному на 10.
Строки как STL-контейнеры
Строку (string) можно рассматривать как последовательный контейнер с
символами. В этом случае вы не должны удивляться заявлению о том, что С++-класс
string— это готовый последовательный контейнер. Он содержит методы begin ()
и end (), которые возвращают строковые итераторы; методы insert (), erase (),
size () и empty (), а также все остальные составляющие "джентльменского набора"
последовательного контейнера. Класс string во многом подобен вектору, ведь в нем
определены такие методы, как reserve () и capacity (). Но в отличие от векторов,
строки не требуют хранения своих элементов в последовательных областях памяти.
Кроме того, класс string не поддерживает такие методы, как pushback ().
В действительности С++-класс string представляет собой typedef-определение
char-реализации шаблонного класса basic_string. Но для простоты мы все же
будем использовать обозначение string. Вся информация, которая здесь приводится
относительно класса string, в равной степени применима как к типу wstring, так
и к другим реализациям шаблона ba s i c_s t r i ng.
Класс string можно использовать в качестве STL-контейнера подобно классу
vector. Рассмотрим пример.
#include <string>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
string strl; i
strl.insert(strl.end()
strl.insert(strl.end()
strl.insert(strl.end()
strl.insert(strl.end()
strl.insert(strl.end()
h
e
1
1
о
/
688 Часть V. Использование библиотек и шаблонов
for (string::const_iterator it = strl.begin();
it != strl.endO; ++it) {
cout << *it;
}
cout << endl;
return (0);
}
Помимо методов последовательных STL-контейнеров, string-объекты могут
использовать множество других полезных методов и friend-функций. При этом
необходимо отметить, что интерфейс string-контейнера— это действительно
показательный пример хаотического интерфейса, один из недостатков которого рассмотрен
в главе 5. Полностью с интерфейсом класса string можно ознакомиться с помощью
Web-ресурса Standard Library Reference, а в этом разделе мы просто показали вам, как
строки (string-объекты) можно использовать в качестве STL-контейнеров.
Потоки как STL-контейнеры
Входные и выходные потоки нельзя назвать контейнерами в традиционном
смысле: они не предназначены для хранения элементов. Но их можно рассматривать как
последовательности элементов, и тогда есть все основания утверждать, что они
обладают характеристиками, присущими всем STL-контейнерам. С++-потоки не
предоставляют никаких характерных для STL методов напрямую, но библиотека STL
поддерживает итераторы типа istream_iterator и ostream_iterator, которые
позволяют "итерировать" содержимое входных и выходных потоков. Их
использование продемонстрировано в главе 23.
Битовое множество
Битовое множество (bitset) представляет собой абстракцию последовательности
битов фиксированной длины. Вспомним, что бит может представлять два значения,
например 1 и 0, "включено" и "выключено" или ИСТИНА и ЛОЖЬ. Для битового
множества также популярны такие термины, как "установить" (бит) и "сбросить" его.
Работая с bitset-наборами, можно "переключать" биты или "перебрасывать" их из
одного состояния в другое.
Битовое множество— не настоящий STL-контейнер: он имеет фиксированный
размер, не принимает шаблонный параметр для типа элемента и не поддерживает
итеративный доступ к своим элементам. И все же это очень полезная утилита, которая
часто стоит в одном ряду с остальными контейнерами, поэтому мы кратко
остановимся на ней. Подробнее об операциях над битовыми множествами можно ознакомиться
с помощью Web-ресурса Standard Library Reference.
Основные операции над битовыми множествами
Битовое множество, определенное в заголовочном файле <bitset>,
шаблонизировано по количеству сохраняемых им битов. Конструктор по умолчанию
инициализирует все поля битового множества нулями. Альтернативный конструктор создает
bitset-объект на основе строки типа string, состоящей из нулей и единиц.
Корректировать значения отдельных битов можно с помощью методов set (),
reset () и flip (), а доступ к конкретным битам можно получить с помощью
перегруженного оператора operator [], который также позволяет устанавливать нужные
биты. Обратите внимание на то, что оператор operator [], примененный к не
Глава 21. Библиотека STL: контейнеры и итераторы 689
const-объекту, возвращает объект-посредник, которому можно присвоить булево
значение, вызвать метод flipO или инвертировать его (т.е. выполнять операцию
отрицания) с помощью оператора "~". Доступ к его отдельным полям можно также
получить с помощью метода test ().
Кроме того, над bit set-объектами можно выполнять обычные операции ввода-
вывода, и в этом случае они обрабатываются как строки, состоящие из нулей и единиц.
Рассмотрим небольшой пример.
#include <bitset>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
bitset<10> myBitset;
myBitset.set (3);
myBitset.set (6);
myBitset[8] = true,-
myBitset[9] = myBitset[3];
if (myBitset.test(3)) {
cout << "Бит З установлен!\n";
}
cout << myBitset << endl;
return (0);
}
Результаты выполнения этой программы таковы.
Бит 3 установлен!
1101001000
Обратите внимание на то, что крайний слева символ в выходной строке считается
самым старшим по нумерации битом.
Поразрядные операторы
Помимо основных методов манипуляции битами, битовое множество
поддерживает реализации всех поразрядных операторов: &, |, А, ~, <<, >>, &=, |=, А=, <<=
и >>=. Они действуют так, как если бы обрабатывали "настоящую последовательность"
битов. Рассмотрим пример.
#include <bitset>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
string strl = "0011001100";
string str2 = "0000111100";
bitset<10> bitsOne(strl), bitsTwo(str2);
bitset<10> bitsThree = bitsOne & bitsTwo;
cout << bitsThree << endl;
bitsThree <<= 4;
cout << bitsThree << endl;
return (0);
}
690 Часть V. Использование библиотек и шаблонов
При выполнении эта программа генерирует такие результаты.
0000001100
0011000000
Пример использования битового множества: представление
кабельного канала
В качестве примера рассмотрим использование битового множества для
отслеживания каналов для кабельных абонентов. Каждому абоненту можно было бы поставить
в соответствие bit set-набор каналов, который он оплачивает. Такая система могла
бы поддерживать даже "пакеты" каналов, также представляемые в виде битовых
множеств, которые бы обозначали обычно подписываемые комбинации каналов.
В качестве примера такой модели рассмотрим нижеследующий класс CableCompany.
В нем используется два отображения (map-контейнера), элементами которых
являются пары значений типа "string-bitset". В этих отображениях должны храниться
кабельные пакеты, а также данные об абонентах.
#include <bitset>
#include <map>
#include <string>
#include <stdexcept>
using std::map;
using std::bitset;
using std::string;
using std::out_of_range; x
const int kNumChannels = 10;
class CableCompany
{
public:
CableCompany() {}
// Метод добавляет в базу данных пакет с заданными
// каналами.
void addPackage(const strings packageName,
const bitset<kNumChannels>& channels);
// Метод удаляет из базы данных заданный пакет,
void removePackage(const string& packageName);
// Метод добавляет в базу данных имя клиента с начальным
// набором каналов, заданным в параметре package.
// Если имя клиента неверно, генерирует
// исключение out_of_range.
void newCustomer(const string& name,
const strings package)
throw (out_of_range);
// Метод добавляет в базу данных имя клиента с начальным
// набором каналов, заданным в параметре channels,
void newCustomer(const strings name,
const bitset<kNumChannels>& channels);
// Метод добавляет канал в профиль клиентов.
void addChannel(const strings name, int channel) ,-
// Метод удаляет канал из профиля клиентов.
void removeChannel(const strings name, int channel);
Глава 21. Библиотека STL: контейнеры и итераторы 691
// Метод добавляет заданный пакет в профиль клиентов.
void addPackageToCustomer(const stringb name,
const strings package);
// Метод удаляет из базы данных имя заданного клиента.
void deleteCustomer(const strings name);
// Метод считывает каналы, на которые подписан данный
// клиент. Генерирует исключение outof range, если
// параметр name содержит недействительное имя клиента.
bitset<kNumChannels>S
getCustomerChannels(const strings name)
throw (out_of_range);
protected:
typedef map<string, bitset<kNumChannels> > MapType;
MapType mPackages, mCustomers;
};
Теперь рассмотрим реализации объявленных выше методов.
#include "CableCompany.h"
using namespace std;
void CableCompany::addPackage(const strings packageName,
const bitset<kNumChannels>S channels)
• {
// Создаем пару "ключ-значение" и вставляем ее в
// отображение mPackages.
mPackages. insert (make_pair (packageName, channels) ) ,-
void CableCompany::removePackage(const strings packageName)
{
// Удаляем пакет из отображения mPackages.
mPackages.erase(packageName);
}
void CableCompany::newCustomer(const strings name,
const strings package)
throw (out_of_range)
{
// Получаем ссылку на заданный пакет.
MapType::const_iterator it = mPackages.find(package);
if (it == mPackages.end() ) { «*.
// Такой пакет не существует. Генерируем исключение.
throw (out_of_range("Недействительный пакет"));
} else {
// Создаем запись с bitset-представлением этого пакета.
// Обратите внимание на то, что it - это ссылка на
// пару "имя-bitset", причем bitset содержится в
// поле second.
mCustomers. insert (make_pair (name, it->second) ) ,-
void CableCompany:mewCustomer(const strings name,
const bitset<kNumChannels>S channels)
// Добавляем в отображение mCustdmers пару "клиент-каналы".
mCustomers.insert(make_pair(name, channels));
void CableCompany::addChannel(const strings name, int channel)
692 Часть V. Использование библиотек и шаблонов
{
// Находим ссылку на клиента.
МарТуре::iterator it = mCustomers. find (name) ,-
if (it != mCustomers.end()) {
// Мы отыскали заданного клиента,- устанавливаем канал.
// Обратите внимание на то, что it - ссылка на
// пару "name-bitset", a bitset содержится в
// поле second.
it->second.set(channel);
void CableCompany::removeChannel(const strings name,
int channel)
{
// Находим ссылку на клиента.
МарТуре::iterator it = mCustomers.find(name);
if (it != mCustomers.end()) {
// Мы нашли заданного клиента; удаляем канал.
// Обратите внимание на то, что it - ссылка на
// пару "name-bitset", в которой bitset занимает
// поле second.
it->second.reset(channel);
void CableCompany::addPackageToCustomer(const strings name,
const strings package)
{
// Находим пакет.
МарТуре::iterator itPack = mPackages.find(package);
// Находим клиента.
МарТуре :: iterator itCust = mCustomers. find (name) ,-
if (itCust != mCustomers.end() S&
itPack != mPackages.end()) {
// Обновлять данные можно только в том случае, если
// найден как пакет, так и клиент. Для набора
// существующих каналов клиента выполняем операцию ИЛИ
// с пакетом. Обратите внимание на то, что itCust и
// itPack - ссылки на пары "name-bitset", в которых
// bitset занимает поле second.
itCust->second |= itPack->second,-
} *
}
void CableCompany::deleteCustom^r(const strings name)
{
// Удаляем имя клиента, заданное аргументом name.
mCustomers.erase(name);
}^
bitset<kNumChannels>&
CableCompany::getCustomerChannels(const strings name)
throw (out_of range)
{
// Находим клиента.
МарТуре::iterator it = mCustomers.find(name);
if (it != mCustomers.end()) {
// Клиент обнаружен!
// Обратите внимание на то, что it - ссылка на
// пару "name-bitset", в которой bitset занимает
// поле second.
return (it->second);
}
Глава 21. Библиотека STL: контейнеры и итераторы 693
// Клиент не найден. Генерируем исключение,
throw (out_of_range("Клиента с таким именем нет."));
}
Наконец, рассмотрим простую программу, которая демонстрирует использование
класса Cable Company.
#include "CableCorapany.h"
^include <iostream>
using namespace std;
int main(int argc, char** argv)
{
CableCompany myCC;
string basic_pkg = "1111000000";
string premium_pkg = "1111111111";
string sports_pkg = "0000100111";
myCC.addPackage("basic", bitset<kNumChannels>(basic_pkg));
myCC.addPackage("premium",
bitset<kNumChannels>(premium_pkg));
myCC.addPackage("sports",
bitset<kNumChannels>(sports_pkg));
my CC.newCustomer("Nicholas Solter", "basic");
myCC.addPackageToCustomer("Nicholas Solter", "sports");
cout << myCC.getCustomerChannels("Nicholas Solter")
<< endl ,-
return (0);
}
Резюме
В этой главе вы познакомились с контейнерами стандартной библиотеки
шаблонов. На примере различных фрагментов программного кода вы получили
представление о возможных способах их использования. Надо полагать, вы оценили могущество
таких STL-контейнеров, как vector, deque, list, stack, queue, priority_queue,
map, multimap, set, multiset, string и bitset. Даже если вы и не станете немед-,
ленно включать их в свои программы, то теперь, узнав об их возможностях, вы
подсознательно "зарезервируете" их для будущих проектов.
Теперь, когда вы узнали о существовании таких полезных контейнеров, можете
смело приступать к освоению материала следующей главы, в которой иллюстрируется
истинная красота STL-элементов путем рассмотрения обобщенных алгоритмов. В
главе 23, третьей и завершающей главе, посвященной STL, описываются более сложные
библиотечные средства и приводится пример реализации контейнера и его итераторов.
7
Освоение
STL-алгоритмов
и функциональных
объектов
i
Как упоминалось в главе 21, библиотека STL содержит обширную коллекцию
обобщенных структур данных. Большинство библиотек этим и ограничивается, но
STL может предложить дополнительный ассортимент обобщенных алгоритмов,
которые (с некоторыми исключениями) можно применить к элементам из любого
контейнера. Используя эти алгоритмы, можно находить элементы в константах,
сортировать их, обрабатывать и выполнять множество других операций. Прелесть таких
алгоритмов в том, что они не зависят не только от типа базовых элементов, но и от
типов самих контейнеров. Действие алгоритмов основано исключительно на
использовании итераторных интерфейсов.
Многие алгоритмы принимают объекты обратного вызова (callback): указатель на
функцию или другие программные элементы, которые ведут себя подобным образом
(например объект с перегруженным оператором operator ()). Удобно то, что STL
предоставляет набор классов, которые можно использовать для создания объектов
Глава 22. Освоение STL-алгоритмов и функциональных объектов 695
обратного вызова для таких алгоритмов. Эти объекты называются функциональными
объектами (function object) или просто функторами (functor).
В этой главе мы рассмотрим такие темы.
□ Обзор алгоритмов и три примера: find (), f ind_if () и accumulate ().
□ Подробно о функциональных объектах.
□ Встроенные классы функциональных объектов: арифметические,
логические и функциональные объекты сравнения.
□ Адаптеры функциональных объектов.
□ Как создать собственный функциональный объект.
□ Детальное рассмотрение STL-алгоритмов.
□ Вспомогательные алгоритмы.
□ Немодифицирующие алгоритмы: поиска, сравнения, численной и
операционной обработки.
□ Модифицирующие алгоритмы.
□ Алгоритмы сортировки.
□ Алгоритмы установки.
□ Пример: аудит регистрации участников голосования.
Обзор алгоритмов
"Магическая сила" алгоритмов состоит в том, что они работают при
посредничестве итераторов, а не самих контейнеров. Это означает, что они не привязаны к
специфике реализации контейнеров. Все STL-алгоритмы реализованы как шаблоны
функций, которые в качестве шаблонного параметра-типа принимают итераторный
тип. Сами же итераторы при этом задаются как аргументы функции. Как упоминалось
в главе 11, шаблонные функции могут "сделать вывод" о шаблонном типе на основе
аргументов функции, поэтому алгоритмы в общем случае можно вызывать так, как
будто они — обычные функции, а не шаблоны.
Аргументы-итераторы обычно представляют собой итераторные диапазоны. Как
упоминалось в главе 21, итераторные диапазоны являются полуоткрытыми, т.е. такими,
которые включают первый элемент диапазона, но не включают последний. Другими словами,
последний итератор в действительности играет роль признака "запредельности".
Некоторые алгоритмы принимают дополнительные шаблонные параметры-типы
и аргументы, которые иногда называются функциями обратного вызова. Они могут быть
представлены в виде указателей на функции или функциональных объектов.
Функциональные объекты более детально рассматриваются в следующем разделе.
А теперь самое время ближе познакомиться с алгоритмами, и лучше всего это сделать
на примерах. Разобравшись в работе некоторых из них, вы без труда поймете, как
работают остальные. В этом разделе подробно описаны алгоритмы find'O, findif ()
и accumulate (), в следующем представлены функциональные объекты, после чего нам
останется рассмотреть использование упомянутых алгоритмов на практике.
696 Часть V. Использование библиотек и шаблонов
Алгоритмы find () MfindifO
Алгоритм find () предназначен для поиска конкретного элемента в диапазоне,
заданном итераторами. Этот алгоритм можно применять к любому типу контейнера. Он
возвращает итератор, ссылающийся на найденный элемент, или итератор конца
диапазона. Обратите внимание на то, что диапазон, задаваемый при вызове алгоритма
find (), не обязательно должен быть полным диапазоном элементов в контейнере; он
может быть его подмножеством.
Если алгоритму find() не удается найти заданный элемент, он
возвращает итератор, совпадающий с итератором конца диапазона,
указанным в вызове функции, а не с конечным итератором базового
контейнера.
Рассмотрим пример использования алгоритма f ind ().
#include <algorithm>
#include <vector>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
int num,-
vector<int> myVector;
while (true) {
cout << "Введите число для вставки (0 для останова): ";
cin » num;
if (num ==0) {
break;
}
myVector.pushback (num) ,-
}
while (true) {
cout << "Введите число, которое нужно найти
Ч>(0 для останова): ";
cin » num,-
if (num ==0) {
break;
}
vector<int>::iterator it = find(myVector.begin(),
myVector.end(), num);
if (it == myVector.end()) {
cout << "Число " << num << " не найдено." << endl,-
} else {
cout << "Найдено число " << *it << endl;
}
}
return (0);
}
Для поиска заданного элемента во всем векторе в качестве аргументов функции
find () используются итераторы myVector. begin () и myVector. end ().
Результаты выполнения этой программы выглядят так.
Глава 22. Освоение STL-алгоритмов и функциональных объектов 697
Введите число для вставки (0 для останова): 3
Введите число для вставки (0 для останова): 4
Введите число для вставки (0 для останова): 5
Введите число для вставки (0 для останова): б
Введите число для вставки (0 для останова): О
Введите число, которое нужно найти (0 для останова): 5
Найдено число 5
Введите число, которое нужно найти (0 для останова): 8
Число 8 не найдено.
Введите число, которое нужно найти (0 для останова): 4
Найдено число 4
Введите число, которое нужно найти (0 для останова): 2
Число 2 не найдено.
Введите число, которое нужно найти (0 для останова): О
В некоторых контейнерах (например тар и set) определены собственные версии
алгоритма find (), реализованные в виде методов класса.
Если в контейнере определен метод с таким лее поведением, как
у обобщенного алгоритма, вам следует использовать этот метод,
поскольку он выполняется быстрее. Например, время работы
обобщенного алгоритма f ind() характеризуется линейной зависимостью
от объема контейнера (даже для итератора отображения), а метода
find () —логарифмической.
Алгоритм find_if () подобен алгоритму find(), за исключением того, что он
принимает не элемент, который необходимо найти в контейнере, а предикативную
функцию обратного вызова. Предикат, как известно, возвращает значение true или
false. Функция findif () вызывает предикат для каждого элемента в заданном
диапазоне до тех пор, пока этот предикат (для некоторого элемента) не возвратит
значение true. Затем функция f indif () возвращает итератор, который ссылается
на этот элемент. При выполнении следующей программы пользователь должен
ввести результаты тестирования, после чего программа "дает заключение" о том, есть ли
среди введенных оценок "идеальные" показатели. К идеальным относятся оценки,
равные 100 баллам (или превышающие их). Эта программа подобна предыдущему
примеру (отличия выделены темным фоном).
#include <algorithm>
#include <vector>
#include <iostream>
using namespace std;
bool perfectScore(int nura)
{
return (num >= 100);
}
int main(int argc, char** argv)
{
int num;
vector<int> myVector;
while (true) {
cout << "Введите оценку для вставки (0 для останова): ";
cin >> num;
if (num == 0) {
break;
698 Часть V. Использование библиотек и шаблонов
}
myVector.push_back(num);
}
vector<int>::iterator it = find_if(myVector.begin(),
myVector.end(),
perfectScore);
if (it == myVector.end()) {
cout << "Идеальных оценок нет.\п";
} else {
cout << "Найдена \"идеальная\" оценка " << *it << endl;
}
return (0);
}
Эта программа передает указатель на предикативную функцию perf ectScore (),
которую алгоритм f ind_if () вызывает для каждого элемента до тех пор, пока этот
предикат не возвратит значение true.
К сожалению, в библиотеке STL не предусмотрено алгоритма find_all (),
который бы возвращал все элементы контейнера, отвечающие заданному предикату. Как
написать собственный алгоритм f ind_all (), показано в главе 23.
Алгоритм accumulate ()
Иногда необходимо просуммировать элементы контейнера. Для этого
предназначена функция accumulate (). В самой общей форме она вычисляет сумму элементов
в заданном диапазоне. Например, следующая функция вычисляет среднее
арифметическое последовательности целочисленных значений в векторе. Среднее
арифметическое — это просто сумма всех элементов, разделенная на их количество.
#include <numeric>
#include <vector>
using namespace std,-
double arithmeticMean(const vector<int>& nums)
{
double sum = accumulate(nums.begin(), nums.end(), 0);
return (sum / nums.size()) ;
}
Обратите внимание на то, что функция accumulate () объявлена в заголовке
<numeric>, а не в <algorithm>. Заметьте также, что функция accumulate () в
качестве третьего аргумента принимает начальное значение суммы, которое в данном
случае должно быть равно 0.
Вторая форма функции accumulate () позволяет задавать выполняемую
операцию (вместо сложения). Эта операция принимает форму двойного обратного вызова.
Предположим, что нам нужно вычислить среднее геометрическое, которое
представляет собой произведение всех чисел последовательности, возведенное в степень,
обратную размеру этой последовательности. В данном случае нам стоит использовать
функцию accumulate () для вычисления произведения чисел, а не их суммы.
Учитывая вышесказанное, мы могли бы написать следующее.
#include <numeric>
#include <vector>
#include <cmath>
Глава 22. Освоение STL-алгоритмов и функциональных объектов 699
using namespace std;
int product(int numl, int num2)
{
return (numl * num2);
}
double geometricMean(const vector<int>& nums)
{
double mult = accumulate(nums.begin(), nums.end(),
1, product);
return (pow(mult, 1.0 / nums.size()));
}
Обратите внимание на то, что функция product () передается функции
accumulate () в качестве функции обратного вызова и что начальное значение для
накопления произведения равно 1, а не 0. В следующем разделе мы покажем, как использовать
функцию accumulate () в функции geometricMean () без написания функции
обратного вызова.
Функциональные объекты
Теперь, познакомившись с несколькими STL-алгоритмами, вы сможете оценить
функциональные объекты. Как упоминалось в главе 16, оператор вызова функций
перегружается в таком классе, объекты которого можно использовать вместо указателей
на функции. Такие объекты и называются функциональными или просто функторами.
Многие STL-алгоритмы, такие как find_if () и вторая форма accumulate (),
требуют в качестве одного из параметров использовать указатель на функцию.
Применяя такие функции, вместо указателя на функцию им молено передать функтор.
Этот факт, сам по себе, не должен заставлять вас прыгать от радости. И хотя вы
можете написать собственные классы функторов, вся прелесть состоит в том, что C++
имеет несколько встроенных классов функторов, которые выполняют большинство
самых популярных операций по технологии обратного вызова. В этом разделе
показано, как использовать такие классы.
Все встроенные классы функциональных объектов определены в
заголовочном файле <functional;».
Функциональные объекты арифметических операторов
В C++ определены шаблонные классы функторов для пяти бинарных
арифметических операторов: plus, minus, multiplies, divides и modulus. Кроме того,
поддерживается унарный оператор negate. Эти классы шаблонизированы по типу
операндов и представляют собой оболочки для реальных операторов. Они принимают
один или два параметра шаблонного типа, выполняют операцию и возвращают
результат. Рассмотрим пример использования шаблонного класса plus.
#include <functional>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
700 Часть V. Использование библиотек и шаблонов
{
plus<int> myPlus;
int res = myPlus(4, 5);
cout << res << endl;
return (0);
}
Этот пример довольно примитивен, и к тому же нет причин для использования
шаблонного класса plus там, где можно обойтись прямым применением оператора
operators. Но преимущество функциональных объектов арифметических
операторов состоит в том, что их можно передавать в алгоритмы как функции обратного
вызова, что невозможно сделать с обычными арифметическими операторами.
Например, в реализации функции geometricMean (), представленной выше в этой
главе, использовалась функция accumulate () с обратным вызовом на основе указателя
на функцию умножения двух целочисленных значений. Теперь перепишем функцию
geometricMean () с использованием функционального объекта multiplies.
#include <numeric>
#include <vector>
#include <cmath>
#include <functional>
using namespace std;
double geometricMean(const vector<int>& nums)
{
double mult = accumulate(nums.begin() , nums.endO, 1,
multiplies<int>() ) ;
return (pow(mult, 1.0 / nums.size()));
}
Выражение multiplies<int> () создает новый объект класса multiplies,
реализуя его для типа int.
Остальные функциональные объекты арифметических операторов ведут себя
аналогично.
Функциональные объекты арифметических операторов— это
просто оболочки, построенные вокруг арифметических операторов.
Предполагая использовать функциональные объекты, в качестве
объектов обратного вызова в алгоритмах, убедитесь, что объекты,
хранимые в вашем контейнере, реализуют соответствующую
операцию, например operator* или operator*.
Функциональные объекты операторов сравнения
Помимо классов арифметических функциональных объектов в языке C++
определены классы всех стандартных операторов сравнения: equal_to, not_equal_to, less,
greater, less_equal и greater_equal. Вы уже встречались в главе 21 с
использованием класса less в качестве средства сравнения по умолчанию элементов контейнера
Глава 22. Освоение STL-алгоритмов и функциональных объектов 701
priority_queue и ассоциативных контейнеров. Теперь вы узнаете, как можно изменить
этот критерий. Рассмотрим пример использования контейнера priority_queue с
действующим по умолчанию оператором сравнения на основе класса less
#include <queue>
#include <iostream>
using namespace std,-
int raain(int argc, char** argv)
{
priority_queue<int> myQueue,-
myQueue.push(3) ;
myQueue.push(4);
myQueue.push(2);
myQueue.pushf 1) ,-
while (!myQueue.empty()) {
cout << myQueue.top() « endl;
myQueue.pop();
}
return (0);
}
Результаты выполнения этой программы таковы.
4
3
2
1
Как видите, элементы контейнера priori tyqueue извлекаются в убывающем
порядке, т.е. в соответствии с действием оператора сравнения "меньше",
реализованного на основе класса less. Применяемую здесь операцию сравнения (класс less)
можно заменить операцией "больше" (т.е. классом greater), указав ее в качестве
шаблонного аргумента сравнения. Определение шаблонного класса priority_queue
(см. главу 21) выглядит так: '
template <typename T, typename Container = vector<T>,
typename Compare = less<T> >;
К сожалению, параметр-тип Compare расположен в списке параметров последним,
поэтому, чтобы задать операцию сравнения, необходимо также задать тип
контейнера. Рассмотрим приведенную выше программу, модифицированную так, чтобы в
контейнере priotity_queue элементы были отсортированы в возрастающем порядке,
т.е. с использованием класса greater.
#include <queue>
#include <functional>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
priority_queue<int, vector<int>, greater<int> > myQueue;
702 Часть V. Использование библиотек и шаблонов
myQueue.push(3) ;
myQueue. push (4) ,-
myQueue. push (2) ,-
myQueue.push(1);
while (!myQueue.empty()) {
cout << myQueue.top() << endl;
myQueue.pop();
}
return (0);
}
На этот раз результаты выполнения программы выглядят так.
1
2
3
4
Некоторые алгоритмы, с которыми мы познакомимся ниже в этой главе,
используют функциональные объекты операторов сравнения, поэтому встроенные
компараторы нам еще пригодятся.
Логические функциональные объекты
В C++ также определены классы функциональных объектов для трех логических
операций: logical not, logical_and и logicalor. Однако они не нашли
широкого применения в библиотеке STL.
Адаптеры функциональных объектов
При попытке использовать базовые функциональные объекты, предоставляемые
стандартом языка C++, часто возникает явное ощущение нестыковки. Например,
функциональные объекты основных операций сравнения нельзя использовать с
алгоритмом f ind_if (), поскольку в нем в качестве обратного вызова предусмотрена
передача только одного аргумента вместо двух. Попытаться решить эту проблему можно
с помощью адаптеров функциональных объектов. Они предлагают средства
поддержки для формирования целой функциональной композиции, позволяя тем самым
объединять функции для достижения необходимого поведения.
Связки
Предположим, что нам нужно использовать алгоритм find_if (), чтобы найти
первый элемент в последовательности, который больше или равен 100. Для решения
этой задачи ранее мы написали функцию perf ectScore () и передали указатель на
нее алгоритму f ind_if (). Теперь, когда мы узнали о функторах сравнения,
попробуем найти решение с помощью шаблонного класса greater_equal.
Дело в том, что объекты класса greater_equal принимают два параметра, в то
время как алгоритм find if () передает своей предикативной функции обратного
вызова только один. Нам нужно найти возможность при каждом обращении к
алгоритму f ind_if () "уведомлять" его об использовании класса greater_equal и при
этом в качестве второго параметра передавать ему значение 100. В этом случае
каждый элемент последовательности будет сравниваться с числом 100. К счастью, C++
предоставляет нам такую возможность.
Глава 22. Освоение STL-алгоритмов и функциональных объектов 703
#include <algorithm>
#include <vector>
#include <iostream>
#include <functional>
using namespace std;
int main(int argc, char** argv)
{
int num;
vector<int> myVector;
while (true) {
cout << "Введите оценку для вставки (0 для останова): ";
cin >> num;
if (num ==0) {
break;
}
myVector.pushback(num);
}
vector<int>::iterator it = find_if(myVector.begin(),
myVector.end(),
bind2nd(greater_equal<int>() ,
100));
if (it == myVector.end()) {
cout << "Идеальных оценок нет.\n";
} else {
cout << "Найдена \"идеальная\" оценка " << *it << endl;
}
return (0);
}
Функция bind2nd() называется связкой (binder), поскольку она "связывает"
значение 100 в качестве второго параметра с классом greater_equal. В результате мы
добились желаемого: алгоритм find_if () сравнивает каждый элемент контейнера
с числом 100 с использованием класса greater_equal.
Функцию-связку bind2nd() можно использовать с любой бинарной функцией.
Существует также эквивалентная функция bindlst (), которая связывает аргумент
с первым параметром бинарной функции.
Инверторы
Инверторы представляют собой функции, подобные "связкам", которые просто
инвертируют результат предиката. Например, если вам нужно найти в
последовательности оценок первый элемент, который меньше 100, вы могли бы к результату
использования функционального объекта сравнения greater_equal применить
инвертирующий адаптер.
int main(int argc, char** argv)
{.
int num,-
vector<int> myVector;
while (true) {
cout << "Введите оценку для вставки (0 для останова): ";
^ cin >> num;
'if (num ==0) {
break;
}
704 Часть V. Использование библиотек и шаблонов
myVector.push_back(num);
}
vector<int>::iterator it = find_if(myVector.begin(),
myVector.end(),
notl(bind2nd(greater_equal<int>() ,
100)));
if (it == myVector.end()) {
cout << "Все оценки идеальные.\n";
} else {
cout << "Найдена оценка \"ниже идеальной\" " << *it
<< endl;
}
return (0);
}
Функция notl () инвертирует результат каждого обращения к предикату,
принимаемому в качестве аргумента. Безусловно, вместо класса greater_equal можно
было бы использовать класс less. Однако возникают ситуации, особенно при
использовании нестандартных функторов, когда такая функция, как notl (), оказываегся как
нельзя к стати. Цифра "1" в имени функции notl() означает, что ее операндом
должна быть унарная функция (которая принимает один аргумент). Если в качестве
операнда предполагается вызвать бинарную функцию (принимающую два аргумента),
необходимо использовать функцию not2 (). Обратите внимание на то, что в данном
случае мы используем функцию notl(), поскольку, несмотря на то, что операция
greaterequal — бинарная, функция bind2nd () уже преобразовала ее в унарную,
навсегда "привязав" второй аргумент к числу 100.
Как видите, использование функторов и адаптеров может довольно быстро стать
трудным для понимания. Поэтому мы советуем ограничить их применение простыми
случаями, в которых их назначение предельно ясно, а в более сложных ситуациях
писать собственные функторы или организовывать явные циклы.
Вызов функций-членов
Если у вас есть контейнер с объектами, то у вас может возникнуть необходимость
передачи указателя на метод класса в качестве "фигуранта" обратного вызова
алгоритма. Например, не исключено, что вам понадобится находить первую пустую строку
в векторе string-объектов путем вызова метода empty () для каждого string-элемента
последовательности. Но если алгоритму f indif () просто передать указатель на метод
string: :empty (), то алгоритм не будет "знать", что он получил указатель на метод,
а не указатель на обычную функцию или функтор. Как разъяснялось в главе 9, код
использования указателя на метод отличается от кода использования указателя на
обычную функцию тем, что в первом случае вызов должен быть организован в контексте
объекта. Поэтому в C++ предусмотрена функция преобразования mem_fun_ref (),
которую можно вызывать для указателя на метод до передачи его алгоритму
(буквосочетание "fun" в имени функции mem_fun_ref () означает начало слова
"function", т.е. "функция", и никоим образом не подразумевает, что ее использование
вас позабавит ). Функцию mem_f unref () можно использовать следующим образом.
#include <functional>
#include <algorithm>
#include <string>
#include <vector>
Слово "fun " в переводе с английского означает шутка, веселье, забава. — Примеч. ред.
Глава 22. Освоение STL-алгоритмов и функциональных объектов 705
#include <iostream>
using namespace std;
void findEmptyString(const vector<string>& strings)
{
vector<string>::const_iterator it = find_if(
strings.begin(),
strings.end(),
memfunref(bstring::empty));
if (it == strings.end()) {
cout << "Пустых строк нет!\п";
} else {
cout << "Пустая строка обнаружена в позиции: "
<< it - strings.begin() << endl;
}
}
Функция memfunref () генерирует функциональный объект, который служит
"фигурантом" обратного вызова для алгоритма f ind_if (). При каждом "обратном
вызове" она вызывает метод empty () для своего аргумента.
Функция mem_f un_ref () работает как для методов без аргументов,
так и для унарных методов. Результат можно использовать для
обратного вызова там, где ожидается унарная или бинарная функция
соответственно.
Если ваш контейнер содержит указатели на объекты, а не сами объекты, то для
вызова функций-членов необходимо использовать другой функциональный адаптер,
а именно mem_f un (). Вот пример.
#include <functional>
#include <algorithm>
#include <string>
#include <vector>
#include <iostream>
using namespace std;
void findEmptyString(const vector<string*>& strings)
{
vector<string*>::const_iterator it = find_if(
strings.begin(),
strings.end(),
mem_fun(bstring::empty));
if (it == strings.end()) {
cout << "Пустых строк нет!\п";
} else {
cout << "Пустая строка обнаружена в позиции: "
<< it - strings.begin() « endl;
}
}
Адаптация реальных функций
С помощью функциональных адаптеров bindlst (), bind2nd (), notl () или not 2 ()
нельзя использовать указатели на обычные функции напрямую, поскольку для этих
адаптеров нужно обеспечить специальные typedef-определения в функциональных
объектах, которые они адаптируют. Поэтому один из функциональных адаптеров
стандартной библиотеки C++, ptr_f un (), позволяет заключать в оболочку обычные указатели
706 Часть V. Использование библиотек и шаблонов
на функции так, что их можно использовать с адаптерами. Это, главным образом,
полезно для применения унаследованных С-функций из стандартной библиотеки С. Если
вы планируете писать собственные функции обратного вызова, мы рекомендуем вам
освоить создание классов функциональных объектов (см. следующий раздел).
Например, предположим, мы хотим написать функцию isNumber (), которая
возвращает значение true, если каждый символ в заданной строке является цифрой. Как
упоминалось в главе 21, С++-тип string поддерживает итераторы. Поэтому алгоритм
f indif () можно законно использовать для поиска первого нецифрового символа.
Если будет найден хотя бы один такой символ, значит, данная строка не является числом.
Заголовочный файл <cctype> поддерживает унаследованную С-функцию isdigit(),
которая возвращает значение true, если заданный символ является цифрой, в
противном случае— значение false. Наша задача— найти в заданной строке первый
нецифровой символ, и для этого нам потребуется адаптер notl (). Но поскольку isdigit () —
это С-функция, а не функциональный объект, нам придется применить адаптер
ptr_f un (), который сгенерирует нужный нам функциональный объект, и вот его-то
уже можно будет использовать с адаптером notl (). Теперь рассмотрим программный код.
#include <functional>
#include <algorithm>
#include <cctype>
#include <string>
using namespace std;
bool isNumber(const strings str)
{
string: : const_iterator it = f ind_if (str .begin() , str.endO,
notl(ptrfun(::isdigit)));
return (it == str.endO);
}
Обратите внимание на использование оператора разрешения контекста ": :",
который позволяет указать, что функцию isdigit () следует искать в глобальной
области видимости.
Создание собственных функциональных объектов
Для выполнения более специальных задач (по сравнению с теми, которые решаются
с помощью встроенных функторов) вы можете написать собственные функциональные
объекты. Если вы хотите иметь возможность использовать функциональные адаптеры
с этими функторами, то вам необходимо позаботиться о поддержке некоторых typedef-
определений. Проще всего это сделать путем выведения ваших классов
функциональных объектов из класса unary_f unction или класса binaryf unction (в зависимости
от количества принимаемых аргументов: одного или двух соответственно). Оба эти
класса (определенные в заголовке <functional>) шаблонизированы по типам
параметра и возвращаемого значения "функции", которую они поддерживают. Например,
вместо использования адаптера ptr_fun () для преобразования функции isdigit (),
можно было бы написать функциональный объект оболочки в таком виде.
#include <functional>
#include <algorithm>
#include <cctype>
#include <string>
using namespace std;
Глава 22. Освоение STL-алгоритмов и функциональных объектов 707
class mylsDigit : public unary_function<char, bool>
{
public:
bool operator() (char c) const {return (::isdigit(c));}
};
bool isNumber(const strings str)
{
string::const_iterator it = f ind_if (str .begin() , str.endO,
notl (mylsDigit ())),-
return (it == str.endO);
}
Обратите внимание на то, что для передачи объектов алгоритму f ind_if ()
оператор вызова перегруженной функции в классе mylsDigit должен иметь
модификатор const.
Для алгоритмов допускается создание нескольких копий
предикатов функциональных объектов и вызов разных копий для
различных элементов. Поэтому вам не обязательно писать их в расчете на
любое внутреннее состояние для объекта, остающегося
постоянным между вызовами.
Алгоритмы в деталях
В этой главе описываются общие категории алгоритмов с примерами. Web-pecypc
Standard Library Reference содержит общее описание всех алгоритмов, но в целях
практического применения вам следует обратиться к одной из книг по библиотеке
STL, перечисленных в приложении Б.
Как упоминалось в главе 21, существует пять типов итераторов: входные,
выходные, однонаправленные, двунаправленные и произвольного доступа. Формально мы
не можем говорить об иерархии классов этих итераторов, поскольку реализации
контейнеров не являются частью иерархии стандартных классов. Тем не менее можно
описать их иерархию на основе функций, которые должны быть реализованы для
обеспечения работоспособности этих итераторов. В частности, каждый итератор
произвольного доступа является одновременно двунаправленным, а каждый
двунаправленный итератор обладает возможностями однонаправленного, при этом любой
однонаправленный итератор без труда справится с задачами, возлагаемыми на
входные или выходные итераторы.
Чтобы указать, какой вид итератора используется алгоритмом, для шаблонных
аргументов итераторов предусмотрены следующие имена: Input Iterator, Output Iterator,
Forwardlterator, Bidirectionallterator и RandomAccessIterator. Эти имена —
всего лишь имена: они не выполняют никакой проверки типов. Поэтому вы могли бы,
например, попытаться вызвать алгоритм, ожидающий приема RandomAccessIterator-
итератора, передав ему двунаправленный итератор. Поскольку шаблон не обеспечивает
контроль типов, такая его реализация вполне возможна. Однако код в функции, которая
использует возможности именно итераторов произвольного доступа, не скомпилирует-
ся, "отвергнув" двунаправленный итератор. Таким образом, о нарушении требований,
708 Часть V. Использование библиотек и шаблонов
предъявляемых алгоритмами к типам итераторов, может быть "заявлено" совсем, казалось
бы, в неожиданном месте. Сообщение об ошибке может даже сбить вас с толку.
Например, при попытке использовать обобщенный алгоритм sort () (который предназначен
для работы с итератором произвольного доступа) для контейнера list, рассчитанного
только на использование двунаправленного итератора, вы получите следующее
сообщение об ошибке (при использовании ключа д++).
/usr/include/c++/3.2.2/bits/stl_algo.h: In function "void
std::sort(_RandoraAccessIter, _RandoraAccessIter) [with
_RandoraAccessIter = std::_List_iterator<int, int&, int*>]':
Sorting.cpp:38: instantiated from here
/usr/include/c++/3.2.2/bits/stl_algo.h:2178: no match for
std::_List_iterator<int, int&, int*>& -
std::_List_iterator<int, int&, int*>&' operator
He стоит волноваться, если вы пока не понимаете смысла этого сообщения об
ошибке. Алгоритм sort () описан ниже в этой главе.
Большинство алгоритмов определено в заголовочном файле <algorithm>, но
некоторые "обосновались" в заголовке <numeric>. Все они "прописаны" в пространстве
имен std. За подробностями обращайтесь к Web-ресурсу Standard Library Reference.
Вспомогательные алгоритмы
Библиотека STL содержит три вспомогательных алгоритма, реализованных как
шаблоны функций: min (), max () и swap (). Шаблоны min () и max () сравнивают два
элемента любого типа с помощью оператора operator< или бинарного предиката,
предложенного пользователем, возвращая при этом ссылку на меньший или больший
элемент соответственно. Алгоритм swap () принимает два элемента любого типа
(переданные по ссылке) и меняет местами их значения.
Эти утилиты работают с отдельными элементами, а не с последовательностью,
поэтому они не принимают параметры итераторного типа.
Использование упомянутых трех функций демонстрируется в следующей программе.
#include <algorithm>
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
int x = 4, у = 5;
cout << "x равен " << x << ", ay равен " << у << endl;
cout << "Максимум равен " << max(x, у) << endl;
cout << "Минимум равен " << min(x, у) << endl;
swap(x, y);
cout << "x равен " << x << ", ay равен " << у << endl;
cout << "Максимум равен " << max(x, у) << endl;
cout << "Минимум равен " << min(x, у) << endl;
return (0);
}
Результаты выполнения этой программы таковы.
х равен 4, а у равен 5
Максимум равен 5
Минимум равен 4
х равен 5, а у равен 4
Максимум равен 5
Минимум равен 4
Глава 22. Освоение STL-алгоритмов и функциональных объектов 709
Немодифицирующие алгоритмы
К немодифицирующим алгоритмам относятся функции, предназначенные для
поиска элементов в заданном диапазоне, формирования числовой информации об
элементах в диапазоне, сравнения двух диапазонов и немодифицирующей обработки
элементов диапазона.
Алгоритмы поиска
Вы уже видели два примера алгоритмов поиска: f ind () и f indi f (). Библиотека
STL содержит и другие вариации "на тему" базового алгоритма find (), которые
работают с отсортированными последовательностями элементов. Так, например,
алгоритм adjacent_f ind() находит первое вхождение двух смежных элементов,
которые равны друг другу. Алгоритм f ind_f irst_of () используется для поиска одного
из нескольких заданных значений. Алгоритмы search () и findendO находят
последовательности элементов, совпадающие с заданной, выполняя поиск с начала либо
с конца заданного диапазона соответственно. Алгоритм searchn () можно
рассматривать как специальный случай алгоритма search () или как общий случай алгоритма
adjacent_f ind (): он находит первую последовательность п соседних элементов,
совпадающих с заданным значением. Наконец, алгоритмы min_element () и maxelement ()
предназначены для поиска минимального и максимального элемента в
последовательности соответственно.
Алгоритм f indendO эквивалентен алгоритму search О, но
отличается от последнего лишь тем, что начинает свою работу с конца
диапазона, а не с его начала. Его нельзя рассматривать как "обратный"
эквивалент алгоритму f ind (). Следует иметь в виду, что "обратного"
эквивалента для findO, find if О и других алгоритмов, которые
находят единичный элемент, не существует, поскольку для
получения "обратного" эффекта можно использовать итератор типа
reverse iterator (см. главу 23).
Алгоритмы find (), adjacent_find (), min_element () и max_element ()
характеризуются линейным временем выполнения, остальные — квадратичным. Все эти
алгоритмы по умолчанию используют такие операторы сравнения, как operator== или
operator<, но их перегруженные версии позволяют клиентам задавать любой
вариант реализации сравнения.
Рассмотрим несколько примеров использования упомянутых здесь алгоритмов поиска.
ttinclude <algorithm>
ftinclude <iostream>
ftinclude <vector>
using namespace std,-
int main(int argc, char** argv)
{
// Список элементов, в котором будем производить поиск.
int elems[] = {5, 6, 9, 8, 8, 3};
// Создаем вектор из списка, используя тот факт, что
// указатели являются также итераторами.
vector<int> myVector(elems, elems + 6);
vector<int>::const_iterator it, it2;
// Находим min- и max-элементы в векторе.
710 Часть V. Использование библиотек и шаблонов
it = minelement(myVector.begin(), myVector. end О) ;
it2 = max_element(myVector.begin(), myVector.endO);
cout << "Минимальный элемент равен " <<: *it
<< ", а максимальный равен " « *it2 << endl;
// Находим первую пару одинаковых смежных элементов.
it = adjacent_find(myVector.begin(), myVector.end());
if (it != myVector.end()) {
cout << "Обнаружено два одинаковых смежных элемента,
Травных " << *it << endl;
}
// Находим первое из двух заданных значений.
int targets[] = {8, 9};
it = findfirstof(myVector.begin(), myVector.end(),
targets, targets + 2);
if (it != myVector.end()) {
cout << "Найден элемент со значением 8 или 9: "
<< *it << endl;
}
//■ Находим первую последовательность.
int sub[] = {8, 3};
it = search(myVector.begin(), myVector.end(), sub, sub+2);
if (it != myVector.end()) {
cout « "Найдена последовательность 8, 3 в позиции "
<< it - myVector.begin() << endl;
}
// Находим последнюю последовательность (которая должна
// быть такой же, как первая).
it2 = findend(myVector.begin(), myVector.end(),
sub, sub + 2);
if (it != it2) {
cout << "Ошибка: алгоритмы search() и find_end() "
« "обнаружили различные последовательности,"
« " хотя существует только одно совпадение.\п";
}
// Находим первую последовательность двух соседних
// элементов, равных 8.
it = search_n(myVector.begin(), myVector.end(), 2, 8);
if (it != myVector.end()) {
cout << "Найдено два соседних элемента, равных 8,
"^начиная с позиции " << it - myVector.begin() << endl;
}
return (0) ;
}
Вот как выглядят результаты выполнения этой программы.
Минимальный элемент равен 3, а максимальный равен 9
Обнаружено два одинаковых смежных элемента, равных 8
Найден элемент со значением 8 или 9: 9
Найдена последовательность 8, 3 в позиции 4
Найдено два соседних элемента, равных 8, начиная с позиции 3
Существуют также алгоритмы поиска, которые работают только с
отсортированными последовательностями: binary_search (), lower_bound (), upper_bound ()
и equal_range (). Алгоритм binary_search () находит заданный элемент за
логарифмическое время. Остальные три сравнимы с эквивалентными методами, определенными
для контейнеров тар и set. (См. главу 21 и Web-ресурс Standard Library Reference.)
Глава 22. Освоение STL-алгоритмов и функциональных объектов 711
По возможности вместо алгоритмов используйте эквивалентные
методы соответствующих контейнеров, поскольку методы работают
более эффективно.
Алгоритмы числовой обработки
Вы уже видели пример использования алгоритма числовой обработки, а именно
алгоритма accumulate (). Существуют также алгоритмы count () и count_if (),
предназначенные для подсчета в контейнере количества элементов, равных
заданному значению. Они действуют подобно методу count (), определенному для
контейнерных классов тар и set.
Существуют и другие алгоритмы числовой обработки, но они не так часто
используются и поэтому здесь не рассматриваются. При необходимости обращайтесь к Web-
ресурсу Standard Library Reference.
Алгоритмы сравнения
Диапазоны элементов можно сравнивать тремя различными способами: equal (),
mismatch () и lexicographicalcompare (). Каждый из этих алгоритмов
сравнивает но порядку элементы двух диапазонов в параллельных позициях. Алгоритм
equal () возвращает значение true, если равны все параллельные элементы.
Алгоритм mismatch () возвращает итераторы, ссылающиеся на первые позиции в
соответствующих диапазонах, в которых параллельные элементы уже не равны друг другу.
Алгоритм lexicographical_compare () возвращает значение true, если все
элементы в первом диапазоне меньше параллельных им элементов во втором, или если
первый диапазон короче второго, а все элементы до этой позиции меньше
соответствующих элементов во втором диапазоне. Эту функцию можно представить себе как
обобщение процесса сортировки по алфавиту на несимвольные элементы.
Если вам нужно сравнить элементы двух контейнеров одного и того же
типа, вместо алгоритмов equal О или lexicographicalcampare()
используйте операторы operator» или operator*:. Названные
алгоритмы полезны, главным образом, для сравнения
последовательностей элементов, содержащихся в контейнерах различного типа.
Рассмотрим примеры использования алгоритмов equal О , mismatch () и
lexicographicalcompare ().
#include <algorithm>
#include <vector>
#include <list>
#include <iostream>
using namespace std;
// Шаблон функции для заполнения контейнера int-элементами.
// Контейнер должен поддерживать метод push__back() .
template<typename Container>
void populateContainer(Containers cont)
{
int num;
while (true) {
cout « "Введите число (0 для выхода): ";
712 Часть V. Использование библиотек и шаблонов
cin >> num;
if (num ==0) {
break;
}
cont.push_back(num);
}
}
int main(int argc, char** argv)
{
vector<int> myVector;
list<int> myList;
cout << "Заполните вектор:\n";
populateContainer(myVector);
cout << "Заполните список:\п";
populateContainer,(myList) ;
if (myList.size() < myVector.size()) {
cout << "Простите, но список имеет недостаточный
"^размер. \п";
return (0);
}
// Сравниваем два контейнера.
if (equal(myVector.begin(), myVector.end(),
myList.begin())) {
cout << "Два контейнера содержат равные элементы.\n";
} else {
// Если контейнеры не равны, находим причину неравенства.
pair<vector<int>: : iterator, list<:int>: : iterator> miss =
mismatch(myVector.begin(), myVector.end(),
myList.begin());
cout << "Первое несовпадение элементов обнаружено в
'Ьпозиции "
<< miss.first - myVector.begin()
<< ". Вектор содержит значение " << *(miss.first)
<< ", а список -- значение " << *(miss.second)
« " . "
<< endl;
}
// Теперь займемся упорядочением элементов.
if (lexicographical_compare(myVector.begin() ,
myVector.end() ,
myList.begin(), myList.end())){
cout << "Вектор лексикографически меньше.\п";
} else {
cout << "Список лексикографически меньше.\п";
}
return (0);
Вот результаты выполнения этой программы.
Заполните вектор:
Введите число (0 для выхода): 5
Введите число (0 для выхода): 6
Введите число (0 для выхода): 7
Введите число (0 для выхода): 8
Введите число (0 для выхода): 0
Заполните список:
Введите число (0 для выхода): 5
Введите число (0 для выхода): б
Глава 22. Освоение STL-алгоритмов и функциональных объектов 713
Введите число (0 для выхода): 7
Введите число (0 для выхода): 9
Введите число (0 для выхода): О
Первое несовпадение элементов обнаружено в позиции 3. Вектор содержит значение
8, а список -- значение 9.
Вектор лексикографически меньше.
Операционные алгоритмы
В этой категории существует только один алгоритм: f or_each (). Но это — один из
самых полезных алгоритмов в STL. Он выполняет обратный вызов для каждого
элемента диапазона. Его можно использовать с простыми функциями обратного вызова для
выполнения таких действий, как печать каждого элемента контейнера. Вот пример.
#include <algorithm>
#include <map>
#include <iostream>
using namespace std;
void printPair(const pair<int, int>& elem)
{
cout << elem.first << "->" << elem.second << endl;
}
int main(int argc, char** argv)
{
map<int# int> myMap;
myMap.insert(make_pair(4, 40));
myMap.insert(make_pair(5, 50) ) ;
myMap. insert (make_jpair (6, 60));
myMap. insert (make_jpair (7, 70) ) ;
myMap.insert(make_pair(8, 80));
foreach(myMap.begin(), myMap.end(), bprintPair);
return (0);
}
Используя функтор, позволяющий сохранять информацию между элементами,
можно выполнять гораздо более интересные задачи. Алгоритм f oreach () возвращает
копию объекта обратного вызова, поэтому мы можем накапливать данные в функторе,
которые можно извлечь после того, как алгоритм f or_each () завершит обработку всех
элементов. Например, написав соответствующий функтор, вы могли бы в одном
проходе отыскать сразу как минимальный (min), так и максимальный (max) элементы.
Показанный в следующем примере функтор MinAndMax написан в предположении, что
диапазон, для которого он вызывается, содержит хотя бы один элемент. Функтор
MinAndMax использует булеву переменную first для инициализации переменных min
и max значением, равным первому элементу диапазона, после чего каждый последующий
элемент сравнивается с текущими значениями, хранимыми переменными min и max.
#include <algorithm>
#include <functional>
#include <vector>
#include <iostream>
using namespace std;
// Функция populateContainer() идентична показанной выше для
// алгоритмов сравнения, поэтому она здесь опущена.
714 Часть V. Использование библиотек и шаблонов
class MinAndMax : public unary function<int, void>
{
public:
MinAndMax();
void operator()(int elem);
// Объявляем rain и max public-переменными, чтобы
// облегчить к ним доступ.
int min, max;
protected:
bool first;
};
MinAndMax::MinAndMax() : min(-l), max(-l), first(true)
void MinAndMax::operator()(int elera)
{
if (first) {
min = max = elem,-
} else if (elera < min) {
min = elem,-
} else if (elem > max) {
max = elem;
}
first = false;
}
int main(int argc, char** argv)
{
vector<int.> myVector,-
populateContainer(myVector);
MinAndMax func;
func = for_each(myVector.begin(), myVector.end(), func);
cout << "Максимальное значение равно " << func.max << endl;
cout << "Минимальное значение равно " << func.min << endl;
return (0);
}
Если бы вы проигнорировали значение, возвращаемое алгоритмом f oreach (),
и попытались бы считать информацию из объекта f unc после вызова, то убедились
бы в том, что такой подход не работает, поскольку объект f unc не обязательно
передается алгоритму f oreach () по ссылке. Для обеспечения корректного поведения
необходимо использовать возвращаемое значение.
В заключение отметим, что объекту обратного вызова, передаваемому алгоритму
f oreach () в качестве параметра, разрешается принимать аргумент по ссылке и
модифицировать его. Это позволяет изменять значения элементов контейнера в реальном
итераторном диапазоне. Использование такой возможности демонстрируется ниже
в этой главе на примере создания средства регистрации участников голосования.
Модифицирующие алгоритмы
Библиотека STL содержит множество разных модифицирующих алгоритмов,
которые выполняют такие задачи, как копирование элементов из одного диапазона
в другой, удаление элементов или изменение порядка следования элементов в
диапазоне на обратное.
Глава 22. Освоение STL-алгоритмов и функциональных объектов 715
Все модифицирующие алгоритмы используют понятие исходного и приемного
диапазонов. Элементы считываются из исходного диапазона и помещаются (или
модифицируются) в приемный. Исходный и приемный диапазоны могут совпадать, и об
алгоритме, применяемом в этом случае, говорят, что он действует "по месту".
Диапазоны контейнеров тар и multimap нельзя использовать в
качестве приемников модифицирующих алгоритмов. Эти алгоритмы
целиком перезаписывают элементы, которые в отображении (тар)
состоят из пар ''ключ-значение". Но если в контейнерах тар и multimap
ключ помечен модификатором const, ему невозможно присвоить
другое значение. Аналогично многие реализации контейнеров set
и multiset обеспечивают только const-итерацию своих элементов,
поэтому в качестве приемников модифицирующих алгоритмов в
общем случае нельзя использовать и диапазоны из этих контейнеров.
Альтернативный вариант молено найти в применении итератора
вставки (insert iterator), описанного в главе 23.
Алгоритм преобразования
Алгоритм transform () подобен алгоритму f oreach () в том, что он применяет
обратный вызов к каждому элементу в заданном диапазоне. Различие состоит в
следующем: алгоритм transform () рассчитан на то, что фигурант обратного вызова
генерирует новый элемент для каждого такого вызова и сохраняет его в заданном
приемном диапазоне. Исходный и приемный диапазоны могут совпадать, если вам нужно,
чтобы в результате преобразования каждый элемент в диапазоне был заменен
результатом обращения к функции обратного вызова. Например, используя следующий код,
можно сложить каждый элемент вектора с числом 100.
#include <algorithm>
#include <functional>
#include <iostream>
#include <vector> )
using namespace std;
// Функция populateContainer() идентична показанной выше для
// алгоритмов сравнения, поэтому она здесь опущена.
void print(int elem)
{
cout << elem << " ";
}
int main(int argc, char** argv)
{
vector<int> myVector;
populateContainer(myVector);
cout << "Вектор содержит такие элементы:\п";
foreach(myVector.begin(), myVector.end(), &print);
cout << endl; .
transform(myVector.begin(), myVector.end(),
myVector.begin () , bind2nd (plus<int> () , 100) ) ,-
cout << "Вектор содержит такие элементы:\п";
for_each(myVector.begin(), myVector.end(), &print);
cout << endl;
return (0);
}
716 Часть V. Использование библиотек и шаблонов
Существует еще одна форма алгоритма transform (), которая вызывает бинарную
функцию для пар элементов заданного диапазона (см. Web-pecypc Standrad Library
Reference). Интересно отметить, что, написав правильные функторы для алгоритма
transform (), можно было бы использовать его для получения эффекта,
достигаемого путем применения многих других модифицирующих алгоритмов, например
сору () и replace (). Однако удобнее использовать более простые алгоритмы.
Алгоритм transform() и другие модифицирующие алгоритмы часто
возвращают итератор, который ссылается на значение,
расположенное за концом приемного диапазона. В примерах, приведенных
в этой книге, значения, возвращаемые алгоритмами, как правило,
игнорируются. Детали можно почерпнуть из Web-ресурса Standard
Library Reference.
Алгоритм копирования
Алгоритм сору () позволяет копировать элементы из одного диапазона в другой.
Исходный и приемный диапазоны должны быть различными, но могут
перекрываться. Обратите внимание на то, что алгоритм сору () не вставляет элементы в
приемный диапазон. Он просто перезаписывает уже находящиеся там элементы. Поэтому
алгоритм сору () нельзя напрямую использовать для вставки элементов в контейнер,
а лишь для перезаписи уже ранее туда помещенных.
В главе 23 описано, как для вставки элементов в контейнер
использовать адаптеры итераторов или выполнить операции ввода-вывода
с помощью алгоритма сору ().
Рассмотрим простой пример использования алгоритма сору (), который
демонстрирует, как с помощью vector-метода resize () можно убедиться в том, что
приемный контейнер обладает достаточным пространством.
#include <algorithm>
#include <vector>
#include <iostream>
using namespace std;
// Функции populateContainer() и print О идентичны показанным
// в предыдущих примерах и поэтому здесь опущены.
int main(int argc, char** argv)
{
vector<int> vectOne, vectTwo;
populateContainer(vectOne);
vectTwo. resize (vectOne. size () ) ,-
copy(vectOne.begin(), vectOne.end(), vectTwo.begin());
for_each(vectTwo.begin(), vectTwo.end(), &print);
return (0);
Глава 22. Освоение STL-алгоритмов и функциональных объектов 717
Алгоритмы замены
Алгоритмы replace () и replace_if () заменяют новым значением элементы,
совпадающие с заданным значением или отвечающие заданному предикату
соответственно. Например, мы могли бы "сузить" диапазон представления всех элементов
контейнера до значений от 0 до 100, заменив все значения, которые меньше 0, — нулем,
а значения, превышающие 100, — числом 100.
#include <algorithm>
#include <functional>
#include <vector>
#include <iostream>
using namespace std;
// Функции populateContainer() и print() идентичны показанным
// в предыдущих примерах и поэтому здесь опущены.
int main(int argc, char** argv)
{
vector<int> myVector;
populateContainer(myVector);
replace_if(myVector.begin(), myVector.end(),
bind2nd(less<int>(), 0), 0);
replace_if(myVector.begin(), myVector.end(),
bind2nd(greater<int>(), 100), 100);
for_each(myVector.begin(), myVector.end(), &print);
cout << endl;
return (0);
}
Существуют также варианты алгоритма replace (), именуемые replace_copy ()
и replace_copy_if (), которые копируют результаты в другой диапазон приемника.
Алгоритмы удаления
Алгоритмы remove () и removeif () предназначены для удаления определенных
элементов из диапазона. Элементы, подлежащие удалению, могут быть заданы либо
значением, либо предикатом. Важно помнить, что эти элементы не удаляются из
базового контейнера, поскольку упомянутые алгоритмы имеют доступ только к итера-
торной абстракции, а не к контейнеру. Это означает, что удаленные элементы
копируются в конец диапазона, а алгоритмическая функция возвращает новый конец (уже
укороченного) диапазона. Если вам нужно "начисто" ликвидировать удаленные из
контейнера элементы, вы должны сначала использовать алгоритм remove (), а затем
вызвать для данного контейнера метод erase (). Рассмотрим пример функции,
которая удаляет пустые строки из вектора, содержащего string-элементы. Она подобна
функции f indEmptyString (), приведенной выше в этой главе.
#include <functional>
#include <algorithm>
#include <string>
#include <vector>
#include <iostream>
using namespace std;
void removeEmptyStrings(vector<string>& strings)
{
vector<string>::iterator it = remove_if(strings.begin(),
718 Часть V. Использование библиотек и шаблонов
strings.end() ,
mem_fun_ref (&string: : empty)) ,-
// "Зачищаем" удаленные элементы.
strings.erase(it, strings.end());
}
void printString(const strings str)
{
cout << str << " ";
}
int main(int argc, char** argv)
{
vector<string> myVector;
myVector.pushback("");
myVector.push_back("stringone");
myVector.push_back("");
myVector.pushback("stringtwo");
myVector.push_back("stringthree");
myVector.push_back("stringfour");
removeEmptyStrings(myVector);
cout << "Размер равен " << myVector.size() << endl;
for_each(myVector.begin(), myVector.end(), &printString);
cout << endl;
return (0);
}
Вариации "на тему удаления" в виде алгоритмов remove_copy () и remove_copy_
i f () не изменяют исходный диапазон. Вместо этого они копируют все неудаленные
элементы в другой приемный диапазон. Они подобны алгоритму сору () в том, что
приемный диапазон должен быть достаточно большим, чтобы принять новые элементы.
Алгоритмы семейства remove () строго поддерживают порядок
элементов, оставшихся в контейнере (даже при перемещении удаляемых
элементов в конец контейнера).
Алгоритм поддержки уникальности элементов
Алгоритм unique () представляет собой специальный случай алгоритма remove (),
который удаляет все смежные элементы-дубликаты. Как упоминалось в главе 21,
контейнер list поддерживает метод unique (), который реализует аналогичную
семантику. В общем случае метод unique () следует использовать для отсортированных
последовательностей, однако ничто не мешает вам выполнить его и для неотсортированных.
Базовая форма алгоритма unique () работает "по месту", но существует также
версия unique_copy (), которая копирует результаты выполнения в новый
приемный диапазон.
В главе 21 показан пример использования алгоритма unique () для контейнера
list, поэтому мы считаем, что дополнительные примеры можно опустить.
Алгоритм реверсирования
Алгоритм reverse () изменяет порядок следования элементов в диапазоне на
обратный. Первый элемент в диапазоне меняется местами с последним, второй —
с предпоследним и т.д.
Глава 22. Освоение STL-алгоритмов и функциональных объектов 719
Базовая форма алгоритма reverse () работает "по месту", но существует также
версия reversecopy (), которая копирует результаты выполнения в новый
приемный диапазон.
Другие модифицирующие алгоритмы
В Web-ресурсе Standard Library Reference описаны и другие модифицирующие
алгоритмы, включая iter_swap(), swapranges(), fill(), generate(), rotate(),
next_permutation () и prev_j?ermutation (). Как показывает наш опыт, эти
алгоритмы используются не так часто, как рассмотренные выше, и поэтому мы не
останавливаемся на них, но при необходимости вы можете обратиться за деталями по их
применению к упомянутому Web-pecypcy.
Алгоритмы сортировки
Библиотека STL содержит различные алгоритмы сортировки. Эти алгоритмы не
применимы к ассоциативным контейнерам, которые всегда сами обеспечивают
внутреннюю сортировку своих элементов. Кроме того, контейнер list поддерживает
собственную версию метода sort (), который работает эффективнее общего
алгоритма. Поэтому большинство алгоритмов сортировки полезно применять только
к векторам (vector) и очередям (deque).
Базовые алгоритмы сортировки и слияния
Функция sort () использует алгоритм, подобный алгоритму быстрой сортировки
(quicksort), и в общем случае сортирует элементы диапазона за время, выражаемое
формулой 0(N\og N). По умолчанию функция sort () сортирует элементы диапазона
в неубывающем порядке (от наименьшего к наибольшему), т.е. в соответствии с
оператором operators При необходимости вы можете задать другой порядок
сортировки, явно указав объект обратного вызова, например greater.
Существует также версия алгоритма sort (), именуемая stablesort (), которая
поддерживает относительный порядок следования одинаковых элементов в
диапазоне. Функция stable_sort () использует алгоритм, подобный алгоритму сортировки
слиянием (merge sort).
Отсортировав диапазон, для поиска нужных элементов в нем можно применить
алгоритм binary_search (), время выполнения которого характеризуется не
линейной зависимостью, а логарифмической.
Функция merge () позволяет объединить два отсортированных диапазона,
поддерживая при этом отсортированный порядок. Результат представляет собой
отсортированный диапазон, содержащий все элементы из двух исходных диапазонов. Время
выполнения функции merge () характеризуется линейной зависимостью. В качестве
альтернативного варианта (т.е. вместо функции merge ()) вы могли бы сначала
выполнить конкатенацию двух диапазонов, а затем применить к результату функцию
sort (), но такой подход менее эффективен (в этом случае время выполнения
выражалось бы нелинейной зависимостью, а формулой 0(iVlog Л/))'.
Используя функцию merge (), позаботьтесь о том, чтобы для
хранения результата объединения был подготовлен диапазон достаточно
большого размера!
720 Часть V. Использование библиотек и шаблонов
Теперь рассмотрим пример сортировки и слияния диапазонов
#include <algorithm>
#include <vector>
#include <iostream>
using namespace std;
// Функции populateContainer() и print() идентичны показанным
// в предыдущих примерах и поэтому здесь опущены.
int main(int argc, char** argv)
{
vector<int> vectorOne, vectorTwo, vectorMerged;
cout << "Введите значения для первого вектора:\n";
populateContainer(vectorOne);
cout << "Введите значения для второго вектора:\п"•
populateContainer(vectorTwo);
sort(vectorOne.begin(), vectorOne.end());
sort(vectorTwo.begin(), vectorTwo.end() ) ;
// Обеспечиваем достаточно большой размер приемного
// вектора для хранения значений из исходных векторов.
vectorMerged.resize(vectorOne.size() + vectorTwo.size());
merge(vectorOne.begin(), vectorOne.end(),
vectorTwo.begin(), vectorTwo.end(),
vectorMerged.beginO ) ;
cout << "Объединенный вектор: ";
for_each(vectorMerged.beginO, vectorMerged.end(), &print);
cout < < endl;
while (true) {
int num;
cout << "Введите искомое число (0 для выхода): ";
cin >> num;
if (num ==0) {
break;
}
if (binary_search(vectorMerged.begin(),
vectorMerged.end(), num)) {
cout << "Это число содержится в векторе.\n";
} else {
cout << "Это число не содержится в векторе.\п";
}
}
}
return (0);
Алгоритмы пирамидальной сортировки
Такая структура данных, как частично упорядоченное полное бинарное дерево (heap
structure), предназначена для хранения элементов (как следует из названия) в частично
отсортированном порядке, что позволяет отыскать самый верхний элемент за время,
характеризуемое некоторой константой. На удаление самого верхнего элемента и
добавление нового требуется логарифмическое время. Более подробную информацию
о таких структурах данных можно почерпнуть из литературы, указанной в приложении Б.
Для управления частично упорядоченными полными бинарными деревьями »
библиотеке STL предусмотрено четыре алгоритма.
Глава 22. Освоение STL-алгоритмов и функциональных объектов 721
□ Алгоритм makeheap () преобразует диапазон элементов в частично
упорядоченное полное бинарное дерево за линейное время. Самым верхним элементом
становится первый элемент из заданного диапазона.
□ Алгоритм push_heap () добавляет новый элемент в частично упорядоченное
полное бинарное дерево путем включения элемента, расположенного в
позиции, предшествующей концу диапазона. Другими словами, алгоритм
pushheapO принимает итераторный диапазон [first, last) в
предположении, что диапазон [first, last-1) допустим для данной структуры, а
элемент, расположенный в позиции last - 1, становится новым элементом,
добавляемым в структуру. Говоря "на языке" контейнеров, если поместить
бинарное дерево в контейнер типа deque, то для добавления в дек нового
элемента можно сначала использовать метод push_back (), а затем вызвать
алгоритм push_heap (), передав ему итераторы начала и конца дека. Алгоритм
push_heap () выполняется в течение логарифмического времени.
□ Алгоритм pop_heap () извлекает самый верхний элемент из heap-структуры
и переупорядочивает оставшиеся элементы, чтобы сохранить структуру
бинарного дерева. Этот алгоритм уменьшает соответствующий диапазон на один
элемент. Если диапазон до вызова этого алгоритма имел вид [first, last), то
новый можно представить как [first, last-1). Обычно алгоритм popheapO
реально не удаляет элемент из контейнера. Если вы действительно хотите
удалить его, после применения алгоритма pop_heap () вызовите метод erase ()
или pop_back (). Для выполнения алгоритма pop_heap () требуется
логарифмическое время.
□ Алгоритм sortheap () преобразует частично упорядоченное полное
бинарное дерево в полностью отсортированный диапазон за время, выражаемое
формулой 0(7Vlog Л/).
Частично упорядоченные полные бинарные деревья часто используются для
реализации очередей по приоритету. Контейнер типа priority_queue,
представленный в главе 21, реализован с помощью этих heap-алгоритмов. Если вы решите
использовать упомянутые алгоритмы напрямую, сначала убедитесь, что интерфейс
контейнера priority_queue не может удовлетворить вашим требованиям. Мы не
приводим здесь примеров использования алгоритмов пирамидальной сортировки, но
при необходимости вы можете обратиться к Web-ресурсу Standard Library Reference.
Другие алгоритмы сортировки
Существуют и другие алгоритмы сортировки, включая partition (), partial_
sort () и nth_element (). Они, в основном, используются как "строительные блоки"
для алгоритма быстрой сортировки. Но поскольку алгоритм sort () сам реализует
принципы быстрой сортировки, то, как правило, нет необходимости в
использовании дополнительных средств. Однако при необходимости детального рассмотрения
вы можете обратиться к Web-ресурсу Standard Library Reference.
Алгоритм randomshuffle О
Наконец, есть еще один алгоритм, относящийся к семейству "сортировочных", но
он, скорее, является "антисортировочным". Алгоритм randomshuf f 1е ()
перегруппировывает элементы диапазона в произвольном порядке. Его используют для
реализации задач, в которых участвует колода карт.
722 Часть V. Использование библиотек и шаблонов
Алгоритмы выполнения операций над множествами
Последнюю категорию алгоритмов STL составляют пять функций,
предназначенных для выполнения операций над множествами. Хотя эти алгоритмы работают с
любыми отсортированными диапазонами итераторов, они явно предназначены для
работы с контейнерами типа set.
Функция includes () реализует стандартное определение подмножества,
проверяя, все ли элементы одного отсортированного диапазона включены в другой
отсортированный диапазон, при этом порядок следования элементов значения не имеет.
Функции set_union (), set_intersection (), set_dif f erence () и set_syrametric_
difference () реализуют стандартную семантику соответствующих операций. В
случае если вы забыли теорию множеств, попробуем вкратце напомнить основные
моменты. Результат объединения множеств (union) включает все элементы множеств,
участвующих в объединении. Результатом пересечения (intersection) двух множеств
являются все элементы, имеющиеся в обоих множествах. Результат разности двух
множеств (difference) включает все элементы первого множества, которые
отсутствуют во втором. Строгая дизъюнкция (symmetric difference) — это, по сути,
применение к множествам операции "исключающее ИЛИ": ее результат составят все
элементы всех множеств, за исключением совпадающих.
При выполнении операций над множествами необходимо
позаботиться о том, чтобы результирующий диапазон был достаточно
большим для хранения результата. Для алгоритмов setunionO
и set_symmetric_diff erence () размер результата по большей мере
может составлять суммарное значение размеров двух входных
диапазонов. Для алгоритмов setintersectionO и set_dif f erence ()
необходимо рассчитывать на максимальный из двух размеров.
При этом следует помнить, что для хранения результатов нельзя
использовать итераторные диапазоны ассоциативных контейнеров,
включая диапазоны множеств (set).
Рассмотрим пример использования упомянутых алгоритмов.
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
// Функции populateContainer() и print О идентичны показанным
// в предыдущих примерах и поэтому здесь опущены.
int main(int argc, char** argv)
{
vector<int> setOne, setTwo, setThree,-
cout << "Введите первое множество:\n";
populateContainer(setOne);
cout << "Введите второе множество:\п";
populateContainer(setTwo);
// Выполняем сортировку элементов, поскольку set-алгоритмы
// работают с отсортированными диапазонами,
sort(setOne.begin(), setOne.end());
sort(setTwo.begin(), setTwo.end());
Глава 22. Освоение STL-алгоритмов и функциональных объектов 723
if (includes(setOne.begin(), setOne.end О, setTwo.begin()
setTwo.end())) {
cout << "Второе множество является подмножеством
Ч>первого. \п";
}
if (includes(setTwo.begin(), setTwo.end(),
setOne.begin(), setOne.end())) {
cout << "Первое множество является подмножеством
Ч>второго.\п";
}
setThree.resize(setOne.size() + setTwo.size()) ;
vector<int>: :iterator newEnd,-
newEnd = set_union(setOne.begin(), setOne.end(),
setTwo.begin(), setTwo.end(),
setThree.begin() ) ;
cout << "Объединение множеств включает: " ;
for_each(setThree.begin(), newEnd, Sprint);
cout << endl;
newEnd = set_intersection(setOne.begin(), setOne.end(),
setTwo.begin(), setTwo.end(),
setThree. begin () ) ,-
cout << "Пересечение множеств включает: " ;
for_each(setThree.begin(), newEnd, &print);
cout << endl,-
newEnd = set_difference(setOne.begin(), setOne.end(),
setTwo.begin(), setTwo.end(),
setThree.begin());
cout << "Разность между первым и вторым множеством
Ч>включает: ";
for_each(setThree.begin(), newEnd, &print);
cout << endl;
newEnd = set_symmetric_difference(
setOne.begin(), setOne.end(),
setTwo.begin(), setTwo.end(),
setThree.begin());
cout << "Строгая дизъюнкция множеств включает: ";
for_each(setThree.begin(), newEnd, &print);
cout << endl;
return (0);
}
Вот пример выполнения этой программы.
Введите первое множество:
Введите число (0 для выхода): 5
Введите число (0 для выхода): 6
Введите число (0 для выхода): 7
Введите число (0 для выхода): 8
Введите число (0 для выхода): 0
Введите второе множество:
Введите число (0 для выхода): 8
Введите число (0 для выхода): 9
Введите число (0 для выхода): 10
Введите число (0 для выхода): 0
Объединение множеств включает: 5 6 7 8 9 10
Пересечение множеств включает: 8
Разность между первым и вторым множеством включает: 5 6 7
Строгая дизъюнкция множеств включает: 5 6 7 9 10
724 Часть V. Использование библиотек и шаблонов
Пример использования алгоритмов
и функциональных объектов:
проверка регистрации участников голосования
Подтасовка голосов на выборах может быть проблемой в США. Иногда люди
стараются зарегистрироваться и проголосовать в двух и более различных округах. Кроме
того, лица, отбывающие наказание и по закону некоторых штатов не имеющие права
голосовать, порой пытаются это сделать. Используя рассмотренные выше алгоритмы
и возможности функциональных объектов, мы могли бы написать простую функцию
проверки регистрации участников голосования, которая бы выявляла в списках
избирателей определенные аномалии.
Постановка задачи проверки регистрации участников
голосования
Цель функции проверки регистрации избирателей — проверить информацию для
одного штата. Предположим, что списки избирателей по округам хранятся в map-
контейнере, который отображает названия округов на списки (list-контейнеры)
участников голосования. Наша функция, принимая этот map-контейнер и список лиц,
отбывающих наказание, в качестве параметров, должна удалить из списков
избирателей имена всех найденных осужденных. Кроме того, эта функция должна найти всех
избирателей, которые зарегистрированы в нескольких округах, а затем удалить эти
имена из всех округов. Для простоты предположим, что список избирателей
представляет собой list-контейнер string-имен. В реальном приложении пришлось бы
обрабатывать больше данных, например адрес и принадлежность к партии.
Функция auditVoterRolls ()
В этом примере реализован нисходящий принцип проектирования, т.е. мы
начинаем с функции высшего уровня и "обращаемся" к функциям и функторам, которые
еще даже не написаны. Опущенные детали будем разрабатывать по ходу дела.
Функция самого высокого уровня, auditVoterRolls (), выполняется в три этапа.
1. Находим все имена-дубликаты во всех списках регистрации избирателей,
вызывая для этого функцию getDuplicates ().
2. Объединяем список дубликатов со списком отбывающих наказание и удаляем
дубликаты в объединенном списке.
3. Удаляем из каждого списка избирателей все имена, обнаруженные в
объединенном списке (дубликатов и отбывающих наказание). Для обработки каждого
списка (list) в отображении (тар) здесь используется алгоритм for_each(),
использующий пользовательский (т.е. не встроенный) функтор RemoveNames
для удаления имен нарушителей закона из списка.
Вот как выглядит реализация функции auditVoterRolls ().
//
// Функция auditVoterRolls
//
// принимает отображение (тар) пар "string-list<string>", в
Глава 22. Освоение STL-алгоритмов и функциональных объектов 725
// которых ключами являются названия округов, а значениями --
// списки всех зарегистрированных избирателей в этих округах.
//
// Функция удаляет из каждого списка любое имя, содержащееся
// в списке convictedFelons, и любое имя, обнаруженное в
// любом другом списке.
//
void auditVoterRolls(map<string,
list<string> >& votersByCounty,
const list<string>& convictedFelons)
{
// Получаем всё имена-дубликаты.
list<string> duplicates = getDuplicates(votersByCounty);
// Объединяем список дубликатов со списком осужденных,
// чтобы удалить из всех окружных списков избирателей
// имена, содержащиеся в обоих "черных" списках.
duplicates.insert(duplicates.end(),
convictedFelons.begin(),
convictedFelons.end());
// Если обнаружены какие-либо дубликаты, удаляем их.
// Вместо обобщенных алгоритмов используем list-версии
// методов sort() и unique О, поскольку они работают
// эффективнее,
duplicates.sort() ;
duplicates.unique();
// Теперь удаляем все имена, которые этого заслуживают,
for_each(votersByCounty.begin(), votersByCounty.end(),
RemoveNames(duplicates));
}
Функция getDuplicates ()
Функция getDuplicates () должна находить любое имя, которое имеет дубликат
в списке регистрации участников голосования. Для решения этой задачи существует
несколько подходов. Данная реализация просто объединяет списки от каждого округа
в один большой список и сортирует его. В результате любые имена-дубликаты из
разных списков окажутся соседями в большом (объединенном) списке. После этого
функция getDuplicates (), чтобы найти все смежные дубликаты, может
использовать алгоритм adjacent_f ind() для большого отсортированного списка. Вот как
выглядит реализация этой функции.
//
// Функция getDuplicates()
//
// возвращает список всех имен, присутствующих в нескольких
// списках отображения.
//
// Данная реализация генерирует один большой список всех имен
// из всех списков отображения, сортирует его, а затем
// находит все дубликаты в отсортированном списке с помощью
// алгоритма adjacent_find().
//
list<string> getDuplicates(const map<string,
list<string> >& voters)
{
list<string> allNames, duplicates;
726 Часть V. Использование библиотек и шаблонов
}
не
// Собираем все имена из всех списков в один большой
// список.
map<string, list<string> >::const_iterator it;
for(it = voters.begin(); it != voters.end(); ++it) {
allNames.insert(allNames.end(), it->second.begin(),
it->second.end());
}
// Сортируем с помощью метода сортировки list-версии, а
// обобщенного алгоритма, поскольку list-версия работает
// быстрее.
allNames.sort();
//
// Теперь в отсортированном списке все имена-дубликаты
// будут соседями. Для их поиска используем алгоритм
// adjacent_find().
//
// Цикл продолжается до тех пор, пока алгоритм
// adjacent_find не возвратит итератор конца.
//
list<string>::iterator lit;
for (lit = allNames.beginO ; lit != allNames.end() ; ++lit) {
lit = adjacent_find(lit, allNames.end());
if (lit == allNames.end()) {
break;
}
duplicates.pushback(*lit);
}
//
// Если некоторое имя находилось в более чем двух списках
// избирателей, оно будет приведено более одного раза в
// списке дубликатов. Отсортируем список и удалим
// дубликаты с помощью метода unique О .
//
// Используем list-версии методов, поскольку они быстрее
// обобщенных алгоритмов.
//
duplicates.sort();
duplicates.unique();
return (duplicates);
Функтор RemoveNames
Функция auditVoterRolls () для удаления всех имен нарушителей закона
(т.е. дубликатов и имен лиц, отбывающих наказание) из каждого окружного списка
(list), включенного в отображение (тар), используемое для регистрации всех
участников голосования штата, выполняет следующую строку кода.
foreach(votersByCounty.begin(), votersByCounty.end(),
RemoveNames(duplicates));
Алгоритм for_each() вызывает функтор RemoveNames для каждой пары
"string-list<string>", входящей в отображение. Определение класса функтора
RemoveNames имеет следующий вид.
Глава 22. Освоение STL-алгоритмов и функциональных объектов 727
//
// RemoveNames
//
// Этот класс функтора принимает пару wstring-list<string>" и
// удаляет из списка любые строки (string-значения), которые
// обнаружены в списке имен (принимаемом конструктором).
//
class RemoveNames : public unary_function<pair<const string,
list<string> >, void>
{
public:
RemoveNames(const list<string>& names) : mNames(names){}
void operator() (pair<const string,
list<string> >& val);
protected:
const list<string>& mNames,-
};
Обратите внимание на то, что класс RemoveNames — производный от класса
unary_function, упомянутого выше в этой главе. Конструктор класса RemoveNames
принимает ссылку на список имен, которую он сохраняет для использования в
операторе вызова функции. Напомним, что параметр, применяемый для функтора
обратного вызова, является элементом отображения (тар), т.е. представляет собой пару
"string-list<string>". Назначение оператора вызова функции— удалить любые
имена из списка строк, обнаруженные в списке mNames. В данной реализации
используется алгоритм remove_if () со специальным предикатом NamelnList.
//
// Оператор вызова функции для функтора RemoveNames
//
// сначала использует алгоритм remove_if(), а затем метод
// erase() для фактического удаления имен из списка
// избирателей.
//
// Имена удаляются из этого списка в том случае, если они
// находятся в нашем списке mNames. Для проверки их наличия в
// нем используется функтор NamelnList.
//
void RemoveNames::operator() (pair<const string, list<string> >& val)
{
list<string>::iterator it = remove_if(val.second.begin(),
val.second.end(),
NamelnList(mNames));
val.second.erase(it, val.second.end());
}
Напомним, что семейство алгоритмов remove () не предназначено для реального
удаления элементов из контейнера; эти алгоритмы лишь перемещают их в конец
заданного диапазона. Для фактического удаления элементов необходимо вызвать для
данного контейнера метод erase ().
Функтор NamelnList
Функтор RemoveNames вызывает алгоритм remove__if () с использованием класса
функтора-предиката NamelnList. Функтор NamelnList возвращает значение true,
если строка, переданная ему в качестве аргумента, находится в списке mNames.
Определение этого класса имеет следующий вид.
728 Часть V. Использование библиотек и шаблонов
//
// NamelnList
//
// Этот класс функтора проверяет, есть ли заданная строка в
■// списке строк (передаваемом конструктору) .
//
class NamelnList : public unary_function<string, bool>
{
public:
NamelnList(const list<string>& names) : mNames(names) {}
bool operator() (const string& val);
protected:
const list<string>& mNames;
};
В реализации оператора вызова функции operator () для поиска имени,
заданного параметром name, в списке строк mNames используется алгоритм find(). Метод
operator () возвращает значение true, если алгоритм findO возвратит
действительный итератор, в противном случае (т.е. если он вернет итератор конца
диапазона) — значение false.
//
// Оператор вызова функции для функтора NamelnList
//
// возвращает значение true, если имя name найдено в списке
// mNames, в противном случае -- значение false. Для поиска
// string-значения используется алгоритм find().
//
bool NamelnList::operator() (const strings name)
{
return (find(mNames.begin(), mNames.end(),
name) != mNames.end());
}
Тестирование функции auditvoterRolls ()
Теперь имеет смысл протестировать полную реализацию программы проверки
списка избирателей.
#include <algorithm>
#include <functional>
#include <map>
#include <list>
#include <iostream>
#include <utility>
#include <string>
using namespace std;
void printString(const stringb str) ,
{
cout << " {" << str << "}";
void printCounty(const pair<const string,
list<string> >& county)
{
cout << county.first << ":";
for_each(county.second.begin(), county.second.end(),
Глава 22. Освоение STL-алгоритмов и функциональных объектов 729
&printString);
cout < < endl;
}
int main(int argc, char** argv)
{
map<string, list<string> > voters;
list<string> nameList, felons,-
nameList.push_back("Amy Aardvark");
nameList.pushback("Bob Buffalo");
nameList.push_back("Charles Cat");
nameList.push_back("Dwayne Dog");
voters. insert (make_j?air ( "Orange" , nameList) •) ;
nameList.clear();
nameList .push_back( "Elizabeth Elephant") ,-
nameLi st.pushback("Fred Flamingo");
nameList.push_back("Amy Aardvark");
voters, insert (make__pair ( "Los Angeles", nameList));
nameList.clear();
nameList.push_back("George Goose");
nameList.push_back("Heidi Hen");
nameLi st.push_back("Fred Flamingo");
voters.insert(make_pair("San Diego", nameList));
felons.pushbackf'Bob Buffalo");
felons.push_back("Charles Cat");
for_each(voters.begin(), voters.end(), &printCounty);
cout << endl;
auditVoterRolls (voters, felons) ,-
foreach(voters.begin(), voters.end(), &printCounty);
return (0);
}
Результаты выполнения этой тестовой программы таковы.
Los Angeles: {Elizabeth Elephant} {Fred Flamingo} {Amy Aardvark}
Orange: {Amy Aardvark} {Bob Buffalo} {Charles Cat} {Dwayne Dog}
San Diego: {George Goose} {Heidi Hen} {Fred Flamingo}
Los Angeles: {Elizabeth Elephant}
Orange: {Dwayne Dog}
San Diego: {George Goose} {Heidi Hen}
Резюме
В этой главе описаны основные возможности библиотеки STL (алгоритмы и
функциональные объекты) и показано, как написать собственные функциональные
объекты. Мы надеемся, что вы по достоинству оценили такие мощные STL-средства, как
контейнеры, алгоритмы и функциональные объекты. Если этого не произошло,
попробуйте хотя бы мысленно переписать пример проверки списков регистрации
участников голосования без использования библиотеки STL. Ведь вам для этого
потребуется написать собственные классы связанного списка (list) и отображения (тар),
730 Часть V. Использование библиотек и шаблонов
а также собственные алгоритмы поиска и удаления. Ваша программа будет намного
длиннее, труднее для отладки и гораздо сложнее для понимания.
Если вас еще не впечатлили алгоритмы и функциональные объекты или вы
посчитали их слишком сложными, вам совсем необязательно их использовать. Выбирайте
свободно то, что вам подходит: например, если алгоритм find() прекрасно
зарекомендовал себя в одной из ваших пробных программ, не стоит отказываться от него по
причине "неприязни" к другим алгоритмам. Не следует строить свои отношения
с библиотекой STL на принципе "все или ничего". Если из всего разнообразия
контейнеров вам полюбился только вектор (vector), это тоже совсем неплохо.
В главе 23 мы продолжаем развивать тему библиотеки STL, переходя к
рассмотрению таких уже более сложных средств, как распределители и итераторные адаптеры,
а также к написанию собственных алгоритмов, контейнеров и итераторов.
Использование
и расширение
возможностей STL
В предыдущих двух главах мы показали, что STL — мощная коллекция
контейнеров и алгоритмов общего назначения. Изложенного до сих пор материала вполне
достаточно для написания большинства приложений. Но, надо сказать, библиотека
STL обладает гораздо большей гибкостью и расширяемостью, чем это было
продемонстрировано в предыдущих главах. Например, мы можем применять итераторы
к входным и выходным потокам, а также создавать собственные контейнеры,
алгоритмы и итераторы и даже определять для своих контейнеров собственную модель
распределения памяти. В этой главе мы постараемся, чтобы вы "на вкус"
попробовали эти возможности, главным образом, на примере разработки нового
STL-контейнера hashmap. Итак, эта глава охватывает следующие темы.
□ Распределители памяти при ближайшем рассмотрении.
□ Итераторные адаптеры.
□ Расширение средств STL:
□ написание алгоритмов;
□ создание контейнеров: реализация хеш-отображения;
□ написание итераторов: реализация итератора для хеш-отображения.
732 Часть V. Использование библиотек и шаблонов
Эта глава— не для "слабонервных"! Мы намерены погрузиться вместе с читателем
в самые сложные и синтаксически запутанные области языка C++. Если вам
достаточно информации об основных STL-контейнерах и алгоритмах из предыдущих двух
глав, можете с легким сердцем эту пропустить. Однако, если вы действительно хотите
понять всю прелесть этой библиотеки, а не просто ее использовать, дайте настоящей
главе шанс быть прочитанной. Но сначала просмотрите материал главы 11, чтобы
убедиться в том, что вы хорошо разбираетесь в шаблонах.
Распределители памяти
Как упоминалось в главе 21, каждый STL-контейнер принимает тип Allocator в
качестве шаблонного параметра, для которого обычно используется значение,
действующее по умолчанию. Например, шаблонное определение контейнера vector
выглядит так.
template <typename T,
typename Allocator = allocator<T> > class vector;
Конструкторы этого контейнера позволяют задавать объект типа Allocator. Этот
дополнительный параметр разрешает изменять способ распределения памяти в данном
контейнере. Каждая операция по вьщелению памяти, выполняемая контейнером,
производится посредством обращения к методу allocate () объекта класса Allocator. Л каждая
операция по освобождению памяти— посредством вызова метода deallocate ().
Стандартная библиотека шаблонов содержит класс распределителя памяти
(именуемый allocator), который реализует эти методы как оболочки для
операторов operator new и operator delete. Именно этот класс используется во многих
шаблонных определениях контейнеров в качестве значения по умолчанию.
Если вы хотите, чтобы контейнеры в вашей программе использовали
нестандартную схему выделения и освобождения памяти, например с использованием пула памяти,
можно написать собственный класс Allocator. Любой класс, в котором определены
методы allocate (), deallocate () и ряд других обязательных атрибутов (т.е. методов
и typedef-определений), можно использовать вместо действующего по умолчанию
класса allocator. Однако, опираясь на собственный опыт, можем сказать, что эта
возможность используется редко, и поэтому мы опустили эту деталь из материала данной
книги. Те читатели, которых интересует этот вопрос, могут обратиться к одной из книг
по стандартной библиотеке C++, перечисленных в приложении Б.
Итераторыые адаптеры
Стандартная библиотека содержит три итераторных адаптера, под которыми
понимаются специальные итераторы, построенные на основе других. Больше о
разработке адаптеров можно узнать в главе 26. А пока просто прикинем, насколько эти
итераторы могут оказаться полезными для нас. Все три итераторных адаптера
объявлены в заголовке <iterator>.
Вы можете создавать собственные итераториые адаптеры. Подробнее об этом
можно прочитать в одной из книг по стандартной библиотеке C++, перечисленных
в приложении Б.
Глава 23. Использование и расширение возможностей STL 733
Реверсивные итераторы
Библиотека STL включает класс reverse_iterator, который позволяет
обращаться к членам контейнера с помощью двунаправленного итератора или итератора
произвольного доступа в реверсивном направлении. Применение оператора operator++
к итератору типа reverse_iterator вызывает для базового (для данного
контейнера) итератора оператор operator-- и наоборот. Каждый обратимый STL-контейнер,
которому "посчастливилось" стать частью стандарта C++, поддерживает typedef-
определение reverse_iterator и методы rbeginO и rend(). Метод rbeginO
возвращает итератор типа reverse_iterator, который ссылается на последний
элемент контейнера, а метод rend () — на первый.
Итератор типа reverse iterator предназначен, главным образом, для алгоритмов
STL, которые не имеют эквивалентов, работающих "в обратном направлении".
Например, базовый алгоритм find () находит первый элемент в последовательности. Если вы
хотите отыскать последний элемент в той же последовательности, можете
использовать итератор типа reverseiterator. При этом имейте в виду, что, вызывая такой
алгоритм, как findO, с итератором типа reverse_iterator, он также возвратит
итератор типа reverse_iterator. Из итератора типа reverse_iterator можно
всегда получить "обычный" итератор, вызвав для реверсивного метод base (). Однако
здесь следует помнить, что (в силу особенностей реализации типа reverse_iterator)
итератор, возвращаемый методом base (), всегда ссылается на элемент,
расположенный после элемента, адресуемого итератором типа reverseiterator, для которого
был вызван этот метод.
Итак, рассмотрим пример использования алгоритма findO с итератором типа
reverse_iterator.
#include <algorithm>
#include <vector>
#include <iostream>
#include <iterator>
using namespace std;
// Реализация функции populateContainer() идентична
// показанной в главе 22, поэтому она здесь опущена.
int main(int argc, char** argv)
{
vector<int> myVector;
populateContainer(myVector);
int num;
cout << "Введите число для поиска: ";
cin >> num;
vector<int>::iterator itl;
vector<int>::reverse_iterator it2;
itl = find(myVector.begin(), myVector.end(), num) ;
it2 = find (myVector. rbeginO , myVector. rend () , num);
if (itl != myVector.end()) {
cout << "Найдено число " << num « "в позиции "
<< itl - myVector.begin()
<< " (при поиске вперед).\n";
cout << "Найдено число " << num « " в позиции "
<< it 2. base () - 1 - myVector.beginO
« " (при поиске назад) . \n" ,-
} else {
734 Часть V. Использование библиотек и шаблонов
cout « "Число " << num << " найти не удалось." « endl;
}
return (0);
}
Одна строка из этой программы требует дополнительных разъяснений. Код
вывода номера позиции, соответствующей найденному числу, при использовании
реверсивного итератора выглядит так.
cout << "Найдено число " << num << " в позиции "
<< it2.base() - 1 - myVector.begin()
« " (при поиске назад).\п";
Как упоминалось выше, метод base () возвращает итератор, ссылающийся на
элемент, расположенный за элементом, адресуемым итератором типа reverseiterator,
для которого был вызван этот метод. Поэтому, чтобы указать на нужный элемент,
необходимо из результата преобразования итераторов (с помощью метода base ())
вычесть единицу.
Потоковые итераторы
Как упоминалось в главе 21, библиотека STL включает адаптеры, которые
позволяют обрабатывать входные и выходные потоки подобно входным и выходным
итераторам. С помощью этих итераторов вы можете адаптировать входные и выходные
потоки так, чтобы они в различных STL-алгоритмах могли служить в качестве
источников и приемников соответственно. Например, для вывода элементов контейнера
при использовании итератора ostreamiterator вместе с алгоритмом сору()
достаточно было бы лишь одной строки кода.
#include <algorithm>
#include <iostream>
#include <iterator>
#include <vector>
using namespace std;
int main(int argc, char** argv)
{
vector<int> myVector;
for (int i = 0; i < 10; i++) {
myVector.push back(i);
}
// Выводим содержимое вектора.
copy(myVector.begin(), myVector.end(),
ostream_iterator<int>(cout, " "));
cout << endl;
}
Тип ostream_iterator определен как шаблонный класс, который принимает
в качестве параметра-типа тип элемента. Его конструктор в качестве параметров
принимает выходной поток и string-значение, подлежащее записи в поток после
каждого выводимого элемента (в данном случае вектора).
Аналогично для считывания значений из входного потока с помощью итератор-
ной абстракции можно использовать итератор типа istreamiterator. В
обобщенных алгоритмах и методах контейнеров итератор типа istream__iterator может
играть роль источника. Однако в этом качестве он менее популярен, чем тип
Глава 23. Использование и расширение возможностей STL 735
ostream__iterator, поэтому приведение соответствующего примера мы посчитали
излишним. При необходимости получить более подробную информацию обратитесь
к одной из книг, перечисленных в приложении Б.
Итераторы вставки
Как упоминалось в главе 22, алгоритмы, подобные сору (), не вставляют элементы
в контейнер; они просто заменяют старые элементы в диапазоне новыми. Чтобы
сделать такие алгоритмы более полезными, в библиотеке STL предусмотрены три итера-
торных адаптера вставки, которые действительно вставляют элементы в контейнер.
Они шаблонизированы по типу контейнера и принимают в своих конструкторах
ссылку на реальный контейнер. Посредством необходимых итераторных
интерфейсов эти адаптеры можно использовать в качестве приемных итераторов для таких
алгоритмов, как сору (). Но в этом случае вместо замены они действительно
выполняют вставку в контейнер новых элементов.
Итератор типа insert_iterator вызывает для контейнера метод insert (position,
element), итератор типа back_insert_iterator— метод push_back (element),
а итератор типа f ront_insert_iterator— метод push_front (element).
Например, чтобы заполнить новый вектор всеми элементами из старого вектора, которые
не равны числу 100, можно использовать итератор типа backinsertiterator
вместе с алгоритмом remove_copy_if ().
#include <algorithm>
#include <functional>
#include <iterator>
#include <vector>
#include <iostream>
using namespace std;
// Реализация функции populateContainer() идентична
// показанной в главе 22, поэтому она здесь опущена.
int main(int argc, char** argv)
{
vector<int> vectorOne, vectorTwo,-
populateContainer(vectorOne);
back_insert_iterator<vector<int> > inserter(vectorTwo);
remove_copy_if(vectorOne.begin(), vectorOne.end(),
inserter, bind2nd(equal_to<int> () , 100)),-
copy(vectorTwo.begin(), vectorTwo.end(),
ostream_iterator<int>(cout, " "));
cout << endl;
return (0);
}
Как видите, при использовании итераторов вставки вам не нужно загодя
"подгонять по размеру" контейнеры-приемники.
Итераторы типа insert__iterator и f ront_insert_iterator функционируют
аналогично, за исключением того, что конструктор класса insert_iterator таклсе
принимает начальную итераторную позицию, которая передается при первом
обращении к методу insert (position, element). Значения же последующих итера-
736 Часть V. Использование библиотек и шаблонов
торных позиций (в качестве рекомендованных) генерируются на основе значения,
возвращаемого каждым предыдущим вызовом метода insert ().
Одно из самых существенных достоинств класса insert_iterator состоит в том,
что он позволяет использовать ассоциативные контейнеры в качестве приемников
модифицирующих алгоритмов. Как упоминалось в главе 22, основная проблема
ассоциативных контейнеров заключается в том, что они не позволяют модифицировать
элементы обычным способом. Но, используя итератор типа insert_iterator, можно
осуществить вставку элементов, разрешив контейнеру отсортировать их надлежащим
образом "внутренними средствами". В главе 21 отмечалось, что ассоциативные
контейнеры действительно поддерживают форму метода insert (), которая принимает ите-
раторную позицию, причем эта позиция воспринимается ими как "рекомендация",
которую они могут проигнорировать. При использовании итератора типа
insert iterator для ассоциативного контейнера можно просто передать
начальный или конечный итератор, зная, что этот параметр будет носить "рекомендательный
характер". Ниже приведен предыдущий пример, модифицированный так, чтобы в
качестве контейнера-приемника выступал не вектор, а множество (set).
#include <algorithm>
#include <functional^
#include <iterator>
#include <vector>
#include <iostream>
#include <set>
using namespace std;
// Реализация функции populateContainer() идентична
// показанной в главе 22, поэтому она здесь опущена.
int main(int argc, char** argv)
{
vector<int> vectorOne;
set<int> setOne;
populateContainer(vectorOne);
insert_iterator<set<int> > inserter(setOne,
setOne.begin() ) ;
remove_copy_if(vectorOne.begin(), vectorOne.end(),
inserter, bind2nd(equal_to<int>(), 100));
copy(setOne.begin(), setOne.end(),
ostream_iterator<int>(cout, " "));
cout << endl;
return (0);
}
Обратите внимание на то, что итератор типа insertiterator модифицирует
рекомендованное значение итераторной позиции, которое передается методу
insert () после каждого предыдущего обращения к том)' же методу, так, чтобы эта
позиция принадлежала следующему после только что вставленного элемента.
Глава 23. Использование и расширение возможностей STL 737
Расширение библиотеки STL
Библиотека STL включает много полезных контейнеров, алгоритмов и
итераторов, которые можно использовать в различных приложениях. Но никакая, даже самая
распрекрасная библиотека не может содержать все возможные средства, которые
могут понадобиться потенциальному клиенту. Поэтому самой лучшей библиотекой
можно считать ту, которая открыта к расширениям, т.е. позволяет клиентам адаптировать
базовые утилиты (и добавлять отсутствующие) к специфическим потребностям.
Библиотека STL в своей основе таковой и является благодаря фундаментальной структуре
отделения данных от алгоритмов, которые их обрабатывают. Программист может
написать собственный контейнер, который будет работать с STL-алгоритмами с
помощью итератора, соответствующего стандарту STL. Аналогично программист может
написать функцию, предназначенную для работы с итераторами стандартных
контейнеров. В этом разделе мы рассмотрим правила расширения библиотеки STL, а
затем теорию закрепим практической реализацией некоторых расширений.
Зачем расширять библиотеку STL
Приступая к написанию алгоритма или контейнера на C++, вы можете сделать его
либо в соответствии с STL-соглашениями, либо— нет. Для простых контейнеров
и алгоритмов, возможно, и не стоит тратить большие усилия на соответствие
требованиям STL. Но для более грандиозных проектов, которые вы планируете
использовать в будущем еще не раз, ваши затраты окупятся сполна. Во-первых, если вы будет
следовать твердо установившимся принципам построения интерфейсов, такой код
будет легче для понимания другими С++-программистами. Во-вторых, вы сможете
применять свой контейнер или алгоритм совместно с другими частями STL
(алгоритмами или контейнерами) без специальных доработок или адаптеров.
Наконец, это просто заставит вас применить в своей работе определенную строгость,
требуемую для разработки профессионального кода такого уровня.
Написание STL-алгоритма
Алгоритмы, описанные в главе 22, очень полезны, но вы неизбежно попадете в
ситуацию, когда для ваших программ понадобятся новые алгоритмы. В этом случае
программисты обычно пишут собственные алгоритмы, которые работают с STL-
итераторами подобно стандартным.
Алгоритм findallО
Предположим, нам нужно отыскать в заданном диапазоне все элементы,
соответствующие заданному предикату. Алгоритмы find{) и find_if () — наиболее
подходящие кандидаты для решения этой задачи, но каждый из них возвращает итератор,
ссылающийся только на один элемент. И в самом деле, нет стандартного алгоритма,
который бы находил все элементы, отвечающие предикату. К счастью, мы сами можем
написать собственную версию алгоритма с таким поведением и назовем ее f indall ().
Сначала мы должны определить прототип функции. Мы можем смоделировать
алгоритм f indall () "по образу и подобию" алгоритма f ind_if (). Это будет шаблонная
функция с двумя параметрами^гипами — для итератора и предиката. Ее аргументами
будут начальный и конечный итераторы и предикативный объект. Единственное отличие
нашего алгоритма от f ind_if () будет касаться возвращаемого значения: наша функция
738 Часть V. Использование библиотек и шаблонов
f ind_all () будет возвращать не один итератор, ссылающийся на отвечающий
предикату элемент, а вектор (vector) итераторов, ссылающихся на все соответствующие
предикату элементы. Вот как выглядит прототип нашего алгоритма.
template <typename InputIterator, typename Predicate>
vector<InputIterator>
find_all(InputIterator first, Inputlterator last.
Predicate pred);
В качестве альтернативного варианта можно было бы "обязать" этот алгоритм
возвращать "интеллектуальный" итератор, который при перемещении по
контейнеру ссылался бы только на элементы, отвечающие предикату, но для этого нам
потребовалось бы написать собственный класс итератора.
Следующий наш шаг— написать реализацию алгоритма. Алгоритм findallO
можно было бы разместить "над" алгоритмом f indif () и организовать его
повторные вызовы. При первом вызове алгоритма f indif () мы бы задали весь диапазон,
т.е. от значения first до значения last. При втором вызове диапазон бы сузился: от
элемента, найденного в результате предыдущего вызова, до значения last. Цикл
вызовов продолжался бы до тех пор, пока функции findif () больше не удалось бы
найти элемент, соответствующий предикату. Вот как выглядит такая реализация.
template <typename Inputlterator, typename Predicate>
vector<InputIterator>
findall(Inputlterator first, Inputlterator last,
Predicate pred)
{
vector<InputIterator> res;
while (true) {
// Находила следующее соответствие предикату
// в текущем диапазоне.
first = find_if(first, last, pred);
// Проверяем, удалось ли алгоритму find_if() найти
// элемент, отвечающий предикату,
if (first == last) {
break,-
}
//Сохраняем найденный элемент.
res.push_back(first) ,-
// Сокращаем диапазон, начиная поиск с элемента,
// следующего за найденным.
++f irst,-
}
return (res);
}
Приведем код тестирования нашей функции.
int main(int argc, char** argv)
{
int arr[] = {3, 4, 5, 4, 5, 6, 5, 8};
vector<int*> all = find_all(arr, arr +8, f,
bind2nd(equal_to<int> () , 5)); »'■
cout << "Найдено " << all.size() << " таких элементов: " ;
for (vector<int*>: :iterator it = all.beginQ;
it != all.end(); ++it) { V!
cout « **it « " "; Ii
Глава 23. Использование и расширение возможностей STL 739
}
cout << endl;
return (0);
}
Этот тестовый код требует некоторых разъяснений. В тесте в качестве
STL-контейнера используется массив int-значений. Как упоминалось в главе 21, С-массивы —
вполне легитимные контейнеры, в которых роль итераторов играют указатели.
Начальным итератором такого массива служит указатель на его первый элемент, а
конечным —указатель на область памяти, расположенную за последним элементом.
Отыскав итераторы, ссылающиеся на все нужные элементы, данная тестовая
программа подсчитывает количество обнаруженных элементов, которое просто-
напросто совпадает с числом итераторов, содержащихся в векторе all. Затем
программа выполняет обход вектора all с целью вывода всех его элементов. Обратите
внимание на двойное разыменование итератора it: при первом разыменовании мы
получаем значение типа int*, а при втором — реальное int-значение.
Особенности итераторов
Некоторые реализации алгоритмов требуют дополнительную информацию о
своих итераторах. Например, для временного хранения значений им необходимо
"знать" тип элементов, адресуемых итератором. Иногда нужна информация о типе
итератора, например, он двунаправленный или произвольного доступа.
В C++ определен шаблонный класс iterator_traits, который позволяет найти
такую информацию. Для этого нужно реализовать этот класс, указав тип
интересующего вас итератора, и получить доступ к одному из пяти typedef-определений:
value_type, difference_type, iterator_category, pointer и reference.
Например, в следующей шаблонной функции объявляется временная переменная, на
тип которой ссылается итератор типа IteratorType.
#include <iterator>
template <typename IteratorType>
void iteratorTraitsTest(IteratorType it)
{
typename std::iterator_traits<IteratorType>
temp = *it;
cout << temp << endl;
}
Обратите внимание на использование ключевого слова typename в начале
iterator^ raits-строки. Как разъяснялось в главе 21, при доступе к типу на основе
одного или нескольких шаблонных параметров необходимо явно задавать слово
typename. В данном случае для доступа к типу value_type используется шаблонный
параметр IteratorType.
Написание STL-контейнера
Стандарт C++ содержит список требований, которым должен соответствовать
любой STL-контейнер, чтобы его можно было таковым квалифицировать. Кроме того,
если вы хотите, чтобы ваш контейнер был последовательным (как vector) или
ассоциативным (кактар), он должен подчиняться дополнительным требованиям.
:value_type
temp;
740 Часть V. Использование библиотек и шаблонов
Поэтому при написании нового контейнера необходимо (с нашей точки зрения)
сначала написать базовый контейнер, который бы соответствовал общим STL-
правилам (например в виде шаблонного класса), но не уделяя при этом большого
внимания конкретным деталям. Разработав реализацию, можно добавить в класс
итератор и методы, которые бы позволили вашему контейнеру "сработаться" с STL-
оболочкой. Описанный подход применяется здесь для разработки осет-отображения.
Описание хеш-отображения
С нашей точки зрения, самым большим упущением разработчиков библиотеки STL
является контейнер хеш-таблицы. В отличие от STL-контейнеров тар и set, которые
гарантируют, что вставку, поиск и удаление элементов можно выполнить за
логарифмическое время, хеш-таблица позволяет выполнить те же операции в среднем за
некоторое постоянное время. Вместо сортировки элементов хеш-таблица отображает
каждый элемент на конкретный сегмент (область памяти). До тех пор пока
количество хранимых элементов ненамного больше числа таких областей и хеш-функция
равномерно распределяет элементы по сегментам, операции вставки, поиска и удаления
выполняются в течение некоторого постоянного времени.
В этом разделе предполагается, что читатель знаком с равнодозированными (хеширо-
ванными) структурами данных. Если это не так, обратитесь к одной из книг,
перечисленных в приложении Б.
Многие реализации библиотеки STL предлагают нестандартные варианты хеш-
таблицы. И поэтому, как нетрудно догадаться, из-за отсутствия стандартизации эти
реализации немного отличаются друг от друга. В этом разделе мы рассмотрим реализацию
простого, но полнофункционального хеш-отображения, которое можно использовать
на различных платформах. Подобно отображению (тар), хеш-отображение (hashmap)
хранит пары "ключ-значение". И его операции практически идентичны операциям,
предоставляемым "обычным" map-контейнером.
В реализации нашего контейнера hashmap используется цепное хеширование (также
называемое открытым хешированием) и не предполагается возможность повторного
хеширования (rehashing).
Хеш-функция
Первая задача, которую необходимо решить, приступая к созданию контейнера
hashmap, — выбрать способ обработки хеш-функций. На ум приходит поговорка
о том, что хорошая абстракция делает легкий случай простым, а сложный —
возможным. Аналогично хороший интерфейс контейнера hashmap должен позволить
клиентам задавать собственную хеш-функцию и количество сегментов — и тогда можно
говорить о настройке поведения хеширования под конкретную рабочую нагрузку.
Однако тем клиентам, которые не имеют желания или возможности написать
хорошую хеш-функцию и выбрать количество сегментов, не должно быть отказано в
использовании этого контейнера. Первое решение— позволить клиентам указывать
хеш-функцию и количество сегментов в hashmap-конструкторе, но при этом
позаботиться о задании значений, действующих по умолчанию. Имеет также смысл
"упаковать" хеш-функцию и количество сегментов в класс хеширования.
Определение нашего класса хеширования выглядит следующим образом.
// Любой класс хеширования должен определять два
// метода: hash() и numBuckets().
template <typename T>
Глава 23. Использование и расширение возможностей STL 741
class DefaultHash
{
public:
// Генерирует исключение invalid_argument, если
// значение numBuckets - неположительное.
DefaultHash(int numBuckets = 101)
throw (invalid_argument);
int hash (const T& key) const;
int numBuckets () const { return mNumBuckets,- }
protected:
int mNumBuckets,-
};
Обратите внимание на то, что класс DefaultHash шаблонизирован по типу
ключа, который он хеширует, чтобы поддержать шаблонный контейнер hashmap.
Реализация конструктора этого класса вполне тривиальна.
// Генерирует исключение типа invalid_argument, если
// значение numBuckets - неположительное.
template <typename T>
DefaultHash<T>::DefaultHash(int numBuckets)
throw (invalid_argument)
{
if (numBuckets <= 0) {
throw (invalid_argument(
"Значение numBuckets должно быть > 0"));
}
mNumBuckets = numBuckets;
}
Реализация конструктора метода hash () посложнее, отчасти оттого, что
необходимо предусмотреть возможность применения ключей любого типа. Предполагается
отображать ключ на сегменты в количестве от одного до mNumBuckets. Для
хеширования здесь используется метод деления, в котором сегмент представляет собой
целочисленный результат деления ключа по модулю mNumBuckets (количество сегментов).
// Метод для хеширования использует деление по модулю.
// Ключ рассматривается как последовательность байтов.
// ASCII-значения байтов суммируются, а сумма делится
// (по модулю) на количество сегментов.
template <typename T>
int DefaultHash<T>::hash(const T& key) const
{
int bytes = sizeof(key);
unsigned long res = 0;
for (int i = 0; i < bytes; ++i) {
res += *((char*)&key + i);
}
return (res % mNumBuckets);
}
К сожалению, описанный метод не работает для string-объектов, поскольку
различные string-объекты могут содержать одинаковые string-значения. Как следствие,
одно и то же string-значение может указывать на различные сегменты. Поэтому имеет
смысл определить частичную специализацию класса DefaultHash для string-объектов.
// Специализация для string-объектов.
template <>
class DefaultHash<string>
742 Часть V. Использование библиотек и шаблонов
{
public:
// Генерирует исключение invalid_argument, если
// значение numBuckets - неположительное.
DefaultHash(int numBuckets = 101)
throw (invalid_argument) ,-
int hash(const strings key) const;
int numBuckets() const { return mNumBuckets; }
protected:
int mNumBuckets;
};
// Генерирует исключение invalid_argument, если
// значение numBuckets - неположительное.
DefaultHash<string>::DefaultHash(int numBuckets)
throw (invalid_argument)
{
if (numBuckets <= 0) {
throw (invalid_argument(
"Значение numBuckets должно быть > 0"));
}
mNumBuckets = numBuckets;
}
// Для хеширования использует метод деления после
// суммирования ASCII-значений всех символов в ключе key.
int DefaultHash<string>::hash(const strings key) const
{
int sum = 0;
for (size_t i = 0; i < key.sizeO; i++) {
sum += key[i];
}
return (sum % mNumBuckets) ;
}
Если клиент в качестве ключа желает использовать другие типы-указатели или
объекты, он должен написать собственный класс хеширования для этих типов.
Хеш-функции, показанные в этом разделе, — это простые примеры
базовой реализации класса hashmap. Они не гарантируют
равномерного хеширования для всего множества ключей. Если вам
необходимо получить математически более строгие хеш-функции (или если вы
не знаете, что такое "равномерное хеширование0), обратитесь к
справочникам по соответствующим алгоритмам.
Интерфейс контейнера hashmap
Класс hashmap поддерживает три базовые операции: вставку, удаление и поиск.
Безусловно, в классе определен конструктор, деструктор, конструктор копии и
оператор присваивания. Вот как выглядит public-раздел шаблонного класса hashmap.
template <typename Key, typename T,
typename Compare = std::equal_to<Key>,
typename Hash = DefaultHash<Key> >
class hashmap
{
public:
typedef Key key_type,-
Глава 23. Использование и расширение возможностей STL 743
typedef T mapped_type,-
typedef pair<const Key, T> value_type;
// Конструктор
// генерирует исключение invalid_argument, если
// объект hash задает неположительное количество
// сегментов.
explicit hashmap(const Compareb comp = Compare(),
const Hash& hash = Hash())
throw(invalid_argument);
// Деструктор, конструктор копии, оператор присваивания
-hashmap();
hashmap(const hashmap<Key, T, Compare, Hash>& src);
hashmap<Key, T, Compare, Hash>& operator=(
const hashmap<Key, T, Compare, Hash>& rhs);
// Метод вставки элемента insert()
// вставляет пару "ключ-значение" х.
void insert(const value_type& x);
// Метод удаления элемента erase()
// удаляет элемент с ключом х, если таковой существует.
void erase(const key_type& x);
// Метод поиска элемента find() возвращает указатель
// на элемент с ключом х. Если элемента с таким
// ключом не существует, возвращает значение NULL.
value_type* find(const key_type& x);
// Оператор operator[] находит элемент с ключом х или
// вставляет элемент с таким ключом, если его не
// существует. Возвращает ссылку на значение,
// соответствующее этому ключу.
Т& operator[] (const key_type& x);
protected:
// Детали реализации пока не показаны.
};
Как видите, типы ключа (key) и значения (value) представляют собой
шаблонные аргументы, как и в STL-контейнере тар. Класс hashmap позволяет хранить в
контейнере элементы типа pair<const Key, Т>. Методы insert (), erase (), find ()
и operator [ ] довольно просты для понимания. Но некоторые другие аспекты этого
интерфейса требуют дополнительных разъяснений.
Шаблонный аргумент типа сравнения Compare
Подобно отображению (тар), множеству (set) и другим стандартным
контейнерам, класс hashmap позволяет клиенту задавать тип сравнения в виде шаблонного
параметра и передавать объект сравнения этого типа в конструктор. В отличие от
контейнеров тар и set, класс hashmap не сортирует элементы по ключу, но должен, тем
не менее, сравнивать ключи на предмет равенства. Поэтому вместо класса less в
качестве операции сравнения по умолчанию в нашем контейнере используется класс
equal_to. Объект сравнения применяется только для обнаружения попыток
вставлять в контейнер ключи-дубликаты.
Шаблонный аргумент типа хеширования Hash
Позволяя клиентам определять собственные классы, на основе которых они будут
создавать объекты для передачи конструктору, вы должны решить, как задавать тип
такого параметра в конструкторе. Это можно сделать разными способами, причем не
744 Часть V. Использование библиотек и шаблонов
самый простой из них (связанный с библиотекой STL) предполагает передачу типа
класса в качестве шаблонного параметра и использование этого шаблонного типа
в конструкторе. Как вы видели выше, для класса хеширования мы выбрали именно
такой подход. Таким образом, шаблон hashmap принимает четыре шаблонных
параметра: тип ключа, тип значения, тип сравнения и тип хеширования.
Определения typedef
Шаблонный класс hashmap содержит следующие три typedef-определения.
typedef Key key_type;
typedef T mappedtype,-
typedef pair<const Key, T> value_type;
Тип value_type, в частности, используется для ссылки на более громоздкий тип
pair<const Key, T>. Как будет показано ниже, эти typedef-определения также
входят в список требований, предъявляемых стандартом к STL-контейнерам.
Реализация базового варианта класса hashmap
Определившись с интерфейсом класса hashmap, пора выбрать модель реализации.
Обычно базовая структура хеш-таблицы состоит из фиксированного числа сегментов,
в каждом из которых хранится один или несколько элементов. К этим сегментам
должен быть обеспечен доступ в течение некоторого постоянного времени. Поэтому для
сегментов больше всего подходит такой контейнер, как vector. Каждый сегмент
должен хранить список элементов, так что в качестве типа сегмента можно
использовать STL-контейнер list. Итак, мы определились со структурой нашего класса: это
будет вектор списков, а элементы списка будут иметь тип pair<const Key, T>.
Приведем раздел protected-членов класса hashmap.
protected:
typedef list<value_type> ListType,-
// В первой реализации было бы проще использовать vector,
// а не указатель на vector, который требует динамического
// выделения памяти. Но мы все же используем указатель на
// вектор, чтобы в конечной реализации метод swapO мог
// выполняться за некоторое постоянное время.
vector<ListType>* mElems;
int mSize;
Compare mComp,-
Hash mHash;
Без typedef-определений для value_type и ListType строка объявления члена
данных mElems выглядела бы так.
vector<list<pair<const Key, T> > >* mElems;
Члены mComp и mHash предназначены для хранения объектов храпения и
хеширования соответственно, а член mSize — для хранения текущего количества элементов
в контейнере.
Конструктор
Конструктор инициализирует все поля и выделяет память для нового вектора.
К сожалению, шаблонный синтаксис нельзя назвать простым, и если вам здесь что-
либо непонятно, обратитесь к главе 11.
// Создаем вектор mElems заданным количеством сегментов.
template <typename Key, typename T,
Глава 23. Использование и расширение возможностей STL 745
typename Compare, typename Hash>
hashmap<Key, T, Compare, Hash>::hashmap(
const Compares comp,
const Hash& hash) throw(invalid_argument) :
mSize(O), mComp(comp), mHash(hash)
{
if (mHash.numBuckets() <= 0) {
throw (invalid_argument("Количество сегментов должно
4>быть положительным. ") ) ;
}
mElems = new vector<list<value_type> > (.mHash.numBuckets () ) ;
}
Реализация такого конструктора потребует создания хотя бы одного сегмента,
поэтому он и включает такое ограничение.
Деструктор, конструктор копии и оператор присваивания
Для члена данных mElems необходимо обеспечить возможность разрушения,
копирования и присваивания. Поэтому приводим реализации деструктора,
конструктора копии и оператора присваивания.
template <typename Key, typename T,
typename Compare, typename Hash>
hashmap<Key, T, Compare, Hash>::~hashmap()
{
delete mElems;
}
template <typename Key, typename T,
typename Compare, typename Hash>
hashmap<Key, T, Compare, Hash>:-.hashmap (
const hashmap<Key, T,
Compare, Hash>& src) :
mSize(src.mSize), mComp(src.mComp), mHash(src.mHash)
{
// Здесь не нужно беспокоиться о том, чтобы значение
// numBuckets было положительным, поскольку объект src
// уже проверен.
// Используем конструктор копии вектора.
mElems = new vector<list<value_type> >(*(src.mElems));
}
template <typename Key, typename T,
typename Compare, typename Hash>
hashmap<Key, T,
Compare,
Hash>& hashmap<Key, T,
Compare, Hash>::operator=(
const hashmap<Key, T, Compare, Hash>& rhs)
{
// Проверяем на самоприсваивание.
if (this != &rhs) {
delete mElems;
mSize = rhs.mSize;
mComp = rhs.mComp;
mHash = rhs.mHash;
// He нужно беспокоиться о том, чтобы значение
// numBuckets было положительным, поскольку объект src
// уже проверен.
// Используем конструктор копии вектора.
mElems = new vector<list<value_type> >(* (rhs .mElems) ) ,-
746 Часть V. Использование библиотек и шаблонов
}
return (*this);
}
Обратите внимание на то, что как конструктор копии, так и оператор
присваивания создают новый вектор, используя конструктор копии класса vector с вектором
(в качестве источника) из исходного hashmap-объекта.
Поиск элементов
Для каждой из трех основных операций (поиск, вставка и удаление) нужен код,
позволяющий найти элемент с заданным ключом. Поэтому для выполнения этой задачи
было бы неплохо создать вспомогательный protected-метод, что мы и делаем. Метод
findElement О сначала использует хеш-объект для "связывания" ключа с нужным
сегментом. Затем в этом сегменте производится поиск элемента, ключ которого
совпадает с заданным. Поскольку элементы в нашем контейнере хранятся как пары
"ключ-значение", реальное сравнение должно выполняться по первому полю
элемента. Для операции сравнения используется функциональный объект сравнения,
задаваемый в конструкторе. Время поиска нужного элемента в list-контейнерах
характеризуется линейной зависимостью, но вместо цикла for можно было бы
использовать алгоритм find ().
template <typename Key, typename T,
typename Compare, typename Hash>
typename list<pair<const Key, T> >::iterator
hashmap<Key, T,
Compare, Hash>::findElement(const key_type& x,
int& bucket) const
{
// Хешируем ключ для получения нужного сегмента.
bucket = mHash.hash(x) ,-
// Находим элемент по заданному ключу в конкретном сегменте,
for (typename ListType::iterator it =
(*mElems)[bucket].begin();
it != (*mElems)[bucket].end(); ++it) {
if (mComp(it->first, x)) {
return (it);
}
}
return ((*mElems)[bucket].end());
}
Обратите внимание на то, что метод findElement () возвращает итератор,
ссылающийся на элемент в списке (list), который представляет сегмент,
соответствующий заданному ключу. Если элемент будет найден, итератор, возвращаемый методом,
ссылается на этот элемент; в противном случае получаем итератор конца данного
списка. Используемый для поиска сегмент возвращается по ссылке в аргументе bucket.
Синтаксис этого метода не слишком прозрачен, особенно в связи с
использованием ключевого слова typename. Как разъяснялось в главе 21, ключевое слово typename
необходимо применять в случае использования типа, который зависит от шаблонного
параметра. В частности, тип list<pair<const Key, T> >:: iterator зависит от
двух шаблонных параметров: Key и Т.
И еще одно замечание по синтаксису. Член данных mElems представляет собой
указатель, поэтому, прежде чем применять к нему оператор operator [] для
получения конкретного элемента, его (указатель) необходимо разыменовать. Вот откуда
такое малоприятное выражение: (*mElems)[bucket].
Глава 23. Использование и расширение возможностей STL 747
Реализовать метод find () можно в виде простой оболочки для метода find-
Element ().
template <typename Key, typename T,
typename Compare, typename Hash>
typename hashmap<Key, T, Compare, Hash>::value_type*
hashmap<Key, T, Compare, Hash>::find(const key_type& x)
{
int bucket;
// Используем вспомогательный метод findElement().
typename ListType::iterator it = findElement(x, bucket);
if (it == (*mElems)[bucket].end()) {
// Если мы не находим элемент, возвращаем значение NULL.
return (NULL);
} Ч
// Если мы находим элемент, возвращаем указатель на него,
return (&(*it));
}
Метод operator [] подобен предыдущему за исключением того, что, если нужный
элемент не найден, он его вставляет в контейнер.
template <typename Key, typename T,
typename Compare, typename Hash>
T& hashmap<Key, T,
Compare, Hash>: : operator [] (const key_type& x)
{
// Делаем попытку найти элемент.
// Если он не существует, добавляем новый элемент.
value_type* found = find(x);
if (found == NULL) {
insert (make_j?air (x, T () ) ) ;
found = find(x);
}
return (found->second);
}
Этот метод трудно назвать эффективным, поскольку в самом худшем случае он
дважды вызывает метод find () и один раз — метод insert (). Но каждая из этих операций
выполняется в течение некоторого постоянного времени, учитывающего количество
элементов в контейнере hashmap, поэтому эти потери не слишком существенны.
Вставка элементов
Метод insert () должен сначала проверить, не находится ли уже элемент с
заданным ключом в контейнере hashmap. Если нет, он добавляет его в соответствующий
сегмент списка. Обратите внимание на то, что метод f indElement () возвращает по
ссылке сегмент, соответствующий заданному ключу, даже если элемент с таким
ключом и не найден.
template <typename Key, typename T,
typename Compare, typename Hash>
void hashmap<Key, T,
Compare, Hash>::insert(const value_type& x)
{
int bucket;
// Пробуем найти заданный элемент.
typename ListType::iterator it = findElement(x.first,
bucket);
if (it != (*mElems)[bucket].end()) {
// Такой элемент уже существует.
748 Часть V. Использование библиотек и шаблонов
return;
} else {
// Мы не нашли элемент, поэтому вставляем новый.
mSize++;
(*mElems)[bucket].insert((*mElems)[bucket].end(), x);
}
}
Удаление элементов
Метод erase () работает по той же схеме, что и метод insert (): сначала он
пытается найти элемент с помощью метода f indElement (). Если такой элемент
существует, он удаляет его из соответствующего сегмента списка. В противном случае не
делает ничего.
template <typename Key, typename T,
typename Compare, typename Hash>
void hashmap<Key, T, Compare, Hash>::erase(const key type& x)
{
int bucket;
// Сначала пытаемся найти заданный элемент.
typename ListType::iterator it = findElement(x, bucket);
if (it != (*mElems)[bucket].end()) {
// Такой элемент существует, поэтому удаляем его.
(*mElems) [bucket] .erase (it) ,-
mSize--;
}
}
Использование базового варианта класса hashmap
Рассмотрим небольшую тестовую программу, демонстрирующую использование
базового варианта шаблонного класса hashmap.
#include "hashmap.h"
int main(int argc, char** argv)
{
hashmap<int, int> myHash,-
myHash.insert(make_pair(4, 40));
myHash. insert (make_j?air (6, 60));
hashmap<int, int>::value_type* x = myHash.find(4);
if (x != NULL) {
cout << "4 соответствует числу " << x->second << endl;
} else {
cout << "He удалось найти число 4 в контейнере.\n";
myHash.erase(4);
hashmap<int, int>::valuetype* x2 = myHash.find(4);
if (x2 != NULL) {
cout << "4 соответствует числу " << x2->second << endl;
} else {
cout << "He удалось найти число 4 в контейнере.\n";
}
myHash [4] = 35,-
return (0) ;
Глава 23. Использование и расширение возможностей STL 749
Результаты выполнения этой тестовой программы таковы.
4 соответствует числу 40
Не удалось найти число 4 в контейнере.
Превращение класса hashmap в STL-контейнер
Базовый вариант класса hashmap, показанный в предыдущем разделе, следует
"духу", но не "букве" STL-закона. Хотя для множества применений предыдущая
реализация вполне пригодна, но если вы хотите для класса hashmap использовать STL-
алгоритмы, необходимо приложить еще некоторые усилия. Согласно стандарту языка
C++, структура данных, претендующая на "звание" контейнера, должна определять
конкретные методы и typedef-определения.
Соблюдение typedef-требований для STL-контейнеров
Перечень typedef-определений, обязательных для STL-контейнеров, приведен
в следующей таблице.
Имя типа Описание
value type Тип элемента, хранимого в контейнере
reference Ссылка на тип элемента, хранимого в контейнере
constref erence Ссылка на тип const-элемента, хранимого в контейнере
iterator Тип "интеллектуального указателя" для обхода элементов контейнера
constiterator Версия для типа iterator для обхода const-элементов контейнера
sizetype Тип, который позволяет представить количество элементов в контейнере;
обычно используется просто тип sizet (из заголовка <cstddef >)
difference type Тип, который позволяет представить разность двух итераторов контейнера;
обычно используется просто тип ptrdif ft (из заголовка <cstddef >)
Приведем все typedef-определения для класса hashmap, за исключением
определений iterator и const_iterator, написание которых откладывается до
следующего раздела. Обратите внимание на то, что тип value_type (а также типы
key_type и mapped_type, о которых речь впереди) уже определен в нашей
предыдущей версии класса hashmap.
template <typename Key, typename T, typename Compare =
std::equal_to<Key>, typename Hash = DefaultHash<Key> >
class hashmap
{
public:
typedef Key key_type,-
typedef T mapped_type;
typedef pair<const Key, T> valuetype;
typedef pair<const Key, T>& reference;
typedef const pair<const Key, T>& const_reference;
typedef size_t size_type;
typedef ptrdif f_t dif ferencetype,-
// Остальная часть определений класса опущена.
};
750 Часть V. Использование библиотек и шаблонов
Требования, предъявляемые к методам контейнеров
Помимо typedef-определений, каждый контейнер должен содержать следующие
методы.
Метод
Описание
Сложность
Конструктор по умолчанию
Конструктор копии
Оператор присваивания
Деструктор
iterator begin ();
const_iterator
begin() const;
iterator end();
const_iterator end() end;
operator==
operator!=
operator<
operator>
operator<=
operator>=
void swap(Containers);
size_type size() const;
size_type max_size()
const:
bool empty() const;
Создает пустой контейнер
Создает детальную копию
Создает детальную копию
Разрушает динамически выделяемую
память; вызывает деструктор для всех
элементов контейнера
Возвращает итератор, ссылающийся
на первый элемент контейнера
Константа
Линейная
зависимость
Линейная
зависимость
Линейная
зависимость
Константа
Возвращает итератор, ссылающийся Константа
на последний элемент контейнера
Операторы сравнения, которые Линейная
поэлементно сравнивают два контейнера зависимость
Обменивает содержимое контейнера,
переданного методу, с объектом,
для которого вызван этот метод
Возвращает количество элементов
в контейнере
Возвращает максимальное количество
элементов, которое может храниться
в контейнере
Определяет, содержит ли контейнер
какие-нибудь элементы
Константа (хотя
формально
в стандарте сказано
"должна быть")
Константа (хотя
формально
в стандарте сказано
"должна быть")
Константа (хотя
формально
в стандарте сказано
"должна быть")
Константа
В этом примере класса hashmap мы опускаем операторы сравнения. Их реализация
может стать прекрасным упражнением для читателя!
Вот как выглядят объявления и определения всех остальных методов за
исключением begin {). и end () (они рассмотрены в следующем разделе).
template <typename Key, typename T,
typename Compare = std::equal_to<Key>,
typename Hash = DefaultHash<Key> >
class hashmap
{
Глава 23. Использование и расширение возможностей STL 751
public:
// typedef-определения опущены
// Конструкторы
explicit hashmap (const Compares^ comp = Compare () ,
const Hash& hash = Hash())
throw(invalid_argument);
// Деструктор, конструктор копии, оператор присваивания
-hashmap () ,-
hashmap (const hashmap<Key, T, Compare, Hash>& src) ,-
hashmap<Key, T, Compare, Hash>& operator=(
const hashmap<Key, T, Compare, Hash>& rhs);
// Методы определения размеров
bool empty() const;
size_type size() const;
sizetype max_size() const;
// Другие модифицирующие утилиты
void swap(hashmap<Key, T, Compare, Hash>& hashln);
// Остальные методы опущены.
};
Реализации конструктора, деструктора, конструктора копии и оператора
присваивания идентичны приведенным выше в разделе "Реализация базового варианта
класса hashmap".
Реализации методов size () и empty () довольно просты, поскольку реализация
класса hashmap содержит информацию о его размере в члене данных mSize.
Обратите внимание на то, что метод size() возвращает значение типа sizetype,
а этот тип должен быть квалифицирован с использованием явно определенного
типа hashmap<Key, T, Compare, Hash>.
template <typename Key, typename T,
typename Compare, typename Hash>
bool hashmap<Key, T, Compare, Hash>::empty() const
{
return (mSize == 0) ;
}
template <typename Key, typename T,
typename Compare, typename Hash>
typename hashmap<Key, T, Compare, Hash>::size_type
hashmap<Key, T, Compare, Hash>::size() const
{
return (mSize);
}
Метод max_size() несколько сложнее. Максимальный размер контейнера
hashmap можно было бы рассматривать как сумму максимальных размеров всех
списков. Но с учетом самого худшего сценария, если все элементы- располагаются в одном
и том же сегменте, за максимальный размер следует принять самый большой размер
одного списка.
template <typename Key, typename T,
typename Compare, typename Hash>
typename hashmap<Key, T, Compare, Hash>::size_type
hashmap<Key, T, Compare, Hash>::max_size() const
{
// В самом худшем случае все элементы попадают в один и
// тот же список, поэтому max_size всего контейнера должен
// быть равен значению max_size одного списка. Этот код
// написан в предположении, что все списки имеют
752 Часть V. Использование библиотек и шаблонов
// одинаковое значение max_size.
return ((*mElems)[0].max_size());
}
Наконец, в реализации метода swap () используется функция swap (),
предназначенная для обмена значениями для каждого из четырех членов данных. Обратите
внимание на то, что обмен указателей на векторы является операцией, выполняемой
за некоторое постоянное время.
// Обмен значениями для четырех членов данных.
// Здесь используется обобщенная шаблонная функция swap О.
template <typename Key, typename T,
typename Compare, typename Hash>
void hashmap<Key, T, Compare, Hash>::swap(
hashmap<Key, T, Compare, Hash>& hashln)
{
// Явно квалифицируем имя с использованием пространства
// имен std::, чтобы компилятор не "подумал", что здесь
// выполняется рекурсивный вызов.
std::swap(*this, hashln);
}
Создание итератора
Самое важное требование, предъявляемое к контейнеру, — обеспечение
итератора. Чтобы работать с обобщенными алгоритмами, каждый контейнер должен
предоставить итератор как средство доступа к его элементам. Поэтому необходимо
позаботиться о том, чтобы итератор в общем случае представлял собой класс, действующий как
интеллектуальный указатель: он должен перегружать методы operator* и operator->,
а также некоторые другие операции, определяемые спецификой контейнера. Если
ваш итератор обеспечивает базовые итераторные операции, никаких проблем с
доступом к элементам контейнера быть не должно.
Первое, о чем следует подумать, приступая к созданию итератора, — о его типе, т.е.
каким вы его видите: одно-, двунаправленным или произвольного доступа. Итераторы
произвольного доступа не имеют большого смысла для ассоциативных контейнеров,
поэтому для hashmap-итератора логичнее выбрать двунаправленный итератор. В этом
случае мы должны обеспечить в классе итератора дополнительные операции,
описанные в главе 21, а именно operator++, operator--, operator== и operator! =.
Определившись с типом итератора, нужно решить, как упорядочивать элементы
контейнера. Класс hashmap не предусматривает сортировку элементов, поэтому обес- -
печить доступ к элементам в отсортированном порядке, вероятно, будет слишком
трудно. По всей видимости, наш итератор может просто "обходить" сегменты, начиная
с первого и заканчивая последним. Такой порядок (с точки зрения клиента) может
показаться произвольным, но в действительности он будет постоянным и повторяющимся.
Наконец, необходимо принять решение о внутреннем представлении нашего
итератора. Реализация итератора обычно зависит от внутренней реализации
контейнера. Основное назначение итератора — ссылаться на один (отдельно взятый) элемент
в контейнере. В нашем случае (для класса hashmap) такой отдельно взятый элемент
находится в STL-списке (list), поэтому можно предположить, что hashmap-итератор
имеет смысл представить в виде оболочки, внутри которой будет находиться list-
итератор, ссылающийся на текущий элемент. Однако, если мы решили использовать
двунаправленный тип итератора, то его второе предназначение — позволить клиенту
переходить от текущего элемента к следующему или предыдущему. Для перехода от
Глава 23. Использование и расширение возможностей STL 753
одного сегмента к следующему необходимо также отслеживать текущий сегмент и объект
типа hashmap, на который ссылается итератор.
Определившись с выбором трех основных аспектов нашей реализации, нужно
подумать о представлении конечного итератора. Вспомните: конечный итератор в
действительности должен быть индикатором граничной "потусторонней" области: т.е.
он должен представлять собой результат применения операции "++" к итератору,
который ссылается на последний элемент контейнера. Итератор класса hashmap в
качестве своего конечного значения (признака конца) может просто использовать
конечный итератор списка (list) последнего сегмента hashmap-контейнера.
Класс Hashlterator
Приняв решения, описанные в предыдущем разделе, пора переходить к
определению класса Hashlterator. При этом отметим, что каждый объект класса
Hashlterator— это итератор для конкретной реализации класса hashmap. Чтобы
обеспечить взаимнооднозначное отображение, шаблонный класс Hashlterator должен
иметь те же шаблонные параметры, что и класс hashmap.
Основной вопрос в определении класса — как обеспечить соответствие требованиям
двунаправленного итератора. Вспомните: все что демонстрирует поведение, подобное
итераторному, может считаться итератором. Для того чтобы наш класс можно было
квалифицировать как двунаправленный итератор, он необязательно должен быть
производным от некоторого другого класса. Но для того, чтобы наш итератор можно было
использовать в обобщенных функциях-алгоритмах, мы должны определить его
качества. Выше (в теме написания STL-алгоритмов) упоминалось, что существует шаблонный
класс iterator_traits, в котором содержится пять typedef-определений для
итератора каждого типа. При желании этот класс может быть частично специализирован
для нового типа итератора. В качестве альтернативного варианта действующая по
умолчанию реализация шаблонного класса iteratortraits может просто "найти"
эти пять typedef-определений вне самого класса итератора. Таким образом, мы
можем определить их (эти typedef-определения) непосредственно в своем итераторном
классе. В действительности это даже более простой вариант. Но вместо того, чтобы
определять их самим, мы можем просто вывести нужный подкласс из шаблонного класса
iterator, который уже содержит эти typedef-определения. В этом случае нам
остается лишь определить тип итератора и тип элемента в качестве шаблонных аргументов
класса iterator. Итератор Hashlterator мы решили сделать двунаправленным,
поэтому в качестве его типа мы можем определить тип bidirectional_iterator_tag.
Приведем другие допустимые типы итераторов: input_iterator_tag, out-
put_iterator_tag, forward_iterator_tag и random_access_iterator__tag.
Представление же типа элемента вам вполне знакомо: pair<const Key, T>.
По существу, все сводится к тому, что мы должны выводить свои классы
итераторов из обобщенного шаблонного класса iterator.
Вот как выглядит базовое определение класса Hashlterator.
// Определение класса Hashlterator.
template<typename Key, typename T,
typename Compare, typename Hash>
class Hashlterator :
public std::iterator<std::bidirectional_iterator_tag,
pair<const Key, T> >
{
public:
Hashlterator() ,- // Двунаправленные итераторы должны
754 Часть V. Использование библиотек и шаблонов
};
// содержать конструкторы по умолчанию.
Hashlterator{int bucket,
typename list<pair<const Key,
T> >::iterator listlt,
const hashmap<Key, T,
Compare, Hash>* inHashmap) ,-
pair<const Key, T>& operator*() const;
// Тип значения, возвращаемого методом operator->(),
// должен позволять применение к нему оператора "->".
// Поэтому обеспечиваем возвращение указателя на
// тип pair<const Key, T>, к которому компилятор
// будет снова применять оператор "->".
pair<const Key, T>* operator-;. () const;
HashIterator<Key, T, Compare, Hash>& operator++()
const HashIterator<Key, T,
Compare, Hash> operator++(int)
HashIterator<Key, T, Compare, Hash>& operator--()
const HashIterator<Key, T,
Compare, Hash> operator--(int)
// Необязательно определять конструктор копии или
// метод operator=(), поскольку поведение, заданное
// средствами, действующими по умолчанию,
// вполне нас устраивает.
// Нам не нужен деструктор, поскольку такое стандартное
// поведение (без удаления объектов класса mHashmap)
// нам подходит.
// Нам вполне подходит статус следующих функций-членов,
// поскольку мы не обеспечиваем сравнение различных
// типов с данным.
foool operator==(const Hashlteratorfc rhs) const;
bool operator!=(const Hashlteratorfc rhs) const;
protected:
int mBucket;
typename list<pair<const Key, T> >::iterator mlt;
const hashmap<Key, T, Compare, Hash>* mHashmap;
// Вспомогательные методы для операторов operator++()
// и operator—().
void increment();
void decrement () ,-
Если определения и реализации (показанные в следующем разделе)
перегружаемых операторов вам непонятны, обратитесь к главе 16, в которой тема перегрузки
операторов описана со всеми подробностями.
Реализации методов класса Hashlterator
Конструкторы класса Hashlterator инициализируют три члена данных.
Конструктор по умолчанию существует только для того, чтобы клиенты могли объявлять
переменные типа Hashlterator без какой бы то ни было инициализации вообще.
Итератор, создаваемый с помощью конструктора по умолчанию, не должен ссылаться
на какое-либо значение, а попытка выполнить с ним какую-нибудь операцию вполне
ожидаемо приведет к неопределенному результату.
Глава 23. Использование и расширение возможностей STL 755
// Разыменование или инкрементирование итератора, созданного
// с помощью конструктора по умолчанию, даст неопределенный
// результат, поэтому не важно, какие значения мы здесь
// приводим.
template<typename Key, typename T,
typename Compare, typename Hash>
HashIterator<Key, T, Compare, Hash>::HashIterator()
{
mBucket = -1;
mlt = list<pair<const Key, T> >::iterator();
mHashmap = NULL;
}
template<typename Key, typename T,
typename Compare, typename Hash>
HashIterator<Key, T, Compare, Hash>::HashIterator(
int bucket,
typename list<pair<const Key, T> >-.: iterator listlt,
const hashmap<Key, T, Compare, Hash>* inHashmap) :
mBucket(bucket), mlt(listlt), mHashmap(inHashmap)
{
}
Реализации операторов разыменования весьма лаконичны, но не просты для
понимания. Как упоминалось в главе 16, методы operator* и operator->
асимметричны. Метод operator* возвращает реальное базовое значение, которое в данном
случае представляет собой элемент, адресуемый итератором. Но метод operator- >
должен возвращать значение, к которому снова можно было бы применить оператор
"стрелка". Поэтому он возвращает указатель на элемент контейнера. После того как
компилятор затем применит оператор "->" к этому указателю, мы получим доступ
к нужному полю элемента.
// Метод operator*() возвращает реальный элемент.
template<typename Key, typename T,
typename Compare, typename Hash>
pair<const Key,
T>& HashIterator<Key, T,
Compare, Hash>::operator*() const
{
return (*mlt);
}
// Метод operator-:» () возвращает итератор, чтобы для
// получения доступа к нужному полю компилятор мог
// применить к нему оператор п->".
template<typename Key, typename T,
typename Compare, typename Hash>
pair<const Key, T>*
HashIterator<Key, T, Compare, Hash>::operator->() const
{
return (&(*mlt) ) ,-
}
Операторы инкремента и декремента реализуются так, как описано в главе 16, за
исключением того, что реальные процедуры инкрементирования и декрементирова-
ния выполняются вспомогательными методами increment () и decrement ()
соответственно.
// Перекладываем детали реализации на "плечи" вспомогательного
// метода increment().
template<typename Key, typename T,
756 Часть V. Использование библиотек и шаблонов
typename Compare, typename Hash>
HashIterator<Key, T, Compare, Hash>&
HashIterator<Key, T, Compare, Hash>::operator++()
{
increment();
return (*this);
}
// Перекладываем детали реализации на "плечи"
// вспомогательного метода increment().
template<typename Key, typename T,
typename Compare, typename Hash>
const HashIterator<Key, T, Compare, Hash>
HashIterator<Key, T, Compare, Hash>::operator++(int)
{
HashIterator<Key, T, Compare, Hash> oldlt = *this,-
increment () ,-
return (oldlt);
}
// Перекладываем детали реализации на "плечи"
// вспомогательного метода decrement().
template<typename Key, typename T,
typename Compare, typename Hash>
HashIterator<Key, T, Compare, Hash>&
HashIterator<Key, T, Compare, Hash>::operator--()
{
decrement();
return (*this);
}
// Перекладываем детали реализации на "плечи"
// вспомогательного метода decrement().
template<typename Key, typename T,
typename Compare, typename Hash>
const HashIterator<Key, T, Compare, Hash>
HashIterator<Key, T, Compare, Hash>::operator--(int)
{
HashIterator<Key, T, Compare, Hash> newlt = *this;
decrement();
return (newlt);
}
Инкрементирование объекта типа Hashlterator означает, что наш итератор
должен ссылаться на "следующий" элемент в контейнере. Метод increment () сначала
должен инкрементировать list-итератор, а затем проверить, не достигли ли мы при
этом конца сегмента. Если да, то необходимо найти следующий непустой сегмент в
контейнере hashmap и установить list-итератор на начало этого сегмента (т.е. чтобы он
ссылался на его начальный элемент). Обратите внимание на то, что мы не можем
просто переместить итератор на следующий сегмент, поскольку в нем может не быть
элементов вообще. Если больше нет непустых сегментов, итератор mlt
устанавливается равным конечному итератору последнего сегмента в контейнере hashmap,
который обозначает специальную "конечную" позицию класса Hashlterator.
Вспомните, что от итераторов не требуется больше безопасности, чем от обычных указателей,
поэтому проверка ошибок для таких операций, как инкрементирование итератора,
в конце контейнера необязательна.
// Если итератор mlt уже ссылается на элемент, расположенный
// за последним элементом в таблице, поведение этого кода
// не определено или операция считается недействительной.
Глава 23. Использование и расширение возможностей STL 757
template<typename Key, typename T,
typename Compare, typename Hash>
void HashIterator<Key, T, Compare, Hash>::increment()
{
// Итератор mlt действителен в одном сегменте.
// Инкрементируем его.
++mlt,-
// Если мы находимся в конце текущего сегмента,
// находим следующий сегмент с элементами.
if (mlt == (*mHashmap->mElems)[mBucket].end()) {
for (int i = mBucket + 1;
i < (*mHashmap->mElems).size(); i++) {
if (!((*mHashmap->mElems)[i].empty())) {
// Мы нашли непустой сегмент.
// Заставляем итератор mlt ссылаться на первый
// элемент в нем.
mlt = (*mHashmap->mElems)[i].begin();
mBucket = i;
return;
}
}
// Больше нет непустых сегментов. Присваиваем итератору
// mlt значение, соответствующее конечному итератору
// последнего списка.
mBucket = (*mHashmap->mElems).size() - 1;
mlt = (*mHashmap->mElems)[mBucket].end();
}
}
Декрементирование— операция, обратная инкрементированию: она заставляет
итератор ссылаться на "предыдущий" элемент в контейнере. Однако здесь имеет
место асимметрия, связанная с асимметрией между способами представления
начальных и конечных позиций: начальная определяется первым элементом, а конечная —
"элементом", следующим за последним. Алгоритм декрементирования сначала
проверяет, не ссылается ли базовый list-итератор на начало текущего сегмента. Если
нет, его можно декрементировать. В противном случае необходимо проверить, не
является ли непустым ближайший сегмент перед текущим (если он пуст, проверяется
очередной предыдущий сегмент и т.д.). Если все-таки будет найден непустой сегмент,
list-итератор нужно установить для ссылки на последний элемент этого сегмента,
а для этого достаточно значение конечного итератора уменьшить на единицу. Если
больше непустых сегментов обнаружено не будет, то операция декремента считается
недействительной, а ее результат — неопределенным.
// Если итератор mlt уже ссылается на первый элемент таблицы,
// поведение этого кода не определено либо операция считается
// недействительной.
template<typename Key, typename T,
typename Compare, typename Hash>
void HashIterator<Key, T, Compare, Hash>::decrement()
{
// Итератор mlt действителен в одном сегменте.
// Если он ссылается на начало текущего сегмента, не
// декрементируем его, а попытаемся найти непустой сегмент
// перед текущим.
if (mlt == (*mHashmap->mElems)[mBucket].begin()) {
for (int i = mBucket - 1; i >= 0; --i) {
if (!((*mHashmap->mElems)[i].empty())) {
mlt = (*mHashmap->mElems)[i].end();
--mlt;
758 Часть V. Использование библиотек и шаблонов
mBucket = i;
return;
// Больше нет непустых сегментов. Эта операция
// декрементирования считается недействительной.
// Присваиваем итератору mlt значение ссылки на
// "элемент", предшествующий начальному элементу
// первого списка (т.е. указываем недействительную
// позицию).
mlt = (*mHashmap->mElems)[0].begin();
--mlt;
mBucket = 0,-
} else {
// Мы находимся не в начале сегмента, поэтому можем
// просто переместиться назад на одну позицию.
--mlt;
}
}
Обратите внимание на то, что оба метода— increment () и decrement () —
получают доступ к protected-членам класса hashmap. Это значит, что класс hashmap
должен объявить класс Hashlterator своим "другом", т.е. friend-классом.
После методов increment () и decrement () реализация операторов operator==
и operator! = должна показаться сущим пустяком. Они просто выполняют сравнение
трех членов данных объектов.
template<typename Key, typename T,
typename Compare, typename Hash>
bool HashIterator<Key, T, Compare, Hash>::operator==(
const HashIterator& rhs) const
{
// Все поля, на которые ссылаются итераторы, должны
// быть равны.
return (mHashmap == rhs.mHashmap && mBucket == rhs.mBucket
&& mlt == rhs.mlt);
}
template<typename Key, typename T,
typename Compare, typename Hash>
bool HashIterator<Key, T, Compare, Hash>::operator!=(
const Hashlteratorb rhs) const
{
return (!operator== (rhs) ) ,-
}
Константные итераторы
Формально для своего класса hashmap мы должны определить не только итератор,
но и const-итератор. Поведение константного итератора подобно "обычному", но
с одной оговоркой: он должен обеспечивать доступ к элементам только для чтения.
При этом необходимо позаботиться о том, чтобы "обычный" итератор можно было
всегда преобразовать в const-итератор. Детали реализации const-итератора мы
опускаем, предоставляя сделать это читателю в качестве упражнения.
Итераторные typedef-определения и методы доступа
В плане итераторной поддержки класса hashmap нам осталось лишь добавить в его
определение необходимые typedef-определения и написать методы begin () и end ().
Вот как выглядят эти typedef-определения и прототипы упомянутых методов.
Глава 23. Использование и расширение возможностей STL 759
template «ctypename Key, typename T,
typename Compare = std::equal_to<Key>,
typename Hash = DefaultHash<Key> >
class hashmap
{
public:
// Другие typedef-определения опущены.
typedef HashIterator<Key, T, Compare, Hash> iterator,-
typedef HashIterator<Key, T,
Compare, Hash> const_iterator;
// Итераторные методы доступа.
iterator begin();
iterator end () ,-
constiterator begin() const;
const_iterator end() const;
// Остальная часть определения класса опущена.
ь-
Самое важное при написании метода begin () — не забыть о необходимости
возвратить конечный итератор, если в таблице нет элементов.
template «ctypename Key, typename T,
typename Compare, typename Hash>
typename hashmap<Key, T, Compare, Hash>::iterator
hashmap<Key, T, Compare, Hash>::begin()
{
if (mSize == 0) {
// Специальный случай: элементы отсутствуют, поэтому
// возвращаем конечный итератор,
return (end () ) ,-
}
// Мы знаем, что существует по крайней мере один элемент.
// Находим первый элемент.
for (size_t i = 0; i < mElems->size(); ++i) {
if (!((*mElems)[i].empty())) {
return (HashIterator<Key, T, Compare,
Hash>(i, (*mElems)[i].begin(),
this));
II Теоретически мы не должны попасть сюда, но если такое
// случится, возвратим конечный итератор,
return (end());
}
Метод end() создает итератор типа Hashlterator, ссылающийся на конец
последнего сегмента.
template «ctypename Key, typename T,
typename Compare, typename Hash>
typename hashmap<Key, T, Compare, Hash>::iterator
hashmap<Key, T, Compare, Hash>::end()
{
// Конечный итератор - это просто конечный итератор
// списка в последнем сегменте.
return (HashIterator<Key, T, Compare, Hash>(
mElems->size() - 1,
(*mElems)[mElems->size() - 1].end(),
this));
}
760 Часть V. Использование библиотек и шаблонов
Поскольку мы привели здесь детали реализации итератора constiterator,
просто отметим, что реализации const-версий begin () и end () идентичны их не const-
методам begin () и end (). •
Использование класса Hashiterator
Теперь, когда в нашем классе hashmap предусмотрена поддержка средств доступа
к элементам контейнера, и мы можем обходить его элементы так, как это делается в любом
STL-контейнере, перейдем к использованию итераторов для вызова методов и функций
#include "hashmap.h"
#include <iostream>
#include <map>
using namespace std;
int main(int argc, char** argv)
{
hashmap<string, int> myHash;
myHash.insert(make_pair("KeyOne", 100) ) ;
myHash.insert(make_pair("KeyTwo", 200));
myHash.insert(make_pair("KeyThree", 300));
for (hashmap<string, int>::iterator it = myHash.begin();
it != myHash.end(); ++it) {
// Для тестирования этих операций
// используем операторы п->" и "*".
cout << it->first << " соответствует значению "
<< (*it).second << endl;
}
// Создаем отображение со всеми теми же элементами,
// что хранятся в контейнере hashmap.
map<string, int> myMap(myHash.begin(), myHash.end());
for (map<string, int>::iterator it = myMap.begin();
it != myMap.end(); ++it) {
// Для тестирования этих операций
// используем операторы "->" и "*".
cout << it->first << " соответствует значению "
<< (*it).second << endl;
}
return (0);
}
Замечания по распределителям памяти
Как описано выше в этой главе, все STL-контейнеры позволяют задавать
пользовательский распределитель памяти. "Приличная" реализация класса hashmap должна
бы делать то же самое. Однако мы опускаем эти детали, чтобы читатель не отвлекался
от основных аспектов этой реализации.
Замечания по реверсивным контейнерам
Если ваш контейнер поддерживает двунаправленный итератор или итератор
произвольного доступа, он считается реверсивным. Предполагается, что реверсивные
контейнеры должны поддерживать два дополнительных typedef-определения.
Кроме того, наш контейнер должен содержать методы rbegin () и rend (),
которые бы вели себя симметрично методам begin () и end (). Обычно для этого в
реализациях используется адаптер reverseiterator, описанный выше в этой главе. Эту
работу мы оставляем в качестве упражнения для читателей.
Глава 23. Использование и расширение возможностей STL 761
Имя типа Описание
reverseiterator Тип "интеллектуального указателя" для обхода элементов
контейнера в обратном порядке
const_reverse_iterator Версия reverse_iterator для обхода const-элементов
контейнера в обратном порядке
Контейнер hashmap — ассоциативный
Помимо основных (уже рассмотренных) требований, предъявляемых к
контейнеру, их состав можно "усилить" дополнительными требованиями, связанными с типом
контейнера: ассоциативным или последовательным. Наш класс" hashmap, как и
отображение (тар), является ассоциативным контейнером, поэтому он должен
подчиняться законам, выраженным в виде следующих typedef-определений и методов.
"Назвался" ассоциативным контейнером — соответствуй typedef-требованиям
Ассоциативные контейнеры должны содержать три дополнительных typedef -
определения.
Имя типа Описание
keytype Тип ключа, для которого контейнер реализуется
keycompare Класс сравнения или тип указателя на функцию, для которого контейнер
реализуется
valuecompare Класс для сравнения двух элементов типа valuetype
Наша реализация (ввиду ее специфики) также включает typedef-определение
mapped_type. Тип value compare реализован не как typedef-определение, а как
определение вложенного класса. В качестве альтернативного варианта этот класс
можно было бы квалифицировать как friend-класс для нашего контейнера hashmap,
однако такое определение соответствует описанию класса тар, "узаконенному" в
стандарте. Назначение класса value_compare — вызывать функцию сравнения (по
ключам) двух элементов.
template «ctypename Key, typename T,
typename Compare = std::equal_to<Key>,
typename Hash = DefaultHash<Key> >
class hashmap
{
public:
typedef Key key_type,-
typedef T mapped_type;
typedef pair<const Key, T> valuetype,-
typedef Compare key_compare;
typedef pair<const Key, T>& reference,-
typedef const pair<const Key, T>& const_reference,-
typedef HashIterator<Key, T, Compare, Hash> iterator;
typedef HashIterator<Key, T,
Compare, Hash> const_iterator,-
762 Часть V. Использование библиотек и шаблонов
typedef sizet size_type,-
typedef ptrdiff_t difference_type;
};
// Требуемое определение класса для ассоциативных
// контейнеров,
class value_compare :
public std::binary_function<value_type,
value_type, bool>
{
};
friend class hashmap<Key, T, Compare, Hash>;
public:
bool operator() (const value_type& x,
const value_type& y) const
{
return comp(x.first, y.first);
}
protected:
Compare comp;
value_compare(Compare c) : comp(c) {}
// Остальная часть определения класса hashmap опущена.
Требования, предъявляемые к методам ассоциативных контейнеров
В отношении методов стандарт содержит довольно большой список
дополнительных требований к ассоциативным контейнерам.
Метод
Описание
Сложность
Конструктор принимает
итераторный диапазон
key_compare
key_comp() const;
value_compare
value_comp() const;
pair<iterator, bool>
insert(value_type&);
iterator insert(
iterator,
value_type&);
void insert(
Inputlterator start,
Inputlterator end);
Создает контейнер и вставляет элементы
в заданный диапазон. Итераторный диапазон
не обязательно должен относиться к другому
контейнеру такого же типа. Обратите внимание
на то, что все конструкторы ассоциативных
контейнеров должны принимать объект
сравнения типа vaiuecompare. При этом
конструкторы обязаны в качестве значения
по умолчанию предоставлять объект,
создаваемый по умолчанию
Возвращают объекты для сравнения лишь
ключей или полных значений
Три различные формы вставки. Позиция
iterator во второй форме является
рекомендацией, которая может быть
проигнорирована. Итераторный диапазон,
заданный в третьей форме, не обязательно
должен относиться к другому контейнеру такого
же типа. Контейнеры, которые позволяют
хранение ключей-дубликатов, возвращают
только значение типа iterator из первой
формы, поскольку метод insert () в этом
случае всегда завершается успешно
ЛЛод/V
Константа
Логарифмическая
зависимость
Глава 23. Использование и расширение возможностей STL 763
Окончание таблицы
Метод
Описание
Сложность
size_type erase(
key_type&);
void erase(
iterator);
void erase(
iterator start,
iterator end);
void clear();
iterator find(
key_type&);
constiterator find(
key_type&) const;
sizetype count(
key_type&) const;
iterator lower_bound(
key_type&);
iterator upper_bound(
key_type&);
pair<iterator,
iterator>
equalrange(
key_type&);
constiterator
lowerbound(
key_type&) const;
constiterator
upperbound(
key_type&) const;
pair<const_iterator,
const_iterator>
equal_range(
keytype&) const;
Три различные формы удаления. Первая
форма возвращает количество удаленных
значений (0 или 1 в контейнерах, которые
не позволяют наличие ключей-дубликатов).
Вторая и третья формы удаляют элементы
в заданной позиции iterator или в диапазоне,
заданном параметрами start и end
соответственно
Удаляет все элементы
Находит элемент с заданным ключом
Возвращает количество элементов с заданным
ключом (0 или 1 в контейнерах, которые
не позволяют наличие ключей-дубликатов)
Возвращают итераторы, ссылающиеся
на первый элемент с заданным ключом,
на "элемент", расположенный за последним
элементом с заданным ключом, и на оба
описанных элемента соответственно
Логарифмическая
зависимость
(за исключением
второй формы,
которая должна
характеризоваться
константой)
Линейная
зависимость
Логарифмическая
зависимость
Логарифмическая
зависимость
Логарифмическая
зависимость
Обратите внимание на то, что методы lower_bound (), upper_bound () и
equal_range () имеют смысл только для отсортированных контейнеров. Поэтому
в классе hashmap они не определены.
Ниже приведено полное определение класса hashmap. Обратите внимание на то,
что прототипы методов insert (), erase () и find () немного отличаются от
показанных выше предыдущих версий.
template <typename Key, typename T,
typename Compare = std::equal_to<Key>,
typename Hash = DefaultHash<Key> >
class hashmap
{
public:
typedef Key key_type,-
typedef T mapped_type;
764 Часть V. Использование библиотек и шаблонов
typedef pair<const Key, T> valuetype,-
typedef Compare key_compare;
typedef pair<const Key, T>& reference;
typedef const pair<const Key, T>& const_reference,-
typedef HashIterator<Key, T, Compare, Hash> iterator;
typedef HashIterator<Key, T, Compare, Hash>
constiterator;
typedef sizet sizetype,-
typedef ptrdifft difference_type;
// Требуемое определение класса для ассоциативных
// контейнеров.
class value_compare :
public std::binary_function<value_type,
valuetype, bool>
{
};
friend class hashmap<Key, T, Compare, Hash>,-
public:
bool operator() (const value_type& x,
const value_type& y) const
{
return comp(x.first, y.first);
}
protected:
Compare comp ,-
value_compare(Compare c) : comp(c) {}
// Класс итератора должен иметь доступ к защищенным
// членам класса hashmap.
friend class HashIterator<Key, T, Compare, Hash>;
// Конструкторы.
explicit hashmap(const Compared comp = Compare(),
const Hash& hash = Hash())
throw(invalid_argument)
template <class InputIterator>
hashmap(InputIterator first, InputIterator last,
const Compared comp = Compare(),
const Hash& hash = Hash())
throw(invalid_argument);
// Деструктор, конструктор копии, оператор присваивания,
-hashmap();
hashmap (const hashmap<Key, T, Compare, Hash>& src) ,-
hashmap<Key, T, Compare, Hash>& operator=(
const hashmap<Key, T, Compare, Hash>& rhs);
// Итераторные методы.
iterator begin ();
iterator end();
const_iterator begin() const;
const_iterator end() const;
// Методы определения размера.
bool empty() const;
size_type size() const;
size_type max_size() const;
Глава 23. Использование и расширение возможностей STL 765
};
// Методы вставки элементов.
Т& operator[] (const key_type& х) ;
pair<iterator, bool> insert(const value_type& x) ;
iterator insert(iterator position, const value_type& x)
template <class InputIterator>
void insert(InputIterator first, Inputlterator last);
// Методы удаления элементов.
void erase(iterator position);
size_type erase(const key_type& x) ;
void erase(iterator first, iterator last);
// Другие методы модификации элементов.
void swap(hashmap<Key, T, Compare, Hash>& hashln);
void clear();
// Методы доступа для соответствия требованиям STL.
key_compare key_comp() const;
valuecompare value_comp () const ,-
// Методы поиска.
iterator find(const key_type& x);
const_iterator find(const key_type& x) const;
size_type count(const key_type& x) const;
protected:
typedef list<value_type> ListType;
typename ListType::iterator findElement(
const key_type& x, int& bucket) const;
vector<ListType>* mElems,-
size_type mSize;
Compare mComp;
Hash mHash;
Конструкторы класса hashmap
Реализация конструктора по умолчанию была приведена выше. Второй
конструктор представляет собой шаблонный метод, чтобы он мог принимать итераторный
диапазон из любого контейнера, а не только из другого контейнера типа hashmap.
Если бы здесь использовался не шаблон, то тип Inputlterator нам пришлось бы явно
определить как тип Hashlterator, что ограничило бы конструктор применением "родных"
итераторов для класса hashmap. Несмотря на такой громоздкий синтаксис,
реализация этого конструктора несложна: он инициализирует все члены данных, а затем
вызывает метод insert (), чтобы реально вставить все элементы в заданный диапазон.
// Обращаемся к методу insert(), чтобы реально вставить
// элементы в контейнер.
template <typename Key, typename T,
typename Compare, typename Hash>
template <class InputIterator>
hashmap<Key, T, Compare, Hash>::hashmap(
Inputlterator first, Inputlterator last,
const Compares comp,
const Hash& hash) throw(invalid_argument) :
mSize(O), mComp(comp), mHash(hash)
{
766 Часть V. Использование библиотек и шаблонов
if (mHash.numBuckets() <= 0) {
throw (invalid_argument("Количество сегментов должно
^быть положительным. ") ) ;
}
mElems = new vector<list<value_type> > (mHash.numBuckets () ) ,-
insert(first, last);
}
Операции вставки элементов в контейнер hashmap
Первая версия метода insert () добавляет в контейнер типа hashmap пару "ключ-
значение". Она идентична версии, показанной выше в разделе "Реализация базового
варианта класса hashmap", за исключением того, что она возвращает пару значений
с типами "iterator-bool". При этом итератор должен быть объектом класса Hash-
Iterator, построенным для ссылки на элемент, который был только что вставлен,
или на элемент с заданным ключом, если таковой уже существует в контейнере.
template <typename Key, typename T,
typename Compare, typename Hash>
pair<typename hashmap<Key, T, Compare, Hash>::iterator, bool>
hashmap<Key, T, Compare, Hash>::insert(const value_type& x)
{
int bucket;
// Делаем попытку найти элемент.
typename ListType::iterator it = findElement(x.first,
bucket);
if (it != (*mElems)[bucket].end()) {
// Элемент уже существует.
// Преобразуем list-итератор в Hashlterator-итератор,
// который также принимает в качестве параметров номер
// сегмента и указатель на объект типа hashmap.
HashIterator<Key, T, Compare, Hash> newlt(bucket, it,
this);
// Некоторые компиляторы "не любят" создание такой пары.
pair<HashIterator<Key, T,
Compare, Hash>, bool> p(newlt, false);
return (p) ,-
} else {
// Мы не находим элемент, поэтому вставляем новый.
mSize++;
typename ListType::iterator endlt =
(*mElems)[bucket].insert((*mElems)[bucket].end(), x);
pair<HashIterator<Key, T, Compare, Hash>, bool> p(
HashIterator<Key, T, Compare, Hash>(bucket, endlt,
this), true);
return (p);
}
}
Версия метода insert (), которая принимает параметр position (определяющий
итераторную позицию), бесполезна для контейнеров типа hashmap. Данная реализация
полностью игнорирует этот параметр и вызывает первую версию метода insert ().
Глава 23. Использование и расширение возможностей STL 767
template <typename Key, typename T,
typename Compare, typename Hash>
typename hashmap<Key, T, Compare, Hash>::iterator
hashmap<Key, T, Compare,
Hash>::insert(
typename hashmap<Key, T, Compare,
Hash>::iterator position,
const value_type& x)
{
// Совершенно игнорируем параметр position.
return (insert(x).first);
}
Третья форма метода insert () представляет собой шаблон по тем же причинам,
которые были описаны выше для конструктора: она должна иметь возможность
вставлять элементы с помощью итераторов, определенных в контейнерах любого
типа. В данной реализации используется итератор типа insert_iterator, который
был описан выше в этой главе.
template <typename Key, typename T,
typename Compare, typename Hash>
template <class InputIterator>
void hashmap<Key, T, Compare, Hash>::insert(
InputIterator first, Inputlterator last)
{
// Копируем каждый элемент в диапазон, используя адаптер
// insert_iterator. Параметр begin() рассматривается как
// средство задания позиции, и метод insert() его
// игнорирует.
insert_iterator<hashmap<Key, T,
Compare, Hash> > inserter(*this, beginO);
copy(first, last, inserter);
}
Операции удаления элементов из контейнера hashmap
Первая версия метода erase () идентична версии, показанной выше в разделе
"Реализация базового варианта класса hashmap", за исключением того, что она
возвращает количество удаленных элементов (0 или 1).
template <typename Key, typename T,
typename Compare, typename Hash>
typename hashmap<Key, T, Compare, Hash>::size_type
hashmap<Key, T, Compare, Hash>: .-erase (const key type& x)
{
int bucket;
// Сначала пытаемся найти элемент.
typename ListType::iterator it = findElement(x, bucket);
if (it != (*mElems)[bucket].end()) {
// Элемент уже существует - удаляем его.
(*mElems)[bucket].erase(it);
mSize--;
return (1);
} else {
return (0) ,-
1
768 Часть V. Использование библиотек и шаблонов
Вторая форма метода erase () предназначена для удаления элемента в
конкретной итераторной позиции. Задаваемый при этом итератор должен иметь, безусловно,
тип Hashlterator. Поэтому в классе hashmap необходимо предусмотреть
возможность получения из итератора типа Hashlterator итератора для базового сегмента
и списка. Для этого мы считаем нужным сделать класс hashmap "другом" класса
Hashlterator (в приведенном выше определении класса это не показано).
template <typename Key, typename T,
typename Compare, typename Hash>
void hashmap<Key, T, Compare, Hash>::erase(
hashmap<Key, T, Compare, Hash>::iterator position)
{
// Удаляем элемент из сегмента.
(*mElems)[position.mBucket].erase(position.mlt);
mSize--,-
}
Последняя версия метода erase () удаляет диапазон элементов. Для этого
выполняется обход элементов в диапазоне от first до last и вызывается для каждого
элемента метод erase (), т.е. вся работа, по сути, перекладывается на "плечи"
предыдущей версии метода erase ().
template <typename Key, typename T,
typename Compare, typename Hash>
void hashmap<Key, T, Compare, Hash>::erase(
hashmap<Key, T, Compare, Hash>::iterator first,
hashmap<Key, T, Compare, Hash>::iterator last)
{
typename hashmap<Key, T,
Compare, Hash>::iterator cur, next;
// Удаляем все элементы в диапазоне.
for (next = first; next != last; ) {
cur = next++;
erase(cur);
}
}
Метод clear () класса hashmap использует алгоритм f or_each () для вызова
метода clear () для списка (list), представляющего каждый сегмент контейнера.
template <typename Key, typename T,
typename Compare, typename Hash>
void hashmap<Key, T, Compare, Hash>::clear()
{
// Вызываем метод clear() для каждого списка.
for_each(mElems->begin(), mElems->end(),
mem_fun_ref(&ListType::clear));
mSize = 0;
}
Операции доступа к членам класса hashmap
Стандарт требует, чтобы для объектов сравнения ключей и сравнения значений
были определены средства доступа.
template <typename Key, typename T,
typename Compare, typename Hash>
typename hashmap<Key, T, Compare, Hash>::keycompare
hashmap<Key, T, Compare, Hash>::key_comp() const
Глава 23. Использование и расширение возможностей STL 769
{
return (mComp);
}
template <typename Key, typename T,
typename Compare, typename Hash>
typename hashmap<Key, T, Compare, Hash>::value_compare
hashmap<Key, T, Compare, Hash>::value_comp() const
{
return (value compare (mComp) ) ,-
}
Метод find () идентичен версии, показанной выше для базовой реализации
класса hashamp, за исключением кода формирования возвращаемого значения. Вместо
возвращения указателя на элемент здесь создается объект типа Hashlterator,
который ссылается на найденный элемент. Поскольку const-версия идентична
предыдущей, ее реализация здесь не показана.
template <typename Key, typename T,
typename Compare, typename Hash>
typename hashmap<Key, T, Compare, Hash>::iterator
hashmap<Key, T, Compare, Hash>::find(const key_type& x)
{
int bucket;
// Используем вспомогательный метод findElement().
typename ListType::iterator it = findElement(x, bucket) ,-
if (it == (*mElems)[bucket].end()) {
// Если мы не находим элемент, возвращаем конечный
// итератор.
return (end());
}
// Если мы нашли элемент, преобразуем итератор сегмента
// в итератор типа Hashlterator.
return (HashIterator<Key, T,
Compare, Hash>(bucket, it, this));
}
Реализация метода count () представляет собой оболочку для метода find (),
возвращающую значение 1, если элемент был найден, и 0 в противном случае.
Вспомните: если заданный элемент не найден, метод find () возвращает конечный итератор.
Метод count () сравнивает конечный итератор (полученный с помощью метода
end ()) с результатом вызова метода find () и дает соответствующее заключение.
template <typename Key, typename T,
typename Compare, typename Hash>
typename hashmap<Key, T, Compare, Hash>::size_type
hashmap<Key, T,
Compare, Hash>::count(const key_type& x) const
{
// В контейнере существует либо 1 элемент с заданным
// ключом х, либо 0 таких элементов.
// Если нам удалось найти такой элемент, возвращаем 1,
// в противном случае возвращаем 0.
if (find(x) == end()) {
return (0);
770 Часть V. Использование библиотек и шаблонов
} else {
return (l);
Последний метод — не требование стандарта, но мы посчитали нужным включить
его, поскольку он позволяет упростить применение нашего контейнера. Его прототип
и реализация идентичны прототипу и реализации метода operator [] для STL-
контейнера тар. Приведенные комментарии разъясняют смысл, выраженный в
одной строке реализации.
template <typename Key, typename T,
typename Compare, typename Hash>
T& hashmap<Key, T,
Compare, Hash>::operator[] (const key_type& x)
{
// Это определение подобно тому, что используется в
// контейнере тар в соответствии со стандартом.
// Это, по сути, попытка вставить новую пару
// "ключ-значение" с заданным ключом х. Независимо от того,
// как завершится вставка (успешно или нет), метод insert()
// возвратит пару значений с типами "iterator-bool".
// Итератор ссылается на пару "ключ-значение". Ее второй
// элемент (second) представляет собой то
// значение, которое мы хотим возвратить,
return (((insert(make_j?air(x, T()))).first)->second);
}
Замечания по последовательным контейнерам
Класс hashmap, разработанный в предыдущих разделах, представляет собой
ассоциативный контейнер. Однако вы могли бы также написать последовательный
контейнер, и в этом случае вам пришлось бы следовать другому набору требований. Мы
не будем их здесь перечислять, но обратим ваше внимание на контейнер deque,
поскольку он практически в точности соответствует требованиям, предъявляемым
стандартом к последовательным контейнерам. Единственное отличие состоит в том, что
он содержит дополнительный метод resize () (который не требуется стандартом).
За подробностями относительно свойств контейнера deque обращайтесь к Web-
ресурсу Standard Library Reference.
Резюме
Завершающий пример, приведенный в этой главе, продемонстрировал почти
полную разработку ассоциативного контейнера hashmap и его итератора. Как
упоминалось во введении, реализацию класса hashmap (и код остальных примеров) вы
можете загрузить с соответствующего сайта и свободно включать в свои программы. Мы
надеемся, что во время чтения этой главы вы получили представление о
последовательности действий по разработке контейнеров. Даже если вам никогда и не придется
писать STL-алгоритм или контейнер, вам, вероятно, стал понятнее "менталитет"
библиотеки STL и особенности построения ее составляющих, и теперь ее использование
станет более осмысленным.
Этой главой завершился обзор библиотеки STL. Безусловно, в трех главах
невозможно рассмотреть все средства библиотеки. Если этот материал вас вдохновил на
Глава 23. Использование и расширение возможностей STL 771
новые "подвиги", то для получения более подробной информации обратитесь к
источникам, перечисленным в приложении Б. Мы понимаем, что синтаксис, да и сам
материал этой главы, не из легких. Как упоминалось в главах 21 и 22, вы не обязаны
использовать все рассмотренные здесь средства. Применение их в коде без насущной
необходимости означает лишь напрасное усложнение программ. Однако мы
благословляем вас на включение STL-средств там, где это имеет смысл. Начните с
контейнеров, затем добавьте парочку алгоритмов и, прежде чем вы хорошо узнаете их "в
деле", вы непременно станете их "фаном"!
Исследование
распределенных
объектов
В основе распределенных вычислений лежит идея, состояща
я в том, что операция, выполняемая программой, может быть разнесена в
пространстве между несколькими компьютерами, связанными сетью. По мере
увеличения размеров сетей и их популярности все больше приложений для обработки своих
данных задействуют по сети другие компьютеры.
В этой главе вы узнаете о распределенных объектах, точнее, о приложении объектно-
ориентированных технологий к распределенным вычислениям. Сначала мы
определим более детально, что понимается под распределенными вычислениями и
распределенными объектами, а затем познакомимся с такой мощной архитектурой для
программирования распределенных объектов, как CORBA. Наконец, мы рассмотрим
XML-технологии и их роль в распределенных вычислениях.
В чем притягательность распределенных
вычислений
В последнее десятилетие на распределенные вычисления было обращено большое
внимание именно по причине "расцвета" Internet. Это не просто стало модным
словечком: распределенная программа — это идеальное решение для приложений неко-
Глава 24. Исследование распределенных объектов 773
торых типов. Например, попробуйте написать программу, которая содержит всю
информацию, доступную в Web-пространстве. Это было бы практически невозможно.
Лишь сама "всемирная паутина" способна охватить весь объем динамически
изменяющихся данных, поскольку она распределена между многими различными компьютерами.
Распределение ради расширяемости
В течение одного рабочего дня ваш настольный компьютер может большую часть
времени простаивать. Даже когда вам кажется, что вы как никогда интенсивно его
используете, ваша активность не идет ни в какое сравнение с современными
процессорами, которые работают настолько быстро, что в действительности они "умирают со
скуки" в ожидании, пока вы их коснетесь. Реальность такова, что большая часть
мирового парка компьютеров не загружена вообще. При этом, как нам кажется, они были
бы "рады" оказаться полезными для человечества.
Если вы воспользуетесь средством, позволяющим оценить загрузку вашего процессора,
то увидите, что он редко бывает загружен на все 100%. Однако некоторые приложения
требуют чрезвычайно интенсивной работы процессора. Иногда приходится ожидать
долгие часы, чтобы программа завершила сложные вычисления на обычном настольном
компьютере. Если бы вы могли по сети "бросить на помощь" такому "трудяге" пару
простаивающих процессоров, это время можно было бы значительно сократить. Описанная
концепция называется "решетками" вычислительных ресурсов (grid computing) 7.
Один из классических примеров приложений, в котором для повышения скорости
вычислений используется распределение, связан с визуализацией изображений. Для
формирования всего лишь одного стоп-кадра качественной компьютерной
мультипликации требуется проделать гигантский объем вычислений. Приложения
пространственной визуализации уже в течение многих лет пользуются преимуществами
распределенных вычислений. С этой целью компонуются так называемые "фермы"
визуализации, в которых одна машина может служить в качестве "мозгового центра"
и раздавать небольшие порции вычислений каждой "рабочей лошадке" этой фермы.
"Мозговому центру" остается лишь собирать результаты и формировать
окончательное изображение или кино. Безусловно, вся эта система будет работать только при
наличии высокоскоростных сетевых линий связи.
Проект SETI@home — еще один пример использования распределенных
вычислений для повышения производительности. Эта программа помогает в поиске
внеземных цивилизаций (Search for Extra-Terrestrial Intelligence — SETI) путем
распределения работы по анализу сигналов из космоса среди всех компьютеров-участников.
Было бы (мягко говоря) непрактично пытаться анализировать такие огромные
объемы данных с помощью одного компьютера. Проект SETT ©home предлагает
пользователям программу, которую они могут запустить на своих домашних компьютерах для
Концепция Grid Computing — одно из ведущих направлений развития информационных
технологий, которое пока еще находится в начальной стадии развития. Аналитики сравнивают нынешнюю
ситуацию с состоянием сети WWW десять лет назад, при этом многие из них предсказывают, что Grid
произведет такую же революцию в области обработки данных, какую сеть Internet произвела в сфере ин-
фокоммуникаций. В попытке разработать стандартный подход к созданию и развитию компьютерной
инфраструктуры, построенной на основе концепции Grid Computing компания Deh\ корпорации EMC, Intel
и Oracle совместно разработали проект MegaGrid. Четыре компании объединяют свои ключевые
технологии и ресурсы, чтобы облегчить работу над интеграцией бизнес-процессов своим клиентам и создать ИТ-
решение, способное превзойти технологию SMP (Symmetrical Multiprocessing— симметричная
многопроцессорная обработка) по показателю стоимости. — Примеч. ред.
774 Часть V. Использование библиотек и шаблонов
обработки порций данных в то время, когда их компьютер не используется для их
собственных нужд. Безусловно, пока мы не можем похвастаться успехами (т.е. пока не был
найден ни один "маленький зеленый человечек"), но, как широко распространенное
распределенное Internet-приложение, оно помогло популяризовать эту технологию.
Распределение ради надежности
Распределенные вычисления можно представить себе как решение проблемы,
формулируемой законом Мэрфи: если что-то может выйти из строя, это обязательно
произойдет. Некоторые приложения, такие как Web-сайты или базы данных, должны
быть всегда доступны и находиться в рабочем состоянии. Подобные приложения
можно написать так, чтобы они работали на нескольких компьютерах в сети. Если
один из них выйдет из строя, другой немедленно примет "эстафету". В таких случаях
задача распределения становится, в основном, задачей синхронизации. Все
экземпляры приложения должны взаимодействовать так, чтобы при возникновении сбоя
пользователь не заметил никакого изменения в работе.
При доступе к крупномасштабному Web-сайту вы в действительности
подключаетесь к одному из набора серверов, которые содержат зеркальные изображения одних
и тех же данных. Устройство, именуемое выравнивателем нагрузки, часто используется
для направления входящих запросов на свободные машины. Если один из серверов
выходит из строя, выравниватель нагрузки прекращает отправку ему запросов до тех
пор, пока он не будет восстановлен.
Распределение ради центрированности
Зачастую полезно иметь одну систему в сети, которая бы контролировала или
отслеживала поведение других приложений. Например, KeyServer (Sassafras Software) —
приложение, которое позволяет сетевым администраторам гарантировать, что
программное обеспечение, используемое в этой сети, не нарушает условия
лицензирования. Если пользователь по сети запускает приложение, оно обращается к KeyServer,
чтобы запросить разрешение "на взлет". Программа KeyServer отслеживает
количество работающих в данный момент копий. Если по условиям лицензирования это
разрешено, приложение запускается нормально. При этом в программу KeyServer
заложено распределение и ради надежности, поскольку администраторы могут установить
несколько "теневых" KeyServer-приложений на случай "болезни" одного из них.
Распределенное содержимое
В последнее время все больше входит "в моду" взаимодействие равноправных
приложений. Основная идея здесь заключается в том, что все пользователи по сети
запускают приложение, которое позволяет им обмениваться информацией на основе
взаимно-однозначного соответствия. В приложениях совместного использования
файлов каждый пользователь сохраняет файлы на своих локальных компьютерах,
объединенных в сеть. Пользователи, которым нужен некоторый файл, могут
обратиться к пользователю, им владеющему, и начать пересылку. При распределении
такого приложения между всеми пользователями оно может предложить больший объем
содержимого, чем в случае, если бы это приложение размещалось на одном сервере.
Оно также распределяет коммуникационную нагрузку, чтобы не было одного-единст-
венного сервера, который бы стал "узким местом" для всех запросов.
Глава 24. Исследование распределенных объектов 775
Сравнение распределенного приложения с сетевым
Следует иметь в виду, что не все приложения, которые используют преимущества
работы в сети, обязательно являются распределенными. Сетевое приложение
общается с другими компьютерами для запроса данных или их передачи. Термин
"распределенный" подразумевает взаимодействие с более широкими возможностями.
Различие иногда понять не так уж просто. Например, рассмотрим какую-нибудь
видеоигру. Если эта игровая программа обменивается информацией с центральным
сервером на предмет обновления данных, то мы имеем дело с обычным сетевым
приложением, поскольку сама игра работает не в виде нескольких приложений— она
просто общается с другим компьютером по ходу выполнения. Но если бы эта игра
предусматривала участие нескольких игроков, сидящих за различными
компьютерами, она могла бы быть реализована как распределенное приложение.
Лучший способ отнести приложение к категории распределенного или сетевого —
поставить себя на место одного компьютера и выяснить, выполняются ли какие-либо
вычисления на другом. Для сетевой игры можно сказать, что отдельные копии игры
работают на тех компьютерах, на которых они установлены, и используют сеть
только для обновления данных о состоянии игры. Как показано на рис. 24.1, когда игрок
за компьютером А делает выстрел, информация о выстреле передается в
компьютер Б. Оба компьютера выполняют соответствующие действия на основе одного и
того же события. Этот стиль сетевой игры в общем случае не дает оснований считать
данное приложение распределенным.
Компьютер А
Компьютер Б
Игрок А делает выстрел.
Компьютер А отсылает
данные о событии.
Компьютер А вычисляет результат.
-► Компьютер Б принимает событие.
Компьютер Б вычисляет результат.
Рис. 24.1
Можно представить такую игру, которая бы на самом деле была реализована в виде
распределенного приложения. Вместо использования одного компьютера,
отправляющего на другой компьютер информацию о событии и самостоятельно
обрабатывающего результат, эту обработку можно было бы распределить между двумя
компьютерами. Как показано на рис. 24.2, компьютер А мог бы отсылать данные о событии
и предоставлять компьютеру Б возможность вычислить результат. С точки зрения
компьютера А, обработка некоторых данных происходит вне его "компетенции", что
является признаком распределенного приложения.
776 Часть V. Использование библиотек и шаблонов
Компьютер А
Компьютер Б
"1в
Игрок А делает выстрел.
Компьютер А отсылает
данные о событии.
Компьютер А
принимает результат.
->. Компьютер Б принимает событие.
Компьютер Б вычисляет результат.
Компьютер Б отсылает результат.
Рис. 24.2
Распределенные объекты
Распределенные вычисления завоевали популярность у разработчиков еще до
объектно-ориентированного программирования (ООП), поэтому эти две идеи
абсолютно независимы. Однако принципы, на которых строится
объектно-ориентированное приложение, в объединении с распределенными вычислениями дают жизнь
новым абстракциям. Если вы представите себе вызов метода для объекта, который
реально существует в памяти компьютера, расположенного от вас на расстоянии тысячи
километров, или передачу объекта между хостами по сети, вам станет понятно, какие
прекрасные результаты можно получить в результате объединения объектно-
ориентированного программирования с распределенными вычислениями.
Сериализация и маршалинг
Назначение сети состоит лишь в передаче данных (по сути, это все, что она
"умеет" делать). Сеть "не имеет никакого понятия" о языке программирования C++,
объектах или выполнении кода. Сети просто передают данные с одного места на
другое. Эта простота — одна из самых замечательных свойств сетей. Одна и та же сеть
может включать гетерогенную (неоднородную) коллекцию компьютеров (с
различными архитектурами и операционными системами), поскольку работа сети основана
на предположении о различных средах ее участников.
Что касается распределенных приложений, то простота сетевой среды
представляет некоторую проблему. Было бы здорово, если бы мы могли отсылать объект с
одного компьютера на другой путем вызова функции, которая помещает этот объект в сеть,
но это уже задачка посложнее простой передачи, поскольку сеть "ничего не знает" об
объектах. Вместо этого вам нужно преобразовать объект в "безликие" байты. Другими
словами, вместо отправки реального объекта вы должны отослать данные, которые
описывают этот объект. Получатель в этом случае должен интерпретировать
полученные байты данных так, чтобы воспроизвести дубликат оригинала.
Процесс преобразования объекта, хранимого в памяти компьютера, в "сплющенное"
представление называется сериализацией (serialization) или маршалингом (marshalling).
Глава 24. Исследование распределенных объектов 777
А процесс восстановления объекта называется воссозданием (десериализацией) или Ье-
маршалингом (демаршалгсзацгсей). Вы уже познакомились с процессом маршалинга в
главе 14 на примере использования строки для представления объекта. Маршалинг
полезен в большей степени, чем просто сетевая передача. Если вы захотите сохранить
объект на диске, то, по всей вероятности, вы сначала преобразуете его в
"сплюснутый" формат.
Сериализация в действии
Рассмотрим следующие объявления функций, которые обеспечивают возможность
отправки данных на другой компьютер по сети и приема данных с другого
компьютера. Как разъяснялось в главе 18, сетевые средства не являются встроенными для языка
C++, поэтому реальная библиотека сетевых средств, предоставляемая вашей
операционной системой, скорее всего будет отличаться от той, в которой создавалась эта
чрезвычайно простая версия.
/**
* Отправляет данные на другой хост сети.
*
* ©param inHostName - имя другого компьютера
* ©param inData - данные, подлежащие пересылке
*/
void send(const strings inHostName, const strings inData);
/**
* Получает поступающие из сети данные.
*
* ©return - данные, которые были получены.
*/
string read () ,-
Предположим, мы создаем приложение управления запасами для компании, которая
владеет товарными складами, расположенными по всей территории США. Каждый
склад должен иметь копию этой программы, но программа должна выполнять заказы
для любого подразделения. Другими словами, эта программа должна быть реализована
так, чтобы с помощью копии, выполняемой, например, в Питсфорде (шт. Нью-Йорк),
можно было заказать товар со склада, расположенного в Онион-Крик (шт. Вашингтон).
Поскольку все программы на всех складах для представления заказа используют
один и тот же класс Order, то ваша реальная задача— отсылать заказы по сети из
Питсфорда в Онион-Крик. Вот как выглядит определение класса Order.
// Order.h
class Order
{
public:
Order();
int getltemNumber() const;
void setltemNumber(int inltemNumber);
int getQuantity() const;
void setQuantity(int inQuantity);
int getCustomerNumber{) const;
void setCustomerNumber (int inCustomerNumber) ,-
778 Часть V. Использование библиотек и шаблонов
protected:
int mltemNumber;
int mQuantity;
int mCustomerNumber,-
};
На данном этапе существует несоответствие между данными, которые мы хотим
отослать (объект), и возможностями сетевых функций, которые обрабатывают только
строки (string). Решение этой проблемы лежит в добавлении в класс Order средств
сериализации и десериализации. Новое определение этого класса выглядит так.
// Order.h
#include <string>
class Order
{
public:
Order();
int getltemNumberО const;
void setltemNumber(int inltemNumber) ,-
int getQuantity() const;
void setQuantity(int inQuantity);
int getCustomerNumber() const;
void setCustomerNumber(int inCustomerNumber);
/**
* Преобразует объект в данные, которые можно отослать
* по сети.
* ©return - строка, представляющая этот объект.
*/
std::string serialize();
/**
* Корректирует этот объект для представления данных в
* виде строки inData.
* Oparam inData - строка, представляющая Order-данные.
*/
void deserialize (const std:: strings inData) ,-
protected:
int mltemNumber,-
int mQuantity;
int mCustomerNumber;
};
Ниже приведена реализация класса Order, в которой выделены только методы
сериализации, поскольку ее остальная часть совсем не представляет интереса.
// Order.cpp
#include "Order.h"
#include <iostream>
#include <sstream>
#include <string>
Глава 24. Исследование распределенных объектов 779
using namespace std,-
Order::Order() : mltemNumber(-1), mQuantity(-1),
mCustomerNumber(-1)
int Order::getItemNumber()
return mltemNumber;
void Order::setltemNumber(int inltemNumber)
mltemNumber = inltemNumber,-
int Order::getQuantity()
return mQuantity;
void Order::setQuantity(int inQuantity)
mQuantity = inQuantity;
int Order::getCustomerNumber()
return mCustomerNumber;
void Order::setCustomerNumber(int inCustomerNumber)
mCustomerNumber = inCustomerNumber,-
string serialize ().
// Используем выходной поток для вывода всех значений,
// разделенных символом табуляции,
ostringstream outStream;
outStream << getltemNumber() << "\t" <<
getQuantityO << "\t" «
getCustomerNumber();
return outStream.str();
}
void deserialize(const strings inData)
{
// Используем входной поток для считывания значений в
// том же порядке,
istringstream inStream(inData);
if (!inStream.good()) {
cerr << "Ошибка при десериализации!" << endl;
} else {
inStream >> mltemNumber,-
inStream >> mQuantity;
inStream >> mCustomerNumber;
780 Часть V. Использование библиотек и шаблонов
Предоставив возможность преобразования объекта в строку и обратно, вы
сможете передать представление объекта по сети. Если ваша программа интенсивно
использует сериализацию, то для ее обеспечения, возможно, стоит включить в каждый
класс методы operator<< и operator>>.
Удаленные вызовы процедур
Под удаленным вызовом процедуры (Remote Procedure Call— RPC) понимается
организация вызова метода или функции, выполняемой на некотором другом
компьютере. В C++ это поведение концептуально по своему характеру, поскольку язык не
содержит никакого реального механизма для вызова функции, которая не связана
с реальным исполняемым двоичным кодом программы. Средство RPC в C++ в общем
случае должно включать локальный вызов stub-метода, под которым в
действительности скрывается некоторый код сетевого обмена, "ответственный" за получение
результата от удаленного хоста.
Используя сетевые возможности вашей операционной системы и методы сериали-
зации (приведенные выше), программист может написать собственный RPC-меха-
низм. Для представления удаленного хоста можно определить класс, содержащий
некоторое количество суррогатных методов, которые бы реально выполнялись на
удаленном хосте, но возвращали результат локальному инициатору их вызова. Ниже
приводится определение такого класса, содержащее stub-методы, которые можно
использовать для получения информации о статусе удаленного компьютера или для
выполнения повторного запуска.
class RemoteHost
{
public:
/**
* Конструктор создает удаленный хост, который доступен
* по заданному адресу.
*/
RemoteHost(const strings inAddress);
int getNumConnectedUsers() const;
int getAvailableMemory() const ;
int getAvailableDiskSpace() const ;
void restartNow();
protected:
string mAddress;
};
Реализации этих методов можно определить обычным образом, но при этом для
получения реального результата от удаленного компьютера следует использовать
библиотеку сетевых средств. Реальный код будет сильно зависеть от этой библиотеки,
но в данном примере используется псевдокод, который вызывает функции сетевого
обмена, определенные в предыдущем разделе.
int RemoteHost::getAvailableMemory() const
{
// Отсылаем на удаленный хост строку "getAvailableMemoryО",
// которая предписывает ему отослать назад объем доступной
// памяти,
send(mAddress, "getAvailableMemory()");
Глава 24. Исследование распределенных объектов 781
// Получаем результат от удаленного хоста,
string result = readO;
// Преобразуем результат в int-значение.
// Возвращаем результат типа int.
}
Реализация метода на удаленном хосте потребует применения средств анализа и
интерпретации сообщений, которые он получает, чтобы корректно на них
отреагировать. Вот как выглядит псевдокод реализации удаленного хоста.
void respondToRPC(const string& inRequestHost,
const strings inMessage)
{
string response = "";
// Анализ сообщения для определения того, какая
// затребована операция.
if (inMessage == "getAvailableMemory()") {
// Используем локальную функцию для получения объема
// доступной памяти на этом компьютере.
int memAvail = getAvailMem();
// Преобразуем результат в строку и помещаем его в
// переменную response.
} else if (...) {
// Обрабатываем другие сообщения.
}
// Отпраляем ответ (в переменной response) назад
// инициатору запроса,
send (inRequestHost, response) ,-
}
Предыдущие примеры с псевдокодом выглядят просто, но при попытке
преобразовать его в реальный код на вас обрушится множество проблем. Перечислим лишь
некоторые из них.
□ Как обращаться с различными видами типами данных, включая такие сложные
типы, как объекты классов?
О Как обрабатывать RPC-вызовы, которые не распознаются удаленным
компьютером?
□ Как управлять версиями? А что если некоторые из удаленных компьютеров
будут использовать обновленные версии и не смогут отвечать на запросы так же,
как остальные?
О Как быть с сетевыми ошибками, отсутствующими хостами, перегруженными
сетями и т.п.?
□ Как решить проблемы, связанные с различием в платформах, например с
различным порядком следования байтов?
По этим причинам, а также во избежание изобретения колеса, программисты
обычно используют существующий RPC-пакет, который предназначен для
обслуживания взаимодействия этого типа. Остальная часть этой главы посвящена
рассмотрению двух различных технологий CORBA и XML, которые позволяют реализовать
RPC-общение.
782 Часть V. Использование библиотек и шаблонов
Архитектура CORBA
Технология построения распределенных объектных приложений (Common Object Request
Broker Architecture — CORBA) представляет собой стандартизованную независимую
от языка и платформы архитектуру, предназначенную для определения, реализации
и использования распределенных объектов. Главная цель CORBA — обеспечить среду
программирования, которая скрывает все детали сериализации и вызовы удаленных
процедур, рассмотренные в предыдущем разделе. Технология CORBA также
поддерживает прозрачность дислокации (location transparency): принцип, согласно которому
одни компоненты сообщают о событиях другим компонентам независимо от их
местоположения, т.е. вы можете писать код, который использует объекты, "не зная"
заранее о том, какими в действительности они являются: локальными или удаленными.
Архитектура CORBA сама по себе не включает реализацию, но определяет ряд
стандартов. Двумя самыми важными стандартами являются язык определения интерфейсов
(Interface Definition Language— IDL), который определяет синтаксис для написания
распределенных объектов, и протокол, определяющий передачу сообщений между
сетевыми объектами по TCP/IP (Internet Inter-ORB Protocol — ПОР) для создания
вызовов удаленных методов. Кроме того, технология CORBA определяет множество
дополнительных служб сопровождения, включающих службы имен, событий, времени и др.
Существует ряд реализаций стандартов CORBA с открытым исходным текстом,
которые можно использовать бесплатно. В примерах этой главы используется оболочка
"omniOREJ (универсальный Object Request Broker— брокер объектных запросов), которую
можно получить по адресу: http: //omniorb. source forge. net/.
Для использования технологии CORBA необходимо выполнить ряд действий,
например, определить интерфейсы объектов, позволяющие генерировать код сетевого
обмена и сериализации, определить реализацию методов класса, написать процесс
сервера и клиентскую часть. В этом разделе рассматривается каждое из этих действий
в контексте разработки чрезвычайно простой распределенной базы данных, в
которой клиенты могут получить доступ к серверу базы данных, расположенному в другом
процессе или даже на другом узле. Здесь мы затрагиваем лишь вершину этого
мощного архитектурного "айсберга". Если вас интересует использование этой технологии
для конкретной оболочки распределенных объектов, обратитесь к соответствующим
литературным источникам, перечисленным в приложении Б.
Язык определения интерфейсов (IDL)
Архитектура CORBA прекрасно справляется с задачей отделения интерфейсов
объектов от их реализаций. Прежде чем приступать к написанию распределенного
CORBA-класса, необходимо определить его интерфейс в языке IDL. Этот язык во
многом подобен языку C++, но не идентичен. В действительности IDL не зависит от
языка реализации. Вы могли бы теоретически написать реализацию этого класса на
C++, а клиентскую часть (которая будет его использовать) — на Java.
Написание интерфейса
На этом этапе вы задаете прототипы для методов, которые реализуются
распределенным CORBA-объектом. Однако при этом (в отличие от определений С++-классов)
вам не надо показывать члены данных или другие детали реализации.
Глава 24. Исследование распределенных объектов 783
Например, предположим, вы хотели бы, чтобы в вашей простой распределенной
базе данных хранились записи "ключ-значение", где ключ и значение были бы
представлены string-объектами. Вот как выглядит IDL-файл для базы данных, которая
поддерживает два метода.
// database.idl
interface database {
void addRecord(in string key, in string record);
string lookupRecord(in string key);
};
Ключевое слово in, расположенное перед каждым из параметров, определяет
параметр-значение, а не параметр-ссылку.
Создание stub- и skeleton-кода
После написания интерфейса объекта мы компилируем IDL-файл с помощью
IDL-компилятора, который генерирует вызов удаленной процедуры и уровни
сетевого обмена за вас. IDL-компиляторы существуют для различных языков, включая
Java, Python, С и, конечно же, C++. На этом этапе генерируется два набора файлов:
stub- и skeleton-файлы.
Построение stub-методов
Как упоминалось в предыдущем разделе, stub-методы представляют собой клиентскую
часть методов объекта, которые скрывают код сетевого обмена и сериал изации,
предназначенный для организации реального удаленного обращения к другому компьютеру. IDL-
компилятор omniORB помещает stub-код из IDL-файла пате. idl в заголовочный файл
name. hh и исходный файл nameSK. ее. Рассмотрим небольшой пример stub-кода в файле
database. hh, который генерируется на основе файла database. idl.
// Этот файл генерируется оболочкой omniidl
// (компилятор omniORB_4_0). He подлежит редактированию.
// <Реальный код больше показанного здесь.>
class _objref_database :
public virtual CORBA::Object, public virtual omniObjRef
{
public:
void addRecord(const char* key, const char* record);
char* lookupRecord(const char* key);
inline objref database() { _PR_setobj(0); } // нуль
objref database(omnilOR*, omnildentity*);
protected:
virtual ~_obj ref_database();
private:
virtual void* _jptrToObjRef(const char*);
_objref_database(const _objref_database&);
_objref databases operator = (const _objref_database&);
// без реализации
friend class database;
};
784 Часть V. Использование библиотек и шаблонов
Вот как выглядит реализация одного из методов из файла databaseSK. ее.
// Этот файл генерируется оболочкой omniidl
// (компилятор omniORB_4_0). He подлежит редактированию.
// < Реальный код больше показанного здесь.>
void _objref_database:raddRecord(const char* key,
const char* record)
{
_0RL_cd_D115D31DB8E47435_00000000 _call_desc(
_0RL_lcfn_D115D31DB8E4743 5_10000000, "addRecord**, 10);
call_desc.arg_0 = key;
_call_desc.argl = record;
invoke(call_desc);
}
Если вы не понимаете этот код, паниковать не надо! Мы просто хотели показать
пример работы, которая выполняется "негласно".
Построение skeleton-кода
Код skeleton-объектов создает основу для реализации класса и обычно
оформляется в виде абстрактных базовых классов, сгенерированных на базе IDL-интерфейса.
Компилятор omniORB помещает skeleton-код в те же самые файлы database.hh
и databaseSK. се, в которые вставляется и stub-код. Вот как выглядит skeleton-код из
файла database.hh.
class _impl_database :
public virtual omniServant
{
public:
virtual ~_impl database();
virtual void addRecord(const char* key,
const char* record) = 0;
virtual char* lookupRecord(const char* key) = 0;
public: // В действительности это protected-раздел
// (специально для xlC).
virtual _CORBA_Boolean _dispatch(omniCallHandle&);
private:
virtual void* _jptrToInterface(const char*);
virtual const char* _mostDerivedRepoId();
};
class POA_database :
public virtual _impl_database,
public virtual PortableServer::ServantBase
{
public:
virtual ~POA_database();
inline ::database_ptr _this() {
return (::database_ptr) _do_this(::database::_PD_repoId);
}
};
Обратите внимание на то, что параметр типа in string в IDL-файле преобразуется
в сгенерированном С++-коде в параметр типа const char*. Аббревиатура РОА
расшифровывается как Portable Object Adapter (переносимый объектный адаптер) и
означает CORBA-компонент, который управляет объектными ссылками в серверной части.
Глава 24. Исследование распределенных объектов 785
Реализация класса
Теперь, когда мы определили интерфейс и сгенерировали stub- и skeleton-код,
наша следующая задача — написать класс, который обеспечивает реальные реализации
методов в IDL-файле. Этот класс можно вывести из абстрактного skeleton-класса и
заполнить его членами данных и реализациями методов. При реализации методов не
нужно беспокоиться о коде сериализации или коде сетевого обмена. Их можно
написать как обычные методы, а уж skeleton-код этого класса обработает все "ужасные"
RPC-детали за вас. Вот как выглядит определение класса DatabaseServer,
основанного на коде приведенного выше omniORB-skeleton-класса.
// DatabaseServer.h
#include "database.hh"
#include <map>
#include <string>
class DatabaseServer : public POA_database,
public PortableServer::RefCountServantBase
{
public:
DatabaseServer();
virtual -DatabaseServer();
virtual void addRecord(const char* key,
const char* record);
virtual char* lookupRecord (const char* key) ,-
protected:
std::map<std::string, std::String> mDb;
};
Обратите внимание на то, что этот класс создается как производный от
приведенного выше абстрактного skeleton-класса POAdat abase, а также от класса-" примеси"
подсчета ссылок, поддерживаемого оболочкой. В класс DatabaseServer добавлен
защищенный map-член данных для хранения пар "ключ-значение".
Теперь приведем реализации перечисленных выше методов.
#include "DatabaseServer.h"
using namespace std,-
DatabaseServer::DatabaseServer()
DatabaseServer::-DatabaseServer()
void DatabaseServer::addRecord(const char* key,
const char* record)
raDb[key] = record;
char* DatabaseServer::lookupRecord(const char* key)
return (CORBA::string_dup(mDb[key].c_str()));
Здесь стоит обратить внимание на необходимость копирования строки,
возвращаемой методом lookupRecord (), с помощью метода CORBA: : string_dup ().
786 Часть V. Использование библиотек и шаблонов
Использование объектов
Теперь вы готовы к использованию распределенных объектов. Для этого
необходимо выполнить два этапа. Одна часть кода должна создать объект и
зарегистрировать его с помощью брокера объектных запросов (Object Request Broker — ORB) при
участии переносимого объектного адаптера (РОА). Этот код должен также
обеспечить для кода клиента возможность поиска ссылки на созданный объект, что можно
сделать с помощью сервера доменных имен (nameserver). Этот сервер имен должен быть
доступен из всех компьютеров, на которых выполняется распределенная программа.
Как сервер имен, он отображает имена на объектные ссылки и отслеживает реальное
физическое местоположение всех распределенных объектов в системе. Если сервер
имен для вас не доступен, можно использовать другие специальные методы
регистрации и поиска объектных ссылок. Несмотря на то что стандарт CORBA включает имя
сервера, и компилятор omniORB поддерживает его, для простоты в нашем примере
базы данных мы будем записывать ключ (и объектную ссылку) в файл.
Код, которому нужен некоторый объект, разыскивает его в сервере имен или, как
в нашем случае, в файле, чтобы получить на него ссылку. Когда этот код клиента
вызывает метод по ссылке, запрос отсылается на ORB-уровень, который (в зависимости от
реализации) представляет собой уровень в каждом процессе либо собственный процесс
в каждом узле. Поэтому необходимо рассматривать два варианта. Если базовый объект
относится к тому же процессу, что и инициатор вызова, метод выполняется локально
как обычный вызов С++-метода. Но если базовый объект относится к другому процессу
на том же или удаленном компьютере, брокер объектных запросов (ORB) отправляет
запрос на метод процессу сервера по сети. Вся эта работа выполняется "тайно": коду,
который реализует вызов метода по объектной ссылке, не нужно беспокоиться о том,
каков в действительности реальный объект: локальный или удаленный.
Базовая CORBA-архитектура показана на рис. 24.3.
Компьютер 1
Клиент
Ищем
объектную
ссылку
Выполняем
вызов
метода
stub-объект
ORB
Создаем удаленный
вызов процедуры I
с помощью МОР
Компьютер 2
Реализация
объекта
1
Skeleton-объект
Переносимый
объектный
адаптер (РОА)
i
ORB
Вызываем
реализацию
метода
Рис. 24.3
Архитектура CORBA осуществляет межпроцессное взаимодействие
на одном или разных компьютерах* Технологию CORBA можно
использовать как механизм "разделения" объектов между процессами
на одном компьютере.
Глава 24. Исследование распределенных объектов 787
В остальной части этого раздела мы продолжаем рассматривать пример
использования базы данных, демонстрируя код реализации сервера и клиента. Мы не ставим
задачу разъяснить вам все детали этого кода; мы просто хотим, чтобы вы получили
общее представление о CORBA-программировании и почувствовали, насколько
мощной может быть эта технология. Если вы хотите стать профессионалом в этой
области, обратитесь к соответствующим источникам, перечисленным в приложении Б.
Процесс сервера
Процесс сервера должен инициализировать брокер объектных запросов (ORB),
создать новый DatabaseServer-объект, зарегистрировать его и сохранить ключ для
поиска ссылки на него в файле, чтобы клиент мог ее отыскать. В нашем примере
предполагается, что клиенты имеют доступ к каталогу, из которого запускается этот процесс
сервера (либо через сетевую файловую систему, либо потому, что они выполняются
в одном узле). Обратите внимание на то, что для компиляции этого кода мы не используем
специальный компилятор; нам подойдет и стандартный С++-компилятор (например g++),
если, конечно, применить соответствующие omniORB-библиотеки.
#include "DatabaseServer.h"
#include <iostream>
#include <fstream>
using namespace std;
const char* objRefFile = "OBJ_REF_FILE.dat";
int main(int argc, char** argv)
{
// Делаем попытку инициализировать ORB-брокер.
CORBA::ORB_var orb;
try {
orb = CORBA::ORB_init(argc, argv);
} catch(CORBA::SystemException&) {
cerr << "He удается инициализировать ORB.\n";
exit(1) ;
}
// Получаем ссылку на переносимый объектный адаптер (РОА)
// и приводим (в нисходящем направлении) ее к
// соответствующему типу.
CORBA: :Object_var obj = orb->resolve_initial_references(
"RootРОА");
PortableServer::POA_var poa =
PortableServer::POA::_narrow(obj);
// Создаем DatabaseServer-объект и регистрируем/
// активизируем его с помощью переносимого объектного
// адаптера.
DatabaseServer* myDb = new DatabaseServer();
PortableServer::ObjectId_var dbid =
poa->activate_object(myDb);
// Записываем string-версию объектной ссылки в файл,
// чтобы клиенты могли найти нас.
CORBA::Object_var dbobj = myDb->_this();
CORBA::String_var sior(orb->object_to_string(dbobj));
ofstream ostr(objRefFile);
if (ostr.fail()) {
cerr << "He удается открыть файл объектных ссылок для
788 Часть V. Использование библиотек и шаблонов
*^> записи. \п" ;
exit(1);
}
ostr << (char*)sior;
ostr.close();
// Уведомляем счетчик ссылок о том, что мы завершили работу
// с объектом. Теперь только РОА имеет ссылку на него.
myDb->_remove_ref();
// Переводим РОА из блокировки в активное состояние, чтобы
// он обрабатывал входящие запросы.
PortableServer::POAManager_var pman = poa->the_POAManager();
pman->activate();
// Ожидаем входящие запросы.
orb->run();
// He следовало бы возвращаться из вызова, но если мы это
// делаем, нам нужно выполнить очистительно-
// восстановительные действия.
orb->destroy();
return (0) ;
}
Процесс клиента
Завершающий этап — создание процесса клиента. Ниже приведен базовый код
клиента, который считывает из файла пару "ключ-значение (объектная ссылка)",
воссоздает по ключу объектную ссылку и вызывает по ней два метода. Эти вызовы
преобразуются средствами ORB-уровня в обращения к DatabaseServer-объекту в процессе сервера.
#include "database.hh"
#include <iostream>
#include <fstream>
using namespace std;
const char* objRefFile = "OBJ_REF_FILE.dat";
int main(int argc, char** argv)
{
// Делаем попытку инициализировать ORB-брокер.
CORBA: :0RB_var orb;
try {
orb = CORBA::0RB_init(argc, argv);
} catch(CORBA::SystemException&) {
cerr << "He удается инициализировать ORB.\n";
exit (1) ,-
}
// Считываем из файла ссылку на объект сервера,
if stream istr (objRefFile) ,-
if (istr.fail ()) {
cerr << "В файле нет ни одной объектной ссылки!\п",■
exit(1);
}
char objRef[1024];
istr.getline(objRef, 1024);
// Создаем объектную ссылку из строки.
database_var dbref,- (*>
try {
Глава 24. Исследование распределенных объектов 789
CORBA::Objectvar obj = orb->string_to_object(objRef);
dbref = database::_narrow(obj);
if(CORBA::is_nil(dbref) ) {
cerr << "He удается сузить ссылку до типа
4>database. \n" ;
exit (1);
}
} catch(CORBA::SystemException&) {
cerr << "He удается найти объектную ссылку.\n";
}
// Создаем вызовы по объектной ссылке, которые
// преобразуются в вызовы для объекта сервера в процессе
// сервера.
try {
dbre f->addRe cord("key1", "va1ue1");
const char* lookup = dbref->lookupRecord("keyl");
if (strcmp(lookup, "valuel") ==0) {
cout << "Все получилось!\n";
} else {
cout << "Нет совпадения строк.\п";
}
} catch(CORBA::COMM_FAILURE&) {
cerr << "Ошибка связи.\п";
exit (1) ;
} catch(CORBA::SystemException&) {
cerr << "Ошибка связи (SystemException).\n";
exit(1);
}
// Завершение работы.
orb->destroy () ,-
return (0);
}
Как видите, использование CORBA-технологии — дело непростое и
характеризуется достаточно крутой кривой обучения. Однако ее роль для распределенного
программирования трудно переоценить.
Язык XML
Расширяемый язык разметки (Extensible Markup Language— XML), no существу,
позволяет представлять практически любые данные. Например, его можно
использовать в качестве файлового формата для хранения списка музыкальных МРЗ-файлов
для воспроизведения или в качестве внутреннего представления сложной формы
документа, используемого клиентом при покупке чего-либо или заказе. Благодаря
простоте применения и межплатформенной поддержке язык XML быстро стал
популярным в качестве формата сетевого обмена данными, удаленных вызовов процедур
и распределенных объектов.
Ускоренный курс по XML
Одними из самых значимых аспектов XML и, несомненно, причиной его быстрой
адаптации являются весьма умеренные требования к начинающим. Другими словами,
процесс обучения этому языку происходит довольно быстро, поскольку достаточно
нескольких минут для освоения терминологии, необходимой для обретения навыков,
790 Часть V. Использование библиотек и шаблонов
позволяющих читать и писать XML-код. Освоив азы, XML-разработчик может вскоре
перейти к таким более сложным технологиям, как XML-преобразования и даже
облегченный протокол для обмена структурированной информацией в децентрализованной,
распределенной среде (Simple Object Access Protocol — SOAP).
Что такое XML
Язык XML — это просто синтаксис для описания данных. Вне специального
приложения XML-данные не имеют никакого значения. Например, вы могли бы написать
программу управления запасами, которая бы формировала вполне действительный
XML-документ, описывающий все ваше личное имущество. Если вы передадите этот
документ кому-то другому, ему, возможно, удастся в нем разобраться, но совсем
необязательно, что XML-ориентированная программа управления запасами, которую
напишет другой программист, сможет его интерпретировать. Дело в том, что язык XML
определяет структуру документа, но не его содержание. Другое приложение может
представлять ту же информацию в другой структуре.
Язык XML написан в формате "открытого текста", т.е. текста без форматирующей
информации (plain text format), который упрощает для человека понимание сути
предмета. Даже если вам никогда раньше не приходилось сталкиваться с языком XML,
вам, вероятно, будет понятен следующий фрагмент XML-данных.
<inventory>
<office>
<desk type="wood"/>
<computer type="Macintosh"/>
<chair type=" leather*'/>
</office>
<kitchen>
<mixer type="chrome"/>
<stove type="electric"/>
</kitchen>
</inventory>
Этот текстовый формат характеризуется не только читабельностью, но и
простотой интеграции с другим программным обеспечением. Чтобы сделать
синтаксический анализ такого текста, вам не нужно изучать сложную оболочку или покупать
дорогой пакет инструментальных средств, поскольку даже самые скромные
операционные системы понимают "открытый текст". Используя язык XML, вам не
нужно беспокоиться о проблемах совместимости на уровне двоичных кодов.
Справедливости ради необходимо отметить, что текстовое описание имеет и
недостатки. Читабельность чревата многословием. Данные в XML-представлении обычно
занимают больший объем, чем эквивалентные данные в двоичном представлении. Если
набор данных достаточно велик, его XML-представление может вырасти до огромных
размеров. Кроме того, для синтаксического анализа текста требуется определенное
время, в то время как двоичный формат не требует никакого анализа вообще.
Важной особенностью XML-кода, выраженной в структурированном
расположении текста (см. предыдущий пример), является его иерархичность. Как будет
показано ниже, для синтаксического анализа XML-кода часто используется древовидная
структура, которую при обработке данных можно последовательно "обходить".
Структура и терминология языка XML
Документы XML начинаются с XML-декларации, которая определяет кодирование
символов и другие метаданные о документе. Многие программисты опускают эту декла-
Глава 24. Исследование распределенных объектов 791
рацию, но некоторые "взыскательные" XML-анализаторы, не найдя ее в первой строке
кода, могут не распознать его как XML-документ. Содержимое декларации выходит за
рамки этой книги. Следующий пример декларации подойдет для большинства случаев.
< ?xml vers ion="1.О"? >
Декларация документа представляет собой тег специального типа, т.е. элемент
синтаксиса XML, который будет распознан как владелец значения некоторого вида.
Если вы когда-либо писали HTML-файлы, то, значит, знакомство с тегами у вас уже
состоялось. Тело XML-документа состоит из тегов элементов. Они представляют
собой просто маркеры, которые идентифицируют начало и конец логической части
структуры. В языке XML каждый тег начального элемента имеет соответствующий тег
конечного элемента. Например, в следующей строке XML-кода тег sentence
отмечает начало и конец элемента предложения.
<sentence>flaBau пойдем за мороженым.</sentence>
В языке XML конечный тег состоит из косой черты и имени элемента. Теги
элементов не всегда включают данные (как показано в предыдущем примере). В языке
XML можно использовать пустой тег, который просто существует сам по себе. Для
образования пустого элемента достаточно после начального тега поставить конечный.
<emptyx/empty>
В языке XML для пустых тегов также разрешена сокращенная запись. Если
завершить тег косой чертой, такая "композиция" будет означать последовательность,
состоящую из начального и конечного тегов.
<empty />
Верхний элемент, который содержит все остальные элементы документа,
называется корневым.
Помимо имени, тег элемента может содержать пары "ключ-значение", именуемые
атрибутами. Существуют также "неписанные" правила, оговаривающие, что можно
писать в качестве атрибута (помните: XML— это просто синтаксис), но, в общем, атрибуты
предоставляют метаинформацию об элементах. Например, элемент sentence мог бы
иметь атрибут, который указывает на того, кто "произносит" предложение (speaker).
<sentence speaker="Marni">flaBaft пойдем за мороженым.
</sentence>
Элементы могут иметь несколько атрибутов, но с уникальными ключами.
<sentence speaker^'Marni" tone="pleading">
Давай пойдем за мороженым.
</sentence>
Если вы увидите XML-элемент, имя которого содержит двоеточие (например
<a:sentence>), знайте, что строка, предшествующая двоеточию, является
пространством умен. Как и в C++, пространства имен в XML позволяют сегментировать
использование имен.
792 Часть V. Использование библиотек и шаблонов
В предыдущих примерах содержимое любого элемента было пустым либо
представляло собой текстовые данные, обычно именуемые текстовыми узлами (text node).
В XML элементы могут также содержать другие элементы, которые придают XML-
коду иерархическую структуру. В следующем примере элемент dialogue состоит из
двух элементов sentence. Обратите внимание на то, что отступы здесь используются
только ради читабельности: XML игнорирует пробельные символы между тегами.
<dialogue>
<sentence speaker="Marni">
Давай пойдем за мороженым.
</sentence>
<sentence speaker="Scott">
После того, как я напишу эту книгу по C++.
</sentence>
</dialogue>
Вот и все, что входит в основные понятия языка XML! Элементы, атрибуты и
текстовые узлы — это и есть "строительные кирпичики" XML. Желающие более глубоко
изучить синтаксис этого языка (например специальные управляющие последовательности
символов), могут обратиться к одной из книг по XML, перечисленных в приложении Б.
Использование языка XML в качестве технологии
распределенных объектов
Простота языка XML сделала его популярным и в качестве механизма сериализации.
Объекты, подвергнутые XML-маршалингу, можно отсылать по сети, а отправитель при
этом может быть уверен, что получатель сможет выполнить демаршалинг, независимо
от используемой им платформы. Например, рассмотрим следующий простой класс.
class Simple
{
public:
std::string mName,-
int mPriority;
std: :string mData,-
b
Объект типа Simple может быть преобразован в следующий XML-код.
<Simple name="some name" priority="7">3TO данные</81тр1е>
Безусловно, поскольку XML не определяет, как именно должны быть
использованы отдельные узлы, вы могли бы поступить с ними так.
<Simple name="some name" priority="7" data="3To данные" />
Если получатель такого XML-кода знаком с правилами, которые вы использовали
для сериализации объекта, он должен знать средства десериализации.
XML-сериализация приобрела популярность в качестве простой альтернативы
таким довольно сложным технологиям распределенных объектов, как CORBA. Язык
XML характеризуется гораздо более пологой кривой обучения по сравнению с
архитектурой CORBA и вместе с тем предлагает множество таких же достоинств,
связанных с платформой и независимостью языков.
Глава 24. Исследование распределенных объектов 793
Формирование и анализ XML-кода в C++
Поскольку XML — это просто файловый формат, а не язык описания объектов,
задача преобразования данных из XML и обратно перекладывается целиком на плечи
программиста. В общем случае создание XML-кода — это довольно простая часть
работы, а его чтение обычно реализуется с помощью какой-нибудь XML-библиотеки
сторонних производителей.
Формирование XML-кода
Чтобы использовать язык XML как технологию сериализации, вашим объектам
потребуется возможность преобразования в XML-формат. Во многих случаях
построение XML-потока "в лоб" — самый простой способ вывода XML-кода. В
действительности понятие о том, что XML-элементы "заворачиваются" в другие элементы,
позволяет еще больше упростить задачу. При таком подходе новые XML-документы
можно строить как сочетание уже существующих. Если это звучит сложно, рассмотрим
следующий пример. Предположим, что у вас есть функция getNextSentenceXML (),
которая предлагает пользователю ввести предложение и возвращает его как XML-
представление. Поскольку эта функция возвращает введенное пользователем
предложение как действительный XML-элемент, мы могли бы создать диалог предложений,
"завернув" результаты нескольких обращений к функции getNextSentenceXML ()
в тег элемента dialogue.
string getDialogueXML()
{
sstringstream outStream;
// Начало элемента диалога (dialogue).
outStream << "<dialogue>";
while (true) {
// Получаем следующее предложение.
string sentenceXML = getNextSentenceXML();
if (sentenceXML == "") break;
// Добавляем элемент предложения (sentence).
outStream << sentenceXML;
}
// Конец элемента dialogue.
outStream << "</dialogue>";
return outStream.toString();
}
Если последующие обращения к функции getNextSentenceXML () возвращали бы
предложения, приведенные в предыдущем примере, результат выполнения этой
функции имел бы следующий вид.
<dialoguexsentence speaker=" "Marni">Давай пойдем за
мороженым. </sentencexsentence speaker= "Scott "> После того,
как я напишу эту книгу по C++.</sentence></dialogue>
Результат выглядит немного странно, поскольку он не отформатирован разрывами
строк и символами табуляции. И тем не менее, это вполне законный XML-код. Если
вы хотите его слегка приукрасить, выполните следующие действия.
794 Часть V. Использование библиотек и шаблонов
□ Можно использовать средство стороннего производителя fact. Например,
программа с интерфейсом типа командной строки (с открытым исходным
текстом) tidy (http: //tidy. sourceforge .net) обладает (наряду с другими
полезными свойствами) XML-средством улучшенной печати.
□ Можно вручную включить в свой код символы возврата каретки и пробелы.
Однако это не так уж просто, поскольку в теле функции getNextSentenceXML ()
код и "понятия не имеет" о том, сколько потребуется символов табуляции.
□ Можно использовать (или написать) простую библиотеку классов
формирования XML-кода, которая бы различала вложенные элементы и потому
форматировала их надлежащим образом.
XML-класс вывода данных
Несмотря на то что вывод XML-кода — задача не из сложных, есть ряд причин, по
которым имеет смысл выделить код вывода XML-кода в отдельный класс или
множество классов. Помимо проблемы форматирования, описанной выше, выделение
такого кода дает ряд следующих преимуществ.
□ Код очистки. (Кому понравятся "разбросанные" повсюду символы "<"?!)
□ Определенное место для реализации специальных управляющих
последовательностей символов.
□ Более объектно-ориентированный подход. (XML-элементы могли бы быть
объектами, которые затем сохраняются, передаются методам и упорядочиваются.)
□ Сокращение возможностей для появления ошибок в XML-синтаксисе путем
централизации вывода.
Создание класса формирования XML-кода также не составляет труда.
Определение простого XML-класса Element выглядит так.
// XMLElement.h
#include <string>
#include <vector>
#include <map>
#include <iostream>
class XMLElement
{
public:
XMLElement{);
void setElementName(const std::strings inName);
void setAttribute(const std::strings inAttributeName,
const std::strings inAttributeValue);
void addSubElement (const XMLElement* inElement) ,-
// Установка текстового узла переопределит любые
// вложенные элементы.
void setTextNode(const std::strings inValue);
friend std::ostreamS operator<<(std::ostreamS outStream,
const XMLElementS inElem);
protected:
Глава 24. Исследование распределенных объектов 795
void writeToStream(std::ostream& outStream,
int inlndentLevel = 0) const;
void indentStream(std::ostream& outStream,
int inlndentLevel) const;
private:
std: :string mElementName;
std: :map<std::string, std::string> mAttributes;
std::vector<const XMLElement*> mSubElements;
std::string mTextNode;
};
Используя этот класс, пользователь сможет легко создавать XMLElement-объекты,
устанавливать их атрибуты, а также текстовые узлы или подэлементы. В любое время
клиент может вызвать метод operator<<, чтобы получить XML-представление
текущего состояния элемента.
Ниже представлена простая реализация методов этого класса. Поскольку здесь
используется С++-синтаксис, в котором вы должны бы уже ориентироваться на
профессиональном уровне, мы считаем комментарии для каждой строки кода излишними.
#include "XMLElement.h"
using namespace std;
XMLElement: .-XMLElement {) : mElementName ("unnamed" )
void XMLElement::setElementName(const strings inName)
mElementName = inName,-
void XMLElement::setAttribute(const strings inAttributeName,
const strings inAttributeValue)
// Устанавливаем пару "ключ-значение", заменяя существующую
// при наличии таковой.
mAttributes[inAttributeName] = inAttributeValue;
void XMLElement::addSubElement(const XMLElement* inElement)
// Добавляем новый элемент в вектор подэлементов.
mSubElements.pushjback(inElement),-
void XMLElement::setTextNode(const strings inValue)
mTextNode = inValue,-
ostreamS operator<<(ostreamS outStream,
const XMLElementS inElem)
inElem.writeToStream(outStream);
return (outStream);
void XMLElement::writeToStream(ostream& outStream,
int inlndentLevel) const
796 Часть V. Использование библиотек и шаблонов
}
indentStream(outStream, inlndentLevel);
outStream << "<" << mElementName; // Открываем
// начальный тег.
// Выводим любые атрибуты.
for (map<string,
string>::constiterator it = mAttributes.begin();
it != mAttributes.end(); ++it) {
outStream << " " << it->first << »=\nn
<< it->second << "\lin;
}
// Закрываем начальный тег.
outStream << ">";
if (mTextNode != "") {
// Если существует текстовый узел, выводим его.
outStream << mTextNode;
} else {
outStream << endl;
// Вызываем метод writeToStreamO на уровне отступа
// inlndentLevel+l для каждого подэлемента.
for (vector<const XMLElement*>::const_iterator it =
mSubElements.begin{);
it != mSubElements.endO ; ++it) {
{*it)->writeToStream(outStream, inlndentLevel + 1) ,-
}
indentStream(outStream, inlndentLevel);
}
// Записываем тег конца.
outStream << "</" << mElementName << ">" << endl;
void XMLElement::indentStream(ostreamfic outStream,
int inlndentLevel) const
{
for (int i = 0; i < inlndentLevel; i++) {
outStream << "\t";
}
}
Предыдущая реализация — прекрасная стартовая площадка и образец для
написания простых XML-приложений. Однако здесь не нашла отображения возможность
вставки управляющих последовательностей символов. Например, символ "&" в теле
XML-документа должен быть представлен как &атр;. Ниже приведен пример
программы, которая демонстрирует использование класса XMLElement для
формирования документа, который выводился "вручную" в предыдущем примере.
int main(int argc, char** argv)
{
XMLElement dialogueElement ,-
dialogueElement.setElementName("dialogue");
XMLElement sentenceElementl;
sentenceElementl.setElementName("sentence");
sentenceElementl.setAttribute("speaker", "Marni");
sentenceElementl.setTextNode("Давай пойдем за мороженым.");
XMLElement sentenceElement2;
sentenceElement2.setElementName("sentence");
sentenceElement2.setAttribute("speaker", "Scott");
Глава 24. Исследование распределенных объектов 797
sentenceElement2.setTextNode(
"После того, как я напишу эту книгу по C++.");
// Добавляем элементы sentence как подэлементы
// элемента dialogue.
dialogueElement.addSubElement(&sentenceElementl);
dialogueElement.addSubElement(&sentenceElement2);
// Выводим элемент dialogue в поток stdout.
cout << dialogeElement ,-
return 0;
}
Результат выполнения этой программы выглядит так.
<dialogue>
<sentence speaker="Marni">Давай пойдем за мороженым.
(b</sentence>
<sentence speaker="Scott">После того, как я напишу
Ч^эту книгу по C++. </sentence>
</dialogue>
Многие библиотеки XML-анализа также включают средства XML-
вывода. Бели вы используете XML-анализатор для ввода (см. ниже),
перед тем, как писать собственные средства вывода, все лее
поинтересуйтесь насчет их существования.
Анализ XML-кода
Для десериализации XML-объектов необходимо выполнить анализ XML-
документа. Для этого имеет смысл использовать библиотеки XML-анализа от
сторонних производителей, которые "встречаются" в двух вариантах: SAX и DOM.
В SAX-анализаторах (Simple API for XML) используется
событийно-ориентированная модель анализа. Для применения SAX-анализатора нужно регистрировать
функции обратного вызова или объект, который реализует определенные методы. В ходе
анализа документа вызываются соответствующие функции или методы, позволяющие
выполнить некоторое действие. Например, если бы вам нужно было найти в
документе дубликаты имен XML-элементов, вы могли бы зарегистрировать обратный вызов,
который бы "срабатывал" при достижении тега начального элемента. При этом вам
пришлось бы вести список элементов, которые уже встречались в документе. С
помощью этого списка и можно было бы выявить дубликаты.
В DOM-анализаторах (Document Object Model) XML-документ преобразуется в
древовидную структуру, обход которой несложно организовать программным путем. Для
программистов, привыкших к объектно-ориентированным иерархиям и древовидным
структурам данных, DOM-подход может показаться более естественным. Его
недостаток состоит в низкой производительности. Поскольку в этом случае анализируется
весь документ, он работает медленнее, чем SAX, и при этом требует большего объема
памяти. Несмотря на то что в остальной части этого раздела мы будем рассматривать
только DOM-анализаторы, вы должны знать, что большинство XML-анализаторов
поддерживает как SAX-, так и DOM-подходы.
798 Часть V. Использование библиотек и шаблонов
XML-библиотека Xerces
Один из самых популярных XML-анализаторов представляет собой часть XML-проекта
Apache, именуемую Xerces. Xerces — это анализатор с открытым кодом, который
реализован на нескольких языках программирования, в том числе и на C++. С++-вариант
Xerces-библиотеки можно загрузить с сайта по адресу: http: / /xml. apache. org/.
Инсталлировав Xerces-библиотеку и добавив ее в свой С++-проект, вы значительно
облегчите себе работу по XML-анализу, поскольку Xerces-анализатор — это образец
прекрасно спроектированной библиотеки!
Самым важным в DOM-анализаторе Xerces является класс DOMNode,
представленный в виде одного модуля XML-данных, который может включать другие узлы.
Перечислим лишь некоторые из подклассов класса DOMNode: DOMDocument, DOMElement,
DOMAttr, DOMText и т.д. Использование DOM-анализатора Xerces в общем случае
предполагает обход (начиная с корневого узла DOMDocument) всего дерева узлов с
целью обнаружения нужных данных. На рис. 24.4 показана слегка упрощенная версия
дерева для XML-документа <dialogue>. Упрощение состоит в том, что здесь
показаны только те узлы, которые реально содержат данные.
<sentence>
(DOMElement)
1
'
Давай пойдем...
(DOMText)
<sentence>
(DOMElement)
1
'
После того, как я...
(DOMText)
Рис. 24.4
На рис. 24.4 XML-атрибуты не показаны, поскольку они являются свойствами
элемента, а не его подэлементами.
Использование анализатора Xerces
Сложность здесь состоит в способе представления строк. Поскольку XML-документ
можно закодировать по-разному, в Xerces-библиотеке определен собственный
символьный тип XMLch, а также вспомогательный класс XMLString, который упрощает работу
с XMLch-строками и преобразует их в более "знакомые" char-символы. Например, если
Xerces-метод возвращает такие данные, как строки типа XMLch*, их можно выводить
с помощью метода XMLString: : transcode () (для получения С-строки).
Глава 24. Исследование распределенных объектов 799
void outputXercesString(XMLch* inXercesString)
{
char* familiarString = XMLString::transcode(,
inXercesString);
cout « familiarString << endl;
}
Поскольку метод transcode () выделяет память для С-строки, вы должны
освободить эту память с помощью метода XMLString: : release (), который принимает
указатель на С-строку. Приведенная ниже модифицированная версия позволяет
избежать утечки памяти.
void outputXercesString(XMLch* inXercesString)
{
char* familiarString = XMLString::transcode(
inXercesString);
cout << familiarString << endl;
XMLString::release(&familiarString);
}
, Пора перейти к практике и проанализировать какой-нибудь XML-код. В
следующем примере содержимое файла test.xml преобразуется в DOM-дерево, а затем
в процессе обхода всех его узлов выводятся имена всех ("встречающихся по дороге")
элементов, любых атрибутов, принадлежащих этим элементам, а также содержимое
всех текстовых узлов.
Программа начинается с включения необходимых стандартных и Xerces-заголовков.
Наличие имени XERCES_CPP_NAMESPACE__USE (оно определено в Xerces с помощью
директивы #def ine) создает для этого файла корректное пространство имен.
#include <xercesc/util/PlatformUtils.hpp>
#include <xercesc/dom/DOM.hpp>
#include <xercesc/parsers/XercesDOMParser.hpp>
#include <xercesc/util/XMLString.hpp>
#include <iostream>
XERCES_CPP_NAMESPACE_USE
using namespace std;
void printNode(const DOMNode* inNode);
Функция main () этой программы довольно проста, несмотря на то, что именно
в ней и происходит реальный анализ. Он начинается с инициализации Xerces-биб-
лиотеки. Затем создается новый DOM-анализатор, которому "отдается
распоряжение" проанализировать файл. Результатом этой операции является объект класса
DOMNode, который представляет документ в целом. Чтобы получить корневой
элемент, вызывается метод get Document Element (). Полученное значение передается
методу printNode (), который "обходит" дерево, выводя "растущие" на нем данные.
Наконец, перед выходом программа очищает XML-библиотеку.
int main(int argc, char** argv)
{ '
XMLPlatformUtils:initialize();
800 Часть V. Использование библиотек и шаблонов
XercesDOMParser* parser = new XercesDOMParser();
parser->parse("test.xml");
DOMNode* node = parser- >getDocument {) ;
DOMDocument* document = dynamic_cast<DOMDocument*>(node);
if (document != NULL) {
printNode(document->getDocumentElement());
}
delete parser;
XMLPlatformUtils::Terminate(),-
return 0;
}
Интересно остановиться на функции printNode (). Поскольку параметр inNode
может представлять XML-узел любого типа, эта функция последовательно
"отрабатывает" два известных ей типа узла. Сначала она с очередным узлом выполняет
операцию dynamiccast, пытаясь привести его к типу текстового узла. Если оказалось, что
этот узел имеет другой тип, перехватывается ошибка приведения типов.
void printNode(const DOMNode* inNode)
{
try {
const DOMTextb textNode =
dynamic_cast<const DOMText&>(*inNode);
char* text = XMLString: rtranscode (textNode.getData ()) ,-
cout << "Обнаружены текстовые данные: " << text << endl;
XMLString::release(&text);
} catch (bad_cast) {
// Это не текстовый узел . . .
}
Затем делается попытка привести узел к типу элемента. Если операция приведения
выполнится успешно, будет выведено имя элемента и все его атрибуты.
try {
const DOMElement& elementNode =
dynamic_cast<const DOMElement&>(*inNode);
char* tagName = XMLString::transcode(
elementNode.getTagName());
cout << "Найден тег с именем: " << tagName << endl;
XMLString::release(&tagName);
// Просматриваем список атрибутов.
DOMNamedNodeMap* attributes =
elementNode.getAttributes();
for (int i = 0; i < attributes->getLength(); i++) {
try {
const DOMAttr& attrNode =
dynamic_cast<const DOMAttr&>(
*attributes->item(i));
char* name = XMLString::transcode(
attrNode.getName());
char* value = XMLString::transcode(
attrNode. get Value () ) ,-
cout << "Найдена пара атрибутов: (" << name
<< "=" << value << ")" << endl;
XMLString::release(&name);
XMLString::release(&value);
Глава 24. Исследование распределенных объектов 801
} catch (bad_cast) {
cerr << "Ошибка при преобразовании атрибута!"
<< endl;
}
}
} catch (bad_cast) {
// Это не узел элемента . . .
}
Наконец, эта функция рекурсивно вызывает метод printNode () для дочерних
узлов. Практически дочерние узлы должны существовать только на узлах элементов.
// Выводим любые подэлементы.
DOMNodeList* children = inNode->getChildNodes();
for (int i = 0; i < children->getLength{); i++) {
printNode(children->item(i));
}
}
Если на вход анализатора будет "подан" документ <dialogue>, эта программа
выведет следующий результат.
Найден тег с именем: dialogue
Обнаружены текстовые данные:
Найден тег с именем: sentence
Найдена пара атрибутов: (speaker=Marni)
Обнаружены текстовые данные: Давай пойдем за мороженым.
Обнаружены текстовые данные:
Найден тег с именем: sentence
Найдена пара атрибутов: (speaker=Scott)
Обнаружены текстовые данные: После того, как я напишу эту книгу по C++.
Обнаружены текстовые данные:
Обратите внимание на то, что пробельные символы между тегами
воспринимаются как текстовые узлы.
В библиотеке Xerces учтена возможность возникновения
исключений. В предыдущем примере предусмотрен перехват только
исключения типа bad_cast, но коммерческая версия должна быть
"вооружена до зубов** на случай всяких других неприятностей.
Аттестация языка XML
Язык XML характеризуется синтаксисом общего назначения без собственной
семантики и встроенных тегов. Однако это не означает, что любое XML-приложение
может интерпретировать любые входные XML-данные. При написании приложения,
которое связано с языком XML, необходимо определить конкретный тип XML-
документов, которые сможет интерпретировать ваше приложение. Аттестация языка
XML позволяет определить конкретный формат XML-данных, разрешенных вашим
приложением, включающий имена элементов, их организацию и атрибуты.
Определение типа документа
Для задания типа XML-документа используются DTD-шаблоны (Document Type
Definition— определение типа документа). Структура DTD-шаблона внешне
напоминает XML-документ, но DTD-шаблоны не имеют иерархического формата; они напи-
802 Часть V. Использование библиотек и шаблонов
саны в виде последовательности объявлений относительно типа документа. Детали
создания DTD-шаблона выходят за рамки этой книги. Но для того чтобы вы получили
представление о том, как он выглядит, приведем DTD-шаблон, соответствующий
документу <dialogue>.
<?xml version="l .0" encoding=l,UTF-8"?>
<!ELEMENT dialogue (sentence*)>
<!ELEMENT sentence (#PCDATA)>
•c'ATTLIST sentence
speaker (Marni | Scott) #REQUIRED
>
В теле XML-документа можно задать DTD-шаблон, которому он соответствует,
включив в начале файла утверждение DOCTYPE.
<?xml version="1.0"?>
<!DOCTYPE dialogue SYSTEM "dialogue.dtd">
<dialogue>
<sentence speaker="Marni">
Давай пойдем за мороженым.
</sentence>
<sentence speaker="Scott">
После того, как я напишу эту книгу по C++.
</sentence>
</dialogue>
Утверждение DOCTYPE требует передачи двух параметров. Первым является
корневой элемент документа, а вторым — местоположение DTD-файла. В данном случае
DTD-файл расположен в файле dialogue. dtd локальной системы.
Большинство библиотек XML-анализа, включая Xerces, может самостоятельно
аттестовать XML-файл. В этом случае вы должны гарантировать, что ваша программа
будет обрабатывать только те данные, которые она может интерпретировать.
Схема XML
Аттестация XML-документов— это прекрасная идея, но DTD-формат оставляет
желать лучшего. Для сложных документов DTD-шаблоны становятся слишком
громоздкими. Они также не предоставляют средства для определения сложных типов,
упорядочения или содержимого данных (не говоря уже о том, что DTD-шаблоны
написаны не на языке XML).
Средство XML Schema — это попытка обеспечить более функциональный способ
определения типа XML-документа. Определения, предлагаемые средством XML
Schema, гораздо более гибкие, чем определения DTD-шаблонов, но дополнительная
гибкость влечет за собой дополнительную сложность. По этой теме есть прекрасные
книги (см. приложение Б), поэтому мы приведем здесь только очень простой пример.
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="dialogue">
<xs:complexType>
<xs:sequence>
<xs:element ref="sentence" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
</xs:e1ement >
Глава 24. Исследование распределенных объектов 803
<xs:element name="sentence">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="speaker" use="required">
<xs:simpleType>
<xs:restriction base="xs:NMTOKEN">
<xs:enumeration value="Marni"/>
<xs:enumeration value="Scott"/>
</xs:restriction>
</xs:simpleType>
</xs:attribute>
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
</xs:schema>
Так же, как и в случае с DTD-шаблоном, вы можете соотнести XML-схему с XML-
документом. Вместо DOCTYPE-объявления вам нужно задать местоположение этой
схемы в атрибуте корневого элемента.
<?xml version="l.0"?>
<dialogue xmlns:xsi="http://www.w3.org/2001/XMLSchema-
^instance"
xsi:noNamespaceSchemaLocat ion="dialogue.xsd">
<sentence speaker^"Marni">
Давай пойдем за мороженым.
</sentence>
<sentence speaker="Scott">
После того, как я напишу эту книгу по C++.
</sentence>
</dialogue>
Обратите внимание на то, что атрибут xmlns :xsi означает, что документ
является экземпляром схемы XML Schema, а его местоположение определяется атрибутом
xsi:noNamespaceSchemaLocation.
Такие программные пакеты, как xmlspy от компании Altova Software
(www.xzalspy.coxa), могут существенно облегчить процесс
генерирования и интерпретации XML-кода, XML-схем и DTD-шаблонов.
Построение распределенных объектов с помощью языка XML
Распределенный XML-объект— это объект, который "знает", как "подать себя" в
XML-формате и заполнить содержимым из XML-документа. В этом разделе мы
преобразуем определенный выше класс Simple в распределенный объект, используя метод
XML-сериализации.
Класс-"примесь" XMLSerializable
В приложении, в котором используется множество распределенных объектов,
зачастую удобно иметь для них общий родительский класс. Класс XMLSerializable,
определенный в следующем примере, требует, чтобы в подклассах были реализованы
методы, позволяющие считывать их содержимое из XML-кода и записывать его
в XML-формате. Рассмотрим пример такого класса-"примеси" (подробнее — в главе 25).
804 Часть V. Использование библиотек и шаблонов
class XMLSerializable
{
public:
virtual std::string toXML{) = 0;
virtual void fromXML(const std::string& inXML) = 0;
};
Вот как выглядит модифицированный класс Simple: теперь он стал производным
от класса XMLSerializable.
class Simple : public XMLSerializable
{
public:
std::string mName;
int mPriority,-
std::string mData;
virtual std::string toXMLO;
virtual void fromXML(const std::string& inXML);
};
Реализация XML-сериализации
В реальном коде сериализации существенную роль играет реализованный выше
класс XMLElement и библиотека Xerces.
string Simple::toXML()
{
XMLElement simpleElement;
simpleElement.setElementName("simple");
simpleElement.setAttribute("name", mName);
// Преобразуем int-значение в строку (string).
ostringstream tempStream,-
tempStream << mPriority;
simpleElement.setAttribute("priority", tempStream.str() ) ;
// Добавляем данные в текстовый узел.
simpleElement.setTextNode(mData);
// Преобразуем XMLElement-объект в строку,
ostringstream resultStream;
resultstream << simpleElement;
return resultStream.str();
}
void Simple::fromXML(const string& inString)
{
static const char* bufID = "simple buffer";
// Используем класс MemBufInputSource для считывания
// XML-содержимого из строки.
MemBufInputSource src((const XMLByte*)inString.c_str(),
inString.length(), bufID);
XercesDOMParser* parser = new XercesDOMParser();
Глава 24. Исследование распределенных объектов 805
parser->parse(src);
DOMNode* node = parser->getDocument();
DOMDocument* document = dynamic_cast<DOMDocument*>(node);
if (document == NULL) {
delete parser;
return;
}
// Документ должен быть элементом <simple>.
try {
const DOMElement& elementNode =
dynamic_cast<const DOMElement&>(
*document->getDocumentElement());
// Получаем атрибут name.
XMLCh* nameKey = XMLString::transcode("name");
char* name = XMLString::transcode(
elementNode.getAttribute(nameKey));
XMLString::release(ЬпатеКеу);
mName = name;
XMLString::release(&name);
// Получаем атрибут priority.
XMLCh* priorityKey = XMLString::transcode("priority");
char* priorityStr =
XMLString::transcode(
elementNode.getAttribute(priorityKey));
XMLString::release(fcpriorityKey);
// Анализируем номер приоритета.
istringstream tmpStream(priorityStr);
tmpStream >> mPriority;
XMLString::release(&priorityStr);
// Получаем данные как текстовый узел.
const XMLCh* textData = elementNode.getTextContent();
char* data = XMLString::transcode(textData);
mData = data;
XMLString: .-release (&data) ;
} catch (bad_cast) {
cerr << "Исключение в результате приведения типов
^во время анализа Simple-объекта из XML-кода." << endl;
} catch (...) {
cerr « "Неизвестная ошибка во время анализа
^Simple-объекта из XML-кода." << endl;
}
delete parser;
}
Ниже приведена функция main (), которая тестирует процесс сериализации путем
создания объекта класса Simple, записывая его в XML-документ, а затем считывая тот
же самый XML-результат в новый Simple-объект. По окончании этой работы оба
объекта должны быть эквивалентны.
int main(ing argc, char** argv)
{
XMLPlatformUtils::Initialize();
Simple test;
806 Часть V. Использование библиотек и шаблонов
test.mName = "myname";
test.mPriority = 7;
test.raData = "my data";
string xmlData = test. toXML () ;
Simple test2;
test2.fromXML(xmlData);
if (test.mName == test2.mName) {
cout << "Имена эквивалентны!" << endl;
} else {
cout << "ОШИБКА: имена не эквивалентны!" << endl;
if (test.mPriority == test2.mPriority) {
cout << "Приоритеты эквивалентны!" << endl;
} else {
cout << "ОШИБКА: приоритеты не эквивалентны!" << endl;
if (test.mData == test2.mData) {
cout << "Данные эквивалентны!" << endl;
} else {
cout << "ОШИБКА: данные не эквивалентны!" << endl;
XMLPlatformUtils::Terminate О;
return 0;
}
Использование распределенного объекта
Теперь, когда Simple-объекты могут сами считывать себя из XML-документа и
записывать себя же в XML-документ, они считаются полностью упорядочиваемыми по
XML-правилам. XML-сериализация — это основа для использования XML в качестве
технологии распределенных объектов. У этой задачи есть и вторая часгь, которая
состоит в передаче сериализированных XML-объектов между различными
компьютерами и приложениями.
Как и традиционные схемы сериализации (описанные выше в этой главе), вы
можете использовать XML-сериализацию с любой сетевой технологией или протоколом
обмена данными. Можно написать программу, которая бы передавала по электронной
почте сериализированные объекты или упаковывала их и отправляла в формате
двоичных данных по сети. Поскольку XML — это всего лишь синтаксис, только
программист определяет точную семантику XML-содержимого и механизм передачи данных.
Протокол SOAP (Simple Object Access Protocol)
Одной из "прикладных программ-приманок" для XML является обмен данными по
сети. Как вы уже знаете, язык XML прекрасно справляется с такими приложениями,
поскольку он прост в применении и распознается всеми платформами. Основной
недостаток такого обмена состоит в том, что приложения, которые обмениваются данными
с использованием языка XML, должны поддерживать конкретную семантику
XML-данных, подлежащих обмену. Если в вашем распоряжении есть только один язык XML, вы
не сможете написать приложение, которое бы выполняло удаленные вызовы (RPC)
к другому приложению, поскольку вы не знаете формата ожидаемых XML-данных.
Глава 24. Исследование распределенных объектов 807
Протокол SOAP — это XML-ориентированный стандарт для обмена данными. Он
обеспечивает типовой способ создания RPC-запросов, включающих метаданные
о языке XML, представляет простые и сложные типы данных в XML (с помощью XML-
схем) и обрабатывает ошибки. Используя SOAP-ориентированный язык XML в
качестве формата обмена данными, приложения могут успешно общаться друг с другом.
Введение в SOAP
В этом разделе мы вводим терминологию, используемую в протоколе SOAP, не
вдаваясь в подробности синтаксиса. За деталями реализации SOAP-приложений
лучше всего обратиться к специальным источникам, посвященным теме SOAP. Кроме
того, существуют SOAP-оболочки и аппаратные устройства, которые оберегают
программистов от деталей синтаксиса SOAP, позволяя им пользоваться программным
или графическим интерфейсом. Поэтому, хотя вам, возможно, и придется
разбираться в SOAP-данных в процессе отладки, этот объем работы все же не сравнить с тем,
который вам пришлось бы "поднять" при написании кода "вручную".
Все данные в SOAP-сообщении содержатся в SOAP-конверте (Envelope). Этот конверт
делится на две части: SOAP Header (заголовок) и SOAP Body (тело). Нетрудно догадаться, что
SOAP-заголовок (Header) содержит метаинформацию о сообщении. Например, поскольку
XML представляет собой простой текстовый формат, он очень чувствителен к
злонамеренным изменениям при передаче по сети. Этот заголовок может содержать цифровые
подписи, которые используются для подтверждения целостности SOAP-сообщения.
Содержимое SOAP-тела сильно зависит от используемого в сообщениях стиля. Доку-
ментальпый стиль (Document) SOAP-сообщений предназначен для обеспечения
полезной XML-нагрузки в SOAP-теле (Body). Приложению, которое предполагает передачу
сериализированных XML-данных с одного компьютера на другой с использованием
стандарта SOAP, имеет смысл воспользоваться преимуществами Document-стиля SOAP.
Приведем пример использования Document-стиля в SOAP-сообщении.
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/
"b enve 1 ope / " >
<soap:Body>
<dialogue>
<sentence speaker="Marni">
Давай пойдем за мороженым.
</sentence>
<sentence speaker="Scott">
После того, как я напишу эту книгу по C++.
</sentence>
</dialogue>
</soap:Body>
</soap:Envelope>
RPC-стгиь представляет собой более структурированный тип SOAP-сообщений,
который используется для создания запросов к удаленным компьютерам и получения
ответов. В запросе, написанном в RPC-стиле, SOAP-тело (Body) содержит описание
запроса (с параметрами), создаваемого на удаленном компьютере. Приведем пример
простого RPC-запроса на метод, который выполняет сложение двух чисел.
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/
"^envelope/ ">
<soap:Body>
<myNS:AddNumbers xmlns:myNS="mynamespace">
•cmyNS: argl >7 < /myNS: argl >
808 Часть V. Использование библиотек и шаблонов
<myNS:arg2 >4 </myNS:arg2 >
</myNS:AddNumbers >
</soap:Body>
</soap:Enve1ope>
SOAP-тело (Body) ответа на запрос, написанный в RPC-стиле, содержит XML-
элемент, включающий результаты RPC-вызова.
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/
1> enve 1 ope / " >
<soap:Body>
<myNS:AddNumbersResponse xmlns:myNS="mynamespace">
<myNS:result>ll</myNS:result>
</myNS:AddNumbersResponse>
</soap:Body>
</soap:Envelope>
Несмотря на то что этот синтаксис кажется несколько загадочным, мы надеемся,
что вы поняли, что содержится в SOAP-сообщении. Кроме того, вы, вероятно,
начинаете догадываться о пользе SOAP применительно к распределенным
приложениям — ведь с помощью простого языка XML можно выдавать запросы и получать на
них ответы от любого приложения, которое "разговаривает" на языке SOAP. При
этом не требуется никаких трудоемких, дорогих или платформозависимых
технологий. Необходимо упомянуть о преимуществе SOAP перед не SOAP-сериализованным
XML-обменом. Если бы два программиста написали приложения, которые
взаимодействуют на основе сериализованного языка XML, они должны были бы договориться
об именах атрибутов и элементов. Если бы они использовали SOAP, им нужно было
бы сосредоточиться лишь на специфике своих приложений.
SOAP быстро завоевывает популярность как механизм обмена данными в сфере
бизнеса и Web-служб. Многие из существующих SOAP-оболочек написаны для языка
Java (исключение составляет среда .NET компании Microsoft, которая планирует
использовать язык программирования С#). Однако, что самое ценное, в отношении
SOAP никто не говорит о привязке к какой-либо платформе. И в самом деле, SOAP
может оказаться идеальным средством демонстрации ваших С++-приложений
широкой публике, которая использует другие языки программирования.
Резюме
В этой главе вы получили представление о том, как можно писать новые типы
приложений на основе сетевых технологий. Вы узнали о механизмах для
распределенных коммуникаций — сериализации и удаленных вызовах процедур. Кроме того,
вы познакомились с несколькими способами реализации этих технологий, включая
пользовательскую сериализацию, CORBA, XML и SOAP.
Распределенные вычисления действительно открывают новые возможности для
ваших приложений. Теперь, когда вы знакомы с идеями и различными технологиями,
можно смело переходить к другим видам распределенных "программ-приманок".
Объединим
возможности
технологий и оболочек
Одна из основных тем этой книги — принятие многократно используемых
технологий и моделей. Как программист, вы время от времени будете сталкиваться с
похожими проблемами. Используя арсенал разнообразных подходов, вы сможете
сэкономить время, применяя к данной проблеме соответствующий метод решения.
Эта глава посвящена методологии проектирования — идиомам C++, которые не
всегда являются встроенными элементами языка программирования, но при этом
используются довольно часто. Первая часть этой главы охватывает языковые средства C++,
которые при всей своей популярности имеют "трудный" синтаксис, почему-то все время
"вылетающий из головы". Большая часть этого материала носит обзорный характер
(с элементами повторения пройденного), но при необходимости может послужить
полезным справочным средством. Итак, здесь рассмотрены следующие темы.
Q С чего начинается создание класса.
Q Расширение "классового сознания" путем выведения производных классов.
□ Генерирование и перехват исключений.
Q Считывание данных из файла.
810 Часть V. Использование библиотек и шаблонов
Q Запись данных в файл.
Q Определение шаблонного класса.
Во второй части этой главы рассматриваются методы высокого уровня,
построенные на языковых средствах C++. Эти методы предлагают более эффективный способ
выполнения повседневных задач программирования. Здесь будут затронуты такие темы.
Q Применение интеллектуальных указателей, выполняющих подсчет ссылок.
□ Метод двойной диспетчеризации (double-dispatch technique).
Q Использование смешанных классов.
Предыдущие главы не содержали подробных примеров по перечисленным выше
темам. Здесь мы рассмотрим конкретные примеры, код которых вы можете
использовать в своих программах.
Завершает эту главу рассмотрение оболочек, которые существенно облегчают
процесс разработки больших приложений.
"Я все время забываю, как ..."
В главе 1 мы сравнивали объем стандарта языка С с объемом С++-стандарта. Для С-
программиста вполне реально "держать в голове" полное описание синтаксиса языка
С, поскольку ключевых слов в нем не так уж много, а количество языковых средств
ограничено вполне усвояемым минимумом. С языком C++ дело обстоит иначе. Далее
авторам этой книги, которые, казалось бы, на этом собаку съели, приходится время от
времени заглядывать в справочные материалы. Поэтому мы решили привести
примеры некоторых способов кодирования, которые полезно применять практически
в любых С++-программах. Если вы помните принцип, но подзабыли синтаксис, пере-
" листайте эти страницы.
... создать класс
Не помните, как приступить к написанию класса? Это не проблема — вот как
выглядит определение простого класса.
/**
* Simple.h
*
* Простой класс, который демонстрирует синтаксис
* определения класса.
*
*/
#ifndef _simple_h_
#define _simple_h_
class Simple {
public:
Simple(); // Конструктор
virtual -Simple(); // Деструктор
virtual void publicMethodO; // Открытый метод
int mPublicInteger; // Открытый член данных
Глава 25. Объединим возможности технологий и оболочек 811
protected:
int mProtectedlnteger; // Защищенный член данных
private:
int mPrivatelnteger; // Закрытый член данных
static const int mConstant = 2; // Закрытая константа
static int sStaticInt; // Закрытый статический член
// данных
// Запрещаем присваивание и передачу по значению.
Simple(const Simple&.src);
Simple& operator=(const Simple& rhs);
};
#endif
Теперь приведем реализацию этого класса, включающую инициализацию
статического члена данных.
/**
* Simple.cpp
*
* Реализация класса Simple.
*
*/
#include "Simple.h"
int Simple::sStaticInt = 0; // Инициализируем статический
// член данных.
Simple::Simple()
// Реализация конструктора.
Simple::-Simple()
// Реализация деструктора.
void Simple::publicMethod()
// Реализация открытого метода.
... вывести подкласс из существующего класса
Чтобы вывести подкласс, достаточно объявить новый класс, который является
открытым (public) расширением некоторого другого класса. Вот как может
выглядеть определение подкласса SubSimple.
/**
* SubSimple.h
*
* Подкласс класса Simple.
*
*/
#ifndef _subsimple_h_
#define _subsimple_h_
812 Часть V. Использование библиотек и шаблонов
#include "Simple.h"
class SubSimple : public Simple
{
public:
SubSimple(); // Конструктор
virtual ~SubSimple(); // Деструктор
virtual void publicMethodO; // Переопределенный метод
virtual void anotherMethod(); // Добавленный метод
};
#endif
Пример реализации этого подкласса.
/**
* SubSimple.cpp
*
* Реализация подкласса класса Simple
*
W
#include "SubSimple.h"
SubSimple::SubSimple() : Simple()
// Реализация конструктора
SubSimple::-SubSimple()
// Реализация деструктора
void SubSimple: :publicMethodO
// Реализация переопределенного метода
void SubSimple::anotherMethod()
// Реализация добавленного метода
... сгенерировать и перехватить исключения
Если вы работаете в команде, которая не использует исключения (стыдно!), или если
вы привыкли работать с исключениями в стиле Java, С++-синтаксис вам, конечно,
придется уточнять и не раз. Поэтому вам стоит иметь под рукой "памятку", с которой при
необходимости вы сможете быстро "свериться". В следующем примере используется
встроенный класс исключений std: :runtime_error. Но чаще всего (особенно
в больших программах) программисты пишут собственные классы исключений.
#include <stdexcept>
#include <iostream>
void throwlffbool inShouldThrow) throw (std::runtime error)
{
U
Глава 25. Объединим возможности технологий и оболочек 813
if (inShouldThrow) {
throw std::runtime_error("Это мое исключение.");
int main(int argc, char** argv)
{
try {
throwlf(false); // не генерирует исключение
throwlf(true); // генерирует!
} catch (const std::runtime_error& e) {
std::cerr << "Перехваченное исключение': " << e.what О
<< std::endl;
... считывать данные из файла
Более подробно файловый ввод данных описан в главе 14. Ниже приведен пример
программы, которая демонстрирует основы организации считывания данных из файла.
Эта программа считывает собственный исходный код и выводит его по одной лексеме.
/**
* readfile.cpp
*/
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
int main()
{
ifstream inFile("readfile.cpp");
if (inFile.fail()) {
cerr << "He удается открыть файл для чтения." << endl;
exit(1);
}
string nextToken;
while (inFile >> nextToken) {
cout << "Лексема: " << nextToken << endl;
}
inFile.close() ;
return 0;
}
... записать данные в файл
При выполнении следующей программы в файл выводится одно сообщение, затем
этот файл снова открывается и в его конец добавляется другое сообщение. Более
подробно файловый вывод данных описан в главе 14.
/**
* writefile.срр
814 Часть V. Использование библиотек и шаблонов
*/
#include <iostream>
#include <fstream>
using namespace std;
int main()
{
ofstream outFile("writefile.out");
if (outFile.fail()) {
cerr << "He удается открыть файл для записи." << endl;
exit(1);
}
outFile << "Привет!" << endl;
outFile.close() ;
ofstream appendFile("writefile.out" , ios_base::app);
if (appendFile.fail() ) {
cerr << "He удается открыть файл для записи." << endl;
exit(1);
}
appendFile << "Данные добавлены!" << endl;
appendFile.close();
}
... создать шаблонный класс
Шаблонный синтаксис — один из самых сложных для запоминания разделов языка
C++. Здесь важно не забывать, что коду, который использует шаблон, необходимо
обеспечить возможность "видения" реализации методов, а также определения шаблонного
класса. Как правило, эта цель достигается путем включения в заголовочный файл (с
помощью директивы #include) исходного файла, чтобы клиенты могли просто включать
(с помощью той же директивы #include) заголовочный файл, как обычно они и
поступают. В следующей программе демонстрируется шаблонный класс, который берет "под
свое покровительство" (т.е. заключает в оболочку) любой объект и добавляет к нему
семантику методов доступа get () и set ().
/**
* SimpleTemplate.h
*/
template <typename T>
class SimpleTemplate
{
public:
SimpleTemplate(T& inObject);
const T& get();
void set(T& inObject);
protected:
T& mObject;
};
Глава 25. Объединим возможности технологий и оболочек 815
#include "SimpleTemplate.cpp" // Подключаем реализацию!
/**
* SimpleTemplate. срр
*/
template<typename T>
SimpleTemplate<T>::SimpleTemplate(Т& inObject) :
mObj ect(inObj ect)
{
}
template<typename T>
const T& SimpleTemplate<T>::get()
{
return mObject;
}
template<typename T>
void SimpleTemplate<T>::set(T& inObject)
{
mObject = inObject;
}
/**
* TemplateTest.cpp
*/
#include <iostream>
#include <string>
#include "SimpleTemplate.h"
using namespace std;
int main(int argc, char** argv)
{
// Пытаемся заключить в оболочку целочисленное значение.
int i = 7;
SimpleTemplate<int> intWrapper(i);
i = 2;
cout << "Значение в оболочке равно "
<< intWrapper.get() << endl,-
// Пытаемся заключить в оболочку строку,
string str = "test";
SimpleTemplate<string> stringWrapper(str);
str += "!";
cout << "Значение в оболочке равно "
<< stringWrapper.get() << endl;
}
Должно быть, есть способ получше
В то время как вы читаете эти слова, тысячи С++-программистов по всему миру
ломают головы над решением проблем, которые уже давно решены. Кто-нибудь,
предположим, в Сан-Хосе пишет "с нуля" реализацию интеллектуального указателя,
выполняющего подсчет ссылок. А юный программист на одном из средиземноморских
816 Часть V. Использование библиотек и шаблонов
островов проектирует иерархию классов, которая могла бы чрезвычайно выиграть от
использования смешанных классов.
Профессиональному С++-программисту нужно тратить меньше времени на
изобретение колеса, а больше— на адаптацию принципов многократного
использования. Следующие методы можно охарактеризовать как методы общего назначения,
которые вы можете напрямую применять к своим программам или корректировать
в соответствии с конкретными требованиями.
Интеллектуальные указатели, выполняющие подсчет ссылок
В главах 4 и 13 мы ввели понятие интеллектуального указателя (smart pointer),
т.е. средства, позволяющего представить в виде безопасной стековой переменной
процесс динамического выделения памяти. В главе 16 показана реализация
интеллектуального указателя с использованием шаблонного класса. Следующий метод
позволяет усовершенствовать вариант интеллектуального указателя, представленный в
главе 16, путем подсчета ссылок.
Необходимость подсчета ссылок
Обобщая понятие, подсчет ссылок — это способ отслеживания количества
используемых экземпляров класса или конкретного объекта. Интеллектуальный указатель
с возможностью подсчета ссылок позволяет отследить, сколько "простых"
интеллектуальных указателей было создано для ссылки на один реальный указатель. При
использовании такого подхода интеллектуальные указатели могут избежать двойного удаления.
Проблема двойного удаления может легко возникнуть при использовании
интеллектуальных указателей, не подсчитывающих ссылки. Рассмотрим класс Nothing, который
просто выводит сообщения при создании или разрушении объекта.
class Nothing
{
public:
Nothing() { cout << "Nothing::Nothing О" << endl; }
-Nothing() { cout << "Nothing::-Nothing()" << endl; }
};
Если бы вы создали два стандартных С++-указателя типа auto_jptr и заставили
обоих ссылаться на один и тот же объект типа Nothing, оба они при выходе из
области видимости попытались удалить этот объект.
void doubleDelete()
{
Nothing* myNothing = new Nothing();
auto_ptr<Nothing*> autoPtrl(myNothing);
auto_ptr<Nothing*> autoPtr2(myNothing);
Результат выполнения предыдущей функции выглядел бы так.
Nothing::Nothing()
Nothing::-Nothing()
Nothing::-Nothing()
Ну вот, приехали! Одно обращение к конструктору и два— к деструктору ? И это из
класса, который должен сделать указатели безопасными?
Глава 25. Объединим возможности технологий и оболочек 817
Если задействовать интеллектуальные указатели лишь для простых случаев,
например для выделения памяти, которая используется только в рамках одной функции,
мы не имели бы проблемы. Но если ваша программа сохраняет несколько
интеллектуальных указателей в некоторой структуре данных или копирует интеллектуальные
указатели, присваивает их или передает функциям в качестве аргументов, добавление
еще одного уровня безопасности будет весьма существенным.
Интеллектуальный указатель, выполняющий подсчет ссылок,
безопаснее встроенного (типа auto_ptr), поскольку он отслеживает
количество ссылок на "обычный" указатель и освобождает память
только в случае, если последний больше не используется.
Класс SuperSmartPointer
Идея интеллектуального указателя, выполняющего подсчет ссылок, выраженная
в виде класса SuperSmartPointer, состоит в ведении статического отображения
(тар). Каждый ключ в этом отображении представляет собой адрес традиционного
указателя, на который ссылается один или несколько указателей типа
SuperSmartPointer. Значение, соответствующее ключу, определяет количество указателей типа
SuperSmartPointer, которые ссылаются на объект.
Представленная ниже реализация класса SuperSmartPointer основана на коде
интеллектуального указателя, приведенном в главе 16. Прежде чем читать дальше,
вам, возможно, стоило бы просмотреть этот код еще раз. Основные изменения
касаются установки нового указателя (через единственный аргумент конструктора,
конструктора копии или метода operator=) и завершения работы с базовым указателем
(в деструкторе или методе operator=).
При инициализации нового указателя метод initPointer () проверяет
статический map-контейнер на предмет того, не содержится ли уже заданный указатель в
существующем SuperSmartPointer-объекте. Если нет, счетчик ссылок инициализируется
значением 1. Если таковой уже хранится в отображении, счетчик инкрементируется.
Если указатель подвергается повторному присваиванию или включающий его Super-
SmartPointer-объект разрушается, вызывается метод f inalizePointer (). Этот
метод выводит сообщение об ошибке, если окажется, что заданного указателя нет
в отображении. Если же указатель был найден, его счетчик декрементируется. Если
при декрементировании счетчик сбрасывается в нуль, базовый указатель можно
безопасно освободить. В этом случае пара "ключ-значение" явно удаляется из
отображения, не допуская тем самым увеличения размера этого map-контейнера.
#include <map>
#include <iostream>
template <typename T>
class SuperSmartPointer
{
public-
explicit SuperSmartPointer(T* inPtr);
-SuperSmartPointer() ;
SuperSmartPointer (const SuperSmartPointer<T>& src) ,-
SuperSmartPointer<T>& operator=(
I const SuperSmartPointer<T>& rhs);
818 Часть V. Использование библиотек и шаблонов
const T& operator*() const;
const T* operator->() const;
Т& operator*();
Т* operator->();
operator void*() const { return mPtr; }
protected:
T* mPtr;
static std::map<T*, int> sRefCountMap;
void finalizePointer() ,-
void initPointer (T* inPtr) ,-
};
template <typename T>
std::map<T*, int>SuperSmartPointer<T>::sRefCountMap;
template <typename T>
SuperSmartPointer<T>::SuperSmartPointer(T* inPtr)
{
initPointer (inPtr) ,-
}
template <typename T>
SuperSmartPointer<T>::SuperSmartPointer(
const SuperSmartPointer<T>& src)
{
initPointer(src.mPtr);
}
template <typename T>
SuperSmartPointer<T>&
SuperSmartPointer<T>::operator=(
const SuperSmartPointer<T>& rhs)
{
if (this == &rhs) {
return (*this);
}
finalizePointer();
initPointer(rhs.mPtr);
return (*this);
}
template <typename T>
SuperSmartPointer<T>::-SuperSmartPointer()
{
finalizePointer();
}
template<typename T>
void SuperSmartPointer<T>::initPointer(T* inPtr)
{
mPtr = inPtr;
if (sRefCountMap.find(mPtr) == sRefCountMap.end()) {
sRefCountMap[mPtr] = 1;
} else {
sRefCountMap[mPtr]++;
}
}
tempiate<typename T>
void SuperSmartPointer<T>::finalizePointer()
{
Глава 25. Объединим возможности технологий и оболочек 819
if (sRefCountMap.find(mPtr) == sRefCountMap.end()) {
std::cerr << "ОШИБКА: отсутствующий элемент в map!"
<< Std::endl;
return;
}
sRefCountMap[mPtr]--;
if (sRefCountMap[mPtr] == 0) {
// На этот объект ссылок больше нет - удаляем его и
// удаляем соответствующий элемент из отображения.
sRefCountMap.erase(mPtr);
delete mPtr;
template <typename T>
const T* SuperSmartPointer<T>::operator->() const
{
return (mPtr);
}
template <typename T>
const T& SuperSmartPointer<T>::operator*() const
{
return (*mPtr);
}
template <typename T>
T* SuperSmartPointer<T>::operator->()
{
return (mPtr);
}
template <typename T>
T& SuperSmartPointer<T>::operator*()
{
return (*mPtr);
}
Поэлементное тестирование класса SuperSmartPointer
Определенный выше класс Nothing можно использовать для простого
поэлементного тестирования класса SuperSmartPointer. В него нужно внести одно изменение,
позволяющее узнать, пройден тест или нет. Для этого добавим в класс Nothing два
статических члена, которые будут отслеживать количество случаев, когда память
выделяется и освобождается. Конструкторы и деструкторы модифицируют эти значения, не
выводя никаких сообщений. Если класс SuperSmartPointer функционирует как
задумано, то по окончании программы эти числа всегда должны быть эквивалентными.
class Nothing
{
public:
Nothing() { sNumAllocations++; }
-Nothing() { sNumDeletions++; }
static int sNumAllocations;
static int sNumDeletions;
};
int Nothing::sNumAllocations = 0;
int Nothing::sNumDeletions = 0;
820 Часть V. Использование библиотек и шаблонов
Ниже приведен код реального теста. Обратите внимание на использование
дополнительной пары фигурных скобок, которая позволяет объектам класса SuperSmart -
Pointer оставаться в собственной области видимости, в результате чего они будут
как создаваться, так и разрушаться в пределах этой функции.
void testSuperSmartPointer()
{
Nothing* myNothing = new Nothing();
{
SuperSmartPointer<Nothing> ptrl(myNothing);
SuperSmartPointer<Nothing> ptr2(myNothing);
} *
if (Nothing::sNumAllocations ! = Nothing::sNumDeletions) {
std::cout << "ТЕСТ НЕ ПРОЙДЕН: память была выделена "
<< Nothing::sNumAllocations
<< " раз, а освобождена "
<< Nothing::sNumDeletions << " раз."
<< std::endl;
} else {
Std::COUt « "ТЕСТ ПРОЙДЕН" << Std::endl;
}
}
При успешном выполнении этой тестовой программы получим такой результат.
ТЕСТ ПРОЙДЕН
Следует также написать дополнительные тесты для класса SuperSmartPointer.
Например, имеет смысл протестировать работу конструктора копии и метода operator=.
Попробуем-ка усовершенствовать эту реализацию
Статическое отображение счетчиков ссылок обеспечивает объект класса
SuperSmartPointer дополнительным уровнем безопасности с помощью встроенных в C++
интеллектуальных указателей. Однако новая реализация не свободна от проблем.
Вспомните, что шаблоны основаны на параметризации типов. Другими словами,
если у вас есть объекты типа SuperSmartPointer, которые хранят указатели на
целочисленные значения, и SuperSmartPointer-объекты, которые хранят указатели
на символы, то реально во время компиляции будут сгенерированы два класса:
SuperSmart Point er<int>. и SuperSmartPointer<char>. Поскольку отображение
счетчиков ссылок хранится в этом классе статически, будут сгенерированы два
отображения. Как правило, это не вызывает проблем, но если вы выполните операцию
приведения типа char* к типу int*, то в результате получите, что два SuperSmart-
Pointer-объекта двух шаблонных классов ссылаются на одну и ту же переменную.
Поскольку табличные данные разделены, то, как продемонстрировано следующим
кодом, может произойти двойное удаление.
char* ch = new char;
SuperSmartPointer<char> ptrl(ch);
SuperSmartPointer<int> ptr2((int*)ch); // ОШИБКА! Произойдет
// двойное удаление!
Одно решение этой проблемы — сделать отображение счетчиков глобальной
переменной, хотя использование глобальных переменных часто подвергается осуждению.
Глава 25. Объединим возможности технологий и оболочек 821
Другое решение— заключить отображение в нешаблонный класс, скажем, с именем
MapManager, адресуемый шаблонными классами SuperSmartPointer.
С этой реализацией сопряжена еще одна проблема, которая заключается в
отсутствии потоковой безопасности. Как упоминалось выше, потоки не являются частью
языка C++. Однако потоки— настолько распространенное средство современного
программирования, что вы должны знать об этом недостатке. Доступ к статическому
отображению следует защитить какой-нибудь блокировкой, чтобы параллельные
добавления и удаления объектов не вступали в конфликт друг с другом.
При использовании класса SuperSmartPointer в коммерческой программе
необходимо проверить, подходит ли приведенный выше код вашему приложению, или все
же вам стоит дополнить его средствами обеспечения потоковой безопасности и
глобальным отображением.
Метод двойной диспетчеризации
Метод двойной диспетчеризации (double dispatch) заключается во введении в
принцип полиморфизма еще одного измерения. Как описано в главе 3, полиморфизм
позволяет программе определять поведение на основе типов, устанавливаемых
динамически. Например, в классе Animal мог бы существовать метод move (). Поскольку все
животные двигаются, но по-разному, метод move () определяется для каждого
подкласса класса Animal. Это позволит во время выполнения программы для данного
животного вызывать соответствующий метод, не зная вид животного (тип данных)
заранее (т.е. во время компиляции). Как использовать виртуальные методы для
реализации такого динамического полиморфизма, разъяснялось в главе 10.
Но иногда необходимо иметь метод, поведение которого определялось бы в
соответствии с динамическим типом не одного, а сразу двух объектов. Например,
предположим, что вы хотите добавить метод в класс Animal, который возвращает значение
true, если одно животное ест другого, и значение false в противном случае. Решение
базируется на двух факторах — типе животного, которое ест, и типе животного,
которого едят. К сожалению, в C++ не предусмотрен механизм выбора поведенческих
характеристик на основе динамического типа нескольких объектов. Для моделирования
этого сценария одних виртуальных методов недостаточно, поскольку они определяют
поведение, зависящее от динамического типа только одного объекта.
Некоторые объектно-ориентированные языки обеспечивают возможность выбора
метода во время выполнения программы на основе динамических типов двух или более
объектов. Это средство называется мультиметодом (multi-method). Но в C++ для
поддержки мультиметода встроенного языкового средства нет. К счастью, сделать функции
виртуальными для нескольких объектов позволяет метод двойной диспетчеризации.
Метод двойной диспетчеризации— это в действительности
специальный случай множественной диспетчеризации, когда поведение
выбирается в зависимости от динамических типов двух или более
объектов. На практике обычно достаточно двойной
диспетчеризации, т.е. когда решение о выборе поведения принимается на основе
динамических типов двух объектов.
822 Часть V. Использование библиотек и шаблонов
Вариант № 1: метод решения "в лоб"
Сначала рассмотрим самый простой способ реализации метода, поведенчески
зависящего от динамических типов двух различных объектов. Для этого с позиции
одного из объектов и с помощью конструкций if /else определим тип другого.
Например, попробуем реализовать метод eats () для каждого объекта Animal-подкласса,
который принимает "другое животное" в качестве аргумента. В базовом классе Animal
этот метод объявим чисто виртуальным.
class Animal
{
public:
virtual bool eats(const Animalb inPrey) const = 0;
};
Каждый подкласс должен реализовать метод eats(), возвращающий
соответствующее значение на основе типа аргумента. Вот как выглядит реализация метода
eats () для нескольких подклассов (обратите внимание на то, что в подклассе Dinosaur
нет никаких конструкций if /else, поскольку, как кажется авторам этой книги,
динозавры едят все что угодно).
bool Bear::eats(const Animal& inPrey) const
{
if (typeid(inPrey) == typeid(Bear&)) {
return false;
} else if (typeid(inPrey) == typeid(Fish&)) {
return true;
} else if (typeid(inPrey) == typeid(Dinosaurs)) {
return false;
}
return false;
}
bool Fish::eats(const Animals inPrey) const
{
if (typeid(inPrey) == typeid(Bears)) {
return false;
} else if (typeid(inPrey) == typeid(Fish&)) {
return true;
} else if (typeid(inPrey) == typeid(Dinosaurs)) {
return false;
}
return false;
}
bool Dinosaur::eats(const Animals inPrey) const
{
return true;
}
Метод решения "в лоб" вполне работоспособен, и, возможно, это самый простой
выход для небольшого количества классов. Однако существует ряд причин, по которым
следует поискать другое решение.
□ Сторонники объектно-ориентированного программирования часто возражают
против явного выяснения типа объекта, поскольку это подразумевает применение
такого способа проектирования, который не полностью соответствует
принципам ООП.
Глава 25. Объединим возможности технологий и оболочек 823
□ Поскольку все типы определяются в рамках одного метода, подклассу придется
переопределить все случаи или не переопределять ни одного. Например, если
бы вам нужно было реализовать класс CannibalisticBear (и выразить тот
факт, что медведи-каннибалы едят других медведей, выражаемых объектами
класса Bear), вам пришлось бы переделать реализацию "питания" для всех
существующих типов Bear в этом подклассе.
□ По мере увеличения количества типов такой код неудержимо разрастался бы
и содержал множество повторений.
□ При таком подходе подклассы не считаются новыми типами. Например, если
добавить подкласс Donkey (Осел), класс Bear будет по-прежнему компилироваться,
но его метод eats () возвратит значение false, если медведю предложат
попробовать ослятины, т.е. методу eats () передадут ссылку на объект типа Donkey,
хотя всем известно, что медведи с превеликим удовольствием едят ослов.
Вариант № 2: однократный полиморфизм с перегрузкой
Можно было бы попытаться использовать полиморфизм с перегрузкой, чтобы
обойти рассмотрение всех каскадно располагаемых конструкций if/else. Вместо
того чтобы каждый класс содержал один метод eats (), принимающий ссылку на объект
типа Animal, почему бы нам не перегрузить этот метод для каждого Animal-
подкласса? Определение базового класса в этом случае могло бы выглядеть так.
class Animal
{
public:
virtual bool eats(const Bears inPrey) const = 0;
virtual bool eats(const Fish& inPrey) const = 0;
virtual bool eats(const Dinosaurs inPrey) const = 0;
};
Поскольку эти методы определены в суперклассе Animal как чисто виртуальные,
каждый подкласс теперь вынужден реализовать поведение для всех остальных
производных от класса Animal типов. Например, класс Bear может содержать следующие методы.
class Bear : public Animal
{
public:
virtual bool eats(const Bear& inBear) const {
return false; }
virtual bool eats(const Fish& inFish) const {
return true; }
virtual bool eats(const Dinosaurs inDinosaur) const {
return false; }
};
На первый взгляд кажется, что этот способ будет работать, но в действительности он
решает только половину проблемы. Чтобы вызвать соответствующий метод eats () для
некоторого Animal-объекта, компилятору необходимо знать тип "поедаемого
животного" уже во время компиляции. Вызов, подобный следующему, завершится успешно,
поскольку типы хищника и его жертвы действительно известны во время компиляции.
Bear myBear;
Fish myFish;
cout << myBear.eats(myFish) << endl;
824 Часть V. Использование библиотек и шаблонов
Слабость этого решения состоит в том, что оно полиморфично только в одном
направлении. Можно было бы получить доступ к объекту myBear в контексте класса
Animal, и это позволило бы вызвать нужный метод.
Bear myBear;
Fish myFish;
Animal& animalRef* = myBear;
cout << animalRef.eats(myFish) << endl?
Обратное утверждение ошибочно. Если бы вы получили доступ к объекту myFish
в контексте класса Animal и передали созданную ссылку методу eats {), то получили бы
сообщение об ошибке компиляции, поскольку метода eats {), принимающего ссылку на
класс Animal, не существует. Во время компиляции невозможно определить, какую
версию метода нужно вызвать. Поэтому следующий пример не скомпилируется.
Bear myBear;
Fish myFish;
Animal& animalRef = myFish;
cout << myBear.eats(animalRef) << endl; // ОШИБКА! Нет такого
// метода: Bear::eats(Animal&).
Поскольку компилятор должен знать, какую именно перегруженную версию
метода eats () вызывают, это решение нельзя назвать по-настоящему полиморфным. Оно
не будет работать, например, если бы вы попытались обойти все элементы массива
ссылок на объекты класса Animal и передать каждую из них методу eats {).
Вариант № 3: двойной полиморфизм
Метод двойной диспетчеризации — это по-настоящему полиморфное решение
проблемы для нескольких типов. В C++ полиморфизм достигается путем переопределения
методов в подклассах. Во время выполнения программы методы вызываются на основе
реального типа объекта. Вариант применения однократного полиморфизма (который
мы рассматривали выше) оказался неработоспособным, поскольку мы пытались
использовать полиморфизм, чтобы определить, какую перегруженную версию метода вызвать,
вместо того, чтобы с его помощью определить, для какого класса следует вызвать метод.
Для начала возьмем один подкласс, скажем, Bear. Он должен содержать метод,
объявление которого может выглядеть так.
virtual bool eats(const Animal& inPrey) const;
Идея двойной диспетчеризации состоит в следующем. Необходимо определить
результат, основанный на вызове метода по переданному ему аргументу. Предположим,
что класс Animal содержит метод eatenBy {), который принимает в качестве
параметра ссылку на объект класса Animal. Этот метод должен возвращать значение true, если
текущий Animal-объект подходит "для обеда" Animal-объекту, переданному методу.
С учетом такого метода определение метода eats {) становится очень простым.
bool Bear::eats(const Animal& inPrey) const
{
return inPrey.eatenBy(*this);
}
Глава 25. Объединим возможности технологий и оболочек 825
На первый взгляд кажется, что в этом определении просто добавлен еще один
уровень обращений методов к методу однократного полиморфизма. Тем не менее
каждый подкласс должен по-прежнему реализовать версию метода eatenBy () для
каждого Animal-подкласса. Однако здесь есть ключевое отличие. В этом случае
полиморфизм используется дважды! При вызове метода eats {) для объекта класса Animal
механизм полиморфизма позволяет определить, какую именно версию вы вызываете:
Bear: :eats {), Fish: :eats () или какую-то другую. А при вызове метода eatenBy {)
опять включается механизм полиморфизма, и с его помощью определяется, версию
какого класса следует вызвать. Метод eatenBy () вызывается с использованием динамически
определяемого типа объекта inPrey. Обратите внимание на то, что динамический тип
объекта, заданного аргументом *this, всегда совпадает со статическим типом (т.е. типом,
определяемым во время компиляции), чтобы компилятор мог вызвать корректно
перегруженную версию метода eatenBy () для данного аргумента (в нашем случае Bear).
Ниже приведены определения классов иерархии Animal, в которых реализован
метод двойной диспетчеризации. Обратите внимание на необходимость включения
опережающих определений классов, поскольку в базовом классе используются ссылки
на подклассы.
// Опережающие ссылки
class Fish;
class Bear,-
class Dinosaur;
class Animal
{
public:
virtual bool eats(const Animal& inPrey) const = 0;
virtual bool eatenBy(const Bear& inBear) const = 0;
virtual bool eatenBy(const Fish& inFish) const = 0;
virtual bool eatenBy(const Dinosaur& inDinosaur)
const = 0;
};
class Bear : public Animal
{
public:
virtual bool eats(const Animal& inPrey) const;
virtual bool eatenBy(const Bear& inBear) const;
virtual bool eatenBy(const Fish& inFish) const;
virtual bool eatenBy(const Dinosaur& inDinosaur) const;
};
class Fish : public Animal
{
public:
virtual bool eats(const Animal& inPrey) const;
virtual bool eatenBy(const Bear& inBear) const;
virtual bool eatenBy(const Fish& inFish) const;
virtual bool eatenBy(const Dinosaur& inDinosaur) const;
};
class Dinosaur : public Animal
{
public:
virtual bool eats(const Animal& inPrey) const;
826 Часть V. Использование библиотек и шаблонов
virtual bool eatenBy(const Bear& inBear) const;
virtual bool eatenBy(const Fishb inFish) const;
virtual bool eatenBy(const Dinosaur& inDinosaur) const;
};
Теперь рассмотрим реализации этих классов. Обратите внимание на то, что
каждый Animal-подкласс реализует метод eats О одинаково, тем не менее его нельзя
внести в родительский класс. Если бы вы попытались сделать это, компилятор не знал
бы, какую перегруженную версию метода eatenBy () вызвать, поскольку аргумент
*this означал бы класс Animal, а не данный подкласс. Вспомните, что решение по
вызову перегруженного метода определяется в соответствии с типом объекта во
время компиляции, а не в соответствии с типом во время выполнения.
bool Bear::eats(const Animal& inPrey) const
return inPrey.eatenBy(*this);
bool Bear::eatenBy(const Bear& inBear) const
return false,-
bool Bear::eatenBy(const Fish& inFish) const
return false;
bool Bear::eatenBy(const Dinosaur& inDinosaur) const
return true;
bool Fish::eats(const Animal& inPrey) const
return inPrey.eatenBy(*this);
bool Fish::eatenBy(const Bear& inBear) const
return true,-
bool Fish::eatenBy(const Fish& inFish) const
return true;
bool Fish::eatenBy(const Dinosaur& inDinosaur) const
return true,-
bool Dinosaur::eats(const Animal& inPrey) const
return inPrey.eatenBy(*this);
bool Dinosaur::eatenBy(const Bear& inBear) const
Глава 25. Объединим возможности технологий и оболочек 827
return false;
bool Dinosaur::eatenBy(const Fish& inFish) const
return false;
bool Dinosaur::eatenBy(const Dinosaur& inDinosaur) const
return true;
Для осознания метода двойной диспетчеризации потребуется некоторое время.
Мы предлагаем вам поиграть этим кодом, чтобы чуть глубже вникнуть в суть этой идеи
и ее реализации.
Смешанные классы
В главах 3 и 10 представлен метод использования множественного наследования для
построения смешанных классов. Как вы помните, "смешанные корни" позволяют
расширить поведение класса в существующей иерархии. Часто смешанный класс можно
"узнать" по имени: например Clickable, Drawable, Printable или Lovable.
Разработка смешанного класса
Смешанные классы могут иметь различные формы. Поскольку смешанные классы
не являются формальным средством языка программирования, вы можете создавать
их "на свой вкус", не нарушая каких-либо правил. "Подмешивание" в иерархию
некоторого класса иногда просто означает поддержку определенного поведения.
Например, любой класс, который в качестве "родителя" использует, скажем,
гипотетический класс Drawable, должен реализовать метод draw {). При этом сам
"подмешиваемый" класс не содержит конкретно заданных функций: он лишь маркирует объект как
поддерживающий поведение метода draw {). Такой способ "употребления" подобен
Java-понятию интерфейса как списка предопределенных поведенческих
характеристик без реализации.
Смешанные классы могут включать реальный код. Например, класс Playable можно
"подмешивать" в аудиовизуальные объекты определенного типа. Смешиваемый класс мог
бы содержать код, необходимый для соединения драйверов. После "подмешивания"
аудиовизуальный объект получит это средство соединения "бесплатно".
При разработке смешанного класса необходимо рассмотреть, в каком
направлении вы собираетесь расширить существующее поведение, а также будет ли это
дополнение касаться отдельного класса или иерархии в целом. Возьмем в качестве примера
аудиовизуальные классы. Если все аудиовизуальные классы имеют свойство
воспроизводимости, то класс Playable должен быть базовым, а не "примесью", добавляемой
во все подклассы. Но если только некоторые аудиовизуальные классы являются
воспроизводимыми и при этом они "разбросаны" по всей иерархии, то имеет смысл
говорить о создании класса-примеси.
Возможны ситуации, когда создание смешанных классов может быть особенно
полезным. Предположим, у вас есть классы, организованные в иерархию по одной оси,
но при этом они обладают сходными атрибутами по другой оси. Например,
рассмотрим игру, имитирующую ведение военных действий на игральной доске
(координатной сетке). Каждая позиция может содержать элемент Item, обладающий средствами
828 Часть V. Использование библиотек и шаблонов
нападения и защиты, а также некоторыми другими характеристиками. Одни
элементы (например Castle) являются стационарными. Другие (например Knight или
FloatingCastle) могут перемещаться по игральной доске. Приступая к разработке
иерархии объектов, вам следует создать иерархию, подобную показанной на рис. 25.1,
организовав классы в соответствии с их средствами нападения и защиты.
Рис. 25.1
В схеме иерархии, показанной на рис. 25.1, не отражены функции перемещения,
присущие определенным классам. Иерархия, отражающая возможности
перемещения, может быть представлена так, как показано на рис. 25.2.
Рис. 25.2
Безусловно, в схеме иерархии, показанной на рис. 25.2, не учтена организация
иерархии, показанной на рис. 25.1. Что же в этом случае сделает классный объектно-
ориентированный программист?
Существует два основных варианта решения этой проблемы. Предположим, вы
выбираете первую иерархию, построенную на основе атакующих и оборонительных свойств
фигур (классов), и тогда вам нужно найти некоторый способ, позволяющий внести
в "уравнение" функции перемещения. Первый вариант — добавить метод move () в
базовый класс Item (он подойдет даже в том случае, если функцию перемещения поддер-
Глава 25. Объединим возможности технологий и оболочек 829
живают лишь некоторые подклассы). Реализация, действующая по умолчанию, не
должна обеспечивать какие бы то ни было действия вообще. Классы, обладающие
"способностью" к перемещению, должны переопределять метод move () так, чтобы
соответствующие объекты могли реально изменить свою позицию на игровой доске.
Второй вариант— написать класс-примесь Movable. Элегантность иерархии,
показанной на рис. 25.1, можно сохранить, но некоторые ее элементы следует сделать
таюке подклассами класса Movable, т.е. в их "свидетельстве о рождении" расширить
содержимое графы "Родители". Результат такой корректировки показан на рис. 25.3.
Defender
Castle
^""
FloatingCastle
~'~
Barrier
Item
Рис.
1
Attacker
--£>
Knight
SuperKnight
25.3
| Movable
Turret
Реализация класса-примеси
Создание класса-примеси не отличается от создания обычного. В действительности
оно даже намного проще. В игре "в войну" определение класса Movable может иметь
следующий вид.
class Movable
{
public:
virtual void move() = 0;
};
Класс-примесь Movable не содержит никаких реальных методов. Но он делает два
очень важных дела. Во-первых, он "обеспечивает типом" объекты класса Item, которые
способны перемещаться по игровой доске. Это позволяет вам создать, например,
массив всех перемещаемых фигур, не зная или просто не беспокоясь о том, к какому
именно подклассу класса Item они принадлежат. Кроме того, класс Movable также
"заявляет" о том, что все перемещаемые фигуры (Item-подклассы с "примесью" класса
Movable) должны реализовать метод move (). В этом случае вы сможете обойти все
объекты класса Movable и каждому из них "отдать приказ" сделать ход.
Использование класса-примеси
Код смешанного класса синтаксически эквивалентен реализации механизма
множественного наследования. В дополнение к "законному родителю", указанному в
основной иерархии, достаточно добавить класс-примесь.
830 Часть V. Использование библиотек и шаблонов
class FloatingCastle : public Castle, public Movable
{
public:
virtual void move () ,-
// Другие методы и члены данных здесь не показаны.
}
Вам осталось лишь предоставить реализацию метода move () для класса Floating-
Castle. После этого вы получите класс, который, хотя и занимает самую удаленную
от корня позицию на логическом дереве этой иерархии, но обладает общими
характеристиками вместе с некоторыми другими объектами той же иерархии.
Объектно-ориентированные оболочки
Когда в 1980-х впервые вышли на сцену графические операционные системы, в мире
вычислительной техники бал правило процедурное программирование. В то время
приложение с графическим интерфейсом пользователя (GUI), как правило, включало
функции управления сложными структурами данных и передачи их системным
функциям. Например, чтобы перетащить прямоугольник в окно, нужно было заполнить
структуру Window соответствующими данными и передать ее функции drawRect ().
По мере того как росла популярность объектно-ориентированного
программирования, программисты искали способ применить парадигму ООП к GUI-разработкам.
Результат их усилий получил название ООП-оболочки (Object-Oriented Framework).
В общем случае оболочка представляет собой набор классов, которые коллективно
используются с целью обеспечения объектно-ориентированного интерфейса с
некоторыми базовыми функциями. Говоря об оболочках, программисты обычно имеют в
виду крупные библиотеки классов, предназначенные для общей разработки приложений.
Однако в действительности оболочка может представлять функциональную среду
любого размера. Если, например, написать несколько классов, которые реализуют функции
базы данных для некоторого приложения, их также можно считать оболочкой.
Работа с оболочками
Под определением характеристик оболочки понимают тот набор методов и
конфигураций, которые она может предложить пользователю. Для того чтобы начать работу
с оболочками, обычно требуется некоторое время на их освоение, поскольку, как
правило, они имеют собственную внутреннюю модель. Прежде чем вы сможете работать
в среде такой крупной оболочки, как библиотека базовых классов Microsoft (Microsoft
Foundation Classes — MFC), вам необходимо понять ее (оболочки) "взгляд на мир".
Оболочки очень различаются по своим абстрактным идеям и реализациям. Одни
оболочки построены на базе традиционных процедурных интерфейсов (API),
которые определяют различные аспекты их структуры. Другие с самого начала
создавались с применением объектно-ориентированного подхода к проектированию.
Некоторые оболочки могут находиться в идеологическом противоречии с определенными
аспектами языка C++ (например, в оболочку BeOS заложено неприятие механизма
множественного наследования).
Приступая к работе с новой оболочкой, вам прежде всего нужно выяснить, что ею
движет. Какие основы проектирования заложены в ней? Какую внутреннюю модель
старались выразить ее разработчики? Какие аспекты языка использует эта оболочка?
Глава 25. Объединим возможности технологий и оболочек 831
Все это — жизненно важные вопросы, даже если они звучат как второстепенные
детали, которые можно выяснить по ходу дела. Если вы не поймете идею проекта,
модели или используемые в оболочке средства языка, то быстро попадете в ситуацию
нарушения границ оболочки. Например, если оболочка использует класс String, а вы
станете программировать строки в стиле языка С, то неминуемо намучаетесь с
преобразованиями, которых можно было бы легко избежать.
Понимание идеологических основ оболочки позволит вам ее расширить.
Например, если в оболочке не реализовано такое средство, как поддержка вывода
информации, вы могли бы написать собственные классы вывода на печать, используя ту же
модель, которая реализована в оболочке. Тем самым вы сохраните согласующуюся
модель для своего приложения и получите код, который можно будет еще не раз
использовать для других приложений.
Парадигма "модель-представление-контроллер"
Как упоминалось выше, оболочки различаются в подходах к
объектно-ориентированному проектированию. Одна популярная парадигма называется "модель-представление-
контрол.4ер" (model-view-controller— MVC). Эта парадигма моделирует точку зрения,
заключающуюся в том, что множество приложений в большинстве случаев
обрабатывают некоторый набор данных, одно или несколько представлений этих данных.
В парадигме MVC набор данных называется моделью. В имитаторе спортивного
автомобиля модель должна отслеживать различные статистические данные, например
текущую скорость автомобиля и сумму ущерба. На практике модель часто принимает
форму класса с множеством методов доступа. Определение класса для модели
спортивного автомобиля может выглядеть так.
class RaceCar
{
public:
RaceCar();
int getSpeedO ;
void setSpeed(int inValue);
int getDamageLevel();
void setDamageLevel(int inValue);
protected:
int mSpeed;
int mDamageLevel;
',};
Представление— это частный случай визуализации модели. Например, существует
два представления для класса RaceCar. Первое могло бы включать графическое
представление автомобиля, а второе — график, который показывает уровень разрушений
в зависимости от времени. Здесь важно то, что оба представления работают на основе
одних и тех же данных, т.е. мы имеем дело просто с различными способами
отображения одной и той же информации. В этом и состоит одно из основных преимуществ
парадигмы MVC: сохраняя данные отдельно от их отображения, можно.добиться
более эффективной организации кода и упростить создание дополнительных вариантов
представления данных.
Заключительную часть парадигмы MVC составляет контроллер. Контроллер— это
часть кода, которая изменяет модель в ответ на некоторое событие. Например, если
832 Часть V. Использование библиотек и шаблонов
оператор имитатора спортивного автомобиля наталкивается на определенную
преграду, контроллер должен уведомить модель об увеличении уровня возможного
ущерба для автомобиля и снизить его скорость.
Три компонента парадигмы MVC взаимодействуют друг с другом в контуре
обратной связи. Действиями управляет контроллер, который корректирует модель, что
выражается в изменении представления (представлений). Это взаимодействие
показано на рис. 25.4.
Представление
Действие пользователя
или событие
Контроллер
Обновление
изображения
Модель
Обновление
данных
Рис. 25.4
Парадигма "модель-представление-контроллер" нашла широкую поддержку во
многих популярных оболочках. Разработчики даже таких нетрадиционных проектов,
как Web-приложения, начали менять свой курс в направлении парадигмы MVC,
поскольку она обеспечивает четкое разделение между собственно данными, их
обработкой и отображением.
Резюме
В этой главе вы познакомились с некоторыми распространенными технологиями,
которые профессиональные программисты постоянно используют в своих проектах.
По мере вашего профессионального роста в качестве разработчика программного
обеспечения вы несомненно сформируете собственную коллекцию многократно
используемых классов и библиотек. Знание методик проектирования открывает двери
к разработке и использованию образцов (шаблонов) проектирования, которые
представляют собой повторно используемые конструкции более высокого уровня. О них
и пойдет речь в главе 26.
Применение
шаблонов
проектирования
Понятие шаблона проектирования основано на простой идее. Если вы можете
выделить в программе повторяющиеся объектно-ориентированные взаимодействия,
поиск элегантного решения становится делом простого выбора соответствующего
образца, который следует применить в том или ином случае. Вы уже знакомы с одним
таким образцом, или шаблоном, который весьма активно используется в библиотеке
STL, — речь идет об итераторе (Iterator). В этой главе детально рассматриваются
другие шаблоны проектирования, а также приводятся примеры их реализации.
Как упоминалось в главе 4, шаблоны проектирования — не совсем устоявшееся
понятие. Некоторые шаблоны имеют различные имена или являются предметом для
разного рода интерпретаций. Любой аспект проектирования способен вызвать
полемику среди программистов, и авторы этой книги не видят в этом ничего плохого. Не
стоит просто принимать эти шаблоны как данность или как единственный способ
выполнения задачи — заимствуйте их идеи, развивайте и создавайте новые шаблоны.
В этой главе рассматриваются следующие шаблоны проектирования:
□ одноэлементное множество;
□ фабрика объектов;
834 Часть V. Использование библиотек и шаблонов
□ посредник;
G адаптер;
G дизайнер;
G цепочка ответственности;
G наблюдатель/слушатель.
Одноэлементное множество
Одноэлементное множество (singleton) — это один из самых простых шаблонов
проектирования. В переводе с английского слово "singleton" означает "единственный
экземпляр" или "индивидуум". Похожее значение это слово имеет и в программировании.
Шаблон одноэлементного множества— это стратегия обеспечения существования
в программе ровно одного экземпляра класса. Применение singleton-шаблона к классу
гарантирует, что в программе может быть создан только один его объект. Этот шаблон
также определяет, что этот один объект глобально доступен из любого места
программы. Класс, отвечающий singleton-шаблону, обычно называется singleton-классом.
Шаблон одноэлементного множества особенно полезен при разработке программы
с использованием общего класса приложений (application class), который управляет
запуском, остановом и ходом выполнения приложения. В одной программе не имеет смысла
говорить о наличии двух объектов приложения. И в самом деле, было бы пагубно иметь
два объекта приложения, которые бы "думали", что они оба управляют логикой
приложения. Используя шаблон одноэлементного множества, можно гарантировать
существование ровно одного объекта приложения, доступного из любого места программы, j
Шаблон одноэлементного множества имеет смысл использовать в тех случаях, когда
в программе нужно создать ровно один объект некоторого класса. Если ваша программа
должна работать в предположении, что будет существовать только один экземпляр
класса, вам следует "закрепить" это предположение с помощью singleton-шаблона. г
Пример: механизм регистрации
Одноэлементные множества особенно полезны для вспомогательных классов.
Многие приложения включают понятие регистратора (logger) — класса, который
"отвечает" за запись информации о состоянии, данных отладки и ошибок. Идеальный
класс регистрации должен обладать следующими характеристиками:
G быть доступным в любое время;
G быть простым для применения;
G обеспечивать набор полезных свойств.
Шаблон одноэлементного множества прекрасно подходит для роли регистратора,
поскольку он принципиально должен быть представлен одним экземпляром
(несмотря на то, что его можно использовать в различных контекстах и для разных
целей). Кроме того, реализация класса регистратора в виде одноэлементного
множества упрощает его применение, поскольку вам не нужно беспокоиться о том, какой
регистратор является текущим в данный момент, или о том, как удержать текущий
регистратор. А все дело в том, что он только один!
Глава 26. Применение шаблонов проектирования 835
Реализация одноэлементного множества
Существует два основных способа реализации одноэлементного множества в C++.
Первый предполагает включение статических методов для формирования класса,
который не нуждается в реализации. Во втором используются уровни контроля доступа,
чтобы регламентировать создание одного экземпляра и доступ к нему.
Ниже рассматриваются оба метода с использованием в качестве примера простого
класса Logger. Этот класс обеспечивает следующие свойства:
О он может зарегистрировать одну строку или вектор строк;
□ каждое зарегистрированное сообщение имеет соответствующий уровень,
который указывается в начале зарегистрированного сообщения;
О каждое зарегистрированное сообщение выводится на диск, чтобы оно
попадало в файл без промедления.
Статический класс одноэлементного множества
Класс, в котором все методы являются статическими, в действительности не
является одноэлементным множеством: это, скорее, "безэлементное" множество. Термин
"одноэлементное множество" подразумевает, что должен существовать ровно один
экземпляр класса. Если же все методы класса являются статическими, и этот класс не
будет реализован вообще, можно ли тогда назвать его singleton-классом? Авторы
утверждают, что, поскольку назначение шаблонов проектирования— помогать в
построении ментальной модели объектно-ориентированных структур, при желании
и статический класс можно назвать одноэлементным множеством. Но при этом
необходимо понимать, что статическому классу как singleton-шаблону не присущ полиморфизм
и встроенный механизм конструкции и деструкции. Для такого класса, как Logger,
подобные потери, возможно, приемлемы.
' Ниже приводится открытый интерфейс статического класса Logger. Обратите
внимание на то, что для доступа к членам данных здесь используются только
статические методы, поэтому в реализации Logger-объекта нет необходимости. А для
обеспечения такого поведения конструктор объявлен закрытым (private).
/**
* Logger. h
*
* Определение singleton-класса регистратора, реализованного
* с использованием исключительно static-методов.
*/
#include <iostream>
#include <fstream>
#include <vector>
class Logger
{
public:
static const std
static const std
static const std
:string kLogLevelDebug;
:string kLogLevellnfo;
:string kLogLevelError;
// Регистрирует одно сообщение на данном уровне
// регистрации.
static void log(const std::string& inMessage,
const std::strings inLogLevel);
836 Часть V. Использование библиотек и шаблонов
// Регистрирует вектор сообщений на данном уровне
// регистрации.
static void log(
const std::vector<std::string>& inMessages,
const std::strings inLogLevel);
// Закрывает файл регистрации,
static void teardownO;
protected:
static void init();
static const char* const kLogFileName,-
static bool sinitialized;
static std::ofstream sOutputStream;
private:
Logger() {}
};
Реализация класса Logger довольно проста. При каждом обращении к
регистратору проверяется статический член sinitialized, чтобы убедиться в том, что был
вызван метод init {) и файл регистрации был открыт. Если файл регистрации уже
открыт, каждое зарегистрированное сообщение записывается в него с указанием
соответствующего уровня регистрации.
/**
* Logger.срр
*
* Реализация singleton-класса регистратора.
*/ ' *
#include <string> 5
#include "Logger.h"
using namespace std;
const string Logger::kLogLevelDebug = "DEBUG";
const string Logger::kLogLevelInfо = "INFO";
const string Logger::kLogLevelError = "ERROR";
const char* const Logger::kLogFileName = "log.out";
bool Logger: :sinitialized = false,-
ofstream Logger::sOutputStream;
\
void Logger::log(const strings inMessage,
const strings inLogLevel)
{
if (!sinitialized) {
init();
}
// Выводим сообщение и сбрасываем на диск содержимое
// файловых буферов.
sOutputStream << inLogLevel << ": " << inMessage << endl;
}
void Logger::log(const vector<string>& inMessages,
const strings inLogLevel)
{
for (size_t i = 0; i < inMessages.size(); i++) {
log(inMessages[i], inLogLevel);
Глава 26. Применение шаблонов проектирования 837
}
}
void Logger::teardown()
{
if (slnitialized) {
sOutputStream.close();
slnitialized = false,-
void Logger::init()
{
if ("slnitialized) {
sOutputStream.open(kLogFileName, ios_base::app);
if (!sOutputStream.good()) {
cerr << "He удается инициализировать Logger!"
<< endl;
return;
}
slnitialized = true;
Одноэлементное множество на основе механизма управления доступом
Борцы за "чистоту" объектно-ориентированного программирования
(предупреждаем: среди нас их нет, но они могут работать в вашей компании!) вправе
оспорить использование статического класса в качестве решения проблемы
одноэлементного множества. Поскольку мы не можем реализовать объект класса Logger, то
нам не удастся построить иерархию регистраторов и воспользоваться механизмом
полиморфизма. Хотя такая иерархия редко применяется в отношении
одноэлементного множества, это все же существенный недостаток. Более важно здесь то, что в
результате использования только статических методов мы совершенно утратили
объектную ориентацию. Класс, созданный в предыдущем примере, по сути, является
коллекцией С-функций, а не связанным классом.
Для построения настоящего одноэлементного множества в C++ можно использовать
механизмы управления доступом, а также ключевое слово static. В этом случае во
время выполнения программы будет существовать реальный объект класса Logger, и этот
класс гарантирует его существование в единственном числе. Клиенты всегда смогут
получить доступ к этому объекту с помощью статического метода instance ().
Определение данного варианта класса Logger выглядит так.
/**
* Logger.h
*
* Определение настоящего singleton-класса регистратора.
*/
#include <iostream>
#include <fstream>
#include <vector>
class Logger
{
public:
static const std
static const std
static const std
:string kLogLevelDebug;
:string kLogLevellnfo;
:string kLogLevelError;
838 Часть V. Использование библиотек и шаблонов
// Возвращает ссылку на единственный Logger-объект,
static Logger& instance();
// Регистрирует одно сообщение на заданном уровне
// регистрации.
void log(const std::stringfc inMessage,
const std::strings inLogLevel);
// Регистрирует вектор сообщений на заданном уровне
// регистрации.
void log(const std::vector<std::string>& inMessages,
const std::string& inLogLevel);
protected:
// Статическая переменная для единственного объекта,
static Logger slnstance;
// Константа для имени файла.
static const char* const kLogFileName;
// Член данных для указания выходного потока.
std::ofstream mOutputStream;
private:
Logger();
-Logger();
};
Одно достоинство этого варианта уже очевидно. Поскольку будет существовать
реальный объект, методы init () и teardown (), задействованные в статическом
решении, можно убрать в расчете на действия конструктора и деструктора. Это — большая
победа, поскольку предыдущее решение требовало от клиента явного вызова метода
teardown (), чтобы закрыть файл. Теперь же благодаря тому, что регистратор
реализован в виде объекта, файл будет закрываться при разрушении этого объекта, что
и происходит при завершении программы. г
Ниже представлена реализация этого класса. Обратите внимание на то, что
реальные методы log () остались без изменений, за исключением того факта, что они
больше не являются статическими. Конструктор и деструктор вызываются
автоматически, поскольку этот класс содержит собственный экземпляр как статический член.
Поскольку они закрыты (объявлены в разделе private), никакой внешний код не
сможет создать или удалить объект класса Logger.
/**
* Logger.срр
*
* Implementation of a singleton logger class
*/
#include <string>
#include "Logger.h"
using namespace std;
const string Logger::kLogLevelDebug = "DEBUG";
const string Logger::kLogLevelInfo = "INFO";
const string Logger::kLogLevelError = "ERROR";
const char* const Logger::kLogFileName = "log.out";
// Статический экземпляр будет создан при запуске программы
Глава 26. Применение шаблонов проектирования 839
// и разрушен по ее завершении.
Logger Logger::sinstance;
Loggers Logger::instance()
{
return slnstance,-
}
Logger::-Logger()
{
mOutputStream.close();
}
Logger::Logger()
{
mOutputStream.open(kLogFileName, ios_base::app);
if (!mOutputStream.good()) {
cerr << "He удается инициализировать Logger-объект!"
< < endl;
}
}
void Logger::log(const strings inMessage,
const strings inLogLevel)
{
mOutputStream << inLogLevel << ": " << inMessage << endl;
}
void Logger::log(const vector<string>S inMessages,
const strings inLogLevel)
{
for (sizet i = 0; i < inMessages.size(); i++) {
log(inMessages[i], inLogLevel);
Использование одноэлементного множества
Следующие две программы отображают использование двух различных версий
класса Logger.
// TestStaticLogger.cpp
#include "Logger.h"
#include <vector>
#incluae <string>
int main(int argc, char** argv)
{
Logger::log("тестовое сообщение", Logger::kLogLevelDebug);
vector<string> items;
items . push__back ( " элемент1") ;
items. push_j3ack (" элемент2 ") ;
Logger::log(items, Logger::kLogLevelError);
Logger::teardown();
}
// TestTrueSingletonLogger.cpp
840 Часть V. Использование библиотек и шаблонов
#include "Logger.h"
#include <vector>
#include <string>
int main(int argc, char** argv)
{
Logger::instance().log("тестовое сообщение",
Logger::kLogLevelDebug);
vector<string> items;
items.push_back("элемент");
items.push_back("элемент2");
Logger::instance().log(items, Logger::kLogLevelError);
}
Обе программы функционируют одинаково. После их выполнения файл log. out
должен содержать следующие строки.
DEBUG: тестовое сообщение
ERROR: элемент1
ERROR: элемент2
Фабрика объектов
Фабрика в реальном мире предназначена для создания таких материальных
объектов, как столы или автомобили. По аналогии фабрика в объектно-ориентированном
программировании служит для построения объектов. Использование фабрик в про-,
граммах осуществляется так: та часть кода, которой нужно получить конкретный объект,
не сама вызывает конструктор объектов, а обращается к фабрике с запросом на
создание экземпляра заданного класса. Например, предположим, что программа
оформления интерьера имеет объект класса FurnitureFactory. Когда некоторой части кода
потребуется такой предмет мебели, как стол, она вызовет для FurnitureFactory-
объекта метод createTable (), который должен возвратить ей новый "стол".
На первый взгляд кажется, что фабрики лишь усложняют проект программы.
Может создаться впечатление, что этой фабрикой мы лишь добавляем в программу еще
один уровень сложности. Вместо вызова метода createTable () для FurnitureFactory-
объекта мы, казалось бы, могли напрямую создать новый объект типа Table. Однако
фабрики могут быть реально полезными. Вместо создания различных объектов по
всей "территории" программы мы сосредоточиваем процесс создания объектов для
конкретной сферы деятельности в одном месте. Такая локализация зачастую
представляет более удачную модель создания реальных объектов.
Еще одно достоинство фабрик состоит в том, что мы можем использовать их
совместно с иерархиями классов, даже не зная точно класс создаваемого объекта. Как
будет показано в следующем примере, фабрики могут работать параллельно с
иерархиями классов.
Пример: имитация автомобильного производства
Если в общем говорить о вождении автомобиля в реальном мире, это можно
делать, не ссылаясь на конкретную его марку. Конечно, вы могли бы аргументированно
сравнивать достоинства марок "Toyota" и "Ford". Но, рассуждая о принципах
вождения, марка не имеет значения, поскольку как "Тойоты", так и "Форды" управляются
Глава 26. Применение шаблонов проектирования 841
аналогичным образом. Теперь предположим, что вам нужен новый автомобиль. Тут
уж, казалось бы, надо указать конкретную марку, правильно? Не всегда. Вы могли бы
просто сказать: "Я хочу новый автомобиль", и в зависимости от того, где вы находитесь
в момент произнесения этих слов, вы получите автомобиль конкретной марки. Если вы
скажете, что вам нужен автомобиль, находясь на территории компании Toyota, то вы и
получите "Тойоту". (Конечно, если вы выразитесь слишком уж агрессивно, то вас могут
и задержать, но сейчас не об этом речь.) А если вы огласите свое желание приобрести
новый автомобиль на территории компании Ford, то вы получите "Форд".
Те же принципы применимы к С++-программированию. Первый принцип
обобщенного автомобиля, который "поддается" вождению, уже не содержит для вас ничего
нового: это стандартное проявление полиморфизма (вы познакомились с ним в главе 3).
Вы могли бы написать абстрактный класс Саг, в котором определили метод drive ().
А затем, как показано на рис. 26.1, из этого класса Саг могли бы вывести подклассы
Toyota и Ford.
Ваша программа могла бы управлять объектами класса Саг, не интересуясь их
маркой. Но согласно принципам стандартного объектно-ориентированного
программирования в той части программы, где происходит создание объекта класса Саг,
необходимо указать конкретный тип: Toyota или Ford, поскольку вам придется вызвать
конструктор того или иного класса. Здесь нельзя просто заявить: "Я хочу
автомобиль". Предположим, что у вас также есть параллельная иерархия классов,
определяющая структуру фабрик автомобилей. В суперклассе CarFactory можно было бы
определить виртуальный метод buildCar(). И тогда подклассы ToyotaFactory
и FordFactory должны были бы переопределить метод buildCar (), чтобы создать
объект типа Toyota или Ford. Иерархия классов CarFactory показана на рис. 26.2.
/
Car
/
Toyota
\
\
Ford
CarFactory
ToyotaFactory
FordFactory
Рис. 26.1
Рис. 26.2
Теперь предположим, что в программе существует один объект класса
CarFactory. Когда часть кода программы (назовем ее дилерской) захочет получить новый
автомобиль, она вызовет метод buildCar () для объекта CarFactory. В зависимости
от того, какая марка автомобиля интересует "дилера", т.е. какая фабрика
автомобилей будет им выбрана (ToyotaFactory или FordFactory), он получит объект либо
типа Toyota, либо типа Ford. На рис. 26.3 показаны объекты в программе
автомобильного дилера, использующего фабрику объектов ToyotaFactory.
На рис. 26.4 показана схема выполнения той же программы, но вместо "фабричного"
класса ToyotaFactory здесь используется класс FordFactory. Обратите внимание
на то, что объект класса CarDealer и его взаимоотношения с фабрикой объектов
остаются прежними.
Главное достоинст&о этого варианта состоит в том, что фабрики абстрагируют
процесс создания объектов: вы можете без труда заменить фабрику в своей
программе. Полиморфизм можно использовать в отношении фабрик так же, как при создании
объектов: когда вы обращаетесь к фабрике автомобилей, чтобы получить автомобиль,
842 Часть V. Использование библиотек и шаблонов
вы можете не знать, на какой из них вы делаете запрос: Toyota или Ford, но в любом
случае вы получите объект класса Саг, которым можно управлять известным вам
способом. Такой подход приводит к созданию программ, легко поддающихся
расширению: простое изменение экземпляра фабрики позволяет программе работать с
совершенно другим набором объектов и классов.
Запрашивает "автомобиль" ToyotaFactory
CarDealer
Рис. 26.3
Возвращает
"автомобиль"
Создает
Toyota-объект
Запрашивает "автомобиль" FordFactory
CarDealer
-ел* ^ЭД|§^
Возвращает
"автомобиль"
Рис. 26.4
Создает
Ford-объект
Реализация фабрики объектов
Итак, в каких случаях стоит использовать фабрики объектов? Например, если тип
объекта, который вы хотите создать, зависит от некоторых условий. Предположим,
вы — дилер, которому нужно в сию минуту заполучить автомобиль. В этом случае
имеет смысл послать свой заказ на фабрику, которая менее других загружена заказами,
ведь вам не важно, какой именно автомобиль вы получите: "Toyota" или "Ford". На
примере следующей реализации показано, как создать такие фабрики объектов в C++.
Прежде всего, вам необходимо иметь иерархию автомобилей. Чтобы не усложнять
этот пример, наш класс Саг будет содержать только абстрактный метод,
предназначенный для получения описания автомобиля. В следующем примере также
определяются оба Car-подкласса, содержащие встраиваемые методы, которые выводят
название марки автомобиля.
■/**
* Car.h
#include <iostream>
class Car
{
public-.
virtual void info() = 0;
};
Глава 26. Применение шаблонов проектирования 843
class Ford : public Car
{
public:
virtual void info() { std::cout << "Ford"
<< std::endl; }
};
class Toyota : public Car
{
public:
virtual void info() { std::cout << "Toyota"
<< Std::endl; }
};
Базовый класс CarFactory чуть интереснее. Каждая фабрика объектов
отслеживает количество автомобилей, находящихся в производстве. При вызове public-
метода requestCar () количество автомобилей, находящихся в производстве на
фабрике, увеличивается на единицу, а при вызове чисто виртуального метода createCar ()
заказчик получает новый "автомобиль". Идея состоит в том, что отдельные фабрики
должны переопределить метод createCar () таким образом, чтобы он возвращал
"автомобиль" соответствующего типа. Сама фабрика реализует метод requestCar (),
который "беспокоится" об обновлении количества автомобилей, находящихся в
производстве. Кроме того, класс CarFactory содержит public-метод getNumCarsIn-
Production (), который позволяет узнать количество автомобилей, производимых
на каждой фабрике.
Вот как выглядят определения класса CarFactory и его подклассов.
/**
* CarFactory.h
*/
// Для этого примера предполагается, что класс Саг уже
// существует.
#include "Car.h"
class CarFactory x
{
public:
CarFactory();
i
Car* requestCar();
«
int getNumCarsInProduction() const;
/
protected:
virtual Car* createCar() = 0;
private:
int mNumCarsInProduction;
};
class FordFactory : public CarFactory
{
protected:
virtual Car* createCar();
};
class ToyotaFactory : public CarFactory
{
844 Часть V. Использование библиотек и шаблонов
protected:
virtual Car* createCarO ;
};
Как видите, подклассы просто переопределяют метод createCar (), чтобы
возвращать автомобиль того типа, который "выпускается" на данной фабрике Ниже
приведена реализация CarFactory-иерархии.
/**
* CarFactory.срр
*/
#include "CarFactory.h"
// При создании фабрики инициализируем количество
// автомобилей нулевым значением.
CarFactory::CarFactory() : mNumCarsInProduction(0) {}
// Инкрементируем количество автомобилей, находящихся в
// производстве, и возвращаем новый автомобиль.
Car* CarFactory::requestCar()
mNumCarsInProduction++;
return createCar();
int CarFactory::getNumCarsInProduction() const
return mNumCarsInProduction;
Car* FordFactory::createCar()
return new Ford();
Car* ToyotaFactory::createCar()
return new Toyota () ,-
Тип реализации, используемый в этом примере, называется абстрактной фабрикой,
поскольку тип создаваемого объекта зависит от конкретного подкласса "фабричного"
класса. Подобный шаблон можно реализовать, используя один класс, а не целую
иерархию. В этом случае метод create () должен принимать тип или строковый
параметр, значение которого позволит понять, какой объект нужно создать. Например,
объект класса CarFactory должен содержать метод buildCar (), который
принимает в качестве параметра строковое представление типа автомобиля, а затем создает
соответствующий тип. Однако этот способ менее интересен и менее гибок, чем с
использованием рассмотренной выше иерархии фабрик.
Методы фабрики объектов представляют собой один из вариантов
реализации виртуальных конструкторов, т.е. методов, которые создают
объекты различных типов. Например, метод buildCar () создает
объекты как типа Toyota, так и типа Ford, в зависимости от конкретного
объекта фабрики, для которого он вызывается. .
Глава 26. Применение шаблонов проектирования 845
Использование фабрики объектов
Самый простой способ использования фабрики объектов — инициализировать ее
и вызвать соответствующий метод, как это сделано в следующем фрагменте кода.
ToyotaFactory myFactory;
Car* myCar = myFactory.requestCar();
Возьмем пример поинтереснее. В нем используется идея виртуального
конструктора: для создания объекта автомобиля выбирается фабрика, загруженная меньше
других, т.е. та, в производстве которой числится наименьшее (по сравнению с
остальными) количество автомобилей. Для этого нам потребуется функция, которая
оценивает несколько фабрик объектов и выбирает из них наименее занятую.
CarFactory* getLeastBusyFactory(
const vector<CarFactory*>& inFactories)
{
if (inFactories.size() == 0) return NULL;
i
CarFactory* bestSoFar = inFactories [0];
for (sizet i = 1; i < inFactories.size(); i++)
{
if (inFactories[i]->getNumCarsInProduction() <
bestSoFar->getNumCarsInProduction()) {
bestSoFar = inFactories [i] ;
}
}
}
return bestSoFar;
В следующем простом примере программы эта функция используется для
построения 10 объектов автомобилей разных марок.
int mainfint argc, char** argv)
{
vector<CarFactory*> factories;
// Создаем 3 Ford-фабрики и 1 Toyota-фабрику.
FordFactory* factoryl = new FordFactory()
FordFactory* factory2 = new FordFactory()
FordFactory* factory3 = new FordFactory()
ToyotaFactory* factory4 = new ToyotaFactory();
// Чтобы получить более интересные результаты,
// сделаем некоторые заказы.
factoryl->requestCar()
factoryl->requestear ()
factory2->requestCar()
factory4->requestCar()
// Помещаем фабрики объектов в вектор,
factories.push_back(factoryl)
factories.push_back(factory2)
factories.push_back(factory3)
factories.push_back(factory4)
// Создаем 10 автомобилей на наименее загруженной фабрике.
for (int i = 0; i < 10; i++) {
846 Часть V. Использование библиотек и шаблонов
CarFactory* currentFactory = getLeastBusyFactory(
factories);
Car* theCar = currentFactory->requestCar();
theCar->info();
}
}
При выполнении этой программы выводится марка каждого созданного
автомобиля.
Ford
Ford
Ford
Toyota
Ford
Ford
Ford
Toyota
Ford ,
Ford
Полученные результаты вполне предсказуемые, поскольку цикл позволяет
последовательно "обойти" все фабрики. Однако представьте себе сценарий, в котором за- .
просы на автомобили поступают сразу от нескольких дилеров, и тогда текущее
состояние каждой фабрики уже не будет таким предсказуемым.
Другие ситуации для использования фабрики объектов
Шаблон фабрики объектов можно использовать не только для моделирования
реальных фабрик. Например, рассмотрим текстовый процессор, предназначенный для
поддержки документов на различных языках, но с условием, чтобы в каждом документе
использовался только один язык. Существует множество аспектов, когда выбор языка
документа в текстовом процессоре требует различных средств поддержки: например,
набор знаков, используемый в документе (т.е. нужны или нет символы с ударением),
средство орфографического контроля, тезаурус и способ отображения документа.
Фабрики объектов можно использовать для проектирования ядра текстового процессора
путем создания абстрактного суперкласса LanguageFactory и конкретных фабрик для
каждого принятого к рассмотрению языка, например EnglishLanguageFactory
и FrenchLangugaeFactory. После того как пользователь укажет язык документа,
программа создаст соответствующую фабрику языка и подключит ее к документу. С этого
момента программе не нужно "знать", какой язык поддерживается в документе. Если же
ей потребуется информация, связанная с используемым языком, она сможет обратиться
к LanguageFactory-объекту. Например, при необходимости использовать средство
орфографического контроля ей достаточно вызвать метод createSpellchecker ()
для данной фабрики, который возвратит это средство для соответствующего языка.
Шаблон посредника
Шаблон посредника (proxy) — это один из шаблонов, которые отделяют абстракцию
класса от его базового представления. Объект посредника служит в качестве
заместителя реального объекта. К таким объектам обычно прибегают в тех случаях, когда
использование реального объекта отнимает много времени или попросту невозможно.
Вероятно, вы уже применяли proxy-шаблон, формально не осознавая его таковым.
Объекты-посредники очень удобны при поэлементном тестировании. Чтобы протес-
Глава 26. Применение шаблонов проектирования 847
тировать средство прогнозирования курса акций, вместо использования настоящих
значений вы могли бы написать proxy-класс, имитирующий поведение фондовой
биржи, с некоторыми фиксированными данными.
Пример: сокрытие проблем сетевого подключения
Рассмотрим сетевую игру на основе класса Player, который представляет Internet-
пользователя, присоединившегося к этой игре. Класс Player должен включать такие
средства взаимодействия с сетью, как возможность моментального обмена
сообщениями. Когда соединение игрока становится медленным или невосприимчивым,
объект класса Player с помощью некоторого события должен обозначить, что данный
игрок больше не получает сообщений.
Поскольку вы не хотите уведомлять пользователя о сетевых проблемах, то,
возможно, вам стоит "завести" отдельный класс (скажем, PlayerProxy), который будет
скрывать сетевую часть Player-объекта. Этот PlayerProxy-объект должен заменить
реальный Player-объект. Клиенты этого класса могут либо все время использовать
класс PlayerProxy в качестве "сторожевого пса" для реального класса Player, либо
система сама будет "включать в игру" PlayerProxy-объект, когда Player-объект станет
недееспособным. В момент сетевого отказа PlayerProxy-объект мог бы по-прежнему
отображать имя игрока (и последнее известное системе состояние) и продолжать
функционировать "как ни в чем ни бывало", в то время как исходный Player-объект будет
находиться в непригодном для работы состоянии. Таким образом, proxy-класс сможет
скрыть некоторую нежелательную семантику базового класса Player.
Реализация шаблона посредника
Итак, рассмотрим открытый интерфейс класса Player. Для надлежащего
функционирования метода sendlnstantMessage () необходимо обеспечить средства
взаимодействия с сетью.
class Player
{
public:
virtual string getName();
// Отправляет по сети сообщение игроку и возвращает
// ответ в виде строки. Для этого требуются средства
// взаимодействия с сетью.
Г virtual string sendlnstantMessage(
const strings inMessage) const;
};
Классы-посредники часто становятся предметом дискуссий по поводу того, какой
тип отношений здесь следует применять: "is-a" или "has-a". Ведь класс PlayerProxy
можно было бы реализовать как совершенно отдельный класс, включающий объект
типа Player. Такой проект был бы самым подходящим, если бы PlayerProxy-объект
всегда использовался программой в ситуации, когда ей необходимо обратиться
к Player-объекту. В качестве альтернативного варианта можно реализовать класс
PlayerProxy как подкласс, который переопределяет поведение, требующее
взаимодействия с сетью. В этом случае, если сетевое соединение прерывается, замена
Player-объекта его "заместителем" (PlayerProxy-объектом) существенно
упрощается. В следующем примере используется второй вариант замены Player-объекта.
848 Часть V. Использование библиотек и шаблонов
class PlayerProxy : public Player
{
public:
virtual string sendlnstantMessage(
const strings inMessage) const;
};
В реализации PlayerProxy-метода sendlnstantMessage () сетевая часть попросту
"отрезается" и обеспечивается возврат строки, означающей, что игрок отключен от сети.
string PlayerProxy::sendlnstantMessage(
const strings inMessage)
{
return "С игроком нет связи.";
}
Использование proxy-шаблона
Если proxy-шаблон написан корректно, то его использование не должно
отличаться от применения любого другого объекта. В примере с классом PlayerProxy код,
который использует объект посредника, мог бы совершенно "не подозревать" о его
существовании. Следующая функция, предназначенная для вызова в случае, если
Player-объект побеждает в игре, могла бы одинаково успешно иметь дело как с
реальным Player-объектом, так и с его "заместителем" (PlayerProxy-объектом). Этот
код может обрабатывать оба случая одинаково, поскольку proxy-объект гарантирует
достоверный результат.
bool informWinner(const Player* inPlayer)
{
string result;
result = inPlayer->sendInstantMessage(
"Вы выиграли! Сыграем еще?");
if (result == "да") {
cout << inPlayer->getName() << " желает сыграть еще"
<< endl;
return true;
} else {
// Игрок ответил отрицательно или отключен от сети.
cout << inPlayer->getName() << " не желает сыграть еще"
<< endl;
return false;
Шаблон адаптера
Мотивацией для изменения абстракции, заданной классом, не всегда является
желание скрыть истинное положение вещей или защититься от проблем, связанных
с производительностью. Иногда базовая абстракция не подходит под текущий проект,
но ее невозможно изменить. В этом случае можно создать адаптер, или класс оболочки.
Адаптер предоставляет абстракцию и код, который служит мостом между желаемой
абстракцией и базовой. Вероятно, вы уже видели адаптеры, используемые библиоте-
Глава 26. Применение шаблонов проектирования 849
кой STL. Вспомните, что STL включает такие контейнерные адаптеры, как стек
(stack) и (queue), которые, по сути, являются оболочками вокруг других
контейнеров, таких как дек (deque) и список (list).
Пример: адаптация библиотеки XML
В главе 24 вы прочитали о XML-библиотеке средств анализа Xerces. Xerces — это
мощное средство общего назначения. Оно реализует многие неясные XML-стандарты
и обеспечивает определенную гибкость. Однако существует ряд причин, вызывающих
у программистов желание заключить Xerces в оболочку. Например, ваши потребности
в этой библиотеке могут ограничиваться довольно простым вариантом ее
использования, и вы бы с радостью обошлись некоторым ее подмножеством. Написав
оболочку, вы можете максимально упростить применение средств, относящихся к вашей
сфере. Кроме того, создание оболочки вокруг библиотеки Xerces дает вам
определенную свободу для переключения между различными XML-библиотеками. Возможно,
в будущем вы предполагаете перейти к специальному XML-коду или хотите разрешить
пользователям писать собственный XML-код. И до тех пор пока их код будет
поддерживать интерфейс, соответствующий вашей оболочке, он будет работоспособным.
Реализация адаптера
Первое, что нужно сделать для написания адаптера, — изучить класс или
библиотеку, которую вы собираетесь адаптировать. Если вы незнакомы с библиотекой
Xerces, просмотрите еще раз материал главы 24, а потом можете читать дальше.
Затем необходимо определить новый интерфейс с базовой абстракцией. Для
данного примера мы предположим, что пользователям нужны только те Xerces-средства,
которые рассматривались в главе 24, т.е. возможность считывать XML-элементы,
атрибуты и текстовые узлы. В качестве адаптера с библиотекой Xerces будет служить
единственный класс ParsedXMLElement. Клиент создает ParsedXMLElement-объект
на основе файла, который представляет корневой узел. Все подэлементы этого
элемента также представляются как ParsedXMLElements-объекты. В следующем
определении демонстрируются открытые функции класса ParsedXMLElement.
// ParsedXMLElement.h
#include <string>
#include <vector>
class ParsedXMLElement
{
public:
ParsedXMLElement(const std::string& inFilename);
-ParsedXMLElement();
std:: string getNameO const;
std::string getTextData() const;
std::string getAttributeValue(
const std::strings inKey) const;
std::vector<ParsedXMLElement*> getSubElements() const;
};
Поскольку наш адаптер будет использовать библиотеку Xerces негласно, в это
определение класса необходимо внести некоторые дополнения. Класс ParsedXMLElement
будет отвечать за инициализацию библиотеки Xerces при создании своего первого
850 Часть V. Использование библиотек и шаблонов
корневого объекта и за завершение работы с этой библиотекой после удаления последнего
корневого объекта. Чтобы реализовать такое поведение, классу ParsedXMLElement
необходимо отслеживать статически определенное количество создаваемых
корневых элементов. Кроме того, каждый ParsedXMLElement-объект должен содержать
указатель на Xerces-объект типа DOMElement, который используется для реального
получения анализируемых данных. Объекту класса XercesDOMParser нужно
оставаться "живым" до тех пор, пока существует соответствующий ему объект типа
DOMElements. Анализатор будет "обитать" в корневом объекте, поэтому, как
предупреждается в комментариях, при разрушении корневого элемента все его подэле-
менты станут недействительными. Вот как выглядит модифицированное определение
класса ParsedXMLElement.
// ParsedXMLElement.h
#include <string>
#include <vector>
#include <xercesc/util/PlatformUtils.hpp>
#include <xercesc/parsers/XercesDOMParser.hpp>
#include <xercesc/dom/DOM.hpp>
XERCES_CPP_NAMESPACE_USE
/**
* Примечание: при удалении корневого элемента его
* подэлементы становятся недействительными.
*/
class ParsedXMLElement
{
public:
ParsedXMLElement(const std::strings inFilename);
-ParsedXMLElement();
std:: string getNameO const;
std::string getTextData() const;
std::string getAttributeValue{
const std::strings inKey) const;
// Инициатор вызова отвечает за освобождение
// ParsedXMLElements-объектов, адресуемых
// элементами вектора,
std::vector<ParsedXMLElement*> getSubElements() const;
protected:
// Этот конструктор используется внутренне для
// создания подэлементов.
ParsedXMLElement(DOMElement* inElement);
XercesDOMParser* mParser;
DOMElement* mElement;
static int sReferences;
private:
// Запрещаем вызов конструктора копии и
// оператора присваивания.
ParsedXMLElement (const ParsedXMLElement&) ;
ParsedXMLElement& operator=(
const ParsedXMLElement& rhs);
};
Глава 26. Применение шаблонов проектирования 851
Реализация этой оболочки имеет большое сходство с примерами, приведенными
в главе 24, поэтому здесь мы не будем вдаваться в детали: приведенный ниже код
говорит сам за себя. Важно отметить, что каждый public-метод класса ParsedXMLElement
в действительности формирует вызовы к библиотеке Xerces. Мы надеемся, что вы
согласитесь с тем, что класс ParsedXMLElement обеспечивает более дружественный
интерфейс с этим подмножеством библиотеки Xerces.
#include "ParsedXMLElement.h"
#include <xercesc/util/XMLString.hpp>
#include <iostream>
XERCES_CPP_NAMESPACE_USE
using namespace std;
// По умолчанию ссылки не определены,
int ParsedXMLElement::sReferences = 0;
ParsedXMLElement::ParsedXMLElement(
const std::strings inFilename)
{
if (sReferences ==0) {
// Для первого элемента нужно инициализировать
// библиотеку.
XMLPlatformUtils::Initialize();
}
sReferences++ ;
mParser = new XercesDOMParser();
mParser->parse(inFilename.c_str());
DOMNode* node = mParser->getDocument();
DOMDocument* document = dynamic_cast<DOMDocument*>(node);
if (document == NULL) {
cerr « "ПРЕДУПРЕЖДЕНИЕ: нет XML-документа!" « endl;
return;
}
mElement = dynamic_cast<const DOMElement*>(
document->getDocumentElement());
if (mElement == NULL) {
cerr << "ПРЕДУПРЕЖДЕНИЕ: XML-документ не имел
^ корневого элемента!" << endl;
}
}
ParsedXMLElement::~ParsedXMLElement()
{
if (mParser != NULL) {
// Это корневой элемент.
delete mParser;
sReferences--;
if (sReferences == 0) {
// Разрушается последний элемент.
XMLPlatformUtils::Terminate();
}
}
}
string ParsedXMLElement::getName() const
852 Часть V. Использование библиотек и шаблонов
{
char* tagName = XMLString::transcode(
mElement->getTagName());
string result (tagName) ,-
XMLString::release(&tagName);
return result;
}
string ParsedXMLElement::getTextData() const
{
// Мы предполагаем, что первый текстовый узел,
// достигнутый нами, именно тот, что нам нужен.
DOMNodeList* children = mElement->getChildNodes();
for (int i = 0; i < children->getLength(); i++) {
DOMText* textNode = dynamic_cast<DOMText*>(
children->item(i));
if (textNode != NULL) {
char* textData = XMLString::transcode(
textNode->getData());
string result(textData);
XMLString::release(btextData);
return result;
// He обнаружено ни одного текстового узла.
return "";
}
string ParsedXMLElement::getAttributeValue(
const std::strings inKey) const
{
XMLCh* key = XMLString::transcode(inKey.c_str() ) ;
const XMLCh* value = mElement->getAttribute(key);
XMLString::release(&key);
char* valueString = XMLString::transcode(value);
string result(valueString);
XMLString::release(&valueString);
return result;
}
vector<ParsedXMLElement*>
ParsedXMLElement::getSubElements() const
{
vector<ParsedXMLElement*> result;
DOMNodeList* children = mElement->getChildNodes () ,-
for (int i = 0; i < children->getLength(); i++) {
DOMElement* elNode = dynamic_cast<DOMElement*>(
children->itern(i));
if (elNode ■ = NULL) {
result.push_back(new ParsedXMLElement(elNode));
return result;
}
ParsedXMLElement::ParsedXMLElement(DOMElement* inElement)
{
Глава 26. Применение шаблонов проектирования 853
mParser = NULL; // Подэлемент не имеет анализатора.
mElement = inElement;
}
Использование адаптера
Поскольку адаптеры предназначены для предоставления более подходящего
интерфейса для базового поведения, их использование должно быть простым и
соответствовать конкретному случаю. С учетом предыдущего примера следующая
программа выводит избранную информацию об XML-файле.
int main(int argc, char** argv)
{
ParsedXMLElement e("test.xml");
cout << "Имя корневого элемента: " << e.getNameO << endl;
vector<ParsedXMLElement*> subelements = e.getSubElements();
for (vector<ParsedXMLElement*>::iterator it =
subelements.begin();
it != subelements.end(); ++it) {
cout << "Имя подэлемента: " << (*it)->getName() << endl;
cout << "Спикер подэлемента: "
<< (*it)->getAttributeValue("speaker") << endl;
cout << "Текстовые данные подэлемента: "
<< (*it)->getTextData() « endl;
}
for (vector<ParsedXMLElement*>::iterator it =
subelements.begin();
it != subelements.end(); ++it) {
delete *it,-
}
return 0;
}
После применения этой программы к примеру файла из главы 24 были получены
такие результаты.
Имя корневого элемента: dialogue
Имя подэлемента: sentence
Спикер подэлемента: Marni
Текстовые данные подэлемента: Давай пойдем за мороженым.
Имя подэлемента: sentence
Спикер подэлемента: Scott
Текстовые данные подэлемента: После того, как я напишу эту книгу по C++.
Шаблон дизайнера
Шаблон дизайнера предполагает возможность изменить "убранство" объекта.
Этот шаблон используется для модификации поведения объекта во время
выполнения программы. Роль дизайнеров сходна с ролью подклассов, но их воздействие на
исходные данные может быть временным. Например, предположим, у вас есть поток
данных, которые вы анализируете, и если обнаружатся данные, представляющие
собой графическое изображение, то вы могли бы временно "украсить" объект потока
с помощью объекта класса ImageStream. Конструктор класса ImageStream
принимает в качестве параметра потоковый объект и обладает встроенными "знаниями"
854 Часть V. Использование библиотек и шаблонов
о том, как следует анализировать изображение. После проведения такого анализа вы
могли бы продолжить работу с исходным объектом, анализируя остальную часть
потока. Класс ImageStream действует подобно дизайнеру, поскольку вносит в
существующий объект (поток) новое поведение (анализ изображения).
Пример: определение стилей в Web-страницах
Как вы уже, вероятно, знаете, Web-страницы создаются с использованием
простого языка гипертекстовой разметки (Hypertext Markup Language— HTML). B HTML
можно применять стили к тексту с помощью таких стилевых тегов (признаков), как
<В> и </В> для выделения текста полужирным шрифтом (bold) или <1> и </1> для
выделения текста курсивом (italic). При выполнении следующей строки HTML-кода
заданное сообщение отобразится полужирным шрифтом.
<В>Вечеринка? В мою честь? Благодарю!</В>
А следующая строка кода приведет к выводу указанного сообщения полужирным
шрифтом и курсивом одновременно.
<1><В>Вечеринка? В мою честь? Благодарю!</В></1>
Предположим, что вы пишете HTML-приложение с функциями редактирования.
Ваши пользователи смогут вводить абзацы текста и применять к ним один или
несколько стилей форматирования. Вы могли бы сделать каждый тип абзаца новым
подклассом, как показано на рис. 26.5, но такой способ проектирования не отличается
элегантностью и будет способствовать тому, что по мере добавления новых стилей
программа будет увеличиваться в объеме.
Рис. 26.5
В качестве альтернативного варианта стилизованные абзацы можно
рассматривать не как отдельные их типы, а как "декорированные" абзацы. При таком подходе
может сложиться ситуация, отображенная на рис. 26.6. Здесь показано, как стиль
ItalicParagraph "накладывается" на стиль BoldParagraph, который в свою
очередь "накладывается" на стиль Paragraph. При рекурсивном декорировании
объектов формируется цепочка вложенных стилей подобно тому, как они (стили)
вкладываются друг в друга в HTML-коде.
Реализация шаблона дизайнера
Для "декоративной отделки" класса Paragraph с использованием некоторого
количества стилей (которое может быть нулевым) нам понадобится иерархия
"стилизованных" Paragraph-классов. Каждый из этих классов должен быть выведен из
существующего класса Paragraph. В этом случае все они смогут "декорировать" любой
исходный абзац (объект класса Paragraph) или уже стилизованный. Удобнее всего
реализовать стилизованные классы путем создания подклассов из класса Paragraph. Вот
как выглядит базовый класс Paragraph (со встроенными реализациями методов).
Глава 26. Применение шаблонов проектирования 855
ItalicParagraph
BoldParagraph
Paragraph
Рис. 26.6
class Paragraph
{
public:
Paragraph(const strings inlnitialText) :
mText(inlnitialText) {}
virtual string getHTML() const { return mText; }
protected:
string mText;
};
Класс BoldParagraph мы выведем из класса Paragraph, чтобы он мог
переопределить метод getHTML (). Но поскольку мы собираемся использовать его в качестве
дизайнера (декоратора), его единственный открытый конструктор (не конструктор
копии) принимает const-ссылку на объект класса Paragraph. Обратите внимание на
то, что он передает пустую строку конструктору класса Paragraph, поскольку в классе
BoldParagraph член данных mText не используется: единственная цель выведения
этого подкласса — переопределить метод getHTML ().
class BoldParagraph : public Paragraph
{
public:
BoldParagraph(const Paragraphs inParagraph) :
Paragraph(""), mWrapped(inParagraph) {}
virtual string getHTML{) const {
return "<B>" + mWrapped.getHTML{) + "</B>";
}
};
protected:
const Paragraphs mWrapped;
Класс ItalicParagraph практически идентичен предыдущему.
class ItalicParagraph : public Paragraph
{
public:
ItalicParagraph(const Paragraphs inParagraph) :
Paragraph( " " ) , mWrapped(inParagraph) {}
virtual string getHTML() const {
856 Часть V. Использование библиотек и шаблонов
return ,,<I>" + mWrapped.getHTML () + "</I>" ;
}
protected:
const Paragraphs mWrapped;
};
И снова-таки, необходимо помнить, что классы BoldParagraph и Italic Paragraph
могут переопределить метод getHTML () только потому, что они выведены из класса
Paragraph. При этом содержимое абзаца, подвергаемого стилевой обработке, "берется"
не из члена данных mText, а из объекта, создаваемого классом Paragraph.
Использование шаблона дизайнера
С точки зрения пользователя, шаблон дизайнера привлекателен простотой
применения и ясностью. Клиенту даже и знать не нужно о его применении. Поведение
производного класса BoldParagraph подобно поведению его родителя (класса Paragraph).
Рассмотрим программу, которая создает и выводит абзац сначала с
использованием полужирного шрифта, а затем — полужирного шрифта и курсива одновременно.
int main(int argc, char** argv)
{
Paragraph p("Вечеринка? В мою честь? Благодарю!");
// Использование полужирного шрифта.
cout << BoldParagraph(p).getHTML{) << endl;
// Использование полужирного шрифта и курсива,
cout << ItalicParagraph(BoldParagraph(p)).getHTML{)
<< endl;
}
Результаты выполнения этой программы таковы.
<В>Вечеринка? В мою честь? Благодарю!</В>
<1><В>Вечеринка? В мою честь? Благодарю!</В></1>
У этой реализации есть весьма интересный побочный эффект: если один и тот
же стиль применить к строке дважды, на выходе никакого "удвоения" наблюдаться
не будет.
cout << BoldParagraph(BoldParagraph(p)) .getHTML{) << endl;
Результат выполнения этой строки выглядит так.
<В>Вечеринка? В мою честь? Благодарю!</В>
Если вы понимаете причин}' такого "явления", значит, вы освоили C++! Дело в том,
что вместо использования конструктора класса BoldParagraph, который принимает
const-ссылку на объект типа Paragraph, компилятор вызывает встроенный
конструктор копии для класса BoldParagraph! С языком HTML здесь никаких проблем не
будет, поскольку не существует такого понятия, как "дважды полужирный шрифт".
Однако другие "декораторы", построенные с использованием аналогичной
оболочки, для надлежащей установки ссылки на "декорируемый" объект могут потребовать
явной реализации конструктора копии.
Глава 26. Применение шаблонов проектирования 857
Шаблон цепочки ответственности
Цепочка ответственности используется в случае, когда нужно, чтобы при
выполнении конкретного действия в объектно-ориентированной иерархии "заработал"
каждый класс. Этот метод в общем случае полагается на механизм полиморфизма,
причем так, чтобы первым вызывался класс с самой узкой специализацией, после чего он
либо сам сможет обработать вызов, либо передаст его для обработки родительскому
классу. Затем аналогичное решение принимает родитель, т.е. он либо обработает вызов
сам, либо передаст своему родителю. Описанная цепочка ответственности
необязательно должна соответствовать иерархии классов, но обычно именно так и происходит.
Цепочки ответственности чаще всего используются для обработки событий.
Работа большинства современных приложений, особенно это касается тех, которые
оснащены графическим интерфейсом пользователя, спроектирована как механизм
формирования некоторых последовательностей событий и ответов на них. Например,
когда пользователь щелкает на меню File (Файл) и выбирает команду Open (Открыть),
формируется событие открытия файла. Когда пользователь щелкает на
перетаскиваемой области программы рисования, формируется событие нажатия кнопки мыши.
По мере перетаскивания фигуры периодически генерируется событие перемещения
курсора мыши, и это длится до тех пор, пока не произойдет событие отпускания
кнопки мыши. В каждой операционной системе есть собственный способ
присваивания имен этим событиям и их использования, но общая идея везде практически
одинакова. При возникновении событие некоторым образом передается программе,
которая предпринимает соответствующее действие.
Как вы знаете, в языке C++ не предусмотрено никаких встроенных средств для
графического программирования. В нем также не определено понятия "событие", их
передачи или обработки. Цепочка ответственности— это рациональный подход
к решению проблемы обработки событий, поскольку в объектно-ориентированной
иерархии обработка событий часто переводится в плоскость структуры класс/подкласс.
Пример: обработка событий
Рассмотрим программу построения чертежей, в которой определена иерархия
Shape-классов (рис. 26.7).
Рис. 26.7
Концевые вершины (листы дерева) обрабатывают определенные события.
Например, классы Square или Circle могут получать события нажатия кнопки мыши,
при которых выбирается определенная фигура. Родительский класс обрабатывает
события, которые характеризуются таким же результатом независимо от конкретной
фигуры. Например, событие удаления обрабатывается одинаково независимо от типа
удаляемой фигуры. Идеальный алгоритм для обработки конкретного события состоит
в следующем: начиная с концевых вершин, вся иерархия обходится до тех пор, пока
не будет обработано полученное сообщение. Другими словами, если для объекта класса
858 Часть V. Использование библиотек и шаблонов
Square произойдет событие нажатия кнопки мыши, сначала шанс его обработать
получает класс Square. Если он не распознает это событие (как "свое"), такой шанс
получает класс Shape. Этот подход может послужить примером цепочки ответственности,
поскольку каждый подкласс имеет возможность передать сообщение следующему
классу, расположенному в цепочке (на иерархической лестнице) "ступенькой выше"
Реализация шаблона цепочки ответственности
Способ обработки сообщений "по цепочке" в значительной степени зависит от
того, как организована обработка событий в конкретной операционной системе, но
все же он будет иметь некоторое сходство со следующим кодом, в котором для
представления типов событий используются целочисленные значения.
void Square::handleMessage(int inMessage)
{
switch (inMessage) {
case kMessageMouseDown:
handleMouseDown();
break;
case kMessagelnvert:
handlelnvert();
break;
default:
// Сообщение не распознано -- передается по цепочке.
// суперклассу.
Shape::handleMessage(inMessage);
}
}
void Shape::handleMessage(int inMessage)
{
switch (inMessage) {
case kMessageDelete:
handleDelete();
break;
default:
cerr << "Получено нераспознанное сообщение: "
<< inMessage « endl;
break;
}
}
При получении сообщения оболочкой или той частью программы, которая
предназначена для обработки событий, выполняется поиск соответствующей фигуры
и вызывается метод handleMessage (). Благодаря механизму полиморфизма
вызывается именно "подклассовая" версия метода handleMessage(). Это дает шанс
первому "попробовать свои силы" в деле обработки сообщения концевой вершине (листу).
Если она "не знает", как это сделать, "эстафета" передается вверх по цепочке, т.е.
суперклассу, который следующим получает шанс справиться с задачей. В нашем
примере финальный получатель сообщения, если он не в состоянии обработать событие,
просто выводит сообщение об ошибке. Можно было бы также сгенерировать
исключение или "обязать" свой метод handleMessage () возвратить булево значение,
обозначающее успешный или неудачный результат обработки события.
Глава 26. Применение шаблонов проектирования 859
Обратите внимание вот на что. Хотя цепочки событий обычно соответствуют
иерархии классов, это совсем необязательно. В предыдущем примере класс Square с такой
же "легкостью" мог бы передать сообщение совершенно другому объекту, а не
родительскому классу. "Цепочечный" метод довольно гибок и имеет весьма привлекательную
структуру для объектно-ориентированных иерархий. Его недостатком можно считать то,
что он требует от программиста определенной аккуратности. Если вы забудете подкласс
объединить в цепь с суперклассом, события "надежно потеряются". Хуже того, если по
ошибке включить в цепь не тот класс, можно получить бесконечный цикл!
Использование цепочки ответственности
Для того чтобы цепочка ответственности могла отвечать на события, должен
существовать еще один класс, который бы отправлял события нужному объекту. Поскольку
постановка этой задачи зависит в огромной степени от оболочки или платформы,
вместо С++-кода мы приведем псевдокод обработки события нажатия кнопки мыши.
MouseLocation loc = getClickLocation();
Shape* clickedShape = findShapeAtLocation(loc);
clickedShape->handleMessage(kMessageMouseDown);
Шаблон наблюдателя
Еще одна популярная модель обработки событий известна под названием
"наблюдатель" ("слушатель") сообщений, или метод публикаций и подписки. Это
более инструктивная модель, которая считается менее "восприимчивой" к ошибкам,
чем цепочки ответственности. С использованием метода публикаций и подписки
отдельные объекты регистрируют события, которые они способны распознавать, с
помощью центрального реестра обработки событий. При получении данных о событии
они передаются в список объектов, "подписавшихся на события".
Пример: обработка событий
Как и для рассмотренного выше шаблона цепочки ответственности, для обработки
событий часто используются наблюдатели. Основное различие между этими двумя
шаблонами состоит в том, что цепочка ответственности работает лучше всего для
логических иерархий, когда необходимо найти единственно правильный класс,
который обработает событие. Наблюдатели же лучше всего проявляются тогда, когда
события могут быть обработаны несколькими объектами или вообще не имеют
никакого отношения к иерархии.
Реализация наблюдателя
В следующем примере показано определение простого класса реестра событий. Он
позволяет любому объекту, который "включает" класс-примесь Listener, подписаться
на одно или несколько событий. Он также содержит метод, который будет вызываться
программой при получении события, чтобы направить его всем Listener-объектам.
/**
* Listener.h
860 Часть V. Использование библиотек и шаблонов
* Класс-примесь для объектов, которые способны отвечать
* на события.
*/
class Listener
{
public:
virtual void handleMessage(int inMessage) const =0;
};
/**
* EventRegistry.h
*
* Поддерживает каталог Listener-объектов и соответствующих
* им событий. Кроме того, обеспечивает передачу
* события соответствующему Listener-объекту.
*/
#include "Listener.h"
#include <vector>
#include <map>
class EventRegistry
{
public:
static void registerListener(
int inMessage,
const Listener* inListener);
static void handleMessage (int inMessage) ,-
protected:
static std::map<int, std::vector<const Listener*> >
sListenerMap;
};
Ниже приведена реализация класса EventRegistry. При регистрации новый
Listener-объект добавляется в вектор Listener-ссылок, хранимых в отображении
(тар) для данного события. При получении события реестр просто считывает этот
вектор и передает событие каждому "слушателю" (Listener-объекту).
/**
* EventRegistry.срр
*
* Реализует класс EventRegistry.
*/
#include "EventRegistry.h"
#include <iostream>
using namespace std;
// Определяем статическое отображение.
map< int,
vector<const Listener*> > EventRegistry::sListenerMap;
void EventRegistry::registerListener(
int inMessage,
const Listener* inListener)
{
// Как упоминалось в главе 21, при индексировании
// в отображение добавляется новый элемент с заданным
// ключом, если он еще там не содержится.
sListenerMap[inMessage].push_back(inListener);
Глава 26. Применение шаблонов проектирования 861
}
void EventRegistry::handleMessage(int inMessage)
{
// Проверяем, имеет ли данное сообщение каких-либо
// слушателей.
if (sListenerMap.find(inMessage) == sListenerMap.endO)
return;
for (int i = 0; i < sListenerMap[inMessage].size(); i++) {
sListenerMap[inMessage].at(i)->handleMessage(inMessage);
}
Использование наблюдателя
Ниже приводится очень простой поэлементный тест, который демонстрирует, как
использовать метод публикаций и подписки. Класс TestListener "подписывается" на
сообщение 0 в своем конструкторе. Подписка на сообщение в конструкторе происходит
по общей схеме для объектов, которые являются "слушателями" (имеют тип Listener).
Этот класс содержит два флага, которые отслеживают факт получения сообщения 0
и остальных "неизвестных" сообщений соответственно. Если было получено
сообщение 0 и при этом "обошлось" без "неизвестных", тест считается пройденным.
class TestListener : public Listener
{
public:
TestListener();
void handleMessage(int inMessage);
};
bool fMessageOReceived;
bool fUnknownMessageReceived;
TestListener::TestListener()
{
fMessageOReceived = false,-
fUnknownMessageReceived = false,-
// Подписываемся на событие 0.
EventRegistry::registerListener(0, this);
}
void TestListener::handleMessage(int inMessage)
{
switch (inMessage) {
case 0:
fMessageOReceived = true;
break;
default:
fUnknownMessageReceived = true;
break;
}
}
int main(int argc, char** argv)
{
TestListener tl;
862 Часть V. Использование библиотек и шаблонов
EventRegistry: : handleMessage (0) ,-
EventRegistry::handleMessage(1);
EventRegistry::handleMessage(2);
if (!tl.fMessageOReceived) {
cout « "ТЕСТ НЕ ПРОЙДЕН: сообщение 0 не получено."
< < endl;
} else if (tl.fUnknownMessageReceived) {
cout « "ТЕСТ НЕ ПРОЙДЕН: TestListener-объект получил
Ч> неизвестное сообщение." << endl;
} else {
cout « "ТЕСТ ПРОЙДЕН" << endl;
}
}
Ваша реальная программа может отличаться от представленной здесь. Это зависит
от сервиса, предоставляемого вашей рабочей средой и вашими индивидуальными
потребностями. Вероятно, вы заметили, что в данной реализации для Listener-объекта
не предусмотрен способ отказа от регистрации. Если не все объекты обязуются вечно быть
зарегистрированными, во избежание ошибок они должны иметь возможность отказаться
от регистрации. Кроме того, эта реализация позволяет объектам регистрироваться
дважды, что в некоторых случаях может иметь весьма нежелательные последствия.
Резюме
Эта глава позволила вам лишь слегка представить, насколько полезными могут
оказаться шаблоны проектирования при внесении объектно-ориентированных
принципов в проекты высокого уровня. На сайте Portland Pattern Repository Wiki
(адрес: www. c2 . com) можно познакомиться с огромным выбором шаблонов
проектирования. Нетрудно увлечься и, позабыв о времени, утонуть в этом море шаблонов,
пытаясь найти вариант, применимый к конкретной задаче. Мы рекомендуем детально
познакомиться лишь с несколькими шаблонами и больше уделить внимания изучению
процесса их разработки, а не просто едва заметным различиям между ними. Так и
хочется перефразировать одно старое изречение: "Покажите мне несколько шаблонов,
и я буду заниматься программированием весь день. Научите меня создавать шаблоны
проектирования, и я буду заниматься программированием всю жизнь".
Шаблоны проектирования — прекрасный способ завершить наше путешествие по
просторам профессионального программирования на C++, поскольку они (эти
шаблоны) могут послужить идеальным примером того, как хороший С++-программист
может стать искусным С++-программистом. Тщательно обдумывая свои и чужие
шаблоны, экспериментируя с различными приемами объектно-ориентированного
проектирования и избирательно пополняя собственный репертуар личных "секретов", вы
очень скоро сможете перевести свой практический опыт в области
программирования на C++ на профессиональный уровень.
Часть VI
Приложения
В ЭТОЙ ЧАСТИ...
Приложение А. Готовимся к С++-интервью
Приложение Б. Аннотированная библиография
Готовимся к С++-
интервью
Несомненно, внимательное отношение к материалу, представленному в этой книге,
положительно скажется на вашей карьере С++-программиста, но вы должны быть
готовы к тому, что работодатели, прежде чем положить вам приличный оклад, захотят, чтобы
вы доказали, что заслуживаете его. Методика проведения интервью в разных компаниях
различна, но многие аспекты интервью в части, связанной с языками программирования,
вполне предсказуемы. Въедливый интервьюер, скорее всего, захочет протестировать
ваши базовые навыки в области кодирования, отладки и проектирования программ, а также
может поинтересоваться вашими стилевыми предпочтениями и методами решения
проблем. Набор вопросов, на которые вам могут предложить ответить, довольно велик.
В этом приложении описаны некоторые типы вопросов, которые вы можете услышать
на собеседовании, а также тактика поведения, которая может помочь вам в получении
высокооплачиваемой работы на ниве С++-программирования.
В этом приложении мы еще раз "перелистаем" главы нашей книги с акцентом на
тех их аспектах, которые с большой вероятностью могут "всплыть" во время
интервью. Каждый раздел приложения включает перечень типов вопросов, которые могут
прозвучать при тестировании ваших навыков, а также лучшие (с нашей точки зрения)
варианты ответов на эти вопросы.
Приложение А. Готовимся к С++-интервью 865
Глава 1: краткий курс C++
Специальное интервью может зачастую включать некоторые базовые вопросы по
теории C++, чтобы "отсеять" кандидатов, которые в своем резюме упомянули об
умении программировать на C++ лишь потому, что когда-то слышали о таком языке. Эти
вопросы могут быть заданы даже во время "телефонной фильтрации", когда
разработчик или агент по найму кадров звонит вам, прежде чем пригласить на личную
встречу. Эти вопросы могут быть также заданы по электронной почте или при очном »
интервью. Отвечая на них, помните, что интервьюер просто пытается понять,
действительно ли вы изучали C++ и использовали его на практике. И поэтому здесь вам
необязательно вдаваться в детали, чтобы заработать очки.
О чем не следует забывать
□ Функция main () и ее параметры.
□ Синтаксис заголовочных файлов, включая опускание расширения " . h" для
заголовков стандартных библиотек.
□ Базовое использование пространств имен.
□ Основы языка, например синтаксис использования циклов, тернарного
оператора и переменных.
□ Различие между стековой памятью и областью "кучи".
□ Динамически создаваемые массивы.
□ Использование спецификатор const.
Типы вопросов
Базовые вопросы по теории C++ часто звучат в форме словарного теста. Интервьюер
может предложить вам дать определение таким С++-понятиям, как спецификатор const
или static. Кто-то, вероятно, попытается вспомнить, что сказано по этому поводу в его
настольном пособии, но у вас есть шанс получить дополнительные очки, приведя
пример использования или упомянув об интересной детали. Например, говоря, что одним
из назначений спецификатора const является запрет на изменение некоторого
ссылочного аргумента, вы могли бы также отметить, что при передаче объекта функции
или методу использование const-ссылки более эффективно, чем его копии.
Существует и другая форма проверки базовых знаний по C++. Вам могут
предложить написать короткую С++-программу прямо под взглядом интервьюера. Для
разминки интервьюер может сказать: "Напишите фразу "Привет, мир!" на языке C++",
что означает задание написать С++-программу, которая бы выводила фразу "Привет,
мир!". Получив, казалось бы, такое простое задание, позаботьтесь о том, чтобы
заработать на нем максимально возможное количество дополнительных очков,
продемонстрировав, что вы понимаете толк в использовании пространств имен и потоков
ввода-вывода (обойдясь без набившей всем оскомину С-функции printf ()) и знаете,
какие стандартные заголовки нужно включить в программу.
866 Часть VI. Приложения
Глава 2: разработка профессиональных
С++-программ
Ваш интервьюер непременно захочет убедиться в том, что вы не только обладаете
определенными знаниями языка C++, но и можете их применять. Вам могут не задать
в явном виде вопрос о проектировании программ, но опытный интервьюер знает ряд
способов, как это сделать "под маской" других вопросов.
О чем не следует забывать
□ Проектирование — процесс субъективный. Подготовьтесь к защите
собственных решений в области проектирования, которые вам предложат принять во
время интервью.
□ Вспомните детали какого-нибудь из последних ваших проектов на случай, если
вас попросят привести пример.
□ Приготовьтесь дать определение абстракции и привести пример.
□ Не забудьте перечислить достоинства многократного использования кода.
□ Будьте готовы к тому, чтобы описать в общих чертах какой-нибудь проект,
включая иерархии классов.
Типы вопросов
Интервьюеру не так уж просто задавать предметные вопросы на тему
проектирования программ, поскольку любая программа, которую вам предложат
спроектировать во время собеседования, вероятно, будет слишком простой для демонстрации
навыков реального проектирования. Вопросы на эту тему могут быть заданы в
несколько туманной форме, например: "Перечислите этапы проектирования
программы" или "Разъясните принципы абстракции". Обсуждая вашу предыдущую работу,
интервьюер может предложить вам описать процесс разработки того проекта,
которым вы занимались до сегодняшнего дня.
Глава 3: проектирование с использованием
объектов
Вопросы на тему объектно-ориентированного проектирования часто ставятся
с целью отсеять С-программистов, которые просто знают о существовании ссылок от
своих приятелей С++-программистов, действительно использующих объектно-
ориентированные средства языка. Интервьюеры ничего не делают просто так; даже
если вы программируете на объектно-ориентированных языках уже в течение
нескольких лет, они все равно захотят получить доказательства того, что вы хорошо
понимаете эту методологию.
О чем не следует забывать
□ Различие между процедурной и объектно-ориентированной парадигмами.
□ Различие между классом и объектом.
866 Часть VI. Приложения
Глава 2: разработка профессиональных
С++-программ
Ваш интервьюер непременно захочет убедиться в том, что вы не только обладаете
определенными знаниями языка C++, но и можете их применять. Вам могут не задать
в явном виде вопрос о проектировании программ, но опытный интервьюер знает ряд
f способов, как это сделать "под маской" других вопросов.
О чем не следует забывать
□ Проектирование— процесс субъективный. Подготовьтесь к защите
собственных решений в области проектирования, которые вам предложат принять во
время интервью.
□ Вспомните детали какого-нибудь из последних ваших проектов на случай, если
вас попросят привести пример.
□ Приготовьтесь дать определение абстракции и привести пример.
□ Не забудьте перечислить достоинства многократного использования кода.
□ Будьте готовы к тому, чтобы описать в общих чертах какой-нибудь проект,
включая иерархии классов.
Типы вопросов
Интервьюеру не так уж просто задавать предметные вопросы на тему
проектирования программ, поскольку любая программа, которую вам предложат
спроектировать во время собеседования, вероятно, будет слишком простой для демонстрации
навыков реального проектирования. Вопросы на эту тему могут быть заданы в
несколько туманной форме, например: "Перечислите этапы проектирования
программы" или "Разъясните принципы абстракции". Обсуждая вашу предыдущую работу,
интервьюер может предложить вам описать процесс разработки того проекта,
которым вы занимались до сегодняшнего дня.
Глава 3: проектирование с использованием
объектов
Вопросы на тему объектно-ориентированного проектирования часто ставятся
с целью отсеять С-программистов, которые просто знают о существовании ссьиюк от
своих приятелей С++-программистов, действительно использующих объектно-
ориентированные средства языка. Интервьюеры ничего не делают просто так; даже
если вы программируете на объектно-ориентированных языках уже в течение
нескольких лет, они все равно захотят получить доказательства того, что вы хорошо
понимаете эту методологию.
О чем не следует забывать
□ Различие между процедурной и объектно-ориентированной парадигмами.
□ Различие между классом и объектом.
Приложение А. Готовимся к С++-интервью 867
□ Выражение классов в терминах компонентов, свойств и поведенческих
характеристик.
□ Отношения типа "is-a" и "has-a".
□ Компромисс, связанный с множественным наследованием.
Типы вопросов
Об объектно-ориентированном проектировании обычно задают вопросы в двух
направлениях. Предлагается или определить принципы объектноюриентированного про-
ектировдния, или описать объектно-ориентированную иерархию. Первый вариант
несколько проще. Примеры, включенные в ответ, позволят вам заработать больше очков.
Если интервьюер предложит вам описать объектно-ориентированную иерархию,
то не исключено, что, взяв в качестве примера такое простое приложение, как игра
в карты, он захочет, чтобы вы спроектировали иерархию классов. Интервьюеры
часто в вопросах о проектировании используют игры, поскольку с такими
приложениями большинство людей уже знакомы. Затрагивая реализации баз данных, им нередко
удается улучшить настроение соискателей работы. Иерархия, которую вы построите
на собеседовании, будет зависеть от конкретной задачи, но все же во время своего
ответа постарайтесь учесть следующие моменты.
□ Интервьюер хочет увидеть процесс вашего мышления. Думайте вслух,
имитируйте поиск решения задачи с помощью техники "мозгового штурма",
вовлекайте в обсуждение проблемы своего интервьюера и не бойтесь отвергнуть
только что высказанный вами вариант решения и пойти в другом направлении.
□ Интервьюер часто предполагает, что всем известна суть игрового приложения,
которое вам было предложено спроектировать. Если же вы никогда не
слышали об игре в очко и получили вопрос именно о ней, не конфузьтесь и попросите
интервьюера разъяснить вам детали или изменить вопрос.
□ Если интервьюер не предлагает вам использовать специальный формат при
описании иерархии, мы рекомендуем строить схему в форме дерева наследования,
"листья" которого будут обозначать методы и члены данных для каждого класса.
□ Возможно, вам придется защищать свой проект или исправлять его с учетом
дополнительных требований. Постарайтесь понять, то ли интервьюер
действительно указывает вам на недостатки проектирования, то ли он просто хочет
"загнать" вас на оборонительную позицию и посмотреть, насколько сильный
у вас дар убеждения.
Глава 4: проектирование с использованием
библиотек и шаблонов
Потенциальный работодатель может поинтересоваться, приходилось ли вам
работать с кодом, который вы не писали сами. Если в вашем резюме перечислены
конкретные библиотеки, с которыми вам приходилось иметь дело, вы должны быть
готовы к ответу на этот вопрос. В противном случае вам следует иметь хотя бы общее
представление о существующих библиотеках и их роли в разработке С++-приложений.
868 Часть VI. Приложения
О чем не следует забывать
□ Компромиссы между созданием приложения "с нуля" и многократным
использованием существующего кода.
□ Понятие о нотации "большого О" (или хотя бы малейшее представление о том,
что значение, выраженное формулой 0(п log n), меньше значения,
выраженного формулой 0(п2)).
□ Функциональный состав стандартной библиотеки C++.
□ Высокоуровневое определение шаблонов проектирования.
Типы вопросов
Если интервьюер спросит вас о конкретной библиотеке, то он (или она), скорее
всего, будет иметь в виду высокоуровневые аспекты этой библиотеки, а не
технические подробности. Например, один из авторов этой книги часто спрашивал
кандидатов о сильных и слабых сторонах библиотеки STL с точки зрения проектирования
библиотек. Лучшие кандидаты в качестве сильных сторон называли широту и
стандартизацию библиотеки STL, а к основному недостатку относили трудности ее освоения.
Вам также могут задать вопрос, который, казалось бы, не связан с библиотеками.
Например, интервьюер может спросить, как вы бы подошли к созданию приложения,
предназначенного для загрузки с Web-сайтов музыкальных файлов в МРЗ-формате
и воспроизведения их на локальном компьютере. Этот вопрос явным образом не связан
с библиотеками, но в действительности ваш ответ может многое сказать интервьюеру.
Вам следует начать с того, что, прежде всего, вы подробно изучили бы требования
и создали начальные прототипы. Поскольку в вопросе были упомянуты две конкретные
технологии, интервьюер поинтересовался бы вашим отношением к ним. Именно в этом
месте библиотеки и "вступают в игру". Если вы скажете интервьюеру, что для решения
поставленной задачи написали бы собственные Web-классы и код воспроизведения
МРЗ-файлов, вы, конечно, не провалили бы тест, но вместе с тем вам бы пришлось
искать веские аргументы для оправдания затрат на изобретение "колеса", т.е. уже
существующих средств. Было бы лучше, если бы вы сказали о необходимости обзора
существующих библиотек, которые выполняют Web-поиск и обрабатывают МРЗ-формат.
Возможно, среди них найдутся такие, которые вполне подходят для вашего проекта.
При этом неплохо назвать известные вам технологии, от которых можно было бы
оттолкнуться, например библиотеку libcurl для Web-поиска в среде Linux или
библиотеку Windows Media для воспроизведения музыкальных файлов в среде Windows.
Глава 5: проектирование с целью
многократного использования кода
Интервьюеры редко задают вопросы о проектировании многократно
используемого кода, а зря. Это упущение заслуживает сожаления, если не сказать порицания,
поскольку, если штат программистов состоит только из тех, кто способен к
написанию "одноразового" кода, такая ситуация может быть убыточна для организации,
специализирующейся на программировании. Но не исключено, что вам повезет на
компанию, руководство которой понимает толк в многократном использовании кода,
и тогда вас могут спросить об этом на собеседовании. Уже одна постановка такого
вопроса — индикатор того, что вы попали в одну из лучших компаний!
Приложение А. Готовимся к С++-интервью 869
О чем не следует забывать
□ Принципы абстракции.
□ Создание подсистем и иерархий классов.
□ Общие правила проектирования эффективных интерфейсов.
□ В каких случаях следует использовать шаблоны и в каких — механизм наследования.
Типы вопросов
Вопросы о многократном использовании кода почти всегда будут касаться ваших
предыдущих проектов. Например, если вы прежде работали на компанию, которая
выпускала видеомонтажные приложения как потребительского, так и профессионального
уровня, то интервьюер может спросить вас, как было организовано совместное
использование одного и того же кода двумя разными приложениями. Даже если вам прямо и не
зададут вопрос о многократном использовании кода, эта тема может подразумеваться.
Описывая некоторые из своих прежних проектов, сообщите интервьюеру,
использовались ли созданные вами модули в других проектах. Даже отвечая на прямые вопросы по
кодированию конкретных модулей, не забудьте упомянуть о разработке интерфейсов.
Глава 6: использование эффективных
методов разработки программного
обеспечения
Если вы чувствуете, что ваше интервью подходит к концу, а интервьюер не задал ни
одного вопроса о технологических процессах, это может означать, что в данной
компании таковые не используются, либо эта тема никого не волнует. В качестве
альтернативного варианта можно предположить, что представители этой компании не хотят вас
пугать тем "чудовищным" технологическим процессом, который "сложился" у них.
В большинстве случаев у вас самих есть шанс расспросить интервьюера о его компании,
и одним из стандартных вопросов может быть вопрос о технологическом процессе.
О чем не следует забывать
□ Традиционные модели жизненного цикла приложений.
□ Компромиссы таких формальных моделей, как рациональный
унифицированный процесс (Rational Unified Process).
□ Основные принципы экстремального программирования (Extreme Programming).
□ Другие процессы, которые вам приходилось использовать.
Типы вопросов
Очень часто просят описать процесс, который использовался вашим предыдущим
работодателем. При ответе на этот вопрос вам следует отметить, какой процесс у вас
работал хорошо, а какой — стопорился, но при этом старайтесь резко не критиковать
никакую конкретную методологию, поскольку именно ее, возможно, и предпочитает
870 Часть VI. Приложения
использовать ваш интервьюер. Например, если вас раздражает одно упоминание об
экстремальном программировании, держите пока свои эмоции при себе!
Нам приходится тратить уйму времени на чтение множества резюме, и мы уже
можем сделать вывод о том, что многие воспринимают экстремальное
программирование на уровне "последнего слова" в этой области. Интересно то, что многие
организации начинают приглядываться к этой технологии и принимают на вооружение
некоторые ее принципы, хотя и без формальной подоплеки.
Если интервьюер спросит вас об экстремальном программировании, то это не
означает, что вы должны с выражением продекламировать определение из учебника по
программированию. Интервьюер прекрасно понимает, что перед собеседованием вы
могли просмотреть оглавление одной из книг по данной теме. Будет лучше, если вы
вспомните несколько идей, отличающих этот процесс от других, а еще лучше, если
озвучите свои мысли по этому поводу. Постарайтесь вовлечь интервьюера в разговор
и повести его в направлении, интересном для него.
Чем больше времени вы потратите на обсуждение высокоуровневых
принципов экстремального программирования, тем меньше времени
у интервьюера останется на "допрос"* по теме, например, синтаксиса
шаблонных классов. И хотя полностью не избежать технических
вопросов, вам, возможно, удастся минимизировать их количество!
Глава 7: кодируем стильно
Всякий, кому приходилось программировать в профессиональном мире, имел дело
с таким коллегой, стиль работы которого можно охарактеризовать отсутствием
какого-либо стиля. Никто не хотел бы сотрудничать с программистом, который пишет
абсолютно беспорядочный код, поэтому интервьюеры иногда пытаются определить
стилевые навыки кандидата.
О чем не следует забывать
□ Стилевые аспекты — это важно, даже если вопросы интервьюера явно не
связаны со стилем программирования!
□ Хорошо написанный код не требует подробных комментариев.
□ Комментарии можно использовать для выражения метаинформации.
□ Принципы декомпозиции.
□ Принцип переделки кода.
□ Соглашение о присваивании имен
Типы вопросов
Вопросы о стиле программирования могут звучать в различных формах. Однажды
на собеседовании одному из авторов этой книги предложили сходу закодировать
довольно сложный алгоритм. Как только он написал имя первой переменной,
интервьюер остановил его и сообщил, что он успешно прошел тестирование. Дело,
оказывается, было не в алгоритме; интервьюер просто хотел в такой завуалированной
Приложение А. Готовимся к С++-интервью 871
форме узнать, как кандидат относится к присвоению имен переменным. Часто на
собеседовании предлагают показать код, написанный соискателем раньше, или просто
высказать свое отношение к стилям программирования.
К представляемому на рассмотрение коду следует отнестись с большой
осторожностью. Вероятно, невозможно на законных основаниях показывать представителю
чужой фирмы то, что вы написали, работая на предыдущего работодателя. Кроме
того, вам желательно найти для демонстрации такой фрагмент кода, с помощью
которого вы могли бы "пустить пыль в глаза", не открывая при этом потенциальному
конкуренту специальной информации. Например, вам не стоит показывать интервьюеру
разделы своей диссертации по теме высокоскоростного воспроизведения изображений,
если вы претендуете в новой компании на должность администратора базы данных.
Если представитель компании предложит вам написать конкретную программу,
это прекрасная возможность показать, чему вы научились, читая эту книгу. Многие ли
из ваших конкурентов догадаются приложить к своей программе поэлементные тесты
или сопроводить отдельные ее строки комментариями? Лучше всего заранее
подготовить небольшую программу специально для демонстрации ваших навыков. Возможно,
вместо "старой" программы имеет смысл "с нуля" написать код, соответствующий
профилю вашей предполагаемой работы и подчеркивающий ваш хороший стиль.
Главы 8 и 9: классы и объекты
Что касается классов и объектов, то тут диапазон типов вопросов просто
необозрим. Одни интервьюеры зацикливаются на синтаксисе, желая поставить кандидата
в тупик трудным вопросом. Другие же меньше "поведены" на реализации и больше
уделяют внимание навыкам в области проектирования.
О чем не следует забывать
□ Базовый синтаксис определения класса.
□ Спецификаторы доступа к методам и членам данных класса.
□ Использование указателя this.
□ Создание и разрушение объектов.
□ Ситуации, при которых компилятор генерирует конструктор за программиста.
□ Списки инициализаторов.
□ Конструктор копии и оператор присваивания.
□ Ключевое слово mutable.
□ Перегрузка методов и задание параметров по умолчанию.
□ Классы-" друзья".
Типы вопросов
Такие вопросы, как "Что означает ключевое слово mutable?", часто звучат во
время телефонного тестирования. Интервьюер может иметь список С++-терминов
и допускать (или не допускать) кандидата к следующему этапу собеседования в
зависимости от количества правильных ответов. Вы можете не знать ответы на все вопросы,
872 Часть VI. Приложения
но имейте в виду, что другие кандидаты подвергнутся такому же блицопросу, и
именно его результаты позволят составить интервьюеру первое мнение о вас.
Интервьюеры и преподаватели любят предлагать кандидату найти в коде ошибку.
Вам могут показать какой-нибудь бессмысленный код и попросить его исправить.
Интервьюеры стараются найти качественные пути анализа кандидатов, и это один из
низь. В этом случае вам следует прочитать каждую строку предложенного кода и вслух
его прокомментировать. Типы ошибок, которые обычно нужно найти, можно
разделить на следующие три категории.
□ Синтаксические. (В редких случаях.) Интервьюеры знают, что такого рода
ошибки легко находятся с помощью компилятора.
□ Проблемы памяти. (Например, утечка памяти или двойное удаление объекта.)
□ Проблемы типа "Этого не следует делать". Сюда относятся формально
корректные операторы, но имеющие нежелательные последствия.
□ Стилевые недочеты. Даже если интервьюер не посчитает такие недочеты
ошибками, обратите его внимание на недостаток в комментариях или на
неудачные имена переменных.
Рассмотрим все перечисленные категории ошибок на примере.
class Buggy
{
Buggy(int param);
-Buggy();
double fjord(double inVal);
int fjord(double inVal);
protected:
void turtle(int i = 7, int j);
int param;
double* graphicDimension;
Buggy::Buggy(int param)
param = param;
graphicDimension = new double;
Buggy::-Buggy()
double Buggy::fjord(double inVal)
return inVal * param;
int Buggy::fjord(double inVal)
return (int)fj ord(inVal);
void Buggy::turtle(int i, int j)
cout << "i равно " << i << ", j равно " << j << endl;
Приложение А. Готовимся к С++-интервью 873
Внимательно просмотрите этот код, а затем в качестве ответа представьте
следующую корректную его версию.
#include <iostream> // В реализации используются потоки.
class Buggy
{
public: // Эти члены класса должны быть открытыми, в
// противном случае этот класс попросту бесполезен.
Buggy(int inParam); // Присваивание имени параметру.
-Buggy();
Buggy(const Buggy& src); // Определяем конструктор копии
// и оператор присваивания, если
Buggy& operator=(const Buggy& rhs); // в классе динамически
// выделяется память.
double fjord(double inVal); // int-версия не скомпилируется
// (перегруженные методы
// отличаются лишь типом
// возвращаемого значения).
// Этот вариант также
// бесполезен, поскольку он
// просто возвращает заданный
// аргумент.
protected:
void turtle(int i, int j); // Только последние аргументы
// могут быть заданы по
// умолчанию.
int mParam; // Присваивание имени члену данных.
double* graphicDintension;
};
Buggy::Buggy(int inParam) : mParam(inParam) // Устраняем
// неоднозначность в присваивании имен.
{
graphicDimension = new double;
}
Buggy::-Buggy()
{
delete graphicDimension; // Устраняем утечку памяти.
}
Ви99У::Buggy(const Buggy& src)
{
graphicDimension = new double,-
♦graphicDimension = *(src.graphicDimension);
874 Часть VI. Приложения
}
Buggy& Buggy::operator=(const Buggy& rhs)
{
if (this == &rhs) {
return (*this);
}
delete graphicDimension;
graphicDimension = new double;
♦graphicDimension = *(rhs.graphicDimension);
return (*this);
}
double Buggy::fjord(double inVal)
{
return inVal * mParam,- // Измененное имя члена данных.
}
void Buggy::turtle(int i, int j)
{
std::cout << "i равно " << i << ", j равно " << j
<< std::endl; // пространства имен
}
Глава 10: осваиваем механизм наследования
Вопросы о наследовании обычно задаются в таких же формах, что и вопросы
о классах. Интервьюер может также предложить реализовать иерархию классов,
чтобы убедиться в том, что вы достаточно серьезно работали с C++ и можете вывести
подклассы, не подглядывая в учебник.
О чем не следует забывать
□ Синтаксис выведения подклассов.
□ Различие между закрытыми и защищенными членами с точки зрения
производных классов.
□ Перегрузка методов и использование виртуальных методов.
□ Цепочка вызовов конструкторов.
□ Преобразование типа в восходящем и нисходящем направлениях.
□ Принципы полиморфизма.
□ Чисто виртуальные методы и абстрактные базовые классы.
□ Множественное наследование.
□ Получение информации о типе (RTTI).
Приложение А. Готовимся к С++-интервью 875
Типы вопросов
В "ловушки" при рассмотрении темы наследования можно попасть из-за
невнимательного отношения к деталям. При написании базового класса не забудьте сделать
его методы виртуальными. Если вы так поступите со всеми методами, приготовьтесь
пояснить свое решение. Вы должны знать, что такое виртуальный метод и как он
работает. Точно так же не забудьте в определении подкласса перед именем
родительского класса поставить ключевое слово public (например, class Foo : public Bar).
Маловероятно, чтобы во время интервью вас попросили продемонстрировать
неоткрытое наследование.
Вопросы о наследовании могут быть и потруднее. Например, о
взаимоотношениях между суперклассом и подклассом. Вы должны уверенно чувствовать себя,
говоря о различных уровнях доступа, а также о различии между спецификаторами
private и protected. Вспомните, что под понятием расслоение (slicing)
понимается ситуация, когда при выполнении некоторых типов операций приведения
происходит потеря классом его "подклассового сознания" (т.е. потеря
переопределенных методов и данных подкласса).
Глава 11: пишем обобщенный код
с помощью шаблонов
Шаблоны — это прекрасный способ для интервьюера отделить новичков в C++ от
"профи". Несмотря на то что большинство интервьюеров простит, если вы забудете
этот сложный синтаксис, все-таки, идя на интервью, вам следует владеть
необходимым минимумом знаний в этой области.
О чем не следует забывать
□ Как написать базовый шаблонный класс.
□ Два основных недостатка шаблонов: ужасный синтаксис и "раздутый" код.
□ Как использовать шаблонный класс.
Типы вопросов
Многие вопросы отталкиваются от простой проблемы и постепенно "набирают
обороты", т.е. становятся все более сложными. Зачастую интервьюеры имеют в
запасе бесконечную цепочку все более сложных вопросов, с помощью которых они
просто хотят понять, до какого уровня вы способны дойти. Например, интервьюер
может начать с того, что предложит вам создать класс, который предоставляет
последовательный доступ к фиксированному количеству int-значений. Затем этот
класс вам придется расширить, чтобы он включал массив произвольного размера.
Затем интервьюер захочет, чтобы этот класс мог работать с произвольными типами
данных, т.е. вот тут наступает момент, когда "на сцену" должен выйти шаблон. С
этого места интервьюер может развивать события в различных направлениях,
предложив вам, например, использовать механизм перегрузки операторов для обеспечения
синтаксиса, подобного тому, который применяется для доступа к элементам массива,
или углубиться в тему шаблонов и предложить вам задать значения по умолчанию для
шаблонных параметров-типов.
876 Часть VI. Приложения
Зная, что синтаксис шаблонов достаточно сложен, интервьюер обычно
удовлетворяется пониманием основ этой темы, поскольку предлагать кандидату написать
шаблонный код прямо во время собеседования было бы довольно жестоко с его стороны.
Глава 12: причуды и странности C++
Многие интервьюеры имеют привычку рассматривать нестандартные случаи,
поскольку именно так можно выявить опытных С++-программистов, которые уже
освоили самые "темные углы" языка C++. Порой у интервьюера возникают проблемы
с интересными вопросами и он предлагает кандидату самому продемонстрировать
знание "трудных тем".
О чем не следует забывать
□ Ссылки должны быть связаны с переменными при их объявлении, и эту связь
изменить нельзя.
□ Преимущества передачи параметров по ссылке перед передачей по значению.
□ Различные случаи использования модификатора const.
□ Различные случаи использования ключевого слова static.
□ Различные виды операторов приведения типов в C++.
Типы вопросов
Предложение определить спецификаторы const и static— классика вопросов
во время С++-интервью. Ответы на них позволяют понять многое о том, кто их дает.
Например, посредственный кандидат расскажет лишь о статических методах и
статических членах данных. Хороший приведет удачные примеры static-методов
и static-членов данных. Прекрасный кандидат поведает также о статическом
связывании и статических переменных в функциях.
Часто на эту тему также дают задания по отысканию ошибок в коде. Здесь надо
держать "ухо востро" в отношении неправильного применения ссылок. Например,
представьте класс, который в качестве члена данных содержит ссылку.
class Gwenyth
{
public:
int& mCaversham;
};
Поскольку член mCaversham является ссылкой, то при создании класса она
должна связываться с некоторой переменной. Для этого необходимо использовать список
инициализаторов. Следующий класс мог бы принимать переменную, адресуемую
ссылкой в качестве параметра его конструктора.
class Gwenyth
{
public:
Gwenyth(int i) ,-
Приложение А. Готовимся к С++-интервью 877
int& mCaversham;
};
Gwenyth::Gwenyth(int i) : mCaversham(i)
Глава 13: эффективное управление
памятью
Вопросы, связанные с управлением памятью, обычно предназначены для
программистов низкого уровня или С++-программистов с недавним опытом работы на С.
Цель этих вопросов — выявить, насколько далеко объектно-ориентированные
аспекты C++ "отодвинули" кандидата от базовых деталей реализации. Вопросы по этой
теме позволят вам доказать, что вы знаете, что происходит на самом деле.
О чем не следует забывать
□ Для использования области памяти "кучи" вместо С-функций malloc () и free ()
следует применять операторы new и delete.
□ Для массивов нужно использовать версии операторов new [ ] и delete [].
□ Если у вас есть массив указателей на объекты, вам необходимо выделять память
для каждого отдельного указателя и освобождать эту память, поскольку
синтаксис выделения памяти для массивов "не озабочен" проблемами указателей!
□ При необходимости вы всегда можете сказать: "Безусловно, на практике для
обнаружения причин утечки памяти я воспользуюсь утилитой valgrind!".
Типы вопросов
Если вам предложат найти в программе ошибку, то необходимо обратить
внимание на возможность двойного удаления (т.е. двойного выполнения оператора delete
для одного и того же указателя), смешанного использования в одной программе
операторов new/new [ ] и ситуации утечки памяти. При внимательном исследовании
кода, в котором интенсивно используются указатели и массивы, следует постоянно
отслеживать состояние памяти, обновляя его после выполнения каждой строки кода.
Если вы схематично изобразите состояние памяти и покажете, как оно меняется по
мере выполнения предложенной вам программы, у интервьюера не останется
сомнений в том, что вы разбираетесь в теме управления памятью.
Часто для выяснения того, насколько хорошо кандидат понимает, что происходит
с памятью компьютера, ему предлагают рассказать, чем указатели отличаются от
массивов. Если вам показалось, что этот вопрос может застичь вас врасплох, перечитайте
заранее соответствующие разделы главы 13.
878 Часть VI. Приложения
Глава 14: использование С++-потоков
ввода-вывода
Если вы проходите собеседование как кандидат на должность разработчика GUI-
приложений, то вам, скорее всего, не будут задавать вопросы по потокам ввода-
вывода, поскольку в GUI-приложениях обычно используются другие механизмы
ввода-вывода. Однако применение потоков в качестве стандартных средств языка C++
можно определить, как игру по правилам, которые вам должны быть хорошо
известны, поэтому интервьюер может посчитать своим долгом проверить эту часть
"обязательной программы", тем более, что потоки могут фигурировать в других
проблемах и "проходить по делу не только в качестве свидетелей".
О чем не следует забывать
□ Определение потока.
G Использование входных и выходных потоков.
□ Применение манипуляторов.
□ Типы потоков (консольные, файловые, строковые).
□ Методы обработки ошибок.
□ Решение проблемы локализации программ.
Типы вопросов
Тема ввода-вывода данных может быть затронута в контексте любого вопроса по
программированию. Например, интервьюер может предложить вам закодировать
считывание файла, содержащего баллы студентов по тестам, и поместить эти данные
в контейнер типа vector. Ваш ответ позволит интервьюеру проверить базовые
навыки программирования на C++, знание библиотеки STL и основы механизма ввода-
вывода. Несмотря на то что средства ввода-вывода составляют небольшую часть
решения предложенной вам задачи, нужно постараться обойтись без элементарных
ошибок. В противном случае у интервьюера возникнут сомнения в вашей способности
написать гениальные программы в ближайшем будущем.
Вас могут напрямую и не спросить о локализации программ, но, желая заработать
несколько дополнительных очков, вы можете сами продемонстрировать свое
дружелюбие ко всему миру, заменив прямо на глазах у умиленного интервьюера тип char типом
wchar_t. Если же вам зададут вопрос о личном опыте в области локализации программ,
не примените заявить о важности учета этого аспекта с самого начала разработки,
показав тем самым, что вы не понаслышке знаете о существовании С++-средств локализации.
Глава 15: обработка ошибок
Менеджеры, как правило, не горят желанием принимать на работу недавних
выпускников или начинающих программистов для выполнения ответственных заданий (или
на высокооплачиваемые должности), поскольку предполагается, что они не в состоянии
написать качественный код. У вас есть шанс доказать интервьюеру, что ваша программа
не "свалится" в самый неподходящий момент, продемонстрировав прямо во время
собеседования свои навыки в использовании средств обработки ошибочных ситуаций.
Приложение А. Готовимся к С++-интервью 879
О чем не следует забывать
□ Перехватывать объекты исключений имеет смысл с использованием const-
ссылок.
□ Следует перехватывать и обрабатывать все возможные исключения,
сгенерированные в программе.
□ Обработка списков типов генерируемых исключений (throw-списков) в языках
C++ и Java различна!
□ Интеллектуальные указатели позволяют предотвратить возможность утечки
памяти при обработке исключений.
Типы вопросов
Вряд ли вам зададут прямой вопрос об исключениях, если только разговор во
время собеседования не зайдет в уж очень узкую конкретику, например, о том, как
происходит "раскрутка" стека (ведь обработка исключений позволяет пропустить ряд
уровней в стеке вызовов). Однако интервьюеру будет любопытно узнать, как вы умеете
сообщать об ошибках и обрабатывать их.
Безусловно, не все программисты понимают или по достоинству ценят
исключения. Некоторые могут быть даже против их использования по причинам, связанным
с производительностью. Если интервьюер предложит вам обойтись без исключений,
вернитесь к традиционным проверкам на NULL-значения и кодам ошибок. Это как раз
тот подходящий момент, когда вы можете с блеском продемонстрировать знание
nothrow-версии оператора new!
Глава 16: перегрузка С++-операторов
Возможно, хотя и маловероятно, что во время интервью вам предложат
выполнить что-то посложнее, чем просто перегрузить заданный оператор. Некоторые
интервьюеры любят задавать "заковыристые" вопросы, даже не надеясь получить
правильный ответ. Сложности перегрузки операторов позволяют "срезать" почти
любого кандидата, поскольку лишь немногие из них могут без подглядки в справочник
справиться с синтаксисом этого механизма. Все вышесказанное означает, что перед
собеседованием рекомендуется просмотреть эти разделы языка очень внимательно.
0 чем не следует забывать
□ Как перегрузить операторы работы с потоками ввода-вывода.
□ Что такое функтор и как его создать.
О Что выбрать: операторный метод (operator) или глобальную friend-функцию.
□ Некоторые операторы можно выразить через другие (например, оператор
operator<= можно написать в виде отрицания результата выполнения
оператора operators-).
Типы вопросов
1 Давайте посмотрим правде в глаза: вопросы по теме перегрузки операторов (не
считая самых простых) относятся к одним из самых суровых. Тот, кто задает такие
вопросы, прекрасно это знает и даже удивляется при получении правильного ответа.
880 Часть VI. Приложения
Конечно, невозможно предсказать, какой вопрос вы получите, но ведь и количество
операторов ограничено. Поэтому не стоит опускать руки: если перед интервью вы
внимательно рассмотрите пример перегрузки каждого оператора, который имеет
смысл перегружать, то вы справитесь с этим испытанием! '
Помимо предложения реализовать перегруженный оператор, вам могут задать
вопросы более высокого уровня. Например, если вам предложат найти ошибку в коде,
то следует иметь в виду, что такая тестовая программа может содержать оператор,
который перегружен для выполнения действий, концептуально далеких от действия,
реализуемого встроенной версией этого оператора. При этом вам стоит держать в уме
не только синтаксис, но теоретические принципы перегрузки операторов.
Глава 17: создание эффективных
С++-программ
Вопросы по эффективности задаются на собеседованиях довольно часто,
поскольку многие организации сталкиваются с проблемой масштабируемости своих
приложений. Поэтому они хотят нанять программистов, которые в этом разбираются.
О чем не следует забывать
G Эффективность на уровне языка — важный фактор, но он действует до
определенных пределов. Более эффективными оказываются решения, принятые на
уровне проектирования.
□ Параметры-ссылки более эффективны, поскольку они позволяют избежать
копирования.
□ Пулы объектов дают возможность избежать неоправданных затрат системных
ресурсов на создание и разрушение объектов.
□ Анализ профиля программы позволяет определить, на выполнение каких
операций уходит больше всего времени.
Типы вопросов
Не исключено, что в качестве примера для задания вопросов по эффективности
интервьюер может использовать собственный продукт, описав симптомы проблемы,
связанной с недостаточной производительностью. Предполагается, что кандидат
предложит новый проект, в котором эта проблема будет устранена. К сожалению,
такие вопросы случаются на собеседованиях, хотя с нашей точки зрения они не
позволяют объективно оценить достоинства кандидата, ведь шансы, что его ответ совпадет
с решением, которое реализовал интервьюер, минимальны. Поэтому вам нужно
найти сильные доводы в пользу своего решения. И пусть оно отличается от реального,
но, может быть, оно еще лучше?
В качестве других вариантов вопросов по эффективности может прозвучать
предложение "вылизать" заданный С++-код. Например, интервьюер может показать вам
программу, которая содержит действия по созданию ненужных копий или
неэффективные циклы.
Приложение А. Готовимся к С++-интервью 881
Глава 18: разработка межплатформенных
приложений
Некоторые программисты указывают в своих резюме только один язык
программирования или технологию и несколько крупных приложений, построенных на
основе одного языка или технологии. Несмотря на то что вас интервьюируют на
должность С++-программиста, вас, тем не менее, могут спросить и о других языках,
особенно их взаимоотношениях с C++.
О чем не следует забывать
□ Аспекты, по которым могут различаться платформы (архитектура, размеры).
□ Тонкая грань между программированием и написанием сценариев.
□ Взаимодействия между C++ и другими языками.
Типы вопросов
Самый популярный вопрос по межъязыковым объединениям — сравнить два
различных языка программирования. Вам не следует говорить только о достоинствах
или только о недостатках конкретного языка, даже если вы действительно терпеть не
можете язык Java. Часто интервьюера интересует, способны ли вы увидеть
компромиссные варианты и на их основе построить соответствующие решения.
Вопросы по межплатформенным приложениям могут в большей степени касаться
ваших предыдущих работ. Если вы в своем резюме указали, что однажды написали
С++-приложение, которое могло работать на нестандартной платформе, то должны быть
готовы к вопросу об используемом вами компиляторе и особенностях той платформы.
Глава 19: становимся экспертами в области
тестирования программ
Потенциальные работодатели ценят наличие у кандидата навыков в области
тестирования программ. Если вы не указали в своем резюме опыт работы в группе
обеспечения высокого качества продукции (QA), то вас могут напрямую спросить о том,
что вы знаете о тестировании.
О чем не следует забывать
□ Различие между тестированием методом прозрачного ящика и тестированием
с алгоритмом типа черного ящика.
□ Принципы поэлементного тестирования и написание тестов по ходу создания кода.
□ Тестирование более высокого уровня.
□ Методы комплексного тестирования и QA-среды, которые вы использовали
прежде: что работало, а что — нет?
882 Часть VI. Приложения
Типы вопросов
Интервьюер может предложить вам написать несколько тестов прямо во время
собеседования, но вряд ли программа, которую вам дадут для тестирования, будет
обладать глубиной, необходимой для написания интересных тестов. Более вероятно,
что вам зададут вопросы по тестированию высокого уровня, Вполне ожидаемо, что
вас спросят о том, как проводилось тестирование в компании, где вы работали
раньше, и что вам в этом нравилось, а что — нет. Ответив на этот вопрос, вы можете
задать его же своему интервьюеру. Это может стать началом разговора, из которого вы
поймете, в какой среде вам, возможно, предстоит работать.
Глава 20: что нужно знать об отладке
Часто компаниям нужны специалисты, которые могут отлаживать как
собственный, так и чужой код. Поэтому интервьюер, возможно, захочет оценить ваш
потенциал по этой части.
О чем не следует забывать
□ Процесс отладки должен начинаться не тогда, когда уже появились ошибки;
вам следует вооружить свою программу отладочными средствами заранее,
чтобы вы были готовы к борьбе с ошибками и быстрой победе над ними.
□ Журналы регистрации ошибок и отладчики позволяют хорошо справиться
с этой задачей.
□ Может оказаться, что симптом, демонстрируемый ошибкой, совсем не связан
с ее реальной причиной.
□ Успешной отладке может способствовать получение "снимка" памяти.
Типы вопросов
Во время интервью вам могут предложить выполнить отладку заданного кода. Если
вам не удастся сразу найти ошибку, опишите, какие действия необходимо выполнить
для этого. Интервьюер может оценить ваши теоретические знания в области отладки
так же высоко, как если бы вы сразу нашли ошибку.
Главы 21, 22 и 23: стандартная библиотека
шаблонов
Использование библиотеки STL потенциально сопряжено с определенными
трудностями. Интервьюер может потребовать от вас описания деталей STL-классов,
и лучше к этому подготовиться заранее. Если вы знаете, что потенциальная работа
связана с STL, вам следует до интервью написать специальный STL-код. Это должно
хорошо освежить вашу память.
Приложение А. Готовимся к С++-интервью 883
О чем не следует забывать
□ Различные типы контейнеров и их отношения с итераторами.
□ Базовое использование контейнера vector, который считается самым
популярным STL-классом.
□ Использование ассоциативных контейнеров, например отображения (тар).
□ Назначение STL-алгоритмов.
□ Возможности расширения библиотеки STL (без деталей).
□ Ваше собственное мнение о библиотеке STL.
Типы вопросов
Если интервьюеры "поведены" на использовании библиотеки STL, то диапазон
вопросов по этой теме может быть чрезвычайно широким. Если вы чувствуете
неуверенность в синтаксисе, не теряйте присутствия духа и скажите: "В реальной жизни я,
конечно, найду это в книге C++ для профессионалов, но я совершенно уверен, что это
должно работать так...". По крайней мере интервьюер может простить выпущенные из
виду подробности, но вы должны убедить его, что правильно понимаете основные идеи.
По ответам на некоторые "индикаторные" вопросы интервьюер может судить
о том, насколько интенсивно вы использовали библиотеку STL в своем недавнем
прошлом. Например, нерегулярные пользователи STL вполне могут знать о
существовании ассоциативных и неассоциативных контейнеров. Более "продвинутый"
пользователь способен определить итератор и описать, как итераторы работают с
контейнерами. Ваш опыт работы с библиотекой STL станет для интервьюера очевиден,
если вы уверенно расскажете ему о работе с STL-алгоритмами и собственном варианте
расширения этой библиотеки.
Глава 24: исследование распределенных
объектов
Поскольку разработка распределенных приложений стала сейчас вполне обычным
делом, вам могут предложить спроектировать распределенную систему или задать
вопросы о конкретной технологии построения распределенных приложений.
О чем не следует забывать
□ Причины использования распределенных вычислений.
□ Различия между распределенными и сетевыми вычислениями.
□ Принципы сериализации и удаленного вызова процедуры (RPC).
□ Детали использования таких технологий, как CORBA или XML.
Типы вопросов
Многие резюме пестрят акронимами и модными словечками. Если вы в резюме
просто укажете технологию XML (в числе прочих), потенциальный работодатель не
сможет сделать вывод об уровне ваших навыков по этой части. Если вы не
конкретизируете этот момент сами, определив себя "базовым XML-пользователем" или "XML-
884 Часть VI. Приложения
экспертом", то можете ожидать вопросы, проясняющие степень вашего знакомства с
указанной технологией. Что касается XML, то вам могут предложить определить
такой термин, как схема, или написать схему, применимую к данному документу.
Поскольку аббревиатура XML обрела нынче довольную широкую популярность,
один из авторов стал практиковать использование простого XML-документа на
интервью. Кандидату предлагают указать в нем все атрибуты, все элементы и все
текстовые узлы. Такой подход сразу же ставит кандидата "на место", но зато эффективно
проявляет, работал ли интервьюируемый с технологией XML или он просто
понимает, что это HTML-подобный синтаксис.
Глава 25: объединим возможности
технологий и оболочек
Каждая из тем, рассмотренных в главе 25, может послужить прекрасным вопросом
для интервью. Мы не будем повторять то, о чем вы прочитали в этой главе, но
советуем перед собеседованием внимательно ее просмотреть и обрести уверенность в том,
что вы действительно разбираетесь в каждом из описанных там методов.
Глава 26: применение шаблонов
проектирования
Поскольку шаблоны проектирования стали весьма популярными в
профессиональном мире (многие кандидаты даже указывают их в своих резюме в качестве навыков), то
весьма вероятно, что вам попадется интервьюер, который захочет, чтобы вы
разъяснили суть шаблона, описали ситуацию для его применения или даже реализовали его.
О чем не следует забывать
□ Основная идея шаблонов в качестве принципа многократного использования
объектно-ориентированных проектов.
□ Примеры шаблонов, о которых вы прочитали в этой книге или с которыми вам
приходилось работать.
□ Факт существования множества шаблонов со сходными именами, в результате
чего вы и интервьюер можете использовать различные слова для названия
одного и того же шаблона.
Типы вопросов
Ответы на вопросы по теме шаблонов проектирования можно сравнить с беседой
во время прогулки в парке, если, конечно, интервьюер не захочет узнать детали
каждого отдельного шаблона, известного человечеству. К счастью, большинство
программистов, которые отдают должное шаблонам проектирования, просто захотят
получить удовольствие, побеседовав с вами на эту тему, и узнать ваше мнение об этом.
Мы считаем, что лучше не пытаться запомнить множество шаблонов, а еще раз
"пройти" основные идеи, описанные в этой книге или почерпнутые, например
в Internet, — чем не прекрасный шаблон для вашего поведения перед собеседованием!
Аннотированная
библиография
Это приложение содержит список книг и Internet-ресурсов по различным
связанным с языком C++ темам, к которым мы либо обращались сами при написании этой
книги, либо рекомендуем читателю для дальнейшего изучения.
C++
Начальный курс по C++
1. Harvey M. Deitel, Paul J. Deitel, C++ How to Program (Fourth Edition), Prentice
Hall, 2002, ISBN: 0-130-38474-7.
Известна просто как книга "Deitel", которая не предполагает у читателя
предыдущего опыта программирования.
2. Bruce Eckel, Thinking in C++, Volume 1: Introduction to Standard C++ (Second
Edition), Prentice Hall, 2000, ISBN: 0-139-79809-9.
Прекрасное введение в программирование на C++, которое предусматривает,
что читатель имеет опыт программирования на языке С. Доступна для
бесплатной загрузки с сайта по адресу: www. bruceeckel. com.
886 Часть VI. Приложения
3. Стенли Б. Липпман, Жози Лажойе, Язык программирования C++. Вводный
курс, 4-е изд. Пер. с англ. — М.: Издат. дом "Вильяме", 2006.
Эта книга не требует предварительных знаний именно языка C++, но
предполагает знакомство с каким-нибудь объектно-ориентированным языком
программирования высокого уровня.
4. Steve Oualline, Practical C++ Programming (Second Edition), O'Reilly, 2003,
ISBN: 0-596-00419-2.
Введение в C++, допускающее отсутствие какого-либо предыдущего опыта
программирования.
5. Walter Savitch, Problem Solving with C++: The Object of Programming (Fourth
Edition), Addison Wesley Longman, 2002, ISBN: 0-321-11347-0.
Для чтения этой книги предыдущий опыт программирования необязателен.
Часто используется в качестве учебника по вводным курсам программирования.
Общий курс по C++
6. Marshall Cline, C++ FAQ LITE, www. parashift .com/c++-faq-lite.
7. Marshall Cline, Greg Lomow, Mike Giru, C++ FAQs (Second Edition), Addison
Wesley, 1998, ISBN: 0-201-30983-1.
Эта компиляция часто задаваемых вопросов от сетевой конференции с адресом
comp. lang.C++ полезна для быстрого поиска ответа по конкретной С++-теме.
Напечатанная версия содержит больше информации по сравнению с
электронной, но для большинства профессиональных программистов сетевого
материала вполне достаточно.
8. Stephen С. Dewhurst, C++ Gotchas, Addison Wesley, 2003, ISBN: 0-321-12518-5.
Содержит 99 конкретных советов по С++-программированию.
9. Bruce Eckel, Chuck Allison, Thinking in C++, Volume 2: Practical Programming
(Second Edition), Prentice Hall, 2003, ISBN: 0-130-35313-2.
Второй том этой книги охватывает больше серьезных С++-тем. Он также
доступен для бесплатной загрузки с сайта по адресу: www. bruceeckel. com.
10. Ray Lischner, C++ in a Nutshell, O'Reilly, 2003, ISBN: 0-596-00298-X.
Справочник по C++, включающий материал в диапазоне от основ до более
сложных разделов.
11. Scott Meyers, Effective C++ (Second Edition): 50 Specific Ways to Improve Your
Programs and Designs, Addison Wesley, 1998, ISBN: 0-201-92488-9.
12. Scott Meyers, More Effective C++: 35 New Ways to Improve Your Programs and
Designs, Addison Wesley, 1996, ISBN: 0-201-63371-X.
Эти две книги содержат мудрые советы и "хитрые" приемы программирования
по тем средствам C++, которые чаще всего используются неправильно или
воспринимаются с трудом при изучении языка.
13. Стивен Прата, Язык программирования C++. Лекции и упражнения, 5-е изд.
Пер. с англ. — М.: Издат. дом "Вильяме", 2006..
Одна из самых исчерпывающих книг по C++.
Приложение Б. Аннотированная библиография 887
14. Bjarne Stroustrup, The C++ Programming Language (Special Third Edition),
Addison Wesley, 2000, ISBN: 0-201-70073-5.
"Библия" по C++, написанная самим изобретателем языка. Каждый С++-програм-
мист должен иметь собственную копию этой книги, но для новичков некоторые
ее места могут быть непонятными.
15. The C++ Standard: Incorporating Technical Corrigendum No. 1, John Wiley &
Sons, 2003, ISBN: 0-470-84674-7.
В этой книге почти 800 страниц текста, набранного убористым шрифтом. В ней
вы не найдете разъяснений, как использовать язык C++, а лишь формальные
правила. Мы не рекомендуем вам обращаться к этой книге, если вы не
собираетесь докапываться до каждой детали C++.
16. Конференции по адресу: http: //groups. google. com, включая comp. lang. C++.
moderated и comp.std.C++.
Эти конференции могут предложить море полезной информации для тех, кто
хочет пройти сквозь битвы, поражения и (порой) дезинформацию.
17. The C++ Resources Network по адресу: www. cplusplus. com/.
Эта Web-страница не так уж полезна, как обещает ее название. В момент
написания этих слов раздел справки по C++ все еще находился "на реконструкции".
Потоки ввода-вывода
18. Cameron Hughes, Tracey Hughes, Mastering the Standard C++ Classes: An
Essential Reference, Wiley, 1999, ISBN: 0-471-328-936.
Прекрасная книга, чтобы научиться писать пользовательские классы istream
и ostream.
19. Cameron Hughes, Tracey Hughes, Stream Manipulators and Iterators in C++,
Professional Technical Reference, Prentice Hall, http: //phptr. com/articles/
article.asp?p=171014&seqNum=2.
Эта статья, здорово написанная авторами известной книги Mastering the Standard
C++ Classes, снимает покров с определения пользовательских потоковых
манипуляторов в C++.
20. Philip Romanik, Amy Muntz, Applied C++: Practical Techniques for Building
Better Software, Addison Wesley, 2003, ISBN: 0-321-10894-9.
Помимо уникального сочетания советов по разработке программных
продуктов и деталей С++-программирования, в этой книге содержится одно из лучших
разъяснений, которые мы когда-либо читали по теме локализации и поддержки
Unicode в C++.
21. Joel Spolsky, The Absolute Minimum Every Software Developer Absolutely,
Positively Must Know About Unicode and Character Sets (No Excuses!),
www.joelonsoftware.com/articles/Unicode.html.
Прочитав трактат этого автора о важности темы локализации, вы обязательно
захотите узнать, что еще он написал по разработке программных продуктов.
22. Unicode, Inc., Where is my Character?, www. Unicode . org/standard/where.
Наилучший источник, в котором можно найти Unicode-символы, диаграммы
и таблицы.
888 Часть VI. Приложения
Стандартная библиотека C++
23. Nicolai M. Josuttis, The C++ Standard Library: A Tutorial and Reference, Addison
Wesley, 1999, ISBN: 0-201-37926-0.
Эта книга содержит полное описание стандартной библиотеки, включая
потоки ввода-вывода и строки, а также контейнеры и алгоритмы. Это великолепный
справочник.
24. Scott Meyers, Effective STL: 50 Specific Ways to Improve Your Use of the Standard
Template Library, Addison Wesley, 2001, 0-201-74962-9.
Автор написал эту книгу в том же духе, что книги "Effective C++". Она включает
конкретные рекомендации по использованию библиотеки STL, но не является
справочником или учебником.
25. David R. Musser, Gillmer J. Derge, Atul Saini, STL Tutorial and Reference Guide
(Second Edition), Addison Wesley, 2001, ISBN: 0-201-37923-6.
Эта книга построена по аналогии с [23], но охватывает только ту часть
стандартной библиотеки, которая посвящена STL.
С++-шаблоны
26. Herb Sutter, Sutter's Mill: Befriending Templates, C/C++ User's Journal,
www.cuj.com/ documents/s=8244/cujcexp2101sutter/sutter.htm.
Здесь мы нашли самые лучшие разъяснения по созданию шаблонных функций
в качестве "друзей" классов.
27. Дэвид Вандевурд, Николай М. Джосаттис, Шаблоны C++: справочник
разработчика. Пер. с англ. — М.: Издат. дом "Вильяме", 2003.
Здесь вы найдете все, что хотели знать (и даже не хотели) о С++-шаблонах. Эта
книга предполагает серьезную предварительную подготовку по общему курсу C++.
Язык С
28. Брайан У. Керниган, Деннис М. Ритчи, Язык программирования С, 2-е изд.
Пер. с англ. — М.: Издат. дом "Вильяме", 2005.
Эта книга, или, как ее называют, "К and R", — прекрасный справочник по
языку С. Но ее не стоит использовать новичкам.
29. Peter Prinz, Tony Crawford (переводчик), Ulla Kirch-Prinz, С Pocket
Reference, O'Reilly, 2002, ISBN: 0-596-00436-2.
Краткий справочник по языку С.
30. Eric S. Roberts, The Art and Science of C: A Library Based Introduction to
Computer Science, Addison Wesley, 1994, ISBN: 0-201-54322-2.
31. Eric S. Roberts, Programming Abstractions in C: A Second Course in Computer
Science, Addison Wesley, 1997, ISBN: 0-201-54541-1.
Эти две книги представляют собой прекрасное введение в программирование
на С. Они часто используются в качестве учебников по вводным курсам
программирования.
Приложение Б. Аннотированная библиография 889
32. Peter Van Der Linden, Expert С Programming: Deep С Secrets, Pearson
Education, 1994, ISBN: 0-131-77429-8.
Наставительный и зачастую истеричный взгляд на язык С, его эволюцию
и внутренние механизмы.
Интеграция C++ и других языков
программирования
33. Ian F. Darwin Java Cookbook, O'Reilly, 2001, ISBN: 0-596-00170-3.
В этой книге содержатся пошаговые инструкции по использованию JNI для
интеграции языка Java с другими языками программирования, включая C++.
Алгоритмы и структуры данных
34. Томас X. Кормен, Чарльз И. Лейзерсон, Рональд Л. Ривест, Клиффорд
Штайн, Алгоритмы: построение и анализ, 2-е изд. Пер. с англ. — М.: Издат. дом
"Вильяме", 2005.
Эта книга представляет собой одно из самых популярных введений в
алгоритмы, охватывающее все общеизвестные структуры данных и алгоритмы. Мы (еще
будучи студентами) по первому изданию этой книги изучали алгоритмы
и структуры данных.
35. Кнут Д.Э., Искусство программирования: В 3-х т. Т. 1: Основные алгоритмы,
3-е изд.: Пер. с англ. — М.: Издат. дом "Вильяме", 2000. — 720 с.
36. Кнут Д.Э., Искусство программирования: В 3-х т. Т. 2: Получисленные методы,
3-е изд.: Пер. с англ. — М.: Издат. дом "Вильяме", 2000. — 832 с.
37. Кнут Д.Э., Искусство программирования: В 3-х т. Т. 3: Сортировка и поиск,
3-е изд.: Пер. с англ. — М.: Издат. дом "Вильяме", 2000. — 832 с.
Для тех, кто получает наслаждение от математической строгости, не написано
пока лучшей книги по алгоритмам и структурам данных, чем этот трехтомник
Кнута. Однако он, скорее всего, будет недоступен для читателей без
университетского курса математики или теории вычислительной техники.
38. Kyle Loudon, Mastering Algorithms with С, O'Reilly, 1999, ISBN: 1-565-92453-3.
Доступный справочник по структурам данных и алгоритмам.
Открытые программные средства
39. The Open Source Initiative по адресу: www. opensource. org.
40. The GNU Operating System — Free Software Foundation по адресу: www. gnu. org.
На этих Web-страницах сторонники двух основных движений распространения
открытых программных средств разъясняют свои принципы и предлагают ин-
890 Часть VI. Приложения
формацию о получении программных продуктов такого рода и внесении вклада
в их движение.
41. sourceforge.net по адресу: www. sourcef orge. net.
Этот Web-сайт содержит множество проектов с открытым исходным кодом.
Это прекрасный источник для поиска полезных открытых программных средств.
Методология разработки программного
обеспечения
42. Barry W. Boehm, TRW Defense Systems Group, A Spiral Model of Software
Development and Enhancement, IEEE Computer, 21(5):61-72, 1988.
В этом эпохальном произведении описано нынешнее состояние подходов
к разработке программного обеспечения и предложена для рассмотрения
спиральная модель.
43. Kent Beck, Extreme Programming Explained: Embrace Change, Pearson
Education, 1999, ISBN: 0-201-61641-6.
Одна из нескольких книг серии, которая представляет экстремальное
программирование как новый подход к разработке программного обеспечения.
44. Роберт Т. Фатрелл, Дональд Ф. Шафер, Линда И. Шафер, Управление
программными проектами: достижение оптимального качества при минимуме
затрат. Пер. с англ. — М.: Издат. дом "Вильяме", 2003.
Путеводитель для тех, кто отвечает за управление процессом разработки
программных продуктов.
45. Robert L. Glass, Facts and Fallacies of Software Engineering, Pearson Education,
2002, ISBN: 0-321-11742-5.
В этой книге рассматриваются различные аспекты процесса разработки
программных продуктов и раскрываются прежде скрытые трюизмы.
46. Philippe Kruchten, Rational Unified Process: An Introduction (Second Edition),
Addison Wesley, 2000, ISBN: 0-201-70710-1.
Описание принципов рационального унифицированного процесса (RUP), его
назначения и эволюции.
47. Edward Yourdon, Death March (Second Edition), Prentice Hall, 2003, ISBN: 0-131-
43635-X.
Поразительно поучительная книга о политике и реальности разработки
программных продуктов.
48. Rational Unified Process from IBM, www3.software.ibm.com/ibmdl/pub/
software/rational/web/demos/viewlets/rup/runtime/index.html.
Web-сайт от компании IBM содержит богатую информацию о рациональном
унифицированном процессе, включающую интерактивное изложение по
указанному7 выше URL-адресу.
Приложение Б. Аннотированная библиография 891
Стиль программирования
49. Martin Fowler, Kent Beck, John Brant, William Opdyke, Don Roberts,
Refactoring: Improving the Design of Existing Code, Addison Wesley, 1999, ISBN: 0-
201-48567-2.
Классическая книга о том, как распознать и усовершенствовать "плохой" код.
50. James Foxall, Practical Standards for Microsoft Visual Basic .NET, Microsoft Press,
2002, ISBN: 0-7356-1356-7.
Здесь рассматриваются принципы стиля кодирования, используемые в Microsoft
Windows на основе Visual Basic.
51. Диомидис Спинеллис, Анализ программного кода на примере проектов Open
Source. Пер. с англ. — М.: Издат. дом "Вильяме", 2004.
Эта уникальная книга возвращается к проблеме стиля программирования путем
обучения программиста правильному чтению кода, что должно поднять его
уровень кодирования.
52. Dimitri van Heesch, Doxygen, http://www.stack.nl/~dimitri/doxygen/
index.html.
Программа с перестраиваемой конфигурацией, которая генерирует
документацию на базе исходного кода и содержащихся в нем комментариев.
Архитектура вычислительных систем
53. David A. Patterson, John L. Hennessy, Computer Organization & Design: The
Hardware/Software Interface (Second Edition), Morgan Kaufman, 1997, ISBN:
1-558-60428-6.
54. John L. Hennessy, David A. Patterson, Computer Architecture: A Quantitative
Approach (Third Edition), Morgan Kaufman, 2002, ISBN: 1-558-60596-7.
Эти две книги содержат всю информацию, которую большинству
программистов нужно знать об архитектуре вычислительных систем.
Эффективность
55. Dov Bulka, David Mayhew, Efficient C++: Performance Programming Techniques,
Addison Wesley, 1999, ISBN: 0-201-37950-3.
Одна из нескольких книг, посвященных исключительно эффективному
программированию на C++, описывает средства достижения производительности
как на уровне языка, так и на уровне проектирования.
56. GNU gprof, www.gnu. org/sof tware/binutils/manual/gprof -2 . 9 .1/
gprof.html.
Информация о средстве профилирования программ gprof.
57. Rational Software from IBM, www-3 06 . ibm. com/software/rational.
Rational Quantify — прекрасное средство профилирования программ (не
бесплатное).
892 Часть VI. Приложения
Тестирование
58. Elfriede Dustin, Effective Software Testing: 50 Specific Ways to Improve Your
Testing, Addison Wesley, 2002, ISBN: 0-201-79429-2.
Несмотря на то что эта книга предназначена для специалистов в области
поддержки качества программных продуктов, любой программист только
выиграет, если прочитает о процессе тестирования программного кода.
Отладка
59. The Gnu Debugger (GDB), адрес: www. gnu. org/sof tware/gdb/gdb. html.
GDB — великолепный символический отладчик.
60. Rational Software from IBM, www-306 . ibm. com/software/rational.
Rational Purify — прекрасное (но не бесплатное) средство отладки ошибок,
связанных с распределением памяти.
61. Valgrind, адрес: http: //valgrind. kde. org.
Средство отладки ошибок, связанных с распределением памяти, с открытым
исходным кодом для среды Linux.
Распределенные объекты
62. Jim Farley, Java Distributed Computing, O'Reilly, 1998, ISBN: 1-56592-206-9.
Содержит Java-ориентированный взгляд на технологии распределенных
вычислений.
63. Ron Hipschman, How SETI@home Works, http: //setiathome. ssl.berkeley. edu/
about_seti/about_seti_at__home_l. html.
Интересные сведения о проекте SETI@home, в котором распределенные
вычисления используются для анализа данных, полученных из космоса.
64. Sassafras Software, General KeyServer Questions, http://www.sassafras.com/
faq/general.html.
Информация о приложении KeyServer, в котором распределенные вычисления
применяются для управления лицензиями на использование программных
продуктов.
CORBA
65. Сайт "The Object Management Group's CORBA" по адресу: http: / /www. corba. org.
CORBA — это "продукт" рабочей группы по развитию стандартов объектного
программирования (Object Management Group— OMG). Данный Web-сайт
содержит основную информацию по этой теме и ссылки на действующие стандарты.
66. Michi Henning, Steve. Vinoski, Advanced CORBA Programming with C++,
Addison Wesley, 1999, ISBN: 0-201-379270-9.
Приложение Б. Аннотированная библиография 893
По теме CORBA книг, ориентированных на язык Java, гораздо больше, чем
с использованием языка C++. Эта книга делает акцент на применении C++ и,
несмотря на название, доступна для начинающих CORBA-программистов.
XML и SOAP
67. Ethan Cerami, Web Services Essentials, O'Reilly, 2002, ISBN: 0-596-00224-6.
В этой книге разъясняются принципы функционирования Web-служб и
рассматривается использование технологии SOAP для реализации
распределенных вычислений. Примеры приведены на языке Java.
68. Erik Т. Ray, Learning XML (Second Edition), O'Reilly, 2003, ISBN: 0-596-00420-6.
Фактически справочник по XML. Включает обзор таких "родственных"
технологий, как XML Schema, XPath и XHTML.
69. James Snell, Doug Tidwell, Pavel Kulchenko, Programming Web Services with
SOAP, O'Reilly, 2001, ISBN: 0-596-00095-2.
В этой книге рассматривается использование технологии SOAP, а также таких
"родственных" технологий, как UDDI и WSDL. Примеры приведены на языках
Java, Perl, C# и Visual Basic.
70. Eric van der Vlist, XML Schema, O'Reilly, 2002, ISBN: 0-596-00252-1.
В этой книге нашла всестороннее отражение такая трудная тема, как
использование технологии XML Schema.
71. Altova Software xmlspy, www. xml spy. com.
Информация о программном пакете xmlspy от компании Altova Software.
Шаблоны проектирования
72. Андрей Александреску, Современное проектирование на C++. Серия C++ 1п-
Depth, т. 3. Пер. с англ. — М.: Издат. дом "Вильяме", 2002.
В этой книге предложен подход к С++-программированию с акцентом на
создании многократно используемого кода и шаблонов проектирования.
73. Cunningham, Cunningham, The Portland Pattern Repository, www. c2 .com/cgi/
wiki?WelcomeVisitors.
Этот Web-сайт о шаблонах проектирования затягивает так, что от него
невозможно оторваться.
74. Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, Design Patterns:
Elements of Reusable Object-Oriented Software, Addison Wesley, 1995, ISBN: 0-
201-63361-2.
Эта книга, "в народе" называемая также "Бандой четырех" (по числу авторов),
содержит плодотворные идеи по теме шаблонов проектирования.
Предметный указатель
#
#defme, 30
#endif, 30
#ifdef, 30
#ifndef, 30
#include, 30
#pragma, 31
<algorithm>, 708
<bitset>, 688
<cassert>, 612
<cctype>, 706
<cstddef>, 749
<cstdlib>, 468; 485
<exception>, 462; 468; 472; 487
<fstream>, 447
<functional>, 699, 706
<iomanip>, 438
<ios>, 438
<iterator>, 732
<locale>, 456
<new>, 483; 485
<numeric>, 698; 708
<ostream>, 435
<queue>, 663; 666
<sstream>, 446
<stdexcept>, 466
<utility>, 670
<vector>, 640
abort(), функция, 468
API, 104; 109
Application programming interface, 109
asm, 572
assert, 612
Associative array, 510
auto_ptr, 121; 430
В
basic_string, класс, 419
BeOS, 830
Big-endian, 557
Binder, 703
bitset, 688
Bjarne Stroustrup, 29
bool, 35
boolalpha, 438; 443
break, 42
Bugzilla, 576
Call Level Interface, 606
Callback, 694
char, 35
cin, поток, 434
class, 197
CLI, 606
COM, 135
Component Object Model, 135
const, 53; 214; 235; 239, 379, 414; 465; 467
методы, 382
переменные, 379
ссылки, 381
указатели, 379
const_cast, 388
continue, 42
CORBA, 782
cout, поток, 434
cppunit, 585
CPPUNIT_ASSERT, 586
Cross-compiling, 556
Cruft, 185
Ctor, 204
D
dec, 438; 444
delete, 48; 204; 403; 431
delete-выражение, 522
Deque, 123; 659
Предметный указатель 895
Difference, 722
Document Object Model, 110
Document Type Definition, 110; 801
DOM, 110, 797
double, 35
Double dispatch, 821
do-while, 42
Downcasting, 281
Doxygen, 181
DTD, 110, 801
dynamic_cast, 282; 311; 390
E
endl, 436
EOF, 441
errno, макроопределение, 459
Exceptions, 457
explicit, 251; 518
Extensible Markup Language, 108; 110,
592; 789
extern, 384; 565
Extreme Programming methodology, 578
F
Facet, 454
FIFO, 124; 663
FILO, 125
Float, 35
for, 43
Free software, 118
free(), 404
Freeware, 118
friend, 248
Function object, 695
Functor, 695
G
gcc, компилятор, 555
gcov, 579
gdb, 628
GNU, 118
Gnu Debugger, отладчик, 629
Google, 116
gprof, 544
Grid computing, 773
GUI, 111
H
Handle, 142
hex, 438; 444
HTML, 151; 854
HTTP, 154
Hypertext Markup Language, 151; 854
I
IDL, 782
if-else, 39
HOP, 782
inline, 244; 536
int, 34
Intel, 554
Interface Definition Language, 782
Internet Inter-ORB Protocol, 782
Intersection, 722
Iterator, 128; 136
J
Japanese Industrial Standard, 455
Java, 458; 460, 470, 565; 783
JavaDoc, 151; 181
Java-пакет junit, 585
JNI-интерфейс, 565
junit, 585
К
Kernighan, 29
L
Libxml, 119
LIFO, 669
Linux, 118; 535; 555; 628
list, 659
Little-endian, 557
Locale, 454
log4cpp, 599
Logger, 834
long, 34
1-значение, 497
M
main(), 31
mallocO, 404
map, 672
896 Предметный указатель
N
Nameserver, 786
NDEBUG, 612
negate, 699
new, 203; 403
new-выражение, 521
noboolalpha, 438; 443
noshowpoint, 438
noskipws, 444
nothrow, 405; 484; 523
О
Object Linking and Embedding, 135
Object Request Broker, 786
Object-Oriented Framework, 830
oct, 438; 444
OLE, 135
OmniORB, 782
ООП, 54
ООП-оболочка, 830
Open Source Initiative, 118
operator+, 250
operator=, 217
P
Pentium, 554
Perl, 561; 568
POA, 784
Portable Object Adapter, 784
POSIX, 459
PowerPC, 555; 557
printf(), 394; 433
priority_queue, 663; 666
private, 197; 198; 268
protected, 197; 198; 268
pthreads, библиотека, 560
public, 197; 198
purify, 619
Python, 783
Q
QA, 573
Quality assurance, 573
queue, 663
R
Rational ClearCase, 164
Rational Quantify, 544
Rational Rose, 164
Rational Software, 163
Rational Unified Process, 163
realloc(), 406
Reference, 372
reinterpret_cast, 389
Remote Procedure Call, 780
Ritchie, 29
Round-robin scheduling, 653
RPC, 780
RTTI, 311; 535
Runtime Type Identification, 311
RUP, 163
s
SAX, 110; 797
scanf(), 433
Serialization, 776
set, 683
setfill, 438
SETI@home, 773
setprecision, 438
setw, 438
SGML, 110
short, 34
showpoint, 438
Simple API for XML, 110
Simple Object Access Protocol, 790, 806
Singleton, 78; 834
skipws, 444
Slicing, 272
Smart pointer, 430, 816
Smoke testing, 595
SOAP, 790, 806
Solaris, 459
Solaris 9, 459, 549
Sparc, 557
Marshalling, 776
MFC, 51; 108, 116; 830
MFC Runner, 587
Microsoft Foundation Classes, 51; 108; 588; 830
MIDI, 434
Mozilla, 576
multimap, 673; 680
multiset, 686
mutable, 241
MVC, 831
Предметный указатель 897
stack, 663; 669
Stagewise Model, 158
Standard Generalized Markup
Standard template library, 122
static, 233; 238; 382
static_cast, 388
STL, 116; 122; 634
string, класс, 419, 687
struct, 198
switch, 40
Symmetric difference, 722
syslog, 599
T
template, 321
terminate(), функция, 468
this, 202; 239
throw, 52; 462
throw-списки, 469
typedef, 386; 424
typeid, 311
typename, 321; 655; 739, 746
и
unexpected(), функция, 471
Unicode, 453
Union, 722
unsigned int, 34
unsigned long, 34
unsigned short, 34
Upcasting, 281
using, 33; 299, 302
UTF-16, 453
V
valgrind, 428; 619
vector, 640
vfprintfO, 395
virtual, 269, 307; 308
Virtual table, 309
Viable, 309
V-габлица, 309, 391
w
Waterfall Model, 159
wcerr, 453
wcin, 453
wcout, 453
while, 42
:, 110 ws, 444
wstring, 687
X
X86, 557
Xerces, 849
Xerces C++Parser, 119
XML, 108; 110, 789
XML Schema, 802
xmlspy, 803
XML-формат, 592
A
Абстрактный базовый класс, 285
Абстракция, 74; 102; 140
Агрегирование, 144
Адаптер, 663
stack, 669
функционального объекта, 702
Алгоритм
accumulate(), 698
adjacent_find(), 709, 725
binary_search(), 710, 719
сору(), 716; 734
count(), 711
count_if(), 711
equal(), 711
equal_range(), 710
find(), 696; 728
find_end(), 709
find_first_of(), 709
find_if(), 696; 697
for_each(), 541; 713; 726
includes(), 722
lexicographical_compare(), 711
lower_bound(), 710
make_heap(), 721
max(), 708
max_element(), 709
merge(), 719
min(), 708
min_eleinent(), 709
mismatch(), 711
pop_heap(), 721
push_hcap(), 721
random_shuffle(), 721
removeQ, 717
898 Предметный указатель
remove_copy(), 718
remove_copy_if(), 718; 735
remove_if(), 717; 727
replace(), 717
replace_copy(), 717
replace_copy_if(), 717
replace_if(), 717
reverse(), 718
reverse_copy(), 719
search(), 709
search_n(), 709
set_difference(), 722
set_intersection(), 722
set_symmetric_difference(), 722
set_union(), 722
sort(), 719
sort_heap(), 721
stable_sort(), 719
swap(), 708
transform(), 715
unique(), 718
unique_copy(), 718
upper_bound(), 710
уникальности, 718
Алгоритмы, 694
вспомогательные, 128; 708
выполнения операций над
множествами, 132
замены, 717
копирования, 716
модифицирующие, 130; 714
немодифицирующие, 129, 709
операционные, 130
поиска, 129
сравнения, 130
числовой обработки, 129
операций над множествами, 722
операционные, 713
поиска, 709
преобразования, 715
реверсирования, 718
сортировки, 131; 719
сравнения, 711
удаления, 717
числовой обработки, 711
Алгоритмы STL, 127
Арность, 494
Архитектура, 555
CORBA, 782
Ассемблер, 571.
Ассоциативные контейнеры, 670
Библиотека
Xerces, 798
Библиотеки, 108
открытые, 118
Битовое множество, 126; 688
Блок кода, 216
Брокер объектных запросов, 786
Бьерн Страуструп, 29
В
Вектор, 122; 640
с динамически изменяемой длиной, 642
фиксированной длины, 640
Венгерская нотация, 189
Виртуальная таблица, 309
Вложенный класс, 245
Внешнее связывание, 353
Водопадная модель, 159
Встраиваемый метод, 244
Д
Двунаправленные потоки, 451
Дедукция, 361
Дек, 123; 659, 669
Декомпозиция, 184
Демаршалинг, 777
Десериализация, 777
Дескриптор, 142
Деструктор, 55; 215; 224; 277; 487
Динамическая память, 401
Динамический массив, 635
Динамическое распределение памяти, 46
Директива #include, 199
Директивы препроцессора, 30
Друзья, 247
Заголовок, 393
<algorithm>, 708
<bitset>, 688
<cctype>, 706
<cstdarg>, 395
<cstddef>, 749
<cstdio>, 394; 395
<cstdlib>, 468; 485
<exception>, 462; 468; 472; 487
<fstream>, 447
<functional>, 699, 706
<iomanip>, 438
<ios>, 438
<iterator>, 732
<locale>, 456
<new>, 483; 485
<numeric>, 698; 708
<ostream>, 435
<queue>, 663; 666
<sstream>, 446
<stdexcept>, 466
<utility>, 670
<vector>, 640
iostream, 30
Заголовочный файл, 393
<cmath>, 75
<string>, 50
И
Иерархии, 98
Инвертор, 703
Инициализатор, 210
Инструкции
catch, 463; 476
if-else, 39
switch, 40
throw, 52
try, 463
Интеллектуальные указатели, 134; 430,
482; 816
Интерфейс, 75; 103; 141
JNI, 566
Исключения, 51; 121; 457; 458
множественные, 465
неожидаемые, 470
неперехваченные, 468
Итеративные процессы, 161
Итератор, 128
создание, 752
типа back_insert_iterator, 735
типа front_insert_iterator, 735
типа insert_iterator, 735
типа istream_iterator, 734
типа ostream_iterator, 734
типа reverse_iterator, 733
Итераторы, 637; 638
вставки, 735
Предметный указатель 899
входные, 638
выходные, 638
двунаправленные, 638
однонаправленные, 638
потоковые, 734
реверсивные, 733
К
Керниган, 29
Кеш, 537
Кеширование, 537
Класс, 54; 195
allocator, 732
auto_ptr, 121; 482; 516; 636
bad_alloc, 483; 484
bad_exception, 471
basic_string, 419; 687
binary_function, 706
bitset, 126; 688
complex, 121
const_iterator, 639
CString, 51
deque, 659
equal_to, 700, 743
exception, 462
FileError, 477
fstream, 451
greater, 700
greater_equal, 700
ifstream, 447
invalid_argument, 466
iostream, 451
istream, 451
istringstream, 446
iterator, 639, 753
iterator_traits, 739
less, 666; 700
less_equal, 700
list, 659
logical_and, 702
logical_not, 702
logical_or, 702
map, 672
multimap, 680
multiplies, 700
multiset, 686
not_equal_to, 700
ofstream, 447
ostream, 451
ostringstream, 446
900 Предметный указатель
pair, 546; 670
plus, 699
priority_queue, 666
reference, 658
reverse_iterator, 733
runtime_error, 467; 471
runtime_exception, 466
set, 683
stack, 669
string, 120, 419, 687
stringstream, 452
SuperSmartPointer, 636; 817
unary_function, 706; 727
valarray, 121
vector, 123; 640
wifstream, 453
wofstream, 453
wstring, 453
абстрактный, 259
абстрактный базовый, 285
виртуальный
базовый, 313
вложенный, 245
производный, 265
родительский, 265
функтора
divides, 699
minus, 699
modulus, 699
multiplies, 699
plus, 699
Классы, 87
Классы:, 102
Кольцевые буферы, 606
Комментарии, 29, 175
метаинформация, 178
префиксные, 180
стили, 179
фиксированного формата, 181
Компилятор
gcc, 555
Комплексные испытания, 592
Компоненты, 87
Константный
метод, 239
член данных, 235
Конструктор, 55; 204; 276; 486
виртуальный, 844
копии, 212; 227; 306
по умолчанию, 207
Контейнер, 635
deque, 659
list, 659
map, 672
multimap, 680
multiset, 686
priority_queue, 666; 701
set, 683
stack, 669
valarray, 123
vector, 640
битовое множество, 126
вектор, 122
множество, 125
мультимножество, 125
мультиотображение, 126
отображение, 126
очередь, 124
по приоритету, 124
очередь с двусторонним доступом, 123
список, 123
стек, 125
Контейнеры
адаптеры, 663
ассоциативные, 670
ассоциативными, 126
последовательные, 639
вектор, 640
Контейнеры STL, 122
Контекст, 392
Контроль качества, 574
Кросс-компиляция, 556
Куча, 46
Л
Литерал
строковый, 418
Локализация, 452
м
Макроопределения препроцессора, 396
Макрос
assert, 612
CPPUNIT_ASSERT, 586
Манипуляторы
ввода данных, 443
вывода данных, 438
Маршалинг, 776
Массив
динамический, 635
Предметный указатель 901
Массивы, 43; 405
многомерные стековые, 409
объектов, 407
Матрица ассоциативных элементов, 510
Межплатформенная разработка, 555
Метод
allocate(), 732
assign(), 645
at(), 642
back(), 642; 650; 659, 663
bad(), 437
base(), 733
begin(), 639
c_str(), 421; 564
capacity(), 652
clear(), 438; 628; 650
deallocate(), 732
empty(), 653; 663; 667; 669, 705
end(), 639
eof(), 443
equal_range(), 680
erase(), 650; 677; 748
fail(), 437
find(), 676
findElementO, 747
flip(), 658; 688
. flush(), 437; 451
front(), 539, 642; 659, 663
get(), 440
getline(), 442
good(), 437
imbue(), 454
insert(), 650, 673; 680, 747
lower_bound(), 680
make_pair(), 674
merge(), 661
name(), 455
operator+, 250
peek(), 442
pop(), 539, 663; 666; 669
pop_back(), 650
pop_front(), 659
push(), 539, 663; 666; 669
push_back(), 461; 539
push_front(), 659
put(), 436
putback(), 442
rbegin(), 733
remove(), 661
remove_if(), 661
rend(), 733
reserveQ, 653
reset(), 688
reverse(), 661
seek(), 448
seekg(), 448
seekp(), 448
set(), 688
size(), 643; 652; 663; 667; 669
sort(), 661
splice(), 661
swap(), 645
tell(), 448
tellg(), 449
tellp(), 449
tie(), 450
top(), 666; 669
unget(), 441
unique(), 661
upperJbound(), 680
what(), 474
write(), 436
встраиваемый, 244
двойной диспетчеризации, 821
мозгового штурма, 581
статический, 238
чисто виртуальный, 285
Механизм RTT1, 311
Множественное наследование, 100, 291
Множество, 125; 683
Модель
водопадная, 159
спиральная, 161
ступенчатая, 158
Модификатор
const, 53; 214; 235
mutable, 241
Мультимножество, 125; 686
Мультиотображение, 126; 673; 680
н
Наследование, 92; 264
множественное, 100, 291
неоткрытое, 312
Нисходящее проектирование, 185
Нотация
венгерская, 189
Нотация, 113
902 Предметный указатель
О
Область видимости, 216; 392
Оболочка, 830
cppunit, 585
Оболочки, 108
Объект, 195
обратного вызова, 694
функциональный, 511; 695; 699
Объявление функции, 30
Оператор
const_cast, 388
delete, 48; 204; 403; 431; 484; 521; 522
dynamic_cast, 282; 311; 390
new, 203; 403; 483; 521
new[], 483
reinterpret_cast, 389
static_cast, 388; 413
typeid, 311
вызова функций, 510
инкремента
постфиксный, 501
префиксный, 501
преобразования, 516
приведения типов, 290
присваивания, 217; 230
равенства, 306
разрешения контекста, 199; 236; 279;
разрешения области видимости, 199
Операторы, 35
бинарные, 35
тернарные, 35
унарные, 35
условные, 41
Опережающая ссылка, 237; 394
Определение функции, 30
Открытые программные средства, 556
Отладка, 597
многопоточных программ, 620
Отладчик, 614
gdb, 628
Gnu Debugger, 629
символический, 614
Отношение
типа is-a, 265
Отношение типа
IS-A, 92
NOT-A, 97
HAS-A, 92
Отображение, 126; 672
Очередь, 124; 663
по приоритету, 124; 663; 666
с двусторонним доступом, 659
Очередь с двусторонним доступом, 123
Ошибка, 598
п
Параметр
по умолчанию, 242
Параметры-ссылки, 374
Перегрузка
методов, 242
оператора
вызова функций, 510
индексации, 505
операторов, 248; 493
new и delete, 522
арифметических, 500
бинарных логических, 503
ввода-вывода, 503
инкремента, 501
поразрядных, 503
разыменования, 512
сравнения, 256
работы с памятью, 520
шаблонов функций, 343
Передача
по ссылке, 531
Переменные, 34
ссылочные, 372
Переопределение методов, 269
Перечислимые типы, 38
Планировщик, 77
Платформа, 554; 559
Подкласс, 94; 265; 286
Полиморфизм, 94; 282
двойной,824
Полуоткрытый диапазон, 639
Поток, 434; 560
сегг, 437
tin, 434
cout, 434
wcerr, 453
wcin, 453
wcout, 453
Потоки, 688
двунаправленные, 451
строковые, 446
файловые, 447
Потоки ввода-вывода, 31; 121
Предикат, 697
Преобразование типа
в восходящем направлении, 281
в нисходящем направлении, 281
Предметный указатель 903
Препроцессор, 30
Приведение типов, 290
с помощью указателей, 413
Приложение, 569
Прогонщик
MFC Runner, 587
Text Runner, 588
тестов, 587
Программный интерфейс приложения,
109
Проектирование
нисходящее, 185
Производительность программы, 529
Пространства имен, 32
анонимное, 383
Протокол
SOAP, 806
Протоколирование программ, 543
Прототип функций, 44
Профайл ер, 544
Пул
объектов, 538; 542
потоков, 543
Р
Разрешение контекста, 392
Распределенные
вычисления, 772
объекты, 776
Расслоение, 272; 281; 476
Рациональный унифицированный
процесс, 163
Реализация, 74; 103
Регистратор, 834
Регистрация ошибок, 599
Регрессивные тесты, 594
Режим отладки, 601
в момент запуска, 603
во время компиляции, 601
Рекурсия
шаблонов, 362
Ритчи, 29
РУЛ, 163
С
Самодокументирование, 183
Самоприсваивание, 219
Свойства, 88
Связка, 703
Связывание
внешнее, 382
внутреннее, 382
статическое, 382
Семантика
значений, 636
ссылок, 636
Сериализация, 776
f; Сигнатура, 44; 296
Склеивание, 661
Скрипт, 560; 569
Слово, 556
Специализация
vector<bool>, 658
шаблонных классов, 336
шаблонных функций, 342
Спецификатор
private, 103
protected, 103
public, 103
Спецификатор доступа, 197
Спецификация исключений, 469
Спиральная модель, 161
Список, 123; 659
Список инициализаторов, 210
Ссылка, 372
опережающая, 237; 394
Ссылки, 51
Ссылочные переменные, 372
Ссылочный член данных, 236
Стандартная библиотека C++, 120
Стандартная библиотека шаблонов, 122
Статический
метод, 238
член данных, 233
Статическое связывание, 382
Стек, 46; 125; 663; 669
Страуструп, 463
Строки, 49, 417; 687
Строковые потоки, 446
Строковый литерал, 418
Структуры, 38
Ступенчатая модель, 158
Суперкласс, 93; 265
Сценарий, 560; 569
т
Тернарный оператор, 40
Тест
регрессивный, 594
системный, 594
904 Предметный указатель
Тестирование, 573; 577
поэлементное, 577
Тестовое покрытие, 579
Тип
string, 49
wchar_t, 453
ковариантный, 296
Типы, 37
перечислимые, 38
Трассировка программы, 600
У
Удаленные вызовы процедур, 780
Указатели, 46; 412
интеллектуальные, 134; 430
на функции, 424
this, 202
Условные операторы, 41
Утечка памяти, 427
Ф
Файловые потоки, 447
Форматирование, 191
Функтор, 511; 695; 699
Функции, 44
Функциональный объект, 511; 695; 699
Функция
abort(), 468; 485
bindlst(), 703
bind2nd.(), 703
free(), 404
getline(), 442
isdigit(), 706
main(), 31
make_pair(), 671
malloc(), 404
mem_fun(), 705
mem_fun_ref(), 704
notl(), 704
not2(), 704
printf(), 394; 433; 444
realloc(), 406
scanf(), 433
seekg(), 628
set_new_handler(), 485
terminate(), 468
uncaught_exception(), 487
unexpected(), 471
vfprintf(), 395
X
Хеширование, 740
Хеш-таблица, 95
Ц
Циклическое планирования, 653
Циклы, 42
do-while, 42
for, 43
while, 42
Чисто виртуальный метод, 285
Член данных
константный, 235
ссылочный, 236
статический, 233
ш
Шаблон, 77; 315
basic_string, 121
адаптер, 848
дизайнер, 853
класса, 317
метода, 330
наблюдатель, 859
одноэлементное множества, 834
посредник, 846
фабрика объектов, 840
функции, 341
цепочка ответственности, 857
Шаблоны, 145
проектов, 135
э
Экстремальное программирование, 165; 578
Эффект расслоения, 281; 476
Эффективность программы, 529
Я
Язык
XML, 789
определения интерфейсов, 782
Научно-популярное издание
Николас А. Солтер, Скотт Длс. Клепер
C++ для профессионалов
Литературный редактор О.Ю. Белозовская
Верстка В.И. Бордюк
Художественный редактор В.Г. Павлютин
Корректоры Л.А. Гордиенко, Т.А. Корзун,
О.В. Мишутина, В. В. Смоляр,
Л. В. Чернокозинская
Издательский дом "Вильяме"
101509, г. Москва, ул. Лесная, д. 43, стр. 1
Подписано в печать 11.07.2006. Формат 70x100/16.
Гарнитура Times. Печать офсетная.
Усл. печ. л. 73,53. Уч.-изд. л. 54,62.
Тираж 3 000 экз. Заказ № 1966.
Отпечатано по технологии CtP
в ОАО "Печатный двор" им. А. М. Горького
197110, Санкт-Петербург, Чкаловский пр., 15
Программистам от программистов
. . для
ТТ профессионалов
C++ — один из самых популярных языков программирования,
но общеизвестно, что при всем своем могуществе он довольно
сложен. Многие полезные аспекты C++ остаются тайной за
семью печатями даже для опытных программистов. Слишком
часто в книгах по программированию больше внимания
уделяется синтаксису языка и меньше — реальным
приложениям. В этом практическом руководстве с большим
количеством примеров авторы попытались изменить
распространенную тенденцию, представив все грани
разработки приложений на C++, включая этапы эффективного
проектирования, тестирования и отладки. Вы освоите
простые, но мощные методы, используемые
профессионалами, малознакомые средства, которые сделают
вашу жизнь проще, и многократно применяемые шаблоны,
которые выведут ваши базовые навыки программирования на
профессиональный уровень.
Сделав обзор основ языка C++, авторы показывают, как
эффективно использовать этот язык в повседневной работе.
Они демонстрируют различные методики и хороший стиль
программирования, а также предлагают пути повышения
качества кода и эффективности программирования в целом.
Вы узнаете, как написать межплатформенный и межъязыковой
код, выполнить поэлементное тестирование и провести
регрессивные испытания, а также использовать стандартную
библиотеку C++.
Вы сможете овладеть языком C++ со всеми его особенностями
и воспользоваться преимуществами его мощных средств для
разработки крупномасштабных программных проектов,
а также не раз применить все описанные шаблоны
проектирования при разработке различных приложений.
В этой книге описаны:
• различные методологии и стили
высококачественного программирования;
• способы эффективного использования средств
C++ для разработки крупномасштабных
приложений;
• методы, гарантирующие создание кода без
ошибок;
• значение объектно-ориентированного
проектирования;
• использование библиотек и шаблонов для
создания более эффективного кода при меньших
затратах;
• наилучшие способы управления памятью в C++;
• технологии ввода-вывода данных;
• и многое-многоадругое!
Руководства для профессионалов
подготовлены программистами-практиками,
не понаслышке знакомыми с реальными
потребностями программистов, разработчиков
и специалистов в области информационных
технологий. Материал этих книг отличается
конкретностью и релевантностью, направленной на
профессиональное решение насущных проблем,
с которыми приходится сталкиваться самим авторам
Представленные здесь примеры, практические
решения и квалифицированные оценки новых
технологий помогут программистам повысить
качество труда.
Для кого предназначена эта книга
Эта книга предназначена для программистов и разработчиков, которые хотят поднять свои навыки
программирования на C++ на профессиональный уровень. Поэтому читатель должен владеть базовыми знаниями C++
или существенным опытом программирования на С и/или Java, а также иметь представление об основах
программирования и использовании компилятора.
Категория:
Предмет рассмотрения:
Уровень:
программирование на языке C++
C++
для пользователей средней и высокой квалификации
Р
ш9=я.штлюмж Издательство "Диалектика"
'/ Ли^Я¥ШЯлА www.dialektika.com
wrox™
p2p.wrox.com
Информационный центр
для программистов
www.wrox.com
ISBN 5-8459-1065-Х
06151
785845"910653