Вы находитесь на странице: 1из 480

ББК

32.973.2-018.1я7
УДК 004.43(075)
Э47

Эллайн А.
Э47 C++. От ламера до программера. — СПб.: Питер, 2015. — 480 с.: ил.
ISBN 978-5-496-01189-1
Эта книга предлагает быстрый способ изучить принципы объектно-ориентированного про-
граммирования и освоить практику программирования на языке С++ новейшего стандарта.
Издание может использоваться как учебный курс для начинающих осваивать C++, так и удоб-
ный справочник для тех, кто хочет быстро найти актуальную информацию о том или ином
аспекте языка.
Автор книги Алекс Эллайн — профеcсиональный разработчик на С++, создатель популяр-
нейшего ресурса Cprogramming.com, предлагает собственную уникальную методику обучения
программирования, которая позволит вам в кратчайшие сроки стать экcпертом разработки на
C++.

6+ (Для детей старше 6 лет. В соответствии с Федеральным законом от 29 декабря


2010 г. № 436-ФЗ.)

ББК 32.973.2-018.1я7
УДК 004.43(075)

Права на издание получены по соглашению с Cprogramming.com. Все права защищены. Никакая часть данной книги
не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав.

Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надеж-
ные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может гаранти-
ровать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные ошибки,
связанные с использованием книги.

ISBN 978-0988927803 англ. © Cprogramming.com, 2012


ISBN 978-5-496-01189-1 © Перевод на русский язык ООО Издательство «Питер», 2015
© Издание на русском языке, оформление ООО Издательство «Питер», 2015
Краткое содержание

Часть I. Погружение в С++

Глава 1. Введение и настройка среды разработки. . . . . . . . . . . . . . . . 20

Глава 2. Основы C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48

Глава 3. Взаимодействие с пользователем


и работа с переменными. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57

Глава 4. Условные операторы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73

Глава 5. Циклы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85

Глава 6. Функции . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 98

Глава 7. Переключатели и перечисления. . . . . . . . . . . . . . . . . . . . . 111

Глава 8. Добавляем в программу случайности . . . . . . . . . . . . . . . . . 119

Глава 9. Что делать, когда не понятно, что делать? . . . . . . . . . . . . . 126

Часть II. Работа с данными

Глава 10. Массивы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 136

Глава 11. Структуры. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 151

Глава 12. Введение в указатели . . . . . . . . . . . . . . . . . . . . . . . . . . . 158

Глава 13. Указатели. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 168


6    Краткое содержание

Глава 14. Динамическое выделение памяти. . . . . . . . . . . . . . . . . . . 182

Глава 15. Введение в структуры данных с использованием


связанных списков. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 197

Глава 16. Рекурсия. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 213

Глава 17. Двоичные деревья. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 230

Глава 18. Стандартная библиотека шаблонов. . . . . . . . . . . . . . . . . . 253

Глава 19. Еще о строках. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 266

Глава 20. Отладка в Code::Blocks. . . . . . . . . . . . . . . . . . . . . . . . . . . 278

Часть III. Большие программы

Глава 21. Разбиение программ на части. . . . . . . . . . . . . . . . . . . . . . 301

Глава 22. Введение в проектирование программ . . . . . . . . . . . . . . . 317

Глава 23. Скрытие представления структурированных данных . . . . . 323

Глава 24. Классы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 330

Глава 25. Жизненный цикл класса. . . . . . . . . . . . . . . . . . . . . . . . . . 337

Глава 26. Наследование и полиморфизм . . . . . . . . . . . . . . . . . . . . . 358

Глава 27. Пространства имен . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 379

Глава 28. Файловый ввод-вывод . . . . . . . . . . . . . . . . . . . . . . . . . . . 385

Глава 29. Шаблоны в C++. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 414

Часть IV. Дополнительная информация

Глава 30. Форматирование выводимых данных с помощью iomanip. . 430

Глава 31. Исключения и отчеты об ошибках . . . . . . . . . . . . . . . . . . 438

Глава 32. Заключение. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 448

Ответы к разделам «Проверьте себя». . . . . . . . . . . . . . . . . . . . . . . . 450


Содержание

Часть I. Погружение в С++

Глава 1. Введение и настройка среды разработки . . . . . . . . . . . . 20


Что такое язык программирования?. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
В чем различие между C и C++?. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
Обязательно ли знать C, чтобы выучить C++?. . . . . . . . . . . . . . . . . . . . . . . . . . . 21
Нужно ли знать математику, чтобы стать программистом? . . . . . . . . . . . . . . . 21
Терминология. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
Программирование . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
Исполняемый файл. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
Редактирование и компиляция файлов. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
О примерах исходного кода. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
Windows. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
Шаг 1. Загрузите Code::Blocks. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
Шаг 2. Установите Code::Blocks. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
Шаг 3. Работа в Code::Blocks . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
Устранение проблем . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
Что же такое Code::Blocks?. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
Macintosh . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
Xcode. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
Установка Xcode 3 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
Запуск Xcode. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
Создание первой программы на C++ в Xcode. . . . . . . . . . . . . . . . . . . . . . . . . . . 31
Установка Xcode 4 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
Запуск Xcode. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
8    Содержание

Создание первой программы на C++ в Xcode. . . . . . . . . . . . . . . . . . . . . . . . . . . 35


Устранение проблем . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
Linux. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
Шаг 1. Установка g++. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
Шаг 2. Запуск g++. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
Шаг 3. Запуск программы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
Устранение проблем . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
Шаг 4. Настройка текстового редактора. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
Настройка Nano. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
Использование Nano. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
Глава 2. Основы C++. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48
Введение в язык C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48
Простейшая программа на C++. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48
Почему работающая программа не видна? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52
Базовая структура программы на C++. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52
Комментарии в программах. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53
Программистское мышление и создание повторно используемого кода. . . 54
Несколько слов о радостях и трудностях практики. . . . . . . . . . . . . . . . . . . . . . . 55
Глава 3. Взаимодействие с пользователем
и работа с переменными. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
Знакомство с переменными. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
Объявление переменных в C++. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58
Использование переменных. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58
Что делать, если программа сразу завершается?. . . . . . . . . . . . . . . . . . . . . . . . 59
Изменение, использование и сравнение переменных . . . . . . . . . . . . . . . . . . . 60
Упрощенное прибавление и вычитание единицы. . . . . . . . . . . . . . . . . . . . . . . 61
Правильное и неправильное использование переменных. . . . . . . . . . . . . . . . . 63
Типичные ошибки при объявлениях переменных в C++. . . . . . . . . . . . . . . . 63
Чувствительность к регистру. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
Присваивание имен переменным. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
Хранение строк. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
Как считывать не только строки, но и другие типы данных. . . . . . . . . . . . . . . 67
Глава 4. Условные операторы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73
Базовый синтаксис оператора If. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73
Выражения. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 75
Что такое истина?. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 75
Тип bool. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 76
9
Содержание      

Операторы else . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77
Else-if . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77
Сравнение строк. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
Несколько любопытных булевых операций. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
Логическое НЕ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80
Логическое И. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80
Логическое ИЛИ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
Комбинация выражений . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
Примеры логических выражений. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83

Глава 5. Циклы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
Циклы while. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86
Типичная ошибка. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86
Циклы for . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
Инициализация переменной . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
Условие цикла. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
Обновление переменной . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
Циклы do-while. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 90
Управление циклами. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91
Вложенные циклы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93
Как выбрать подходящий цикл. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94
Циклы for. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94
Циклы while. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94
Циклы do-while. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 95

Глава 6. Функции. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 98
Синтаксис функций . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 98
Локальные и глобальные переменные . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 100
Локальные переменные. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 100
Глобальные переменные. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102
Предупреждение по поводу глобальных переменных. . . . . . . . . . . . . . . . . . 103
Подготовка функций к использованию. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 104
Определения и объявления функций. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 105
Пример прототипа функции. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106
Деление программы на функции . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106
Когда код многократно повторяется. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106
Когда код должен хорошо читаться. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
Именование и перегрузка функций. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108
Кратко о функциях. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109
10    Содержание

Глава 7. Переключатели и перечисления . . . . . . . . . . . . . . . . . . 111


Переключатель. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
Сравнение операторов switch и if-else. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
Создание простых типов данных с помощью перечислений. . . . . . . . . . . . . . 114

Глава 8. Добавляем в программу случайности . . . . . . . . . . . . . . 119


Получение случайных чисел в C++. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 120
Случайные числа и отладка. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 123

Глава 9. Что делать, когда не понятно, что делать?. . . . . . . . . . 126


Разделение задачи на части . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 127
Кратко об эффективности и безопасности . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 130
Что делать, если алгоритм неизвестен . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 132

Часть II. Работа с данными

Глава 10. Массивы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 136


Базовый синтаксис массивов. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 137
Примеры использования массивов. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 138
Массивы для хранения упорядоченных данных. . . . . . . . . . . . . . . . . . . . . . . 138
Представление матриц многомерными массивами. . . . . . . . . . . . . . . . . . . . . 138
Использование массивов. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 139
Массивы и циклы for. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 139
Передача массивов в функции. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 140
Запись в конец массива . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 142
Сортировка массивов. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 143

Глава 11. Структуры. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 151


Связывание значений. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 151
Синтаксис. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 152
Передача структур . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 154

Глава 12. Введение в указатели. . . . . . . . . . . . . . . . . . . . . . . . . . 158


Забудьте все, о чем вам говорили. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 158
Что такое указатели и зачем они нужны . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 158
Что такое память . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 159
Переменные и адреса. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 161
Структура памяти. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .162
Другие преимущества и недостатки указателей. . . . . . . . . . . . . . . . . . . . . . . . . 164
11
Содержание      

Глава 13. Указатели. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 168


Синтаксис указателей. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 168
Объявление указателя . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 168
Определение адреса переменной . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 169
Использование указателя . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 170
Неинициализированные указатели и NULL . . . . . . . . . . . . . . . . . . . . . . . . . . . . 174
Указатели и функции. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 175
Ссылки. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 177
Сравнение ссылок и указателей . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 178

Глава 14. Динамическое выделение памяти. . . . . . . . . . . . . . . . 182


Выделение дополнительной памяти с помощью оператора new. . . . . . . . . . 182
Свободной памяти больше нет. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 183
Ссылки и динамическое выделение памяти. . . . . . . . . . . . . . . . . . . . . . . . . . . 184
Указатели и массивы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 184
Многомерные массивы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 187
Арифметика указателей. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 188
Знакомство с двумерными массивами . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 189
Указатели на указатели. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .190
Указатели на указатели и двумерные массивы. . . . . . . . . . . . . . . . . . . . . . . . . 192
Заключение об указателях . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 193

Глава 15. Введение в структуры данных с использованием


связанных списков. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 197
Ценность сложных структур данных . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 197
Указатели и структуры. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 200
Создание связанного списка . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 201
Первый проход . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 202
Второй проход. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 203
Обход связанного списка . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 205
Выводы о связанных списках. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 206
Массивы или связанные списки?. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 206

Глава 16. Рекурсия. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 213


Как представить себе рекурсию . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 213
Рекурсия и структуры данных. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 216
Циклы и рекурсия. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 218
Стек. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 221
12    Содержание

Достоинства стека . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 224


Недостатки рекурсии . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 224
Отладка переполнений стека. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 225
Производительность. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 227
Подводя итоги. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 227

Глава 17. Двоичные деревья. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 230


Для чего нужны двоичные деревья?. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 230
Что такое двоичное дерево?. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 231
Соглашение о терминах. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 233
Реализация двоичных деревьев. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 234
Вставка в дерево. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 234
Поиск в дереве. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 237
Уничтожение дерева . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 238
Удаление узлов из дерева. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 240
Двоичные деревья и реальный мир. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 248
Стоимость деревьев и словарей. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 250

Глава 18. Стандартная библиотека шаблонов. . . . . . . . . . . . . . . 253


Вектор — массив переменного размера. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .254
Вызов методов векторов. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 255
Другие возможности векторов. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 256
Словари. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 257
Итераторы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 258
Проверка существования значения в словаре . . . . . . . . . . . . . . . . . . . . . . . . . 261
Заключение об STL. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 262
Дополнительная информация об STL . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 263

Глава 19. Еще о строках . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 266


Считывание строк. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 267
Длина строк и доступ к отдельным элементам . . . . . . . . . . . . . . . . . . . . . . . . . . 268
Поиск и подстроки. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 269
Передача по ссылке. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 271
Константы похожи на вирус. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 273
Константы и STL . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 274

Глава 20. Отладка в Code::Blocks. . . . . . . . . . . . . . . . . . . . . . . . . 278


Настройка. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 280
Прерывание программы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 281
Отладка аварийных завершений программы. . . . . . . . . . . . . . . . . . . . . . . . . . 288
13
Содержание      

Прерывание зависшей программы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 291


Изменение переменных. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 295
Заключение . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 296
Задача 1. Проблемы с экспонентой . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 296
Задача 2. Проблема добавления чисел . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 297
Задача 3. Ошибка в числах Фибоначчи . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 297
Задача 4. Некорректное чтение и вывод списка. . . . . . . . . . . . . . . . . . . . . . . . 298

Часть III. Большие программы

Глава 21. Разбиение программ на части . . . . . . . . . . . . . . . . . . . 301


Процесс сборки в C++. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 301
Предварительная обработка. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 302
Компиляция. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 304
Компоновка . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 304
Зачем разделять компиляцию и компоновку. . . . . . . . . . . . . . . . . . . . . . . . . . 305
Как разделить программу на файлы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 305
Шаг 1. Разделите объявления и определения функций . . . . . . . . . . . . . . . . 306
Шаг 2. Определите, какие функции будут общими. . . . . . . . . . . . . . . . . . . . 306
Шаг 3. Переместите общие функции в новые файлы. . . . . . . . . . . . . . . . . . .307
Другие правила работы с заголовочными файлами. . . . . . . . . . . . . . . . . . . . 311
Работа с несколькими файлами. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 311

Глава 22. Введение в проектирование программ . . . . . . . . . . . . 317


Избыточный код. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 317
Как хранятся данные. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .318
Проектирование и комментарии. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 321

Глава 23. Скрытие представления


структурированных данных . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 323
Скрытие формата структуры с помощью функций. . . . . . . . . . . . . . . . . . . . . . 324
Объявление методов и синтаксис их вызова. . . . . . . . . . . . . . . . . . . . . . . . . . . 326

Глава 24. Классы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 330


Скрытие данных. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 331
Объявление экземпляра класса. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 333
Обязанности класса. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 333
В чем истинный смысл закрытых членов класса?. . . . . . . . . . . . . . . . . . . . . . 335
Заключение . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 335
14    Содержание

Глава 25. Жизненный цикл класса. . . . . . . . . . . . . . . . . . . . . . . . 337


Создание объекта. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 338
Что произойдет, если не создать конструктор . . . . . . . . . . . . . . . . . . . . . . . . . 341
Инициализация членов класса . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 341
Использование списка инициализации для константных полей. . . . . . . . 343
Уничтожение объекта. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 343
Уничтожение объекта оператором delete. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 346
Уничтожение объекта при его выходе из области видимости. . . . . . . . . . . 346
Уничтожение объекта другим деструктором. . . . . . . . . . . . . . . . . . . . . . . . . . .347
Копирование классов. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 348
Оператор присваивания. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 349
Конструктор копирования . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 352
Полный список методов, генерируемых компилятором. . . . . . . . . . . . . . . . 354
Как избежать полного копирования объекта . . . . . . . . . . . . . . . . . . . . . . . . . . 354

Глава 26. Наследование и полиморфизм. . . . . . . . . . . . . . . . . . . 358


Наследование в C++. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 360
Другие преимущества и недостатки наследования. . . . . . . . . . . . . . . . . . . . . 364
Наследование, создание и уничтожение объектов . . . . . . . . . . . . . . . . . . . . . 365
Полиморфизм и уничтожение объектов. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 367
Проблема срезки. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 369
Совместное использование кода с подклассами . . . . . . . . . . . . . . . . . . . . . . . 370
Защищенные данные. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 371
Общие данные класса. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 372
Как реализован полиморфизм?. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 373

Глава 27. Пространства имен . . . . . . . . . . . . . . . . . . . . . . . . . . . . 379


Использование пространств имен. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 379
Когда следует использовать оператор using namespace. . . . . . . . . . . . . . . . . 382
Когда следует создавать пространство имен. . . . . . . . . . . . . . . . . . . . . . . . . . . 383

Глава 28. Файловый ввод-вывод . . . . . . . . . . . . . . . . . . . . . . . . . 385


Основы файлового ввода-вывода. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 385
Чтение из файлов. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 386
Форматы файлов. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 388
Конец файла. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 389
Запись в файлы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 391
Создание новых файлов. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 392
15
Содержание      

Позиция в файле . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 393


Прием командно-строковых аргументов. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 397
Работа с численными командно-строковыми аргументами. . . . . . . . . . . . . 399
Ввод-вывод в двоичные файлы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 399
Работа с двоичными файлами . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 401
Преобразование в char* . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 402
Пример двоичного ввода-вывода . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 403
Хранение классов в файле. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 404
Чтение из файла. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 406

Глава 29. Шаблоны в C++. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 414


Шаблонные функции. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 415
Вывод типа данных . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 416
Утиная типизация. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .416
Шаблонные классы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 418
Рекомендации по работе с шаблонами. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 420
Шаблоны и заголовочные файлы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 422
Заключение о шаблонах. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 422
Диагностика сообщений об ошибках в шаблонах. . . . . . . . . . . . . . . . . . . . . . 423

Часть IV. Дополнительная информация

Глава 30. Форматирование выводимых данных с помощью


iomanip . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 430
Работа в ограниченном пространстве. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 430
Управление шириной с помощью метода setw. . . . . . . . . . . . . . . . . . . . . . . . . 430
Изменение символа-заполнителя. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 432
Изменение глобальных настроек . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 432
Обобщим информацию о iomanip. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 433
Вывод чисел. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 435
Точность вывода чисел задается в методе setprecision. . . . . . . . . . . . . . . . . . 435
Что делать с денежными величинами?. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 436
Вывод значений в различных системах счисления. . . . . . . . . . . . . . . . . . . . . 436

Глава 31. Исключения и отчеты об ошибках . . . . . . . . . . . . . . . . . . . . 438


Освобождение ресурсов при исключениях. . . . . . . . . . . . . . . . . . . . . . . . . . . . 440
Очистка ресурсов вручную в блоке catch. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 441
Создание исключений. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 442
16    Содержание

Спецификация исключений. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 443


Преимущества исключений . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 444
Неправильное использование исключений. . . . . . . . . . . . . . . . . . . . . . . . . . . . 445
Коротко об исключениях. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 446

Глава 32. Заключение. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 448

Ответы к разделам «Проверьте себя». . . . . . . . . . . . . . . . . . . . . 450


Ч а с ть I
Погружение в С++

Давайте учиться программировать! Программирование, как и другие


виды искусства, позволяет реализовать творческий потенциал и усили-
вает ваш талант благодаря скорости и возможностям компьютера. Вы
можете создавать увлекательные игры вроде World of Warcraft, Bioshock,
Gears of War, Mass Effect или реалистичные симуляторы, подобные Sims.
Вы можете писать программы, объединяющие людей: веб-браузеры типа
Chrome, редакторы электронной почты, чат-клиенты или веб-сайты вроде
Facebook или Amazon.com. Вы можете разрабатывать развлекательные
приложения для пользователей мобильных устройств, таких как iPhone
или Android-смартфоны.
Разумеется, чтобы создавать все эти программы, нужны время и опыт. Тем
не менее уже в самом начале пути вы можете создавать интересные про-
граммы: одни помогут справиться с домашним заданием по математике,
другими (вроде Тетриса) вы сможете похвастаться друзьям, а третьи по-
служат инструментами, выполняющими рутинную работу или сложные
вычисления, на которые могли бы уйти несколько дней или недель. Овладев
основами программирования, вы научитесь использовать графические или
сетевые библиотеки для создания любых интересующих вас программ — от
игр до научных имитаций.
C++ — это мощный язык, позволяющий погрузиться в современные мето-
ды программирования. Поскольку его концепции реализованы во многих
других языках, знания, которые вы получите, пригодятся и при их освоении
(почти все программисты знают несколько языков).
18    Часть I. Погружение в С++

Навыки программирования на C++ позволяют работать над разнообразны-


ми проектами. Большинство приложений и программ, которыми вы еже-
дневно пользуетесь, написаны на C++. Это же касается всех упомянутых
ранее приложений или, как минимум, существенной части их компонентов1.
На самом деле интерес к C++ неуклонно растет, несмотря на увеличи-
вающуюся популярность более новых языков программирования, таких
как Java и C#. Это наглядно демонстрирует объем входящего трафика на
моем сайте Cprogramming.com. На C++ пишут высокопроизводительные
приложения, которые, как правило, работают быстрее приложений на
Java и других языках. Потенциал C++ растет: C++ 11 содержит новые
возможности, которые упрощают и ускоряют разработку программ и при
этом по-прежнему обеспечивают их высокую производительность2. Кроме
того, уверенные знания C++ высоко ценятся на рынке труда и позволяют
найти интересную и высокооплачиваемую работу.
Первая часть этой книги посвящена начальной подготовке к написанию
программ и использованию базовых компонентов C++. Освоив пред-
ложенный материал, вы научитесь думать как программист и создавать
реальные программы, которые можно демонстрировать друзьям (хотя пока
лишь очень близким). Вы не превратитесь в эксперта по C++, но хорошо
подготовитесь к изучению возможностей языка, которые позволяют пи-
сать действительно полезные и мощные программы. Вы получите знания
и терминологическую базу для освоения более сложных тем.
Вы узнаете, как писать программы, которые работают с большими объ-
емами данных (считывают данные из файлов и легко и продуктивно их
обрабатывают), и освоите эффективные приемы программирования. Вы
научитесь писать большие и сложные программы с четкой и ясной струк-
турой и пользоваться профессиональными инструментами.
Проработав материал этой книги, вы будете способны читать и писать
настоящие компьютерные программы, которые делают разнообразные по-
лезные и интересные вещи. Если вы увлекаетесь компьютерными играми,
то сможете с легкостью решать задачи, которые возникают в процессе их

1
Эти приложения и многие другие примеры использования языка C++ можно
найти на странице http://www.stroustrup.com/applications.html
2
Эта спецификация была утверждена, когда работа над книгой уже подходила
к концу, поэтому я не включил в книгу материалы нового стандарта. О стан-
дарте C++ 11 вы можете почитать на странице http://www.cprogramming.com/
c++11/what-is-c++0x.html
Благодарности    19

разработки. Если вы собираетесь пойти на курсы по C++ или уже ходите


на них, то полученная информация позволит вам добиться блестящих
результатов. Если же вы занимаетесь самообразованием, ваших знаний
хватит, чтобы писать почти любые программы и пользоваться большин-
ством инструментов C++.

Благодарности
Я хочу поблагодарить Александру Хоффер (Alexandra Hoffer) за вниматель-
ное, терпеливое редактирование этой книги и конкретные советы. Без ее
усилий книга не вышла бы в свет. Выражаю благодарность Андрею Черем-
скому (Andrei Cheremskoy), Миньяню Цзяню (Minyang Jiang) и Йоханнесу
Петеру (Johannes Peter) за ценные отзывы, предложения и поправки.
Г лава 1
Введение и настройка среды разработки

Что такое язык программирования?


Чтобы управлять компьютером, необходимо как-то с ним общаться. В отли-
чие от кошек и собак, которые разговаривают между собой на собственных,
непонятных для нас языках, компьютеры понимают языки программиро-
вания, созданные людьми. Компьютерная программа представляет собой
фрагмент текста, который похож на книгу или статью, но имеет особую
структуру. Языки, на которых пишутся такие тексты, понятны людям, но
отличаются от языков человеческого общения более жесткой организацией
и очень ограниченным словарным запасом. Одним из распространенных
языков программирования является C++.
Созданную компьютерную программу необходимо запустить на компью-
тере, чтобы интерпретировать написанное. Это действие обычно называют
выполнением программы. Способ выполнения программы зависит от языка
программирования и среды разработки. Скоро мы подробнее узнаем, как
выполнить программу.
Есть много языков программирования, каждый с особыми структурой
и словарем. Тем не менее языки программирования во многом схожи. Изу­
чив один, вы легче справитесь с изучением другого.
Нужно ли знать математику, чтобы стать программистом?    21

В чем различие между C и C++?


Язык C был изначально разработан для создания операционной системы
Unix. Это мощный низкоуровневый язык, в котором, однако, отсутствуют
многие современные и полезные конструкции. C++ — более новый язык,
основанный на C. В него добавлены многочисленные возможности совре-
менных языков программирования, благодаря которым писать программы
на C++ проще, чем на C.
C++ обладает всей мощью C, но дает программистам новые возможности,
упрощающие создание полезных изящных программ.
Например, в C++ проще управлять памятью и добавлять возможности
«объектно-ориентированного» и «стандартного» программирования. Мы
раскроем смысл этих терминов позже, а пока просто запомните, что язык
C++ позволяет программистам не думать о технических деталях и уделять
главное внимание проблемам, которые они хотят решить.
Если вы размышляете о том, какой язык лучше изучать — C или C++, на-
стоятельно рекомендую начать с C++.

Обязательно ли знать C, чтобы выучить C++?


Нет. C++ является расширением C; все, что можно сделать в C, можно
сделать и в C++. Зная язык C, вы легко адаптируетесь к объектно-ориен-
тированным возможностям C++. Если вы не знакомы с C, ничего страшно-
го — нет никакого смысла изучать C перед C++, можно сразу пользоваться
преимуществами C++, которых нет в C (в первую очередь, значительно
упрощенными вводом и выводом данных).

Нужно ли знать математику, чтобы стать программистом?


Если бы каждый, кто задает этот вопрос, давал мне монетку, пришлось
бы немало потрудиться, чтобы подсчитать свое состояние. Мой ответ:
категорически нет! Программирование требует в первую очередь умения
проектировать и логически мыслить, а не быстро считать или глубоко
знать алгебру и численные методы. Главное сходство математики и про-
граммирования — в точном и логичном мышлении. Математические знания
понадобятся, лишь если вы захотите создавать программы для сложной
обработки трехмерных изображений, статистического анализа или специ-
ализированных численных методов.
22    Глава 1. Введение и настройка среды разработки

Терминология
Новые термины будут появляться на протяжении всей этой книги, а пока
мы сконцентрируемся на фундаментальных понятиях, которые помогут
начать знакомство с C++.

Программирование
Программирование — это написание инструкций, которые компьютер
способен воспринять и выполнить. Сами эти инструкции называются
исходным кодом. Написание исходного кода — это именно то, чем вам
предстоит заниматься. Мы увидим первые примеры исходного кода уже
через несколько страниц.

Исполняемый файл
Конечным результатом программирования является исполняемый файл.
Исполняемый файл можно запускать: если вы пользуетесь операционной
системой Windows, то знакомы с exe-файлами, которые являются исполня-
емыми. Конкретный пример исполняемого файла — программа Microsoft
Word. У некоторых программ есть дополнительные файлы (графические,
музыкальные и др.), но у каждой программы есть исполняемый файл.
Чтобы создать исполняемый файл, необходим компилятор — программа,
которая преобразует исходный код в исполняемый файл.
Единственное, что можно делать без компилятора — это читать исходный
код. Поскольку это не слишком увлекательное занятие, наш следующий
шаг — научиться пользоваться компилятором.

Редактирование и компиляция файлов


Остальная часть этой главы посвящена знакомству с простой и легкой
в использовании средой разработки. Я расскажу о двух конкретных ин-
струментах — компиляторе и редакторе. Вы уже знаете: компилятор нужен,
чтобы заставить программу что-то делать. Назначение редактора менее
очевидно, но не менее важно: редактор позволяет создавать исходный код
в подходящем формате.
Исходный код необходимо писать в виде простого текста. Файлы с про-
стым текстом не содержат ничего, кроме текста; в них нет сведений, как
форматировать и отображать их содержание. Файлы, которые вы создаете
в Microsoft Word (и аналогичных продуктах), не являются файлами с про-
стым текстом, поскольку содержат информацию о шрифтах, размерах
Windows    23

и форматировании текста. Вы не видите эту информацию, когда открываете


файл в Word, но тем не менее она есть в файле. Файлы с простым текстом
не содержат ничего, кроме текста, и можно создавать их инструментами,
которые мы рассмотрим в самое ближайшее время.
Редактор обеспечивает две удобные возможности: выделение синтаксиса
и автоматические отступы. Выделение синтаксиса просто отображает
код различным цветом, чтобы легко отличать одни элементы программы
от других. Автоматический отступ (смещение текста вправо) позволяет
форматировать код так, чтобы его было удобно читать.
Если вы пользуетесь Windows или Mac, я познакомлю вас с мощным
редактором, который называется интегрированной средой разработки
или IDE (integrated development environment) и сочетает возможности
редактора и компилятора. Если вы работаете в Linux, то воспользуйтесь
удобным редактором nano. Вы получите всю необходимую информацию,
чтобы начать работу.

О примерах исходного кода


Эта книга содержит многочисленные примеры исходного кода, которыми
вы можете пользоваться при написании собственных программ без всяких
ограничений — но и без каких-либо гарантий. Архив с примерами исход-
ного кода можно скачать по адресу http://www.cprogramming.com/c++book/
code/sample_code.zip. Все файлы с примерами исходного кода хранятся
в отдельных папках с именами, соответствующих главам, в которых ис-
пользуются эти файлы (например, файлы для этой главы хранятся в папке
ch1). Заголовок каждого листинга исходного кода в этой книге содержит
имя соответствующего файла.

Windows
Мы настроим инструмент Code::Blocks — бесплатную среду разработки
для языка C++.

Шаг 1. Загрузите Code::Blocks


• Посетите веб-сайт http://www.codeblocks.org/downloads.
• Перейдите по ссылке Download the binary release (Скачать двоичный
выпуск).
• Перейдите в раздел Windows 2000 / XP / Vista / 7.
24    Глава 1. Введение и настройка среды разработки

• Найдите файл, имя которого содержит mingw. (На момент написания


книги файл назывался codeblocks-12.11mingw-setup.exe, но название
файла уже могло измениться.)
• Сохраните этой файл на компьютере. На момент написания книги его
размер составлял около 74 Мбайт.

Шаг 2. Установите Code::Blocks


• Дважды щелкните на установщике.
• Несколько раз нажмите Next (Далее). В других руководствах предпола-
гается, что вы выполнили установку в папку C:\Program Files\CodeBlocks
(она используется по умолчанию), но устанавливать программу можно
в любое место.
• Выполните полную установку: в раскрывающемся меню Select the type of
install (Выберите тип установки) выберите пункт Full: All plugins, all tools,
just everything (Все подключаемые модули, все инструменты и другие
компоненты).
• Запустите Code::Blocks.

Шаг 3. Работа в Code::Blocks


На экране появится окно Compilers auto-detection (Автообнаружение ком-
пиляторов):

Нажмите OK. Возможно, Code::Blocks поинтересуется, хотите ли вы назначить


ее программой для просмотра файлов C/C++ по умолчанию, — рекомендую
Windows    25

согласиться. Откройте меню File (Файл) и в пункте New (Создать) выберите


Project... (Проект...).

Появится следующее окно:

Щелкните на значке Console Application (Консольное приложение) и на-


жмите Go (Перейти). Все примеры кода из этой книги могут выполняться
как консольные приложения.
Нажимайте Next (Далее), пока не появится диалоговое окно выбора языка:
26    Глава 1. Введение и настройка среды разработки

Вас попросят выбрать используемый язык, C или C++. Поскольку мы


изу­чаем C++, выберите C++.
Нажав Next (Далее), вы получите предложение Code::Blocks указать место
для консольного приложения:

Рекомендую поместить приложение в отдельную папку, поскольку не ис-


ключено, что оно создаст несколько файлов (эта рекомендация особенно ак-
туальна, если вы создаете проекты других типов). Дайте проекту любое имя.
Нажмите Next (Далее), и вам будет предложено настроить компилятор:
Windows    27

Не выполняйте здесь никаких действий, просто примите значения по


умолчанию, нажав Finish (Готово).
Теперь можно открыть файл main.cpp:

(Если вы не видите файл main.cpp, возможно, потребуется развернуть со-


держимое папки Sources.)
Теперь у вас есть файл main.cpp, который при желании можно изменить.
Обратите внимание на расширение файла: стандартным расширением
исходных файлов C++ является .cpp, а не .txt, хотя формат этих файлов —
простой текст. Наша программа просто выводит текст «Hello World!» (При-
вет, мир!), и мы можем запустить ее прямо сейчас. Нажмите F9; программа
будет скомпилирована и запущена. Можно перейти в меню BuildBuild
(СборкаСобрать) и выбрать пункт Run (Запустить).
28    Глава 1. Введение и настройка среды разработки

Теперь у вас есть работающая программа! Вы можете редактировать файл


main.cpp, а затем компилировать и запускать программу, нажимая F9.

Устранение проблем
Если ваша программа не запустится, скорее всего, это вызвано ошибками
компиляции или неправильными настройками IDE.

z z Настройка IDE
Самая распространенная ошибка, которую видят разработчики, когда
программа не работает, выглядит так: ’CB01– Debug” uses an invalid compiler.
Probably the toolchain path within the compiler options is not setup correctly?!
Skipping... (’CB01– Debug” использует некорректный компилятор. Воз-
можно, цепочка инструментов внутри компилятора настроена неверно.
Пропущено...).

Сначала проверьте, подходящая ли версия Code::Blocks загружена (нуж-


на та, что содержит MinGW в своем названии). Если все правильно,
Windows    29

проблема, скорее всего, в автоматическом выборе компилятора. Чтобы


проверить текущий режим автообнаружения, перейдите в SettingsCompiler
(НастройкиКомпилятор) и Debugger... (Отладчик...). Слева выберите
Global Compiler Settings (Глобальные настройки компилятора) (со значком
инструмента) и перейдите на вкладку Toolchain executables (Исполняемые
файлы цепочки инструментов) справа. На ней есть кнопка Auto-detect (Ав-
тообнаружение), с помощью которой можно попытаться решить проблему.
Если это не помогает, можно заполнить форму вручную. Вот снимок экра-
на, который демонстрирует, как все выглядит в любой системе. Измените
путь в поле Compiler’s installation directory (Каталог установки компилятора),
если выполнили установку в другое местоположение, и убедитесь, что вся
остальная информация введена верно.
Затем снова нажмите F9 и запустите программу.

z z Ошибки компилятора
Ошибки компилятора происходят, если вы изменили файл main.cpp
и компилятор его не понимает. Чтобы определить, что вы сделали не так,
взгляните на окно Build messages (Сообщения сборки) или Build log (Жур-
нал сборки). Окно Build messages отображает только ошибки компилятора,
а окно Build log содержит сведения и о других проблемах. При наличии
ошибки окно выглядит следующим образом:

Здесь вы видите имя файла, номер строки и краткое текстовое сообщение,


описывающее ошибку. В данном случае ошибка вызвана тем, что я изменил
строку return 0; на kreturn 0;, что в C++ недопустимо.
Программируя, вы будете часто обращаться к этому окну, чтобы разобраться
в причинах ошибок компиляции.
В этой книге вы увидите много примеров исходного кода. Можно создавать
для каждого из них новое консольное приложение либо изменять исходный
30    Глава 1. Введение и настройка среды разработки

файл первоначальной программы. Рекомендую создавать для каждой про-


граммы новое консольное приложение, чтобы вы могли вносить изменения
в коды примеров и сохранять их для изучения в дальнейшем.

Что же такое Code::Blocks?


Ранее я упомянул концепцию интегрированной среды разработки.
Code::Blocks — это IDE, которая позволяет легко писать исходный код и соз-
давать программы из одного и того же приложения. Имейте в виду, что сам
Code::Blocks не является компилятором. Когда вы скачивали Code::Blocks,
выбранный установочный пакет включал и компилятор (в данном случае
GCC из MinGW — бесплатный компилятор для Windows). Code::Blocks
берет на себя все неочевидные нюансы настройки и вызывает компилятор,
который выполняет реальную работу.

Macintosh
В этом разделе речь только о настройке IDE в OS X1. OS X содержит мощную
среду с оболочкой на основе Unix, поэтому вы можете пользоваться многими
из инструментов, рассмотренных в разделе для Linux этой книги. Тем не
менее не исключено, что вы захотите опробовать работу в Xcode от компа-
нии Apple. Независимо от того, захотите вы использовать Xcode или нет, ее
установка обязательна для работы со стандартными инструментами Linux.
Хотя вы не обязаны пользоваться Xcode для разработки программ на C++
для Mac, стоит освоить ее, если хотите заняться разработкой графических
интерфейсов для Mac.

Xcode
Среда разработки Xcode бесплатно распространяется в составе Mac OS X,
но не устанавливается по умолчанию. Можно найти ее на DVD-диске с Mac
OS X или скачать новейшую версию. Если у вас медленное интернет-со-
единение, поищите Xcode на компакт-диске с Mac OS X, поскольку объем
загрузки с документацией очень велик. Обратите внимание, что даже про-
стейшие компиляторы, такие как gcc и g++, которые обычно по умолчанию

Если вы работаете в Mac OS версии 9 (и более старой) и не можете обновить


1

ее, воспользуйтесь мастер-классом Macintosh Programmer's Workshop на сайте


Apple: http://developer.apple.com/tools/mpw-tools/. Поскольку 9-я версия ОС очень
устарела, я не могу помочь ее настроить.
Macintosh    31

устанавливаются в Linux, не устанавливаются в Mac OS X по умолчанию.


Их можно получить, скачав Xcode Developer Tools.
Ниже приведены инструкции по установке Xcode 3 или Xcode 4 и работе
с ней. Если вы используете Snow Leopard версии 10.6 и ранее, следуйте
инструкциям для Xcode 3. В противном случае сразу переходите к разделу
об установке Xcode 4.

Установка Xcode 3
Чтобы скачать Xcode, выполните следующие действия.
• Зарегистрируйтесь как разработчик Apple на сайте http://developer.
apple.com/programs/register/. Регистрация бесплатна. На веб-сайте Apple
может показаться, что необходимо платить, но вышеуказанная ссылка
приведет прямо на страницу бесплатной регистрации, где необходимо
ввести основные личные сведения.
• Перейдите по ссылке https://developer.apple.com/downloads/index.action
и выполните поиск Xcode 3.2.6. Вы увидите один результат; щелкните
сначала на нем, а затем на ссылке для скачивания Xcode 3.2.6 и iOS
SDK 4.3.
Xcode распространяется в виде обычного файла образа диска, который
можно открыть. Откройте файл образа диска и запустите файл Xcode.mpkg.
В процессе установки вас попросят принять условия лицензионного со-
глашения и предложат список компонентов для установки. Достаточно
компонентов по умолчанию. Выберите их и доведите процедуру установки
до конца.

Запуск Xcode
По завершении работы установщика можно найти Xcode в Developer|Appli­
cations|Xcode. Запустите приложение Xcode. Xcode поставляется с подроб-
ной документацией; при желании можно ознакомиться с руководством
«Getting Started with Xcode» (Как начать работать с Xcode). Тем не менее
это не обязательно для изучения оставшейся части данного раздела.

Создание первой программы на C++ в Xcode


Итак, к делу. В главном окне Xcode, которое появляется при ее запуске,
выберите Create a new Xcode project (Создать проект Xcode). (Можно вы-
брать пункт FileNew Project… (ФайлСоздать проект…) или нажать
Shift-⌘-N.)
32    Глава 1. Введение и настройка среды разработки

Выберите элемент Application (Приложение) на левой боковой панели под


Mac OS X, а затем элемент Command Line Tool (Средство командной строки).
(В iOS также можно видеть элемент Application, но в данный момент это
нас не интересует.)

В списке Type необходимо изменить тип проекта с C на C++ stdc++.

Далее нажмите Choose… (Выбор...) и выберите имя и местоположение ново-


го проекта. В указанном вами месте будет создан каталог с тем же именем,
что и у проекта. В этом примере я не буду оригинален и воспользуюсь
именем HelloWorld.

Нажмите Save (Сохранить). Появится новое окно:


Macintosh    33

В этом окне несколько полезных компонентов. Правая панель предостав-


ляет доступ к исходным кодам, документации и продуктам. Папка Source
содержит файлы C++, связанные с проектом, а папка Documentation — доку-
ментацию (как правило, материалы справочных страниц). Сейчас их можно
игнорировать. Папка Products хранит результаты компиляции программы.
Содержимое этих папок можно видеть в верхней части центрального окна.
Теперь поработаем с исходным файлом. Выберите main.cpp в центральном
окне или папке Source. (Обратите внимание на расширение файла: стан-
дартным расширением исходных файлов C++ является .cpp, а не .txt, хотя
формат этих файлов — простой текст.) Щелкнув на файле, вы увидите его
код в окне, которое в текущий момент содержит фразу «No Editor» (Ре-
дактор отсутствует). Теперь можно начать вводить текст.
34    Глава 1. Введение и настройка среды разработки

При двойном щелчке на файле вызывается окно редактора.


По умолчанию Xcode содержит небольшой пример программы, с которого
легко можно начать работу. Скомпилируем и запустим эту программу.
Сначала нажмите Build and Run на панели инструментов.
В результате компиляции программы будет создан исполняемый файл.
В Xcode 3 программа не запустится; чтобы запустить ее, дважды щелкни-
те на исполняемом файле HelloWorld. Обратите внимание: раньше он был
красным, а теперь стал черным:

Для запуска дважды щелкните на вашей первой программе.


Вы должны увидеть результаты, которые выглядят примерно так (по со-
ображениям конфиденциальности я скрыл имя пользователя, который
одолжил мне Macintosh, чтобы сделать этот снимок экрана):
Macintosh    35

Готово — вы запустили свою первую программу!


Когда вам понадобится запустить какую-нибудь программу, воспользуй-
тесь только что созданным проектом или создайте новый. В любом случае,
если вы захотите добавить в программу собственный код, можете начать
с изменения примера, который Xcode создает в файле main.cpp.

Установка Xcode 4
Скачать Xcode 4 можно в магазине приложений для Mac (Mac App Store)
и установить ее. Размер Xcode 4 — около 4,5 Гбайт.
После загрузки Xcode из магазина приложений для Mac на вашем доке
появится значок Install Xcode (Установить Xcode). Дважды щелкните на
нем для запуска установки.
Вас попросят принять условия лицензионного соглашения и предложат
список компонентов для установки. Выберите компоненты по умолчанию
и доведите до конца процедуру установки.

Запуск Xcode
По завершении работы установщика Xcode можно найти в Developer|Appli­
cations|Xcode. Запустите приложение Xcode. Xcode поставляется с подроб-
ной документацией; при желании можно ознакомиться с руководством
«Xcode Quick Start Guide» (Как начать работать с Xcode), открыв его по
ссылке Learn about using Xcode (Узнайте об использовании Xcode) на на-
чальном экране. Это не требуется для изучения оставшейся части данного
раздела.

Создание первой программы на C++ в Xcode


Итак, к делу. В главном окне Xcode, которое появляется при ее запуске, вы-
берите Create a new Xcode project (Создать проект Xcode). (Можно выбрать
пункт FileNew Project… или нажать Shift-⌘-N.) Вы увидите такое окно.
36    Глава 1. Введение и настройка среды разработки

Выберите элемент Application (Приложение) на левой боковой панели под


Mac OS X, а затем элемент Command Line Tool (Средство командной строки).
(В iOS можно также видеть элемент Application, но в данный момент он не
нужен.) Нажмите Next (Далее).
Вы увидите следующее окно:
Macintosh    37

Я уже указал в нем имя продукта HelloWorld и тип C++ в Type (по умолчанию
задан тип C). Еще раз нажмите Next. Вы увидите следующее окно:

Если флажок Create local git repository for this project (Создать локальный
репозиторий git для этого проекта) установлен, можно снять его. Git — это
система контроля исходного кода, которая позволяет хранить несколько
версий одного и того же проекта; ее рассмотрение выходит за рамки этой
книги. Выберите местоположение вашего проекта; я помещу его в папку
Documents. Задав настройки, нажмите Create (Создать). Появится новое окно:

В этом окне есть несколько удобных вещей. Правая панель обеспечивает


доступ к исходному коду и проектам. Исходный код находится в каталоге
с именем проекта, в данном случае HelloWorld. Остальная часть этого окна
38    Глава 1. Введение и настройка среды разработки

в основном содержит информацию о настройках компилятора, с которым


сейчас не нужно ничего делать.
Поработаем с самим исходным файлом. Выберите main.cpp в папке на левой
боковой панели. (Обратите внимание на расширение файла: стандартным
расширением исходных файлов C++ является .cpp, а не .txt, хотя формат
этих файлов — простой текст.) Щелкнув на файле, вы увидите его исход-
ный код в главном окне. Можете вводить текст непосредственно в файл.

Если дважды щелкнуть на файле, вызывается окно редактора, которое


можно перемещать. По умолчанию в Xcode находится небольшой пример
программы, с которого можно начать работу. Скомпилируем и запустим эту
программу. Все, что нужно сделать, — нажать Run (Запустить) на панели
инструментов! Результат появится в нижнем правом углу:
Macintosh    39

Готово — вы запустили свою первую программу!


Если в будущем понадобится запустить какой-нибудь пример программы,
воспользуйтесь только что созданным проектом или создайте новый. В лю-
бом случае, если захотите добавить в программу собственный код, можете
начать с примера, который Xcode создает в файле main.cpp.

Устранение проблем
Обратите внимание, что в этом разделе используются снимки экранов из
Xcode 3.
Возможно, программа не компилируется из-за ошибок компилятора
(скажем, из-за опечатки в примере или настоящей ошибки в вашей про-
грамме). Если это произойдет, компилятор отобразит одно или несколько
сообщений об ошибках.
Среда Xcode выводит сообщения об ошибках компилятора прямо в тех
строках исходного кода, где они происходят. В приведенном ниже примере
я изменил исходную программу и вместо конструкции std::cout написал c.

В прямоугольнике можно видеть ошибку компилятора: Xcode не знает, что


такое 'c'. Можно видеть и сообщение, что сборка завершилась неудачно,
сначала в левом нижнем углу, а затем в правом нижнем углу вместе с коли-
чеством ошибок (в данном случае 1). (В Xcode 4 выводится аналогичный
значок, но в верхнем правом углу.)
40    Глава 1. Введение и настройка среды разработки

Чтобы увидеть полный список ошибок, в Xcode 3 щелкните на значке


с молотком в нижнем правом углу и вызовите диалоговое окно, которое
показывает в виде списка все ошибки, обнаруженные компилятором:

Я выделил рамкой ошибку. Щелкнув на ней, можно увидеть небольшое


окно редактора с той же ошибкой непосредственно в коде.
В Xcode 4 вместо правой панели с исходными файлами выводятся ошибки
компилятора, если сборка заканчивается неудачно.
Исправив ошибку, запустите программу еще раз, нажав Build and Run (Со-
брать и запустить).

Linux
Если вы работаете в Linux, у вас почти наверняка уже установлен ком-
пилятор C++. Обычно пользователи Linux работают с компилятором
g++, который входит в коллекцию компиляторов GNU (GNU Compiler
Collection, GCC).
Linux    41

Шаг 1. Установка g++


Чтобы проверить, установлен ли g++, откройте окно терминала. Введите
g++ и нажмите Enter. Если компилятор уже установлен, вы должны уви-
деть следующее:
g++: no input files

Если вы видите фразу вроде


command not found

скорее всего, необходимо установить g++. Процесс установки g++ зависит


от программного обеспечения, управляющего пакетами в вашем дистри-
бутиве Linux. Если вы работаете в Ubuntu, возможно, достаточно просто
ввести команду
aptitude install g++

В других дистрибутивах Linux управление пакетами может как выпол-


няться с такой же легкостью, так и требовать дополнительных действий.
Более подробную информацию можно получить в документации вашего
дистрибутива Linux.

Шаг 2. Запуск g++


Запустить компилятор g++ относительно просто. Создадим нашу первую
программу прямо сейчас. Создайте простой файл с расширением .cpp.

Пример 1: hello.cpp
#include <iostream>
int main ()
{
std::cout << "Hello, world" << std::endl;
}

Сохраните этот файл как hello.cpp и запомните, в какой каталог вы его


поместили. (Обратите внимание на расширение файла: стандартным рас-
ширением для исходных файлов C++ является .cpp, а не .txt, хотя формат
этих файлов — простой текст.)
Вернитесь в окно терминала и перейдите в каталог с сохраненным файлом.
Введите команду
g++ hello.cpp -o hello

и нажмите Enter.
42    Глава 1. Введение и настройка среды разработки

Параметр –o компилятора g++ указывает имя результирующего файла.


Без этого параметра по умолчанию используется имя a.out.

Шаг 3. Запуск программы


Мы дали файлу имя hello, и теперь можно запустить новую программу
командой
./hello

Вы должны увидеть следующий результат:


Hello, world

Итак, ваша первая программа только что вступила в контакт с внешним


миром.

Устранение проблем
Возможно, ваша программа по какой-то причине не компилируется. Как
правило, это происходит из-за ошибок компилятора (скажем, опечатки
при вводе примера программы). В такой ситуации компилятор отображает
одно или несколько сообщений об ошибках компиляции.
Например, если в примере вы ввели x перед cout, компилятор выведет
следующие ошибки:
gcc_ex2.cc: In function 'int main ()':
gcc_ex2.cc:5: error: 'xcout' is not a member of 'std'

Каждая ошибка содержит имя файла, номер строки и сообщение об ошибке.


В данном случае проблема в том, что компилятору ничего не известно об
элементе xcout, поскольку это должен быть cout.

Шаг 4. Настройка текстового редактора


Если вы используете Linux, для работы потребуется и хороший тексто-
вый редактор. Для Linux есть ряд весьма развитых текстовых редакторов,
например Vim и Emacs (работая в Linux, я пользуюсь Vim). Тем не менее
освоить их относительно непросто, и для этого потребуется потратить
немало времени. В долгосрочной перспективе эти затраты окупятся, но,
возможно, не хочется изучать редакторы, поскольку вы уже заняты освое-
нием программирования. Если вы знакомы с каким-либо из перечисленных
инструментов, продолжайте использовать его; если любимого редактора
еще нет, воспользуйтесь, например, редактором nano. Текстовый редактор
Linux    43

nano относительно прост, но у него есть несколько ценных возможностей,


например выделение синтаксиса и автоматический отступ (избавляет от
необходимости нажимать табуляцию при переходе на новую строку; это
тривиально, но очень полезно). Редактор nano основан на редакторе pico,
который очень прост в освоении, но лишен многих возможностей, необхо-
димых для написания программ. Возможно, вам даже приходилось пользо-
ваться pico, если вы работали с почтовой программой Pine. Если нет, ничего
страшного — чтобы начать пользоваться nano, не нужно никакого опыта.
Чтобы узнать, установлен ли редактор nano в вашей системе, введите nano
в окне терминала. Возможно, nano запустится автоматически; если этого
не произойдет и вы получите сообщение, что такая команда не найдена,
придется установить nano. Для этого воспользуйтесь инструкциями ме-
неджера пакетов вашего дистрибутива Linux. Этот раздел рассчитан на
версию 2.2.4, но можно использовать и предыдущие версии.

Настройка Nano
Чтобы воспользоваться некоторыми возможностями редактора nano,
придется настроить его конфигурационный файл — .nanorc, который, как
большинство конфигурационных файлов Linux, находится в каталоге (~/.
nanorc).

Если файл уже существует, его можно просто отредактировать; если нет —
создать. (Если у вас вообще нет опыта использования текстовых редакторов
Linux, для конфигурирования можно воспользоваться редактором nano.
Информация об основах работы с nano приведена ниже.)
Чтобы правильно настроить nano, воспользуйтесь готовым примером
файла .nanorc. Вы получите удобное выделение синтаксиса и автоотступ,
что существенно упростит редактирование исходного кода.

Использование Nano
Чтобы создать новый файл, можно запустить nano без аргументов; чтобы
редактировать файл, укажите его имя в командной строке:
nano hello.cpp

Если файл не существует, nano начнет редактировать новый связанный


с ним буфер, но не создаст файл на диске до сохранения изменений.
Вот как выглядит редактор nano при запуске.
44    Глава 1. Введение и настройка среды разработки

В прямоугольнике в верхней части окна находится заголовок редактиру-


емого файла или надпись New Buffer (Новый буфер), если вы запустили
nano, не указав имя файла.
В прямоугольнике в нижней части окна находится набор команд, вводимых
с клавиатуры. Если перед буквой стоит символ ^, значит необходимо нажать
Ctrl и соответствующую букву. Например, команда выхода отображается
как ^X, следовательно, чтобы выйти из редактора, нужно нажать Ctrl-X.
Регистр неважен.
Если вы привыкли работать в Windows, некоторые термины редактора
nano могут быть незнакомы, поэтому сейчас мы рассмотрим, как выполнить
несколько базовых действий nano.

z z Редактирование текста
При запуске nano вы создаете новый файл либо открываете существующий.
После этого можно вводить в него текст — в этом отношении nano очень
похож на Блокнот (Notepad) Windows. Тем не менее в nano используется
иной подход к копированию и вставке текста: для копирования текста
нажмите Ctrl-K (операция Cut Text — вырезать текст), а для вставки —
Ctrl-U (операция UnCut Text — отменить вырезание текста). Если вы не
выделили текст, по умолчанию эти команды вырежут одну строку текста.
Кроме того, можно искать текст в файле с помощью функции «Where Is»
(Где находится), нажимая Ctrl-W. Вам предлагается задать набор параме-
тров, но проще всего ввести искомую строку и нажать Enter.
Вы можете перемещаться на соседние страницы с помощью функ-
ций Prev Page (предыдущая страница) и Next Page (следующая
Linux    45

страница) — клавиатурные комбинации Ctrl-Y и Ctrl-V соответственно.


Обратите внимание: клавиатурные комбинации редактора nano мало по-
хожи на Windows.
Единственная важная возможность, которая отсутствует в nano и имеется
в большинстве других текстовых редакторов, — это отмена и повторение
операций (в nano версии 2.2 эти функции используются лишь в экспери-
ментальном режиме). По умолчанию все функции отмены и повторения
операций отключены.
Ctrl-R в редакторе nano предоставляет расширенный поиск и замену текста
в файле. Сначала нужно ввести искомый текст, а затем замену.

z z Сохранение файлов
Сохранение файла в nano называется выводом (WriteOut, Ctrl-O).

При выполнении команды WriteOut всегда предлагается ввести имя файла


для вывода, даже если он уже открыт. Если вы уже редактируете файл, его
имя отображается по умолчанию, поэтому можно просто нажать Enter и со-
хранить файл. Если вы хотите сохранить файл в новое местоположение,
можно ввести имя сохраняемого файла или выбрать его из пункта меню To
Files (Файлы для записи) или нажать Ctrl-T. Действие Cancel (Отменить)
говорит само за себя: большинство команд можно отменить, но, в отличие от
Windows, отмена действия выполняется по умолчанию не Escape, а Ctrl-C.
Пока не обращайте внимания на другие доступные параметры: они очень
редко нужны в процессе работы.

z z Открытие файлов
Чтобы открыть файл для редактирования, воспользуйтесь командой Read
File (Считать файл) или Ctrl-R. Эта команда выводит параметры меню.

Чтобы открыть файл, а не прямо вставлять текст в редактируемый файл,


перед тем как выбрать файл, выберите в этом меню пункт New Buffer (Но-
вый буфер).
46    Глава 1. Введение и настройка среды разработки

Команду New Buffer можно вызвать, нажав M-F. Буква M означает meta
key (метаклавиша) — в данном случае, скорее всего, потребуется нажать
Alt: Alt-F1. Это сочетание указывает редактору nano, что вы собираетесь
открыть файл. Затем можно ввести имя редактируемого файла или вывести
список файлов с помощью Ctrl-T и выбрать в нем нужный файл. Отменить
выполненные действия можно, как обычно, Ctrl-C.

z z Изучение исходного файла


Теперь вы знаете редактор nano достаточно хорошо, чтобы открыть исход-
ный файл и начать работать над ним. При корректной настройке файла
.nanorc открытый файл hello.cpp, который мы запускали ранее, должен
выглядеть следующим образом:

Вы должны увидеть текст, фрагменты которого отображаются различным


цветом в соответствии с их смыслом. Например, если вы откроете в редак-
торе программу «Hello World», ее текст должен быть отображен розовым
цветом. Выделение текста зависит от расширения файла, поэтому текст не
будет размечен цветом, пока файл не сохранен как исходный (.cpp).

Иногда при использовании Alt возникают проблемы. Если клавиша не ра-


1

ботает, нажмите и отпустите Esc перед тем, как нажать символ (например,
последовательность Esc-F эквивалентна Alt-F).
Linux    47

Если в будущем понадобится запустить какой-нибудь пример программы,


создайте для нее новый текстовый файл с помощью редактора nano и ком-
пилируйте его по приведенным ранее шагам.

z z Дополнительная информация
Теперь можно редактировать основные файлы в программе nano, а допол-
нительную информацию о ней можно получить во встроенной справочной
системе (Ctrl-G). Для изучения более сложных возможностей редактора
nano рекомендую воспользоваться веб-сайтом http://freethegnu.wordpress.
com/2007/06/23/nano-shortcuts-syntax-highlightand-nanorc-config-file-pt1/.
Г лава 2
Основы C++

Введение в язык C++


Настраивая IDE в предыдущей главе, вы уже запускали свою первую про-
грамму. Поздравляем! Вы продвинулись далеко вперед.
В этой главе вы узнаете об основных строительных блоках языка C++,
позволяющих начать создание собственных простых программ. Я рас-
скажу о нескольких концепциях, которые вы будете видеть вновь и вновь:
структуре программы, функции main, стандартных функциях, предостав-
ляемых компилятором, добавлении комментариев в программу и основах
программистского мышления.

Простейшая программа на C++


Начнем, шаг за шагом рассмотрев самую простую программу, не выпол-
няющую никаких действий.

Пример 2: empty.cpp
int main ()
{
}

Как видите, ничего страшного.


Введение в язык C++    49

Первая строка
int main ()

сообщает компилятору, что существует функция с именем main, возвра-


щающая целое число (integer), которое в языке C++ сокращается до int.
Функция представляет собой фрагмент кода, как правило, написанный
с использованием других функций или простейших возможностей языка
C++. В данном случае наша функция ничего не делает, но скоро мы по-
знакомимся с функциями, которые выполняют действия.
Функция main — особая, единственная, которая должна входить во все
программы на C++. Именно с нее начинается выполнение запускаемой
программы. Перед функцией main указан тип возвращаемого ею значения:
int. Если функция возвращает значение, вызывающий ее код может им
воспользоваться. Значение, возвращаемое функцией main, используется
операционной системой. Обычно здесь необходимо явно возвращать зна-
чение, однако язык C++ позволяет опускать оператор возврата в функции
main; по умолчанию она возвращает 0 — код, который сообщает операци-
онной системе, что программа выполнена успешно.
Фигурные скобки, { и }, показывают начало и конец функции (и других
блоков кода, как мы скоро увидим). Можно считать, что они означают на-
чало и конец функции. В данном случае мы видим, что функция не выпол-
няет никаких действий, поскольку между фигурными скобками ничего нет.
Запустив эту программу, вы не увидите никаких результатов, поэтому
мы сразу перейдем к программе, которая представляет немного больший
интерес (но только немного).

Пример 3: hello.cpp
#include <iostream>
using namespace std;
int main ()
{
cout << "HEY, you, I'm alive! Oh, and Hello
World!\n";
}

Для начала обратите внимание, что между фигурными скобками что-то


есть: значит, программа что-то делает! Шаг за шагом рассмотрим, что.
Первая строка
#include <iostream>
50    Глава 2. Основы C++

представляет собой оператор include, который сообщает компилятору, что


в нашу программу требуется включить код заголовочного файла с име-
нем iostream перед созданием исполняемого файла. Заголовочный файл
iostream поставляется вместе с компилятором и позволяет выполнять
операции ввода и вывода. При использовании директивы #include все
содержание заголовочного файла фактически вставляется в вашу про-
грамму. Включение заголовочных файлов предоставляет доступ ко многим
функциям компилятора.
Если нужны базовые функции, мы должны включить в программу предо-
ставляющий к ним доступ заголовочный файл. В текущий момент боль-
шинство требуемых функций находится в заголовочном файле iostream.
Вы увидите его в начале почти всех программ, однако подавляющее боль-
шинство программ начинается с одного или нескольких операторов include.
За оператором include следует строка
using namespace std;

Это стандартный код, который входит в состав почти всех программ на C++.
Сейчас просто используйте его в начале всех программ сразу за операто-
рами include. Он облегчает применение сокращенных версий некоторых
процедур, предоставляемых заголовочным файлом iostream. Позже мы
поговорим, как именно он работает, а пока просто не забывайте включать
его в свои программы.
Обратите внимание: строка завершается точкой с запятой. Точка с за-
пятой — часть синтаксиса C++. Она указывает компилятору на конец
оператора. Точка с запятой используется для завершения большинства
операторов в языке C++. Начинающие программисты часто забывают
использовать точки с запятой, поэтому, если ваша программа не работает,
проверяйте, все ли точки с запятой расставлены в ее коде. Рассказывая
о новых концепциях, я буду отмечать, надо ли в них использовать точку
с запятой.
Далее следует функция main, с которой начинается выполнение программы:
int main ().

Следующая строка программы с забавным символом << может показаться


странной:

cout << "HEY, you, I'm alive! Oh, and Hello


World!\n";
Введение в язык C++    51

Здесь язык C++ использует объект cout (читается «си аут») для ото-
бражения текста. Специально для доступа к этому объекту мы включили
в программу заголовочный файл iostream.

Он использует символы <<, которые называются операторами вставки и


указывают на выводимые данные. Коротко говоря, конструкция cout <<
вызывает функцию, аргументом которой является текст. Вызов функции
приводит к выполнению кода, связанного с этой функцией. Как прави-
ло, функции принимают аргументы, используемые их кодом. В данном
случае аргументом является текстовая строка, которую мы написали.
Аргументы функций можно рассматривать как параметры уравнения.
Чтобы вычислить площадь квадрата, используется формула, которая
возводит в квадрат длину стороны; длина стороны является аргументом
этой формулы. Функции, как и формулы, принимают переменные в ка-
честве аргументов. В данном случае функция выводит предоставленный
ей аргумент на экран.

Кавычки показывают компилятору, что вы хотите вывести строки лите-


ралов как есть, за исключением некоторых специальных последователь-
ностей. \n — особая последовательность: она рассматривается как символ
новой строки, эквивалентный нажатию Enter (позже мы поговорим об
этом подробнее). Этот символ переводит курсор на экране на следующую
строку. Иногда вместо символа новой строки используется значение endl;
конструкции cout << "Hello" << endl и cout << "Hello\n" полностью
эквивалентны. Слово endl означает «end line» (конец строки), будьте вни-
мательны: последняя буква — L, а не 1. Легко допустить ошибку, написав
end1 с цифрой 1 вместо буквы L.

Наконец, снова обратите внимание на точку с запятой: она нужна, по-


скольку мы вызываем функцию.

Функция завершается закрывающей фигурной скобкой. Пора попробовать


скомпилировать программу и запустить ее. Наберите код или откройте
соответствующий готовый файл. Можно просто скопировать и вставить
исходный код программы, однако рекомендую ввести его самостоятельно —
это не займет много времени, но вы уделите внимание деталям, важным
для компилятора (например, точкам с запятой).

После того как вам удастся запустить вашу первую программу, поэкспери-
ментируйте с cout. Выведите другой текст, несколько строк — посмотрите,
на что способен компьютер.
52    Глава 2. Основы C++

Почему работающая программа не видна?


Не исключено, что при запуске программ из этой книги вы не сможете
увидеть результаты их работы, поскольку эти программы отрабатывают
очень быстро, а затем сразу же закрываются (это зависит от операционной
системы и компилятора, которыми вы пользуетесь). Если вы используете
одну из сред, рекомендуемых в этой книге, скорее всего, такая проблема
не возникнет, однако вы можете столкнуться с ней, работая в другой IDE.
Эту проблему можно решить, добавив строку
cin.get();

в конец кода. Она заставит программу ждать нажатия любой клавиши,


что позволит увидеть результаты работы программы до закрытия ее окна.

Базовая структура программы на C++


Ух ты, в этой короткой программе поразительно много интересного! От-
влечемся от деталей и рассмотрим общую структуру программы на C++:

[операторы include]
using namespace std;
int main()
{
[здесь ваш код];
}

Что произойдет, если что-нибудь пропустить?


Если пропустить оператор include или using namespace std, ваша про-
грамма не скомпилируется. Если программа не компилируется, значит
компилятор чего-то не понимает. Возможно, вы ошиблись в синтаксисе (на-
пример, пропустили точку с запятой) или не указали заголовочный файл.
Когда вы только начинаете писать программы, очень трудно обнаруживать
ошибки компиляции. При неудачной компиляции компилятор всегда гене-
рирует одну или несколько ошибок, которые объясняют причину неудачи.
Ниже приведен простейший пример сообщения компилятора об ошибке:
error: 'cout' was not declared in this scope

Если вы видите похожее сообщение, проверьте, присутствуют ли в начале


программы операторы include для iostream и using namespace std;.
Сообщения компилятора об ошибках не всегда легко интерпретировать.
Забыв указать точку с запятой, вы, скорее всего, получите много оши-
бок компилятора сразу после строк с ошибкой. Видя много непонятных
Комментарии в программах    53

ошибок, взгляните на предыдущие строки и проверьте, есть ли в них точ-


ка с запятой. Не беспокойтесь — с течением времени вы научитесь очень
быстро интерпретировать ошибки компилятора, и их станет меньше. Вы
только начинаете программировать, и естественная задача — научиться
исправлять ошибки.

Комментарии в программах
В процессе освоения программирования стоит научиться документировать
программы (если не для других, то для себя). Для этого в код добавляются
комментарии; так я буду часто пояснять его смысл.
Текст, помеченный как комментарий, игнорируется компилятором при вы-
полнении кода, что позволяет описывать код любым текстом. Чтобы создать
комментарий, воспользуйтесь последовательностью символов //, которая
показывает компилятору, что оставшаяся часть строки — комментарий,
или последовательностями символов /* и */, весь текст между которыми
также считается комментарием:
// это одна строка комментария
Этот код не входит в комментарий
/* это много строк комментария
Эта строка - часть комментария
*/

В некоторых средах компиляции комментарии выделяются цветом, чтобы


отличаться от исполняемого кода. Это один из примеров, в котором ис-
пользуется выделение синтаксиса.
Иногда полезно комментировать фрагменты кода и наблюдать, как их
исключение из программы влияет на результат ее работы. Чтобы коммен-
тировать код, достаточно заключить между маркерами комментария те
его строки, которые вы не хотите обрабатывать компилятором. Например,
если хотите определить, как повлияет на программу удаление оператора
cout, закомментируйте его:

Пример 4: hello.cpp
#include <iostream>
using namespace std;
int main ()
{
// cout << "HEY, you, I'm alive! Oh, and Hello
// World!\n";
}
54    Глава 2. Основы C++

Будьте осторожны — не комментируйте случайно нужный код!


Если вы комментируете необходимый код вместе со, скажем, заголовочным
файлом, при компиляции программы могут возникнуть ошибки. Если у вас
много проблем компиляции фрагмента кода, можно комментировать части
программы, которые, возможно, некорректны; если программа компили-
руется при отсутствии этого кода — проблема в комментированном коде.

Программистское мышление и создание повторно


используемого кода
Отвлечемся ненадолго от синтаксиса и поговорим о самом программиро-
вании. Сюжет одной из передач State Farm (Опиши предприятие) посвя-
щен автомойке, где машины возвращали владельцам, не очистив корпуса
от моющего средства1. Смысл сюжета в том, что некоторые страховые
компании предлагают клиентам условия, существенно усложняющие по-
лучение страховых выплат. Кажется, будто условия покрывают широкий
круг ситуаций, что на самом деле совсем не так.
Эта передача — отличная аналогия мышлению программиста. Компьютеры,
как мойка из видеоролика, мыслят очень конкретно. Они делают в точности
то и только то, что вы им указываете, и не воспринимают ваши скрытые
намерения. Если вы говорите им «вымой машину», они моют машину. Если
вы хотите, чтобы машину еще и ополоснули, лучше сказать это прямо. На
первый взгляд такая детальность может показаться обескураживающей,
поскольку вы должны продумывать процесс от начала до конца, не про-
пуская ни единого шага.
К счастью, когда вы пишете программу, вы сохраняете указания компьютеру
под определенным именем и можете многократно обращаться к ним, вме-
сто того чтобы раз за разом повторять шаги с нуля. Программирование —
гораздо менее рутинное занятие: однажды написав точные инструкции,
можно пользоваться ими снова и снова. Вы скоро убедитесь в этом, когда
мы дойдем до функций.

Э ту передачу можно посмотреть в Интернете: http://www.youtube.com/


1

watch?v=QaTx1J7ZeLY, она длится всего 57 секунд.


Несколько слов о радостях и трудностях практики    55

Несколько слов о радостях и трудностях практики


Вы только начинаете работать с книгой, но уже в конце этой главы найдете
несколько практических задач. Мой опыт говорит, что едва ли можно найти
более эффективный способ обучения программированию, чем решение
практических задач. В первую очередь, программирование требует внима-
ния к деталям. Знаю по собственному опыту, что можно легко прочитать
фрагмент текста, удовлетвориться понятым смыслом и не вникнуть в
подробности. Но нельзя хорошо освоить синтаксис и нюансы языка C++,
если не писать собственный код. Только учась программировать, нелегко
придумывать идеи для простых программ. По этой причине в большин-
стве глав данной книги приведены практические задания, которые пред-
лагается решать. Количество этих задач невелико, поэтому настоятельно
рекомендую, прочтя каждую главу, прорабатывать их целиком, лишь затем
переходя к чтению следующей главы.
Поздравляю! Вы познакомились со своей первой программой и даже кое-
что знаете о том, как мыслит программист. Эта глава содержит много ма-
териала. Эксперименты с кодом позволят понять, что еще можно сделать.
В следующей главе мы узнаем, как программа взаимодействует с пользо-
вателем, в том числе научимся вводить данные пользователя в программу.

Проверьте себя
1. Какое значение возвращает операционной системе программа после
успешного выполнения?
А. –1
Б. 1
В. 0
Г. Программы не возвращают значения.
2. Как называется единственная функция, входящая во все программы
на C++?
А. start()
Б. system()
В. main()
Г. program()
3. Какие знаки пунктуации показывают начало и конец блоков кода?
А. { }
Б. -> и <-
56    Глава 2. Основы C++

В. BEGIN и END
Г. ( и )
4. Какой знак пунктуации ставится в конце большинства строк кода C++?
А. .
Б. ;
В. :
Г. '
5. Какая из приведенных ниже конструкций является правильным ком-
ментарием?
А. */ Comments */
Б. ** Comment **
В. /* Comment */
Г. { Comment }
6. Какой заголовочный файл необходим для доступа к объекту cout?
А. stream
Б. Никакой, поскольку объект cout доступен по умолчанию.
В. iostream
Г. using namespace std;
(Решения см. на с. 450.)

Практические задания
1. Напишите программу, которая выводит ваше имя.
2. Напишите программу, которая выводит на экран несколько строк, со-
держащих имя вашего друга.
3. Последовательно закомментируйте каждую из строк первой созданной
нами программы и проверьте, компилируется ли программа. Изучите
появляющиеся ошибки. Ясен ли вам их смысл? Понимаете ли вы, по-
чему изменения, внесенные в код, приводят к этим ошибкам?
Г лава 3
Взаимодействие с пользователем
и работа с переменными

Вы уже знаете, как создать простую программу, которая отображает


введенную вами информацию, и как описать ее в комментариях. Это
здорово, но что делать, если программа должна взаимодействовать
с пользователем?

Знакомство с переменными
Для взаимодействия с пользователем программа должна принимать
входные данные — информацию, которая поступает извне программы.
Входные данные надо где-то сохранять; в программировании входные
и прочие данные хранятся в переменных. Существует несколько типов
переменных, которые содержат различные виды информации (например,
числа и буквы). Говоря компилятору, что объявляете переменную, вы
должны указать соответствующий ей тип данных, или просто тип, и имя
переменной.
Основными типами данных являются символы char, целые числа int
и вещественные числа с двойной точностью double. Переменная типа char
хранит один символ, переменная типа int — целое число (без десятич-
ных знаков), а переменная типа double — число с десятичными знаками
(странное имя, верно?). Типы переменных являются ключевыми словами,
которые используются при объявлении переменной.
58    Глава 3. Взаимодействие с пользователем и работа с переменными

Объявление переменных в C++


Перед использованием о переменной необходимо сообщить компилятору,
объявив ее (компилятор требует, чтобы ему обо всем докладывали зара-
нее). Синтаксис объявления переменной: тип <имя>; (еще раз обратите
внимание на точку с запятой!).
Вот несколько примеров объявления переменных:
int whole_number;
char letter;
double number_with_decimals;

Несколько переменных одинакового типа можно объявить в одной строке;


имена переменных должны быть разделены запятыми:
int a, b, c, d;

Тем не менее рекомендую объявлять каждую переменную в отдельной


строке, чтобы читать код было легче.

Использование переменных
Итак, вы знаете, как сообщить компилятору о переменных, но как исполь-
зовать сами переменные?
Для чтения входных данных используется объект cin (читается «си ин»),
за которым следует оператор вставки, имеющий противоположное на-
правление (>>), и переменная, в которую помещается значение, вводимое
пользователем.
Вот пример программы, который демонстрирует использование пере-
менной:

Пример 5: readnum.cpp
#include <iostream>
using namespace std;
int main ()
{
int thisisanumber;
cout << "Please enter a number: ";
cin >> thisisanumber;
cout << "You entered: " << thisisanumber << "\n";
}

Рассмотрим эту программу построчно. Вы уже видели ее первую часть,


поэтому сосредоточимся на теле функции main.
int thisisanumber;
Знакомство с переменными    59

В этой строке объявлена переменная thisisanumber целого типа. Следую-


щая строка программы имеет вид cin >> thisisanumber;
Функция cin >> сохраняет значение, введенное пользователем, в пере-
менную thisisanumber. Чтобы программа считала число, пользователь
должен нажать Enter.

Что делать, если программа сразу завершается?


Если раньше, чтобы программа не завершалась мгновенно, приходилось
вызывать функцию cin.get(), теперь это не помогает. Можно устранить
эту проблему, добавив перед вызовом cin.get():
cin.ignore();

Это функция, которая считывает и игнорирует символ (в данном случае


Enter, нажатый пользователем). Когда пользователь вводит данные в про-
грамму, она принимает и Enter, но отбрасывает его за ненадобностью. Эта
строка нужна, лишь когда вы добавляете cin.get(), чтобы программа ожи-
дала нажатия клавиши. Без этой строки вызов cin.get() считает символ
новой строки, что приведет к немедленному завершению программы.
Имейте в виду, что объявленная переменная имеет целый тип, и если поль-
зователь попытается ввести десятичное число, оно будет округлено (си-
стема отбросит десятичную составляющую числа; например, число 3,1415
будет преобразовано в 3). Запустите пример и введите последовательность
символов или десятичное число: реакция программы будет зависеть от
вводимых данных, но ее вид вряд ли будет приятным. Опустим пока об-
работку ошибок, которую следовало бы выполнить в данной ситуации.
cout << "You entered: " << thisisanumber << "\n";

Эта строка выводит на экран данные, введенные пользователем. Обратите


внимание, что при выводе переменной не используются кавычки. Если
заключить в кавычки переменную thisisanumber , программа выведет
строку "You Entered: thisisanumber". Отсутствие кавычек показывает
компилятору, что в строке кода присутствует переменная; при отображе-
нии результата программа должна определить ее значение и заменить им
имя переменной.
Использовать два отдельных оператора вставки в одной строке вполне
допустимо. Можно беспрепятственно помещать в одну строку несколько
операторов вставки, и весь их вывод будет направлен в одно и то же ме-
сто. На самом деле вы должны разделять строковые константы (строки,
помещенные в кавычки) и переменные, используя для каждой из них
60    Глава 3. Взаимодействие с пользователем и работа с переменными

собственные операторы вставки (<<). Если вы попытаетесь отобразить


переменную вместе со строковой константой в одном операторе вставки
<<, получите сообщение об ошибке:
BAD CODE (НЕКОРРЕКТНЫЙ КОД)
cout << "You entered: " thisisanumber;

Как и любой вызов функции, эта строка заканчивается точкой с запятой.


Если забыть про точку с запятой, компилятор выведет сообщение об ошибке.

Изменение, использование и сравнение переменных


Считывать и выводить на экран переменные — занятие весьма скучное,
поэтому добавим в нашу программу возможность изменять переменные
и в зависимости от их значений заставим программу выполнять различ-
ные действия. Мы будем по-разному реагировать на различные данные,
вводимые пользователем.
Значение переменной задается с помощью оператора присваивания =:
int x;
x = 5;

Код устанавливает значение переменной x равным 5. Возможно, вы поду-


мали, что знак равенства сравнивает значения левой и правой частей, но
это не так. В C++ для сравнения используется другой оператор, двойной
знак равенства: ==. Оператор == часто используется в условных операторах
и циклах. Мы будем активно применять сравнения в следующих двух гла-
вах, изучая, как программа принимает решения в зависимости от данных,
вводимых пользователем.
a == 5 // НЕ присваивает число 5 переменной a, а проверяет,
// равно ли ее значение 5.

Над переменными можно выполнять арифметические операции.

* Умножает два значения


- Вычитает одно значение из другого
+ Складывает два значения
/ Делит одно значение на другое

Вот несколько примеров:


a = 4 * 6; // (Обратите внимание на использование комментариев и точки
с запятой) переменная a равна
// 24
a = a + 5; // значение a равно исходному значению a,
// увеличенному на 5
Знакомство с переменными    61

Упрощенное прибавление и вычитание единицы


В языке C++ часто приходится увеличивать значение переменной на
единицу:
int x = 0;
x = x + 1;

Когда мы начнем работать с циклами, эта последовательность будет по-


стоянно встречаться. Она используется так часто, что существует оператор
++, предназначенный только для увеличения переменной на единицу.

Приведенный выше код можно написать следующим образом:


int x = 0;
x++;

После выполнения этих команд значение переменной x станет равным 1.


Увеличение переменной на единицу называют инкрементированием, а со-
ответствующий оператор — оператором инкрементирования.
Оператор -- действует аналогичным образом, но уменьшает значение
переменной на единицу. Уменьшение переменной на единицу называют
декрементированием, а оператор -- — оператором декрементирования.
Возможно, теперь вы догадываетесь, откуда появилось название C++.
Язык C++ основан на языке C; C++ буквально расшифровывается как
«C плюс один».
C++ не является новым языком программирования, а расширяет возмож-
ности языка C. Если бы создатели C++ знали, насколько мощнее их язык
окажется по сравнению с C, они бы назвали его «C в квадрате».
Существуют аналогичные укороченные операторы для сложения пере-
менной с любым значением:
x += 5; // складывает 5 и x

а также для деления, вычитания и умножения:


x -= 5; // вычитает 5 из x
x *= 5; // умножает x на 5
x /= 5; // делит x на 5

Наконец, операторы ++ и -- можно использовать не только после пере-


менной, но и перед ней:
--x;
++y;
62    Глава 3. Взаимодействие с пользователем и работа с переменными

Различие в значении, которое возвращает выражение:


int x = 0;
cout << x++;

Результат: 0. Несмотря на то что значение переменной x изменяется, вы-


ражение x++ возвращает исходное значение x . Поскольку оператор ++
стоит после переменной, можно считать, что он выполняется после того,
как получает значение переменной.
Если поместить операцию перед переменной, получим новое значение:
int x = 0;
cout << ++x;

На экран будет выведено число 1, поскольку переменная x сначала уве-


личивается на единицу, а затем выводит свое значение. С помощью этих
операций можно создать небольшой калькулятор на C++:

Пример 6: calculator.cpp
#include <iostream>
using namespace std;
int main()
{
int first_argument;
int second_argument;
cout << "Enter first argument: ";
cin >> first_argument;
cout << "Enter second argument: ";
cin >> second_argument;
cout << first_argument << " * " << second_argument
<< " = " << first_argument * second_argument
<< endl;
cout << first_argument << " * " << second_argument
<< " = " << first_argument + second_argument
<< endl;
cout << first_argument << " / " << second_argument
<< " = " << first_argument / second_argument
<< endl;
cout << first_argument << " - " << second_argument
<< " = " << first_argument - second_argument
<< endl;
}
Правильное и неправильное использование переменных    63

Правильное и неправильное использование переменных


Типичные ошибки при объявлениях переменных в C++
Объявления переменных существенно расширяют возможности програм-
мы, однако ошибки в них могут сразу вызвать ряд проблем. Например, если
попытаться воспользоваться необъявленной переменной, компиляция
завершится неудачно и компилятор вернет ошибку с сообщением, что вы
не объявили переменную. Как правило, эта ошибка выглядит так:
error: 'x' was not declared in this scope

(в данном случае не была объявлена переменная x). Точный текст зависит


от компилятора; этот пример взят из MinGW и Code::Blocks.
Можно использовать несколько переменных одного типа, но нельзя ис-
пользовать несколько переменных с одинаковым именем. Например,
нельзя объявить две переменные типов double и int с одним и тем же
именем my_val. Если вы объявите две переменные с одинаковым именем,
сообщение об ошибке будет выглядеть примерно так:
error: conflicting declaration 'double my_val'
error: 'my_val' has a previous declaration as `int my_val'
error: declaration of `double my_val'
error: conflicts with previous declaration `int my_val'

Третья проблема на этапе компиляции возникает, если вы забываете по-


ставить точку с запятой в конце строки:
НЕКОРРЕКТНЫЙ КОД
int x

Это может привести к самым разным ошибкам компилятора, которые


зависят от кода, следующего за объявлением переменной. Как правило,
в ошибке компилятора указывается строка, следующая за объявлением
переменной.
Наконец, некоторые ошибки происходят не во время компиляции про-
граммы, а при ее выполнении.
Когда вы впервые объявляете переменную, она не инициализирована. Перед
ее использованием необходима инициализация. Чтобы инициализировать
переменную, вы должны присвоить значение раньше, чем воспользуетесь
ею; в противном случае поведение вашей программы будет непредсказуе-
мым. Типичная ошибка выглядит следующим образом:
64    Глава 3. Взаимодействие с пользователем и работа с переменными

int x;
int y;
y = 5;
x = x + y;

Здесь значение переменной y устанавливается равным 5 раньше, чем


используется переменная y, но начальное значение x неизвестно. Оно
будет выбрано случайно при запуске программы, поэтому результиру-
ющее значение x может быть абсолютно любым. Не полагайтесь на то,
что переменные инициализируются каким-либо удобным значением,
например 0.
Один из методов инициализации переменных заключается в том, что вы
всегда инициализируете ее при объявлении:
int x = 0;

Это гарантирует, что значение переменной точно известно при ее создании.


Выработав привычку инициализировать переменные в момент их объ-
явления, вы сэкономите время, поскольку избавитесь от необходимости
исправлять неприятные ошибки позже.

Чувствительность к регистру
Пришло время рассмотреть еще одну важную концепцию, способную
легко сбить вас с толку, — чувствительность к регистру. В языке C++
буквы в верхнем и нижнем регистре считаются различными. Компилятор
воспринимает Cat и cat как два разных имени. В C++ все ключевые слова
языка, функции и переменные чувствительны к регистру.
Использование одних и тех же символов в разных регистрах при объявле-
нии и использовании переменной (например, X и x) приводит к тому, что
компилятор возвращает ошибку о необъявленной переменной, хотя вы
уверены, что объявили ее.

Присваивание имен переменным


Для переменных важно выбирать осмысленные и наглядные имена. Вот
пример неудачных имен:
val1 = val2 * val3;

Что означает эта конструкция? Непонятно — имена переменных почти


бессмысленны. Когда вы пишете программу, кажется, что создаваемый код
вполне понятен, однако это впечатление длится недолго. Уже на следую-
щий день ваш код покажется малопонятным. Переменные с наглядными
Хранение строк    65

именами позволят не запутаться в коде, когда вы откроете его в следующий


раз. Пример:
area = width * height;

Эта формула гораздо осмысленнее предыдущей, хотя отличие лишь в име-


нах переменных.

Хранение строк
Возможно, вы обратили внимание, что все типы данных, которыми мы
пользовались до настоящего времени, содержат очень простые значения —
например, целое число или символ. С помощью этих типов уже можно
многое сделать, но язык C++ предоставляет и другие типы данных1.
Один из самых полезных типов данных — строка. Строка может содер-
жать множество символов. Вы уже видели, как строки отображали текст
на экране:
cout << "HEY, you, I'm alive! Oh, and Hello World!\n";

Строковый класс языка C++ позволяет сохранять и изменять строки и вы-


полнять над ними другие действия.
Объявить строку просто.

Пример 7: string.cpp
#include <string>
using namespace std;
int main ()
{
string my_string;
}

Обратите внимание: для использования строки, в отличие от других встро-


енных типов данных, необходимо включать в программу заголовочный
файл <string>. Дело в том, что строковый тип не встроен в компилятор,
как, например, целые числа. Возможность работать со строками предостав-
ляется стандартной библиотекой C++ с большим количеством многократно
используемого кода.
Строки, вводимые пользователем, можно считывать с помощью объекта
cin, как и другие базовые типы данных C++.

На самом деле в C++ можно создавать собственные типы данных, мы еще
1

поговорим об этом при изучении структур.


66    Глава 3. Взаимодействие с пользователем и работа с переменными

Пример 8: string.cpp
#include <iostream>
#include <string>
using namespace std;
int main ()
{
string user_name;
cout << "Please enter your name: ";
cin >> user_name;
cout << "Hi " << user_name << "\n";
}

Эта программа создает строковую переменную, предлагает пользователю


ввести свое имя и выводит его на экран.
Строкам, как и другим переменным, можно задавать начальные значения:
string user_name = "<unknown>";

Можно объединить две строки, добавив одну в конец другой с помощью


знака +.

Пример 9: string_append.cpp
#include <iostream>
#include <string>
using namespace std;
int main ()
{
string user_first_name;
string user_last_name;
cout << "Please enter your first name: ";
cin >> user_first_name;
cout << "Please enter your last name: ";
cin >> user_last_name;
string user_full_name =
user_first_name + " " + user_last_name;
cout << "Your name is: " << user_full_name << "\n";
}

Эта программа берет значения трех отдельных строк (имя пользователя,


один пробел и фамилия пользователя) и составляет из них одно значение1.
Иногда нужно считать всю строку в один прием. Для этого есть специ-
альная функция getline. Она автоматически отбрасывает символ новой
строки, завершающий ввод.

Иногда объединение строк называют конкатенацией. Этот термин образован


1

от латинского «составлять цепочку» (catena — цепочка).


Как считывать не только строки, но и другие типы данных    67

Чтобы воспользоваться функцией getline, укажите источник ввода (cin),


строку, в которую будет записано считанное значение, и символ, показыва-
ющий конец ввода. Например, следующий код считывает имя пользователя:
getline( cin, user_first_name, '\n' );

Функция getline полезна, если нужно считать вводимые пользователем


данные лишь до другого символа, например запятой (при этом, чтобы про-
грамма считала данные, пользователю по-прежнему нужно нажать Enter):
getline( cin, my_string, ',' );

Теперь, если пользователь введет


Hello, World

значение "Hello" будет присвоено переменной my_string. Оставшаяся


часть текста (в данном случае "World") сохранится в буфере ввода, пока
ваша программа не считает ее при помощи другого оператора ввода.

Как считывать не только строки, но и другие типы данных


Возможно, вам интересно, почему базовых типов переменных так много.
Рассмотрим два вида строительных блоков, из которых складываются все
компьютерные программы, — бит и байт. Бит представляет собой базовую
единицу хранения информации на компьютере. Он подобен выключателю
в одном из двух состояний — включен (единица) или выключен (ноль).
Байт состоит из восьми битов; поэтому он может содержать 256 возмож-
ных сочетаний нулей и единиц (восемь позиций, каждая из которых имеет
одно из двух значений). Рассмотрим это утверждение подробнее. Один
бит может хранить два значения — ноль и единицу. Два бита могут хра-
нить вдвое больше значений: 00, 01, 10 и 11. Третий бит снова удваивает
количество значений, добавляя ноль и единицу к каждому двухбитному
сочетанию. Таким образом, с каждым дополнительным битом количество
возможных значений удваивается; другими словами, n битов позволяют
нам представить 2n значений.

ПРИМЕЧАНИЕ

Этот раздел содержит сложный материал, пользоваться


которым вам еще рано. Если разобраться в нем будет сложно,
пропустите раздел и вернитесь к нему позже.
68    Глава 3. Взаимодействие с пользователем и работа с переменными

Поскольку байт состоит из 8 битов, он представляет 28 возможных сочета-


ний. Два байта содержат 16 битов и, следовательно, позволяют представить
216 (65 536) значений.
Если приведенные рассуждения не вполне понятны, ничего страшного;
их ключевой смысл в том, что с ростом количества байтов расширяется
диапазон данных, которые они позволяют хранить.
Например, символьный тип позволяет хранить небольшое количество
данных — всего лишь 256 различных значений: он занимает один байт.
Целочисленный тип использует четыре байта, а значит, может хранить
около четырех миллиардов различных чисел.
Хороший пример двух переменных, которые отличаются друг от друга
только занимаемым пространством, — тип double и его младший брат
float. Изначально тип float позволял хранить десятичные числа; его
название было обусловлено тем, что десятичная точка1 могла плавать
(float — плавать) — занимать различные позиции внутри числа. Другими
словами, вы могли хранить число, у которого две цифры располагались
до точки (12.2345), или число, у которого четыре цифры располагались
до точки, а две — после (3421.12). Количество цифр до и после точки не
ограничивалось.
Если вы не вполне поняли описанный механизм, ничего страшного — на-
звание этого типа данных сохранилось, главным образом, в силу истори-
ческих причин. Просто знайте, что числа с плавающей точкой — это числа
с десятичными разрядами. Но числа с плавающей точкой занимают всего
четыре байта и поэтому не могут хранить так же много различных значе-
ний, как числа с двойной точностью, которые занимают восемь байтов.
Во времена, когда у компьютеров было значительно меньше памяти, чем
сейчас, эта разница была существеннее, и программисты часто приклады-
вали большие усилия, чтобы сэкономить несколько байтов. Сегодня почти
всегда рекомендуется использовать тип double, однако в ситуациях, когда
объем памяти очень ограничен (например, в мобильных телефонах), вы
все еще можете использовать тип float.
Самый компактный тип данных — char: он занимает всего один байт. Вы
спросите: если объем данных не имеет значения, то почему мы продолжаем

1
Во избежание путаницы в качестве десятичного разделителя мы всюду будем
использовать принятую в программировании точку, а не запятую. — Примеч.
ред.
Как считывать не только строки, но и другие типы данных    69

пользоваться типом char? Ответ заключается в том, что у типа char есть
особый смысл — ввод и вывод информации в виде символов, а не в виде
чисел. Считывая значение в символьную переменную, пользователь
может ввести символ, а когда вы выводите символ, объект cout отобра-
жает символ, соответствующий числу, которое хранится в переменной,
а не само это число. Вы спросите: «Что это значит? Почему числа — это
символы?» Дело в том, что в действительности компьютер хранит не то,
что мы воспринимаем как символ (например, букву a), а число, которое
соответствует этому символу. Существует таблица соответствий чисел
и символов, которая называется таблицей ASCII. Она содержит ин-
формацию о числе, соответствующем тому или иному символу. Выводя
символ, программа не выводит соответствующее ему число, а находит
символ в таблице ASCII1.

z z Маленький секрет чисел с плавающей точкой


Хочу рассказать кое-что о числах с плавающей запятой float и double —
они весьма полезны на практике, поскольку способны представлять очень
широкий диапазон чисел. Максимальное значение числа double прибли-
зительно равно 1.8 × 10308. Это значение имеет 308 нулей на конце. Тем не
менее тип double имеет размер 8 байт; означает ли это, что он содержит
всего 264 (18 446 744 073 709 551 616) возможных значений? (Конечно,
в этом числе МНОГО нулей, но не 308.)
Да, именно так! В действительности тип double способен хранить около
18 квинтиллионов чисел. Это так много, что пришлось поискать название
числа с восемнадцатью нулями. Тем не менее восемнадцать нулей — это
гораздо меньше 308. Числа с плавающей точкой в их диапазоне позволяют
точно представлять лишь небольшое количество значений, для его расши-
рения нужно перейти к формату, схожему с научной нотацией.
В научной нотации числа записываются в виде x × 10y. Как правило, x хра-
нит первые несколько цифр числа, а экспонента y — показатель степени,
в которую возводится число. Например, можно записать, что расстояние
от Земли до Солнца составляет 9,2956 × 107 (приблизительно 93 милли-
она) миль.

1
Таблица ASCII не велика — всего 256 символов. Это означает, что ASCII нельзя
использовать в японском и китайском языках. Для устранения этой проблемы
используется кодировка Unicode, изучение которой выходит за рамки темы
этой книги. Вы можете ознакомиться с ней на сайте http://www.cprogramming.
com/tutorial/unicode.html
70    Глава 3. Взаимодействие с пользователем и работа с переменными

Такой формат позволяет компьютеру хранить огромные числа благодаря


большому значению показателя. Однако неэкспоненциальная часть чис-
ла может хранить не 300 цифр, а примерно 15. Таким образом, точность
чисел с плавающей точкой составляет всего 15 цифр. Если вы работаете
с относительно небольшими числами, разница между хранимым и ре-
альным числом будет очень маленькой. Если же вы оперируете огром-
ными значениями, то даже при небольшой относительной погрешности
абсолютная будет значительной. Например, если я могу представлять
числа с точностью всего лишь до двух цифр, то напишу, что расстояние
от Земли до Солнца составляет 9.3 × 107 миль. Это значение очень близко
к реальному — погрешность менее 0.1%. Абсолютная погрешность при
этом составляет 44 тысячи миль — примерно в два раза больше экватора
Земли! Конечно, это всего две цифры точности. Пятнадцать цифр дают
гораздо большую точность.
В большинстве случаев погрешность чисел с плавающей точкой не имеет
значения, если только вы не занимаетесь серьезными научными расчетами,
но если занимаетесь — то имеет.

z z Маленький секрет целых чисел


У целых чисел есть свои проблемы. Целые числа и числа с плавающей
запятой не ладят друг с другом. В отличие от чисел с плавающей точкой,
целые числа всегда хранят целые значения, но не переносят десятичную
точку. Если вы выполняете над целым числом операцию и получаете не-
целое число, результат операции округляется. Получаемая недесятичная
составляющая корректна, но дробная часть числа отбрасывается.
К примеру, написав, что 5/2 = 2, вы, скорее всего, провалите любой тест
по арифметике. Тем не менее компьютер считает именно так! Хотите по-
лучить ответ с десятичными знаками — воспользуйтесь нецелым типом
данных.
Числа, вводимые в программу, считаются целыми; именно поэтому ре-
зультатом деления 5 на 2 является 2. Если же указать в числах десятичные
знаки, например 5.0/2.0, компилятор воспримет эту операцию как деление
чисел с плавающей точкой и выдаст ответ, который вам нужен — 2.5.

Проверьте себя
1. Какой тип переменной следует использовать для хранения числа
3.1415?
Проверьте себя    71

А. int
Б. char
В. double
Г. string
2. Какой оператор сравнивает две переменные?
А. :=
Б. =
В. equal
Г. ==
3. Как получить доступ к строковому типу данных?
А. Он встроен в язык, поэтому делать ничего не нужно.
Б. Поскольку строки используются при вводе-выводе, необходимо
включить в программу заголовочный файл iostream.
В. Необходимо включить в программу заголовочный файл string.
Г. Язык C++ не поддерживает строки.
4. Какое из приведенных ниже слов не обозначает тип переменной?
А. double
Б. real
В. int
Г. char
5. Как целиком считать строку, введенную пользователем?
А. С помощью cin>>.
Б. С помощью readline.
В. С помощью getline.
Г. Простого способа нет.
6. Что будет отображено на экране для выражения C++:
cout << 1234/2000?
А. 0
Б. .617
В. Примерно .617, но результат невозможно сохранить с абсолютной
точностью в числе с плавающей точкой.
Г. Это зависит от типов обеих сторон выражения.
7. Почему в языке C++ используется тип char, когда уже есть тип int?
А. Потому что символы и целые числа предназначены для совершенно
разных данных, чисел и букв.
Б. Для обратной совместимости с языком C.
72    Глава 3. Взаимодействие с пользователем и работа с переменными

В. Чтобы считывать и выводить символы, а не числа, хотя символы


хранятся как числа.
Г. Для интернационализации и поддержки языков с большим количе-
ством символов (например, китайского и японского).
(Решения см. на с. 451.)

Практические задания
1. Напишите программу, которая выводит ваше имя.
2. Напишите программу, которая считывает два числа и складывает их.
3. Напишите программу, которая делит два числа, введенные пользовате-
лем, и выводит точный результат. Проверьте работу программы, вводя
как целые значения, так и значения с плавающей точкой.
Г лава 4
Условные операторы

До сих пор вы учились создавать программы с последовательным выпол-


нением операторов, шаг за шагом, независимо от результата обработки вве-
денных пользователем данных. Условные операторы позволяют управлять
вхождением программы в раздел кода в зависимости от выполнения или
невыполнения заданного условия. Другими словами, условный оператор
дает программе возможность выбирать различные действия в соответствии
с данными, введенными пользователем. Например, с помощью условного
оператора можно проверить правильность пароля, введенного пользова-
телем, и понять, разрешить ли ему доступ к программе.

Базовый синтаксис оператора If


Условный оператор if имеет простую структуру:
если(<выражение истинно>)
выполнить этот оператор

или
если(<выражение истинно>)
{
выполнить все операторы этого блока
}

Код, который следует за условным оператором (и выполняется при со-


блюдении условия), называется телом оператора (по аналогии с кодом
функции main).
74    Глава 4. Условные операторы

Вот примитивный пример, демонстрирующий этот синтаксис:


if(5 < 1)
cout << "Five is now less than one, that's a big surprise";

Здесь мы просто проверяем истинность оператора «пять меньше единицы»;


конечно же, он ложен! При желании вы можете написать свою собственную
программу: включите в нее файл iostream, поместите приведенный выше
код в функцию main и запустите программу.
Вот пример, демонстрирующий фигурные скобки при нескольких опера-
торах:
if(5 < 1)
{
cout << "Five is now less than one, that's a big
surprise\n";
cout << "I hope this computer is working
correctly.\n";
}

Чтобы при истинном значении условного оператора выполнить несколько


операторов, их необходимо заключить в фигурные скобки. Рекомендую
всегда помещать тело условного оператора в фигурные скобки: вы никогда
не забудете про них, если потребуется выполнить несколько операторов,
а тело условного оператора будет четче выделяться. Существует распростра-
ненная ошибка: второй оператор помещается в тело условного оператора
без фигурных скобок, и в результате второй оператор выполняется всегда.
if(5 < 1)
cout << "Five is now less than one, that's a big
surprise\n";
cout << "I hope this computer is working
correctly.\n";

Подобные ошибки трудно обнаружить, безопаснее пользоваться скобками.


Рассмотренные условные операторы не представляют большого интереса.
Познакомимся с реальным условным оператором для работы с данными,
вводимыми пользователем.

Пример 10: variable.cpp


#include <iostream>
using namespace std;
int main ()
{
int x;
Выражения    75

cout << "Enter a number: ";


cin >> x;
if ( x < 10 )
{
cout << "You entered a value less than 10"
<< '\n';
}
}

Эта программа отличается от предыдущего примера тем, что считывает


значение, которое вводит пользователь, а не выполняет сравнение с жестко
заданным числом. Мы сделали большой шаг вперед — впервые написали
программу, поведение которой существенно зависит от действий пользо-
вателя. Обратимся к гибкости условных операторов.

Выражения
Условные операторы проверяют единственное выражение. Выражение —
это оператор или последовательность взаимосвязанных операторов, вы-
числяющие единственное значение. В большинстве ситуаций, где исполь-
зуются переменные или постоянные значения (например, числа), можно
использовать и выражения. На самом деле и переменные и константы
представляют собой простые выражения. Операции, такие как сложение
и умножение, тоже являются выражениями, хотя и чуть более сложными.
Когда результат выражения используется в контексте сравнения (напри-
мер, в условном операторе), он преобразуется в значение true (истина)
или false (ложь).

Что такое истина?


Для поэтов истина — это красота, красота — это истина, и это все, что
нужно знать1.
Но компиляторы — не поэты. Для компилятора выражение истинно, если
его значение не равно нулю, и ложно, если равно. Таким образом, выра-
жение вида
if(1)

всегда приводит к выполнению кода условного оператора, а выражение


if(0)

никогда.

http://www.bartleby.com/101/625.html
1
76    Глава 4. Условные операторы

В языке C++ есть специальные ключевые слова, true и false, которые мож-
но использовать в коде. Если вывести целое число, соответствующее true,
вы увидите единицу, целое число, соответствующее false, разумеется, ноль.
Сравнивая числа, оператор сравнения возвращает true или false. Напри-
мер, результатом проверки 0 == 2 является false. (Обратите внимание:
при проверке равенства используется двойной знак равенства ==. Один
знак равенства присваивает значение переменной.)
Результатом проверки 2 == 2 является true. Если операция сравнения
используется в условном операторе, нам не нужно проверять ее результат:
if(x == 2)

эквивалентно
if(( x == 2) == true)

и прочесть первую строку значительно проще.


В процессе программирования придется часто сравнивать значения пере-
менных.
Вот таблица операторов сравнения.

> больше 5 > 4 равно true


< меньше 4 < 5 равно true
>= больше или равно 4 >= 4 равно true
<= меньше или равно 3 <= 4 равно true
== равно 5 == 5 равно true
!= не равно 5 != 4 равно true

Тип bool
Язык C++ позволяет хранить результаты сравнений с помощью специ-
ального типа bool1. Тип bool не очень отличается от целого, но может при-
нимать лишь два возможных значения: true и false. Эти ключевые слова
и тип bool проясняют намерения. Результат всех операторов сравнения
логический (булев).

Тип bool получил свое название в честь Джорджа Буля (George Boole), ма-
1

тематика, который придумал булеву логику. Эта логика оперирует только


двумя значениями, истиной и ложью, и является фундаментом, на котором
построены цифровые компьютеры.
Else-if    77

int x;
cin >> x;
bool is_x_two = x == 2;
// двойной знак равенства для сравнения
if(is_x_two)
{
// выполните некое действие, поскольку x равен 2!
}

Операторы else
Часто требуется, чтобы программа сначала сравнила значения друг с дру-
гом, а затем выполнила одно действие, если сравнение истинно (например,
пользователь ввел правильный пароль), и другое, если сравнение ложно
(пароль неверен).
Оператор else позволяет создавать конструкции вида «если/иначе». Код,
следующий за ключевым словом else (как одна строка, так и несколько
строк в скобках), выполняется, если условие оператора if ложно. Вот при-
мер, который проверяет, отрицательно ли число, введенное пользователем.

Пример 11: non_negative.cpp


#include <iostream>
using namespace std;
int main()
{
int num;
cout << "Enter a number: ";
cin >> num;
if ( num < 0 )
{
cout << "You entered a negative number\n";
}
else
{
cout << "You entered a non-negative number\n";
}
}

Else-if
Оператор else используется и в ситуациях, когда в программе есть несколь-
ко условных операторов, каждый из которых имеет истинное значение, но
при этом необходимо выполнить тело только одного оператора. Например,
нужно изменить приведенный ранее код так, чтобы он обнаруживал три
78    Глава 4. Условные операторы

разных ситуации: отрицательные числа, ноль и положительные числа.


Можно воспользоваться оператором else-if после условного операто-
ра и его тела. Если первый оператор окажется истинным, else-if будет
проигнорирован, но если первый оператор окажется ложным, проверка
оператора else-if будет выполнена. Если оператор if истинен, оператор
else не проверяется. Чтобы выполнить только один блок кода, можно ис-
пользовать последовательность операторов else-if.
Добавим в приведенный код оператор else-if, чтобы проверить равенство
переменной нулю.

Пример 12: else_if.cpp


#include <iostream>
using namespace std;
int main()
{
int num;
cout << "Enter a number: ";
cin >> num;
if ( num < 0 )
{
cout << "You entered a negative number\n";
}
else if ( num == 0 )
{
cout << "You entered zero\n";
}
else
{
cout << "You entered a positive number\n";
}
}

Сравнение строк
Строковые объекты С++ позволяют использовать все операторы сравне-
ния, о которых шла речь ранее в этой главе. Сравнивая строковые объекты,
можно написать собственную систему проверки паролей.

Пример 13: password.cpp


#include <iostream>
#include <string>
using namespace std;
int main()
{
Несколько любопытных булевых операций    79

string password;
cout << "Enter your password: " << "\n";
getline(cin, password, '\n');
if(password == "xyzzy")
{
cout << "Access allowed" << "\n";
}
else
{
cout << "Bad password. Denied access!" << "\n";
// returning is a convenient way to stop the
// program
return 0;
}
// continue onward!
}

Эта программа считывает строку, вводимую пользователем, и сравнивает


ее с паролем "xyzzy". Если введенная строка не совпадает с паролем, про-
грамма немедленно возвращается из функции main1.
Можно применять к строкам другие операторы сравнения; например,
сравнить две строки, чтобы определить, какая из них первая по алфавиту,
или проверить идентичность строк с помощью оператора !=.

Несколько любопытных булевых операций


До настоящего момента за один раз вы умели проверять только одно усло-
вие. Чтобы проверить два условия, например правильность пароля и имени
пользователя, придется написать необычный оператор if/else. К счастью,
язык C++ позволяет выполнять несколько проверок одновременно с помо-
щью булевых операторов (их название связано с упомянутым ранее типом
bool; булевы операторы работают с булевыми (логическими) значениями).

С помощью булевых операторов можно создавать более сложные условные


операторы. Например, для проверки, что значение переменной age одно-
временно больше пяти и меньше десяти, можно воспользоваться операцией
«логическое И» и определить, истинны ли оба оператора age > 5 и age < 10.
Булевы операторы действуют так же, как операторы сравнения: они воз-
вращают true или false в зависимости от результата выражения.

Разумеется, настоящая проверка пароля гораздо сложнее, и уж точно пароль


1

не указывают прямо в исходном коде!


80    Глава 4. Условные операторы

Логическое НЕ
Оператор логического НЕ принимает единственный аргумент. Если он
истинен, оператор возвращает ложь, и наоборот. Например, НЕ (истина) =
ложь, а НЕ (ложь) = истина. НЕ (любое ненулевое число) является ложью.
В языке C++ символ логического НЕ — ! (восклицательный знак). Пример:
if(!0)
{
cout << "!0 evaluates to true";
}

Логическое И
Логическое И возвращает истину (true), только если оба операнда истин-
ны. Если один из операндов ложен, логическое И возвращает ложь (false).
Любое ненулевое число И «ложь» равняется «лжи».
В языке C++ оператор «логическое И» обозначается как &&. Он не проверяет
равенство чисел, а определяет, истинны ли оба его аргумента.
if(1 && 2)
{
cout << "Both 1 and 2 evaluate to true";
}

z z Сокращенные проверки
Если первое выражение, аргумент логического И, ложно, значение второго
выражения не вычисляется. Другими словами, выполняется сокращенная
проверка.
Сокращенные проверки полезны, когда второе выражение требуется
проверять лишь при истинности первого выражения — например, для
предотвращения деления на ноль. Рассмотрим условный оператор, который
сравнивает результат деления числа 10 на x с числом 2:
if(x != 0 && 10 / x < 2)
{
cout << "10 / x is less than 2";
}

При вычислении значения оператора if программа сначала определяет,


равен ли x нулю. Если равен, следующее условие проверять не требуется,
и программа его пропускает. Таким образом, не нужно беспокоиться, что
программа рухнет из-за деления на ноль. Без сокращенной проверки при-
шлось бы писать:
Несколько любопытных булевых операций    81

if(x != 0)
{
if(10 / x < 2)
{
cout << "10 / x is less than 2";
}
}

С сокращенной проверкой код понятнее и лаконичнее.

Логическое ИЛИ
Логическое ИЛИ возвращает истину, если хотя бы один операнд — истина.
Например, истина ИЛИ ложь равно истине, а ложь ИЛИ ложь — лжи. Логиче-
ское ИЛИ обозначается символами || (две вертикальные черты). На кла-
виатуре черта может быть изображена с небольшим разрывом посередине,
хотя в большинстве шрифтов она непрерывна. На многих клавиатурах она
находится там же, где и символ \, и ее можно ввести, нажав Shift.
В операции логического ИЛИ, как и в логическом И, используется со-
кращенная проверка: если первое условие истинно, второе не проверяется.

Комбинация выражений
Базовые логические операторы позволяют проверить два условия одно-
временно. Что делать, если нужны более широкие возможности? Помните,
что выражения можно составлять из переменных, операторов и значений?
Выражения можно составлять и из других выражений.
Например, можно проверить, что значение x равно 2 и значение y равно 3,
объединив операторы проверки равенства логическим И:
x == 2 && y == 3

Рассмотрим пример программы, которая использует логическое И для


одновременной проверки имени пользователя и пароля.

Пример 14: username_password.cpp


#include <iostream>
#include <string>
using namespace std;
int main()
{
string username;
string password;
cout << "Enter your username: " << "\n";
getline(cin, username, '\n');
продолжение 
82    Глава 4. Условные операторы

cout << "Enter your password: " << "\n";


getline(cin, password, '\n');
if(username == "root" && password == "xyzzy")
{
cout << "Access allowed" << "\n";
}
else
{
cout << "Bad username or password. Denied
access!" << "\n";
// returning is a convenient way to stop the
// program
return 0;
}
// continue onward!
}

После запуска эта программа разрешит доступ только пользователю


с именем root и правильным паролем. Программу можно легко усовер-
шенствовать с помощью операторов else-if, добавив в нее нескольких
пользователей, каждый со своим паролем.

z z Порядок вычислений
В C++ операторы имеют приоритет, в соответствии с которым происходит
их вычисление. Приоритеты арифметических операторов (+, -, / и *) такие
же, как в обычной арифметике: умножение и деление выполняются раньше
сложения и вычитания.
Среди логических операторов первым вычисляется НЕ, затем следуют
операторы сравнения, далее — логическое И и, наконец, логическое ИЛИ.
Приоритеты логических операторов и операторов сравнения представлены
в следующей таблице.

!
==, <, >, <=, =>, !=
&&
||

Порядком вычисления логических и арифметических операторов (на-


пример, сложением и вычитанием) можно управлять с помощью скобок.
Рассмотрим наш предыдущий пример:
x == 2 && y == 3
Проверьте себя    83

Желая написать «если это условие НЕ выполняется», можно воспользо-


ваться скобками:
!(x == 2 && y == 3)

Примеры логических выражений


Рассмотрим несколько более сложных логических выражений; с их помо-
щью можно проверить, насколько хорошо вы разбираетесь в логических
операторах. Чему равно следующее выражение?
!( true && false )

Оно равно true, поскольку true && false равно false, а ! false равно true.
Вот несколько задач (ответы даны в сносках):

! ( true || false )1
! ( true || true && false )2
! ( ( true || false ) && false )3

Проверьте себя
1. Какое из значений эквивалентно true?
А. 1
Б. 66
В. .1
Г. -1
Д. Все
2. Как обозначается оператор логического И?
А. &
Б. &&
В. |
Г. |&
3. Чему равно значение выражения !(true && ! (false || true))?
А. true
Б. false

false.
1

false (И вычисляется раньше ИЛИ).


2

true.
3
84    Глава 4. Условные операторы

4. Как выглядит правильный синтаксис условного оператора?


А. if выражение
Б. if{выражение
В. if(выражение)
Г. выражение if
(Решения см. на с. 452.)

Практические задания
1. Спросите возраст двух пользователей и покажите, кто старше; если оба
пользователя старше 100 лет, программа выполняет другое действие.
2. Реализуйте простую систему проверки паролей, которая принимает
пароль в виде числа. Сделайте так, чтобы любое из двух чисел было
правильным, но для проверки используйте только один условный
оператор.
3. Напишите простой калькулятор, который в качестве входных данных
принимает одну из четырех арифметических операций и два ее аргу-
мента, а затем выводит ее результат.
4. Расширьте программу проверки паролей, приведенную ранее в этой
главе. Программа должна принимать несколько имен пользователей с
собственными паролями и проверять правильность введенного соче-
тания имени и пароля. Дайте пользователю повторную попытку войти
в систему, если первая попытка оказалась неудачной. Подумайте, на-
сколько легко (или сложно) это сделать для большого количества имен
пользователей и паролей.
5. Подумайте, какие конструкции и возможности языка могли бы упро-
стить добавление новых пользователей без повторной компиляции
программы проверки паролей. (Примечание: не пытайтесь решить эти
задачи с помощью уже изученных средств C++, подумайте об использо-
вании инструментов, которые будут рассмотрены в следующих главах.)
Г лава 5
Циклы

Теперь вы знаете, как заставить программу по-разному действовать в за-


висимости от данных, введенных пользователем, однако данные в ней
вводятся всего один раз. Пока вы не можете написать программу, которая
многократно запрашивает у пользователя новые данные. В конце предыду-
щей главы была предложена практическая задача, в которой требовалось
предоставить пользователю вторую попытку ввода пароля при условии,
что первая попытка завершилась неудачно. Если вы решили ее, то, скорее
всего, повторно воспользовались условными операторами, чтобы проверить
пароль второй раз; вы не могли заставить пользователя вводить пароль,
пока он не окажется правильным.
Для решения этой задачи предназначены циклы. Циклы многократно
выполняют определенный блок кода. Это мощные инструменты, игра-
ющие ключевую роль в большинстве программ. Многие программы и
веб-сайты, которые отображают очень сложные данные (например, доски
объявлений), всего лишь многократно выполняют одну и ту же задачу.
Подумайте об этом: цикл позволяет написать простой оператор и достичь
значительно больших результатов, просто повторяя его. Вы можете запро-
сить у пользователя пароль столько раз, сколько он захочет попытаться
ввести его, или отобразить тысячи сообщений на интернет-форуме. Это
очень удобно.
В языке C++ есть три вида циклов для различных целей: while, for и do-
while. Последовательно рассмотрим каждый из них.
86    Глава 5. Циклы

Циклы while
Циклы while — самые простые. Их базовая структура имеет следующий вид:
while(<условие>)
{
[код, который выполняется, пока соблюдается условие]
}

На самом деле цикл while почти идентичен условному оператору, с тем


исключением, что повторяет выполнение своего тела. Условие, как и в
операторе if, является логическим выражением. Например, следующий
цикл while содержит два условия:
while(i == 2 || i == 3)

А вот простейщий пример цикла while:


while(true)
{
cout << "I am looping\n";
}

ВНИМАНИЕ

Однажды запущенный, этот цикл никогда не остановится.


Его условие всегда будет иметь значение true : такой цикл
называется бесконечным. Поскольку бесконечный цикл никогда не
прекращается, придется принудительно завершить программу,
нажав Ctrl-C, Ctrl-Break или закрыв консольное окно. Чтобы цикл
не был бесконечным, надо сделать так, чтобы его условие не всегда
было истинным.

Типичная ошибка
Теперь пришло время сказать, что типичная причина зацикливания про-
граммы состоит в том, что в условии цикла используется не один, а два
знака равенства:
НЕКОРРЕКТНЫЙ КОД
int i = 1;
while (i = 1)
{
cin >> i;
}
Циклы for    87

Этот цикл считывает данные, вводимые пользователем, пока пользователь


не введет число, отличное от единицы. К сожалению, условие цикла вы-
глядит так:
i = 1

а не так:
i == 1

Выражение i = 1 просто присвоит переменной i значение 1. Выражение


присваивания действует так, как будто оно возвращает присвоенное зна-
чение — в данном случае 1. Поскольку единица не равна нулю, ей соответ-
ствует значение true, из-за которого цикл будет выполняться бесконечно.
Рассмотрим цикл, который работает как надо. Ниже приведена полноцен-
ная программа, которая отображает числа от 0 до 9 с помощью циклов while.

Пример 15: while.cpp


#include <iostream>
using namespace std;
int main()
{
int i = 0; // не забывайте объявлять переменные
while(i < 10) // пока i меньше 10
{
cout << i << '\n';
i++; // обновляем i, чтобы в определенный момент условие
// могло быть выполнено
}
}

Если сложно понять циклы, представьте их так: когда программа дости-


гает скобки, закрывающей тело цикла, она переходит в начало цикла, где
снова проверяется условие и принимается решение, должен ли блок быть
выполнен еще раз либо следует остановить цикл и передать управление
следующему за ним оператору.

Циклы for
Циклы for очень гибки и удобны. Синтаксис цикла for выглядит так:
(инициализация переменной; условие; обновление переменной).
{
// код, который выполняется при соблюдении условия
}
88    Глава 5. Циклы

В этом цикле происходит много интересного, поэтому рассмотрим короткий


пример и разберем каждый элемент цикла. На самом деле этот цикл работа-
ет в точности так же, как цикл while, который мы только что рассмотрели:
for(int i = 0; i < 10; i++)
{
cout << i << '\n';
}

Инициализация переменной
Инициализация переменной, в данном случае int i = 0, позволяет объ-
явить переменную и присвоить ей значение (или присвоить значение уже
существующей переменной). Здесь мы объявили переменную i. Если в ци-
кле проверяется значение единственной переменной, в данном случае i,
эту переменную иногда называют переменной цикла. В программировании
в качестве переменных цикла традиционно используются i и j. Переменная,
которая увеличивается на единицу на каждой итерации цикла, называется
счетчиком цикла.

Условие цикла
Условие цикла указывает программе, что она должна повторять цикл, пока
условное выражение истинно (аналогично циклу while). В данном случае
мы проверяем, меньше ли значение x, чем 10. Как и в цикле while, условие
впервые проверяется еще до выполнения тела цикла; затем оно проверя-
ется после каждого выполнения цикла, чтобы определить, требуется ли
повторить цикл еще раз.

Обновление переменной
Во фрагменте, обновляющем переменную, значение переменной цикла
можно изменить. В этом фрагменте можно выполнять команды вроде i++,
i = i + 10 или вызывать функции; можно даже вызвать функцию, которая
ничего не делает с переменной, но выполняет какой-либо полезный код.
Поскольку очень многие циклы работают с одной переменной, одним усло-
вием и одним обновлением переменной, оператор for позволяет компактно
описать цикл, разместив его основные элементы в одной строке.
Обратите внимание, что элементы этой строки разделены точками с запя-
той; эти точки с запятой обязательны. Любые элементы (хоть все) могут
быть пустыми, однако точки с запятой должны присутствовать. Незаданное
условие считается истинным, и цикл выполняется до принудительной
остановки; это еще один способ написать бесконечный цикл.
Циклы for    89

Чтобы хорошо разобраться, когда выполняется каждая из частей цикла for,


сравните его с рассмотренным ранее циклом while, который выполняет то
же действие:
int i = 0; // объявление и инициализация переменной
while(i < 10) // условие
{
cout << i << '\n';
i++; // обновление переменной
}

Цикл for просто позволяет компактнее записать тот же код.


Рассмотрим еще один пример цикла, который выполняет действия инте-
реснее вывода последовательности чисел. Ниже приведена готовая про-
грамма, которая отображает квадраты всех чисел от 0 до 9.

Пример 16: for.cpp


#include <iostream>
using namespace std;
int main ()
{
// Цикл продолжается, пока i < 10; i увеличивается на единицу
// на каждой итерации
for(int i = 0; i < 10; i++)
{
// Имейте в виду, что условие цикла
// условный оператор проверяет до начала новой
// итерации. Таким образом, когда i равно 10,
// цикл завершается. Значение i обновляется раньше,
// чем проверяется условие.
cout<< i << " squared is " << i * i << endl;
}
}

Эта программа демонстрирует очень простой пример цикла for. Чтобы


понять, когда именно выполняется каждая часть цикла for, пройдем его
по шагам.
1. Сначала выполняется инициализация: переменной i присваивается
нулевое значение.
2. Затем проверяется условие: поскольку i меньше 10, выполняется тело
цикла.
3. Далее выполняется обновление: значение переменной i увеличивается
на единицу.
4. Затем проверяется условие, и, если оно ложно, цикл прекращается.
90    Глава 5. Циклы

5. Если условие истинно, выполняется тело цикла, а затем все действия,


начиная с шага 3, повторяются, пока значение i не перестанет быть
меньше 10.
Обратите внимание, что обновление происходит только после выполнения
цикла, а не на первой итерации цикла перед выполнением его тела.

Циклы do-while
Циклы do-while имеют особое назначение и используются довольно редко.
Их основная цель — упростить написание тел циклов, которые выполня-
ются хотя бы один раз.
Структура цикла do-while имеет вид
do
{
// тело...
} while ( условие );

Проверка условия выполняется не в начале, а в конце тела цикла; таким


образом, тело цикла выполнится хотя бы один раз, прежде чем условие
будет проверено. Если условие истинно, переходим в начало блока и вы-
полняем его еще раз. Цикл do-while фактически представляет собой цикл
while, выполняемый в обратном порядке.

Цикл while говорит: «продолжать цикл, пока условие истинно, и выпол-


нить этот блок кода»; цикл do-while говорит: «выполнить этот блок кода,
а затем выполнить его еще раз, если условие истинно». Ниже приведен
простой пример, который позволяет пользователю вводить пароль, пока
он не окажется правильным:

Пример 17: dowhile.cpp


#include <string>
#include <iostream>
using namespace std;
int main()
{
string password;
do
{
cout << "Please enter your password: ";
cin >> password;
} while(password != "foobar");
cout << "Welcome, you got the password right";
}
Управление циклами    91

Тело этого цикла будет выполнено как минимум один раз, что даст поль-
зователю возможность ввести пароль. Если пароль неверен, цикл будет
повторяться и предлагать пользователю ввести пароль, пока не введен
правильный пароль.
Обратите внимание, что в приведенном примере после слова while следует
завершающая точка с запятой. Эту точку с запятой легко забыть, поскольку
ее не нужно использовать в других циклах, что приводит к путанице.

Управление циклами
Хотя выход из цикла обычно выполняется при проверке его условия,
иногда требуется делать это раньше. Для этой цели в C++ предусмотрено
ключевое слово break. Оператор break немедленно завершает любой цикл,
в котором вы находитесь.
Ниже приведен пример, в котором оператор break используется для пре-
кращения цикла, который в его отсутствие был бы бесконечным. Этот при-
мер является переработанной программой проверки пароля пользователя.

Пример 18: break.cpp


#include <string>
#include <iostream>
using namespace std;
int main()
{
string password;
while(1)
{
cout << "Please enter your password: ";
cin >> password;
} while(password != "foobar");
{
break;
}
}
cout << "Welcome, you got the password right";
}

Оператор break немедленно завершает цикл и переходит на скобку, за-


крывающую цикл. В этом примере цикл заканчивается, когда пользователь
вводит правильный пароль.
Поскольку оператор break может находиться в любом месте цикла, в том
числе в его конце, вы можете пользоваться бесконечными циклами вместо
92    Глава 5. Циклы

цикла do-while, который мы создали. Действие оператора break аналогично


проверке условия в конце цикла do-while.
Операторы break полезны, если нужно выйти из большого цикла, однако
если их слишком много, код программы трудно воспринять.
Еще один способ управления циклами — пропуск одной итерации цикла
с помощью оператора continue.
Когда программа встречает оператор continue, текущая итерация цикла
немедленно завершается, но при этом выход из цикла не выполняется.
Например, вы можете написать цикл, который не выводит число 10:

int i = 0;
while (true)
{
i++;
if (i == 10)
{
continue;
}
cout << i << "\n";
}

Этот цикл бесконечен, однако когда значение переменной i становится


равным 10, оператор continue переводит программу обратно в начало
цикла, что приводит к пропуску вызова cout. Условие цикла при этом про-
веряется. Если оператор continue используется в цикле for, обновление
происходит сразу после оператора continue.
Оператор continue полезнее всего, если вы хотите пропустить часть кода
тела цикла. Например, вы проверяете данные, введенные пользователем,
и при обнаружении ошибок можете пропустить их обработку, воспользо-
вавшись циклом со следующей структурой:

while (true)
{
cin >> input;
if (! isValid(input))
{
continue;
}
// продолжение обычной обработки входных данных
}
Вложенные циклы    93

Вложенные циклы
В языке C++ очень часто встречаются циклы с использованием не одной,
а двух взаимосвязанных переменных. Например, на интернет-форуме
можно опубликовать список сообщений (один цикл), а для каждого со-
общения — набор его элементов: тему, автора и тело (второй цикл). Однако
необходимо, чтобы второй цикл выполнялся внутри первого — по одной
итерации на каждое сообщение. Такие циклы называются вложенными,
поскольку один цикл находится внутри другого.
Рассмотрим пример, который не так сложен, как публикация сообщений
на форуме. Отличный способ применить вложенные циклы — вывести на
экран таблицу умножения.

Пример 19: nested_loops.cpp


#include <iostream>
using namespace std;
int main ()
{
for (int i = 0; i < 10; i++)
{
// \t соответствует символу табуляции, с помощью которого
// мы придадим нашему выводу удобный для чтения вид
cout << '\t' << i;
}
cout << '\n';
for (int i = 0; i < 10; ++i)
{
cout << i;
for (int j = 0; j < 10; ++j)
{
cout << '\t' << i * j;
}
cout << '\n';
}
}

Вложенные циклы можно разделить на внешние и внутренние. В следую-


щем примере цикл с переменной j — внутренний, а цикл с переменной i,
который содержит первый цикл, — внешний.
Будьте внимательны и не используйте одну и ту же переменную одновре-
менно во внешнем и внутреннем циклах.
НЕКОРРЕКТНЫЙ КОД
for (int i = 0; i < 10; i++)
{
// здесь мы по ошибке переопределили i!
продолжение 
94    Глава 5. Циклы

for (int i = 0; i < 10; i++)


{
}
}

Можно создавать вложенные циклы с любым количеством уровней —


тремя, четырьмя и т. д.

Как выбрать подходящий цикл


Итак, вы узнали, что в C++ есть циклы нескольких типов, и теперь, веро-
ятно, задаетесь вопросом: что с ними делать? Зачем вам три разных вида
циклов?
На самом деле совсем не обязательно использовать каждый из них. Циклы
do-while я чаще вижу в учебниках, чем в реальном коде. Циклы for и while
распространены значительно шире.
Ниже приведено несколько кратких рекомендаций по выбору цикла под-
ходящего типа. Это нестрогие правила; с течением времени вы научитесь
определять, какой тип цикла наилучшим образом подходит в конкретной
ситуации. Не наделяйте эти рекомендации большим приоритетом, чем ваш
собственный практический опыт.

Циклы for
Цикл for следует использовать, если точно известно число итераций цик-
ла. Например, он отлично подойдет для счета от 0 до 100 или заполнения
таблицы умножения. Циклы for также являются стандартным способом
обработки массивов, мы рассмотрим ее при изучении массивов (см. главу 10
«Массивы»). Цикл for не рекомендуется использовать, если его переменная
обновляется по сложному принципу; преимущество цикла for в том, что
его работа описывается одним коротким оператором. Если для обновления
цикла требуется несколько строк кода, вы лишаетесь этого преимущества.

Циклы while
Если цикл содержит сложное условие или для определения следующего
значения его переменной требуются объемные математические вычисления,
воспользуйтесь циклом while. Циклы while позволяют легко определить,
когда они завершатся, но обнаружить изменения на каждой их итерации
гораздо труднее. Если изменения сложны, лучше использовать цикл while,
поскольку человек, изучающий код, будет по крайней мере знать, что цикл
обновляется непросто.
Как выбрать подходящий цикл    95

Например, есть две переменные цикла:

int j = 5;
for (int i = 0; i < 10 && j > 0; i++)
{
cout << i * j;
j = i - j;
}

Обратите внимание, что не все аспекты управления циклом умещаются


в одной строке. Некоторые действия выполняются в конце тела цикла. Это
сбивает читающего с толку, лучше воспользуйтесь циклом while.
int i = 0;
int j = 5;
while (i < 10 && j > 0)
{
cout << i * j;
j = i - j;
i++;
}

Такой цикл тоже выглядит не лучшим образом, но по крайней мере понятнее.


Цикл while хорош, если число итераций близко к бесконечному — напри-
мер, если программа предназначена для игры в шахматы и дает каждой
стороне возможность делать ходы, пока партия не закончится.

Циклы do-while
Как я уже сказал, эти циклы являются белыми воронами в программиро-
вании и встречаются крайне редко. Единственная ситуация, в которой есть
смысл использовать цикл do-while, — если вы хотите, чтобы действие было
выполнено как минимум однажды. Хорошим примером такой ситуации яв-
ляется приведенный выше код, запрашивающий пароль пользователя, или,
вообще говоря, любой пользовательский интерфейс, который предлагает
пользователю вводить данные до тех пор, пока они не будут корректными.
Циклы do-while неоптимальны в ситуациях, когда тело цикла повторяется,
но первая итерация немного отличается от остальных — например, при
отображении сообщения, что введенный пользователем пароль неверен.
Как бы вы написали следующий код, используя цикл do-while?

string password;
cout << "Enter your password: ";
cin >> password;
while (password != "xyzzy")
продолжение 
96    Глава 5. Циклы

{
cout << "Wrong password--try again: ";
cin >> password;
}
string password;
do
{
if (password == "")
{
cout << "Enter your password: ";
}
else
{
cout << "Wrong password--try again: ";
}
cin >> password;
} while (password != "xyzzy");

Как видите, цикл do-while усложняет код, а не упрощает его. Проблема


в том, что тело цикла меняется — мы по-прежнему считываем данные,
которые вводит пользователь, но отображаем ему другое сообщение.

Проверьте себя
1. Каким будет окончательное значение переменной x после выполнения
кода int x; for(x=0; x<10; x++) {}?
А. 10
Б. 9
В. 0
Г. 1
2. При каком условии будет выполнен блок кода, следующий за операто-
ром while(x<100)?
А. Если x меньше 100.
Б. Если x больше 100.
В. Если x равно 100.
Г. В любом случае.
3. Что из перечисленного ниже не является структурой цикла?
А. for
Б. do-while
В. while
Г. repeat until
Как выбрать подходящий цикл    97

4. Каково минимальное количество итераций у цикла do-while?


А. 0
Б. Бесконечное.
В. 1
Г. Зависит от конкретного цикла.
(Решения см. на с. 453.)

Практические задания
1. Напишите программу, которая выводит весь текст песни "99 Bottles of
Beer"1.
2. Напишите программу-меню, которая предлагает пользователю выбрать
один вариант списка. Если пользователь вводит вариант, которого
в списке нет, список выводится заново.
3. Напишите программу, которая накапливает сумму чисел, вводимых поль-
зователем. Программа завершает работу, если пользователь вводит ноль.
4. Напишите программу, которая запрашивает у пользователя пароль
с ограниченным числом попыток ввода, чтобы пользователь не мог
легко создать программу для взлома пароля.
5. Решите эти задачи, поочередно используя все типы циклов; опреде-
лите, какие циклы наилучшим образом подходят для каждой из задач.
6. Напишите программу, которая отображает квадраты первых 20 целых
чисел.
7. Напишите программу, которая подсчитывает результаты опроса с тремя
возможными значениями. Первым входным параметром программы
является вопрос, остальные три параметра — варианты ответа на него.
Первому ответу соответствует число 1, второму — число 2, третьему —
число 3. Ответы принимаются, пока не будет введен 0. После этого
программа отображает результаты опроса. Создайте столбчатую диа-
грамму, которая показывает правильно масштабированные результаты
независимо от количества введенных результатов.

1
Текст песни можно найти на странице http://en.wikipedia.org/wiki/99_Bottles_of_Beer
Г лава 6
Функции

Теперь вы знакомы с циклами и можете создавать весьма интересные про-


граммы. Тем не менее они должны целиком размещаться в функции main.
Если попытаться реализовать какой-нибудь сложный алгоритм внутри
функции main, она быстро разрастется и станет сложной для восприятия.
Возможно, вы уже сталкивались с этим, выполняя самые сложные упраж-
нения предыдущих глав. Кроме того, иногда придется выполнять одни
и те же действия в различных частях программы, многократно копируя
фрагменты ее кода.
Функции позволяют разбивать программу на части и много раз использовать
их, не прибегая к копированию и вставке. На самом деле вы уже пользова-
лись несколькими стандартными функциями при вводе и выводе данных.
До сих пор вы преимущественно учились решать новые задачи. Назначение
функций — структурировать код, облегчить его многократное использо-
вание и сделать удобнее для чтения.

Синтаксис функций
Вы уже знаете, как создать функцию, ведь все ваши программы содержат
функцию main.
Рассмотрим устройство функции на другом примере:

int add (int x, int y)


{
return x + y;
}
Синтаксис функций    99

Что же здесь происходит? Во-первых, обратите внимание, что эта функция


внешне очень напоминает функцию main, которую вы уже неоднократно
создавали. Между ними есть лишь два различия.
1. Эта функция принимает два аргумента — x и y, а функция main не при-
нимала никаких аргументов.
2. Эта функция явно возвращает значение (вспомните, что функция main
тоже возвращает значение, но его не нужно самостоятельно указывать
в операторе return).
В строке
int add (int x, int y)

тип возвращаемого значения указан первым, перед именем функции. За


именем функции следуют два аргумента. Не используя аргументы, просто
ставьте скобки:
int no_arg_function ()

Если вы не хотите, чтобы функция возвращала значение (например, когда


она просто выводит данные на экран), можно объявить, что она возвращает
значение типа void. Это не позволит использовать функцию как выражение
(например, при присваивании переменных и в условиях оператора if).
Значение, возвращаемое функцией, указывается с помощью оператора
return; эта функция содержит единственную строку
return x + y;

но можно составлять функции из большего количества строк (как функ-


цию main). Выполнение функции завершается оператором return, который
передает ее значение вызывающему окружению.
Объявив функцию, можно вызвать ее следующим образом:
add(1, 2); // игнорируем возвращаемое значение

Можно использовать функцию и как выражение: присвоить ее значение


переменной или вывести его на экран.

Пример 20: add_function.cpp


#include <iostream>
using namespace std;
int add (int x, int y)
{
return x + y;
}
100    Глава 6. Функции

int main ()
{
int result = add( 1, 2 ); // вызываем add и присваиваем
// результат переменной
cout << "The result is: " << result << '\n';
cout << "Adding 3 and 4 gives us: " << add( 3, 4 );
}

Создается впечатление, что в этом примере объект cout выводит функцию


add, однако, как и в случае с переменными, cout выводит результат выра-
жения, а не "add(3, 4)”. Результат будет таким же, как при выполнении
следующего кода:
cout << "Adding 3 and 4 gives us: " << 3 + 4;

Обратите внимание, что в этом примере мы несколько раз вызываем функ-


цию add вместо многократного копирования кода. Поскольку функция
очень короткая, мы не получаем от этого существенной пользы, однако
если мы решим позже добавить в функцию add код (например, отладочные
операторы для вывода аргументов и результата), придется внести в про-
грамму гораздо меньше изменений (потребуется изменить лишь саму
функцию, а не все копии повторяющегося кода).

Локальные и глобальные переменные


Теперь, когда вы можете составлять программу из нескольких функций, вы,
скорее всего, будете использовать гораздо больше переменных, в том числе
в ваших собственных функциях. Поговорим об именах, которые присваи-
ваются переменным. Объявляя переменную внутри функции, вы задаете
ее имя. Где можно использовать это имя, чтобы обратиться к переменной?

Локальные переменные
Возьмем простую функцию:

int addTen(int x)
{
int result = x + 10;
return result;
}

Она содержит две переменные, x и result. Переменная result доступна


только в пределах фигурных скобок, внутри которых определена, то есть
в двух строках функции add. Другими словами, можно написать еще одну
функцию с переменной result:
Локальные и глобальные переменные    101

int getValueTen()
{
int result = 10;
return result;
}

Можно даже воспользоваться именем getValueTen внутри addTen:

int addTen(int x)
{
int result = x + getValueTen();
return result;
}

В программе есть две переменные с именем result, одна из которых при-


надлежит функции addTen, а другая — функции getValueTen.
Эти переменные не конфликтуют: функция getValueTen при выпол-
нении имеет доступ только к собственной копии переменной result,
и наоборот.
Часть программы, в которой видна переменная, называется областью
видимости переменной. Область видимости переменной определяет
код, в котором можно получить доступ к этой переменной по ее имени.
Переменные, объявленные внутри функции, доступны только в области
видимости этой функции, то есть во время ее выполнения. Переменные,
которые объявлены в области видимости одной функции, недоступны
другим функциям, которые вызываются в процессе выполнения первой
функции. Когда одна функция вызывает другую, доступными являются
только переменные новой функции.
Аргументы функции также объявляются в области ее видимости. Эти пере-
менные недоступны вызывающему окружению функции, даже если оно
передает значение. Переменная x является аргументом функции addTen
и может использоваться только внутри нее. Более того, переменная x, как
и любая переменная, объявленная внутри функции, не может использо-
ваться функцией, которая вызывается из функции addTen. В приведенном
выше примере переменная x является аргументом функции addTen и не-
доступна функции getValueTen.
Аргументы как бы дублируют переменные, которые передаются в функ-
цию; изменение аргумента функции не оказывает влияния на исходную
переменную. Для этого переменная, передаваемая в функцию, копируется
в ее аргумент.
102    Глава 6. Функции

Пример 21: local_variable.cpp


#include <iostream>
using namespace std;
void changeArgument (int x)
{
x = x + 5;
}
int main ()
{
int y = 4;
changeArgument( y ); // вызов функции
// не испортит переменную y
cout << y; // still prints 4
}

Область видимости переменной может быть у ˆ же функции. Каждая пара фи-


гурных скобок определяет новую, более узкую область видимости, например:
int divide (int numerator, int denominator)
{
if (0 == denominator)
{
int result = 0;
return result;
}
int result = numerator / denominator;
return result;
}

Первая переменная result находится в области видимости фигурных


скобок оператора if . Вторая переменная result находится в области
видимости, которая начинается с ее объявления и заканчивается вместе
с функцией. Компилятор не препятствует созданию двух переменных
с одинаковым именем, если они находятся в разных областях видимости.
В ситуациях, аналогичных функции divide, использование нескольких
переменных с одним и тем же именем может запутать человека, который
пытается разобраться в коде программы.
Любая переменная, объявленная в области видимости функции или внутри
блока, называется локальной переменной. Некоторые переменные имеют
более широкую доступность; они называются глобальными.

Глобальные переменные
Иногда необходимо использовать единственную переменную, которая
доступна всем вашим функциям. Например, если вы создаете настольную
игру, игровое поле имеет смысл хранить как глобальную переменную, чтобы
Локальные и глобальные переменные    103

все функции, его использующие, могли получить доступ к этой переменной,


не принимая ее каждый раз в виде аргумента.
Именно это и позволяют делать глобальные переменные. Глобальная
переменная — это переменная, которая определена за пределами всех
функций. Глобальные переменные доступны в любой точке программы
после объявления.
Ниже приведен простой пример, который демонстрирует объявление и ис-
пользование глобальной переменной.

Пример 22: global_variable.cpp


#include <iostream>
using namespace std;
// небольшая функция, которая демонстрирует области видимости
int doStuff()
{
return 2 + 3;
}
// глобальные переменные инициализируются так же,
// как любые другие переменные
int count_of_function_calls = 0;
void fun ()
{
// глобальная переменная доступна здесь
count_of_function_calls++;
}
int main ()
{
fun();
fun();
fun();
// глобальная переменная доступна и здесь!
cout << "Function fun was called "
<< count_of_function_calls << " times";
}

Область видимости переменной count_of_function_calls начинается перед


функцией fun. Функция doStuff не имеет доступа к этой переменной, по-
скольку была объявлена позже doStuff; функции fun и main имеют доступ
к этой переменной, поскольку были объявлены позже нее.

Предупреждение по поводу глобальных переменных


Может показаться, что глобальные переменные упрощают программу, по-
скольку их можно использовать в любом ее месте. Тем не менее глобальные
переменные затрудняют восприятие кода, ведь для того чтобы понять, как
104    Глава 6. Функции

используется глобальная переменная, требуется изучить всю программу!


Глобальные переменные рекомендуется использовать лишь в редких случаях,
когда какие-либо данные действительно должны быть широко доступными.
Предпочтительнее передавать аргументы в функции, чем использовать в них
глобальные переменные. Даже если вам кажется, что какие-либо данные
будут использоваться глобально, позже может оказаться, что это не так.
Рассмотрим упомянутую ранее настольную игру. Допустим, вы решили
создать функцию, которая отображает игровое поле, и воспользовались
в ней глобальной переменной.
Что произойдет, если вы захотите отобразить не текущую ситуацию на
поле, а, скажем, альтернативный вариант хода? Ваша функция не при-
нимает поле в качестве аргумента, а лишь отображает текущую игровую
ситуацию. Согласитесь, это не очень удобно!

Подготовка функций к использованию


Правила использования переменных в их областях видимости (например,
переменная доступна только после объявления) также распространяются
и на функции. (Здорово, если все делается по аналогии!)
Например, эта программа не скомпилируется.

Пример 23: badcode.cpp


НЕКОРРЕКТНЫЙ КОД
#include <iostream> // для объекта cout
using namespace std;
int main()
{
int result = add(1, 2);
cout << "The result is: " << result << '\n';
cout << "Adding 3 and 4 gives us: " << add(3, 4);
}
int add (int x, int y)
{
return x + y;
}

Скомпилировав эту программу, вы увидите примерно следующее сообще-


ние об ошибке:
badcode.cpp:7: error: 'add' was not declared in this scope
Подготовка функций к использованию    105

Проблема в том, что в момент вызова функция add еще не была объявлена,
а следовательно, ее вызов оказался вне области ее видимости. Когда вы
пытаетесь вызвать необъявленную функцию, компилятор оказывается
в полном недоумении!
Эту проблему можно решить, поместив функцию выше всех ее вызовов, как
я делал в предыдущих примерах. Еще одно решение — объявить функцию
до ее определения.
Хотя объявление и определение функции звучат очень похоже, они имеют
абсолютно разный смысл. Разберемся в этих терминах.

Определения и объявления функций


Определение функции содержит полное ее описание, в том числе тело
функции. Написав функцию add, мы создали ее определение, поскольку
указали, что именно она делает. Определение функции играет и роль ее
объявления, поскольку содержит всю информацию объявления функции.
Объявление функции содержит лишь основные сведения о ней, необходи-
мые вызывающему окружению функции: ее имя, тип возвращаемого зна-
чения и аргументы. Функция должна объявиться раньше, чем кто-нибудь
ею воспользуется; для этого нужно ее объявить или полностью определить.
Чтобы объявить функцию, необходимо написать ее прототип. Объявление
функции информирует компилятор о том, какое значение она вернет, как она
будет называться и какие аргументы можно ей передать. Прототип функции
можно рассматривать как эскиз, который демонстрирует ее использование.
тип_возвращаемого_значения имя_функции (
тип_аргумента аргумент1, ...,
тип_аргумента аргументN);

тип_аргумента означает тип данных, которые будут использованы в каче-


стве аргумента, например int, double или char. Это в точности то же, что
вы делаете при объявлении переменной.
Рассмотрим прототип функции:
int add. (int x, int y);

Этот прототип показывает, что функция add принимает два аргумента


целочисленного типа и возвращает целое число. Точка с запятой указывает
компилятору, что приведенное описание — лишь прототип, а не полное
определение функции. Проявите внимание к компилятору: не забудьте по-
ставить завершающую точку с запятой, чтобы не ввести его в заблуждение.
106    Глава 6. Функции

Пример прототипа функции


Рассмотрим исправленный вариант представленного выше кода, в котором
отсутствовал прототип функции.

Пример 24: function_prototype.cpp


#include <iostream>
using namespace std;
// прототип функции add
int add(int x, int y);
int main()
{
int result = add(1, 2);
cout << "The result is: " << result << '\n';
cout << "Adding 3 and 4 gives us: " << add(3, 4);
}
int add (int x, int y)
{
return x + y;
}

Программа как обычно начинается с включения необходимых файлов


и оператора using namespace std;.
Далее следует прототип функции add, который содержит завершающую
точку с запятой. Теперь любой код, в том числе функция main, может вы-
зывать функцию add, даже несмотря на то, что сама функция add определена
позже, за функцией main. Поскольку прототип функции add находится выше
функции main, компилятор знает, что функция add объявлена, и может
определить ее аргументы и возвращаемое значение.
Хотя функцию можно вызывать раньше, чем она определяется, не за-
бывайте, что ваша программа скомпилируется, лишь если вы определите
функцию1.

Деление программы на функции


Теперь, зная, как написать функцию, нужно понять, когда ее следует писать.

Когда код многократно повторяется


Главное назначение функций — упростить повторное использование кода.
Функции существенно упрощают многократное использование фрагмента

С технической точки зрения неудачей завершится компоновка; позднее мы


1

рассмотрим различие компиляции и компоновки.


Деление программы на функции    107

программы, поскольку все, что нужно сделать, — в нужном месте вызвать


функцию, а не копировать в него требуемый код. Копирование и вставка
кода может показаться проще, однако это приводит ко многим повторениям
блоков кода в программе.
Использование функций также существенно сокращает размер программы,
что делает ее чтение удобнее и упрощает внесение изменений. Что легче:
внести одно изменение в тело функции или сорок изменений в различные
части большой программы? Ответ, наверное, очевиден.
Существует хорошее эмпирическое правило: если вы трижды написали
один и тот же код, оформите его в виде функции, чтобы не повторять
снова и снова.

Когда код должен хорошо читаться


Даже если в программе нет многократного использования кода, иногда
длинный блок, выполняющий сложные и специфические действия, может
затруднить понимание общего смысла кода. Написав функцию, вы можете
сказать: «Я хочу выполнить это действие» — и выполнить его. Например,
действие «считать данные, введенные пользователем» легко понять, если
его совершает одна функция. Понять код, который обнаруживает нажатие
клавиш, преобразует их в электрические сигналы и считывает их в пере-
менную, значительно сложнее! Согласитесь, что читать код
int x;
cin >> x;

гораздо приятнее, чем код, который реализует все нюансы ввода данных.
Если в процессе работы над кодом вы обнаруживаете, что в нем трудно
уловить общий смысл, стоит подумать, не структурировать ли его в виде
функций.
При написании функции вы концентрируетесь на ее входных и выходных
данных и избавляетесь от необходимости держать в голове все детали про-
граммы в целом.
Вы спросите: «А разве не надо уделять внимание деталям?» В действитель-
ности иногда нужно знать программу до мелочей, но эти мелочи гораздо
проще понять, если вся информация о конкретной функции собрана в од-
ном месте. Если же мелочи разбросаны по разным частям большой про-
граммы, понять ее очень трудно.
Например, рассмотрим программу-меню, которая выполняет сложный
код, когда пользователь выбирает один из пунктов. Программа должна
108    Глава 6. Функции

содержать функции для каждого пункта меню. Смысл пунктов меню ясен
из соответствующих функций. Код, обеспечивающий ввод данных, должен
иметь простую и быстроизучаемую структуру.
Наихудшие программы состоят из единственной обязательной функции
main с многостраничным и хаотичным кодом. Вы ознакомитесь с примером
такой программы в следующей главе.

Именование и перегрузка функций


Выбирайте подходящие имена функций, переменных и других элементов
вашего кода — так вы лучше будете понимать, что делает программа.
По вызовам функций невозможно определить, как они реализованы,
поэтому важно выбрать имя, которое дает представление об основном
действии функции. Имя играет настолько важную роль, что иногда при-
дется использовать одно имя для нескольких функций. Например, у вас
есть функция, которая вычисляет площадь треугольника по координатам
трех его вершин:
int computeTriangleArea(int x1, int y1, int x2, int y2, int x3, int y3);

Что делать, если понадобилась еще одна функция, вычисляющая площадь


треугольника по его основанию и высоте? Скорее всего, вы захотите вос-
пользоваться именем computeTriangleArea еще раз, поскольку оно хорошо
описывает функционал. Но не вызовет ли это конфликт с существующей
computeTriangleArea?

Только не в C++!
C++ позволяет перегружать функции; можно давать одно имя несколь-
ким функциям, если у них разные списки аргументов. Можно написать
следующий код:
int computeTriangleArea (int x1, int y1, int x2, int y2,
int x3, int y3);

и
int computeTriangleArea (int width, int height);

При вызове функции компилятор сможет распознать, какая именно из двух


функций была вызвана, поскольку у них разное количество аргументов.
(Компилятор таким же образом обработает функции, у которых одинако-
вое количество аргументов, но сами аргументы имеют различные типы.)
Таким образом, если вы напишете код
Кратко о функциях    109

computeTriangleArea( 1, 1, 1, 4, 1, 9 );
computeTriangleArea( 5, 10 );

компилятор определит, какую из двух функций вызвать.


Не следует злоупотреблять перегрузкой функций: то, что две функции
могут иметь одно и то же имя, еще не значит, что им следует давать оди-
наковые имена. Тем не менее есть смысл перегружать функции, если они
выполняют одно и то же действие с разными наборами аргументов.

Кратко о функциях
Функции, как и переменные, циклы и условные операторы, — один из
базовых инструментов языка C++. Функции за простым интерфейсом
скрывают сложные вычисления и содержат многократно повторяющиеся
фрагменты кода программы, что существенно упрощает повторное ис-
пользование этого кода в будущем.

Проверьте себя
1. Что из перечисленного ниже не является правильным прототипом?
А. int funct(char x, char y);
Б. double funct(char x)
В. void funct();
Г. char x();
2. Какой тип имеет значение, возвращаемое функцией с прототипом int
func(char x, double v, float t);?
А. char
Б. int
В. float
Г. double
3. Что из нижеперечисленного является корректным вызовом функции
(при условии, что функция существует)?
А. funct;
Б. funct x, y;
В. funct();
Г. int funct();
4. Что из нижеперечисленного является законченной функцией?
110    Глава 6. Функции

А. int funct();
Б. int funct(int x) {return x=x+1;}
В. void funct(int) {cout<<"Hello"}
Г. void funct(x) {cout<<"Hello";}
(Решения см. на с. 454.)

Практические задания
1. Структурируйте «программу-меню», написанную ранее, в виде последо-
вательности вызовов функций для каждого из пунктов меню. Добавьте
калькулятор и «99 Bottles of Beer» в виде двух различных функций,
которые можно вызывать.
2. Структурируйте программу-калькулятор так, чтобы каждый тип вы-
числений выполнялся в отдельной функции.
3. Измените написанную ранее программу ввода пароля так, чтобы про-
верка пароля выполнялась в специальной функции отдельно от осталь-
ной части программы.
Г лава 7
Переключатели и перечисления

Проверяя много разных условий, вы часто используете операторы if-else.


Например, считывая клавишу, нажатую пользователем, вы проверяете пять,
а то и больше возможных вариантов полученного значения. При работе
над созданием компьютерной игры вас интересуют нажатия стрелок впра-
во, влево, вверх, вниз и пробела. В этой главе мы изучим удобный способ
написания проверок с многими условиями и операторами переключения
и создание собственных простых типов данных, эффективно работающих
с переключателями.

Переключатель
Переключатели заменяют длинные условные операторы и сравнивают
единственную переменную с несколькими целочисленными значениями.
Целочисленное значение — это число, которое может быть представлено
как целое, например int или char.
Ниже представлен базовый формат переключателя. Значение переменной,
помещенной в переключатель (switch), сравнивается со всеми значениями
вариантов (case), и при совпадении компьютер продолжает выполнять
программу от точки, где было обнаружено совпадение, до конца блока
переключателя или до оператора break.

switch (<переменная>)
{
case первое-значение:
// Код, который выполняется, если <переменная> == первое-значение
112    Глава 7. Переключатели и перечисления

break;
case второе-значение:
код, который выполняется, если <переменная> == второе-значение
break;
// ...
default:
// код, который выполняется, если <переменная> нe равна
// никакому из значений
break;
}

Будет выполнен код, следующий за двоеточием первого варианта, зна-


чение которого совпадает со значением переменной. Вариант default
выполнится, если не будет выполнен никакой другой вариант. Вариант
default не является обязательным, но рекомендуется для обработки не-
учтенных ситуаций.
Обратите внимание, что каждый фрагмент кода завершается оператором
break. Оператор break предотвращает сквозное выполнение программой
других операторов case. Действительно, это выглядит странно, но таков
принцип работы переключателя, поэтому не забывайте пользоваться
операторами break, если только вам на самом деле не нужно сквозное вы-
полнение вариантов переключателя.
Значение, которое вы указываете в каждом операторе case, должно яв-
ляться целочисленным константным выражением. К сожалению, нельзя
использовать варианты следующим образом:

НЕКОРРЕКТНЫЙ КОД
int a = 10;
int b = 10;
switch ( a )
{
case b:
// код
break;
}

Пытаясь компилировать этот код, вы увидите следующую ошибку ком-


пилятора:
badcode.cpp:9: error: 'b' cannot appear in a constantexpression

Ниже приведен работоспособный пример программы, демонстрирующий


использование переключателей.
Переключатель    113

Пример 25: switch.cpp


#include <iostream>
using namespace std;
void playgame ()
{}
void loadgame ()
{}
void playmultiplayer ()
{}
int main ()
{
int input;
cout << "1. Play game\n";
cout << "2. Load game\n";
cout << "3. Play multiplayer\n";
cout << "4. Exit\n";
cout << "Selection: ";
cin >> input;
switch ( input )
{
case 1: // Обратите внимание: после каждого варианта двоеточие,
// а не точка с запятой
playgame();
break;
case 2:
loadgame();
break;
case 3:
playmultiplayer();
break;
case 4:
cout << "Thank you for playing!\n";
break;
default: // Обратите внимание: после оператора default двоеточие,
// а не точка с запятой
cout << "Error, bad input, quitting\n";
break;
}
}

Эта программа успешно компилируется и демонстрирует простую модель


обработки данных, вводимых пользователем, хотя впечатление от игры
очень напоминает пьесу «В ожидании Годо».
Один из недостатков программы в том, что пользователь может сделать
выбор всего один раз — после этого программа завершается. Введя непра-
вильное значение, пользователь не может исправить ошибку. Устранить
этот недостаток легко, поместив весь переключатель в цикл, но что делать
114    Глава 7. Переключатели и перечисления

с операторами break? Не приведут ли они к преждевременному завершению


цикла? К счастью, нет — оператор break всего лишь переводит программу
в конец оператора switch.

Сравнение операторов switch и if-else


Если вы не понимаете логику оператора switch, мы представим ее с опе-
ратором if на месте каждого оператора case:
if (1 == input)
{
playgame();
}
else if (2 == input)
{
loadgame();
}
else if (3 == input)
{
playmultiplayer();
}
else if (4 == input)
{
cout << "Thank you for playing!\n";
}
else
{
cout << "Error, bad input, quitting\n";
}

Возникает вопрос: если можно сделать то же с помощью if/else, зачем во-


обще использовать переключатель? Главное преимущество переключателя
в том, что он позволяет легко понять логику программы: одна переменная
управляет ветвью кода. Если вы используете набор условий if/else, каждое
условие требует внимательного изучения.

Создание простых типов данных с помощью перечислений


Иногда при написании программ удобно использовать переменные, кото-
рые принимают ограниченный набор заранее известных значений.
Например, вы предлагаете пользователю выбрать один из нескольких цве-
тов фона. Было бы очень удобно иметь набор констант, соответствующих
этим цветам, и тип переменной, которая специально предназначена для
хранения этих констант.
Создание простых типов данных с помощью перечислений    115

Кроме того, такую переменную можно было бы использовать в переклю-


чателе, поскольку вы знаете все ее возможные значения.
Рассмотрим, как все это можно сделать с помощью перечислений. Перечис-
ление (enum), или перечислимый тип, — новый тип переменной, которую
можно создать, перечислив фиксированный список ее значений. Хороший
пример данных перечислимого типа — цвета радуги:
enum RainbowColor {
RC_RED,
RC_ORANGE,
RC_YELLOW,
RC_GREEN,
RC_BLUE,
RC_INDIGO,
RC_VIOLET
};

Отметим несколько важных аспектов.


1. Новое перечисление создается с помощью ключевого слова enum.
2. Новый тип получает собственное уникальное имя — RainbowColor.
3. Перечисляются все возможные значения для этого типа (я указал пре-
фикс RC_ на случай, если кто-то уже использует такие же имена в дру-
гом перечислении с иной целью).
4. Разумеется, в конце стоит точка с запятой.
Теперь можно объявить переменную типа RainbowColor:
RainbowColor chosen_color = RC_RED;

и написать следующий код:


switch (chosen_color)
{
case RC_RED: /* сделать экран красным */
case RC_ORANGE: /* сделать экран оранжевым */
case RC_YELLOW: /* сделать экран желтым */
case RC_GREEN: /* сделать экран зеленым */
case RC_BLUE: /* сделать экран голубым */
case RC_INDIGO: /* сделать экран сине-фиолетовым */
case RC_VIOLET: /* сделать экран фиолетовым */
default: /* обработать непредусмотренные цвета */
}

Используя перечислимый тип данных, мы можем обработать все воз-


можные значения переменной. На самом деле перечислимый тип явля-
ется целым числом — он может принимать значения, которые не указаны
116    Глава 7. Переключатели и перечисления

в перечислении, однако ни в коем случае не пользуйтесь этим, если не


хотите переквалифицироваться в инженера технической поддержки!
Вы спросите: «Какие же значения на самом деле принимают мои пере-
числения?» Если вы не указываете конкретное значение при объявлении
перечисления, значение перечисления равно предыдущему значению,
увеличенному на единицу. Значение первого перечисления равно нулю.
Таким образом, значение RC_RED равно 0, а значение RC_ORANGE — 1.
Можно объявлять собственные значения, это полезно, если код использует
значения из другой системы (например, устройства или фрагмента готового
кода) и вы хотите присвоить им удобные имена.
enum RainbowColor {
RC_RED = 1,
RC_ORANGE = 3,
RC_YELLOW = 5,
RC_GREEN = 7,
RC_BLUE = 9,
RC_INDIGO = 11,
RC_VIOLET = 13
};

Веская причина использовать перечисления в том, что с их помощью мож-


но именовать значения, которые в противном случае пришлось бы жестко
кодировать. Например, если вы хотите написать игру «крестики-нолики»,
нужно каким-то образом представить крестики и нолики на игровом поле.
Можно сопоставить пустой клетке число 0, клетке с ноликом — число 1,
а клетке с крестиком — число 2. Код, сравнивающий клетку игрового поля
с числами 0, 1 и 2, может выглядеть следующим образом:
if (board_position == 1)
{
/* выполнить действия, если в клетке нолик */
}

Читать этот код неудобно — в нем используется число, имеющее некий


смысл, который невозможно понять, просто глядя на код (если только в нем
нет содержательного комментария, вроде добавленного мной). С помощью
перечислений можно создать имена для этих значений:
enum TicTacToeSquare {TTTS_BLANK, TTTS_O, TTTS_X};
if (board_position == TTTS_O)
{
/* код */
}
Создание простых типов данных с помощью перечислений    117

Теперь инженер технической поддержки, которому придется исправлять


ошибки в этой программе в будущем (кстати, в его роли можете оказаться
и вы!), способен разобраться в том, что она делает.
Перечисления эффективны при использовании фиксированных данных,
а переключатели позволяют удобно обрабатывать данные, вводимые поль-
зователем. Тем не менее оба эти инструмента предназначены для работы
не более чем с несколькими значениями одновременно. Допустим, что
вы хотите считать большой объем статистических данных о спортивных
соревнованиях, чтобы провести их анализ. В такой ситуации не следует
использовать переключатель; вам нужны средства для сохранения и об-
работки больших объемов информации.
Речь о них пойдет во второй части этой книги. Тем не менее перед их
изучением мы рассмотрим еще несколько интересных приемов програм-
мирования, которые не требуют работы с большими наборами данных.
В частности, мы узнаем о том, как использовать случайные числа (скажем,
для создания компьютерной игры).

Проверьте себя
1. Какой знак следует за оператором case?
А. :
Б. ;
В. -
Г. Символ новой строки.
2. Какой оператор необходимо использовать, чтобы избежать сквозного
выполнения операторов case?
А. end;
Б. break;
В. stop;
Г. Нужно поставить точку с запятой.
3. Какое ключевое слово обрабатывает неучтенные варианты?
А. all
Б. contingency
В. default
Г. other
118    Глава 7. Переключатели и перечисления

4. Чему равен результат выполнения следующего кода?


int x = 0;
switch(x)
{
case 1: cout << "One";
case 0: cout << "Zero";
case 2: cout << "Hello World";
}

А. 1
Б. 0
В. Hello World
Г. ZeroHello World
(Решения см. на с. 454.)

Практические задания
1. Перепишите созданную при решении практической задачи программу-
меню из главы 6 «Функции», используя оператор switch-case.
2. Напишите программу, которая выводит на экран весть текст песни The
Twelve Days of Christmas1, с помощью оператора switch-case (совет:
воспользуйтесь сквозным выполнением операторов case).
3. Напишите игру «крестики-нолики» для двух игроков; по возможности
представляйте содержимое игрового поля с помощью перечислений.

http://en.wikipedia.org/wiki/The_Twelve_Days_of_Christmas_(song)
1
Г лава 8
Добавляем в программу случайности

Есть два способа заставить программу выполнять разные действия после


каждого ее запуска.
1. Вводить различные данные (или считывать их из файлов).
2. Написать программу так, чтобы она по-разному реагировала на одни
и те же данные пользователя.
Во многих случаях отлично работает первый способ, и пользователи часто
хотят, чтобы действия их программ были предсказуемыми. Например,
при написании текстового редактора или веб-браузера, скорее всего, вы
хотите, чтобы программа выполняла одни и те же действия каждый раз,
когда пользователь вводит фрагмент текста или веб-адрес. Вряд ли вам
понравится, если веб-браузер будет открывать случайные страницы (по
крайней мере, если вы не пользуетесь сервисом StumbleUpon)1.
Тем не менее встречаются ситуации, в которых одинаковое поведение про-
граммы после запуска является абсолютно нежелательным. Так, например,
в основе многих компьютерных игр лежит фактор случайности. Это нагляд-
но демонстрирует тетрис — если бы в каждой игре появлялись одни и те
же фигуры, пользователи постепенно запоминали бы все более длинные
их последовательности и устанавливали новые рекорды, зная, какая фи-
гура упадет следующей. Такая игра не увлекательнее запоминания числа π

1
StumbleUpon — это веб-сайт со случайной подборкой интересных веб-страниц:
http://www.stumbleupon.com/
120    Глава 8. Добавляем в программу случайности

с точностью до тысячи знаков после запятой. Чтобы тетрис доставлял


игроку удовольствие, необходимо выбирать следующую фигуру случайно.
Для этого требуется, чтобы компьютер генерировал случайные числа. Без-
условно, компьютеры делают в точности то, что вы им указываете. Если вы
у них что-либо запрашиваете, то всегда получаете один и тот же результат.
В этом и состоит сложность генерации абсолютно случайных значений.
К счастью, нет строгой необходимости всегда использовать такие числа.
Достаточно работать с числами, которые лишь выглядят случайными. Они
называются псевдослучайными числами.
Для генерации псевдослучайных чисел компьютер использует начальное
число и преобразует его в другие числа математическими методами. Новое
число используется генератором в качестве следующего начального числа.
Если при каждом запуске программа выбирает разные начальные числа, вы
никогда не получите одну и ту же последовательность случайных чисел.
Математические преобразования выбираются так, чтобы все случайные
числа генерировались с одинаковой частотой и не образовывали очевидных
последовательностей (например, таких, где каждое последующее число
больше предыдущего на единицу).
Язык C++ предоставляет все эти возможности, не нужно самостоятельно
применять математические методы — можно воспользоваться готовыми
функциями. Все, что требуется, — выбрать начальное число. Для этого доста-
точно воспользоваться текущим временем. Рассмотрим вопрос подробнее.

Получение случайных чисел в C++


В языке C++ есть две функции, одна из которых задает начальное число,
а другая генерирует по нему случайные числа:
void srand (int seed);

Функция srand устанавливает начальное число равным указанному зна-


чению. Ее следует вызвать однократно при запуске программы, и обычно
в нее передают результат функции time, которая возвращает число, соот-
ветствующее текущему времени1:

1
Функция time возвращает число секунд, прошедших с 1 января 1970 года. Это
соглашение заимствовано из операционной системы Unix, и его иногда назы-
вают Unix-временем. В большинстве случаев время хранится в 32-разрядном
целом числе со знаком. У такого формата хранения времени есть интересная
особенность: когда-нибудь это число переполнится, станет отрицательным
Получение случайных чисел в C++    121

srand(time(NULL1));

При многократном вызове функции srand начальное число каждый раз


будет устанавливаться заново, это окажет негативное влияние на случай-
ность результатов (они будут рассчитаны исходя из близких друг к другу
значений времени). Чтобы воспользоваться функцией srand, вы должны
включить в программу заголовочный файл cstdlib, а для использования
функции time — заголовочный файл ctime.

Пример 26: srand.cpp


#include <cstdlib>
#include <ctime>
int main ()
{
// вызовите эту функцию только один раз в самом начале программы
srand( time( NULL ) );
}

Получим случайные числа с помощью функции rand со следующим про-


тотипом:
int rand ();

Обратите внимание: функция не принимает никаких аргументов, а просто


возвращает число.
Выведем на экран полученный результат:

Пример 27: rand.cpp


#include <cstdlib>
#include <ctime>
#include <iostream>
using namespace std;
int main()
{
// вызовите эту функцию только один раз в самом начале программы
srand(time(NULL));
cout << rand() << '\n';
}

и будет указывать на время в прошлом. Это случится в 2038 году: компьютеры,


использующие Unix-время, решат, что настал 1901 год. «Проблема 2038 года»
широко обсуждается, более подробную информацию можно найти в Википе-
дии: https://ru.wikipedia.org/wiki/Проблема_2038_года
Пока не обращайте внимания на параметр NULL. Считайте его необходимой
1

формальностью, все станет понятнее, когда вы познакомитесь с указателями.


122    Глава 8. Добавляем в программу случайности

Ура! Теперь при каждом запуске программа ведет себя по-разному, а значит,
можно часами получать от нее удовольствие. Каким будет следующее число?
Возможно, это не очень интересно: в конце концов, можно получить число
в очень широких границах. Гораздо интереснее, если вы получаете число
из определенного диапазона. Оказывается, функция rand возвращает
значение от 0 до константы с именем RAND_MAX (ее минимальное значе-
ние равно 32 767). Скорее всего, вам нужен диапазон значительно уже.
Разумеется, можно вызывать функцию rand в цикле, пока она не вернет
требуемое значение:
int randRange (int low, int high)
{
while(1)
{
int rand_result = rand();
if(rand_result >= low
&&
rand_result <= high)
{
return rand_result;
}
}
}

Такое решение неудачно в силу нескольких причин. Первая проблема —


низкое быстродействие: если вам нужно число от 1 до 4, пройдет много
времени, прежде чем вы его получите, поскольку диапазон чисел, генериру-
емых функцией rand, существенно шире. Вторая проблема в том, что цикл
может оказаться бесконечным. Не исключено (хотя и очень маловероятно),
что вы никогда не получите число из требуемого диапазона. Не стоит по-
лагаться на удачу, если можно получить гарантированный результат.
В языке C++ есть операция, которая возвращает остаток от деления (на-
пример, 4 / 3 — это 1 плюс 1 в остатке). Эта операция называется взятием
по модулю и обозначается символом %. Если поделить любое число на 4,
остаток от деления будет в диапазоне от 0 до 3. Если поделить случайное
число на размер диапазона, получим число в пределах от 0 до размера диа-
пазона (сам размер диапазона всегда исключен).

Пример 28: modulus.cpp


#include <ctime>
#include <cstdlib>
#include <iostream>
using namespace std;
Случайные числа и отладка    123

int randRange (int low, int high)


{
// Получим случайное число; поместим его в пределы
// от 0 до числа значений в нашем диапазоне
// и сложим с минимальным возможным значением
return rand() % (high - low + 1) + low;
}
int main()
{
srand(time(NULL));
for(int i = 0; i < 1000; ++i)
{
cout << randRange(4, 10) << '\n';
}
}

Отметим два аспекта. Во-первых, мы должны увеличить значение high–low


на единицу. Чтобы понять почему, представьте себе, что нам нужно полу-
чить число в диапазоне от 0 до 10. Этот диапазон включает 11 чисел. Вы-
читание дает нам разность границ диапазона, а не количество чисел в нем,
поэтому мы добавляем к ней единицу. Во-вторых, обратите внимание, что
нужно прибавить значение low, чтобы попасть в нужный диапазон. К при-
меру, если мы хотим получить число от 10 до 20, требуется генерировать
случайное число от 0 до 10, а затем прибавить к нему 10.
Генерируя случайные числа в определенном диапазоне, можно создавать
забавные игры на угадывание или имитировать бросание костей.

Случайные числа и отладка


Если программа еще не закончена, генерация случайных чисел может
стать источником проблем. Ошибку в программе проще обнаружить, если
программа при каждом запуске выполняет одни и те же действия. Иначе
ошибка может проявляться не каждый раз, и вы рискуете потерять много
времени, тестируя безошибочную работу программы. Можно столкнуться
и с еще одной неприятной неожиданностью. Не исключено, что впервые
тестируя или отлаживая программу, вы комментируете вызов функции
srand. Если начальное число для генератора случайных чисел не задано,
функция rand будет возвращать одну и ту же последовательность значений
при каждом запуске программы, а следовательно, и поведение программы
будет одним и тем же.
Что, если вы обнаружите ошибки после включения вызова функции srand?
Можно сохранять начальное число при каждом запуске программы:
124    Глава 8. Добавляем в программу случайности

int srand_seed = time(NULL);


cout << srand_seed << '\n';
srand(srand_seed);

Обнаружив ошибку, вы можете отладить программу, используя начальное


число, при котором выявлена ошибка. Например, если начальное число
равно 35 434 333, добавьте в программу следующие строки:
int srand_seed = 35434333; // time(NULL);
cout << srand_seed << '\n';
srand(srand_seed);

Теперь результаты каждого запуска программы будут предсказуемыми.

Проверьте себя
1. Что произойдет, если не вызвать функцию srand перед функцией rand?
А. Функция rand завершится ошибкой.
Б. Функция rand будет всегда возвращать 0.
В. Функция rand будет возвращать одну и ту же последовательность
чисел при каждом запуске программы.
Г. Ничего.
2. Для чего следует задавать начальное число, передавая текущее время
в функцию srand?
А. Чтобы программа всегда выполнялась одинаково.
Б. Чтобы генерировать новые случайные числа при каждом запуске
программы.
В. Чтобы компьютер генерировал совершенно случайные числа.
Г. Это уже сделано за вас; если вы хотите, чтобы начальное число всегда
было одним и тем же, нужно лишь вызвать функцию srand.
3. Какой диапазон значений возвращает функция rand?
А. Какой вы хотите.
Б. От 0 до 1000.
В. От 0 до RAND_MAX.
Г. От 1 до RAND_MAX.
4. Какое значение возвращает выражение 11 % 3?
А. 33
Б. 3
В. 8
Г. 2
Случайные числа и отладка    125

5. Когда следует использовать функцию srand?


А. Каждый раз, когда нужно получить случайное число.
Б. Никогда, эта функция бесполезна.
В. Однократно при запуске программы.
Г. После нескольких вызовов функции rand, чтобы повысить степень
случайности чисел.
(Решения см. на с. 455.)

Практические задания
1. Напишите программу, которая имитирует подбрасывание монеты.
Запустите ее несколько раз: кажутся ли случайными ее результаты?
2. Напишите программу, которая выбирает число от 1 до 100 и позволяет
пользователю угадать его. Программа должна информировать пользо-
вателя, что предложенное им значение слишком мало, слишком велико
или он угадал задуманное число.
3. Напишите программу, которая решает проблему игры на угадывание.
Сколько угадываний требуется вашей программе?
4. Создайте эмулятор игрового автомата, который отображает игроку
случайные результаты с помощью как минимум трех различных значе-
ний на каждом барабане. Можете не выводить надпись типа «барабан
вращается»; просто генерируйте результат, отобразите его и определите
размер выигрыша (для этого задайте выигрышные комбинации).
5. Напишите программу для игры в покер. Сдайте игроку пять карт, пре-
доставьте возможность брать новые карты и определите комбинацию,
которую ему удалось получить. Подумайте, насколько легко решить
эту задачу. Какие проблемы могут возникнуть с отслеживанием карт,
взятых из колоды? Сравните сложность этой задачи и задачи об игро-
вом автомате.
Г лава 9
Что делать, когда не понятно,
что делать?

Вы уже изучили многие базовые возможности языка C++ и можете писать


самые разные программы. Стоп! А как вы решите, какие именно программы
писать? Даже зная проблему, которую решаете, вы можете оказаться в по-
ложении кальсонных гномов из сериала «Южный парк»:
Шаг 1. Украсть кальсоны.
Шаг 2. ??
Шаг 3. Выгода!
Вы знаете, с чего начать и к чему прийти, но не понимаете, что делать на
шаге 2.
Он может быть совершенно непонятным, пока вы знакомитесь с примерами
исходного кода, однако вы научитесь лучше понимать задачи, когда начнете
писать собственные программы (хотя, возможно, и нет; в этом случае вы
существенно опережаете график, так что посвятите вечер отдыху, выпейте
кружку пива и приходите завтра).
Итак, вы — один из многих, застрявших на шаге 2, и это нормально! Именно
сейчас начинается самое интересное (только не открывайте этот секрет сво-
ему приятелю, открывающему пиво, иначе он расстроится). С другой сторо-
ны, это один из самых трудных аспектов программирования; к примеру, он
Разделение задачи на части    127

сложнее, чем синтаксис языка, зато приносит гораздо больше удовольствия.


В разработке программы с нуля есть свое волшебство — ничто не сравнит-
ся с ощущением, которое вы испытываете, когда программа воплощается
в жизнь и упрощает сложную проблему. Чем больше вы практикуетесь,
тем совершеннее навыки, но надо знать, в чем именно практиковаться.
Именно этому посвящена настоящая глава. Единственная плохая новость
в том, что шаг 2, как правило, включает пару десятков этапов, поскольку
для решения проблемы ее необходимо разбить на небольшие фрагменты.
Итак, самое время накрыть стол, достать приборы и приступить к аперити-
ву. Я хотел сказать, что сейчас мы начнем изучать ситуации, в которых у вас
есть некоторое представление о том, как решать задачу. Если вы хорошо
понимаете, что делать, и всего лишь затрудняетесь написать код, вы знаете
базовый алгоритм. Этот алгоритм представляет собой последователь-
ность шагов, ведущих к решению задачи. Даже если у вас есть алгоритм,
его логику не всегда просто превратить в код; иногда программа должна
выполнять очень много действий. К счастью, есть инструменты, которые
облегчают решение этой проблемы.

Разделение задачи на части


Как я уже говорил, программирование — это разбиение задач на небольшие
фрагменты, понятные компьютеру. Функции предоставляют важную воз-
можность — формировать программы из составных частей, а не начинать
работу над ними с нуля каждый раз. Что я имею в виду?
Допустим, вы хотите вывести все простые числа от 1 до 100. Очевидно,
эта задача не решается в один прием, поэтому необходимо разбить ее на
части, понятные компьютеру.
Проблема в том, что для решения этой задачи требуется очень многое.
Держать весь процесс ее решения в голове крайне сложно, поэтому попро-
буем подойти к ней иначе и найти способ разбить ее на мелкие фрагменты.
Эти фрагменты необязательно являются конкретными инструкциями; нам
следует разделить задачу на шаги, которые проще, чем задача в целом. Вот
разумный вариант таких шагов.
1. Перебрать все числа от 1 до 100.
2. Проверить, является ли число простым.
3. Если число простое, вывести его.
128    Глава 9. Что делать, когда не понятно, что делать?

Отлично! Мы разбили задачу на несколько отдельных мелких подзадач, но


пока не можем превратить их в программу. Что нужно сделать? Подумаем,
как перебрать все числа от 1 до 100. Вероятно, это можно сделать с помощью
цикла. На самом деле можно написать код этого цикла:
for ( int i = 0; i < 100; i++ )
{
// проверка, является ли число i простым, и вывод, если да
}

Мы создадим функцию-заместитель и назовем ее isPrime. Эта функция


будет возвращать истину, если ее аргумент — простое число, и ложь в про-
тивном случае. Нам потребуется реализовать функцию isPrime, однако
если мы представим себе, что уже сделали это, то сможем продвинуться
дальше в написании кода. Мы способны реализовать большинство за-
думанных нами функций. Упрощаем задачу, поскольку легче проверить,
является ли число простым, чем выполнить такую проверку для ста чисел.
Это означает, что мы на правильном пути.
for(int i = 0; i < 100; i++)
{
if(isPrime(i))
{
cout << i << endl;
}
}

Здорово, не так ли? У нас есть структура, с которой мы можем работать.


Теперь все, что нам нужно, — написать функцию isPrime. Подумаем, как
проверить, является ли число простым. Число простое, если делится без
остатка только на единицу и на само себя. Достаточно ли в этом определе-
нии информации, чтобы разбить задачу на подзадачи? Думаю, да. Чтобы
определить, является ли число простым, нужно выяснить, существует ли
хотя бы одно число, отличное от исследуемого и не равное единице, на
которое исследуемое число делится без остатка. Поскольку поиск таких
чисел предполагает перебор, нам понадобится еще один цикл.
Эта часть алгоритма состоит из следующих шагов.
1. Для каждого числа от 1 до исследуемого числа.
2. Проверить, делится ли число на переменную цикла. Если да, вернуть
false.
3. Если число не делится ни на одну из переменных цикла, вернуть true.
Попробуем оформить этот алгоритм в виде кода. Пока мы не знаем, как
проверить, делится ли нацело одно число на другое; тем не менее будем
Разделение задачи на части    129

считать, что можем решить эту задачу, и возложим ее на функцию-заме-


ститель isDivisible.
bool isPrime(int num)
{
for(int i = 2; i < num; i++)
{
if(isDivisible(num, i))
{
return false;
}
}
return true;
}

Мы снова реализовали проверку множества значений в виде цикла и легко


преобразовали условный оператор алгоритма в условный оператор if.
Теперь займемся созданием функции isDivisible. Ее можно реализовать
с помощью оператора взятия по модулю:
10 % 2 == 0 // 10 / 2 = 5 без остатка

Все, что нужно сделать, — проверить отсутствие остатка при делении числа
на делитель:
bool isDivisible (int number, int divisor)
{
return num % divisor == 0;
}

Мы разбили проблему на части, понятные компьютеру! Мы написали все


необходимые функции; наша программа состоит из готовых инструкций
и функций, которые мы определили. Сведем воедино все наработки:
#include <iostream>
// обратите внимание на прототипы функций
bool isDivisible(int number, int divisor);
bool isPrime(int number);
using namespace std;
int main()
{
for(int i = 0; i < 100; i++)
{
if(isPrime(i))
{
cout << i << endl;
}
}
} продолжение 
130    Глава 9. Что делать, когда не понятно, что делать?

bool isPrime(int number)


{
for(int i = 2; i < number; i++)
{
if(isDivisible(number, i))
{
return false;
}
}
return true;
}
bool isDivisible(int number, int divisor)
{
return number % divisor == 0;
}

Благодаря прототипам функций можно даже расположить код в порядке


обдумывания решения задачи. Кроме того, можно легко читать код и по-
нимать как его общий смысл (в виде исходной задумки), так и нюансы
реализации вспомогательных функций.

Кратко об эффективности и безопасности


Мы можем изменить этот код, чтобы сделать его эффективнее. В функции
isPrime необязательно перебирать в цикле все числа от 2 до num; тривиаль-
ный алгоритм не всегда самый лучший и эффективный. В данном случае
можно сократить перебор, ограничив его диапазоном от 2 до квадратного
корня из num. Поскольку мы проверяем простоту лишь очень небольшого
количества чисел, эффективность алгоритма не имеет существенного зна-
чения. С другой стороны, генерация больших простых чисел используется
в некоторых важных алгоритмах, например RSA, который предназначен
для шифрования с открытым ключом, применяется на большинстве сайтов
банков и электронной торговли и защищает персональные данные. В нем
большие простые числа используются для создания ключей шифрования1.
Разумеется, чтобы генерировать большое простое число, необходимо прове-
рить, является ли оно простым. Для создания большого количества ключей
шифрования RSA вам бы понадобился быстрый и эффективный генератор!
Решая задачу, которая кажется слишком масштабной, попытайтесь разде-
лить ее на более мелкие фрагменты, с которыми будет проще справиться.

Более подробную информацию об алгоритме RSA вы можете найти в Вики-


1

педии: https://ru.wikipedia.org/wiki/RSA
Кратко об эффективности и безопасности    131

Необязательно сразу знать решения для этих фрагментов, хотя иметь в голо-
ве идеи о том, как их решить, конечно, полезно. Важно, чтобы вы понимали,
какие данные нужны подзадаче и к каким результатам она приводит. Если
вы можете написать программу, которая состоит из функций, решающих
эти подзадачи, можете сделать следующий шаг — приступить к созданию
этих функций. Через некоторое время у вас появится исходный код.
Иногда доведется столкнуться с тем, что решить подзадачу по какой-либо
причине невозможно. Спроектировать программу не всегда легко, в про-
тивном случае количество скучающих разработчиков ПО было бы гораз-
до больше. Если у вас возникают проблемы разбиения задачи на части,
вернитесь на шаг назад и придумайте другой способ, который позволяет
решать подзадачи успешнее.
Такой подход к декомпозиции задач называется нисходящим проекти-
рованием. Он обеспечивает весьма мощные возможности разработки
программ. Существует и другой подход, который называется восходя-
щим проектированием. Он предполагает, что сначала разрабатываются
вспомогательные функции, а затем с их помощью решается проблема
масштабнее. При восходящем проектировании некоторые вспомогатель-
ные функции в итоге оказываются ненужными; тем не менее восходящее
проектирование позволяет легче приступить к программированию, по-
скольку в вашем распоряжении оказываются готовые функции. Однако
новичкам, как правило, рекомендуется пользоваться нисходящим под-
ходом, поскольку он позволяет концентрироваться на создании функций,
которые действительно помогают решить поставленную задачу. Вместо
того чтобы строить догадки о том, какие функции окажутся полезными
для решения проблемы, вы точно определяете, какие вспомогательные
функции понадобятся1.
Кроме того, при проектировании необязательно представлять все компо-
ненты программы в исходном коде. Нарисуйте эскиз программы на бумаге
или на доске; вы сможете увидеть, как ее части складываются в целое, не
заботясь о синтаксисе C++ и ошибках компилятора. Проектируя про-
грамму и непосредственно используя исходный код, вы рискуете упустить
из вида ее общую картину из-за того, что внимание сконцентрировано

Конечно же, мои рекомендации не означают, что вам необходимо отказаться


1

от восходящего проектирования: некоторые люди пользуются им весьма


эффективно, и, возможно, вы окажетесь в их числе. Если у вас не получается
решить задачу методом нисходящего проектирования, измените подход, пре-
жде чем откажетесь от дальнейших попыток.
132    Глава 9. Что делать, когда не понятно, что делать?

на соблюдении всех синтаксических правил. Нет ничего плохого в том,


чтобы выписать все шаги процесса и разделить их на последовательности
мелких шагов, не прибегая к написанию исходного кода. Это нормальный
и абсолютно естественный подход к проектированию программ.
Помните, что не всегда легко придумать решение задачи. Мои рекоменда-
ции помогут, но это не панацея от любых проблем. Ваш главный помощ-
ник — практика, чем больше вы практикуетесь, тем легче справляетесь
с задачами. Опыт приходит со временем — не сдавайтесь!

Что делать, если алгоритм неизвестен


Задача о поиске простых чисел была очень легкой, поскольку алгоритм ее
решения заложен в определении простого числа. Все, что нужно сделать, —
превратить алгоритм в программный код. Как правило, справляться с за-
дачами сложнее, поскольку нужно придумывать алгоритмы их решения.
Допустим, необходимо создать программу, которая отображает числа про-
писью на английском языке (например, 1204 — one thousand, two hundred
four). Произнося числительные, вы делаете это так естественно, что даже
не задумываетесь о структуре алгоритма (если английский язык для вас
родной). Чтобы создать алгоритм, нужно понять закономерность постро-
ения числительных.
Для начала составим несколько примеров и попробуем найти в них общие
закономерности и различия:
1 one
10 ten
101 one hundred one
1001 one thousand one
10 001 ten thousand one
100 001 one hundred thousand one
1 000 001 one million one
10 000 001 ten million one
100 000 001 one hundred million one

Видите ли вы закономерность?
1 one
10 ten
101 one hundred one
1001 one thousand one
Что делать, если алгоритм неизвестен    133

10 001 ten thousand one


100 001 one hundred thousand one
1 000 001 one million one
10 000 001 ten million one
100 000 001 one hundred million one

Через каждые три цифры мы поднимаемся на уровень вверх — сначала


ничего, затем thousand, затем million.
В каждой группе из трех цифр существует закономерность: «one, ten, one
hundred». Эти закономерности объединяются в закономерности более
высокого уровня: «one thousand», «ten thousand», «one hundred thousand».
Таким образом, наш алгоритм должен сначала разбить число на группы из
трех цифр, затем определить порядок текущей группы (тысяча, миллион
или миллиард), а затем преобразовать группу в текст с учетом ее поряд-
ка. Каждая группа из трех цифр образует число меньше тысячи, поэтому
проблема, которую требуется решить, существенно упрощается. Поищем
еще закономерности:
5 five
15 fifteen
25 twenty five
35 forty five
45 one hundred five
105 one hundred five
115 one hundred fifteen
125 one hundred fifteen
135 one hundred twenty five
145 one hundred forty five

Здесь мы видим схожую закономерность: если число больше 100, текст


имеет вид «X hundred» и нам нужно добавить к нему текст для группы из
двух цифр. Если же в числе отсутствуют сотни, текст для группы из двух
цифр является окончательным.
Теперь осталось решить, как обрабатывать группы из двух цифр. В них
тоже наблюдается закономерность. Для двузначных чисел (за исключением
меньших 20) текст имеет вид «название десятков» «название единиц», такой
текст можно создать с помощью последовательности операторов if/else.
Обработка чисел от 1 до 19 должна быть жестко закодирована — алгоритма
их обработки не существует (мне он, по крайней мере, неизвестен).
134    Глава 9. Что делать, когда не понятно, что делать?

Таким образом, алгоритм принимает следующий вид.


1. Разбить число на группы из трех цифр.
2. Генерировать текст для каждой группы; добавить к нему порядок; объ-
единить полученные фрагменты.
3. Чтобы генерировать текст для группы из трех цифр, определить ко-
личество сотен и преобразовать его в текст, а затем добавить текст для
оставшихся двух цифр.
4. Чтобы генерировать текст для группы из двух цифр, сравнить число
с 20; если оно меньше, найти жестко закодированный текст; если оно
больше 20, то определить количество десятков, найти соответствующее
ему слово и добавить к нему слово, соответствующее количеству единиц.
Чтобы превратить этот алгоритм в исходный код, потребуется пройти его
еще раз, поскольку мы учли не все его нюансы, однако теперь есть доста-
точно проработанная структура для реализации алгоритма по принципу
нисходящего проектирования.
Процесс решения этой задачи выглядел так: мы изучили различные приме-
ры и обнаружили закономерности в структуре чисел. Мы создали базовый
алгоритм, не прорабатывая все его детали. В этом нет ничего неправильного:
после каждого шага алгоритм будет становиться подробнее, пока задача
не будет полностью решена.

Практические задания
1. Создайте исходный код, который преобразует числа от –999 999 до
999 999 в текст на английском языке. (Совет: воспользуйтесь тем, что
переменные целого типа округляют числа, отбрасывая знаки после
десятичной запятой. Помните, что ваш алгоритм не обязан быть уни-
версальным — он должен работать с числами, состоящими не более чем
из шести цифр.)
2. Подумайте, как решить обратную задачу — считывать английский текст
и переводить его в исходный код. Сравните сложность этой и предыду-
щей задач. Как вы обработаете ввод некорректных данных?
3. Разработайте программу, которая находит все числа от 1 до 1000, сумма
простых множителей которых является простым числом (например,
число 12 имеет простые множители 2, 2 и 3, которые в сумме дают про-
стое число 7). Создайте программный код для этого алгоритма. (Совет:
если вы не знаете, как разложить число на простые множители, спроси-
те об этом Google. Говоря, что необязательно знать математику, чтобы
быть программистом, я имел в виду именно это!)
Ч а с ть I I
Работа с данными

Вы многое узнали о том, как создавать простые, но интересные програм-


мы, которые отображают данные (например, ваше имя), взаимодействуют
с пользователем, принимают решения в зависимости от введенных дан-
ных, циклически выполняют простые операции и даже позволяют играть
в азартные игры.
Все это здорово, но через некоторое время работать с небольшими объ-
емами данных становится скучно, а у вас пока нет достаточных знаний для
того, чтобы манипулировать большим количеством данных. Вспомните
задачу про покер из последней главы и подумайте: легко ли вести учет
карт, которые уже участвовали в игре, или перетасовывать всю колоду
и отображать ее?
После недолгих размышлений вы придете к выводу: это сложно. Во-первых,
нужно хранить 52 различных значения, а для этого требуются 52 перемен-
ные. Чтобы выбрать очередную карту, вы должны проверить все переменные
и определить, была ли эта карта использована ранее. К моменту, когда вы
закончите работать с 52-й картой, вы получите огромную программу, но
лишитесь желания заниматься программированием дальше.
К счастью, программисты ленивы: поскольку они не любят работать без
должной необходимости, они придумали удобные способы решения опи-
санной проблемы.
Эта часть книги посвящена считыванию, сохранению в памяти и обработке
больших объемов данных. Сначала мы узнаем, как решить проблему с по-
кером и хранить большой объем данных, не создавая много переменных.
Г лава 1 0
Массивы

Массивы предназначены для хранения большого количества данных.


В первую очередь, массив представляет собой переменную с единственным
именем, позволяющую хранить несколько значений, каждое из которых
имеет собственный числовой индекс. Массив можно считать нумерованным
списком, элементы которого доступны по своим порядковым номерам.
Массивы можно легко изобразить следующим образом:

Я всегда представляю массивы как длинные цепочки из пристыкован-


ных друг к другу коробок: каждая коробка является элементом массива.
Считать значение из массива означает запросить коробку с определен-
ным номером: «Коробку номер пять, пожалуйста!» В этом волшебство
массива — поскольку он хранит все значения под одним именем, можно
запрограммировать выбор элементов из массива в процессе выполнения
программы. Под словом «запрограммировать» я понимаю, что можно
получить интересующее значение, не только указав имя переменной, но и
заставив программу рассчитать номер требуемого элемента массива. Чтобы
при игре в покер раздать руку из пяти карт, можно сохранить их в массиве
из пяти элементов. Чтобы выбрать новую карту для руки, нужно указать
индекс карты, которую вы меняете, а не использовать новую переменную.
Таким образом, сохранив индекс в переменной, вы сможете вытягивать
Базовый синтаксис массивов    137

карты из колоды с помощью одного и того же кода, а не писать отдельные


фрагменты кода для каждой переменной. Сравните:
Card1 = getRandomCard();
Card2 = getRandomCard();
Card3 = getRandomCard();
Card4 = getRandomCard();
Card5 = getRandomCard();

и
for(int i = 0; i < 5; i++)
{
card[i] = getRandomCard();
}

Представьте, какой была бы разница между этими фрагментами кода, если


бы рука состояла из 100 карт!

Базовый синтаксис массивов


Для объявления массива необходимо указать его имя, тип и размер.
int my_array[6];

Эта строка объявляет массив с шестью целочисленными элементами.


Обратите внимание, что размер указан в квадратных скобках за именем
переменной.
Для доступа к элементам массива используются скобки, но вместо размера
в них указывается индекс элемента:
my_array[3];

Визуально это можно представить следующим образом:

Обращение my_array относится к массиву в целом, my_array[0] — к его


первому элементу, а my_array[3] — к четвертому элементу. Хорошо если
138    Глава 10. Массивы

последнее утверждение вызвало вопросы — значит, вы читаете внимательно.


В нем нет опечатки: первым индексом массива является 0. Под индексом
я понимаю число, с помощью которого вы извлекаете значение из массива.
Возможно, это не то, к чему вы привыкли, если только ваши родители (или
тот, кто научил вас считать) не являются программистами.
Индекс можно легко представить как расстояние, которое нужно пройти
по списку, чтобы добраться до нужной коробки. Скорее всего, в какой-то
момент вам встретится термин «смещение» — он имеет тот же смысл: зна-
чение в массиве представляет собой смещение относительно его начала
на величину индекса. Поскольку первый элемент массива уже находится
в его начале, его смещение, а значит, и индекс, равны нулю.
Конкретный элемент массива можно использовать как любую другую
переменную. Можно изменить элемент массива следующим образом:

int my_array[4]; // объявляем массив


my_array[2] = 2; // задаем третий элемент (да, именно третий)
// равным 2

Примеры использования массивов


Массивы для хранения упорядоченных данных
Вернемся к вопросу, как перетасовать колоду из 52 карт. Часть этой задачи
состоит в том, что необходимо каким-то образом представить 52 карты:
теперь это можно сделать с помощью массива. Еще одна часть задачи —
представить порядок карт в колоде. Ее решение облегчает доступ к масси-
вам по номеру: можно считать, что порядок элементов массива совпадает
с порядком карт в колоде. Если присвоить массиву 52 случайных значения,
первый элемент (с индексом 0) будет верхней картой колоды, а последний
элемент (с индексом 51) — ее нижней картой.
В массивах часто хранят и сортированные значения. Например, может
потребоваться считать 100 значений и отобразить их в определенном
порядке. Этот порядок создается размещением значений в массиве (мы
не рассматриваем задачу сортировки), элементы которого расположены
естественным образом.

Представление матриц многомерными массивами


Массивы можно использовать для представления многомерных данных,
например шахматной доски (или, для простоты, поля для игры в крести-
ки-нолики).
Использование массивов    139

Для доступа к многомерным данным используется несколько индексов.


Чтобы объявить двумерный массив, укажите две его размерности:
int tic_tac_toe_board[ 3 ][ 3 ];

Поле для игры в крестики-нолики можно изобразить следующим образом:

Поскольку двумерный массив имеет прямоугольную форму, для доступа


к нему необходимо использовать два индекса — по одному для строки
и столбца. На рисунке представлены индексы для доступа к каждому
элементу массива. Нужно лишь указать два значения — в первой и второй
позиции.
Можно создать и трехмерный массив, хотя он вряд ли понадобится. На
самом деле можно создавать массивы с четырьмя, пятью и большим количе-
ством измерений; их сложно изобразить, и вы вряд ли будете пользоваться
ими на практике, поэтому не стану рисовать для них схемы.
Матричный массив позволяет лучше структурировать данные, с его помо-
щью можно описать поле для игры в крестики-нолики и задать значение
каждого элемента в соответствии с текущей игровой ситуацией. Массив
также позволяет описать лабиринт или схему уровня в ролевой компью-
терной игре.

Использование массивов

Массивы и циклы for


Массивы и циклы for отлично работают вместе. Доступ к массиву можно
получить, инициализировав переменную значением 0 и увеличивая ее на
единицу, пока она не станет равной длине массива. Это в точности соот-
ветствует модели цикла for.
Ниже приведена небольшая программа, которая создает таблицы умноже-
ния и сохраняет их в двумерном массиве с помощью циклов for.
140    Глава 10. Массивы

Пример 29: multidimensional_array.cpp


#include <iostream>
using namespace std;
int main()
{
int array[8][8];
// объявляем массив шахматной доски
for(int i = 0; i < 8; i++)
{
for(int j = 0; j < 8; j++)
{
// присваиваем значение каждому элементу
array[i][j] = i * j;
}
}
cout << "Multiplication table:\n";
for(int i = 0; i < 8; i++ )
{
for(int j = 0; j < 8; j++)
{
cout << "["<< i <<"]["<< j <<"] = ";
cout << array[i][j] <<" ";
cout << "\n";
}
}
}

Передача массивов в функции


Скоро вы увидите, что возможности языка взаимодействуют друг с дру-
гом. Например, вы уже знаете, что такое массив, и имеете все основания
проявить интерес к тому, как его можно передать в функцию. К счастью,
с точки зрения синтаксиса в этом нет ничего сложного.
Вызывая функцию, вы просто используете имя массива:

int values[ 10 ];
sum_array( values );

Объявляя функцию, вы указываете имя массива следующим образом:


int sum_array (int values[]);

«Стоп! — скажете вы. — В чем дело? Почему не указан размер массива?»


В этом нет никакой ошибки; для одномерного массива не требуется ука-
зывать размер. Размер необходим при определении массива, поскольку
компилятору нужно выделить для него память. Когда вы передаете мас-
сив в функцию, компилятор передает в нее исходный массив, а поскольку
Использование массивов    141

он не создает новый массив, нет и необходимости указывать его размер.


Если вы передаете исходный массив в функцию, то изменения, которые
она вносит в массив, сохранятся в нем после возврата из функции. Как
мы наблюдали ранее, для обычных переменных создаются копии; когда
функция принимает аргумент и изменяет его, исходное значение пере-
менной остается прежним.
Разумеется, если функция не знает размер массива, то для работы с ним
ей нужно передать размер массива в качестве второго параметра:
int sumArray (int values[], int size)
{
int sum = 0;
for(int i = 0; i < size; i++)
{
sum += values[i];
}
return sum;
}

С другой стороны, передавая в функцию многомерный массив, вы должны


указать все его размерности, кроме первой.
int check_tic_tac_toe (int board[][3]);

Очень странно, не так ли? Пока просто запомните, что указывать первую
размерность массива не нужно (это можно сделать, но она будет проигно-
рирована).
Я подробнее расскажу о передаче массивов в функции в главе 12 «Введение
в указатели» и объясню арифметику, которая происходит за кулисами,
а пока считайте это синтаксической причудой C++.
Рассмотрим законченную программу, которая демонстрирует функцию
sum_array.

Пример 30: sum_array.cpp


#include <iostream>
using namespace std;
int sumArray(int values[], int size)
{
int sum = 0;
// этот массив заканчивается, когда i == size, поскольку его последний
// элемент имеет индекс size - 1
for(int i = 0; i < size; i++)
{
sum += values[i];
продолжение 
142    Глава 10. Массивы

}
return sum;
}
int main()
{
int values[10];
for(int i = 0; i < 10; i++)
{
cout << "Enter value " << i << ": ";
cin >> values[i];
}
cout << sumArray(values, 10) << endl;
}

Подумайте, как написать подобную программу, не используя массив. У вас


не будет возможности хранить все значения, и придется накапливать их
сумму, прибавляя к ней новые значения по мере их ввода пользователем.
Вы не сможете легко сохранять значения для использования в будущем
(например, чтобы отобразить их).

Запись в конец массива


Хотя у вас есть свободный доступ к элементам массива, никогда не пытай-
тесь записывать данные вне его пределов (например, в смещение, равное
10, если массив состоит из 10 элементов).
НЕКОРРЕКТНЫЙ КОД
int my_array[10];
my_array[10] = 4; // попытка записи в 11 элемент

Массив состоит только из 10 элементов, поэтому последним корректным


индексом является 9. Использование индекса 10 недопустимо и часто
приводит к аварийному завершению программы (я объясню, почему это
происходит, во время изучения принципов работы памяти). Чаще всего
такая ситуация встречается при обработке массива в цикле.
НЕКОРРЕКТНЫЙ КОД
int vals[10];
for(int i = 0; i <= 10; i++)
{
cin >> vals[i];
}

В этом массиве 10 элементов, однако условие цикла проверяет, не пре-


восходит ли переменная i 10. Это означает, что при выполнении цикла
в массив vals[10] будут записаны некорректные данные. К сожалению,
компилятор, который обычно ведет себя придирчиво, не предупредит вас
Сортировка массивов    143

об этих ошибках. Вы узнаете о них, лишь когда программа аварийно завер-


шится или поведет себя странно из-за того, что другой код воспользуется
измененным значением.

Сортировка массивов
Вернемся к вопросу, который я уже задавал раньше: как взять 100 значений
и отсортировать их? Базовая структура кода очевидна: понадобится цикл,
который запрашивает у пользователя 100 значений.

Пример 31: read_ints.cpp


#include <iostream>
using namespace std;
int main()
{
int values[100];
for(int i = 0; i < 100; i++)
{
cout << "Enter value " << i << ": ";
cin >> values[i];
}
}

Здесь нет ничего сложного, однако теперь предстоит сортировать введен-


ные данные.
Самый естественный способ сортировки, к которому прибегает большин-
ство, — найти наименьшее значение, переместить его в начало списка,
найти наименьший элемент среди остальных, переместить его на второе
место и т. д.
Сортировку списка из трех элементов можно визуально представить сле-
дующим образом:
3, 1, 2

Сначала перемещаем число 1 в начало списка:


1, 3, 2

Затем перемещаем число 2 на вторую позицию в списке:


1, 2, 3

Можно ли воспользоваться языком C++, чтобы реализовать этот алгоритм?


Мне кажется, это можно сделать с помощью цикла. Просматриваем массив
начиная с первого элемента и определяем значение, которое требуется
144    Глава 10. Массивы

в него поместить, выявляя наименьший элемент в оставшейся (неотсор­


тированной) части массива. Затем меняем местами найденное значение
и элемент с текущим индексом, поскольку его нужно куда-то поместить.
Мы можем написать часть кода для этого алгоритма, воспользовавшись
нисходящим подходом к проектированию:
void sort (int array[])
{
for(int i = 0; i < 100; i++)
{
int index = findSmallestRemainingElement(array, i);
swap(array, i, index);
}
}

Теперь займемся реализацией двух вспомогательных функций, findSmalles-


tRemainingElement и swap. Начнем с функции findSmallestRemainingElement;
она должна сканировать массив начиная с индекса i и находить в нем
наименьший элемент. Похоже, нам нужно воспользоваться еще одним
циклом, верно? Мы будем просматривать каждый элемент массива и, если
он оказывается меньше элемента, наименьшего среди уже просмотренных,
присвоим его индекс текущему индексу наименьшего элемента.
int findSmallestRemainingElement (int array[], int index)
{
int index_of_smallest_value = index;
for(int i = index + 1; i < ???; i++)
{
if(array[i]
<
array[index_of_smallest_value])
{
index_of_smallest_value = i;
}
}
return index_of_smallest_value;
}

Выглядит разумно, не так ли? Есть одна небольшая проблема: как опреде-
лить, в какой момент остановить цикл? Аргументы функции не содержат
информацию о размере массива! Нужно добавить ее и указать размер мас-
сива в вызове функции findSmallestRemainingElement. Обратите внимание:
в этой ситуации мы вынуждены вернуться к исходному коду, созданному
при нисходящем проектировании, и внести в него изменения. Это действия
естественны для процесса проектирования, и вы можете выполнять их
без каких-либо сомнений. Мы избавимся и от жестко заданного размера
массива (100 элементов).
Сортировка массивов    145

int findSmallestRemainingElement (
int array[],
int size,
int index
).
{
int index_of_smallest_value = index;
for (int i = index + 1; i < size; i++)
{
if(array[i]
<
array[index_of_smallest_value])
{
index_of_smallest_value = i;
}
}
return index_of_smallest_value;
}
void sort (int array[], int size)
{
for(int i = 0; i < size; i++)
{
int index =
findSmallestRemainingElement(
array,
size,
i
);
swap(array, i, index);
}
}

Наконец, необходимо реализовать функцию swap. Поскольку функция


может изменять передаваемый ей массив, мы просто поменяем местами
два его элемента, воспользовавшись временной переменной для хранения
перезаписываемого значения.
void swap (int array[], int first_index, int second_index)
{
int temp = array[first_index];
array[ first_index ] = array[ second_index ];
array[ second_index ] = temp;
}

Поскольку исходный массив, переданный в функцию swap, можно изменять


напрямую, не нужно предпринимать какие-либо дополнительные действия.
Чтобы убедиться, что этот алгоритм сортировки работает, заполните мас-
сив случайными числами и отсортируйте его. Приведем полный текст
программы.
146    Глава 10. Массивы

Пример 32: insertion_sort.cpp


#include <cstdlib>
#include <ctime>
#include <iostream>
using namespace std;
int findSmallestRemainingElement(
int array[], int size, int index);
void swap (int array[], int first_index, int second_index);
void sort (int array[], int size)
{
for(int i = 0; i < size; i++)
{
int index =
findSmallestRemainingElement(
array,
size,
i
);
swap(array, i, index);
}
}
int findSmallestRemainingElement(
int array[],
int size,
int index
)
{
int index_of_smallest_value = index;
for(int i = index + 1; i < size; i++)
{
if(array[i ]
<
array[index_of_smallest_value])
{
index_of_smallest_value = i;
}
}
return index_of_smallest_value;
}
void swap(int array[], int first_index, int second_index)
{
int temp = array[ first_index ];
array[first_index] = array[second_index];
array[second_index] = temp;
}
// небольшая вспомогательная функция, отображающая
// исходный и отсортированный массив
void displayArray (int array[], int size)
{
cout << "{";
Сортировка массивов    147

for ( int i = 0; i < size; i++ )


{
// этот шаблон будет часто использоваться для удобного
// форматирования списков; мы проверяем, находимся ли
// за первым элементом, и, если да, добавляем запятую
if(i != 0)
{
cout << ", ";
}
cout << array[i];
}
cout << "}";
}
int main()
{
int array[10];
srand(time(NULL));
for(int i = 0; i < 10; i++)
{
// используйте небольшие числа,
// чтобы было легко их читать
array[i] = rand() % 100;
}
cout << "Original array: ";
displayArray(array, 10);
cout << '\n';
sort(array, 10);
cout << "Sorted array: ";
displayArray(array, 10);
cout << '\n';
}

Только что изученный алгоритм называется сортировкой методом вставки.


Это не самый быстрый алгоритм сортировки чисел, однако его легко понять
и реализовать. Если бы стояла задача реализовать алгоритм сортировки
очень большого объема данных, мы бы воспользовались более быстрым,
пусть и более сложным в реализации методом. В процессе написания
программ вам придется часто сталкиваться с подобными компромиссами.
Во многих случаях простейший алгоритм является наилучшим, однако
если ваш веб-сайт обслуживает несколько миллионов посетителей в день,
самый простой алгоритм вряд ли справится с вашей задачей. Вы должны
принять взвешенное решение, какой алгоритм использовать, исходя из
объема данных, который предполагается обрабатывать с его помощью,
и требований к быстродействию алгоритма. Если есть возможность запу-
стить групповую обработку данных на всю ночь, допустимо использовать
относительно медленный алгоритм, если же вы создаете поисковую систему
(наподобие Google), которая должна в реальном времени предоставлять
информацию пользователю, такой алгоритм не подойдет.
148    Глава 10. Массивы

Как видно, массивы обладают мощными возможностями: с их помощью


можно обрабатывать и структурировать значительно большие объемы
данных, чем раньше. Тем не менее есть и проблемы, которые нам еще пред-
стоит решить. Что делать, если мы хотим связать друг с другом несколько
различных значений, при этом не храня их в одной переменной? Массивы
позволяют хранить четко определенные фрагменты данных, но не позво-
ляют структурировать данные, связанные между собой. Мы узнаем, как
решить эту проблему, в следующей главе о структурах.
Еще одна проблема: массивы предоставляют фиксированный объем памяти.
Если необходимо, можно выделить под массив много памяти, однако ее
объем задается один раз в процессе написания программы. Если вы хотите
создать программу, которая способна хранить и обрабатывать неограни-
ченный объем данных, массив фиксированного размера не позволит это
сделать. Скоро мы узнаем, как решить и эту проблему.
Несмотря на перечисленные ограничения, массивы позволили сделать
огромный шаг вперед, и теперь мы будем постоянно пользоваться индек-
сами для доступа к данным.

Проверьте себя
1. Что из нижеперечисленного является корректным объявлением массива?
А. int my_array[10];
Б. int anarray;
В. anarray{10};
Г. array anarray[10];
2. Какой индекс имеет последний элемент массива, состоящего из 29
элементов?
А. 29
Б. 28
В. 0
Г. Этот индекс определяется программистом.
3. Что из перечисленного является двумерным массивом?
А. array anarray[20][20];
Б. int anarray[20][20];
В. int array[20, 20];
Г. char array[20];
Практические задания    149

4. Какой из операторов корректно получает доступ к седьмому элементу


стоэлементного массива с именем foo?
А. foo[6];
Б. foo[7];
В. foo(7);
Г. foo;
5. Что из перечисленного корректно объявляет функцию, которая при-
нимает в качестве аргумента двумерный массив?
А. int func(int x[][]);
Б. int func(int x[10][]);
В. int func(int x[]);
Г. int func(int x[][10]);
(Решения см. на с. 456.)

Практические задания
1. Напишите функцию insertion_sort, которая использует созданный
нами код для сортировки методом вставки, но работает с массивами
любого размера.
2. Напишите программу, которая принимает 50 значений и выводит
наибольшее, наименьшее и среднее значения, затем сами введенные
значения по одному в строке.
3. Напишите программу, которая определяет, отсортирован ли массив,
и, если он не отсортирован, сортирует его.
4. Напишите программу, которая позволяет двум игрокам играть в крести-
ки-нолики. Программа должна проверять, выиграл ли кто-то из игроков
и заполнено ли игровое поле целиком (в этом случае игра завершается
вничью). Дополнительный вопрос: можете ли вы сделать так, чтобы
программа определяла, что игра завершится вничью, раньше чем поле
окажется целиком заполненным?
5. Создайте игру в крестики-нолики, в которой размер поля больше 3 × 3,
но для выигрыша требуется составить ряд из четырех крестиков или
ноликов. Дайте игрокам возможность выбирать размер поля, когда
программа уже запущена (совет: пока что придется определять габа-
риты игрового поля на этапе компиляции, поэтому есть смысл задать
максимальный размер поля).
150    Глава 10. Массивы

6. Создайте шашечную игру для двух игроков, которая каждому из них


дает возможность делать ходы, проверяет их корректность и определя-
ет, завершилась ли игра. Обеспечьте поддержку перехода в дамки! По
желанию добавьте в игру любые собственные правила. Дайте пользо-
вателю возможность выбирать тип правил в начале работы программы.
Г лава 1 1
Структуры

Связывание значений
Теперь, умея хранить единственное значение в массиве, можно писать про-
граммы, работающие с большим объемом данных. С увеличением объема
данных в программе будут появляться фрагменты, связанные друг с дру-
гом, например имена и координаты игроков на экране в видеоигре. Сейчас
можно оформить эти данные в виде трех отдельных массивов:

int x_coordinates[ 10 ];
int y_coordinates[ 10 ];
string names[ 10 ];

Тем не менее придется учитывать, что эти массивы связаны друг с другом,
и, меняя положение элемента в одном из этих массивов, вы должны из-
менить его положение и в двух других.
Если добавить к этим трем значениям четвертое, работать станет очень не-
удобно: придется создать еще массив и синхронизировать его с тремя дру-
гими. К счастью, люди, которые разрабатывают языки программирования,
не мазохисты, поэтому они придумали более удобный способ связывания
значений друг с другом — структуры. Структуры позволяют хранить раз-
личные значения в переменных с одним и тем же именем. Они полезны
для объединения несколько фрагментов данных в группу.
152    Глава 11. Структуры

Синтаксис
Формат определения структуры имеет следующий вид:
struct SpaceShip
{
int x_coordinate;
int y_coordinate;
string name;
}; // <- Notice that pesky semicolon; you must include it

Здесь SpaceShip является именем определяемого нами типа структуры.


Другими словами, вы создали собственный тип данных, такой же, как
double или int. С его помощью можно объявить переменную:
SpaceShip my_ship;

x_coordinate, y_coordinate и name являются полями нашего нового типа.


Что означает слово «поля»?
Мы только что создали составной тип данных — переменную, которая
хранит несколько связанных друг с другом значений (наподобие двух ко-
ординат точки на экране или пары имя-фамилия). Для доступа к нужному
значению вы указываете имя поля переменной. Это аналогично использо-
ванию двух отдельных переменных с различными именами, однако здесь
переменные сгруппированы и именованы по одному принципу.
Считайте, что структура — это форма с полями, которая напоминает
паспорт: в ней содержится множество связанных между собой данных,
и каждое ее поле включает часть этих данных. Объявление структуры за-
дает форму, а объявление переменной этой структуры создает копию этой
формы, которую можно заполнить данными и сохранить.
Для доступа к полю структуры указываем имя переменной (а не структу-
ры — у каждой переменной собственные значения полей), затем символ .,
а за ним — имя поля:
// объявляем переменную
SpaceShip my_ship;
// используем ее
my_ship.x_coordinate = 40;
my_ship.y_coordinate = 40;
my_ship.name = "USS Enterprise (NCC-1701-D)";

Как видите, структура может содержать много полей (практически сколько


нужно), и они необязательно одного типа.
Рассмотрим пример программы, который демонстрирует считывание имен
пяти игроков (сама игра не включена в программу) с использованием
массивов и структур:
Связывание значений    153

#include <iostream>
using namespace std;
struct PlayerInfo
{
int skill_level;
string name;
};
int main ()
{
// можно создавать массивы из структур
// так же, как из обычных переменных
PlayerInfo players[5];
for(int i = 0; i < 5; i++)
{
cout << "Please enter the name for player : "
<< i << '\n';
// сначала получаем доступ к первому элементу массива с помощью
// обычного синтаксиса массивов; затем получаем доступ к полю
// структуры с помощью синтаксиса '.'
cin >> players[i].name;
cout << "Please enter the skill level for"
<< players[i].name << '\n';
cin >> players[i].skill_level;
}
for(int i = 0; i < 5; ++i)
{
cout << players[i].name
<< " is at skill level "
<< players[i].skill_level << '\n';
}
}

Структура PlayerInfo объявляет, что у нее есть два поля: имя игрока (name)
и уровень его опыта (skill_level). Поскольку структуру PlayerInfo можно
использовать так же, как любой другой тип переменной (например, int),
можно создать массив игроков. Создавая массив структур, вы работаете
с каждым его элементом так же, как с отдельным экземпляром структуры.
Для доступа к имени игрока, хранящемуся в первой структуре массива,
воспользуемся конструкцией players[0].name.
Эта программа считывает два элемента информации о пяти игроках в од-
ном цикле for с помощью массивов и структур и отображает введенную
информацию во втором цикле.
Необходимости использовать несколько взаимосвязанных массивов для
каждого фрагмента данных нет. Не нужно пользоваться отдельными мас-
сивами player_names и player_skill_level.
154    Глава 11. Структуры

Передача структур
Часто приходится создавать функции, которые принимают структуру
в качестве аргумента или возвращают ее. Допустим, вы пишете небольшую
компьютерную игру с космическими кораблями и хотите создать функцию,
которая инициализирует структуру, описывающую нового противника.
struct EnemySpaceShip
{
int x_coordinate;
int y_coordinate;
int weapon_power;
};
EnemySpaceShip getNewEnemy();

В этом случае вызов функции getNewEnemy вернет экземпляр структуры,


все значения которого инициализированы. Написать функцию можно так:
EnemySpaceShip getNewEnemy()
{
EnemySpaceShip ship;
ship.x_coordinate = 0;
ship.y_coordinate = 0;
ship.weapon_power = 20;
return ship;
}

Эта функция фактически создаст копию локальной переменной ship, ко-


торую она возвращает. Это означает, что она последовательно копирует
каждое поле структуры в новую переменную. Может показаться, что для
копирования большого количества полей потребуется много времени,
однако большинство компьютеров работает настолько быстро, что это не
имеет никакого значения. Тем не менее если вы работаете с большим чис-
лом структур, временные затраты станут значимыми. В следующей главе
об указателях мы поговорим, как не создавать эти лишние копии.
Чтобы получить переменную, которую возвращает функция, воспользу-
емся кодом:
EnemySpaceShip ship = getNewEnemy();

Теперь переменную ship можно использовать так же, как любую другую
структурную переменную. Передача структуры в функцию выглядит так:

EnemySpaceShip upgradeWeapons(EnemySpaceShip ship)


{
ship.weapon_power += 10;
return ship;
}
Связывание значений    155

При передаче в функцию происходит копирование структуры (аналогичное


выполнявшемуся при возврате структуры), а следовательно, любые изме-
нения, вносимые в структуру внутри функции, будут потеряны. По этой
причине функция должна вернуть копию структуры после ее изменения:
исходная структура останется неизменной.
Чтобы обновить структуру EnemySpaceShip с помощью функции upgrade­
Weapons, воспользуемся оператором:
ship = upgradeWeapons(ship);

При вызове функции переменная ship копируется в аргумент функ-


ции. Когда функция возвращает значение, возвращенная переменная
EnemySpaceShip копируется обратно в переменную ship и перезаписывает
ее исходные поля.
Ниже приведен пример программы, которая демонстрирует создание
и обновление данных о вражеском космическом корабле.

Пример 33: upgrade.cpp


struct EnemySpaceShip
{
int x_coordinate;
int y_coordinate;
int weapon_power;
};
EnemySpaceShip getNewEnemy()
{
EnemySpaceShip ship;
ship.x_coordinate = 0;
ship.y_coordinate = 0;
ship.weapon_power = 20;
return ship;
}
EnemySpaceShip upgradeWeapons (EnemySpaceShip ship)
{
ship.weapon_power += 10;
return ship;
}
int main ()
{
EnemySpaceShip enemy = getNewEnemy();
enemy = upgradeWeapons(enemy);
}

Возможно, вас интересует, что делать, чтобы создать неограниченное ко-


личество вражеских кораблей и отслеживать данные о них в ходе игры.
156    Глава 11. Структуры

Как создавать вражеские корабли? Для этого нужно вызывать функцию


getNewEnemy . Но где хранить информацию о кораблях? Сейчас можно
пользоваться лишь массивами фиксированного размера и создать массив
значений EnemySpaceShip:
EnemySpaceShip my_enemy_ships[10];

Но такая операция не позволит работать с более чем десятью противника-


ми одновременно. Возможно, этого будет достаточно, но возможно, и нет.
Решение этой проблемы будет описано в следующих нескольких главах,
начиная с главы 12 «Введение в указатели».

Проверьте себя
1. Что из перечисленного получает доступ к переменной в структуре b?
А. b->var;
Б. b.var;
В. b-var;
Г. b>var;
2. Что из перечисленного является корректно определенной структурой?
А. struct {int a;}
Б. struct a_struct {int a};
В. struct a_struct int a;
Г. struct a_struct {int a;};
3. Что из перечисленного объявляет структурную переменную типа foo
с именем my_foo?
А. my_foo as struct foo;
Б. foo my_foo;
В. my_foo;
Г. int my_foo;
4. Каково окончательное значение, получаемое в результате выполнения
следующего кода?
#include <iostream>
using namespace std;
struct MyStruct
{
int x;
};
void updateStruct (MyStruct my_struct)
Практические задания    157

{
my_struct.x = 10;
}
int main()
{
MyStruct my_struct;
my_struct.x = 5;
updateStruct(my_struct);
cout << my_struct.x << '\n';
}

А. 5
Б. 10
В. Этот код не скомпилируется.
(Решения см. на с. 457.)

Практические задания
1. Напишите программу, которая позволяет пользователю заполнить
структуру, содержащую имя, адрес и номер телефона одного человека.
2. Создайте массив объектов космических кораблей и напишите про-
грамму, которая периодически обновляет их местоположение, пока они
не переместятся за пределы экрана. Считайте, что экран имеет размер
1024 × 768 пикселей.
3. На основе задачи 1 создайте программу для ведения адресной книги;
здесь пользователь должен иметь возможность не только заполнять
единственную структуру, но и добавлять новые записи с отдельными
именами и номерами телефона. Дайте пользователю возможность вве-
сти столько записей, сколько он хочет. Легко ли это сделать? Возможно
ли это вообще?
4. Добавьте возможность отображения всех записей и отдельных записей,
которые пользователь выбирает из их списка.
5. Напишите программу, которая позволяет вводить результаты игр, фик-
сируя имена игроков и их результаты. Добавьте возможность отобра-
жения лучших результатов каждого игрока, всех результатов данного
игрока, всех результатов всех игроков и списка игроков.
Г лава 1 2
Введение в указатели

Забудьте все, о чем вам говорили


К сожалению, и новички, и даже профессиональные программисты отно-
сятся к указателям как к чему-то мистическому. Если вам говорили, что
тема указателей сложна для изучения, запутанна или непонятна, забудьте
об этом и не берите это в голову.
Среди людей, которых я обучал программированию, почти каждый, кто
изучал указатели, успешно справлялся с их освоением. Я обещаю, что лю-
бой читатель этой книги, который уделит указателям достаточно времени,
поймет, как они устроены, для чего предназначены и как ими пользоваться.
На некоторое время вам придется серьезно напрячь мозг, однако гимнастика
ума всегда полезна. Я разделю следующие несколько глав на небольшие
фрагменты, чтобы вы могли отдохнуть от умственных нагрузок. Мы начнем
с того, что познакомимся с концепцией указателей и целями их использо-
вания, а затем разберемся в тонкостях их синтаксиса.

Что такое указатели и зачем они нужны


До сего момента мы могли работать только с фиксированным объемом
памяти, который определялся еще до запуска программы. Когда вы объ-
являете переменную, для нее неявно выделяется некоторое количество
Что такое память    159

памяти, в которой хранится ее содержимое. При объявлении переменной


количество выделяемой памяти определяется во время компиляции: нельзя
изменить его в процессе работы программы. Мы умеем создавать массивы,
которые занимают большие области памяти и хранят множество перемен-
ных, однако в массивах невозможно хранить больше элементов, чем вы
указали при написании программы. В следующих нескольких главах мы
научимся получать доступ к большему количеству памяти, чем доступно
в момент ее запуска. Вы узнаете, как создавать бесконечное множество
вражеских кораблей, одновременно летающих вокруг вас.
Чтобы получить доступ к (почти) неограниченному объему памяти, необ-
ходима переменная, способная напрямую обращаться к памяти, хранящей
другие переменные. Такая переменная и называется указателем.
Название «указатель» хорошо отражает суть концепции — это переменная,
которая указывает на область памяти. Указатель очень похож на гипер­
ссылку. Веб-страница расположена в определенном месте на веб-сервере.
Желая отправить кому-то ее копию, вы не станете скачивать ее целиком,
а отправите ссылку на нее. Аналогично указатель позволяет хранить или
отправлять «ссылки» на переменные, массивы и структуры, не создавая
их копии.
Указатель, как и гиперссылка, хранит сведения о местонахождении других
данных. Поскольку указатель содержит информацию о расположении
других данных (их адрес), его можно использовать для доступа к памяти,
которая выделяется операционной системой. Другими словами, программа
может запросить дополнительную память и получить к ней доступ с по-
мощью указателей.
На самом деле вы уже видели один пример указателя: помните, как мы пере-
давали в функцию массив, который не копировался, и функция получала
прямой доступ к его элементам? Это было сделано с помощью указателей.
Теперь вы знаете, что указатели совсем не такие страшные!
Перед тем как продолжить их изучение, поговорим немного о памяти.

Что такое память


Чтобы легко представить себе память компьютера, ее можно сравнить
с электронной таблицей Excel. Электронные таблицы содержат много
ячеек, каждая из которых может хранить данные. Память компьютера
тоже состоит из многочисленных последовательных фрагментов данных.
160    Глава 12. Введение в указатели

В отличие от Excel, память хранит в своих ячейках лишь очень небольшое


количество данных — один байт, который представляет всего 256 различных
значений. Кроме того, память имеет линейную структуру, в то время как
Excel представляет собой матрицу. На самом деле память можно предста-
вить как очень длинный массив символов.
У каждой ячейки Excel есть номер строки и столбца, по которым ее можно
найти, аналогично у каждой ячейки памяти есть собственный адрес. Этот
адрес хранится в указателе, когда его значение содержит данные о располо-
жении памяти (в Excel указателем является ячейка, которая хранит в себе
имя другой ячейки, — например, ячейка C1, в которой записана строка A1).
На рисунке представлена диаграмма, которая иллюстрирует небольшой
фрагмент памяти. Обратите внимание, что эта диаграмма очень напоми-
нает массив: массив представляет собой просто набор последовательных
элементов памяти.

Здесь прямоугольники — это фрагменты памяти, в которых можно хра-


нить данные, а числа — адреса, позволяющие идентифицировать элементы
памяти. Они отмечены с шагом, равным четырем, поскольку большинство
переменных в памяти занимает четыре байта; таким образом, на рисунке мы
видим шесть различных переменных, размер каждой из которых составляет
четыре байта1. (Кстати, вы часто будете видеть адреса памяти, записанные
в шестнадцатеричном формате. Если вы не сталкивались с ним раньше, он
может быть непонятен, однако я буду пользоваться обычными числами2.)
На схеме ячейка памяти с адресом 4 хранит значение 16, которое может
являться адресом другой ячейки памяти. Память с адресом 4 принадлежит

1
На самом деле эта схема относится только к 32-разрядным компьютерам
(32 бита — это 4 байта), в которых большинство встроенных операций про-
цессора работает с 4-байтными значениями. Но даже на таких компьютерах
есть переменные, размер которых превышает 4 байта (например, double). Тем
не менее для простоты мы не будем уделять большого внимания этим деталям.
2
Шестнадцатеричные числа представлены в системе с основанием 16, например
0x10ab0200. Символы 0x указывают на шестнадцатеричный формат числа,
а буквы от A до F соответствуют цифрам от 10 до 15.
Что такое память    161

переменной-указателю. Другие ячейки памяти помечены символами «??»,


которые означают, что в них не хранится какое-либо заданное значение;
тем не менее в любой ячейке памяти всегда имеется содержимое. Пока
память не инициализирована, значение не имеет смысла — оно может
быть любым.

Переменные и адреса
Возможно, вас несколько запутывает различие переменной и адреса. Пере-
менная представляет значение; это значение фактически хранится в опре-
деленном месте памяти (по определенному адресу). Другими словами,
компилятор использует адреса памяти, чтобы реализовать переменные
в вашей программе. Указатель — это особый тип переменной, который
позволяет хранить адрес другой переменной.
Если адрес переменной известен, с его помощью можно считать значе-
ние этой переменной. Если нужно передать большое количество данных
в функцию (во время выполнения программы), гораздо эффективнее ука-
зать их местоположение, а не копировать все эти данные, как мы видели на
примере массивов. Этот подход также позволяет избежать копирования
структур при их передаче в функции. Основная идея в том, чтобы взять
адрес, по которому хранятся данные, связанные со структурной пере-
менной, и передать его в функцию вместо того, чтобы копировать данные
структуры.
Самая важная функция указателей — дать возможность в любое время
получить от операционной системы дополнительную память. Как это
сделать?1 Операционная система сообщает адрес памяти. Для его сохра-
нения требуется указатель. Если позже понадобится дополнительная па-
мять, можно запросить ее и изменить значение указателя. Таким образом,
с помощью указателей мы можем выходить за пределы фиксированного
объема данных и решать, сколько памяти требуется, в процессе выполне-
ния программы.

1
Управляет памятью операционная система, поэтому наше утверждение в целом
верно, однако в действительности существует несколько уровней кода, которые
занимаются выделением памяти. Операционная система является одним из
этих уровней, однако есть уровни выше нее. Я не буду принимать во внимание
эти нюансы, чтобы не вносить путаницу в материал книги. Если вы не поняли
вышеизложенное, не беспокойтесь — будь оно важным, я не писал бы о нем
в сноске. Сейчас перечисленные детали не имеют существенного значения, но
позднее вы поймете их смысл.
162    Глава 12. Введение в указатели

z z Замечание о терминах
Под термином «указатель» понимается:
1) сам адрес памяти;
2) переменная, которая хранит адрес памяти.
Как правило, различие этих понятий непринципиально: передавая пере-
менную-указатель в функцию, вы передаете значение, которое хранится
в указателе, — адрес памяти.
Говоря о местоположении памяти, я называю его адресом памяти или про-
сто адресом. Если же речь о переменной, которая хранит адрес памяти,
я называю ее указателем.
Когда переменная хранит адрес другой переменной, я говорю, что первая
переменная указывает на вторую переменную.

Структура памяти
Откуда именно берется память? Почему вы всегда должны запрашивать
ее у операционной системы?
В Excel есть одна большая группа ячеек, к которым у вас есть доступ.
В памяти компьютера тоже есть большой объем доступного пространства,
однако память имеет иную структуру. Некоторые фрагменты памяти, до-
ступной программе, уже программой используются. Один из фрагментов
хранит переменные, объявленные в функциях, выполняемых в текущий
момент: он называется стеком. Его название обусловлено тем, что при вы-
зове нескольких функций их локальные переменные складываются (stack
up) друг над другом внутри этого фрагмента памяти. Все переменные,
с которыми мы работали до текущего момента, хранились в стеке.
Следующий фрагмент памяти — свободное хранилище, иногда называемое
кучей: оно представляет собой невыделенную память, которую можно за-
прашивать по частям. Этот фрагмент управляется операционной системой:
предоставленная область памяти должна использоваться только кодом,
который ее выделил, или кодом, которому распределитель памяти сообщил
ее адрес. Доступ к этой памяти можно получить с помощью указателей.
Это мощная возможность, однако она накладывает серьезную ответ-
ственность. Память — дефицитный ресурс. Разумеется, ее нехватка не
ощущается столь же остро, как во времена, когда ее объем не измерялся
гигабайтами, однако количество памяти до сих пор ограничено. Про-
грамма должна освобождать элемент памяти, выделенный в свободном
Что такое память    163

хранилище, когда он уже не нужен. Часть кода, которая отвечает за осво-


бождение определенного фрагмента памяти, называется владельцем этой
памяти. Когда память перестает быть нужной (например, при уничтожении
космического корабля в компьютерной игре), владелец должен вернуть
ее в свободное хранилище. Если не возвращать выделенные фрагменты,
программа постепенно исчерпает всю доступную память, что приведет
к замедлению работы или даже аварийному завершению. Возможно, вы
слышали, как люди жалуются, что браузер Firefox потребляет очень много
памяти и крайне медленно работает (или сами обращали на это внимание).
Это связано с тем, что выделенная память не возвращается; такой процесс
часто называют утечкой памяти1.
Концепция владельца памяти является частью интерфейса между функци-
ей и пользователями, а не входит в состав языка. Когда вы пишете функцию,
принимающую указатель, сообщайте, является ли она владельцем памяти.
Язык C++ не отслеживает этот аспект и освобождает память, которую вы
явным образом запросили, только если вы сами об этом позаботитесь.
Поскольку конкретная область памяти должна использоваться конкрет-
ным кодом, произвольно запрашивать память нельзя: представьте, что
произойдет, если генерировать случайное число и воспользоваться им как
адресом памяти! Технически это можно сделать, однако такую идею никто
не назовет хорошей. Неизвестно, кто использует эту память, возможно, она
относится к стеку. Если вы измените ее, то уничтожите данные, которыми
кто-то пользуется! Чтобы предотвратить такие ситуации, операционная
система заботится о памяти, которая вам не выделена. Доступ к ней за-
прещен; попытка воспользоваться этой памятью приводит к аварийному
завершению программы, что позволяет обнаружить проблему2.

В защиту Firefox скажу, что некоторые жалобы, по всей видимости, связаны


1

с некачественными расширениями (дополнительными модулями, которые


были написаны пользователями), а не с базовым кодом браузера. Тем не менее
это не влияет на конечный результат: нехватка памяти приводит к серьезным
последствиям!
Кстати, со случайной генерацией адресов памяти есть еще одна небольшая
2

проблема. Как правило, адреса памяти должны надлежащим образом «вы-


равниваться». Чтобы получить доступ к целому числу, используйте адреса,
кратные четырем (4, 8, 12, 16 и др.). Если вы генерируете случайный адрес,
его придется подгонять. Требования к «выравниванию» памяти зависят от
архитектуры, однако само их существование в первую очередь объясняется
соображениями производительности.
164    Глава 12. Введение в указатели

Помните, я говорил, что аварийное завершение программы полезно? Это не


шутка: диагностировать крах программы из-за проблем с доступом к памя-
ти существенно легче, чем ошибки, обусловленные записью некорректных
данных в доступную память. Как правило, аварийные завершения программ
обнаруживаются довольно быстро. Если вы внесете изменения в память,
владельцем которой вы не являетесь, ошибка не проявится, пока код, вла-
деющий этой памятью, не попытается ею воспользоваться. Это может про-
изойти гораздо позже недопустимой операции записи в память. Один мой
коллега любил объяснять этот принцип фразой: «Если только что отвалилось
колесо, гайка слетела милей раньше». Желаю удачи в поиске этой гайки!
Тем не менее найдутся люди, которые скажут, что аварии, вызванные не-
допустимыми операциями с памятью, очень сложно диагностировать. Это
люди, которые не читали эту книгу. В главе 20 «Отладка в Code::Blocks»
мы поговорим о том, как можно почти мгновенно отладить аварийное за-
вершение программы из-за ошибок памяти.

z z Некорректные указатели
Одна из возможных причин некорректного доступа к памяти — неиници-
ализированный указатель. Только что объявленный указатель содержит
случайные данные. Он может указывать как на доступную, так и на недо-
ступную память, однако пользоваться им в любом случае весьма опасно.
На самом деле его значение почти так же случайно, как результат работы
генератора случайных чисел. Использование его значения приведет к краху
программы или к повреждению данных. Указатели всегда следует иници-
ализировать до использования.

z z Память и массивы
Помните, я говорил, что запись данных за пределами массива вызывает про-
блемы? Теперь, зная о памяти больше, вы понимаете, какие это проблемы.
С массивом связано некоторое количество памяти, зависящее от его размера.
Пытаясь получить доступ к элементу за пределами массива, вы обратитесь
к памяти, которая с ним не связана: ее содержимое зависит от конкретного
кода и реализации компилятора. В любом случае, использование памяти,
которая не является частью массива, почти наверняка вызовет проблемы.

Другие преимущества и недостатки указателей


Теперь, зная о нюансах указателей, вернемся к нашей предыдущей аналогии
и рассмотрим несколько компромиссов, связанных с указателями. У ги-
перссылок и указателей много одинаковых преимуществ и недостатков.
Другие преимущества и недостатки указателей    165

1. Не нужно копировать данные: это может быть затруднительно, если


веб-страница сложна или имеет большой размер (представьте, что вы
отправляете кому-нибудь копию Википедии!). Аналогично, данные
в памяти могут иметь сложную структуру, и их трудно копировать без
ошибок (подробнее об этом позже), или копирование требует много
времени (если объем памяти велик).
2. Не нужно заботиться об отправке самой свежей версии страницы. Если
автор страницы обновит ее, вы получите обновления, как только заново
откроете ссылку. Если есть указатель на память, вы всегда можете счи-
тать самое новое значение, которое хранится по этому адресу.
Разумеется, у ссылок есть и недостатки.
1. Страница может быть перемещена или удалена. Аналогично, память
может быть возвращена операционной системе, даже если указатель
на нее существует. Чтобы избежать этих проблем, код, который владеет
памятью, должен отслеживать, используется ли она кем-либо.
2. Для доступа к ссылке вы должны быть подключены к Интернету. Это
напрямую не затрагивает указатели.
Сравнение указателя с веб-ссылкой помогает понять, для чего нужны ука-
затели, однако в этой аналогии есть и некоторые изъяны. Одна из проблем
заключается в том, что гиперссылки и веб-страницы — не одно и то же,
в отличие от указателей и переменных. Что я имею в виду? Указатель — это
всего лишь тип переменной (хотя и особый), а гиперссылка совершенно
не является веб-страницей. С другой стороны, указатель отличается от
переменных других типов так же, как гиперссылка от веб-страницы.
Понимаете ли вы, о чем речь? Я обещал, что главы об указателях будут
короткими, чтобы дать вашему мозгу возможность отдохнуть. На этом
данная глава заканчивается; поскольку у вас уже есть основные пред-
ставления об указателях, в следующей главе мы рассмотрим технические
нюансы их использования.

Проверьте себя
1. Что из нижеперечисленного НЕ является веской причиной для ис-
пользования указателей?
А. Вы хотите, чтобы функция изменяла переданный ей аргумент.
Б. Вы хотите сэкономить место, избегая копирования переменной
большого размера.
166    Глава 12. Введение в указатели

В. Вы хотите иметь возможность запрашивать у операционной системы


дополнительную память.
Г. Вы хотите быстрее получать доступ к переменным.
2. Что хранит указатель?
А. Имя другой переменной.
Б. Целое значение.
В. Адрес другой переменной в памяти.
Г. Адрес в памяти, необязательно относящийся к другой переменной.
3. Как получить дополнительную память в процессе выполнения про-
граммы?
А. Вы не можете получить дополнительную память.
Б. В стеке.
В. В свободном хранилище.
Г. Объявив другую переменную.
4. Какую ошибку можно допустить при использовании указателей?
А. Вы можете попытаться воспользоваться памятью, доступ к которой
запрещен, что приведет к аварийному завершению программы.
Б. Вы можете получить доступ к некорректному адресу памяти, что
приведет к повреждению данных.
В. Вы можете забыть вернуть память операционной системе, что при-
ведет к исчерпанию памяти.
Г. Вы можете допустить все вышеперечисленные ошибки.
5. Где выделяется память для обычной переменной, объявленной в функ-
ции?
А. В свободном хранилище.
Б. В стеке.
В. Обычные переменные не используют память.
Г. В двоичном файле программы (именно поэтому exe-файлы такие
большие!).
6. Что необходимо сделать с выделенной памятью?
А. Ничего, ее можно использовать бесконечно.
Б. Вернуть ее операционной системе по окончании использования.
В. Задать значение, на которое указывает указатель, равным нулю.
Г. Присвоить указателю нулевое значение.
(Решения см. на с. 458.)
Практические задания    167

Практические задания
1. Возьмите небольшую написанную вами программу (например, ка-
кую-нибудь практическую задачу из предыдущих глав этой книги).
Найдите в ней все переменные и представьте себе память, которая свя-
зана с каждой из них. Нарисуйте схему с прямоугольниками, аналогич-
ную представленной в этой главе и демонстрирующую связь перемен-
ных и памяти. Подумайте, как можно представить последовательность
переменных, которые не входят в состав одного массива, но занимают
смежные области в памяти.
2. Подумайте, сколько областей памяти необходимо следующей про-
грамме:
int main ()
{
int i;
int votes[ 10 ];
}

Можете ли вы сделать какие-либо утверждения о расположении в памяти


переменных votes[ 0 ], votes[ 9 ] и i? (подсказка: вы можете не знать,
где находится переменная i, но вы точно знаете, где ее нет). Нарисуйте
возможные конфигурации памяти для этой программы.
Г лава 1 3
Указатели

Теперь, зная, как устроена память и как ее представлять, мы можем на-


учиться писать код, который ее использует. В этой главе вы узнаете
о синтаксисе работы с указателями, который проиллюстрируем много-
численными схемами. Мы рассмотрим несколько базовых примеров ис-
пользования указателей в реальных программах. Не будем углубляться
в нюансы доступа к памяти свободного хранилища, поскольку это тема
следующей главы, тем не менее в нашем распоряжении будут все соот-
ветствующие инструменты.

Синтаксис указателей

Объявление указателя
В языке C++ имеется специальный синтаксис, который определяет, что
переменная является указателем, и описывает тип памяти, на которую
она указывает.
Объявление указателя выглядит следующим образом:
<тип> *<имя_указателя>;

Например, можно объявить указатель, который хранит адрес целого числа,


с помощью синтаксиса:
int *p_points_to_integer;
Определение адреса переменной    169

Обратите внимание на использование символа *. Это ключ к объявлению


указателя; если добавить его перед именем переменной, она будет объяв-
лена как указатель. Пробел можно поставить с любой стороны от знака *.
Следующие два объявления эквивалентны:
int *p_points_to_integer;

и
int* p_points_to_integer;

Префикс p_ необязателен, но я всегда использую его, чтобы выделить пере-


менные-указатели. Небольшой нюанс: объявляя несколько указателей на
одной строке, звездочку следует ставить перед именем каждой переменной:

// один указатель, одно обычное целое


int *p_pointer1, nonpointer1;
// два указателя
int *p_pointer1, *p_pointer2;

Вы спросите: почему нельзя объявить указатель проще — в виде p_pointer?


Чтобы использовать адрес памяти, компилятор должен знать, какие данные
находятся по этому адресу; в противном случае он не сможет корректно
их интерпретировать (например, одни и те же байты имеют разный смысл
в данных типа double и int). Вместо того чтобы пользоваться отдельными
названиями для указателей на каждый тип данных (int_ptr, char_ptr и др.),
указатель всегда объявляется с помощью знака * и имени типа данных.

Определение адреса переменной


Хотя мы можем использовать указатели для доступа к новой памяти, нач-
нем с того, что заставим указатели работать с существующими перемен-
ными. Чтобы определить адрес (местоположение) переменной в памяти,
укажите знак & перед именем переменной. & называется оператором адреса,
поскольку возвращает адрес переменной в памяти:

int x;
int *p_x = & x;
*p_x = 2; // начальное значение x равно 2

Слова «амперсанд» и «адрес» начинаются с буквы «а», это позволяет легко


запомнить, что для определения адреса переменной используется символ &.
Использовать амперсанд — то же, что узнавать URL веб-сайта, глядя на
адресную строку браузера, а не на содержание страницы.
170    Глава 13. Указатели

Определять местоположение переменной в памяти имеет смысл, только


если надо сделать с ней нечто особенное. Как правило, достаточно знать
лишь значение переменной.

Использование указателя
Использование указателя требует нового синтаксиса, потому что вы долж-
ны выполнять два разных действия:
1) запрашивать адрес памяти, хранимый в указателе;
2) запрашивать значение, хранимое по этому адресу.
Используя указатель как обычную переменную, вы получаете адрес памяти,
который хранится в указателе.
Следующий фрагмент кода выводит адрес переменной x, на которую ука-
зывает (адрес которой хранит) p_pointer_to_integer:
int x = 5;
int *p_pointer_to_integer = & x;
cout << p_pointer_to_integer; // выводит адрес x
// это эквивалентно cout << & x;

Чтобы получить значение, которое хранится по этому адресу памяти, ис-


пользуется символ *. Ниже приведен небольшой пример, в котором ини-
циализируется указатель, указывающий на другую переменную:
int x = 5;
int *p_pointer_to_integer = & x;
cout << *p_pointer_to_integer; // выводит 5
// это эквивалентно cout << x;

Код *p_pointer_to_integer говорит: «Следуй за указателем и считай значе-


ние, хранящееся в памяти, на которую он указывает». Поскольку в данном
случае p_pointer_to_integer указывает на переменную x, а значение x
равно 5, программа выводит значение 5.
Чтобы легко запомнить, что для получения значения переменной, на кото-
рую указывает указатель, используется знак *, запомните, что переменная-
указатель похожа на обычную переменную: вы получаете ее значение по
ее имени. Значением указателя является адрес памяти. Чтобы выполнить
необычное и нетипичное действие — считать значение, хранящееся по
этому адресу, необходимо воспользоваться специальным синтаксисом.
Считайте, что символ * отмечает особое поведение программы так же, как
звездочка, которая отображается рядом с рекордным хоум-раном бейсбо-
листа Барри Бондса.
Определение адреса переменной    171

Получение значения по адресу, на который указывает указатель, с помощью


символа *, называется разыменованием указателя. Разыменование озна-
чает преобразование имени указателя в значение, на которое он указывает.
Разыменование переменной позволяет задать значение, хранящееся по
адресу памяти.
int x;
int *p_pointer_to_integer = & x;
*p_pointer_to_integer = 5; // теперь x равен 5!
cout << x;

Иногда нелегко определить, нужно ли добавлять звездочку или амперсанд


к имени переменной. В этом поможет следующая таблица.

Действие Требуемая Пример


пунктуация

Объявление указателя * int *p_x;


Получение адреса, хранящегося Ничего cout << p_x;
в указателе
Задание адреса, хранящегося Ничего int *p_x; p_x =
в указателе /*адрес*/;
Получение значения, хранящегося * cout << *p_x;
по этому адресу
Задание нового значения по этому адресу * *p_x = 5;
Объявление переменной Ничего int y;
Получение значения, хранящегося Ничего int y; cout << y;
в переменной
Задание значения, хранящегося в пере- Ничего int y; y = 5
менной
Получение адреса переменной & int y; int *p_x;
p_x = & y;
Задание адреса переменной Невозможно Невозможно — переменная
не может менять адрес

• Указатель хранит адрес, поэтому, используя сам указатель, вы работа-


ете с адресом. Чтобы получить или изменить значение, хранящееся по
этому адресу, к имени указателя нужно добавить звездочку.
• Переменная хранит значение, поэтому, используя переменную, вы ра-
ботаете с ее значением. Чтобы получить адрес переменной, к ее имени
нужно добавить амперсанд.
172    Глава 13. Указатели

Рассмотрим программу, иллюстрирующую описанные возможности, и по-


знакомимся с полезным методом анализа происходящего в памяти.

Пример 34: pointer.cpp


#include <iostream>
using namespace std;
int main()
{
int x; // обычное целое
int *p_int; // указатель на целое
p_int = & x; // считываем его, "присваиваем адрес x
// переменной p_int"
cout << "Please enter a number: ";
cin >> x; // помещаем значение в переменную x; здесь можно
// воспользоваться *p_int
cout << *p_int << '\n'; // обратите внимание на использование *
// чтобы считать значение
*p_int = 10;
cout << x; // снова выводит число 10!
}

Первый оператор cout выводит значение, которое хранится в переменной x.


Почему? Пошагово изучим программу и определим, как она модифици-
рует память. Отметим стрелками области памяти, на которые указывает
указатель, и поместим в память значения переменных, не являющихся
указателями.
Начнем с целого числа x и указателя на целое число p_int. Можно считать,
что есть две переменные (возможно, расположенные рядом друг с другом),
значения которых неизвестны.

Далее код программы сохраняет в переменной p_int адрес переменной x,


который определяется оператором взятия адреса (&).

p_int = & x; // считываем его, "присваиваем адрес x


// переменной p_int"

Теперь можно соединить переменные p_int и x линией, которая демон-


стрирует, что p_int указывает на x.
Определение адреса переменной    173

Затем пользователь вводит число, которое сохраняется в переменной x по


адресу, на который указывает p_int.

cin >> x; // помещаем значение в переменную x; здесь можно


// воспользоваться *p_int

Представим, что пользователь ввел число 5. Ситуацию изобразим так:

Следующая строка передает указатель*p_int объекту cout. Конструкция


*p_int разыменовывает указатель p_int, она определяет адрес, который
хранится в p_int, переходит по нему и возвращает значение. Можно счи-
тать, что программа переходит по стрелке, изображенной на схеме памяти.

cout << *p_int << '\n'; // обратите внимание на использование * для


// того, чтобы считать значение

Последние две строки показывают, что изменение указателя приводит к из-


менению исходной переменной. Эта строка сохраняет число 10 в памяти, на
которую указывает указатель p_int (в этой же памяти хранится значение x):
*p_int = 10;

Теперь память находится в следующем состоянии:


174    Глава 13. Указатели

Надеюсь, схемы со стрелками и прямоугольниками помогли разобраться


в работе с указателями. Если вы не понимаете, что происходит с памятью
программы, сначала изобразите начальное состояние ее памяти, а затем
пошагово пройдите по программе и нанесите на схему изменения памяти.
Если изменяется местоположение, на которое указывает указатель, чертите
новую линию; если изменяется значение переменной, указывайте его на
схеме. Это позволит понять, как ведут себя даже сложные системы.

Неинициализированные указатели и NULL


Обратите внимание, что в приведенном примере определенный адрес памя-
ти присваивается указателю p_int раньше, чем этот указатель впервые ис-
пользуется. Неинициализированный указатель может указывать на любую
ячейку памяти, а его использование способно привести к нежелательным
последствиям: перезаписи памяти, принадлежащей другой переменной,
или аварийному завершению программы. Чтобы избежать этих и других
неприятностей, всегда инициализируйте указатели до использования.
Тем не менее иногда необходимо явно пометить неинициализированный
указатель. Для этого в C++ существует значение NULL. Если указатель
указывает на NULL (хранит значение NULL), значит он не инициализирован.
Создавая новый указатель, сначала присвойте ему значение NULL, так чтобы
всегда иметь возможность проверить корректность указателя. В против-
ном случае некорректный указатель может спровоцировать аварийное
завершение программы.

int *p_int = NULL;


// код, который задает или не задает значение p_int
if(p_int != NULL)
{
*p_int = 2;
}

Указатель NULL можно добавить в схему памяти, просто написав, что его
значение равно NULL, а не нарисовав стрелку, указывающую на NULL:
Указатели и функции    175

Указатели и функции
Указатели позволяют передавать в функцию адрес локальной переменной,
а затем изменять ее. Стандартный пример, иллюстрирующий этот меха-
низм, — функция, меняющая местами значения двух переменных.

Пример 35: swap.cpp


#include <iostream>
using namespace std;
void swap1(int left, int right)
{
int temp = left;
left = right;
right = temp;
}
void swap2(int *p_left, int *p_right)
{
int temp = *p_left;
*p_left = *p_right;
*p_right = temp;
}
int main()
{
int x = 1, y = 2;
swap1(x, y);
cout << x << " " << y << '\n';
swap2(& x, & y);
cout << x << " " << y << '\n';
}

Подумайте, какая из функций корректно меняет местами два значения.


Функция swap1 меняет местами значения локальных переменных функции
swap; swap1 не может изменить переданные ей значения, поскольку они
лишь хранят копии исходных значений (значений, хранимых в переменных
x и y). Вызов функции копирует значения x и y в переменные left и right:

Затем значение переменной left присваивается переменной temp, а зна-


чение переменной left — переменной right:
176    Глава 13. Указатели

Наконец, значение temp присваивается right, в результате переменные left


и right меняются местами, при этом x и y сохраняют прежние значения:

Интереснее другое: функция swap2 принимает адреса локальных перемен-


ных x и y; переменные p_left и p_right теперь указывают на x и y:

Теперь функция имеет доступ к памяти, хранящей значения этих двух


переменных, поэтому при перестановке этих значений происходит запись
в память переменных x и y. Сначала функция копирует значение, на ко-
торое указывает p_left, в переменную temp, а затем значение, на которое
указывает p_right, — в переменную p_left:

Обратите внимание, что память, хранящая значение переменной x, на этот


раз была изменена.
Наконец, значение переменной temp присваивается памяти, на которую
указывает p_right, и перестановка завершается:
Ссылки    177

Способность менять местами значения двух переменных не является глав-


ной ценностью указателей, тем не менее в языке C++ есть средство, которое
позволяет с легкостью писать подобные функции обмена с помощью не
указателей, а ссылок.

Ссылки
Иногда нужны лишь некоторые возможности указателей (исключение
копирования больших объемов данных), а не вся их мощь. В таких ситуа-
циях можно воспользоваться ссылками. Ссылка — это переменная, которая
указывает на другую переменную, хранящуюся в той же памяти. Ссылки
используются как и обычные переменные. Ссылку можно представить как
упрощенный указатель, который не требует использования звездочки или
амперсанда для доступа к значению, на которое указывает, и присваивания
ссылке значения. В отличие от указателя, ссылка должна всегда указывать
на доступную память. Ссылки объявляются при помощи амперсанда:
int &ref;

Однако такое объявление некорректно, поскольку ссылки всегда должны


быть инициализированы (то есть ссылаться на доступный адрес).
int x = 5;
// обратите внимание, что амперсанд перед x не нужен!
int &ref = x;

Ссылку можно изобразить и как указатель; разница в том, что при исполь-
зовании ссылки вы получаете значение, хранящееся в памяти, на которую
она ссылается, а не адрес этой памяти:
int x = 5;
int &ref = x;
178    Глава 13. Указатели

Здесь в памяти переменной ref хранится указатель на память переменной x.


Компилятору известно, что при записи в переменную ref нужно факти-
ческое значение, на которое она указывает. В некотором смысле ссылки
являются указателями с противоположным поведением «по умолчанию»
в ситуации, когда используется имя переменной.
С помощью ссылок можно передавать структуры в функции, не передавая
всю структуру и не заботясь об указателях NULL.
struct myBigStruct
{
int x[100]; // большая структура, занимающая много памяти!
};
void takeStruct (myBigStruct& my_struct)
{
my_struct.x[0] = 23;
}

Поскольку ссылка всегда указывает на исходный объект, вы обходитесь


без копирования и в то же время можете изменять исходный объект, пере-
данный в функцию. Приведенный выше пример демонстрирует присвоение
значения переменной my_struct.x[0]: после возврата из функции исходная
структура, ей переданная, содержит значение 23.
Мы видели пример функции, меняющей местами значения переменных
при помощи указателей, а теперь рассмотрим еще более простой способ
решения этой задачи с использованием ссылок:
void swap (int& left, int& right)
{
int temp = right;
right = left;
left = temp;
}

Обратите внимание, что эти действия существенно проще манипуляций


с указателями. Можно считать, что ссылка на исходную переменную является
ее дублером. Разумеется, для хранения ссылок компилятор использует ука-
затели, однако при считывании данных разыменование выполняется без вас.

Сравнение ссылок и указателей


Ссылки заменяют указатели, если к переменной нужно обращаться
с использованием нескольких имен (аналогично тому, как вы передаете
аргументы в функцию, не копируя их) и если функция должна иметь воз-
можность изменять параметры, переданные вызывающим окружением.
Ссылки    179

Ссылки не обладают такой же гибкостью, как указатели, поскольку они


всегда должны быть действительными. Нельзя задать неинициализиро-
ванную ссылку при помощи значения NULL, ссылки предназначены для
других целей. Поскольку ссылки не могут иметь значение NULL, с их по-
мощью нельзя создавать сложные структуры данных. Создание структур
данных будет подробно рассмотрено в нескольких следующих главах.
Выполняя действие, всегда задавайте себе вопрос, можно ли сделать то
же, используя ссылку.
Еще одна отличительная особенность ссылки в том, что после ее инициа-
лизации нельзя изменить память, на которую она указывает. Ссылка по-
стоянно указывает на одну и ту же переменную, что также ограничивает
возможность ее использования в сложных структурах данных.
Далее в книге я буду пользоваться ссылками там, где это требуется. С их
помощью я почти всегда буду передавать в функцию в качестве аргумента
экземпляры структур (а впоследствии и классов) следующим образом:
void (myStructType& arg);

Проверьте себя
1. Что из нижеперечисленного является корректным объявлением ука-
зателя?
А. int x;
Б. int &x;
В. ptr x;
Г. int *x;
2. Что из нижеперечисленного является адресом памяти целочисленной
переменной a?
А. *a;
Б. a;
В. &a;
Г. address(a);
3. Что из нижеперечисленного является адресом памяти переменной, на
которую указывает указатель p_a?
А. p_a;
Б. *p_a;
В. &p_a;
Г. address(p_a);
180    Глава 13. Указатели

4. Что из нижеперечисленного является значением, хранимым по адресу,


на который указывает указатель p_a?
А. p_a;
Б. val(p_a);
В. *p_a;
Г. &p_a;
5. Что из нижеперечисленного является корректным объявлением ссыл-
ки?
А. int *p_int;
Б. int &my_ref;
В. int &my_ref = & my_orig_val;
Г. int &my_ref = my_orig_val;
6. Для чего НЕ следует использовать ссылку?
А. Для хранения адреса, динамически выделенного в свободном хра-
нилище.
Б. Для исключения копирования большого значения при его передаче
в функцию.
В. Для того чтобы значение параметра, передаваемого в функцию,
никогда не равнялось NULL.
Г. Для доступа функции к исходному значению переданной ей пере-
менной без использования указателей.
(Решения см. на с. 459.)

Практические задания
1. Напишите функцию, которая запрашивает у пользователя его имя и фа-
милию в виде двух отдельных значений. Функция должна возвращать
оба значения вызывающему окружению при помощи дополнительного
параметра — указателя или ссылки, передаваемой в функцию. Решите
задачу, используя сначала указатель, а затем ссылку (подсказка: про-
тотип этой функции похож на прототип функции swap из приведенного
ранее примера).
2. Для функции, написанной в упражнении 1, нарисуйте схему, аналогич-
ную иллюстрирующей функцию swap.
3. Измените программу, написанную в упражнении 1, так, чтобы она за-
прашивала у пользователя фамилию только при условии, что вызыва-
ющее окружение передает в качестве фамилии указатель NULL.
Практические задания    181

4. Напишите функцию, которая принимает два входных аргумента и пере-


дает вызывающему окружению два результата, первый из которых
является произведением аргументов, а второй — их суммой. Поскольку
из функции можно непосредственно вернуть только одно значение,
придется вернуть второе значение при помощи второго параметра,
являющегося указателем или ссылкой.
5. Напишите программу, которая сравнивает адреса памяти двух раз-
личных переменных стека и выводит переменные в порядке роста их
адресов. Удивляет ли вас порядок расположения переменных в памяти?
Г лава 1 4
Динамическое выделение памяти

Справляясь с несколькими последними главами, вы делаете большие


успехи! Займемся самой интересной темой, касающейся указателей, — их
использованием в решении реальных задач. Я уже намекал, что скоро мы
узнаем, как в процессе работы программы получить в свое распоряжение
любое количество памяти. При желании мы даже можем забрать всю па-
мять, хотя вряд ли стоит это делать.

Выделение дополнительной памяти


с помощью оператора new
Под динамическим выделением памяти понимается использование про-
граммой такого количества памяти, которое необходимо ей в каждый
момент времени. Ваша программа будет вычислять требуемое количество
памяти, а не использовать фиксированный набор переменных с конкрет-
ными размерами. В этом разделе речь пойдет о том, как выделять память,
а следующие разделы посвящены максимально полному использованию
преимуществ динамической памяти.
Для начала мы узнаем, как получить дополнительную память. Инициализа-
ция указателей памятью из свободного хранилища выполняется с помощью
ключевого слова new. Помните, что свободное хранилище представляет со-
бой фрагмент неиспользуемой памяти, к которому ваша программа может
запросить доступ. Базовый синтаксис запроса имеет вид
int *p_int = new int;
Выделение дополнительной памяти с помощью оператора new    183

Оператор new принимает образец переменной, по которому вычисляет


требуемый размер памяти. В данном случае он принимает целое число
и возвращает память, достаточную для хранения целого значения.
На эту память указывает переменная p_int, теперь p_int и код, который
ее использует, становятся ее владельцами. Другими словами, код, исполь-
зующий p_int, в какой-то момент должен вернуть эту память обратно
в свободное хранилище с помощью операции освобождения памяти. Пока
указатель p_int не будет освобожден, память, на которую он указывает,
будет помечена как используемая, и никакая другая программа не сможет
ею воспользоваться. Если выделять память, не освобождая, она закончится.
Возврат памяти в свободное хранилище осуществляется при помощи клю-
чевого слова delete. Операция delete освобождает память, выделенную
с помощью new. Освобождение p_int выполняется следующим образом:
delete p_int;

После удаления указателя рекомендуется снова присвоить ему значение NULL:


delete p_int;
p_int = NULL;

Это действие необязательно, но удаление указателя не позволяет читать


и писать в память, на которую он указывал, поскольку она возвращена
в свободное хранилище и, возможно, выделена снова. Если присвоить
указателю значение NULL, а код попытается разыменовать его после осво-
бождения (эту ошибку часто допускают даже опытные программисты),
программа сразу же аварийно завершится. Это гораздо лучше повреждения
или уничтожения данных пользователя.

Свободной памяти больше нет


Ресурсы памяти не бесконечны — ее можно исчерпать. В этом случае вы
не сможете получить дополнительную память. В языке C++ вызов new
генерирует исключение, если ему не удается получить память из-за ее
недостатка. В целом не нужно беспокоиться, что делать в этой ситуации:
в современных операционных системах она происходит настолько редко,
что многие программы игнорируют ее (такая ситуация совсем малове-
роятна, если программа хорошо написана и освобождает память надле-
жащим образом; если же программа не освобождает память, вероятность
ее исчерпания существенно выше). Исключения — более сложная тема,
которая рассматривается ближе к концу этой книги. Главное, что от вас
требуется, — всегда освобождать запрошенную память. Не уделяйте особого
внимания тому, что вызов new может завершиться неудачно.
184    Глава 14. Динамическое выделение памяти

Ссылки и динамическое выделение памяти


Не следует хранить память, только что выделенную по ссылке:
int &val = *(new int);

Дело в том, что ссылка не обеспечивает мгновенный доступ к содержимому


памяти по ее адресу. Можно считать это содержимое с помощью операто-
ра &, однако ссылка предназначена для создания дополнительного имени
переменной, а не для хранения динамически выделенной памяти.

Указатели и массивы
Возникает вопрос: как получить дополнительную память с помощью опера-
тора new после запуска программы, если он может лишь инициализировать
один указатель? Дело в том, что указатели могут указывать на последова-
тельность значений; другими словами, с указателем можно обращаться так
же, как с массивом. Массив, по сути, представляет собой набор значений,
которые последовательно расположены в памяти. Поскольку указатель
хранит адрес памяти, он может хранить и адрес первого элемента массива.
Чтобы получить доступ к конкретному элементу массива, нужно отсчитать
фиксированное расстояние от начала массива до интересующего значения.
Этот принцип полезен тем, что можно динамически создавать новые масси-
вы в свободном хранилище, определяя требуемый объем памяти в процессе
выполнения программы. Чуть позже я продемонстрирую это на примере,
а пока разъясню несколько ключевых аспектов.
Массив можно присвоить указателю, не используя оператор получения
адреса:
int numbers[8];
int* p_numbers = numbers;

Теперь p_numbers используется как массив:


for(int i = 0; i < 8; ++i)
{
p_numbers[i] = i;
}

Хотя p_numbers является всего лишь указателем, к нему можно применять


номера элементов массива. Важно понимать, что массивы и указатели — не
одно и то же, однако массивы можно присваивать указателям. Компилятор
C++ знает, как преобразовать массив в указатель на первый элемент мас-
сива. (Такое преобразование часто происходит в C++: например, можно
Указатели и массивы    185

присвоить значение переменной char переменной int. Типы char и int


различны, но компилятор знает, как выполнить преобразование.)
С помощью оператора new можно динамически выделить массив памяти
и присвоить эту память указателю:
int *p_numbers = new int[8];

Использование синтаксиса массивов в аргументе оператора new указывает


компилятору требуемое количество памяти (достаточное для размещения
8-элементного массива целых чисел). Теперь можно пользоваться указате-
лем p_numbers так, как будто он указывает на массив. Тем не менее работая
с p_numbers, нужно освободить память, на которую он указывает. Указатель
на статический массив, напротив, никогда не нужно освобождать. Для
освобождения памяти существует особый синтаксис оператора delete:
delete[] p_numbers;

Скобки показывают компилятору, что указатель указывает не на одно


значение, а на их массив.
Требуемое количество памяти динамически определяется так:
int count_of_numbers;
cin >> count_of_numbers;
int *p_numbers = new int[count_of_numbers];

Данный код запрашивает у пользователя нужное ему количество чисел,


а затем с помощью этой переменной определяет размер динамически вы-
деляемого массива. На самом деле необязательно заранее знать точное
количество элементов: если оно увеличится, мы выделим для них допол-
нительную память. Для этого потребуются дополнительные операции
копирования, однако это возможно. Рассмотрим программу, которая
демонстрирует описанный метод. Она считывает числа, вводимые пользо-
вателем, и если пользователь вводит больше чисел, чем способен вместить
массив, мы расширяем его.

Пример 36: resize_array.cpp


#include <iostream>
using namespace std;
int *growArray (int* p_values, int *size);
void printArray(
int* p_values,
int size,
int elements_set
);
продолжение 
186    Глава 14. Динамическое выделение памяти

int main()
{
int next_element = 0;
int size = 10;
int *p_values = new int[size];
int val;
cout << "Please enter a number: ";
cin >> val;
while (val > 0)
{
if(size == next_element + 1)
{
// Теперь нужно реализовать функцию growArray.
// Обратите внимание: мы должны передать
// размер в виде указателя, поскольку необходимо
// следить за размером массива при его
// расширении!
p_values = growArray(p_values, & size);
}
p_values[ next_element ] = val;
next_element++;
cout << "Current array values are: " << endl;
printArray( p_values, size, next_element );
cout << "Please enter a number (or 0 to exit):
";
cin >> val;
}
delete[] p_values;
}
void printArray (int *p_values, int size, int elements_set)
{
cout << "The total size of the array is: " << size
<< endl;
cout << "Number of slots set so far: "
<< elements_set << endl;
cout << "Values in the array: " << endl;
for(int i = 0; i < elements_set; ++i)
{
cout << "p_values[" << i << "] = "
<< p_values[i] << endl;
}
}

Теперь подумаем, как увеличить размер массива. Что для этого нужно
сделать? Нельзя просто попросить систему расширить выделенную нам
память по аналогии с созданием нового столбца в Excel, если требуется
дополнительное место в электронной таблице. Мы должны запросить до-
полнительную память и скопировать в нее имеющиеся данные.
Многомерные массивы    187

Еще один вопрос касается количества запрашиваемой памяти. Добавлять


в массив по одному целому за раз неэффективно — потребуется много не-
нужных операций выделения памяти. Вы не исчерпаете свободную память
системы, но снизите быстродействие программы. Рекомендуется выделять
память так, чтобы текущий размер массива удваивался. Это, с одной сто-
роны, не приводит к простою большого количества выделенной памяти,
если мы перестаем считывать новые значения, а с другой — избавляет от
необходимости постоянно добавлять новую память. Разумеется, нужно
знать размер текущего массива и размер исходного, чтобы скопировать
из него значения.

Пример 37: resize_array.cpp (продолжение)


int *growArray (int* p_values, int *size)
{
*size *= 2;
int *p_new_values = new int[*size];
for(int i = 0; i < *size; ++i)
{
p_new_values[i] = p_values[i];
}
delete[] p_values;
return p_new_values;
}

Обратите внимание, что этот код аккуратно удаляет память p_values по


окончании копирования данных из исходного массива, в противном слу-
чае мы бы создали утечку памяти, переписав указатель, содержащий наш
массив, после возврата из функции growArray.

Многомерные массивы
Изменение размера одного большого массива — очень полезное действие,
однако иногда придется работать не только с единственным большим
массивом данных. Помните рассмотренную ранее концепцию многомер-
ных массивов? Было бы здорово выбирать размер таких массивов. Такая
возможность существует, и ее изучение не только принесет практическую
пользу, но послужит хорошим упражнением, чтобы тщательно разобраться
в указателях. Тем не менее чтобы освоить эту тему, понадобятся некоторые
дополнительные знания. Речь о них пойдет в следующих двух разделах
этой главы, после чего мы рассмотрим динамическое выделение много-
мерных структур данных.
188    Глава 14. Динамическое выделение памяти

Арифметика указателей
Этот раздел посвящен более глубокому изучению указателей, и потре-
буется напрячь воображение, чтобы освоить его непростые концепции.
Если не удастся понять этот раздел с первой попытки, прочтите его еще
раз. Поняв все его содержание, в том числе выделение памяти двумерным
массивам, вы сможете освоить любые аспекты указателей. Изложенный
материал несколько тяжел для восприятия, и практическая отдача от него
неочевидна, однако разобравшись в нем, вы быстрее изучите оставшуюся
часть книги (поверьте на слово).
Поговорим об адресах памяти и о том, как их представлять. Указатели хра-
нят адреса памяти, которые являются обычными числами. Над указателями,
как и над числами, можно выполнять некоторые математические операции,
например складывать указатель с числом или вычитать один указатель
из другого. Для чего? Прежде всего, чтобы записать блок памяти по из-
вестному смещению. Вы уже много раз делали это, работая с массивами.
Код
int x[10];
x[3] = 120;

выполняет арифметическую операцию над указателем, присваивая третьей


области памяти значение 120. Синтаксис с фигурными скобками всего
лишь упрощает операторы арифметических действий над указателями.
Ту же операцию может выполнить следующий код:
*(x + 3) = 120;

Что здесь происходит? Как ни удивительно, данный код увеличивает


значение x не на 3, а на 3 * sizeof(int). Ключевое слово sizeof означает
размер переменной указанного типа в байтах и часто используется при
работе с памятью. Арифметика указателей всегда складывает области
памяти, а не обращается с указателем как с числом (аналогично тому, как
скобки обеспечивают доступ к определенной области массива). Сложение
с шагом, равным размеру переменной, предотвращает случайное считыва-
ние и запись между значениями (например, двух последних байтов одной
области памяти и двух первых байтов следующей за ней области)1.

Можно вычесть один указатель из другого и вычислить расстояние между


1

ними. Это расстояние будет исчисляться количеством областей памяти, а не


количеством байт (по этой причине нельзя вычитать указатели разных ти-
пов — соответствующие им области памяти могут иметь разный размер). Тем
Арифметика указателей    189

В большинстве случаев следует пользоваться синтаксисом массивов, а не


пытаться корректно реализовать арифметику указателей. Второе очень
трудно сделать без ошибок, поскольку можно легко забыть, что вы скла-
дываете области памяти, а не отдельные байты. Тем не менее арифметика
указателей позволяет выполнить ряд трюков, и мы воспользуемся ею
в следующих главах книги. Кроме того, с ее помощью вы поймете, как
динамически выделять память для многомерных массивов.
Знакомство с двумерными массивами
Перед тем как мы начнем выделять память для многомерных массивов,
необходимо понять, как на самом деле устроен многомерный массив.
Хотя этот раздел так же сложен для понимания, приложите усилия к его
освоению — они обязательно окупятся.
Вернемся к упоминавшейся ранее необычной ситуации: объявляя функ-
цию, принимающую в качестве аргумента двумерный массив, необходимо
указывать не обе размерности массива, а только вторую.
Можно указать обе размерности:
int sumTwoDArray(int array[4][4]);

или одну из них:


int sumTwoDArray(int array[][4]);

но нельзя опустить обе размерности:


int sumTwoDArray(int array[][]);

или указать только первую из них:


int sumTwoDArray(int array[4][]);

Это объясняется тем, что для выполнения арифметических действий над


указателями требуется только вторая размерность массива! На самом деле
двумерные массивы имеют в памяти плоскую структуру. Компилятор
позволяет работать с ними как с квадратными блоками памяти, однако
двумерные массивы фактически представляют собой линейный набор
адресов. Компилятор преобразует операции доступа к массиву, например
array[3][2], в эти адреса памяти. Механизм преобразования можно легко
представить следующим образом. Массив размером 4 × 4 с пронумерован-
ными строками имеет вид:

не менее вычитание указателей используется редко. Складывать указатели


нельзя — можно лишь сложить указатель со смещением (обратите внимание
на разные типы результатов сложения и вычитания указателей).
190    Глава 14. Динамическое выделение памяти

[0][0][0][0]
[1][1][1][1]
[2][2][2][2]
[3][3][3][3]

Его фактическое расположение в памяти выглядит так:


[0][0][0][0][1][1][1][1][2][2][2][2][3][3][3][3]

Чтобы воспользоваться элементом array[3][2], компилятору нужно получить


доступ к памяти на три строки ниже и два столбца правее первого элемента
массива. Чтобы спуститься ниже на три строки, каждая из четырех целых
чисел, нужно пройти 4*3 областей памяти, а затем сдвинуться еще на две
области, чтобы попасть на третий элемент последней строки. Расположение
элемента array[3][2] вычисляется по следующей арифметике указателей:
*(array + 3 * <width of array> + 2)

Теперь ясно, что необходимо знать ширину массива: без нее расчет невоз-
можен. Второе измерение и представляет собой ширину массива.
Нельзя провести аналогичный расчет, используя высоту массива, так как
он не согласуется с физическим расположением массива в памяти (высоту
можно было бы использовать, если бы массив располагался в памяти ина-
че — по строкам). По этой причине можно передать в функцию аргумент,
являющийся двумерным массивом с переменной высотой, но его ширина
должна быть известна. На самом деле следует указывать все размерности
многомерного массива, кроме высоты. Одномерный массив можно считать
особым типом массива, единственным измерением которого является высота.
К сожалению, при объявлении двумерного массива необходимо задавать
его ширину, а следовательно, для динамического выделения двумерного
массива с произвольной шириной требуется еще одна возможность языка
C++ — указатели на указатели.

Указатели на указатели
Указатели могут указывать не только на обычные данные, но и на другие
указатели. Указатель — это всего лишь переменная с адресом, к которому
можно получить доступ.
Объявление указателя на указатель выглядит следующим образом:
int **p_p_x;

Переменная p_p_x указывает на адрес памяти, по которому хранится ука-


затель на целое число: я использую префикс p_p, чтобы показать, что этот
Арифметика указателей    191

указатель указывает на другой указатель. Это означает, что ему нужно


присвоить адрес памяти указателя:
int *p_y;
int **p_p_x;
p_p_x = & p_y;

Затем присвоить указатель переменной p_y, используя указатель p_p_x:


*p_p_x = new int;

Мы можем создать двумерный массив с помощью указателей на указатели


так же, как создаем одномерный массив произвольного размера с помощью
единственного указателя.
Представьте себе одномерный массив указателей, каждый из которых
указывает на второй одномерный массив. Проиллюстрируем это графи-
чески на примере поля для игры в крестики-нолики, которое мы создали
с помощью указателя на указатель:

Первый указатель указывает на набор указателей, каждый из которых


указывает на одну строку игрового поля. Следующий код создает эту
структуру данных:
int **p_p_tictactoe;
// обратите внимание: это int*, поскольку мы выделяем
// массив указателей
p_p_tictactoe = new int*[3];
// поместим в каждый указатель адрес
// массива целых чисел
for(int i = 0; i < 3; i++)
{
p_p_tictactoe[i] = new int[3];
}
192    Глава 14. Динамическое выделение памяти

Теперь можно воспользоваться выделенной памятью как двумерным мас-


сивом. Например, инициализируем все поле с помощью двух циклов for:
for(int i = 0; i < 3; i++)
{
for(int j = 0; j < 3; j++)
{
p_p_tictactoe[i][j] = 0;
}
}

Мы освобождаем память в порядке, обратном ее инициализации: сначала


каждый ряд, а затем указатель, указывающий на ряды:
for(int i = 0; i < 3; i++)
{
delete[] p_p_tictactoe[i];
}
delete[] p_p_tictactoe;

Как правило, этот подход не применяется, если требуемый размер памяти


известен (как в ситуации с полем для игры в крестики-нолики), гораздо
проще решить задачу следующим образом:
int tic_tac_toe_board[3][3];

Тем не менее чтобы создать поле произвольного размера, следует пользо-


ваться описанным выше подходом.

Указатели на указатели и двумерные массивы


Обратите внимание, что указатель на указатель, используемый для хра-
нения двумерного массива, структурирован в памяти иначе, нежели
двумерный массив. Стандартный двумерный массив занимает в памяти
непрерывное пространство, а массив, основанный на указателе, — нет!
Схема показывает, что каждая строка представляет собой отдельный набор
данных и может храниться вдалеке от другой памяти.
Это влияет на функции, которые принимают массив в качестве аргумента.
Как вы знаете, указателю можно присвоить массив:
int x[8];
int *y = x;

Тем не менее указателю на указатель нельзя присвоить двумерный массив:


НЕКОРРЕКТНЫЙ КОД
int x[8][8];
int **y = x; // не компилируется!
Заключение об указателях    193

В первом случае с массивом можно обращаться как с указателем на блок


памяти, содержащим все данные. Во втором случае массив представляет
собой лишь единственный указатель на блок памяти.
Главное следствие этого различия: нельзя передать указатель на указа-
тель в функцию, которая принимает многомерный массив (хотя передать
указатель в функцию, которая принимает одномерный массив, можно).
int sum_matrix(int values[][4], int num_rows)
{
int running_total = 0;
for(int i = 0; i < num_vals; i++)
{
for(intj = 0; j < 4; j++)
{
running_total += values[i][j];
}
}
return running_total;
}

Если выделить указатель на указатель и передать его этой функции, ком-


пилятор укажет на ошибку:
НЕКОРРЕКТНЫЙ КОД
int **x;
// выделяем массиву x 10 строк
sum_matrix(x, 10); // не компилируется

Если массив одномерный, оба действия используют определенное смеще-


ние от адреса указателя. Если массив двумерный, указатель на указатель
должен разыменовать два указателя: первый — для поиска нужной строки,
второй — для получения нужного значения в строке. При использовании
обычного массива доступ к нужному значению обеспечивает арифметика
указателей. Поскольку указатель на указатель не подчиняется этой ариф-
метике, компилятор не позволяет передать его в функцию как двумерный
массив, хотя написан практически тот же код!

Заключение об указателях
На первый взгляд тема указателей кажется очень запутанной, однако с ней
вполне можно справиться. Если не удалось понять указатели от начала
до конца, наберитесь терпения, прочитайте эту главу еще раз, пройдите
тест и решите практические задания. Необязательно разбираться во всех
тонкостях работы с указателями, но вы должны понимать синтаксис их
использования и инициализации и принципы выделения памяти.
194    Глава 14. Динамическое выделение памяти

Проверьте себя
1. Что из нижеперечисленного является ключевым словом, с помощью
которого в C++ выделяется память?
А. new
Б. malloc
В. create
Г. value
2. Что из нижеперечисленного является ключевым словом, с помощью
которого в C++ освобождается память?1
А. free
Б. delete
В. clear
Г. remove
3. Какое из перечисленных ниже утверждений верно?
А. Указатели и массивы — это одно и то же.
Б. Массивы нельзя присваивать указателям.
В. Указатели можно использовать как массивы, но указатели и масси-
вы — не одно и то же.
Г. Указатели можно использовать как массивы, но указатели нельзя
выделять так же, как массивы.
4. Каковы окончательные значения переменных x, p_int и p_p_int в сле-
дующем коде? (Поскольку целые числа и указатели — разные типы
данных, этот код не компилируется, однако данное упражнение полезно
выполнить на бумаге, чтобы разобраться, что происходит с нескольки-
ми указателями.)
int x = 0;
int *p_int = & x;
int **p_p_int = & p_int;
*p_int = 12;
**p_p_int = 25;
p_int = 12;
*p_p_int = 3;
p_p_int = 27;

А. x = 0, p_p_int = 27, p_int = 12.


Б. x = 25, p_p_int = 27, p_int = 12.

1
Если вы ответили malloc и free, то вы правы, поскольку это функции языка C,
однако эти ответы не относятся к теме данной главы.
Практические задания    195

В. x = 25, p_p_int = 27, p_int = 3.


Г. x = 3, p_p_int = 27, p_int = 12.
5. Как отметить указатель, который не указывает на корректный адрес
памяти?
А. Присвоить ему отрицательное значение.
Б. Присвоить ему значение NULL.
В. Освободить связанную с ним память.
Г. Присвоить ему значение false.
(Решения см. на с. 460.)

Практические задания
1. Напишите функцию, которая создает двумерную таблицу умножения
произвольных размеров.
2. Напишите функцию, которая принимает три аргумента, длину, ширину
и высоту, динамически выделяет трехмерный массив с этими размер-
ностями и заполняет его как таблицу умножения. По окончании работы
с массивом освободите занимаемую им память.
3. Напишите программу, которая выводит адреса памяти всех элементов
двумерного массива. Удостоверьтесь, что понимаете, почему выведен-
ные значения образуют последовательность, структуру которой я объ-
яснял ранее.
4. Напишите программу, которая позволяет пользователю отслеживать
время последних разговоров с его друзьями. Пользователи должны
иметь возможность добавлять новых друзей в любом количестве и хра-
нить число дней с момента последнего общения с каждым из друзей.
Предоставьте пользователю возможность обновлять это значение, но не
позволяйте ему вводить некорректные данные (например, отрицатель-
ные числа). Реализуйте возможность отображения списка, отсортиро-
ванного по именам друзей и времени, прошедшему с момента вашего
последнего контакта с ними.
5. Напишите версию игры Connect Four (четыре в ряд) для двух игроков1,
в которой пользователь может задать ширину и высоту поля и оба
игрока по очереди роняют фишки в ячейки. Отображайте одну сторо-
ну поля значками +, другую — значками x, а пустые ячейки помечайте
значками _.

https://ru.wikipedia.org/wiki/Четыре_в_ряд
1
196    Глава 14. Динамическое выделение памяти

6. Напишите программу, которая создает лабиринт с шириной и вы-


сотой, заданными пользователем. Лабиринт должен всегда содержать
путь к выходу (как это гарантировать?). После генерации выведите
лабиринт на экран.
Решая практические задачи, создавайте две версии программ, одна из ко-
торых использует указатели, другая — ссылки. Не забывайте освобождать
всю выделенную память.
Г лава 1 5
Введение в структуры данных
с использованием связанных
списков

В предыдущей главе вы узнали, как динамически создавать массивы, ис-


пользуя механизмы выделения памяти. В этой главе вы познакомитесь
с еще более гибкими способами динамического выделения памяти. Большое
количество доступной памяти позволяет хранить большие объемы инфор-
мации — вы уже умеете размещать в памяти различные типы данных. Воз-
никает вопрос: как быстро сохранять данные и впоследствии оперативно
получать к ним доступ? Именно об этом мы и поговорим.
Для начала введем несколько терминов. Структура данных — это способ
организации данных в памяти. Например, массив представляет собой
очень простую структуру данных, организующую память линейно. Каж-
дый элемент массива — элемент структуры данных. Двумерный массив,
реализованный с помощью указателей на указатели, — более сложная
структура данных.

Ценность сложных структур данных


Проблема в том, что нельзя записать данные в массив, в котором закончи-
лось свободное место. Как мы видели, придется начать с нуля: выделить
новый массив, а затем копировать в него все существующие элементы те-
кущего массива. Программисты говорят, что такая операция затратна: она
занимает много времени процессора. Затратные операции необязательно
198    Глава 15. Введение в структуры данных с использованием связанных списков

существенны для пользователя программы; если не злоупотреблять,


пользователь даже не узнает об их существовании, поскольку процессоры
компьютеров работают очень быстро. Тем не менее во многих случаях за-
тратные операции способны создать серьезные неприятности.
Вторая проблема массивов в том, что непросто вставлять данные между
существующими элементами массива. Например, чтобы поместить новый
элемент между элементами 1 и 2 массива из 1000 элементов, придется
сдвинуть все элементы со 2-го по 1000-й! Разумеется, эта операция весьма
затратна.
Когда вы раздражаетесь во время подвисания вашего компьютера, он вы-
полняет какую-то затратную операцию. Структуры данных обеспечивают
их эффективное хранение и избавляют пользователей от необходимости
ждать выполнения действий над ними.
Кроме того, структуры данных позволяют использовать более высоко-
уровневый подход к программированию: вместо создания циклов можно
создавать списки. Структуры данных логически организуют их и позволя-
ют быстро выполнять базовые операции программы: к примеру, в списках
можно хранить данные, эффективно добавлять и удалять их. По мере
знакомства с различными структурами данных вы начнете рассматривать
программы с точки зрения необходимых данных и их организации. На этом
мы закончим текущий экскурс в теорию и обратимся к связанным спискам.
Помните проблему, заключающуюся в том, что при добавлении в массив
новых элементов приходится копировать все его содержимое? (Речь о про-
блеме шла несколько абзацев назад, надеюсь, вы о ней не забыли.) Было
бы здорово иметь структуру данных, каждый элемент которой содержит
информацию о том, где найти ее следующий элемент. Чтобы добавить эле-
мент в конец такой структуры, достаточно сделать так, чтобы ее последний
элемент указывал на новый элемент. Чтобы вставить новый элемент между
двумя элементами этой структуры, достаточно направить на него указатель
одного из этих элементов. Вернемся к примеру с хранением информации
о вражеских кораблях. Вам требуется какой-либо список врагов, каждый
элемент которого представляет собой структуру с информацией о конкрет-
ном враге. (Этот список нужен, чтобы в каждом раунде игры выполнять
какое-либо действие, например заставлять врагов двигаться. Такой список
отличается от списка покупок в магазине или списка студентов. Иногда
списки необходимы для слежения за всеми имеющимися объектами —
в данном случае врагами.) Кроме того, нужно иметь возможность быстро
добавлять и удалять врагов. Что будет, если каждый враг располагает
информацией о следующем враге?
Ценность сложных структур данных    199

Допустим, элемент, соответствующий врагу, содержит координаты x и y


и мощности вооружения. Представим его визуально:

Здесь структура EnemySpaceShip связана со следующей структурой. Что


это за связь? Указатель! Каждый вражеский корабль содержит указатель
на следующий корабль:
struct EnemySpaceShip
{
int x_coordinate;
int y_coordinate;
int weapon_power;
EnemySpaceShip* p_next_enemy;
};

Стоп! Мы используем тип EnemySpaceShip внутри определения структу-


ры EnemySpaceShip; можно ли так делать? Да, можно! Язык С++ отлично
справляется с подобными ссылками на себя. Запись внутри структуры
EnemySpaceShip next_enemy;

вызовет проблему, поскольку структура окажется зацикленной: объявле-


ние одного корабля займет всю память системы. Тем не менее обратите
внимание, что мы используем указатель на EnemySpaceShip, а не сам тип
EnemySpaceShip. Поскольку указатели не обязаны указывать на доступ-
ную память, вы получаете не бесконечный список космических кораблей,
а лишь один корабль, который может указывать на другой корабль. Если
он действительно указывает на другой корабль, понадобится дополнитель-
ная память, в противном случае хранится только сам указатель, который
занимает всего лишь несколько байтов. Указатель может ссылаться на
доступную память и требует в памяти столько места, сколько необходимо
для хранения адреса. Объявляя тип EnemySpaceShip, вы требуете достаточ-
ного места для хранения полей x_coordinate, y_coordinate и weapon_power
и финального указателя. Необязательно хранить еще один космический
корабль (которому так же понадобился бы еще один космический корабль).
Всего лишь указатель.
200    Глава 15. Введение в структуры данных с использованием связанных списков

Напоследок приведу еще одну аналогию. Представьте себе поезд: каж-


дый его вагон оснащен соединительным элементом, с помощью которого
к нему можно прицепить другой вагон. Чтобы включить в состав новый
вагон, необходимо прицепить его к одному из текущих вагонов состава,
а остальную часть состава — к новому вагону. Сцепление вагона может не
использоваться, если вагон последний в составе: в этом случае оно анало-
гично указателю NULL.
Теперь, зная принцип создания подобных списков, можно ознакомиться
с синтаксисом и нюансами использования в них указателей.

Указатели и структуры
Для доступа к полям структуры через указатель вместо оператора . ис-
пользуется оператор ->:
p_my_struct->my_field;

Все поля структуры имеют разные адреса памяти; как правило, они рас-
полагаются в нескольких байтах от начала структуры. Синтаксис со стрел-
кой вычисляет смещение, необходимое для доступа к соответствующему
полю структуры, при этом все остальные свойства указателей сохраняются
(указатель указывает на область памяти, нельзя использовать недействи-
тельные указатели и др.). Синтаксис со стрелкой в точности эквивалентен
конструкции:
(*p_my_struct).my_field;

но гораздо удобнее для чтения (и написания!), если только привыкнуть


им пользоваться.
Если функция принимает указатель на структуру в качестве аргумента, она
может изменять память по адресу, связанному с этой структурой, что дает
ей возможность модифицировать переданную структуру. Этот механизм
работает точно так же, как при передаче массива в структуру. Рассмотрим,
как применить его к структуре EnemySpaceShip.

Пример 38: upgrade.cpp


// этот заголовочный файл нужен для использования NULL;
// обычно он включен в другие заголовочные файлы,
// однако здесь они не используются.
#include <cstddef>
struct EnemySpaceShip
{
int x_coordinate;
int y_coordinate;
Создание связанного списка    201

int weapon_power;
EnemySpaceShip* p_next_enemy;
};
EnemySpaceShip* getNewEnemy()
{
EnemySpaceShip* p_ship = new EnemySpaceShip;
p_ship->x_coordinate = 0;
p_ship->y_coordinate = 0;
p_ship->weapon_power = 20;
p_ship->p_next_enemy = NULL;
return p_ship;
}
void upgradeWeapons(EnemySpaceShip* p_ship)
{
p_ship->weapon_power += 10;
}
int main()
{
EnemySpaceShip* p_enemy = getNewEnemy();
upgradeWeapons(p_enemy);
}

В функции getNewEnemy мы используем оператор new, чтобы выделить


память для нового корабля. В функции upgradeWeapons мы можем непо-
средственно изменять p_ship, поскольку p_ship указывает на блок памяти
со всеми полями структуры.

Создание связанного списка


Теперь, зная синтаксис указателей на структуры, можно создать наши
списки. Список, который состоит из структур, содержащих указатель
на следующий элемент, называется связанным списком. Чтобы иденти-
фицировать такой список, необходимо каким-либо образом хранить его
начало. Вернемся к нашему примеру и опишем космический корабль, как
показано ниже:

struct EnemySpaceShip
{
int x_coordinate;
int y_coordinate;
int weapon_power;
EnemySpaceShip* p_next_enemy;
};
EnemySpaceShip* p_enemies = NULL;

Переменная p_enemies используется для хранения списка врагов: каждый


новый враг добавляется в этот список (с его помощью мы будем выполнять
202    Глава 15. Введение в структуры данных с использованием связанных списков

действия, общие для всех врагов). Эту переменную можно было бы на-
звать p_first или p_head, чтобы показать, что она первый элемент списка.
Вводя в игру нового врага, мы добавляем его в начало списка.
EnemySpaceShip* getNewEnemy ()
{
EnemySpaceShip* p_ship = new EnemySpaceShip;
p_ship->x_coordinate = 0;
p_ship->y_coordinate = 0;
p_ship->weapon_power = 20;
p_ship->p_next_enemy = p_enemies;
p_enemies = p_ship;
return p_ship;
}

Начальным значением переменной p_enemies является NULL (список пуст).


Когда появляется новый враг, мы обновляем соответствующий ему элемент
так, чтобы он указывал на бывший первый элемент списка, хранящийся
в переменной p_enemies, а затем присваиваем переменной p_enemies ука-
затель на нового врага. Мы фактически добавляем новый элемент в начало
списка, спуская все остальные элементы на одну позицию вниз. При этом
мы ничего не копируем, а всего лишь изменяем два указателя.
Возможно, этот алгоритм не совсем понятен, поэтому пройдем его поша-
гово и проиллюстрируем.

Первый проход
Начальным значением переменной p_enemies является NULL; другими
словами, в игре не участвует ни один враг (мы всегда будем использовать
значение NULL, чтобы показать, что находимся в конце списка).
1. Создается новый вражеский корабль p_ship. Теперь у нас есть враг
с именем SHIP 1, которого пока еще нет в списке. На схеме видно, что
указателю p_next_enemy еще не присвоено значение, и он указывает на
неизвестную область памяти.
2. Полю p_next_enemy корабля SHIP 1 присваивается указатель на теку-
щий список врагов, который в данном случае имеет значение NULL.
3. Затем переменной p_enemies присваивается указатель на новый ко-
рабль.
4. Наша функция возвращает указатель p_ship. Вызывающее окружение
может использовать его по своему усмотрению, а переменная p_enemies
обеспечивает доступ ко всему списку, который в текущий момент со-
стоит из одного элемента.
Создание связанного списка    203

Второй проход
В начале второго прохода переменная p_enemies указывает на только что
созданный корабль.
1. Мы создаем новый корабль p_ship: у нас есть второй враг, переменная
p_next_enemy которого снова указывает на неизвестную область памяти.
2. Переменной p_next_enemy присваивается указатель на текущий список
врагов — в данном случае на врага, созданного на первом проходе.
3. Переменной p_enemies присваивается указатель на только что создан-
ный корабль (теперь она указывает на второй корабль, который, в свою
очередь, указывает на первый корабль).
4. Наша функция возвращает указатель p_ship. Вызывающее окружение
может использовать его по своему усмотрению, а переменная p_enemies
обеспечивает доступ ко всему списку, который в текущий момент со-
стоит из двух элементов.
204    Глава 15. Введение в структуры данных с использованием связанных списков

Можно считать, что при каждом добавлении нового элемента в список все
его существующие элементы сдвигаются на одну позицию вниз. При этом
копировать весь список, в отличие от массива, не требуется. Вы просто
обновляете указатель на начало списка, направляя его на новый первый
элемент. Первый элемент списка называется его головой, как правило,
существует указатель на головной элемент списка — в данном случае p_
enemies. В конце функции указатели p_ship и p_enemies указывают на один
и тот же элемент, однако до этого мы использовали указатель p_ship для
хранения новой памяти, чтобы иметь возможность перенаправить указа-
тель p_next_enemy на предыдущую голову списка, хранящуюся в p_enemies.
Хотя написанная мной функция использует глобальную переменную,
можно создать функцию, которая принимает голову списка в качестве
аргумента. Такая функция будет работать с любым списком, а не с един-
ственным глобальным списком. Она может выглядеть следующим образом:
EnemySpaceShip* addNewEnemyToList (EnemySpaceShip* p_list)
{
EnemySpaceShip* p_ship = new EnemySpaceShip;
p_ship->x_coordinate = 0;
p_ship->y_coordinate = 0;
p_ship->weapon_power = 20;
p_ship->p_next_enemy = p_list;
return p_ship;
}

Обратите внимание, что эта функция отличается от функции getNewEnemy,


поскольку возвращает указатель на список, а не на нового врага. Поскольку
эта функция не может изменять ни глобальную переменную, связанную
со списком, ни переданный ей указатель (а только значение, на которое он
указывает), она должна вернуть вызывающему окружению начало списка1.
Затем вызывающее окружение сможет написать:
p_list = addNewEnemyToList(p_list);

и добавить новый элемент в список.


Такой интерфейс функции addNewEnemyToList позволяет вызывающему
окружению выбирать исходный список и место, в котором будет сохранен
возвращенный список.

Если хотите устроить настоящую интеллектуальную тренировку, решите эту


1

задачу, воспользовавшись указателем на указатель вместо возвращения ис-


ходного значения.
Обход связанного списка    205

Теперь повторим действия, которые раньше выполняли с использованием


глобальной переменной p_enemies:
p_enemies = addNewEnemyToList(p_enemies);

Обход связанного списка


Итак, мы знаем, как хранить данные в списке. Как теперь им воспользо-
ваться? Мы умеем последовательно обращаться ко всем элементам массива
с помощью цикла for. Сейчас мы узнаем, как выполнить над связанным
списком такую же операцию, которая называется обходом списка.
Чтобы считать следующий элемент списка, нужен лишь текущий элемент.
Можно написать цикл с переменной, которая указывает на текущий элемент
списка, а после выполнения операции над ним ей присваивается указатель
на следующий элемент списка.
Рассмотрим пример кода, который обновляет оружие всех вражеских ко-
раблей, участвующих в игре (например, в ситуации, когда игрок перешел
на следующий уровень).
EnemySpaceShip *p_current = p_enemies;
while(p_current != NULL)
{
upgradeWeapons(p_current);
p_current = p_current->p_next_enemy;
}

Этот код почти так же короток, как при использовании массива. Перемен-
ная p_current указывает на текущий элемент списка. Сначала она указывает
на первого врага в списке (на которого указывает переменная p_enemies).
Пока значение p_current не равно NULL (то если мы не достигли конца
списка), обновляем оружие текущего врага и присваиваем переменной
p_current указатель на следующего врага в списке.

Значение указателя p_current меняется, а p_enemies и другие переменные


указывают на одно и то же место. Мощь указателей в том, что вы перемеща-
етесь по списку, просто изменяя их значения и не создавая никаких копий.
В каждый момент времени в программе существует всего один экземпляр
каждого корабля; это позволяет обновлять оружие в самом корабле, а не
в его копии. Ниже изображены структуры данных и переменные в про-
цессе обхода списка.
206    Глава 15. Введение в структуры данных с использованием связанных списков

Выводы о связанных списках


Связанные списки позволяют легко добавлять новую память в структуры
данных, не прибегая к операциям копирования и сдвига, необходимым при
работе с массивами. Можно реализовать и такие операции, как добавление
элементов в середину списка и удаление элементов. Чтобы реализация
связанного списка была полной, следует предоставить все эти операции.
Раскрою небольшой секрет: скорее всего, вам никогда не придется реали-
зовывать связанные списки самостоятельно. Вместо этого можно восполь-
зоваться стандартной библиотекой шаблонов, о которой я скоро расскажу.
Тем не менее важность связанных списков в том, что похожими методами
можно создавать более интересные структуры данных. Я не зря водил вас
по дебрям связанных списков — полученные навыки пригодятся, даже
если вы никогда не напишете собственный связанный список. Кроме того,
понимая, как реализован связанный список, вы сможете лучше оценить
преимущества и недостатки использования массивов и связанных списков.

Массивы или связанные списки?


Главное преимущество связанного списка над массивом в том, что его
можно легко масштабировать и добавлять в него элементы, не перемещая
все остальное содержимое списка. Например, в связанный список можно
без труда добавить новый узел.
Выводы о связанных списках    207

Как поступить, чтобы добавить в список новый элемент, при этом не на-
рушив сортировку списка? Например, если список состоит из элементов
1, 2, 5, 9, 10 и нужно вставить в него число 6, оно должно оказаться между
элементами 5 и 9. Используя массив, вы должны были бы изменить его
размер, чтобы вставить новый элемент, а затем переместить все элемен-
ты, начиная с 9, в конец списка. Если бы массив содержал еще тысячу
элементов после числа 10, каждый из них пришлось бы сдвинуть на одну
позицию вправо. Другими словами, быстродействие операции вставки
элемента в середину массива пропорционально длине массива. Работая
же со связанным списком, вы переводите указатель элемента 5 на новый
элемент, присваиваете указателю нового элемента адрес элемента 9, и всё!
Длительность этой операции не зависит от длины списка.
Главное преимущество массива перед связанным списком в том, что массив
позволяет очень быстро выбрать любой элемент по его индексу. Чтобы
выбрать элемент связанного списка, необходимо просматривать все его
элементы, пока не найдется нужный. Таким образом, чтобы воспользо-
ваться преимуществами массива, индекс элемента должен быть связан со
значением, хранимым в наборе элементов. В противном случае для поиска
нужного элемента придется просмотреть весь набор.
Например, можно воспользоваться массивом для регистрации результатов
голосования, в котором пользователи отдают предпочтение кандидату, вво-
дя число от 0 до 9. Каждый элемент массива содержит количество голосов,
отданных за кандидата, номер которого соответствует индексу элемента.
Между числами и кандидатами не существует естественной связи, однако
можно присвоить каждому кандидату определенное число, а затем полу-
чить информацию о кандидате с помощью соответствующего ему числа.
Приводим реализацию этого голосования с помощью массива.

Пример 39: vote.cpp


#include <iostream>
using namespace std;
int main()
{
int votes[10];
// гарантируем чистоту выборов, обнуляя массив
for(int i = 0; i < 10; ++i)
{
votes[i] = 0;
}
int candidate;
cout << "Vote for the candidate of your choice, using
продолжение 
208    Глава 15. Введение в структуры данных с использованием связанных списков

numbers: 0) Joe 1) Bob 2) Mary 3) Suzy 4) Margaret 5)


Eleanor 6) Alex 7) Thomas 8) Andrew 9) Ilene" << '\n';
cin >> candidate;
// вводим голоса, пока пользователь не введет номер,
// не соответствующий ни одному из кандидатов
while(0 <= candidate && candidate <= 9)
{
// Обратите внимание: нельзя использовать цикл do-while
// Перед обновлением массива необходимо проверять,
// находится ли кандидат в допустимом диапазоне.
// Для использования do-while потребовалось бы
// сначала считать значение кандидата, проверить его,
// а затем добавить голос в его пользу.
votes[candidate]++;
cout << "Please enter another vote: ";
cin >> candidate;
}
// выводим голоса
for(int i = 0; i < 10; ++i)
{
cout << votes[i] << '\n';
}
}

Обратите внимание, как легко обновить число голосов, отданных за кон-


кретного кандидата.
Можно подойти к задаче с большей изобретательностью и создать массив
структур, в каждой из которых содержится число голосов, отданных за
кандидата, и его имя. Это упростит вывод имен кандидатов и результатов
голосования.
Представим, как эта же задача решается с помощью связанного списка.
Чтобы найти нужного кандидата, программе необходимо поэлементно
просканировать список. Чтобы узнать результаты кандидата номер 5, при-
дется сначала пройти от элемента кандидата номер 0 к элементу кандидата
номер 1, а затем — к кандидату номер 2. Нельзя перепрыгнуть в середину
связанного списка.
Время доступа к элементу массива по его индексу постоянно и не зависит
от размера массива. Время поиска элемента в связанном списке пропорцио­
нально его размеру независимо от наличия индекса. По мере расширения
списка поиск в нем становится все медленнее.
Если бы вы решали описанную задачу с помощью связанного списка, при-
сваивать номера кандидатам не было бы смысла. Вместо этого вы могли
бы искать их по имени. (Сравнение имен требует больше времени, чем
Выводы о связанных списках    209

сравнение индексов, но используя связанный список, вы, скорее всего, не


очень беспокоитесь о максимальной эффективности кода.)

z z Сколько места требует связанный список


Пространство, занимаемое структурой данных, — важный аспект, если
число элементов списка очень велико. Для небольших структур данных
разница незаметна, если же структура велика, удвоение ее объема может
сказаться весьма существенно.
Как правило, в массивах средний размер элемента меньше, чем в других
структурах данных. Элемент связанного списка должен включать как со-
держимое, так и указатель на следующий элемент списка. Значит, размер
элемента в связанном списке примерно вдвое больше, чем в массиве. Тем
не менее иногда связанные списки занимают меньше места, если заранее
неизвестно, сколько элементов придется хранить. Чтобы не выделять боль-
шой массив, значительная часть которого не используется, можно создавать
новые узлы связанного списка, только когда они нужны, и не расходовать
память впустую. (Во избежание этой проблемы можно выделять массив
динамически, однако придется копировать его элементы при каждом вы-
делении памяти, что частично сведет на нет экономию пространства1.)

z z Прочие соображения
Массивы могут быть многомерными. К примеру, шахматную доску раз-
мером 8 на 8 можно легко представить многомерным массивом. Чтобы
сделать это с помощью связанного списка, придется формировать список,
состоящий из других списков, а доступ к конкретному элементу будет
гораздо медленнее и труднее для понимания.

z z Общие рекомендации
Приводим две рекомендации по использованию связанных списков и мас-
сивов.
Используйте массивы, если необходим доступ к элементам по индексам
при неизменном времени, если заранее известно, сколько элементов нужно
хранить, и если минимизировать объем одного элемента.

Воспользоваться этим подходом можно в любом случае, особенно чтобы обе-


1

спечить одинаковое время доступа к элементам за счет индекса. Сравнивая


структуры данных, нельзя выбрать правильный вариант, если только неэф-
фективность одного из решений не очевидна.
210    Глава 15. Введение в структуры данных с использованием связанных списков

Используйте связанные списки, если новые элементы1 необходимо по-


стоянно добавлять или вставлять в середину списка.
Другими словами, связанные списки и массивы служат взаимодопол-
няющим целям, и использовать те или другие следует в зависимости от
решаемой задачи.

Проверьте себя
1. В чем преимущество связанного списка перед массивом?
А. В связанных списках меньше средний размер элемента.
Б. Связанные списки могут динамически расширяться; в них можно
добавлять отдельные новые элементы, не копируя уже существующие.
В. Поиск отдельного элемента в связанных списках быстрее, чем в мас-
сивах.
Г. Элементами связанных списков могут являться структуры.
2. Какое из перечисленных утверждений верно?
А. Нет никаких причин пользоваться массивами.
Б. Характеристики эффективности связанных списков и массивов
одинаковы.
В. Связанные списки и массивы обеспечивают одинаковое время до-
ступа к элементу по его индексу.
Г. Добавление элемента в середину связанного списка требует меньше
времени, чем добавление в середину массива.
3. Когда обычно используется связанный список?
А. Если необходимо хранить единственный элемент.
Б. Если число хранимых элементов известно на этапе компиляции.
В. Чтобы динамически добавлять и удалять элементы.
Г. Чтобы мгновенно получать доступ к любому элементу отсортиро-
ванного списка без каких-либо итераций.
4. Почему можно объявить связанный список со ссылкой на тип его эле-
мента? (struct Node {Node* p_next;};)

На самом деле класс вектора из стандартной библиотеки шаблонов (standard


1

template library, STL) позволяет добавлять новые элементы в массивоподобные


структуры данных, лишая связанные списки преимущества. Таким образом,
класс вектора предпочтительнее связанного списка или массива. Мы рассмот­
рим векторы в ближайшем будущем.
Выводы о связанных списках    211

А. Объявить список таким способом нельзя.


Б. Поскольку компилятор может определить, что на самом деле не
требуется память для элементов, которые ссылаются сами на себя.
В. Поскольку тип является указателем, требуется лишь достаточно ме-
ста для хранения одного указателя; память для следующего узла будет
выделена позже.
Г. Такое объявление допустимо только при условии, что указателю
p_next не присвоен адрес следующей структуры.
5. Почему в конце связанного списка важно использовать значение NULL?
А. Потому что оно отмечает конец списка и предотвращает доступ кода
к неинициализированной памяти.
Б. Потому что без него список превращается в последовательность
циклических ссылок.
В. Для отладки: выйдя за пределы списка, программа аварийно завер-
шится.
Г. Если не указать NULL, из-за ссылки на себя список потребует бес-
конечно много памяти.
6. В чем схожесть массивов и связанных списков?
А. Они позволяют быстро добавлять новые элементы в середину теку-
щего списка.
Б. Они обеспечивают последовательное хранение данных и доступ
к ним.
В. Их легко расширять, последовательно добавляя элементы.
Г. Они обеспечивают быстрый доступ ко всем элементам списка.
(Решения см. на с. 462.)

Практические задания
1. Напишите программу, которая удаляет элемент из связанного списка.
Функция, выполняющая удаление, должна принимать в качестве аргу-
мента только удаляемый элемент. Легко ли написать такую функцию?
Будет ли она работать быстро? Можно ли упростить или ускорить ее
работу, добавив в список указатель?1
2. Напишите программу, которая добавляет элементы в связанный список
в порядке его сортировки, а не в начало.

1
Подсказка. Что будет, если добавить указатель на предыдущий узел? Поможет
ли он решить задачу?
212    Глава 15. Введение в структуры данных с использованием связанных списков

3. Напишите программу, которая находит элемент в связанном списке по


его имени.
4. Создайте игру в крестики-нолики для двух игроков. Сначала реа-
лизуйте игровое поле в виде связанного списка, а затем в виде массива.
Что проще? Почему?
Г лава 1 6
Рекурсия

Вы много раз встречали алгоритмы, содержащие циклические действия.


Есть и другой способ выполнения кода, в котором вместо циклов ис-
пользуются повторяющиеся вызовы функций. Этот способ называется
рекурсией. Рекурсия позволяет определять операции, используя сами эти
операции, — иначе говоря, создавать функции, которые вызывают сами
себя. Рекурсия похожа на циклы, но обладает более мощными возможно-
стями. Она позволяет легко писать программы, которые почти невозможно
реализовать с помощью циклов. Рекурсию особенно эффективно применять
к структурам данных, таким как связанные списки и двоичные деревья
(с которыми вы познакомитесь в ближайшее время). В этой и следующей
главе вы изучите ключевые принципы рекурсии и несколько конкретных
примеров ее использования.

Как представить себе рекурсию


Рекурсивную функцию можно представить в виде процедуры, которая
содержит инструкцию «повторить процедуру». По описанию она очень
похожа на цикл, поскольку повторяет один и тот же код. И у рекурсии,
и у цикла есть общие черты. С другой стороны, рекурсия позволяет про-
ще описывать идеи, в которых для решения задачи необходим результат
рекурсивного вызова. Разумеется, иногда процесс может завершаться и без
рекурсивного вызова. Предположим, мы хотим построить стену высотой
в десять футов. Для этого нужно сначала построить стену высотой в девять
футов, а затем добавить к ней еще один фут кирпичей. Можно представить
214    Глава 16. Рекурсия

эту задачу в виде функции «построить стену», которая принимает в каче-


стве аргумента высоту стены. Если эта высота больше единицы, функция
сначала вызывает себя, чтобы построить более низкую стену, а затем до-
бавляет к ней один фут кирпичей.
Ниже приведена простейшая структура этой функции; в ней есть пара за-
служивающих внимания изъянов, которые мы скоро рассмотрим. Главная
идея этой структуры в том, что задача построения стены сводится к по-
строению стены меньшей высоты.
void buildWall(int height)
{
buildWall(height - 1);
addBrickLayer();
}

Этот код порождает небольшую проблему. Когда он прекратит вызывать


функцию buildWall? К сожалению, никогда. Эта проблема легко решается:
мы должны остановить рекурсивный вызов, когда высота стены станет
равной нулю. В этой ситуации мы просто положим слой кирпичей, не
строя стену с меньшей высотой.
void buildWall(int height)
{
if(height > 0)
{
buildWall(height - 1);
}
addBrickLayer();
}

Условие, при котором функция не вызывает сама себя, называется ее ба-


зовым вариантом. В данном примере функция «построить стену» знает,
что если мы достигли уровня земли, для построения стены можно просто
добавить слой кирпичей. В противном случае мы должны сначала постро-
ить стену меньшего размера, а затем добавить слой кирпичей сверху. Если
логика кода непонятна (а она может быть непонятной, если вы впервые
видите рекурсию), представьте себе физический процесс строительства
стены. Сначала вы ставите себе задачу построить стену определенной
высоты. Вы рассуждаете так: «Чтобы положить кирпичи здесь, нужно
построить стену на кирпич ниже». В какой-то момент вы скажете: «Мне
не нужна стена ниже — я могу положить кирпичи на землю». Это условие
является базовым вариантом рекурсии.
Этот алгоритм сводит текущую задачу к более простой (построить стену
ниже) и решает ее. В какой-то момент задача становится настолько простой,
Как представить себе рекурсию    215

что ее можно решить непосредственно (положить один слой кирпичей


на землю). В реальности это означает, что мы можем построить стену, а в
C++ — что функция в определенный момент перестанет осуществлять
рекурсивные вызовы. Описанный подход очень похож на ранее рассмо-
тренное нисходящее проектирование — в нем мы разбивали задачу на более
мелкие подзадачи, создавали функции для каждой из них и строили из этих
функций законченную программу. Но при нисходящем проектировании
подзадачи отличаются от исходной задачи, а при рекурсии мы сводим
решаемую задачу к ее собственным уменьшенным копиям.
После того как функция завершает вызов самой себя, она может продол-
жить работу, совершая операции и вызывая другие функции. В ситуации
со строительством стены функция после рекурсивного вызова добавляет
новый слой кирпичей.
Рассмотрим пример, который можно запустить и увидеть реальный ре-
зультат. Задача заключается в написании рекурсивной функции, которая
выводит число 123456789987654321. Для ее решения можно написать
функцию, которая принимает число, а затем выводит его дважды: один
раз до рекурсивного вызова и один раз после.

Пример 40: printnum.cpp


#include <iostream>
using namespace std;
void printNum (int num)
{
// Между двумя вызовами cout в этой функции
// формируется внутренняя последовательность
// чисел (num+1)...99...(num+1)
cout << num;
// Пока begin меньше 9, рекурсивно выводим
// последовательность (num+1) ... 99 ... (num+1)
if(num < 9)
{
printNum(num + 1);
}
cout << num;
}
int main()
{
printNum(1);
}

Рекурсивный вызов printnum(num + 1) выводит последовательность


(num+1)...99...(num+1) . Выводя значение num до и после вызова
printnum(num + 1), мы фактически создаем бутерброд: число num выводится
216    Глава 16. Рекурсия

на обеих сторонах последовательности (num+1)...99...(num+1), придавая


ей вид (num)(num+1)...99...(num+1)(num). Если значение num равно 1, по-
следовательность выглядит следующим образом: 12...99...21.
Эту функцию можно представить и иначе: сначала она выводит после-
довательность от 1 до 9, каждый раз повторно вызывая printnum. Когда
функция printnum достигает базового варианта, все ее рекурсивные вызовы
завершаются, снова выводя числа в порядке завершения вызовов. Посколь-
ку последнему вызову был передан аргумент 9, по достижении базового
варианта он сразу выведет результат, а не повторно вызовет функцию.
Этот вызов возвращается в вызов, в котором значение переменной num
равно 8; этот вызов выводит число 8, завершается, значение num становится
равным 7 и т. д. Этот процесс продолжается, пока не будет достигнут первый
вызов со значением 1. Этот вызов выводит число 1 и завершает рекурсию.

Рекурсия и структуры данных


Некоторые структуры данных можно обрабатывать с помощью рекурсив-
ных алгоритмов, поскольку их можно представить в виде уменьшенных
копий самих себя. Поскольку рекурсивные алгоритмы сводят задачу
к аналогичной задаче меньшего масштаба, они эффективно работают со
структурами данных, которые содержат уменьшенные версии самих себя.
Примером такой структуры данных являются связанные списки.
До настоящего момента мы рассматривали связанные списки как спи-
ски, в начало которых можно добавлять новые элементы. Тем не менее
связанный список можно рассматривать и как его первый узел, который
указывает на другой связанный список меньшего размера. Другими сло-
вами, связанный список состоит из отдельных узлов, каждый из которых
указывает на другой узел, являющийся началом оставшейся части списка.
Это важное свойство связанных списков позволяет разделить их обработку
на обработку текущего узла и оставшейся части списка. Например, можно
найти определенный узел списка по следующему базовому алгоритму.
• Если находимся в конце списка, вернуть NULL.
• В противном случае, если текущий узел искомый, вернуть его.
• В противном случае повторить поиск в оставшейся части списка.
Этот алгоритм можно представить в виде кода следующим образом:
struct node
{
int value;
Рекурсия и структуры данных    217

node *next;
};
node* search(node* list, int value_to_find)
{
if(list == NULL)
{
return NULL;
}
if(list->value == value_to_find)
{
return list;
}
else
{
return search(list->next, value_to_find);
}
}

Рассматривая рекурсивный вызов, я описал некоторую работу, которую


должна выполнять вызываемая функция. Действия, которые функция вы-
полняет при определенных входных данных, называются ее контрактом.
Контракт функции описывает, что она делает. Контракт функции поиска
заключается в поиске заданного узла в списке. Алгоритм функции поиска
можно описать следующими словами: «если текущий узел искомый, вер-
нуть его; в противном случае контрактом функции является поиск узла
в оставшейся части списка».
Важно вызвать функцию поиска не для всего списка, а именно для его
оставшейся части. Рекурсия работает лишь при соблюдении следующих
условий.
1. Можно свести задачу к ее уменьшенной версии.
2. Можно решить базовый вариант.
Функция поиска решает два возможных базовых варианта: «мы достигли
конца списка» и «мы нашли требуемый узел». Если ни одно из этих условий
не выполнено, функция поиска сводит задачу к ее уменьшенной версии.
В этом и заключается суть рекурсии: вы решаете уменьшенную задачу, а затем
применяете полученный результат для решения более масштабной задачи.
Иногда значение, которое возвращает рекурсивная функция, не возвра-
щается сразу, а используется. Рассмотрим вычисление факториала (это
типичный пример, иллюстрирующий рекурсию):
Factorial(x) = x * (x - 1) *(x - 2)...*1

или, другими словами:


218    Глава 16. Рекурсия

Factorial(x) =
If(x == 1) 1
Else x * Factorial(x - 1)

Таким образом, факториал вычисляется умножением текущего значения


на факториал предыдущего значения. Здесь мы воспользуется значением,
которое возвращает рекурсивный вызов, — умножим его на другое число.
Соответствующий код выглядит так:

int factorial(int x)
{
if(x == 1)
{
return 1;
}
return x * factorial(x – 1);
}

Здесь мы можем решить базовый вариант, если x имеет значение 1, либо


решить уменьшенную задачу factorial(x - 1) и воспользоваться полу-
ченным результатом для вычисления факториала x. Поскольку каждый
вызов факториала уменьшает значение x, в определенный момент мы до-
стигнем базового варианта.
Обратите внимание, что мы сначала решаем подзадачу, а затем берем ре-
зультат и выполняем над ним какое-либо действие. Все, что мы делали при
поиске в связанном списке, — возвращали результат решения подзадачи.
Рекурсию можно использовать двумя способами — возложить полную
ответственность за решение задачи на рекурсивный вызов либо сначала
получить результат решения подзадачи, а затем использовать его в допол-
нительных вычислениях.

Циклы и рекурсия
В некоторых случаях рекурсивный алгоритм можно легко выразить как
цикл с такой же структурой. Например, поиск в списке можно реализовать
следующим образом:

node *search (node *list, int value_to_find)


{
while ( 1 )
{
if ( list == NULL )
{
Циклы и рекурсия    219

return NULL;
}
if ( list->value == value_to_find )
{
return list;
}
else
{
list = list->next;
}
}
}

Сравнив этот код с рекурсивной версией решения задачи, вы увидите, что


в нем используются те же проверки. Единственное различие двух алго-
ритмов в том, что в этом коде используется не рекурсия, а цикл, и вместо
рекурсивного вызова список сокращается за счет перевода указателя list
на оставшуюся часть списка. В этой ситуации рекурсивное и итеративное
(использующее цикл) решения работают по схожим принципам.
Обычно рекурсивный алгоритм можно легко реализовать в виде цикла
и наоборот, когда не нужно ничего делать с результатом, возвращаемым
рекурсивной функцией. Такая рекурсия называется хвостовой — рекур-
сивный вызов находится в хвосте функции и является ее последним
действием. Если рекурсивный вызов является последней операцией, он
ничем не отличается от перехода к следующей итерации цикла. По за-
вершении следующего вызова никакой результат предыдущего вызова
не требуется. Решение задачи с поиском в списке демонстрирует пример
хвостовой рекурсии.
Теперь обратимся к факториалу. Пытаясь преобразовать его в цикл на
основе рекурсивной реализации, мы столкнемся с небольшой проблемой:

int factorial (int x)


{
while ( 1 )
{
if ( x == 1 )
{
return 1;
}
// что поместить сюда??
// return x * factorial( x – 1 );
}
}
220    Глава 16. Рекурсия

Поскольку нам нужно использовать результат factorial(x – 1), нельзя


просто зациклить алгоритм. Чтобы выполнить расчет, требуется решить под-
задачу, а для этого необходимо, чтобы оставшаяся часть цикла завершилась.
Тем не менее вычисление факториала можно легко реализовать в виде
цикла, если подойти к нему иначе. Рассмотрим исходное определение:
Factorial( x ) = x * ( x - 1 ) *( x - 2 )...*1

Если хранить текущее значение, мы сможем рассчитать факториал, на-


капливая результат умножения: x * (x - 1) * (x - 2)...

int factorial(int x)
{
int cur = x;
while(x > 1)
{
x--;
cur *= x;
}
return x;
}

Обратите внимание, что мы не используем результат решения подзадачи


(меньший факториал), а на самом деле выполняем умножение в обратном
порядке.
Например, если мы вычисляем факториал пяти, то рекурсивное решение
сделает это следующим образом:
1*2*3*4*5
Итеративное решение выполняет умножение так:
5*4*3*2*1
У этой задачи есть как рекурсивное, так и итеративное решение, однако
их структуры различаются. Придав алгоритму другую структуру, мы смог-
ли реализовать факториал в виде очень простого цикла. Иногда создать
цикл значительно сложнее, чем в этом примере. Решение о том, стоит ли
использовать рекурсию, зависит от простоты реализации итеративного
алгоритма. В ситуации с факториалом это несложно, однако в некоторых
случаях создать итеративный алгоритм существенно труднее, и скоро мы
увидим демонстрирующие это примеры.
Стек    221

Стек
Пришло время подробнее обсудить, как работают вызовы функций, и рас-
смотреть несколько любопытных схем. Освоив принцип действия вызовов
функций вы сможете разобраться в рекурсии и поймете, почему некоторые
алгоритмы проще реализовать с помощью рекурсии, а не циклов.
Вся информация, которой пользуется функция, хранится внутри стека.
Представьте стопку тарелок: можно положить тарелку поверх либо взять
верхнюю тарелку. Стек устроен аналогично, однако он состоит не из таре-
лок, а из стековых фреймов. В момент вызова функции выделяется новый
фрейм на вершине стека и хранит в нем все локальные переменные, кото-
рыми пользуется. Когда эта функция вызывает другую функцию, исход-
ный стековый фрейм сохраняется, а на вершине стека размещается новый
фрейм, в котором вызываемая функция хранит собственные переменные.
Функция, которая выполняется в текущий момент, всегда использует
верхний фрейм стека.
В простейшем случае, когда выполняется только функция main, стек вы-
глядит следующим образом:

У нас есть только функция main, и стек содержит только ее переменные.


Допустим, main вызывает другую функцию, та создаст поверх фрейма
функции main стековый фрейм, который будет выглядеть так:

Переменные 2-й функции

Переменные main

Теперь для хранения переменных и работы с ними у текущей функции


есть отдельное место, которое не пересекается с переменными функции
main. Если вторая функция вызовет третью, стек примет следующий вид:
222    Глава 16. Рекурсия

Переменные 3-й функции

Переменные 2-й функции

Переменные main

У новой функции есть собственный стековый фрейм. Стековый фрейм


создается при каждом вызове функции. После возврата из функции стек
принимает прежний вид:

Переменные 2-й функции

Переменные main

Когда вторая функция вернется в main, в стеке останется единственный


фрейм:

Активный фрейм всегда связан с функцией, которая исполняется в текущий


момент, и всегда расположен на вершине стека.
Стековый фрейм содержит не только переменные, используемые функци-
ей, но и ее аргументы и строку кода, которую функция должна вернуть по
завершении. Другими словами, стековый фрейм хранит местоположение
функции и все данные, которыми она пользуется. При рекурсивном ис-
пользовании функции в момент ее вызова создается новый стековый фрейм,
хотя вызываемая функция та же, что и вызывающая. Рекурсия работает
именно поэтому: у каждой функции есть собственный фрейм, аргументы и
переменные. Это позволяет каждому вызову пользоваться собственной ин-
формацией, и каждая функция может работать над решением уменьшенной
версии исходной задачи, представленной ее собственными переменными.
Как мы видели на схеме, когда функция завершается, она удаляет свой
фрейм с вершины стека и возвращается в точку выполнения в вызывающем
Стек    223

окружении, что делает фрейм ее вызывающего окружения доступным для


использования.
Особенно важно, чтобы стековый фрейм содержал место, в которое проис-
ходит возврат, и удалялся из стека после завершения функции. В отсутствие
подходящего стекового фрейма вызывающая функция не может корректно
продолжать выполнение после возврата из вызванной функции; напри-
мер, у вызывающей функции не будет корректных значений локальных
переменных.
Представьте себе описанный выше механизм следующим образом: при
вызове новой функции сохраняется все, что необходимо вызывающей
функции для выполнения. Если в процессе работы над проектом вы ре-
шили пообедать, вы напишете себе заметки, которые помогут вспомнить,
на чем остановились, и закончить работу, когда вернетесь. Стек дает ком-
пьютеру возможность создавать очень подробные заметки о том, что он
делает в каждый момент времени; эти заметки содержат гораздо больше
информации, чем пишет для себя человек.
Вот стек, который демонстрирует три рекурсивных вызова функции
buildWall, начиная со значения height, равного 2. Как вы видите, каждый
стековый фрейм содержит новое значение height, переданное в функцию
buildWall (обратите внимание, что вызов со значением 0 находится на
вершине стека и соответствует подножию физической стены).

Такой подход к изображению стека часто сокращают следующим образом:

buildWall(x = 0)
buildWall(x = 1)
buildWall(height = 2)
main()

Каждая функция располагается над функцией, которая ее вызвала, вместе


с переданными аргументами. Этот метод помогает понять, как работает
224    Глава 16. Рекурсия

конкретная рекурсивная функция. Иногда полезно выписать рядом с каж-


дым фреймом имя функции, ее аргументы и локальные переменные.
height = 1
height = 2
height = 0

Достоинства стека
Ключевая ценность рекурсии в том, что есть стек вызовов функций, а не
единственный стековый фрейм. Рекурсивные алгоритмы используют всю
дополнительную информацию, которая хранится в каждом фрейме, в то
время как у цикла есть лишь один набор локальных переменных. В резуль-
тате рекурсивная функция может дожидаться возврата из рекурсивного
вызова и продолжать выполнение с того места, где она прервалась. Чтобы
создать цикл, способный работать подобным образом, придется реализовать
собственную версию стека.

Недостатки рекурсии
Поскольку стек имеет фиксированный размер, рекурсия не может быть
бесконечной. В какой-то момент в стеке не хватит места для размещения
нового фрейма на его вершине (в шкафу закончится свободное простран-
ство для тарелок).
Ниже приведен пример рекурсии, которая теоретически бесконечна:
void recurse ()
{
recurse(); // Функция вызывает себя
}
int main ()
{
recurse(); // Запускает рекурсию
}

Тем не менее место в стеке закончится, и программа аварийно завершится


из-за переполнения стека. Переполнение стека происходит, когда в нем
не остается свободного пространства. Программа лишается возможности
вызывать функции, и попытка сделать это приведет к ее аварийному завер-
шению. Переполнение стека происходит нечасто и, как правило, вызвано
рекурсивной функцией с неудачным базовым вариантом. Так, в рассмо-
тренном примере с вычислением факториала есть небольшая проблема:
в нем отсутствует проверка, не отрицательно ли число. Если вызывающая
функция передаст аргумент, равный –1, это почти наверняка приведет
Стек    225

к переполнению стека (опробуйте это на практике — программа аварийно


завершится, не нанеся никакого вреда компьютеру).
Вот простая программа, которая показывает, сколько рекурсивных вызовов
требуется, чтобы миниатюрная функция переполнила стек. (Чем больше
стековый фрейм функции, тем меньше рекурсивных вызовов можно со-
вершить; тем не менее это обстоятельство редко ограничивает программу,
если базовый вариант рекурсии составлен корректно.)
#include <iostream>
using namespace std;
void recurse(int count) // каждый вызов получает свое значение count
{
cout << count << "\n";
// Инкрементировать count необязательно,
// поскольку у каждой функции есть отдельные переменные
// (поэтому переменная count в каждом стеке
// будет инициализироваться значением
// на единицу больше предыдущего)
recurse(count + 1);
}
int main()
{
// Первый вызов функции начинает с единицы
recurse(1);
}

Отладка переполнений стека


При отладке переполнения стека важнее всего определить, какая
функция (или группа функций) периодически создает новые стековые
фреймы. Например, если применить к предыдущему примеру отлад-
чик (который мы рассмотрим в главе 20 «Отладка в Code::Blocks»),
вы увидите, что при аварийном завершении программы стек выглядит
следующим образом:
recurse(10000);
recurse(9999);
recurse(9998);
...
recurse(1)
main()

Этот случай легко проанализировать, поскольку в нем участвует всего


одна функция. Очевидно, в ней отсутствует какой-то базовый вариант,
и, скорее всего, из-за этого рекурсия не останавливается, когда аргумент
рекурсивной функции достигает определенного размера.
226    Глава 16. Рекурсия

Возможна и взаимная рекурсия — ситуация, когда две функции вызывают


друг друга.
Ниже приведен весьма замысловатый пример, в котором вновь использует-
ся факториал. Он вычисляется с помощью двух функций, одна из которых
вычисляет факториал четных чисел, а другая — нечетных:
int factorial_odd(int x)
{
if(x == 0)
{
return 1;
}
return factorial_even(x – 1);
}
int factorial_even(int x)
{
if(x == 0)
{
return 1;
}
return factorial_odd(x – 1);
}
int factorial (int x)
{
if(x % 2 == 0)
{
return factorial_even(x);
}
else
{
return factorial_odd(x);
}
}

Обратите внимание, что базовые варианты не защищены от отрицательных


значений. Вызов factorial(-1) приведет к формированию следующего
стека:
factorial_even(-10000)
factorial_odd(-9999)
factorial_even(-9998)
factorial_odd(-9997)

Просто взглянув на стек, можно предположить, что существует проблема


с базовым вариантом и что две функции постоянно вызывают друг друга.
Следующий шаг — взглянуть на код и попытаться определить, какая из
функций должна обнаруживать отрицательные числа в базовом варианте.
Подводя итоги    227

При вычислении факториала стоит включить одну и ту же проверку в ба-


зовые варианты обеих функций, в других ситуациях ответственность за
окончательный базовый вариант можно возложить только на одну из них.
При отладке сложных рекурсивных вызовов необходимо отслеживать по-
вторяющиеся функции, в данном случае factorial_even и factorial_odd,
которые вызывают друг друга. Когда период повторений очень велик, вы
должны сначала обнаружить весь набор повторяющихся вызовов функций,
а затем определить, чем вызваны повторы.

Производительность
Рекурсия подразумевает большое количество вызовов функций. Каждый
вызов функции создает стековый фрейм и передает аргументы, что при-
водит к дополнительным затратам времени, которые отсутствуют при ис-
пользовании циклов. На современных компьютерах этими затратами почти
всегда можно пренебречь, однако если код исполняется с очень высокой
периодичностью (миллионы или миллиарды раз за короткий промежуток
времени), затраты на вызовы функций могут стать заметны.

Подводя итоги
Рекурсия позволяет создавать алгоритмы, которые решают задачи, сводя
их к их собственным уменьшенным копиям. Кроме того, рекурсия обеспе-
чивает более мощные возможности, чем циклы, поскольку хранение теку-
щего состояния каждой рекурсивной функции в стеке позволяет функции
продолжить выполнение после получения результата решения подзадачи.
Рекурсивная реализация алгоритма часто естественнее равнозначной ей
реализации в виде цикла. Мы познакомимся с несколькими соответству-
ющими примерами в следующей главе о двоичных деревьях. По мере на-
копления практического опыта написания программ вы придете к выводу,
что существует широкий круг задач, решать которые с помощью рекурсии
проще, чем лишь с использованием циклов.
Ниже приведены практические советы о том, когда следует использовать
рекурсию и циклы.
Пользуйтесь рекурсией, если:
1) задача разбивается на уменьшенные копии самой себя, и нет очевид-
ного способа решить ее написанием цикла;
228    Глава 16. Рекурсия

2) вы работаете с рекурсивной структурой данных, например со связан-


ными списками.
Пользуйтесь циклами, когда:
1) существует очевидное решение задачи при помощи цикла (например,
можно объединить два списка, используя рекурсивную функцию, но
такое решение не оптимально);
2) вы работаете со структурой данных, индексированной по номеру (на-
пример, с массивом).

Проверьте себя
1. Что такое хвостовая рекурсия?
А. Когда вы подзываете свою собаку.
Б. Когда функция вызывает сама себя.
В. Когда рекурсивная функция вызывает себя непосредственно перед
возвращением.
Г. Когда можно написать рекурсивный алгоритм в виде цикла.
2. Когда можно воспользоваться рекурсией?
А. Когда невозможно написать алгоритм в виде цикла.
Б. Когда алгоритм естественнее выражается в виде подзадачи, а не
цикла.
В. Никогда — это слишком сложно.
Г. При работе с массивами и связанными списками.
3. Каковы неотъемлемые составляющие рекурсивного алгоритма?
А. Базовый вариант и рекурсивный вызов.
Б. Базовый вариант и способ сведения задачи к уменьшенной версии
самой себя.
В. Способ перекомпоновки уменьшенных версий задачи.
Г. Все вышеперечисленное.
4. Что может произойти при неполном базовом варианте?
А. Преждевременное завершение алгоритма.
Б. Ошибка компилятора.
В. Ничего — это не проблема.
Г. Переполнение стека.
(Решения см. на с. 463.)
Практические задания    229

Практические задания
1. Напишите рекурсивный алгоритм, реализующий возведение в степень
с помощью функции pow(x, y) = x^y.
2. Напишите рекурсивную функцию, которая принимает массив и ото-
бражает его элементы в обратном порядке, не индексируя их с конца
массива (другими словами, обойдитесь без написания цикла, который
начинает выводить элементы с конца массива).
3. Напишите рекурсивный алгоритм, удаляющий элементы из связанно-
го списка. Напишите рекурсивный алгоритм, добавляющий элементы
в связанный список. Реализуйте те же алгоритмы с помощью итерации.
Какие реализации естественнее: рекурсивные или итеративные?
4. Напишите рекурсивную функцию, которая ищет элемент в отсортиро-
ванном массиве (отсортированный массив и искомый элемент являют-
ся ее аргументами). Функция возвращает индекс элемента или –1, если
элемент отсутствует в массиве. Насколько быстро можно осуществить
этот поиск? Можете ли вы предложить способ его выполнения, более
эффективный, чем просмотр всех элементов?
5. Напишите рекурсивный алгоритм для задачи Ханойской башни. Вы
можете прочесть описание задачи на сайте http://www.mazeworks.com/
hanoi/index.htm и попытаться решить ее самостоятельно.
Г лава 1 7
Двоичные деревья

ПРИМЕЧАНИЕ

В этой главе рассматривается одна из самых интересных


и полезных базовых структур данных — двоичное дерево. Двоичные
деревья представляют собой идеальный пример использования
рекурсии и указателей для выполнения очень полезных действий.
Тем не менее работа с ними требует уверенного понимания
рекурсии и концепций в основе связанных списков. Почему я так
считаю? Я неоднократно видел, как студенты сталкиваются
с проблемами при изучении двоичных деревьев, оставляя указатели
и рекурсию без должного внимания. В сущности, в двоичных
деревьях нет ничего сложного, чтобы их не понять, однако вам
необходимы прочные фундаментальные знания. Если освоение
концепций этой главы вызовет сложности, скорее всего, стоит
глубже разобраться в указателях или рекурсии. Перечитайте
соответствующие главы книги и выполните упражнения.

Для чего нужны двоичные деревья?


Связанные списки — отличный способ перечисления предметов, но поиск
конкретного элемента в таком списке может занять много времени. Более
того, если данные представляют собой один большой неструктурированный
Что такое двоичное дерево?    231

список, даже массивы не помогут с ним работать. Можно попытаться


упорядочить массив: это даст возможность очень быстро искать в нем
данные. Тем не менее вставлять элементы в массив будет трудно: если вы
не захотите нарушить упорядоченность массива, то при добавлении нового
придется выполнять много дополнительной работы. Более того, быстро
искать данные очень важно. Вот несколько примеров.
1. Если вы создаете глобальную сетевую многопользовательскую ролевую
игру вроде World Of Warcraft и хотите, чтобы игроки могли оперативно
подключаться, вы должны быстро находить конкретного игрока.
2. Если вы создаете программное обеспечение для работы с кредитными
картами и требуется обрабатывать миллионы транзакций в час, вы
должны быстро определять баланс банковского счета по номеру кре-
дитной карты.
3. Если вы отображаете адресную книгу на устройстве с небольшими
ресурсами (например, смартфоне), недопустимо, чтобы пользовате-
лю приходилось ждать из-за того, используется медленная структура
данных.
В этой главе речь пойдет об инструментах, которые позволяют решать эти
и многие другие задачи.
Основная идея в том, что элементы хранятся в структуре, напоминающей
связанный список. Она позволяет использовать указатели для структури-
рования памяти (так же, как мы делали со связанными списками), но упро-
щает поиск значений. Такая структура памяти сложнее обычного списка.

Что такое двоичное дерево?


Рассмотрим суть структурирования данных в виде двоичного дерева. Ког-
да вы начинали изучать программирование, в вашем распоряжении были
только массивы, которые позволяли организовывать данные лишь в виде
последовательного списка. Связанный список использует указатели для
наращивания последовательного списка, но не использует их гибкость,
позволяющую строить более сложные структуры.
Что я понимаю под сложной структурой в памяти? Прежде всего, можно
создать структуру данных, у элементов которой может быть несколько
следующих. Для чего? Если есть два следующих узла, один из которых
может содержать элементы со значением меньше, чем у текущего элемента,
а другой — элементы со значением больше, чем у текущего. Такая структура
называется двоичным деревом.
232    Глава 17. Двоичные деревья

Название «двоичное дерево» говорит о том, что у каждого узла есть не


более двух ветвей. «Следующие» узлы называются дочерними, а текущий
узел по отношению к дочернему — родительским.
Двоичное дерево можно изобразить так:

10

14

11 18

Обратите внимание, что в этом дереве каждый левый дочерний элемент


меньше своего родителя, а правый дочерний элемент — больше. Узел 10
является родителем всего дерева, а его дочерние узлы, 6 и 14, — родителя-
ми своих деревьев меньшего размера, которые называются поддеревьями.
Важное свойство двоичного дерева в том, что каждый дочерний элемент
узла сам является двоичным деревом. Если учесть это свойство и принять
во внимание, что дочерние узлы слева меньше своих родителей, а справа —
больше, легко разработать алгоритм поиска узла в дереве. Сначала нужно
сравнить текущий узел с искомым значением: если они равны, поиск завер-
шен. Если искомое значение меньше, чем у текущего узла, нужно перей­ти
влево, в противном случае — вправо. Этот алгоритм работает, потому что
все узлы в левом поддереве меньше текущего, а в правом — больше.
Желательно, чтобы дерево было сбалансированным, то есть содержало
одинаковое число узлов в левом и правом поддеревьях. В этом случае раз-
мер каждого дочернего двоичного дерева равен примерно половине размера
всего дерева, и, ища значение в дереве, при каждом переходе на дочерний
узел можно исключать из поиска половину элементов. Если дерево состо-
ит из 1000 элементов, моментально отбрасывается около 500 элементов и
поиск продолжается в 500-элементном дереве, в котором снова можно от-
бросить половину (то есть 250) элементов. Таким образом, поиск значения
не занимает много времени, если на каждом шаге количество элементов
сокращается вдвое. Чтобы найти искомый элемент, необходимо разделить
дерево log2n раз, где n — число элементов дерева. Это число невелико даже
Что такое двоичное дерево?    233

для очень больших деревьев (оно равно 32 для дерева с 4 млрд элементов;
поиск в таком дереве будет почти в 100 млн раз быстрее, чем в связанном
списке такого же размера, где придется просматривать каждый элемент).
Тем не менее если дерево не сбалансировано, его деление точно пополам
может оказаться невозможным. В худшем случае у каждого узла будет
один дочерний, и дерево превратится в псевдосвязанный список (с до-
полнительными указателями), время поиска в котором равно n.
Если дерево относительно (необязательно идеально) сбалансировано,
поиск узлов в нем существенно быстрее, чем в связанном списке. Все это
возможно благодаря тому, что вы способны структурировать память по
своему желанию, не ограничиваясь простыми списками1.

Соглашение о терминах
Для удобства изучения примеров кода, работающего с двоичными де-
ревьями, сначала договоримся о принципах изображения и именования
различных частей дерева.
Простейшее дерево — это пустое дерево, которому соответствует значение
NULL. Я не буду изображать ссылки на пустые деревья на схеме.

Конкретные поддеревья я буду называть так: <дерево с вершиной [зна-


чение родительского узла]>. Например,

10

14

11 18

1
Простейшее двоичное дерево редко имеет такую же структуру, как связанный
список, что обусловливается порядком вставки элементов в дерево. Существу-
ют более сложные виды двоичных деревьев, которые всегда сбалансированы,
однако их рассмотрение выходит за рамки этой книги. Примером такой струк-
туры является красно-черное дерево: https://ru.wikipedia.org/wiki/Красно-черное
_дерево
234    Глава 17. Двоичные деревья

<дерево с вершиной 6> относится к следующему поддереву:

Реализация двоичных деревьев


Рассмотрим код простой реализации двоичного дерева. Сначала объявим
структуру узла:
struct node
{
int key_value;
node *p_left;
node *p_right;
};

Этот узел хранит значение в виде простого целого числа key_value и со-
держит два дочерних поддерева с названиями p_left и p_right.
Основные действия над двоичными деревьями — вставка нового узла,
поиск значения в дереве, удаление узла и уничтожение дерева для осво-
бождения памяти.
node* insert (node* p_tree, int key);
node *search (node* p_tree, int key);
void destroyTree(node* p_tree);
node *remove(node* p_tree, int key);

Вставка в дерево
Начнем с того, что реализуем вставку узла в дерево с помощью рекур-
сивного алгоритма. Рекурсия отлично сочетается с деревьями, поскольку
каждое дерево содержит два дерева меньшего размера, а следовательно,
имеет рекурсивную структуру (если бы дерево включало, скажем, массив
или указатель на связанный список, а не на другие деревья, оно не было
бы рекурсивным).
Наша функция принимает ключ и существующее дерево (возможно, пустое)
и возвращает новое дерево, содержащее вставленное значение.
Реализация двоичных деревьев    235

node* insert(node *p_tree, int key)


{
// Базовый вариант: мы достигли пустого дерева и должны
// вставить в него новый узел
if(p_tree == NULL)
{
node* p_new_tree = new node;
p_new_tree->p_left = NULL;
p_new_tree->p_right = NULL;
p_new_tree->key_value = key;
return p_new_tree;
}
// В зависимости от значения узла решаем,
// в какое поддерево его вставить,
// в левое или правое
if(key < p_tree->key_value)
{
// Строим новое дерево на основе p_tree->left,
// добавляя в него ключ. Заменяем текущее
// значение p_tree->left указателем на новое дерево.
// Нужно задать значение указателя p_tree->p_left,
// если p_tree->left равен NULL.
// (Если он не равен NULL,
// то p_tree->p_left не изменится,
// но в этом нет ничего страшного.)
p_tree->p_left = insert(p_tree->p_left, key);
}
else
{
// Вставка с правой стороны, в точности
// симметричная вставке с левой стороны
p_tree->p_right =
insert(p_tree->p_right, key);
}
return p_tree;
}

Базовая логика этого алгоритма такова: если дерево пустое — создать новое
дерево; в противном случае, если вставляемое значение больше текущего
узла, вставить его в левое поддерево и заменить новым поддеревом; в про-
тивном случае вставить значение в правое поддерево и заменить его новым
поддеревом.
Рассмотрим этот код в действии, превратив пустое дерево в дерево с двумя
узлами. При вставке значения 10 в пустое дерево (NULL) мы сразу попадаем
в базовый вариант. В результате получается очень простое дерево:
236    Глава 17. Двоичные деревья

Оба его дочерних дерева являются пустыми.


При вставке значения 5 в это дерево мы совершаем вызов:
insert(<tree with parent 10>, 5)

Поскольку 5 меньше 10, получаем рекурсивный вызов в левое поддерево:


insert(NULL, 5)
insert(<дерево с родителем 10>, 5)

Вызов insert(NULL, 5) создаст новое дерево и вернет его:

Получив возвращенное дерево, функция insert(<дерево с родителем


10>, 5) свяжет два дерева воедино. В данном случае дочерний узел эле-
мента 10 слева раньше был равен NULL, поэтому он становится абсолютно
новым деревом:

Если теперь добавить в дерево значение 7, получим:


insert(NULL, 7)
insert(<дерево с родителем 5>, 7)
insert(<дерево с родителем 10>, 7)

Вызов
insert(NULL, 7)

возвращает новое дерево:


Реализация двоичных деревьев    237

Затем
insert(<дерево с родителем 5>, 7)

подключает поддерево 7 следующим образом:

И наконец, это дерево возвращается в вызов


insert( <дерево с родителем 10>, 7 )

который пристыковывает его обратно:

Поскольку у узла 10 уже был указатель на узел, содержащий число 5, по-


вторное связывание левого дочернего узла элемента 10 с родителем 5 не
является строго обязательным, однако оно устраняет необходимость до-
полнительно проверять, является ли поддерево пустым.

Поиск в дереве
Теперь рассмотрим реализацию поиска в дереве. Ключевая логика поиска
почти в точности повторяет логику вставки в дерево: сначала мы прове-
ряем два базовых варианта (мы нашли узел или ищем в пустом дереве),
а затем выясняем, в каком поддереве выполнять поиск, если не попадаем
в базовый вариант.
238    Глава 17. Двоичные деревья

node *search (node *p_tree, int key)


{
// Если мы достигаем пустого дерева, элемента здесь точно нет!
if(p_tree == NULL)
{
return NULL;
}
// Если мы нашли ключ, поиск завершен!
else if(key == p_tree->key_value)
{
return p_tree;
}
// В противном случае пытаемся найти ключ в левом
// или правом поддереве
else if(key < p_tree->key_value)
{
return search(p_tree->p_left, key);
}
else
{
return search(p_tree->p_right, key);
}
}

Приведенная функция поиска сначала проверяет два базовых варианта:


мы достигли конца ветви дерева либо нашли ключ. В обоих случаях мы
знаем, что вернуть — NULL при достижении конца дерева и само дерево при
обнаружении ключа.
Если мы не попали в базовый вариант, сводим задачу поиска ключа к его
поиску в одном из дочерних деревьев — левом или правом, в зависимости
от значения ключа. Обратите внимание, что при каждом рекурсивном вы-
зове во время поиска узла мы уменьшаем размер дерева примерно вдвое.
Я говорил об этом в начале главы, когда демонстрировал, что время поис-
ка в двоичном дереве пропорционально log2n и существенно меньше, чем
время поиска в связанном списке и массиве, если количество хранимых
данных очень велико.

Уничтожение дерева
Функция destroy_tree также должна быть рекурсивной. Следующий
алгоритм уничтожает два поддерева текущего узла, а затем и сам узел:

void destroy_tree(node *p_tree)


{
if(p_tree != NULL)
{
destroy_tree(p_tree->p_left);
Реализация двоичных деревьев    239

destroy_tree(p_tree->p_right);
delete p_tree;
}
}

Чтобы понять, как работает алгоритм, выведем на экран значение каждого


узла перед удалением:

void destroy_tree (node *p_tree)


{
if(p_tree != NULL)
{
destroy_tree(p_tree->p_left);
destroy_tree(p_tree->p_right);
cout << "Deleting node: " << p_tree->key_value;
delete p_tree;
}
}

Вы увидите, что удаление происходит снизу вверх. Сначала удаляются узлы


5 и 8, затем узел 6. После этого удаляется другая сторона дерева: узлы 11
и 18, а затем 14. Узел 10 удаляется последним после удаления всех его до-
черних элементов. Значения, хранящиеся в дереве, неважны, важно лишь
местоположение узла. Так выглядит двоичное дерево, в которое вместо
значений я ввел порядок удаления узлов:

Чтобы понять этот код, полезно вручную разобрать его работу на примере
пары деревьев.
Удаление узла из дерева — рекурсивный алгоритм, который весьма за-
труднительно реализовать в итеративной форме: придется написать цикл,
который должен каким-либо образом одновременно работать как с левой,
так и с правой ветвью дерева! Проблема в том, что необходимо удалять одно
поддерево, при этом отслеживая второе удаляемое поддерево, на каждом
240    Глава 17. Двоичные деревья

уровне дерева. В этом поможет стек. Каждый стековый фрейм фактически


хранит информацию о том, какая ветвь дерева уже удалена:
destroy_tree(<поддерево>)
destroy_tree(<дерево>) – знает о том, с какой стороны (левой или правой)
было расположено поддерево

Каждый стековый фрейм знает, какие части дерева требуется удалить,


поскольку ему известно, с какой точки функции будет продолжено вы-
полнение. При первом вызове destroy_tree стековый фрейм указывает
программе продолжить выполнение следующим вызовом destroy_tree.
При втором вызове стековый фрейм указывает программе продолжить
выполнение удалением дерева. Поскольку у каждого вызова функции есть
собственный стековый фрейм, он содержит всю текущую информацию об
уничтожении дерева по одному уровню за раз.
Единственный нерекурсивный способ реализовать этот алгоритм — создать
структуру данных, хранящую ту же информацию, что и стековый фрейм.
Например, можно имитировать стек, написав функцию, которая органи-
зует связанный список (эмулирующий стек) поддеревьев, находящихся
в процессе уничтожения.
В этом связанном списке содержится информация о том, какие стороны
дерева предстоит удалить. Затем создается циклический алгоритм добавле-
ния поддеревьев в этот список и их удаления из него после полного унич-
тожения. Другими словами, рекурсия дает возможность воспользоваться
преимуществами структуры данных встроенного стека вместо создания
собственной. В качестве упражнения предлагаю попытаться реализовать
нерекурсивный эквивалент функции destroy_tree . Вы убедитесь, на-
сколько проще выразить этот алгоритм, не создавая собственный стек, и в
результате существенно глубже усвоите рекурсию.

Удаление узлов из дерева


Алгоритм удаления из двоичного дерева сложнее. Его базовая структура
напоминает описанную ранее: если дерево пустое, задача решена; если
удаляемое значение находится в левом поддереве, задача сводится к его
удалению из левого поддерева, в противном случае — к удалению из право-
го поддерева. Если значение обнаружено, оно удаляется.
node* remove (node* p_tree, int key)
{
if(p_tree == NULL)
{
return NULL;
}
Реализация двоичных деревьев    241

if(p_tree->key_value == key)
{
// что делать?
}
else if(key < p_tree->key_value)
{
p_tree->left = remove(p_tree->left, key);
}
else
{
p_tree->right = remove(p_tree->right, key);
}
return p_tree;
}

С одним из базовых вариантов есть проблема: что именно необходимо сде-


лать, если найдено удаляемое значение? Вспомним, что двоичное дерево
должно удовлетворять следующим условиям.
Каждое значение в дереве слева от текущего узла должно быть меньше
его ключевого значения; каждое значение в дереве справа от текущего узла
должно быть больше его ключевого значения.
Необходимо рассмотреть три основных случая.
1. У удаляемого узла нет дочерних узлов.
2. У удаляемого узла есть один дочерний узел.
3. У удаляемого узла есть два дочерних узла.
Случай 1 самый простой: удаляя узел без дочерних узлов, нужно лишь
вернуть NULL. Случай 2 также несложен: если у узла единственный до-
черний узел, возвращаем его. Случай 3 сложнее: нельзя просто взять один
из дочерних узлов и поднять его. Например, что произойдет, если мы
воспользуемся узлом слева от элемента, который собираемся удалить?
Что при этом произойдет с элементами справа от этого узла? Возьмем
рассмотренный ранее пример дерева:
242    Глава 17. Двоичные деревья

Что будет, если удалить элемент 10? Нельзя просто заменить его элемен-
том 6, поскольку дерево примет следующий вид:

14

11 18

Теперь число 8, которое больше числа 6, находится слева от него. Это раз-
рушает дерево: поиск числа 8 будет происходить справа от числа 6 и никогда
не завершится удачно. По этой же причине нельзя взять элемент справа:

14

11

18

Здесь число 11, меньшее 14, находится справа от дерева, что недопустимо.
В двоичном дереве нельзя произвольным образом поднимать узлы.
Что же делать? Значения всех элементов слева от узла должны быть
меньше значения этого узла. Может быть, следует найти максимальное
значение слева от удаляемого узла и переместить его на вершину дерева?
Максимальное значение слева от дерева можно смело перенести на место
текущего узла: оно гарантированно больше любого другого узла слева от
него и, поскольку оно оказалось слева от дерева, с которым мы начали
работать, оно гарантированно меньше любого узла справа от него1.

По этой же причине в правой части дерева нельзя найти узел с наименьшим
1

значением. На практике хороший алгоритм не должен постоянно выбирать


какое-либо одно направление во избежание разбалансирования дерева, но мы
реализуем более простой вариант, не учитывающий этот аспект.
Реализация двоичных деревьев    243

В этом примере нужно в результате получить следующее дерево, поскольку


8 является наибольшим значением слева от 10:

14

11 18

Для этого требуется алгоритм, который находит самое большое значе-


ние в левой части дерева, — функция find_max. Мы реализуем функцию
find_max, воспользовавшись тем, что большие значения находятся в правом
поддереве. Мы можем просто обходить его, пока не получим NULL. Други-
ми словами, функция find_max вернет максимальное значение в дереве,
работая с ним как со связанным списком указателей на правые деревья:
node* find_max (node* p_tree)
{
if(p_tree == NULL)
{
return NULL;
}
if(p_tree->p_right == NULL)
{
return p_tree;
}
return find_max(p_tree->p_right);
}

Обратите внимание, что нам нужны два базовых варианта: один для отсут-
ствия дерева, а другой — для достижения конца списка дочерних деревьев
справа1. Чтобы возвращать указатель на последний узел, нужно заглядывать
на один узел вперед, пока есть действительный указатель.
Итак, попробуем воспользоваться нашими соображениями и написать
функцию remove. Если в базовом варианте функция find_max возвращает

1
В нашей реализации удаления узла проверка первого базового варианта (дерево
пусто) необязательна, но защита от некорректных данных является хорошим
тоном в программировании.
244    Глава 17. Двоичные деревья

NULL, можно просто заменить удаляемый узел деревом слева, поскольку


удаляемое значение максимально. В противном случае мы должны заме-
нить удаляемый узел результатом функции find_max.
node* remove (node* p_tree, int key)
{
if(p_tree == NULL)
{
return NULL;
}
if(p_tree->key_value == key)
{
// первые два случая обрабатывают узлы без дочерних
// узлов и с одним дочерним узлом
if(p_tree->p_left == NULL)
{
node* p_right_subtree = p_tree->p_right;
delete p_tree;
// этот оператор возвращает NULL, если число дочерних узлов
// равно нулю, но это то, что нам нужно
return p_right_subtree;
}
if(p_tree->p_right == NULL)
{
node* p_left_subtree = p_tree->p_left;
delete p_tree;
// этот оператор всегда возвращает допустимый узел, поскольку
// мы знаем, что p_tree->p_left не равен NULL
// благодаря предшествующему оператору if
return p_left_subtree;
}
node* p_max_node = find_max(p_tree->p_left);
p_max_node->p_left = p_tree->p_left;
p_max_node->p_right = p_tree->p_right;
delete p_tree;
return p_max_node;
}
else if(key < p_tree->key_value)
{
p_tree->p_left = remove(p_tree->p_left, key);
}
else
{
p_tree->p_right =
remove(p_tree->p_right, key);
}
return p_tree;
}

Но работает ли эта функция корректно? В ней есть неочевидная ошиб-


ка — на самом деле мы никогда не удаляем max_node из его исходного
Реализация двоичных деревьев    245

местоположения в дереве! Это значит, что где-то в дереве существует


указатель на max_node, который ведет обратно на верхний уровень дерева.
Более того, исходные дочерние деревья max_node больше не доступны.
Необходимо удалить узел max_node из дерева. К счастью, мы знаем, что
у max_node нет правого поддерева, но есть левое поддерево. Следовательно,
у max_node не более одного дочернего узла1, а этот случай легко обработать.
Нужно только изменить указатель родительского узла max_node, направив
его на левое поддерево max_node.
Можно написать простую функцию, которая принимает указатель на
max_node, корень дерева, содержащего max_node, и возвращает новое дерево,
которое надлежащим образом удаляет узел max_node. Обратите внимание,
что у узла max_node нет правого поддерева!
node* remove_max_node (node* p_tree, node* p_max_node)
{
// Безопасное программирование: мы не должны сюда попасть
if(p_tree == NULL)
{
return NULL;
}
// Мы нашли узел и можем заменить его
if(p_tree == p_max_node)
{
// Мы можем сделать это лишь потому, что знаем:
// указатель p_max_node->p_right равен NULL, и мы
// не теряем никакую информацию.
// Если у p_max_node нет левого поддерева,
// возвращаем NULL в этой ветви; это приведет
// к тому, что узел p_max_node будет заменен
// пустым деревом, что и требуется сделать.
return p_max_node->p_left;
}
// каждый рекурсивный вызов заменяет правое поддерево
// новым поддеревом, содержащим p_max_node.
p_tree->p_right =
remove_max_node(p_tree->p_right, p_max_node);
return p_tree;
}

Теперь с помощью этой вспомогательной функции можно легко изменить


функцию remove так, что она будет удалять узел с максимальным значе-
нием из левого поддерева раньше, чем заменять удаляемый узел узлом
с максимальным значением.

Мы должны сделать так, потому что значение max_node максимальное в под-
1

дереве, а следовательно, у него не может быть узла справа.


246    Глава 17. Двоичные деревья

node* remove (node* p_tree, int key)


{
if(p_tree == NULL)
{
return NULL;
}
if(p_tree->key_value == key)
{
// Первые два случая обрабатывают узлы
// без дочерних узлов и с одним дочерним узлом
if(p_tree->p_left == NULL)
{
node* p_right_subtree = p_tree->p_right;
delete p_tree;
// Этот оператор может вернуть NULL, если
// какие-либо дочерние узлы пустые, но это
// именно то, что нам требуется.
return p_right_subtree;
}
if(p_tree->p_right == NULL)
{
node* p_left_subtree = p_tree->p_left;
delete p_tree;
// Этот оператор всегда возвращает
// действительный узел: p_tree->p_left
// не равен NULL благодаря
// предшествующему оператору if
return p_left_subtree;
}
node* p_max_node = find_max( p_tree->p_left );
// Поскольку p_max_node заимствован
// из левого поддерева, нужно удалить его оттуда
// до того, как заново свяжем это поддерево
// с остальной частью дерева.
p_max_node->p_left =
remove_max_node(
p_tree->p_left,
p_max_node
);
p_max_node->p_right = p_tree->p_right;
delete p_tree;
return p_max_node;
}
else if(key < p_tree->key_value)
{
p_tree->p_left = remove(p_tree->p_left, key);
}
else
{
p_tree->p_right =
remove( p_tree->p_right, key );
}
return p_tree;
}
Реализация двоичных деревьев    247

Рассмотрим, как этот код обработает дерево из приведенного ранее примера:

10

14

11 18

Если мы решим удалить из дерева узел 10, функция remove сразу найдет
его. Она обнаружит, что у этого узла есть левое и правое поддерево, и опре-
делит узел с максимальным значением в поддереве с корнем 6. Значение
этого узла равно 8. Затем функция remove свяжет левое поддерево узла 8
с новым поддеревом с корнем 6, которое не содержит узел 8.
Удалить узел 8 из поддерева легко. Мы начинаем с поддерева

Первый вызов remove_max_node определяет, что удалять узел 6 неверно,


поэтому функция remove_max_node рекурсивно вызывается для поддерева
с корнем 8. Поскольку узел 8 является искомым, функция возвращает его
левое поддерево (NULL), и правый указатель узла 6 становится равным NULL.
Наше дерево имеет вид
248    Глава 17. Двоичные деревья

Теперь в вызове remove левый указатель узла 8 указывает на дерево, которое


возвращено функцией remove_max_node (показанной выше), поэтому наше
новое дерево выглядит следующим образом:

Правому указателю узла 8 присваивается адрес правого поддерева узла 14,


и наше дерево оказывается полностью перестроенным:

14

11 18

И наконец, мы удаляем исходный узел 10.


Файл binary_tree.cpp содержит весь исходный код, представленный в этой
главе, и простую программу, позволяющую выполнять различные действия
с деревом.

Двоичные деревья и реальный мир


Хотя я много рассуждал о скорости поиска данных, вы можете спросить:
действительно ли важно быстро находить конкретное значение в структуре
данных? Разве компьютеры не работают с достаточной скоростью? И при
каких условиях вообще стоит заниматься поиском данных?
Двоичные деревья и реальный мир    249

Существует две ситуации, в которых поиск данных необходим. Первая —


вам нужно проверить, содержится ли конкретное значение в структуре
данных. Допустим, вы создаете игру, в которой пользователь должен заре-
гистрироваться, и в процессе регистрации необходимо проверить, не занято
ли введенное имя другим игроком. В игре вроде World of Warcraft такая
проверка должна быть очень быстрой, поскольку количество пользователей
исчисляется миллионами. Поскольку имена пользователей на самом деле
являются строками, а не целыми числами, их сравнение занимает больше
времени, поскольку оно требует сопоставления каждой отдельной буквы.
Несколько таких сравнений будут выполнены довольно быстро, но не-
сколько миллионов сравнений окажутся весьма длительными. Хранение
имен пользователей в двоичных деревьях существенно ускорит процедуру
их регистрации. Если вы стремитесь вовлечь в игру новых пользователей,
их регистрация обязана быть простой.
Быстрый поиск необходим, когда с хранимым значением связана какая-
либо дополнительная информация. Такая структура данных называется
словарем (map). Словарь хранит ключ и связанное с ним значение (кото-
рое не обязательно является одним элементом данных: оно может быть
структурой, списком или другим словарем, если нужно хранить много
информации).
В качестве примера возьмем игру World of Warcraft. В любой сетевой игре
с большим количеством пользователей необходимо устанавливать соот-
ветствие между именами пользователей и паролями1, чтобы игроки могли
подключаться к игре и, возможно, сохранять статистику своих персонажей.
Когда вы вводите имя пользователя и пароль, World of Warcraft ищет ваше
имя, находит соответствующий пароль, сверяет его с введенным и, если он
верен, считывает параметры персонажа и подключает вас к игре.
Можно реализовать такой словарь с помощью двоичного дерева. Это
двоичное дерево использует ключ для вставки узлов (имя пользователя)
и хранит значение (пароль) в том же узле рядом с ключом.

На самом деле в словаре хранится не сам пароль, а его хэшированная версия.
1

Хэш — это алгоритм, преобразующий текстовую строку в другую строку или


число так, что исходное значение невозможно восстановить. В данном слу-
чае хэшированная версия пароля не позволяет узнать сам пароль. Хранение
паролей в хэшированной форме предотвращает их хищение из файла или
базы данных, в которых они хранятся. Алгоритмы, хэширующие пароли, обе-
спечивают крайне низкую вероятность совпадения строк, генерируемых для
двух различных паролей.
250    Глава 17. Двоичные деревья

Концепция словарей широко распространена в реальной жизни. Например,


словари необходимы компаниям, работающим с кредитными карточками.
При покупке по кредитной карте некоторые данные вашего банковского
счета изменяются. Количество владельцев кредитных карт исчисляется
сотнями миллионов. Если бы платежные системы сканировали весь на-
бор номеров кредитных карт при каждой транзакции, это парализовало
бы электронную торговлю во всем мире. В такой ситуации не обойтись
без очень быстрого поиска информации о банковском счете клиента по
номеру его кредитной карты. Чтобы создать такой метод, можно связать
номер кредитной карты с банковским счетом при помощи словаря, реали-
зованного в виде двоичного дерева.
Тогда любая транзакция будет сведена к простому поиску узла в двоичном
дереве и обновлению баланса, хранимого в этом узле. Если у вас миллион
кредитных карт, для поиска карты с нужным номером потребуется про-
смотреть в среднем log21 000 000 (то есть около 20) узлов. Это в 50 000 раз
эффективнее линейного сканирования списка узлов. Безусловно, компании,
которые работают с кредитными картами, используют структуры данных
еще сложнее описанных здесь двоичных деревьев. Так, вся информация
о банковских счетах должна постоянно храниться в базе данных, а не
временно располагаться в оперативной памяти. За простыми словарями
также могут скрываться более сложные и замысловатые структуры данных.
Важно, что концепции двоичного дерева и словари являются фундаментом
для создания более сложных структур.
Быстрый поиск данных важен и в ситуациях, когда объем данных не столь
велик. Вероятно, ваш мобильный телефон может отображать имя любого
звонящего вам человека, записанное в адресной книге. Эта функция должна
быстро находить имя по номеру телефона. Мне неизвестны подробности
реализации этой задачи в мобильных телефонах — возможно, адресные
книги не настолько объемны, что их оформление в виде двоичного дерева
обеспечивает существенные преимущества. Тем не менее в этом примере
используется организационная мощь словарей, которые часто реализуются
в форме двоичных деревьев для быстрого поиска информации1.

Стоимость деревьев и словарей


Для создания словаря с двоичным деревом требуется время. Каждый узел
необходимо добавить в дерево; для этого требуется в среднем log2n операций

Для реализации словаря используются и другие структуры данных, в том


1

числе хэш-таблицы.
Проверьте себя    251

(столько же, сколько для поиска узла, поскольку и при поиске, и при до-
бавлении узла дерево делится пополам). Таким образом, для построения
дерева понадобится n  log2n операций. При каждом линейном поиске
в связанном списке просматривается в среднем n/2 узлов, поэтому если
вы выполните 2*log2n операций поиска в связанном списке, то потратите
на них примерно столько же времени, сколько требуется для построения
двоичного дерева. (Почему? Суммарное время равно среднему времени
одного поиска, умноженному на количество операций поиска: n/2×2log2n =
= n log2n.) Другими словами, если вы собираетесь воспользоваться двоич-
ным деревом лишь однажды, нет смысла его создавать; если же оно будет
использоваться многократно, создайте его (даже если в словаре будет
миллион узлов, средняя производительность программы увеличится уже
после 40 операций поиска). Для компаний, обрабатывающих миллионы
транзакций по кредитным картам, это однозначно выгодно; для записной
книжки мобильного телефона выгода зависит от количества звонков
и размера адресной книги (выгоду нетрудно подсчитать самостоятельно).

Проверьте себя
1. В чем главное достоинство двоичного дерева?
А. Оно использует указатели.
Б. Оно может хранить любое количество данных.
В. Оно позволяет быстро искать данные.
Г. Из него легко удалить данные.
2. В каких случаях вы предпочтете использовать связанный список вместо
двоичного дерева?
А. Если необходимо обеспечить быстрый поиск данных.
Б. Если необходимо получать доступ к отсортированным элементам.
В. Если необходимо быстро добавлять элементы в начало или конец
структуры, при этом элементы в ее середине никогда не используются.
Г. Если не нужно освобождать используемую память.
3. Какое из нижеперечисленных высказываний верно?
А. Порядок добавления элементов в двоичное дерево может изменить
его структуру.
Б. Чтобы структура двоичного дерева была оптимальной, следует до-
бавлять в него элементы в отсортированном порядке.
В. Поиск элементов в связанном списке будет быстрее, чем в двоичном
дереве, если элементы вставляются в двоичное дерево в случайном по-
рядке.
252    Глава 17. Двоичные деревья

Г. Структура двоичного дерева никогда не может быть такой же, как


у связанного списка.
4. Какая из причин объясняет высокую скорость поиска узлов в двоичных
деревьях?
А. Двоичные деревья не обеспечивают быстрый поиск узлов — наличие
двух указателей увеличивает объем работы при обходе дерева.
Б. Спускаясь по дереву на один уровень, вы приблизительно вдвое со-
кращаете количество узлов, в которых требуется найти искомый узел.
В. Эффективность двоичных деревьев не выше, чем у связанных спи-
сков.
Г. Обработка двоичных деревьев с помощью рекурсивных вызовов про-
исходит быстрее, чем обработка связанных списков с помощью циклов.
(Решения см. на с. 464.)

Практические задания
1. Напишите программу, которая отображает содержимое двоичного дере-
ва. Можете ли вы написать программу, которая выводит узлы двоичного
дерева в отсортированном порядке? В обратном порядке сортировки?
2. Напишите программу, которая подсчитывает число узлов двоичного
дерева.
3. Напишите программу, которая проверяет, сбалансировано ли двоичное
дерево.
4. Напишите программу, которая проверяет, правильно ли отсортировано
двоичное дерево (значения всех его узлов слева от определенного узла
меньше значения этого узла, а значения всех узлов справа — больше).
5. Напишите программу, которая удаляет все узлы двоичного дерева, не
используя рекурсию.
6. Реализуйте в виде двоичного дерева простой словарь, который хранит
содержимое адресной книги. Ключом карты должно быть имя человека,
а значением — адрес его электронной почты. Обеспечьте возможность
добавлять, удалять, обновлять и находить в карте адреса электрон-
ной почты. При завершении программы содержимое адресной книги
должно быть очищено. Напоминание: для сравнения двух строк можно
использовать любые стандартные операторы сравнения C++: ==, < и >.
Г лава 1 8
Стандартная библиотека шаблонов

Создавать собственные структуры данных — отличное занятие, но это


приходится делать не так часто, как могло показаться в предыдущей гла-
ве этой книги. Не беспокойтесь, я заставил читать ее не просто так! Вы
многое знаете о том, как создавать собственные структуры данных, когда
они действительно нужны; вы изучили свойства нескольких распростра-
ненных структур данных, и есть случаи, когда резонно реализовать их
самостоятельно.
При этом важная возможность C++ (ее нет в C) — большая библиотека
повторно используемого кода, поставляемая вместе с компилятором. Это
стандартная библиотека шаблонов, или STL (standard template library).
Стандартная библиотека шаблонов содержит коллекцию распространенных
структур данных, в том числе связанные списки, и несколько структур дан-
ных на основе двоичных деревьев. При создании каждой из этих структур
можно задавать тип хранящихся в ней данных, чтобы использовать их для
хранения любых данных — целых чисел, строк или структурированных
данных.
Благодаря такой гибкости стандартная библиотека шаблонов часто из-
бавляет от необходимости создавать собственные структуры данных для
базовых нужд программирования. Фактически STL делает код программы
более высокоуровневым в силу следующих причин.
1. К программам можно подходить с точки зрения требуемых структур
данных, не беспокоясь, удастся ли реализовать их самостоятельно.
254    Глава 18. Стандартная библиотека шаблонов

2. У вас есть доступ к первоклассным реализациям этих структур данных


с производительностью и использованием памяти, эффективными для
большинства задач.
3. Не нужно беспокоиться о выделении и освобождении памяти для ис-
пользуемых структур данных.
Тем не менее работа со стандартной библиотекой шаблонов связана с не-
которыми затратами и ограничениями.
1. Придется изучать интерфейсы стандартной библиотеки шаблонов и их
использование.
2. Придется исправлять труднообнаруживаемые ошибки компилятора,
которые генерируются при неправильном использовании STL.
3. Не каждая нужная вам структура данных реализована в STL.
STL — большая тема, которой посвящены сотни книг, поэтому я не смогу
рассмотреть ее полностью1. Цель этой главы — кратко рассказать о самых
полезных и распространенных структурах данных STL. В дальнейшем
я буду пользоваться этими структурами по мере необходимости.

Вектор — массив переменного размера


Библиотека STL включает вектор — тип данных, заменяющий массив. Век-
тор STL очень похож на массив, но его размер можно изменять, не заботясь
о нюансах выделения памяти и переноса существующих элементов вектора.
Однако у вектора и массива различный синтаксис — сравним их объяв-
ление.
int an_array[10];

и
#include <vector>
using namespace std;
vector<int> a_vector(10);

Во-первых, для работы с векторами необходимо включить в программу


заголовочный файл vector и воспользоваться пространством имен std,
поскольку вектор входит в состав стандартной библиотеки, аналогично
объектам cin и cout.

Почитайте дополнительно: Майерс С. Эффективное использование STL. —


1

СПб.: Питер, 2002.


Вектор — массив переменного размера    255

Во-вторых, при объявлении вектора необходимо в угловых скобках указать


тип данных, который будет храниться в векторе:
vector<int>

Этот синтаксис использует возможность языка C++, которая называется


шаблонами (отсюда название библиотека шаблонов). Вектор может хранить
любые данные, надо лишь указать компилятору их тип. Другими словами,
здесь используются два типа данных: один тип относится к структуре дан-
ных и определяет способ их организации, другой — к данным, хранящимся
в этой структуре. Шаблоны позволяют сочетать различные типы структур
данных и хранящихся в них типов данных.
Наконец, размер вектора указывается в круглых скобках, а не в квадратных:
vector<int> a_vector(10);

Этот синтаксис используется при инициализации определенных видов


переменных — в данном случае мы передаем значение 10 в процедуру
инициализации, которая называется конструктором и создает вектор разме-
ра 10. В следующих главах мы подробнее познакомимся с конструкторами
и объектами, их использующими.
После создания вектора доступ к его отдельным элементам можно полу-
чить так же, как к элементам массива:
for(int i = 0; i < 10; i++)
{
a_vector[i] = 0;
an_array[i] = 0;
}

Вызов методов векторов


Векторы обеспечивают гораздо более широкие функции, чем массивы. На-
пример, можно добавлять элементы за пределы вектора. Такие операции
обеспечиваются функциями в составе вектора с синтаксисом, отличающим-
ся от того, что мы видели ранее. Векторы используют возможность языка
C++, которая называется методом. Метод представляет собой функцию,
которая объявлена вместе с типом данных (в нашем случае — с вектором).
Вызов метода использует новый синтаксис:
a_vector.size();

Этот код вызывает метод size объекта a_vector и возвращает размер век-
тора. Это немного напоминает доступ к полю структуры, однако вместо
него вызывается метод, принадлежащий этой структуре. Хотя очевидно,
256    Глава 18. Стандартная библиотека шаблонов

что метод size работает с объектом a_vector, не нужно передавать этому


методу a_vector как аргумент. Синтаксис метода передает объект a_vector
в size как неявный аргумент.
Синтаксис
<переменная>.<вызов функции>(<аргументы>);

можно рассматривать как вызов функции, которая принадлежит типу


переменной. Другими словами, это примерно то же, что конструкция
<вызов функции>(<переменная>, <аргументы>);

В нашем примере вызов


a_vector.size();

аналогичен
size(a_vector);

В следующих главах мы гораздо подробнее рассмотрим методы, их объ-


явление и использование. Сейчас просто имейте в виду, что есть много
методов обработки векторов и для их вызова необходимо использовать
этот особый синтаксис. Этот синтаксис — единственный способ вызова
таких функций, нельзя использовать конструкции вида size(a_vector).
Другие возможности векторов
Итак, каковы же замечательные возможности векторов? Векторы позволя-
ют легко увеличивать количество хранящихся в них значений, не прибегая
к рутинным операциям выделения памяти. Например, чтобы добавить
элементы в вектор, нужно написать
a_vector.push_back(10);

Этот оператор добавляет в вектор один новый элемент, или, конкретнее,


добавляет элемент 10 в конец текущего вектора. Все действия для измене-
ния размера вектора выполняются за вас! Для выполнения аналогичной
операции над массивом нужно выделить новую память, скопировать в нее
все существующие значения и лишь затем добавить требуемый элемент.
Разумеется, векторы выделяют память и копируют элементы с помощью
внутренних операций, но они выделяют столько памяти, сколько нужно,
чтобы не выделять ее при каждом добавлении новых элементов.
Предупреждение: хотя можно добавлять элементы в конец вектора с по-
мощью метода push_back, нельзя достичь того же эффекта, просто вос-
пользовавшись квадратными скобками. В этом странность языка C++:
Словари    257

квадратные скобки способны работать лишь с выделенными данными.


Скорее всего, это вызвано желанием избежать выделения памяти без ве-
дома разработчика. По этой причине код
vector<int> a_vector(10);
a_vector[10] = 10; // последним действительным элементом является 9

не работает — он опасен и может привести к аварийному завершению про-


граммы. При этом код
vector<int> a_vector(10);
// добавляем новый элемент в вектор
a_vector.push_back(10);

изменит размер элемента, сделав его равным 11.

Словари
Мы уже немного поговорили о концепции словарей, которая позволяет
искать одно значение по другому значению. Этот прием постоянно ис-
пользуется в программировании — например, для поиска электронного
адреса по имени владельца, информации о банковском счете по номеру
счета или для регистрации пользователя в компьютерной игре.
Стандартная библиотека шаблонов поддерживает очень удобный тип map
(словарь), который позволяет задавать типы ключей и значений словаря.
Например, структура данных, хранящая простую адресную книгу (вроде
созданной при работе над упражнениями последней главы), может быть
реализована следующим образом:
#include <map>
#include <string>
using namespace std;
map<string, string> name_to_email;

Здесь необходимо указать два типа данных, связанных со словарем, —


string для ключа и string для значения, которое в данном случае хранит
адрес электронной почты.
Очень полезная особенность словарей STL: тот же синтаксис, что и для
массивов!
Значение добавляется в словарь так же, как и в массив, но вместо целого
числа используется тип ключа:
name_to_email[ "Alex Allain" ] = "webmaster@cprogramming.com";
258    Глава 18. Стандартная библиотека шаблонов

Значение считывается из словаря почти так же:


cout << name_to_email["Alex Allain"];

Это очень удобно — пользоваться словарем как массивом, но при этом


хранить данные любого типа! Кроме того, в отличие от работы с векторами,
вы даже не обязаны задавать размер словаря перед тем, как воспользуетесь
оператором [] для добавления элементов.
Так же легко удалять элементы из словаря.
Допустим, вы решили, что больше никогда не будете писать мне электрон-
ные письма: в этом случае можно просто удалить меня из адресной книги
с помощью метода erase:
name_to_email.erase(“Alex Allain”);

Всего хорошего!
Проверить размер словаря можно при помощи метода size:
name_to_address.size();

Проверить, пуст ли словарь, можно методом empty:


if(name_to_address.empty())
{
cout << "You have an empty address book. Don't you wish you hadn't
deleted Alex?";
}

Не путайте этот код с очисткой словаря методом clear:


name_to_address.clear();

Кстати, контейнеры STL используют одинаковое соглашение об именах,


поэтому методы clear, empty и size можно применять как к векторам, так
и к словарям.

Итераторы
Иногда необходимо не только хранить данные и получать доступ к от-
дельным элементам, но и перебирать все элементы в конкретной структуре
данных. Работая с массивом или вектором, можно прочитать каждый от-
дельный элемент, используя длину массива. А что делать со словарями?
Поскольку в словарях часто используются нечисловые ключи, перебирать
их с помощью переменной-счетчика не всегда возможно.
Итераторы    259

Для решения этой проблемы в STL существуют итераторы. Итератор — это


переменная, которая может получить доступ к каждому элементу любой
структуры данных, даже если сама эта структура не обеспечивает столь
простой доступ. Для начала мы рассмотрим работу итераторов с вектора-
ми, а затем — с элементами словаря. Итератор хранит текущую позицию
в структуре данных и обеспечивает доступ к конкретному элементу. Пере-
меститься на следующий элемент структуры данных можно, вызвав метод
итератора.
Для объявления итератора используется необычный синтаксис. Так вы-
глядит объявление итератора для вектора целых чисел:
vector<int>::iterator

Такой синтаксис говорит, что есть vector<int> и нужно создать итератор,


работающий с этим типом (отсюда конструкция ::iterator). Итак, как
пользоваться итератором? Поскольку итератор отмечает позицию в струк-
туре данных, опрашивается итератор из самой этой структуры:

vector<int> vec;
vec.push_back(1);
vec.push_back(2);
vector<int>::iterator itr = vec.begin();

Вызов метода begin возвращает итератор, который обеспечивает доступ


к первому элементу вектора. Можно увидеть сходство итератора с указа-
телем: итератор определяет местоположение элемента в структуре данных,
и его можно использовать для считывания этого элемента. В этом случае
можно считать первый элемент вектора с помощью синтаксиса
cout << *itr; // выводим первый элемент вектора

Оператор * используется так же, как с указателем. Это имеет определенный


смысл: и итератор, и указатель хранят данные о местоположении.
Чтобы считать следующий элемент вектора, итератор инкрементируется:
itr++;

Это дает итератору указание перейти к следующему элементу вектора.


Можно использовать и префиксный оператор:
++itr;
260    Глава 18. Стандартная библиотека шаблонов

С некоторыми итераторами этот подход работает несколько продуктивнее1.


Проверить, находитесь ли вы в конце итерации, можно, сравнив итератор
с конечным итератором, определяемым вызовом
vec.end();

Перебрать весь вектор может следующий код:


for(vector<int>::iterator itr = vec.begin();
itr != vec.end();
++itr)
{
cout << *itr << endl;
}

Этот код создает итератор, считывает первый элемент вектора целых чи-
сел, и, пока интератор не равен конечному итератору, происходит перебор
вектора и вывод каждого элемента.
Можно внести в этот цикл несколько небольших улучшений. Следует из-
бегать вызова vec.end() на каждой итерации цикла:
vector<int>::iterator end = vec.end();
for(vector<int>::iterator itr = vec.begin();
itr != end;
++itr)
{
cout << *itr << endl;
}

Можно поместить несколько переменных в первую часть цикла и сделать


код удобнее для восприятия:
for(vector<int>::iterator itr = vec.begin(),
end = vec.end();
itr != end; ++itr)
{
cout << *itr << endl;
}

Очень похожим алгоритмом можно воспользоваться для перебора словаря.


Словарь хранит не только значение, но и ключ. Как получить из итератора

Префиксный оператор (++itr) возвращает значение после инкрементирования,


1

постфиксный оператор (itr++) должен вернуть предшествующее значение itr,


а следовательно, его необходимо сохранить. Префиксный оператор уже имеет
значение, которое необходимо вернуть, поскольку результат операции известен.
Итераторы    261

значение? При разыменовании итератор содержит два поля: first и second,


соответственно ключ и значение.
int key = itr->first; // считываем из итератора ключ
int value = itr->second; // считываем из итератора значение

Рассмотрим код, который отображает содержимое словаря в удобном


формате:
void displayMap(map<string, string> map_to_print)
{
for(map<string, string>::iterator itr =
map_to_print.begin(),
end = map_to_print.end();
itr != end;
++itr)
{
cout << itr->first << " --> "
<< itr->second << endl;
}
}

Этот код, отображающий содержимое словаря, очень похож на код, пере-


бирающий векторы. Единственное различие в том, что в этом коде исполь-
зуется словарь и поля итератора first и second.

Проверка существования значения в словаре


Иногда необходимо проверить, хранится ли указанный ключ в словаре.
Например, чтобы найти человека в адресной книге, поинтересуемся, есть
ли он в ней вообще. Метод словаря find проверяет существование значения
в словаре и считывает его, если оно найдено. Метод find возвращает ите-
ратор с положением объекта с заданным ключом либо конечный итератор,
если не удалось найти объект.
map<string, string>::iterator itr =
name_to_email.find( "Alex Allain" );
if(itr != name_to_email.end())
{
cout << "How nice to see Alex again. His email is: "
<< itr->second;
}

Впрочем, к элементу словаря, которого нет в списке, можно получить до-


ступ с помощью обычных квадратных скобок:
name_to_email["John Doe"];
262    Глава 18. Стандартная библиотека шаблонов

Если значение еще не существует, словарь автоматически вставит пустой


элемент. Таким образом, чтобы проверить существование значения в карте,
используйте метод find; в остальных случаях можно обойтись квадратны-
ми скобками.

Заключение об STL
Тема STL в этой главе далеко не исчерпана, но теперь у вас достаточно
знаний, чтобы пользоваться многими ее ключевыми типами данных. Тип
vector полностью заменяет массивы и может использоваться вместо связан-
ных списков, если несущественно время вставки или изменения элемента
списка. Массивы очень редко имеют преимущества перед векторами — как
правило, только в сложных ситуациях, например при вводе-выводе файлов.
Тип map, возможно, наилучший из существующих — я постоянно использую
словареподобные структуры, существенно упрощающие сложные про-
граммы и избавляющие от необходимости создавать различные структуры
данных. Словари позволяют концентрироваться на конкретной задаче. Они
часто заменяют двоичные деревья. Как правило, разработчик не реализует
двоичные деревья самостоятельно, если только нет особых требований
к быстродействию программы и структуре дерева. В этом мощь STL:
примерно в 80% случаев библиотека предоставляет ключевые структуры
данных и дает возможность заниматься решением целевой задачи про-
граммы. В остальных 20% случаев надо знать, как строить собственные
структуры данных.
Некоторые программисты страдают синдромом «придумано не здесь» —
стремлением использовать свой, а не чужой код. Как правило, не следует
реализовывать собственные структуры данных, поскольку встроенные
структуры чаще всего работают лучше, быстрее и полнее реализованы.
Тем не менее зная, как их создавать, вы гораздо глубже разбираетесь в их
использовании и при необходимости можете сформировать собственные
структуры данных.
Когда же возникает такая необходимость? Предположим, вы хотите создать
небольшой калькулятор для ввода арифметических выражений и вычис-
лений с соблюдением приоритета операций. Например, калькулятор полу-
чает выражение 5 * 8 + 9 / 3 и при вычислении выполняет умножение
и деление раньше сложения.
Естественный способ решения этой задачи — дерево. Представить выра-
жение 5 * 8 + 9 / 3 в виде дерева можно следующим образом:
Проверьте себя    263

Каждый узел вычисляется одним из двух способов.


1. Если это число, возвращается его значение.
2. Если это оператор, вычисляются значения двух поддеревьев и выпол-
няется операция.
Чтобы построить такое дерево, необходимо напрямую работать со струк-
турами данных: нельзя решить эту задачу, используя лишь словарь. Если
STL — ваш единственный инструмент, справиться с написанием програм-
мы нелегко, если же вы понимаете двоичные деревья и рекурсию, задача
существенно упрощается.

Дополнительная информация об STL


Чтобы узнать больше о возможностях библиотеки STL, я рекомендую
воспользоваться следующими ресурсами.
У SGI есть веб-сайт с большим объемом документации об STL: http://www.
sgi.com/tech/stl/. Другой хороший ресурс — книга Скотта Майера, в которой
описаны многие концепции и идиомы STL. На сайте http://en.cppreference.
com/w/cpp представлена информация о многих элементах STL. Хотя цель
этого сайта — не начальное ознакомление читателей с STL, он содержит
много справочных материалов о стандартной библиотеке C++.

Проверьте себя
1. Когда уместно использовать вектор?
А. Для хранения связи ключа и значения.
Б. Для максимального быстродействия при изменении коллекции
элементов.
264    Глава 18. Стандартная библиотека шаблонов

В. Если программист хочет избавить себя от нюансов обновления


структуры данных.
Г. Использование вектора всегда уместно.
2. Как одновременно удалить все элементы из словаря?
А. Присвоить элементу пустую строку.
Б. Вызвать метод erase.
В. Вызвать метод empty.
Г. Вызвать метод clear.
3. Когда следует реализовывать собственные структуры данных?
А. Когда требуется обеспечить высокое быстродействие программы.
Б. Когда требуется обеспечить высокую надежность программы.
В. Когда требуется напрямую работать со структурой данных, напри-
мер строить дерево выражений.
Г. Реализовывать собственные структуры данных можно лишь ради
удовольствия.
4. Какая из конструкций корректно объявляет итератор, который можно
использовать с вектором vector<int>?
А. iterator<int> itr;
Б. vector::iterator itr;
В. vector<int>::iterator itr;
Г. vector<int>::iterator<int> itr;
5. Что из нижеперечисленного получает доступ к ключу элемента, на
который в текущий момент указывает итератор карты?
А. itr.first
Б. itr->first
В. itr->key
Г. itr.key
6. Как определить, можно ли использовать итератор?
А. Сравнить его с NULL.
Б. Сравнить его со значением, возвращенным методом end() контей-
нера, перебор которого выполняется.
В. Сравнить его с 0.
Г. Сравнить его со значением, возвращенным методом begin() контей-
нера, перебор которого выполняется.
(Решения см. на с. 465.)
Практические задания    265

Практические задания
1. Создайте небольшую программу для работы с адресной книгой, которая
позволяет пользователям вводить имена и адреса электронной почты,
удалять, изменять и перечислять записи в адресной книге. Не беспо-
койтесь о сохранении адресной книги на диск; можно терять введенные
данные при выходе из программы1.
2. Реализуйте список лучших результатов видеоигры с помощью векторов.
Результаты должны обновляться автоматически, а новые результаты до-
бавляться в правильную позицию в списке. Дополнительные операции
над векторами описаны на приведенном выше веб-сайте SGI.
3. Напишите программу, которая предоставляет две функции: регистра-
цию пользователя и вход в систему. Регистрация позволяет пользова-
телю создать логин и пароль, а вход в систему — авторизоваться и по-
лучить доступ к функциям «Изменить пароль» и «Выйти из системы».
Функция «Изменить пароль» дает пользователю возможность задать
новый пароль, а функция «Выйти из системы» возвращает его к ис-
ходному экрану.

Это справедливо лишь потому, что вы и программист, и пользователь. 


1
Г лава 1 9
Еще о строках

Итак, мы рассмотрели огромный объем материала — от души поздрав-


ляю с успехом! Немного отдохнем от изучения новых структур данных
и вернемся к уже известной структуре — обычным строкам. Несмотря на
простоту, строки используются очень широко, многие программы почти
целиком заняты считыванием и модификацией строк. Программы нередко
отображают на экране строки, вводимые пользователем, однако чаще их
приходится определенным образом интерпретировать. Например, при
реализации функции поиска может потребоваться извлекать из строк
некоторые значения. Возможно, вы считываете набор табличных данных,
разделенных запятыми, создаете список рекордов или реализуете интер-
фейс для текстовой приключенческой игры. Одно из самых популярных
приложений, которым вы пользуетесь каждый день, веб-браузер, в первую
очередь представляет собой гигантский обработчик строк — веб-страниц
в формате HTML. Решение всех этих задач требует не только считывания
строк и вывода их на экран целиком.
Строки могут быть очень длинными и хранить в памяти большое количе-
ство символов. Для создания максимально эффективных программ даже
в условиях, когда их функции передают друг другу строки, есть известные
возможности языка C++, в особенности ссылки. В этой главе речь о раз-
личных операциях над строками и о сохранении высокого быстродействия
программ, использующих строки. В разделе «Практические задания» вы
напишете несколько интересных программ и освоите мощные возмож-
ности строк.
Считывание строк    267

Считывание строк
Иногда необходимо целиком считывать строку, введенную пользователем,
а не отдельные слова, разделенные пробелами.
Для этого используется функция getline. Функция getline принимает
входной поток и считывает из него текстовую строку. Примером входного
потока является объект cin, с помощью которого вы обычно считываете
отдельные слова. (Открою маленький секрет: метод cin представляет собой
тип данных «входной поток», аналогичный строке или вектору, а cin>> —
метод, считывающий данные. Объяснять это в первой главе было бы не
лучшей идеей!)
Приведем простой пример, который считывает одну строку, вводимую
пользователем:

Пример 41: getline.cpp


#include <iostream>
#include <string>
using namespace std;
int main()
{
string input;
cout << "Please enter a line of text: ";
getline(cin, input, '\n');
cout << "You typed in the line " << '\n' << input;
}

Эта программа считывает последовательность символов в строку input,


пока не будет введен символ перехода на новую строку (то есть пока поль-
зователь не нажмет Enter).
Сам символ новой строки отбрасывается — введенные данные содержат все
предшествующие ему символы. Чтобы символ новой строки присутствовал
в строке, надо добавить его дополнительно. Маркером окончания считыва-
ния может служить не только символ новой строки, но и любой символ (его
называют разделителем, поскольку он отделяет считываемые символы от
последующего ввода). Чтобы функция getline завершилась, пользователь
должен нажать Enter, однако считан будет лишь текст до разделителя.
Рассмотрим пример, который показывает, как считать текст в формате
CSV (comma separated values — значения, разделенные запятыми). Данные
в формате CSV выглядят следующим образом:
Sam, Jones, 40 Asparagus Ave, New York, New York, USA
268    Глава 19. Еще о строках

Каждая запятая выделяет один раздел данных. Файл CSV напоминает


электронную таблицу, столбцы которой разделены запятыми. Напишем
программу, которая считывает вводимый пользователем список участников
видеоигры. Данные списка имеют следующий формат CSV:
<имя игрока>,<фамилия игрока>,<класс игрока>

Прочтя позднее раздел о вводе-выводе файлов, вы сможете считывать фай-


лы CSV с диска, немного изменив эту программу, а пока будем принимать
данные, которые вводит пользователь. Эта программа завершается, когда
задается пустое имя игрока.

Пример 42: csv.cpp


#include <iostream>
#include <string>
using namespace std;
int main()
{
while(1)
{
string first_name;
getline(cin, first_name, ',' );

if(first_name.size() == 0)
{
break;
}
string last_name;
getline(cin, last_name, ',');

string player_class;
getline(cin, player_class, '\n');
cout << first_name << " " << last_name
<< " is a " << player_class << endl;
}
}

Обратите внимание: к строке применяется метод size, определяющий,


пуста ли она. К строкам можно применять и множество других методов.

Длина строк и доступ к отдельным элементам


Для определения длины строки можно воспользоваться функцией length
или size, которую вы только что видели. Эти функции входят в класс
string, и каждая возвращает количество символов в строке:
string my_string1 = "ten chars.";
int len = my_string1.length(); // or .size();
Поиск и подстроки    269

Между методами size и length нет разницы — используйте тот, который


больше нравится1.
Строки поддерживают числовое индексирование, используемое в масси-
вах. Например, можно перебрать все символы строки, обращаясь к ним
по индексу так, будто строка является массивом. Это полезно при работе
с отдельными символами строки — например, при поиске в ней запятой
или другого конкретного символа.
Обратите внимание: здесь важно использовать метод length или size,
чтобы не выйти за пределы строки. Выходить за пределы строки, как и за
пределы массива, рискованно.
Ниже приведен небольшой пример, который демонстрирует, как отобразить
строку путем ее циклической обработки:

for(int i = 0; i < my_string.length(); i++)


{
cout << my_string[i];
}

Поиск и подстроки
Класс string поддерживает простой поиск и получение подстрок при
помощи методов find, rfind и substr. Метод find принимает подстроку
и позицию в исходной строке и обнаруживает первый экземпляр подстроки
в строке, начиная с указанной позиции. Результатом является индекс перво-
го экземпляра подстроки или специальное целое значение string::npos,
которое показывает, что найти подстроку не удалось.
В следующем примере выполняется поиск всех экземпляров «cat» в за-
данной строке и подсчитывается их количество.

Пример 43: search.cpp


#include <iostream>
#include <string>
using namespace std;
int main()
продолжение 
1
Существование двух методов объясняется тем, что size используется во всех
контейнерных объектах STL, поэтому метод size поддерживается для совме-
стимости. Для большинства программистов, работающих со строками, length
(длина) звучит естественнее.
270    Глава 19. Еще о строках

{
string input;
int i = 0;
int cat_appearances = 0;

cout << "Please enter a line of text: ";


getline(cin, input, '\n');

for(i = input.find( "cat", 0 );


i != string::npos;
i = input.find("cat", i))
{
cat_appearances++;
// перемещаемся за последний обнаруженный экземпляр,
// чтобы повторно не обнаружить одну и ту же строку
i++;
}
cout << "The word cat appears " << cat_appearances
<< " in the string " << '"' << input << '"';
}

Чтобы поиск подстроки начинался с конца строки, воспользуйтесь функ-


цией rfind. Она выполняет поиск с заданной позиции, но не в прямом,
а в обратном направлении (при этом сопоставление строк по-прежнему
происходит слева направо; другими словами, rfind с аргументом "cat" не
будет искать подстроку «tac»).
Функция substr создает новую строку, которая содержит фрагмент за-
данной строки, определяемый начальной позицией и длиной:
// пример прототипа
string substr (int position, int length);

Например, чтобы извлечь из строки первые десять символов, можно на-


писать следующий код:
#include <iostream>
#include <string>

using namespace std;

int main()
{
string my_string = "abcdefghijklmnop";
string first_ten_of_alphabet =
my_string.substr(0, 10);
cout << "The first ten letters of the alphabet are "
<< first_ten_of_alphabet;
}
Передача по ссылке    271

Передача по ссылке
Строки могут содержать много данных и иметь большую длину. Разумеется,
это не относится к каждой строке, но строковые параметры рекомендуется
передавать по ссылке:
void printString (string& str);

Напоминаю, что ссылочный параметр похож на указатель: в функцию


передается не копия строковой переменной, а ссылка на ее оригинал:
string str_to_show = "there is one x in this string";
printString(str_to_show);

Здесь функция printString принимает адрес переменной str_to_show,


а не ее копию. Параметр str можно использовать как исходную строку.
Тем не менее у передачи по ссылке есть недостаток: получив адрес пере-
менной, функция может ее изменить. Скорее всего, вы не допустите эту
ошибку при написании функции, однако по мере добавления новых воз-
можностей в программу можно забыть, что переданную переменную нельзя
изменять, и изменить ее. Программист, который вызовет вашу функцию,
будет очень удивлен, когда увидит, что она изменила его данные!
В C++ есть механизм предотвращения случайной модификации ссылочных
параметров. Функция может указать, что ссылка является константной.
Константная ссылка в C++ обозначается ключевым словом const. Кон-
стантную ссылку можно считывать, но нельзя изменять.
void print_string(const string& str)
{
cout << str; // корректно
str = "abc"; // некорректно!
}

Добавляя ссылочный параметр в функцию, определите, должна ли она из-


менять эту ссылку. Если не хотите, чтобы параметр изменялся, пометьте его
как константный, и функция не сможет его модифицировать. Использова-
ние ключевого слова const четко указывает, что он не подлежит изменению.
Константными могут быть не только ссылки. Те же действия можно вы-
полнить с памятью, на которую указывает указатель. Тогда можно написать:
void print_ptr(const int* p_val)
{
if(p_val == NULL ) // OK, p_val указывает на
// неизменяемую память
продолжение 
272    Глава 19. Еще о строках

{
return;
}
cout << *p_val; // OK, доступ к памяти корректен
*p_val = 20; // НЕ ОК, p_val указывает на измененную
// память
p_val = NULL; // ОК, изменяем не память, а только
// указатель
}

Обратите внимание, что компилятор очень сообразителен и сообщает,


присваивает ли код значение памяти, на которую указывает указатель.
Он определяет не только разыменование указателя, но и действия, кото-
рые программа выполняет над ссылкой. Совершенно законно изменять
сам указатель, поскольку соответствующее ему значение копируется,
и изменение p_val никак не влияет на переменную, передаваемую
в функцию.
Можно шире использовать константы для документирования и защиты
переменных от изменения. Если попытаться изменить значение констант-
ной переменной, компилятор укажет на недопустимость такого действия.
Объявляя константную переменную, следует немедленно присвоить ей
значение (поскольку ее больше нельзя изменить).

const int x = 4; // присвоение допустимо в момент создания


// переменной
x = 4; // присвоение недопустимо, поскольку нельзя вносить
// изменения

Рекомендуется использовать ключевое слово const везде, где это воз-


можно. Объявляя переменную константной, вы существенно упрощаете
чтение остальной части кода, поскольку знаете, что никто не изменит эту
переменную. Видя, что константной переменной присваивается значение,
можете быть уверены, что никто не изменит его в будущем. Вы избавляетесь
от необходимости следить, чтобы это значение оставалось постоянным.
Можно концентрироваться на том, что происходит с неконстантными
переменными и их значениями. Кроме того, ключевое слово const гаран-
тирует, что вы случайно не измените константную переменную, а вместе
с ней и поведение кода, который рассчитан на то, что переменная сохраняет
значение, присвоенное ей в момент создания.
Например, если код запрашивает имя и фамилию пользователя и создает
новую строку с его полным именем, эту строку следует сделать константой,
поскольку изменять ее не следует.
Передача по ссылке    273

Константы похожи на вирус


Константы похожи на вирус. Объявленную переменную с ключевым словом
const нельзя передать по ссылке в метод, принимающий неконстантные
ссылки. Ее нельзя передать и по указателю в метод, принимающий не-
константный указатель, поскольку этот метод может попытаться изменить
значение с помощью указателя. Переменные const X* и X* имеют разные
типы, так же как const X& (ссылка на X) и X&. Можно преобразовать X*
в  const X*, а X& в const X&, но нельзя поступить иначе. Например, при-
веденный ниже метод никогда не скомпилируется:
void print_nonconst_val(int& p_val)
{
cout << p_val;
}
const int x = 10;
// не компилируется: нельзя передать const int
// в функцию, принимающую неконстантную ссылку
print_nonconst_val(x);

Это ограничение распространяется только на ссылки и указатели, исходное


значение которых используется совместно. При копировании переменной
(например, при передаче по значению) параметр функции необязательно
объявлять константным:
void print_nonconst_val(int val)
{
cout << val;
}
const int x = 10;
// переменная x копируется, и неважно, что переменная val
// не константна: она локальна
// для функции print_nonconst_val
print_nonconst_val(x);

Таким образом, если вы объявили одну переменную константной, не ис-


ключено, что придется объявить константными и другие переменные,
в особенности указатели и ссылки, являющиеся параметрами функций.
Использование ключевого слова const может вызвать затруднения при
работе с библиотекой или с набором вспомогательных методов, не ис-
пользующих константные переменные. С другой стороны, при создании
библиотеки или собственных вспомогательных методов следует исполь-
зовать ключевое слово const, чтобы код, вызывающий ваши методы, мог
тоже пользоваться преимуществами константных переменных.
274    Глава 19. Еще о строках

Стандартная библиотека C++ рассчитана на константные переменные,


поэтому можно уверенно использовать их как в собственном коде, так и в
вызовах стандарной библиотеки. В оставшейся части книги я буду исполь-
зовать константные переменные там, где это необходимо.
Следует избегать объявления константных переменных в цикле, даже
присваивая им новое значение на каждой итерации:
for(int i = 0; i < 10; i++)
{
const i_squared = i * i;
cout << i_squared;
}

Переменную i_squared можно объявить константной, несмотря на то что


она задается при каждом проходе цикла, поскольку область видимости i_
squared ограничена телом цикла. С точки зрения компилятора при каждом
проходе цикла переменная i_squared создается заново.

Константы и STL
В последней главе, посвященной библиотеке STL, мы рассматривали
функцию, которая отображала содержимое словаря. Возможно, вы обра-
тили внимание, что словарь передавался по значению и, следовательно,
копировался в функцию displayMap. Приведем текст этой функции снова:
// словарь копируется!
void displayMap(map<string, string> map_to_print)
{
for(map<string, string>::iterator itr =
map_to_print.begin(),
end = map_to_print.end();
itr != end;
++itr)
{
cout << itr->first << " --> " << itr->second
<< endl;
}
}

Чтобы избежать копирования словаря, следует использовать ссылку, а еще


лучше — константную ссылку, чтобы подчеркнуть, что эта функция зани-
мается лишь отображением словаря и никак не изменяет его содержимое.
void displayMap(const map<string, string>& map_to_print)
{
for(map<string, string>::iterator itr =
map_to_print.begin(),
Передача по ссылке    275

end = map_to_print.end();
itr != end;
++itr)
{
cout << itr->first << " --> " << itr->second
<< endl;
}
}

И вы увидите огромное количество ошибок компилятора! Проблема в том,


что, делая словарь константым, вы запрещаете изменять его элементы, но
итераторы позволяют модифицировать словарь. Можно написать:
if(itr->first == "Alex Allain")
{
itr->second = "webmaster2@cprogramming.com"
}

Это код изменит мой адрес в адресной книге. К счастью, библиотека STL
поддерживает константы, и ее контейнеры содержат специальные итера-
торы — const_iterator. Их можно использовать как обычный итератор, за
одним исключением: нельзя изменять контейнер, по которому выполняется
итерация, путем записи в const_iterator:
void displayMap(const map<string, string>& map_to_print)
{
for(map<string, string>::const_iterator itr =
map_to_print.begin(),
end = map_to_print.end();
itr != end;
++itr)
{
cout << itr->first << " --> " << itr->second
<< endl;
}
}

Следует всегда использовать const_iterator, если контейнер, по которому


выполняется итерация, константный. Рекомендуется использовать const_
iterator во всех ситуациях, когда вы намерены использовать итератор для
доступа к данным, но не для изменения контейнера.

Проверьте себя
1. Какой код корректен?
А. const int& x;
Б. const int x = 3; int *p_int = & x;
276    Глава 19. Еще о строках

В. const int x = 12; const int *p_int = & x;


Г. int x = 3; const int y = x; int& z = y;
2. Какие из перечисленных ниже прототипов функций позволяют ском-
пилировать следующий код: const int x = 3; fun(x);?
А. void fun(int x);
Б. void fun(int& x);
В. void fun(const int& x);
Г. А и Б.
3. Как лучше всего определить, что поиск строки завершился неудачно?
А. Сравнить позицию результата с 0.
Б. Сравнить позицию результата с –1.
В. Сравнить позицию результата с string::npos.
Г. Проверить, не превышает ли позиция результата длину строки.
4. Как создать итератор для константного контейнера STL?
А. Объявить итератор константным.
Б. Воспользоваться индексами для циклического прохода по нему, а не
итератором.
В. Воспользоваться const_iterator.
Г. Объявить типы шаблонов константными.
(Решения см. на с. 466.)

Практические задания
При решении всех практических задач по возможности используйте кон-
станты и константные ссылки. Это означает, что строки следует переда-
вать по константной ссылке почти во все функции, принимающие строки
в качестве аргументов.
1. Напишите программу, которая считывает две строки и подсчитывает
число случаев, когда одна строка встречается в другой.
2. Напишите программу ввода табличных данных в формате, аналогич-
ном CSV, но в качестве разделителя используйте не запятую, а другой
символ, который программа должна распознать. Сначала предложите
пользователю ввести строки с табличными данными. Затем определите
возможные разделители, проанализировав небуквенные, нецифровые
и непробельные символы, встречающиеся в данных. Найдите такие
символы, встречающиеся в каждой строке, выведите список и попро-
Практические задания    277

сите пользователя указать, какой из символов следует использовать


в качестве разделителя. Например, по данным
Alex Allain, webmaster@cprogramming.com
John Smith, john@nowhere.com

предложите пользователю следующие варианты: запятая, точка и @.


3. Напишите программу, которая считывает вводимый пользователем
текст HTML (считывание из файла будет рассмотрено позднее).
Программа должна поддерживать следующие теги: <html> , <head> ,
<body>, <b>, <i> и <a>. Каждый тег HTML состоит из открывающего
тега (например, <html> ) и закрывающего тега, который начинается
с косой черты (</html>). Теги управляют текстом внутри них: <b>текст,
отображаемый жирным шрифтом</b> или <i>курсивный текст</i>.
Теги <head> </head> управляют текстом с метаданными, а теги <body>
</body>  — отображаемым текстом. Теги <a> отмечают гиперссылки
и содержат URL в формате <a href=URL>текст</a>.
4. Считав часть текста HTML, программа должна просто игнорировать тег
<html>. Она удаляет весь текст из раздела <head>, чтобы он не появлял-
ся при отображении. После этого программа отображает весь текст тела,
изменяя его вид: текст между тегами <b> и </b> выводится с символами
* до и после него; текст между тегами <i> и </i> — с символами _ до
и после него. Текст внутри тега <a href=linkurl>текст ссылки</a> ото-
бражается в виде ссылки.
Г лава 2 0
Отладка в Code::Blocks

Теперь вы уже знаете много методов программирования, но поиск ошибок


в сложных программах может быть непрост. К счастью, есть инструмент,
способный в этом помочь, — отладчик. Отладчик проверяет состояние
программы в процессе ее выполнения и упрощает разбор фактически
сделанного программой. Начинающие программисты часто откладывают
изучение отладчика из-за кажущейся сложности и ненужности. Действи-
тельно, чтобы пользоваться инструментом, сначала необходимо научиться
с ним работать, но, сэкономив на использовании отладчика, в долгосрочной
перспективе вы потеряете гораздо больше. Отладчики экономят очень
много времени; выражаясь фигурально, используя отладчик, вы начинае-
те ходить, а не ползать. Нужно практиковаться — сначала это нелегко, но
освоясь, вы достигнете отличных результатов!
В этой главе рассматривается отладчик Code::Blocks, поскольку вы уже
установили и настроили его, если работаете в операционной системе
Windows. Тем не менее концепции, которые мы изучаем, применимы и ко
многим другим отладчикам. В этой главе есть многочисленные снимки
экрана, которые помогут следить за происходящим и знакомиться с внеш-
ним видом отладчика, даже если используется не Windows. В вашей IDE
почти наверняка имеется отладчик1.

Работая в Linux, можно использовать GDB. Visual Studio или Visual Studio
1

Express предоставляют пользователям очень хорошие отладчики. Кроме того,


существуют и отдельные отладчики, в том числе WinDbg, входящий в пакет от-
ладочных средств Microsoft для Windows: http://www.microsoft.com/whdc/devtools/
debugging/default.mspx. Среда Apple Xcode также содержит отладчик.
Отладка в Code::Blocks    279

В этой главе я покажу вам живую отладку программ с ошибками. Чтобы


практиковаться во время чтения, для каждого примера можно создать
новый проект с соответствующей программой в Code::Blocks или среде
разработки, которой вы пользуетесь.
Первая программа, текст которой приведен ниже, подсчитывает годовые
процентные ставки для определенной денежной суммы. К сожалению, она
содержит ошибку и выводит неправильное значение.
Пример 44: bug1.cpp
#include <iostream>
using namespace std;
double computeInterest(double base_val, double rate, int years)
{
double final_multiplier;
for(int i = 0; i < years; i++)
{
final_multiplier *= (1 + rate);
}
return base_val * final_multiplier;
}

int main()
{
double base_val;
double rate;
int years;
cout << "Enter a base value: ";
cin >> base_val;
cout << "Enter an interest rate: ";
cin >> rate;
cout << "Enter the number of years to compound: ";
cin >> years;

cout << "After " << years << " you will have "
<< computeInterest( base_val, rate, years )
<< " money" << endl;
}

Ниже приведен результат выполнения этой программы:


Enter a base value: 100
Enter an interest rate: .1
Enter the number of years to compound: 1
After 1 you will have 1.40619e-306 money

Ясно, что значение 1.40618e–306 не может быть правильным и в програм-


ме есть ошибка. Давайте запустим программу в отладчике и попытаемся
найти причину проблемы.
280    Глава 20. Отладка в Code::Blocks

Настройка
Сначала мы должны проверить, что отладчик Code::Blocks настроен пра-
вильно, чтобы облегчить процесс отладки.
Для этого мы создадим так называемые символы отладки. Символы от-
ладки позволяют отладчику определить, какая строка кода выполняется
в текущий момент и в каком месте программы вы находитесь. Чтобы
корректно настроить символы, в интерфейсе Code::Blocks воспользуйтесь
командой Project|Build Options (Проект|Параметры сборки). Вы увидите
следующее диалоговое окно:

Проверьте, что для цели Debug (Отладка) установлен параметр Produce


debugging symbols (Создать отладочные символы). Необходимо также
выбрать для проекта цель Debug в Build|Select Target|Debug (Сборка|Выбор
цели|Отладка).
Это позволит собирать программу с помощью отладочных символов, ко-
торые создаются для цели Debug.
Прерывание программы    281

Если двух целей, Debug и Release (Выпуск), нет, можно установить флажок
Produce debugging symbols [-g] (Создать отладочные символы [-g]) для теку-
щей цели сборки. Проверьте, что параметр Strip all symbols from binary (mini-
mizes size) [-s] (Удалить все символы из двоичного файла (уменьшает раз-
мер) [-s]) НЕ установлен. (Как правило, эти цели сборки создаются вместе
с проектом. Проще всего обеспечить правильность настройки Code::Blocks,
приняв параметры по умолчанию при конфигурировании проекта.)
По окончании настройки можно приступать к отладке. Собрав програм-
му ранее, но изменив затем конфигурацию, соберите программу заново.
Приступим!

Прерывание программы
Ценность отладчика в том, чтобы позволить наблюдать действия про-
граммы — код, который она выполняет, и значения ее переменных. Чтобы
взглянуть на эту информацию, необходимо прервать программу — дать
отладчику команду приостановить ее выполнение. Для этого определяем
в программе точку останова, а затем запускаем программу в отладчике.
Отладчик выполняет программу, пока не встречает точку останова, после
чего можно изучить состояние программы или продолжить ее выполнение
в пошаговом режиме, наблюдая, как каждая строка кода влияет на значения
переменных.
282    Глава 20. Отладка в Code::Blocks

Зададим точку останова в начале функции main, чтобы пронаблюдать за


выполнением всей программы. Для этого поместите курсор на строку
double base_val;

и выполните команду DebugToggle Breakpoint (ОтладкаСоздать/удалить


точку останова) или нажмите F5. На боковой панели рядом с этой строкой
появится маленькая красная точка, которая отмечает точку останова:

Точки останова можно создавать и удалять указанной командой или щел-


кая на точке.
Теперь, создав точку останова, можно запустить программу. Выполните
команду DebugStart (ОтладкаНачать) или нажмите F8.
Прерывание программы    283

Программа будет выполняться как обычно, пока не дойдет до точки остано-


ва. В данном случае это произойдет очень скоро, поскольку точка останова
находится на первой строке кода программы.
Вы увидите, как отладчик открывается и приобретает следующий вид:

(Возможно, у вас будут открыты и некоторые другие окна, мы поговорим


об этом чуть позже.) Желтый треугольник под красной точкой указывает
на строку кода, которая будет выполнена следующей: на две строки ниже
текущей. Треугольник не указывает на саму строку с красной точкой, по-
тому что ей не соответствует никакой машинный код (код, получаемый
в результате компиляции программы на C++). Эта строка всего лишь
объявляет переменную, поэтому наша точка останова находится не на
строке 17, как может показаться, а на строке 20! (Числа слева от точки
и треугольника — номера строк.)
Взглянем на некоторые другие окна: начнем с окна Watches (Контроль-
ные значения). В зависимости от используемой версии Code::Blocks
окно открывается автоматически либо его придется открыть вручную.
Если вы не видите окна Watches, откройте его командой Debug|Debugging
Windows|Watches (Отладка|Отладочные окна|Контрольные значения). В раз-
личных версиях Code::Blocks это окно может содержать числа или быть
пустым. В Code::Blocks версии 12 это окно выглядит так:
284    Глава 20. Отладка в Code::Blocks

Выглядит весьма полезным, не так ли? Действительно, в этом окне можно


сделать многое. Для начала щелкните на узком прямоугольном поле, рас-
положенном слева: курсор начнет мигать. Введите имя любой переменной.
Вид окна после ввода в него трех переменных, доступных в функции main:

Вы видите имена переменных, их значения в среднем столбце и типы


переменных справа. Обратите внимание: показанные значения абсолютно
бессмысленны! Дело в том, что они еще не инициализированы: соответ-
ствующие строки кода расположены ниже1.
Для выполнения следующей пары строк необходимо указать отладчику
перейти на следующую строку. При переходе на следующую строку (от-
меченную желтой стрелкой) отладчик ее выполняет. Соответствующая
команда в Code::Blocks — Next line (Следующая строка):

Вспомните: объявление переменной еще не инициализация.


1
Прерывание программы    285

Для выполнения можно нажать F71.


Перейдя на следующую строку, программа выполнит оператор cout и вы-
ведет на экран запрос ввода значения. Тем не менее попытка ввести значе-
ние ни к чему не приведет — программа вернулась в отладчик. Выполните
следующую строку кода, нажав F7 еще раз. После этого программа будет
ожидать ввода данных, поскольку функция cin еще не завершилась: для
этого ей необходим ввод данных пользователем. Введите значение 100
(ранее вызвавшее ошибку) и повторите процедуру для двух оставшихся
переменных. Присвойте им те же значения, что и при первом запуске про-
граммы: 0.1 процентной ставке и 1 числу лет.
Теперь мы находимся на строке кода

cout << "After " << years << " you will have "
<< computeInterest(base_val, rate, years)
<< " money" << endl;

Проверим корректность обработки введенных данных: узнаем значения


локальных переменных в окне контрольных значений.

Вроде бы все правильно: денежная сумма равна 100, процентная ставка —


0.1, количество лет — 1. Если внимательно присмотреться, процентная
ставка, строго говоря, равна 0.10000000000000001. Единица на конце этого
значения демонстрирует, что числа с плавающей точкой представляются
не с идеальной точностью. Тем не менее погрешность настолько мала, что
не влияет на работу большинства программ.

Одновременное присутствие команд Next line (Следующая строка) и Next


1

instruction (Следующая инструкция) может вызвать некоторое удивление.


Вторая команда служит для режима работы без символов отладки, но мы не
будем рассматривать ее в этой книге.
286    Глава 20. Отладка в Code::Blocks

Теперь мы знаем, что пока все идет как надо, и рассмотрим, что происходит
внутри функции computeInterest. Для этого воспользуемся еще одной
командой отладчика Step Into (Вход в функцию):

Команда Step Into входит в функцию, которая вызывается в текущей стро-


ке, — в отличие от команды Next, которая просто исполняет ее целиком и де-
монстрирует результат, как мы видели на примере функции cin. Команда
Step Into используется для отладки внутри функции, как в данной ситуации.

Войдем в функцию computeInterest, сначала взглянув на строку


cout << "After " << years << " you will have "
<< computeInterest( base_val, rate, years )
<< " money" << endl;

Что за вызовы cout? Отладчик Code::Blocks достаточно сообразителен,


чтобы не входить в функции из стандартной библиотеки. Мы сразу вой­
дем в функцию computeInterest, пропустив не представляющие интерес.
Теперь мы находимся внутри функции computeInterest, и первое, что
нужно сделать, — проверить корректность ее аргументов (возможно, мы
указали их в неправильном порядке). Окно Watches (Контрольные зна-
чения) отображает переменные в области видимости текущей функции
computeInterest, и все они правильные!
Прерывание программы    287

Взглянем на другие локальные переменные. Для этого сначала добавим


их в список контрольных значений:

Нет ли здесь чего-либо странного? Судя по информации, представленной


в окне, символ i отсутствует. Это объясняется тем, что мы еще не выполни-
ли строку с объявлением i, поэтому окно не знает о существовании такой
переменной. Значение переменной final_multiplier кажется абсолютно
неверным, но вспомните о том, что в этом окне мы уже наблюдали бессмыс-
ленные значения еще не инициализированных переменных. Перейдем на
следующую строку, выполнив оператор инициализации цикла (нажмите
F7), и посмотрим, что произойдет.
Инициализация цикла занимает одну строку, поэтому теперь мы можем
снова проверить наши локальные переменные. Они должны выглядеть
следующим образом:
288    Глава 20. Отладка в Code::Blocks

С i все в порядке, но как насчет final_multiplier? Создается впечатле-


ние, что она инициализирована некорректно. Кроме того, строка кода, на
которой мы находимся, использует переменную final_multiplier:
final_multiplier *= (1 + rate);

Эта строка вычисляет произведение final_multiplier * (1 + rate)


и присваивает его переменной final_multiplier, но раз значение final_
multiplier неверно, ошибочным оказывается и расчет. Как его исправить?

Необходимо инициализировать переменную final_multiplier при ее


объявлении. В данном случае ее начальное значение должно быть равно 1.
Вот и все: мы нашли проблему и решили ее. Спасибо отладчику!

Отладка аварийных завершений программы


Рассмотрим еще один тип неполадок — аварийное завершение программы.
Аварийные завершения часто вызывают у начинающих программистов наи-
больший страх, поскольку происходят внезапно. Тем не менее с течением
времени их поиск станет при отладке вашим любимым занятием, поскольку
вы сможете точно определять местонахождение проблемы. Программа
аварийно завершается из-за некорректных данных, и можно определить,
что это за данные и откуда они появились, остановив программу в точке
аварийного завершения.
Приводимая простая, но некорректная программа создает два узла в свя-
занном списке и выводит на экран их значения.

Пример 45: bug2.cpp


#include <iostream>
using namespace std;
struct LinkedList
{
int val;
LinkedList *next;
};
void printList(const LinkedList *lst)
{
if(lst != NULL)
{
cout << lst->val;
cout << "\n";
printList(lst->next);
}
}
Прерывание программы    289

int main()
{
LinkedList *lst;
lst = new LinkedList;
lst->val = 10;
lst->next = new LinkedList;
lst->next->val = 11;
printList(lst);

return 0;
}

Запущенная программа не работает: она аварийно завершается или входит


в бесконечный цикл. Где-то есть ошибка!
Запустим программу в отладчике и попробуем найти ошибку. Выполните
команду Debug|Start (Отладка|Начать) или нажмите F8.
Отладчик почти сразу выдаст сообщение:

Сбой сегментирования (segmentation fault, segfault) возникает из-за не-


корректных указателей. Как правило, он происходит, когда программа
пытается разыменовать неинициализованный указатель, указатель со зна-
чением NULL или указывающий на освобожденную память. Можно считать,
что программа пытается воспользоваться сегментом памяти, к которому
у нее нет доступа1.
Как определить причину появления некорректного указателя? Отладчик
остановил программу на строке, вызвавшей аварийное завершение. На-
жмите OK в диалоговом окне и найдите желтую стрелку, указывающую
на код, ставший причиной аварии:
cout << lst->val;

1
В некоторых средах используется термин нарушение доступа (access violation),
имеющий то же значение.
290    Глава 20. Отладка в Code::Blocks

В этой строке есть только один указатель — lst. Посмотрим на содержимое


lst в окне контрольных значений. Значение lst равно 0xbaadf00d!1 Вы-
глядит странно, правда? Это специальное значение, которое компилятор
использует для инициализации памяти при ее выделении. Такая возмож-
ность применяется только в отладчике, поэтому можно наблюдать разли-
чия в поведении программы при ее запуске в отладчике и вне его. Помощь
отладчика в том, что он использует определенное известное значение, при
доступе к которому происходит сегментный сбой. Следовательно, если вы
используете неинициализированный указатель, это немедленно выяснится2.
Теперь мы знаем, что указатель lst не был инициализирован. Почему так
произошло? Воспользуемся еще одной возможностью отладчика — стеком
вызовов. Чтобы его просмотреть, выполните команду Debug|Debug Windows|
Call Stack (Отладка|Отладочные окна|Стек вызовов). Стек вызовов отобра-
жает все функции в процессе выполнения и выглядит следующим образом:

Он содержит несколько столбцов: Nr — это номер, по которому можно об-


ращаться к определенному стековому фрейму, а Address — адрес функции3.
Function — это имя и аргументы функции (фактически вы видите, что lst =

1
Этот синтаксис применяется для записи шестнадцатеричных чисел. Как пра-
вило, они начинаются с префикса 0x и используют буквы A–F для обозначения
цифр от 0 до 15 (например, число 0xA соответствует 10 в десятичной системе
счисления).
2
В качестве альтернативы можно использовать значение, хранившееся в пере-
менной, которая раньше располагалась там, где теперь хранится указатель.
Поскольку это значение предугадать невозможно, не исключено, что программа
станет вести себя странно, и определить причину такого поведения окажется
сложно. Программа может считать неразрешенную память и аварийно завер-
шиться не сразу, а при попытке воспользоваться считанным значением. От-
ладчик упрощает жизнь — он вносит определенность в поведение программы,
позволяя максимально быстро обнаружить причину неполадки.
3
Адрес функции пригодится при отладке на уровне ассемблера.
Прерывание программы    291

0xbadf00d, просто глядя на стек вызовов). Вы также видите файл и номер


строки, что позволяет найти выполненную строку кода.
Функция на вершине стека выполняется в текущий момент, функция под
ней вызвала текущую функцию и т. д. В самом низу находится функция
main, поскольку с нее началось выполнение программы.

Мы видим, что функция printList вызывалась трижды. Первые два вызова


работают с корректными значениями указателей, а третий — со значением
0xbaadf00d. Вспомним, что функция main создала список с двумя узлами.
Первые два вызова функции printList используют эти узлы, а третий —
неинициализированный указатель. Теперь ясно, как снова вывести код,
инициализирующий список. Как видно, мы никогда не задаем следующее
значение равным NULL для узла в конце списка.
Хотя проблема решена, иногда необходимо получить более подробную ин-
формацию о различных стековых фреймах. Можно переключить контекст
отладчика на любой стековый фрейм, чтобы изучить локальные перемен-
ные. Для этого выделите интересующий стековый фрейм и воспользуйтесь
командой Switch to this frame (Переключиться на этот фрейм):

Отладчик переместится на желтую стрелку и покажет вызов функции, вы-


полняемый в этом стековом фрейме. Вы также сможете изучить значения
локальных переменных, находящихся во фрейме, в окне контрольных
значений.

Прерывание зависшей программы


Иногда программа не завершается аварийно, а зависает из-за бесконечного
цикла или длительного ожидания завершения системного вызова. В такой
ситуации можно запустить программу в отладчике, дождаться появления
проблемы, а затем дать отладчику команду прервать программу.
292    Глава 20. Отладка в Code::Blocks

Для этого воспользуемся еще одним примером кода.

Пример 46: bug3.cpp


#include <iostream>
using namespace std;
int main()
{
int factorial = 1;
for(int i = 0; i < 10; i++)
{
factorial *= i;
}
int sum = 0;
for(int i = 0; i < 10; i++)
{
sum += i;
}
// factorial w/o two
int factorial_without_two = 1;
for(int i = 0; i < 10; i++)
{
if(i == 2)
{
continue;
}
factorial_without_two *= i;
}
// сумма без 2-го элемента
int sum_without_two = 0;
for(int i = 0; i < 10; i++)
{
if(i = 2)
{
continue;
}
sum_without_two += i;
}
}

Эта программа выполняется бесконечно и зависает. Чтобы понять, где это


происходит, запустим ее в отладчике, дождемся, когда программа зависнет,
а затем оценим ситуацию.
Сначала соберите эту программу и запустите ее в отладчике командой
Debug|Start (Отладка|Начать) или F8.

Запустив программу, вы обнаружите, что она не завершает работу. В какой-


то момент она зависла, по всей видимости, войдя в бесконечный цикл.
Прервем программу, чтобы понять, что происходит. Выполните команду
Прерывание программы    293

Debug|Break Debugger (Отладка|Прервать). Если вы не видите параметр


Break Debugger, то, возможно, используете устаревшую версию Code::Blocks.
В этом случае воспользуйтесь командой Stop Debugger (Остановить от-
ладчик). Если же вы видите две команды, Break Debugger и Stop Debugger,
обязательно используйте Break Debugger. Команда Break Debugger прервет
программу, и вы получите возможность изучить то, что происходит в точке
останова. Командой можно воспользоваться для окончания сеанса отладки,
если программа уже находится в отладчике.
После остановки программы вы увидите стек вызовов. (Если вы его
не видите, выполните команду Debug|Debugging Windows|Call stack
(Отладка|Отладочные окна|Стек вызовов).) На этот раз стек выглядит
весьма странно.

Наш код не предполагает ничего подобного! Что здесь происходит?! Это


результат прерывания работающей программы. Обратите внимание: на
вершине стека находится ntdll — библиотека ядра Windows. Но где же
код программы в процессе выполнения? Оказывается, для прерывания
процесса отладчик создал дополнительный поток: потоки — это способ
одновременного выполнения нескольких фрагментов кода. Чтобы при-
остановить процесс, отладчику необходимо выполнить определенный
код в момент, когда код программы еще выполняется. Для этого отладчик
создает новый поток, который прерывает наш код. Этого потока не было
в предыдущих примерах, поскольку процесс отладки был начат с уже за-
данной точкой останова, и отладчик мог прервать программу, не создавая
второй поток. В этой ситуации, чтобы определить, какой код выполнялся,
нужно приостановить программу в конкретный момент времени, а не на
заданной строке кода. Теперь, чтобы найти наш собственный код, необхо-
димо переключиться на правильный поток.
Для переключения потоков используется окно потоков, вызываемое ко-
мандой Debug|Debugging windows|Running Threads (Отладка|Отладочные
окна|Выполняемые потоки).
294    Глава 20. Отладка в Code::Blocks

В этом окне видны два потока:

В столбце Active (Активные) текущая строка (в данном случае строка, пре-


рвавшая процесс) помечена знаком *. Для получения информации о дру-
гом потоке надо переключиться на него. Выберите интересующий поток
и выполните команду Switch to this thread (Переключиться на этот поток).

Теперь можно вернуться к стеку вызовов и увидеть гораздо более понят-


ную информацию (возможно, для ее загрузки в стек вызовов придется
дополнительно нажать F7).

Это наш код. Видно, что отладчик поместил желтую стрелку на строку
29, показав, что она будет выполнена следующей. Вот соответствующий
фрагмент кода:

for(int i = 0; i < 10; i++)


{
if(i = 2)
{
continue;
}
sum_without_two += i;
}
Прерывание программы    295

Поскольку программа зависает внутри цикла, логично предположить, что


цикл не заканчивается. Как это проверить? Выполним программу пошаго-
во, проявив некоторую осторожность. Если просто выбрать команду Next
Line (Следующая строка), отладчик выполнит код, начинающийся с дру-
гой строки, прервавшей процесс, поскольку эта строка текущая. Вместо
этого нужно в коде задать точку останова и дать программе возможность
выполняться, пока она не попадет в эту точку1. Поместим точку останова
на оператор if, а затем выберем команду Continue (Продолжить) или на-
жмем Ctrl-F7. В момент останова мы окажемся в правильном потоке, и вы
сможете продолжить наблюдение за тем, что происходит в программе,
с помощью команды Next line.
Вы увидите, что мы всегда попадаем на оператор if(i = 2), а затем воз-
вращаемся в начало цикла.
Что происходит? Взглянем на значение i в окне локальных переменных.
Когда мы находимся в строке цикла, значение i равно 2, а после выпол-
нения цикла for — 3. Затем, когда мы выполняем строку с оператором if,
значение i снова становится равным 2!
Создается впечатление, что кто-то присваивает переменной i значение
2 — в данном случае это может быть сам оператор if. Действительно, он
содержит распространенную опечатку: вместо двух знаков равенства ис-
пользуется лишь один.
Можно спросить: почему программа никогда не доходит до строки continue,
а вместо этого возвращается в цикл for прямо из оператора if? Это причуда
отладчика: иногда трудно сопоставить машинный код конкретной строке
программы. В данном случае отладчик не распознает if(x = 2) и continue
как два различных оператора. Время от времени вы будете сталкиваться с
тем, что исполняемый код, который отображается в отладчике, не вполне
соответствует ожиданиям. По мере расширения опыта отладки вы научи-
тесь обнаруживать подобные ситуации.

Изменение переменных
Иногда в процессе отладки требуется изменять значение переменной,
например, чтобы удостовериться, что при определенном значении пере-
менной код работает правильно. Это можно сделать в окне контрольных
переменных. Щелкнув на значении переменной, вы сможете его изменить.

Некоторые отладчики позволяют управлять выполнением потоков, замора-


1

живая их, но у Code::Blocks нет такой возможности.


296    Глава 20. Отладка в Code::Blocks

Будьте аккуратны — не делайте этого до того, как код инициализирует


значение, иначе введенное значение будет немедленно перезаписано.

Заключение
Отладчик Code::Blocks позволяет быстро начать поиск неполадок в про-
граммах. Работая в операционной системе, отличной от Windows, можно
воспользоваться многими (если не всеми) изложенными концепциями,
возможно, с некоторыми изменениями. Суть отладки в том, что вы полу-
чаете дополнительную информацию о состоянии программы при помощи
таких инструментов, как точки останова, пошаговое выполнение кода,
просмотр стека вызовов и значений различных переменных.

Практические задания
Здесь, в отличие от других глав, я предлагаю не пройти тест и написать код,
а отладить несколько ведущих себя неправильно программ с ошибками.
Создайте для каждой из них отдельный проект в Code::Blocks и отладьте
его. Некоторые программы содержат не одну, а несколько ошибок!

Задача 1. Проблемы с экспонентой

Пример 47: practice1.cpp


#include <iostream>
using namespace std;
int exponent(int base, int exp)
{
int running_value;
for(int i = 0; i < exp; i++)
{
running_value *= base;
}
return base;
}
Практические задания    297

int main()
{
int base;
int exp;

cout << "Enter a base value: ";


cin >> base;
cout << "Enter an exponent: ";
cin >> exp;
exponent(exp, base);
}

Задача 2. Проблема добавления чисел

Пример 48: practice2.cpp


#include <iostream>
using namespace std;
int sumValues (int *values, int n)
{
int sum;
for(int i = 0; i <= n; i++)
{
sum += values[i];
}
return sum;
}
int main()
{
int size;
cout << "Enter a size: ";
cin >> size;
int *values = new int[size];
int i;
while(i < size)
{
cout << "Enter value to add: ";
cin >> values[++i];
}
cout << "Total sum is: "
<< sumValues(values, size);
}

Задача 3. Ошибка в числах Фибоначчи

Пример 49: practice3.cpp


#include <iostream>
using namespace std;
int fibonacci(int n)
продолжение 
298    Глава 20. Отладка в Code::Blocks

{
if(n == 0)
{
return 1;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}

int main()
{
int n;
cout << "Enter the number to compute fibonacci for: "
<< endl;
cin >> n;
cout << fibonacci(n);
}

Задача 4. Некорректное чтение и вывод списка

Пример 50: practice4.cpp


#include <iostream>
using namespace std;
struct Node
{
int val;
Node *p_next;
};
int main()
{
int val;
Node *p_head;
while(1)
{
cout << "Enter a value, 0 to replay: " <<endl;
cin >> val;
if(val = 0)
{
break;
}
Node *p_temp = new Node;
p_temp = p_head;
p_temp->val = val;
p_head = p_temp;
}
Node *p_itr = p_head;
while(p_itr != NULL)
{
cout << p_itr->val << endl;
p_itr = p_itr->p_next;
delete p_itr;
}
}
Ч а с ть I II
Большие программы

ПРИМЕЧАНИЕ

Если до сих пор вы бегло читали книгу и не решали практические


задачи, остановитесь. Представленная здесь информация
бесполезна, если вы еще не программировали. В этой части книги
затрагивается ряд ключевых тем, и изучать их без практического
опыта не имеет смысла.

Многие концепции, о которых мы говорили ранее, позволяют решать но-


вые задачи. Теперь речь пойдет не только о новых, но и о более крупных
задачах. До настоящего момента вы создавали лишь очень небольшие
программы — их объем не превышал несколько сотен строк. Обдумывать
простые программы легко, но, возможно, вы уже начали замечать, что чем
длиннее программа, тем сложнее с ней работать. Даже если вы это еще не
почувствовали, однажды наступит момент, когда вы поймете, что программа
стала слишком большой. Непринципиально, сколько в ней будет строк —
несколько сотен, тысяч или больше. Хорошая память — отличное качество
для программиста, но его недостаточно, чтобы создавать действительно
интересные программы. Все программы постепенно становятся слишком
большими, чтобы целиком держать их в голове. Нужны методы, которые
структурируют и упрощают понимание больших программ — от компью-
терных игр и научно-исследовательских приложений до операционных
систем.
300    Часть III. Большие программы

К счастью, многие программисты осознали эту проблему и создали методы,


упрощающие разработку больших программ. Принципы, изложенные в
последующих главах, позволяют создавать крупные и сложные программы
и упрощают разработку небольших программ.
Мы начнем изучать подходы к созданию больших программ с физического
кода. Мы узнаем, как структурировать программу на диске так, чтобы она
не состояла из единственного огромного файла cpp. Затем мы рассмотрим
логическую структуру программы и узнаем, как избавиться от необходи-
мости целиком держать в голове ее алгоритм.
Г лава 2 1
Разбиение программ на части

По мере увеличения размера программ неудобно размещать весь их код


в единственном исходном файле. Вносить изменения все труднее, и вы нач-
нете теряться в файле, пытаясь найти в нем нужные фрагменты. Если объем
программы превышает пару сотен строк, однозначно следует заняться раз-
биением программы на части и размещением в нескольких исходных файлах1.
Используя несколько исходных файлов, проще сориентироваться, где
искать интересующую часть программы, поскольку каждый файл имеет
небольшой размер и может содержать код, относящийся к определенному
аспекту программы. Кроме того, проще проектировать программу, по-
скольку каждый заголовочный файл включает конкретный интерфейс для
соответствующего исходного кода, и другие файлы не смогут использовать
функции и структуры данных, не определенные в заголовочном файле.
Это может показаться ограничением, но на самом деле проще отделять
реализацию каждой подсистемы от функций других подсистем.

Процесс сборки в C++


Прежде чем приступить к распределению кода в нескольких файлах, необ-
ходимо подробнее познакомиться с основными принципами компиляции
C++.

Однажды мне довелось работать с файлом длиной в 20 000 строк и размером


1

в полмегабайта. Никто не хотел даже прикасаться к нему!


302    Глава 21. Разбиение программ на части

На самом деле компиляция — не вполне подходящий термин, поскольку


она даже не подразумевает создание исполняемого файла. Создание испол-
няемого файла — многоступенчатый процесс, основные этапы которого —
предварительная обработка, компиляция и компоновка. В совокупности
процесс преобразования исходного кода в исполняемый файл лучше всего
называть сборкой. Компиляция — часть процесса сборки, а не его эквива-
лент. Тем не менее люди часто говорят о компиляции, подразумевая весь
процесс сборки. Как правило, не нужно запускать отдельные команды для
каждого этапа сборки — например, компилятор сам запускает препроцессор.

Предварительная обработка
На первом шаге в процессе сборки компилятор запускает препроцессор C.
Цель препроцессора C — внести изменения в текст программы перед ее ком-
пиляцией. Препроцессор понимает директивы препроцессора — команды,
встраиваемые непосредственно в файл с исходным кодом, но адресованные
препроцессору, а не компилятору.
Все директивы препроцессора начинаются с символа решетки (#). Сам
компилятор никогда не знает об их существовании!
Например, оператор
#include <iostream>

дает препроцессору указание встроить текст файла iostream непосред-


ственно в текущий файл. Каждый заголовочный файл, включаемый в про-
грамму, вставляется в нее перед тем, как программу видит компилятор,
а сама директива #include удаляется.
Препроцессор также выполняет подстановку макросов. Макрос пред-
ставляет собой текстовую строку, которая заменяется другой, как правило,
более сложной. С помощью макроса можно создать единственную точку,
в которой определяются константы, что позволяет легко их изменять.
Например, написав следующий код:
#define MY_NAME "Alex"

можно использовать имя MY_NAME вместо «Alex» во всем исходном файле.


cout << "Hello " << MY_NAME << '\n';

Компилятор увидит эту строку так:


cout << "Hello " << "Alex" << '\n';
Процесс сборки в C++    303

Чтобы использовать вместо «Alex» другое имя, нужно всего лишь из-
менить строку #define, в которой определено это имя, а не обрабатывать
весь написанный код функцией поиска с заменой. Макросы позволяют
концентрировать информацию в одном месте, чтобы ее было проще из-
менять. Чтобы присвоить программе номер версии и использовать его
в коде, применим макрос:
#define VERSION 4
// ...
cout << "The version is " << VERSION

Препроцессор, обрабатывающий код раньше компилятора, можно исполь-


зовать и для удаления кода: иногда часть кода включается в программу
только для ее отладки. Можно дать препроцессору указание включить
в программу исходный код только при условии, что определен некоторый
макрос. Если вы захотите воспользоваться этим кодом, определите макрос,
а если нет — удалите определение макроса из программы.
Допустим, программа содержит отладочный код, который выводит значе-
ния некоторых переменных, но вы не хотите, чтобы это происходило все
время. Можно сделать так, чтобы отладочный код включался в сборку при
определенных условиях.
Пример 51: define.cpp
#include <iostream>
#define DEBUG
using namespace std;
int main()
{
int x;
int y;
cout << "Enter value for x: ";
cin >> x;
cout << "Enter value for y: ";
cin >> y;
x *= y;
#ifdef DEBUG
cout << "Variable x: " << x << '\n' << "Variable y: "
<< y;
#endif
// дальнейшее использование x и y
}

Чтобы отключить отображение переменных, закомментируйте директиву


#define DEBUG:
// #define DEBUG
304    Глава 21. Разбиение программ на части

Препроцессор C также поддерживает проверку того, что макрос НЕ опре-


делен. Например, можно организовать выполнение кода только при усло-
вии, что определение DEBUG отсутствует: для этого используется директива
#ifndef (if not defined — если не определено). Мы будем использовать ее
при изучении использования нескольких заголовочных файлов.

Компиляция
Компиляция означает преобразование файла с исходным кодом (.cpp)
в объектный файл (.o или .obj). Объектный файл содержит программу
в виде воспринимаемых процессором компьютера инструкций машинного
языка, которые создаются для каждой функции исходного файла. Каждый
файл с исходным кодом проходит процедуру отдельной компиляции. Это
означает, что объектный файл содержит машинные инструкции только для
одного файла исходного кода. Например, если вы скомпилируете (но не
скомпонуете) три отдельных файла, то создадите три объектных файла,
каждый из которых имеет имя вида <имя_файла>.o или <имя_файла>.obj
(расширение зависит от компилятора). Каждый из этих файлов содержит
перевод исходного кода на машинный язык. Тем не менее объектные файлы
нельзя запустить: их необходимо преобразовать в исполняемые файлы,
которые может использовать операционная система. Для этой цели ис-
пользуется компоновщик.

Компоновка
Компоновка — это создание единственного исполняемого файла (например,
EXE или DLL) из набора объектных файлов и библиотек1. Компоновщик
создает исполняемый файл в подходящем формате и передает в него со-
держимое каждого отдельного объекта. Кроме того, компоновщик обра-
батывает объектные файлы со ссылками на функции, определенные вне
исходных файлов, из которых получены эти объектные файлы, — например,
функции стандартной библиотеки C++. Функции стандартной библиотеки
C++ (например, cout << "Hi") объявлены не в вашем коде, а в объектном
файле, который предоставляется поставщиком компилятора. В момент
компиляции компилятор считает вызовы функций стандартной библиотеки
корректными, поскольку вы включили в программу заголовочный файл
iostream. Тем не менее поскольку эти функции не входят в состав файла .cpp,
компилятор оставляет на месте вызова заглушку. Компоновщик обходит
объектный файл, находит адрес соответствующей функции для каждой

Или единственного объектного файла, если у вас один исходный файл. Ком-
1

поновка происходит, даже если вся программа находится в одном файле.


Как разделить программу на файлы    305

заглушки и заменяет ее корректным адресом из одного из компонуемых


объектных файлов.
Иногда эту операцию называют адресной привязкой. Разбивая программу
на множество исходных файлов, вы используете возможность компонов-
щика выполнять адресные привязки всех функций, вызовы которых на-
правлены в другие исходные файлы. Если компоновщику нигде не удается
найти определение функции, он сообщит, что функция не определена.
Успешная обработка кода компилятором не означает, что код корректен.
Компоновщик первым обрабатывает сразу всю программу и способен
обнаруживать такие проблемы.

Зачем разделять компиляцию и компоновку


Поскольку объявлять все функции в одном и том же объектном файле
необязательно, можно компилировать исходные файлы по отдельно-
сти, а позднее компоновать их друг с другом. Если вы измените файл
FrequentlyUpdated.cpp, но не измените файл InfrequentlyChanged.cpp,
объектный файл, соответствующий InfrequentlyChanged.cpp, не нужно
компилировать заново. Отказ от необязательной компиляции экономит
много времени при сборке программ. Чем больше кодовая база, тем больше
времени вы экономите1.
Чтобы максимально полно использовать преимущества условной компи-
ляции, нужен инструмент, который определяет актуальность объектных
файлов (то есть изменяли ли вы соответствующий исходный файл или
используемые им заголовочные файлы с момента последней компиля-
ции). Если вы работаете в Windows и используете Code::Blocks, эта задача
уже решена. Если вы используете Mac, Xcode автоматически выполняет
проверку файлов при добавлении новых файлов с помощью команды
FileNewNew file… (ФайлСоздатьНовый файл…). Если вы использу-
ете Linux, можете воспользоваться утилитой make, которая присутствует
в большинстве дистрибутивов *nix2.

Как разделить программу на файлы


Итак, как структурировать код программы, чтобы воспользоваться пре-
имуществами раздельной компиляции? Рассмотрим простой пример,

Я видел кодовые базы, сборка которых с нуля занимает несколько часов,


1

и слышал о существовании баз, которые собираются несколько дней.


Подробнее о make-файлах: http://www.cprogramming.com/tutorial/makefiles.html
2
306    Глава 21. Разбиение программ на части

в котором часть кода Orig.cpp необходимо повторно использовать в новой


программе. Я подробно опишу каждый шаг.

Шаг 1. Разделите объявления и определения функций


Если вы еще не пытались разбить код на несколько файлов, скорее всего,
объявления и определения функций не отделены друг от друга. Первое,
что предстоит сделать, — создать объявления всех функций и переместить
их в начало файла. Это выглядит так:

Общие и файловые
Общие и файловые объявления
объявления
и реализации
Общие и файловые
определения

Шаг 2. Определите, какие функции будут общими


Разделив объявления и определения функций, просмотрите их и опреде-
лите, какие из них специфичны, а какие следует поместить в общий файл.

Общие и файловые Общие объявления


объявления
Файловые объявления

Общие и файловые Общие реализации


реализации

Файловые реализации
Как разделить программу на файлы    307

Шаг 3. Переместите общие функции в новые файлы


Теперь можно переместить общие объявления в новый файл Shared.h,
а соответствующие реализации — в файл Shared.cpp. Необходимо вклю-
чить и файл Shared.h из Orig.cpp. Общие функции по-прежнему можно
вызывать, поскольку их объявления находятся в файле Shared.h. Потре-
буется настроить его так, чтобы при сборке Orig.cpp он компоновался и с
объектным файлом Shared.obj. Чуть позже мы рассмотрим, как это сделать.

Общие объявления Общие объявления


Файловые
Файловые объявления объявления

Общие реализации Файловые


реализации от
Общие реализации

Файловые реализации

z z Пример
Ниже приведена простая программа с общим кодом, создающим связанный
список и помещенным в файл Orig.cpp. Разобьем этот код и разместим
в заголовочном и исходном файлах для повторного использования.

Пример 52: orig.cpp


#include <iostream>
using namespace std;

struct Node
{
Node *p_next;
int value;
};

Node* addNode (Node* p_list, int value)


{
Node *p_new_node = new Node;
p_new_node->value = value; продолжение 
308    Глава 21. Разбиение программ на части

p_new_node->p_next = p_list;

return p_new_node;
}

void printList (const Node* p_list)


{
const Node* p_cur_node = p_list;
while(p_cur_node != NULL)
{
cout << p_cur_node->value << endl;
p_cur_node = p_cur_node->p_next;
}
}

int main()
{
Node *p_list = NULL;
for(int i = 0; i < 10; ++i)
{
int value;
cout << "Enter value for list node: ";
cin >> value;
p_list = addNode( p_list, value);
}
printList(p_list);
}

Сначала разделим объявления и определения. Для краткости демонстри-


рую только актуальные объявления; остальная часть файла сохраняется
в прежнем виде.
orig.cpp
struct Node
{
Node *p_next;
int value;
};

Node* addNode(Node* p_list, int value);


void printList(const Node* p_list);

Поскольку файл не содержит специфичных для него объявлений, не нуж-


но их выделять. Можно сразу переместить приведенные выше в новый
заголовочный файл Shared.h (назовем его linkedlist.h). Демонстрирую
содержание каждого файла целиком.

Пример 53: linkedlist.h


struct Node
{
Node *p_next;
Как разделить программу на файлы    309

int value;
};

Node* addNode (Node* p_list, int value);


void printList (const Node* p_list);

Пример 54: linkedlist.cpp


#include <iostream>
#include "linkedlist.h"

using namespace std;

Node* addNode (Node* p_list, int value)


{
Node *p_new_node = new Node;
p_new_node->value = value;
p_new_node->p_next = p_list;

return p_new_node;
}

void printList(const Node* p_list)


{
const Node* p_cur_node = p_list;
while(p_cur_node != NULL)
{
cout << p_cur_node->value << endl;
p_cur_node = p_cur_node->p_next;
}
}

Пример 55: orig_new.cpp


#include <iostream>
#include "linkedlist.h"

using namespace std;

int main()
{
Node *p_list = NULL;
for(int i = 0; i < 10; ++i)
{
int value;
cout << "Enter value for list node: ";
cin >> value;
p_list = addNode(p_list, value);
}
printList(p_list);
}
310    Глава 21. Разбиение программ на части

Обратите внимание: заголовочный файл не должен содержать никакие


определения функций. Если поместить определение функции в заголо-
вочный файл, а затем включить в несколько исходных файлов, то на этапе
компоновки определение функции будет присутствовать в двух экземпля-
рах и вызовет крайне неодобрительную реакцию компоновщика.
Необходимо позаботиться и о том, чтобы объявления функций не вклю-
чались в один исходный файл дважды. Возможна ситуация, когда файл
Orig.cpp содержит еще несколько заголовочных файлов, один из которых,
в свою очередь, включает linkedlist.h:
newheader.h
#include "linkedlist.h"
// другой код
orig.cpp
#include "linkedlist.h"
#include "newheader.h"

/* остальной код orig.cpp */

Orig.cpp включает linkedlist.h дважды: один раз прямо и один раз косвенно,
за счет включения newheader.h.
Для решения этой проблемы требуется защита от повторных включений,
использующая препроцессор C для принятия решений, следует ли вклю-
чать тот или иной файл. В основе такой защиты лежит следующая идея:
если <мы еще не включили этот файл>
<сделать пометку, что мы включили файл>
<включить файл>

Этот подход гарантирует, что никакой заголовочный файл не будет включен


в программу несколько раз.
Для защиты от повторных включений воспользуемся командой препро-
цессора #ifndef, которую мы уже видели ранее в этой главе. Оператор
#ifndef имеет следующий смысл: если макрос не определен, то включить
в программу блок кода до ближайшей директивы #endif.
#ifndef ORIG_H
// содержание заголовочного файла
#endif

Этот код говорит: «если никто не определил ORIG_H, то включить весь код
до #endif». Затем определяем ORIG_H:

#ifndef ORIG_H
#define ORIG_H
Как разделить программу на файлы    311

// содержание заголовочного файла


#endif

Представьте, что произойдет, если кто-нибудь включит этот заголовочный


файл дважды. В первый раз макрос ORIG_H не определен, поэтому директива
#ifndef включит всю оставшуюся часть файла, в том числе определение
ORIG_H (разумеется, этот макрос пуст, но он существует). При следующем
включении файла результатом директивы #ifndef будет ложь, и код не
будет включен в программу.
Необязательно придумывать уникальные имена макросов, защищающих
заголовочные файлы от повторного включения. Хороший метод — ис-
пользовать имя заголовочного файла с постфиксом _H. Скорее всего, такое
имя будет уникальным и не вступит в конфликт со значениями, которые
другие программисты используют в директиве #define или собственных
защитах от повторных включений1.

Другие правила работы с заголовочными файлами


Никогда не включайте файл .cpp напрямую. Это приведет к проблемам,
поскольку компилятор создаст копию определения каждой функции файла
.cpp в каждом объектном файле, что не понравится компоновщику. Даже
аккуратно избежав этого, вы лишитесь преимущества экономии времени
за счет раздельной компиляции.
Есть интересное следствие из правила о том, что все функции должны суще-
ствовать в единственном экземпляре: в каждой сборке должен присутство-
вать единственный файл с функцией main. Функция main является точкой
входа в программу, поэтому должна существовать единственная ее версия.

Работа с несколькими файлами


Настройка компоновки нескольких исходных файлов зависит от среды,
в которой вы работаете. Я рассмотрю процесс настройки для всех IDE
начиная с Code::Blocks.

z z Code::Blocks
Чтобы добавить новый файл в текущий проект в Code::Blocks, выберите
в меню команду FileNewEmpty Source File… (ФайлСоздатьПустой
исходный файл…).

Если вы собираетесь делиться своим кодом или использовать большое коли-


1

чество чужого кода, можете указать имя или название компании в директиве
#define.
312    Глава 21. Разбиение программ на части

Среда спросит, добавить ли файл в текущий проект:

Выберите Yes (Да).


Задайте имя файла. После этого Code::Blocks спросит, в каких конфи-
гурациях сборки используется этот файл. Исходный файл на этом шаге
добавляется в компоновку.

Выберите все возможные варианты (как правило, Debug (Отладка) и Re-


lease (Выпуск)). Хотя заголовочный файл никогда не компонуется, эти
параметры можно задать и для него, поскольку Code::Blocks достаточно
сообразительна, чтобы не добавлять его в параметры компоновки.
Для использования новых файлов обычно требуется добавить как заго-
ловочный, так и исходный файл, а затем изменить исходный код в соот-
ветствии с уже рассмотренным.

z z g++
Если вы используете g++, нужно лишь создать файлы и присвоить им имена
в командной строке. Например, если вы работаете с исходными файлами
Как разделить программу на файлы    313

orig.cpp, shared.cpp и заголовочным файлом shared.h, скомпилировать ис-


ходные файлы можно командой
g++ orig.cpp shared.cpp

Указывать заголовочный файл в командной строке не нужно: он должен


быть включен в файлы .cpp, которым он необходим. Каждый файл можно
компилировать по отдельности, воспользовавшись флагом -c:
g++ -c orig.cpp
g++ -c shared.cpp

а затем скомпоновать полученные объектные файлы:


g++ orig.o shared.o

или
g++ *.o

если вы уверены, что в текущем каталоге нет лишних объектных файлов.


Управление раздельной компиляцией вручную — дело непростое. Гораздо
проще выполнить ее с помощью файла сборки. Файл сборки содержит
определение процесса сборки программы. В нем можно определять за-
висимости между различными исходными файлами: если вы изменяете
один исходный файл, файл сборки заново скомпилирует все зависящие
от него исходные файлы.
Файлы сборки в этой книге не рассматриваются. Дополнительная инфор-
мация о них есть на странице http://www.cprogramming.com/tutorial/makefiles.
html. Если вы пока не хотите изучать файлы сборки, можете компилировать
все последующие файлы C++ одновременно командой
g++ orig.cpp shared.cpp

z z Xcode
Чтобы добавить новый исходный файл в проект Xcode, выполните команду
меню FileNew File (ФайлНовый файл). Чтобы новые файлы отобра-
жались в папке Sources в иерархическом представлении слева, перед вы-
полнением команды FileNew File… выберите Sources в качестве каталога,
в котором располагается файл main.cpp. Это необязательно, но полезно
для структурирования проекта.
Выполнив команду FileNew, выберите один из нескольких типов файлов:
314    Глава 21. Разбиение программ на части

На левой панели выберите C and C++ (C и C++), а затем C++ file (Файл
C++) справа (или Header file (Заголовочный файл), если хотите добавить
только заголовочный файл). Чтобы добавить одновременно исходный и
заголовочный файлы, выберите вариант C++ file. В следующем окне будет
предложено создать заголовочный файл. Нажмите Next (Далее).
Проверьте себя    315

Выберите имя файла и его новое местоположение, если хотите изменить


местоположение по умолчанию. Можете просто принять параметры по
умолчанию: в этом примере я добавляю файл непосредственно в каталог
add_file, связанный с одноименным проектом.
Если вы выберете C++ file, будет возможность создать заголовочный файл;
я установил флажок на приведенном рисунке. Если воспользоваться этим
вариантом, заголовочный файл откроется после нажатия на кнопку Finish
(Готово).
Среда Xcode автоматически настроит процесс сборки так, чтобы новый
файл cpp скомпилировался и скомпоновался с другими файлами.

Проверьте себя
1. Что из нижеперечисленного не входит в процесс сборки C++?
А. Компоновка.
Б. Компиляция.
В. Предварительная обработка.
Г. Окончательная обработка.
2. Когда происходит ошибка, связанная с отсутствием определения функ-
ции?
А. На этапе компоновки.
Б. На этапе компиляции.
В. При запуске программы.
Г. При вызове функции.
3. Что произойдет, если включить заголовочный файл несколько раз?
А. Появятся ошибки из-за повторных объявлений.
Б. Ничего, заголовочные файлы всегда загружаются однократно.
В. Зависит от реализации заголовочного файла.
Г. Заголовочные файлы не могут быть включены несколькими исход-
ными файлами одновременно, поэтому никаких проблем не возникнет.
4. В чем преимущества разделения компиляции и компоновки?
А. Ни в чем: процесс сборки становится запутанным и медленным из-за
одновременной работы нескольких программ.
Б. Проще обнаруживать ошибки, поскольку компоновщик и компиля-
тор позволяют определить местоположение проблемы.
316    Глава 21. Разбиение программ на части

В. Можно повторно компилировать только измененные файлы, что


сокращает длительность компиляции и компоновки.
Г. Можно повторно компилировать только измененные файлы, что
сокращает длительность компиляции.
(Решения см. на с. 466.)

Практические задания
1. Напишите программу, которая содержит функции add, subtract, multi-
ply и divide. Каждая должна принимать два целых числа и возвращать
результат операции. Создайте небольшой калькулятор, который ис-
пользует эти функции. Поместите объявления функций в заголовочный
файл, оставив при этом код этих функций в исходном файле.
2. Переработайте программу, описанную в задаче 1: поместите определе-
ния функций в новый исходный файл отдельно от кода калькулятора.
3. Возьмите реализацию двоичных деревьев, созданную при работе над
упражнениями главы 17, и поместите все объявления функций в один
заголовочный файл, объявления структур — в другой заголовочный
файл, а всю реализацию — в исходный файл. Создайте простую про-
грамму, которая использует базовые функции двоичного дерева.
Г лава 2 2
Введение в проектирование программ

Теперь, поняв, как физически хранить код на диске так, чтобы легко его
редактировать по мере расширения программы, перейдем к решению задачи
более высокого уровня: как логически организовать код с той же целью.
Рассмотрим самые распространенные проблемы, возникающие с ростом
размера программы.

Избыточный код
Хотя при первом знакомстве с функциями мы кратко затронули проблему
повторяющегося кода, изучим ее подробнее. По мере расширения программ
их фрагменты начинают многократно повторяться. При работе над созда-
нием видеоигры потребуется код, который отображает на экране различные
графические элементы (например, космический корабль или снаряд).
Чтобы нарисовать космический корабль, сначала необходимо решить зада-
чу попроще: нарисовать пиксель. Пиксель представляет собой цветную точ-
ку на экране, расположение которой задается двумерными координатами.
Как правило, отображение пикселя выполняет графическая библиотека1.
Кроме того, понадобится код, который использует пиксели (и другие базо-
вые графические элементы, предоставляемые графической библиотекой,

1
В этой книге мы не будем пользоваться графикой, но более подробную инфор-
мацию можно найти на странице http://www.cprogramming.com/graphics-programming.
html
318    Глава 22. Введение в проектирование программ

например линии и окружности) для отображения реальных элементов


игры: космических кораблей, снарядов и др.
Скорее всего, такое отображение придется выполнять часто: движущийся
космический корабль или летящий снаряд необходимо постоянно пере-
рисовывать. Поместив код, который рисует снаряд, всюду, где он отобра-
жается, вы создали бы очень много избыточного кода.
Такая избыточность неоправданно усложняет программу и ее восприятие.
Во избежание многократного повторения нужны стандартные способы
выполнения таких действий, как отрисовка космических кораблей или
снарядов. Для чего? Допустим, вы хотите что-нибудь изменить — скажем,
цвет снаряда. Если код, который рисует снаряд, повторяется в программе
десять раз, необходимо внести в программу десять изменений лишь для
того, чтобы поменять цвет снаряда. Это крайне неудобно!
Придется искать все экземпляры кода, отображающего снаряд, копировать
и вставлять новый код, иногда изменяя имена переменных во избежание
конфликтов. В любом случае придется рисовать снаряд вместо того, чтобы
давать команду «нарисуйте мне снаряд». Кроме того, в процессе чтения
кода необходимо разбираться в том, что он делает.
Понять, что код
circle(10, 10, 5);
fillCircle(10, 10, RED);

рисует снаряд, гораздо сложнее, чем если бы он выглядел так:


displayBullet(10, 10);

Функции позволяют использовать в коде удобные имена, по которым легко


запомнить, что он делает. Скорее всего, вы еще не ощутили это на практике,
однако позже, когда станете разрабатывать более крупные программы, вы
будете тратить больше времени на чтение кода, чем на его написание. По
этой причине одно лишь использование удобных имен и функций способно
существенно упростить работу.

Как хранятся данные


«Вирус избыточности» способен заразить не только ваши алгоритмы. Для
примера рассмотрим код со скрытой избыточностью. Допустим, вы хотите
создать шахматную игру, в которой текущая игровая ситуация представлена
в виде массива. Чтобы сделать ход, достаточно получить доступ к этому
массиву.
Как хранятся данные    319

Код, инициализирующий второй ряд с белыми пешками, может выглядеть


так:
enum ChessPiece {
WHITE_PAWN,
WHITE_ROOK
/* и другие фигуры */
};

// ... много кода

for(int i = 0; i < 8; i++)


{
board[i][1] = WHITE_PAWN;
}

Чтобы определить, какая фигура находится на определенной клетке, вы


просто считываете элемент массива:
// ... много кода
if(board[0][0] == WHITE_ROOK)
{
/* действия */
}

В процессе расширения программы в ее различных частях будет все больше


кода, использующего игровую доску. Чем это плохо? Операцию чтения из
массива, занимающую одну строку кода, вроде бы сложно назвать повторе-
нием. Тем не менее это повторение, поскольку вы обращаетесь к одной и той
же структуре данных. Код, работающий с шахматной доской, тесно связан
со способом ее представления. Вы не повторяете алгоритм, но повторяете
допущения о формате представления данных. То, что для доступа к доске
достаточно одной строки кода, не означает, что так будет всегда. Если вы ре-
ализуете доску иначе, для доступа к ней придется создать метод посложнее.
Сложные шахматные программы представляют доску в виде не массива,
а нескольких битовых полей1, для доступа к которым требуется несколь-
ко строк кода. Создавая шахматную программу, я, скорее всего, начал бы
с массива, чтобы сконцентрировать внимание на ключевых алгоритмах,
а не на оптимальном быстродействии кода. Но чтобы легко изменить пред-
ставление шахматной доски, я бы скрыл массив. Как это сделать?
Чтобы скрыть подробности отрисовки снарядов, мы поместили их в функ-
цию и вызывали ее вместо того, чтобы напрямую отображать снаряды на

См. http://en.wikipedia.org/wiki/Bitboard
1
320    Глава 22. Введение в проектирование программ

экране. Здесь мы тоже можем воспользоваться функцией, чтобы скрыть


нюансы представления шахматной доски. Вместо того чтобы напрямую
использовать массив, мы вызовем функцию getPiece, которая получает
доступ к нему:
int getPiece(int x, int y)
{
return board[x][y];
}

Обратите внимание: функция принимает два аргумента и возвращает


значение — так же, как операция доступа к массиву. Она не уменьшает
объем набираемого текста, поскольку вы указываете те же входные дан-
ные, что и раньше, — координаты x и y. Разница в том, что метод доступа
к доске теперь скрыт внутри функции. Для доступа к доске вся остальная
программа может пользоваться этой функцией (и именно так ей следует
поступать). Если позже вы решите изменить представление доски, доста-
точно отредактировать всего одну функцию — вся остальная программа
будет работать без изменений1.
Использование функции для скрытия нюансов реализации иногда назы-
вают функциональным абстрагированием. При функциональном абстра-
гировании все повторяющиеся действия помещаются в функцию, которая
определяет входные и выходные данные, но не предоставляет вызываю-
щему окружению никакой информации о том, как она реализована. Под
как подразумеваются алгоритмы и структуры данных, дающие результат.
Вызывающее окружение может использовать интерфейс функции, не зная
о подробностях ее реализации.
Скрытие данных и алгоритмов в функции имеет ряд достоинств.
1. Упрощается работа в будущем. Вместо того чтобы запоминать, как реа-
лизован алгоритм, вы просто используете написанную ранее функцию.
Если вы доверяете результатам, которые функция генерирует для всех
корректных входных данных, не нужно держать в голове ее алгоритм.
2. Убедившись, что функции «просто работают», можно приступить к их
многократному использованию и переключиться на решение целе-
вых задач. Не нужно беспокоиться о нюансах (как получить доступ
к игровой доске) — сконцентрируйтесь на новых задачах (как создать
интерфейс приложения).

При условии, что вы всегда использовали эту функцию для доступа к доске.
1

Вам понадобятся функции, которые ставят фигуры на доску, но изменить пару


функций гораздо проще, чем десятки или сотни.
Проектирование и комментарии    321

3. Если в логике обнаружится ошибка, придется внести изменения лишь


в одну функцию, а не во множество мест в коде.
4. Функции, которые скрывают структуры данных, обеспечивают гиб-
кость хранения и представления данных. Можно сначала создать не-
эффективные простые представления, а затем при необходимости за-
менить их быстрыми реализациями, изменив всего несколько функций.

Проектирование и комментарии
Продуманные функции следует не только создавать, но и документировать.
Однако хорошо документировать функцию не так просто, как кажется.
Хорошие комментарии отвечают на вопросы, которые возникают у того,
кто читает код. Не следует применять на практике комментарии, которые
я использовал в примерах этой книги:
// объявляем переменную i и присваиваем ей начальное значение, равное 3
int i = 3;

Подобные комментарии отвечают на вопросы тех, кто только начинает


изучать программирование, но в реальной жизни люди, которые читают
код, уже знают C++.
Хуже то, что с течением времени комментарии теряют актуальность, и тот,
кто читает устаревший комментарий, не только теряет время, но и рискует
совершенно неверно понять происходящее в программе.
Лучше писать комментарии, которые отвечают на вопросы типа «Это
странный подход, почему он используется?» или «Каковы допустимые
значения для этой функции и что они означают?». Приведем образец до-
кументации, к которому следует стремиться при создании любой функции:
/*
* вычисляет значение Фибоначчи для данного положительного целого n.
Если * значение от n меньше 1, функция возвращает 1
*/
int fibonacci(int n);

Обратите внимание: комментарий к этой функции точно описывает, что


она делает, какие аргументы корректны и что происходит, если в функцию
передаются некорректные аргументы. Это отличная документация — она
избавляет пользователя от изучения реализации функции!
Хорошие комментарии необязательно подробны — не пытайтесь сопрово-
ждать объяснениями каждую строку кода. Я всегда документирую функции,
322    Глава 22. Введение в проектирование программ

предназначенные для использования вне файла, в котором реализованы,


и привожу пояснения кода, необычного или неочевидного.
Есть вредная привычка — откладывать документирование программ до
завершающей стадии цикла разработки, из-за которой количество коммен-
тариев в них слишком мало. Возвращаться к написанному коду и создавать
информативные комментарии слишком поздно: вы всего лишь излагаете
информацию, которую можно получить, читая код. Полезнее всего писать
комментарии в процессе создания программы.

Проверьте себя
1. В чем преимущество использования функции перед прямым доступом
к данным?
А. Компилятор может оптимизировать функцию и ускорить доступ
к данным.
Б. Функция скрывает нюансы реализации от вызывающего окружения,
что упрощает его изменение.
В. Функции — единственный способ хранения одной структуры данных
в нескольких файлах с исходным кодом.
Г. Такого преимущества нет.
2. Когда следует помещать код в общую функцию?
А. Если собираетесь вызвать ее хотя бы один раз.
Б. Если используете один и тот же код более чем в двух местах про-
граммы.
В. Если компилятор выводит ошибки, связанные с большим размером
функции.
Г. Б и В.
3. С какой целью скрывается представление структуры данных?
А. Для упрощения ее замены.
Б. Для упрощения чтения кода, использующего структуру данных.
В. Для упрощения использования структуры данных в новых фрагмен-
тах кода.
Г. Во всех вышеперечисленных целях.
(Решения см. на с. 467.)
Г лава 2 3
Скрытие представления
структурированных данных

Мы уже видели, как можно скрывать данные, хранимые в глобальных


переменных и в массиве. Данные скрываются и другими способами.
Одна из самых распространенных ситуаций, в которых нужно скрыть
данные, — создание структуры. Это может показаться очень странным,
ведь структура хранит набор специфически организованных значений.
Когда вы рассматриваете структуру как группу полей, она не позволяет
скрыть детали собственной реализации (какие поля и в каком формате
она хранит). Вы можете задать вопрос: разве смысл структуры не в том,
чтобы обеспечивать доступ к определенным фрагментам данных? Зачем
скрывать их представление? Дело в том, что структуры можно рассма-
тривать с иной точки зрения, в которой скрытие представления данных
действительно необходимо.
Если есть набор взаимосвязанных данных, важно не то, как они хранятся,
а то, что с ними можно делать. Это существенный аспект, который может
изменить подход к работе с данными. Сформулируем это в виде правила и
выделим его жирным шрифтом: важно не то, как хранятся данные, а то,
как они используются.
В качестве простого примера рассмотрим строку. Если вы не реализуете
класс string самостоятельно, не важно, как именно хранится строка: нужно
знать, как определить длину строки, получить доступ к конкретным ее сим-
волам или отобразить ее на экране. Возможно, символы строки хранятся
324    Глава 23. Скрытие представления структурированных данных

в массиве, а ее длина — в отдельной переменной. Возможно, строка реа-


лизована в виде связанного списка или с использованием возможностей
C++, о которых вы никогда не слышали.
Вас, как пользователя строки, интересуют не нюансы ее реализации, а что
с ней можно делать. В языке C++ около 35 различных операций над стро-
ками, большинством из которых вы будете пользоваться лишь изредка.
Часто необходимо создавать новые типы данных, не раскрывая их вну-
треннюю структуру. Например, создавая строку, не нужно беспокоиться
о буфере, в котором хранятся ее символы. Таким же образом устроены
векторы и словари STL: чтобы их использовать, необязательно знать, как
они реализованы.

Скрытие формата структуры с помощью функций


Поля структуры можно скрыть, связав с ней функции. Представим реали-
зацию небольшой шахматной доски, хранящую данные о текущей игровой
позиции и стороне, которая должна сделать следующий ход (белые или
черные). Фигуры и игроки описаны перечислениями:

enum ChessPiece {
EMPTY_SQUARE,
WHITE_PAWN
/* и др. */
};
enum PlayerColor {PC_WHITE, PC_BLACK};
struct ChessBoard
{
ChessPiece board[8][8];
PlayerColor whose_move;
};

Можно создать функции, управляющие игровой доской, принимая ее как


аргумент:

ChessPiece getPiece (
const ChessBoard *p_board,
int x,
int y
)
{
return p_board->board[x][y];
}
Скрытие формата структуры с помощью функций    325

PlayerColor getMove(const ChessBoard *p_board)


{
return p_board->whose_move;
}
void makeMove(
ChessBoard* p_board,
int from_x,
int from_y,
int to_x,
int to_y
)
{
// по идее, нужен код, который сначала проверяет
// ход
p_board->board[to_x][to_y] =
p_board->board[from_x][from_y];
p_board->board[from_x][from_y] = EMPTY_SQUARE;
}

Эти функции можно использовать так же, как и любые другие:

ChessBoard b;
// сначала инициализируем шахматную доску;
// затем воспользуемся ею следующим образом:
getMove(& b);
// передвигаем фигуру с позиции 0, 0 на позицию 1, 0
makeMove(& b, 0, 0, 1, 0);

Это отличный подход, который давно используют программисты на язы-


ке C. С другой стороны, все эти функции связаны только со структурой
ChessBoard, поскольку принимают ее в качестве аргумента. В программе
нет оператора, который бы прямо говорил: «эта функция должна являться
неотъемлемой частью данной структуры». Было бы очень удобно иметь
возможность указать, что структура содержит не только данные, но и опе-
рации, которые над ними можно выполнять.
В C++ эта возможность является ключевой и встроена непосредственно
в язык. В ее основе лежит концепция метода — функция, которая является
частью структуры и объявлена внутри нее (вы уже видели методы в главе
об STL). В отличие от независимых функций, не связанных со структурой,
методы могут легко оперировать данными, которые в ней хранятся. Объяв-
ление метода внутри структуры напрямую связывает метод со структурой
и избавляет вызывающее окружение от необходимости передавать струк-
туру в качестве отдельного аргумента. Тем не менее для работы с методами
необходимо использовать специальный синтаксис.
326    Глава 23. Скрытие представления структурированных данных

Объявление методов и синтаксис их вызова


Если превратить функции в методы, код будет выглядеть так:

Пример 56: method.cpp


enum ChessPiece {
EMPTY_SQUARE,
WHITE_PAWN
/* и др. */
};
enum PlayerColor {PC_WHITE, PC_BLACK};
struct ChessBoard
{
ChessPiece board[8][8];
PlayerColor whose_move;
ChessPiece getPiece (int x, int y)
{
return board[x][y];
}
PlayerColor getMove()
{
return whose_move;
}
void makeMove(
int from_x,
int from_y,
int to_x,
int to_y
)
{
// по идее, нужен код, который сначала проверяет
// ход
board[to_x][to_y] =
board[from_x][from_y];
board[from_x][from_y] = EMPTY_SQUARE;
}
};

Как видите, методы объявлены внутри структуры. Это означает, что методы
являются ее неотъемлемой частью.
Кроме того, объявления методов не включают отдельный аргумент типа
ChessBoard, поскольку метод имеет прямой доступ ко всем полям струк-
туры. Обращение board[x][y] получает непосредственный доступ к полю
board структуры, метод которой был вызван. Но как код определяет, с каким
экземпляром структуры он должен работать, если в программе существует
несколько переменных типа ChessBoard?
Скрытие формата структуры с помощью функций    327

Вызов метода выглядит так:

ChessBoard b;
// код, инициализирующий доску
b.getMove();

Вызов функции, связанной со структурой, почти ничем не отличается от


обращения к ее полю.
Доступ метода к данным структуры, в которую он входит, обеспечивается
компилятором. Синтаксис <переменная>.<метод> представляет собой со-
кращенное обозначение передачи переменной методу. Теперь ясно, почему
этот синтаксис был нужен в главе о библиотеке STL: ее функции устроены
как методы.

z z Определение функций за пределами структуры


Размещение тел всех функций внутри структуры делает код запутанным
и трудночитаемым. К счастью, можно объявлять методы внутри структуры,
а определять — за ее пределами. Пример:

enum ChessPiece {
EMPTY_SQUARE,
WHITE_PAWN
/* и др. */
};
enum PlayerColor {PC_WHITE, PC_BLACK};

struct ChessBoard
{
ChessPiece board[8][8];
PlayerColor whose_move;

// объявления методов внутри структуры


ChessPiece getPiece(int x, int y);
PlayerColor getMove();
void makeMove(
int from_x,
int from_y,
int to_x,
int to_y
);
};

Объявления методов находятся внутри структуры и внешне не отличаются


от обычных прототипов функций.
328    Глава 23. Скрытие представления структурированных данных

Определения методов необходимо связать со структурой, для чего ис-


пользуется специальный синтаксис, показывающий, что метод отно-
сится к определенной структуре. Имя метода записывается в виде <имя
структуры>::<имя метода>, а его код остается неизменным:
ChessPiece ChessBoard::getPiece(int x, int y)
{
return board[x][y];
}
PlayerColor ChessBoard::getMove()
{
return whose_move;
}
void ChessBoard::makeMove(
int from_x,
int from_y,
int to_x,
int to_y
)
{
// по идее, нужен код, который сначала проверяет
// ход
board[to_x][to_y] = board[from_x][from_y];
board[from_x][from_y] = EMPTY_SQUARE;
}

В следующих главах на объявления и определения я буду разбивать все


методы, длина которых превышает несколько строк. Некоторые практики
рекомендуют никогда не размещать методы внутри структуры, поскольку
это предоставляет избыточную информацию об их реализации. Чем боль-
ше известно о нюансах реализации метода, тем больше вероятность того,
что кто-нибудь напишет код, который воспользуется ими в дополнение к
интерфейсу метода. Тем не менее для экономии места я буду иногда раз-
мещать объявления методов внутри класса.

Проверьте себя
1. Почему следует использовать метод вместо прямого доступа к полю
структуры?
А. Метод легче читать.
Б. Метод работает быстрее.
В. Следует пользоваться не методом, а прямым доступом к полю.
Г. Это позволяет изменить представление данных.
Проверьте себя    329

2. Что из нижеперечисленного определяет метод, связанный со структу-


рой struct MyStruct{int func();};?
А. int func() {return 1;}
Б. MyStruct::int func() {return 1;}
В. int MyStruct::func() {return 1;}
Г. int MyStruct func () {return 1;}
3. Почему следует размещать определение метода внутри класса?
А. Чтобы пользователи класса понимали, как он работает.
Б. Чтобы ускорить выполнение кода.
В. Не следует, поскольку это разглашает излишние подробности реа-
лизации.
Г. Не следует, поскольку это замедляет работу программы.
(Решения см. на с. 468.)

Практические задания
1. Напишите структуру, предоставляющую интерфейс с полем для игры
в крестики-нолики. Реализуйте игру в крестики-нолики, используя
методы этой структуры. Основные операции, такие как ходы игроков
и проверка выигрыша одного из них, должны выполняться интерфей-
сом структуры.
Г лава 2 4
Классы

Создавая язык C++, Бьерн Страуструп хотел развить идею создания струк-
тур, в которых главное место занимают не данные, а функции. Он мог бы
достичь своей цели, расширив существующую концепцию структуры, но
вместо этого создал абсолютно новую идею класса.
Класс подобен структуре, но дает дополнительную возможность опре-
делять, какие методы и данные относятся к его внутренней реализации,
а какие предназначены для пользователей класса. Класс является сино-
нимом категории: определяя класс, вы создаете новую категорию или вид
предметов. Класс уже не является структурированным набором данных: он
определяется методами, из которых образован его интерфейс. Более того,
классы способны упреждать непреднамеренное использование внутренних
механизмов их реализации.
Язык C++ позволяет методам, не принадлежащим классу, запретить ис-
пользовать его внутренние данные. На самом деле когда вы создаете класс,
по умолчанию его внешнему окружению доступны только его методы!
Вы должны явно указывать, какие составляющие класса общедоступны.
Возможность запрещать использование данных класса за его пределами
позволяет компилятору пресекать доступ программистов к внутренним
данным класса. Это очень хорошо с точки зрения надежности программы:
можно изменять базовые структурные элементы класса (например, формат
представления шахматной доски), сохраняя неизменным код вне его.
Даже являясь единственным программистом в проекте, приятно быть уве-
ренным, что никто не попытается проникнуть в написанные вами методы.
Скрытие данных    331

Как вы скоро увидите, только методы имеют доступ к внутренним данным


класса.
В дальнейшем я буду использовать классы в ситуациях, когда требуется
скрывать формат хранения данных, а структуры — когда такой необходи-
мости нет. Вы удивитесь, как редко используются структуры — настолько
ценна возможность скрывать данные. Почти единственной ситуацией,
в которой я буду использовать обычную структуру, является реализация
класса, часть данных которого удобно хранить в виде структуры. Поскольку
вспомогательная структура принадлежит конкретному классу и не явля-
ется общедоступной, как правило, нет необходимости оформлять ее в виде
класса. Такой подход не является строгим правилом, но принят на практике.

Скрытие данных
Рассмотрим синтаксис скрытия данных классами. Воспользуемся классом,
чтобы скрыть некоторые данные и при этом обеспечить общий доступ
к ряду методов. Класс позволяет определить все методы и поля (их часто
называют членами класса) как открытые и закрытые. Открытые члены
доступны любому, а закрытые — только другим членам класса1.
Приведем пример класса, в котором методы объявлены открытыми, а все
данные — закрытыми.

Пример 57: class.cpp


enum ChessPiece{
EMPTY_SQUARE,
WHITE_PAWN
/* и др. */
};

enum PlayerColor{PC_WHITE, PC_BLACK};

class ChessBoard
{
public:

ChessPiece getPiece(int x, int y);


PlayerColor getMove();
void makeMove(
int from_x,
int from_y, продолжение 

Есть и третий тип: защищенный. О нем позже.


1
332    Глава 24. Классы

int to_x,
int to_y
);

private:
ChessPiece _board[8][8];
PlayerColor _whose_move;
};

// Те же определения методов!
ChessPiece ChessBoard::getPiece(int x, int y)
{
return _board[ x ][ y ];
}

PlayerColor ChessBoard::getMove()
{
return _whose_move;
}

void ChessBoard::makeMove(
int from_x,
int from_y,
int to_x,
int to_y
)
{
// по идее, нужен код, который сначала проверяет
// ход
_board[to_x][to_y] = _board[from_x][from_y];
_board[from_x][from_y] = EMPTY_SQUARE;
}

Обратите внимание: объявление класса очень похоже на рассмотренное


ранее объявление структуры, но с одним существенным отличием. Я вос-
пользовался двумя новыми ключевыми словами: public и private. Члены
класса, объявленные после ключевого слова public (в данном случае
методы getPiece, getMove и makeMove), доступны любому окружению,
в котором используется объект. Члены класса, объявленные в разделе, на-
чинающемся со слова private, доступны только методам, реализованным
в классе ChessBoard (_board и _whose_move).
Любые члены класса можно делать открытыми и закрытыми. В следующем
объявлении открытыми являются те же члены класса, что и в предыдущем
примере:

class ChessBoard
{
public:
Обязанности класса    333

ChessPiece getPiece (int x, int y);

private:
ChessPiece _board[8][8];
PlayerColor _whose_move;

public:
int getMove();
void makeMove(int from_x, int from_y, to_x, to_y);
};

Сначала я всегда создаю раздел public, а затем — раздел private. Это


подчеркивает, что раздел public предназначен для пользователей класса
(других программистов), поскольку именно его пользователи видят в пер-
вую очередь.

Объявление экземпляра класса


Объявление экземпляра класса похоже на объявление экземпляра струк-
туры:
ChessBoard b;

Метод класса вызывается точно так же:


b.getMove();

Тем не менее есть одно терминологическое отличие. Переменная класса


называется объектом. Объект ассоциируется с предметами реального мира,
такими как рулевое колесо: они предоставляют пользователям простой
интерфейс, скрывающий различные нюансы. Поворачивая руль влево,
вы не задумываетесь, как устроен механизм передачи. Все, что нужно
сделать, — повернуть колесо и нажать педаль газа. Все тонкости скрыты за
простым пользовательским интерфейсом. В языке C++ нюансы реализации
объекта скрыты за набором открытых методов — именно они формируют
пользовательский интерфейс объекта. После определения интерфейса
класс может реализовать его как угодно. Вы сами решаете, в каком виде
представить данные и какие действия выполнить в методах.

Обязанности класса
Создавая класс в C++, представляйте его себе как новый вид переменной
(тип данных), который похож на int или char, но обладает более мощными
возможностями. Вы уже знакомы с классами на примере строк: строки
334    Глава 24. Классы

являются классом, который фактически представляет собой новый тип


данных. Идея деления членов класса на открытые и закрытые полезна при
создании нового типа данных: вы обеспечиваете его специфичные функ-
ции и интерфейс. Например, строка способна отображать себя, работать
с подстроками и отдельными символами и определять основные атрибуты
(например, длину). При этом не важно, каким образом реализована строка.
Рассматривая создание класса как определение нового типа данных, вы
в первую очередь должны решить, какие члены класса будут открытыми
(что класс будет делать). Любой программист может использовать все
открытые члены класса: рассматривайте их как интерфейс, аналогичный
прототипу функции, который содержит ее аргументы и возвращаемое
значение. Интерфейсу следует уделить особое внимание, поскольку вне-
сение в него любых изменений после того, как он передан пользователям
класса, потребует, чтобы они соответствующим образом изменили свой код.
Открытый метод может вызываться из самых разных частей программы:
нельзя запросто ограничить доступ к нему, и количество исправлений, ко-
торое потребуется внести в код, неизвестно. Если бы любой водитель мог
объявить, что его принципы вождения автомобиля являются стандартом
для всех, нам бы пришлось постоянно переучиваться! При этом можно без
проблем заменить бензиновый двигатель гибридным, поскольку это затро-
нет лишь реализацию автомобиля и сохранит неизменным его интерфейс.
Создав базовый открытый интерфейс класса, вы должны подумать, как
реализовать его методы. Все методы и поля, которые используются откры-
тыми методами, но сами не обязаны быть открытыми, следует объявить
закрытыми.
В отличие от открытого интерфейса, закрытые методы и данные можно
легко изменять. Закрытые члены класса доступны только методам этого
класса (как открытым, так и закрытым). Объявляя детали реализации за-
крытыми, вы даете себе возможность изменять функционирование класса
в будущем. (Очень сложно достичь нужного результата с первого раза.)
Помните о гибридном автомобиле!
Мой совет прост: никогда не объявляйте поля данных открытыми, делайте
методы закрытыми по умолчанию и переносите их в открытый интерфейс,
лишь если уверены, что это необходимо. Сделать закрытый член класса
открытым гораздо проще, чем наоборот: невозможно поместить джинна
в бутылку. Для доступа к определенному полю класса напишите метод
чтения, возвращающий значение этого поля, и метод записи, присваива-
ющий полю заданное значение.
Заключение    335

Может показаться, что скрывать все поля данных класса неудобно. При-
дется написать много методов чтения и записи вроде getMove, которые
всего лишь возвращают значения закрытых полей структуры, таких как
_whose_move.

Иногда это действительно так, однако затраты на написание таких мето-


дов несравнимо меньше проблем расширения функциональности метода
чтения. Допустим, вы хотите не просто хранить значение в переменной,
а вычислять его, используя несколько переменных. Если у вас нет метода
чтения и вы открываете свободный доступ к данным, объявляя их откры-
тыми, возникнут трудности.
Есть примеры полей, которые можно смело сделать открытыми, однако не
рекомендую так поступать: вы сэкономите немного времени, но рискуете
получить очень серьезные проблемы позже, поскольку изменить неудачно
выбранную структуру класса сложно.

В чем истинный смысл закрытых членов класса?


Объявление члена класса закрытым еще не гарантирует его защищенность.
Закрытые поля класса, как и открытые, как правило, хранятся в памяти
рядом друг с другом. Любой код может считать эти данные, умело исполь-
зуя указатели. Операционная система и язык C++ не гарантируют защиту
закрытых данных от вредоносных действий со стороны. Компилятор всего
лишь предотвращает случайное использование закрытых данных, однако
это приносит некоторую пользу.
Использование открытых методов, чтобы оградить закрытые данные от
доступа, называется инкапсуляцией. Инкапсуляция предполагает, что
пользователи класса работают лишь с определенным набором методов,
образующих интерфейс класса. Фразы вроде «скрытие данных» или
«нюансы реализации» понятнее, однако термин «инкапсуляция» весьма
распространен, и теперь вы знаете его смысл.

Заключение
Класс — один из фундаментальных компонентов, образующих большин-
ство реальных программ на языке C++. С помощью классов программисты
создают масштабные компоненты, которые легко понять и использовать.
Вы изучили одну из самых мощных возможностей классов — скрытие
данных, и в следующих главах продолжите знакомиться с другими много-
численными возможностями классов.
336    Глава 24. Классы

Проверьте себя
1. Для чего используются закрытые данные?
А. Для защиты от хакеров.
Б. Чтобы другие программисты не могли их изменять.
В. Для внутренней реализации класса.
Г. Не следует использовать закрытые данные: они усложняют про-
граммирование.
2. Чем класс отличается от структуры?
А. Ничем.
Б. Все члены класса по умолчанию являются открытыми.
В. Все члены класса по умолчанию являются закрытыми.
Г. Класс позволяет указывать, какие его члены являются открытыми,
а какие — закрытыми.
3. Что делать с полями данных класса?
А. По умолчанию объявлять их открытыми.
Б. По умолчанию объявлять их закрытыми, но при необходимости
делать открытыми.
В. Никогда не объявлять их открытыми.
Г. Как правило, классы не содержат данные, но если содержат, то ничего.
4. Как определить, следует ли объявить метод открытым?
А. Никогда не объявляйте методы открытыми.
Б. Всегда объявляйте методы открытыми.
В. Объявляйте методы открытыми только при условии, что они по-
зволяют использовать основные возможности класса.
Г. Объявляйте методы открытыми, если есть хотя бы малейший шанс,
что кто-нибудь ими воспользуется.
(Решения см. на с. 468.)

Практические задания
1. Возьмите структуру (поле для игры в крестики-нолики) из практиче-
ской задачи в конце предыдущей главы и реализуйте ее в виде класса.
Объявите общедоступные методы открытыми, а данные и все вспомо-
гательные функции — закрытыми. Сколько кода пришлось изменить?
Г лава 2 5
Жизненный цикл класса

Создавая класс, вы стремитесь, чтобы он был как можно проще в исполь-


зовании. Практически любой класс поддерживает следующие три базовые
операции.
1. Инициализация себя.
2. Очистка памяти или других ресурсов.
3. Копирование себя.
Все три действия важны для удобства создаваемого типа данных в работе.
Возьмем в качестве примера строку: она должна уметь инициализировать
себя, даже если она пуста. Для этого она не должна пользоваться внешним
кодом и быть доступной для дальнейших операций сразу после ее объявле-
ния. Когда вы заканчиваете работать со строкой, она должна освобождать
занимаемую память. Для этого вы не вызываете специальный метод —
очистка происходит автоматически. Наконец, строковый класс должен
уметь копировать одну переменную в другую, так же как целочисленный
тип данных. Любой класс должен обеспечивать три перечисленные воз-
можности, чтобы с ним было легко работать и не допускать ошибки при
его использовании.
Рассмотрим каждую возможность отдельно и увидим, что язык C++ по-
зволяет с легкостью их реализовать. Начнем с инициализации объекта.
338    Глава 25. Жизненный цикл класса

Создание объекта
Возможно, вы обратили внимание, что в интерфейсе ChessBoard (открытой
части класса) не было кода, который инициализировал доску. Исправим.
Переменную класса при объявлении необходимо как-то инициализировать:
ChessBoard board;

В языке C++ код, который выполняется при объявлении объекта, называ-


ется конструктором. Конструктор должен настроить объект так, чтобы его
можно было использовать без дальнейшей инициализации. Конструктор
может принимать аргументы — вы наблюдали это при объявлении вектора
определенного размера:
vector<int> v(10);

Этот оператор вызывает конструктор вектора с аргументом 10, конструк-


тор инициализирует новый вектор так, что он немедленно готов хранить
10 значений.
Чтобы создать конструктор, вы просто объявляете функцию, которая
имеет имя, совпадающее с именем класса, не принимает аргументов и не
возвращает значения (даже void  — вы просто не указываете тип возвра-
щаемого значения).

Пример 58: constructor.cpp


enum ChessPiece{
EMPTY_SQUARE,
WHITE_PAWN
/* и др. */
};
enum PlayerColor{PC_WHITE, PC_BLACK};

class ChessBoard
{
public:

ChessBoard(); // <-- нет возвращаемого значения!

PlayerColor getMove();
ChessPiece getPiece(int x, int y);
void makeMove(
int from_x,
int from_y,
int to_x,
int to_y
);
Создание объекта    339

private:
ChessPiece _board[8][8];
PlayerColor _whose_move;
};

ChessBoard::ChessBoard() // <-- снова нет возвращаемого значения


{
_whose_move = PC_WHITE;
// сначала очищаем всю доску, затем заполняем
// фигурами
for(int i = 0; i < 8; i++)
{
for(int j = 0; j < 8; j++)
{
_board[i][j] = EMPTY_SQUARE;
}
}
// дальнейшие действия инициализации доски...
}

(Не буду приводить определения методов, которые остались без измене-


ний, но покажу объявление класса целиком, чтобы вы смогли понять, как
оно устроено.)
Обратите внимание: конструктор входит в открытую часть класса. Если
бы конструктор ChessBoard не был открытым, было бы невозможно созда-
вать экземпляры объекта. Почему? Конструктор вызывается при каждом
создании объекта, но если бы он был закрытым, к нему нельзя было бы
обратиться вне класса! Поскольку для собственной инициализации все
объекты должны вызывать конструктор, вы просто не сможете объявить
объект.
Конструктор вызывается в строке, создающей объект:
ChessBoard board; // вызывает конструктор ChessBoard

или при выделении памяти:

//вызывает конструктор ChessBoard при выделении памяти


ChessBoard *board = new ChessBoard;

Если вы объявляете несколько объектов:

ChessBoard a;
ChessBoard b;

конструкторы выполняются в порядке объявления объектов (сначала a,


затем b).
340    Глава 25. Жизненный цикл класса

Конструктор, как и обычная функция, может принимать любое количество


аргументов. Можно перегружать конструкторы с помощью типов аргу-
ментов, если объект можно инициализировать различными способами.
Например, можно создать второй конструктор ChessBoard, который при-
нимает в качестве аргумента размер игровой доски:
Class ChessBoard
{
ChessBoard();
ChessBoard(int board_size);
};

Объявление конструктора ничем не отличается от объявления любого


другого метода класса:
ChessBoard::ChessBoard(int size)
{
// ... код
}

Передача аргумента конструктору осуществляется следующим образом:


// 8 является аргументом конструктора класса ChessBoard
ChessBoard board(8);

При использовании операции new передача аргумента выглядит так, как


будто вы вызываете конструктор напрямую:
ChessBoard *p_board = new ChessBoard(8);

Небольшое замечание о синтаксисе: хотя аргументы конструктору пере-


даются в круглых скобках, их нельзя использовать при объявлении объекта
конструктором без аргументов.
Некорректный код:
ChessBoard board();

Правильной будет следующая запись:


ChessBoard board;

Тем не менее круглые скобки можно указывать при использовании опе-


ратора new:
ChessBoard *board = new ChessBoard();

К сожалению, это странность языка C++, обусловленная малопонятными


нюансами разбора кода. Так что избегайте круглых скобок при объявлении
объекта с помощью конструктора, который не принимает аргументы.
Создание объекта    341

Что произойдет, если не создать конструктор


Если конструктор не создан, C++ делает это за вас. Этот конструктор не
принимает аргументы и инициализирует все поля класса, вызывая кон-
структоры по умолчанию (будьте аккуратны — он не инициализирует поля
int или char). Рекомендую создавать собственный конструктор, чтобы
инициализация объекта выполнялась так, как вам нужно.
Если вы объявляете конструктор класса, C++ не создает конструктор
по умолчанию. Компилятор предполагает, что вы знаете, что делаете,
и сами создадите все конструкторы для своего класса. В частности, если
вы создаете конструктор, который принимает аргументы, в коде не будет
конструктора по умолчанию, если только специально его не объявить.
Такой подход может приводить к любопытным последствиям. Если ваш код
использует автоматически сгенерированный конструктор по умолчанию,
а затем вы добавляете в класс собственный конструктор с аргументами, то
код, использовавший конструктор по умолчанию, перестает компилиро-
ваться. Придется вручную написать конструктор по умолчанию, поскольку
компилятор больше не создает его автоматически.

Инициализация членов класса


Каждый член класса должен инициализироваться в конструкторе. Допу-
стим, наш класс ChessBoard содержит строку:
class ChessBoard
{
public:

ChessBoard();
string getMove();
ChessPiece getPiece(int x, int y);
void makeMove(
int from_x,
int from_y,
int to_x,
int to_y
);

private:
PlayerColor _board[8][8];
string _whose_move;
};

Разумеется, можно просто присвоить значение переменной _whose_move:


342    Глава 25. Жизненный цикл класса

ChessBoard::ChessBoard()
{
_whose_move = "white";
}

Код, который здесь фактически выполняется, может показаться несколько


неожиданным. Прежде всего, в самом начале конструктора ChessBoard вы-
зывается конструктор члена _whose_move. Это хорошо, поскольку можно
уверенно пользоваться любыми полями класса в конструкторе. Если бы
конструкторы полей класса не вызывались, значения полей были бы не-
действительными, а ведь главная цель конструктора — сделать весь объект
пригодным для использования!
При желании можно передавать аргументы конструктору члена класса, а не
вызывать конструктор по умолчанию. Для этого используется несколько
непривычный синтаксис, который называется списком инициализации:
ChessBoard::ChessBoard()
// после двоеточия следует список переменных
// с аргументами для конструктора
: _whose_move("white")
{
// к этому моменту выполнен конструктор _whose_move
// со значением "white"
}

В дальнейшем мы несколько раз воспользуемся списками инициализации,


и я буду инициализировать члены класса при помощи этого синтаксиса.
Элементы списка инициализации разделяются запятыми. Добавив в класс
ChessBoard новый член для хранения количества ходов во время партии,
мы могли бы инициализировать его в списке инициализации:
class ChessBoard
{
public:

ChessBoard();

string getMove();
ChessPiece getPiece(int x, int y);
void makeMove(
int from_x,
int from_y,
int to_x,
int to_y
);
Уничтожение объекта    343

private:
PlayerColor _board[8][8];
string _whose_move;
int _move_count;
};

ChessBoard::ChessBoard()
// после двоеточия следует список переменных
// с аргументами конструктора

: _whose_move("white")
, _move_count(0)
{
}

Использование списка инициализации для константных полей


Поле класса, определяемое как константное, должно быть инициализиро-
вано в списке инициализации:
class ConstHolder
{
public:
ConstHolder(int val);

private:
const int _val;
};

ConstHolder::ConstHolder(int val)
: _val(val)
{}

Константное поле невозможно инициализировать, присвоив ему значение,


поскольку такое поле нельзя изменять. Список инициализации — един-
ственное место, где класс еще не полностью сформирован, поэтому можно
задавать значения константных объектов. По этой причине ссылки тоже
необходимо инициализировать в списке инициализации.
Мы познакомимся с еще одним применением списков инициализации при
изучении наследования.

Уничтожение объекта
Нужен не только конструктор, инициализирующий объект, но и некий
код, который удаляет объект, когда он больше не нужен. Например, если
344    Глава 25. Жизненный цикл класса

конструктор выделяет память (или любые другие ресурсы), их нужно вер-


нуть операционной системе после завершения работы с объектом. Такие
действия называются уничтожением объекта и выполняются специальным
методом — деструктором. Деструкторы вызываются, когда объект больше
не нужен, например при удалении указателя на него оператором delete.
Рассмотрим следующий пример. Допустим, класс представляет связанный
список. Чтобы реализовать его, вы создаете поле, которое хранит текущую
голову списка:

struct LinkedListNode
{
int val;
LinkedListNode *p_next;
};

class LinkedList
{
public:
LinkedList(); // конструктор
void insert(int val); // добавляет узел

private:
LinkedListNode *_p_head;
};

Мы знаем, что головной узел связанного списка, как и другие элементы,


указывает на память, выделенную с помощью оператора new. Это означа-
ет, что в какой-то момент, закончив работать с объектом LinkedList, мы
должны вернуть выделенную ему память. Именно для этого и потребу-
ется деструктор. Сейчас мы добавим его в наш класс. Деструктор, как и
конструктор, имеет особое имя, которое состоит из имени класса и знака
~ (тильда) перед ним и не имеет возвращаемого значения. В отличие от
конструктора, деструктор никогда не принимает аргументы.

class LinkedList
{
public:
LinkedList(); // конструктор
~LinkedList(); // деструктор, обратите внимание на тильду (~)

void insert(int val); // добавляет узел

private:
LinkedListNode *_p_head;
};
Уничтожение объекта    345

LinkedList::~LinkedList()
{
LinkedListNode *p_itr = _p_head;
while(p_itr != NULL)
{
LinkedListNode *p_tmp = p_itr->p_next;
delete p_itr;
p_itr = p_tmp;
}
}

Код деструктора похож на пример с удалением всех элементов связанного


списка, который вы видели ранее. Единственное отличие в том, что теперь
у нас есть класс со специальным методом, который выполняет эту очист-
ку. Стоп! А не лучше ли сделать так, чтобы каждый узел сам удалял свои
данные? Что, если мы напишем следующий код?

class LinkedListNode
{
public:
~LinkedListNode();
int val;
LinkedListNode *p_next;
};

LinkedListNode::~LinkedListNode()
{
delete p_next;
}

Хотите верьте, хотите нет, но этот код инициализирует цепь рекурсивных


вызовов функций. Вызов delete активирует деструктор объекта, на кото-
рый указывает указатель p_next (или не делает ничего, если значение p_next
равно NULL). Этот деструктор, в свою очередь, вызывает delete и активиру-
ет следующий деструктор. Но каков базовый вариант? Чем закончится цепь
деструкторов? В какой-то момент значение p_next окажется равным NULL,
и вызов delete не выполнит никаких действий. Таким образом, базовый
вариант существует и скрыт внутри вызова delete. Деструктор LinkedList
должен просто вызвать деструктор LinkedListNode:

LinkedList::~LinkedList()
{
delete _p_head;
}

Оператор delete запускает рекурсивную цепь, которая завершается в конце


списка.
346    Глава 25. Жизненный цикл класса

Вы спросите: зачем деструктор в этом замечательном алгоритме? Нельзя


ли создать метод и назвать его произвольным именем? Можно, но у де-
структора есть преимущество: он вызывается автоматически, когда объект
больше не нужен. «Объект больше не нужен» может означать следующее.
1. Вы удаляете указатель на объект.
2. Объект выходит из области видимости.
3. Объект принадлежит классу, деструктор которого вызывается.

Уничтожение объекта оператором delete


Как вы уже видели, оператор delete явно вызывает деструктор:
LinkedList *p_list = new LinkedList;
// ~LinkedList (деструктор) вызывается для p_list
delete p_list;

Уничтожение объекта при его выходе из области видимости


В этой ситуации уничтожение объекта происходит неявно. Объект, объ-
явленный внутри фигурных скобок, перестает быть видимым при выходе
за пределы этих скобок:
if(1)
{
LinkedList list;
} // деструктор list вызывается здесь

Более сложный пример — объект, объявленный внутри функции. Если


функция содержит оператор return, деструктор вызывается в процессе
завершения функции. Представьте себе, что деструкторы объектов, объ-
явленных внутри блока кода, вызываются в момент выполнения закры-
вающей фигурной скобки. Выход из блока происходит после выполнения
его последнего оператора или оператора break:
void foo()
{
LinkedList list;

// код
if(/* some condition */)
{
return;
}
} // деструктор list вызывается здесь
Уничтожение объекта    347

Хотя в этом случае оператор return находится внутри оператора if, де-
структор выполняется по достижении функцией закрывающей фигурной
скобки. Главное, что следует понять: деструктор выполняется один раз
в тот момент, когда объект оказывается за пределами видимости, то есть
когда обращение к нему вызовет ошибку компилятора.
Если у вас несколько объектов с деструкторами, которые должны быть
выполнены в конце блока кода, они выполняются в порядке, обратном
порядку создания объектов. Например, если объекты создавались следу-
ющим образом:
{
LinkedList a;
LinkedList b;
}

то деструктор объекта b будет выполнен раньше деструктора объекта a.

Уничтожение объекта другим деструктором


Наконец, если объект находится внутри другого класса, деструктор этого
объекта вызывается после выполнения деструктора класса. Допустим, что
есть следующий простой класс:

class NameAndEmail
{
/* здесь должны быть какие-нибудь методы */
private:
string _name;
string _email;
};

Здесь деструктор полей _name и _email будет вызван по завершении


деструктора для NameAndEmail. Это очень удобно: не нужно специально
предпринимать никаких действий для очистки объектов класса! Требуется
лишь очистить указатели с помощью операции delete (или освободить
другие ресурсы, такие как дескрипторы файлов или сетевые соединения).
Кстати, если вы не создадите деструктор для своего класса, компилятор все
равно запустит деструкторы всех объектов, входящих в состав этого класса.
Название принципа использования конструктора для инициализации
класса и деструктора для освобождения памяти и других ресурсов, принад-
лежащих классу, — выделение ресурсов является инициализацией (RAII,
resource allocation is initialization). Его основной смысл в том, что класс соз-
дается для работы с ресурсами, конструктор класса — для инициализации,
348    Глава 25. Жизненный цикл класса

а деструктор — для возврата выделенных ресурсов. Пользователи класса не


должны предпринимать никаких действий для решения этих задач. Мно-
гие классы похожи на приведенный выше класс NameAndEmail: две строки
сами освобождают занимаемую ими память, поэтому не нужно вручную
создавать деструктор класса NameAndEmail.

Копирование классов
Третий важный аспект работы с классами — копирование их экземпляров.
В C++ принято создавать новые классы, которые можно копировать:
LinkedList list_one;
LinkedList list_two;
list_two = list_one;
LinkedList list_three = list_two;

В языке C++ есть две функции, корректно копирующие объекты. Одной


из этих функций является оператор присваивания, название второй —
конструктор копирования. Сначала рассмотрим оператор присваивания,
а затем — конструктор копирования.
Вы спросите: для чего нужны эти функции? Почему они не могут «про-
сто работать»? Действительно, иногда они работают без дополнительных
усилий с вашей стороны; язык C++ предоставляет версии конструктора
копирования и оператора присваивания по умолчанию.
Тем не менее есть ситуации, в которых нельзя использовать версии по умол-
чанию, поскольку компилятор не способен догадаться, чего именно от него
хотят. Конструктор копирования и оператор присваивания по умолчанию
выполняют поверхностное копирование указателей. Поверхностное копи-
рование — это присваивание одному указателю значения другого указателя
так, чтобы они оба указывали на одну и ту же память (такое копирование
называется поверхностным, поскольку копируется не содержимое памяти,
а лишь указатель на нее). Иногда поверхностное копирование допустимо,
однако существуют ситуации, когда оно приводит к проблемам.
Допустим, у нас есть класс LinkedList и мы написали:
LinkedList list_one;
LinkedList list_two;
list_one = list_two;

Проблема в том, что оператор присваивания по умолчанию генерирует код


list_one._p_head = list_two._p_head;
Копирование классов    349

Можно визуально представить это следующим образом:

Какое-то значение

<Остаток списка>

Теперь есть два объекта с одинаковым значением указателя, и деструктор


каждого из них будет пытаться освободить память, связанную со своим
указателем.
Деструктор объекта list_two удалит указатель list_two._p_head (он за-
пустится первым, поскольку конструктор объекта list_two был запущен
вторым, а у конструкторов и деструкторов порядок выполнения противо-
положен). После этого деструктор list_one запустится и удалит указатель
list_one._p_head. Проблема в том, что указатель list_two._p_head уже
удален, а попытка дважды удалить один и тот же указатель приводит
к аварийному завершению программы.
Ясно, что выполнение одного деструктора создаст проблемы в другом
списке. Операторы присваивания позволяют предотвращать подобные
ситуации; далее мы рассмотрим, как ими пользоваться.

Оператор присваивания
Оператор присваивания вызывается при присваивании одному объекту
содержимого существующего объекта, например:
list_two = list_one;

Чтобы реализовать оператор присваивания, необходимо определить его


с помощью нового синтаксиса. К счастью, в нем нет ничего сложного:

LinkedList& operator= (
LinkedList& lhs,
const LinkedList& rhs
);

Он очень похож на обычное объявление функции, которая принимает два


аргумента — неконстантную ссылку на LinkedList и константную ссылку
на LinkedList — и возвращает ссылку на LinkedList. Необычно выглядит
лишь имя функции: operator=. Оно означает, что мы определяем не новую
функцию, а смысл знака равенства в ситуациях, когда он применяется
350    Глава 25. Жизненный цикл класса

к объектам класса LinkedList. Первый аргумент — это левый операнд


оператора присваивания; поскольку ему присваивается новое значение, он
не константный. Второй аргумент — это правый операнд оператора при-
сваивания, значение которого присваивается левому операнду. Поскольку
нет причин изменять правый операнд, его следует объявлять константным,
хотя это не строгое требование.
lhs = rhs;

Оператор присваивания возвращает ссылку на LinkedList, чтобы операции


присваивания можно было объединять в цепочки:
linked_list = lhs = rhs;

Как правило, функция operator= не определяется отдельно, как показано


выше, а является членом класса, чтобы иметь возможность работать с его
закрытыми полями. Это выглядит следующим образом:
class LinkedList
{
public:
LinkedList (); // конструктор
~LinkedList (); // деструктор (обратите внимание на тильду)
LinkedList& operator= (const LinkedList& other);

void insert(int val); // добавляет узел

private:
LinkedListNode *_p_head;
};

Обратите внимание: одного аргумента не хватает, поскольку все методы


класса неявно принимают класс в качестве аргумента. В данном случае
метод operator= используется, когда класс находится в левой части при-
сваивания. Другими словами, в коде
lhs = rhs;

функция operator= работает с переменной lhs так же, как если написать
lhs.operator=(rhs);

После завершения этой функции переменная lhs имеет такое же значение,


как переменная rhs. Рассмотрим, как реализовать функцию operator= для
класса LinkedList.
LinkedList& LinkedList::operator= (const LinkedList& other)
{
// что должно быть здесь?
}
Копирование классов    351

Из предыдущих рассуждений мы знаем, что просто копировать адрес ука-


зателя — плохое решение.
Нам нужно копировать всю структуру целиком. Для этого сначала осво-
бодим существующий список (поскольку он нам больше не нужен), а за-
тем скопируем каждый его узел, чтобы получить два отдельных списка.
Наконец, поскольку нам нужно вернуть значение, вернем копию класса,
с которой работаем.
Чтобы выполнить последнее действие, потребуется новый синтаксис, по-
скольку мы должны каким-то образом сослаться на текущий объект. Для
этого в C++ существует специальная переменная — указатель this, кото-
рый указывает на экземпляр класса. Например, если написать list_one.
insertElement(2 );, внутри объекта insertElement можно воспользоваться
ключевым словом this, которое указывает на list_one. С помощью указа-
теля this мы сделаем метод надежнее.
LinkedList& LinkedList::operator= (const LinkedList& other)
{
// Проверяем, не присваиваем ли объект самому себе;
// в этом случае можно игнорировать операцию. Обратите внимание:
// указатель 'this' позволяет проверить, что адрес other
// отличается от адреса нашего объекта
if(this == & other)
{
// возвращаем объект this, чтобы обеспечить
// непрерывность цепочки присваиваний
return *this;
}
// перед копированием новых значений мы должны освободить
// старую память, которая больше не используется
delete _p_head;
_p_head = NULL;
LinkedListNode *p_itr = other._p_head;
while(p_itr != NULL)
{
insert(p_itr->val);
}
return *this;
}

Несколько комментариев об этой функции. Во-первых, обратите внимание:


мы проверяем попытку присвоения объекта самому себе. Обычно такое
действие не происходит, однако следует позаботиться о его безопасности:
операции вроде
a = a;
352    Глава 25. Жизненный цикл класса

должны быть абсолютно безопасны и не приводить к каким-либо изме-


нениям.
Во-вторых, нужно освободить память, выделенную для старого списка,
поскольку мы больше с ним не работаем. Удалив указатель _p_head, мы
удаляем весь список, как в деструкторе.
Наконец, надо заполнить список правильными новыми значениями: об-
ходим старый список и вставляем все его элементы в новый. Теперь класс
можно копировать — вуаля!
К счастью, не всем классам требуется столь сложное копирование. Если
среди членов класса нет указателей, оператор присваивания, скорее всего,
вообще не потребуется! Удобный и продуманный язык C++ предоставит
оператор присваивания, который по умолчанию скопирует каждый эле-
мент класса с помощью его оператора присваивания (если он является
объектом класса) или битового копирования (если он является указателем
или значением другого типа). В большинстве случаев можно использовать
оператор присваивания по умолчанию, если класс не содержит указателей.
Есть практическое правило: если нужно написать деструктор, скорее всего,
потребуется написать и оператор присваивания. Дело в том, что если есть
деструктор, скорее всего, необходимо освобождать память, а значит, нужно
проверять, работает ли каждая копия класса с собственной копией памяти.

Конструктор копирования
Осталось рассмотреть последний ключевой вопрос: что делать для создания
копии существующего объекта.

LinkedList list_one;
LinkedList list_two( list_one );

Для этого потребуется специальный конструктор, принимающий в качестве


аргумента объект того же типа, что сам создает. Такой конструктор назы-
вается конструктором копирования. Конструктор копирования должен
создать точную копию переданного ему оригинала. В этом примере объект
list_two нужно инициализировать так, чтобы он ничем не отличался от
list_one. Это напоминает оператор присваивания, но вместо существую-
щего класса вы работаете с неинициализированным объектом. Это хорошо,
поскольку не нужно тратить время процессора на конструирование класса
лишь для того, чтобы сразу перезаписать значения. Обычно конструктор
копирования легко реализовать: он очень похож на оператор присваивания.
Вот конструктор копирования класса LinkedList:
Копирование классов    353

class LinkedList
{
public:
LinkedList(); // конструктор
~LinkedList(); // деструктор, обратите внимание на тильду
LinkedList& operator= (const LinkedList& other);
LinkedList(const LinkedList& other);

void insert(int val); // добавляет узел

private:
LinkedListNode *_p_head;
};

LinkedList::LinkedList (const LinkedList& other)


: _p_head(NULL) // Начинаем с NULL, если
// список other пуст.
{
// Обратите внимание: этот код очень похож на operator=
// В реальной программе есть смысл создать
// вспомогательный метод для выполнения этих действий.
LinkedListNode *p_itr = other._p_head;
while(p_itr != NULL)
{
insert(p_itr->val);
}
}

Как видите, ничего сложного.


Если вы не создадите собственный конструктор копирования, компилятор
предоставит конструктор копирования по умолчанию, который работает
аналогично оператору присваивания по умолчанию: вызывает конструктор
копирования для каждого объекта класса и побитно копирует значения
вроде целых чисел и указателей. Как правило, чтобы создать оператор
присваивания, необходимо создать и конструктор копирования.
Следует иметь в виду один аспект конструктора копирования, который
иногда вызывает удивление у новичков (к числу которых в свое время
относился и я). Что произойдет, если написать следующий код:

LinkedList list_one;
LinkedList list_two = list_one;

Будет ли вызван оператор присваивания? Нет! Компилятор определит, что


объект list_two инициализируется с помощью объекта list_one, и вызовет
354    Глава 25. Жизненный цикл класса

только конструктор копирования, исключив избыточные действия из про-


цесса инициализации. Здорово, правда?

Полный список методов, генерируемых компилятором


Вы познакомились со всеми методами, которые автоматически генериру-
ются компилятором.
1. Конструктор по умолчанию.
2. Деструктор по умолчанию.
3. Оператор присваивания.
4. Конструктор копирования.
Каждый раз, создавая класс, вы должны определить, можно ли использо-
вать реализации этих методов, предлагаемые компилятором по умолчанию.
Как правило, это возможно, однако при работе с указателями вам, скорее
всего, придется объявить собственный деструктор, оператор присваивания
и конструктор копирования (если нужен один из этих методов, обычно
нужны и остальные).

Как избежать полного копирования объекта


Иногда нет никакой необходимости копировать объекты. Было бы удоб-
но блокировать копирование определенных объектов: это позволило бы
отказаться от реализации конструктора копирования или оператора при-
сваивания и запретило бы компилятору создавать небезопасные версии
этих методов.
Существуют и ситуации, в которых предоставлять возможность копи-
рования объектов неправильно. Например, если компьютерная игра со-
держит класс, который представляет космический корабль пользователя
со всей информаций о нем, создавать копии этого космического корабля
недопустимо.
Чтобы блокировать возможность копирования объектов класса, следу-
ет объявить конструктор копирования и оператор присваивания, но не
реализовывать их. Компилятор не генерирует версии по умолчанию для
объявленных методов. Если попытаться ими воспользоваться, на этапе
компоновки получим ошибку вызова необъявленной функции. Это может
внести некоторую путаницу, поскольку компоновщик не указывает строку
кода, вызвавшую ошибку. Чтобы сообщения об ошибках были содержа-
тельнее, следует объявить эти методы закрытыми: ошибки почти всегда
будут происходить на этапе компиляции и сопровождаться понятными
сообщениями. Это можно сделать следующим образом.
Проверьте себя    355

class Player
{
public:
Player();
~Player();

private:
// эти методы запрещены, поскольку объявлены,
// но не определены; компилятор не генерирует их
// автоматически
operator=(const Player& other);
Player (const Player& other);

PlayerInformation *_p_player_info;
};
// реализации оператора присваивания и конструктора
// копирования отсутствуют

Таким образом, вы почти всегда выбираете один из трех вариантов.


1. Использовать конструктор копирования и оператор присваивания по
умолчанию.
2. Создать собственный конструктор копирования и оператор присваи-
вания по умолчанию.
3. Объявить конструктор копирования и оператор присваивания закры-
тыми методами и не реализовывать их.
Если не делать ничего, компилятор реализует вариант 1. Как правило, про-
ще всего начать с варианта 3, а затем при необходимости добавить оператор
присваивания и конструктор копирования.

Проверьте себя
1. Когда необходимо создавать конструктор класса?
А. Всегда — без конструктора класс нельзя использовать.
Б. Если необходимо инициализировать класс значениями, отличными
от значений по умолчанию.
В. Никогда — компилятор всегда предоставляет конструктор класса.
Г. Только если уже есть деструктор.
2. Как связаны деструктор и оператор присваивания?
А. Никак.
Б. Деструктор класса вызывается до выполнения оператора присваи-
вания.
356    Глава 25. Жизненный цикл класса

В. Оператор присваивания должен указать, какую память удаляет


деструктор.
Г. Оператор присваивания должен проверить, что одновременный за-
пуск деструкторов скопированного и нового класса безопасен.
3. Когда необходимо использовать список инициализации?
А. Когда требуется сделать конструкторы максимально эффективными
и избежать создания пустых объектов.
Б. При инициализации константного значения.
В. Когда необходимо выполнить для поля класса конструктор, отлич-
ный от конструктора по умолчанию.
Г. Во всех перечисленных случаях.
4. Какая функция выполняется во второй строке кода?
string str1;
string str2 = str1;

А. Конструктор объекта str2 и оператор присваивания объекта str1.


Б. Конструктор объекта str2 и оператор присваивания объекта str2.
В. Конструктор копирования объекта str2.
Г. Оператор присваивания объекта str2.
5. Какие функции вызываются при выполнении этого кода и в каком по-
рядке?
{
string str1;
string str2;
}

А. Конструктор объекта str1, конструктор объекта str2.


Б. Деструктор объекта str1, конструктор объекта str2.
В. Конструктор объекта str1, конструктор объекта str2, str1, деструк-
тор объекта str2.
Г. Конструктор объекта str1, конструктор объекта str2, деструктор
объекта str2, деструктор объекта str1.
6. Если известно, что у класса есть конструктор копирования, отличный
от конструктора копирования по умолчанию, какое утверждение об
операторе присваивания этого класса верно?
А. У класса есть оператор присваивания по умолчанию.
Б. У класса есть оператор присваивания, отличный от оператора при-
сваивания по умолчанию.
Практические задания    357

В. У класса есть объявленный, но нереализованный оператор присва-


ивания.
Г. Утверждения Б и В.
(Решения см. на с. 469.)

Практические задания
1. Реализуйте собственный вектор vectorOfInt, работающий только с це-
лыми числами (обычно не нужно работать с шаблонами вроде STL).
Интерфейс класса должен быть следующим.
• Конструктор без аргументов, создающий 32-элементный вектор.
• Конструктор, принимающий начальный размер вектора в качестве
аргумента.
• Метод get , принимающий индекс и возвращающий значение по
этому индексу.
• Метод set , принимающий индекс и значение и присваивающий
значение элементу с заданным индексом.
• Метод pushback, добавляющий элемент в конец массива и при не-
обходимости изменяющий размер массива.
• Метод pushfront, добавляющий элемент в начало массива.
• Конструктор копирования и оператор присваивания.
Класс не должен допускать утечек памяти; следует освобождать всю вы-
деленную память. Тщательно обдумайте возможные варианты некоррект-
ного использования класса (пользователь вводит отрицательный размер
вектора, отрицательный индекс и т. д.) и способы реагирования на них.
Г лава 2 6
Наследование и полиморфизм

До настоящего момента мы говорили о том, как создавать полнофункцио-


нальные и удобные в использовании классы с помощью четких открытых
интерфейсов и механизмов создания, копирования и уничтожения объ-
ектов. Теперь пришло время развить эту концепцию. Представьте, что
у вас есть машина. Эта машина довольно старая и ржавая. К сожалению,
автопроизводители используют в автомобилях разные рулевые механизмы:
одни — рулевое колесо, другие — джойстики, третьи — мыши. Одни машины
оснащены педалью газа, а в других приходится пользоваться полосой про-
крутки1. Это было бы ужасно — каждый раз, садясь в незнакомую машину
(новую или взятую напрокат), учиться управлять!
К счастью, автомобили проектируются по определенным стандартам.
У всех машин один и тот же интерфейс: руль и педаль газа. Единствен-
ное существенное отличие — коробка передач: у одних автоматическая,
у других — механическая. Таким образом, у машин два типа интерфейса:
автоматический и механический.
Зная, как пользоваться автоматической коробкой передач, можно водить
машину, в которой она установлена. В управлении машиной нюансы ра-
боты ее механизмов не имеют значения, важна лишь возможность рулить,
разгоняться и тормозить так же, как на любой другой машине.
Какое отношение это имеет к языку C++? В C++ можно писать код, рассчи-
танный на конкретный интерфейс (по аналогии с приведенным примером,

Рулевой механизм с колесом прокрутки, скорее всего, вызовет много ДТП.


1
Наследование и полиморфизм    359

вы — код, а рулевой механизм — интерфейс). Реализация интерфейса (са-


мой машины) не имеет значения; код (вы) может воспользоваться любой
реализацией интерфейса (машиной), поскольку работает с самим интер-
фейсом. Возможно, вы отдаете предпочтение тем или иным автомобилям,
но способны сесть за руль любого из них.
Когда вы станете писать код, обладающий аналогичным свойством? При
разработке видеоигры. Она содержит множество объектов, отображаемых
на экране: снаряды, корабли, враги и др. В главном цикле игры вы будете
отрисовывать кадры, перемещая объекты с одних позиций на другие.
Ваш код должен иметь следующую структуру:
Очистить экран
Перебрать список отображаемых объектов
Вывести на экран каждый отображаемый объект

В идеале список отображаемых объектов содержит все объекты, которые


можно вывести на экран. Все такие объекты должны реализовать общий
интерфейс, который позволяет отображать их на экране. При этом снаряды,
корабли и противники должны быть различными классами, содержащими
особые данные (корабль игрока — количество попаданий, вражеские ко-
рабли — интерфейс, позволяющий им перемещаться, а снаряды — сведения
о поражающей силе).
Для цикла, отображающего объекты, эти нюансы не имеют никакого значе-
ния. Важно лишь то, что каждый из этих классов поддерживает интерфейс,
позволяющий рисовать его объекты на экране. Нам нужно создать набор
классов с одним и тем же интерфейсом, но разными его реализациями.
Как это сделать? Сначала определим, что такое отображаемый объект.
class Drawable
{
public:
void draw();
};

Этот простой класс Drawable определяет единственный метод draw, ко-


торый отображает текущий объект. Было бы здорово создать вектор
vector<Drawable*> и сохранить в нем все объекты, реализующие метод
draw!1 Мы смогли бы выводить на экран все, что нам нужно, перебирая
содержимое этого вектора и вызывая метод draw для каждого объекта.

Я помещаю указатель в вектор, потому что он еще понадобится.


1
360    Глава 26. Наследование и полиморфизм

Все, кто использует объекты этого вектора, смогут вызывать лишь методы,
определенные в интерфейсе Drawable, но ничего другого нам сейчас и не
нужно.
У меня для вас хорошая новость: C++ действительно позволяет сделать
то, о чем я только что сказал! Посмотрим, как именно.

Наследование в C++
Сначала введем новый термин — наследование. Наследование означает,
что один класс получает свойства другого класса. В данном случае на-
следуемым свойством является интерфейс класса Drawable , а именно
метод draw. Класс, который наследует свойства другого класса, называется
подклассом, а класс, свойства которого наследуются, — суперклассом1.
Суперкласс часто определяет метод (или методы) интерфейса, которые
могут по-разному реализовываться каждым подклассом. В нашем примере
класс Drawable является суперклассом. Каждый объект Drawable в игре
будет подклассом класса Drawable. Каждый класс унаследует метод draw,
что позволит коду, получающему объект Drawable, рассчитывать, что в нем
доступен метод draw. Затем все классы реализуют собственные версии ме-
тода draw: фактически они обязаны сделать это для гарантии присутствия
метода draw во всех подклассах класса Drawable.
Теперь, поняв базовую концепцию, перейдем к синтаксису.
class Ship : public Drawable
{
};

Конструкция : public Drawable показывает, что класс Ship наследует


свойства класса Drawable. Приведенный выше код говорит, что класс Ship
наследует все открытые методы и данные своего суперкласса Drawable —
в данном случае метод draw. Если вы напишете
Ship s;
s.draw();

вызов draw обратится к реализации метода draw класса Drawable. Это не то,
что нужно, поскольку класс Ship должен отрисовывать себя собственным
методом, а не методом интерфейса Drawable.

1
Иногда вместо «суперкласс» используют термин родительский класс, а вместо
«подкласс» — дочерний класс. В этой книге я буду пользоваться терминами
«суперкласс» и «дочерний класс».
Наследование в C++    361

Чтобы предоставить классу Ship такую возможность, класс Drawable дол-


жен указать, что другие классы могут переопределять метод Drawable. Для
этого метод объявляется виртуальным; виртуальный метод входит в состав
суперкласса, но может быть переопределен отдельными подклассами.
class Drawable
{
public:
virtual void draw();
};

Часто суперкласс ничего не реализует, а подклассы обязаны реализовывать


методы, которые они наследуют (к примеру, вряд ли можно придумать
хороший способ отрисовки объекта по умолчанию). В таких ситуациях
функция объявляется как чисто виртуальная (обратите внимание на кон-
струкцию =0):
class Drawable
{
public:
virtual void draw() = 0;
};

На первый взгляд такой синтаксис совершенно непонятен, однако в нем


есть логика: присвоение методу нулевого значения показывает, что метод
не существует. Если класс содержит чисто виртуальный метод, его под-
классы обязаны реализовать этот метод. Для этого подклассу необходимо
снова объявить этот метод без конструкции = 0. Это означает, что класс
действительно реализует этот метод:
class Ship : public Drawable
{
public:
virtual draw();
};

Теперь этот метод можно определить, как любой обычный метод:

Ship::draw()
{
/* код отрисовки */
}

Вы спросите: зачем нам вообще нужен суперкласс вроде Drawable, если


все, что мы собираемся сделать, — это объявить никак не реализуемый
метод draw? Суперкласс нужен, чтобы определить интерфейс, который
будет реализован всеми его подклассами. Это даст возможность написать
362    Глава 26. Наследование и полиморфизм

код, который использует интерфейс Drawable, но не связан с каким-либо


конкретным классом. Некоторые языки программирования позволяют
передавать любой объект в любую функцию, и если объект реализует
методы, которые использует эта функция, программа работает. Тем не
менее язык C++ требует явного описания аргументов функции. Если бы
мы не создали интерфейс Drawable, то не смогли бы даже поместить все
эти классы в один вектор: у них не было бы ничего общего. Рассмотрим
код, который отображает все объекты в векторе:
vector<Drawable*> drawables;
// сохраняем Ship в векторе, создавая новый указатель
// на объект Ship
drawables.push_back(new Ship());
for(vector<int>::iterator itr = drawables.begin(),
end = drawables.end();
itr != end;
++itr)
{
// помните, что при использовании указателя на объект
// мы должны вызывать его методы с помощью синтаксиса ->
(*itr)->draw(); // вызывает Ship::Draw
}

Мы можем добавлять в вектор различные объекты Drawable. Предположим,


есть класс Enemy, который также наследует класс Drawable:
drawables.push_back( new Ship() );
drawables.push_back( new Enemy() );

Оба вызова сработают — мы будем вызывать метод Ship::draw для кораб­


лей и Enemy::draw для врагов.
Здесь очень важно, что тип созданного нами вектора vector<Drawable*>, а не
vector<Drawable>. Если не воспользоваться указателем, код не сработает.

Чтобы понять почему, представьте себе, что мы создали вектор объектов,


а не указателей на них:
vector<Drawable> drawables;

Теперь в памяти находятся различные объекты Drawable одинакового


размера:
[Drawable 1][Drawable 2][Drawable 3]

Вектор, работающий не с указателями, должен хранить объекты целиком,


однако они могут быть разного размера: например, объекты Ship и Enemy
содержат разные поля и занимают меньше места, чем базовый объект
Drawable. Таким образом, код не сможет работать корректно.
Наследование в C++    363

С другой стороны, указатели всегда одного размера1:


[Указатель на Drawable][Указатель на Drawable][Указатель на Drawable]

Если поместить в вектор [Указатель на Ship], он займет ровно столько же


места, сколько [Указатель на Drawable]. Именно поэтому мы объявили
вектор так:
vector<Drawable*> drawables;

Теперь можно поместить в вектор любой указатель, если он указывает на


класс, являющийся наследником Drawable. Все объекты вектора будут
выведены на экран в цикле с помощью своих методов draw. (Технически
в вектор можно записать указатель на любой тип данных, но нам это не
нужно, поскольку вектор предназначен для хранения объектов, которые
можно нарисовать. Если мы поместим в вектор объект, не поддерживающий
обрисовку, возникнут серьезные проблемы.)
Просто запомните: чтобы класс унаследовал интерфейс своего суперкласса,
необходимо передавать этот класс по указателю.
Теперь, подробно рассмотрев пример, вернемся назад и перечислим сде-
ланное.
1. Сначала мы объявили интерфейс Drawable, который могут наследовать
его подклассы.
2. Любая функция способна принимать объект Drawable или любой код
может работать с интерфейсом Drawable. Этот код может вызывать ме-
тод draw, реализованный в конкретном объекте, на который указывает
указатель.
3. Это позволяет использовать в существующем коде новые типы объ-
ектов, реализующих интерфейс Drawable . Можно добавлять в игру
новые элементы — символы, увеличивающие силу игрока или количе-
ство жизней, фоновые изображения и др. Обрабатывающий их код не
должен знать об этих элементах ничего, кроме того, что они реализуют
интерфейс Drawable.
Все эти механизмы обеспечивают многократное использование кода.
Существующий код способен работать с новыми классами. Эти классы

Это утверждение близко к истине с точки зрения наших целей. В некоторых


1

компьютерах используются разные указатели для разных типов данных, од-


нако мы не будем в это углубляться. Более подробная информация по теме
приведена на странице http://stackoverflow.com/questions/1241205/are-all-data-pointers-
of-the-same-size-in-one-platform
364    Глава 26. Наследование и полиморфизм

можно написать так, что они будут совместимы с существующим кодом


(например, циклом, выводящим на экран все элементы игры), который
будет работать с ними, даже не зная, что это новые классы (разумеется,
мы должны добавить объекты новых классов в вектор объектов Drawable,
но цикл отображения не изменится).
Такое поведение называется полиморфизмом. Поли значит много, а мор-
фа — форма, следовательно, полиморфизм — это многообразие форм.
Другими словами, каждый класс, реализующий определенный интерфейс,
является формой. Поскольку код, который использует интерфейс, спосо-
бен работать с различными классами, он может поддерживать различные
формы этого интерфейса. Возвращаясь к примеру с автомобилями, чело-
век, который умеет водить машину, способен вести машину с бензиновым,
электрическим или гибридным двигателем.

Другие преимущества и недостатки наследования


Полиморфизм зависит от наследования, однако наследование не ограни-
чивается только интерфейсами. Как я упоминал ранее, с помощью насле-
дования можно перенять реализацию функции.
Например, если бы интерфейс Drawable включал еще один невиртуальный
метод, он был бы унаследован всеми объектами, реализовавшими Drawable.
Иногда люди считают, что наследование — это использование существу-
ющего кода, позволяющее отказаться от написания метода в каждом под-
классе. Такой способ повторного использования кода является весьма
ограниченным. Разумеется, можно сэкономить время, воспользовавшись
готовыми методами, но есть ли уверенность, что унаследованная реали-
зация подходит для всех подклассов? Чтобы ответить на этот вопрос, не-
обходимо тщательно его обдумать.
Рассмотрим пример, который демонстрирует, что это очень непросто. До-
пустим, есть объекты Player и Ship, которые реализуют интерфейс Drawable
и содержат метод getName. Вы решаете добавить метод getName в класс
Drawable, чтобы оба подкласса использовали одну и ту же его реализацию.
class Drawable
{
public:
string getName();
virtual void draw() = 0;
};

Поскольку метод getName не виртуальный, все подклассы унаследуют


его реализацию. Что произойдет, если вы решите добавить в игру новый
Наследование в C++    365

отображаемый класс Bullet, представляющий снаряды? Нужно ли имя


каждому экземпляру такого класса? Очевидно, нет! Конечно, появление
в классе Bullet ненужного метода getName — не конец света, но много-
кратное повторение такой ситуации приведет к формированию сложных
и запутанных иерархий классов, в которых смысл интерфейса станет не-
понятным.

Наследование, создание и уничтожение объектов


Когда вы наследуете суперкласс, конструктор подкласса вызывает кон-
структор суперкласса так же, как конструкторы всех своих полей.
Рассмотрим следующий код.

Пример 59: constructor.cpp


#include <iostream>
using namespace std;
// Foo – распространенное в программировании имя поля
class Foo
{
public:
Foo() {cout << "Foo's constructor" << endl;}
};

class Bar : public Foo


{
public:
Bar() {cout << "Bar's constructor" << endl;}
};

int main()
{
// красивый слон ;)
Bar bar;
}

При инициализации панели сначала вызывается конструктор Foo, а за-


тем — конструктор Bar. Программа выведет на экран следующее:
Foo's constructor
Bar's constructor

Конструктор суперкласса выполняется первым и инициализирует все поля


суперкласса до того, как конструктор подкласса получает возможность их
использовать. Это гарантирует корректность значений полей, с которыми
работает конструктор подкласса.
366    Глава 26. Наследование и полиморфизм

Чтобы вызвать конструктор суперкласса, не нужно ничего делать: обо


всем позаботится компилятор. Аналогично, деструктор суперкласса ав-
томатически вызывается после деструктора подкласса. Вот пример кода,
который это демонстрирует.

Пример 60: destructor.cpp


#include <iostream>
using namespace std;
// Foo – распространенное в программировании имя поля
class Foo
{
public:
Foo() {cout << "Foo's constructor" << endl;}
~Foo() {cout << "Foo's destructor" << endl;}
};

class Bar : public Foo


{
public:
Bar() {cout << "Bar's constructor" << endl;}
~Bar() {cout << "Bar's destructor" << endl;}
};

int main()
{
// красивый слон ;)
Bar bar;
}

Результат работы этой программы выглядит так:

Foo's constructor
Bar's constructor
Bar's destructor
Foo's destructor

Обратите внимание: конструктор и деструктор вызываются в противопо-


ложном порядке, это позволяет деструктору Bar безопасно использовать
методы, унаследованные от Foo, поскольку данные, с которыми работают
эти методы, все еще действительны и доступны для использования. При-
чина такого порядка вызова деструкторов подкласса и суперкласса анало-
гична причине, объясняющей порядок вызова их конструкторов.
Иногда необходимо вызвать конструктор суперкласса, который не является
конструктором по умолчанию. Это можно сделать, указав имя суперкласса
в списке инициализации.
Наследование в C++    367

class FooSuperclass
{
public:
FooSuperclass(const string& val);
};

class Foo : public FooSuperclass


{
public:
Foo ()

// пример списка инициализации


: FooSuperclass("arg")
{}
};

Вызов конструктора суперкласса должен предшествовать полям класса


в списке инициализации.

Полиморфизм и уничтожение объектов


Уничтожение объекта с использованием интерфейса — сложная процедура.
Допустим, есть код:
class Drawable
{
public:
virtual void draw() = 0;
};

class MyDrawable : public Drawable


{
public:
virtual void draw();
MyDrawable();
~MyDrawable();

private:
int *_my_data;
};

MyDrawable::MyDrawable()
{
_my_data = new int;
}

MyDrawable::~MyDrawable()
{
delete _my_data;
}
продолжение 
368    Глава 26. Наследование и полиморфизм

void MyDrawable::draw()
{
/* код отрисовки на экране */
}

void deleteDrawable (Drawable *drawable)


{
delete drawable;
}

int main()
{
deleteDrawable(new MyDrawable());
}

Что происходит внутри deleteDrawable? Как вы знаете, при использовании


оператора delete вызывается деструктор, поэтому строка
delete drawable;

вызывает функцию объекта. Но как компилятор обнаружит деструктор


объекта MyDrawable? Он не знает точный тип отображаемой переменной,
ему известно лишь, что она реализует интерфейс Drawable с методом draw.
Компилятор может найти деструктор, связанный с классом Drawable, но
не деструктор MyDrawable. К сожалению, в конструкторе класса MyDraw-
able происходит выделение памяти, поэтому важно, чтобы деструктор
MyDrawable запустился и освободил ее.

Вы спросите: разве эта проблема не решается с помощью виртуальной


функции? Да, именно так! Нам необходимо объявить деструктор класса
Drawable виртуальным, чтобы при удалении указателя на Drawable с по-
мощью оператора delete компилятор искал перегруженный деструктор.
class Drawable
{
public:
virtual void draw ();
virtual ~Drawable ();
};

class MyDrawable : public Drawable


{
public:
virtual void draw();
MyDrawable ();
virtual ~MyDrawable();
private:
int *_my_data;
};
Наследование в C++    369

Объявив деструктор суперкласса виртуальным, мы организуем вызов пере-


груженного деструктора при каждом освобождении интерфейса Drawable
с помощью операции delete.
Общее правило таково: объявляя хотя бы один метод суперкласса вирту-
альным, вы должны объявить виртуальным и его деструктор. Присутствие
в классе виртуальных методов говорит, что его можно передавать методам,
принимающим интерфейс. Эти методы могут выполнять любые действия,
в том числе удалять объект; чтобы очистка объекта была выполнена кор-
ректно, деструктор следует сделать виртуальным.

Проблема срезки
При работе с наследованием следует иметь в виду проблему срезки. Срезка
объектов происходит, если код подобен приведенному ниже:
class Superclass
{};
class Subclass : public Superclass
{
int val;
};

int main()
{
Subclass sub;
Superclass super = sub;
}

Поле val класса Subclass не копируется при присвоении значения пере-


менной super. Такое поведение нежелательно (хотя C++ допускает его),
поскольку содержимое объекта копируется лишь частично. Иногда срезка
работает корректно, но, как правило, приводит к возникновению аварий-
ных ситуаций.
К счастью, есть способ указать компилятору на проблему срезки. Можно
объявить конструктор копирования класса Superclass закрытым и не
реализовывать его:

class Superclass
{
public:
// объявляя конструктор копирования,
// мы должны предоставить собственный
// конструктор по умолчанию
Superclass() {}
private:
продолжение 
370    Глава 26. Наследование и полиморфизм

// запрещено - мы не будем определять этот метод


Superclass(const Superclass& other);
};

class Subclass : public Superclass


{
int val;
};

int main()
{
Subclass sub;
// теперь эта строка вызывает ошибку компиляции
Superclass super = sub;}

Но что делать, если все-таки нужен конструктор копирования? Еще один


способ обойти проблему — написать конструктор копирования так, чтобы
любой создаваемый суперкласс включал как минимум одну чисто вирту-
альную функцию. Если вы напишете
Superclass super;

код не скомпилируется, поскольку невозможно создать объект с чисто


виртуальной функцией. С другой стороны, можно написать
Superclass *super = & sub;

Таким образом, вы пользуетесь преимуществами полиморфизма и в то же


время решаете проблему срезки.

Совместное использование кода с подклассами


До настоящего момента мы говорили об открытых и закрытых членах
класса: открытые методы можно вызывать снаружи класса, а закрытые
данные и методы доступны только другим методам и данным этого класса.
Возникает вопрос: как дать подклассам возможность вызывать методы
суперкласса, но лишить такой возможности внешние классы? Есть ли
смысл в таком ограничении? В действительности — да. Подклассы часто
пользуются общим кодом.
Допустим, есть метод clearRegion, который помогает отображать объекты,
очищая область экрана:
class Drawable
{
public:
virtual void draw();
Наследование в C++    371

virtual ~Drawable();
void clearRegion(int x1, int y1, int x2, int y2);
};

Такой механизм наследования позволяет избежать наследования интер-


фейса, предоставляя подклассам доступ к общему коду реализации. Этот
механизм корректен, поскольку всем подклассам необходимо использовать
данный метод (по крайней мере, существует такая вероятность). Не входя
в открытый интерфейс класса, он является лишь нюансом реализации
создаваемой иерархии классов.
Но как исключить этот метод из интерфейса класса? Если сделать его от-
крытым, как показано выше, вызывать его сможет кто угодно, что нежела-
тельно. С другой стороны, этот метод нельзя сделать закрытым, поскольку
подклассы не имеют доступа к закрытым полям и методам суперкласса,
а это лишает смысла всю нашу идею!

Защищенные данные
Решение заключается в том, чтобы воспользоваться третьим, последним
модификатором доступа — защищенным (protected).
В отличие от закрытых, защищенные члены суперкласса доступны его
подклассам, но недоступны вне класса — в отличие от открытых. Защи-
щенные члены класса описываются тем же синтаксисом, что открытые
и закрытые члены:
class Drawable
{
public:
virtual void draw();
virtual ~Drawable();
protected:
void clearRegion(int x1, int y1, int x2, int y2);
};

Теперь только подклассы класса Drawable имеют доступ к методу clear-


Region.

Защищенные методы часто полезны, но я не рекомендую использо-


вать защищенные данные. Вы скрываете данные от внешней среды
ради возможности изменять их в будущем. По этой же причине нет
необходимости предоставлять доступ к ним всей иерархии классов.
Обеспечивайте подклассам доступ к данным суперкласса с помощью
защищенных методов.
372    Глава 26. Наследование и полиморфизм

Общие данные класса


До сих пор вы использовали класс лишь для хранения данных в его экзем-
плярах. Часто этого вполне достаточно, но иногда возникает необходимость
хранить данные, которые относятся не к конкретному объекту, а к классу
в целом. Допустим, вы создаете класс, каждый объект которого содержит
уникальный серийный номер. Как определить серийный номер, который
должен быть присвоен следующему объекту? «Следующий серийный но-
мер» необходимо хранить на уровне класса и использовать его при создании
новых объектов (на практике серийные номера можно использовать для
поиска записей, содержащих сведения об объектах, в журналах программы).
Для хранения общих данных класса используются статические члены
класса. В отличие от данных экземпляров класса, статические данные не
принадлежат никакому отдельному объекту; они доступны всем объектам
класса и, если являются открытыми, то и внешней среде. Статическая
переменная очень похожа на глобальную, но с одним исключением: для
доступа к статической переменной извне класса требуется указать имя
класса перед ее именем. Ниже приведен пример класса, который использует
статическую переменную:

class Node
{
public:
static int serial_number;
};
// объявление вне класса – мы должны указать
// префикс Node::
static int Node::serial_number = 0;

Помимо статических переменных существуют и статические методы. Они


входят в состав класса, но могут использоваться без экземпляра объекта.
Например, можно создавать серийный номер для каждого узла с помощью
закрытого статического метода _getNextSerialNumber:
class Node
{
public:
Node ();

private:
static int _getNextSerialNumber ();

// статическая переменная – общая для всего класса


static int _next_serial_number;
Наследование в C++    373

// нестатическая переменная – доступна всем объектам,


// но не статическим методам
int _serial_number;
};

// объявление вне класса – мы должны указать


// префикс Node::
static int Node::serial_number = 0;

Node::Node()
: _serial_number(_getNextSerialNumber())
{ }

int Node::_getNextSerialNumber ()
{
// воспользуемся постфиксной версией оператора ++ для возврата
// предыдущего значения переменной
return _next_serial_number++;
}

При использовании статического метода помните, что он входит в состав


класса, но не имеет доступа к полям объектов: он может работать только со
статическими данными. Статическому методу не передается указатель this.

Как реализован полиморфизм


Ключевая идея полиморфизма в том, что когда компилятор встречает
функцию, работающую с интерфейсом, а не с конкретным подклассом, он
не знает точно, какой код будет выполнен при ее вызове. Например, при
компиляции кода

vector<Drawable*> drawables;

void drawEverything()
{
for ( int i = 0; i < drawables.size(); i++ )
{
drawables[i]->draw();
}
}

конструкцию drawables[i]->draw() нельзя преобразовать в конкретный


вызов функции, поскольку метод draw является виртуальным. В зависи-
мости от типа объекта, являющегося наследником класса Drawable, метод
draw может нарисовать снаряд, космический корабль игрока или врага или
тайник с энергией.
374    Глава 26. Наследование и полиморфизм

ПРИМЕЧАНИЕ

Реализация полиморфизма в компиляторе — сложная тема,


затрагивающая тонкости концепции полиморфизма в языке
C++. Я включил этот раздел в книгу потому, что полиморфизм
реализован настолько изящно, что я не мог не поделиться им
с вами. Если полиморфизм — новая для вас тема, читать этот
раздел необязательно. Если же интересно, как работают его
механизмы, читайте дальше. Вы всегда можете вернуться
к этому разделу, если захотите глубже вникнуть в детали.

Более того, метод drawEverything вызывает код, о котором ему ничего не


известно. Коду, вызывающему метод draw, нужно лишь видеть интерфейс
Drawable, ему не нужно знать, кто действительно его реализует. Но как это
возможно, если этот код собирается вызвать метод подкласса Drawable?
Объект содержит скрытое поле, хранящее список виртуальных методов.
В данном примере этот список состоит из одного элемента с адресом ме-
тода draw. Каждому методу конкретного интерфейса присваивается номер
(метод draw получает номер 0). При вызове виртуального метода его номер
используется в качестве индекса списка виртуальных методов объекта.
При компиляции вызова виртуального метода выполняется поиск метода
в списке виртуальных методов, и найденный метод вызывается. В приве-
денном выше коде вызов draw активирует поиск метода 0 в таблице методов
и переход по найденному адресу. Список виртуальных методов называется
vtable — виртуальная таблица (virtual table).
Виртуальная таблица выглядит следующим образом:

Поскольку объект содержит собственную таблицу методов, компилятор


может изменять в ней адреса при компиляции различных классов, чтобы
предоставить определенную реализацию виртуального метода. Разумеется,
не нужно делать это самостоятельно — компилятор берет эту задачу на
Наследование в C++    375

себя. Код, использующий таблицу, должен точно знать индекс искомого


виртуального метода.
Виртуальная таблица содержит только методы, объявленные виртуальны-
ми. Невиртуальным методам вышеописанный механизм не нужен, поэтому
их записей нет в таблице. У класса без единого виртуального метода не
будет виртуальной таблицы.
Вызов виртуального метода преобразуется в код, который обращается
к виртуальной таблице и находит в ней метод по индексу. Компилятор
трактует оператор
drawables[ i ]->draw();

следующим образом.
1. Считать указатель, хранящийся в drawables[i].
2. По этому указателю найти адрес виртуальной таблицы для группы
методов, связанных с интерфейсом типа Drawable (в данном случае эта
группа состоит из единственного метода).
3. Найти в этой таблице функцию с заданным именем (в данном случае
draw). Эта таблица буквально содержит набор адресов, по которым рас-
положены все функции.
4. Вызвать эту функцию с соответствующими аргументами.
Как правило, на шаге 2 используется не имя функции, а индекс таблицы,
в который компилятор преобразует имя функции. Это обеспечивает высо-
кую скорость вызовов виртуальных функций при выполнении программы.
Вызовы обычных и виртуальных функций выполняются почти за одно
и то же время.
Можно считать, что компилятор генерирует следующий код (разумеется,
операция call является вымышленной).
call drawables[i]->vtable[0];

Использование виртуальных функций имеет и один заметный недостаток.


Объект должен включать виртуальные таблицы почти для всех интер-
фейсов, которые он наследует. Это означает, что каждый виртуальный
интерфейс увеличивает размер объекта на пару байтов. На практике это
является серьезной проблемой только для кода, который создает очень
много объектов с небольшим количеством полей.
376    Глава 26. Наследование и полиморфизм

Проверьте себя
1. Когда запускается деструктор суперкласса?
А. Только когда объект уничтожается путем освобождения указателя
на суперкласс с помощью оператора delete.
Б. Перед вызовом деструктора подкласса.
В. После вызова деструктора подкласса.
Г. В процессе выполнения деструктора подкласса.
2. Что необходимо сделать в конструкторе класса Cat в условиях приво-
димой иерархии классов?
class Mammal {
public:
Mammal (const string& species_name);
};

class Cat : public Mammal


{
public:
Cat();
};

А. Ничего особенного.
Б. Вызвать конструктор Mammal с аргументом cat, воспользовавшись
списком инициализации.
В. Вызвать конструктор Mammal с аргументом cat.
Г. Удалить конструктор Cat и воспользоваться конструктором по умол-
чанию, который выполнит все требуемые действия.
3. В чем ошибка следующего определения класса?
class Nameable
{
virtual string getName();
};

А. Метод getName не является открытым.


Б. Отсутствует виртуальный деструктор.
В. Реализация метода getName отсутствует, но он не объявлен как чисто
виртуальный метод.
Г. Все вышеперечисленное.
4. Когда вы определяете виртуальный метод в интерфейсном классе, что
должна сделать функция, чтобы получить возможность использовать
метод интерфейса для вызова метода подкласса?
Практические задания    377

А. Принять интерфейс в виде указателя или ссылки.


Б. Ничего, она может просто скопировать объект.
В. Ей нужно знать имя подкласса, метод которого она должна вызвать.
Г. Не понял! Что такое виртуальный метод?
5. Как наследование улучшает многократное использование кода?
А. Позволяет наследовать методы у суперклассов.
Б. Позволяет суперклассу реализовывать виртуальные методы для
подкласса.
В. Позволяет использовать в коде интерфейс, а не конкретный класс.
Это дает возможность создавать новые классы, реализующие этот ин-
терфейс, и использовать их вместе с существующим кодом.
Г. Позволяет новым классам наследовать характеристики конкретного
класса, которые можно использовать вместе с виртуальными методами.
6. Какое из утверждений об уровнях доступа к классу верное?
А. Подкласс имеет доступ только к открытым методам и данным роди-
тельского класса.
Б. Подкласс имеет доступ к закрытым методам и данным родительского
класса.
В. Подкласс имеет доступ только к защищенным методам и данным
родительского класса.
Г. Подкласс имеет доступ к защищенным и открытым методам и данным
родительского класса.
(Решения см. на с. 471.)

Практические задания
1. Реализуйте функцию sort, принимающую вектор указателей на ин-
терфейсный класс Comparable , в котором определен метод compare
(Comparable& other). Функция возвращает 0, если объекты одинаковы,
1, если текущий объект больше, чем other, и –1, если текущий объект
меньше, чем other. Создайте класс, который реализует этот интерфейс,
и несколько экземпляров этого класса и отсортируйте их. Например,
можно создать класс HighScoreElement, который содержит имя игрока
и количество набранных им очков. Функция сортирует объекты этого
класса в порядке убывания результатов, но если у игроков одинаковый
результат, они упорядочиваются по имени.
2. Реализуйте функцию сортировки другим способом. Создайте интер-
фейс Comparator с методом compare(const string& lhs, const string&
378    Глава 26. Наследование и полиморфизм

rhs), который устроен так же, как метод compare в предыдущей задаче:
он возвращает 0, если два значения совпадают, 1, если lhs > rhs, и –1,
если lhs < rhs . Напишите два класса, один из которых сравнивает
строки без учета регистра, а второй сортирует их в порядке, обратном
алфавитному.
3. Реализуйте метод ведения журнала, который принимает в каче-
стве аргумента интерфейсный класс StringConvertable . Класс
StringConvertable содержит метод toString, который преобразует
результирующий объект в строку. Метод ведения журнала должен вы-
водить дату, время и сам объект (вы можете узнать о том, как считать
дату, на странице http://www.cplusplus.com/reference/clibrary/ctime/). Еще
раз обратите внимание: можно повторно использовать метод ведения
журнала, реализовав интерфейс.
Г лава 2 7
Пространства имен

Создавая все больше и больше собственных классов, вы, возможно, за-


дадитесь вопросом: «Нет ли готового кода, который делает то, что мне
нужно? Могу ли я использовать его?» Иногда такой код действительно
существует. Многие ключевые алгоритмы и структуры данных (такие как
связанные списки или двоичные деревья) уже реализованы в надежной
и пригодной для многократного использования форме, и вы можете при-
менять их в программах. Если же вы собираетесь воспользоваться кодом,
который написал кто-то другой, следует делать это внимательно во из-
бежание конфликтов имен.
Допустим, вы хотите написать класс с именем LinkedList, чтобы реали-
зовать собственный связанный список. Не исключено, что код, который
вы используете, включает класс с таким же именем, но с другой реализа-
цией. Конфликт необходимо устранить: два класса не могут называться
одинаково.

Использование пространств имен


Во избежание конфликта можно расширить базовое имя типа, создав про-
странство имен. Например, я могу поместить класс, реализующий связан-
ный список, в пространство имен с названием com::cprogramming — полным
именем этого типа будет com::cprogramming::LinkedList. Использование
пространства имен значительно сокращает вероятность конфликтов имен.
380    Глава 27. Пространства имен

В этом примере используется тот же оператор ::, что и при доступе к ста-
тическим членам класса и объявлении метода, но в данном случае этот
оператор обеспечивает доступ к элементам не класса, а пространства имен.
Вы спросите: если пространства имен так удобны, почему стандартная
библиотека их не использует? Не получается ли так, что длинные имена
используются без всякого смысла?
На самом деле вы уже встречались с пространствами имен. В начале каждой
программы мы указывали оператор во избежание использования полных
имен при обращении к объектам вроде cin и cout
using namespace std;

Если бы мы не воспользовались этим оператором, пришлось бы указывать


имена std::cin и std::cout при каждом обращении к этим объектам. Дан-
ный метод работает, если мы не обязаны использовать пространства имен
во избежание конфликтов имен в конкретном файле. Для удобства можно
использовать короткие имена, если знаем, что это не создаст коллизий.
Если же коллизии существуют, нам нужно лишь опустить объявление using
для пространства имен и полностью указывать в файле имена всех типов.
Рассмотрим, как этот принцип работает в приведенном выше примере. Если
у меня есть два разных класса с именем LinkedList, большинство моих
файлов будет начинаться с оператора using namespace com::cprogramming.
Если в одном из файлов обнаружится конфликт имен, в этом файле я буду
обращаться к классу LinkedList как к com::cprogramming::LinkedList. Та-
кое обращение обязательно не во всей программе, а лишь в файлах, где ис-
пользуются оба класса LinkedList. В таких файлах я буду удалять оператор
using namespace com::cprogramming и указывать полные имена классов.

Ниже приведен пример объявления, помещающего код (в данном случае


отдельную переменную) в пространство имен:
namespace cprogramming
{
int x;
} // <-- обратите внимание: точка с запятой не нужна

Теперь я должен обращаться к переменной x как к cprogramming::x или


воспользоваться оператором
using namespace cprogramming;

Я могу использовать имя x в файле, который начинается с using namespace


cprogramming.
Использование пространств имен    381

Одни пространства имен можно вкладывать в другие. Это полезно, если


вы работаете в крупной компании с большим количеством подразделений,
каждое из которых ведет самостоятельную разработку программного обе-
спечения. В такой ситуации можно использовать имя компании в качестве
внешнего пространства имен, а названия рабочих групп в качестве вну-
тренних пространств имен. Ниже приведен простой пример объявления
вложенного пространства имен для com::cprogramming.
namespace com {
namespace cprogramming
{
int x;
} }

Теперь полное имя переменной x выглядит так: com::cprogramming::x


(в этом примере я не выравниваю код для каждого пространства имен: если
пространств имен много, выравнивание каждого из них создаст путаницу
с табуляциями). Для доступа к элементам этого пространства имен вос-
пользуйтесь оператором
using namespace com::cprogramming;

Пространства имен являются открытыми в том смысле, что можно поме-


щать в них код, находящийся в разных файлах. Например, можно создать
заголовочный файл с определением класса и поместить этот класс в про-
странство имен:
namespace com {
namespace cprogramming
{
class MyClass
{
public:
MyClass ();
};
} }

В соответствующем исходном файле можно написать:


#include "MyClass.h"
namespace com {
namespace cprogramming
{
MyClass::MyClass ()
{}
} }

В пространство имен можно добавить код из обоих файлов, что вам и нужно.
382    Глава 27. Пространства имен

Когда следует использовать оператор using namespace


Как правило, объявления using следует помещать только в файлы cpp, а не
в заголовочные файлы. Проблема в том, что в каждом файле, использующем
заголовочный файл, есть риск конфликтов имен: все файлы cpp должны
контролировать пространства имен, которыми они пользуются. Обычно
я рекомендую использовать полные имена в заголовочных файлах, а объ-
явления using только внутри файлов cpp.
Есть несколько общеизвестных исключений из этого правила. Его нарушает
сама стандартная библиотека, хотя и по веской причине.
Если вы пишете
#include <iostream.h>

вместо
#include <iostream>

не нужно использовать объявление using для пространства имен std. Файл


iostream.h включает следующее содержание:

#include <iostream>
using namespace std;

Это сделано для обратной совместимости с программами, созданными


раньше появления в языке пространства имен. Проверьте пример:

Пример 61: iostream_h.cpp


#include <iostream.h>
int main ()
{
cout << "Hello world";
}

Он успешно компилируется после добавления пространств имен в стан-


дартную библиотеку.
Для нового кода рекомендую использовать новый заголовочный файл (без
.h), чтобы не вносить путаницу в пространства имен. Нет ничего сложного
в том, чтобы написать using namespace std; в каждом файле, при этом вы
пользуетесь самой свежей версией языка C++.
Проверьте себя    383

Когда следует создавать пространство имен


Как правило, создавать собственные пространства имен нет смысла, если
вы работаете над программой, которая состоит лишь из пары файлов.
Пространства имен предназначены для программ, включающих десятки
и сотни файлов, хранящихся в различных каталогах, — в таких программах
действительно есть вероятность столкнуться с конфликтами имен. Про-
стым программам из одного или нескольких файлов пространства имен не
нужны. Рекомендую начать размещать код в пространстве имен, планируя
использовать его в будущем повторно или предполагая, что программа
станет настолько большой, что ее придется разбить на несколько каталогов.
Когда код достигает таких размеров, следует использовать все доступные
инструменты, чтобы поддерживать в нем порядок.
Хотя пространства имен не играют ведущую роль среди возможностей
языка C++, ими удобно пользоваться при работе с большими базами кода.
Понимая назначение пространств имен и причины, по которым другие
их используют, вы с большей легкостью интегрируете наработки коллег
в собственный код.

Проверьте себя
1. Когда следует использовать директиву using namespace?
А. Во всех заголовочных файлах после директивы include.
Б. Никогда, это опасно.
В. В начале любого файла cpp при отсутствии конфликтов пространств
имен.
Г. Непосредственно перед использованием переменной из соответству-
ющего пространства имен.
2. Для чего нужны пространства имен?
А. Чтобы поставить интересную задачу перед разработчиками компи-
ляторов.
Б. Чтобы усилить инкапсуляцию кода.
В. Чтобы предотвращать конфликты имен в крупных базах кода.
Г. Чтобы пояснять, для чего предназначен класс.
384    Глава 27. Пространства имен

3. Когда следует помещать код в пространство имен?


А. Всегда.
Б. При разработке программы, состоящей из более чем десятка файлов.
В. При разработке библиотеки, которая будет распространяться среди
других программистов.
Г. Б и В.
4. Почему не следует помещать объявление using namespace в заголовоч-
ный файл?
А. Это не разрешено.
Б. В этом нет смысла: объявление using действительно только в самом
заголовочном файле.
В. Действие объявления using распространяется на всех, кто включает
заголовочный файл в код, даже если это приводит к конфликтам.
Г. Это может привести к конфликтам, если объявления using присут-
ствуют в нескольких заголовочных файлах.
(Решения см. на с. 472.)

Практические задания
1. Добавьте в пространство имен реализацию вектора из последней прак-
тической главы 25.
Г лава 2 8
Файловый ввод-вывод

Файлы — источник жизни компьютера. Без них все результаты работы


уничтожались бы при перезагрузке компьютера или закрытии приложения.
Язык C++ позволяет выполнять чтение и запись в файлы. Операции над
файлами называются файловым вводом-выводом.

Основы файлового ввода-вывода


Операции чтения и записи в файлы очень похожи на использование объ-
ектов cout и cin, однако cin и cout являются глобальными переменными,
а для чтения и записи в файлы необходимо объявлять собственные объ-
екты1. Это означает, что вы должны знать фактические типы данных.
Двумя такими типами являются входящий файловый поток и исходящий
файловый поток. Поток представляет собой набор данных, доступных для
чтения и записи. Принцип действия этих типов в том, что они преобразуют
файл в длинный поток данных, которые вы можете использовать так, как
будто взаимодействуете с пользователем. Оба типа требуют включения
в программу заголовочного файла fstream (сокращение от file stream —
файловый поток).

Для удобства я иногда называю их функциями, однако в действительности


1

они представляют собой объекты, методы которых вызываются.


386    Глава 28. Файловый ввод-вывод

Чтение из файлов
Сначала рассмотрим считывание данных из файлов. Для этого восполь-
зуемся типом ifstream. Экземпляр ifstream можно инициализировать по
имени файла, из которого мы хотим считать данные:

Пример 62: ifstream.cpp


#include <fstream>
using namespace std;
int main ()
{
ifstream file_reader( "myfile.txt" );
}

Эта небольшая программа пытается открыть файл myfile.txt: она ищет его
в том каталоге, где выполняется (это рабочий каталог программы). При не-
обходимости можно указать полный путь к файлу, например c:\myfile.txt.
Хочу обратить особое внимание, что программа лишь делает попытку
открыть файл: возможно, этот файл не существует. Можно проверить
результат создания объекта ifstream и определить, был ли файл успешно
открыт, с помощью метода is_open1:

Пример 63: ifstream_error_checking.cpp


#include <fstream>
#include <iostream>

using namespace std;


int main ()
{
ifstream file_reader( "myfile.txt" );
if ( ! file_reader.is_open() )
{
cout << "Could not open file!" << '\n';
}
}

При работе с файлами вы должны писать код, который обрабатывает


возможные ошибки. Возможно, файлы не существуют, они могут быть
повреждены или использоваться другим процессом системы. Во всех этих
случаях операции над файлами завершаются неудачно. Вы должны быть

1
Информацию об этих стандартных функциях можно найти на веб-сайтах вроде
http://en.cppreference.com/w/cpp или http://cplusplus.com/reference/
Основы файлового ввода-вывода    387

готовы к разнообразным ошибкам, вызывающим проблемы при работе


с файлом, таким как сбои диска, повреждения файлов, отключение пита-
ния, некорректные секторы диска и др.
После открытия файла объект ifstream можно использовать так же, как
cin. Следующий код считывает число из текстового файла:

Пример 64: read_file.cpp


#include <fstream>
#include <iostream>

using namespace std;


int main ()
{
ifstream file_reader( "myfile.txt" );
if ( ! file_reader.is_open() )
{
cout << "Could not open file!" << '\n';
}
int number;
file_reader >> number;
}

Эта строка считывает цифры из файла так же, как если бы их вводил поль-
зователь. Считывание продолжается до обнаружения пробела или другого
разделителя. Например, если файл содержит текст
12 a b c

после операции считывания значение переменной number становится


равным 12.
При работе с файлами необходимо определять, не произошла ли ошибка.
В C++ это можно сделать по значению, которое возвращает операция
чтения:

Пример 65: read_error_checking.cpp


#include <fstream>
#include <iostream>

using namespace std;


int main ()
{
ifstream file_reader( "myfile.txt" );
if ( ! file_reader.is_open() )
продолжение 
388    Глава 28. Файловый ввод-вывод

{
cout << "Could not open file!" << '\n';
}
int number;
// здесь проверяем успешность считывания целого значения
if ( file_reader >> number )
{
cout << "The value is: " << number;
}
}

Проверяя результат вызова file_reader >> number, мы способны обна-


руживать проблемы, связанные с дисковым накопителем и форматом
считываемых данных. Помните, как в начале книги мы говорили, что
пользователь может ввести символ, даже если мы просим его ввести чис-
ло? Данный метод позволяет защититься от подобных неприятностей.
Проверяйте значение, возвращаемое функцией чтения: если оно истинно,
операция прошла успешно и можно уверенно пользоваться данными; если
ложно — что-то пошло не так и это следует трактовать как ошибку.

Форматы файлов
Запрашивая данные у пользователя, можно указать ему, какие значения вы
хотите получить, и, если вводимые данные некорректны, инструктировать
его, как их исправить. При считывании из файлов такой возможности нет.
Файлы создаются заранее — возможно, еще до того, как вы заканчиваете
писать программу. Чтобы считать данные, нужно знать формат файла.
Формат файла представляет собой его структуру (необязательно сложную).
Допустим, есть таблица рекордов, которую вы хотите сохранять между за-
пусками программы. Пример простого формата файла — это десять строк,
каждая из которых содержит одно число:

Пример файла 1: highscores.txt


1000
987
864
766
744
500
453
321
201
98
5
Форматы файлов    389

Ниже приведен пример программы, которая считывает этот список.

Пример 66: highscore.cpp


#include <fstream>
#include <iostream>
#include <vector>

using namespace std;


int main ()
{
ifstream file_reader( "highscores.txt" );
if ( ! file_reader.is_open() )
{
cout << "Could not open file!" << '\n';
}
vector<int> scores;
for ( int i = 0; i < 10; i++ )
{
int score;
file_reader >> score;
scores.push_back( score );
}
}

В этом коде нет ничего сложного: он просто открывает файл и считывает


рекорды по одному за раз. Этот код даже не полагается на то, что рекорды
разделены символами новой строки, — он работает с пробелами. Тем не
менее это недостаток реализации, а не особенность формата файла. Дру-
гие программы, работающие с файлами такого формата, могут оказаться
требовательнее к данным, которые считывают. Существует хороший прин-
цип работы с форматами файлов, называемый законом Постела: «Будьте
либеральны к данным, которые принимаете, и консервативны к данным,
которые отправляете». Другими словами, код, создающий файл, должен
очень тщательно следовать спецификации, а код, считывающий формат
файла, должен быть терпимым к небольшим ошибкам программ, напи-
санных не столь безупречно. В приведенном выше примере мы проявляем
либеральность, принимая не только символы новой строки, но и пробелы.

Конец файла
Этот код работает лишь с конкретным файловым форматом и не преду­
сматривает никакой обработки ошибок. Например, если количество запи-
сей в файле меньше десяти, код не прекратит считывание, даже достигнув
конца файла. Ситуация, когда количество рекордов меньше десяти, вполне
390    Глава 28. Файловый ввод-вывод

возможна — например, если в игру играли лишь дважды. Конец файла часто
обозначается аббревиатурой EOF (end of file, конец файла).
Можно сделать код устойчивее (либеральнее по отношению к вводимым
данным), если обрабатывать ситуации, в которых файл содержит меньше
десяти элементов. Для этого можно еще раз проверить результат метода,
который считывает данные.

Пример 67: highscore_eof.cpp


#include <fstream>
#include <iostream>
#include <vector>

using namespace std;

int main ()
{
ifstream file_reader( "myfile.txt" );
if ( ! file_reader.is_open() )
{
cout << "Could not open file!" << '\n';
}
vector<int> scores;
for ( int i = 0; i < 10; i++ )
{

int score;
if ( ! ( file_reader >> score ) )
{
break;
}

scores.push_back( score );
}
}

Обрабатывая файл менее чем с десятью записями, код прекращает чте-


ние, достигнув конца файла. Воспользовавшись вектором вместо массива
с фиксированной длиной, мы можем легко обрабатывать короткие файлы.
Вектор сохранит ровно столько данных, сколько было считано. Если мы
сделаем то же с массивом, придется отслеживать количество хранимых
в нем записей, поскольку мы не знаем, заполнен ли массив целиком.
Иногда требуется считывать из файла все данные. В таких случаях необхо-
димо отличать ошибки чтения, обусловленные достижением конца файла,
от ошибок, вызванных некорректностью файла. Метод eof определяет,
Запись в файлы    391

достигнут ли конец файла. Можно написать цикл, который считывает


максимально возможное количество данных и проверяет результат чтения,
пока не происходит ошибка. Затем следует проверить значение, возвра-
щаемое методом eof: если оно равно true, вы находитесь в конце файла,
в противном случае произошла ошибка. Выяснить причину ошибки можно
с помощью метода fail, который возвращает true, если введенные данные
некорректны или возникла проблема считывания данных с устройства.
После того как конец файла достигнут, вы должны вызвать метод clear,
чтобы продолжить выполнение операций над файлом. В следующем раз-
деле мы познакомимся с примером, в котором все перечисленные методы
используются для добавления нового элемента в список рекордов.
Существует еще одно важное различие между ситуациями, когда данные
считываются из файлов и когда их вводит пользователь. Что произойдет,
если мы включим в список рекордов не только количество очков, но и имя
игрока? Нам потребуется считать из файла как количество очков, так и имя
игрока, а для этого необходимо соответствующим образом изменить код.
Предыдущие версии нашей программы не смогут считывать файл нового
формата. Если у вас много пользователей, изменение формата файла может
повлечь серьезные проблемы! Существуют методы обеспечения буду-
щей совместимости файла, которые основаны на использовании в файле
дополнительных полей или игнорировании новых элементов формата
в устаревших версиях программы. Изучение этих методов не относится
к теме этой книги, просто имейте в виду, что определение формата файла
является гораздо важнее определения базового интерфейса.

Запись в файлы
Для записи в файлы применяется тип ofstream (сокращение от output file
stream — исходящий файловый поток). Этот тип почти идентичен типу
ifstream с единственным исключением: он используется аналогично не
объекту cin, а объекту cout.
Рассмотрим простую программу, которая выводит числа от 0 до 9 в файл
highscores.txt (этот код скоро создаст нам нечто более похожее на таблицу
рекордов).

Пример 68: ofstream.cpp


#include <fstream>
#include <iostream>
#include <cstdlib>
продолжение 
392    Глава 28. Файловый ввод-вывод

using namespace std;

int main ()
{
ofstream file_writer( "highscores.txt" );
if ( ! file_writer.is_open() )
{
cout << "Could not open file!" << '\n';
return 0;
}

// поскольку у нас нет реальных рекордов, мы выведем


// числа от 10 до 1
for ( int i = 0; i < 10; i++ )
{
file_writer << 10 – i << '\n';
}
}

Здесь не нужно беспокоиться о том, что мы достигли конца файла. Когда


при записи в файл вы попадаете в его конец, объект ofstream расширяет
файл. Эта операция называется добавлением в конец файла.

Создание новых файлов


Когда для записи в файл вы используете объект ofstream, по умолчанию
он создает этот файл, если он не существует, или перезаписывает содержи-
мое существующего файла. При работе со списком рекордов, скорее всего,
перезапись файла при каждом сохранении вполне допустима, поскольку
вы возвращаете в него все нужные данные. Тем не менее если вы ведете
журнал и регистрируете в нем дату и время каждого запуска вашей про-
граммы, периодически перезаписывать его, безусловно, нельзя.
Конструктор типа ofstream принимает второй аргумент, который опреде-
ляет метод обработки файла:

ios::app Добавляет данные в конец файла; после каждой операции записи


текущая позиция устанавливается в конец файла
ios::ate Устанавливает текущую позицию в конец файла
ios::trunc Удаляет все содержимое файла (урезает его)
ios::out Разрешает вывод в файл
ios::binary Разрешает выполнение двоичных операций над потоком (также
может использоваться при чтении из файла)

Чтобы воспользоваться несколькими методами одновременно (например,


открыть файл для добавления данных в конец и использовать двоичный
Позиция в файле    393

ввод-вывод, который мы скоро рассмотрим), можно объединить их с по-


мощью вертикальной черты (|)1:
ofstream a_file( "test.txt", ios::app | ios::binary );

Этот код открывает файл, при этом не уничтожая его текущее содержимое,
и позволяет добавлять двоичные данные в конец файла.

Позиция в файле
Когда программа читает файл или записывает в него, код ввода-вывода
должен знать, в каком месте файла следует выполнить соответствующую
операцию. Позиция в файле аналогична курсору на экране, который ука-
зывает, где будет отображен следующий введенный символ.
При выполнении базовых операций нет необходимости уделять особое
внимание позиции в файле: код может просто считывать или записывать
очередной фрагмент данных. Тем не менее можно изменять позицию
в файле, не выполняя операцию чтения. Это часто необходимо при работе
с файлами, которые хранят сложные структуры данных, например ZIP или
PDF, и с файлами, считывание каждого байта из которых занимает много
времени или невозможно (например, если вы реализуете базу данных).
Фактически в файле существуют две позиции, одна из которых опреде-
ляет место для следующей операции чтения, а другая — для следующей
операции записи. Узнать текущую позицию для чтения можно с помощью
метода tellg (g означает get — получение, чтение данных), а текущую
позицию для записи — с помощью метода tellp (p означает put — раз-
мещение, запись данных).
Новую позицию в файле также можно задать, перемещаясь от текущей
позиции с помощью методов seekp и seekg. Перемещение по файлу назы-
вается поиском (это отражено в названиях методов, которые происходят
от слова seek — искать). При поиске в файле позиция чтения или записи
перемещается на новое место. Оба указанных метода принимают два па-
раметра: расстояние и начало поиска. Расстояние измеряется в байтах,

Символ вертикальной черты — это побитный оператор «или». Каждый из


1

параметров ios:: устанавливает определенный бит в 1, их можно объединять


с помощью операции побитного «и». Более подробную информацию о по-
битных операторах можно найти по адресу http://www.cprogramming.com/tutorial/
bitwise_operators.html
394    Глава 28. Файловый ввод-вывод

а началом поиска является текущая позиция, начало или конец файла. По-
сле поиска вы можете считывать или записывать данные в файл, начиная
с новой позиции. Изменение одной позиции с помощью операции поиска
не влияет на другую позицию.
Позиция в файле задается с помощью одного из трех флагов:

ios_base::beg Поиск от начала файла


ios_base::cur Поиск от текущей позиции
ios_base::end Поиск от конца файла

Например, чтобы переместить текущую позицию в начало файла перед


тем, как начать запись в него, можно написать следующее:
file_writer.seekp( 0, ios_base::beg );

Значение, возвращаемое методами tellp и tellg , представляет собой


особый тип переменной — streampos, который определен в стандартной
библиотеке. Значение можно преобразовать в целое число и обратно, одна-
ко использование streampos явно задает тип данных. Целое число можно
использовать где угодно, а у типа streampos есть конкретное назначение.
Переменные streampos хранят позиции внутри файлов и используются для
установки указателя на эти позиции. Задавая переменным подходящий
тип, мы четко указываем их назначение.
streampos pos = file_reader.tellg();

Иногда перемещать текущую позицию в файле не требуется: достаточно


просто считать файл от начала до конца. Тем не менее многие форматы
файлов оптимизированы для добавления в них новых данных. Новые
данные добавляются в конец файла гораздо быстрее, чем в середину. Когда
данные добавляются в середину файла, необходимо сдвигать все его со-
держимое, находящееся после места вставки, так же как при добавлении
нового элемента в середину массива1.
Дополним написанную ранее программу, которая работает с таблицей
рекордов, возможностью добавления нового рекорда в файл. Поскольку
для этого необходимо как считывать данные из файла, так и записывать
их, мы воспользуемся классом fstream, который поддерживает обе опе-
рации (можно считать, что он объединяет возможности классов ofstream

Существует особый случай: если вы записываете новые данные поверх суще-


1

ствующих и их длины совпадают, не нужно ничего перемещать, и эта операция


выполняется так же быстро, как запись в конец файла.
Позиция в файле    395

и ifstream). Сначала мы считаем новый рекорд, который вводит пользо-


ватель, а затем будем считывать каждую строку файла, пока не найдем
рекорд меньше введенного. В этом месте мы вставим новый рекорд. Мы
сохраним эту позицию и вернемся к ней, считав все оставшиеся строки
файла в вектор. Мы запишем в файл новый рекорд, а затем все остальные
рекорды, которые уже были в файле.
Поскольку мы используем класс fstream, мы можем читать и писать в файл,
но теперь нам нужно явно указать конструктору, что мы хотим открыть
файл для чтения и записи. Для этого воспользуемся флагами ios::in |
ios::out. Перед тем как запустить программу, нужно создать файл с ре-
кордами, поскольку сама программа не создает пустой файл.

Пример 69: file_position.cpp


#include <fstream>
#include <iostream>
#include <vector>

using namespace std;

int main ()
{
fstream file (
"highscores.txt",
ios::in | ios::out
);
if ( ! file.is_open() )
{
cout << "Could not open file!" << '\n';
return 0;
}
int new_high_score;
cout << "Enter a new high score: ";
cin >> new_high_score;

// Приведенный ниже цикл while выполняет поиск в файле,


// пока не найдет значение, меньшее
// текущего рекорда; после этого нужно вставить
// рекорд перед найденным значением.
// Чтобы определить требуемую позицию, мы следим
// за позицией, предшествующей текущему рекорду, -
// pre_score_pos
streampos pre_score_pos = file.tellg();
int cur_score;
while ( file >> cur_score )
{
продолжение 
396    Глава 28. Файловый ввод-вывод

if ( cur_score < new_high_score )


{
break;
}
pre_score_pos = file.tellg();
}

// если fail возвращает true и мы не находимся в конце файла,


// ввод некорректен
if ( file.fail() && ! file.eof() )
{
cout << "Bad score/read--exiting";
return 0;
}
// если мы достигли конца файла, то
// не сможем вести запись в файл, не вызвав clear
file.clear();

// Возврат к точке перед последним считанным рекордом,


// чтобы считать все рекорды, меньшие нового,
// и сдвинуть их в файле на одну позицию
file.seekg( pre_score_pos );

// Теперь мы считаем все рекорды, начиная


// со считанного ранее
vector<int> scores;
while ( file >> cur_score )
{
scores.push_back( cur_score );
}
// Этот цикл чтения должен выполняться до конца файла,
// поскольку мы хотим считать все рекорды,
// которые в нем хранятся
if ( ! file.eof() )
{
cout << "Bad score/read--exiting";
return 0;
}
// Поскольку достигнут конец файла, нужно снова вызвать метод
// clear, чтобы продолжить запись в файл
file.clear();

// Возврат на позицию вставки


file.seekp( pre_score_pos );
// Записывая данные не в начало файла,
// мы должны указать символ новой строки; поскольку
// считывание числа заканчивается на первом разделителе,
// перед записью мы находимся в конце числа,
// а не в начале новой строки
if ( pre_score_pos != std::streampos(0) )
Прием командно-строковых аргументов    397

{
file << endl;
}
// запись нового рекорда
file << new_high_score << endl;
// циклическая запись всех оставшихся рекордов

for ( vector<int>::iterator itr = scores.begin();


itr != scores.end();
++itr)
{
file << *itr << endl;
}
}

Прием командно-строковых аргументов


В программах, работающих с файлами, пользователям часто удобно указы-
вать имя файла в аргументе командной строки. Как правило, это облегчает
использование программы и написание сценариев, которые ее вызывают.
Ненадолго отвлечемся от чтения и записи в файлы и рассмотрим работу
с командно-строковыми аргументами.
Командно-строковые аргументы указываются после имени программы
и передаются ей из операционной системы:
C:\my_program\my_program.exe arg1 arg2

Командно-строковые аргументы передаются непосредственно в функцию


main: чтобы воспользоваться ими, необходимо полностью объявить ее (все
функции main, которые вы видели ранее, имели пустой список аргумен-
тов). На самом деле функция main принимает два параметра: количество
аргументов в командной строке и полный список этих аргументов.
Полное объявление функции main выглядит следующим образом:
int main (int argc, char *argv[])

Целое число argc хранит количество аргументов, переданных программе


через командную строку, с учетом имени программы. Вы спросите: почему
мы не пользовались этими аргументами в каждой программе? Ответ прост:
если вы не указываете аргументы, компилятор игнорирует их передачу
в функцию.
Массив указателей на символы содержит все аргументы. Элемент
argv[0] хранит имя программы или пустую строку, если оно недоступно,
398    Глава 28. Файловый ввод-вывод

а последующие элементы с номерами меньшими, чем argc, — командно-


строковые аргументы. Все элементы массива argv можно использовать как
строки. Значение указателя argv[argc] равно NULL.
Рассмотрим пример программы, которая принимает имя файла в качестве
командно-строкового аргумента и выводит содержимое этого файла на
экран.

Пример 70: cat.cpp


#include <fstream>
#include <iostream>

using namespace std;

int main (int argc, char *argv[])


{
// Для корректного выполнения программы значение argc должно быть равно 2
// (имя программы и имя файла)

if ( argc != 2)
{
// При выводе на экран инструкций по использованию программы можно
// воспользоваться именем файла, хранящимся в argv[0]
cout << "usage: " << argv[ 0 ] << " <filename>"
<< endl;
}
else
{
// Мы считаем, что в argv[1] хранится имя открываемого файла
ifstream the_file( argv[1] );
// Всегда проверяйте успешность открытия файла
if ( ! the_file.is_open() )
{
cout << "Could not open file "
<< argv[ 1 ] << endl;
return 1;
}

char x;
// вызов the_file.get(x) считывает следующий символ
// из файла в переменную x; он возвращает false при
// достижении конца файла или появлении ошибки
while ( the_file.get( x ) )
{
cout << x;
}
} // здесь деструктор неявно закрывает файл
}
Ввод-вывод в двоичные файлы    399

Эта программа использует полное объявление функции main для доступа


к параметрам командной строки. Сначала она проверяет, что пользователь
ввел имя файла, а затем проверяет корректность файла, пытаясь открыть
его. Если файл корректен, его открытие успешно; в противном случае про-
грамма выводит пользователю сообщение об ошибке. Если файл открыт
успешно, программа выводит все его символы на экран.

Работа с численными командно-строковыми аргументами


Чтобы принять командно-строковый параметр и воспользоваться им как
числом, можно считать его в виде строки и вызвать функцию atoi (ее на-
звание является сокращением ASCII to integer — преобразование символа
ASCII в целое число). Функция atoi принимает аргумент типа char *
и возвращает целое число, которое содержится в строке; чтобы использо-
вать ее, необходимо включить в программу заголовочный файл cstdlib.
Например, следующая программа считывает аргумент из командной строки,
преобразует его в число и выводит результат его возведения в квадрат:

Пример 71: atoi.cpp


#include <cstdlib>
#include <iostream>

using namespace std;


int main (int argc, char *argv[])
{
if ( argc != 2 )
{
// При выводе на экран инструкций по использованию программы
// можно воспользоваться именем файла, хранящимся в argv[0]
cout << "usage: " << argv[ 0 ] << " <number>"
<< endl;
}
else
{
int val = atoi( argv[ 1 ] );
cout << val * val;
}
return 0;
}

Ввод-вывод в двоичные файлы


До настоящего момента мы изучали работу с файлами, содержавши-
ми текстовые данные. Теперь мы узнаем, как пользоваться двоичными
400    Глава 28. Файловый ввод-вывод

файлами, которые часто используются для максимальной эффективности.


К двоичным файлам применяются другие средства программирования,
нежели к текстовым файлам. Поймите правильно: все файлы в вашей
системе хранятся в двоичном виде, однако многие из них доступны для
чтения пользователю. Например, исходные файлы C++ состоят из симво-
лов, которые можно прочесть с помощью простого текстового редактора.
Файлы, каждый байт которых представляет собой символ, называются
текстовыми файлами.
Тем не менее не все файлы состоят исключительно из текста, который
можно вывести. Некоторые файлы содержат лишь двоичные данные одной
или нескольких структур, записанные непосредственно на диск.
Допустим, есть структура, представляющая плеер:
struct player
{
int age;
int high_score;
string name;
};

Можно записать ее в файл двумя способами. Первый способ — записать


age, high_score и name как текстовые поля, чтобы файл можно было от-
крыть в блокноте. Этот файл будет иметь следующий вид:
19
120000
Tom

Рекорд, представленный в таком формате, занимает 6 байт, поскольку


каждый символ хранится в одном байте. Тем не менее рекорд является
целым числом, которое занимает лишь четыре байта (в 32-разрядной си-
стеме), поэтому возникает вопрос: нельзя ли обойтись для его хранения
только четырьмя байтами? Ответ на этот вопрос утвердителен, но если
мы сохраним число в 4-байтном формате, то уже не сможем прочесть его
в текстовом редакторе. Почему? Когда мы записываем число 120 000 в виде
символьной строки, она кодируется так, что каждый ее символ хранится
в байте, который имеет определенное значение. Если же мы сохраняем
в файле само число, его байты не преобразуются в символы. Файл содер-
жит четыре байта, из которых состоит целое число. Если вы откроете этот
файл в текстовом редакторе, он воспримет байты как символы, но они не
будут иметь ничего общего с отображаемым числом! Результат будет бес-
смысленным, поскольку файл имеет другую кодировку.
Ввод-вывод в двоичные файлы    401

Файлы в двоичном формате занимают меньше места. Приведенный выше


пример показал, что для хранения числа 120 000 в символьном виде нужно
на 50% больше места, чем для хранения в двоичном формате. Этот фактор
может оказаться существенным, если вы отправляете данные по сети или
не располагаете быстрым и объемным жестким диском. С другой стороны,
содержимое двоичных файлов сложнее воспринимать: чтобы узнать, какие
данные находятся в двоичном файле, его нельзя просто открыть в текстовом
редакторе. Разработчики файлового формата обеспечивают баланс его эф-
фективности и удобства чтения. Файловые форматы на основе текстовых
языков разметки вроде XML, как правило, занимают относительно много
места, но их очень легко читать.
Если в системе мало свободного пространства, можно использовать тех-
нологии сжатия файлов вроде ZIP — современные процессоры обладают
достаточным для этого быстродействием. Технологии сжатия сокращают
размер текстовых файлов и при этом позволяют пользоваться ими после
разархивирования. Поскольку разархивировать файл очень просто, работа
со сжатыми файлами не составляет труда, при этом они имеют гораздо
меньший размер, чем несжатые текстовые файлы.
Тем не менее двоичные файлы распространены весьма широко. Большин-
ство существующих форматов файлов являются двоичными, и многие
файлы просто не могут не быть двоичными, поскольку картинки, видео
и звук не имеют точного и осмысленного текстового представления. В си-
туациях, когда требуется максимальная производительность или эконо-
мия пространства, двоичные файлы вне конкуренции: например, в пакете
программ Office 2007 компания Microsoft ввела новые форматы файлов,
основанные на XML внутри файла ZIP. Тем не менее был введен и новый
формат Excel (.xlsb) для пользователей, которым требуется максимальная
производительность. Другими словами, двоичные файлы имеют весьма
важное значение, и при разработке файлового формата следует искать
подходящий баланс между простотой реализации и представления, с одной
стороны (текстовые форматы), и производительностью и компактностью —
с другой стороны (двоичные форматы).
Итак, как же работать с двоичными файлами?

Работа с двоичными файлами


Первое, что нужно сделать, — открыть файл в двоичном режиме:
ofstream a_file( "test.bin", ios::binary );
402    Глава 28. Файловый ввод-вывод

Открыв файл, вы не сможете вызывать функции ввода и вывода данных,


которыми мы пользовались раньше: для работы с двоичными данными
потребуются специальные функции. Необходимо записывать байты не-
посредственно в файл из блока памяти. Мы будем пользоваться методом
write, который осуществляет запись в файл и принимает указатель на
блок памяти и размер этого блока. Указатель имеет тип char*, но данные
необязательно являются символами. Почему же мы используем символы?
В языке C++ для работы с отдельными байтами используется переменная
размером один байт — char, или указатель на набор байтов — char*. Чтобы
записать последовательность байтов в файл, необходимо воспользоваться
типом char*. Чтобы записать в файл целое число, мы должны работать
с ним как с набором байтов и передать указатель char* методу, который
запишет байты из памяти непосредственно в файл. Метод write последо-
вательно записывает в файл каждый байт. Допустим, есть число 255: оно
представляется байтом 0xFF (так число 255 выглядит в шестнадцатеричной
форме). Целое число, хранящее байт 0xFF, имеет в памяти следующий вид:
0x000000FF
или побайтно:
00 00 00 FF
Чтобы записать в память целое число, нужно обратиться напрямую к этой
последовательности байтов; именно поэтому мы должны использовать
указатель char*. Он предназначен не для представления символов ASCII,
а для работы с байтами.
Нам также потребуется указать компилятору, чтобы он работал с нашими
данными так, как будто они представляют собой массив символов.

Преобразование в char*
Итак, как нам указать компилятору, чтобы он обращался с переменной как
с указателем на тип char, а не на ее настоящий тип? Для этого придется
воспользоваться преобразователями типов. Преобразователь типов уве-
домляет компилятор, что вы берете на себя ответственность за обработку
переменной нестандартным для нее образом. Мы хотим использовать пере-
менную как набор отдельных байтов и с помощью преобразователя типов
потребуем от компилятора, чтобы он предоставил нам доступ к каждому
байту этой переменной.
Двумя наиболее распространенными преобразователями типов явля-
ются static_cast и reinterpret_cast. static_cast используется для
Ввод-вывод в двоичные файлы    403

преобразования (приведения) схожих типов данных. Например, можно


указать компилятору использовать переменную double как обычное целое
число, чтобы его можно было усечь, с помощью оператора static_cast<int>
( 3.4 ). Преобразуемый тип данных указывается в угловых скобках после
имени преобразователя.
Мы имеем дело с другой ситуацией: нам требуется полностью проиг-
норировать систему типов и сделать так, чтобы компилятор воспринял
последовательность байтов как совершенно иной тип данных. Для этого
необходимо воспользоваться преобразователем reinterpret_cast. Напри-
мер, чтобы работать с массивом целых чисел как с массивом символов,
можно написать
int x[10];
reinterpret_cast<char*>(x);

Кстати, работа с двоичными данными — одна из немногих ситуаций, в ко-


торых рекомендуется использовать преобразователь reinterpret_cast.
Будьте с ним очень осторожны! reinterpret_cast обладает мощными воз-
можностями изменять обычное поведение компилятора и снижать строгость
проверок кода, использующего преобразователь. В данном случае нам
действительно требуется получить набор байтов, однако если это не то, что
нужно, пользоваться преобразователем reinterpret_cast не рекомендуется.

Пример двоичного ввода-вывода


Наконец, мы можем продемонстрировать ввод и вывод двоичных данных!
Следующий код заполняет массив и выводит его в файл. Он использует
рассмотренный ранее метод write, принимающий указатель char* на дан-
ные, которые требуется записать, и их размер. В данном случае источником
данных является массив, а размером данных — его длина в байтах.
int nums[10];

for (int i = 0; i < 10; i++)


{
nums[ i ] = i;
}
a_file.write(
reinterpret_cast<char*>(nums),
sizeof(nums)
);

Сначала мы работаем с массивом целых чисел, но после преобразова-


ния к типу char* он используется как массив байтов и записывается
404    Глава 28. Файловый ввод-вывод

непосредственно на диск. Когда позже мы повторно считываем эти байты,


в памяти оказывается в точности то же содержимое, и мы можем пре-
образовать его к исходным целым числам. Обратите внимание, что для
определения размера записываемых данных используется оператор sizeof.
С помощью команды sizeof очень удобно определять размер конкретных
переменных. В данном случае она возвращает количество байтов, занима-
емых массивом nums.
Будьте осторожны при применении оператора sizeof к указателю: в этой
ситуации sizeof возвращает размер указателя, а не той памяти, на которую
он указывает. Приведенный выше код работает потому, что переменная
nums объявлена как массив, а не как указатель, и оператору sizeof изве-
стен его размер. Размер указателя int *p_num — это размер адреса памяти,
который, как правило, составляет 4 байта. Определить размер данных, на
которые указывает указатель, можно с помощью оператора sizeof(*p_num).
В данной ситуации этот оператор вернет тот же результат, что sizeof(int).
Если указатель указывает на массив (если вы написали int *p_num = new
int[length]), размер массива равен sizeof(* p_num) * length.

Метод write можно использовать и для того, чтобы записать структуру


непосредственно в файл. Допустим, есть следующая структура:
struct PlayerRecord
{
int age;
int score;
};

Можно создать экземпляр PlayerRecord и записать его в файл:


PlayerRecord rec;
rec.age = 10;
rec.score = 890;

a_file.write(
reinterpret_cast<char*>(& rec),
sizeof(rec)
);

Обратите внимание, что в данном случае мы принимаем адрес переменной


rec, чтобы передать указатель на структуру.

Хранение классов в файле


Допустим, мы хотим добавить в собственную структуру нестандартный
тип данных (например, строку).
Ввод-вывод в двоичные файлы    405

struct PlayerRecord
{
int age;
int score;
string name;
};

В данном случае мы просто добавили в структуру имя игрока. Что про-


изойдет со строкой, если теперь мы захотим записать эту структуру в файл?
В файл будет помещена информация, которая хранится в строке, но со-
держимое самой строки, скорее всего, не будет записано.
Строковый тип реализован как указатель на строку (возможно, с дополни-
тельными данными — например, длиной строки). При записи структуры
в двоичном формате в файл помещается все, что непосредственно в ней
хранится, — указатель и длина. Тем не менее указатель имеет смысл, толь-
ко пока работает программа! Адрес памяти, хранимый указателем, теряет
актуальность по завершении программы, поскольку данные в этой памяти
уничтожаются. Тот, кто позже считает структуру, получит указатель на
память, которая не была надлежащим образом выделена или не имеет
никакого отношения к строке.
Нам нужно представлять двоичные данные, хранимые на диске, в фикси-
рованном и четко определенном формате, а не просто записывать на диск
непосредственное содержимое структуры. Этот формат должен включать
символы строки и ее размер (скоро станет понятно, для чего нужен размер).
Он выглядит следующим образом:
PlayerRecord rec;
rec.age = 11;
rec.score = 200;
rec.name = "John";

fstream a_file(
"records.bin",
ios::trunc | ios::binary | ios::in | ios::out
);

a_file.write(
reinterpret_cast<char*>(& rec.age),
sizeof(rec.age)
);
a_file.write(
reinterpret_cast<char*>(& rec.score),
sizeof(rec.score)
);
продолжение 
406    Глава 28. Файловый ввод-вывод

int len = rec.name.length();


a_file.write(
reinterpret_cast<char*>(& len),
sizeof(len)
);
a_file.write( rec.name.c_str(), len + 1);
// + 1 для NULL-ограничителя

Метод c_str считывает указатель на символьную строку в памяти вместо


того, чтобы использовать сам строковый объект, структура размещения
которого в памяти непостоянна. Если ваша строка считывает «abc», вы-
зов c_str возвращает адрес последовательности символов с буквами «abc».
Эта строка оканчивается символом со значением 0, который называется
нуль-ограничителем и указывает на конец строки1. Строка в таком фор-
мате называется C-строкой, поскольку это единственный универсальный
формат строки в языке C.
Нет ничего плохого в том, что мы записываем символьные данные в дво-
ичный файл. Символьные данные тоже являются двоичными — их особен-
ность лишь в том, что их можно читать.
Мы также поступаем абсолютно правомерно, не записывая в файл исходное
содержимое структуры. Принципиально то, что мы способны преобразовы-
вать формат файла на диске в объект в памяти, а не напрямую записывать
байты из памяти на диск. Формат файла и структура — это два представления
одних и тех же данных, которые необязательно совпадают друг с другом.

Чтение из файла
Чтобы считать данные из двоичного файла, воспользуемся методом read.
Его аргументы почти такие же, как у метода write: место, в которое следует
поместить данные, и количество этих данных2. Считать из файла целое
значение можно с помощью следующего кода:
int x = 3;
a_file.read(reinterpret_cast<char*>(& x), sizeof(x));

1
Иногда нуль-ограничитель записывают как '\0'. Это абсолютно корректно; раз-
ница между 0 и '\0' в том, что значение '\0' имеет тип char, а 0 — целое число,
которое преобразуется в char. В нашей ситуации подойдут оба варианта.
2
Важное различие методов read и write в том, что в write можно передавать
константный указатель на объект, участвующий в записи. Не забудьте, что
понадобится преобразователь reinterpret_cast<const char*> (обратите
внимание на модификатор const).
Ввод-вывод в двоичные файлы    407

При работе с файлами нужна возможность записывать и считывать все


виды структур данных, которые вы хотите хранить в файле. Рассмотрим
считывание структуры PlayerRecord. Сначала выполним простую задачу —
сбросим позицию в файле, а затем считаем поля age и score, записанные
на диск без изменения формата.
a_file.seekg(0, ios::beg);

PlayerRecord in_rec;

if (! a_file.read(
reinterpret_cast<char*>(& in_rec.age),
sizeof(in_rec.age)
))
{
// обработка ошибки
}
if (! a_file.read(
reinterpret_cast<char*>( & in_rec.score ),
sizeof( in_rec.score )
) )
{
// обработка ошибки
}

Возникает вопрос: как считать строку? Мы не можем считать char* из


файла; формат строки в памяти отличается от ее формата на диске. Мы
должны сначала считать char*, а затем создать новую строку.
Теперь ясно, для чего мы сохранили длину строки: мы должны знать,
сколько места потребуется выделить для хранения char*. Мы считаем
длину строки, затем выделим память для нее и считаем строку в эту память.
int str_len;

if ( ! a_file.read(
reinterpret_cast<char*>(& str_len),
sizeof(str_len)
) )
{
// обработка ошибки
}
// проверяем, что выделяемое количество памяти
// не слишком велико
else if ( str_len > 0 && str_len < 10000 )
{
char *p_str_buf = new char[ str_len ];
// + 1 для нуль-ограничителя
продолжение 
408    Глава 28. Файловый ввод-вывод

if (! a_file.read(p_str_buf, str_len + 1))


{
// обработка ошибки
}
// проверяем, что строка оканчивается нулем
if (p_str_buf[ str_len ] == 0)
{
in_rec.name = string(p_str_buf);
}
delete[] p_str_buf;
}

cout << in_rec.age << " " <<in_rec.score << " " << in_rec.name << endl;

Ниже приведена полная версия программы, с которой можно экспери-


ментировать.

Пример 72: binary.cpp


#include <fstream>
#include <string>
#include <iostream>

using namespace std;

struct PlayerRecord
{
int age;
int score;
string name;
};

int main ()
{
PlayerRecord rec;
rec.age = 11;
rec.score = 200;
rec.name = "John";

fstream a_file(
"records.bin",
ios::trunc | ios::binary | ios::in | ios::out
);

a_file.write(
reinterpret_cast<char*>(& rec.age),
sizeof(rec.age)
);
a_file.write(
reinterpret_cast<char*>(& rec.score),
Ввод-вывод в двоичные файлы    409

sizeof(rec.score)
);

int len = rec.name.length();


a_file.write(
reinterpret_cast<char*>(& len),
sizeof( len )
);

a_file.write(
rec.name.c_str(),
rec.name.length() + 1
);

PlayerRecord in_rec;

a_file.seekg(0, ios::beg);
if (! a_file.read(
reinterpret_cast<char*>(& in_rec.age ),
sizeof(in_rec.age)
))
{
cout << "Error reading from file" << endl;
return 1;
}
if (! a_file.read(
reinterpret_cast<char*>(& in_rec.score),
sizeof(in_rec.score)
))
{
cout << "Error reading from file" << endl;
return 1;
}

int str_len;

if (! a_file.read(
reinterpret_cast<char*>(& str_len),
sizeof(str_len)
))
{
cout << "Error reading from file" << endl;
return 1;
}
// проверяем, что выделяемое количество памяти
// не слишком велико
if (str_len > 0 && str_len < 10000)
{
char *p_str_buf = new char[ str_len + 1];
if (! a_file.read(p_str_buf, str_len + 1))
продолжение 
410    Глава 28. Файловый ввод-вывод

// + 1 for null terminator


{
delete[] p_str_buf;
cout << "Error reading from file"
<< endl;
return 1;
}
// проверяем, что строка оканчивается нулем
if (p_str_buf[ str_len ] == 0)
{
in_rec.name = string(p_str_buf);
}
delete[] p_str_buf;
}
cout << in_rec.age << " " <<in_rec.score << " "
<< in_rec.name << endl;
}

Запустите эту программу, а затем попытайтесь открыть файл в Блокноте


или другом текстовом редакторе. Вы сможете прочитать имя John, по-
скольку оно хранится в виде символов, а все остальное содержимое файла
прочитать нельзя.

Проверьте себя
1. Какой тип можно использовать для чтения из файла?
А. ifstream
Б. ofstream
В. fstream
Г. А и В
2. Какое из нижеперечисленных утверждений верно?
А. Текстовые файлы занимают меньше места, чем двоичные.
Б. Двоичные файлы легче отлаживать.
В. Двоичные файлы используют пространство эффективнее текстовых.
Г. Текстовые файлы слишком медленны для использования в реальных
программах.
3. Почему нельзя передавать указатель на строковый объект при написа-
нии двоичного файла?
А. В метод write всегда необходимо передавать char*.
Б. Строковый объект невозможно хранить в памяти.
В. Нам неизвестна структура строкового объекта, она может содержать
указатели, которые будут записаны в файл.
Ввод-вывод в двоичные файлы    411

Г. Строки занимают слишком много места, и их необходимо записывать


по частям.
4. Какие из нижеперечисленных утверждений о форматах файлов спра-
ведливы?
А. Изменять форматы файлов так же легко, как любые другие входные
данные.
Б. Перед тем как изменить формат файла, необходимо определить, что
произойдет, когда старая версия программы попытается прочитать файл.
В. При разработке формата файла необходимо учитывать, что про-
изойдет, если новая версия программы откроет старую версию файла.
Г. Б и В.
(Решения см. на с. 473.)

Практические задания
1. Перепишите программу, которая вставляет новые значения в таблицу
рекордов, заменив текстовый файл двоичным. Как узнать, корректно
ли работает программа? Создайте программу, которая преобразует
двоичный файл в текстовый вид.
2. Измените программу, разбирающую код HTML, которую вы реализо-
вали в главе 19, таким образом, чтобы она могла считывать данные из
файла на диске.
3. Создайте простую программу, которая анализирует код XML. XML
представляет собой язык разметки, схожий с HTML. Документ XML
представляет собой дерево узлов вида <узел>[данные]</узел>, где эле-
мент [данные] представляет собой текст или вложенный узел. Узлы
XML могут иметь атрибуты вида <узел атрибут="значение"></узел>
(реальная спецификация XML содержит гораздо больше деталей, но
для их реализации потребуется много усилий). Программа разбора
должна включать интерфейсный класс с методами, которые вызыва-
ются при наступлении интересных событий.
1) При чтении узла вызывается метод nodeStart, которому передается
имя узла.
2) При считывании атрибута вызывается метод attributeRead ; он
должен вызываться сразу же после метода nodeStart для узла,
с которым связан атрибут.
3) Если узел содержит текст, вызывается метод nodeTextRead ,
которому текст передается в виде строки. Если узел имеет формат
412    Глава 28. Файловый ввод-вывод

<узел>текст<подузел>текст</подузел>еще текст</узел>, метод


nodeTextRead должен вызываться как для текста перед подузлом, так
и для текста за подузлом.
4) При считывании конечного узла вызывается метод nodeEnd, которому
передается имя узла.
5) Символы < и > считаются частью тега узла. Если автор документа
XML хочет использовать символы < или >, он должен записать их в
виде &lt; или &gt; («меньше» и «больше»). Поскольку амперсанды
также входят в управляющие последовательности, их представляют
как &amp;. Тем не менее в коде не нужно выполнять преобразование
&lt;, &gt; и &amp;.

Ниже приведены примеры документов XML, которыми можно вос-


пользоваться для тестирования вашей программы:
<address-book>
<entry>
<name>Alex Allain</name>
<email>webmaster@cprogramming.com</email>
</entry>
<entry>
<name>Joe Doe</name>
<email>john@doe.com</email>
</entry>
</address-book>

и
<html>
<head>
<title>Doc title</title>
</head>
<body>This is a nice <a href="http://www.cprogramming.com">link</a>
to a website.</body>
</html>

Чтобы проверить корректность разбора, можно написать фрагмент


кода, который отображает каждый узел файла в процессе его разбора,
и убедиться, что программа правильно обнаруживает все его элементы.
Можете также выполнить следующее упражнение, в котором программа
разбора используется на практике.
4. Перепишите программу разбора HTML так, чтобы она использова-
ла ваш парсер кода. Добавьте возможность отображения списков.
Программа должна считывать теги <ul> и <nl> для ненумерованных
Практические задания    413

и нумерованных списков. Элементы списка должны располагаться


между тегами <li> и </li>. Список
<ul>
<li>first item</li>
<li>second item</li>
</ul>

должен отображаться в виде


* first item
* second item

а список
<nl>
<li>first item</li>
<li>second item</li>
</nl>

в виде
1. first item
2. second item

При обнаружении следующего нумерованного списка нумерация должна


начинаться заново.
Г лава 2 9
Шаблоны в C++

До сих пор приходилось задавать типы всех данных, которыми вы пользо-


вались в C++. При объявлении переменной требуется указать ее тип, а при
объявлении функции — типы всех ее параметров, возвращаемого значения
и всех локальных переменных.
Но иногда нужно создавать код, реализующий одну и ту же логику для
переменных разных типов. Вы уже наблюдали такой код в библиотеке
STL. STL — набор структур данных и алгоритмов, которые действуют по
схожим принципам и могут работать с любым заданным типом данных.
Используя вектор STL, вы задаете тип данных, который он будет хра-
нить, — вы не обязаны работать только с заранее заданными возможно-
стями. Авторы STL реализовали один вектор, который способен хранить
данные любого типа.
Как же им удалось создать такую замечательную возможность? Они
воспользовались средством языка C++, которое называется шаблонами.
Шаблоны позволяют создавать шаблоны функций и классов, не указывая
все используемые в них типы данных. Если потребуется обеспечить под-
держку какого-либо конкретного типа, компилятор создаст экземпляр
шаблона — его версию, в которой определены все типы данных. Именно
это происходит, когда вы пишете vector<int> vec: компилятор вводит
в шаблон вектора тип int и создает пригодный к использованию класс.
Как вы уже видели, в использовании шаблонов нет ничего сложного. В этой
главе пойдет речь о том, как создавать собственные шаблонные функции
и классы. Начнем с шаблонных функций.
Шаблонные функции    415

Шаблонные функции
С помощью шаблонов удобно создавать обобщенные функции. Рассмотрим
небольшую вспомогательную функцию, которая вычисляет площадь тре-
угольника:
int triangleArea (int base, int height)
{
return base * height * .5;
}

Если вы захотите вычислить площадь треугольника, высота и основание


которого равны 0.5, значения будут округлены до 0, поскольку оба аргу-
мента целые. В результате функция вернет 0, хотя площадь треугольника
нулю не равна.
Можно написать еще один метод:
double triangleAreaDouble (double base, double height)
{
return base * height * .5;
}

Этот код идентичен первой функции, за исключением строки, в которой


вместо типа int используется double. Если мы захотим реализовать этот
алгоритм для еще одного типа данных (возможно, это будет наш собствен-
ный класс чисел), придется написать третью функцию.
В таких ситуациях очень удобно пользоваться шаблонами. Шаблон по-
зволяет абстрагировать функцию от типов данных. Вместо задания типов
при вызове функции можно воспользоваться способностью компилятора
генерировать функции для каждого требуемого типа.
Синтаксис объявления шаблона сложен, но я разобью его на более простые
фрагменты. Синтаксис шаблона представит функцию так:
template <typename T>
T triangleArea (T base, T height)
{
return base * height * .5;
}

Прежде всего, мы объявляем, что функция является шаблоном, с помощью


ключевого слова template. Затем мы перечисляем в треугольных скобках
параметры шаблона — значения, которые будут заданы пользователем
шаблона (например, int в выражении vector<int>). Поскольку параметр
шаблона является типом данных, а не значением, мы используем ключевое
416    Глава 29. Шаблоны в C++

слово typename. После имени типа данных указываем имя параметра T.


Все это очень напоминает объявление аргумента функции. Когда вызы-
вающее окружение функции указывает тип данных в качестве параметра
шаблона, шаблон использует любую ссылку на параметр T так, будто он
принадлежит этому типу. Это опять очень напоминает передачу значений
в функцию через аргументы.
Например, если написать вызов
triangleArea<double>( .5, .5 );

то во всем коде параметр T будет заменен типом double. Это будет выгля-
деть так, будто мы написали функцию triangleAreaDouble. Созданный код
фактически является шаблоном, с помощью которого компилятор создает
конкретную функцию для работы с типом double.
Другими словами, строка
template <typename T>

означает, что данная функция или класс является шаблоном, а символ T


обозначает тип данных — например, int, double или char, либо имя кон-
кретного класса. Чтобы воспользоваться шаблоном, необходимо указать
конкретный тип вместо параметра T, поместив его в угловые скобки (<>)
перед именем функции или класса.

Вывод типа данных


Вызывающему окружению шаблонной функции даже не обязательно всегда
явно задавать параметры шаблона — компилятор часто способен определять
их по аргументам функции. Например, если вы напишете
triangleArea( .5, .5);

компилятор поймет, что вместо T следует подставить тип double. Это объ-
ясняется тем, что шаблонный параметр T используется для объявления
аргументов функции. Поскольку компилятору известны типы аргументов,
он догадывается, чем является T.
Вывод типа данных всегда выполняется, если в качестве типа одного из
аргументов функции используется параметр шаблона.

Утиная типизация
Есть поговорка: «Если что-то выглядит как утка, ходит как утка и говорит
как утка, это на самом деле утка». Как ни странно, поговорка часто имеет
отношение к шаблонам языка C++, и вот почему.
Шаблонные функции    417

Когда вы передаете параметр в шаблон, компилятор должен определить


допустимость такой передачи. Например, значения, передаваемые в шаблон
triangleArea, должны поддерживать операцию умножения:
return base * height * .5;

Не все типы данных можно перемножать. Можно умножать друг на друга


числовые типы данных, такие как int, double и другие, но что делать с
vector<int>? Операция умножения бессмысленна для вектора, и он ее не
поддерживает. При попытке передать три вектора в шаблон triangleArea
вызов функции не скомпилируется.

НЕКОРРЕКТНЫЙ КОД
int main ()
{
vector<int> a, b, c;
triangleArea( a, b, c );
}

Компилятор дает точную информацию о том, какие операции не поддер-


живает тип vector<int>.
template_compile.cc: In function 'T compute_equation(T, T, T) [with T =
std::vector<int, std::allocator<int> >]':
template_compile.cc:13: instantiated from here
template_compile.cc:5: error: no match for 'operator*' in 'base * height'

Это длинное сообщение можно разделить на части. Его первая строка со-
общает, в какой шаблонной функции возникла ошибка (triangleArea),
а вторая строка — в какой строке кода вы пытались воспользоваться
этой шаблонной функцией (как правило, интересует именно это). Фраза
«instantiated from here» (созданный здесь экземпляр) означает «место, где
вы пытались воспользоваться шаблоном». В данном случае создание эк-
земпляра подразумевает попытку реализации шаблона compute_equation
с параметром vector<int>.
В следующих строках сообщения описывается причина неудачи при ком-
пиляции: no match for 'operator*' in 'base * height' ('operator*' отсутствует
в 'base * height'). Это означает, что компилятор не смог определить, как
перемножить переменные base и height (оператор * для векторов не
определен). Поскольку обе переменные — векторы, можно догадаться, что
векторы нельзя перемножать1.

Возможно, вас удивляет, что компилятор ничего не написал о сложении век-


1

торов. Добравшись до этой операции, он выдал бы ошибку, однако обнаружив


проблему с умножением раньше, он и не стал заниматься сложением.
418    Глава 29. Шаблоны в C++

Другими словами, вектор не является числом; он «не выглядит как число,


не ходит как число и не разговаривает как число». При использовании ша-
блонной функции компилятор определяет, можно ли использовать внутри
нее тип, который ей передан. Компилятор интересует лишь поддержка
конкретных методов и операций, в которых участвует тип; тип должен
«выглядеть» надлежащим образом.
Утиная типизация существенно отличается от полиморфных функций; по-
лиморфная функция принимает указатель на интерфейсный класс и может
вызывать только методы, которые в нем определены. Шаблон не обязан
быть совместимым ни с каким конкретным интерфейсом. Если параметр
шаблона можно использовать, как написано в функции, функция скомпи-
лируется. Другими словами, если тип шаблона «выглядит как утка, ходит
как утка и разговаривает как утка», шаблон воспринимает его как утку.
Эту аналогию не следует воспринимать буквально, но, надеюсь, теперь
понятно, почему говорят, что шаблоны используют утиную типизацию:
все, что требуется от типов данных — поддерживать методы, необходимые
для работы с шаблоном.

Шаблонные классы
Шаблонные классы часто используются программистами, которые пишут
библиотеки и хотят сами создавать классы вроде vector и map. Тем не менее
обобщение кода полезно и при написании обычных программ. Не стоит
пользоваться шаблонами лишь потому, что вы умеете это делать, но по
возможности избавляйтесь от классов, отличающихся друг от друга только
типами своих членов. Скорее всего, вам придется создавать шаблонные
методы чаще, чем шаблонные классы, однако полезно знать, как их ис-
пользовать (например, для реализации собственной структуры данных).
Объявление шаблонного класса очень похоже на объявление шаблонной
функции.
Например, создадим небольшой класс, который обертывает массив1:

В программировании термин обертывание описывает ситуации, когда одна


1

функция вызывает другую, которая делает большую часть требуемых действий


и небольшую дополнительную работу, например записывает информацию
в журнал или проверяет ошибки. Тогда говорят, что основной метод использу-
ется для реализации внешнего метода, а внешний метод обертывает основной
метод.
Шаблонные классы    419

template <typename T> class ArrayWrapper


{
private:
T *_p_mem;
};

Как и при создании шаблонной функции, сначала мы объявляем шаблон


с помощью ключевого слова template, а затем добавляем список параметров.
В данном случае шаблон имеет единственный параметр T.
Мы можем использовать тип T всюду, так же как в шаблонной функции.
Синтаксис шаблона необходим и при определении функции шаблонного
класса. Допустим, мы хотим добавить конструктор в шаблон ArrayWrapper:
template <typename T> class ArrayWrapper

{
public:
ArrayWrapper (int size);

private:
T *_p_mem;
};

// теперь для определения конструктора за пределами класса


// необходимо сначала обозначить его как шаблон
template <typename T>
ArrayWrapper<T>::ArrayWrapper (int size)
: _p_mem( new T[ size ] )
{ }

Мы задаем стандартное начало шаблона и заново объявляем шаблонный


параметр. Единственное отличие этого кода от предыдущего заключается
в том, что имя класса включает шаблон (ArrayWrapper<T>), откуда ясно,
что это часть шаблонного класса, а не шаблонная функция нешаблонного
класса с названием ArrayWrapper.
В этой реализации метода, как и в шаблонных функциях, можно ис-
пользовать шаблонный параметр как замену передаваемого типа данных.
В отличие от шаблонной функции, вызывающее окружение этого метода
никогда не обязано указывать шаблонный параметр — он заимствуется
из начального объявления типа шаблона. Например, чтобы узнать размер
вектора целых чисел, пишем не vec.size<int>() или vec<int>.size(),
а vec.size().
420    Глава 29. Шаблоны в C++

Рекомендации по работе с шаблонами


Как правило, проще сначала создать класс для конкретного типа данных,
а затем переписать его в виде шаблона. Например, вы объявляете класс,
используя целые числа, а затем создаете шаблон на основе написанного
кода. Уверенно владея разработкой шаблонов, можно не следовать этой
рекомендации, но при написании первых шаблонов она отделяет проблемы
с синтаксисом шаблонов от проблем с алгоритмами.
Например, рассмотрим простой класс калькулятора, который сначала
работает только с целыми числами.
class Calc
{
public:
Calc ();
int multiply (int x, int y);
int add (int x, int y);
};

Calc::Calc ()
{}

int Calc::multiply (int x, int y)


{
return x * y;
}

int Calc::add (int x, int y)


{
return x + y;
}

Этот небольшой класс очень хорошо работает с целыми числами. Теперь


можно преобразовать его в шаблон и создавать калькуляторы для нецелых
типов данных.

Пример 73: calc.cpp


template <typename Type>
class Calc
{
public:
Calc ();
Type multiply (Type x, Type y);
Type add (Type x, Type y);
};
Рекомендации по работе с шаблонами    421

template <typename Type> Calc<Type>::Calc ()


{}

template <typename Type> Type Calc<Type>::multiply (


Type x,
Type y
)
{
return x * y;
}

template <typename Type> Type Calc<Type>::add (


Type x,
Type y
)
{
return x + y;
}

int main ()
{
// демонстрируем объявление
Calc<int> c;
}

Чтобы создать этот код, пришлось внести несколько изменений в преды-


дущий — мы объявили шаблон с типом Type:
template <typename Type>

Затем мы добавили объявление этого шаблона перед определениями класса


и каждой функции:
template <typename Type> class Calc
template <typename Type> int Calc::multiply (int x, int y)

Кроме того, мы изменили определения всех функций, указав, что они


входят в состав шаблонного класса:
template <typename Type> int Calc<Type>::multiply (
int x,
int y
)

Наконец, мы везде заменили тип int на Type:


template <typename Type> Type Calc<Type>::multiply (
Type x,
Type y
)
422    Глава 29. Шаблоны в C++

Привыкнув пользоваться шаблонами, вы будете механически преобразо-


вывать класс, определенный для конкретного типа данных, в шаблонный
класс, работающий со многими типами. С течением времени вы освоите
синтаксис шаблонных классов так хорошо, что сможете писать их с нуля
без всякого вспомогательного кода.

Шаблоны и заголовочные файлы


До настоящего момента мы рассматривали шаблоны, которые размещались
непосредственно в файлах .cpp. Что произойдет, если мы захотим объявить
шаблон в заголовочном файле? Проблема в том, что код, использующий
шаблонную функцию или класс, должен иметь доступ ко всему опреде-
лению шаблона для каждого вызова шаблонной функции и для каждого
вызываемого метода шаблонного класса. В этом существенное отличие
шаблонов от обычных функций, для работы с которыми вызывающему
окружению достаточно знать их объявления. Например, поместив класс
Calc в отдельный заголовочный файл, полное определение конструктора и
метода add придется поместить в этот же заголовочный файл, а не в файл
.cpp, как вы привыкли. В противном случае любая попытка воспользоваться
Calc закончится неудачей.

Эта неприятная особенность шаблонов связана с механизмом их компи-


ляции: как правило, при первом разборе программы компилятор игнори-
рует шаблоны. Он генерирует код шаблона, лишь когда вы подставляете
в него конкретный тип данных (скажем, тип int, с помощью обращения
Calc<int>). Для генерации кода большинству компиляторов необходим
шаблон, а для этого в каждый файл, использующий шаблон, требуется
включить весь его код. Более того, компилируя файл, содержащий шаблон,
вы можете не знать о синтаксических ошибках в нем, пока кто-нибудь не
воспользуется этим шаблоном в первый раз.
Простейший подход при создании шаблонного класса — поместить все
определения шаблона в заголовочный файл. Чтобы показать, что файл
содержит шаблон, можно присвоить ему файлу расширение, отличное от
.h (например, .hxx).

Заключение о шаблонах
Шаблоны позволяют создавать обобщенный код, работающий с любыми
типами данных, а не с конкретным типом, например int. Шаблоны часто
используются при разработке библиотек C++ (например, стандартной
библиотеки шаблонов). Скорее всего, вам нечасто придется создавать
Заключение о шаблонах    423

шаблоны, тем не менее рекомендуется обнаруживать фрагменты кода оди-


наковой структуры, но работающие с разными типами данных: например,
цикл, который выполняет одни и те же действия над различными видами
векторов. На самом деле необходимость использования шаблона часто воз-
никает при работе с типом данных, который уже оформлен в виде шаблона
(например, с контейнерами STL).
Допустим, вы пишете две функции, одна из которых складывает все числа,
а другая объединяет все строки, хранящиеся в векторе. Структура этих
функций одинакова: они обходят вектор и используют оператор + , но
работают с разными типами данных. Видя такой код, следуйте принципу
«не повторять одно и то же». Встретив код, выполняющий одни и те же
действия над разными типами данных, воспользуйтесь шаблоном, а не
реализуйте эти действия дважды.

Диагностика сообщений об ошибках в шаблонах


Недостаток шаблонов в том, что при их некорректном использовании
большинство компиляторов выводит сложные сообщения об ошибках, даже
если не вы автор шаблона (например, когда вы используете STL). Одна
ошибка может стать причиной появления целой страницы с сообщениями,
трудно воспринимаемыми, поскольку в них отображаются параметры ша-
блонов с полными типами (в том числе параметры, которыми вы обычно
не пользуетесь, поскольку они заданы по умолчанию).
Для примера рассмотрим объявление вектора, выглядящее вполне без-
обидно:
vector<int, int> vec;

В этом объявлении есть одна маленькая проблема — оно должно включать


только один шаблонный параметр. Однако при попытке его компиляции
получим огромное число ошибок:
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h: In instantiation of 'std::_Vector_base<int, int>':
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:159: instantiated from 'std::vector<int, int>'
template_err.cc:6: instantiated from here
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:78: error: 'int' is not a class, struct, or union type
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:95: error: 'int' is not a class, struct, or union type
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:99: error: 'int' is not a class, struct, or union type
продолжение 
424    Глава 29. Шаблоны в C++

/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h: In instantiation of 'std::_Vector_base<int, int>::_
Vector_impl':
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:123: instantiated from 'std::_Vector_base<int, int>'
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:159: instantiated from 'std::vector<int, int>'
template_err.cc:6: instantiated from here
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:82: error: 'int' is not a class, struct, or union type
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:86: error: 'int' is not a class, struct, or union type
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h: In instantiation of 'std::vector<int, int>':
template_err.cc:6: instantiated from here
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:161: error: 'int' is not a class, struct, or union type
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:193: error: no members matching 'std::_Vector_base<int,
int>::_M_get_Tp_allocator' in 'struct std::_Vector_base<int, int>'
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h: In destructor 'std::vector<_Tp, _Alloc>::~vector()
[with _Tp = int, _Alloc = int]':
template_err.cc:6: instantiated from here
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:272: error: '_M_get_Tp_allocator' was not declared in
this scope
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h: In constructor 'std::_Vector_base<_Tp, _Alloc>::_
Vector_base(const _Alloc&) [with _Tp = int, _Alloc = int]':
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:203: instantiated from 'std::vector<_Tp, _
Alloc>::vector(const _Alloc&) [with _Tp = int, _Alloc = int]'
template_err.cc:6: instantiated from here
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:107: error: no matching function for call to 'std::_
Vector_base<int, int>::_Vector_impl::_Vector_impl(const int&)'
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:82: note: candidates are: std::_Vector_base<int,
int>::_Vector_impl::_Vector_impl(const std::_Vector_base<int, int>::_
Vector_impl&)
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h: In member function 'void std::_Vector_base<_Tp, _
Alloc>::_M_deallocate(_Tp*, size_t) [with _Tp = int, _Alloc = int]':
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:119: instantiated from 'std::_Vector_base<_Tp, _
Alloc>::~_Vector_base() [with _Tp = int, _Alloc = int]'
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:203: instantiated from 'std::vector<_Tp, _
Alloc>::vector(const _Alloc&) [with _Tp = int, _Alloc = int]'
Заключение о шаблонах    425

template_err.cc:6: instantiated from here


/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:133: error: 'struct std::_Vector_base<int, int>::_
Vector_impl' has no member named 'deallocate'

Что здесь происходит?! Откуда взялось это сообщение об ошибке? Про-


блема в том, что у вектора есть второй параметр — шаблон по умолчанию,
и компилятор, как правило, подставляет его автоматически. В данном
случае вы указали второй параметр типа int, и компилятор попытался
подставить его на место второго шаблонного параметра, который не мо-
жет быть типа int. Компилятор информирует об этом в одной из первых
выведенных ошибок:
error: 'int' is not a class, struct, or union type
(ошибка: тип 'int' не является классом, структурой или объединением)

Код шаблона пытается выполнить над параметром действие, неприменимое


к целому числу. Например, если есть код
template <typename T>
class Foo
{
Foo ()
{
T x;
x.val = 1;
}
};

параметр T не может быть целочисленным, поскольку переменная x типа T


должна иметь поле с именем val, а целые числа вообще не имеют полей.
Если мы напишем
Foo<int> a;

то код не скомпилируется.
Мы снова имеем дело с утиной типизацией (см. с. 416): шаблон интересу-
ется не конкретным типом данных, а тем, как этот тип вписывается в код.
В данном случае целое число не поддерживает синтаксис x.val, и компи-
лятор отказывается его обрабатывать.
Похожее ограничение действует на второй параметр шаблона vector —
у него должен быть тип, который поддерживает более широкую функ-
циональность, чем простое целое число. Приведенные выше сообщения
об ошибках по-разному жалуются на то, тип int не подходит к этому
параметру шаблона!
426    Глава 29. Шаблоны в C++

Получив столь массивный текст, лучше всего, как обычно, начать с перво-
го сообщения и последовательно исправить ошибки. Ниже приведены
фрагменты текста до слова error.
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h: In instantiation of 'std::_Vector_base<int, int>':
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:159: instantiated from 'std::vector<int, int>'
template_err.cc:6: instantiated from here
/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/
bits/stl_vector.h:78: error: 'int' is not a class, struct, or union type

Выглядит гораздо лучше, не так ли? Этот текст состоит всего лишь из не-
скольких строк — почти как в разделе «Утиная типизация» (с. 416). Мы
вполне способны с ним разобраться!
Рассмотрим первую строку: In instantiation of std::_Vector_base<int,
int>. (При создании экземпляра std::_Vector_base<int, int>.) Словосо-
четание «при создании экземпляра шаблона» означает «при попытке
скомпилировать шаблон с конкретным набором параметров». Эта ошибка
появилась потому, что при создании шаблона с заданными параметрами
возникла проблема (_Vector_base представляет собой вспомогатель-
ный класс реализации вектора). Вторая строка показывает, что шаблон
Vector_base не удалось скомпилировать при попытке создать шаблон
vector<int,int>, и ошибка произошла в строке 6 файла template_err.cc.
Поскольку файл template_err.cc содержит наш код, мы знаем, какая его
часть вызвала ошибку.
Первое действие при поиске ошибки — обнаружить строку кода, которая
вызывает проблему. Чтобы понять причину ошибки, часто достаточно
просто взглянуть на код. Если эта причина неочевидна, можно просмо-
треть список создания экземпляров и в конечном счете увидеть реальное
сообщение об ошибке: error: 'int' is not a class, struct, or union
type (ошибка: тип 'int' не является классом, структурой или объеди-
нением). Оно говорит, что компилятор ожидал использование класса или
структуры, а не встроенного типа данных вроде int. Поскольку векторы
должны уметь хранить данные любого типа, можно предположить, что про-
блема связана с шаблонным параметром, переданным вектору. Если еще
раз проверить объявление вектора, увидим, что необходимо использовать
только один шаблонный параметр.
Теперь, когда мы диагностировали первую проблему, необходимо устра-
нить ее и заново скомпилировать программу. Обычно удается одновре-
менно обнаружить как минимум две ошибки, однако при использовании
Проверьте себя    427

шаблонов одна ошибка часто влечет за собой все остальные. Исправив ее,
вы избавляетесь и от других ошибок, которые устраняются автоматически.
В приведенном выше примере многочисленные ошибки, сообщения о ко-
торых заняли целую страницу, были вызваны единственной причиной —
использованием второго параметра int в шаблоне.

Проверьте себя
1. Для чего следует использовать шаблоны?
А. Для экономии времени.
Б. Для ускорения выполнения кода.
В. Для написания одного и того же кода для разных типов данных.
Г. Для возможности повторного использования кода в будущем.
2. Когда необходимо указывать тип параметра шаблона?
А. Всегда.
Б. Только при объявлении экземпляра класса шаблона.
В. Только если тип нельзя определить автоматически.
Г. В шаблонных функциях, при условии, что тип нельзя определить
автоматически, и всегда для классов шаблона.
3. Как компилятор определяет, можно ли применить шаблонный параметр
к конкретному шаблону?
А. Он реализует определенный интерфейс C++.
Б. Следует задать соответствующие ограничения при объявлении ша-
блона.
В. Он пытается использовать шаблонный параметр: если тип шаблона
поддерживает все требуемые операции, параметр принимается.
Г. Следует перечислить все допустимые типы шаблонов при его объ-
явлении.
4. В чем различие размещения в заголовочном файле класса шаблона
и обычного класса?
А. Различий нет.
Б. Методы обычного класса нельзя определять в заголовочном файле.
В. Все методы класса шаблона должны быть определены в заголовочном
файле.
Г. Класс шаблона, в отличие от обычного класса, может не иметь файла
.cpp.
428    Глава 29. Шаблоны в C++

5. Когда следует делать функцию шаблонной?


А. Сразу же: следует делать шаблонными все методы, поскольку неиз-
вестно, когда потребуется применить написанный алгоритм к другому
типу данных.
Б. Только если не удается привести данные к какому-либо из типов,
с которыми работает текущая функция.
В. Чтобы применить уже реализованный алгоритм, работающий с одним
типом данных, к другому типу данных с похожими свойствами.
Г. Если две функции делают почти одно и то же, и надо слегка изменить
логику их работы при помощи дополнительных логических параметров.
6. Когда вы узнаете о большинстве ошибок в коде шаблона?
А. Сразу после компиляции шаблона.
Б. В процессе компоновки.
В. Во время выполнения программы.
Г. При первой компиляции кода, который создает экземпляр шаблона.
(Решения см. на с. 474.)

Практические задания
1. Напишите функцию, которая принимает вектор и суммирует все его
значения независимо от типа хранимых числовых данных.
2. Переработайте класс, заменяющий вектор, реализованный в одной из
практических задач главы 25, и сделайте его шаблоном, который спо-
собен хранить данные любого типа.
3. Напишите метод поиска, который принимает вектор и значение любого
типа и возвращает true, если значение присутствует в векторе, и false
в противном случае.
4. Реализуйте функцию сортировки, которая принимает вектор любого
типа и сортирует его значения в естественном порядке (который фор-
мируется при использовании операторов <or>).
Ч а с ть I V
Дополнительная информация

Вы уже умеете пользоваться инструментами для создания больших и ин-


тересных программ, но осталось несколько важных тем, не вошедших в ос-
новную часть этой книги, например считывание аргументов из командной
строки и форматирование входных и выходных данных. Данные темы
относятся больше к пользовательскому интерфейсу, чем к алгоритмам,
однако это ничуть не умаляет их важность. Программа, которая не взаи-
модействует с пользователем, никому не интересна!
Вы можете изучать главы этой части в любом порядке. Возможно, вы даже
захотите ознакомиться с ними раньше, чем закончите читать предыдущие
части книги.
Г лава 3 0
Форматирование выводимых данных
с помощью iomanip

Пользователи обязательно захотят, чтобы программа выводила информа-


цию в красивом виде. (И только потом — чтобы она работала!) Язык C++
может выводить данные в удобном для восприятия формате с помощью
объекта cout и функций заголовочного файла iomanip.

Работа в ограниченном пространстве


Самая распространенная проблема форматирования — неудачная компо-
новка. Выводимые данные должны красиво выглядеть. Длина текстовых
столбцов должна быть не слишком маленькой и не слишком большой,
а все данные должны быть выровнены надлежащим образом. Сейчас мы
узнаем, как этого добиться.

Управление шириной с помощью метода setw


Функция setw позволяет задать минимальную ширину для вывода. Если
ширина выводимых данных не превышает минимальную, данные раз-
деляются свободным пространством; в противном случае при выводе не
выполняется никаких дополнительных действий (данные не обрезаются).
Функция setw выглядит несколько странно: возвращаемое значение пере-
дается объекту cout.
Работа в ограниченном пространстве    431

Пример 74: setw.cpp


#include <iostream>
#include <iomanip>

using namespace std;

int main()
{
cout << setw( 10 ) << "ten" << "four" << "four";
}

Результат работы программы:


tenfourfour

Если функция setw не передается объекту cout, ничего не происходит. Как


видно из примера, действие setw распространяется только на следующую
операцию вывода.
По умолчанию строки выравниваются по правому краю (пустое простран-
ство остается слева от текста), другими словами, символ-заполнитель рас-
полагается перед выводимой строкой. Выравниванием можно управлять
(влево или вправо), передавая объекту cout параметр left или right со-
ответственно. В следующем примере текст выравнивается как по левому,
так и по правому краю.

Пример 75: setw_left.cpp


#include <iostream>
#include <iomanip>

using namespace std;

int main()
{
cout << setw( 10 ) << left << "ten" << "four" << "four";
}

Результат работы программы:


ten fourfour

Функция setw позволяет изменять ширину столбца в процессе выполне-


ния программы. Например, при выводе нескольких строк данных можно
узнать размер самой длинной и указать ширину чуть больше полученного
значения.
432    Глава 30. Форматирование выводимых данных с помощью iomanip

Изменение символа-заполнителя
Иногда в качестве заполнителя необходимо использовать не пробел,
а другой символ. Чтобы указать заполнитель, следует воспользоваться
методом setfill. Метод setfill, как и setw, передается непосредственно
объекту cout.
Добавим в исходный пример метод setfill, устанавливающий в качестве
заполнителя дефис:
cout << setfill( '-' ) << setw( 10 ) << "ten" << "four"
<< "four";

Результат программы:
-------tenfourfour

Изменение глобальных настроек


Функция-член fill объекта cout позволяет изменить глобальную настрой-
ку, определяющую символ-заполнитель. Например, код
cout.fill( '-' );

cout << setw( 10 ) << "A" << setw( 10 ) << "B"


<< setw( 10 ) << "C" << endl;

дает результат:
---------A---------B---------C

Метод fill возвращает предыдущий заполнитель, чтобы его можно было


восстановить. Это позволяет не вызывать метод setfill многократно, если
вы используете метод fill, например:
const char last_fill = cout.fill( '-' );

cout << setw( 10 ) << "A" << setw( 10 ) << "B"


<< setw( 10 ) << "C" << endl;

cout.fill( last_fill );

cout << setw( 10 ) << "D" << endl;

Теперь результат будет иметь вид:


D
Обобщим информацию о iomanip    433

Метод setf объекта cout управляет глобальными настройками вырав-


нивания текста. Функция setf принимает флаги ios_base::left и ios_
base::right, задающие выравнивание влево и вправо соответственно1.
cout.setf( ios_base::left );

Этот вызов, как и метод fill, возвращает предыдущее значение, чтобы его
было возможно восстановить.
Добавьте приведенный вызов setf в предыдущий пример и оцените раз-
личия форматирования.

Обобщим информацию о iomanip


Теперь мы сведем изученные методы воедино и напишем код, который
выведет имена и фамилии в два столбца, аккуратно их выравнивая:
Joe Smith
Tonya Malligans
Jerome Noboggins
Mary Suzie-Purple

Нам нужно указать подходящую ширину столбцов — чуть больше самого


длинного элемента. Можно определить размер этого элемента в цикле и
передать его методу setw (добавив чуть-чуть символов-заполнителей). Код,
реализующий эти действия, выглядит следующим образом.

Пример 76: column_alignment.cpp


#include <iostream>
#include <vector>
#include <iomanip>
#include <string>

using namespace std;

struct Person
{
Person (
const string& firstname,
const string& lastname
)
: _firstname( firstname )
, _lastname( lastname )
{} продолжение 

setf — сокращение «set flag» (установить флаг).


1
434    Глава 30. Форматирование выводимых данных с помощью iomanip

string _firstname;
string _lastname;
};

int main ()
{
vector<Person> people;

people.push_back( Person( "Joe", "Smith" ) );


people.push_back( Person( "Tonya", "Malligans" ) );
people.push_back( Person( "Jerome", "Noboggins" ) );
people.push_back( Person( "Mary", "Suzie-Purple" ) );

int firstname_max_width = 0;
int lastname_max_width = 0;

// определение максимальных значений ширины

for ( vector<Person>::iterator iter = people.begin();


iter != people.end();
++iter )
{
if ( iter->_firstname.length()
>
firstname_max_width )
{
firstname_max_width =
iter->_firstname.length();
}
if ( iter->_lastname.length()
>
lastname_max_width )
{
lastname_max_width =
iter->_lastname.length();
}
}

// вывод элементов вектора


for ( vector<Person>::iterator iter = people.begin();
iter != people.end();
++iter )
{
cout << setw( firstname_max_width ) << left
<< iter->_firstname;
cout << " ";
cout << setw( lastname_max_width ) << left
<< iter->_lastname;
cout << endl;
}
}
Обобщим информацию о iomanip    435

Вывод чисел
Выводимые численные данные также необходимо правильно оформлять.
Шестнадцатеричные значения желательно предварять префиксом «0x»,
указывая на систему счисления. Кроме того, стоит подумать о необходи-
мом количестве разрядов после десятичной точки (например, 2, если ваше
приложение работает с «деньгами»).

Точность вывода чисел задается в методе setprecision


Функция setprecision определяет максимальное количество знаков, ото-
бражаемых при выводе числа. Ее, как и метод setw, необходимо вставлять
в поток. На самом деле принципы использования setprecision и setw
схожи. Чтобы вывести число 2.71828, ограничившись тремя цифрами,
следует написать
std::cout << setprecision( 3 ) << 2.71828;

setprecision надлежащим образом округлит выводимое число, поэтому


вместо усеченного 2.71 будет выведено округленное 2.72. Если же вы за-
хотите вывести 2.713, то получите 2.71.
В отличие от большинства других команд, которые работают с потоками,
метод setprecision изменяет точность представления чисел до своего сле-
дующего использования. Если мы изменим предыдущий пример, написав
cout << setprecision( 3 ) << 2.71828 << endl;
cout << 1.412 << endl;

получим результат:
2.72
1.41

Возможно, вас интересует, что произойдет, если попытаться вывести число


с количеством цифр перед десятичной точкой, превышающим точность,
заданную в методе setprecision. Результат зависит от типа числа — цело-
го или с плавающей точкой. Целое число выводится полностью, а число с
плавающей точкой — в научной нотации с соответствующим количеством
цифр. Код
cout << setprecision( 2 ) << 1234.0 << endl;

генерирует результат
1.2e3

Запись e3 эквивалентна *103.


436    Глава 30. Форматирование выводимых данных с помощью iomanip

Код
cout << setprecision( 2 ) << 1234 << endl;

генерирует результат
1234

Что делать с денежными величинами?


Возможно, вы обратили внимание, что пока не знаете подходящего способа
отображения денежных величин — значений, которые, как правило, имеют
два знака после запятой и никогда не округляются.
Говоря кратко, денежные величины не следует хранить в переменных типа
double! Поскольку значения double не обладают достаточной точностью,
возникают ошибки округления, а следовательно, искажения в долях цен-
тов. В большинстве приложений наилучший способ хранения денежных
величин — центы. Для идеальной точности, выводя денежную величину,
следует разделить ее на 100 и получить количество долларов, затем вос-
пользоваться оператором взятия по модулю, чтобы определить количество
центов, и вывести два полученных значения по отдельности.
int cents = 1001; // $10.01
cout << cents / 100 << "." << setw(2) << setfill('0')
<< cents % 100;

Разумеется, стоит создать стандартные вспомогательные функции, которые


выполняют эти расчеты, и класс, который представляет денежные значения
и скрывает детали формата.

Вывод значений в различных системах счисления


В процессе программирования часто приходится отображать числа в вось-
меричной и шестнадцатеричной системах счисления. Для этого можно
воспользоваться функцией setbase. Функция setbase задает основание
системы счисления: 8, 10 или 16. Например, код
cout << "0x" << setbase(16) << 32 << endl;

генерирует результат
0x20

который соответствует значению 32 в шестнадцатеричной системе. Обра-


тите внимание, что при вставке в поток можно использовать сокращения
dec, oct и hex вместо setbase(10), setbase(8) и setbase(16) соответственно.
Обобщим информацию о iomanip    437

В приведенном коде префикс 0x выводится явно, но можно воспользоваться


методом setiosflags, который дает объекту cout указание автоматически
выводить основание системы счисления. Если передать результат метода
setiosflags(ios_base::showbase) объекту cout, десятичные числа будут
отображаться без префикса, шестнадцатеричные числа — с префиксом 0x,
а восьмеричные — с префиксом 0. Код
cout << setiosflags(ios_base::showbase) << setbase(16)
<< 32 << endl;

дает
0x20

Метод setiosflags, как и метод setprecision, управляет постоянными


настройками. Передав ему аргумент noshowbase, можно отключить ото-
бражение префикса.
Теперь в вашем распоряжении целый арсенал инструментов, которые позво-
ляют сделать выводимые данные гораздо более понятными и красивыми!
Г лава 3 1
Исключения и отчеты об ошибках

С ростом размера программ требуется эффективный метод обработки


ошибок. Существуют два классических способа оповещения об ошибках:
коды ошибок и исключения. Использование кодов ошибок не требует
никаких дополнительных усилий в программировании, но все функции,
способные вызвать ошибку, должны возвращать код ошибки (или успеш-
ного завершения), который характеризует результат выполнения функции.
Преимущество этого метода — относительная простота:
int failableFunction ();

const int result = failableFunction();

if ( result != 0 )
{
cout << "Function call failed: " << result;
}

С другой стороны, у такой обработки есть недостаток: каждая функция


должна возвращать код ошибки, даже если необходимы другие данные.
Значение, которое вычислила функция, придется возвращать через ее
параметр (ссылку или указатель):
int failableFunction (int& out_val);

int res_val;

const int result = failableFunction( res_val );


Исключения и отчеты об ошибках    439

if ( result != 0 )
{
cout << "Function call failed: " << result;
}
else
{
// используем res_val для каких-либо действий
}

Такой подход работает, но код имеет несколько противоестественный вид.


С другой стороны, исключения — новая возможность языка. Принцип
их действия: функция, которая хочет сообщить об ошибке, немедленно
прекращает выполнение и генерирует исключение. Программа ищет об-
работчик этого исключения.
Чтобы понять механизм работы исключений, представьте, что функция за-
вершается, не возвращая значение, и передает управление не вызвавшему
ее окружению, а коду, который обрабатывает исключение. Если такого кода
нет, программа аварийно останавливается из-за необработанного исклю-
чения, в противном случае выполняется код обработчика. Исключения
позволяют написать код, который будет вызываться при любой ошибке
и обрабатывать все ошибки сразу.
Чтобы указать место, в которое должна передать управление функция при
ошибке, используется блок try/catch:
try
{
// код, генерирующий исключение при ошибке
}
catch(...)
{
// место, где обрабатывается ошибка
// (куда функция передает управление)
}

Любая функция в блоке try способна генерировать исключения, которые


обрабатываются в блоке catch. Существуют различные типы исключений,
что позволяет создавать несколько блоков catch, обрабатывающих ошибки
конкретного типа. Блок catch..., как в приведенном коде, будет работать
с любым исключением, не перехваченным другим, более специализиро-
ванным блоком catch. Многоточие показывает, что блок перехватывает
все исключения. Поскольку исключение обрабатывается первым блоком
catch, способным его обработать, универсальный обработчик следует по-
местить в конце:
440    Глава 31. Исключения и отчеты об ошибках

try
{
// код, генерирующий исключение при ошибке
}
catch(const FileNotFoundException& e)
{
// обработка ошибок
// невозможности найти файл
}
catch(const HardDriveFullException& e)
{
// обработка ошибок нехватки
// свободного места на диске
}
catch(...)
{
// обработка всех остальных ошибок
// (т. е. место, куда функция возвращает управление)
}

Освобождение ресурсов при исключениях


При вызове функции, генерирующей исключение, необязательно его
перехватывать — исключение начнет распространяться и, возможно, обна-
ружится блоком catch в функции более высокого уровня. Это абсолютно
правильно, если на исключение не нужно реагировать. На самом деле ре-
агировать на исключение часто не требуется, поскольку при завершении
функции из-за исключения вызываются деструкторы всех ее локальных
объектов. Например:
int callFailableFunction()
{
const string val("abc");
// вызов кода, генерирующего исключение
failableFunction();
}

int main()
{
try
{
callFailableFunction();
}
catch(...)
{
// обработка ошибки
}
}
Исключения и отчеты об ошибках    441

Если функция failableFunction генерирует исключение, строка val ,


созданная в функции callFailableFunction, уничтожается, а ресурсы,
выделенные для ее хранения, освобождаются. Этот механизм называется
очисткой стека: каждый стековый фрейм, не перехватывающий исклю-
чение, очищается или возвращается в исходное состояние, с помощью
деструкторов, вызываемых для каждого объекта фрейма. Запомните:
если вы не создали деструктор явно, то вызывается деструктор объекта
по умолчанию, который и выполнит очистку.

Очистка ресурсов вручную в блоке catch


Иногда при генерации исключения требуется очищать ресурсы вручную.
Как правило, для очистки ресурсов создается контрольный объект, но если
это невозможно, можно перехватить исключение, очистить ресурс, а затем
повторно генерировать исключение. Пример:
int callFailableFunction()
{
const int* val = new int;
// вызов кода, генерирующего исключение
try
{
failableFunction();
}
catch(...)
{
delete val;
// обратите внимание на повторную генерацию исключения с помощью
throw;
throw;
}
// Здесь мы тоже должны воспользоваться оператором delete.
// Если исключение не генерируется, блок catch не выполняется.
// Единственный способ гарантировать выполнение кода
// всегда - поместить его в деструктор локального объекта.
delete val;
}

int main()
{
try
{
callFailableFunction();
}
catch(...)
{
// обработка ошибки
}
}
442    Глава 31. Исключения и отчеты об ошибках

Создание исключений
Вы уже видели много примеров, иллюстрирующих перехват и обработку
исключения. Но как сгенерировать исключение самостоятельно? В этом
нет ничего сложного, поскольку исключение представляет собой обычный
класс. Так что заполняем поля по собственному усмотрению и предоставля-
ем методы для чтения информации об исключении. Интерфейс типичного
исключения выглядит так:
class Exception
{
public:
virtual ~Exception() = 0;
virtual int getErrorCode() = 0;
virtual string getErrorReport() = 0;
};

Все конкретные типы ошибок являются наследниками класса Exception


и реализуют следующие виртуальные методы:
class FileNotFoundException : public Exception
{
public:

FileNotFoundException(
int err_code,
const string& details
)
: _err_code(err_code)
, _details(details)
{}

virtual ~FileNotFoundException()
{}

virtual int getErrorCode()


{
return _err_code;
}

virtual string getErrorReport()


{
return _details;
}

private:

int _err_code;
string _details;
};
Исключения и отчеты об ошибках    443

Реализовав эти классы, можно создавать исключения так, как будто вы


создаете экземпляр соответствующего класса:
throw FileNotFoundException(1, "File not found");

Преимущество наследования всех исключений из одного базового класса в


том, что исключения могут перехватываться суперклассом. Можно написать:
catch(const Exception& e)
{
}

Этот блок перехватывает все исключения, которые являются наследниками


класса Exception. Тщательно продуманная иерархия исключений позволяет
создавать код, способный обрабатывать множество ошибок в одном блоке
catch. Например, если сделать все ошибки ввода и вывода наследниками
класса IOException, можно обрабатывать все исключения ввода-вывода
в одном месте программы и при этом иметь возможность перехватывать
конкретные подклассы IOException в особых случаях, требующих индиви-
дуальной обработки. В стандартной библиотеке есть суперкласс исключе-
ния для встроенных конструкций C++ — std::exception. Необязательно
порождать от него собственную иерархию исключений, но, работая со стан-
дартной библиотекой, есть смысл использовать std::exception как общий
базовый класс, перехватывающий все исключения, которые генерируются в
программе как стандартной библиотекой, так и вашим собственным кодом.

Спецификация исключений
Генерировать и перехватывать исключения можно при появлении ошибок,
но как узнать, создает ли исключения конкретная функция? В C++ можно
задать исключения, которые может генерировать функция, с помощью
спецификации исключений. Спецификация исключений представляет
собой список исключений (возможно, пустой) в конце объявления и опре-
деления функции.
В заголовочном файле:
void canFail() throw(FileNotFoundException);
void cannotFail() throw();

В файле cpp:

void canFail() throw(FileNotFoundException)


{
throw FileNotFoundException();
} продолжение 
444    Глава 31. Исключения и отчеты об ошибках

void cannotFail () throw ()


{
}

Проблема в том, что спецификации исключений проверяются не во время


компиляции программы, а в процессе ее выполнения. Более того, если
функция генерирует исключение, которое отсутствует в спецификации,
это может привести к немедленному завершению программы. Таким об-
разом, рассчитывать на точность спецификации исключений нельзя, но
она, безусловно, будет вызывать аварийные завершения вашей програм-
мы. Некоторые инструменты, например PC-Lint (http://www.gimpel.com/
html/pcl.htm), проверяют исключения на этапе компиляции и сокращают
количество проблем спецификаций исключений. В новом стандарте языка
C++, C++ 11, полные спецификации исключений признаны устаревшими
и, скорее всего, в перспективе будут из языка исключены1.
Из сказанного следует вывод: приходится рассчитывать, что исключения,
которые способна генерировать функция, документированы ее автором.
Разрабатывая функцию, генерирующую исключение, опишите его в до-
кументации.

Преимущества исключений
У исключений есть два важных преимущества. Первое из них в том, что
исключения упрощают логику обработки ошибок, целиком помещая ее
в единственный блок catch и устраняя необходимость выполнения много-
численных проверок кодов ошибок. Второе преимущество в том, что ис-
ключения содержат больше информации об ошибке, чем ее код.
Благодаря первому преимуществу исключений можно преобразовать код
if(funCall1() == ERROR)
{
// обработка ошибки
}
if(funCall2() == ERROR)
{
// обработка ошибки
}
if(funCall3() == ERROR)
{
// обработка ошибки
}

Спецификация позволяет указать, что функция не генерирует исключения:


1

иногда это повышает эффективность программы.


Исключения и отчеты об ошибках    445

в код
try
{
funCall1();
funCall2();
funCall3();
}
catch(const Exception& e)
{
// обработка ошибки
}

Весь код, обрабатывающий ошибки, находится в одном месте, и его логику


очень легко понять.
Очень полезно и второе преимущество исключений — способность предо-
ставлять дополнительную информацию об ошибке. Используя код ошиб-
ки, вы не получаете о ней никаких сведений, кроме собственно ее кода.
Исключение может содержать дополнительную информацию: например,
исключение FileNotFoundException позволяет узнать имя файла, который
не удалось обнаружить.

Неправильное использование исключений


Исключения — отличный метод оповещения об ошибках в программе. Тем
не менее их способность немедленно передавать управление из функции
вызвавшему ее окружению, находящемуся в стеке, может приводить к не-
правильному обращению с исключениями. Не следует использовать исклю-
чения для обработки стандартных, а не ошибочных, ситуаций. Например,
с помощью исключения теоретически можно передать результат функции,
не пользуясь механизмом возврата значения из нее. Тем не менее это дей-
ствие будет значительно более долгим (для обработки сгенерированного
исключения потребуется некоторое время) и внесет путаницу в программу.
Как мы видели в приведенных примерах, исключения упрощают логику
функции, но если пользоваться исключениями для обычных действий, вы
лишитесь этого преимущества.
Рассмотрим анализатор, в котором исключения используются не по
назначению, и перепишем его без их участия. Анализатор считывает
и интерпретирует содержимое, написанное на четко структурированном
языке, например HTML. Анализатор часто содержит функции, которые
выполняют разбор отдельных элементов структуры программы (напри-
мер, ссылок и таблиц).
446    Глава 31. Исключения и отчеты об ошибках

В анализаторе можно реализовать функции parseLink и parseTable, кото-


рые генерируют исключение, если не могут разобрать очередной фрагмент
текста:
try
{
parseLink();
return;
}
catch(const ParseException& e)
{
// не ссылка, пробуем следующий тип
}
try
{
parseTable();
return;
}
catch(const ParseException& e)
{
// не таблица, пробуем следующий тип
}

Проблема в том, что если следующий фрагмент текста не является ни


ссылкой, ни таблицей, это нельзя рассматривать как ошибку. Лучше на-
писать анализатор так:
if(expectLink())
{
parseLink();
}
else if(expectTable())
{
parseTable();
}

Поскольку язык HTML, как правило, задает тип элемента страницы корот-
ким ключевым словом, можно легко написать методы, которые определяют,
является ли следующий элемент ссылкой или таблицей. Теперь вместо
сложных исключений программа использует простые операторы if.

Коротко об исключениях
Исключения — это четко структурированные оповещения об ошибках,
которые позволяют не загромождать код обработкой отдельных некоррект-
ных ситуаций. Благодаря очистке стека и деструкторам, уничтожающим
объекты, исключения позволяют создавать код, большая часть которого
реализует основной алгоритм, а не проверяет коды ошибок.
Исключения и отчеты об ошибках    447

Поскольку генерация исключений снижает производительность програм-


мы, исключения следует использовать только для обработки ошибок, а не
для выполнения обычных действий приложения. Например, анализатор
должен генерировать исключения при считывании заведомо недопустимых
символов, но не в процессе обработки файла с корректным форматом. Это
позволяет легко понимать, какие ситуации на самом деле ошибочны. Кро-
ме того, если обработка исключений выполняется лишь в редких случаях,
когда в программе действительно возникают проблемы, код выполняется
с максимальной производительностью. Серьезные ошибки почти всегда
приводят к завершению алгоритма, поэтому нет ничего страшного в том,
что их обработка происходит медленнее обычного.
Поскольку во многих реальных программах обработка ошибок оказывает
существенное влияние на длительность разработки продуктов, придется
подробнее ознакомиться с исключениями за рамками этой книги.
Г лава 3 2
Заключение

Вы изучили многие возможности языка C++, но путешествие в мир про-


граммирования только начинается. Теперь в вашем распоряжении есть ин-
струменты для создания интересных и сложных программ. Вам предстоит
создавать сложные системы и практиковаться в реализации алгоритмов
и структур данных. Программирование — гораздо более широкая сфера,
чем конкретный язык написания программ, она требует знаний о том, как
проектировать программы, разрабатывать алгоритмы и пользовательские
интерфейсы, выбирать библиотеки для использования в программах, орга-
низовывать работу команды программистов и даже задавать приоритеты
поставленным задачам. Другими словами, вам предстоит еще многое узнать
о разработке программного обеспечения. Разумеется, в этой книге был
затронут целый ряд актуальных тем, но каждая из них требует отдельного
и серьезного изучения.
Чтобы научиться общаться на иностранном языке, недостаточно выучить
азы его грамматики и синтаксиса, а научившись говорить, вы не сможете
сразу сесть и написать отличный роман. Это же касается и программиро-
вания: между созданием приложений на C++ и написанием операционной
системы огромная дистанция. Тем не менее у вас есть важные знания о фун-
даментальных концепциях и идеях, которые позволяют сделать следующий
шаг. Ниже приведены советы, что делать дальше1.

Некоторые из этих советов появились благодаря статье Питера Норвига


1

«Как научиться программировать за десять лет» (Peter Norvig, «Teach Yourself


Programming in Ten Years»): http://norvig.com/21-days.html
Заключение    449

1. Начните читать книги о разработке программного обеспечения и алго-


ритмов. Книга «Жемчужины программирования» Джона Бентли (из-
дательство «Питер») и ей подобные увлекательно описывают аспекты
создания программ, не связанные с языками программирования, в том
числе анализ базовых алгоритмов, проектирование и вычисления.
2. Пишите программы. Сначала займитесь копированием существующих
программ — создавайте клоны инструментов и изучайте нужные для
этого библиотеки. Затем усложните задачу — устройтесь на стажировку
или начните работать в проекте с открытым кодом. Чем больше кода
вы напишете, тем больше совершите ошибок, но только так вы сможете
научиться писать хороший код.
3. Изучайте не только программирование, но и другие дисциплины —
тестирование, управление проектами и продуктами, маркетинг. Чем
лучше вы поймете процесс создания программного обеспечения, тем
более компетентным разработчиком, архитектором или руководителем
вы в конечном счете станете.
4. Общайтесь с другими программистами — работайте вместе с ними
и учитесь у них. Отличные возможности в этом отношении дают ста-
жировка или обучение в университете.
5. Найдите наставника, прошедшего путь, который вы хотите осилить.
Книги не способны ответить на вопросы, о которых не написал автор;
общение с более опытным коллегой поможет преодолеть многие пре-
пятствия. Относитесь к нему с уважением, но не бойтесь задавать во-
просы и не стесняйтесь, что чего-то не понимаете. Отсутствие знаний —
отличный повод их приобрести!
6. Получайте удовольствие от программирования. Невозможно
построить карьеру, если то, что вы делаете, не доставляет радости.
Радуйтесь! Не занимайтесь скучными делами, которые отбивают у вас
желание писать программы.
Конец этой книги — начало вашей карьеры. В добрый путь!
Ответы к разделам «Проверьте себя»

Глава 2
1. Какое значение возвращает операционной системе программа после
успешного выполнения?
А. –1
Б. 1
В. 0
Г. Программы не возвращают значения.
2. Как называется единственная функция, входящая во все программы
на C++?
А. start()
Б. system()
В. main(
Г. program()
3. Какие знаки пунктуации показывают начало и окончание блоков кода?
А. { }
Б. -> и <-
В. BEGIN и END
Г. ( и )
4. Какой знак пунктуации ставится в конце большинства строк кода C++?
А. .
Б. ;
В. :
Г. '
Ответы к разделам «Проверьте себя»    451

5. Какая из приведенных ниже конструкций является правильным ком-


ментарием?
А. */ Comments */
Б. ** Comment **
В. /* Comment */
Г. { Comment }
6. Какой заголовочный файл необходим для доступа к объекту cout?
А. stream
Б. Никакой, поскольку объект cout доступен по умолчанию.
В. iostream
Г. using namespace std;

Глава 3
1. Какой тип переменной следует использовать для хранения числа
3.1415?
А. int
Б. char
В. double
Г. string
2. Какой оператор сравнивает две переменные?
А. :=
Б. =
В. equal
Г. ==
3. Как получить доступ к строковому типу данных?
А. Он встроен в язык, поэтому не нужно предпринимать никаких спе-
циальных действий.
Б. Поскольку строки используются при вводе-выводе, необходимо
включить в программу заголовочный файл iostream.
В. Необходимо включить в программу заголовочный файл string.
Г. Язык C++ не поддерживает строки.
4. Какое из приведенных ниже слов не обозначает тип переменной?
А. double
Б. real
В. int
Г. char
452    Ответы к разделам «Проверьте себя»

5. Как целиком считать строку, введенную пользователем?


А. С помощью cin>>.
Б. С помощью readline.
В. С помощью getline.
Г. Простого способа нет.
6. Что будет отображено на экране для выражения C++: cout <<1234/2000?
А. 0
Б. .617
В. Примерно .617, но результат невозможно сохранить с абсолютной
точностью в числе с плавающей запятой.
Г. Это зависит от типов обеих сторон равенства.
7. Почему в языке C++ используется тип char, когда уже есть тип int?
А. Потому, что символы и целые числа предназначены для совершенно
разных данных, чисел и букв.
Б. Для обратной совместимости с языком C.
В. Чтобы считывать и выводить символы, а не числа, хотя символы
хранятся как числа.
Г. Для интернационализации и поддержки языков с большим количе-
ством символов (например, китайского и японского).

Глава 4
1. Какое из значений эквивалентно true?
А. 1
Б. 66
В. .1
Г. -1
Д. Все.
2. Как обозначается оператор логического И?
А. &
Б. &&
В. |
Г. |&
3. Чему равно значение выражения !(true && ! (false || true))?
А. true
Б. false
Ответы к разделам «Проверьте себя»    453

4. Как выглядит правильный синтаксис условного оператора?


А. if выражение
Б. if{выражение
В. if(выражение)
Г. выражение if
Глава 5
1. Каким будет окончательное значение переменной x после выполнения
кода int x; for(x=0; x<10; x++) {}?
А. 10
Б. 9
В. 0
Г. 1
(Если вы не понимаете, как решить задачу, подумайте, что произойдет,
если добавить оператор cout после завершения цикла.)

2. При каком условии будет выполнен блок кода, следующий за операто-


ром while(x<100)?
А. Если x меньше 100.
Б. Если x больше 100.
В. Если x равно 100.
Г. В любом случае.
3. Что из перечисленного ниже не является структурой цикла?
А. for
Б. do-while
В. while
Г. repeat until
4. Каково минимальное количество итераций у цикла do-while?
А. 0
Б. Бесконечное.
В. 1
Г. Зависит от конкретного цикла.
454    Ответы к разделам «Проверьте себя»

Глава 6
1. Что из перечисленного ниже не является правильным прототипом?
А. int funct(char x, char y);
Б. double funct(char x)
В. void funct();
Г. char x();
(Обратите внимание на отсутствие точки с запятой.)

2. Какой тип имеет значение, возвращаемое функцией с прототипом int


func(char x, double v, float t);?
А. char
Б. int
В. float
Г. double
3. Что из нижеперечисленного является корректным вызовом функции
(при условии, что функция существует)?
А. funct;
Б. funct x, y;
В. funct();
Г. int funct();
4. Что из нижеперечисленного является законченной функцией?
А. int funct();
Б. int funct(int x) {return x=x+1;}
В. void funct(int) {cout<<"Hello"}
Г. void funct(x) {cout<<"Hello";}

Глава 7
1. Какой знак следует за оператором case?
А. :
Б. ;
В. -
Г. Символ новой строки.
2. Какой оператор необходимо использовать, чтобы избежать сквозного
выполнения операторов case?
А. end;
Б. break;
Ответы к разделам «Проверьте себя»    455

В. stop;
Г. Нужно поставить точку с запятой.
3. Какое ключевое слово обрабатывает неучтенные варианты?
А. all
Б. contingency
В. default
Г. other
4. Чему равен результат выполнения следующего кода?
int x = 0;
switch(x)
{
case 1: cout << "One";
case 0: cout << "Zero";
case 2: cout << "Hello World";
}

А. 1
Б. 0
В. Hello World
Г. ZeroHello World

Глава 8
1. Что произойдет, если не вызвать функцию srand перед функцией rand?
А. Функция rand завершится ошибкой.
Б. Функция rand будет всегда возвращать 0.
В. Функция rand будет возвращать одну и ту же последовательность
чисел при каждом запуске программы.
Г. Ничего.
2. Для чего следует задавать начальное число, передавая текущее время
в функцию srand?
А. Чтобы программа всегда выполнялась одинаково.
Б. Чтобы генерировать новые случайные числа при каждом запуске
программы.
В. Чтобы компьютер генерировал совершенно случайные числа.
Г. Это уже сделано за вас; если вы хотите, чтобы начальное число всегда
было одним и тем же, то вам нужно лишь вызвать функцию srand.
456    Ответы к разделам «Проверьте себя»

3. Какой диапазон значений возвращает функция rand?


А. Какой вы хотите.
Б. От 0 до 1000.
В. От 0 до RAND_MAX.
Г. От 1 до RAND_MAX.
4. Какое значение возвращает выражение 11 % 3?
А. 33
Б. 3
В. 8
Г. 2
5. Когда следует использовать функцию srand?
А. Каждый раз, когда нужно получить случайное число.
Б. Никогда, эта функция бесполезна.
В. Однократно при запуске программы.
Г. После нескольких вызовов функции rand, чтобы повысить степень
случайности чисел.

Глава 10
1. Что из нижеперечисленного является корректным объявлением массива?
А. int my_array[10];
Б. int anarray;
В. anarray{10};
Г. array anarray[10];
2. Какой индекс имеет последний элемент массива, состоящего из 29 эле-
ментов?
А. 29
Б. 28
В. 0
Г. Этот индекс определяется программистом.
3. Что из перечисленного является двумерным массивом?
А. array anarray[20][20];
Б. int anarray[20][20];
В. int array[20, 20];
Г. char array[20];
Ответы к разделам «Проверьте себя»    457

4. Какой из операторов корректно получает доступ к седьмому элементу


стоэлементного массива с именем foo?
А. foo[6];
Б. foo[7];
В. foo(7);
Г. foo;
5. Что из перечисленного корректно объявляет функцию, которая при-
нимает в качестве аргумента двумерный массив?
А. int func(int x[][]);
Б. int func(int x[10][]);
В. int func(int x[]);
Г. int func(int x[][10]);

Глава 11
1. Что из перечисленного получает доступ к переменной в структуре b?
А. b->var;
Б. b.var;
В. b-var;
Г. b>var;
2. Что из перечисленного является корректно определенной структурой?
А. struct {int a;}
Б. struct a_struct {int a};
В. struct a_struct int a;
Г. struct a_struct {int a;};
3. Что из перечисленного объявляет структурную переменную типа foo
с именем my_foo?
А. my_foo as struct foo;
Б. foo my_foo;
В. my_foo;
Г. int my_foo;
4. Каково окончательное значение, получаемое в результате выполнения
следующего кода?
#include <iostream>
using namespace std;
struct MyStruct
продолжение 
458    Ответы к разделам «Проверьте себя»

{
int x;
};
void updateStruct (MyStruct my_struct)
{
my_struct.x = 10;
}
int main()
{
MyStruct my_struct;
my_struct.x = 5;
updateStruct(my_struct);
cout << my_struct.x << '\n';
}

А. 5
Б. 10
В. Этот код не скомпилируется.
Глава 12
1. Что из нижеперечисленного НЕ является веской причиной для ис-
пользования указателей?
А. Вы хотите, чтобы функция изменяла переданный ей аргумент.
Б. Вы хотите сэкономить место, избегая копирования переменной
большого размера.
В. Вы хотите иметь возможность запрашивать дополнительную память
у операционной системы.
Г. Вы хотите быстрее получать доступ к переменным.
2. Что хранит указатель?
А. Имя другой переменной.
Б. Целое значение.
В. Адрес другой переменной в памяти.
Г. Адрес в памяти, но не обязательно относящийся к другой пере-
менной.
3. Как получить дополнительную память в процессе выполнения про-
граммы?
А. Вы не можете получить дополнительную память.
Б. В стеке.
В. В свободном хранилище.
Г. Объявив другую переменную.
Ответы к разделам «Проверьте себя»    459

4. Какую ошибку можно допустить при использовании указателей?


А. Вы можете попытаться воспользоваться памятью, доступ к которой
запрещен, что приведет к аварийному завершению программы.
Б. Вы можете получить доступ к некорректному адресу памяти, что
приведет к повреждению данных.
В. Вы можете забыть вернуть память операционной системе, что при-
ведет к исчерпанию памяти.
Г. Вы можете допустить все вышеперечисленные ошибки.
5. Где выделяется память для обычной переменной, объявленной в функ-
ции?
А. В свободном хранилище.
Б. В стеке.
В. Обычные переменные не используют память.
Г. В двоичном файле программы (именно поэтому exe-файлы такие
большие!).
6. Что необходимо сделать с выделенной памятью?
А. Ничего, ее можно использовать бесконечно.
Б. Вернуть ее операционной системе по окончании использования.
В. Задать значение, на которое указывает указатель, равным нулю.
Г. Присвоить указателю нулевое значение.

Глава 13
1. Что из нижеперечисленного является корректным объявлением ука-
зателя?
А. int x;
Б. int &x;
В. ptr x;
Г. int *x;
2. Что из нижеперечисленного является адресом памяти целочисленной
переменной a?
А. *a;
Б. a;
В. &a;
Г. address(a);
460    Ответы к разделам «Проверьте себя»

3. Что из нижеперечисленного является адресом памяти переменной, на


которую указывает указатель p_a?
А. p_a;
Б. *p_a;
В. &p_a;
Г. address(p_a);
4. Что из нижеперечисленного является значением, хранимым по адресу,
на который указывает указатель p_a?
А. p_a;
Б. val( p_a );
В. *p_a;
Г. &p_a;
5. Что из нижеперечисленного является корректным объявлением ссыл-
ки?
А. int *p_int;
Б. int &my_ref;
В. int &my_ref = & my_orig_val;
Г. int &my_ref = my_orig_val;
6. Для чего НЕ следует использовать ссылку?
А. Для хранения адреса, динамически выделенного в свободном
хранилище.
Б. Для исключения копирования большого значения при его передаче
в функцию.
В. Для того чтобы значение параметра, передаваемого в функцию,
никогда не равнялось NULL.
Г. Для доступа функции к исходному значению переданной ей пере-
менной без использования указателей.

Глава 14
1. Что из нижеперечисленного является ключевым словом, с помощью
которого в C++ выделяется память?
А. new
Б. malloc
В. create
Г. value
Ответы к разделам «Проверьте себя»    461

2. Что из нижеперечисленного является ключевым словом, с помощью


которого в C++ освобождается память?
А. free
Б. delete
В. clear
Г. remove
3. Какое из перечисленных ниже утверждений верно?
А. Указатели и массивы — это одно и то же.
Б. Массивы нельзя присваивать указателям.
В. Указатели можно использовать как массивы, но указатели и мас-
сивы — не одно и то же.
Г. Указатели можно использовать как массивы, но указатели нельзя
выделять так же, как массивы.
4. Каковы окончательные значения переменных x, p_int и p_p_int в сле-
дующем коде? (Поскольку целые числа и указатели являются разными
типами данных, это код не скомпилируется, однако данное упражнение
полезно выполнить на бумаге, чтобы разобраться, что происходит с не-
сколькими указателями.)
int x = 0;
int *p_int = & x;
int **p_p_int = & p_int;
*p_int = 12;
**p_p_int = 25;
p_int = 12;
*p_p_int = 3;
p_p_int = 27;

А. x = 0, p_p_int = 27, p_int = 12


Б. x = 25, p_p_int = 27, p_int = 12
В. x = 25, p_p_int = 27, p_int = 3
Г. x = 3, p_p_int = 27, p_int = 12
5. Как отметить указатель, который не указывает на корректный адрес
памяти?
А. Присвоить ему отрицательное значение.
Б. Присвоить ему значение NULL.
В. Освободить связанную с ним память.
Г. Присвоить ему значение false.
462    Ответы к разделам «Проверьте себя»

Глава 15
1. В чем преимущество связанного списка перед массивом?
А. В связанных списках меньше средний размер элемента.
Б. Связанные списки могут динамически расширяться; в них можно
добавлять отдельные новые элементы, не копируя уже существующие.
В. Поиск отдельного элемента в связанных списках быстрее, чем в мас-
сивах.
Г. Элементами связанных списков могут являться структуры.
2. Какое из перечисленных утверждений верно?
А. Нет никаких причин пользоваться массивами.
Б. Характеристики эффективности связанных списков и массивов
одинаковы.
В. Связанные списки и массивы обеспечивают одинаковое время до-
ступа к элементу по его индексу.
Г. Добавление элемента в середину связанного списка требует меньше
времени, чем добавление в середину массива.
3. Когда обычно используется связанный список?
А. Если необходимо хранить единственный элемент.
Б. Если число хранимых элементов известно на этапе компиляции.
В. Чтобы динамически добавлять и удалять элементы.
Г. Чтобы мгновенно получать доступ к любому элементу отсортиро-
ванного списка без каких-либо итераций.
4. Почему можно объявить связанный список со ссылкой на тип его эле-
мента? (struct Node {Node* p_next;};)
А. Объявить список таким способом нельзя.
Б. Потому что компилятор может определить, что на самом деле не
требуется память для элементов, которые ссылаются сами на себя.
В. Поскольку тип является указателем, требуется лишь достаточно
места для хранения одного указателя; память для следующего узла
будет выделена позже.
Г. Такое объявление допустимо только при условии, что указателю
p_next не присвоен адрес следующей структуры.
5. Почему в конце связанного списка важно использовать значение NULL?
А. Потому что оно отмечает конец списка и предотвращает доступ
кода к неинициализированной памяти.
Б. Потому что без него список превращается в последовательность
циклических ссылок.
Ответы к разделам «Проверьте себя»    463

В. Для отладки: выйдя за пределы списка, программа аварийно завер-


шится.
Г. Если не указать NULL, из-за ссылки на себя список потребует бес-
конечно много памяти.
6. В чем схожесть массивов и связанных списков?
А. Они позволяют быстро добавлять новые элементы в середину теку-
щего списка.
Б. Они обеспечивают последовательное хранение данных и доступ
к ним.
В. Их легко расширить, последовательно добавляя элементы.
Г. Они обеспечивают быстрый доступ ко всем элементам списка.

Глава 16
1. Что такое хвостовая рекурсия?
А. Когда вы подзываете свою собаку.
Б. Когда функция вызывает сама себя.
В. Когда рекурсивная функция вызывает себя непосредственно перед
возвращением.
Г. Когда можно написать рекурсивный алгоритм в виде цикла.
2. Когда можно воспользоваться рекурсией?
А. Когда невозможно написать алгоритм в виде цикла.
Б. Когда алгоритм естественнее выражается в виде подзадачи, а не
цикла.
В. Никогда — это слишком сложно.
Г. При работе с массивами и связанными списками.
3. Каковы неотъемлемые составляющие рекурсивного алгоритма?
А. Базовый вариант и рекурсивный вызов.
Б. Базовый вариант и способ сведения задачи к уменьшенной версии
самой себя.
В. Способ перекомпоновки уменьшенных версий задачи.
Г. Все вышеперечисленное.
4. Что может произойти при неполном базовом варианте?
А. Преждевременное завершение алгоритма.
Б. Ошибка компилятора.
В. Ничего — это не проблема.
Г. Переполнение стека.
464    Ответы к разделам «Проверьте себя»

Глава 17
1. В чем главное достоинство двоичного дерева?
А. Оно использует указатели.
Б. Оно может хранить любое количество данных.
В. Оно позволяет быстро искать данные.
Г. Из него легко удалить данные.
2. В каких случаях вы предпочтете использовать связанный список вместо
двоичного дерева?
А. Если необходимо обеспечить быстрый поиск данных.
Б. Если необходимо получать доступ к отсортированным элемен-
там.
В. Если необходимо быстро добавлять элементы в начало или конец
структуры, при этом элементы в ее середине никогда не использу-
ются.
Г. Если не нужно освобождать используемую память.
3. Какое из нижеперечисленных высказываний верно?
А. Порядок добавления элементов в двоичное дерево может изменить
его структуру.
Б. Чтобы структура двоичного дерева была оптимальной, следует до-
бавлять в него элементы в отсортированном порядке.
В. Поиск элементов в связанном списке будет быстрее, чем в двоичном
дереве, если элементы вставляются в двоичное дерево в случайном по-
рядке.
Г. Структура двоичного дерева никогда не может быть такой же, как
у связанного списка.
4. Какая из причин объясняет высокую скорость поиска узлов в двоичных
деревьях?
А. Двоичные деревья не обеспечивают быстрый поиск узлов — наличие
двух указателей увеличивает объем работы при обходе дерева.
Б. Спускаясь по дереву на один уровень, вы приблизительно вдвое
сокращаете количество узлов, в которых требуется найти искомый
узел.
В. Эффективность двоичных деревьев не выше, чем у связанных спи-
сков.
Г. Обработка двоичных деревьев с помощью рекурсивных вызовов
происходит быстрее, чем обработка связанных списков с помощью
циклов.
Ответы к разделам «Проверьте себя»    465

Глава 18
1. Когда уместно использовать вектор?
А. Для хранения связи ключа и значения.
Б. Для максимального быстродействия при изменении коллекции
элементов.
В. Если программист хочет избавить себя от нюансов обновления
структуры данных.
Г. Использование вектора всегда уместно.
2. Как одновременно удалить все элементы из словаря?
А. Присвоить элементу пустую строку.
Б. Вызвать метод erase.
В. Вызвать метод empty.
Г. Вызвать метод clear.
3. Когда следует реализовывать собственные структуры данных?
А. Когда требуется обеспечить высокое быстродействие программы.
Б. Когда требуется обеспечить высокую надежность программы.
В. Когда требуется напрямую работать со структурой данных, на-
пример строить дерево выражений.
Г. Реализовывать собственные структуры данных можно лишь ради
удовольствия.
4. Какая из конструкций корректно объявляет итератор, который можно
использовать с вектором vector<int>?
А. iterator<int> itr;
Б. vector::iterator itr;
В. vector<int>::iterator itr;
Г. vector<int>::iterator<int> itr;
5. Что из нижеперечисленного получает доступ к ключу элемента, на
который в текущий момент указывает итератор словаря?
А. itr.first
Б. itr->first
В. itr->key
Г. itr.key
6. Как определить, можно ли использовать итератор?
А. Сравнить его с NULL.
Б. Сравнить его со значением, возвращенным методом end() контей-
нера, перебор которого выполняется.
466    Ответы к разделам «Проверьте себя»

В. Сравнить его с 0.
Г. Сравнить его со значением, возвращенным методом begin() контей-
нера, перебор которого выполняется.

Глава 19
1. Какой код корректен?
А. const int& x;
Б. const int x = 3; int *p_int = & x;
В. const int x = 12; const int *p_int = & x;
Г. int x = 3; const int y = x; int& z = y;
2. Какие из перечисленных ниже прототипов функций позволяют ском-
пилировать следующий код: const int x = 3; fun( x );?
А. void fun (int x);
Б. void fun (int& x);
В. void fun (const int& x);
Г. А и Б.
3. Как лучше всего определить, что поиск строки завершится неудачно?
А. Сравнить позицию результата с 0.
Б. Сравнить позицию результата с –1.
В. Сравнить позицию результата с string::npos.
Г. Проверить, больше ли позиция результата длины строки.
4. Как создать итератор для константного контейнера STL?
А. Объявить итератор константным.
Б. Воспользоваться индексами для циклического прохода по нему, а не
итератором.
В. Воспользоваться const_iterator.
Г. Объявить типы шаблонов константными.

Глава 21
1. Что из нижеперечисленного не входит в состав процесса сборки C++?
А. Компоновка.
Б. Компиляция.
В. Предварительная обработка.
Г. Окончательная обработка.
Ответы к разделам «Проверьте себя»    467

2. Когда происходит ошибка, связанная с отсутствием определения функ-


ции?
А. На этапе компоновки.
Б. На этапе компиляции.
В. При запуске программы.
Г. При вызове функции.
3. Что произойдет, если включить заголовочный файл несколько раз?
А. Появятся ошибки из-за повторных объявлений.
Б. Ничего, заголовочные файлы всегда загружаются однократно.
В. Зависит от реализации заголовочного файла.
Г. Заголовочные файлы не могут быть включены несколькими исход-
ными файлами одновременно, поэтому никаких проблем не возникнет.
4. В чем преимущества разделения компиляции и компоновки?
А. Ни в чем: процесс сборки становится запутанным и медленным из-за
одновременной работы нескольких программ.
Б. Проще обнаруживать ошибки, поскольку компоновщик и компиля-
тор позволяют определить местоположение проблемы.
В. Можно повторно компилировать только измененные файлы, что
сокращает длительность компиляции и компоновки.
Г. Можно повторно компилировать только измененные файлы, что
сокращает длительность компиляции.

Глава 22
1. В чем заключается преимущество использования функции перед пря-
мым доступом к данным?
А. Компилятор может оптимизировать функцию и ускорить доступ
к данным.
Б. Функция скрывает нюансы реализации от вызывающего окруже-
ния, что упрощает его изменение.
В. Функции — единственный способ хранения одной структуры данных
в нескольких файлах с исходным кодом.
Г. Такого преимущества нет.
2. Когда следует помещать код в общую функцию?
А. Если собираетесь вызвать ее хотя бы один раз.
Б. Если используете один и тот же код более чем в двух местах про-
граммы.
468    Ответы к разделам «Проверьте себя»

В. Когда компилятор выводит ошибки, связанные с большим размером


функции.
Г. Б и В.
3. С какой целью скрывается представление структуры данных?
А. Для упрощения ее замены.
Б. Для упрощения чтения кода, использующего структуру данных.
В. Для упрощения использования структуры данных в новых фрагмен-
тах кода.
Г. Во всех вышеперечисленных целях.

Глава 23
1. Почему следует использовать метод вместо прямого доступа к полю
структуры?
А. Метод легче читать.
Б. Метод работает быстрее.
В. Следует пользоваться не методом, а прямым доступом к полю.
Г. Это позволяет изменить представление данных.
2. Что из нижеперечисленного определяет метод, связанный со структу-
рой struct MyStruct{int func();};?
А. int func() {return 1;}
Б. MyStruct::int func() {return 1;}
В. int MyStruct::func() {return 1;}
Г. int MyStruct func () {return 1;}
3. Почему следует размещать определение метода внутри класса?
А. Чтобы пользователи класса понимали, как он работает.
Б. Чтобы ускорить выполнение кода.
В. Не следует, поскольку это разглашает излишние подробности
реализации.
Г. Не следует, поскольку это замедляет работу программы.

Глава 24
1. Для чего используются закрытые данные?
А. Для защиты от хакеров.
Б. Чтобы другие программисты не могли их изменять.
В. Для внутренней реализации класса.
Ответы к разделам «Проверьте себя»    469

Г. Не следует использовать закрытые данные, поскольку они услож-


няют программирование.
2. Чем класс отличается от структуры?
А. Ничем.
Б. Все члены класса по умолчанию являются открытыми.
В. Все члены класса по умолчанию являются закрытыми.
Г. Класс позволяет указывать, какие его члены являются открытыми,
а какие — закрытыми.
3. Что следует делать с полями данных класса?
А. По умолчанию объявлять их открытыми.
Б. По умолчанию объявлять их закрытыми, но при необходимости
делать их открытыми.
В. Никогда не объявлять их открытыми.
Г. Как правило, классы не содержат данные, но если содержат, то ничего.
4. Как определить, следует ли объявить метод открытым?
А. Никогда не объявляйте методы открытыми.
Б. Всегда объявляйте методы открытыми.
В. Объявляйте методы открытыми только при условии, что они по-
зволяют использовать основные возможности класса.
Г. Объявляйте методы открытыми, если есть хотя бы малейший шанс,
что кто-нибудь воспользуется ими.

Глава 25
1. Когда необходимо создавать конструктор класса?
А. Всегда — без конструктора класс нельзя использовать.
Б. Если необходимо инициализировать класс значениями, отличными
от значений по умолчанию.
В. Никогда — компилятор всегда предоставляет конструктор класса.
Г. Только если уже есть деструктор.
2. Как связаны деструктор и оператор присваивания?
А. Никак.
Б. Деструктор класса вызывается до выполнения оператора присваи-
вания.
В. Оператор присваивания должен указать, какую память должен уда-
лить деструктор.
470    Ответы к разделам «Проверьте себя»

Г. Оператор присваивания должен проверить, что одновременный


запуск деструкторов скопированного и нового класса безопасен.
3. Когда необходимо использовать список инициализации?
А. Когда требуется сделать конструкторы максимально эффективными
и избежать создания пустых объектов.
Б. При инициализации константного значения.
В. Когда необходимо выполнить для поля класса конструктор, отлич-
ный от конструктора по умолчанию.
Г. Во всех перечисленных случаях.
4. Какая функция выполняется во второй строке кода?
string str1;
string str2 = str1;

А. Конструктор объекта str2 и оператор присваивания объекта str1.


Б. Конструктор объекта str2 и оператор присваивания объекта str2.
В. Конструктор копирования объекта str2.
Г. Оператор присваивания объекта str2.
(Поскольку объект str2 еще не инициализирован, вместо оператора при-
сваивания выполняется конструктор копирования.)
5. Какие функции вызываются при выполнении этого кода и в каком по-
рядке?
{
string str1;
string str2;
}

А. Конструктор объекта str1, конструктор объекта str2.


Б. Деструктор объекта str1, конструктор объекта str2.
В. Конструктор объекта str1, конструктор объекта str2, str1, деструк-
тор объекта str2.
Г. Конструктор объекта str1, конструктор объекта str2, деструктор
объекта str2, деструктор объекта str1.
6. Если вы знаете, что у класса есть конструктор копирования, отличный
от конструктора копирования по умолчанию, какое утверждение об
операторе присваивания этого класса верно?
А. У класса есть оператор присваивания по умолчанию.
Б. У класса есть оператор присваивания, отличный от оператора при-
сваивания по умолчанию.
Ответы к разделам «Проверьте себя»    471

В. У класса есть объявленный, но нереализованный оператор присва-


ивания.
Г. Утверждения Б и В.
(Этот оператор должен быть закрытым, чтобы компилятор мог быстро
обнаружить проблему.)

Глава 26
1. Когда запускается деструктор суперкласса?
А. Только когда объект уничтожается путем освобождения указателя
на суперкласс с помощью оператора delete.
Б. Перед вызовом деструктора подкласса.
В. После вызова деструктора подкласса.
Г. В процессе выполнения деструктора подкласса.
2. Что необходимо сделать в конструкторе класса Cat в условиях приве-
денной ниже иерархии классов?
class Mammal {
public:
Mammal (const string& species_name);
};

class Cat : public Mammal


{
public:
Cat();
};

А. Ничего особенного.
Б. Вызвать конструктор Mammal с аргументом cat, воспользовавшись
списком инициализации.
В. Вызвать конструктор Mammal с аргументом cat.
Г. Удалить конструктор Cat и воспользоваться конструктором по умол-
чанию, который выполнит все требуемые действия.
3. В чем ошибка в следующем определении класса?
class Nameable
{
virtual string getName();
};

А. Метод getName не является открытым.


Б. Отсутствует виртуальный деструктор.
472    Ответы к разделам «Проверьте себя»

В. Реализация метода getName отсутствует, но он не объявлен как чисто


виртуальный метод.
Г. Все вышеперечисленное.
4. Когда вы определяете виртуальный метод в интерфейсном классе, что
должна сделать функция, чтобы получить возможность использовать
метод интерфейса для вызова метода подкласса?
А. Принять интерфейс в виде указателя или ссылки.
Б. Ничего, она может просто скопировать объект.
В. Ей нужно знать имя подкласса, метод которого она должна вызвать.
Г. Не понял! Что такое виртуальный метод?
5. Как наследование улучшает многократное использование кода?
А. Позволяет наследовать методы у суперклассов.
Б. Позволяет суперклассу реализовывать виртуальные методы для
подкласса.
В. Позволяет использовать в коде интерфейс, а не конкретный класс.
Это дает возможность создавать новые классы, реализующие этот
интерфейс, и использовать их вместе с существующим кодом.
Г. Позволяет новым классам наследовать характеристики конкретного
класса, которые можно использовать вместе с виртуальными методами.
6. Какое из утверждений об уровнях доступа к классу верное?
А. Подкласс имеет доступ только к открытым методам и данным роди-
тельского класса.
Б. Подкласс имеет доступ к закрытым методам и данным родительского
класса.
В. Подкласс имеет доступ только к защищенным методам и данным
родительского класса.
Г. Подкласс имеет доступ к защищенным и открытым методам и дан-
ным родительского класса.

Глава 27
1. Когда следует использовать директиву using namespace?
А. Во всех заголовочных файлах после директивы include.
Б. Никогда, это опасно.
В. В начале любого файла cpp при отсутствии конфликтов пространств
имен.
Г. Непосредственно перед использованием переменной из соответству-
ющего пространства имен.
Ответы к разделам «Проверьте себя»    473

2. Для чего нужны пространства имен?


А. Чтобы поставить интересную задачу перед разработчиками компи-
ляторов.
Б. Чтобы усилить инкапсуляцию кода.
В. Чтобы предотвращать конфликты имен в крупных базах кода.
Г. Чтобы пояснять, для чего предназначен класс.
3. Когда следует помещать код в пространство имен?
А. Всегда.
Б. При разработке программы, состоящей из более чем десятка фай-
лов.
В. При разработке библиотеки, которая будет распространяться среди
других программистов.
Г. Б и В.
4. Почему не следует помещать объявление using namespace в заголовоч-
ный файл?
А. Это не разрешено.
Б. В этом нет смысла: объявление using действительно только в самом
заголовочном файле.
В. Действие объявления using распространяется на всех, кто включает
заголовочный файл в код, даже если это приводит к конфликтам.
Г. Это может привести к конфликтам, если объявления using присут-
ствуют в нескольких заголовочных файлах.

Глава 28
1. Какой тип можно использовать для чтения из файла?
А. ifstream
Б. ofstream
В. fstream
Г. А и В
2. Какое из нижеперечисленных утверждений верно?
А. Текстовые файлы занимают меньше места, чем двоичные.
Б. Двоичные файлы легче отлаживать.
В. Двоичные файлы эффективнее используют пространство, чем
текстовые.
Г. Текстовые файлы слишком медленны для использования в реальных
программах.
474    Ответы к разделам «Проверьте себя»

3. Почему нельзя передавать указать на строковый объект при написании


двоичного файла?
А. В метод write всегда необходимо передавать char*.
Б. Строковый объект невозможно хранить в памяти.
В. Нам неизвестна структура строкового объекта; она может содер-
жать в себе указатели, которые будут записаны в файл.
Г. Строки занимают слишком много места, и их необходимо записывать
по частям.
4. Какие из нижеперечисленных утверждений о форматах файлов спра-
ведливы?
А. Изменять форматы файлов так же легко, как любые другие входные
данные.
Б. Перед тем как изменить формат файла, необходимо определить, что
произойдет, когда старая версия программы попытается прочитать файл.
В. При разработке формата файла необходимо учитывать, что про-
изойдет, если новая версия программы откроет старую версию файла.
Г. Б и В.

Глава 29
1. Для чего следует использовать шаблоны?
А. Для экономии времени.
Б. Для ускорения выполнения кода.
В. Для написания одного и того же кода для разных типов данных.
Г. Для возможности повторного использования кода в будущем.
2. Когда необходимо указывать тип параметра шаблона?
А. Всегда.
Б. Только при объявлении экземпляра класса шаблона.
В. Только если тип нельзя определить автоматически.
Г. В шаблонных функциях, при условии, что тип нельзя определить
автоматически, и всегда для классов шаблона.
3. Как компилятор определяет, можно ли применить шаблонный параметр
к конкретному шаблону?
А. Он реализует определенный интерфейс C++.
Б. Следует задать соответствующие ограничения при объявлении ша-
блона.
Ответы к разделам «Проверьте себя»    475

В. Он пытается использовать шаблонный параметр: если тип шаблона


поддерживает все требуемые операции, то параметр принимается.
Г. Следует перечислить все допустимые типы шаблонов при его объ-
явлении.
4. В чем различие размещения в заголовочном файле класса шаблона
и обычного класса?
А. Различий нет.
Б. Методы обычного класса нельзя определять в заголовочном файле.
В. Все методы класса шаблона должны быть определены в заголо-
вочном файле.
Г. Класс шаблона, в отличие от обычного класса, может не иметь файла
.cpp.
5. Когда следует делать функцию шаблонной?
А. Сразу же: следует делать шаблонными все методы, поскольку неиз-
вестно, когда потребуется применить написанный алгоритм к другому
типу данных.
Б. Только если не удается привести данные к какому-либо из типов,
с которыми работает текущая функция.
В. Чтобы применить уже реализованный алгоритм, работающий с од-
ним типом данных, к другому типу данных с похожими свойствами.
Г. Если две функции делают почти одно и то же, и надо слегка изменить
логику их работы при помощи дополнительных логических параметров.
6. Когда вы узнаете о большинстве ошибок в коде шаблона?
А. Сразу после компиляции шаблона.
Б. В процессе компоновки.
В. Во время выполнения программы.
Г. При первой компиляции кода, который создает экземпляр шаблона.
Алекс Эллайн
C++. От ламера до программера

Перевел с английского А. Кузнецов

Заведующий редакцией П. Щеголев


Руководитель проекта П. Щеголев
Ведущий редактор Ю. Сергиенко
Литературный редактор В. Рычков
Художественный редактор В. Шимкевич
Корректоры С. Беляева, В. Ганчурина
Верстка Л. Родионова

ООО «Питер Пресс», 192102, Санкт-Петербург, ул. Андреевская (д. Волкова), д. 3, литер А, пом. 7Н.
Налоговая льгота — общероссийский классификатор продукции ОК 034-2014,
58.11.12.000 — Книги печатные профессиональные, технические и научные.
Подписано в печать 27.02.15. Формат 70х100/16. Усл. п. л. 38,700. Тираж 1000. Заказ
Отпечатано в ООО «Чеховский печатник». МО, г. Чехов, ул. Полиграфистов, 1.
Изучаем C#
3-е издание
Э. Стиллмен, Дж. Грин

В отличие от большинства книг


по программированию, построенных
на основе скучного изложения
спецификаций и примеров, с этой книгой
читатель сможет сразу приступить
к написанию собственного кода на языке
программирования C# с самого начала. Вы
освоите минимальный набор инструментов,
а далее примете участие в забавных
и интересных программных проектах: от
разработки карточной игры до создания
Формат: 20,3 х 23,5 см
серьезного бизнес-приложения. Второе
Объем: 816 c. издание книги включает последние версии
C# 5.0, Visual Studio 2012 и .NET 4.5
Framework и будет интересно всем
изучающим язык программирования С#.
Особенностью данного издания
является уникальный способ подачи
материала, выделяющий серию «Head
First» издательства O’Reilly в ряду
множества скучных книг, посвященных
программированию.
Философия Java
4-е полное издание
Б. Эккель

Впервые читатель может познакомиться


с полной версией этого классического
труда, который ранее на русском
языке печатался в сокращении. Книга,
выдержавшая в оригинале не одно
переиздание, за глубокое и поистине
философское изложение тонкостей языка
Java считается одним из лучших пособий
для программистов.
Чтобы по-настоящему понять язык Java,
необходимо рассматривать его не просто как
набор неких команд и операторов, а понять
его «философию», подход к решению задач
Формат: 16,5 х 23,3 см в сравнении с таковыми в других языках
Объем: 1168 c. программирования.
На этих страницах автор рассказывает об
основных проблемах написания кода: в чем
их природа и какой подход использует Java
в их разрешении. Поэтому обсуждаемые
в каждой главе черты языка неразрывно
связаны с тем, как они используются для
решения определенных задач.
PHP. Рецепты программирования
3-е издание
Д. Скляр, А. Трахтенберг

Третье издание этой популярной книги


представляет собой подборку готовых
решений наиболее распространенных
задач на языке РНР. Изложен материал,
интересный каждому разработчику:
базовые типы данных, операции
с ними, файлы cookie, функции РНР,
аутентификация пользователей, работа
со слоями, проблемы безопасности,
ускорение действия программ, работа
в Cети, создание графических изображений,
обработка ошибок, отладка сценариев
и написание тестов. Даны рецепты,
затрагивающие основы объектно-
Формат: 16,5 х 23,3 см
ориентированного программирования
Объем: 784 c. и новые функциональные возможности РНР.
Каждый рецепт является самодостаточным
и показывает весь путь решения задачи.
Третье издание книги полностью обновлено
под версию PHP 5.4, а также включает ряд
новых разделов по работе с данными.
Изучаем программирование
на JavaScript
Э. Фримен, Э. Робсон

Вы готовы сделать шаг вперед в веб-


программировании и перейти от верстки
в HTML и CSS к созданию полноценных
динамических страниц? Тогда пришло время
познакомиться с самым «горячим» языком
программирования — JavaScript!
С помощью этой книги вы узнаете все
о языке JavaScript — от переменных
до циклов. Вы поймете, почему разные
браузеры по-разному реагируют на код
и как написать универсальный код,
Формат: 20,3 х 23,5 см
поддерживаемый всеми браузерами. Вам
Объем: 640 c.
станет ясно, почему с кодом JavaScript
никогда не придется беспокоиться
о перегруженности страниц и ошибках
передачи данных. Не пугайтесь, даже если
ранее вы не написали ни одной строчки
кода, — благодаря уникальному формату
подачи материала эта книга с легкостью
проведет вас по всему пути обучения: от
написания простейшего скрипта до создания
сложных веб-проектов, которые будут
работать во всех современных браузерах.
Особенностью этого издания
является уникальный способ подачи
материала, выделяющий серию «Head
First» издательства O’Reilly в ряду
множества скучных книг, посвященных
программированию.

Вам также может понравиться