Текст
                    С книгой «C++ без страха» вы:
Брайан
Оверленд
ЗНАНИЯ
И ОПЫТ
ЭКСПЕРТОВ
Брайан
Оверленд
	Быстро освоите основы программирования;
	Напишете свои первые программы на языке C++;
	Оцените хорошо иллюстрированные объяснения,
примеры и упражнения;
	Запустите написанные программы сразу,
воспользовавшись прилагаемым программным
обеспечением;
	Сможете обратиться к удобным итоговым сводкам
особенностей языка.
CD-ROM
ДИСК
X
На CD-ROM диске, поставляемом вместе с книгой, находится свободно
распространяемый компилятор языка C++ для написания и запуска
программ, написанных на языке C++, который позволит вам
немедленно начать работу. На диске также содержатся примеры и
ответы на все упражнения в данной книге.
и
СП
ш
ш
БЕЗ СТРАХА
Прилагаемый CD-ROM диск будет работать на любом персональном
компьютере под управлением операционных систем MS-DOS или
Windows.
ТРИУМФ
PRENTICE
HALE
PTR
PRENTICE
PTR
ТРИУМФ

Серия «Знания и опыт экспертов» Брайан Оверленд C++ БЕЗ СТРАХА PRENTICE HALL PTR «Издательство ТРИУМФ» Москва
C++ Without Fear A Beginner's Guide That Makes You Feel Smart Brian Overland PRENTICE HALL PTR Prentice Hall Professional Technical Reference Upper Saddle River, New Jersey 07458 www.phptr.com
УДК 004.451.9С++(075.8) ББК 32.973.26-018.1С++.Я78-1 0-31 Оверленд, Брайан. 0-31 C++ без страха : [учеб, пособие : пер. с англ.] / Брайан Оверленд. — М.: Изд-во Триумф, 2005. — 432 с.: ил. — (Серия «Знания и опыт экспертов»), — Доп. тит. л. англ. — ISBN 5-89392-107-0. Агентство CIP РГБ В большинстве книг по программированию на языке C++ предполагается, что читатель уже программировал на другом языке, а еще лучше на нескольких. Эту книгу можно читать с нуля. Книга содержит большое количество примеров программного кода. Все примеры записаны на прилагаемый к книге компакт-диск, чтобы читатель мог активизировать полученные знания, каждый пример сопровождается рядом упражнений. Выполнив их, вы научитесь думать «как программист» и станете настоящим «асом» программирования C++. Автор книги Брайан Оверленд на протяжении десяти лет работал в компании Microsoft программистом, руководителем проектов и писателем - уникальная комбинация, которая позволила написать множество понятных и точных книг по программированию. Посетите наш Интернет-магазин «Три ступеньки »: www.3st.ru E-mail: post@triumph.ru Authorized translation from the English language edition, entitled C++ WITHOUT FEAR: A Beginner’s Guide That Makes You Feel Smart, 1S1 Edition, ISBN 0321246950 by Overland, Brian, published by Pearson Education, Inc, publishing as Prentice Hall PTR, Copyright © 2005 Pearson Education, Inc. All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc. Russian language edition published by Triumph Publishing (ООО «Издательство Триумф»). Copyright © 2005. Авторизованный перевод англоязычного издания под названием C++ WITHOUT FEAR: A Beginner’s Guide That Makes You Feel Smart, 1st Edition, ISBN 0321246950 by Overland, Brian, published by Pearson Education, Inc, publishing as Prentice Hall PTR, Copyright © 2005 Pearson Education, Inc. Все права защищены. Никакая часть данной книги не может быть переделана или изменена в какой-либо форме, электронной или механической, включая ксерокопирование, запись на носители информации без разрешения Pearson Education, Inc. Русскоязычная версия, изданная ООО «Издательство Триумф». Все права защищены © ООО «Издательство Триумф», 2005. ISBN 5-89392-107-0 © Обложка ООО «Издательство ТРИУМФ», 2005 ISBN 0-321-24695-0 (амер.) © Верстка и оформление ООО «Издательство.ТРИУМФ», 2005
To all the great teachers I have known, and to Fara And Skyler, who will hot be forgotten Посвящается всем великим наставникам, которых я знал, а также Фаре (Fara) и Скайлеру (Skyler), которые не будут забыты.
Ha CD-ROM диске, поставляемом вместе с книгой, находится свободно распространяемый компилятор языка C++ - программное обеспечение, необходимое для того, чтобы программы заработали. Все примеры программ в этой книге разработаны с учетом того, что они могут быть выполнены на любом, достаточно современном компиляторе языка C++. Однако, если вы используете компилятор, выпущенный до 2000 года, вполне возможно, что некоторые примеры могут не запуститься. Если это произошло, просто пропустите этот пример и перейдите к следующему - или установите компилятор, записанный на компакт-диске.
Краткое содержание Предисловие...........................................18 ГЛАВА 1. Ваши первые программы на языке C++...........24 ГЛАВА 2. Решения, решения.............................54 ГЛАВА 3. Удобная и универсальная инструкция «for».....83 ГЛАВА 4. функции: вызываются много раз................96 ГЛАВА 5. Массивы: множество чисел....................124 ГЛАВА 6. Указатели: способ управления данными........148 ГЛАВА 7. Строки: разбор текста.......................170 ГЛАВА 8. Файлы: электронное хранилище................200 ГЛАВА 9. Некоторые более сложные приемы программирования.................................. 221 ГЛАВА 10. Станьте объектно-ориентированными..........244 ГЛАВА 11. Класс Fraction.............................258 ГЛАВА 12. Конструкторы: если вы их создаете..........284 ГЛАВА 13. Функции операторов: реализация при помоши классов..................................302 ГЛАВА 14. Что такое «new»? Класс StringParser........326 ГЛАВА 15. Что такое «this»? Класс String.............343 ГЛАВА 16. Наследование: что в наследство?............361 ГЛАВА 17. Полиморфизм: независимость объекта..........384 Приложения......................................... 404
Содержание Предисловие.........................................................18 Зачем нужна еще одна книга по языку C++?............................18 Что еще нового можно сказать об этой книге?.........................18 Несколько путей изучения: который для вас лучше?....................19 Что, если у вас уже есть знания в области программирования?.........19 Что не рассматривается в книге?.....................................20 Почему необходимо начинать именно с языка C++?......................21 Приступая к работе................................................ 21 Советы и подсказки: на что обратить особое внимание?................22 Благодарности............................. ;...................... 23 ГЛАВА 1. Ваши первые программы на языке C++....................... 24 Мыслить «как программист».......................................... 24 Компьютеры делают только то, что вы им скажете....................24 Определите, что программа будет делать.......................... 24 Запись эквивалентных инструкций для языка C++.....................25 Некоторые специфические определения - обзор..............,........27 Чем отличается язык C++?............................................29 Генерация программы на языке C++....................................31 Ввод инструкций программы.........................................31 Генерация программы (Компиляция и сборка)...................... 31 Тестирование программы............................................33 Исправление по мере необходимости....,........................... 33 Установка вашего собственного компилятора языка C++................ 34 Пример 1.1. Печать сообщения...................................... 35 Если вы используете среду RHIDE...................................35 Если вы используете приложение Microsoft Visual Studio............36 Как это работает..,........................................... 37 Упражнения........................................................38 Переход на следующую печатную строку................................40 Пример 1.2. Печать нескольких строк.................................40 Как это работает.............................................. 41 Упражнения...................................................... 42 Хранение данных: переменные языка C++...............................43 Введение в типы данных..............................................44
Содержание 9 Пример 1.3. Преобразование температуры......................................46 Как это работает..........................................................47 Вариации примера..........................................................49 Упражнения................................................................50 Несколько слов об именах переменных и ключевых словах.......................51 Упражнения................................................................51 Резюме.................................................................... 52 ГЛАВА 2. Решения, решения............................................... 54 Но, сначала, несколько слов о типах данных..................................54 Принятие решений в программах............................................. 58 Инструкции if и if-else.....................................................58 Пример 2.1. Чет или нечет?..................................................61 Как это работает..........................................................62 Оптимизация кода...........................................................63 Упражнение................................................................64 Введение в циклы.......................................................... 64 Пример 2.2. Печать чисел от 1 до N...............................•..........68 Как это работает......................................................... 69 Упражнения................................................................70 Значения true и false в языке C++...........................................70 Оператор инкремента (++)....................................................71 Инструкции в сравнении с выражениями...................................... 73 Введение в булеву (короткозамкнутую) логику.................................74 Пример 2.3. Проверка возраста человека......................................76 Как это работает.................:...................................... 77 Упражнения............................................................. 77 Введение в математическую библиотеку........................................77 Пример 2.4. Проверка на простое число.......................................78 Как это работает.........:................................................79 Оптимизация программы.................................................................................................. 80 Упражнения................................................................81 Резюме..................................................................... 81 ГЛАВА 3. Удобная и универсальная инструкция «for»...........................83 Циклы, используемые для счета...............................................83 Введение в цикл «for».......................................................84 Множество примеров..........................................................85 Пример 3.1. Печать чисел от 1 до N с использованием инструкции for..........87
10 C++ без страха Как это работает.......................................................88 Упражнения.............................................................88 Блоки инструкций при использовании инструкции for...................... 89 Динамическое объявление переменных цикла............................... 89 Пример 3.2. Проверка на простое число с использованием цикла for.......... 90 Как это работает.......................................................92 Упражнения.............................................................93 Сравнительные языки 101: Инструкция «for» языка Basic....................93 Резюме................................................................. 94 ГЛАВА 4. Функции: вызываются много раз...................................96 Понятие функции..........................................................96 Вызовы функции и процесс выполнения программы.........................98 Основы использования функций.............................................99 Шаг 1: Объявление (создание прототипа) функции...................................................... 99 Шаг 2: Определение функции .......................................... 100 Шаг 3: вызов функции............................................... 101 Пример 4.1. Функция вычисления числового треугольника...................102 Как это работает................................................... 103 Оптимизация программы................................................ 105 Упражнения........................................................... 106 Пример 4.2. Функция проверки на простое число...........................107 Как это работает..................................................... 107 Упражнения......................................................... 108 Локальные и глобальные переменные.......................................109 Рекурсивные функции................................................... 110 Пример 4.3. Наибольший общий делитель (GCF).............................111 Как это работает....................................... ........ 114 Упражнения.......................................................... 115 Пример 4.4. Разложение на простые множители.............................115 Как это работает..................................................... 116 Упражнения........................................................... 119 Пример 4.5. Генератор случайных чисел....................'..............119 Как это работает..................................................... 121 Упражнения........................................................... 122 Резюме..................................................................122 ГЛАВА,5. Массивы: множество чисел.......................................124 Первый взгляд на массивы в языке C++....................................124 Инициализация массивов..................................................125
Содержание 11 Индексирование с отсчетом от нуля.................................126 Пример 5.1. Печать элементов......................................127 Как это работает..................................... ;....... 128 Упражнения..................................................... 129 Пример 5.2. Насколько случайное число является случайным?.........129 Как это работает............................................... 132 Упражнения..................................................... 133 Строки и массивы строк.............................................134 Пример 5.3. Сдающий карты #1......................................135 Как это работает............................................... 136 Упражнения..................................................... 137 Пример 5.4. Сдающий карты #2......................................137 Как это работает...............:.....,......................... 139 Упражнения..................................................... 140 Пример 5.5. Сдающий карты #3...................................... 140 Как это работает............................................... 142 Оптимизация программы.......................................... 143 Упражнения..................................................... 145 Умный понимает с полуслова........................................145 Двумерные массивы: в матрицу......................................146 Резюме............................................................147 ГЛАВА 6. Указатели: способ управления данными.....................148 Понятие указателя.................................................148 Объявление й использование указателей.............................151 Пример 6.1. Функция Double-it.....................................154 Как это работает.............................................. 154 Упражнения..................................................... 156 Функция перестановки: еще одна функция, использующая указатели....156 Пример 6.2. Сортировщик массива...................................158 Как это работает............................................... 161 Упражнения..................................................... 162 Арифметика с указателями..........................................162 Указатели и обработка массива.....................................164 Пример 6.3. Обнуление массива......................................166 Как это работает.............................................. 167 Оптимизация программы.......................................... 167 Упражнения..................................................... 168 Резюме............................................................169
12 C++ без страха ГЛАВА 7. Строки: разбор текста....................................170 Хранение текста в компьютере......................................170 Это бессмысленно, если нет этой строки............................172 Функции для работы со строками....................................173 Пример 7.1. Построение строк......................................175 Как это работает............................................... 176 Упражнения..................,.................................. 177 Чтение введенных строк............................................179 Пример 7.2. Получение числа........................................ 181 Как это работает............................................... 182 Упражнения..................................................... 183 Пример 7.3. Преобразование в верхний регистр......................184 Как это работает................................................ 184 Упражнения..................................................... 185 Отдельные символы в сравнении со строками.........................185 Пример 7.4. Разбор введенного текста..............................186 Как это работает............................................. 188 Упражнения..................................................... 191 Новый класс String языка C++......................................191 Включение поддержки класса string.............................. 192 Объявление и инициализация переменных типа string............. 192 Работа с переменными типа string............................... 193 Ввод и вывод................................................... 194 Пример 7.5. Построение строк с использованием типа string.........194 Как это работает............................................... 195 Упражнения..............................................;...... 196 Другие операции над типом string..................................196 Резюме............................................................198 ГЛАВА 8. Файлы: электронное хранилище.............................200 Введение в объекты файловых потоков...............................200 Как обращаться к дисковым файлам..................................202 Пример 8.1. Запись текста в файл..........:.......................203 Как это работает................................................204 Упражнения......................................................205 Пример 8.2. Отображение текстового файла..........................206 Как это работает...........................'...................207 Упражнения......................................................208 Текстовые файлы в сравнении с «двоичными» файлами.................208
Содержание 13 Введение в двоичные операции....................................... 211 Пример 8.3. Запись с произвольной выборкой........................212 Как это работает.............................................. 213 Упражнения................................................ 216 Пример 8.4. Чтение с произвольной выборкой........................216 Как это работает................................;...............218 Упражнения...'................................................ 218 Резюме............................................................219 ГЛАВА 9. Некоторые более сложные приемы программирования...........................................221 Аргументы командной строки........................................221 Пример 9.1. Отображение файла из командной строки.................223 Как это работает...................:.......................... 224 Упражнения...............................:.....................224 Перегрузка функций (Overloading)..................................225 Пример 9.2. Печать массивов различных типов.......................227 Как это работает................................................228 Упражнения........................................>........... 228 Цикл do-while.....................................................228 Инструкция switch-case............................................. 230 Многочисленные модули.............................................231 Обработка исключений..............................................234 Поприветствуем исключения.......................................234 Обработка исключений: первая попытка.............................235 Введение в обработку исключений с помощью блока try-catch.......236 Пример 9.3. Обработка исключений при использовании функции GCF....238 Как это работает................................................ 239 Упражнения.................................................... 240 Резюме............................................................. 241 ГЛАВА 10. Станьте объектно-ориентированными.......................244 Зачем становиться объектно-ориентированным?.......................244 Строковый анализатор...;...........................................245 Объекты в сравнении с классами....................................247 Другой пример: класс Fraction.....................................247 Создание и уничтожение объектов................................... 248 Наследование, или создание подклассов.............................249 Создание общих интерфейсов........................................251
14 C++ без страха Полиморфизм: настоящая независимость объектов........................................252 Полиморфизм и виртуальные функции...................................................253 Как насчет возможности повторного использования?...............:....................255 Резюме.............................................................................. 256 ГЛАВА 11. Класс Fraction............................................................258 Класс Point: простой класс......................................................... 258 Закрытые данные: допуск только для членов клуба! (защита данных)..................260 Пример 11.1. Тестирование класса Point............................................. 263 Как это работает.......................................:.........................264 Упражнения.......................................................................264 Введение в класс Fraction.......................................................... 264 Встраиваемые функции........................................................... ...267 Нахождение наибольшего общего делителя..............................................269 Нахождение наименьшего общего кратного..............................................270 Пример 11.2. Вспомогательные функции класса Fraction................................271 Как это работает............................................................... 273 Упражнения.......................................................................274 Пример 11.3. Тестирование класса Fraction......................................... 274 Как это работает:................................................................276 Упражнение.......................................................................277 Пример 11.4. Арифметика с дробями: функции add и mult...............................278 Как это работает.................................................................281 Упражнения.................................................................... 282 Резюме..............................................................................282 ГЛАВА 12. Конструкторы: если вы их создаете.........................................284 Введение в конструкторы.......................................................:.....284 Несколько конструкторов (перегрузка)................................................285 Конструктор по умолчанию... и предупреждение........................................286 Пример 12.1. Конструкторы класса Point..............................................289 Как это работает............................................................... 290 Упражнения...................................................................... 290 Пример 12.2. Конструкторы класса Fraction......................................... 290 Как это работает........................................................................................................... 293 Упражнения....................................................................................................................... 293 Переменные и аргументы ссылочного типа (&)..........................................293 Конструктор копирования............................................................ 295 Пример 12.3. Конструктор копирования класса Fraction................................297
Содержание 15 Как это работает...........................................................300 Упражнения.................................................................300 Резюме.......................................................................300 ГЛАВА 13. Функции операторов: реализация при помоши классов................................................302 Введение в функции операторов для класса.....................................302 Функции операторов как глобальные функции....................................304 Повышение эффективности при помощи ссылок....................................306 Пример 13.1. Операторы класса Point..........................................308 Как это работает...........................................................309 Упражнения............;....................................................310 Пример 13.2. Операторы класса Fraction.......................................311 Как это работает...........................................................313 Упражнения.................................................................314 Работа с другими типами.......................................;..............314 Функция присваивания класса (=)..............................................315 Функция проверки равенства (==)..............................................316 Функция «Print» класса...................................................... 318 Пример 13.3. Завершенный класс Fraction......................................319 Как это работает...........................................................322 Упражнения.................................................................323 Резюме.......................................................................323 ГЛАВА 14. Что такое «new»? Класс StringParser ...............................326 Оператор «new»...............................................................326 Объекты и оператор «new».....................................................328 Размещение массива данных....................................................329 Пример 14.1. Динамическая память в действии..................................331 Как это работает...........................................................332 Упражнение................................................................ 332 Разработка синтаксического анализатора (лексического анализатора)............333 Пример 14.2. Класс StringParser...................... ,.....................337 Как это работает........................................................................................................... 339 Усовершенствование кода....................................................341 Упражнения.................................................................341 Резюме.......................................................................342
16 C++ без страха ГЛАВА 15. Что такое «this»? Класс String..............................343 Введение в класс String...............................................343 Введение в деструкторы класса.........................................344 Пример 15.1. Простой класс String.................................:...345 Как это работает....................................................347 Упражнения;.........................................................349 Детальное копирование и конструктор копирования....................... 349 Зарезервированное слово «this»........................................351 Вернемся к оператору присваивания.....................................352 Написание функции конкатенции (сращивания).......................... 354 Пример 15.2. Класс String в полном объеме.............................356 Как это работает................................................ 358 Упражнения.....................;....................................359 Резюме................................................................359 ГЛАВА 16. Наследование: что в наследство?.............................361 Создание подклассов: совмещаем полезное с приятным....................361 Пример 16.1. Класс FloatFraction.................................... 364 Как это работает....................................................368 Упражнения..........................................................368 Проблемы с классом FloatFraction.................................... 369 Конструкторы по умолчанию для подклассов........................... 370 Конструкторы копирования для подклассов...............'.............370 Функция присваивания для подклассов.............................. 370 Добавление недостающих конструкторов................................370 Разрешение конфликтов типа с базовым классом........................371 Пример 16.2. Завершенный класс FloatFraction..........................372 Как это работает....................................................372 Упражнения.........................................................373, Пример 16.3. Класс ProperFraction.....................................373 Как это работает....................................................375 Упражнения..........................................................377 Закрытые и защищенные члены...........................................377 Пример 16.4. Содержащиеся члены: Fractionunits........................379 Как это работает.................................................. 380 Упражнение........................,.................................382 Резюме................................................................382
Содержание 17 ГЛАВА 17. Полиморфизм: независимость объекта................. 384 Другой подход к классу FloatFraction.......................... 384 Виртуальные функции на выручку!................................386 Пример 17.1. Исправленный класс FloatFraction..................388 Как это работает.............................................390 Усовершенствование кода......................................391 Упражнение.................................................. 392 «Чистые» виртуальные функции и другие загадки..................392 Абстрактные классы и интерфейсы................................394 Почему cout не является истинно полиморфным....................395 Пример 17.2. Истинный полиморфизм: класс Printable.............396 Как это работает........................................... 398 Упражнение...................................................399 Последнее слово (или два)........:............................ 400 Самое последнее слово........................................... 401 Резюме...........................,.............................402 Приложение А. Операторы C++.....................................404 Приложение Б. Встроенные типы данных......................... 408 Приложение В. Краткий обзор синтаксиса C++.....................410 Литерные константы.............................................410 Синтаксис элементарного выражения............................. 410 Синтаксис основного утверждения................................411 Управляющие структуры..........................................411 Специальные управляющие операторы..............................414 Объявления данных..............................................414 Объявления функций.............................................415 Объявления класса..............................................415 Приложение Г. Коды ASCII.......................................417 Приложение Д. Основные библиотечные функции....................419 Строковые функции..............................................419 Функции преобразования данных................................. 420 Односимвольные функции.........................................421 Математические функции.......................................... 421 Рандомизация............................................... 423 Приложение Е. Глоссарий.....ь..................................424
Предисловие Работая в компании Microsoft на протяжении десяти дет, я обнаружил, что высококласс- ные программисты (мы их называли «инженеры по разработке программного обеспече- ния») - это очень интересный тип людей. Если только вы сумеете заставить их разот- кровенничаться и заговорить о своих проектах, они могут очень ясно и эмоционально рассказывать. Главное - преодолеть начальный психологический барьер и убедить их, что вы разгова- риваете с ними на одном языке. Опытные программисты иногда делят мир на две части: на тех, кто является «технарями», и тех, кто не является ими. Иногда кажется, что между этими группами существует зияющая пропасть, как между людьми с абсолютным слу- хом и глухими. В наши дни границей подобного разделения для программистов является умение про- граммировать на языке C++. Это мнение основывается на том, что язык C++ считается тяжело изучаемым. Основная мысль данной книги в том, что язык C++ вовсе не обязательно должен ока- заться трудным. Хотя этот язык зачастую требует большего напряжения сил, чем язык Basic, но, если вовремя и правильно помочь, вы сможете успешно освоить его особенно- сти и трюки. Зачем нужна еше одна книга по языку C++? Существует огромное количество книг, посвященных введению в программирование на языке C++. Но множество их - а, возможно, и подавляющее большинство - является «введением» только в том смысле, что они не предполагают знания языка C++. Обычно предполагается, что вы до этого программировали на другом языке, а еще лучше на не- скольких. Данная книга не основывается на таком предположении. Все, что от вас требуется, - это некоторый опыт работы с компьютером и умение запуска приложений, например тек- стового редактора или программы чтения электронной почты. Если собрать доступные книги по языку C++, для чтения которых не нужен никакой опыт в программировании, ассортимент, из которого можно выбирать, значительно сузится. Что еше нового можно сказать об этой книге? Книга, которую вы держите в руках, делает упор на основах программирования. Даже если вы программировали до этого (скажем, вы проходили начальный курс лекций в университете или колледже), возможно, данная книга пригодится для повторения прой- денного материала. В этой книге подробно исследуется тема, как думать «как програм- мист». .. и зачем именно нужны конкретные особенности определенного языка програм- мирования. Здесь вопрос «зачем» в такой же степени важен, как и вопрос «как». Наилучшего эффекта от обучения люди достигают, когда пользуются преимуществами нескольких обучающих методик, которые подкрепляют друг друга. Поэтому каждая те-
Предисловие 19 ма в данной книге начинается с общего обсуждения и коротких примеров программного кода, причем их сопровождают: ✓ Пример полной программы. Обычно я привожу полный пример, который может быть запущен и протестирован. В этой книге акцент делается на небольших примерах, вы- полняющих что-то интересное, полезное и, когда это возможно, забавное. ✓ Упражнения по программированию. Каждый пример сопровождается рядом упраж- нений, в которых нужно изменить пример или написать похожие программы, чтобы с самого начала вы писали код на языке C++. Решения данных упражнений находятся на компакт-диске, поставляемом вместе с книгой, в папке Sample Code and Answers. ✓ Широкое использование иллюстраций. Много так называемых «книг для новичков» вовсе не используют этот подход. Но зачастую я обнаруживал, что правильные ри- сунки могут прояснить абстрактную концепцию. Иногда один рисунок стоит тысячи лекций. ✓ Специальный раздел «Как это работает» для каждого значимого примера в книге. Книги, обучающие программированию, печально известны длинными примерами, сопровождающимися парой абзацев текста. Этот подход здесь не используется. Ис- ходный код полных примеров оформлен таким образом, что все можно увидеть в контексте. Но после каждого примера я возвращаюсь назад и разбираю программу по строкам кода, объясняя, как и почему каждый бит программы делает именно то, что он делает. Несколько путей изучения: который для вас лучше? Помимо тех методов изучения, которые были только что описаны, в книге содержится много Вставок, где более любознательный читатель найдет дополнительные сведения и объяснения, почему функции языка C++ работают именно так, как они работают. Если же вы жаждете просто заставить работать программы на языке C++, то можно просто пропустить эти Вставки и вернуться к ним позднее. Одним из преимуществ этой книги является то, что она совмещает в себе несколько путей изучения. В отличие от некоторых других, эта книга не начинается с исчерпывающего описания всех особенностей языка, таких как типы данных, управляющих структур и операторов. Это было бы похоже на попытку изучить французский язык, запоминая множество раз- розненных слов, но без обучения говорить законченные фразы. Эта книга нацелена на то, чтобы тотчас же заставить работать реальные программы. В то же время полезно иметь доступ к исчерпывающей и одновременно сжатой сводке особенностей языка. В данной книге такая сводка собрана в виде ряда удоб- ных приложений. Что, если у вас уже есть знания в области программирования? Если вы знакомы с другим языком программирования, но в языке C++ вы - новичок, это не является проблемой. Некоторые идеи в программировании никогда не устареют: что означает думать «как программист», что происходит «под поверхностью», почему язык
20 C++ без страха был сконструирован именно таким способом. Этот обзор основ программирования в любом случае может быть интересен. Но если вы так не считаете, можете пропустить первую и вторую главы. Язык C++ довольно скоро потребует от вас напряжения сил. Что не рассматривается в книге? Цель данной книги заключается в том, чтобы основательно и всесторонне ознакомить вас с языком C++, включая возможности объектно-ориентированного программирования (классы и объекты), которые, хотя и являются относительно непростой темой, составля- ют самое сердце языка C++. Цель книги не в том, чтобы досконально изучить синтаксис языка или детально описать, как именно каждая языковая конструкция выполняется компь- ютером (т.е. как это реализовано), хотя в некоторых случаях я и буду это обсуждать. На мой взгляд, в большинстве книг для новичков заключена одна ошибка, которая со- стоит в попытке осветить абсолютно все непонятные моменты языка, хотя достаточно места для рассмотрения этих тем уделяется в книгах среднего и углубленного уровня. В том случае, если вы являетесь экспертом в программировании на языке C++, или же ведущим специалистом, внимательно рассматривающим эту книгу, или вы имеете неко- торое представление о границах языка, ниже представлена сводка того, что есть в языке C++, но что не рассматривается в этой книге. (Считайте это своеобразной «предупреж- дающей формулировкой для программистов», наподобие предупреждающих юридиче- ских формул в предисловиях книг.) ✓ Битовые поля и операции с битами. Операции с битами порой могут быть полезны в программах, которые должны использовать чрезмерно ограниченное пространство, но, вообще говоря, необходимость в битовых операциях возникает редко. Эта тема отлично подходит для углубленного изучения. Также я не рассматриваю ключевое слово union (еще одна возможность, используемая для уплотнения). ✓ Программирование под Windows и GUI. Это сложные темы, заслуживающие рас- смотрения в отдельной книге (или даже - в трех книгах). Программирование с ис- пользованием среды разработки Visual C++ требует понимания сложной архитектуры и основательных знаний в объектно-ориентированных системах программирования (object-oriented programming systems - OOPS). Прочтя эту книгу, вы получите основ- ные знания в области OOPS. ✓ Шаблоны и STL (Standard Template Library - Стандартная библиотека шабло- нов). Это еще одна тема для книги углубленного уровня. Шаблон - это способ созда- ния обобщенной структуры данных, в которой абстрактный механизм может быть объединен с любым числом определенных типов данных. Первоначально в специфи- кации языка C++ не было возможности определения шаблонов, хотя теперь это явля- ется стандартом. Хотя в этой книге рассматривается обработка исключений - метод реагирования на ошибки во время выполнения (runtime errors), - я не делал акцент на этом, поскольку этот механизм больше подходит к сложным программам и не будет в такой же мере по- лезен новичку.
Предисловие 21 Почему необходимо начинать именно с языка C++? Некоторые люди скажут вам, что язык C++ не подходит для новичков; поэтому, пока вы не относитесь к элите талантливых и опытных программистов, не стоит о нем беспокоиться. Я не согласен с этим. Существует несколько достаточных оснований для изучения языка C++ в начале вашей карьеры программиста. Люди тратили много времени, овладевая сначала языком С. Однако язык С уже редко используется в реальной работе. Теперь студенты изучают этот язык, в основном, как средство для перехода к языку C++. Но в этом мало смысла. Изучая язык С, можно получить некоторые вредные привычки. Лучше всего сразу пе- рейти к языку C++. Язык C++ теперь подходит как для системных программистов, так и для написания коммерческого программного обеспечения - включая игры, графику и программы для решения коммерческих задач. Некоторые другие языки (в особенности язык Visual Basic компании Microsoft) являются более снисходительными. Однако, как и в случае с языком С, язык Basic также может привить вредные привычки в работе с ним. Язык C++ предлагает любому ученику не- сколько уникальных результатов в подарок. ✓ Как и язык С, язык C++ является языком программирования систем (systems- programming language). Изучая язык C++ (по крайней мере, в этой книге), вы много узнаете о том, как и почему выполняются действия в компьютере. ✓ В отличие от языка С, C++ является хорошей реализацией объектно-ориентированного программирования. Это подход к программированию, в котором создаются осмыс- ленные структуры данных, особенно подходящие для таких областей, как програм- мирование графики. Объектно-ориентированное программирование также позволяет определять новые типы, которые на самом деле расширяют возможности самого язы- ка. Изучая объектно-ориентированное программирование, вы узнаете больше о теку- щем состоянии проектирования программного обеспечения и перспективах развития. В первой половине книги рассматриваются фундаментальные основы языка C++: как добиться того, чтобы программа работала и выполняла элементарные задачи. Однако с самого начала вы будете использовать объекты и разбираться в них. Во второй половине книги акцент больше делается на объектно-ориентированном про- граммировании; особое внимание уделяется тому, как использовать его и писать полез- ный - и повторно используемый (reusable) - программный код. Приступая к работе... С книгой поставляется дополнительный бесплатный материал: сопроводительный ком- пакт-диск со свободно распространяемым компилятором, являющимся языковым транс- лятором, который необходим для написания и выполнения программ на языке C++. Все примеры программ в этой книге были несколько раз протестированы с использованием этого компилятора. Примеры также работают с такими компиляторами, как компилятор языка C++ в среде разработки Microsoft Visual Studio.NET, хотя, чтобы использовать эту среду, придется выполнить ряд указаний, данных в главе 1.
22 C++ без страха Чтобы установить свободно распространяемый компилятор C++, просто вставьте ком- пакт-диск в компьютер и следуйте инструкциям, описанным в файле README.TXT, на- ходящемся в корневом каталоге. Этот компилятор является бесплатной версией компилятора GNU C++. Вы можете сво- бодно использовать его для создания и распространения своих программ. Вместе с ком- пилятором также поставляется бесплатная среда разработки; таким образом (это описано в главе 1), вы можете писать программы, а затем компоновать их (переводить в испол- няемую форму), нажав всего лишь комбинацию клавиш. Советы и подсказки: на что обратить особое внимание? Возможно, что языки программирования, основанные на языке С, заслужили свою репу- тацию более сложных, чем остальные, языков из-за присутствия так называемых «сбоев в программе» (gotchas) - то, что застигнет вас врасплох, если рядом с вами нет настав- ника, который поможет вам обойти эти ловушки. Кроме прочего, эта книга касается вопросов ограждения вас от сбоев в программе. Для очень большого количества людей способность программировать приобретается после повторения одних и тех же досадных ошибок снова и снова. Прежде всего, я надеюсь связать воедино все, что касается этого вопроса, по крайней мере, иногда, ради интереса. Разработка программного обеспечения может испытывать ваше терпение при поиске неуловимых ошибок. Однако решения могут быть увлекаю- щими. В нашем новом веке программирование компьютеров стало новым видом квали- фицированной работы, новым способом создания инструментов для мира, основанном на информации.
Благодарности Эта книга существует благодаря усилиям Лоры Льюин (Laura Lewin) (моего агента) и Питера Гордона (Peter Gordon), который твердо верил в необходимости такой кни- ги и который убедил меня написать ее. Питер с самого начала задал направление книги, особенно с учетом роли вопросов языка C/C++ и объектно-ориентированного программирования. Ему помогал Бернард Гэфни (Bernard Gaffney), который выверял черновик книги, начиная с самых ранний стадий представления, рецензирования и переработки. Питеру также помогала Чэнда Лири-Коту (Chanda Leary-Coutu), соста- вившая отличный план маркетинга, а также Лара Вайсонг (Lara Wysong) - координа- тор по вопросам производства. Во время технического редактирования и публикации мне посчастливилось работать с Кати Глидден (Kathy Glidden) из компании Stratford Publishing Services и Брайаном Рай- том (Brian Wright). Они сумели внедрить в книгу изменения, сделанные «в последнюю минуту», как и результаты многочисленных дискуссий о ее стиле, несмотря на сжатые сроки сдачи книги. Много людей внесли свой вклад в эту книгу, выполняя техническое рецензирование и тщательное рассмотрение каждой строки кода - тем самым избавив автора от необходи- мости слышать об определенных ошибках после поступления книги в печать. (Ничто не совершенно, но стандартом для этой книги стал принцип «отсутствия дефектов» (zero defect), в силу которой мы избавились от очевидных ошибок.) Некоторые из этих людей знакомы мне только по фамилиям, но среди наиболее полезных рецензентов были Мэри Дэйджфорд (Mary Dageford), Билл Локк (Bill Locke), Шона Келли (Shauna Kelly) и Мэ- тью Джонсон (Matthew Johnson). Предложение Мэтью по улучшению алгоритма вычисления наибольшего общего дели- теля было особенно полезным. Мне казалось, что я изучил этот алгоритм в математиче- ском классе с углубленным изучением предмета, однако с того момента прошло больше лет, чем я мог предположить. Дик Бауэр (Dick Bower) создал предметный указатель, а также нашел несколько неболь- ших ошибок, которые пропустили все остальные. Он доказал, что просматривал текст крайне внимательно. Также я хотел бы поблагодарить инженера компании Microsoft Джона Беннета (John Bennett), который раскрыл механизмы индексирования массива и генерации случайных чисел. И, наконец, хочу особенно поблагодарить моих двоюродных братьев Даррена (Darren) и Кевина Оук (Kevin Оке), которые одними из первых высказали свое мнение и оказали мне поддержку.
ГЛАВА 1. Ваши первые программы на языке C++ На самом деле, в программировании на языке C++ нет ничего страшного. Как и все язы- ки программирования, язык C++ просто является способом передачи компьютеру логи- чески точных инструкций. Язык C++ может стать сложным до такой степени, до какой вы пожелаете, однако вначале необходимо научиться использовать его для решения фундаментальных задач программирования. Таков подход, используемый в книге. В первых разделах я делаю обзбр основных принципов программирования. Если вы уже программировали на другом языке, возможно, вы захотите пропустить эти разделы или бегло просмотреть их. Но даже если вы решили не пропускать ни одного раздела, обе- щаю не быть слишком скучным. Мыслить «как программист» Программирование может быть очень похожим на прочую деятельность, которой вы когда-либо занимались. В основном, здесь вы просто даете инструкции - однако делаете это логическим, систематическим способом. Компьютеры делают только то, что вы им скажете Компьютеры делают только то, что вы им скажете: это является важнейшим правилом данной книги, особенно если вы - новичок в программировании. Используя язык про- граммирования, например C++, Visual Basic, Pascal или FORTRAN, вы даете компьютеру список действий, которые необходимо выполнить; этот список действий и является про- граммой. Когда-то я работал в компьютерной лаборатории в городе Такома, штат Вашингтон (из- вестном как самый стрессовый город в Америке). Один из студентов, невысокий, в со- ломенной шляпе и старой одежде, каждый день приходил ко мне со стопкой результатов скачек и говорил, что если бы мы смогли внести эту информацию в компьютер, то могли бы выиграть миллионы долларов, позволив компьютеру угадывать лошадей, которые победят. На самом деле это работает не так. Безусловно, компьютеру требуется информация - это данные (data) для программы. Но компьютер также должен знать, что делать с этими данными. Инструкции, которые предписывают компьютеру, что нужно делать (по неко- торым причинам это будет тщательно рассмотрено позднее), называются кодом (code) программы. Определите, что программа будет делать Итак, чтобы компьютер выполнил какое-то действие, ему необходимо точно сказать, что делать. Вплоть до настоящего времени вы, возможно, использовали компьютер для выполнения программ, которые были написаны для вас другими людьми (такими, как Билл Гейтс и его друзья). В этом качестве вы были конечным пользователем (end user) - для кратко- сти обычно именуемый пользователем (user).
ГЛАВА 1. Ваши первые программы на языке C++ 25 Теперь, когда вы будете писать программы сами, вы продвинетесь на следующий, более высокий уровень - программистов. Теперь вы сами будете решать, какие действия будет выполнять программа. И вы сделаете так, чтобы эти действия были выполнены. Но компьютер - даже больше, чем Дастин Хоффман (Dustin Hoffman) в фильме «Чело- век дождя» (Rain Man) - является чрезвычайно умственно отсталым существом. Он ни- когда не догадается, чего от него хотят. Он никогда не сделает независимых выводов. Компьютер - чрезвычайно буквален и будет выполнять в точности только то, что вы ему говорите, каким бы глупым это не было. Поэтому следует быть осторожным, когда го- ворите ему, что вы имеете в виду. Вы даже не сможете дать компьютеру команду, которая может показаться-относительно понятной для человека, например: «Переведи число из шкалы температуры Цельсия в число градусов по Фаренгейту». Даже если это чрезвычайно просто. Вместо этого вы должны быть более конкретным, написав шаги, похожие на следующие: > Напечатать сообщение «Введите температуру по Цельсию: ». > Получить число, введенное с клавиатуры, и сохранить его в переменной ctemp. > Преобразовать введенное число в число из шкалы Фаренгейта, используя формулу ftemp = (ctemp * 1.8) + 32. > Напечатать сообщение «Температура по Фаренгейту: ». > Напечатать значение переменной ftemp. Но если даже для выполнения такой простой задачи необходимо потратить столько уси- лий, зачем утруждать себя? Ответ заключается в том, что однажды написанную про- грамму можно выполнять снова и снова. И хотя для написания программ требуется вре- мя, обычно программы выполняются молниеносно. Запись эквивалентных инструкций для языка C++ После того как вы точно определили, что будет делать программа шаг за шагом, необхо- димо записать эквивалентные инструкции языка C++. Грубо говоря, инструкция - это эквивалент английского предложения в языке C++; она может выполнить одно или больше действий или создать какие-либо данные - что вы и увидите далее в этой главе. Например, допустим, что мы хотим, чтобы программа, рассмотренная только что, вы- полнила следующее: > Напечатала сообщение «The Fahrenheit temperature is: », т.е. «Введите температуру по Цельсию: » > Напечатала значение переменной ftemp. Мы преобразуем эти шаги в следующие инструкции языка C++: cout « "The Fahrenheit temperature is: cout << ftemp; Помните, что цель программирования заключается в том, чтобы заставить компьютер выполнить ряд специфических задач. Однако компьютер понимает только свой родной язык - машинный код (machine code), состоящий из единиц и нулей. В 1950-х годах про-
26 C++ без страха граммисты записывали инструкции именно в машинном коде, и этот процесс был чрез- вычайно сложным и трудоемким. Чтобы сделать написание программ более простым, инженеры по вычислительной технике разработали языки программирования, например FORTRAN, Basic и С, которые позволяли писать программы, обладавшие, по крайней мере, сходством с английским языком. Чтобы написать программу, вы можете начать с написания псевдокода (pseudocode) - этот подход я часто использую в этой книге. Псевдокод похож на английский язык, од- нако он описывает действие программы систематическим способом, который отражает логическую схему выполнения программы. Ниже представлен пример программы, напи- санной при помощи псевдокода. Если (оператор If) а больше чем b Печать (оператор Print) “a is greater than b.” («а больше, чем Ь») Иначе (оператор Else) Печать (оператор Print) “a is not greater than b." («а не больше, чем Ь») После того, как вы написали псевдокод, осталось совсем немного до получения про- граммы, написанной на языке C++. Все, что для этого необходимо, - отыскать для каж- дого действия соответствующие инструкции языка C++. •if (а > Ь) cout <<,"а is greater than b."; ' else cout << "a is not greater than b."; Преимущество любого языка программирования заключается в том, что он следует пра- вилам, которые не допускают неопределенности. Инструкции языка C++ являются на- столько точными, что могут быть преобразованы в машинный код, состоящий из единиц и нулей, без каких-либо допущений. Не должно стать неожиданностью, что языки программирования имеют строгие синтак- сические правила. Эти правила являются более согласованными (и, обычно, более про- стыми), чем правила человеческого языка. Время от времени я резюмирую эти правила. Вот, например, синтаксис инструкции if-else: if (условие) инструкция else инструкция Слова, выделенные полужирным шрифтом, являются ключевыми словами (keywords); они должны быть вставлены в программу именно так, как показано. Слова, выделенные курсивом (также называемые заменителями (placeholders)), представляют инструкции, которые вы вставляете на их место. Приложение, которое преобразует инструкции языка C++ в машинный код, называется компилятором (compiler). Компиляторы более подробно будут рассмотрены в разделе «Генерация программы на языке C++». Однако для начала давайте рассмотрим несколь- ко ключевых определений.
ГЛАВА 1. Ваши первые программы на языке C++ 27 Некоторые специфические определения - обзор Мне хочется избежать жаргонизмов. Мне действительно хочется этого. Но давайте бу- дем искренними. Когда вы начинаете изучать программирование, вы оказываетесь в ми- ре, в котором необходима новая терминология. Ниже приведены некоторые определе- ния, которые необходимы для выживания в этом новом мире. Приложение (application) По существу является тем же, что и программа, но только с точки зрения пользователя. Приложение - это программа, которую выполняет пользователь, чтобы выполнить ка- кую-то задачу. Текстовый редактор -.это приложение; Интернет-браузер или система управления базой данных также являются приложениями. Даже компилятор (см. ниже) является приложением, хотя и весьма специфическим, поскольку используется про- граммистами. Проще говоря, когда программа написана, скомпонована и протестирова- на, она становится приложением. Код (code) Еще один синоним для «программы», но с точки зрения программиста. «Код» представ- ляет собой ряд инструкций и поддерживаемый синтаксис, что и составляет программу; это определение может относиться или к машинному коду (единицы и нули), или к ис- ходному коду (source code) (инструкции языка C++). Термин «код» пришел из того вре- мени, когда все программисты писали программы, используя машинный код. Каждая машинная команда закодирована уникальной комбинацией единиц и нулей и поэтому является кодом компьютера для выполнения какого-то действия. Программисты про- должают говорить о «коде», даже когда используют такие языки программирования, как C++, Java, FORTRAN или Visual Basic. (За более детальной информацией обратитесь к определению исходный код). Термин «код» иногда используется для отличия пассивной информации в программе (ее данных).и части программы, которая выполняет действия (ее кода). Компилятор (compiler) Языковый транслятор, который на входе принимает инструкции языка C++ (исходный код на языке C++), а на выходе получается программа в машинном коде. Данная транс- ляция необходима, поскольку компьютер - его центральный процессор (CPU) - понима- ет только машинный код. Данные (data) Информация, сохраняемая программой, которая будет обрабатываться или отображать- ся. На самом базовом уровне эта информация состоит из слов и/или чисел (хотя она мо- жет быть организована в гораздо более интересные типы данных, называемые «класса- ми» и «объектами»). Машинный код (machine code) Собственный язык центрального процессора, в котором каждая машинная команда со- стоит из уникальной комбинации (или кода) единиц и нулей. Можно программировать, используя машинный код, однако это требует поиска каждой команды, а также необхо-
28 C++ без страха димы знания архитектуры центрального процессора - оба вопроса выходят за рамки рас- смотрения этой книги. Языки, наподобие языка C++, обеспечивают возможность написания программ, которые близки к английскому языку, однако остаются логически достаточно точными, чтобы иметь возможность их перевода в машинный код. Язык C++ также обеспечивает и дру- гие полезные возможности. Программа (program) Ряд команд, которые будут выполнены компьютером над исходными данными. Как я отме- чал ранее, для написания программы может потребоваться немало времени, однако однажды написанная программа выполняется молниеносно и ее можно запускать снова и снова. Исходный код (source code) Программа, написанная на языке программирования высокого уровня, например C++. Исходный код состоит из инструкций языка C++, которые и составляют программу. Перед тем как выполнить исходный код на компьютере, он должен быть преобразован в машинный код. Машинный код, как было замечено ранее, состоит из единиц и нулей, однако обычно он представляется в шестнадцатеричном коде (с основанием 16), поэтому машинный код похож на следующее: 08 А7 СЗ 9Е 58 6С 77 90 Непонятно, что делает этот код, не правда ли? До тех пор пока вы не найдете все коды команд, такая программа непонятна - и поэтому уже очень мало людей используют ма- шинный код для написания программ. В отличие от этого, исходный код, по крайней мере, имеет некоторое сходство с английским языком. Например, исходный код на язы- ке C++ выглядит как: if (salary < 0) print_error_message() ; Инструкция (statement) Обычно одна строка в программе на языке C++. Грубо говоря, инструкция в языке C++ соответствует предложению в естественном языке, например английском. Язык C++ также поддерживает сложные структуры, состоящие из одной или нескольких инструк- ций поменьше; грубо говоря, эти структуры соответствуют сложносочиненным предло- жениям в английском языке. Большинство инструкций языка C++ выполняют одно дей- ствие, некоторые - несколько. Пользователь (user) Человек, который запускает программу, - то есть это человек, который использует ком- пьютер для выполнения чего-то полезного, например редактирования текстового файла, чтения электронной почты, «прогулки» в Интернете или расчета баланса чековой книж- ки. Более официальное название для пользователя - конечный пользователь (end user). Когда я работал в компании Microsoft, пользователь создавал большинство проблем в этом мире, однако одновременно он оплачивал все чеки и служил источником всей прибыли. Когда вы начнете разрабатывать серьезные программы, вы должны будете внимательно учитывать требования пользователя и пытаться предвидеть все, что может пойти «не так».
ГЛАВА 1. Ваши первые программы на языке C++ 29 Хотя для программиста является привычным смотреть на пользователя свысока, первым пользователем программы почти всегда является... сам программист! После того как вы написали программу, вы, возможно, станете первым человеком (а иногда и единствен- ным), который запустит и протестирует ее. Поэтому помните, что вы всегда одновре- менно являетесь и пользователем, и программистом. Чем отличается язык C++? Большинство реалий языка C++, о которых я только что рассказал, применимы также и к другим языкам программирования, например'Pascal, Java, FORTRAN и Basic. Эти языки являются языками программирования высокого уровня (high-level languages)', а это озна- чает, что они не следуют непосредственно машинному коду, а используют определен- ные ключевые слова (например «if» и «while»), имеющие сходство с английским язы- ком, хоть и отдаленное. v Но если все эти языки по существу выполняют одно и то же (обеспечивают более простой способ написания программ, чем это делается на машинном коде), то почему их так много? Каждый из языков был разработан для различных целей. Язык Basic, например, был спроектирован для простого изучения и использования. В результате он разрешал сво- бодный синтаксис, который, к сожалению, мог привести к вредным привычкам в про- граммировании. Тем не менее компания Microsoft разработала язык Visual Basic и превра- тила его в мощный, удобный и быстрый инструмент для разработки приложений для платформы Windows. Язык Pascal был разработан для использования в образовательных учреждениях для обучения сложным концепциям программирования. Если язык Basic является быстрым, с нестрогими синтаксическими правилами, то язык Pascal уже является продуманным и наполнен сложным синтаксисом. Это хороший язык, но большинство программистов предпочитает тот язык, где существует меньше ограничений. Язык С изначально разрабатывался для написания операционных систем. В то время как его синтаксис более структурирован (и имеет лучшие особенности), чем у языка Basic, язык С является чистым языком, поддерживающим сокращения и позволяющим писать более сжатые программы. Несложный и в то же время всеобъемлющий синтаксис языка С за годы использования стал чрезвычайно популярным среди программистов. Другим пре- имуществом языка С является то, что он накладывает не много ограничений, поэтому все, что можно в машинном коде, почти всегда можно выполнить с помощью языка С. Что же насчет языка C++? Главное отличие языка С от языка C++ заключается в том, что язык C++ реализует воз- можность объектно-ориентированного программирования. Этот подход особенно хо- рошо подходит для работы со сложными системами, например графическими интерфей- сами пользователя и сетевыми средами. Как программист, использующий объектно- ориентированный подход, вы могли бы задать следующие вопросы: > Какие основные типы данных (то есть информация) необходимы для решаемой проблемы? > Какие операции должны быть определены для каждого типа данных? > Как объекты данных взаимодействуют друг с другом?
30 C++ без страха Вставка Как насчет языков Java и С#? Когда в. конце 1980-х годов стало модным объектно-ориентированное программиро- вание, было сделано несколько попыток для создания объектно-ориентированной версии языка С. Бьерн Страуструп (Bjarne Stroustrup) создал первый подобный язык, получивший широкое распространение. Этот язык, C++, широко используется и в наши дни (обстоятельство, которому обязано существование этой книги). Но язык C++ вовсе не является последним словом в разработке объектно- ориентированной версии языка С. Два новых языка - Java и C# - достаточно похожи на языки С и C++, чтобы называться «основанными на языке С» (C-based), но каждый из этих языков имеет небольшие отличия. Существует ряд отличий между этими тремя языками. Язык C++ разрабатывался с условием, чтобы по большей части быть обратно совместимым с языком С, и некото- рые трюки кодирования, используемые в языке С (некоторые из них резко критику- ются современными экспертами), продолжают работать в языке C++, но в языках Java и C# они просто немыслимы. Языки Java и C# являются инструментами для создания приложений и не подходят для написания операционных систем. И хотя они позаимствовали большую часть синтаксиса языков C/C++, они, например, не позволяют получать доступ к произ- вольным адресам в памяти. Также некоторые люди считают, что эти языки являются более точными реализациями объектно-ориентированного программирования. Что касается каждого из языков, различия в синтаксисе языков Java и C# не такие уж и большие. Язык Java был разработан компанией Sun Microsystems в качестве языка, не зависящего от платформы; язык C# был разработан компанией Microsoft для ее платфор- мы .NET. Языки отличаются по платформе и по поддерживаемым ими библиотекам. Хотя я упомянул, что язык C++ является инструментом для написания операционных систем, вы также легко можете использовать его для создания коммерческих и игро- вых программ, а также программ для собственных нужд. Язык обеспечивает боль- шую свободу, чем некоторые другие языки, включая возможность допускать ошибки на низком уровне. Именно поэтому я пытаюсь помочь вам обойти потенциальные «сбои в программе». Приятной новостью является то, что если вы изучите язык C++, то изучить языки Java и C# будет гораздо легче. Язык C++ проще изучать людям, у которых есть опыт программирования на языке С. Изучая объектно-ориентированное программирование, я обнаружил, что будет гораздо про- ще, если вы сначала освоите основные инструкции синтаксиса. Таким образом, я не сосредо- точиваюсь очень сильно на объектно-ориентированном подходе вплоть до главы 10. Но я представляю некоторые объекты - порции данных, которые могут реагировать на операции, - в самом начале книги. Например, в этой главе я использую объект cout, объект данных, не являющийся частью языка С' В языке С печать информации произво- дится посредством вызова функции,, которая является заранее определенной последова-
ГЛАВА 1. Ваши первые программы на языке C++31 тельностью инструкций. Но при использовании объекта cout вы отправляете данные в объект - в прямом смысле - который знает, как отображать информацию. Поэтому вместо того, чтобы думать «Я вызову функцию, которая напечатает этот текст на экране», вы думаете «Я отправлю этот текст в объект cout, который представляет вывод данных на консоль, контрольный экран компьютера, и пусть этот объект заботит- ся о том, как выполнить печать». Это оказывается лучшим способом выполнения задачи по ряду причин - некоторые из них более очевидны, а другие - менее. В частности, объект cout (объект вывода на кон- соль) знает, как печатать множество типов данных, и - что более важно - эти знания мож- но распространить вообще на любые новые типы данных. Объектно-ориентированный подход не ограничивается узкими наборами форматов данных, которые используются в старомодном подходе языка С. Что означает отправлять команды объекту и чем это отличается от старомодного про- граммирования - один из основных вопросов, рассматриваемых в данной книге; мы со- средоточимся на этом во второй половине книги. Генерация программы на языке С++ Написание программы на самом деле является только первым шагом в создании прило- жения. В следующих разделах я опишу все шаги, через которые нужно пройти. Ввод инструкций программы Чтобы написать программу на языке C++ (или фактически большую часть программ другого вида), необходимо каким-то образом ввести инструкции программы. Для этого существует пара способов: ✓ Можно использовать текстовый редактор, например Microsoft Word или Notepad, при- чем последний поставляется вместе с операционной системой Windows. На самом де- ле, это можно сделать с помощью почти любого текстового редактора. Если вы ис- пользуете этот подход, то должны сохранить документ (точнее, исходный файл) как обычный текст. ✓ Можно ввести текст в интегрированной среде разработки (IDE - Integrated Develop- ment Environment). Среда разработки представляет собой текстовый редактор, со- вмещенный с другими полезными инструментами программирования. Приложение Microsoft Visual Studio является такой средой разработки. После того как вы ввели инструкции программы (и проверили их на наличие ошибок), можно переходить к компоновке программы. Генерация программы (Компиляция и сборка) Генерация программы - это процесс преобразования вашего исходного кода (инструк- ции языка C++) в приложение, которое может выполняться. Если программа написана правильно, этот процесс обычно так же прост, как нажатие на функциональную клави- шу. На самом деле, процесс разбивается на два этапа.
32 C++ без страха На первом этапе программа компилируется', это означает, что исходный код на языке C++ преобразуется в машинный код (также называемый «объектным кодом»). Если этот этап выполнен успешно, то на следующем этапе запускается программа-сборщик, или линкер (linker), который компонует полученный машинный код с кодом библиоте- ки языка C++. Библиотека языка C++ (в технических кругах также называемая «библиотекой испол- няющей системы») содержит функции, которые вызываются для выполнения общих задач. (Функция - это другое название подпрограммы). Например, библиотека содер- жит стандартную функцию sqrt (квадратный корень), так что вам не придется само- му рассчитывать квадратные корни. Библиотека также содержит подпрограммы, кото- рые посылают данные на монитор и знают, как читать или записывать файлы данных на жесткий диск. На приведенном рисунке показан процесс генерации программы. Помните, что если вы используете интегрированную среду разработки, то для вас эти этапы автоматизирова- ны; вы просто нажимаете функциональную клавишу. Если генерация программы прошла успешно, можете себя поздравить; ни компилятор, ни линкер не обнаружили ошибок. Но значит ли это, что все закончено? Не совсем. Компилятор находит грамматические (синтаксические) ошибки. Но существует множе- ство ошибок, которые компилятор не в силах обнаружить. Рассмотрим такую аналогию. Предположим, у нас есть следующее предложение: The moon is made green cheese. (Луна сделана зеленый сыр). В английском языке это предложением является грамматически неправильным. Чтобы поправить грамматику, необходимо вставить предлог «of»: The moon is made of green cheese. (Луна сделана из зеленого сыра). Теперь в предложении нет синтаксических ошибок. Но если предложение грамматиче- ски правильно, обязательно ли это означает, что утверждение верно в более широком смысле - то есть фактически является правдивым утверждением? Конечно же, нет. В этом случае, чтобы сделать предыдущее утверждение правдивым, необходимо вста- вить слово «not». The moon is not made of green cheese. (Луна не сделана из зеленого сыра).
ГЛАВА 1. Ваши первые программы на языке C++ 33 Языки программирования устроены похожим образом. Компилятор языка C++ опреде- ляет, правильно ли оформлена программа с точки зрения синтаксиса; если нет, то ком- пилятор указывает на конкретную строку, в которой произошла ошибка. Однако ответ на более важный вопрос, работает ли программа правильно во всех случаях, не так уж и очевиден. Что и приводит нас к следующему этапу. Тестирование программы После того как вы успешно сгенерировали программу, необходимо выполнить ее не- сколько раз, чтобы убедиться, что она делает именно то, что вы хотите от нее. В случае с серьезной программой - программой, которая будет передана или продана другим лю- дям - вам может потребоваться протестировать ее множество раз. (На самом деле, в крупных компаниях, разрабатывающих программное обеспечение, существуют целые департаменты, которые не занимаются ничем, кроме тестирования.) .Ошибки, которые вы ищете на данном этапе, называются ошибками логики программы (program-logic errors). В этом случае вы правильно использовали синтаксис языка (на- пример, в исходном коде программы нет неправильно расположенных запятых), однако по какой-то причине, программа не работает так, как вы хотите. Ошибки логики программы могут быть куда более трудноуловимы, чем синтаксические ошибки. Предположим, что программа печатает неправильное число или внезапно пре- рывает свое выполнение без видимой причины. Какая именно инструкция привела к ошибке? Ответ не всегда очевиден. Может быть множество причин; например, вы сде- лали предположения, которые верны только в некоторых случаях, но не во всех. Процесс тестирования программы и определения источника проблемы называется ее отладкой (debugging). Исправление по мере необходимости Если программа выполняется правильно, то работа завершена. Но если в программе присутствуют ошибки логики, как было описано выше, необходимо определить источ- ник ошибок, вернуться назад и внести изменения в исходный код на языке C++, после чего снова сгенерировать программу. Для логически сложных участков программного обеспечения вам может потребоваться пройти через этот цикл множество раз. Для тестирования такой программы могут пона- добиться большие усилия, чтобы убедиться, что во всех случаях программа функциони- рует правильно. Пока вы не закончите тестирование и исправление, программа в дейст- вительности не является завершенной. Но в случае с простыми программами обычно можно ограничиться более умеренным уровнем тестирования. 2 - 6248
34 C++ без страха Установка вашего собственного компилятора языка C++ Если у вас есть собственный компилятор языка C++, вы можете использовать его для компиляции и запуска примеров из этой книги. Чтобы добиться наилучших результатов, необходимо использовать последнюю версию языка C++, однако примеры написаны таким образом, что могут работать с множеством компиляторов языка C++. Вы также можете установить компилятор GNU C++, который находится на компакт- диске, поставляемом вместе с книгой. Это свободно распространяемый, условно бес- платный компилятор для среды MS-DOS; из операционной системы Windows вы можете запустить этот компилятор, открыв сначала окно командной строки MS-DOS. Вы можете свободно распространять любые программы, созданные с использованием компилятора GNU. На компакт-диске также находится и среда разработки RHIDE, которую можно использовать для написания и тестирования программ. Чтобы установить компилятор, найдите файл README.TXT, находящийся в корневой папке компакт-диска, и следуйте инструкциям, содержащимся в этом файле. На ком- пакт-диске также находится папка Sample Code and Answers. Ее вложенные папки со- держат ответы на все упражнения данной книги. На компакт-диске также находятся исходные файлы для компилятора. Нет необходимости устанавливать их до тех пор, пока они вам не понадобятся.
ГЛАВА 1. Ваши первые программы на языке C++ 35 Пример 1.1. Печать сообщения Чтобы начать программировать, откройте новый исходный файл и введите код, пред- ставленный ниже. Если вы используете среду разработки RHIDE, выберите в меню ко- манду File ♦ New (Файл ♦ Новый). Затем напечатайте код. Если вы используете прило- жение Microsoft Visual Studio, следуйте инструкциям, которые я привожу в разделе «Если вы используете приложение Microsoft Visual Studio». В приложении Visual Studio придется выполнить несколько других действий, поэтому не ожидайте, что ваш код правильно скомпилируется до тех пор, пока вы не обратитесь за справкой к этому разделу. Листинг 1.1. printl.cpp #include <iostream> using namespace std; int main() { cout << "Never fear, C++ is here!"; return 0; J' В пятой строке, начинающейся с cout, нет необходимости делать отступ фиксированным количеством пробелов. Также, промежуток между словами и пунктуацией (например «<<») может включать любое количество пробелов. Но вы должны обратить особое внимание на пару вещей: во-первых, регистр («боль- шие/маленькие» буквы) имеет значение. Язык C++ привередлив к буквам в верхнем ре- гистре, в отличие от букв в нижнем регистре. Не печатайте заглавными буквами ничего, кроме текста в кавычках. Во-вторых, убедитесь, что вы не забыли поставить точку с за- пятой (;) в конце второй, пятой и шестой строки. После ввода программы сохраните ее под именем print-!, скомпилируйте и запустите. Вот что напечатает правильно введенная и запущенная программа: Never fear, C++ is here! (Что означает в вольном переводе: «Ничего я не боюсь, если рядом СИ-ПЛЮС-ПЛЮС») Если вы используете среду RHIDE Если вы установили условно бесплатную версию языка C++, описанную в предыдущем разделе, здесь приведены шаги, как скомпилировать и запустить программу: > Сохраните программу в файле под именем printl.cpp, если вы еще не сделали этого. (Вы можете выбрать для программы другое имя, но всегда используйте расширение .срр.) В меню среды RHIDE выберите команду File ♦ Save (Файл ♦ Сохранить). > Нажмите клавишу чтобы сгенерировать программу. 2*
36 C++ без страха > Если вы не получили информации о каких-либо ошибках, значит программа была успешно скомпилирована и скомпонована. Поздравляю! Если вы получили сообще- ния об ошйбках, значит, вы либо неправильно установили компилятор, либо допус- тили ошибки при вводе части примера. Вернитесь обратно и проверьте, что вы напеча- тали каждый символ - включая пунктуацию - в точности, как это сделано в примере. > Как только программа будет успешно скомпилирована, протестируйте ее, покинув среду RHIDE. Для этого в меню выберите команду File ♦ DOS Shell (Файл ♦ Оболоч- ка DOS). > Находясь в режиме DOS Shell (Оболочка DOS), наберите на клавиатуре имя про- граммы: printl > После того как вы завершите тестирование программы, наберите на клавиатуре: exit | Можно также запускать программы прямо из среды RHIDE, но после запуска программы управление тотчас же возвращается к среде, поэтому пока про- | грамма не сделает паузу при выполнении, вы не сможете хорошо увидеть вы- I вод программы. Вот почему я рекомендую использовать команду DOS Shell J (Оболочка DOS). Если вы используете приложение Microsoft Visual Studio Если для написания программ на языке C++ вы используете среду разработки Microsoft Visual Studio, существует пара вещей, которые необходимо сделать. Среда Visual Studio является превосходным инструментом для написания программ, но разрабатывалась она главным образом для создания серьезных приложений для операционной системы Win- dows, а не простых программ (которые необходимо писать на первых порах, если вы новичок в языке C++). Чтобы написать программу в среде Visual Studio, во-первых, необходимо выбрать пра- вильный тип проекта. (Проект - на жаргоне среды Visual Studio - это все файлы, кото- рые собираются вместе для формирования программы.) > Выберите в меню команду File ♦ File ♦ New (Файл ♦ Файл ♦ Новый). Или можете щелкнуть на кнопке New Project (Новый проект), если в текущий момент она ото- бражается около центра экрана. > Заполните диалог, выбрав в. качестве типа проекта консольное приложение (для этого нужно щелкнуть на значке «Console Application» (Консольное приложение)). Также введите имя программы - в данном случае printl - и щелкните на кнопке ОК. > Если файл printl .срр не отображается, найдите его в списке имен файлов в левой части экрана и дважды щелкните на нем. Перед тем как ввести какой-либо код на языке C++, сначала удалите весь код, который вы видите в файле printl .срр, кроме следующей строки: #include <stdafx.h>
ГЛАВА 1. Ваши первые программы на языке C++ 37 В консольных приложениях (то есть «не \Мпбоууз»-приложениях), создаваемых в среде Visual Studio, эта инструкция должна всегда быть включена. Если вы используете среду Visual Studio для выполнения примеров и упражнений в этой книге, не забывайте всегда вставлять эту строку в начале каждой программы. Исходный код, содержащийся в файле printl.cpp, следовательно, должен выглядеть сле- дующим образом (добавленная строка выделена полужирным шрифтом): #include <stdafx.h> #include <iostream> using namespace std; int mainO { cout << "Never fear, C++ is here!"; return 0; ' } ' . Чтобы сгенерировать программу, просто нажмите клавишу рт|. Будут запущены как компилятор, так и линкер. Если получилось, примите поздравления! Вы на правильном пути. Если программа не сгенерировалась успешно, вернитесь обратно и убедитесь, что вы дословно ввели каж- дую строку. Чтобы после этого запустить программу, нажмите комбинацию клавиш |Гсл1 |+jj^]. Хотя существуют и другие способы запуска программы из среды Visual Studio, этот путь явля- ется единственным, который избегает проблемы с окном MS-DOS, появляющимся на экране для вывода данных и сразу же исчезающим. Комбинация клавиш |Pctn |+[[f5| (Запуск без отладки) выполняет программу, после чего печатает полезное сообщение «Press any key to continue» (Нажмите любую клавишу для продолжения), с тем, чтобы вы имели возможность посмотреть на вывод программы. Как это работает Хотите - верьте, хотите - нет, эта простая программа состоит только из одной настоя- щей инструкции. На данный момент можете считать оставшуюся часть программы шаб- лоном - текст,, который необходимо включать, но можно благополучно игнорировать. (Если вы хотите узнать об этом подробнее, в следующей «Вставке» рассматривается директива #include.) В синтаксисе, представленном ниже, стандартные обязательные элементы выделены полужирным шрифтом. Сейчас не беспокойтесь о том, зачем это нужно, просто исполь- зуйте. Между фигурными скобками ({}) вы вставляете фактические строки програм- мы - которая в данном случае состоит всего из одной инструкции. #include <iostream> using namespace std; int main() { Здесь_находятся_ваши_инструкцииI return 0; }
38 C++ без страха В этой программе только одна фактическая инструкция (которую мы вставляем в пятую строку). cout « "Never fear, C++ is here!"; Что такое cout? Это объект - концепция, которую я буду рассматривать гораздо под- робнее во второй половине книги. Тем временем, все, что вам необходимо знать - объ- ект cout отвечает за «консольный вывод». Другими словами, он представляет экран компьютера. Когда вы посылаете что-либо на экран, оно будет напечатано, как пред- полагалось! В языке C++ вы печатаете вывод с использованием объекта cout и направленный влево «потоковый» оператор («), который представляет поток данных из значения (в данном случае текстовой строки Never fear, C++ is here!) на консоль. Вы никогда не используете объект cout неправильно, если представите этот процесс визуально таким образом. консоль (вывод) "Never fear, C++ is here!" cout « "Never fear, C++ is here!"; He забывайте о точке с запятой (;)• Каждая инструкция языка C++ должна завершаться точкой с запятой, кроме нескольких исключений. По техническим причинам слово cout, должно всегда находиться с левой стороны, ко- гда бы оно ни использовалось. Данные в таком случае перемещаются влево. Используй- те «стрелки», направленные влево, которые фактически представляют собой два знака «меньше», объединенных вместе. В следующей таблице представлены другие простые примеры использования объекта cout. Табл. 1.1 Инструкция (Statement) Действие (Action) cout < < "Do you C++?"; Печатает слова «Do you C++?» cout < < "Hello!"; Печатает слово «Hello!» cout < < "Hi there, sailor!"; Печатает слова «Hi there, sailor!» Упражнения Упражнение 1.1.1. Напишите программу, которая печатает сообщение «Get with the program!» Если желаете, можете работать с тем же исходным файлом, который использовал- ся для похожего примера, и изменить его по мере необходимости. (Подсказка: измените только текст, находящийся в кавычках; в остальном используйте тот же программный код.) Упражнение 1.1.2. Напишите программу, печатающую ваше имя.
ГЛАВА 1. Ваши первые программы на языке C++39 Вставка Как насчет директивы #include и инструкции «using»? Я сказал, что пятая строка программы является первой «фактической» инструкцией программы. Я пропустил первую строку: #include <iostream> Это пример препроцессорной директивы (preprocessor directive) языка C++, обычной команды компилятору языка C++. Директива вида ftinclude <filenames загружает объявления (declarations) и определения (definitions), которые поддержи- вают часть стандартной библиотеки языка C++. Без этой директивы вы не сможете использовать объект с out. Если вы использовали более старые версии языка C++ и С, вы можете поинтересо- ваться, почему не указывается определенный файл (например «,Ь» файл). Имя файла «iostream» является виртуальным включаемым файлом, который содержит информа- цию в предварительно скомпилированном виде. Если вы новичок в языке C++, просто запомните, что необходимо использовать ди- рективу #include для включения поддержки определенных частей стандартной библиотеки языка C++. Позднее, когда мы начнем использовать математические функции, например sqrt (квадратный корень), необходимо будет включить под- держку библиотеки с математическими функциями: #include <math.h> Является ли это дополнительной работой? Да. Мог ли язык C++ быть разработанным без этого? Возможно. Файлы «включений» изначально появились из-за различия ме- жду базовым языком С и стандартной библиотекой исполняющей системы. (Профессиональные программисты на языке C/C++ иногда избегают библиотеку или модифицируют ее.) Функции и объекты библиотеки - несмотря на важность для но- вичков - рассматриваются просто как функции, определенные пользователем, что означает (вы узнаете об этом в главе 4), что функции должны быть объявлены. Это как раз то, для чего нужны файлы включений: они помогают избежать необходимо- сти самому объявлять функции. Вам также необходимо вставить инструкцию using. Это позволит вам обращаться к объектам, например std: :cout, напрямую. Без использования этой инструкции пришлось бы печатать сообщения следующим образом: std::cout << "Never fear, C++ is here!"; Мы собираемся использовать объект cout (и его кузена, объект с in) довольно час- то- как и другой символ из пространства имен std, называемый endl, - поэтому сейчас проще помещать инструкцию using в начале каждой программы.
40 C++ без страха Переход на следующую печатную строку В языке C++ текст, посылаемый на экран, не переходит автоматически на следующую физическую строку. Чтобы выполнить переход, необходимо напечатать символ разде- лителя строки (newline). Если вы не напечатаете разделитель строки, весь текст будет выводиться на одной и той же физической строке. (Исключение: если вы вообще не на- печатаете разделитель строки, текст может быть автоматически перенесен на новую строку, когда текущая физическая строка будет заполнена, но обычно это приводит к визуально безобразным результатам.) Одним из способов напечатать разделитель строки является использование предопреде- ленной константы endl. Как и объект cout, константа endl является частью про- странства имен std: std::cout << "Never fear, C++ is here!" << std::endl; Но поскольку в начале программы вы поместили эту инструкцию: using namespace std; нет необходимости квалифицировать каждое использование объекта cout и константы endl; поэтому вы можете записать инструкцию, выводящую сообщение, как: cout « "Never fear, C++ is here!" « endl; Имя «endl» является сокращением для фразы «end line» (конец строки); следо- вательно, оно читается как «end ELL», а не «end ONE». Другим способом печати разделителя строки является вставка символов \п. Этот сим- вол является управляющей последовательностью, который язык C++ воспринимает как имеющий особое значение, а не трактует его буквально. Результат выполнения следую- щей инструкции будет таким же, как и у предыдущего примера. cout « "Never fear, C++ is here!\n"; Пример 1.2. Печать нескольких строк Программа в этом разделе печатает сообщения на нескольких строках. Если вы следуете вместе с книгой и вводите программы, не забывайте использовать буквы в верхнем и нижнем регистре в точности, как показано в примере, - хотя вы можете изменить капи- тализацию (регистр) текста в кавычках и программа все равно запустится. Листинг 1.2. print2.cpp ttinclude <iostream> using namespace std; int main() { cout << "I am Blaxxon," « endl; cout << "the godlike computer." « endl; cout << "Fear me!" << endl; return 0; _
ГЛАВА 1, Ваши первые программы на языке C++ 41 Сохраните программу под именем print2.cpp, после чего скомпилируйте ее и запустите, как это было описано ранее, в примере 1.1. Как это работает Этот пример похож на первый представленный мной пример. Основное различие заклю- чается в том, что в данном примере используются символы разделителя строки. Если эти символы опустить, результат программы был бы следующим: I am Blaxxon.the godlike computer.Fear те! Однако мы ожидали не этого. Концептуально, приведенный рисунок представляет то, как работают инструкции в программе: "I am Blaxxon1 newline консоль (вывод) cout "I am Blaxxon," « endl; Данным способом можно напечатать любое количество отдельных элементов - хотя, повторюсь, они не будут переведены на следующую физическую строку без символа разделителя строки (endl). Вы можете отправить на консоль несколько элементов, ис- пользуя одну инструкцию. cout << "This is а " « "nice " « "C++ program."; что при запуске выведет следующее: This is a nice C++ program. Или вы можете встроить разделитель строки следующим образом: cout << "This is а" « endl << "C++ program.'1; что напечатает: This is a C++ program. В этом примере, как и в предыдущем, возвращается значение. «Возвращение значе- ния» - это процесс отсылки назад сигнала - в данном случае, операционной системе или среде разработки. Для возврата значения используется инструкция return: return 0; Возвращаемое значение функции main - это код, отсылаемый операционной системе, в которой 0 означает успешное выполнение. Все примеры в данной книге возвращают 0.
42 C++ без страха Возвращаемые значения более полезны для других типов функций, о которых вы узнаете в главе 4. Возвращение значения из функции main - это одна из тех надоедливых вещей, которые с первого взгляда кажутся бесполезными, но которые просто необходимо выполнять. (Примечание: некоторые программы делают выбор возвращать значения, отличные от нуля, указывающие на опре- Л- деленные проблемы.) В данный момент, возвращение нуля из функции main - ' ’ это просто одна из тех вещей, которые необходимо вставлять в программу, чтобы быть уверенным, что программа правильна. «Почему я должен делать это?» - спрашивает ребенок. «Потому что я так сказал», - отвечает отец. Упражнения Упражнение 1.2.1. Удалите разделители строк из примера в данном разделе, но вставьте дополнительные пробелы, чтобы ни одно из слов не слилось с другим. (Подсказка: пом- ните, что язык C++ автоматически не вставляет пробел между выводимыми строками.) Результат выполнения программы должен выглядеть следующим образом: I am Blaxxon, the godlike computer. Fear me! Упражнение 1.2.2. Модифицируйте пример таким образом, чтобы между каждыми ли- ниями печаталась пустая линия - другими словами, сделайте результаты с двойным про- белом, а не с одним. (Подсказка: печатайте два символа разделителя строки после каж- дой текстовой строки.) Вставка Что такое строка? С самого начала я использовал текст, заключенный в кавычки, как в следующей ин- струкции cout « "Never fear, C++ is here!"; Все, что находится вне кавычек, является частью синтаксиса языка C++. Все, что на- ходится внутри, является данными. Фактически все данные, хранящиеся на компьютере, в конечном счете являются цифровыми. Но в зависимости от того, как используются данные, они могут рассмат- риваться в качестве строки, состоящей из печатаемых знаков. И это правда. Возможно, вы слышали где-нибудь про код ASCII. «Never fear, C++ is here!» в этом примере является данными ASCII. Символы ‘N\ ‘е’, ‘v’, ‘е’, ‘г’ и так далее хранятся в отдельных байтах, каждый из которых является числовым кодом, соответствующим печатаемому знаку. Я расскажу гораздо больше об этом типе данных в главе 7. Важно помнить, что текст, заключенный в кавычки, является данными, в отличие от команды. Этот тип данных считается строкой текста или, что более распространено, просто строкой.
ГЛАВА 1. Ваши первые программы на языке C++43 Хранение данных: переменные языка С++ Если бы все, что можно было делать, - это печатать глупые сообщения, то язык C++ не был бы очень полезен. Целью обычно является получение откуда-нибудь новых дан- ных - например, ввод конечным пользователем - а затем выполнение над ними чего- нибудь интересного. Такие операции требуют переменных (variables)-, они представляют собой места, куда можно поместить данные. Вы можете считать переменные волшебными ящиками, в ко- торых хранятся значения. Во время выполнения программа может читать, записывать или изменять эти значения по мере необходимости. Следующий пример использует пе- ременные, называемые ctemp и ftemp, для хранения значений по шкалам термометров Цельсия и Фаренгейта соответственно. ctemp ftemp Как значения помещаются в переменные? Одним из способов является ввод значений через консоль. В языке C++ вы можете вводить значения, используя объект cin, пред- ставляющий (довольно удачно) консольный ввод. Вместе с объектом cin используется потоковый оператор, показывающий перемещение данных вправо (»). консоль (ввод) cin » ctemp; Вот что происходит в ответ на данную инструкцию. (Фактический процесс немного сложнее и включает в себя проверку «входного буфера», но описанные этапы по суще- ству отражают то, как это работает в простой программе.) > Программа приостанавливает выполнение и ждет, пока пользователь введет число. > Пользователь вводит число и нажимает клавишу ||Enter|. > Число принимается и помещается в переменную ctemp (в данном случае). > Программа заканчивает выполнение. Таким образом, если вы размышляли об этом, в ответ на инструкцию происходит многое cin » ctemp; Но перед тем как использовать переменную в языке C++, ее необходимо объявить. Это является абсолютным правилом и тем самым отличает язык C++ от языка Basic, который
44 C++ без страха в этом плане является небрежным и не требует объявления. (Поколения программистов на языке Basic бились головой о свои терминалы, когда находили ошибки, возникшие из-за слабости языка Basic относительно переменных.) Это достаточно важно, чтобы оправдать новую формулировку, поэтому я делаю это ос- новным правилом: В языке C++ перед использованием переменной ее обязательно необходимо * объявить. Чтобы объявить переменную, для начала необходимо знать, какой тип данных использовать. В языке C++, как и в большинстве других языков, это является ключевой концепцией. Введение в типы данных Вы можете считать переменную неким волшебным ящиком, в который можно помещать информацию - или, точнее, данные. Но какой тип данных? Все данные в компьютере, в конечном счете, являются числовыми, но организованы в одном из трех основных форматов: целое (integer), с плавающей точкой (floating-point) и текстовая строка. Целое С плавающей, десятичной точкой Текстовая строка [ "Call me Ishmael'1 ~| Существует несколько отличий между форматом с плавающей точкой и форматом цело- го. Но для первых программ правило простое: о Если необходимо хранить числа с дробной частью, используйте переменную с типом плавающей точки; в остальных случаях используйте целое. Основным типом данных с плавающей точкой в языке C++ является тип double. На- звание может показаться странным: оно означает «double-precision floating point» (С пла- вающей точкой двойной точности). В языке также есть тип с одинарной точностью (float), но он редко используется. Если необходимо иметь возможность сохранять дробные части, наилучшие результаты - и меньше сообщений об ошибках - вы получите в случае использования типа double. Объявление переменной типа double имеет следующий синтаксис. Обратите внимание, что инструкция завершается точкой с запятой (;), так же как и большинство инструкций. double имя_переменной; Объявление типа double можно использовать для создания ряда переменных: double имя_переменной1, имя_переменной2, ...;
ГЛАВА 1. Ваши первые программы на языке C++ 45 Например, данная инструкция создает переменную aDouble типа double; double aDouble; Эта инструкция создает переменную типа double. aDouble Следующая инструкция, использующая более сложный синтаксис, объявляет четыре переменных типа double с именами b, с, d и amount: double Ь, с, d, amount; Результат выполнения данной инструкции эквивалентен следующему: double b; double с; double d; double amount; Результатом этих объявлений является создание четырех переменных типа double. Вставка Почему двойная точность, а не одинарная? Двойная точность похожа на одинарную точность, за исключением того, что двойная точность лучше. Двойная точность поддерживает больший диапазон значений и де- лает это с лучшей точностью. (Да, потеря точности возможна при использовании пе- ременных с плавающей точкой; к этой теме я вернусь в последующих главах.) Поскольку двойная точность более точная, в языке C++ она считается предпочти- тельным типом переменных с плавающей точкой; перед выполнением вычислений с плавающей точкой язык C++ приводит все значения к двойной точности, если они еще не в этом формате. Язык C++ также сохраняет константы с плавающей точкой в формате с двойной точностью, пока вы не укажете другой способ (например, исполь- зуя нотацию 12.5F вместо 12.5). Двойная точность имеет один недостаток: для нее требуется больше места - в част- ности, восемь байт вместо четырех (на персональных компьютерах). Для простых программ это не является даже фактором, поскольку математические сопроцессоры непосредственно поддерживают восьмибайтовые операции. Это является фактором только в том случае, когда имеется большое количество значений с плавающей точ- кой, которые необходимо сохранить на диск. Тогда и только тогда следует рассмат- ривать использование типа с одинарной точностью, типа float.
46 C++ без страха Пример 1.3. Преобразование температуры Не знаю как вы, но я каждый раз, когда еду в Канаду, в уме вынужден преобразовывать температуру из шкалы Цельсия в шкалу Фаренгейта. Если бы у меня был карманный компьютер, было бы замечательно, чтобы он выполнял это преобразование за меня; в этом плане компьютеры хороши. Вот формула преобразований. Звездочка (*), когДа используется для объединения двух значений, означает «умножить на». Fahrenheit = (Celsius *1.8) + 32 В наше время полезная программа не будет все время просто рассчитывать единствен- ное значение температуры и завершаться. В Этом случае было бы проще использовать программу Windows Calculator! Нет, действительно полезная программа будет принимать любое значение температуры по шкале термометра Цельсия и преобразовывать его. Для этого потребуется использование нескольких новых возможностей: ✓ Организовать пользовательский ввод; ✓ Сохранять введенное значение в переменной. Ниже приведена такая программа полностью. Откройте новый исходный файл, наберите код и сохраните его под именем convert.срр. После этого скомпилируйте и запустите программу. Листинг 1.3. convertl.cpp ftinclude <iostream> using namespace std; int mainQ { double ctemp, ftemp; cout « "Input a Celsius temp and press ENTER: "; cin » ctemp; ftemp = (ctemp * 1.8) + 32; cout << "Fahrenheit temp is: " << ftemp; return 0; } Программы проще сопровождать, если в них добавлены комментарии, которые в языке C++ обозначаются двойными значками дроби, слэшами (//). Комментарии игнорируются компилятором языка C++ (то есть они не влияют на поведение программы), однако по- лезны для людей. Ниже приведена версия с комментариями. Листинг 1.4. convert2.cpp #include <iostream> using namespace std; int main() {
ГЛАВА 1. Ваши первые программы на языке C++47 // Объявление переменных с плавающей точкой. double ctemp, ftemp; // Вывести подсказку и считать значение переменной ctemp (Cel- sius Temp). cout << "Input a Celsius temp and press ENTER: cin » ctemp; // Высчитать значение переменной ftemp (Fahrenheit Temp) и вы- вести его на консоль. ftemp = (ctemp * 1.8) + 32; cout « "Fahrenheit temp is: " « ftemp; return 0; 2 Хотя человеку проще читать эту версию с комментариями, для ее ввода требуется боль- ше работы. Следуя примерам в этой книге, вы всегда можете пропускать комментарии или добавлять их позднее. Запомните это основное правило, касающееся комментариев: Код языка C++, начинающийся с двойного знака дроби, «слэша» (//), являет- ♦+♦ ся комментарием вплоть до конца строки и игнорируется компилятором языка C++. Добавление комментариев не обязательно, однако это является хорошей мыслью - осо- бенно в том случае, когда кто-либо (включая вас) соберется однажды просмотреть этот код на языке C++. Как это работает Первая инструкция функции main объявляет переменные (типа double) ctemp и ftemp, которые хранят температуры по шкале термометра Цельсия и Фаренгейта соответственно. double ctemp, ftemp; Это дает нам две ячейки, в которых можно хранить значения. Поскольку они имеют тип double, в них можно хранить дробные части. Помните, что тип double обозначает «число с плавающей запятой двойной точности». ctemp ftemp Следующие две инструкции печатают подсказку для пользователя и сохраняют введен- ное значение в переменной ctemp. Предположим, что пользователь вводит «10». После этого численное значение 10.0 помещается в переменную ctemp. )
48 C++ без страха консоль (вывод) cout « "Enter a Celsius temp and press ENTER: " ; консоль (ввод) 10.0 ctemp cin » ctemp В общем случае, вы можете использовать похожие инструкции в своих программах для печати сообщения-подсказки и последующего сохранения введенного значения. Под- сказка чрезвычайно полезна - поскольку в противном случае пользователь может не знать, когда от него ожидаются какие-либо действия. Хотя введенное в данном случае число - это «10», оно было сохранено как 10.0. С точки зрения исключительно математических, терминов числа 10 и 10.0 яв- ляются одинаковыми, однако с точки зрения терминов языка C++ нотация «10.0» указывает, что значение хранится в формате с плавающей запятой, а не в целочисленном формате. Это приводит к получению важных результатов, которые я объясню в следующей главе. Следующие инструкции выполняют фактическое преобразование, используя значение, сохраненное в переменной ctemp, для расчета значения переменной ftemp: ftemp = (ctemp * 1.8) + 32; Эта инструкция содержит присваивание (assignment)-, значение, расположенное с правой стороны знака «равно» (=), вычисляется и затем копируется в переменную, находящую- ся с левой стороны. Эта инструкция является наиболее часто используемым типом инст- рукций в языке C++. И снова, предположим, что пользователь ввел «10»; представленная диаграмма отображает процесс перемещения данных в программе. 10.0 ctemp 50.0 (ctemp * 1.8)+ 32 (10.0 * 1.8)+ 32 ftemp ftemp = (ctemp * 1.8) + 32 ;
ГЛАВА 1. Ваши первые программы на языке C++ 49 В конечном итоге программа выводит результат - в данном случае 50. "Fahrenheit temp is: 50.0 КОНСОЛЬ (вывод) cout « "Fahrenheit temp is: «ftemp ; Вариации призера Если вы внимательно посмотрите на последний пример, то, возможно, спросите себя, действительно ли необходимо объявлять две переменные вместо одной. Вообще говоря, нет необходимости в таком объявлении. Добро пожаловать в мир опти- мизации. Следующая версия улучшает первую версию программы, избавившись от пе- ременной ftemp и объединив вместе этапы преобразования и вывода. Это работает не всегда - в более сложной программе нам может понадобиться сохранить значение темпера- туры по шкале термометра Фаренгейта - но в нашем случае работает просто замечательно. Листинг 1.5. convert3.cpp #include <iostream> using namespace std; int main() { // Объявление переменной ctemp с плавающей, точкой. double ctemp; // Вывод подсказки и ввод значения переменной ctemp (Celsius Temp). cout << "Input a Celsius temp and press ENTER: "; cin >> ctemp; // Преобразование значения переменной ctemp и вывод результа- тов . Cout << "Fahrenheit temp is: " « (ctemp * 1.8) + 32; return 0; }
50 C++ без страха Обнаружили ли вы теперь шаблон действий? Для самых простых программ этот шаблон обычно выглядит как > Объявить переменные; > Получить введенные пользователем данные (после печати подсказки); > Выполнить вычисления и вывести результаты. Например, следующая программа делает что-то другое, однако выглядит она хорошо знакомой. Данная программа выводит подсказку для ввода числа, а затем печатает ре- зультат возведения числа в квадрат (число, умноженное на само себя). Инструкции по- хожи на инструкции последнего примера; единственные отличия заключаются в назва- нии переменной (п) и используемых особых вычислениях (п * п). Листинг 1.6. square.cpp #include <iostream> using namespace std; int main() { // Объявление переменной n с плавающей точкой. double n; // Вывод подсказки и ввод значения переменной п. cout << "Input a number and press ENTER: cin >> n; // Расчет и вывод результата возведения в квадрат. cout « "The square is: ” « n * n; return 0; ]. Упражнения Упражнение 1.3.1. Перепишите пример таким образом, чтобы он выполнял обратное преобразование: сохранял вводимое значение в переменной ftemp (значение по шкале термометра Фаренгейта), а результат преобразований сохранял в переменной ctemp (значение по шкале термометра Цельсия). Затем напечатайте результат. (Подсказка: формула для обратных преобразований - ctemp = (ftemp - 32) /1.8.) Упражнение 1.3.2. Напишите программу преобразования значений из шкалы термомет- ра Фаренгейта в шкалу термометра Цельсия, используя только одну переменную, ftemp. Это оптимизация упражнения 1.3.1.
ГЛАВА 1. Ваши первые программы на языке C++51 Упражнение 1.3.3. Напишите программу, которая сохраняет введенное значение в пе- ременной п и выводит куб этого числа (п * п * п). Убедитесь, что инструкция вывода ис- пользует слово «cube», а не «square». Упражнение 1.3.4. Перепишите пример square.cpp, используя переменную «пит» вме- сто переменной «п». Убедитесь, что изменили имя переменной везде, где использова- лось имя «п». Несколько слов об именах переменных и ключевых словах В этой главе использовались переменные ctemp, ftemp и п. В упражнении 1.3.4. было предложено заменить название переменной «п» на «пит», если вы последовательно вы- полните подстановку по всей программе. Поэтому «пит» также является допустимым именем переменной. Вместо имен «п» или «пит» я мог бы выбирать имена из бесконечного множества имен переменных. Например, я мог присвоить некоторым переменным имена «ter- minator2003», «killerRobot» и «GovernorOfCalifornia». Итак, какие имена переменных являются допустимыми, а какие - нет? Ответ: вы можете использовать любое имя, какое пожелаете, если будете следовать этим правилам: ✓ Первым символом должна быть буква. Число не может быть первым символом. Технически первым символом может быть знак подчеркивания (_), но это соглаше- ние об именовании используется внутри библиотеки C++, поэтому лучше всего будет избегать использования этого символа в именах. ✓ Остальная часть имени может включать в себя буквы, числа или знак подчеркивания (_). ✓ Вы должны избегать использования слов, которые уже имеют особое предопреде- ленное значение в языке C++. Слова, имеющие определенное значение в языке C++, называются «ключевыми слова- ми» (keywords). Одним из таких слов является слово main. Другие ключевые слова вклю- чают в себя стандартные типы данных языка C++, например int, float и double. Кроме того, другие ключевые слова включают if, else, while, do, switch и class. Нет необходимости сидеть и запоминать все ключевые слова языка C++, хотя многие книги по программированию советуют делать именно это! Вам необходимо знать только то, что если используется имя, конфликтующее с одним из ключевых слов языка C++, компилятор отреагирует сообщением, относящимся к конфликту ключевого слова или синтаксической ошибке. В этом случае используйте другое имя. Упражнения Упражнение 1.3.5. В приведенном списке - какие из перечисленных слов являются до- пустимыми именами переменных в языке C++, а какие - нет? Еще раз, по мере необхо- димости, просмотрите только что упомянутые правила. X х1 EvilDarkness
52 C++ без страха PennslyvaniaAvel 600 1600PennsylvaniaAve Bobby_the_Robot Bobby+the+Robot whatThe??? amount count2 count2five 5count main main2 Резюме Вот основное содержание главы 1: ✓ Создание программы начинается с написания исходного кода на языке C++. Код со- стоит из инструкций языка C++, который обладает по крайне мере некоторым сход- ством с английским языком. (В отличие от него, машинный код является полностью непонятным, пока вы старательно не отыщете значение каждой комбинации единиц и нулей.) Но перед тем, как программа может быть запущена, она должна быть переве- дена в машинный код, который, в конечном счете, и понимает компьютер. ✓ Процесс перевода инструкций языка C++ в машинный код называется компиляцией. ✓ После компиляции программа также должна быть скомпонована со стандартными функ- циями, хранящимися в библиотеке языка C++. Этот процесс называется компоновкой. После того как этот этап успешно завершен, у вас будет исполняемая программа. ✓ К счастью, у вас есть среда разработки, где процесс компиляции и компоновки про- граммы (называемый генерацией программы) автоматизирован, поэтому вам необхо- димо только нажать одну функциональную клавишу. ✓ Простые программы на языке C++ имеют следующую общую форму: #include <iostream> using namespace std; int main() { Здесь_вводите_инструкции! return 0; } ✓ Чтобы напечатать что-либо, используйте объект cout. Например: cout << "Never fear, C++ is here!"; ✓ Чтобы напечатать что-либо и перейти на следующую строку, используйте объект cout и посылайте символ разделителя строки (encll). Например: cout « "Never fear, C++ is here!" « endl;
ГЛАВА 1. Ваши первые программы на языке C++ 53 ✓ Почти каждая инструкция языка C++ завершается точкой с запятой (;). Исключение составляют директивы препроцессора, после которых точка с запятой не ставится. ✓ Двойной слэш (//) обозначает комментарий; весь текст до конца строки игнорирует- ся компилятором, однако комментарии полезны для тех людей, кто будет вынужден сопровождать программу. ✓ Перед использованием переменной ее нужно объявить. Например: double х; // Объявляет переменную х как число с плавающей точкой. Переменные, которые могут хранить вещественную часть, должны быть объявлены с типом double. Этот тип обозначает число «с плавающей точкой двойной точности». Тип одинарной точности (float) должен быть использован только при сохранении на диск большого количества данных с плавающей точкой, в случаях, когда место для хранения драгоценно. ✓ Д ля помещения ввода с клавиатуры в переменную можно использовать объект с in. Например: cin >> х; ✓ Занести данные в переменную можно также используя присваивание (=). Эта опера- ция вычисляет выражение, расположенное с правой стороны от знака «равно», и по- мещает вычисленное значение в переменную, расположенную с левой стороны. Например: х = у * 2; // Умножить значение переменной у на 2, // результат поместить в переменную х.
ГЛАВА 2. Решения, решения Теперь, когда вы знаете, как выполнять ввод и вывод данных, а также вычисления с их использованием, вы находитесь на пути к написанию настоящих программ на языке C++. Но программирование может быть использовано для гораздо более разнообразных це- лей, нежели только ввод, вывод и вычисления. Наиболее полезные программы обладают свойством реагировать на условия. В действительности компьютеры принимают решения. Вся эта глава целиком посвящена именно этому их свойству. Мы начнем с рассмотрения нескольких простых программ. К окончанию главы вы буде- те иметь инструменты для выполнения кое-чего интересного: выполнения'проверки, является ли число простым. Эта работа тяжела для людей, особенно в случае с больши- ми числами, однако она отлично подходит для компьютера. Но сначала несколько слов о типах данных Перед тем как рассмотреть процесс принятия решения компьютером, важно пони- мать, как устроены компьютерные данные. В конечном счете, вся информация на компьютере представляет собой последовательности единиц и нулей. Что дает ин- формации смысл, Рак это - как она организована в значимые элементы, называемые типами данных (data types). При изучении математики вам не нужно было беспокоиться о типах. Число является числом, которое в свою очередь является числом. Все эти выражения математически эквивалентны: 3 3.0 три 2+1 Но компьютерные языки и системы отличаются от чистой математики. Недостаточно иметь просто значение: необходимо иметь способ для его хранения. В главе 1 я ссылался на переменную, как на «волшебный ящик», но, возможно, правильнее будет сказать, что переменная - это участок памяти, который может удерживать информацию, - и как все участки памяти, он не бесконечен. Этот участок может удерживать только такой объем информации. В отличие от мира чистой математики, мир компьютеров - это единственный мир, в ко- тором данные часто являются ценным ресурсом. Примеры главы 1 были разработаны с использованием данных с плавающей точкой. В данной главе используются целочисленные данные. Наиболее важное отличие, как я уже отмечал, заключается в том, что значения с плавающей точкой могут содержать дробную часть числа, тогда как целочисленные значения не могут. Но на этом отличия не заканчиваются. Если заглянуть вглубь, целочисленный формат и формат с плавающей точкой выглядят совершенно непохожими. Большую часть време- ни эти отличия для вас незаметны: вы просто используете тот тип, который необходим, и оставляете заботиться о деталях языку C++. Но иногда компилятор предупреждает о
ГЛАВА 2. Решения, Решения 55 «преобразованиях» или «потере данных», и в этих случаях полезно знать, о чем нас ин- формирует компилятор. Ниже представлено, как отдельное значение, число 150, хранится в целочисленном фор- мате и в формате с плавающей точкой. (Я сделал несколько упрощающих предположе- ний. На самом деле формат с плавающей точкой использует бинарное (двоичное), а не десятичное представление.) Integer format (int) 0 | 150 (Целочисленный формат (int)) s vaiue (значение) Floating-point (double) (Формат с плавающей головкой (int)) 0 2 1.50000000 s exponent (экспонента) mantissa (мантисса) Знаковый бит, s, указывает на то, является число положительным или отрицательным; 0 указывает на неотрицательное число. Поле экспоненты - это то, что отличает формат с плавающей точкой от целочисленного формата - а также делает его более гибким. Рассмотрим проблему сохранения числа 10 в 18-й степени. Вот как записывается это число: 1 000 000 000 000 000 000 Это значение невозможно сохранить в целочисленной переменной. Для этого недоста- точно места. Однако переменная с плавающей точкой может легко сохранить это значе- ние - это просто вопрос использования достаточного большого поля экспоненты. (Если бы компьютер использовал десятичный формат, то значение степени равнялось бы 18. В двоичном формате, который на самом деле используется компьютером, значение сте- пени в несколько раз больше.) Вот сокращенный способ представления этого числа в коде на языке C++: 1е18 Суть состоит в том, что для конкретной задачи необходимо использовать правильный тип данных. Чтобы хранить число целиком, например 2 или 3, можно использовать хра- нилище с плавающей точкой, если выберете этот способ (как в предыдущем примере с числом 150). Однако формат с плавающей точкой занимает больше места и более сло- жен, чем целочисленный формат. В этом случае вы усложняете работу компьютера больше, чем это необходимо. Лучше использовать целочисленный формат при работе только с целыми числами, пока эти числа находятся в стандартном целочисленном диа- пазоне (около двух миллиардов, или двух тысяч миллионов). В некоторых редких случаях поле с плавающей точкой не может точно сохранить целое значение. Это происходит только с самыми большими целочисленными значениями. Вот еще одна причина для того, чтобы избегать формата с плавающей точкой при работе только с целыми числами. Целочисленная переменная объявляется с использованием синтаксиса, похожего на син- таксис объявления переменной типа double: int variable_name;
56 C++ без страха Константные (постоянные) значения также хранятся в целочисленном формате или формате с плавающей точкой. Присутствие десятичной точки автоматически указывает на то, что значение будет сохранено в формате с плавающей точкой. Число, например 3.141592, очевидно требует формата с плавающей точкой и таким образом будет сохра- нено в переменной типа double. Число 3.0 также будет сохранено в переменной типа double, поскольку оно содержит десятичную точку. Если бы число было обозначено как 3, то оно было бы сохранено в переменной типа int. И это важно, поскольку язык C++ делает вам одолжение и поддерживает преобразования типов без недовольств, но только в том случае, если может выполнить преобразование без потенциальной потери данных. Например, константа типа int перед присвоением переменной типа double должна быть преобразована в формат с плавающей точкой. double х; х = 3; // ОК: преобразование типа int Компилятор языка C++ не выражает недовольства.в этом случае, поскольку формат dou- ble может хранить любое значение, которое может быть сохранено в формате int. Но в следующем примере значение с плавающей точкой (3,7) должно быть преобразова- но к целочисленному значению. Компилятор продолжает работу и выполняет требуемое преобразование, однако выдает предупреждающее сообщение, говорящее про возмож- ную потерю данных. int П; п = 3.7; // Предупреждение: преобразование типа double // в тип int В результате дробная часть 0,7 будет отброшена, и в переменной п будет сохранено значение 3. Менее очевидно, что в результате выполнения следующей инструкции - выглядящей совершенно невинной - выдается такое же предупреждение, поскольку «.0» говорит «Я формат с плавающей точкой». Для компилятора языка C++ любое преобразование из формата с плавающей точкой в целочисленный формат автоматически является подо- зрительным. п = 3.0; // Предупреждение: преобразование типа double // в тип int Несмотря на предупреждение, программа в этом случае работает корректно, поскольку 3,0 математически эквивалентно 3. Однако большинство программистов любят избав- ляться от предупреждающих сообщений (warnings) компилятора - они являются раз- дражающими и дают постоянное впечатление о том, что в программе что-то неправиль- но. Способом избавления от предупреждений в этом случае является поддержка приве- дений, сообщающая компилятору «Преобразуй в тип int». Поскольку в этом случае пре- образование выполняется обдуманно, компилятор предполагает, что вы отдаете отчет в том, что делаете, и не выдает предупреждение. n = static_castcint>(3.0); Или, что еще лучше, вы можете использовать целочисленную константу, а не число с плавающей точкой; в этом случае проблем не будет, поскольку вы просто присваиваете константу в формате int переменной типа int.
ГЛАВА 2. Решения^ Решения 57 п = 3 ; Кстати, общая форма записи оператора static_cast следующая: static_cast <тип>(выражение) Оператор static..cast в качестве параметра принимает выражение и возвращает но- вое выражение с тем же математическим значением, но уже с указанным типом. Стандартными версиями языка C++ (за исключением самых старых, вышедших из упот- ребления версий) поддерживается несколько операторов приведения. Из них оператор static_cast является наиболее употребляемым; он прост в использовании. Осталь- ные операторы имеют более специализированные назначения. Вставка Для программистов на языке С Если у вас есть опыт программирования на языке С, вы можете посмотреть на опера- торы приведения языка C++ и спросить, зачем нужна дополнительная работа по вво- ду всех этих дополнительных символов? В конце концов, инструкция n = static_cast<int>(3.0) ; на языке С может быть переписана более кратко: n = (int) 3.0; На самом деле, более короткая запись будет работать, хотя есть некоторые премуд- рые гуру языка C++, которые сошли бы с ума от того, что я позволил вам узнать это. Язык C++ с самого начала разрабатывался для обратной совместимости с языком С, и по этой причине более короткая запись операции приведенйя типов в стиле языка С все еще работает. Но существует несколько причин для выбора более длинной записи в стиле языка C++. Каждый из операторов приведения языка C++ имеет специфическое назначение. Например, оператор static_cast может выполнять приведение типа из типа int в тип double, но он не может привести один тип указателя к другому. (Если вы не программист на языке С, не беспокойтесь, я объясню, что такое указатели (pointers), в главе 6.) Последнее является специальным типом приведения (cast), которое может иметь странные результаты, если вы точно не уверены, зачем делаете это. Приведение между типами указателей имеет свой собственный оператор, reinterpret_cast. Каждое из четырех приведений языка C++ (static, dynamic, const и reinterpret) имеет специализированное назначение, в отличие от приведения в стиле языка. С, которое является общим. Использование приведений языка C++ поэтому сделает проще по- нимание того, для чего нужно приведение, когда вы будете просматривать чей-либо код на языке C++. От использования приведений в стиле языка С, которые хотя и записываются более кратко, специалисты в настоящее время настоятельно отговаривают. Нет гарантий, что спецификация ANSI C++ будет постоянно продолжать поддерживать приведения в стиле С.
58 C++ без страха Принятие решений в программах Принятие решений в программе ограничено. Компьютер может выполнять только те инструкции, которые абсолютно понятны и точны. В некоторых отношениях это хорошо; в других отношениях - это является проблемой. Приятной новостью является то, что компьютер всегда будет в точности выполнять то, что вы ему скажете. Плохой новостью является то, что компьютер всегда будет в точно- сти выполнять то, что вы ему скажете, - независимо от того, насколько это глупо. Повторю еще раз, это одно из важнейших правил в программировании - возможно, даже самое важное. Компьютер может выполнять только те инструкции, которые абсолютно * понятны. В случае с принятием решения это означает, что у компьютера нет такого понятия, как проницательность или рассудительность. Он может выполнять только математически точные правила: например, сравнение двух значений для определения, равны ли они. Только в области искусственного интеллекта (Al - artificial intelligence) компьютерные специалисты высказывают мнения, что компьютер может иметь что-то наподобие того, что мы называем рассудительностью. Однако искусственный интеллект является исклю- чением, подтверждающим правило. Сложные программы принятия решений состоят из тысяч или даже миллионов инструкций, каждая из которых является простой, точной и понятной. (И именно так программа Deep Thought компании IBM смогла обыграть меж- дународного гроссмейстера по шахматам.) Инструкции if и if-else Самый простой способ запрограммировать поведение - это сказать «Если А истинно, то необходимо выполнить В». Это именно то, что делает инструкция i f языка C++. Ниже представлена простая форма синтаксиса инструкции if: if (условие) инструкция Существуют более сложные формы этой инструкции, до которых мы вот-вот доберемся. Но сначала рассмотрим инструкцию if, сравнивающую две переменные, х и у. (Пред- положим, что переменные были заранее объявлены, как это и полагается.) if (х == у) cout << "х and у are equal."; Странно. В примере используются два знака «равно» (==) вместо одного (=). Но это не опечатка. В языке C++ в этом отношении есть два отдельных оператора: один знак «рав- но» означает присваивание, которое копирует значение в переменную; два знака «равно» выполняют проверку на равенство.
ГЛАВА 2. Решения, Решения 59 По мере продвижения в изучении языка C++ вы обнаружите, что использование при- сваивания (=) там, где подразумевается проверка на равенство (-=), является од- ной из самых распространенных ошибок. Проблема заключается в том, что ис- пользование присваивания (=) внутри условия разрешено; просто это работает неправильно. (В следующей вставке эта проблема обсуждается более подробно.) Что, если вместо выполнения только одной инструкции в ответ на условие, вы желаете выполнить ряд инструкций? Ответ заключается в использовании составной инструкции (compound statement) (называемой также «блоком инструкций» (statement block)): if (х == у) { cout << "х and у are equal." « endl; cout « "Isn't that nice?"; they_are_equal = true; } Значение этого синтаксиса заключается в том, что либо выполняются все инструкции, либо не выполняется ни одна из них. Если условие (х равняется у, в данном случае) лож- но, управление программы переходит на конец составной инструкции - другими словами, управление переходит на первый оператор после закрывающей фигурной скобки (}). Открывающая и закрывающая фигурные скобки ({}) определяют, где начинается и за- канчивается составная инструкция. Это может быть вставлено в синтаксис инструкции if, благодаря еще другому важному правилу: Везде, где в языке C++ можно использовать инструкцию, можно использо- * вать составную инструкцию. Формально составная инструкция является просто еще одним видом инструкций. Обра- тите внимание, что после составной инструкции не ставится точка с запятой (;) - только после инструкций внутри нее. Это одно из тех исключений, касающихся правила точки с запятой, о котором я упомянул в предыдущей главе. Вот синтаксис инструкции i f снова: if (условие) инструкция Применив основное правило, которое я только что сформулировал, вместо слова инст- рукция мы можем вставить составную инструкцию: if (условие) { инструкции } где инструкции - это ноль или более инструкций. Можно также указать действия, выполняемые в том случае, когда условие ложно. Это совсем необязательно. Как можно предположить, в этой вариации используется ключе- вое слово else. if (условие) инструкция! else ин с трукция2
60 C++ без страха Как обычно, инструкция! или инструкция!, или обе, могут быть составной инструкци- ей. Теперь у нас есть полный синтаксис инструкции if. Вот небольшой пример. if (х == у) cout « "х and у are equal"; else cout « "х and у are NOT equal"; Этот код может быть переписан с использованием стиля составной инструкции, хотя это совсем не обязательно. if (X == у) { cout « "х and у are equal”; } else { cout << "х and у are. NOT equal"; } * Весь код с использованием инструкций if и if-el.se может быть переписан данным способом, поэтому фигурные скобки присутствуют всегда, даже если каждый из резуль- тирующих блоков инструкций состоит всего из одной инструкции. Лично я не всегда использую этот подход, поскольку это является дополнительной работой, несмотря на то, что некоторые программисты настоятельно рекомендуют использовать его. Преимуществом этого подхода (в котором составные инструкции всегда используются в синтаксисе инструкции if) является то, что вы можете вернуться назад позднее и доба- вить одну или две инструкции между фигурными скобками, не ломая при этом сущест- вующий код. Мой подход заключается в добавлении фигурных скобок обычно по мере необходимости, но вы можете предпочесть этот более осторожный подход. Вставка Зачем нужны два оператора (= и ==)? Если вы использовали другие языки программирования, например, Pascal или Basic, вы можете спросить, почему операторы = и == - это два разных оператора. Ведь язык Basic использует один знак «равно» (=) как для присваивания, так и для проверки на равенство, различая их по контексту. В языках С и C++ следующий код разрешен. Хотя почти всегда поведение програм- мы неправильно. if (х = у) // НЕПРАВИЛЬНО! Присваивание! cout << ”х and у are equal"; В этом примере происходит (1) присваивание значения переменной у переменной х и (2) это значение используется в качестве условия проверки. Если это значение не равняется нулю, то условие считается «истинным» (true). Следовательно, если значе- нием переменной у является любое число, отличное от нуля, приведенное условие всегда считается истинным и инструкция выполняется всегда!
ГЛАВА 2. Решения, Решения 61 Ниже приведена правильная версия, которая делает то, что вы хотите: if (х == у) // ПРАВИЛЬНО: проверка на равенство cout << "х and у are equal"; В этом примере, х == у - это операция, выполняющая проверку на равенство и воз- вращающая подходящее значение «true» (истина) или «false» (ложь). Важно запом- нить, что нельзя путать сравнение на равенство с присваиванием (х = у), в котором данные переменной у копируются в переменную х и возвращается значение «true» при любом ненулевом значении. Почему допускается такой потенциальный источник проблем? Ну, разработчики языка С решили дать ему большую гибкость, чем у других языков, а язык C++ унас- ледовал эти возможности. В частности, почти каждое выражение в языке С или C++ возвращает значение, и это распространяется на оператор присваивания (=), который считается «выражением с побочным эффектом» (an expression with a side effect). Таким образом, за один раз можно проинициализировать сразу три переменные, выполнив следующую инструкцию: х^= у = z = 0; // установить значения всех // переменных в 0. что эквивалентно следующему: х = (у = (z = 0)); // установить значения // всех переменных в 0. Любое присваивание начинается с самого правого (z = 0), возвращающего значе- ние равное 0, которое после этого используется в следующем присваивании (у = 0). Другими словами, значение 0 передается три раза, каждый раз новой переменной. Поэтому язык C++ трактует выражение «х = у» как обычное выражение, возвра- щающее значение, как любое другое выражение. И в этом нет ничего неправильного, если не считать еще одно правило языка С, унас- ледованное языком C++: любое значение может быть использовано в качестве усло- вия. Следовательно, если вы введете инструкции, похожие на следующую, компиля- тор не остановит свое выполнение: if (х = у) // . . . Поэтому будьте чрезвычайно внимательны относительно того, где использовать один знак равно (=), а где - два (==). Пример 2.1. Чет или нечет? Хорошо, довольно вступлений. Настало время посмотреть на готовую программу, ис- пользующую принятие решений. Это простой, почти банальный пример, но он знакомит нас с новым оператором (%) и демонстрирует синтаксис инструкции if-else в действии.
62 C++ без страха Эта программа принимает значение, введенное с клавиатуры, и сообщает, является ли число четным либо нечетным. Операция является простейшей, однако она демонстриру- ет простое использование инструкции if. Листинг 2.1. event.срр #include <iostream> using namespace std; int main() { int n, remainder; // Получить число, введенное с клавиатуры. cout << "Enter a number and press ENTER: "; cin >> n; // Получить остаток от деления числа на 2. remainder = п % 2; // Если значение переменной remainder равно О, // введенное число является четным. if (remainder == 0) cout << "The number is even."; else cout << "The number is odd."; return 0; 2 Еще раз, если вы следуете за изложением и желаете ввести этот пример вручную, ком- ментарии - строки, начинающиеся с двойного слэша ( / / ), являются необязательными. Как это работает Первая инструкция программы объявляет две целочисленные переменные, п и remainder. int n, remainder; Следующее, что делает программа, - это получает число и сохраняет его в переменной п. К настоящему времени это должно выглядеть знакомым: cout « "Enter a number and press ENTER: "; cin >> П; Теперь необходимо выполнить простую проверку значения переменной п на предмет того, является ли оно четным или нечетным. Как это сделать? Ответ: разделить число на 2 и посмотреть на остаток. Если остаток равняется нулю, значит число - четное (други- ми словами, делимое без остатка на 2). В противном случае число - нечетное.
ГЛАВА 2. Решения, Решения 63 Это в точности то же самое, что происходит в примере. Следующая инструкция делит значение переменной п на 2 и получает остаток. Эта операция называется делением по модулю (modulus) или остатком от деления (remainder). Результат сохраняется в пере- менной, называемой (достаточно подходяще) «remainder» (остаток от деления). remainder = п % 2; Знак процентов (%) в языке C++ теряет свое обычное значение и вместо этого обозначает остаток от деления. Вот несколько примеров результатов: Пример Остаток от деления Примечания 3 % 2 1 Odd (Нечетное) 4 % 2 0 Even (Четное) 25 % 2 1 Odd 60 % 2 0 Even 25 % 5 0 Divisible by 5 (Делится на 5) 13 % 5 3 Not divisible by 5 (He делится на 5) После деления значения переменной п на 2 и получения остатка от деления мы получаем в результате 0 (четное число) или 1 (нечетное число). Инструкция if сравнивает остаток от деления с нулем и выводит подходящее сообщение. if (remainder == 0) cout << "The number is even."; else cout « "The number is odd."; Обратите внимание, что в этом коде используются два знака «равно» (==). Как уже было замечено ранее, операция проверки на равенство обозначается двумя знаками «равно» - один знак «равно» (=) обозначает присваивание. Если я повторяюсь по этой теме, то это только потому, что во время моего первого изучения языка С, я сам делал эту ошибку слишком много раз! Попутно приведу пример кода, написанного с использованием стиля составной инструк- ции, которому оказывают предпочтение по общему принципу некоторые программисты. if (remainder == 0) { cout << "The number is even."; } else { cout << "The number is odd."; } Оптимизация кода Только что представленная версия программы, проверяющей число на четность и нечет- ность, не так эффективна, как могла бы быть. Переменная remainder не особо нужна. Данная версия немного лучше:
64 C++ без страха Листинг 2.2. even2.cpp #include <iostream> using namespace std; int main() { int n; // Получить число, введенное с клавиатуры. cout « "Enter a number and press ENTER; cin » n; // Получить остаток от деления числа на 2. // Если остаток от деления равен нулю, то число - четное. if (п % 2 == 0) cout « "The number is even."; else cout « "The number is odd."; return 0; 2 В этой версии деление по модулю выполняется внутри условия, после чего результат сравнивается с нулем. Упражнение Упражнение 2.1.1. Напишите программу, которая сообщает, делится ли введенное чис- ло без остатка на 7. (Подсказка: если число делится на 7 без остатка, это означает, что число можно разделить на 7 и в остатке получить 0.) Введение в циклы Одной из самых мощных концепций в любом языке программирования являются циклы. В этом разделе вы увидите, как несколько строчек кода на языке C++ могут сделать так, что операция будет выполняться (потенциально) тысячи раз. Когда программа находится в цикле, она выполняет какую-либо операцию снова и снова до тех пор, пока условие является истинным. Простейшей формой является классическая инструкция whi 1 е: while (.условие) инструкция Как и в случае с инструкцией if, вы можете заменить инструкцию составной инструк- цией, которая в свою очередь позволяет разместить внутри цикла столько инструкций, сколько пожелаете. while (условие) { инструкции }
ГЛАВА 2. Решения, Решения 65 Как и в случае с инструкцией if, инструкция while вычисляет условие и затем, если условие является истинным, выполняет инструкцию. Отличие заключается в том, что инструкция while повторяет операцию снова и снова до тех пор, пока условие не станет ложным. Точнее говоря, программа заново вычисляет условие после всех до единого выполнений инструкции. Если условце остается истинным, инструкция выполняется снова. Вот как инструкция while может быть представлена при помощи инструкций if и goto. (Инст- рукция goto выполняет безусловный переход на определенное положение в программе;) label 1: -<------------ if (условие) { инструкция gotolabeM; - —— ' } Или, чтобы выразить идею в терминах, близких к английскому языку: > Проверить условие. Если условие истинно, выполнить шаги 2 и 3. (Иначе мы закон- чили; перейти на первую инструкцию за концом цикла.) > Выполнить инструкцию. > Вернуться к шагу 1. Самым простым примером использования инструкции while является цикл, выводя- щий на консоль числа от 1 до N, где N - это число, введенное с клавиатуры. Сначала мы посмотрим на эту программу в форме псевдокода-, это означает, что шаги записаны на английском языке. По соглашению, программисты на языках С и C++ в именах переменных используют буквы в нижнем регистре: например, «п» вместо «N». Сам язык не заставляет использо- вать это соглашение; можно использовать буквы в верхнем регистре столько, сколько пожелаете. Данная книга следует соглашению о буквах в нижнем регистре, только ино- гда используя буквы в верхнем регистре. На нескольких следующих страницах я исполь- зую имена переменных «I» и «N», поскольку в этом случае псевдокод проще понимать. В начале программы необходимо объявить переменные. Предположим, что I и N (кото- рые позднее мы заменим на «!» и «п») объявлены как целочисленные переменные. Это имеет смысл, поскольку никогда не будет необходимости хранить в этих переменных дробную часть. Вот как можно распечатать числа от 1 до N: 1 Считать число с клавиатуры и сохранить в переменной N. 2 Установить значение переменной I в 1. 3 Пока (инструкция while) значение переменной I меньше либо равно значению пере- менной N, ЗА Вывести значение переменной I на консоль. ЗВ Прибавить 1 к значению переменной I. 3-6248
66 C++ без страха Первые два шага инициализируют целочисленные переменные I и N. Переменная I уста- новлена непосредственно в значение 1. Значение переменной N устанавливается вводом с клавиатуры. Предположим, что пользователь ввел «2». Шаг 3 является самым интересным. Сначала программа проверяет, является ли значение переменной I (которое равняется 1) меньшим или равным значению переменной N (ко- торое равняется 2). Поскольку значение переменной I меньше, чем значение переменной N, программа выполняет шаги ЗА и ЗВ. Сначала она выводит значение переменной I. 3 Пока значение переменной I меньше либо равно значению переменной N ----► ЗА Напечатать значение переменной! ЗВ Добавить 1 к значению переменной I консоль (вывод) Затем программа увеличивает значение переменной I на 1 (эта операция называется ин- крементированием). 3 Пока значение переменной I меньше либо равно значению переменной N ЗА Напечатать значение переменной I ----ЗВ Добавить 1 к значению переменной I Выполнив все эти шаги, программа снова выполняет сравнение. Поскольку это инструк- ция while, а не инструкция if, программа продолжает выполнять шаги ЗА и ЗВ до тех пор, пока условие не станет ложным.
ГЛАВА 2. Решения, Решения 67 Условие все еще истинно (поскольку значения переменных равны), поэтому программа продолжает выполнение. 3 Пока значение переменной I меньше либо равно значению переменной N ----> ЗА ЗВ Напечатать значение переменной I Добавить 1 к значению переменной I консоль (вывод) После вывода нового значения переменной I, программа снова инкрементирует значение переменной. 3 Пока значение переменной I меньше либо равно значению переменной N ЗА Напечатать значение переменной I ----ЗВ Добавить 1 к значению переменной I Программа выполняет проверку еще раз. Поскольку сейчас значение переменной I больше значения переменной N, условие (значение переменной I меньше, чем значение переменной N?) становится ложным. Программа завершается. Число 3 не будет напеча- тано. В данном случае вывод программы следующий: 1 2 Поскольку пользователь ввел число 2, цикл выполнился дважды. Но введя большее зна- чение для переменной N (скажем, 1024), цикл будет выполняться гораздо больше раз. Если вы являетесь новичком в программировании, это может стать открытием: вот про- грамма, состоящая из нескольких шагов, которая может (в зависимости от введенного значения для переменной N) напечатать миллионы чисел! Теоретическое значение пере- менной N безгранично, за исключением максимального размера целочисленного форма- та; самое большое число, которое может быть сохранено в переменной типа int, равня- ется примерно двум миллиардам (то есть двум тысячам миллионов). В чрезвычайных случаях ограничения памяти для хранения данных могут влиять на то, что вы можете делать с инструкциями while, из-за чего я и начал главу с разговора о типах данных. з*
68 C++ без страха Вставка Бесконечные циклы Можно ли задать условие цикла таким образом, что оно всегда будет истинным? И, если это возможно, что случится? Ответ - (1) да, это распространенная ошибка программирования; и (2) цикл будет выполняться до тех пор, пока' компьютер не по- теряет мощность, не выйдет из строя оборудование, не случится программное преры- вание, или - если ничего из перечисленного не произошло - цикл будет выполняться миллиарды лет, пока солнце не превратится в сверхновую звезду и уничтожит Землю. Это называется «бесконечным циклом» и является неприятной вещью. Чтобы избежать бесконечных циклов, необходимо проявлять особую внимательность при работе с инструкцией while и другими типами циклов (с которыми мы позна- комимся позднее в этой главе). Убедитесь, что вы задали условие, инструкции цикла и начальные настройки с тем, чтобы цикл в конечном счете завершился. Пример 2.2. Печать чисел от 1 до N Теперь давайте используем инструкции языка C++, чтобы реализовать цикл, описанный в предыдущем разделе. Все, что для этого нужно, - это простой цикл с использованием инструкции while и синтаксиса составной инструкции, чтобы в цикле каждый раз вы- полнялись две инструкции. Здесь и в оставшейся части книги я придерживаюсь стандартного соглашения языка C++ по использованию букв нижнего регистра для имен переменных. Листинг 2.3. countl.cpp #include <iostream> using namespace std; int main() { int i, n; // Считать число с клавиатуры 11 переменную i. cout « "Enter a number cin >> n; i = 1; while (i <= n) { и проинициализировать and press ENTER: cout « i « ” " i = i + 1; // Пока значение переменной : // меньше или равно значению // переменной и // Напечатать значение // переменной i, // Прибавить 1 к значению // переменной i. } return 0;
ГЛАВА 2. Решения, Решения 69 Обратите внимание, что некоторые комментарии находятся на тех же строках, что и ин- струкции языка C++. Это работает потому, что комментарии начинаются с двойного слэша (/ /) и продолжаются до конца строки. Комментарии могут находиться как на от- дельных строках, так и справа от инструкций. Программа при запуске считает до указанного числа. Например, если пользователь ввел «б», программа напечатает 1 2 3 4 5 6 Как это работает В этом примере был введен новый оператор, хотя я уверен, что вы догадались о его на- значении. Это оператор проверки «меньше или равно». i <= п Оператор «меньше или равно» (<=) - это один из нескольких операторов сравнения, все из которых возвращают значение true (истина) или false (ложь). Оператор Назначение -= Проверка на равенство 1 — Проверка на неравенство (меньше чем или больше чем) > Больше чем < Меньше чем > = Больше или равно < = Меньше или равно Если вы следили за логикой в разделе «Введение в циклы», цикл сам по себе не сложен. Фигурные скобки ({}) создают составную инструкцию с тем, чтобы цикл с использова- нием инструкции while каждый раз выполнял две инструкции, а не одну. while (i <= n) { // Пока значение переменной I // меньше или равно значению // переменной п cout << i << " // Напечатать значение // переменной i, i = i + 1; // Прибавить 1 к значению // переменной i. } Если вы подумаете об этом, то увидите, что последним напечатанным числом будет зна- чение переменной п - как раз то, что мы хотим. Как только значение переменной i ста- нет больше, чем значение переменной п, цикл завершится и инструкция вывода в этом случае больше не выполнится. Первой инструкцией в цикле является: cout << i << " // Напечатать значение // переменной i,
70 C++ без страха Эта инструкция после печати значения переменной i добавляет символ пробела. Вот по- чему вывод программы разделен пробелами таким образом: 1 2 3 4 5 а не таким: 12345 После этого, перед продолжением выполнения следующего цикла, к значению перемен- ной i добавляется 1. Это гарантирует, что цикл в конечном итоге завершится, поскольку значение переменной i рано или поздно станет больше, чем значение переменной п (в этом случае результат условия цикла станет ложным). i = i + 1; // Прибавить 1 к значению // переменной i. Упражнения Упражнение 2.2.1. Напишите программу, которая печатает все числа от п1 до п2, где п1 и п2 - это два числа, введенные пользователем. (Подсказка: необходимо вывести на эк- ран подсказку для ввода двух значений, п1 и п2, а затем проинициализировать перемен- ную i значением переменной п1 и в условии цикла использовать переменную п2.) Упражнение 2.2.2. Модифицируйте пример таким образом, чтобы он печатал все числа от п до 1 в обратном порядке. Например: 5 4 3 2 1. (Подсказка: чтобы декрементировать значение переменной внутри цикла, используйте инструкцию i = i - 1;). Значения true и false в языке С++ Чем на самом деле являются значения «true» и «false»? Хранятся ли эти значения в ком- пьютере в цифровой форме, как и все другие значения? Безусловно, так оно и есть. Каждый булевский оператор (то есть логический оператор сравнения) возвращает 1 или 0. Если результат вычисления условия Выражение возвращает true 1 false 0 Также любое ненулевое значение ведет к тому, что условие считается истинным (true). Вот почему в данном примере инструкции выполняются всегда. // ВСЕГДА ВЫПОЛНЯЕТСЯ! if (1) { // Выполнить инструкции. } В следующем примере создается один из тех печально известных циклов, о которых я рассказал ранее: // БЕСКОНЕЧНЫЙ ЦИКЛ!
ГЛАВА 2. Решения, Решения 71 while (1) { // Выполнить инструкции. } Поскольку все операторы сравнения возвращают 1 или 0, вы можете объявить целочис- ленную переменную и использовать ее в качестве булевского «флага» - переменная, которая хранит значение «true» или «false». Например: int is_less_than; is_less_than = ti < n); // Сохранить значение "true" (1) // если значение переменной I // меньше, чем // значение переменной п. Переменная is_less_than хранит результат проверки условия. Позднее в этой главе мы на практике воспользуемся похожей переменной (is_prime). Вставка Тип данных bool Самые последние версии языка C++ поддерживают специальный тип данных bool («Boolean», т.е. булевский, логический), который похож на целочисленный тип, од- нако может хранить только два значения: true (1) или false (0). Любое ненулевое це- лое значение, присвоенное переменной типа bool, приводит к тому, что в перемен- ной будет храниться значение true (1). Если ваш компилятор поддерживает тип bool, использование в данной ситуации типа bool предпочтительнее, чем исполь- зование типа int, поскольку назначение этой переменной понятно без труда: хра- нить значение true/false. bool is_less_than; is_less_than = (i < n); // Сохранить значение "true" (1) ' // если значение переменной I // меньше, чем // значение переменной п. Оператор инкремента (++) Разработчики языка С; на котором по большей части основан язык C++, были одер- жимы созданием сокращений. Одним из любимых сокращений у программистов все- гда был оператор инкремента (++). Этот оператор прибавляет 1 к значению пере- менной. Например: П + + ; // П = П + 1 Рассмотрим цикл, представленный в предыдущем разделе. while (i <= n) { // Пока значение переменной I // меньше или равно значению' // переменной п
72 C++ без страха cout << i « // Напечатать значение // переменной i, // Прибавить 1 к значению // переменной i. Вторая инструкция внутри цикла может быть заменена инструкцией, использующей оператор инкремента, и в итоге получаем: while (i <= n) { cout << i << i++; // Пока значение переменной I // меньше или равно значению // переменной п // Напечатать значение // переменной i, // Прибавить 1 к значению и переменной i. Пока эта подстановка избавила нас лишь от нескольких нажатий. Но все улучшится. Инструкция i + + является «выражением с побочным эффектом» - это значит, что она порождает значение и выполняет действие. В частности, i++ - это выражение с тем же значением, что и значение переменной i, но после того, как выражение i++ будет вы- числено, к значению переменной i будет прибавлена 1. Таким образом,- цикл может быть сокращен до: while (i <= n) { // Пока значение переменной I. // меньше или равно значению // переменной п cout << i++ << " // Напечатать значение // переменной i, после чего // прибавить 1 к значению // переменной. } Видите, что происходит в данном случае? Инструкция печатает текущее значение пере- менной i, после чего инкрементирует его. Теперь нам не нужен синтаксис составной инструкции, и цикл может быть сокращен еще больше: while (i <= n) // Пока значение переменной I. // меньше или равно значения // переменной п cout << i++ << " "; // Напечатать значение // переменной i, после чего // прибавить 1 к значению // переменной. Но этот вид программирования, несмотря на изящность сокращений, может быть риско- ванным. В сложной инструкции, в которой многократно используется переменная i, побочные эффекты создают непредсказуемые результаты. Единственная безопасная стратегия - это использование инструкции i++ отдельно или в инструкции, где пере- менная встречается только один раз.
ГЛАВА 2. Решения, Решения 73 Вы можете поинтересоваться, существует ли соответствующий оператор для вычитания. На самом деле в языке C++ есть четыре оператора инкремента/декремента. Здесь слово var обозначает любую переменную. Оператор Действие var++ Возвращает текущее значение переменной var, после чего прибавляет 1 к значению переменной var. ++var Прибавляет 1 к значению переменной var: затем возвращает результат. var— Возвращает текущее значение переменной var, после чего вычитает 1 из значения переменной var. —var Вычитает 1 из значения переменной var, затем возвращает результат. Инструкции в сравнении с выражениями До сих пор я беспечно использовал термины «инструкция» (statement) и «выражение» (expression). Эти термины являются фундаментальными в языке C++, поэтому важно прояснить, что я имею в виду. Тяжело дать здесь определения, за исключением использования и примера. Только об одном можно сказать с уверенностью: (1) программа на языке C++ состоит из одной или более функций и (2) функция, в свою очередь, состоит из нуля или более инструкций. В общем случае вы можете узнать инструкции по использованию завершающей точки с запятой (;). Вот пример: cout << i++ << " "; Простая инструкция обычно представляет собой одну строчку программы на языке C++. Но помните, что точка с запятой завершает инструкцию, поэтому можно (хотя и не ре- комендуется) разместить две инструкции на одной строчке: cout << i << " i++; Отлично, скажете вы. Так что же такое выражение? Это тесно связанная концепция, но различие важно. Выражение в языке C++ - это все, что создает значение, за исключени- ем выражений, возвращающих значение типа void. Это касается констант, переменных и всего того, что создается при помощи операторов. Ниже приведен список выражений вместе с описанием того, какое значение создает ка- ждое из них. X // Возвращает значение переменной х 12 // Возвращает 12 х + 12 и Возвращает результат х + 12 - х == 33 II Проверка на равенство: возвращает. 1 или 0 х = 33 И Присваивание: возвращает присвоенное II значение (33) num++ и Возвращает значение переменной num перед и инкрементированием i = num-f+ + 2 // Сложное выражение; возвращает // новое значение переменной i
74 C++ без страха Поскольку все перечисленное является выражениями, любое из них может быть ис- пользовано в составе более сложного выражения, включающего операцию присваи- вания (=). Три последних выражения с побочным эффектом. Выражение х = 33 модифицирует значение переменной х, а выражение num++ изменяет значение пе- ременной num. Последнее выражение изменяет как значение переменной num, так и значение переменной i. Любое выражение может быть превращено в инструкцию; для этого за выражением нужно поставить точку с запятой. num+ +; Тот факт, что любое выражение может быть превращено в инструкцию таким способом, делает возможным использование некоторых странных инструкций. Можно, например, превратить константу в инструкцию: 35; Но эта инструкция абсолютно ничего не делает. Обычно вы будете видеть, что выраже- ние, превращенное в инструкцию, имеет возможность изменить значение, напечатать результат или выполнить любое другое полезное действие. Введение в булеву (короткозамкнутую) логику Иногда приходится использовать слова «и», «или» и «не» для выражения сложного ус- ловия. Это просто очевидно. Например, ниже (в псевдокоде) используется условие со словом «и»: Если аде > 12 и аде < 20 Человек является подростком Для выражения условий с использованием слова «и» программисты ЭВМ используют булеву алгебру (Boolean algebra), названную так в честь математика девятнадцатого века Джорджа Буля. Булева алгебра делает то, что требуется. В частности, вычисляется каж- дое подвыражение «age > 12» и «age < 20», и если результат обоих подвыражений равен значению true, результат всего выражения аде > 12 и аде < 20 также равен значению true. В следующей таблице приведены три булевых (логических) оператора языка C++. Табл. 2.1. Булевы операторы. Обозначение Операция Синтаксис языка C++ Действие && AND exprl && ехрг2 Вычисляет выражения exprl и ехрг2. Если результаты обоих выражений равны значению true, возвращается значение true; в противном случае - значение false.
ГЛАВА 2. Решения, Решения 75 Обозначение Операция Синтаксис языка C++ Действие OR exprl || ехрг2 Вычисляет выражения exprl и ехрг2. Если результат хотя бы одного из них равен значению true, будет возвращено значение true; в противном случае - 0. 1 NOT ! exprl Вычисляет выражение exprl. Если результат равен нулю, воз- вращается значение true; в про- тивном случае - значение false. Таким образом, предыдущий пример, использующий условие «И» на языке C++ может быть выражен следующим образом: if (age > 12 && age < 20) // если age > 12 И age < 20 cout « "The subject is a teenager.'1; Булевы операторы && и | ) имеют меньший приоритет, чем операторы сравнения (<, >, >=, <=, ! = и ==), которые, в свою очередь, имеют меньший приоритет, чем арифметиче- ские операторы, например сложение (+) и умножение (*). (Однако обратите внимание, что логическое отрицание (!) имеет высокий приоритет. Порядок приоритетов полно- стью приведен в приложении А.) Приоритет - это правило, определяющее, в каком порядке выполняются операции. Операторы с более высоким приоритетом.выполняются раньше операторов с более низким приоритетом. Поэтому следующая инструкция делает тд, что вы, наверняка, ожидаете: if (х + 2 > у && а == Ь) cout << "The data passes the test"; Это означает: «Если значение выражения х+2 больше значения переменной у и значение переменной а равняется значению переменной Ь, то напечатать сообщение». В то же время вы всегда можете достичь большей ясности, используя круглые скобки. if ( ( (х + 2) > у) && (а == Ь) ) cout << "The data passes the test"; В языке C++ операторы «и» и «или» (&& и | |) используют короткозамкнутую логику. Это означает, что второй операнд вычисляется только тогда, когда это необходимо. Например, в случае использования операции «и» (&&), если результатом первого опе- ранда является значение false, то второй операнд вычисляться не будет. Аналогично, в случае использования операции «или» (| |), если значением первого операнда является true, то второй операнд вычисляться не будет.
76 C++ без страха Не путайте булевы операторы с побитовыми операторами (&, /, Л и ~). Язык C++ поддерживает оба типа операторов, однако каждая группа работает не- много по-разному; они не совмещены, как это сделано в языке Basic. Побитовые операторы сравнивают каждый бит одного операнда с соответствующим би- том в другом операнде. Хотя во многих случаях результаты выполнения поби- товых операторов такие же, как и в случае выполнения булевых операторов, но во многих случаях результаты не совпадают. Важным отличием является то, что побитовые операторы не используют короткозамкнутую логику; этот факт может иметь серьезные последствия, если второй операнд имеет какой- либо тип побочных эффектов. Вставка Что такое значение «true»? В некоторых областях язык C++ является более общим, чем другие языки програм- мирования. Булевы операторы - &&, | | и ! - могут принимать на входе любое число булевых выражений. Любое ненулевое выражение считается «истинным» (true). Некоторые программисты используют преимущество такого поведения для написа- ния сокращений: if (n && Ь > 2) cout << "n is nonzero and b is greater than 2.”; Но этот аспект языка C++ также означает возможность написания действительно странных кусков кода, наподобие приведенного ниже: if (1.05 && 33) cout << "Both 1.05 and 33 are nonzero."; Выражения, например 1,05 && 33, почти бессмысленны, и их необходимо избе- гать. На самом деле, много программистов не любят использовать какие-либо усло- вия кроме тех, которые явно возвращают значение true/false (например, х > 0). Пример 2.3. Проверка возраста человека В этом разделе демонстрируется простое использование оператора И (&&). Программа определяет, находится ли число в определенном диапазоне - в данном случае диапазон включает в себя числа лет подросткового возраста от 13 до 19. Листинг 2.4. range.cpp #include <iostream> using namespace std; int main() { int П; cout << "Enter an age and press ENTER:
ГЛАВА 2. Решения, Решения 77 Сin >> П; if (п > 12 && п < 20) cout << "Subject is a teenager."; else cout << "Subject is not a teenager."; return 0; j Как это работает Эта небольшая программа использует условие, состоящее из двух операций сравнения: п > 12 && п < 20 Поскольку булевский оператор «и» (&&) имеет меньший приоритет, чем операции срав- нения (> и <), то операция «и» выполняется последней. Проверка выполняется так, как если бы это было записано следующим образом: (п > 12) && (п < 20) Следовательно, если введенное число больше 12 и меньше 20, результат вычисления выра- жения равен значению true и программа напечатает сообщение: «Subject is a teenager». Упражнения Упражнение 2.3.1. Напишите программу, которая проверяет принадлежность числа диапазону от 0 до 100 включительно. Введение в математическую библиотеку Вплоть до текущего момента я использовал стандартную библиотеку языка C++ для поддержки потоков ввода-вывода. Это позволяло использовать в коде объекты cout и cin, и поэтому в программы нужно было включать следующую строку: #include <iostream> Сейчас я собираюсь использовать одну из математических функций. Можно использо- вать любые операторы языка C++ (например +, *, - и %) без поддержки библиотеки, по- скольку операторы встроены в язык. Однако, чтобы использовать любую из специаль- ных математических функций, необходимо включить следующую строку: #include <math.h> Математические функции включают тригонометрические функции (sin, cos, tan, asin, acos, atan и т.д.), логарифмические функции (log, Iog10), экспоненциальные функции (pow, exp) и другие хорошие вещи. В этой главе используется только одна математиче- ская функция: sqrt, возвращающая квадратный корень. #include <math.h> //• • .-
78 C++ без страха double х; х = sqrt(2.0); // Присвоить квадратный корень из числа 2 // переменной х Программисты нежно называют функцию sqrt функцией «squirt» (струя). Как и все математические функции, эта функция принимает и возвращает значение типа double. Если вы присвоите результат целочисленной переменной, язык C++ отбросит дробную часть (а также выдаст предупреждение). int П; п = sqrt(2.0); // Эта инструкция присваивает // переменной п значение 1, // после усечения числа 1.41421 до 1. Вы можете заметить то, что здесь не согласуется. Параметрами директивы #include являются isotream и math.h. Один из них включает расширение .h, а второй - нет. Объ- яснение заключается в том, что math.h является реальным файлом; iostream - это «виртуальный» включаемый файл, который существует в предварительно скомпилированном виде. Язык C++ находится в стадии перехода и сейчас необходимо использовать и файл «iostream», и файл «math.h», чтобы максимально увеличить шансы на то, что программа будет работать со всеми компиляторами. Со временем, поддержка всех библиотек будет осуществляться при помощи виртуальных1 включаемых файлов, благодаря чему код на языке C++ станет более согласуемым. ttinclude <iostream> #include <math.h> Пример 2.4. Проверка на простое число Теперь в нашем распоряжении имеется достаточно инструментов языка C++, чтобы сде- лать что-то интересное и полезное: определить, является ли введенное число простым. Простое число - это число, которое делится без остатка только на само себя и 1. Оче- видно, что число 12.000 не является простым (поскольку оно кратно 10), но совсем не понятно, является ли простым числом 12.001. Определение того, является ли число простым - это классический случай чего-то сложно- го для людей, но - с правильной программой - простого для компьютеров. Вот сам код. Листинг 2.5. primel.cpp #include <iostream> #include <math.h> using namespace std; int main() { int n; // Проверяемое число int i; // Счетчик циклов int is_prime; // Булев флаг // Предположим, что число является простым, пока // не докажем обратное is_prime = true;
ГЛАВА 2. Решения, Решения 79 // Считать число с клавиатуры. cout << "Enter a number and press ENTER: cin » n; // Проверка числа, выполняя проверку делимости //на все целые числа от 2 to sqrt(n). i = 2; while (i <= sqrt(static_cast<double>(n))) { // Пока значение переменной i <= sqrt(n), if (n % i == 0) // Если значение переменной I [I случайно делится на n, is_prime = false; // n не является // простым числом. i++; // Прибавить 1 к значению // переменной i. } // Напечатать результаты if (is_prime) cout << "Number is prime."; else cout << "Number is not prime."; return 0; 2 При запуске программы, если пользователь введет «12000», программа напечатает: Number is not prime. Чтобы узнать, что произойдет с числом 12001, запустите программу сами. При выполнении программы введите «12000», а не «12,000». Программа на язы- ке C++ обычно не ожидает и не разрешает запятые внутри чисел. (Единствен- ное исключение будет тогда, когда вы напишете программу, которая будет ожидать запятую на месте тысячного разряда, что само по себе не является тривиальной задачей.) Как это работает Ядром программы является следующий цикл. Обратите внимание: чтобы избежать про- блем с преобразованием данных, целочисленная переменная п должна быть приведена к типу double перед ее передачей в функцию sqrt в качестве параметра; это необходи- мо, поскольку функция принимает и возвращает значения типа double. while (i <= sqrt(static_cast<double>(n))) { // Пока значение . // переменной i <= sqrt(n), if (n % i == 0)_ // Если значение переменной n
80 C++ без страха * // случайно делится на i, is_prime = false; // п не является // простым чйслом. i++; // Прибавить 1 к значению переменной i. } Давайте рассмотрим это более подробно. Ниже представлена версия этого цикла, запи- санная в псевдокоде (английский язык). Установить значение переменной i в 2. Пока (инструкция while) значение переменной i меньше либо равно квадратному корню из числа п, Если значение переменной п делится без остатка на значение счетчика циклов (i), Значение переменной п не является простым числом. Прибавить 1 к значению переменной i. В цикле проверяется, делится ли без остатка число п на каждое целое число, начиная с 2. Цикл завершается на квадратном корне числа п, поскольку все делители (числа, на кото- рые делится число п без остатка) уже будут найдены. Небольшое рассуждение, показы- вающее, что это действительно так: если число п имеет делитель больший, чем его квад- ратный корень, также должен быть делитель меньший, чем квадратный корень. (Если а * b = п и а > sqrt(n), то b < sqrt(n).) Если вы не поняли полностью приведенное математическое рассуждение, не расстраивай- тесь. Здесь важно то, как вы можете использовать язык C++ для реализации процедуры. Для проверки на делимость используется оператор деления по модулю (%), который был введен в начале главы. Вспомните, что этот оператор выполняет деление и возвращает остаток. Если второе число отлично делится на первое, остаток от деления равен 0; сле- довательно, второе число (в данном случае, i) не является простым. if (п % i == 0) is_prime = false; В начале программы делается предположение,- что число является простым (is_prime = true), таким образом, если делители не найдены, результат будет равняться значению true. Значения true (l) и false (0) в языке C++ являются предопределенными. Оптимизация программы Существует несколько путей для улучшения этой программы, но наиболее важным из- менением является следующее: как только найдется первый делитель числа п, цикл дол- жен немедленно прерываться. Нет смысла продолжать выполнение программы, по- скольку это только будет попусту тратить время центрального процессора. Ключевое слово break языка C++ позволяет выйти из ближайшего включенного цикла. Вот исправленный код: i = 2; ' while (i <= sqrt((static_cast<double>(n))) { if (n % i == 0) { is_prime = false;
ГЛАВА 2. Решения, Решения 81 break; } i++; } Обратите внимание, как здесь используются фигурные скобки ({}) для создания состав- ной инструкции (то есть блока инструкций) для инструкции if, поскольку должно быть выполнено два действия. В данном случае фигурные скобки являются обязательными, если вы хотите, чтобы программа работала правильно. Еще одним способом улучшения кода является инициализация. В языке C++ переменные могут быть проинициализированы при объявлении - используя любое выражение справа от знака «равно». Например: int is_prime = true; Упражнения Упражнение 2.4.1. Оптимизируйте программу еще, вычисляя квадратный корень числа п один раз, а не раз за разом, как это сделано в примере. Чтобы выполнить такую оптимиза- цию, нужно объявить еще одну переменную и установить ее значение равным квадратно- му корню числа п. Тип переменной должен быть double. Затем вы можете использовать эту переменную в условии цикла for. Напишите программу, содержащую как данную оптимизацию, так и оптимизацию из раздела «Оптимизация программы», целиком. Резюме В этой главе были рассмотрены следующие концепции: ✓ Использование нужного типа данных для соответствующей задачи. Переменная, в которой никогда не будет храниться дробная часть, должна иметь тип int (стан- дартный целочисленный формат в языке C++), пока число не превышает допустимый диапазон значений для типа int - свыше двух миллиардов (две тысячи миллионов). Поскольку формат с плавающей точкой использует внутреннее поле для экспоненты, го- раздо большие значения могут быть сохранены в этом формате при необходимости. ✓ Для объявления целочисленных переменных используется ключевое слово int, за которым следует имя переменной и точка с запятой. (Также можно объявлять несколько переменных, разделяя смежные имена переменных запятой.) int variable_name; ✓ Константы имеют тип int или double по обстановке. Любое значение с десятичной точкой автоматически рассматривается как значение с плавающей точкой: значение 3 сохраняется как тип int, а значение 3.0 - как тип double, поскольку оно записано с десятичной точкой. ✓ Самой простой структурой принятия решения в языке C++ является инструкция if : if (условие) инструкция
82 C++ без страха ✓ Инструкция if имеет необязательный оператор else, так что можно использовать данную форму: if (условие) инструкция else инструкция ✓ Везде, где допустимо использование инструкции, можно использовать составную инструкцию (также называемую «блоком инструкций»), состоящую из одной или бо- лее инструкций, заключенных в фигурные скобки ({}). if (условие) { инструкции ✓ Не путайте оператор присваивания (=) с оператором проверки на равенство (==). Последний сравнивает два значения и возвращает либо значение true (1), либо значе- ние false (0). Оператор присваивания возвращает присвоенное значение. Вот пример правильного использования двух операторов: if (х == у) is_equal = true; ✓ Инструкция while многократно выполняет инструкцию (или составную инструк- цию) до тех пор, пока указанное условие является истинным. Более конкретно: после каждого выполнения инструкции условие проверяется заново. Если оно истинно, ин- струкция выполняется снова. while (условие) инструкция ✓ Оператор деления по модулю выполняет деление и затем возвращает остаток от де- ления. Например, результат следующего выражения равен 3: 13 % 5 ✓ Инструкция (в большинстве случаев) - это одна строка программы на языке C++, завер- шающаяся точкой с запятой (;). Помимо этого некоторые инструкции могут состоять из ряда более мелких инструкций. Такие инструкции могут занимать несколько строк. ✓ Выражение - это значение, формируемое переменной, константой или любым чис- лом подвыражений, объединенных с операторами языка C++ (включая оператор при- сваивания). Выражения могут использоваться внутри больших выражений. ✓ Выражение может быть преобразовано в инструкцию путем добавлением точки с запятой. Например: num++; ✓ Оператор инкремента является удобным сокращением для прибавления 1 к числу. Это создает выражение с побочным эффектом. cout « п++; // Напечатать значение переменной п, после // чего к-п прибавить 1. ✓ Для создания сложных условий можно использовать булевы операторы языка C++: И(&&),ИЛИ(| |)иНЕ(1).
Г Л А В A 3. Удобная и универсальная инструкция «for» Некоторые задачи являются настолько общими, что язык C++ предоставляет специаль- ный синтаксис для их решения, используя при этом для написания программ меньше нажатий на клавиши. Примером является оператор инкремента (++), представленный в главе 2. Поскольку операция прибавления единицы к переменной является общей, язык C++ предоставляет этот оператор для прибавления единицы, хотя вы можете обойтись и без него. п++; // Прибавить 1 к значению переменной- п. Другим примером является инструкция for. Ее единственное назначение в реальной жизни - сделать определенные виды циклов с использованием инструкции while более сжатыми. Но она оказалась такой полезной, что программисты начали интенсивно ис- пользовать ее. Я использую эту инструкцию на протяжении оставшейся части книги. Вы обнаружите, что после нескольких случаев ее использования инструкция for станет для вас второй натурой. К сожалению, когда вы посмотрите на нее первый раз, она будет выглядеть странно. И, откровенно говоря, большинство обучающих книг не утруждают себя объяснением этой инструкции. Для обращения к данной проблеме я выделил целую главу для инструкции for. Циклы, используемые для счета Работая с циклами, использующими инструкцию while, в главе 2, вы могли заметить, что общее назначение цикла - это выполнение счета до числа - при этом выполняя ка- кие-то действия определенное число раз. Например: i = 1; while(i <= 10) { cout « i « " " ; i + + ; } После всего, что было сказано и сделано, что же происходит на самом деле, когда ком- пьютер выполняет счет от 1 до 10? Это именно то, что хорошо умеют делать компьюте- ры. Переменная цикла инициализируется значением 1, после чего инкрементируется, увеличивается каждый раз после выполнения цикла. Можно резюмировать, что проис- ходит в этом случае: Л > Установить значение переменной i, равное 1. > Выполнить действие цикла. > Установить значение переменной i, равное 2. > Выполнить действие цикла.
84 C++ без страха > Установить значение переменной i, равное 3. > Выполнить действие цикла. > Продолжить таким же образом, пока значение переменной i не станет равно 10 вклю- чительно. Другими словами, выполнить цикл 10 раз, каждый раз присваивая переменной i другое значение. В циклах такого вида (которые являются чрезвычайно популярными в про- граммировании) определенные действия выполняются всегда. Мы можем выделить три таких действия: инициализатор: вычисляется всего 1 раз перед началом цикла - ..... условие i = 1; while (i <= 10) { cout«i«" i++; } I-----------:— инкремент: вычисляется каждый раз после выполнения инструкции цикла Было бы приятно иметь возможность записать все эти действия в одной краткой инст- рукции. Тогда было бы легко записать цикл, считающий до 10. Введение в цикл «for» Инструкция for предоставляет именно такой механизм, позволяющий указать инициа- лизатор, условие л инкремент. инициализатор: вычисляется всего 1 раз -----перед началом цикла условие -------инкремент: вычисляется каждый раз после выполнения тела цикла for (i = 1;i <= 10; i++) cout<<i< Эта запись является не только более сжатой, но и более аккуратной. Все параметры, управляющие выполнением цикла, располагаются в круглых скобках. Более формально, вот синтаксис инструкции for вместе с эквивалентным циклом, использующим инст- рукцию while.
ГЛАВА 3. Удобная и универсальная инструкция «for 85 for (инициализатор; условие; инкремент) инструкция инициализатор; (2 while (условие){ инструкция инкремент; Взгляните на пример инструкции for снова: for (i = 1; i <= 10; i++) cout << i « " "; Мысленно расшифровывая инструкцию for, не забывайте распознавать три выражения, расположенных в круглых скобках - инициализатор, условие и инкремент — в таком же порядке. Вот как они используются в данном случае. > Инициализатором является выражение i = 1. Оно вычисляется всего один раз, перед выполнением цикла. В-данном случае, переменная i получает первоначаль- ное значение 1. > Условием является выражение i <= 10. Это такое же условие цикла, как и при написа- нии кода, с использованием, цикла с инструкцией while. В результате цикл будет повторяться до тех пор, пока значение переменной i будет меньше или равно 10. > Инкрементом является выражение i++. Оно вычисляется в конце цикла. Иными сло- вами, значение переменной i инкрементируется на единицу после каждого выполне- ния инструкции внутри цикла (так сказать, «тела цикла»), И снова, этот цикл с использованием инструкции for эквивалентен циклу с использова- нием инструкции while, который был использован ранее. i = 1 // Присвоить переменной i // значение 1 while (i <= 10) { // Пока значение переменной i // меньше или равно 10 cout << i << " "; // Напечатать значение переменной // i вместе с пробелом, i++; - // Прибавить 1 к значению // переменной i. } Я обнаружил, что даже со всем обсуждением синтаксиса и эквивалентных циклов, структура наподобие инструкции for все еще может быть неясной до тех пор, пока вы не рассмотрите множество примеров. Это цель следующего раздела. Множество примеров Я начну с небольшого изменения примера, который вы уже видели. Переменная цикла, i, инициализируется значением 1 (i = 1), и цикл выполняется до тех пор, пока условие (i <= 5) является истинным (true). Все осталось таким же, как и в предыдущем примере, за ис- ключением того, что цикл выполняет счет до 5.
86 C++ без страха for(i = 1; i <= 5; i++) cout << i << " " ; В результате получим: 1 2345 В следующем примере цикл выполняется со значения 10 до значения 20, а не с 1 до 5. for(i=10;i<=20;i++) cout <.< i « " " ; В результате получим: 1011 121314151617181920 В данном случае инициализатором является выражением i = 10, а условием - i <= 20. Эти выражения определяют исходные и терминальные (концевые) параметры цикла. (Усло- вие завершает цикл, когда оно больше не является истинным; следовательно, в данном случае самым большим значением переменной i будет 20.) Эти параметры не обязательно должны быть константами. В следующем примере они определяются переменными. Цикл выполняет счет со значения переменной п1 до значе- ния переменной п2. п1 = 32; п2 = 38; for (i = nl; i <= n2; i++) cout « i « " " ; В результате получим: 32 33 34 35 36 37 38 Выражение инкремента может быть вовсе любым выражением; оно не обязательно должно быть i++. Можно просто использовать выражение Ь-,что заставит цикл for счи- тать в обратном направлении. Обратите внимание на использование оператора «больше или равно» (>=) в условии следующего примера. for(i =10; i >= 1; i--) cout « i « " "; В результате получаем: 10987654321 Инструкция for является чрезвычайно гибкой. Изменив выражение инкремента, можно увеличить шаг счета на 2, а не на 1. for(i = 1; i <= 11; i = i + 2) cout << i « " ”; В результате получим: 1 3579 11 В последнем примере демонстрируется, что не обязательно использовать переменную i в качестве переменной цикла. Вот пример, в котором используется переменная цикла с именем j:
ГЛАВА 3. Удобная и универсальная инструкция «for»87 for(j = 1; j <= 5; j++) cout << j * 2 « " " В результате получаем: 2468 10 Обратите внимание, что в данном случае инструкция цикла печатает результат выраже- ния j * 2, благодаря чему на экран выводятся только четные числа. Вставка Всегда ди инструкция «for» ведет себя как инструкция «while»? Я сказал, что цикл с использованием инструкции for является частным случаем ин- струкции while и выполняет то же самое, что и соответствующий цикл С использо- ванием инструкции while. Это почти правда. Существует одно небольшое исключе- ние - во всей этой книге и 99,9% всего кода, который вы когда-либо будете писать - о котором не стоит беспокоиться. Исключение включает в себя ключевое слово con- tinue. Можно использовать это ключевое слово в цикле, разместив его как отдельную инструкцию, говорящую «Немедленно перейди на следующую итерацию цикла». continue; Это является типом инструкции «Иди прямо дальше». Инструкция не прерывает цикл (что делает ключевое слово break), она просто повышает производительность. Различие в поведении заключается в следующем: в цикле, организованном с исполь- зование инструкции while, инструкция continue не выполняет инкремент (i++) перед переходом на следующую итерацию цикла. В цикле, организованном с исполь- зованием инструкции for, инструкция continue выполняет инкремент перед пе- реходом. Поведение инструкции for обычно является именно тем поведением, кото- рое вы ожидаете, и это еще одна причина, почему полезна инструкция for. Пример 3.1. Печать чисел от 1 до N с использованием инструкции for Теперь мы применим инструкцию for в готовой программе. Этот пример делает то же, что и пример 2.2: печатает все числа, находящиеся в диапазоне от 1 до значения пере- менной п. Однако эта версия более компактна. Листинг 3.1. count2.cpp #include <iostream> using namespace std; int main() { , int i, n; // Считать число с клавиатуры и проинициализировать переменную i. cout « “Enter a number" and press ENTER:
88 C++ без страха cin >> n; for (i = 1; i <= n; i++) // Для i = 1 до n, cout << i << " // Печать значения переменной i. return 0; } При запуске программа выполняет счет до указанного числа. Например, если пользова- тель ввел «9», программа напечатает 123456789 Как это работает В этом примере используется простой цикл, реализованный с использованием инструк- ции for, похожий на первый пример инструкции for, приведенный ранее. Единствен- ное отличие заключается в том, что в условии цикла этого примера используется пере- менная п - число, которое программа получает от пользователя. cout « "Enter a number and press ENTER: cin >> n; • ' Цикл печатает числа от 1 до п, где п - это введенное число. for (i = 1; i <= n; i++) // Для i = 1 до n, cout << i << " // Печать значения переменной i. Для повторения: ✓ Выражение i = 1 является выражением инициализатора-, оно вычисляется только один раз, перед выполнением цикла. Таким образом, начальное значение переменной i равняется 1. Выражение i <= п является условием. Оно проверяется перед каждой итерацией цикла, чтобы решить, следует ли продолжать выполнение. Если, например, значение пере- менной п равняется 9, цикл завершится, когда значение переменной i достигнет 10, по- этому цикл не выполнится в случае, когда значение переменной i будет равно 10. ✓ Выражение i++ - это выражение инкремента, которое выполняется после каждого выполнения инструкции цикла. Цикл будет прибавлять 1 к значению переменной i каждый раз при выполнении очередной итерации. Таким образом, логика программы следующая: Установить значение переменной i в 1. Пока значение переменной i меньше или равно значению переменной п, Напечатать значение переменной i, Прибавить 1 к значению переменной i. Упражнения Упражнение 3.3.1. Используйте инструкцию for в программе, которая печатает все числа в диапазоне от п1 до п2, где п1 и п2 - это два числа, введенных пользователем. (Подсказка: необходимо вывести на экран подсказку для ввода двух чисел, а затем,
ГЛАВА 3. Удобная и универсальная инструкция «for» 89 внутри инструкции for, проинициализировать переменную i значением п1 и использо- вать переменную п2 в условии цикла.) Упражнение 3.3.2. Перепишите пример таким образом, чтобы числа от п до 1 печата- лись в обратном порядке. Например, пользователь ввел число 5 и программа напечатала 5 4 3 2 1. (Подсказка: в цикле, образованном при помощи инструкции for, проинициа- лизируйте переменную i значением п, используйте в условии i >= 1 и вычитайте 1 из зна- чения переменной i на шаге инкремента.) Блоки инструкций при использовании инструкции for До сих пор я использовал следующую инструкцию в теле каждого цикла: cout « i « " "; Конечно, вы не обязаны использовать эту инструкцию. Вы не должны печатать значение переменной i; на самом деле, вы вообще не обязаны печатать что-либо. Я выбрал эту инструкцию за ее ценность при демонстрации того, что делает цикл. Вы можете выпол- нять в цикле множество других вещей. Как и в случае с инструкциями if и while, с инструкцией for можно использовать блок инструкций: for (инициализатор; условие; инкремент) { инструкция Как и раньше, этот синтаксис следует из правила, гласящего, что везде, где можно ис- пользовать инструкцию в языке C++, можно использовать составную инструкцию. Вот пример, выполняющий внутри цикла, созданного с использованием инструкции for, две инструкции: for (i = 1; i <= 10,; i++) { cout << "The square root of " « i « " is "; cout « sqrt(i) « endl; ) Это является эквивалентом следующего кода: i - 1 ; while (i <= 10) { cout « "The square root of " « i « " is "; cout « sqrt(i) « endl; i++; ) Динамическое объявление переменных цикла Одним из дополнительных преимуществ инструкции for является то, что вы можете использовать ее для объявления переменной, имеющей локальную область видимости для самого цикла for. Переменная объявляется «динамически» для быстрого использо- вания в самом цикле for. Например:
90 C++ без страха for (int i = 1; i'<= 10; i++) // Для i = 1 до n, cout << i ; - // Печать значения // переменной i. В данном примере переменная i объявляется внутри выражения инициализатора инст- рукции for. Если вы используете этот метод, нет необходимости объявлять переменную i вне цикла. Можно переписать пример 3.1 следующим способом: Листинг 3.2. count3.cpp ttinclude <iostream> using namespace std; int main() { int n ; // Считать число с клавиатуры cout << "Enter a number and press ENTER: "; ' cin » n; for (int i = 1; i <= n; i++) // Для i = 1 до n, cout << i << " "; //Печать значения переменной i. return 0; 2 Пример 3.2. Проверка на простое число с использованием цикла for В этом разделе я возвращаюсь к примеру 2.4, чтобы продемонстрировать, как написать эту программу с использованием цикла for вместо цикла while. Этот пример выпол- няет нечто более интересное, чем просто печатает значение переменной i; программа определяет, является ли введенное число простым. (Вспомните, что число является про- стым, если оно делится без остатка только на само себя и единицу.) В этом примере используется та же основная логика, что и в примере 2.4. Простите ме- ня, если это кажется немного излишним. Логика проверки на простое число следующая: Установить значение переменной i в 2. Пока значение переменной i меньше или равно квадратному корню числа п, Если число п.делится без остатка на значение переменной i, Число п не является простым. Прибавить 1 к значению переменной i. Версия программы с циклом for использует такой же подход: после компиляции про- грамма выполняет такие же инструкции, как и в цикле while. Однако, поскольку при- сущее циклу for свойство заключается в выполнении счета - в данном случае счета от 2
ГЛАВА 3. Удобная и универсальная инструкция «for» 91 до квадратного корня числа п - мы можем думать об этом немного по-другому. По су- ществу, данный подход проще: Для (инструкция for) всех целых чисел от 2 до квадратного корня числа п, Если число п делится без остатка на значение переменной i, Число п не является простым. Вот законченная программа, выполняющая проверку, является ли число простым. Повторюсь, это еще одна версия программы, описанной в примере 2.4, поэтому большая ее часть будет выглядеть для вас знакомо. Листинг 3.3. рпте2.срр #include <iostream> #include <math.h> using namespace std; int main() { int n; // Проверяемое число int i; // Счетчик циклов int is_prime; // Булев флаг // Предположим, что число является простым, пока ' ’ // не докажем обратное is_prime = true; // Считать число с клавиатуры. cout << "Enter a number and press ENTER: "; cin » n; // Проверка числа, выполняя проверку делимости // на все целые числа от 2 to sqrt(n). for (i = 2; i <= sqrt((double) n); i++) { if (n % i == 0) is_prime = false; } // Напечатать результаты if (is_prime) cout << "Number is prime."; else cout « "Number is not prime."; return 0;- 2 После запуска, если пользователь введет «23», программа напечатает: Number is prime.
92 C++ без страха Как это работает В начале программы используются директивы #include для обеспечения необходимой поддержки библиотек языка C++. Библиотека языка C++ используется здесь потому, что в программе будет вызываться функция sqrt для получения квадратного корня числа. #include <iostream> #include <math.h> Оставшаяся часть программы определяет функцию main - основную (и пока единст- венную) функцию. Первое, что делает функция main, - это определяет три переменные, которые будут использованы в программе. int п; // Проверяемое число int i; // Счетчик циклов int is_prime; // Булев флаг Хотя переменная is_prime является целочисленной переменной, ее назначение заключа- ется в хранении значения true (1) или false (0). Обратите внимание, что если ваша вер- сия языка C++ поддерживает тип bool, то было бы логичнее использовать именно его: bool is_prime; // Булев флаг Если программа не сможет найти делитель для числа п, она должна сделать заключение, что число является простым. Именно поэтому значение переменной is_prime по умолча- нию равно значению true. Другими словами, если значение переменной is_prime специ- ально не установлено в значение false, это будет (правильно) отражать тот факт, что число является простым. // Предположим, что число является простым, пока //не докажем обратное is_prime = true; Сердцем программы является цикл for, выполняющий проверку на простое число. Как я уже объяснял в главе 2, необходимо проверять делители только до квадратного корня числа и. Если делитель среди этих чисел не найден, значит проверяемое число не имеет делителей, кроме себя самого и единицы. Выражение и % I использует оператор деления по модулю (%) для выполнения деления и получения остатка от деления. Этот остаток равен 0, если число и делится без остатка на значение переменной I, - в этом случае число и не является простым. for (i = 2; i <= sqrt((double) n) ; i++) { if (n % i == 0) is_prime = false; } Запомните, как работает цикл for: первое выражение в скобках - это инициализатор, второе - условие и самое последнее - инкремент. Данный цикл for эквивалентен сле- дующему коду: 1 = 2; while (i <= sqrt((double) n)) { if (n % i == 0) is„prime = false; i + +; }
ГЛАВА 3. Удобная и универсальная инструкция «for»93 Обратите внимание, что версия с циклом for содержит только одну инструкцию внутри цикла - эта вложенная инструкция является инструкцией if. Фигурные скобки ({}) до- пустимы, поскольку вы всегда можете иметь составную инструкцию, содержащую всего одну инструкцию внутри фигурных скобок. В данном случае их единственное назначе- ние заключается в понятности. Если вы запишете цикл for следующим образом, без фигурных скобок, он также будет работать: for (i = 2; i <= sqrt((double) n); i + +) if (n % i == 0) is_prime = false; Как я уже упомянул в главе 2, некоторые программисты- выступают против написания когда бы то ни было инструкций for или if без использования синтаксиса блока инст- рукций (то есть использования фигурных скобок) даже в том случае, когда они абсолют- но не нужны. В данном случае оставить фигурные скобки - хотя бы для инструкции for - это хорошая идея, поскольку Они помогают проще читать программу. Упражнения Упражнение 3.2.1. Модифицируйте пример таким образом, чтобы он использовал оп- тимальный код. Когда вы учитываете молниеносную скорость современных микропро- цессоров, наверняка вы не заметите различия в скорости выполнения, хотя если вы по- пытаетесь выполнить проверку для чрезвычайно огромного числа, скажем, больше мил- лиарда, возможно, вы заметите небольшое отличйе во времени ответа. (Между прочим, желаю вам удачи в поиске простого числа в этом диапазоне, если вы просто ищете одно из них случайным образом. Среди больших чисел простые числа встречаются реже.) В любом случае, следующие изменения в коде помогут сделать программу более эффек- тивной при работе с большими числами: ✓ Вычисляйте квадратный корень числа и только один раз, объявив переменную square_root_of_n и определив ее значение перед входом в цикл for. Чтобы избежать предупреждений компилятора, необходимо объявить переменную типа double. ✓ Как только найден делитель числа и, нет необходимости продолжать поиск. -Поэтому, в инструкцию if внутри цикла добавьте инструкцию break (прерывающую выпол- нение цикла) после установки значения переменной is_prime в значение false. Я рассказывал про эти оптимизации в главе 2. Цель данного упражнения заключается в работе с ними с использованием инструкции for. Сравнительные языки 101: Инструкция «for» языка Basic Если вы программировали на языке Basic или FORTRAN, вы видели инструкции, чем-то похожие на инструкцию for языка C++, назначение которых заключалось в счете от одного числа до другого. Например, этот цикл на языке Basic печатает все целые значе- ния в диапазоне от 1 до 10: For i = 1 То 10 Print i Next i
94 C++ без страха Инструкция «for» языка Basic имеет преимущество в понятности и простоте использо- вания. По общему признанию, для использования цикла необходимо меньше раз нажать при вводе на клавиши, чем при использовании цикла for языка C++. Однако на фоне этого преимущество инструкции for языка C++ заключается в том, что она является бесконечно более гибкой. «Бесконечно» - это очень серьезное слово, по- этому требуется некоторое обоснование. Одна из причин, по которой инструкция for языка C++ является гораздо более гибкой, - это возможность ее использования с любыми тремя допустимыми выражениями языка C++. Условие (среднее выражение) не обязательно должно быть булевым выражением, например «i < п», хотя использование других типов выражений может быть рискован- ным. При вычислении условия в инструкциях if, while или for, помните, что любое ненулевое значение рассматривается как значение «true». Инструкция for даже не требует от вас использования всех трех выражений {инициали- затора, условия и инкремента). Если какое-то из выражений пропущено, этот факт иг- норируется. Если пропущено условие, то по умолчанию оно считается «истинным» и устанавливается бесконечный цикл. for(;;) { // Бесконечный цикл! } Бесконечный цикл обычно является нехорошей вещью, о чем я говорил в главе 2. Одна- ко, если у вас есть способ прервать этот цикл (например, используя инструкцию break), бесконечный цикл может быть вполне корректным. В следующем примере пользователь может выйти из цикла, введя значение 0. for (;;) { // Выполнить некоторые действия... cout << "Enter a number and press ENTER: cin >> n; if (n == 0) break; // Выполнить дополнительные действия... } Резюме Давайте освежим наше понимание основных вопросов главы 3: ✓ Назначение инструкции for обычно заключается в повторении действия и парал- лельного выполнения счета до определенного значения. Инструкция имеет следую- щий синтаксис: for {инициализатор; условие; инкремент) инструкция
ГЛАВА 3. Удобная и универсальная инструкция «for»95 ✓ Это эквивалент следующего цикла while: инициализа тор; while {условие) { инструкция инкремент', } ✓ Цикл for ведет себя так же, как и его аналог, цикл while (как уже описывалось), кроме одного исключения: инструкция continue инкрементирует переменную цикла перед переходом на начало следующей итерации цикла. ✓ Как и в случае с другими управляющими структурами, всегда можно использовать составную инструкцию в цикле for, используя открывающую и закрывающую фи- гурные скобки ({}): for {инициализатор; условие; инкремент) { инструкция } ✓ Переменная, похожая на переменную i в следующем примере, называется переменной цикла (loop variable): for (i = 1; i <= 10; i + +) cout « i « " "; ✓ В выражении инициализатора можно объявлять переменную «динамически». Это объявление дает переменной локальную область видимости в пределах цикла for, что означает, что изменения значения переменной не коснутся переменных с таким же именем, объявленных за пределами цикла. for (int i = 1; i <= 10; i + +) cout « i « " "; ✓ Как и в случае с инструкциями if и while, условие цикла инструкции for может быть любым допустимым выражением языка C++; любое ненулевое значение счита- ется «истинным». Но лучше придерживаться настоящих булевых выражений, например х > 0 и а == Ь. ✓ Можно опустить любое или все три выражения внутри круглых скобок инструкции for (инициализатор, условие, инкремент). Если опущено условие, цикл выполняется безусловно. (Другими словами, цикл является бесконечным.) Не забывайте использо- вать инструкцию break для выхода из него. fОГ(;;) { // Бесконечный цикл! }
ГЛАВА 4. Функции: вызываются много раз С самых первых дней эры компьютеров одной из основных задач, стоящих перед про- граммистами, было избежать необходимости повторного написания одной и той же груп- пы инструкций. Это было поиском возможности повторного использования кода и глав- ной причиной, из-за которой разработано ООП, т.е. объектно-ориентированное програм- мирование (OOP - Object-Oriented Programming). Однако основополагающим методом для написания повторно используемого кода является использование функций - в других язы- ках программирования известных как процедуры или подпрограммы. Функция - это группа связанных инструкций, выполняющих определенную задачу. Определив однажды функцию, ее можно использовать всякий раз, когда это необходи- мо. Помимо этого, функции предоставляют возможность разделения сложной програм- мы на более мелкие, более выполнимые задачи. Без такого разделения труда серьезное программирование было бы практически невозможным. Понятие функции Если вы следили за изложением книги вплоть до этого места, то вы уже видели исполь- зование функции - а именно, функции sqrt, принимающей на вход один параметр и возвращающей результат. double sqrt_of_n = sqrt(n); Выполнение функции и использование ее результата (то есть ее возвращаемого значе- ния) называется вызовом функции (function call). Понятие функции в языке C++ не сильно отличается от понятия функции в математике, если вы вспомните алгебру высшей школы или колледжа. Функция принимает ноль или более входных параметров и возвращает результат, называемый возвращаемым значени- ем (return value). Вот другой пример - этот пример является гипотетическим и не является частью стан- дартной библиотеки языка C++. Данная функция принимает два входных параметра и возвращает их среднее число. cout << avg(1.0, 4.0); Если предположить, что функция avg была правильно написана, то эта строка кода на- печатает число 2,5. По существу, идея данного примера похожа на идею функции sqrt: получить несколь- ко входных параметров и вернуть результат. Вы можете представить вызов функции следующим образом: 2.5 < V avg()
ГЛАВА 4. Функции: вызываются много раз 97 Функция может не иметь входных параметров, как в случае с функцией rand, которая возвращает случайное целое число: n = rand() ; При вызове функции rand используются круглые скобки; круглые скобки присутству- ют всегда при вызове функции в языке C++, даже в том случае, когда функция не имеет входных параметров. Это оказывается полезным, поскольку с их присутствием стано- вится абсолютно понятно, когда выполняется именно вызов функции, а когда - нет. Вообще говоря, функции делятся на две категории. Они не сильно отличаются - обе ка- тегории подчиняются одним и тем же правилам - но существует отличие в объеме рабо- ты, которую необходимо выполнить: ✓ Некоторые функции являются частью стандартной библиотеки языка C++. Они уже написаны и скомпилированы, поэтому вы не должны определять то, что. они будут делать. ✓ Функции, не являющиеся частью стандартной библиотеки языка C++, должны быть написаны в самой программе. Вы определяете то, что будут делать эти функции. Оба типа функций подчиняются одному из крайне важных, незыблемых правил языка C++: Перед вызовом функции для нее необходимо полностью объявлять инфор- мацию о типе. Вы можете быть освобождены от этого требования только в * том случае, если «шс1ш1е»-файл выполнит эту работу по объявлению за вас (как это делает файл math.h для функции sqrt). Вот почему директива #include, полезна при работе с библиотечными функциями. Например, включение файла «math.h» избавляет от необходимости объявления какой- либо математической функции - включая функцию sqrt - перед ее использованием. #include <math.h> cout « "square root of 2 is " « sqrt(2.0); Если функция не объявлена в «includew-файле, вы должны объявить ее самостоятельно - идеально это делать в начале исходного кода. Вы можете спросить, зачем это нужно? Один из ответов относится к проблеме нескольких модулей - когда исходный код раз- мещен в более чем одном файле. Определение функции может находиться в другом ис- ходном файле, чем инструкция, вызывающая эту функцию, поэтому без явного объявле- ния компилятор не будет иметь возможности получить информацию о типе для функ- ции. А без этой информации не будет возможности проверить и узнать, правильно ли используется данная функция. Помимо этого, привычка объявлять функцию перед ее использованием является хоро- шим стилем программирования. Поскольку язык C++ является строгим относительно информации о типе, иногда требуется немного дополнительной работы. Однако эта ра- бота доказывает, что время потрачено не зря. 4 - 6248
98 C++ без страха Вызовы функции и процесс выполнения программы В этой главе я собираюсь сосредоточиться на функциях, определяемых пользователем. В процессе работы я буду использовать библиотечные функции sqrt и rand, однако все остальное, что будет сказано в этой главе, относится к задаче создания и вызова функций, определяемых вами. Мысленно лучше всего представить функцию C++ как какую-то определенную задачу (а specific task). Программа может выполнять множество задач. Если это коммерческая программа, как например Microsoft Word, она может выполнять сотни или даже тысячи задач. Что делает возможным написание сложного программного обеспечения, так это то, что можно писать отдельные функции, каждая из которых выполняет ограниченную работу. Каждая функция может быть отдельно написана и протестирована. Представьте, что программисту необходимо написать серьезный, полнофункциональный текстовый редактор, и сделать это нужно, написав единственный блок кода. Эта задача была бы невыполнимой. Даже Билл Гейтс и Пол Аллен не смогли бы сделать это. Одна- ко программист может написать ряд функций, выполняющих конкретные задачи - на- пример, «загрузить файл» или «вставить текст из буфера обмена», т- а затем вызывать их по мере необходимости. Этот подход делает написание сложного программного обеспе- чения выполнимым. После того как функция написана, ее можно вызывать любое количество раз. Каждый раз при вызове функции выполнение программы временно переносится из текущей функции (например, main) на описание вызываемой функции. На следующем рисунке показано, как это может происходить в простой программе. ПРОГРАММА int main() { double а = 1.2; double b=2.7; cout«avg(a,b); double avg(double a, double b) { double v=(a+b)/2; return v; --------------------- ) ЗАВЕРШАЕТСЯ Вот как происходит выполнение программы в этом примере: > Выполнение программы начинается как обычно, с автоматического выполнения функции main (которая выполняется до вызова какой-либо функции или до конца программы). > Вызов функции avg передает управление из функции main в функцию avg.
ГЛАВА 4. Функции: вызываются много раз 99 ► Выполнение функции avg продолжается до инструкции return или до конца функ- ции. В этой точке управление возвращается обратно функции main, на инструкцию,. следующую прямо за вызовом функции. ► Функция main завершает выполнение. Инструкция return 0; возвращает управ- ление обратно операционной системе. Программа завершена. Исходя из этой логики можно сделать вывод, что возможно определить функции, которые никогда не будут выполнены. Это действительно так. Только функция main будет Обяза- тельно выполнена. Другие функции выполняются только тогда, когда они вызываются. Основы использования функций Я очень рекомендую следующий подход для создания и вызова функций, определенных пользователем: ✓ В начале программы объявляйте функцию в виде прототипа. Это объявление, кото- рое содержит только информацию о типе. ✓ Где-нибудь в программе определите функцию. Здесь описывается то, что будет де- лать функция. ✓ После этого можно выполнять функцию в любом месте программы. Этот процесс из- вестен как вызов функции. Вызов функции можно выполнять любое количество раз. Вы можете спросить: «Обязательно ли и объявлять, и затем определять функцию? В чем отличие?» Необходимо предоставить полную информацию о типе для функции перед тем, как вы- звать ее, - язык C++ требует точного описания типов входных и выходных данных. Для этого можно определить функцию перед ее вызовом из функции main. Но в большой программе такой подход становится громоздким, когда вы не можете вспомнить, какие функции вызываются первыми. Поэтому, чтобы избежать данной проблемы, самым лучшим подходом является объяв- ление всех функций в начале программы. После этого можно размещать определения функций в любом порядке. Шаг 1: Объявление (создание прототипа) функции Объявление (declaration) функции (или «прототипа») содержит только информацию о типе. Объявление имеет следующий синтаксис: тип имя_функции (списдк_аргументов) ; Тип - это определенный тип данных, например int, float или double, сообщающий, какой тип значения возвращает функция (что она передает обратно). Если функция не воз- вращает значения, используется специальный тип void (тип void означает «пустой тип»). Список_аргументов - это список, состоящий из нуля или более имен аргументов, - раз- деленных запятыми, если аргументов больше, чем один, - перед каждым из которых указано соответствующее имя типа. 4*
100 C++ без страха Например, следующая инструкция объявляет функцию с именем avg, принимающую два аргумента типа double и возвращающую значение типа double. double avg(double x, double y); Список_аргументов может быть пустым, что означает, что функция не принимает аргу- менты. Например, следующая инструкция объявляет функцию get_todays_date, возвращающую значение типа int: int get_todays_date(); В этом смысле, язык C++ отличается от языка С. Объявление с пустым спи- ском аргументов не означает, как в языке С, что список аргументов не опреде- лен. Это означает, что функция не может иметь какие-либо аргументы. Это одно из изменений, которое необходимо произвести в коде языка С при его пе- реносе на язык C++. Другое отличие состоит в том, что язык C++ требует стиль объявления прототипа, описанный в этом разделе. Вот еще примеры объявления функций: void print_spaces(int n) ; .// Объявление функции print_spaces; // Принимает один целочисленный аргумент, // Не возвращает значение (void). double get_odds(int a, int b); // Объявление функции get_odds; // Принимает два целочисленных аргумента // и возвращает значение типа double, int is_prime(int n); // Объявление функции is_prime; // Принимает один целочисленный аргумент // и возвращает целочисленное значение. Шаг 2: Определение функции Определение функции описывает то, что делает функция. Для этого используется сле- дующий синтаксис: тип имя__функции (список_аргументов) { инструкции } Большая часть этого синтаксиса выглядит, как объявление. Единственное, чем он отли- чается от объявления, - точка с запятой заменяется рядом инструкций, расположенных между двумя фигурными скобками ({}). Часть синтаксиса инструкции может не содержать инструкций вообще. Но в любом слу- чае фигурные скобки необходимы. Вот пример, использующий одну инструкцию в оп- ределении: double avg(double х, double у) { return (х + у) / 2; }
ГЛАВА 4. Функции: вызываются много раз 101 Эта же функция может быть переписана как более длинная версия - хотя она занимает больше места для выполнения того же. Обратите внимание, что эта версия объяйляет внутреннюю переменную с именем V. (Переменная является локальной-, она не видна за пределами функции.) double avg(double х, double у) { double v = (х + у) / 2; return v; } Инструкция return указывает, что функция возвращает результат выражения (х + у) / 2. Функции с возвращаемым типом void не возвращают значение (хотя инструкция re- turn без аргументов может использоваться для раннего выхода из функции). Вот про- стой пример определения функции с возвращаемым типом void и - в этом частном слу- чае - без аргументов. void print_messages() { cout << "You just attempted an illegal action." « endl ; cout << "This will not be allowed." « endl; cout << "Try not to do that ever again." « endl; } Шаг 3: вызов функции Как только функция объявлена и определена, она может быть использована - или, вер- нее, «вызвана» - любое количество раз из любой функции. Например: n = avg(9.5, 11.5); n = avg(5, 25); n = avg(27, 154.3); Вызов функции является выражением: если функция возвращает значение, отличное от значения void, она может быть использована внутри большего выражения. Например: z = х + у + avg(а, Ь) +25.3; При вызове функции значения, указанные в вызове функции, передаются в качестве ар- гументов функции. Вот как работает вызов функции avg со значениями 9,5 и 11,5 в ка- честве входных параметров: z = avg(9.5, 11.5); F double avg(double х, double у) { return (х + у)й; } (9.5+11.5)72 21.0 /2 Z <---------------- Ю.5
102 C++ без страха В другом вызове функции могут передаваться другие значения - в данном случае 6 и 26: z - avg(6,26); i ▼ double avg(double x, double y) { return (x + y)/2; ) (6.0 + 26.0)72 32.0 П Z <------- 16.0 Значения 6 и 26 являются целочисленными значениями. Язык C++ преобразовывает эти значения в тип double без проблем. Однако, когда вы идете обратным путем, - при- сваивая возвращаемое значение типа double переменной типа int, - компилятор вы- дает предупреждение, что некоторые значения из большего диапазона могут не уме- ститься в меньшем диапазоне. Используя приведение, вы сообщаете компилятору, что вам известно о проблеме, но в любом случае желаете продолжить выполнение операции присваивания. Вспомните из главы 2 синтаксис приведения данных: static_cast<тип> {выражение) При использовании этого выражения вы избежите предупреждающего сообщения. Например: int i = static_cast<int>(avg(3.5, .10)); Пример 4.1. Функция вычисления числового треугольника В этом разделе показан вызов функции в контексте завершенной программы. Треуголь- ник суммирует все целые числа от 1 до указанного числа. Например, треугольник числа 5 равняется: triangle(5) =1+2+3+4+5=15 Числовой треугольник для числа 7 равен: triangle(7) =1+2+3+4+5+6+7=28 Написание программы, включающей и проверяющей данную функцию, является доста- точно простой задачей. Вот код: Листинг 4.1. triangle.cpp #include <iostream> using namespace std; // Функция должна быть объявлена перед использованием, int triangle(int num);
ГЛАВА 4. Функции: вызываются много раз 103 int main() { int n; cout « "Enter a number and press ENTER: cin >> n; cout << "Function returned " « triangle(n); return 0; - } // Функция числового треугольника. // Возвращает 1 + 2 + . . . + n int triangle(int n) { int i; int sum = 0; for (i = 1; i <= n; i++) // Для i = 1 до n, sum = sum + i; // Прибавить значение переменной i // к значению переменной sum return sum; } Как это работает Этот код прост. Во-первых, объявление функции (или «прототип») размещается в начале программы, int triangle(int num); После этого объявления любая инструкция теперь может вызывать функцию треуголь- ника. Все, что для этого требуется, это чтобы функция была определена где-то в про- грамме. Инструкция в функции main вызывает данную функцию: cout << "Function returned " « triangle(n); Значение переменной п вводится с клавиатуры. Предположим, что пользователь ввел «4». В этом случае вызов функции передает значение 4 в качестве аргумента п: cout « "Function returned" « triangle(4); I int triangle (int n){ int i: int sum = 0 for(i = 1; i<= n;i++) sum = sum + i; return sum; 1 j 10'
104 C++ без страха После этого программа печатает возвращенное значение, 10 (поскольку 1+2+3+4 = 10). Результат такой же, если бы это значение заменило вызов функции. cout « "Function returned " << 10; Вот определение функции triangle. Функция объявляет свои собственные перемен- ные и использует цикл for для повторения тела цикла п раз. int triangle ( int ii) { int i ; int sum '= 0; for (i = 1; i <= n; i++) // Для i = 1 до n, sum = sum + i; // Прибавить значение переменной i // к значению переменной sum return sum; ) Цикл суммирует все целые числа от 1 до значения переменной п, накапливая сумму в переменной sum. И снова, если пользователь ввел значение 4, цикл for выполнится четыре раза, каждый раз увеличивая значение переменной i на 1. Значение переменной i во время очередной ите- рации цикла Действие инструкции - цикла Значение переменной sum после выполнения этого действия 1 Прибавление 1 к значению переменной sum 1 2 Прибавление 2 к значению переменной sum 3 3 Прибавление 3 к значению переменной sum 6 . 4 Прибавление 4 к значению переменной sum 10 После этого функция возвращает окончательное значение переменной sum, в данном случае «10». return sum; Переменные i и sum являются локальными для функции треугольника, поэтому они объ- явлены внутри определения функции. Когда переменная является локальной, другие функции могут иметь переменную с таким же именем и то, что происходит в одной функции, не касается того, что происходит в другой. Например, обе функции main и triangle могут иметь переменную i, и когда функция triangle изменит значение переменной i, изменения не коснутся значения переменной i функции main. При объявлении в функции локальной переменной sum ее значение инициализируется нулем. Язык C++ не буквален относительно инициализации и позволяет инициализиро- вать переменную любым допустимым выражением (а не просто константным выражени- ем, как это требуется в языке С). int sum = 0;
ГЛАВА 4. Функции: вызываются много раз 105 Оптимизация программы Разработчики языка С представляли, что операция инкрементирования значения пере- менной (в цикле или в другом контексте) является одной из самых распространенных операций, которые вы Можете делать в программе. Одержимые идеей создания сокра- щений, они видели, что прибавление значения к переменной является распространенной операцией и поэтому должно иметь сокращенную версию. (Эта Инструкция также транслируется в эффективную инструкцию центрального процессора.) Мы уже видели, как прибавить 1 к переменной: i + +; Это наш старый друг - оператор инкремента. Он прибавляет 1 к переменной. Но что делать, если вы желаете прибавить к числу значение, отличное от 1? Это то, для чего предназначен оператор прибавления с присваиванием (+=). Например, следующая инструкция прибавляет 5 к значению переменной п: п += 5; // п = п + 5 Как и любой другой тип выражения в языке C++ (отличный от вызова функции с воз- вращаемым значением типа void), выражение п += 4 возвращает значение: в частности, это выражение возвращает новое значение, присвоенное переменной п. Следовательно, следующие два выражения являются эквивалентными, поскольку оба возвращают зна- чение переменной i после инкрементирования: ++i i += 1 Выражение sum = sum + i в функции треугольника может быть заменено выражением sum += i, в результате чего получим следующий код: int triangle(int n) { int i; . int sum = 0; for (i = 1; i <= n; i++) //':-Для i = 1 до n, sum += i; // Прибавить значение переменной i // к значению переменной sum return sum; } Все арифметические операторы (как и другие, которые вы еще не видели) имеют соот- ветствующий оператор присваивания. Здесь сокращение var - это любая переменная, а сокращение ехрг - любое допустимое выражение. Оператор Расшифровка var += ехрг var = var + ехрг var -= ехрг var = var - ехрг var *= ехрг var = var * ехрг var /— ехрг var - var / ехрг
106 C++ без страха Упражнения Упражнение 4.1.1. Напишите программу, определяющую и проверяющую функцию вычисления факториала. Факториал числа - это результат произведения всех целых чи- сел от 1 до N. Например, факториал числа 5 равен 1*2*34*5 = 120. (Подсказка: можно использовать похожий код примера 4.1, просто изменив несколько строк.) Упражнение 4.1.2. Измените программу упражнения 4.1.1. таким образом, чтобы в ней использовался оператор умножения с присваиванием *=. (Например, выражение и *= 4 умножает значение переменной п на 4.) Упражнение 4.1.3. Напишите функцию print_out, печатающую все целые числа в диа- пазоне от 1 до N. Проверьте работу функции, поместив ее в программу и передав ей чис- ло п - число, введенное с клавиатуры. Возвращаемый тип функции print_out должен быть void; функция не возвращает значение. Функция может быть вызвана простой инструкцией: print_out(п); Пример 4.2. Функция проверки на простое число В конце главы 2 был приведен действительно полезный пример: определяющий, являет- ся ли указанное число простым или нет. Не было бы лучше иметь возможность выполнять проверку на простое число тогда, ко- гда мы это хотим? Если проверка написана в виде функции, она может быть выполнена любое количество раз посредством вызовов функции. И вам не придется выполнять про- грамму снова и снова, чтобы проверить несколько чисел. Следующая программа использует пример проверки на простое число из глав 2 и 3, но размещает соответствующие инструкции языка C++ в отдельной функции is_prime. Листинг 4.2. рптеЗ.срр #include <iostream> #include <math.h> using namespace std; // Функция должна быть объявлена перед использованием, int prime(int n); int main() { . int i; ‘ // Устанавливается бесконечный цикл; прерывается, если // пользователь введет 0. // Иначе, выполняет проверку числа п на простоту, while (1) { cout « "Enter a number (0 to exit)"; cout « "and press ENTER:'1; cin >> i; if (i == 0) // Если пользователь ввел 0, break; // ВЫХОД if (primed) ) // Вызов функции prime (i)
ГЛАВА 4. Функции: вызываются много раз 107 cout << i « " is prime" « endl; else cout « i « " is not prime" « endl; } return 0; } // Функция проверки на простое число. Проверяет делители // от 2 до квадратного корня числа п.- Возвращает // значение false, если делитель найден; иначе // возвращает значение true. int prime(int n) { int i ; for (i = 2; i <= sqrt((double) n); i++) { if (n % i == 0) // Если число n делится без // остатка на значение переменной i return false; // п не простое число. ) return true; // Если делители не найдены, // п простое число. _____________________________________________________ Как это работает И вновь мы видим, что эта программа соблюдает шаблон (1) объявления информации о типе функции в начале программы («создания прототипа» функции), (2) определения функции где-то в программе и (3) вызова функции из функции main. Прототип сообщает о том, что функция prime принимает целочисленный аргумент и возвращает целочисленное значение. (На самом деле это булево значение - то есть true/false - значение, однако язык C++ позволяет сохранять подобные переменные в пе- ременной типа int, поскольку значения true и false представляются значениями 1 и 0 соответственно.) int prime(int n) ; Определение функции является вариацией кода, выполняющего проверку на простое число, из главы 3, в котором использовался цикл for. Если сравнить этот код с кодом из примера 3.2, можно найти только небольшие отличия. Конечно же, в этой версии ис- пользуется синтаксис кода функции: int prime(int n) { int i; for (i = 2,- i <= sqrt ( (double) n) ; i++) { if (n % i == 0) // Если число n делится без // остатка на значение переменной i return false; // п не простое число. ) return true; // Если делители не найдены, // п простое число.
108 C++ без страха Другое отличие заключается в том, что вместо использования булевой переменной is_prime в этой версии возвращается булев результат. Логика следующая: Для всех целых чисел от 2 до квадратного корня числа п, Если значение переменной п делится без остатка на переменную цикла (i), Тотчас же возвратить значение false. Помните, что оператор деления по модулю (%) выполняет деление двух целых чисел и возвращает остаток от деления. Если остаток равняется 0, значит, второе число является делителем первого. Здесь действие инструкции return является ключевым. Эта инструкция тотчас же воз- вращает значение - происходит выход из функции и производится передача управления обратно в функцию main. Нет необходимости использовать инструкцию break для выхода из цикла: for (i = 2; i <= sqrt((double) n); i++) if (n % i == 0) { return false; break; // не обязательно ) Цикл в функции main вызывает функцию prime. С помощью инструкции break здесь обеспечивается выход из цикла, поэтому цикл на самом деле не будет бесконеч- ным. Как только пользователь введет «0», цикл прервется и программа завершится. Добавленный комментарий показывает, где заканчивается цикл: while (1) { , cout << "Enter a number (0 to exit)"; cout « "and press ENTER"; cin » i; if (i == 0) // Если пользователь ввел 0,то ВЫХОД break; if (prime (i) ) // Вызов функции primed) cout « i « " is prime" « endl; else cout « i « " is not prime" << endl; ) В оставшейся части цикла происходит вызов функции prime и печатается результат проверки на простое число. Обратите внимание, что функция prime возвращает значе- ние true/false, и поэтому вызов функции prime (i) может быть использован в условии инструкции i f - е 1 s е. Упражнения Упражнение 4.2.1. Оптимизируйте функцию проверки на простое число, вычисляя квадратный корень числа п только один раз, во время каждого вызова функции. Объяви- те локальную переменную sqrt_of_n типа double. (Подсказка: переменная является локальной, если она объявлена внутри функции.) После этого используйте эту перемен- ную в условии цикла.
ГЛАВА 4. Функции: вызываются много раз 109 Упражнение 4.2.2. Перепишите функцию main таким образом, чтобы она проверяла все числа в диапазоне от 2 до 20 и печатала результаты, каждый на отдельной строке. (Подсказка: используйте цикл for с переменной i, принимающей значения от 2 до 20.) Упражнение 4.2.3. Напишите программу, которая находит первое простое число, кото- рое больше одного миллиарда (1000000000). Локальные и глобальные переменные Почти каждый из существующих языков программирования (машинный код является основным исключением) имеет понятие локальной переменной. Это означает, что, если две функции имеют свои собственные данные, как это и было, они не будут влиять на данные друг друга. Это определенно является особенностью последнего примера (пример 4.2). Обе функции main и prime имеют локальную переменную с именем i. Если бы переменная i не была локальной - то есть совместно используется функциями, - давайте рассмотрим, что мог- ло бы случиться. Во-первых, функция main выполняет функцию prime как часть вычисления условия инструкции if. Предположим, что значение переменной i равняется 24. if (prime(i)) cout << i << " is prime" << endl; else cout << i << " is not prime" « endl; Значение 24 передается в функцию prime. // Предположим, что переменная i не объявлена здесь, // а является глобальной, int prime(int n) { for (i = 2; i <= sqrt((double) n); i++) if (n % i == 0) return false; return true; // Если делители не найдены, // число п - простое. } Давайте взглянем на то, что делает эта функция. Она присваивает переменной i значение 2, а затем проверяет, делится ли на него переданное число, 24. Эта проверка проходит - поскольку 24 делится на 2 без остатка - и функция возвращает значение. Однако теперь значение переменной i равняется 2, а не 24. Перед возвращением значения программа выполняет cout << i << " is not prime" << endl; что печатает: 2 is not prime Это не то, что мы ожидали, поскольку проверялось число 24!
110 C++ без страха Поэтому, чтобы избежать этой проблемы, объявляйте переменные локальными, пока не найдется веской причины этого не делать. Если вы снова посмотрите на пример 2.3, вы увидите, что переменная i является локальной; функции main и prime объявляют свои собственные версии переменной i. Существует ли веская причина, чтобы не использовать локальные переменные? Да, хотя если есть выбор, лучше использовать локальную версию переменной, поскольку вы же- лаете, чтобы функции влияли друг на друга как можно меньше. Можно объявлять глобальные - то есть нелокальные - переменные, объявляя их за пре- делами определения всех функций. Обычно лучше всего помещать все глобальные объ- явления в цачале программы, перед ее первой функцией. Глобальная переменная распо- знается с точки зрения, что она объявлена до конца файла. Например, можно объявить глобальную переменную status перед функцией main: #include <iostr“eam> #include <math.h> using namespace std; int Status = 0; void main () { / / . } Теперь, переменная status может быть доступна из любой функции. Поскольку эта пе- ременная является глобальной, то существует только одна ее копия; если какая-либо функция изменяет значение переменной status, это отражается на значении переменной status, которое «видят» другие функции. Рекурсивные функции До сих пор я демонстрировал использование функции main для вызова других функций, определенных в программе, однако, на самом деле, любая функция может вызывать лю- бую функцию. Но может ли функция вызывать саму себя? Да. И, как вы скоро увидите, это не настолько безумно, как звучит. Также можно иметь две функции, вызывающие друг друга, и для определенного класса программ (например, для программы, которая сама реализует компилятор языка C++) это может быть полез- ным. Это еще одна причина, по которой я рекомендую помещать все объявления функ- ций в начале программы: поскольку, если две функции вызывают друг друга, будет ло- гически невозможно определить каждую функцию перед ее использованием. Метод, когда функция вызывает саму себя, называется рекурсией (recursion). Очевидная проблема здесь такая же, как и в случае бесконечных циклов: если функция вызывает саму себя, когда же она должна остановиться? Однако эта проблема легко решается с использованием какого-либо механизма для остановки.
ГЛАВА 4. Функции: вызываются много раз 111 Помните функцию triangle из примера 4.1? Мы можем переписать ее как рекурсив- ную функцию; int triangle(int n) { if (n <= 1) return 1; else return n + triangle(n - 1); // РЕКУРСИЯ! ) Для любого числа, которое больше 1, функция triangle выполняет вызов самой себя, но уже с меньшим числом. В конце концов будет вызвана функция triangle (1). В этом случае инструкция if приводит к остановке цикла: вызов функции triangle (1) просто вернет значение 1. Существует, по сути, «стек» вызовов, сделанных функцией, каждый вызов - с отличным аргументом п, и теперь начинается обработка стека вызовов. (Стек - это механизм типа «последним пришел, первым вышел», внутренне поддерживаемый и хранящий значения аргументов для всех отложенных вызовов функций.) Вы можете представить, как выполняется вызов функции triangle (4) следующим образом: triangle(4) 4 + triangle(3) 3 + triangle(2) 2 + triangle(T) 1 Большинство функций, использующих инструкцию for, могут быть переписаны с ис- пользованием рекурсии. Но всегда ли имеет смысл использование этого подхода? Приведенный пример не является идеальным. Он заставляет программу сохранить все значения от 1 до п в стеке вместо того, чтобы просто подсчитать их сумму прямо в цик- ле. Этот подход не настолько эффективен, как подход с использованием стандартных циклов, - однако со скоростью и емкостью памяти современных компьютеров было бы трудно определить разницу. В следующем разделе приводится лучшее и более эффек- тивное использование рекурсии. Пример 4.3. Наибольший обший делитель (GCF) При решении некоторых проблем лучше использовать рекурсивные решения, чем дру- гие. Одним из наилучших примеров использования рекурсивного решения является классический метод расчета наибольших общих делителей (GCF - Greatest Common Fac- tor). Далее в этой книге (начиная с главы 11) мы вернемся и используем это решение в качестве важного основания для класса Fraction.
112 C++ без страха Этот метод является красивым алгоритмом - красивым из-за его простоты, элегантности и того факта, что в итоге он экономит большое множество циклов центрального процес- сора по сравнению с подходом решения проблемы в лоб. Чтобы понять этот метод, вспомните использование оператора деления по модулю (%), который был представлен в главе 2. Этот оператор делит первое число на второе и воз- вращает остаток от деления. Рассмотрим это выражение: 215 % 100 Это выражение делит число 215 на 100 и возвращает остаток от деления, в данном случае 15. Вооружившись этим оператором, мы теперь можем сформулировать элегантное реше- ние для нахождения наибольшего общего делителя для двух целых чисел - то есть самое большое число, на которое делятся оба введенных числа без остатка. • Чтобы найти наибольший общий делитель (GCF) для чисел А и В: Если выражение А % В равняется 0, Вернуть (инструкция return) В Иначе Вернуть GCF(B, А % В) Как и в случае с другими рекурсивными алгоритмами, этот метод имеет как общий слу- чай, так и терминальный (А % В равняется 0). Каждый рекурсивный алгоритм должен иметь, по крайней мере, один терминальный случай, чтобы избежать бесконечной рег- рессии. (Одного такого случая достаточно.) Причина, по которой работает терминальный случай, должна быть понятна: если выра- жение А % В равняется 0, значит, А делится без остатка на В; поэтому В является наи- большим общим делителем, поскольку он делится на себя и на него делится А. Напри- мер, если А равняется 4, а В равняется 2, то 4 % 2 равняется 0 (4 делится на 2 без остат- ка), поэтому 2 является наибольшим общим делителем чисел 4 и 2. Причина, по которой работает общий случай, менее очевидна, если предположить, что вы не провели жизнь за изучением теории чисел. Но выполнение нескольких примеров должно убедить вас, что метод работает. Имейте в виду форму общей процедуры: GCF(A, В) => GCF(B,A % В) Возьмем для примера числа 300 и 500. Наибольшим общим делителем является 100. Давайте посмотрим, выдаст ли наш метод такой результат. На первом шаге мы вычисляем выражение 300 % 500. Если попытаться разделить число 300 на 500, в результате получится 0, а остаток будет равен 300. Поэтому результат вы- ражения 300 % 500 равен 300. GCF(300, 500) => GCF(500, 300 % 500) GCF(500, 300) Пока ничего не получилось, кроме перестановки двух чисел, большее из которых оказа- лось на первом месте. Но мы еще только начали. На следующем шаге 500 делится на 300 с остатком 200 - что дает полезный результат. GCF(500, 300) => GCF(300, 500 % 300) GCF(300, 200)
ГЛАВА 4. Функции: вызываются много раз 113 Обнаружили ли вы, что мы куда-то продвинулись? На самом деле, мы очень близки к ответу. Следующий шаг включает деление 300 на 200 и получение остатка... который равен - сюрприз! - 100. GCF(300, 200) => GCF(200, 300 % 200) GCF(200, 100) На следующем (и последнем) шаге мы вычисляем выражение 200 % 100. Результат ра- вен 0; мы достигли нашего терминального условия (первое число делится на второе без остатка) и, таким образом, ответ равен 100. Вы можете выполнять этот метод бесконечное число раз и, если будете выполнять его правильно над любыми двумя положительными числами, он всегда будет работать. Вот другой пример, использующий значения 45 и 35. GCF(A, B) A % В GCF(45, 35) io => GCF(35, 10) 5 => GCF(10, 5) 0 И снова мы достигли терминального условия (А % В равняется 0), поэтому ответом яв- ляется окончательное значение В, в данном случае 5. Возможно, самое сложное в этом методе - убедить себя, что он работает. Но как только вы согласитесь, что он работает, написать функцию будет очень просто. Ниже приведена программа, включающая эту функцию и выполняющая повторяющиеся проверки. Листинг 4.3. gcfl.cpp ttinclude <iostream> using namespace std; int gcf(int a, int b) ; int main() { int a = 0, b = 0; while(1) { cout < < "Enter a number (0 to quit): " cin >> a; if (a == 0) break; cout < < "Enter 2nd number: H . r cin >> b; cout < < "GCF = " « gcf(a, b) « endl; } return 0; } int gcffint a, int b) '{ if (a % b == 0) return b;
114 C++ без страха else return gcf(b, a % b) ; } Как это работает После указания стандартного заголовка программы (директива #include и инструкция using) программа сначала объявляет прототип функции вычисления наибольшего об- щего делителя (gcf). Этот прототип объявляет функцию как принимающую в себя два целочисленных аргумента (а, Ь) и возвращающую целочисленный результат. int gcf(int a, int b); Функция main начинается с объявления двух переменных, а и Ь. Так получилось, что переменные используют такие же имена, что и аргументы функции gcf. Здесь не требу- ется повторное использование имен (например, могут быть использованы имена п и гл), но в любом случае это не вызовет конфликта. Каждый вызов функции gcf будет полу- чать свою собственную копию переменных а и Ь, которые (как аргументы) работают как локальные переменные. int а = О, b = 0; В функции main цикл while делает несколько вещей: (1) получает число от пользова- теля, (2) выходит из цикла при помощи инструкции break, если пользователь введет 0 в качестве первого числа, (3) получает второе число и (4) печатает наибольший общий делитель, вызывая функцию gcf. whiled) { cout << "Enter a number (0 to quit): cin » a; if (a == 0) break; cout « "Enter 2nd number: cin » b; cout « "GCF = " « gcf(a, b) « endl; } . Это классический случай, когда цикл выглядит, как бесконечный, однако на самом деле не является таковым, поскольку он имеет условие выхода - просто условие выхода на- ходится в середине цикла, а не сверху. Цикл печатает наибольший общий делитель, а затем выполняется снова. Процесс повто- ряется до тех пор, пока пользователь не введет 0 в качестве первого числа. Саму функцию gcf написать легко. Все, что нужно сделать, так это посмотреть на алго- ритм GCF, приведенный ранее, и перевести его в инструкции языка C++. int gcf(int a, int b) { if (a % b == 0) return b; else return gcf(b, a % b) ; ' ,)
ГЛАВА 4. Функции: вызываются много раз 115 Упражнения Упражнение 4.3.1. Модифицируйте программу, чтобы она отображала все шаги, входя- щие в алгоритм. Вот.примерный вывод программы: GCF(300, 500) => GCF(500, 300) => CGF(300, 200) => GCF(200, 100) => GCF =100 (Подсказка: какое первое действие должна выполнять функция gcf, чтобы сделать это?) Упражнение 4.3.2. Возможно ли модифицировать функцию gcf таким образом, чтобы она вычисляла выражение а % b один раз за вызов. Если возможно, напишите это опти- мальное решение. Упражнение 4.3.3. Какой компромисс вовлечен в это «лучшее» решение, на которое ссылается пример 4.3.2? Сколько работы сэкономлено? Есть ли затраты, хоть и не большие, в размере программы? (Обратите внимание, что нахождение компромисса между временем и размером является классической задачей при разработке и оптими- зации программ.) Пример 4.4. Разложение на простые множители Примеры проверки на простое число, которые мы уже видели, хороши, однако они име- ют ограничение. Например, они говорят, что число 12001 не является простым, но больше ничего не говорят. Было бы замечательно узнать, почему число 12001 не являет- ся простым - то есть, на какие числа в действительности делится этот монстр? Что хочется сделать, так это разложить на простые множители (prime factorization) любое запрашиваемое число. Это продемонстрирует нам, на какие точно простые числа делится данное число. Например, если введено число 36, мы получим: 2,2,3, 3 Если введено число 99, получим: 3,3,11 И если было введено простое число, результатом будет являться само число. Например, если было введено число 17, будет напечатано число 17. Это может показаться трудновыполнимой задачей, однако, на самом деле, у нас есть почти весь программный код для ее выполнения. В код программы для проверки на про- стое число необходимо внести лишь небольшие изменения. Ключевым моментом при разложении на простые множители является возможность по- лучить наименьший делитель, после чего продолжить разложение на множители остав- шегося частного. Чтобы получить все делители числа п: Для всех целых чисел от 2 до квадратного корня числа п, Если число и делится без остатка на значение переменной цикла (i), Напечатать значение переменной i с запятой, и Выполнить функцию снова над результатом выражения n / i, и Выйти из текущей функции Если делители не найдены, напечатать само число п
116 C++ без страха Эта логика является рекурсивным решением, которое можно реализовать на языке C++, вызывая функцию get„divisors из самой себя. Листинг 4.4. рп'те4.срр ttinclude <iostream> ttinclude <math.h> using namespace std; void get_divisors(int n) ; int main() { int i, n; cout << "Enter a number and press ENTER: cin >> n; get_divisors(n); return 0; } // Функция получения делителей // Функция печатает все делители числа п, // находя наименьший делитель, i, и затем // выполняя себя заново над значением n/i, // оставшимся частным, void get—divisors(int n) { int i ; double sqrt_of_n = sqrt((double) n) ; for (i = 2; i <= sqrt—of_n; i++) if (n % i == 0) { // Если число n делится на // значение переменной i без .// остатка, cout << i << ", // Напечатать значение 7/ переменной i, get—divisors(n / i); // Разложить на // множители n/i, return; // и выйти. } // Если делители не найдены, число п является простым // Напечатать число п и не выполнять дальнейшие вызовы. ‘ cout << П; } Как это работает Как всегда, программа начинается с объявления используемых функций - в данном слу- чае, кроме функции main (которую никогда не нужно объявлять), есть еще одна функ- ция. Новой функцией является функция get_divisors. Также в начале программы включаются два файла iostream.h и math.h, потому что в программе используются объекты cout, cin и функция sqrt. Между прочим, не нуж- но непосредственно объявлять функцию sqrt, поскольку это уже сделано в файле math.h.
ГЛАВА 4. Функции: вызываются много раз 117 ttinclude <iostream> ttinclude <math.h> using namespace std; void get_divisors(int n) ; Сама по себе функция main делает немного. Все, что она делает, - это получает число с клавиатуры, после чего вызывает функцию get_divisors, которая и выполняет прак- тически всю работу. void main'() { int i, n; cout « "Enter a number and press ENTER: "; cin >> n; get_divisors(n) ; } Функция get_divisors является интересной частью этой программы. Раньше мы та- кого еще не видели: функция с возвращаемым значением типа void. Это означает, что функция обратно не возвращает значения, однако все же использует инструкцию re- turn для выхода из функции раньше времени. void get_divisors(int n) { int i ; double sqrt_of_n = sqrt((double) n); for (i = 2; i <= sqrt_of_n; i++) if (n % i == 0) { // Если число n делится на // значение переменной i без // остатка, cout « i << ", // Напечатать значение // переменной 1, get_divisors(n / i); // Разложить на // множители n/i, return; // и выйти. } // Если делители не найдены, число п является простым // Напечатать число п и не 'выполнять дальнейшие вызовы, cout « П; } Ядром этой функции является цикл, проверяющий значения от 2 до квадратного корня числа и (квадратный корень числа был рассчитан и помещен в переменную sqrt_of_n). for (i = 2; i <= sqrt_of_n; i+,+ ) if (n % i == 0) { // Если число n делится на // значение переменной i без // остатка, cout << х << "; // Напечатать значение // переменной i, get_divisors(n / i); // Разложить на // множители n/i, return; // и выйти. }
118 C++ без страха Если выражение п % i == 0 - истинно, значит, значение переменной и делится без остат- ка на значение переменной цикла. В этом случае функция выполняет несколько дейст- вий: (1) печатает значение переменной цикла, которое является делителем, (2) рекурсив- но вызывает саму себя и (3) завершается. Функция вызывает саму себя со значением и / i. Поскольку множитель i уже найден, функции необходимо получить целочисленные множители для оставшихся множите- лей (the remaining factors) числа п; они содержатся в результате выражения n / i. Если делители не найдены, это означает, что проверяемое число является простым. Правильной реакцией будет печать этого числа и остановка. COUt << П; Например, предположим, что было введено число 30. Функция проверяет, какое число является наименьшим делителем числа 30. Функция печатает число 2 и затем выполняет себя над оставшимся частным, 15 (поскольку 30 разделить на 2 равняется 15). Во время следующего вызова функция находит наименьший делитель для числа 15. Им является число 3, поэтому функция печатает 3 и выполняет себя над оставшимся частным, 5 (поскольку 15 разделить на 3 равняется 5). К тому времени, когда функция завершит выполнение, она напечатает числа 2, 3 и 5, которые являются множителями числа 30. На рисунке наглядно показано, как работает вызов функции get_divisors в данном случае. В каждом вызове функция get_divisors получает наименьший делитель чис- ла, а затем (если проверяемое число не является простым) выполняет очередной вызов. get_divisors(30) print "2," --> get_divisors(15) 1 print "3," -—get_divisors(5) ч print "5" Вставка Для одержимых математикой Небольшое рассуждение доказывает, почему наименьший делитель числа всегда яв- ляется простым числом. Предположим, что число А является наименьшим делите- лем, но в то же время оно не является простым числом. Число, не являющееся про- стым, должно иметь, по крайне мере, два собственных делителя, В и С. Но если число делится без остатка на число А, значит, оно делится также на числа В и С. Это должно быть понятно на интуитивном уровне, однако математическое дока- зательство этого факта (если нужно) простое. и = Ат (где т - некоторое целое число)
ГЛАВА 4. Функции: вызываются много раз 119 n = (ВС)т (поскольку А = ВС) п = В(Ст) следовательно, число п делится без остатка на число В Итак, если число п делится без остатка на число А, то оно также делится и на делите- ли числа А (В и С). Эти делители, конечно же, меньше самого числа А. Следователь- но, число А не может быть наименьшим делителем. Гипотеза, что наименьший общий делитель не является простым числом, опроверг- нута. Поэтому наименьший делитель должен быть простым числом. Посмотрите на это с другой стороны. Любое число, которое делится на 4 (не простое число), также делится на 2 (простое число). Любое число, которое делится на 9 (не простое число), также делится на 3 (простое число). Простые множители всегда бу- дут найдены в самом начале, если вы ищете наименьший делитель. Упражнения Упражнение 4.4.1. Перепишите функцию main примера 4.4, чтобы печаталось сообще- ние-подсказка «Enter a number (0 = exit) and press ENTER». Программа должна вызвать функцию get_divisors, чтобы продемонстрировать разложение на множители, а за- тем снова выводить подсказку ввода (prompt), пока пользователь не введет 0. (Подсказ- ка: если необходимо, посмотрите на код примера 4.2.) Упражнение 4.4.2. Напишите программу, которая вычисляет факториалы, используя рекурсивную функцию. Напомню, что факториал - это произведение всех целых чисел от 1 до N, где N - это указанное число. Например, factorial(5) = 5*4‘3‘2* 1. Упражнение 4.4.3. Измените пример 4.4 таким образом, чтобы использовалось нерекур- сивное решение. В результате придется написать больше кода. (Подсказка: чтобы упро- стить задачу, напишите две функции: get_all_divisors и get_lowest_divisor. Функция main должна вызывать функцию get_all_divisors, которая в свою оче- редь имеет цикл: функция get_all_divisors повторно вызывает функцию get_lowest_divisor, каждый раз заменяя значение п результатом выражения n/i, где i - это найденный делитель. Если возвращается само число п, значит, число является простым и. цикл должен быть прекращен. Еще одна. подсказка: чтобы написать этот цикл, вы можете захотеть использовать бесконечный цикл, завершаемый изнутри цикла инструкцией break.) Пример 4.5. Генератор случайных чисел Вы не зачарованы простыми числами? Не являются ли они для вас самыми любимы- ми в мире? Возможно, нет. Поэтому давайте обратимся к чему-то еще более забавному: генера- тору случайных чисел. Генератор случайных чисел является ядром многих игровых программ. Тестовая программа имитирует любое число игровой кости. Она делает это, вызывая функцию rand_0toN1, принимающую аргумент п и возвращающую случайное число в диапазоне от 0 до п - 1. Например, если в качестве аргумента в функцию передано зна-
120 C++ без страха чение 10, то функция вернет число в диапазоне от 0 до 9. Функция main использует функцию rand_0toN1, указывая в качестве аргумента значение 6 и прибавляя 1 к резуль- тату, чтобы получить число в диапазоне от 1 до 6. После запуска, программа выведет что-то наподобие этого: 346253116 Вот код программы: Листинг 4.5. dice.cpp ttinclude <iostream> ttinclude <math.h> #include <stdlib.h> ttinclude <time.h> using namespace std; int rand_0toNl(int n) ; int main() { int n, i; int r; srand(time(NULL) ) ; // Установка начального числа для // генерации случайных чисел. cout << "Enter number of dice to roll: 11 ; cin >> n; for (i = 1; i <= n; i++) { r = rand_0toNl(6) + 1; // Получить число в I/ диапазоне от 1 до 6 cout « г << " // Напечатать это число } return 0; } // Функция Random 0-to-Nl. // Генерирует случайное целое число в диапазоне // от 0 до N - 1, обеспечивая равное распределение вероятности // для появления каждого целого числа. // int rand_0toNl(int n) { return rand() % n; 2 Как это работает В начале программы необходимо включить несколько файлов для поддержки функций генерации случайных чисел: ttinclude <iostream> ttinclude <math.h>
ГЛАВА 4. Функции: вызываются много раз 121 ttinclude <stdlib.h> ttinclude <time.h> . using namespace std; Всегда при использовании генерации случайных чисел обязательно включайте три по- следних файла - math.h, stdlib.h и time.h. Генерация случайных чисел на самом деле является сложной проблемой в вычислитель- ной технике, поскольку компьютеры по своей природе следуют детерминистическим правилам - которые, по самому определению, являются неслучайными. Решением биб- лиотеки языка C++ является генерация так называемой «псевдослучайной» последова- тельности, заключающееся в получении числа и выполнении над ним ряда сложных преобразований. Но, чтобы сделать это, необходимо число, максимально случайное, с которого можно начать последовательность. Это то, что делает следующая инструкция, получающая сис- темное время и использующая его в качестве начального числа. Фраза «начальное чис- i ло» в данном контексте - это просто модный термин для фразы «первое число в после- довательности». Каждое псевдослучайное число получается с помощью детерминисти- ческих - но очень сложных - математических преобразований, выполняемых над пре- дыдущим числом последовательности. srand(time(NULL)); Значение NULL - это предопределенное значение, обозначающее адрес данных, который никуда не указывает. По существу, значение NULL является эквивалентом значения О, однако в данном случае необходимо использовать именно значение NULL, поскольку требуется адресное выражение (address expression). (Подробнее про адресные выражения я расскажу в главе 6.) В любом случае не беспокойтесь об этом. Выражение «time(NULL)» служит просто для получения текущего времени. Любая программа, использующая случайные числа, сначала должна выполнить эту ин- струкцию. Системное время изменяется слишком быстро, чтобы человек смог точно до- гадаться, каким оно будет, и даже незначительное отличие в значении начального числа вызывает радикальные изменения в результирующей последовательности. Это является практическим применением того, что теоретики Хаоса называют Эффектом Бабочки (Butterfly Effect). Вы можете, если захотите, использовать значение 0 вместо значения NULL. Фактически некоторые программисты на языке C++ предпочитают использо- вать значение 0, поскольку оно менее отражает старомодный «стиль языка С». В любом случае оба выражения time(NULL) и time(O) будут работать. Помните, что значение NULL по существу (или почти) то же, что и значение 0. Оставшаяся часть функции main выводит подсказку для ввода числа, после чего пе- чатает запрошенное количество случайных чисел. Цикл for выполняет повторяю- щиеся вызовы функции rand_OtoN1 - функции, возвращающей случайное число в диапазоне от 0 до п - 1: for (i = 1; i <= n; i++) { r = rand_0toNl(6) +1; // Получить число в // диапазоне от 1 до 6
122 C++ без страха cout « г << " "; // Напечатать это число } Вот определение функции rand_OtoN1. Просто, не правда ли? int rand_0toNl(int n) { return rand() % n; } Функция rand () возвращает число, которое может быть любым в диапазоне для типа int. Это может быть относительно небольшое число, однако в такой же степени оно может быть достаточно большим числом, например 1336588. Проблема, конечно, состоит в том, что мы не хотим получить в результате такое огром- ное число. То, что мы хотим, - это число в диапазоне от 0 до 9 (если в качестве значения переменной п было введено значение 9) или от 0 до 5 (если было введено значение 6). Вот где наш старый друг - оператор деления по модулю (%), который мы уже так много использовали, - приходит на помощь. Напомню, что этот оператор выполняет деление двух целых чисел и возвращает остаток от деления. Теперь, если вы будете делить на 6 (например), вы должны получать в остатке 0, 1, 2, 4 или 5. Вы не сможете получить больший или меньший результат, каким бы большим не был первый операнд. Более того, при выполнении над большим случайным числом, операция деления по мо- дулю на 6 должна получать одно из этих шести чисел (0, 1, 2, 3, 4, 5) с равной частотой. Нет причины, по которой один или другой результат получит преимущество. Следовательно, результатом является случайное целое число в диапазоне от 0 до п - 1, чего мы и добивались. Упражнения Упражнение 4.5.1. Напишите генератор случайных чисел, возвращающий число в диа- пазоне от 1 до N (а не от 0 до N - 1), где N - это целочисленный аргумент, передаваемый в функцию. Резюме В этой главе мы обсудили следующие важные концепции: ✓ В языке C++ можно использовать функции для определения какой-либо особой зада- чи, как в другом языке используются «подпрограммы» или «процедуры». Язык C++ использует понятие функции для всех таких подпрограмм, независимо от того, воз- вращают они значение или нет. ✓ Необходимо объявлять все ваши функции (кроме функции main) в начале програм- мы, чтобы язык C++ имел требуемую информацию о типе. Объявления функций, также называемые «прототипами», используют следующий синтаксис: тип имя_функции (список_аргументов) ; t/ Также необходимо определить функцию где-либо в программе, чтобы описать то, что делает функция. Для определений функции используется следующий синтаксис:
ГЛАВА 4. Функции: вызываются много раз 123 тип имя_функции (список_аргументов) { инструкции ✓ Функция выполняется (прогоняется) до ее конца или до выполнения инструкции re- turn. Инструкция return, возвращающая значение обратно вызывающей функции, имеет следующий вид: return выражение; S Инструкция return может также быть использована в функции, возвращающей тип void (функции, не возвращающей значения), для того, чтобы выйти из функции раньше, имеет следующий упрощенный вид; return; ✓ Локальные переменные объявляются внутри определения функции; глобальные пе- ременные объявляются за пределами всех определений функций, желательно перед функцией main. Если переменная является локальной, она не используется совмест- но с другими функциями; две функции могут иметь свою переменную с именем i (на- пример), при этом не влияя друг на друга. ✓ Глобальные переменные позволяют функциям совместно использовать общие дан- ные, однако такое совместное использование приводит к тому, что одна функция может влиять на другую. Хорошей практикой является неиспользование глобальных переменных, пока нет очевидной необходимости. ✓ Оператор прибавления с присваиванием (+=) обеспечивает краткий способ прибав- ления значения к переменной. Например: п += 50; // n = п + 50 ✓ Функции языка C++ могут использовать рекурсию - это означает, что они могут вы- зывать сами себя. (Вариацией этого является случай, когда две или более функции вызывают друг друга.) Такой метод является допустимым, если определен случай, прерывающий вызовы. Например: int triangle(int n) { if (n <= 1) return 1; else } return n + trianglefn - 1); // РЕКУРСИЯ!
ГЛАВА 5. Массивы: множество чисел Основной темой предыдущих глав было: определив задачу один раз (неважно насколько простую или сложную), можно запросить ее выполнение компьютером любое число раз. Мы видели этот принцип в действии, когда рассматривали циклы while и for, а также и функции. Именно этот факт - больше чем любой другой - демонстрирует возможно- сти компьютерного программного обеспечения. Но волшебство компьютеров не заключается просто в том факте, что они могут выпол- нять сколь угодно большое число повторений. Они также могут работать со сколь угод- но большими объемами данных. Именно для такой работы предназначен т.н. «массив» (array) - набор данных сколь угодно большого размера, индексированный числом. Нажав несколько раз на клавиши - как вы увидите в этой главе, - можно создать структуры массивов данных любого раз- мера. Затем, используя циклы, можно обработать структуру данных, написав всего не- сколько строк кода. Циклы и массивы идут рука об руку. Вместе они позволяют созда- вать не просто мощные программы, но и - что даже более важно - полезные. Первый взгляд на массивы в языке С++ Предположим, что вы пишете программу, анализирующую оценки, выставляемые пятью судьями на новом виде состязаний - олимпийских соревнованиях по запуску воздушных змеев (Olympic kite-flying contest). Все пять оценок должны быть сохранены, чтобы можно было определить ряд статистических параметров: диапазон, среднее значение, медиану, среднее отклонение и т.п. Будем считать, что на данный момент все пять судей являются анонимными; у нас нет их личностей - только оценки. Один из способов сохранения этой информации заключа- ется в простом объявлении пяти переменных. Поскольку у оценок имеется дробная часть (0,1 будет наименьшей оценкой, а 9,9 - почти наивысшей), в качестве типа переменных нужно выбрать тип double. double scoresl, scores2, scores3, scores4, scores5; В данном случае необходимо напечатать изрядное количество кода. Не проще ли было бы просто напечатать слово «scores» и попросить язык C++ объявить пять переменных? Это в точности то, что происходит при объявлении массива. Вот как это будет выглядеть для данного примера: double scores[5]; Данное объявление создает пять элементов данных типа double и размещает их в па- мяти друг за другом. В выполняемых инструкциях (executable statements) (инструкции, в действительности выполняющие работу) языка C++ к этим элементам можно обратиться как scores[0], scores[1], scores[2], scores[3] и scores[4]. Числа в квадратных скобках называются индексами (indexes).
ГЛАВА 5. Массивы: множество чисел 125 scores[0] scores[1] scores[2] scores[3] scores[4] В оставшейся части кода можно выполнять операции над каждым из элементов, как если бы он являлся просто отдельной переменной. scores[0] = 2.7; // Судья #0 присудил низкую оценку. scores[2] = 9.5; // Судья #2 присудил высокую оценку. scores[1] = scores[2]; // Судья #1 взял оценку судьи #2. После выполнения приведенных операций массив выглядит следующим образом: scores[0] = 2.7; scores[2] = 9.5; 2.7 9.5 9.5 scoresfO] scores[1] scores[2] scores[3] scores[4] scores[1] = scores[2]; Использование массива в случае с пятью элементами может сделать код программы бо- лее сжатым. Но это буквально ничто по сравнению с тем, что может быть сэкономлено в случае больших массивов. Массив, состоящий из пяти элементов, довольно короткий. Подумайте, сколько труда вы сэкономите при использовании массива, состоящего из тысячи элементов. Это так же просто, как и объявление массива меньшего размера: int votes[1000]; // Объявление массива, состоящего из // 1.000 элементов Это объявление создает массив, состоящий из тысячи элементов, начиная с элемента votes[0] и заканчивая элементом votes[999]. Инициализация массивов Если вы посмотрите на рисунки предыдущего раздела, то увидите, что некоторые пози- ции в массиве не заполнены. Это потому, что элементам данных не были присвоены значения. Но какие значения содержат элементы в таком начальном случае? Это явля- ется задачей инициализации. Обращение к переменной, которую вы забыли проинициалдзировать, может привести к получению мусора («мусор» - это технический термин для переменной, содержащей бессмысленное значение). Если вы собираетесь использовать переменную в качестве счетчика циклов и позднее явно присваиваете ей значение (например, в инструкции
126 C++ без страха f or), то можно обойтись и без ее инициализации. В других случаях инициализация все- гда является полезной. Проинициализировать переменную можно при ее объявлении. int sum = 0; Можно даже проинициализировать переменные, объявленные на одной строке. int sum = 0, fingers = 10; И, наконец, можно проинициализировать массив, используя то, что называется агрега- цией (aggregate). В данном подходе используется простая нотация, включающая фигур- ные скобки и запятые: double scores[5] = {0, 0, 0, 0, 0); int ordinals[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; Каждая из строк завершается закрывающей фигурной скобкой, за которой следует точка с запятой (};). Это является исключением из правила, гласящего, что за фигурной скоб- кой не ставится точка с запятой. Объявление класса или данных всегда завершается точ- кой с запятой, независимо от того, используются фигурные скобки или нет. Если переменная или массив являются глобальными, то язык C++ по умолчанию инициализирует их нулями. (В случае с массивами язык C++ инициализирует каждый элемент нулем.) Однако локальные переменные не будут проинициали- зированы вовсе, если вы сами не сделаете это. Не проинициализированные гло- бальные переменные содержат нулевое значение; не проинициализированные локальные переменные содержат мусор. Индексирование с отсчетом от нуля Теперь вы можете заметить, что в языке C++ массивы работают немного по-другому, чем можно было ожидать. Если в массиве N элементов, то они нумеруются не от 1 до N, а от 0 до N - 1. То есть для массива, объявленного следующим способом: double scores[5]; элементами являются: scores[0] scores[1] scores[2] scores [3] scores[4] Независимо от того, как вы объявляете массив, наибольшее значение индекса (в данном случае 4) будет на единицу меньше размера массива (в данном случае 5). Это может по- казаться трудным для понимания. Но, если посмотреть с другой стороны, это имеет безупречный смысл. Значение индекса массива в языках С и C++ - это не столько простое число (то есть позиция), сколько смещение (offset). Другими словами, значение индекса элемента является мерой расстоя- ния от начала массива.
ГЛАВА 5. Массивы: множество чисел 127 Как далеко находится первый элемент от начала массива? Правильно: между ними вовсе нет расстояния... между ними находится ноль позиций! Поэтому значение индекса для первого элемента равняется 0. Для этого случая стоит добавить в сводку важнейших правил языка следующее: ♦ В массиве, состоящем из N элементов, в языке C++ значения индекса нахо- * дятся в диапазоне от 0 до N - 1. Вставка Почему используются индексы с отсчетом от нуля? Большинство других языков, например FORTRAN и COBOL, используют индексиро- вание с отсчетом от единицы. Объявление ARRAY(5) на языке FORTRAN создает мас- сив, значения индекса которого находятся в диапазоне от 1 до 5. Для новичков это, вероятно, немного бодее естественно. Но, независимо от того, на каком языке написаны программы, они все должны быть преобразованы в машинный код. Этот код является тем, что на самом деле выполняет центральный процессор (CPU). На машинном уровне индексирование массива выполняется с помощью смещений: регистр (специальная ячейка памяти в самом центральном процессоре) содержит ад- рес массива - в действительности адрес первого элемента. Другой регистр содержит смещение, такое как расстояние до нужного элемента. Чему равно смещение для первого элемента? Правильно: нулю, просто как и в языке C++. В языке FORTRAN, например, индекс с отсчетом от единицы сначала должен быть преобразован в индекс с отсчетом от нуля посредством вычитания 1. Затем он умножается на размер каждого элемента. Чтобы получить элемент с индексом I: адрес элемента I = базовый адрес + ((1 - 1) * размер каждого элемента) В языке, в котором индексирование выполняется с отсчетом от нуля, например C++, нет необходимости в выполнении вычитания. Это приводит к немного более эффек- тивным вычислениям во время выполнения. адрес элемента I = базовый адрес + (I * размер каждого элемента) И хотя в результате экономится лишь немного циклов центрального процессора, это очень много с точки зрения языков, основанных на языке С, чтобы использовать дан- ный подход, поскольку это лучше отражает то, что делает центральный процессор. Пример 5.1. Печать элементов Давайте начнем с того, что взглянем на одну из самых простых из возможных программ, использующих массив. В оставшейся части главы рассматриваются более интересные задачи программирования.
128 C++ без страха Листинг 5.1. print arr.cpp ttinclude <iostream> using namespace std; int main() { int i ; double scores[5] = {0.5, 1.5, 2.5, 3.5, 4.5}; for(i =0; i < 5; i++) { cout « scores[i] « " "; } return 0; } После выполнения программа напечатает 0.5 1.5 2.5 3.5 4.5 Как это работает В программе используется цикл for, который присваивает переменной i ряд значений: 0, 1, 2, 3, 4, соответствующий диапазону индексов массива scores. for(i =0; i < 5; i++) { cout « scores[i] « " "; } Этот вид циклов является чрезвычайно популярным в коде языка C++, поэтому вы часто видите данные выражения, используемые в цикле for: i = 0, i < SIZE_OF_ARRAY и i++. Цикл выполняется пять раз, каждый раз с новым значением переменной I. Значение переменной i Действие цикла Печатаемое значение 0 Печатает scores[0] 0.5 1 Печатает scores[1] 1.5 2 Печатает scores[2] 2.5 3 Печатает scores[3] 3.5 4 Печатает scores[4] 4.5 . - Вы также можете визуально представить действие данного цикла. Следующий рисунок демонстрирует действие первых двух итераций цикла.
ГЛАВА 5. Массивы: множество чисел 129 Упражнения Упражнение 5.1.1. Напишите программу, инициализирующую массив, состоящий из восьми целочисленных элементов, значениями'5, 15, 25, 35, 45, 55, 65 и 75, а затем печа- тающую значение каждого из элементов. (Подсказка: вместо использования условия цикла i < 5, используйте условие i < 8, поскольку в данном случае массив состоит из восьми элементов.) Упражнение 5.1.2. Напишите программу, инициализирующую массив из шести цело- численных элементов значениями 10, 22, 13, 99, 4 и 5. Напечатайте значение каждого из элементов, а затем напечатайте сумму этих значений. Упражнение 5.1.3. Напишите программу, которая выводит пользователю подсказку для ввода каждого из семи значений, сохраняет введенные значения в массиве, а затем печа- тает значение каждого из элементов и их общую сумму. Для этой программы вам потре- буется написать два цикла for: один для сбора данных, а второй для подсчета суммы и для вывода значений. Пример 5.2. Насколько случайное число является случайным? В последнем примере главы 4 была представлена функция rand_0toN1, генерирующая случайное число. Как я уже отмечал в той главе, запрограммированная случайность - рассчитанное отсутствие порядка и предсказуемости - является проблематичной, по- скольку это внутренне противоречивая идея. Самое лучшее, что вы можете сделать - это использовать системное время и затем выполнить ряд сложных математических преобразо- ваний. Настоящая случайность теоретически может быть невозможной. 5-6248
130 C++ без страха Но является ли это практической возможностью? То есть может ли программа на языке C++ имитировать результаты случайности таким образом, что для пользователя станет практически невозможно заранее предугадать случайные числа? И если мы запросим программу напечатать ряд этих чисел, будут ли они удовлетворять всем качествам, ко- торые мы ожидаем от настоящей случайной последовательности? Функция rand_0toN1 возвращает целочисленное значение от 0 до N - 1, где N - аргу- мент, передаваемый в функцию. Можно использовать эту функцию для получения ряда чисел от 0 до 9 и подсчета того, сколько раз было получено каждое число. Мы ожидаем следующее: ✓ Каждое из десяти чисел должно быть получено в одном из десяти случаев. ✓ Но числа не должны быть получены с абсолютно равной частотой. Особенно в слу- чае с небольшим количеством попыток вы должны заметить варьирование. Однако с возрастанием числа попыток соотношение между действительными попаданиями и ожидаемыми попаданиями (одна десятая от общего числа) должно становиться все ближе и ближе к значению 1,0. Программа может проверить эту теорию, используя массив из десяти целочисленных элементов для сохранения результатов. Она может быть написана с использованием де- сяти независимых переменных, каждая из которых будет собирать данные для одного из десяти чисел от 0 до 9, но для этого, как вы увидите, потребуется написать гораздо больше кода. Массивы позволяют написать эту программу гораздо проще. После запуска программы будет предложено ввести число, попыток. После этого будет напечатан отчет об общем количестве попаданий для каждого из чисел от 0 до 9. Вот как примерно должен выглядеть вывод для 20000 попыток. Enter number of cases to do: 20000 0: 1950 Accuracy: 0.975 1: 2026 Accuracy: 1.013 2: 1897 Accuracy: 0.9485 3: 2102 Accuracy: 1.051 4: 2019 Accuracy: 1.0095 5: 1997 Accuracy: 0.9985 6: 1999 Accuracy: 0.9995 7: 1969 Accuracy: 0.9845 8: 2033 Accuracy: 1.0165 9: 2008 Accuracy: 1.004 При 20000 попыток вы должны получить мгновенный ответ. В зависимости от компью- тера, для получения заметной задержки могут потребоваться миллионы попыток; и даже после этого, задержка будет составлять всего несколько секунд. Я запустил эту про- грамму для двух миллиардов попыток (ввел число 2000000000). В данном случае моему настольному компьютеру, которому уже несколько лет, для ответа потребовалось 28 минут. Но ваш компьютер может выдать ответ быстрее. Интересно выполнить эту программу повторно с различными значениями для N. Вы должны обнаружить, что при увеличении числа попыток точность (отношение меж- ду действительными попаданиями и ожидаемыми попаданиями) на самом деле приближается к значению 1,0.
ГЛАВА 5. Массивы: множество чисел 131 Вот код этой программы: Листинг 5.2. stats.cpp ttinclude <iostream> ttinclude <stdlib.h> ttinclude <time.h> ttinclude <math.h> using namespace std; int rand_0toNl(int n); int hits[10]; int main() { int n; int i; int r; srand(time(NULL)); // Установка начального числа для // генерации случайных чисел. cout « "Enter number of trials to run" cout « "and press ENTER: cin » n; // Выполнить n попыток. Для каждой попытки получить // число от 0 до 9, после чего инкрементировать // соответствующий элемент в массиве hits. for (i = 1; i <= n; i++) { r = rand_0toNl(10); hits[r]++; } // Напечатать все элементы массива hits, вместе // с отношением между попаданиями и ОЖИДАЕМЫМИ // попаданиями (п / 10). for (i = 0; i < 10; i++) { cout « i « " « hits[i] « " Accuracy: "; cout « static_cast<double>(hits[i]) / (n / 10) « endl; } return 0; } // Функция Random 0-to-Nl. // Генерирует случайное целое число в диапазоне от 0 до N - 1. // int rand_0toNl(int n) { return rand() % n; } 5"
132 C++ без страха Как это работает Программа начинается с пары объявлений: int rand_0toNl(int n) ; int hits[10]; Функция rand_OtoN1 объявляется здесь, поскольку она будет вызвана из функции main. Объявление массива hits создает массив, состоящий из десяти целых чисел, индекс ко- торого принимает значение от 0 до 9. Из-за того, что этот массив является глобальным (объявлен вне функций), все его элементы проинициализированы значением 0. Функция main начинается с определения трех целочисленных переменных - i, п и г - и с установки начального числа для последовательности случайных чисел. Помните, что это нужно делать в любой программе, использующей генерацию случайных чисел. srand(time(NULL)); // Установка начального числа для // генерации случайных чисел. После этого программа предлагает ввести значение переменной п. На данный момент это должно выглядеть знакомым. cout « "Enter number of trials to run" cout « "and press ENTER: cin >> П; В следующей части программы устанавливается цикл for, работающий с массивом как с частью его действия. // Выполнить п попыток.. Для каждой попытки, получить // число от 0 до 9, после чего инкрементировать // соответствующий элемент в массиве hits. for (i = 1; i <= n; i++) { r = rand_0toNl(10); hits[r]++; } Данный цикл выполняет n попыток, где п может быть большим числом, например 20000. Во время каждой итерации цикл получает случайное число г в диапазоне от 0 до 9, после чего считает его «попаданием» (hit) для выбранного числа, прибавляя 1 к соответ- ствующему элементу массива. В конце процесса элемент hits[O] содержит число сгене- рированных нулей, hits[1] содержит число сгенерированных единиц и так далее. Выражение hits[r]++ экономит много работы по программированию. Если бы вы не ис- пользовали массив, то вам бы пришлось написать ряд инструкций if/else наподобие следующих: if (г == 0) hitsO++; else if (г == 1) hitsl++; else if (r == 2) hits2++;
ГЛАВА 5. Массивы: множество чисел 133 else if (г == 3) hits3++; // и т.д. Поскольку мы работаем с массивами, всего одна строка кода заменит двадцать строк! Эта единственная инструкция прибавляет 1 к любому элементу, выбранному значением переменной г. Например, если значение переменной г равняется 1, инкрементируется элемент hits[1]. Если значение переменной г равняется 2, инкрементируется элемент hits[2]. И так далее. hits[г]++; Оставшаяся часть функции main состоит из цикла, печатающего все элементы массива. Это действие выводит результаты и выполняется после того, как были выполнены все попытки. По-прежнему данный код более сжат, чем в том случае, когда не используется массив. // Напечатать все элементы массива hits, вместе // с отношением между попаданиями и ОЖИДАЕМЫМИ // попаданиями (п / 10). for (i = 0; i < 10; i++) { cout << i « " « hits[i] << " Accuracy: cout << static_cast<double>(hits[i]) / (n / 10) « endl; } . Обратите внимание, что здесь необходимо приведение к типу double для получения результата с плавающей точкой для отношения между действительными попаданиями и ожидаемыми. В противном случае программа выполнит целочисленное деление, которое просто отбросит любой остаток. Функция rand_OtoN1 - это та же функция, которую я привел в конце главы 4. // Функция Random 0-to-Nl. // Генерирует случайное целое число в диапазоне // от 0 до N - 1. // int rand_0toNl(int n) { return rand() % n; } Упражнения Упражнение 5.2.1. Измените пример 5.2 таким образом, чтобы он генерировал не 10 различных значений, а пять: другими словами, используйте функцию rand_OtoN1 для получения значений 0, 1, 2, 3 или 4. Затем выполните запрошенное количество по- пыток, в котором, по вашему мнению, каждое значение из пяти возможных будет сгене- рировано один раз из пяти. Упражнение 5.2.2. Измените пример таким образом, чтобы он мог работать с любым чис- лом значений, просто изменяя один параметр.в программе. Это можно сделать с помощью директивы #def ine в начале кода. Данная директива говорит компилятору заменить все вхождения символического имени (в данном случае «VALUES») указанным текстом.
134 C++ без страха Например, чтобы сгенерировать пять различных значений, сначала поместите следую- щую директиву в начале кода. #define VALUES 5 Затем используйте символическое имя VALUES везде, где программа ссылается на число возможных значений. Например, вы бы объявили массив hits следующим образом: int hits[VALUES] ; После этого вы можете управлять числом различных значений, возвращаясь назад и за- меняя одну строку - директиву #define - другим числом и компилируя программу заново. Красота данного подхода заключается в том, что поведение программы может быть легко изменено этой одной строкой кода. Упражнение 5.2.3. Перепишите код функции main, чтобы в нем использовался цикл for, похожий на цикл примера 4.3, позволяющий пользователю продолжать выполнять сессии повторно любое число раз, пока он или она не введет 0 для завершения програм- мы. Перед каждой сессией необходимо снова инициализировать все элементы массива hits нулем. Это можно сделать либо включив цикл for, устанавливающий значение ка- ждого элемента в 0, либо вызывая функцию, содержащую данный цикл. Строки и массивы строк Для примеров в оставшейся части данной главы мне необходимо продвинуться немного вперед в изложении и продемонстрировать, как объявлять массивы строк. В главе 7 вы вернемся к вопросу о строках. До сих пор я демонстрировал использование строковых литералов - или, точнее, строко- вых констант. Например, для печати сообщения используется строка кода, похожая на следующую: cout « "What a good C++ am I."; Можно использовать строковые переменные так же, как используются целочисленные переменные и переменные с плавающей точкой. Для этого требуется странно выглядя- щая нотация char*. Например, следующий код сначала сохраняет строку сообщения в переменной, а затем печатает ее. char *message = "What a good C++ am I"; cout « message; В оставшейся части главы используются массивы строк. Объявления таких массивов выглядят просто как объявления массивов чисел, за исключением того, что в качестве типа данных массива используется нотация char*. Например: char *members[4] = {"Sally", "Alex", "George", "Martha" }; Чтобы обратиться к отдельной строке в исполняемом коде, используется нотация для массивов (но не нужно использовать оператор * при печати строки). Например: cout << "The leader of the club is " « members[0];
ГЛАВА 5. Массивы: множество чисел 135 В результате будет выведено следующее: The leader of the club is Sally. Поскольку все имена членов сохранены в массиве, можно использовать цикл для разум- ной печати их всех. Например, приведенный код for (i =0; i < 4; i++) cout « members[i] « endl; печатает этот список имен: Sally Alex George Martha Пример 5.3. Сдаюший карты #1 и Теперь мы готовы немного позабавиться. Пример этого раздела использует два массива строк - ранги и масти - для имитации раздачи карт из стандартной колоды, состоящей из 52 игральных карт. Этот пример имеет одно значительное ограничение: одна и та же карта может быть роз- дана снова до полной раздачи колоды. Имитируемое поведение - это действие по вытя- гиванию карты, ее показу, а затем ее возврату в колоду и перетасовыванию перед вытя- гиванием новой карты. В последующих разделах я разработаю метод, предотвращающий повторное вытягивание одной и той же карты. Этот пример, по крайне мере, демонстрирует некоторую часть основного кода, необхо- димого для программы по раздаче карт, хотя с нескольких точек зрения он пока не явля- ется незавершенным. Листинг 5.3. dealerl.cpp ttinclude <iostream> #include <stdlib.h> #include <time.h> #include <math.h> using namespace std; int rand_0toNl(int n) ; void draw_a_card(); char *suits[4] = {"hearts", "diamonds", "spades", "clubs"}; char *ranks[13] = {"ace", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "jack", "queen", "king" }; int main() { int n,i; srand(time(NULL)); // Установка начального значения для // случайных чисел.
136 C++ без страха while (1) { cout << "Enter no. of cards to draw (0 to" cout « "exit): " ; cin » n; if (n == 0) - break; for (i = 1; i <= n; i++) draw_a_card(); } return 0; } // Функция Draw-a-card // Выполняет действие по вытягиванию одной карты, получая // случайное число в диапазоне от 0 до 4 и случайное число в // диапазоне от 0 до 12. Эти числа впоследствии используются // для индексации массивов строк рангов, т.е. значений карт // (ranks)и их мастей (suits) // void draw_a_card() { int г; // Случайный индекс (от 0 до 12) для массива ranks int s; // Случайный индекс (от 0 до 3) для массива suits г = rand_0toNl(13); s = rand_0toNl(4); cout << ranks[г] « " of " « suits[s] << endl; ) // Функция Random 0-to-N. // Генерирует случайное число в диапазоне от 0 до N-1. // int rand_0toNl(int n) { return rand() % n; 2 Как это работает Ключевым моментом в этом примере является использование двух объявленных гло- бальных массивов: suits (масти) и ranks (значения карт). char *suits[4] = {"hearts", "diamonds", "spades", "clubs"); char *ranks[13] = {"ace", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", ."jack", "queen", "king" }; Каждый из массивов является массивом строк, который может быть индексирован, как и любой другой массив. Массив suits состоит из четырех элементов, которые индексиру- ются значениями от 0 до 3. Массив ranks состоит из 13 элементов, которые индексиру- ются значениями от 0 до 12.
ГЛАВА 5. Массивы: множество чисел 137 По счастливой случайности, функция rand_OtoN1 генерирует такие числа, которые под- ходят для индексирования массивов в языке C++: значение, находящееся в диапазоне от О до N -1 (где N - это количество элементов). Это позволяет проще написать программу. Функция draw_a_card управляет всей работой, связанной с вытягиванием одной карты. void draw_a_card() { int г; // Случайный индекс (от 0 до 12) для // массива ranks int s; // Случайный индекс (от 0 до 3) для // массива suits г = rand__0toNl (13 ) ; s = rand_0toNl(4); cout « ranks[r] << " of ". << suitsfs] « endl; } Программа вызывает функцию rand_OtoN1, передавая в качестве аргумента значение 13. Назад мы получаем случайное целочисленное значение в диапазоне от 0 до 12: диапазон этих значений соответствует всем элементам массива ranks. Следовательно, каждое из 13 значений ранга будет выбрано с одинаковой вероятностью. г = rand_0toNl(13 ) ; После этого программа вызывает функцию rand_OtoN1, указывая в качестве параметра значение 4. Обратно мы получим случайное целое число в диапазоне от 0 до 3; этот диа- пазон соответствует всем элементам массива, suits. s = rand_0toNl(4); Теперь все, что должна сделать функция, - это выбрать две строки и напечатать их, и все будет готово. Упражнения Упражнение 5.3.1. Напишите программу, которая случайным образом выбирает объект из сумки, в которой находится восемь предметов. Каждый предмет может быть красным, синим, оранжевым или зеленым, а также он может быть шаром или кубом. Предположите, что в сумке находится по одному предмету для каждой комбинации (один красный шар, один красный куб, один оранжевый шар, один оранжевый куб, и так далее). Напишите код, похожий на код примера 5.3, использующий два массива строк - один для иденти- фикации цветов, а второй - для идентификации форм. Пример 5.4. Сдающий карты #2 Следующим шагом в написании завершенной программы для раздачи карт является вы- бор одного случайного числа и его дальнейшее использование для получения как ранга карты, так и масти. Позднее такой подход сделает возможным создание массива для слежения за состоянием каждой карты. Программа генерирует случайное число в диапа- зоне от 0 до 51, а затем связывает это число с уникальной комбинацией масти и ранга. .
138 C++ без страха Каждое вытягивание карты здесь все еще рассматривается как независимое событие. В следующем разделе добавляется логика, имитирующая настоящую колоду карт, в ко- торой карта не может быть вытянута второй раз. В следующем коде новые строки (или строки, которые нужно изменить) выделены по- лужирным шрифтом. Остальная часть кода полностью совпадает с кодом примера 5.3. Листинг 5.4. dealer2,cpp ttinclude <iostream> ttinclude <stdlib.h> ttinclude <time.h> ttinclude <math.h> using namespace std; int rand_0toNl(int n) ; void draw_a_card(); char *suits[4] = {"hearts", "diamonds", "spades", "clubs"}; char *ranks[13] = {"ace", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "jack", "queen", "king" }; int main() { int n,i; srand(time(NULL)); // Установка начального значения для // случайных чисел. while (1) { cout « "Enter no. of cards to draw (0 to" cout « "exit): "; cin » n; if (n == 0) break; for (i = 1; i <= n; i++) draw_d_card(); ) return 0; } II Функция Draw-a-card // Выполняет действие по вытягиванию одной карты, получая' // случайное число в диапазоне от 0 до 4 и случайное число в // диапазоне от 0 до 12. Эти числа впоследствии используются // для индексации массивов строк, рангов и мастей. // void draw_a_card() {
ГЛАВА 5. Массивы: множество чисел 139 int г; // Случайный индекс (от 0 до 12) для массива ranks int s;- // Случайный индекс (от 0 до 3) для массива suits int card; card = rand_0toNl(52); // Получить случайное число. //в диапазоне оз ' 0 до 51 г = card % 13; // г = случайное число в // от 0 до 12 диапазоне s = card / 13; // s = случайное число в // от 0 до 3 диапазоне cout « ranks[г] « " of " « suits[s] « endl; } // Функция Random O-to-N. // Генерирует случайное число в диапазоне от 0 до N-1. И int rand_0toNl(int n) { return rand() % n; j j Как это работает В программе появилось всего четыре новых строки... но я считаю, что они достаточно важны, чтобы на них основывать весь пример. Все эти инструкции являются частью функции draw_a_card. int card; card = rand_0toNl(52) ; // Получить случайное число //в диапазоне от 0 до 51 г = card % 13; // г = случайное число в диапазоне // от 0 до 12 s = card / 13; // s = случайное число в диапазоне // от 0 до 3 Для каждой вытянутой карты эта версия программы выполняет всего один вызов функ- ции rand_OtoN1. Данный подход согласуется с основной целью: выбирать одно значе- ние, соответствующее уникальной карте. Вытаскиваемая карта должна иметь такую комбинацию масти и ранга, которую не имеет ни одна другая карта. Получая число в диапазоне от 0 до 51, программа определяет уни- кальную комбинацию масти и ранга. Одним из способов выполнить данную задачу, является создание еще двух массивов: int rank_chooser[52]={0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 0, 1, 2, 3, 4, 5, б, 7, 8, 9, 10, 11, 12, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, . о, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, }; int suit_chooser[52]= {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, };
140 C++ без страха Значения переменных/ и s затем можно получить, выбрав элементы из этих массивов: г = rank__chooser [card] ; s = suit_chooser[card]; Значения, полученные таким путем, будут уникальными, что вы сможете увидеть, если внимательно посмотрите на массивы. Например, для значения переменной card, равного 12, значения переменных г и s будут равны 12 и 0 соответственно, а для значения пере- менной card, равного 25, значения переменных г и s будут равны 12 и 1. Такое решение - получение элементов из данных массивов - является эквивалентом следующих математических операций: г = card % 13; // г = случайное число в диапазоне // от 0 до 12 s = card /13; // s = случайное число в диапазоне // от 0 до 3 Вспомните из предыдущих глав, что оператор деления по модулю (%) выполняет деле- ние двух целых чисел, и возвращает остаток от деления. Здесь действие заключается в делении на 13 и получении числа в диапазоне от 0 до 12 - те же значения, что и в масси- ве rank_chooser. В результате деления двух целых чисел возвращается частное, округленное вниз и с от- брошенным остатком. Эта операция создает число в диапазоне от 0 до 3 - те же значе- ния, что и в массиве suit_chooser. Итак, используя данные математические операции, вы ограждаете себя от проблемы создания массивов rank_chooser и suit_chooser. Результаты останутся прежними. Упражнения Упражнение 5.4.1. Напишите программу для сценария, описанного в упражнении 5.3.1. Пример характеризуется наличием восьми предметов, каждый из которых имеет уни- кальную комбинацию цвета (красный, синий, оранжевый и зеленый) и формы (шар, куб). Используйте подход, похожий на подход, используемый в примере 5.4, в котором для каждого вынимаемого предмета генерируется одно случайное число и затем оно ис- пользуется для получения уникальной комбинации, состоящей из двух значений, одно из которых выбирает цвет, а второе - форму. Пример 5.5. Сдающий карты #3 Теперь, когда мы знаем, как использовать число в диапазоне от 0 до 51 для представле- ния колоды карт, мы может добавить заключительную часть головоломки. Аккуратная программа для раздачи карт должна запоминать, какие карты уже были розданы, и избе- гать раздачи этих карт снова. Существует два способа реализовать это. Первый способ заключается в инициализации массива, состоящего из 52 элементов, каждый элемент которого представляет положе- ние карты в колоде, присвоении каждому элементу значения карты и последующем «пе- ретасовывании» колоды с использованием ряда случайных перестановок. Подход, который я использовал в данном примере, мне показался немного проще и оп- ределенно более легким для реализации: я использую массив, состоящий из 52 элемен-
ГЛАВА 5. Массивы: множество чисел 141 тов, в котором каждый элемент относится к какой-либо карте. Элемент содержит значе- ние true/false, указывающее на то, была ли уже выбрана соответствующая карта или еще нет. Как только очередная карта будет вытянута, данный массив обновляется «призна- ком отсутствия данной карты в колоде», чтобы показать, что эту карту в дальнейшем нужно пропустить. Вот и сама программа. Как и ранее, полужирным шрифтом выделены лишь новые стро- ки кода. Все остальные строки остались такими же, как и в предыдущем примере. Листинг 5.5. dealer3.cpp ttinclude <iostream> ttinclude <stdlib.h> ttinclude <time.h> ttinclude <math.h> using namespace std; int rand_0toNl(int n); void draw_a_card(); int select_next_available(int n); char *suits[4] = {"hearts", "diamonds", "spades", "clubs"}; char *ranks[13] = {"ace", "two", "three", "four", "f ive", "six", "seven", "eight", "nine", "ten", "jack", "queen", "king" }; int card_drawn[52]; int cards_remaining =52; int main() { int n, i; srand(time(NULL)); // Установка начального значения для 11 случайных чисел. while (1) { cout << "Enter no. of cards to draw (0 to" , cout << "exit): "; Cin >> П; if (n == 0) break; for (i = 1; i <’= n; i++) draw_a_card(); } return 0; } . . // Функция Draw-a-card // Выполняет действие по вытягиванию одной карты, получая // случайное число в диапазоне от 0 до 4 и случайное число в // диапазоне от 0 до 12. Эти числа в последствии используются // для индексации массивов строк, рангов и мастей.
142 C++ без страха // void draw_a_card() { int г; // Случайный индекс (от 0 до 12) для массива ranks int s; // Случайный индекс (от 0 до 3) для массива suits int n, card; n = rand_0toNl(cards_remaining--); card * select_next_available(n); r = card % 13; // r = random 0 to 12 . s = card I 13; // s = random 0 to 3 cout << ranks[r] « " of " « suits[s] « endl; } // Функция Select-next-available-card. // Найти N-ый элемент массива card_drawn, пропустив все // те элементы,, значения которых установлены в значение true. // int select_next_available(int n) { int i = 0; //At beginning of deck, skip past cards already // drawn. while (card_drawn[i]) i+ + ; while (n-- >0) { // Выполнить следующее n раз: i++; // Перейти к следующей карте while (card_drawn[i]) // Пропустить уже i++; // вытянутые карты. } card_drawn[i] = true; // Отметить вытягиваемую карту return i; , // Возвратить это число. // Функция Random 0-to-N. // Генерирует случайное число в диапазоне от 0 до N-1. // int rand_0toNl(int n) { return rand() % n; );. Как это работает Эта версия программы использует дополнительную функцию select_next_available для. определения вытягиваемой карты. Программа использует глобальную целочисленную переменную cards_remaining, хранящую количество оставшихся в колоде карт. int cards_remaining - 52; Каждый раз, когда вытягивается карта, значение этой переменной увеличивается на единицу.
ГЛАВА 5. Массивы: множество чисел 143 Функция draw_a_card сначала получает случайное целое число на основании значения переменной cards_remaining. Это целое число затем передается в функцию se- lect_next_available, n = rand_0toNl(cards_remaining--); card = select_next_available(n); Функция select_next_available предназначена для выполнения счета в массиве п + 1 раз, каждый раз пропуская карты, которые уже были вытянуты. Эти карты помечены значе- нием true (1) в массиве card_drawn. Например, вот как бы мы считали через три карты: 0 1 1 0 1 1 0 1 0 0 Массив card_drawn Код функции select_nextr_available находит первую доступную карту, пропуская все вытянутые карты с начала массива. while (card_drawn[i]) i + + ; Затем находится еще п доступных карт, каждый раз пропуская карты, которые уже были вытянуты. while (п~ — > 0) { // Выполнить следующее п раз: i++; // Перейти к следующей карте while (card_drawn[i]) // Пропустить уже i++; // вытянутые карты. В цикле используется условие п-- > 0. Если значение переменной п больше нуля, усло- вие считается истинным; после этого значение переменной п уменьшается, декременти- руется. Цель условия - выполнить цикл п раз. Когда функция select_next_available, наконец, находит карту, которую нужно вытянуть, она делает две вещи: устанавливает значение элемента с индексом в массиве card_drawn в значение true (1), после чего возвращает индекс вызывающей функции. card_drawn[i] = true; // Отметить вытягиваемую карту return i; // Возвратить это число. Оптимизация программы Если вы - внимательный программист, то можете заметить, что у программы есть один вопиющий дефект: если пользователь попытается вытянуть больше чем 52 карты, не существует процедуры, которая возвратит колоду в исходное положение (в сущности, перетасует ее) и начнет сначала. Вместо этого программа завершается выходом за пре- делы массива card_drawn. Результат может быть катастрофическим, так как код, запи- сывающий значения массива, завершается перезаписыванием других областей памяти. Поэтому важно обработать эту ситуацию каким-либо способом.
144 C++ без страха Возможно, наиболее дружественным для пользователя вариантом является обнаружение этого условия в начале функции draw_a_card и установка глобального массива card_drawn и глобальной переменной cards_remaining в исходные значения, установ- ленные в начале программы. if (cards_remaining == 0) { cout « "Reshuffling." « endl; cards_remaining =52; // Восстановить первоначальное // значение переменной cards_remaining. for (int i = 0; i < 52; i++) // Восстановить все card_drawn[i] = false; // значения в массиве 11 card_drawn. } При использовании здесь цикла for можно воспользоваться уловкой, описанной в главе 3. Нам необходима переменная i для использования в этом особом цикле, однако перемен- ная i еще не была объявлена в функции draw_a_card. Результатом выражения int i = О является «динамическое» объявление переменной i и ее инициализация значением 0. Переменной i назначается локальная область видимости для цикла for: изменения зна- чения переменной i, внесенные внутри цикла, не влияют на значение переменной i в дру- гих контекстах. Существует вторая особенность программы, которая является не оптимальной. Если вы проанализируете код функции select_next_available, то увидите, что она выполняет один и тот же цикл while (код, выполняющий действие по «переходу вперед») в не- скольких местах. Можно свернуть эти циклы в один, если представить, что функция проходит по массиву не п раз, а п + 1. (Например, при введенном значении 0 счет про- должается до первого доступного элемента, при значении 1 - до второго доступного элемента и так далее.) Вот улучшенная, более короткая версия: int select_next_available(int n) { int i = -1; n++; // Настроить значение для n + 1 операций счета while (n-- >0) { // Выполнить следующее п раз: i++; // Перейти к следующей карте while (card_drawn[i]) // Пропустить уже i++; // вытянутые карты. ) card_drawn[i] = true; // Отметить вытягиваемую // карту. return i; // Возвратить это число. }' Обратите внимание, что в этой версии кода переменная i инициализируется значением -1, а не значением 0. Это сделано потому, что выбор первого элемента должен инкременти- ровать значение переменной до 0, а не до 1.
ГЛАВА 5. Массивы: множество чисел 145 Упражнения Упражнение 5.5.1. Напишите программу, похожую на ту, которая описывалась ранее для сумки с восьмью предметами: каждый предмет имеет уникальную комбинацию цве- та (красный, синий, оранжевый, зеленый) и формы (шар, куб). Каждый раз, когда пред- мет извлекается из сумки, он не может быть вытянут снова, поэтому число возможных вариантов выбора уменьшается на единицу. Логика должна быть такой же, как и в при- мере 5.5, однако массивы и начальные значения будут различаться. Также, возможно, вы захотите дать своим переменным другие имена, например items_remaining и (для мас- сива целочисленных элементов) items_picked. Умный понимает с полуслова Просматривая примеры в этой главе, у вас может появиться вопрос: что случится, если мы попытаемся получить доступ к элементу массива, которого не существует? То есть, что произойдет, если использовать значение индекса, которое не поддерживается? Ответ - в этом случае язык C++ является ненадежным. Например, объявлен массив, со- стоящий из пяти элементов, однако по какой-то причине была предпринята попытка за- писать значение в элемент с индексом 5. Это не должно быть разрешено, поскольку зна- чение индекса последнего элемента массива равняется 4. п = 5; а[п] = 13; ▼ I 13 а[0] а[1] а[2] а[3] а[4] amount Язык C++ не останавливает вас. Вместо этого операция выполняется в том участке памя- ти, где бы находился элемент аггау[5], если бы он существовал. В результате часть дан- ных за пределами массива оказывается перезаписанной. Это может приводить к ошиб- кам, которые очень трудно отыскать. Почему язык C++ позволяет это делать? Почему он не выполняет проверку индексов мас- сива, а затем не запрещает операции, которые перезаписывают другие области памяти? Некоторые языки программирования выполняют, на самом деле, такую проверку. Одна- ко недопустимый доступ к массиву не может обязательно быть определен при компиля- ции программы. В приведенном примере значение переменной п может не быть извест- ным до тех пор, пока программа не будет запущена. Языки, например Basic и FORTRAN, вынуждены вводить проверку во время выполнения перед всеми и каждым доступом к элементу массива. Это приводит к тому, что программа выполняется менее эффективно. Язык C++ и его предшественник, язык С, разрабатывались с таким подходом, что про- граммист должен знать, что он (или она) делает. Программы на языке C/C++ быстрее и компактнее, чем те, которые созданы на других языках. Но взамен этой большей эффек- тивности необходимо заботиться о том, чтобы индексы массивов в ваших программах не выходили за их границы.
146 C++ без страха Двумерные массивы: в матрицу Большинство компьютерных языков предоставляют возможность создавать не только простые, одномерные массивы, но также и многомерные массивы. Язык C++ не является исключением. Двумерные массивы в языке C++ имеют следующую форму: тип имя_массива [размер!] [размер2] / Число элементов массива равняется результату выражения размер1 * размер2, и индек- сы для каждого измерения начинаются с нуля, как и в одномерных массивах. Например, рассмотрим следующее объявление. int matrix [10] [10].; Это объявление создает массив 10 на 10, состоящий из 100 элементов. Значения индекса для каждого измерения находятся в диапазоне от 0 до 9. Следовательно, первым элемен- том массива является matrix[0][0], а последним элементом - matrix[9][9]. Чтобы обработать такой массив программным путем, необходимо использовать вложен- ный цикл с двумя переменными цикла. Например, в этом коде все члены массива ини- циализируются значением 0: int i, j; for (i = 0; i < 10; i++) for (j = 0; j < 10; j++) matrix[i][j] = 0; Вот как это работает: ✓ Значение переменной i устанавливается в 0, и сначала выполняется полный набор итераций внутреннего цикла: значение переменной j пробегает от 0 до 9. ✓ После этого одна итерация внешнего цикла завершена; значение переменной i увели- чивается до следующего большего числа, которым является 1. Затем все итерации внутреннего цикла выполняются снова, при этом значение переменной ] (как всегда) пробегает от 0 до 9. »/ Процесс повторяется до тех пор, пока значение переменной i не станет больше его окончательного значения, 9. В результате значения переменных i и j будут равны (0, 0), (0, 1), (0, 2), ... (0, 9), в этом месте внутренний цикл завершается, значение переменной i увеличивается и внутренний цикл начинается снова: (1, 0), (1, 1), (1, 2) ... Всего будет выполнено 100 операций, по- скольку каждая итерация внешнего цикла - который выполняется 10 раз - выполняет 10 итераций внутреннего цикла. В массивах в языке C++ крайний правый индекс изменяется быстрее. Это означает, что элементы matrix[5][0] и matrix[5][1] располагаются в памяти друг за другом. Между прочим, синтаксис составной инструкции (использующий фигурные скобки для явного определения того, где начинается и заканчивается цикл) может быть использован здесь, как и в других управляющих структурах. Вот пример того, как это может выгля- деть. Для этого требуется немного больше работы, однако смысл более понятен.
ГЛАВА 5. Массивы: множество чисел 147 int i, j; for (i = 0; i < 10; i + + ) { for (j = 0; j < 10; j++) {• matrix[i][j] = 0; } } Резюме Глава 5 посвящена использованию массивов в языке C++. Некоторые важные момен- ты таковы: ✓ Для объявления массива в языке C++ используется нотация с квадратными скобками. Объявления имеют следующий вид: тип имя_массива[количество_элементов]; Для массива, состоящего из п элементов, индексы элементов находятся в диапазоне от 0 до п - 1. Например, следующая инструкция присваивает первому элементу зна- чение 3. scores[0] = 3; »/ Для эффективной обработки массивов любого размера можно использовать циклы. Например, предположим, что был объявлен массив, состоящий из SIZE_OF_ARRAY элементов. Следующий цикл присваивает всем элементам массива значение 0: for(i =0; i < SIZE_OF_ARRAY; i + + ) my_array[i] = 0; t/ Для инициализации массивов можно использовать агрегацию - список значений, за- ключенный в фигурные скобки. double scores[5] = {6.8, 9.0, 9.0, 8.3, 7.1 }; ✓ Для объявления строковой переменной используется нотация char*. Например: char *name = "Joe Bloe"; ✓ Можно объявлять массивы строк точно так же, как и другие виды массивов. Например: char *names[4] = {"John", "Paul", "George", "Ringo" }; ✓ При печати строки или члена массива строк оператор * не используется. cout « "The organization of the group was " « names[0]; ✓ Язык C++ не проверяет границы массива во время выполнения. Поэтому, необходимо тщательно проверить, что вы не написали код для доступа к массиву, который пере- записывает другие участки памяти. ✓ Двумерные массивы объявляются следующим способом: тип имя_массива[размер11 [размер2] ;
ГЛАВА 6. Указатели: способ управления данными Возможно, даже больше, чем что-либо еще, языки, основанные на языке С (великолеп- ным членом которых является язык C++), характеризуются использованием указателей - переменных, которые хранят адреса памяти. Эта тема иногда считается трудной для но- вичков. Некоторым людям кажется, что указатели являются частью заговора опытных программистов, которые хотят отомстить оставшейся части человечества (поскольку они не были выбраны для игры в баскетбол, не были приглашены на студенческий бал или из-за чего-либо другого). Однако указатель - это просто еще один способ обращения к данным. В использовании указателей нет ничего мистического; вы всегда будете успешно использовать указатели, если будете следовать простым, точно определенным шагам. Конечно, вы можете быть сбиты с толку (как однажды был сбит и я), зачем вообще следовать этим шагам - почему просто не использовать элементарные имена переменных во всех случаях? Вы правы, для обращения к данным использование обыкновенных переменных зачастую является достаточным. Но иногда программам требуется нечто особенное. В этой главе я надеюсь продемонст- рировать, почему необходимо делать дополнительные шаги, привлекая в программу ис- пользование указателей. Понятие указателя Самые простые программы состоят из одной функции (main), которая не взаимодейст- вует с сетью или операционной системой. С такими программами вы можете прожить оставшуюся часть жизни без указателей. Но при написании программ, состоящих из множества функций, может понадобиться, чтобы одна функция передавала адрес данных другой функции. В языках С и C++ пере- дача адреса данных является единственным способом, которым функция может изме- нить значения аргументов, передаваемых в нее. Это называется передачей по ссылке (pass by reference). Язык C++ предоставляет альтернативный способ передачи по ссылке, с кото- рым я познакомлю вас в главе 12, однако механика, лежащая в основе обоих ме- тодов, является схожей. Лучше сначала разобраться с указателями. Указатель - это переменная, хранящая адрес. Например, предположим, что у нас есть три переменные типа int: а, Ь, с и одна переменная-указатель: р. Допустим, что пере- менные а, Ь и с проинициализированы значениями 3, 5 и 8, а указатель проинициализи- рован адресом переменной а. В этом случае схема памяти компьютера может выглядеть следующим образом:
ГЛАВА 6. Указатели: способ управления данными 149 На рисунке предполагается, что переменные а, Ь, с и р имеют числовые адреса 1000, 1004, 1008 и 1012. Адреса данных на разных системах могут различаться, однако рас- стояния между адресами остаются неизменными. Тип данных int и все типы указате- лей в 32-битной компьютерной системе занимают по четыре байта (4*8=32); переменная типа double занимает восемь байт (64 бита). Все ячейки памяти компьютера имеют числовые адреса - хотя вы почти никогда не знаете или не заботитесь о них. Тем не менее на уровне машинного кода адреса - это то единственное, что понимает центральный процессор. Ссылка на переменную Num в ва- шей программе, например, переводится в ссылку на числовой адрес. (Компилятор, лин- кер и загрузчик (loader) назначают этот адрес переменной Num, поэтому вам не нужно думать об этом.) Использование указателя основано на косвенной (indirect) ссылке на ячейку памяти. Центральные процессоры оптимизированы для эффективного выполнения данных ссылок, что является одной из причин, по которой языки, основанные на языке С, поддерживают их использование. Во время выполнения адрес загружается во внут- ренний регистр центрального процессора, который затем используется для доступа к элементам в памяти. Вставка Как на самом деле выглядят адреса? В предыдущем разделе я предположил, что переменные а, Ь и с имеют физические адреса 1000, 1004 и 1008. Хотя это и возможно, но маловероятно - просто предполо- жение, высказанное наугад. На самом деле, при представлении двоичного кода или адресов «крутыми» програм- мистами используется шестнадцатеричная система счисления. Это означает исполь- зование основания счисления 16, а не 10. Существует достаточное основание, чтобы использовать шестнадцатеричную нота- цию. Поскольку число 16 является точным результатом возведения числа 2 в четвер- тую степень (2 *2*2*2 = 16), каждое шестнадцатеричное число соответствует уни- кальному набору точно из четырех битовых разрядов - ни больше, ни меньше. Таб- лица 6.1 демонстрирует, как работают шестнадцатеричные числа.
150 C++ без страха Табл. 6.1. Шестнадцатеричные эквиваленты Шестнадцатеричное число Десятичный эквивалент Двоичный эквивалент 0 0 0000 1 1 0001 2 2 0010 3 3 ООП 4 4 0100 5 5 0101 6 6 оно 7 7 - 0111 8 8 1000 9 9 1001 А 10 1010 В 11 1011 С 12 1100 D 13 1101 Е 14 1110 F 15 1111 Преимущество использования шестнадцатеричной нотации для адресов заключается в том, что, просто посмотрев на адрес, можно сказать, насколько он большой. Одно шестнадцатеричное число всегда в точности соответствует четырем двоичным чис- лам. Адрес 0x8000 состоит из четырех шестнадцатеричных чисел. (Префикс «Ох» в нотации языка C++ означает тот факт, что число является шестнадцатеричным.) Че- тыре шестнадцатеричных числа в точности соответствуют 16 двоичным числам. Ма- тематика очень проста: 4*4=16. В отличие от этого, тяжело сказать, скольким двоичным числам (или битам) соответ- ствует десятичное число 1000. Ответ - 10 бит. (Вы знали это?) Однако для представ- ления десятичного числа 5000 в двоичном виде требуется 13 бит. Перевод из деся- тичной системы счисления в двоичную гораздо более сложен, чем перевод из шест- надцатеричной в двоичную; поэтому системные программисты предпочитают ис- пользование шестнадцатеричной системы счисления. Почти на всех персональных компьютерах, используемых в наши дни, размер адреса составляет не 16 бит, а 32. Такой адрес, наподобие 0x8FFF1000, является 32-битным адресом, поскольку он состоит из восьми шестнадцатеричных чисел (8*4 = 32). Ад- рес 0x00002222 также является 32-битным, поскольку делается допущение, что пе- редние нули сохраняются.
ГЛАВА 6. Указатели: способ управления данными 151 Когда в 1970-х годах появились персональные компьютеры, 16-битные адреса были нормой. Число возможных адресов составляло 2 в степени 16, или 64К. Это означало, что, независимо от того, сколько карт памяти вы купите, процессор просто не мог распознать более чем 64К памяти. Процессор 8086, используемый в компьютерах IBM PC, и последовавшие за ним ранние копии-клоны использовали 20-битную систему адресации, которая поддер- живала до 16 «сегментов», каждый из которых мог быть объемом 64К. (И снова, это делало 64К волшебным числом.) Адресуемая память увеличилась до объема немного большего, чем один мегабайт - лучше, чем 64К, однако все еще совсем не отвечало требованиям современных стандартов. К середине 1990-х годов 32-битные адреса стали стандартом, и операционная систе- ма Microsoft Windows полностью поддерживала их. Программное обеспечение было переписано и скомпилировано заново для Того, чтобы прекратить использование не- удобного режима сегментированной адресации и перейти к чистым 32-битным адре- сам. Число возможных адресов памяти теперь равняется 2 в степени 32, или что-то более четырех миллиардов - верхняя граница в четыре гигабайта! Однако со скоро- стью, с которой совершенствуется память, дело только в® времени, когда возможности аппаратного обеспечения выйдут за эту границу. Приготовьтесь, чтобы увидеть; какие обходные пути изобретут разработчики компьютеров, когда наступит этот день. Объявление и использование указателей Для объявления указателя используется следующий синтаксис: тип *имя; Например, можно объявить указатель р, который может указывать на переменные типа int: int *р; В данный момент указатель не проинициализирован. Все, что мы знаем, - это то, что он может указывать на объекты данных типа int. Имеет ли значение этот тип? Да. Базо- вый тип указателя определяет, как интерпретируются данные, на которые он указывает. Сделайте так, чтобы он указывал на какой-либо другой тип; и будет использован непра- вильный формат данных. Указатель р имеет тип int*; поэтому он должен указывать только на переменные типа int. Следующие инструкции объявляют целочисленную переменную п, инициализируют ее значением 0, а затем присваивают ее адрес указателю р. int п = 0; , р = &п; // переменная р теперь указывает на // переменную п! Амперсанд (&) - это еще один новый оператор. Его назначение в жизни - получение ад- реса. И снова, в действительности, вам не нужно беспокоиться о том, какой это числовой адрес; все, что имеет значение, - это то, что переменная р хранит адрес переменной п, - то есть переменная руказывает на переменную п.
152 C++без страха После выполнения приведенных инструкций возможной схемой памяти для программы может быть следующая: Здесь появляется интересная деталь. Существует пара способов использования перемен- ной-указателя. Первый способ заключается в изменении значения самой переменной р. Р++; // Указывает на следующей элемент в памяти. Эта инструкция прибавляет единицу к значению переменной р, после чего переменная р указывает на переменную, расположенную в памяти за переменной п. Результаты не- предсказуемы, кроме случая с элементами массива, о чем- я расскажу в разделе «Указатели и обработка массива». В остальных случаях следует избегать данного типа операций. Второй способ использования указателя является более полезным - по крайней мере, в данном случае. Применение оператора разыменования (*) говорит: «это то, на что ука- зывает данный указатель». Следовательно, присвоение значения выражению *р имеет такой же эффект, что и присвоение значения переменной п, поскольку переменная п это то, на что указывает переменная р. *р = 5; // Присвоение значения 5 переменной типа int, // на которую указывает переменная р. Таким образом, эта операция изменяет значение того, на что указывает переменная р, а не само значение переменной р. Теперь схема памяти выглядит следующим образом:
ГЛАВА 6. Указатели: способ управления данными 153 Результат выполнения инструкции в данном случае равен результату выражения «п = 5». Компьютер находит ячейку памяти, на которую указывает переменная р, и помещает значение 5 в эту ячейку. Можно использовать значение, на которое указывает указатель, как для получения, так и для присвоения данных. Вот еще один пример использования указателя: *р = *р + 3; // Прибавить значение 3 к значению // переменной типа int, на которую указывает // переменная р. Значение переменной п снова изменяется - на этот раз со значения 5 на значение 8. Результат выполнения этой инструкции такой же, как и результат выражения «п = п + 3». Компьютер находит ячейку памяти, на которую указывает переменная р, и прибавляет значение 3 к значению, находящемуся в данной ячейке. Подводя итог: когда переменная р указывает на переменную п, обращение к выражению *р имеет такой же эффект, что и обращение к переменной п. Вот еще несколько примеров: Когда переменная р указывает на перемен- ную п, эта инструкция Эквивалентна данной инструкции *р = 33; *р = *р + 2 ; cout << *р; cin » *р; п = 33 ; n = п + 2 ; COUt << п; cin » п; Но если использование выражения *р имеет такой же результат, что и, при использова- нии переменной п, зачем отдавать предпочтение выражению *р? Ответом является то, что (помимо других вещей) этим достигается передача по ссылке - при которой функция может изменять значение аргумента. Вот как это работает: ✓ Вызывающая функция передает адрес переменной, значение которой нужно изменить. Например, вызывающая функция передает выражение &п (адрес переменной п). ✓ Функция имеет аргумент-указатель, например р, который получает это значение ад- реса. Впоследствии функция может использовать выражение *р для обращения к зна- чению переменной п. В следующем разделе демонстрируется простой пример, который делает именно это.
154 C++ без страха Пример 6.1. Функция Double-it Вот пример программы, использующей функцию с именем double Ji, которая умножает значение переменной, переданной в нее, на 2, - или, более конкретно, умножает значе- ние переменной, адрес которой передан в функцию, на 2. Это может звучать немного витиевато, но пример должен помочь прояснить ситуацию. Листинг 6.1. double it.cpp ttinclude <iostream> using namespace std; void double_it(int *p); int main() { int a = 5, b = 6; cout<< "Value of a before doubling is ” « a « endl; cout« "Value of b before doubling is " << b « endl; double_it(&a); // Передача адреса переменной a. double_it(&b); // Передача адреса переменной b. cout« "Value of a after doubling is " « a « endl; cout<< "Value of b after doubling is " « b « endl; return 0; } void double_it(int *p) { *p = *p * 2; } Как это работает Это программа с прямолинейной логикой работы. Вот все, что делает функция main: ✓ Печатает значения переменных а и Ь. ✓ Вызывает функцию double_it для удвоения значения переменной а, передавая адрес переменной а (&а). ✓ Вызывает функцию double_it для удвоения значения переменной Ь, передавая адрес переменной b (&Ь). ✓ Снова печатает значения переменных а и Ь. В этом примере для работы необходимы указатели. Можно написать версию функции dou- blejt, которая принимает простой аргумент типа int, но она ничего не будет делать. void double_.it (int n) { // ЭТО НЕ РАБОТАЕТ! n = п * 2 ; } Проблема здесь заключается в том, что при передаче аргумента в функцию функция по- лучает копию аргумента. Но после возврата из функции эта копия уничтожается. Однако, если функция получает адрес переменной, то она может использовать этот адрес для изменения исходного значения самой переменной.
ГЛАВА 6. Указатели: способ управления данными 155 Приведу аналогию. Получение передаваемой переменной похоже на получение фото- копий секретного документа. Можно просмотреть информацию, однако у вас нет дос- тупа к оригиналам. Получение же указателя похоже на получение местоположения и кодов доступа к настоящим документам; можно не только посмотреть на них, но и внести изменения! Таким образом, чтобы позволить функции внести изменения в значение переменной, используются указатели. В данной функции это достигается объявлением аргумента р, указателя на целое число: void double_it(int *р); Это объявление говорит о том, что «то, на что указывает переменная р» имеет тип int. Следовательно, сам аргумент р - это указатель на тип int. Поэтому вызывающая функция должна передать адрес, что достигается использованием оператора взятия адреса (&). double_it(&a); void double_it(int *р) { *Р = *Р*2 I } Результаты выполнения этих инструкций в терминах схемы памяти можно представить наглядно. Адрес переменной а передается в функцию, которая затем использует его для изменения значения переменной а. После этого программа вызывает функцию снова, но на этот раз передавая адрес переменной Ь. Функция использует этот адрес для изменения значения переменной Ь. Значение Адрес РЧ-& b Значение Адрес *р = *р * 2;
156 C++ без страха Упражнения Упражнение 6.1.1. Напишите программу, вызывающую функцию triple_.it, которая при- нимает адрес переменной типа int и увеличивает значение переменной, на которую ука- зывает аргумент, в .три раза. Протестируйте ее, передав аргумент п, проинициализиро- ванный значением 15. Напечатайте значение переменной п до и после вызова функции. (Подсказка: функция будет похожа на функцию из примера 6.1, поэтому можно исполь- зовать тот код и внести необходимые изменения. При вызове функции не забудьте пере- дать выражение &п.) Упражнение 6.1.2. Напишите программу с функцией, имя которой convert_temp: функ- ция принимает адрес переменной типа double и применяет преобразование температу- ры по шкале Цельсия в температуру по шкале Фаренгейта. Переменная, хранящая тем- пературу по шкале Цельсия, после вызова функции должна содержать эквивалентную температуру по шкале Фаренгейта. Проинициализируйте переменную temperature зна- чением 10.0 и напечатайте значение переменной до и после вызова функции. Подсказка: соответствующая формула - F = (С * 1.8) + 32. Функция перестановки: еше одна функция, использующая указатели Функция double_it, представленная в предыдущем разделе, отлично подходит для демонстрации некоторых основных принципов действия, однако, возможно, это не то, что будет использоваться в настоящей программе. Сейчас я перейду к функции swap (обмен), которую вы найдете гораздо более полезной. Предположим, что у вас есть две переменные типа int и вы хотите обменять их значе- ния. Это легко сделать с использованием третьей переменной temp, предназначенной для хранения временного значения. temp = а; а = Ь; b = temp; А теперь, не полезно было бы поместить это в функцию, которую можно вызывать все- гда, когда это необходимо? Да, но, как я уже объяснял ранее, пока аргументы не пере- даются по ссылке, изменения в значениях переменных игнорируются. Вот другое решение. В этом варианте используются указатели для передачи аргументов по ссылке. // Функция перестановки. // Меняет местами значения, на которые // указывают переменные pl и р2. // void swap(int *pl, int *р2) { int temp = *pl; *pl = *р2; *р2 = temp; }
ГЛДВД 6. Указатели: способ управления данными 157 Выражения *р1 и *р2 являются целочисленными значениями, и их можно использовать, как любые другие целочисленные переменные. Здесь результатом является обмен значе- ний, на которые указывают переменные р1 и р2. Следовательно, если вы передадите адреса двух целочисленных переменных а и Ь, их значения будут обменены местами. Вот что буквально делают три инструкции внутри функции: > Объявляется локальная переменная temp и инициализируется значением переменной, на которую указывает переменная р1. > Значение переменной, на которую указывает переменная р2, присваивается значению переменной, на которую указывает переменная р1. > Значение переменной temp присваивается значению переменной, на которую указы- вает переменная р2. Переменные р1 и р2 являются адресами, и они не изменяются. Изменяемые данные - это данные, на которые указывают переменные р1 и р2. Это легко представить с помощью примера. Предположим, что переменные big и little проинициализированы значениями 100 и 1 соответственно. int big = 100; int little = 1; Следующая инструкция вызывает функцию swap, передавая адреса этих двух перемен- ных. Обратите внимание здесь на использование оператора взятия адреса (&). swap(&big, &little); Теперь, если напечатать значения этих переменных, вы увидите, что значения перемен- ных поменялись местами, и, таким образом, значение переменной big теперь равно 1, а значение переменной little равно 100. cout << "The value of big is now " << big « endl; cout « "The value of little is now " << little; Заметьте, что адреса памяти переменных big и little не изменились. Однако значения, хранящиеся по этим адресам, изменились. Вот почему оператор разыменования (*) час- то называется оператором «at» («по адресу»). Инструкция *р = 0 изменяет значение, хра- нящееся по адресу р. Пример 6.2. Сортировщик массива Теперь настало время продемонстрировать способности этой функции swap. Сначала я вынужден разъяснить, что указатели могут указывать не только на простые перемен- ные - хотя вначале я использовал эту терминологию, чтобы упростить понимание мате- риала. Указатель типа int (например) может указывать на любую ячейку памяти, хра- нящую значение типа int. Это означает, что указатель может указывать на элементы массива так же, как и на переменную. Например, здесь функция swap используется для обмена значений двух элементов массива агг.
158 C++ без страха int arr[5] = {0, 10, 30, 25, 50}; swap(&arr[2], &arr[3]);t Почему я так горжусь этим фактом? Потому что, задав правильную процедуру, можно использовать функцию swap для сортировки всех значений массива. Рассмотрим обычный массив. Взглянем на массив агг снова - на этот раз с неупорядо- ченными данными. 30 25 0 50 10 агг[О] агг[1] агг[2] агг[3] агг[4] Вот очевидное решение проблемы сортировки. Можно легко проверить, что это работает: > Найти наименьшее значение и поместить это значение в элемент агг[О]. > Найти следующее наименьшее значение и поместить его в элемент агг[1]. > Продолжать таким же образом до тех пор, пока не будет достигнут конец массива. Не Смейтесь. Этот простой метод решения «в лоб» не такой бессмысленный, как кажет- ся. Небольшое усовершенствование даст нам сущность классического алгоритма сорти- ровки методом выбора, который я и использую здесь. Вот более улучшенная версия, где а[] - это массив, ап- число элементов. Для1 = 0доп-2, Найти наименьшее значение в диапазоне от a[i] до а[п -1] Если i не равно индексу найденного наименьшего значения, Поменять местами значения элементов a[i] и а[индекс_наименьшего_значения] Это базовый план. Результатом будет являться помещение наименьшего значения в элемент а[0], следующего наименьшего значения - в элемент а[1] и так далее. Обра- тите внимание, что шаг Для i = 0 до п - 2, Обозначает цикл for, в котором значение переменной i устанавливается в 0 на первой итерации цикла, в 1 - на второй итерации и так далее до тех пор, пока значение переменной i не будет установлено в результат выражения п - 2, что означает, что за- вершено выполнение последней итерации. На каждой итерации цикла нужный элемент помещается на место элемента a[i], после чего значение переменной i инкрементируется. Внутри цикла элемент a[i] сравнивается с диапазоном, включающим в себя сам элемент и все оставшиеся элементы (диапазон от a[i] до а[п - 1], включающий все элементы справа). К тому моменту, когда каждое значение переменной i будет обработано в цикле, весь массив уже будет отсортирован. Вот пример, иллюстрирующий первые три итерации цикла. Сущность процедуры за- ключается в сравнении по очереди каждого элемента со всеми элементами справа от него, при необходимости меняя элементы местами.
ГЛАВА 6. Указатели: способ управления данными 159 Поменять местами элемент а[0| и наименьший элемент в данном диапазоне 8 33 15 7 12 16 2 59 012.34567 t___f Поменять местами элемент а[1] и наименьший элемент в данном диапазоне ./ 7 - 1 7 7 ; ч 2 33 15 7 12 16 8 59 0 1 2 3 4 5 6 7 t Поменять местами элемент а[2] и наименьший элемент в данном диапазоне _______ 2 7 15 33 12 16 8 j 59 0 1 2 3 4 5 6 7 ff Но как мы найдем наименьшее значение в диапазоне от а[1] до а[п - 1]? Помните, что мы должны быть осторожны, чтобы никогда не потерять элемент, поскольку в дальнейшем он нам понадобится. Нам нужен другой алгоритм. Вот что происходит в следующем алгоритме: (1) делается предположение, что i-й эле- мент является наименьшим элементом и значение переменной «low» инициализируется значением переменной I; и (2) всякий раз, когда находится меньший элемент, он становится новым элементом для переменной «low». Для нахождения наименьшего значения в диапазоне от a[i] до а[п -1]: Установить значение переменной low в значение переменной i Для j = j + 1 до п - 1, Если a[j] меньше, чем a[low] Присвоить переменной low значение переменной j В этом алгоритме используются две дополнительные переменные типа int, j и low: пере- менная j - это еще одна переменная цикла, а переменная low - это целочисленная пере- менная, принимающая индекс наименьшего из найденных элементов. Всякий раз, когда находится меньший элемент, значение переменной low обновляется. Далее мы объединим эти два алгоритма вместе. После этого написать код на языке C++ будет очень просто. Для I = 0 до п - 2, Присвоить переменной low значение переменной I Для j = I + 1 до п - 1, Если a[j] меньше, чем a[low] Присвоить переменной low значение переменной j Если значение переменной i не равно значению переменной low, Поменять местами элементы a[i] и a[low]
160 C++ без страха Вот законченная программа, использующая этот алгоритм для сортировки массива: Листинг 6.2. sort.cpp #include <iostream> using namespace std; void sort(intn); void swap(int *pl, int *p2); int a [10] ; int main () { int i ; for (i = 0; i < 10; i++) { cout << "Enter array element #" << i << " : 11 ; cin » a[i]; } sort(10); cout << "Here are all the array elements, sorted:" « endl; for (i = 0; i < 10; i++) cout << a[i] << " "?; return 0; } // Функция сортировки массива: сортирует массив а, // состоящий из п элементов. // void sort (int n) { int i, j, low; for(i = 0; i < n - 1; i++) { // В этой части цикла производится поиск // наименьшего элемента в диапазоне от i до п-1; // индекс найденного элемента присваивается // переменной low. low = i; for (j = i + 1; j < n; j++) if (a [ j] < a[low]) low = j; // В этой части цикла выполняется перестановка, // если это необходимо. if (i != low) swap(&a[i], &a[low]); } } // Функция перестановки. // Меняет местами значения, на которые указывают переменные // pl и р2. //
ГЛАВА 6. Указатели: способ управления данными 161 void swap(int *pl, int *р2) { int temp = *pl; *pl = *р2; *р2 = temp; 2________________________________________________________________________ Как это работает Только две части этого примера непосредственно относятся к пониманию указате- лей. Первая часть - это вызов функции swap, в которую передаются адреса элемен- тов a[i] и a[low]: swap(&a[i], &a[low]); Здесь важным моментом является то, что можно использовать оператор взятия адреса (&) для получения адресов элементов массива так же, как это можно делать с перемен- ными. Другая часть примера, которая касается использования указателя, - это определение функции для перестановки, которую я описывал в предыдущем разделе. // Функция перестановки. // Меняет местами значения, на которые указывают // переменные pl и р2. // void swap(int *pl, int *р2) { int temp = *pl; *pl = *p2; *p2 = temp; } Что касается функции sort, ключом к ее пониманию является анализ того, что делает каждая часть основного цикла. Основной цикл for последовательно присваивает пере- менной i значения 0, 1, 2 ... вплоть до значения выражения п - 2 включительно. Почему именно п - 2? Потому что к тому времени, когда цикл дойдет до последнего элемента (п - 1), вся сортировка уже будет завершена. (Нет необходимости в сравнении послед- него элемента с самим собой.) for(i =0; i < n - 1; i++) { // . . . } Первая часть цикла находит наименьший элемент в диапазоне, включающие элемент a[i] и все элементы, находящиеся справа от него. (Элементы слева от элемента a[i] игнори- руются, поскольку они уже отсортированы.) Внутренний цикл проводит этот поиск, ис- пользуя переменную j, инициализируемую в начале значением i + 1 (первая позиция справа от i). low = i; for (j = i + 1; j < n; j++) if (a[j] < a[low]) low = j; 6 - 6248
162 C++ без страха Это, между прочим, является примером вложенного цикла (nested loop) и абсолютно до- пустимо. Инструкция for является просто еще одним видом инструкции; следователь- но, она может быть помещена внутрь другой инструкции if, while или for до любо- го уровня сложности. Другая часть цикла выполняет простую работу. Все, что ей необходимо сделать, - это спросить, отличается ли значение переменной i от индекса (хранящегося в переменной low) наименьшего элемента. Помните, что оператор ! = означает «не равно». Нет смысла выполнять перестановку, если элемент a[i] сам по себе является наименьшим элементом в диапазоне; это и есть причина использования здесь условия if. if (i != low) swap(&a[i], &a[low]); Упражнения Упражнение 6.2.1. Перепишите пример таким образом, чтобы вместо сортировки мас- сива снизу вверх (от наименьшего элемента к наибольшему) выполнялась сортировка в обратном порядке: сверху вниз. В действительности это гораздо проще, чем может пока- заться. Новая программа должна выполнять поиск наибольшего значения в каждом диа- пазоне. Следовательно, необходимо переименовать переменную low в «high». Во всем остальном, необходимо изменить только одну инструкцию; эта инструкция включает в себя сравнение. (Подсказка: это сравнение не относится к условию цикла.) Упражнение 6.2.2. Перепишите пример таким образом, чтобы он сортировал массив эле- ментов типа double. (Это обеспечит большую гибкость для пользователя, предоставив возможность ввода значений для массива.) Чтобы работать с данными нужного типа, не- обходимо переписать функцию swap. Но примите к сведению, что не следует изменять тип каких-либо переменных, используемых в качестве счетчиков цикла или индексов мас- сива, - такие переменные всегда должны иметь тип int, независимо от остальных данных. Арифметика с указателями Хотя передача по ссылке является наиболее очевидным примером, особенно если вы изучаете язык C++ с самого начала, указатели имеют ряд важных назначений. Одно из них - эффективная обработка массивов. Это не является важной особенностью языка, однако часто используется программистами, пытающимися написать как можно более компактный код. Допустим, объявлен массив: int arr[5] = (5, 15, 25, 35, 45}; Все элементы в диапазоне от агг[О] до агг[4], конечно, могут быть использованы как от- дельные целочисленные переменные. Можно, например, написать инструкции, подоб- ные данной: ar г [ 1 ] = 10;. Но чем является само выражение агг? Может ли выражение агг в какой-либо ситуации появляться само по себе? Да: агг - это константа, которая преобразуется в адрес - конкретнее, в адрес первого эле- мента. Поскольку это константа, нельзя изменить значение самого выражения агг. Можно, однако, использовать его для присвоения значения переменной-указателю:
ГЛАВА 6. Указатели: способ управления данными 163 int *р; р - агг; Инструкция р я агг является эквивалентом следующего: р = &агг[0]; Таким образом, мы нашли более короткий и более понятный способ инициализации ука- зателя адресом первого элемента агг[0]. Есть ли похожий способ для остальных элемен- тов? Будьте уверены. Например, чтобы присвоить переменной р адрес элемента агг[2]: р =' агг + 2; / / р = &агг [ 2 ] ; В действительности, язык C++ внутренне интерпретирует все обращения к массиву как обращения к указателям. Обращение к элементу агг[О] преобразуется к: *(агг + 2) Если вы всегда обращали пристальное внимание на все, то, возможно, сначала подумае- те, что это выглядит ошибочно. Мы прибавляем 2 к адресу начала массива, агг. Но мас- сив агг - это массив элементов типа int, а не массив байт. Элемент агг[2], следователь- но, находится от начала массива не в двух байтах, а в восьми (четыре байта на каждое целочисленное значение - допуская, что вы используете 32-битную систему)! Все же это работает. Почему? Все благодаря арифметике с указателями (pointer arithmetic). Над указателями и други- ми адресными выражениями (как, например, агг) допустимы только определенные арифметические операции. Вот они: ✓ адресное_выражение + целое_число; целое_число + адресное выражение; адресное,„выражение - целое_число; адресное_выражение - адресное„выражение. Когда целое число и адресное выражение суммируются, результатом является другое адресное выражение. Однако перед тем, как вычисление будет завершено, целое число автоматически пересчитывается (scaled) с учетом размера базового типа. Компилятор языка C++ выполняет этот пересчет автоматически. новый_адрес = старый_адрес + (целое„число *размер_базового_типа) Итак, например, если переменная р имеет базовый тип int, прибавление значения 2 к переменной р в результате приведет к увеличению значения на 8 - поскольку умноже- ние 2 на размер базового типа (4 байта) дает 8. Пересчет является чрезвычайно удобной возможностью языка C++, поскольку это озна- чает, что если переменная р указывает на элемент массива и она увеличивается на еди- ницу, то в результате переменная р всегда будет указывать на следующий элемент: р++; // Указывает на следующий элемент массива. Это, в свою очередь, облегчит задачу написания кода в следующем разделе. Также стоит сформулировать еще одно основополагающее правило. Это один из наиболее важных моментов, которые нужно запомнить для работы с указателями. 6*
164 C++ без страха * Когда целое число прибавляется или вычитается из адресного выражения, * компилятор автоматически умножает это число на размер базового типа. Адресные выражения можно сравнивать между собой. Повторюсь, вы не должны делать предположения относительно схемы памяти, за исключением того случая, когда исполь- зуются элементы массива. Следующее выражение всегда истинно: &arr[2] < &агг[3] что является другим способом сказать то, что следующее выражение всегда истинно, как вы и ожидали: агг + 2 < агг + 3 Указатели и обработка массива Поскольку арифметика с указателями работает так, как нужно, функции могут получать доступ к элементам через обращения к указателям, а не через индексацию массива. Результат такой же, однако версия, использующая указатели (я продемонстрирую это), выполняется немного быстрее. В наши дни невероятно быстрых процессоров такие незначительные увеличения скоро- сти для большинства программ несущественны. Производительность центрального про- цессора была гораздо важнее в 1970-х годах, когда процессоры были относительно мед- ленными. В то время выигрыш в работе центрального процессора часто являлся желан- ным призом. Но для определенного класса программ лучшая производительность, достигаемая при помощи языков С и C++, все еще может быть полезна. Языки С и C++ являются выбором тех людей, которые пишут операционные системы, а подпрограммы в операционной системе или драйвере устройства могут вызываться для выполнения тысячи или даже миллионы раз в секунду. В таких случаях небольшой выигрыш в производительности благодаря использованию указателей может иметь значение. Вот функция, использующая обращение к прямому указателю для обнуления целочисленного массива, состоящего из п элементов. void zero_out_array(int *р, int n) { while (n—— >0) { // Выполнить n раз: *p = 0; // Присвоить 0 элементу, // на который указывает // переменная р. р++; // Указывает на следующий // элемент. } } Это удивительно компактная функция, которая была бы еще компактней без коммента- риев. (Но помните, что комментарии никаким образом не влияют на программу во время выполнения.)
ГЛАВА 6. Указатели: способ управления данными 165 Вот еще одна версия функции, использующая код, который может показаться более зна- комым. void zero_out_array2(int *arr, int n) { int i ; for (i = 0; i < n; i++) { arr[i] = 0; } } .. Однако эта версия, почти такая же компактная, выполняется немного медленнее, и вот почему: в инструкции цикла значение переменной i должно быть пересчитано и прибав- лено к адресу массива агг во время каждой итерации цикла, чтобы получить местополо- жение элемента массива arr[i]. arr [ i ] = 0 ; Это выражение в свою очередь является эквивалентом следующего: * (arr + i) = 0; В действительности, это хуже предыдущей версии, поскольку действие по пересчету должно осуществляться во время выполнения; таким образом, на уровне машинного кода вычисление выглядит как: * (arr + (i * 4)) = 0; Здесь проблема заключается в том, что адрес должен вычисляться снова и снова. В вер- сии с использованием прямого указателя адрес массива агг фигурирует всего один раз. Инструкция цикла выполняет меньшую работу: * р = 0; Конечно, значение переменной р должно быть инкрементировано после каждой итера- ции цикла; однако у обеих версий есть переменная цикла, которую нужно обновлять. Для инкрементирования значения переменной р потребуется не больше работы, чем для инкрементирования значения переменной i. Мысленно, вот как работает версия, использующая прямой указатель. Во время выпол- нения очередной итерации цикла значение выражения *р устанавливается в 0, после чего само значение переменной р инкрементируется и указывает на следующий элемент. (Из-за пересчета на каждой итерации цикла значение переменной р на самом деле уве- личивается на 4, но это простая операция.) р = а; *р = 0; р++; *р = 0; р++; *Р = 0;
166 C++ без страха Пример 6.3. Обнуление массива Здесь представлена функция zero_out_array в контексте завершенного примера. Все, что делает эта программа, - это инициализация массива, вызов функции, а затем печать эле- ментов, чтобы продемонстрировать, что она работает. Сама по себе программа не слиш- ком захватывающая, однако она демонстрирует, как работает этот вид использования указателей. Листинг 6.3. zero out.cpp #include <iostream> using namespace std; void zero_out_array(int *arr, int n); int a[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9,10}; int main() { int i ; zero_out_array(a, 10) ; // Печать всех элементов массива. for (i = 0; i < 10; i++) cout « a[i] << " "; return 0; } // Функция обнуления массива. // Присваивает значение 0 все элементам массива типа int // размерности п. // void zero_out_array(int *р, int n) { while (n-- >0) { // Выполнить n раз: *p = 0; // Присвоить 0 элементу, // на который указывает // переменная р. р++; // Указывает на следующий // элемент. } } Как это работает Я объяснил, как работает функция zero_out_array, в предыдущем разделе «Указатели и обработка массива». Напомню, что ключом к пониманию этой функции является то, что после прибавления 1 к указателю он будет указывать на следующий элемент массива: р++; Другим моментом, на который стоит обратить внимание в этом примере программы, является то, что в примере демонстрируется передача массива в языке C++. Первым ар- гументом функции является имя массива. Помните, что использование имени массива преобразуется в адрес начала массива (то есть адрес его первого элемента).
ГЛАВА 6. Указатели: способ управления данными 167 zero_out_array(а, 10) ; Поэтому для передачи массива просто используется имя массива. Функция должна при- нимать адресное выражение (то есть указатель) в качестве типа аргумента. Здесь функ- ция принимает аргумент типа int*, поэтому использование имени массива в качестве аргумента является правильным поведением: как я уже отмечал, эта инструкция переда- ет адрес первого элемента. Это поведение может показаться несколько противоречивым,' если вы не использовали его. Помните, что при передаче простой переменной передается копия значения; при передаче же имени массива передается адрес. Для простого массива с базовым типом int, имя массива имеет тип int*. (Имя двумерного массива имеет тип int* *.) Оптимизация программы Строго говоря, в описанном здесь методе не выполняется никакой оптимизации в смыс- ле создания различных инструкций для исполнения во время выполнения. Однако мож- но написать даже еще более компактный код, если вы желаете. Как я уже отмечал, разработчики языка С (предшественника языка C++) были одержимы идеей иметь возможность для записи максимально возможных компактных инструкций. Вот почему часто имеется возможность выполнить несколько действий в единственной инструкции. Этот вид программирования может быть опасен, если вы не знаете, что де- лаете. Тем не менее... Цикл while в функции zero_out_array выполняет две задачи: обнуляет элемент и затем инкрементирует указатель, чтобы он указывал на следующий элемент: while (п—— >0) { *Р = 0; Р++; } Если вспомните из предыдущих глав, р++ является простым выражением, а выражения могут быть использованы внутри больших выражений. Это означает, что мы можем объединить операции доступа по указателю и инкремента, чтобы получить следующее: while (п—— > 0) *р+ + = 0; Что в мире делает это? Чтобы правильно интерпретировать выражение *р++, необходи- мо поговорить о двух аспектах вычисления выражений, о которых я до сих пор не упо- минал: приоритет (precedence) и ассоциативность (associativity). Операторы наподобие присвоения (=) и проверки на равенство (==) имеют относительно низкий приоритет, что означает, что они выполняются только после того, как выполнены остальные операции. Операторы разыменования указателя (*) и инкремента (++) имеют одинаковый приори- тет, однако (в отличие от большинства операторов) они ассоциированы справа налево. Следовательно, инструкция *р+ + = 0; вычисляется так, как если бы она была запи- сана следующим образом: * (р++) = 0;
168 L C++ без страха Это означает: инкрементировать указатель р, но только после использования его значе- ния в операции * р = 0 ; Между прочим, использование круглых скобок другим способом может привести к то- му, что выражение будет допустимым, однако в данном случае бесполезным. (*р)++ = 0; // Присвоить 0 выражению *р, а затем // инкрементировать значение выражения *р. Результатом данной инструкции будет установка первого элемента массива в 0, а затем - в 1; значение самой переменной р никогда не будет инкрементировано. Вот сколько требуется изучения для понимания крошечного кусочка кода. Вас простят, если вы поклянетесь никогда не писать такие загадочные инструкции.. Но теперь вы за- ранее вооружены, если натолкнетесь на такой код, написанный другими программиста- ми на языке C++. В Приложении А приведены приоритеты и ассоциативность для всех операто- ров языка C++. Упражнения Упражнение 6.3.1. Перепишите пример 6.3 таким образом, чтобы в цикле, печатающем значения массива, использовалось обращение к прямому указателю. Объявите указатель р и проинициализируйте его адресом начала массива. Условие цикла должно быть р < а +10. Упражнение 6.3.2. Напишите и протестируйте функцию сору_аггау, которая копиру- ет содержимое одного массива типа int в другой массив такого же базового типа и раз- мера. Функция должна принимать два аргумента-указателя. Операция внутри цикла должна быть следующей: * р1 = *р2; р1++ ; р2++ ; Если вы желаете написать более компактный, однако загадочный код, можете использо- вать эту инструкцию: * (р1++) = *(р2++); или даже следующую инструкцию, которая означает то же самое: * р1++ = *р2++; Резюме Несколько полезных моментов было приведено во время обсуждения указателей. Они суммированы как: ✓ Указатель - это переменная, которая может хранить числовой адрес ячейки памяти. Можно объявить указатель, используя следующий синтаксис: ТИП *Р;
ГЛАВА 6. Указатели: способ управления данными 169 ✓ Инициализировать указатель можно, используя оператор взятия адреса (&): р = &п; // Присвоить адрес переменной и указателю р. ✓ Как только указатель был проинициализирован, можно использовать оператор разы- менования (*) для обработки данных, на которые указывает указатель. р = «П ; *р = 5; // Присвоить 5 значению переменной п. ✓ Чтобы позволить функции изменять данные (передача по ссылке), передавайте адрес. double_it(&п); ✓ Для получения адреса объявите аргумент, имеющий тип указателя. void double_it(int *р); ✓ Имя массива - это константа, которая преобразуется в адрес первого элемента массива. ✓ Обращение к элементу массива а[п] преобразуется в обращение к указателю: * (а + п) ✓ Когда целое число прибавляется к адресному выражению, язык C++ выполняет пере- счет, умножая целое число на размер базового типа адресного выражения. новый_адрес = старый_адрес + (целое_число * размер_базового_типа) ✓ Унарные операторы * и ++ ассоциируются справа налево. Следовательно, эта инст- рукция: *р++ = 0; ✓ Выполняет то же, что и следующая инструкция, которая присваивает выражению *р значение 0, а затем инкрементирует указатель р, чтобы он указывал на следующий элемент: * (р + +) = 0 ;
ГЛАВА 7. Строки: разбор текста Большинство компьютерных программ в какой-то момент своего функционирования вынуждены взаимодействовать с человеком. Особенно когда вы начинаете писать про- граммы, которые будут использоваться другими людьми, важно дать пользователю представление о том, что же происходит. В консольных приложениях в этой книге нужно использовать слова и текст на англий- ском или каком-либо другом естественном языке (в противоположность компьютерному языку - хотя иногда, я допускаю, для некоторых программистов компьютерный язык на самом деле может стать более естественным). Работа с языком включает использование текстовых строк. Использование строк для печати несложных сообщений является довольно простым. Строки становятся более интересными при их разбиении на части, объединении и анали- зе. Вся данная глава посвящена именно этому. В последних- двух разделах этой главы представлен простой в использовании строковый тип, поддерживаемый современными компиляторами языка C++. Хранение текста в компьютере В главе 1 я сказал, что компьютер хранит текст в числовом виде, как и любой другой тип данных. Различие заключается в том, что для текстовых данных каждый байт представ- ляет собой код ASCII, соответствующий отдельному символу. Допустим, я создал стро- ку, используя следующее объявление: char *str = "Hello!"; Язык C++ отводит ровно семь байт - по одному байту для каждого символа и один байт для завершающего нуля (подробнее об этом чуть далее). Вот как выглядят строковые данные в памяти: Фактические данные: 72 101 108 108 111 33 0 Код ASCII для: 'Н' 'е' Т 'о' (null) Вы можете обратиться к приложению D и найти код ASCII для любого символа. В дей- ствительности компьютер, как ни странно, не хранит буквенно-цифровые символы; он хранит только числа. В таком случае, когда и как числовые значения преобразуются в текстовые символы? Ответ заключается в том, что это преобразование выполняется по крайней мере два раза: при вводе данных с клавиатуры и при их отображении на мониторе. Например, если вы наберете на клавиатуре символ «Н», на нижнем уровне выполнится ряд действий, кото- рые приведут к тому, что программа считает код ASCII для символа «Н» (72) и сохранит его в области данных.
ГЛАВА7. Строки: разбортекста 171 Все остальное время текстовая строка - это просто последовательность чисел или, точнее, последовательность байт, принимающих значение от 0 до 255. Но, как программисты, мы можем думать, что язык C++ хранит в памяти текстовые символы, один байт за раз. Вставка Как компьютер транслирует программы? Книги по программированию иногда обращают ваше внимание на то, что централь- ный процессор не понимает язык C++.. Все инструкции языка C++, чтобы они могли быть выполнены, должны быть преобразованы в машинный код. Но кто выполняет это преобразование? О, здесь нет ничего тайного, говорят книги по программированию; преобразование выполняет компилятор - который сам по себе является компьютерной программой. Но в таком случае преобразование выполняет компьютер. Когда я начал изучать программирование, это казалось для меня неразрешимым па- радоксом. Центральный процессор («мозг» в сердце компьютера) не понимает слова языка C++, тем не менее выполняет преобразование между языком C++ и своим внутренним языком. Как это вообще возможно? Не является ли это противоречием? C++ source (.СРР) (исходный код на языке C++) compiler & linker (компилятор и линкер) —> executable (•EXE) (исполняемый файл) Большая часть ответа заключается в следующем: исходный код на языке C++ хранит- ся в текстовом файле, так же, как хранятся ваши рефераты или памятки. Однако тек- стовые символы, как я отметил, хранятся в числовой форме. Поэтому при работе с этими данными компилятор выполняет другой вид перемалывания чисел, вычисле- ния данных и принятия решений в соответствии с логически точными условиями. Если это не прояснило ситуацию, представьте следующее: перед вами стоит задача прочитать письма от человека, который знает японский язык, но не знает английско- го. Между тем, вы знаете английский язык, но не знаете ни одного слова из японско- го языка. Но, допустим, у вас есть руководство, в котором описывается, как переводить япон- ские символы в их эквивалент на английском языке. Само руководство написано на английском языке, поэтому при его использовании не возникает никаких проблем. Итак, хотя вы и не понимаете японский язык, вы можете перевести с японского языка все, что захотите, внимательно следуя инструкциям. В самом деле, это и есть компьютерная программа; центральный процессор читает руководство. Компьютерная программа является инертной - последовательность ин- струкций и данных - тем не менее «знания» внутри компьютера возникают из его программ. Программы позволяют компьютеру выполнять все виды искусных дейст- вий - включая трансляцию текстового файла, содержащего код на языке C++.
172 C++ без страха Компилятор, конечно, - это весьма особенная программой, однако то, что она делает, вовсе не является неизвестным или невозможным. Как и компьютерная программа, - это «руководство», как описывалось выше. Оно описывает, как прочитать текстовый файл, содержащий исходный код на языке C++, и в результате получить другое руко- водство: этот результат является вашей программой на языке C++ в исполняемой форме. Приходится ли некоторым программистам когда-либо писать машинный код? Да. Например, самые первые компиляторы приходилось писать на машинном коде. Позднее старые компиляторы могли быть использованы для написания новых ком- пиляторов... так что в процессе совершенствования даже самые опытные програм- мисты могут все меньше и меньше полагаться на написание машинного кода. Это бессмысленно, если нет этой строки Если вы прочитали главу 5 «Массивы: множество чисел», то могли догадаться, чем явля- ется строка: массивом. Точнее, строка - это массив, имеющий базовый тип char. Технически тип char является целочисленным типом размером в один байт, доста- точным для хранения 256 различных значений (в диапазоне от 0 до 255). Этого более чем достаточно для хранения всех различных кодов ASCII стандартного набора сим- волов, включая буквы верхнего и нижнего регистров, цифры и огромный набор зна- ков препинания. Ниже приведено несколько вариаций способа объявления строки. Можно создать массив типа char определенного размера, но без начальных значений: char str[10]; Это объявление создает строку, которая может хранить до 10 байтов, но которая пока еще не проинициализирована. (Помните, если массив является глобальным, все его зна- чения по умолчанию инициализируются нулем, а если массив локальный, его значения- ми может быть все что угодно.) Почти всегда программисты при объявлении присваи- вают строке начальное значение, как например: char str[10] = "Hello!"; Это объявление создает показанный на рисунке массив и ассоциирует начальный адрес с именем str. (Помните, что имя массива всегда преобразуется в его начальный адрес.) На этом рисунке я обошелся без отображения численных кодов и показал только представляемые символы. Место, зарезервированное для строки н е 1 1 0 ! \0 Символ «\0» является обозначением нулевого символа на языке C++: это означает, что в данном байте хранится фактическое значение 0 (а не значение 48, которое является ко- дом ASCII цифры «0»),
ГЛАВА 7. Строки: разбор текста 173 Строковые данные завершаются нулевым байтом. Это необходимо потому, что компью- теру нужен способ определения, где заканчивается строка. Некоторые языки програм- мирования, например Basic, используют скрытую структуру данных, которая содержит информацию о длине строки, однако языки С и C++ не используют этот подход. Если вы не укажете определенный размер, но проиницийлизируете строку каким-либо образом, язык C++ выделит ровно столько памяти, сколько необходимо для хранения строки (включая ее завершающий нулевой байт). char s[] = "Hello!"; char *p = "Hello!"; Результаты этих двух выражений приблизительно одинаковы. (Отличие заключается в том, что имя «з» - это имя массива и поэтому является константой, в отличие от пере- менной р, которая может указывать на различные значения.) Язык C++ выделяет в сег- менте данных ровно столько памяти, сколько необходимо, и присваивает начальный адрес переменной s или р: Н е I I о ! \0 Функции для работы со строками Так же как можно вызывать функции для обработки чисел (функции sqrt и rand яв- ляются двумя примерами), можно вызывать функции и для работы со строками. Важным моментом, который нужно запомнить при использовании Этих функций, является то, что они принимают аргументы-указатели - то есть получают адреса строк - но работают функции со строковыми данными, на которые указывают аргументы. Вот несколько наиболее часто используемых функций для работы со строками: Табл. 7.1. Часто используемые функции языка C+ + для работы со строками Функция Описание strcpy(sl, s2) Копирует содержимое строки s2 в результирующую строку si. strcat(sl, s2) Конкатенирует (объединяет) содержимое строки s2 с кон- цом строки si. strlen(s) Возвращает длину строки 5 (без учета завершающего нуля). strncpy(sl, s2, n) Копирует содержимое строки s2 в строку si, но не боль- ше, чем п символов (без учета нуля). strncat(sl, s2, n) Конкатенирует содержимое строки s2 с концом строки si, копируя не больше, чем п символов (без учета нуля).
174 C++ без страха Вероятно, самыми часто используемыми из всех являются функции strcpy (сокраще- ние от «string сору» - копирование строки) и strcat, название которой получилось из фразы «string concatenation» (конкатенация строк). Вот пример использования этих функций: char s[80]; strcpy(s, "One"); strcat (s, "Two");. strcat(s, "Three "); cout << s; В результате, на экран будет выведено: OneTwoThree Этот пример, хотя и является относительно простым, демонстрирует некоторые основ- ные моменты, которые необходимо иметь в виду: ✓ Строковая переменная s должна быть объявлена с достаточным размером, чтобы уместить в себе все символы результирующей строки. Это важно. Язык C++ не дела- ет ничего, чтобы убедиться, что выделенной памяти хватит для размещения всех не- обходимых строковых данных; эта ответственность лежит на вас. ✓ Хотя строка и не проинициализирована, всего для нее зарезервировано 80 байт. В этом примере предполагается, что для хранения 80 символов (включая нулевой символ) будет достаточно. ✓ Все строковые литералы «One», «Two» и «Three» используются в качестве аргумен- тов. Когда в коде появляется строковый литерал, язык C++ выделяет память в сег- менте данных и возвращает адрес выделенной памяти; то есть в коде на языке C++ имя строки становится адресом. Следовательно, строки «Two» и «Three» интерпре- тируются как адресные аргументы, что и требовалось. Действие инструкции: strcat(s, "Two"); выглядит следующим образом: В качестве альтернативы можно конкатенировать одну строку с другой при помощи строковых переменных: strcat(si, s2); Как вы уже могли заметить, при использовании этих строковых функций существует риск: как, в самом деле, гарантировать, что первая строка является достаточно большой для хранения существующих строковых данных вместе с новыми данными?
ГЛАВА 7. Строки: разбор текста 175 Одним из способов является задание целевой строки такой большой, чтобы было маловероятно, что этот лимит будет исчерпан. Этот способ может подойти для про- стых программ. Более надежный подход заключается в использовании функций strncat и strncpy. Каждая из этих функций исключает копирование больше, чем N символов (не считая нулевого байта). Например, следующая операция гарантирует, что не произойдет выход за пределы памяти, выделенной для строки s1. char si[20]; // . . . strncpy(sl, s2, 19); strncat(si, s3, 19 - strlen(sl)); Вы можете заметить, что число копируемых символов здесь ограничено значением 19, а не 20. Это необходимо для того, чтобы оставить один дополнительный байт для завер- шающего нуля. Пример 7.1. Построение строк Давайте начнем с простой строковой операции: построение строки из более мелких строк. Следующая программа получает пару строк от пользователя (вызывая функцию get line, описываемую далее), строит большую строку из этих меньших строк, после чего печатает результаты. Листинг 7.1, buildstrl.cpp ttinclude.<iostream> ttinclude<string.h> using namespace std; int main() { char str[600]; char name[100];’ char addr[200]; char work[200]; // Получить от пользователя три строки. cout « "Enter name and press ENTER: "; cin.getline(name, 99); cout << "Enter address and press ENTER: cin.getline(addr, 199); cout « "Enter workplace and press ENTER: ", cin.getline(work, 199); // Построить результирующую строку и напечатать ее. strcpy(str, "\nMy name is "); strcat(str, name);
176 C++ без страха strcat(str, ", I live at "); strcat (str, addr)'; strcat(str, ",\nand I work at "); strcat(str, work); strcat(str, "."); cout « str; return 0; }' Вот пример сеанса работы с этой программой. Enter name and press ENTER: Niles Cavendish Enter address and press ENTER: 123 Mayfair Street Enter work and press ENTER: Bozo's Carnival of Fun My name is Niles Cavendish, I live at 123 Mayfair Street, and I work at Bozo's Carnival of Fun. Как это работает Этот пример начинается с директивы ttinclude, включающей новый файл: ttinclude <string.h> Включение данного файла необходимо, потому что в нем содержатся объявления функ- ций strcpy и strcat. В качестве общего правила использование любой функции, имя которой начинается с трех букв «str», стандартной библиотеки требует включения файла string, h. Первое, что делает функция main, заключается в объявлении ряда строк для хранения данных: В программе делается предположение, что эти строки достаточно большие и их размера хватит для хранения всех данных: char str[600]; char name[100]; char addr[200] ; char work[200]; Кажется абсурдным, что вы когда-либо захотите ввести имя длиной более 100 символов, поэтому этих пределов, наверняка, будет достаточно, особенно если вы пишете про- грамму для своих целей. Но, конечно, любой из таких пределов может быть превышен, и если вы пишете про- граммы для большого числа других людей, было бы разумно предположить, что пользо- ватели начнут проверять каждый предел, который смогут, в какой-то момент времени. (Я адресуюсь к этой проблеме в упражнении 7.1.1.) Другая часть примера, которая является новой, - это использование функции getline: cin.getline(name, 99);
ГЛАВА 7, Строки: разбор текста 177 В данном вызове функции, между прочим, используется внутренняя точка (.). Это не опечатка. Я остановлюсь более подробно на этом синтаксисе в следующем разделе. Пока просто запомните, что нужно включить точку. Функция get line получает всю введенную строку: все введенные символы до того момента, когда пользователь нажал клавишу ([Enter]. Первый аргумент (в данном случае, переменная name) определяет расположение, куда будут скопированы символы. Второй аргумент определяет максимальное количество символов для копирования; это значение никогда не должно превышать результат выражения N-1, где N - это число байт, выде- ленных для строки. После того как программа сохранила введенный текст в трех строках - name, addr и work - она строит строку. Первой вызывается функция strcpy, которая копирует стро- ковые данные в начало строки str. (Вызвав функцию strcat, в данном случае мы не получим правильные результаты, пока не будем знать, что первый байт’строки, str был нулевым - здесь это является небезопасным предположением.) strcpy(str, "\nMy name is "); Символы «\п» в языке C++ являются управляющей последовательностью (escape se- quence): это означает, что они не используются буквально, а представляют специальный символ. В данном случае символы «\п» обозначают символ разделителя строки. Программа строит оставшуюся часть строки, повторно вызывая функцию strcat. strcat(str, strcat(str, strcat(str, strcat(str, strcat(str, strcat(str, name); ", I live at”); addr); ",\nand I work at work); Упражнения Упражнение 7.1.1. Перепишите пример таким образом, чтобы было невозможно превы- сить пределы строки str. Например, необходимо заменить инструкцию strcat(str, addr); инструкцией strncat(str, addr, 599 - strlen(str)); Упражнение 7.1.2. После завершения упражнения 7.1.1, протестируйте его, поэкспери- ментировав с различными ограничениями строки str. Будет проще, если вы замените число 600 символической константой STRMAX, разместив в начале программы следую- щую директиву #def ine. Во время предварительной обработки эта директива сообщит компилятору заменить все вхождения константы STRMAX в исходном коде указанным текстом (599). #define STRMAX 599
178 C++ без страха Затем можно использовать выражение STRMAX+1 для объявления длины строки str: char str[STRMAX+1]; после чего использовать символическую константу STRMAX для определения того, сколько байт копировать; strncpy(str, "\nMy name is ", STRMAX); strncat(str, name, STRMAX - strlen(str)); Красота данного подхода заключается в том, что если понадобится изменить макси- мальный размер строки, нужно будет изменить всего лишь одну строку кода (строку, содержащую директиву ^define), после чего скомпилировать программу заново. Вставка Что насчет управляющих последовательностей? Управляющие последовательности могут придавать коду несколько странный вид, если вы не привыкли использовать их. Рассмотрим следующую инструкцию: cout << "\nand I live at"; Результат выполнения инструкции такой же, как и для следующей инструкции: cout << endl « "and I live at"; Ключом к пониманию странно выглядящей строки, как например «\nand», является приведенное правило: В исходном коде на языке C++, когда компилятор встречает обратную косую черту (\), следующий символ интерпретируется как имеющий специальное значение. Помимо последовательности «\п», представляющей разделитель строки, другие управляющие последовательности включают в себя «\t» (табуляция) и «\Ь» (возврат). Теперь, если у вас пытливый ум, вы’можете спросить: как же напечатать обратную косую черту? Ответ прост. Две обратные косые черты, идущие подряд (\\), представляют одну об- ратную косую черту. Например, рассмотрим следующую инструкцию: cout « "\\nand I live at"; В результате будет напечатан следующий текст: \nand I live at
ГЛАВА 7. Строки: разбор текста 179 Чтение введенных строк До настоящего времени я трактовал ввод данных в упрощенном виде. В предыдущих примерах я предполагал, что пользователь печатает число - например «15» - и это зна- чение непосредственно вводится в программу. На самом деле, происходит больше дей- ствий, чем я описал. Все данные, введенные с клавиатуры, первоначально являются текстовыми данными, то есть кодами ASCII. Поэтому если вы являетесь пользователем и вводите с клавиатуры цифры «1» и «5», то первое, что происходит - эти символы помещаются во входной поток. Входной поток: Фактические данные: • • 32 49 53 32 • • Код ASCII для: {sp) ,r ,g, {sp) (пробел) (пробел) ' Объект cin, который был использован для получения числа, анализирует этот введен- ный текст и генерирует одно целочисленное значение: в данном случае значение 15. Это число присваивается целочисленной переменной инструкцией наподобие следующей: cin >> п; Если тип переменной п был другим (скажем, она имела тип double), было бы исполь- зовано другое преобразование. Формат с плавающей точкой требует получения совершенно другого типа значения. Обычно объект cin выполняет все это сам. В следующих нескольких разделах рассматриваются способы обхода процесса, который используется в объекте cin, чтобы получить больше контроля над интерпретацией вво- да. Для выполнения этого с вашей стороны требуется немного больше работы, однако это обеспечит большую гибкость во взаимодействии с пользователем. В последнем разделе представлена функция getline, которая имеет немного странно выглядящий синтаксис: cin.getline(name, 99); Это наш первый взгляд на функции-члены (member functions). Точка (.) необходима, чтобы показать, что функция getline является членом объекта cin. Вероятно, здесь используется некоторая новая терминология. Гораздо более подробно про объекты я начну объяснять, начиная с главы 10. В настоя- щее время, считайте, что объект - это структура данных, имеющая встроенные знания о том, как выполнять определенные действия. Способом, с помощью которого можно об- ратиться к возможностям объекта, является вызов функции-члена: объект,функция(аргументы) Вот как можно рассматривать данный синтаксис: объект - это то, к чему применяется функция - в данном случае объект cin. Функцией в данном случае является функция getline. Существуют другие объекты, поддерживающие функцию getline, однако я
180 C++ без страха познакомлю вас с ними позднее. (Кстати, запомните, что объекты файлового ввода, ко- торые будут представлены в главе 8, также поддерживают данную функцию.) Вызов функции cin.getline является альтернативой получения введенных данных, ис- пользуя потоковый оператор (»): cin » var; Мы уже видели, что такой вид инструкций используется для получения данных типа int и double. Можно ли использовать такие инструкции для строк? Да. cin » name; Проблема с этой инструкцией заключается в том, что она не делает то, чего мы ожидаем. Вместо того, чтобы получить всю введенную строку - то есть все данные, которые поль- зователь ввел до нажатия клавиши piter], она получает данные до первого разделителя («разделитель» - это программистский термин, обозначающий пробел, табуляцию или разделитель строки). Таким образом, для данной введенной строки: Niles Cavendish результатом выражения «cin » пате» будет являться копирование символов «Niles» в строковую переменную name, в то время как символы «Cavendish» будут оставаться в входном потоке. (Они будут оставаться там до тех пор, пока не будут извлечены сле- дующей операцией ввода.) Общий процесс работы объекта cin следующий: ✓ Язык C++ хранит все введенные данные в скрытом строковом буфере, пока пользова- тель не нажмет клавишу j|Enterj. Если программа нуждается во входных, данных, она «сидит и ждет». ✓ Как только входные данные становятся доступными, потоковый оператор (») счи- тывает за один раз не более одной группы символов, завершаемых разделителем. Ос- тавшиеся неиспользованные данные остаются в буфере до следующего использова- ния оператора ». Итак, допустим, пользователь ввел следующую строку, после чего нажал клавишу j|Enter|. 50 3.141592 Joe Bloe Это работает отлично в том случае, когда вы ожидаете ввода двух чисел и двух строк, разделенных пробелами. Вот инструкция, которая бы успешно прочитала введенные данные: cin » n » pi » first_name » last_name; Однако в общем случае использование потокового оператора ограничивает вас в управ- лении. Сам я избегаю его использования, за исключением простых тестовых программ. Одним из ограничений этого оператора является то, что он не позволяет установить зна- чение по умолчанию. Предположим, вы выводите подсказку для ввода числа: cout « "Enter number: cin » n;
ГЛАВА 7. Строки: разбор текста 181 Если пользователь нажмет клавишу {(Enterl, не напечатав ничего, ничего не случится. Компьютер просто останется на том же месте, продолжая ожидать, что пользователь введет число и нажмет клавишу [[Enter). Если пользователь продолжит нажимать клавишу [[Enter], программа будет по-прежнему ждать, как непонятливый ребенок. Что касается меня, думаю, было бы гораздо лучше реализовать программную поддержку поведения, подразумеваемого следующей подсказкой: Enter a number (or press ENTER to specify 0): Вы не находите, что было бы удобно иметь значение 0 (или любое другое значение, ко- торое вы выберете) в качестве значения по умолчанию? Но как реализовать такое пове- дение? Следующий пример демонстрирует, как это можно сделать. Если вы всегда используете функции getline, то можете обнаружить, что дальнейшие операции с использованием потокового оператора ввода (») ра- ботают неправильно. Это потому, что функция getline и оператор потоко- вого ввода делают различные предположения относительно того, когда «по- глощается» символ разделителя строки. Хорошим стилем будет последова- тельно придерживаться либо первого, либо второго подхода. Пример 7.2. Получение числа Следующая программа получает числа и печатает их квадратные корни до тех пор, пока пользователь не напечатает «0» или не нажмет клавишу [[Enter] сразу после подсказки. Листинг 7.2. get num.cpp ttinclude <iostream> ttinclude <string.h> ttinclude <math.h> ttinclude <stdlib.h> using namespace std; double get_number(); int main() { double x; for (;;) { cout « "Enter a number (press ENTER to exit): x = get_number(); if (x == 0.0) break; cout « "The square root of x is: " << sqrt(x); cout « endl; } return 0;
182 C++ без страха // Функция получения числа. // Получает число, введенное пользователем, при этом принимает // только первое введенное число. Если пользователь нажал // клавишу ENTER, не вводя число, возвращает значение по // умолчанию, равное 0.0. // double get_number() { char s[100]; cin.getline(s, 99); if (strlen(s) == 0) return 0.0; return atof(s) ; J Вы можете использовать этот же код функции (get_number) во всех ваших програм- мах в качестве лучшего способа получения введенных чисел. Как это работает Программа начинается с включения файлов string.h и math.h, которые содержат инфор- мацию о типе для строковых и математических функций соответственно. (Последний тип необходим для вычисления квадратных корней.) Также заранее объявляется функ- ция get_number: #include <iostream> #include <string.h> #include <math.h> #include <stdlib.h> using namespace std; double get_number(); To, что делает функция main, к этому времени должно быть вам уже знакомо. Она вы- полняет бесконечный цикл, который завершается, когда функция get_number возвра- щает значение 0. Когда введено любое значение, отличное от нуля, программа вычисля- ет квадратный корень и печатает результаты. for (;;) { cout « "Enter a number (press ENTER to exit): "; x= get_number(); if (x == 0.0) break; cout « "The square root of x is: " << sqrt(x); cout « endl; } Новым здесь, в основном, является сама функция get_number. Когда эта функция вы- зывает функцию getline, то получает ни больше, ни меньше всей введенной строки (хотя все символы после первых 99 отбрасываются, поскольку вторым аргументом, пе-
ГЛАВА 7. Строки: разбор текста 183 редаваемым в функцию, в данном случае является значение 99). Если пользователь на- жмет клавишу |[Ёп)ёг| сразу после подсказки ввода, функция getline вернет пустую строку - то есть строку, длина которой равняется 0. double get_number() { char s[100] ; cin.getline(s, 99); if (strlen(s) == 0) return 0.0;. return atof(s); ) Как только введенная строка сохранена в локальной строковой переменной S, вернуть значение 0 в случае, если строка пустая, является тривиальной задачей. if (strlen(s) == 0) return 0.0; Константа, 0.0 равна значению 0, но сохраняется в формате double, а не в целочислен- ном формате. (Помните, что любая константа, содержащая десятичную точку, рассмат- ривается языком C++ как число с плавающей точкой.) Сама функция также имеет воз- вращаемый тип double, поэтому использование значения 0.0 избавляет программу от выполнения ненужного преобразования. Если длина строки s не равна значению 0, значит, в строке находятся данные, которые необходимо преобразовать. Поскольку мы не полагаемся на потоковый оператор (»), функция get_number должна взять ответственность за интерпретацию данных на себя. Следовательно, она должна проверить считанные символы - коды ASCII, посланные с клавиатуры, и сгенерировать значение типа double. К счастью, стандартная библиотека языка C++ поставляет удобную функцию - atof - предназначенную для выполнения именно этого, и мы можем ее использовать здесь. Функция atof принимает введенную строку и генерирует значение с плавающей точкой (типа double) точно так же, как ее кузина, функция atoi, генерирует значение типа int. return atof(s); Эта функция определена в файле stdlib.h (вот почему пришлось включить этот файл в начале программы). Функция имеет родственную функцию atoi, которая делает то же самое для целых чисел: return atoi(s); // Возвращает значение типа int. Упражнения Упражнение 7.2.1. Перепишите пример 7.2 таким образом, чтобы в нем принимались только целые числа. (Подсказка: вам нужно заменить все непосредственно задейство- ванные типы с формата double на формат int - включая константы.)
184 C++ без страха Пример 7.3. Преобразование в верхний регистр Позже в этой главе, в примере 7.4, я собираюсь выполнить некоторую необычную обра- ботку строки, обращаясь к отдельным символам. Однако сначала я продемонстрирую простой пример, в котором происходит обращение к отдельным символам. Листинг 7.3. иррег.срр ttinclude <iostream> ttinclude <string.h> ttinclude <ctype.h> using namespace std; void convert_to_upper(char *s) ; int main() { char s[100]; cout « "Enter string to convert and press ENTER: cin.getline(s, 99); convert_to_upper(s); cout << "The converted string is:" « endl; cout « s; return 0; } void convert_to_upper(char *s) { , int i; int length = strlen(s); for (i = 0; i < length; i++) s[i] = toupper(s[i]); 2 Как это работает Основное назначение этого примера - продемонстрировать, как можно обрабатывать отдельные. символы строки. Чтобы передать строку в функцию, передается ее адрес. Конечно, чтобы сделать это, нужно просто присвоить строке имя. (Это стандартная про- цедура для передачи массива любого вида.) convert_to_upper(s); Функция использует передаваемый аргумент - который как-никак является адресом - для индексирования данных в строке.
ГЛАВА 7. Строки: разбор текста 185 void convert_to_upper(char *s) { int i; int length = strlen(s); for (i = 0; i < length; i++) s [ i] = toupper(s[i]) ; } В этом примере представлена новая функция toupper. Эта функция вместе с рядом других функций объявлена во включаемом файле ctype.h. Две функции toupper и to lower оперируют с отдельными символами: Табл. 7.2. Функции toupper и tolower. Функция Описание toupper(с) Если значение с - буква нижнего регистра, возвращает ее эквивалент в верхнем регистре; иначе возвращает значение с, как есть. tolower(с) Если значение с - буква верхнего регистра, возвращает ее эквивалент в нижнем регистре; иначе возвращает значение с, как есть. Следовательно, следующая инструкция преобразует символ в верхний регистр (если это буква в нижнем регистре) и заменяет исходный символ результатом. s[i] = toupper(s[i] ) ; Упражнения Упражнение 7.3.1. Напишите программу, похожую на программу примера 7.3, но пре- образующую все символы введенной строки в нижний регистр. (Подсказка: используйте функцию tolower библиотеки языка C++.) Используйте имя функции «соп- vert_to_lower» вместо имени «convert_to_upper». Упражнение 7.3.2. Перепишите пример 7.3 таким образом, чтобы в нем использовалось обращение к прямому указателю - которое было описано в конце главы б - вместо ин- дексирования массива. Если достигнут конец строки, значение текущего символа равня- ется завершающему нулю, поэтому можно выполнять проверку на конец строки, ис- пользуя выражение *р == ‘\0’. В качестве условия можно использовать само значение выражения *р, поскольку оно является ненулевым в том случае, когда не указывает на нулевое (или пустое) значение. while (*р++) // Выполнить какие-то действия... Отдельные символы в сравнении со строками Язык C++ устанавливает большое различие между отдельными символами и строками. Многое зависит от того, используете вы одиночные или двойные кавычки.. Выражение ‘А’ представляет отдельный символ. Во время компиляции язык C++ заменя- ет это выражение кодом ASCII для буквы ‘А’, который равен значению 65 (его десятич- ное значение).
186 C++ без страха С другой стороны, выражение “А” представляет строку, длина которой равняется 1. Когда язык C++ «видит» такое выражение, он размещает в области данных два байта: ✓ Код ASCII для буквы ‘А’, как это было сделано выше. ✓ Завершающий нулевой байт. Эта строка занимает два байта: один для значения ‘А’ и второй - для завершающего нуля. После этого компилятор языка C++ заменяет выражение “А” адресом этого массива, со- стоящего из двух байт. Выражения ‘А’ и “А” фундаментально отличаются, поскольку одно из них преобразуется в целочисленное значение, в то время как второе представля- ет строку и, следовательно, преобразуется в адрес. Может показаться, что здесь нужно многое понять, однако просто сделайте для себя правилом уделять пристальное внимание кавычкам. Следующий код является хорошим примером, как они могут правильно сочетаться: char s[] = "А"; if (s[OJ == 'А') cout << "The first letter of the string is 'A'. В данном случае получается правильный результат. Но сравнение наподобие следующе- го приводит к абсолютно неправильным результатам или вообще может не быть разре- шено компилятором. if (s[OJ == "А") // НЕПРАВИЛЬНО! / / . . . В этом фрагменте кода производится попытка сравнить элемент строкового массива s с адресным выражением “А”. Важными правилами для вышеописанного являются сле- дующие: Выражения в одиночных кавычках (например ‘А’) рассматриваются как це- ♦♦♦ лочисленные значения после преобразования в коды ASCII. Они не являют- ся массивами. Выражения в двойных кавычках (например “А”) являются массивами типа * char и, как таковые, преобразуются в адреса. Это отличие важно для следующего примера. Пример 7.4. Разбор введенного текста Завершающая возможность в получении введенного данных заключается в получении введенной строки целиком и разборе ее содержимого. В данном разделе описывается, как это сделать. Следующая программа получает введенную пользователем строку и распознает новое «поле» везде, где встречает запятую. Например, из следующей введен- ной строки Me, Myself, Joe Bloakes
ГЛАВА 7. Строки: разбор текста 187 программа извлечет строки «Ме», «Myself» и «Joe Bloakes». Затем она сохранит эти строки в массиве и напечатает его содержимое, чтобы вы могли убедиться, что про- грамма работает. Узнав, как использовать запятые в качестве разделителей полей, вы можете использо- вать любой вид разделителя, который пожелаете. Можно добиться похожих результатов, просто используя функцию strtok («string tokenizer» - разметчик строки), которую я коротко описываю в приложе- нии Е. Однако, хотя даже функция strtok отлично подходит для данного при- мера - и, следовательно, избавляет нас от написания изрядного количества кода - будет полезно узнать, как разбирать введенный текст своим способом. Впоследствии, если вам когда-либо придется разобрать строку способом, не поддерживаемым функцией strtok, вы сможете реализовать это. Листинг 7.4. lexi.cpp ttinclude <iostream> ttinclude <string.h> using namespace std; int get_a_string(char * *buffer, char *s, int start); char strs[10][100]; int main() { int i; int n; int pos = 0; char buffer[200]; cout << "Enter strings, separated by commas,"; cout « endl « "and press ENTER: "; cin.getline(buffer, 199); • for (i = 0; i < 10; i++) { pos = get_a_string(buffer, strs[i], pos); if (pos == -1) break; } if (i == 11) n — 10; else n = i ; cout << n << ” strings were read." « endl; for (int i = 0; i < n; i++) cout « strsfi] « endl; return 0;
188 C++ без страха // Функция получения строки. // Начиная с позиции "statt," прочитать следующую подстроку //из буфера в результирующую строку s. // Возвращает позицию первого непрочитанного символа; // возвращает значение -1, если нет символов для чтения. // int get_a_string(char *buffer, char *dest, int pos) { int i = pos, j = 0; // "Пропустить" начальную запятую и пробел(ы). while (bufferfi] = = ' , ' [| buffer[i] == 1 ') i + +; // Возвратить значение -1, если достигнут конец буфера. if (buffer[i] == 1\0') return -1; // Прочитать символы в результирующую строку, пока не // встретится запятая или символ конца строки. while (bufferfi] 1= && bufferfi] != '\0') dest[j++] = buffer[i++]; // Завершить результирующую строку и возвратить // позицию первого непрочитанного символа. dest[j] = 1 \ 0 ' ; return i; 2 Как это работает Приведенной программе необходимо выполнить ряд действий, чтобы избежать ошибок. Однако эти действия легко понять, если внимательно посмотреть на каждую часть. Первое, что делает программа после обычных директив ttinclude и объявлений, - это объявляет массив из 10 строк, каждая из которых может хранить до 100 байт: char strs[10][100]; Важно объявить массив именно таким способом. Следующее объявление, хотя и допус- кается компилятором (то есть не является синтаксически неправильным), будет работать неправильно. char *strs[10]; Это описание объявило бы массив из 10 указателей, каждый из которых имел бы тип char*. Проблема заключается в том, что такое объявление совсем не выделяет память
ГЛАВА 7. Строки: разбор текста , 189 для самих строковых данных. Мы видели, что такой тип объявления отлично работает, когда все строки проинициализированы: char *band[4] = {"John", "Paul", "George", "Ringo"}; Указатели занимают четыре байта, поэтому данное объявление выделяет 16 байт (4 ум- ножить на 4) для массива. Благодаря инициализациям справа, объявление также выделя- ет достаточное количество памяти для каждой из строк «John», «Paul» и так далее, включая завершающий ноль для каждой из них. Ни одна из строк не имеет пространства для расширения. А без инициализации для строковых данных память вообще не будет выделена. Но объявление переменной strs в качестве массива размерностью 10 на 100 резервирует 1000 байт - достаточно памяти для десяти строк, каждая из которых может хранить мак- симум 99 символов и плюс завершающий ноль. После получения строки ввода основной цикл функции main выполняет повторяющие- ся вызовы функции get_a_string - продолжая выполнять вызовы до тех пор, пока существует другая подстрока для чтения и было прочитано не более 10 подстрок. for (i = 0; i < 10; i++) { pos = get_a_string(buffer, strs[i], pos); if (pos == -1) break; } Первая инструкция в теле цикла выполняет действие по получению следующей под- строки и ее копированию в массив строк. Функция get_a_string начинает чтение символов с позиции, указанной значением переменной pos, которая проинициализиро- вана значением 0. ; pos = get_a_string(buffer, strs[i], pos); При каждом вызове функции get_a_string возвращается позиция первого непрочи- танного символа. Таким образом, после первого вызова функции, строка выглядит сле- дующим образом: м е ) м У S е 1 f » а п d 1 \0 t pos Значение переменной pos в данном примере, следовательно, равняется 2. (Помните, что ин- дексы всех массивов, включая строки, отсчитываются с нуля.) Это значение возвращается обратно переменной pos, чтобы подготовиться к следующему вызову функции get_ a_s tring. Это необходимо для того, чтобы функция начала читать символы с индекса 2. После этой операции чтения символов значение переменной pos будет установлено в 10. pos
190 C++ без страха Этот процесс продолжается до тех пор, пока не останется данных для чтения. Если функция возвратит значение -1, значит, больше нет введенных данных; цикл завершится раньше времени: if (pos == -1). break; Что касается определения функции get__a_string, вот поручение для нее: ✓ Возвратить значение -1, если для чтения больше ничего не осталось; ✓ В противном случае копировать символы в указанную результирующую строку и возвратить позицию первого непрочитанного символа. Функции необходимо предусмотреть несколько возможностей. После первого вызова функции новая позиция обычно указывает на запятую. Это происходит потому, что функция останавливается, когда видит запятую, и не читает ее. Следовательно, любой определенный вызов функции может иметь необходимость про- пустить начальную запятую и ведущие пробелы, если они есть. Эти символы являются «поглощенными» (или «съеденными», если это вам нравится больше), то есть это озна- чает, что они были прочитаны и проигнорированы. Если после попытки прочитать запя- тые и пробелы функция находится в конце строки, она немедленно возвращает значение -1. // "Пропустить" начальную запятую и пробел(ы). while (buffer[i] == || buffer[i] == ' ') i + +; // Возвратить значение -1, если достигнут конец буфера. if (buffer[i] == '\0') return -1; Следующий шаг прост: скопировать символы в результирующую строку-аргумент s. Помните, что значение переменной j является позицией в результирующей строке s, ку- да считываются символы; поэтому переменная j инициализируется значением 0. Переменная i инициализируется значением указателя текущей позиции во входной строке. // Прочитать символы .в результирующую строку, пока не // встретится запятая или символ конца строки. while (buffer[i] != ',1 && buffer[i] 1= '\0') dest[j++] = buffer[i++]; После выполнения последней итерации данного цикла while значение переменной i указывает на первый непрочитанный символ (либо запятую или завершающий ноль), а значение переменной j указывает на конец результирующей строки. После этого все, что остается сделать, - это завершить результирующую строку и возвратить значение пере- менной i: dest [ j ] = ' \ 0.' ; return i;
ГЛАВА 7. Строки: разбор текста 191 Упражнения Упражнение 7.4.1. Перепишите пример 7.4 таким образом, чтобы в качестве разделите- лей использовались пробелы; то есть программа начинает чтение новой подстроки после каждой серии одного или более пробелов. Это простое упражнение, если вы поняли пример, поскольку вам необходимо изменить всего лишь две строки в функции get_a_string; остальная часть программы остается прежней. (Подсказка: одно из этих изменений заключается в изменении условий, по которым функция завершает чтение символов в подстроку.) Упражнение 7.4.2. Перепишите пример таким образом, чтобы функция get__a_string использовала только два аргумента: начальный адрес, с которого необходимо читать символы во входной строке, и (как и раньше) адрес результирующей строки. Функция должна возвращать адрес первого непрочитанного символа. Или, если никакие строки не были прочитаны, она должна возвратить нулевой указатель, как показано ниже: return (char*) 0; Эта версия примера, по существу, использует ту же логику, но с использованием на- чального адреса - вместо использования комбинации адреса массива и индекса началь- ной позиции, что уменьшает число аргументов. Функция get_a_string должна иметь следующее объявление: char *get_a_string(char *start_addr, char *dest); Само определение функции может использовать либо индексирование массива, либо методику обращения к прямому указателю, описанную в главе 6. Новый класс String языка C++ Новейшие версии языка C++ поддерживают новый, более простой в использовании строковый тип. Поскольку этот тип является расширенной версией типа, определенного в стандартной библиотеке, он, говоря техническим языком, является классом. К понятию класса мы еще вернемся - и вы там еще хлебнёте лиха - в главе 10. Класс - это тип данных, определяемый в программе или в библиотеке. В языке C++ такие типы выглядят и ведут себя просто как встроенные типы * данных (int, double и так далее) и могут иметь операции, определенные для них. В оставшейся части главы я буду ссылаться на этот новый тип (или, точнее, этот класс), просто как на тип string. Тип string скрывает большую часть деталей обработки символьных данных. Старый строковый тип не прячет ничего из этих деталей; это просто указатель на массив типа char. Для удобства я ссылаюсь на данные строки в старом стиле, как на строки типа char*. Существует две причины, по которым я отложил знакомство с этим более простым в использовании классом string вплоть до текущего момента.
192 C++ без страха ✓ Не все компиляторы языка C++ поставляются с поддержкой нового класса string на уровне библиотеки. Если в вашем компиляторе отсутствует такая поддержка, вы должны знать, как работать со строками в старом стиле типа char*. (Примечание: в главе 15 объясняется, как определить свой собственный класс String, имеющий множество возможностей, представленных здесь.) ✓ Невозможно избежать использования строк типа char* совсем. Строковые констан- ты (то есть строковые литералы) все еще имеют тип char*. Невозможно выполнить любую из расширенных операций типа string над строкой типа char* без исполь- зования переменной типа string. Имея эти объяснения в виду, давайте перейдем к рассмотрению класса string. Включение поддержки класса string Для использования нового типа string первое, что необходимо сделать, - это вклю- чить его поддержку, воспользовавшись директивой ttinclude <string>. Это не та директива, которая включает поддержку строк в библиотеке функций. ttinclude <string> // Поддерживает новый класс string В отличие от этого, следующая директива включает поддержку функций, работающих со строками в старом стиле, типа char*: ttinclude <string.h> // Поддерживает функции старого стиля: // strcmp, strlen, и другие. Какое различие вносит расширение .h, правда? Между прочим, можно включить под- держку для обеих библиотек одновременно, если пожелаете. Вероятно, вы будете делать это редко, поскольку класс string обычно делает поддержку функций для работы со строками в старом стиле ненужной. ttinclude <string> // Поддерживает новый класс string ttinclude <string.h> // Поддерживает функции // в старом стиле Как и в случае с объектами cin и cout, имя string должно быть квалифицировано префиксом std, пока вы не включите следующую инструкцию, распознающую все имена в пространстве имен std: using namespace std; Можно и не включать эту инструкцию, но в таком случае к новому типу придется обра- щаться как std: : string. (Подобным образом, к объектам cin и cout пришлось бы обращаться как std: : cin и std: : cout.) Объявление и инициализация переменных типа string После того как была включена поддержка класса string, его легко использовать для объявления переменных. string а, Ь, с;
ГЛАВА 1. Строки: разбор текста 193 Эта инструкция создает три переменные, имеющие тип string. Уже сейчас вам должно показаться, что это проще, чем использование строк в стиле языка С. Можно объявлять эти переменные, не заботясь о том, сколько для них потребуется места. Строки можно проинициализировать рядом способов. Вот как можно проинициализиро- вать их во время объявления: string a("Here is a string."), b("Here is another."); Можно также использовать оператор присваивания (=) для задания их значений. string а, Ь; а = "Here is a string." b = "Here is another." Также можно объединить шаги объявления и присваивания. Результат будет таким же, как и при инициализации. string а = "Here is a string. "; Обратите внимание, что для инициализации этих переменных необходимо использовать строковые константы («Неге is a string.», «Неге is another.»). Как я,упомянул ранее, стро- ковые константы являются строками в старом стиле, типа char*. Их преобразование осуществляется при присваивании переменным, имеющим тип string. Работа с переменными типа string Класс string работает так, как вы, возможно, ожидаете - особенно если вы работали с какой-либо версией языка Basic. В отличие от строк в старом стиле, типа char*, для копирования или сравнения содержимого не нужно вызывать функцию. Например, допустим, что у нас есть следующие строки: string cat("Persran"); string dog("Dane"); Этим переменным можно свободно присвоить новые строковые данные, не заботясь о проблемах с объемом памяти. Строка dog, например, при необходимости автоматически увеличится. dog = "Persian"; Сравнить содержимое строк можно, используя оператор проверки на равенство (==). В случае с классом string это сравнение делает то, что вы ожидаете; значение условия равняется true, если содержимое строк одинаково. (Чтобы выполнить подобное сравне- ние для двух строк типа char*, необходимо вызвать функцию strcmp.) if (cat == dog) // Значение этого условия теперь // равняется TRUE cout « "cat and dog have the same name."; Для копирования значения одной переменной типа string в другую используйте опе- ратор присваивания (=). Для класса string операция присваивания выполняется так, как вы ожидаете: копирует содержимое одной строки в другую. 7 - 6248
194 C++ без страха string country = dog; // Копирует содержимое строки.. Как и в языке Basic, конкатенировать строки можно с использованием оператора сложения (+): string str = а + b; В операцию такого вида можно даже вставлять строковые константы: string str = а + " " + b; Однако следующее выражение скомпилировано не будет. string str = "The dog " + "is my friend."; // ОШИБКА! Проблема в том, что хотя знак сложения (+) поддерживается в качестве оператора кон- катенации двух переменных типа string или переменной типа string и строки типа char*, однако он не поддерживается для двух строк типа char*. Запомните, что использование класса string не изменяет природу всех строк в языке C++; создается новый класс, имеющий расширенные операции, определенные для него. Константы остаются простыми массивами типа char, завершаемыми нулем. Ввод и вывод Переменные типа string работают без проблем с объектами cin и cout, как вы и ожидаете. string prompt = "Enter your name: "; string name; cout « prompt; cin » name; Использование объекта cin в этом контексте имеет такой же недостаток, как и в случае со строками в старом стиле, типа char*: с клавиатуры возвращаются символы до пер- вого символа разделителя (например, пробела или табуляции). Для помещения всей введенной строки в переменную типа string можно использовать функцию getline. Эта версия является глобальной, а не функцией-членом. getline(cin, name); В отличие от предыдущей версии функции getline, эта версия не требует от вас ввода максимального количества символов для чтения. В максимальном количестве символов нет никакой необходимости, поскольку размер строки будет в точности таким, какой нужен для хранения любой введенной строки. Пример 7.5. Построение строк с использованием типа string Этот пример выполняет те же действия, что и пример 7.1, за исключением того, что в данном примере используются переменные типа string. Листинг 7.5. buildstr2.cpp ttinclude <iostream> ttinclude <string>
ГЛАВА 1. Строки: разбор текста 195 using namespace std; int main() { string str, name, addr, work; // Получить от пользователя три строки, cout << "Enter name and press ENTER: "; getline(cin, name); cout « "Enter address and press ENTER: "; getlinefcin, addr); cout « "Enter workplace and press ENTER: ", getline(cin, work); // Построить результирующую строку и напечатать ее. str = "\nMy name is " + name + ", " + "I live at " + addr + ",\nand I work at " + work + ".\n"; cout « str; return 0; }. Как это работает Во всяком случае, данную версию программы значительно проще написать, чем версию в примере 7.1. Первое отличие заключается в директиве include, которая должна ссы- латься на <string>, а не на <string.h>. ttinclude <string> using namespace std; Инструкция using namespace, как обычно, позволяет вам обращаться к символам пространства имен std (например объектам cin, cout, а также к классу string) без использования префикса std. В остальном все просто. В этой версии программы объявляется четыре строковых пере- менных, не заботясь о том, сколько памяти необходимо зарезервировать. Помните, что при необходимости эти строки автоматически растут или уменьшаются в объеме памяти, string str, name, addr, work; Подобным образом программа вызывает функцию getline, не указывая максимальное количество символов для чтения. cout << "Enter name and press ENTER: "; getline(cin, name); cout « "Enter address and press ENTER: "; getline(cin, addr); 7*
196 C++без страха cout << "Enter workplace and press ENTER: ", getline(cin, work); И, наконец, программа строит строку. Это просто сделать, поскольку для класса string поддерживается оператор сложения (+), предоставляя краткий способ для представления конкатенации строк. str = "\nMy name is " + name + ", " + "I live at " + addr + ",\nand I work at " + work + ".\n"; В конце концов, сконструированная строка печатается точно так же, как и в другой версии, cout << str; Упражнения Упражнение 7.5.1. Получите от пользователя три порции информации: имя собаки, ее породу и возраст. Затем напечатайте предложение, использующее эту информацию. Упражнение 7.5.2. Перепишите одну из программ «Сдающий карты» главы 5, используя класс string. (Подсказка: чтобы объявить массив типа string, рассматривайте его просто как массив любого другого фундаментального типа данных. Вам придется выде- лить достаточно места для членов массива, однако каждая строка рассматривается в ка- честве отдельной единицы. Никакой синтаксис указателей не требуется. Рассматривайте массив типа string как простой массив типа int или double.) Другие операции над типом string Новый тип string не был бы полезным, если бы нельзя было получить доступ к со- держимому строки. К счастью, можно индексировать отдельные символы, используя такой же синтаксис, который использовался для доступа к символам строк типа char*. строка[индекс] Например, следующий код печатает отдельные символы строки, по одному на строчке. (Вероятно, в реальной программе вы не будете делать это очень часто, но этот код де- монстрирует возможности данной особенности.) ttinclude <strihg> using namespace std; / / . . . string dog = "Mac"; for (int i = 0; i < dog.size(); i++) cout << dog[i] << endl; После запуска, этот код напечатает: М а с
ГЛАВА 7. Строки: разбор текста 197 Как и в случае со строками типа char* (или, если на то пошло, с любым массивом языка C++), переменные типа string используют индексацию с отсчетом от нуля. Вот поче- му начальное значение переменной i устанавливается в 0. Условие цикла зависит от длины строки. При использовании строки типа char* вы по- лучаете эту длину, вызывая функцию strlen: char doggy[] = "Sam"; int length = strlen(doggy); При использовании типа string вы получаете длину, вызывая функцию-член size: string dog = "Napoleon"; int length = dog.size(); Поскольку тип string является классом языка C++, он разработан для использования с функциями-членами, а не для вызовов стандартных функций. Синтаксически помимо того, что функции-члены используют синтаксис объект.функция, существует еще не- много нового относительно их вызова. Синтаксис для получения длины строки прост. строка.size() Используя оператор индексирования ([ ]) и функцию-член size, над переменными типа string можно выполнять множество операций. Например, следующий код преобразует все символы строки в верхний регистр. ttinclude <string> ttinclude <ctype.h> using namespace std; / / • - - string name = "Grandmaster Trash"; for (int i = 0; i < name.sizef); i++) name[i] = toupper(name[i]); С технической точки зрения, переменная типа string - это простой объект, а не массив. Причина, по которой переменная типа s tring поддерживает индек- сацию, - это то, что квадратные скобки ([]) определены в качестве оператора для типа. В результате индексация имитирована. Не предполагайте, что во всех контекстах переменную типа s tring можно рассматривать так же, как и массив; I в частности, имя переменной типа string не равняется адресу первого символа. Помимо функции size, класс string поддерживает ряд других полезных функций- членов. Вот небольшой список некоторых, наиболее часто используемых функций. Табл. 7.3. Функции-члены класса string. Функция (с синтаксисом) Действие string.assign(string2, Получает подстроку строки string!, начиная с по- start, num) зиции start и длиной пит символов; затем копирует эти данные в строку string.
198 C++ без страха Функция (с синтаксисом) Действие s tring. empty () Возвращает значение true, если строка string имеет нулевую длину, иначе возвращает значение false. string.find(substring, start) Ищет первое совпадение строки substring в строке string', поиск начинается с позиции start. string.insert(start, substring) Вставляет содержимое строки substring в строку string', вставка производится в позиции start. string.replace(start, num, newstring)' Заменяет подстроку в строке string - начиная с по- зиции start и длиной пит символов - содержимым строки newstring. s tring. swap (s tring2) Меняет местами содержимое строк string и string2. Резюме Вот основные моменты главы 7: ✓ Текстовые символы хранятся в компьютере в соответствии с их кодами ASCII. Например, строка «Hello!» представляется значениями байт 72, 101, 108, 108, 111, 33 и 0 (для завершающего нуля). ✓ Любая строка в языках С и C++ должна иметь завершающий ноль - значение байта 0. Это позволяет функциям, обрабатывающим строки, определять, где заканчивается строка. Когда вы объявляете строковый литерал, например «Hello!», язык C++ ав- томатически выделяет место для этого завершающего нуля, как и для остальных символов. ✓ Текущая длина строки (определяемая поиском завершающего нуля) не то же самое, что общий объем памяти, зарезервированный для строки. Следующее объявление ре- зервирует 10 байт памяти для строки str, но инициализирует ее таким образом, что ее текущая длина равняется только шести. В результате строка будет иметь три неис- пользованных байта, что позднее при необходимости позволит ей увеличиться. char str[10] = "Hello!"; ✓ Функции библиотеки, как например strcpy (копирование строки) и strcat (кон- катенация строки), могут изменять длину существующей строки. При выполнении этих операций важно, чтобы строки имели достаточно зарезервированной памяти для размещения строки новой длины. ✓ Функция strlen возвращает текущую длину строки. ✓ Файл string.h включается для предоставления информации о типе функций, рабо- тающих со строками. ttinclude <string.h> ✓ Если вы попытаетесь увеличить длину строки, не имея достаточно зарезервирован- ной памяти, вы перезапишете область данных другой переменной, тем самым созда- вая трудно обнаруживаемые ошибки.
ГЛАВА 1. Строки: разбор текста 199 char str(] = "Hello!"; strcat(str, " So happy to see you."); // ОШИБКА!!!! ✓ Чтобы убедиться, что вы не копируете в строку слишком много символов, можно использовать функции strncat и strncpy. char str[100]; strncpy(str, s2, 99); strncat(str, s2, 99 - strlen(str)); ✓ Потоковый оператор (»), используемый вместе с объектом cin, обеспечивает толь- ко ограниченный контроль над введенными данными. При его использовании для от- правки данных в строку по адресу он считывает символы только до первого раздели- теля (пробела, табуляции или разделителя строки). ✓ Чтобы получить всю введенную строку, используйте функцию cin.getline. Второй аргумент определяет максимальное количество символов для копирования в строку (не считая завершающий нуль). cin.getline(input_string, max); ✓ Такое выражение, как ‘А’, представляет одно целочисленное значение (после преоб- разования в код ASCII); такое выражение, как “А”, представляет массив типа char и, следовательно, преобразуется в адрес. ✓ Новейшие версии языка C++ поддерживают новый класс string, который похож на строковый тип char*, но проще в использовании. Можно объявлять переменные ти- па string, не заботясь о том, сколько для них потребуется памяти. Чтобы включить поддержку этого типа, используйте директиву # include <string>. ttinclude <string> using namespace std; string str; ✓ Присваивание переменной типа string приводит к копированию строковых данных (это похоже на использование функции strcpy). Оператор проверки на равенство (==) приводит к сравнению содержимого строк (похоже на использование функции st гетр). string dog = "Red Rover"; if (dog == "Red Rover") cout << "The strings have same contents."; ✓ Можно получить доступ к отдельным символам переменной типа string, используя квадратные скобки, как если бы это была просто переменная типа char*. Но помни- те, что переменные типа string не являются настоящими массивами. Функция-член size () возвращает текущую длину строковых данных. if (int i = 0; i < dog.sizeO; i++) cout << dog[i] << endl; ✓ Помните, что строковые константы имеют тип char*, в старом стиле. Операции оп- ределены для объектов типа char* и string, но не для двух объектов типа char*.
ГЛАВА В Файлы: электронное хранилище До сих пор программы в данной книге выполняли вычисления и печатали результаты. Это хорошее начало, однако вы только-только перенеслись в реальный мир. Большинство практических приложений (системы резервирования, системы с базами данных, электронные таблицы и даже игры) сохраняют и извлекают постоянную инфор- мацию. Это данные, которые сохраняются после завершения программы и даже после выключения компьютера. В отличие от этого, оперативная память («RAM» - ОЗУ), безусловно, является одним из видов запоминающих устройств, однако не для постоянных данных. Как только компь- ютер отключается, все данные в памяти теряются навсегда (вот почему у вас будет по- вод разозлиться, если, работая в программе Word, не выполняете достаточно часто со- хранение на диск.) Но даже если бы оперативная память была более постоянной, даже если бы она не обнулялась при выключении компьютера, оперативная память является слишком дорогим ресурсом, чтобы предназначаться для длительного хранения записей. Поэтому, если вам нужно хранилище для размещения данных, которые будут приме- няться в дальнейшем, вы будете использовать дисковые файлы. Введение в объекты файловых потоков Так как вы работали с объектами с in и cout, значит вы уже воспользовались объекта- ми. Теперь настало время представить еще несколько объектов. Язык C++ предоставляет классы объектов файловых потоков, поддерживающий такое же множество вызовов функций и операторов, как и у объектов cin и cout. (В действительно- сти они поддерживают расширенное множество.) На жаргоне объектно-ориентированного программирования мы бы сказали, что эти объекты поддерживают такой же интерфейс, как у объектов с in и с out. Термины файл и поток тесно связаны между собой. Потоком является все, откуда мож- но читать или куда можно записывать данные. Этот термин ассоциируется с представле- нием бесконечного потока данных, похожего на маленькую реку, и хотя не все потоки бесконечны, это полезный образ. Термин поток относится ко всем видам каналов ввода/вывода данных - к консоли так же, как и к дисковым файлам, - тогда как понятие файлового потока (file stream) отно- сится только к дисковым файлам. Объекты файловых потоков бывают нескольких типов: ✓ Объекты файлового ввода, которые могут быть использованы точно так же, как объект cin. Объекты файлового вывода, которые могут быть использованы точно так же, как объект cout.
ГЛАВА 8. Файлы: электронное хранилище 201 ✓ Объекты файлового ввода/вывода, реализующие возможности обоих типов объектов. (Такая функциональность требуется для доступа с произвольной выборкой, пред- ставленного в последних двух разделах данной главы.) Файловые потоки также могут использовать текстовый (text mode) или двоичный режим (binary mode). Я начну с текстового режима, поскольку он проще для понимания. Запись в текстовый файл включает в себя несколько простых шагов. Первый шаг заклю- чается во включении поддержки операций с файловым потоком, используя следующую директиву ttinclude: #include <fstream> Эта директива включает поддержку операций с файловым потоком. Второй шаг заключается в создании объекта файлового потока. Для удобства я выбрал имя «fout», но вы можете выбрать любое имя, которое пожелаете (например «MyStupidFile», «RoundFile» или любое другое). При создании этого объекта проини- циализируйте его, указав имя дискового файла, в который будет производиться запись. Здесь я указал выходной файл с именем output.txt. - ofstream fout ("output ..txt") ; // Открыть дисковый файл с // именем output.txt Объект имеет тип ofstream. При открытии файловых потоков можно использовать следующие типы: ✓ ofstream для потоков файлового вывода; ✓ if stream для потоков файлового ввода; ✓ fstream, общий файловый поток (которому при открытии необходимо указать, предназначен ли он для ввода, вывода или для обеих операций - подробнее об этом я расскажу позднее). После успешного создания объекта в него можно записывать точно так же, как вы запи- сываете в объект cout. Это является третьим и заключительным шагом. fout « "This is a line of text."; Например, можно создать следующую вариацию примера 1.2, выполняя запись в диско- вый файл с именем output.txt вместо консоли. #include <fstream> II... ofstream fout("output.txt"); // Открыть дисковый файл с // именем output.txt fout « "I am Blaxxon," « endl; fout « "the cosmic computer."; Вот как можно мысленно представить действие потокового оператора << при использо- вании с объектом файлового потока:
202 C++ без страха "I am Blaxxon" newline (разделитель строки) (disk file) (дисковый файл) fout « "I am Blaxxon" « endl; Но в отличие от объекта cout, объекты файловых потоков не являются уникальными. Можно иметь несколько объектов файловых потоков - по одному для каждого файла, с которыми вы желаете взаимодействовать. ofstream out_file_l("memo.txt") ; ofstream out_file_2("messages.txt"); Существует еще одно отличие между объектом консоли и объектами дисковых файлов. После завершения чтения из файла или записи в файл хорошим тоном является вызов функции close. Это заставляет программу предоставить доступ к файлу, таким обра- зом, кто-то еще сможет использовать его. После успешного завершения программы язык C++ закроет файл сам, однако было бы неплохо делать это самим. out_file_l.close(); out_file_2.close(); Как обращаться к дисковым файлам В предыдущем разделе я продемонстрировал, как можно создавать файловый объект, указывая имя файла. В случае успеха это объявление открывает файл для вывода, что означает, что вы можете записывать в него; вам также предоставляется монопольный доступ. ofstream fout("output.txt"); Но где располагается файл? Если выполнить поиск по компьютеру, где мы найдем этот файл? По умолчанию, файл, к которому происходит обращение, находится в текущем катало- ге - в каталоге, из которого запущена программа. (Или, если использовать жаргон опе- рационных систем Windows или Macintosh, это текущая папка). Однако вы можете при желании указать полное имя пути, необязательно включая имя диска. Все это является частью полного имени файла или, более точно, спецификации файла (file specification) - термин, используемый в справочных руководствах. Например, можно открыть файл в корневом каталоге диска С:. ofstream fout ("с : Woutput. txt”) ; Строковый литерал, приведенный здесь, использует нотацию обратной косой черты языка C++. В предыдущей главе я объяснял, что символ обратной косой черты имеет специальное значение в программах на языке C++: например, выражение *\п’ представ- ляет разделитель строки, а *\Г - табуляцию. Для представления самой обратной косой
ГЛАВА 8. Файлы: электронное хранилище 203 черты используются две черты, идущие подряд. Таким образом, строка c:\\output.txt в коде программы на языке C++ называет следующий файл c:\output.txt При вводе выражения c:\\output.txt в код программы язык C++ не помещает в строку две обратных косых черты. Вместо этого он заменяет одной обратной косой чертой каждое встречаемое выражение «\\»; язык C++ попросту интерпретирует выражение «\\» в коде программы как представляющее одиночную обратную косую черту, точно так же, как он интерпретирует выражение «\п» как разделитель строки. В качестве другого примера следующая инструкция создает объект файлового потока вывода, расположенный в каталоге c:\programs\text: ofstream fout ("с: WprogramsW text\\output. txt") ; Эта инструкция открывает файл c:\programs\text\output.txt Пример 8.1. Запись текста в файл Пример в этом разделе делает почти самое простое, что можно сделать с текстовым файлом: открывает его, записывает пару строк текста и завершает работу. Программа просит пользователя ввести имя файла для записи. Являясь пользователем, вы вводите точное имя файла, включив при желании букву диска и полный путь. Не ис- пользуйте две обратных косых черты для представления одной: это всего лишь соглаше- ние об обозначениях в программном коде на языке C++ и не касается пользователя или того, как обычно хранятся строки. Например, вы можете ввести: c:\documents\output.txt Эта программа заменит любой указанный файл, уничтожив его прежнее содер- жимое. Поэтому после запуска программы будьте осторожны, чтобы не ввести имя существующего файла, если вы не хотите потерять содержимое этого \ файла. Вот сама программа: Листинг 8.1. writetxt.cpp ttinclude <iostream> ttinclude <fstream> using namespace std; int main() { char filename[81]; cout << "Enter a file name and press ENTER: cin.getline(filename, 80);
204 C++ без страха ofstream file_out(filename); if (! file_out) { cout « "File " << filename; cout << " could not be opened."; return -1; } cout << "File " << filename << " was opened."; file_out « "I am Blaxxon," << endl; file_out << "the cosmic computer." << endl; file_out << "Fear me."; file_out.close() ; return 0; 2 После выполнения программы вы, возможно, захотите просмотреть содержимое файла, чтобы убедиться, что программа успешно записала текст. Для этого вы можете восполь- зоваться любым текстовым редактором или текстовым процессором. (Или, если вы на- ходитесь в режиме MS-DOS Command shell, можно использовать команду TYPE.) Еще раз имейте в виду, если вы запустите программу и передадите ей имя су- ществующего файла, программа с большим удовольствием перезапишет этот файл. Поэтому будьте осторожны! Как это работает В начале программы включается поддержка частей iostream и fstream библиотеки языка C++. #include <iostream> ttinclude <fstream> using namespace std; В программе всего лишь одна функция, main. Первое, что она делает - это выводит под- сказку для ввода имени файла. char filename[81]; cout « "Enter a file name and press ENTER: "; cin.getline(filename, 80); После этого она создает файловый объект file_out. ofstream file_out(filename); Эта инструкция пытается открыть файл, имя которого ввел пользователь. Если попытка открыть файл была неудачной, в объект file_out помещается значение null. Данное зна- чение может быть проверено в инструкции if. (Помните, что значение null, или нуль, в данном контексте приравнивается значению false.) Если файл не был успешно открыт, программа печатает сообщение об ошибке и завер- шает работу. Помните, что оператор логического отрицания (!) меняет значение true/false на противоположное.
ГЛАВА 8. Файлы: электронное хранилище 205 if (! file_out) { cout « "File " « filename; cout « " could not be opened."; return -1; } Почему же попытка открыть файд когда-либо может закончиться неудачей? Существует пара причин, из-за которых что-то может пойти не так. Наиболее очевидной возможностью является то, что пользователь ввел некорректную спецификацию файла. Другой причиной является то, что пользователь попытался открыть файл, которому опе- рационной системой были присвоены привилегии «только для чтения», и поэтому он не может быть перезаписан. Если файл был успешно открыт, программа переходит к записи нескольких текстовых строк, после чего закрывает поток. .file_out « "I am Blaxxon," « endl ; file_out << "the cosmic computer." « endl; file_out « "Fear me'."; file_out.close(); return 0; Упражнения Упражнение 8.1.1. Перепишите пример 8.1 таким образом, чтобы местоположение ка- талога и имя файла вводились отдельно, а не в одной строке. (Подсказка: используйте две строки, а для их объединения используйте функцию strcat.) Упражнение 8.1.2. Напишите программу, позволяющую пользователю вводить любое количество строк текста, одну за раз. В результате получится примитивный редактор, позволяющий вводить текст, однако не разрешающий редактировать строку текста после того, как она была введена. Реализуйте цикл, выход из которого происходит лишь тогда, когда пользователь нажмет клавишу pnter], не напечатав никакого текста (строка нулевой длины). В качестве альтернативы можно распознавать специальный код (например «@@@») для завершения сеанса. Чтобы определить, соответствует ли строка текста условию «выхо- да», можно использовать функцию strcmp («string compare» - сравнение строки). if (strcmp(input_line, "@@@")) break; He забывайте печатать короткую подсказку перед каждой строкой текста, например: Enter (@ @ @ to exit)» Подсказка: чтобы написать эту программу, запрашивайте у пользователя целую введен- ную строку (используя функцию getline), после чего записывайте введенную строку в файл.
206 C++ без страха Пример 8.2. Отображение текстового файла После того как вы создали файл и записали в него данные, вы захотите иметь возмож- ность для его просмотра. Это уже можно сделать с помощью множества приложений (например, программы Notepad в операционной системе Windows). Для просмотра файла можно создавать свои собственные программы. Написание завершенного, полнофункционального текстового редактора находится за пределами рассмотрения этой книги. Однако примеры данной главы охватывают неко- торые основные элементы. Основными действиями, выполняемыми любым текстовым процессором или текстовым редактором, являются открытие файла, чтение строк текста, предоставление пользователю возможностей по манипулированию данными строками текста (которые как-никак являются простыми строками) и сохранение изменений. Данный пример за раз отображает 24 строки текста, опрашивая пользователя, желает ли он продолжить или нет. Пользователь может напечатать еще 24 строки или завершить выполнение программы. Листинг 8.2. readtxt.cpp'- ttinclude <iostream> ttinclude <fstream> using namespace std; int main() { int с; // введенный символ int i; // счетчик цикла char filename[81]; char input_line[81]; cout « "Enter a file name and press ENTER: cin.getline(filename, 80); ifstream file_in(filename); if (I file_in) { cout « "File " « filename; cout « " could not be opened."; return -1; ) while (1) { for (i = 1; i <= 24 && ! file_in.eof(); i++) { file_in.getline(input_line, 80); cout « input_line « endl; ) if (file_in.eof()) . break; cout << "More? (Press ’Q1 and ENTER to quit.)1'; cin.getline(input—line, 80); c = input—line[0];
ГЛАВА 8. Файлы: электронное хранилище 207 if (с == 1Q' || с -== 1 q1 ) break; } return 0; } Как это работает Этот пример похож на пример 8.1, однако он немного более сложный, в основном из-за проверки пары различных условий для определения, нужно ли продолжать чтение строк. После определения того, что файловый поток был успешно открыт (программа заверша- ет выполнение, если поток не был успешно открыт), программа запускает бесконечный цикл, выход из которого осуществляется, когда любое из следующих условий становит- ся истинным: ✓ Достигнут конец файла. ✓ Пользователь указывает, что он (или она) не желает продолжать выполнение. Вот основной цикл в схематическом «скелетном» виде: while (1) { II... } В этом основном цикле программа считывает до 24 строк - или меньше, если раньше был достигнут конец файла. Простым способом такой реализации является использова- ние цикла for со сложным условием: for (i = 1; i <= 24 && ! file_in.eof(); i++) { file_in.getline(input_line, 80); cout << input-line « endl; } Выполнение цикла продолжается только тогда, когда значение переменной i меньше или равно 24 и не обнаружено условие конца файла. Выражение file_in.eof() ' . 1 возвращает значение true, если был достигнут конец файла. Логическое «не» (!) изме- няет значение выражения на обратное, таким образом выражение ! f ile_in. eof () возвращает значение true только тогда, когда еще имеются данные для чтения. Оставшаяся часть основного цикла проверяет, нужно ли продолжать выполнение про- граммы; если не нужно, цикл прерывается и программа завершается. if (file_in.eof()) break; cout « "More? (Press 'Q' and ENTER to quit.)"; cin.getline(input_line, 80); c = input_line[0]; 1f (c == 1Q' | | c == 1q1 ) break;
208 C++ без страха Упражнения Упражнение 8.2.1. Модифицируйте пример таким образом, чтобы пользователь при желании мог ввести число в ответ на подсказку «More?». Это число определяет, сколько строк отобразить за один раз вместо 24. (Подсказка: используйте библиотечную функ- цию atoi для преобразования строкового ввода в целое число; если введенное значение больше 0, измените число считываемых за один раз строк.) Упражнение 8.2.2. Измените пример таким образом, чтобы он печатал все содержимое файла прописными буквами. Вам может помочь копирование части кода упражнения 7.3. Текстовые файлы в сравнении с «двоичными» файлами До сих пор мы использовали файлы как потоки текста; мы читали из этих файлов и за- писывали в них так же, как бы делали это с консолью. Если вы просмотрите файл в тек- стовом редакторе - или отправите содержимое файла прямо на консоль - вы увидите, что содержимое находится в удобной для восприятия человеком форме. Например, при записи числа «255» в текстовый файл программа запишет коды ASCII для символов «2», «5» и «5». file_out « 255; Но существует и другой способ записи данных в файл: запись непосредственных значе- ний. Вместо того, чтобы записывать коды ASCII для символов, составляющих строку «255», можно было бы записать само значение 255. Если после этого вы попытаетесь просмотреть файл при помощи текстового редактора, вы не увидите цифры «255». Вме- сто этого текстовый редактор попытается отобразить символ с кодом ASCII 255, кото- рый является непечатаемым символом. В руководствах по программированию говорится о двух типах файлов: ✓ Текстовые файлы, для которых чтение и запись осуществляется так же, как и для консоли. Обычно каждый байт, записанный в текстовый файл, является кодом ASCII печатаемого символа. ✓ Так называемые двоичные (bynary) файлы, для которых чтение и запись осуществля- ется с использованием фактических числовых значений данных. С этим подходом коды ASCII не связаны. О втором методе может сложиться впечатление, что он более простой, однако, что пара- доксально, он не является таковым. Для просмотра такого файла осмысленным способом необходимо приложение, понимающее, чем должны являться поля файла и как их ин- терпретировать. Должна ли группа байт быть проинтерпретирована как целое число, число с плавающей точкой или строковые данные? И где начинается одна группа бай- тов, а где - другая? В случае со строками вы, конечно же, будете записывать коды ASCII символов. Но в других случаях коды ASCII не связаны с двоичными файлами. При создании объекта файлового потока можно указать текстовый режим (по умолча- нию) или двоичный режим. Само значение режима изменяет одну важную деталь:
ГЛАВА 8. Файлы: электронное хранилище 209 В текстовом режиме каждый символ разделителя строки (код ASCII 10) ♦♦♦ преобразуется в пару «возврат каретки - перевод строки» во время опе- рации записи. Давайте рассмотрим, почему это преобразование необходимо в текстовом режиме. Ранее в этой книге - еще в первой главе - примеры использовали символы разделителя строки. Эти символы могли быть напечатаны отдельно или в составе самих строк. char *msg_string = "Hello\nYou\n"; Строки используют один байт (код ASCII 10) для обозначения разделителя строки. Но когда строка выводится на консоль, должны быть выполнены две операции: напеча- тать символ возврата каретки (код ASCII 13), который перемещает курсор на начало строки, и напечатать символ перевода строки (код ASCII 10). Когда строка печатается на консоли, каждый символ разделителя строки в памяти пре- образуется в пару «возврат каретки - перевод строки». Например, вот как выглядит строка Hello\nYou\n при хранении в основной памяти и как она выглядит напечатанной на консоли. н е 1 1 о \10 Y о U \10 \о консоль (или дисковый файл) Хорошо, скажете вы. Таким образом, это преобразование должно быть выполнено толь- ко при печати строк на консоли. Но необходимо ли это также для текстовых файлов? Да, и для этого есть достаточные основания. Данные, отправляемые в текстовый файл, должны быть в таком же формате, в каком они отправляются на консоль. Это позволяет языку C++ рассматривать все текстовые потоки (консоль или на диске) абсолютно оди- наковым способом. Изменяется только адресат информации. Но в случае с двоичным файлом важно, чтобы никакое преобразование не выполнялось. Значение 10 может оказаться в середине числового поля, и оно не должно интерпрети- роваться как символ разделителя строки. Если вы преобразуете это значение везде, где вы встретите его, вероятно, вы создадите множество ошибок. Существует еще одно отличие - возможно, наиболее важное - между' операциями в тек- стовом режиме и в двоичном режиме. Это касается выбора, который вы делаете как про- граммист.
210 C++ без страха ✓ Если файл открыт в текстовом режиме, следует использовать такие же операции, ко- торые используются для сообщения с консолью; они включают в себя потоковые операторы («, >>) и функцию getline. ✓ Если файл открыт в двоичном режиме, необходимо передавать данные только с ис- пользованием функций-членов read и write. Эти операции предназначены для не- посредственного чтения/записи. В следующем разделе я рассмотрю эти две функции. Вставка На самом ли деле «двоичные файлы» более двоичные? Причина, по которой люди используют термин «двоичный файл», заключается в том, что при использовании такого файла, если вы записываете значение байта 255, на самом деле вы записываете двоичное разложение значения 255, показанное здесь в виде строки, состоящей из единиц. Это потому, что, в конечном счете, все данные сохраняются в двоичном виде. 11111111 Использование термина «двоичный» в некоторых случаях вводит в заблуждение. Если вы записываете значение «255» в виде текста, вы все равно записываете двоич- ные данные - за исключением того, что теперь каждое из этих двоичных значений представляет код ASCII символа. Мысленно программисты склоняются к тому, что это «текстовый» формат, а не «двоичный», поскольку текстовый редактор отобража- ет файл в виде текста и у него не возникает проблем с двоичным представлением. Между прочим, вот как в действительности записывается число «255» в текстовом режиме. 00110010 00110101 00110101 Эта двоичная последовательность представляет числа 50, 53 и 53, которые в свою очередь являются кодами ASCII для цифр «2», «5» и «5». Когда эти данные отправ- ляются на консоль, все, что вы видите, - это строка цифр «255». Но здесь важным моментом является то, что это текстовый, в отличие от двоичного, режим - поскольку, работая в текстовом режиме, вы не заботитесь о лежащем в ос- нове двоичном представлении. Все, о чем вы заботитесь - это то, что файл рассмат- ривается, как поток текстовых символов. Таким образом, даже если все, в конце кон- цов, является двоичным, вы должны думать об этом режиме как о «текстовом режиме». На всем протяжении этой главы я адаптирую стандартный термин «двоичный файл» к обозначению файла, в котором данные не интерпретируются в виде кодов ASCII символов (если это не строковые данные, с которых начинается файл). Это все, что означает данный термин на самом деле. Как я уже отмечал, большие части такого фай- ла могут быть не прочитаны с использованием текстового редактора, тогда как в случае с текстовым файлом все должно читаться в виде текста, поскольку все данные в текстовом файле интерпретируются как последовательности кодов ASCII символов.
ГЛАВА 8. Файлы: электронное хранилище 211 Введение в двоичные операции Работая с двоичными файлами, вы считываете и записываете данные непосредственно в файл вместо того, чтобы преобразовывать данные в текстовые представления - то, что делают потоковые операторы (« и >>). Предположим, у нас есть следующие объявления данных. Объявленные переменные занимают 4, 8 и 16 байт соответственно. int п = . 3 ; double amount = 215.3 char *str[16] = "It's C++." Следующие инструкции записывают значения переменных n, amount и str непосредст- венно в файл. Предположим, что объект binfil - это объект файлового потока, успешно открытый в двоичном режиме. binfil.write(reinterpret—cast<char*>(&n) , sizeof(n)); binfil.write(reinterpret_cast<char*>(&amount), sizeof(amount)); binfil.write(str, sizeof(str)); Вот как выглядят данные после того, как они были записаны. (Реальные двоичные пред- ставления используют строки, состоящие из единиц и нулей, однако я преобразовал эти значения, чтобы сделать их более читабельными.) 4 байта 8 байт 16 байт 3 215.3 "It's C++!" Чтобы правильно прочитать этот файл, необходимо знать, как считать эти три поля. Это не так просто, как кажется. В действительности линии между различными полями неви- димы; в реальности они даже не существуют - только в уме программиста. (Помните, что данные на компьютере - включая дисковые файлы - это не что иное, как последова- тельность байт, содержащих двоичные числа.) В самом файле нет ничего такого, что могло бы указать, где заканчивается одно поле и начинается другое. При использовании текстового файла вы всегда можете прочитать поле, выполнив считывание до следующего разделителя или до символа разделителя строки; однако это нельзя сделать с двоичным файлом. Следовательно, при чтении двоичного файла необходимо знать, значения каких типов данных хранятся в файле. В только что продемонстрированном примере кода данные имели следующую структуру: тип int, тип double и 16-байтовый массив типа char в таком же порядке. Поэтому эти данные можно правильно прочитать, используя следую- щую процедуру: > Считать четыре байта непосредственно в целочисленную переменную. > Считать восемь байт непосредственно в переменную типа double. > Считать 16 байт в строку. Это как раз то, что делают следующие строки кода.
212 C++ без страха binfil.read(reinterpret_cast<char*>(&n), sizeof(n)); binfil.read(reinterpret_cast<char*>(kamount), sizeof(amount)); binfil.read(str, 16); Важным является порядок, в котором выполняются эти операции чтения. Если вы, на- пример, попытаетесь прочитать сначала поле типа double (с плавающей точкой), ре- зультатом окажется мусор - поскольку целочисленные данные и данные с плавающей точкой-имеют несовместимые форматы. Двоичное чтение требует гораздо больше точности, чем чтение потоков текста. В случае с текстовыми файлами строка чисел, например «12000», может быть прочитана либо в целочисленную переменную, либо в переменную с плавающей точкой, поскольку функ- ция преобразования текста в число точно знает, как интерпретировать подобную строку. Но непосредственное двоичное чтение не выполняет никаких преобразований. Копиро- вание восьмибайтового значения типа double в четырехбайтовую целочисленную пе- ременную создаст неприятную ситуацию для вашей программы. Прежде всего, это дей- ствие перезаписывает память, создавая трудно отслеживаемую ошибку. Ввод в двоичный файл и вывод из него осуществляется при помощи функций read и write. Каждая из этих функций принимает два аргумента: адрес данных и число байт. fstream.read(адрес, число_байт); // Считать данные // в адрес fstream.write(адрес, число_байт); // Записать данные // из адреса Первый аргумент - это адрес данных в памяти: для функции read это результирующий адрес, куда будут считаны данные файла. Для функции write это адрес источника, го- ворящий откуда брать данные. (Эти данные впоследствии будут записаны в файл.) В любом случае, этот первый аргумент должен иметь тип char*, поэтому необходимо передавать адресное выражение (указатель, имя массива или адрес, полученный при по- мощи оператора взятия адреса «&»). Также, если тип адреса отличен от типа char*, необходимо сменить тип, используя приведение данных. Для этой операции требуется особый вид приведения данных, преобразующий указатель из одного типа (int*) в другой (char*). Поскольку приведение изменяет способ ин- терпретации данных, на которые указывает указатель, вместо оператора static_cast вызывается оператор reinterpret_cast. binfil.read(reinterpret_cast<char*>(&n), sizeof(n)); Эту инструкцию можно понять проще, если вы осознаете, что следующее выражение принимает адрес переменной п, но преобразует этот адрес к типу char*. reinterpret_cast<char*>(&n) Для текстовых данных нет необходимости использовать приведение к типу char*, по- скольку первый аргумент уже имеет этот тип. binfil,write(str, sizeof(str)); Оператор sizeof здесь полезен для указания второго аргумента. Он возвращает размер указанного типа, переменной или массива в байтах.
ГЛАВА 8. Файлы: электронное хранилище 213 Пример 8.3. Запись с произвольной выборкой В данном примере двоичные данные записываются в файл. Как я отмечал, ключом для управления двоичными файлами является выбор формата, а затем нужно придержи- ваться этого формата. В результате поля данных разграничиваются не разделителем или символом разделителя строки (как в текстовом файле), а поведением программы. Другими словами, вы должны писать свои программы таким образом, чтобы они зна- ли, какой правильный формат данных использовать перед выполнением операции чте- ния или записи. Программы в этом разделе и в следующем представляют файл в виде последовательно- сти записей фиксированной длины, в котором каждая запись хранит два информацион- ных поля: строковое поле длиной 20 байт (максимум 19 символов плюс один байт для завер- шающего нуля); ✓ целое число. Тот факт, что данный установленный шаблон используется снова и снова по всему фай- лу, облегчает использование двоичных операций. У нас есть простой, постоянный фор- мат файла, которого мы и придерживаемся. Этот пример реализует механизм произвольной выборки (random access), означающий, что пользователь может непосредственно переходить на любую запись, заданную чис- лом. Пользователю не нужно читать данные последовательно (как это приходится де- лать, например, с текстовым файлом), начиная с начала файла и читая или записывая каждую запись в последовательности. Если пользователь записывает данные по номеру существующей записи, эта запись пе- резаписывается. Если пользователь записывает данные по номеру записи, который нахо- дится за пределами текущей длины файла, файл автоматически расширяется по длине, как необходимо. Листинг 8.3. writebin.срр ttinclude <iostream> ttinclude <fstream> using namespace std; int get_int(int default_value); char name[20]; int main() { char filename[81]; int n; int age; int recsize = sizeof(name) + sizeof(int); cout « "Enter file name: cin.getline(filename, 80);
214 C++ без страха // Открыть файл для двоичного чтения и записи. fstream fbin(filename, ios::binary | ios::in | ios::out); if (!fbin) { cout « "Could not open file " « filename; return -1; } // Получить номер записи для сохранения данных. cout « "Enter file record number: n = get_int(0) ; // Получить данные от пользователя. cout << "Enter name: cin.getline(name, 19); cout « 11 Enter age: "; age = get_int(0); // Записать данные в файл. fbin.seekp(n * recsize); fbin. write (name, 20),; fbin.write(reinterpret_cast<char*>(Sage) , sizeof(int)); fbin.close(); return 0; } // Функция получения целого числа // Получить целое число, введенное с клавиатуры; возвратить // значение по умолчанию, если пользователь ввел строку // нулевой длины. // int get_int(int default_value) { char s[81] ; cin.getline(s, 80); if (strlen(s) == 0) return default_value; return atoi(s);
ГЛАВА 8. Файлы: электронное хранилище 215 Как это работает Понятие записи является основой этого примера. Запись является форматом данных - обычно содержащим более чем одно поле - повторяющимся по всему файлу, тем самым придавая структуре файла единообразие. Неважно, на сколько увеличится файл; всегда будет просто найти запись по ее номеру. Всякий раз, когда вы используете записи в массиве или в двоичном файле, есте- ственным путем их реализации является использование структуры языка С или класса языка C++. Я потрачу много времени на классы, начиная с главы 10. Пример в этой главе использует простую структуру записи (или класса), не применяя синтаксис класса. Но будьте терпеливы; мы доберемся до этого синтаксиса в свое время. Одним из первых действий, которые выполняет программа, является вычисление этой длины: int recsize = sizeof(name) + sizeof(int); Данную информацию о длине можно использовать для перехода к любой записи. Например, запись под номером 0 находится в файле по смещению 0, запись под номером 1 находится по смещению 24, запись номер 2 - по смещению 48 и так далее. offset (смещение): 0 20 24 44 48 char * 20 int char * 20 int rec.# (запись N): Q j 2 Программа открывает файл, указывая несколько флагов для задания режима: ios: : binary, ios : : in и ios : : out. Последние два флага заставляют файловый по- ток поддерживать как ввод, так И вывод. Необходимо открыть файл в совмещенном ре- жиме ввода/вывода, чтобы включить возможность произвольной выборки записей. fstream fbin(filename, ios::binary | ios::in | ios::out); Если файл был открыт успешно, программа продолжает выполнение, запрашивая у пользователя номер записи. cout << "Enter file record number: n = get_int(0); Функция get_int, также определенная в коде, использует способ получения целого числа, описанный в предыдущей главе. (Аргумент, между прочим, определяет значение по умолчанию, которое используется, когда пользователь нажимает клавишу |; Enter], ни- чего не введя.) После этого программа получает данные для сохранения в указанной записи. cout « "Enter name: cin.getline(name, 19); cout « "Enter age: age. - get_int (0) ;
216 C++ без страха Перемещение к положению указанной записи заключается в простом умножении числа на размер записи (значение переменной recsize, равное 24), а затем в переходе на это смещение. Функция-член seekp выполняет этот переход. fbin.seekp(n * recsize); После этого программа записывает данные и закрывает файл. fbin.write(name, 20); fbin.write(reinterpret_cast<char*>(Sage) , sizeof(int)); fbin.close(); Упражнения Упражнение 8.3.1. Напишите программу, похожую на пример 8.3, записывающую запи- си в файл, в котором каждая запись содержит следующую информацию: модель, строка длиной 20 байт; марка, еще одна строка длиной 20 байт; год, строка длиной пять байт; и пробег, целое число. Упражнение 8.3.2. Модифицируйте пример 8.3 таким образом, чтобы программа проси- ла пользователя ввести номер записи, затем оставшуюся часть данных, после чего про- цесс повторялся. Для завершения программы пользователь вводит значение -1. Пример 8.4. Чтение с произвольной выборкой Конечно же, программа в примере 8.3 не очень полезна, пока у нас нет возможности прочитать данные, сохраненные в файле. Текстовый редактор не может удовлетво- рительно просмотреть файл - хотя он все же сможет отобразить части файла, содер- жащие строки. Программа в этом разделе читает данные, используя тот же формат записи, что и в пре- дыдущем разделе: строка длиной 20 байт, за которой следует четырехбайтовое целое число. Код похож на код примера 8.3, за исключением нескольких ключевых инструкций. Листинг 8,4, readbin.cpp ttinclude <iostream> ttinclude <fstream> using namespace std; int get_int(int default_value); char name[20]; int main() { char filename[81]; int n; int age; int recsize = sizeof(name) + sizeof(int); cout << "Enter file name: "; cin.getline(filename, 80);
ГЛАВА 8. Файлы: электронное хранилище 217 // Открыть файл для двоичного доступа в режиме // чтения-записи. fstream fbin(filename, ios::binary | ios::in | ios::out); if (!fbin) { cout << "Could not open file " « filename; return -1; } // Получить номер записи и перейти к этой записи. cout « "Enter file record number: n = get_int(0); fbin.seekp(n * recsize); // Прочитать данные из файла. fbin.read(name, 20); fbin.read(reinterpret_cast<char*>(&age) , sizeof(int)); // Отобразить данные и закрыть файл. cout << "The name is: " << name << endl; cout << "The age is: " « age «endl; fbin.close(); return 0; ' // Функция получения целого числа // Получить целое число, введенное с клавиатуры; возвратить // значение по умолчанию, если пользователь ввел строку // нулевой длины. // int get_int(int default_value) { char s [81] ; cin.getline(s, 80); if (strlen(s) == 0) return default_value; return atoi(s); }
218 C++ без страха Как это работает Большая часть этой программы делает то же, что и программа в примере 8.3. Как и раньше, программа получает номер записи и затем перемещается на соответствующее смещение (после умножения на размер каждой записи). fbin.seekp(n * recsize); Первые инструкции, которые отличаются от инструкций примера 8.3, считывают данные из файла в переменные name и аде. Эти инструкции почти такие же, как соответствую- щие инструкции для записи в другом примере; на самом деле, аргументы одинаковые. fbin.read(name, 20); fbin.read(reinterpret_cast<char*>(&age) , sizeof(int)); Как только данные были прочитаны и занесены в переменные - name и аде, программа печатает эти данные, закрывает файл и завершает свое выполнение. cout << "The name is: " << name « endl; cout << "The age is: " « age «endl; fbin.close() ; Упражнения Упражнение 8.4.1. Напишите программу, похожую на программу в примере 8.4, кото- рая читает записи из файла, каждая запись которого содержит следующую информацию: модель, строка длиной 20 байт; марка, еще одна строка длиной 20 байт; год, строка дли- ной пять байт; и пробег, целое число. Упражнение 8.4.2. Модифицируйте пример 8.4 таким образом, чтобы программа проси- ла пользователя ввести номер записи, затем печатала данные этой записи, после чего процесс повторялся. Для завершения программы пользователь вводит значение -1. Упражнение 8.4.3. Модифицируйте пример еще больше, таким образом, чтобы про- грамма выполняла как чтение, так и запись с произвольной выборкой. После того, как задача будет решена, у вас появится программа, которая сможет выполнять все операции ввода/вывода для файлов, соответствующих данному формату. Вам придется представить пользователю команды, напечатав меню выбора: > Сохранить запись. > Прочитать запись. > Выйти из программы. Основной цикл программы должен выполнять следующее: печатать меню, выпол- нять команду и завершать программу, если был выбран пункт 3. После этого повто- рять процесс.
ГЛАВА 8. Файлы: электронное хранилище 219 Резюме Вот основные моменты главы 8: ✓ Чтобы включить поддержку файлового потока из стандартной'библиотеки языка C++, используйте следующую директиву ttinclude. ttinclude <fstream> v' Объекты файлового потока предоставляют возможность взаимодействия с файлами. Чтобы создать поток файлового вывода, используйте объявление типа of st ream. Например: ‘ ofstream fout(filename); > ✓ После этого в поток можно записывать таким же образом, как и в объект cout: fout << "Hello, human."; ✓ Чтобы создать поток файлового ввода, используйте объявление типа ifstream. По- ток файлового ввода поддерживает те же операции, что и объект cin, включая функцию getline. ifstream fin(filename) ; char input_string[81); fin.getline(input_string, 80) ; S Если файл не может быть открыт, объект файлового потока устанавливается в значе- ние null (нуль). Объект можно проверить в условии; если значение равняется нулю, произошла ошибка и программа должна отреагировать соответствующим образом. if (I file_in) { cout « "File " « filename; cout « " could not be opened."; return -1; } После того, как вы завершили работу с объектом файлового потока (независимо от режима), хорошим стилем программирования является его закрытие. Эта операция освобождает файл, делая его доступным для других программ. fout.close() ; Файлы могут быть открыты либо в текстовом режиме, либо в двоичном. В текстовом режиме вы читаете из файла и записываете в него точно так же, как делаете это с консолью. В двоичном режиме вы используете функции-члены для непосредственно- го чтения и записи данных (без преобразования данных в их текстовое представле- ние). Чтобы открыть файловый поток в двоичном режиме с произвольной выборкой,, используйте флаги ios : : in, ios : : out И ios : : binary. Режим с произвольной выборкой означает, что вы можете перейти на любую пози- цию в файле, не удаляя при этом части файла, которые вы пропустили. Можно про- читать любую часть файла и перезаписать любые существующие части, не затрагивая
220 C++ без страха остальные части. Если вы вышли за пределы текущей длины файла и записываете данные, файл автоматически будет увеличен. ✓ Используйте функцию-член (member-function) seekp для перемещения на позицию в файле. Функция принимает аргумент, задающий смещение (в байтах) от начала файла. fbin.seekp(offset) ; ✓ Каждая из функций read и write принимает два аргумента: адрес данных типа char* и число байт для копирования. fstream. read {адрес, число_байт) f str earn, write (адрес, чйсло_байт) ; ✓ Для функции read адресный аргумент является результирующим адресом; функ- ция считывает данные из файла в эту область памяти. Для функции write адрес- ный аргумент является адресом источника; функция считывает данные из этого источника в файл. ✓ Поскольку типом адресного аргумента является тип char*, необходимо применять приведение данных в случае, если это не строка. Используйте оператор sizeof для определения количества байт для чтения или записи. binfil.write(reinterpret—cast<char*>(&n) , sizeof(n)) ; binfil.write(reinterpret_cast<char*>(&amount) , sizeof(amount)); binfil.write(str, sizeof(str));
ГЛАВА а Некоторые более сложные приемы программирования Имея возможность обрабатывать данные, печатать и анализировать строки, а также по- лучать доступ к дисковым файлам, вы находитесь на правильном пути. У вас есть инст- рументы для написания серьезных программ на языке C++. Однако существует несколь- ко других трюков, на которые хотелось бы обратить внимание перед тем, как оставить основы и сфокусироваться на объектном ориентировании. Эти более сложные возможности помогут сэкономить время и добавить в программу дополнительной функциональности. Одна из таких возможностей - перегрузка функ- ций- имеет важные связи с объектным ориентированием. Но эту возможность можно начинать использовать прямо сейчас, без необходимости понимания концептуальной основы объектов и классов (о которой пойдет речь в следующей главе). Мы начнем с вопроса об аргументах командной строки, возможности, которая может быть использована для улучшения программ, приведенных в главе 8. Аргументы командной строки Все программы в главе 8 работают с файлами, и первое, что делает каждая из этих про- грамм (после объявления переменных) - выводит пользователю подсказку для ввода имени файла. cout « "Enter a file name and press ENTER: cin.getline(filename, 80); Этот подход работает, но он не является идеальным решением. Любой, кто использует командную строку операционной системы DOS - или любую другую систему, основан- ную на командной строке, почти во всех случаях предпочел бы вводить имя файла вме- сте с именем программы. Например: readtxt output.txt Использовать программу таким способом гораздо быстрее и проще, чем ожидать, пока программа загрузится и попросит ввести имя файла. Это поведение можно реализовать с использованием возможности языка C++, аргумен- тов командной строки (command-line arguments). Первое, что необходимо сделать - иначе определить функцию main: int main(int argc, char *agrv[]) { / / . . . } Два аргумента функции main - argc и argv - предоставляют информацию о том, что пользователь ввел в командной строке.
222 C++ без страха ✓ Аргумент argc предоставляет общее число аргументов командной строки, введенных пользователем, включая имя самой программы. Таким образом, для следующей ко- мандной строки аргумент argc равняется значению 2. readtxt output.txt ✓ Аргумент argv - это массив строк, содержащий все аргументы командной строки, начиная с имени программы. В только что продемонстрированном примере элемент массива argv[0] указывает на readtxt, а элемент массива argv[1] - на output.txt. Элементы массива argv можно рассматривать, как обыкновенные строки, за исключени- ем того, что они должны считаться предназначенными только для чтения: вы не можете их увеличивать и не должны пытаться скопировать в них данные. Но их можно беспре- пятственно печатать или копировать данные в другие строки. Например, чтобы напечатать первые два аргумента командной строки (включая, напом- ню, имя программы), можно использовать следующий код: cout « argv[0) « endl; cout « argv[l] « endl << endl; cout « "argc is equal to " << argc; который для примера, показанного выше, напечатает readtxt output.txt argc is equal to 2 В качестве другого примера рассмотрим следующую командную строку для программы с именем «copyfile»: copyfile file 1 .txt file2.txt Вот как работают аргументы argc и argv в данном случае: copyfile filel .txt z file1.txt, A A A argv[0] argv[1] argv[2] argc Таким образом, вы могли бы сделать следующее: cout « argvfO]; cout « argv[l]; cout « argv[2]; cout « argc; // Напечатать "copyfile" //.Напечатать "filel.txt" // Напечатать "file2.txt" // Напечатать "3"
ГЛАВА 9. Некоторые более сложные приемы программирования 223 Пример 9.1. Отображение файла из командной строки Данный пример является вариацией примера 8.2 и отличается от него только одним: в нем используется имя файла, указанное в командной строке; если имя файла не указано, программа просит ввести имя файла, как и в примере 8.2. В следующем коде я выделил полужирным шрифтом строки, отличающиеся от кода примера 8.2. Оставшаяся часть кода не изменилась. Листинг 9.1. readtxt2.cpp' ttinclude <iostream> ttinclude <fstream> ttinclude <string.h> using namespace std; int main(int argc, char *argv[]) { int с; // введенный символ int i; // счетчик цикла char filename[81]; char input_line[81]; if (argc >1) strncpy(filename, argv[l], 80); else { cout << "Enter a file name and press ENTER: "; cin.getline(filename, 80); } ifstream file_in(filename); if (! file_in) { cout << "File " « filename; cout << " could not be opened."; return -1; } while (1) { for (i = 1;- i <= 24 && ! file_in.eof(); i++) { file_in.getline(input_line, 80); cout « input_line; } if (file_in.eof()) break; cout << endl; cout « "More? (Press 'Q' and ENTER to quit.)"; cin.getline(input_line, 80); c = input_line[0]; if (c == ’Q* || c == ’q‘) break; } return 0; }
224 C++ без страха При использовании данной версии программы можно использовать командную строку для ввода команд, аналогичных следующей: readtxt2 output.txt В качестве альтернативы можете просто ввести имя программы и позволить ей вывести подсказку для ввода имени файла для отображения. readtxt2 Как это работает Всего лишь несколько строк в приведенной программе отличаются от примера 8.2; в данном разделе описывается каждая из этих строк. Одним из файлов, включаемых в программу, является файл string.h, который дает возмож- ность использовать функции для обработки строк. Вы скоро увидите, зачем это нужно. ttinclude <string.h> Другим отличием является список аргументов для функции main, который не является пустым: int main(int argc, char *argv[]) { Программа имеет еще одно отличие от примера 8.2. Первое, что делает программа - это проверяет значение аргумента argc. Если значение аргумента argc больше, чем 1, значит пользователь ввел аргумент в командной строке (помимо имени самой программы), по- этому программа копирует этот аргумент в строку filename. Аргументы командной строки, следующие за аргументом argv[1], если они присутствуют, игнорируются. if (argc >1) strncpy(filename, argv[l], 80); Если значение аргумента argv меньше 1, значит пользователь не ввел имя файла в командной строке. Поэтому программа просит ввести имя файла, как это сделано в примере 8.2. else { cout « "Enter a file name and press ENTER: "; cin.getline(filename, 80); } Упражнения Упражнение 9.1.1. Измените пример 9.1 таким образом, чтобы он требовал от пользова- теля ввести имя файла в командной строке. Другими словами, синтаксис программы требует: readtxt3 имя_файла Если пользователь введет в командной строке больше или меньше аргументов, необхо- димо напечатать сообщение об ошибке и завершить работу. Упражнение 9.1.2. Напишите программу, которая не делает ничего, кроме печати всех аргументов командной строки, располагая каждый аргумент на отдельной строке.
ГЛАВА 9. Некоторые более сложные приемы программирования 225 Перегрузка функций (Overloading) Естественные языки - особенно английский - часто придают слову ряд различных зна- чений в зависимости от контекста. Подумайте, как много значений имеет слово «fair». Можно пойти на ярмарку (fair), наслаждаться ясной (fair) погодой и обращать внимание на всех людей со светлыми (fair) волосами на выставке (fair). Используя терминологию компьютерного программирования, мы могли бы сказать, что это пример перегрузки (overloading)- введения слова несколькими значениями. Язык C++ также использует перегрузку. Но вместо того, чтобы создавать источник бес- порядка или заблуждений, перегрузка в языке C++ является полезной и четкой. Одно и то же имя функции можно использовать для работы с различными типами данных. Рассмотрим функцию swap. Функция swap, как и все функции языка C++, должна быть объявлена с определенной информацией о типе. void swap(int *pl, int *р2); Это неуместное ограничение. Функция swap должна быть объявлена для работы с од- ним типом данных: в данном случае, с указателями на переменные типа int. Но вы, возможно, захотите, чтобы функция swap работала с другими типами данных. Одно из решений - решение, которое необходимо использовать в языке С, Basic и в большинстве не объектно-ориентированных языков - написать свою версию функции для каждого типа данных, выбирая соглашение об именовании таким образом, чтобы имя функции нельзя было использовать повторно. void swap__int (int *pl, int *р2); void swap_dbl(double *pl, double *p2) ; void swap_ptrs(char **pl, char **p2); Это решение работает, однако язык C++ предлагает свое, лучшее решение. Язык C++ позволяет повторно использовать одно и то же имя - swap - с различными типами дан- ных, полагаясь на информации о типе в списке аргументов, чтобы разделять эти версии. Имя «swap» используется повторно - то есть перегружено - для удобства программиста. void swap(int *pl, int *р2); void swap(double *pl, double *p2); void swap(char **pl, char **p2); Версия функции swap, показанная здесь, для типов char* не перемещает строковые данные; она только меняет местами значения двух указателей ти- па char*. Например, переменные р1 и р2 указывают на две строки. После вы- зова функции swap (&pl, &р2) переменная р1 указывает на строку, на кото- рую раньше указывала переменная р2 и наоборот. Как компилятор языка C++ различает все эти функции? Очень просто: он «смотрит» на аргументы, используемые в вызове функции. Поскольку каждая переменная имеет определенный тип, язык C++ всегда может распознать, какую версию функции swap использовать. 8 - 6248
226 C++ без страха Кроме того, для каждой версии функции должно быть отдельное объявление и отдель- ное описание. Вот два определения функции swap. void swap(int *pl, int *p2) { int temp = *pl; * pl = *p2 ; * p2 = temp; } void swap(double *pl, double *p2); double temp = *pl; * pl = *p2; * p2 = temp; } Вставка Перегрузка и системы OOPS Перегрузка функций относится к одной из глубочайших идей систем объектно- ориентированного программирования (OOPS - Object-Oriented Programming Systems), а именно, идеи о том, что тип данных обусловливает поведение функции или оператора. Тесно связанной с ней идеей является перегрузка операторов. Оператор (например + или -) может применяться с любым числом различных типов данных, и этот опера- тор будет выполнять правильные действия для используемых типов - предполагает- ся, что соответствующий код существует. (В главе 13 описывается, как написать та- кие функции-операторы для собственных типов.) Элементарный пример перегрузки операторов присутствует в базовом языке. Для сложения двух целых чисел и сложения двух чисел с плавающей точкой требуются различные машинные инструкции. Компилятор языка C++ выполняет различные низ- коуровневые процедуры в зависимости от типов, используемых в выражении, например: а + b Благодаря перегрузке функций, можно изменять типы переменных, не изменяя имя функции. Вызов функции выполнит правильные действия... поскольку (вероятно) вы запрограммировали несколько версий функции для работы с различными типами. В примере с функцией swap представьте, что вы объявили переменные а и b как це- лочисленные переменные; int а, Ь; // . . . swap(&a, &b); Если вы затем измените тип переменных а и b на тип double, вызов функции swap будет продолжать работать. Компилятор языка C++ выполняет вызов к другой версии функции. Еще раз, эта идея глубоко проникла в объектное ориентирование: используемый тип данных обусловливает поведение функции.
ГЛАВА 9. Некоторые более сложные приемы программирования 227 Однако перегрузка не является полной реализацией этого принципа. Как вы узнаете из следующих глав, во время компиляции информация о типе может быть известна не полностью. В некоторых случаях вы можете знать только общий тип аргумента - вы можете знать только то, что он реализует какой-то общий интерфейс - и в этом случае перегрузки функции будет недостаточно. Это как раз тот случай, где вступают в игру полиморфизм и виртуальные функции. Я расскажу вам более подробно об этих понятиях в главе 17. Пример 9.2. Печать массивов различных типов Вот простой пример, в котором используется перегрузка функций. Программа печатает массивы трех типов, каждый раз вызывая функцию print_array. Листинг 9.2. prlnt arrs.cpp ttinclude <iostream> using namespace std; void print_arr(int *arr, int n) ; void print_arr(double *arr, int n) ; void print_arr(char **arr, int n) ; int a[] = {1, 1, 2, 3, 5, 8, 13}; double b[] = {1.4142, 3.141592 }; char *c[] = {"Inken", "Blinken", "Nod" }; int main() { print_arr(a, 7); print_arr(b, 2); print_arr(c, 3); return 0; } void print_arr(int *arr, int n) { for (int i = 0; i < n; i++) cout << arr[i] « " cout « endl; } void print_arr(double *arr, int-n) { for (int i = 0; i < n; i++) cout « arr[i] « " cout « endl; } void print_arr(char **arr, int n) { ' for (int i = 0; i < n; i++) cout « arr[i] « endl; } s*
228 C++ без страха Как это работает Данный пример демонстрирует простое использование перегрузки функций. Первое, что делает программа (после директив ttinclude), -- объявляет все различные версии функ- ции print_arr. void print_arr(int *arr, int n) ; void print__arr (double *arr, int n) ; void print_arr(char **arr, int n); Затем объявляются массивы трех типов: int а[] = (1, 1, 2, 3, 5, 8, 13} ; double b[] = {1.4142, 3.141592 }; char *c[] = {"Inken", "Blinken", "Nod" }; Изнутри функции main программа после этого использует одно и то же имя функции - print_arr, чтобы напечатать все эти массивы. print_arr(a, 7) ; pri-nt_arr (b, 2); print__arr ( с , 3); В заключение, программа определяет версии функции print_arr. Упражнения Упражнение 9.2.1. Напишите две версии общей функции get__number таким образом, чтобы функция get_number могла быть использована для целого числа или для числа с плавающей точкой, как потребуется. Как и в примерах get_int и get_dbl в главе 7, функция должна принимать числовой аргумент, определяющий значение по умолчанию. Для приведенного вызова: get_number(0) функция должна возвращать целочисленное значение, тогда как get__number (0.0) должна возвращать значение типа double. Помните, что нотация языка C++ распознает любое константное выражение с десятичной точкой как выражение с плавающей точкой типа double. Таким образом, все, что вам придется сделать - это перегрузить две вер- сии функции, одна из которых принимает и возвращает значение типа int, а другая принимает и возвращает значение типа double. Цикл do-while Ниже представлены управляющие структуры, с которыми я познакомил вас к настояще- му времени. Существует три структуры (или четыре, если считать, что инструкция if имеет две версии). if (условие) Инструкция
ГЛАВА 9. Некоторые более сложные приемы программирования 229 if (.условие) инструкция else инструкция while (условие) инструкция for ( инициализатор; условие; инкремент) ин стр укция Помните, что любой экземпляр инструкции в любой из этих структур может быть заме- нен составной инструкцией, состоящей из одной или более инструкций, расположенных внутри пары фигурных скобок ({}). ( инструкции } Помимо этих инструкций, я также демонстрировал использование инструкций break и return для передачи управления из цикла или функции. С этим синтаксисом у вас есть все'инструменты, необходимые для управления програм- мами на языке C++. Этих инструкций достаточно для реализации любого вида процесса выполнения программы, который вам когда-либо может понадобиться. Технически можно обойтись всего лишь при помощи инструкций whi 1е и if. Также существует пара других управляющих структур, которые часто бывают полезны, , хотя и не являются абсолютно необходимыми. Одной из таких структур является инст- рукция do-while, имеющая следующий синтаксис: do инструкция while (условие) ; Эта управляющая структура похожа на обычную инструкцию w?hile. Отличие заключается в том, что в этой версии инструкции while инструкция обязательно выполнится хотя бы раз перед тем, как будет вычислено условие. Я уже демонстрировал пример, где эта инструкция была бы полезна. В примере 5.5 ис- пользовался следующий код внутри большего цикла: i++; // Перейти к следующей карте while (card_drawn[i]) // Пропустить уже i++; // вытянутые карты. Очевидно, что в этом фрагменте инструкция i + +; выполняется, по крайне мере, один раз, после чего она может выполняться снова. Благодаря этому, фрагмент кода становит- ся идеальным кандидатом для использования инструкции do-while. Можно перепи- " сать код следующим способом: do i++; ‘ // Переходить к следующей карте, while (card_drawn[i]); // пока текущая карта была уже // вытянута
230 C++ без страха Данный фрагмент кода, хотя и является более сжатым, становится более сложным для чтения. Привычно использовать версию инструкции do-while с составным условием, подставляя {инструкции} вместо одной инструкции. do { i++; // Переходить к следующей карте, } vizhile (card_drawn [ i] ) ; // пока текущая карта была уже // вытянута Это более читабельно, вам не кажется? Действие инструкции do-while может быть выражено при помощи инструкций if и goto, что отражает фактическую логику машинного кода, который генерирует компи- лятор. Вот что делает инструкция do-while: top_of_loop: > инструкция if (условие) goto top_of_loop; Инструкция switch-case Завершением управляющий структур, поддерживаемых языком C++, является инструк- ция switch. Как и в случае с инструкцией do-while, инструкция switch не является абсолютно необходимой, однако в некоторых случаях она может сделать код более сжа- тым и читабельным. Один из наиболее распространенных шаблонов, которые вы встречаете в программиро- вании - ряд инструкций if-else, проверяющих одно значение с набором целевых зна- чений. Например, следующий фрагмент кода печатает one, two, three и так далее, в зависимости от значения переменной х. if (х == 1) cout << "one"; else if (x == 2) cout « "two"; else if (x == 3) cout « "three"; //. . . Это можно также записать с помощью инструкции switch: switch(x) { case 1: cout « "one"; break; case 2 : 1 - cout << "two"; break; case 3: cout « "three"; break;
ГЛАВА 9. Некоторые более сложные приемы программирования 231 Технически инструкция switch имеет простой синтаксис. switch (значение) { инструкции Чтобы сделать инструкции полезными, необходимо включить помеченные инструкции. Метки внутри инструкции switch могут иметь одну из следующих особых форм: case целевое_значениег инструкция default: инструкция Вот как работает инструкция switch: > Вычисляется значение, стоящее за ключевым словом switch. > Если существует метка инструкции case, совпадающая с данным значением, то управление передается этой помеченной инструкции. > Если ни одна из меток инструкций case не совпадает со значением и присутствует метка default, то управление передается инструкции с меткой default. Как только управление было передано помеченной инструкции, программа продолжает выполнять действия в прямом направлении, как обычно, пока не встретится инструкция break, после чего управление передается на конец инструкции switch. Вот почему каждый блок инструкции case должен завершаться инструкцией break, если вы не хотите, чтобы управление перешло на следующую инструкцию case. case 1: cout << "one"; break; Многочисленные модули В главе 4 я упомянул, что использование множества функций в программе позволяет внедрить подход разделения труда, что сделает большие проекты более выполнимыми. Разделение труда улучшается еще больше при использовании многочисленных модулей. Что я подразумеваю под словом модуль! Хорошо, рассмотрим путь, по которому созда- вались все программы в этой книге: существует один исходный файл (файл с расшире- нием .срр), который транслируется в один объектный файл, содержащий машинный код (файл с расширением .о), а затем объектный файл компонуется в исполняемый файл (файл с расширением .ехе). Можно ли создать программу более чем из одного исходного файла? Да. Ключом к реа- лизации этого является использование функций. Описание одной функции не может на- ходиться в нескольких исходных файлах. Однако отдельные функции можно разместить в различных исходных файлах. Вот простой пример. В данной программе используется четыре функции: main, calc, get_int и get_dbl. Все эти функции можно поместить в один исходный файл, одна- ко, ради иллюстрации, я помещу описания функций в два отдельных файла modi .срр и mod2.cpp. Они показаны на следующем рисунке.
232 C++ без страха Каждый из исходных файлов (.срр) является отдельным модулем. В этом примере также демонстрируется использование включаемого файла myproj.h. (На самом деле, это хорошая иллюстрация того, почему включаемые файлы вообще по- лезны.) Предположим, что каждая из трех функций, отличная от функции main - это функции calc, get_int и get_dbl, могут быть вызваны функцией из другого моду- ля. Чтобы разрешить такие вызовы, необходимо разместить объявления функций в нача- ле каждого исходного файла. modl.cpp mod2.cpp Следующие объявления должны быть включены в каждый файл: void calc(); int get_int(int); double get_dbl(double) ; Это может немного сбить с толку, поэтому запомните приведенные правила: > Каждая функция описывается только в одном месте - то есть в одном исходном фай- ле. Например, в данном примере функция calc определена в файле modl.cpp. > Но для каждого исходного файла необходимо объявление (то есть прототип) этой функции, чтобы обеспечить к ней вызовы. Иначе функция будет не видна и вызвать ее будет невозможно. Компилятор языка C++ будет искать определение каждой функции, прототип которой включен в файл, в исходном файле; если определение не найдено, компилятор предпола- гает, что функция определена в другом модуле. В том случае, когда у вас имеется множество модулей, вы можете обнаружить, что разме- щение всех этих объявлений в начале каждого модуля занимает много времени и подвер- жено ошибкам. Лучший способ заключается в размещении всех объявлений функций в центральном файле - включаемом файле, а затем использовании директивы #include. Использование включаемого файла может показаться непрямым путем, однако в этом есть преимущества. Все прототипы общих функций содержатся в одном данном файле, что позволяет легко вносить изменения.
ГЛАВА 9. Некоторые более сложные приемы программирования 233 Таким же способом можно предоставлять доступ к общим переменным, однако пере- менные считаются собственностью того модуля, в котором они созданы - пока в каждый файл (или во включаемый файл) не будет добавлено объявление extern. Например, следующее объявление констатирует, что переменные а, b и с объявлены где-то в про- грамме, возможно (хотя и не обязательно), в другом модуле. extern int а, b, с; Конечно же, переменные должны где-то быть объявлены - это означает, что каждая пе- ременная должна быть объявлена в одном, и только одном, модуле (точно так же, как каждая функция должна быть определена только в одном модуле). Переменная создается с использованием стандартного объявления переменной, не обязательно с инициализацией. int а = О, b = О, с = 1; В этой книге большей частью я не использую многочисленные модули, поскольку для коротких примеров это представляет избыточность. Однако для действительно больших программных проектов использование многочисленных модулей предоставляет огром- ные преимущества: ✓ Возможно, наибольшее преимущество заключается в том, что многочисленные мо- дули' позволяют организовать одновременную работу нескольких программистов. Каждый программист может работать над своим собственным модулем. Если бы был только один модуль, только один программист смог бы писать код в любой заданный момент времени. Остальным пришлось бы, скучая, смотреть в окно. ✓ Многомодульный подход предоставляет еще один способ логического подразделения программы. Функции, которые специализируются на выполнении математических расчетов, могут быть помещены в один модуль, тогда как в другом модуле могут на- ходиться функции для работы с пользовательским интерфейсом. Это расширяет под- ход разделения труда, который возможен благодаря функциям. ✓ Можно контролировать уровень взаимодействия между модулями. Например, можно объявить глобальную переменную или функцию, не объявляя ее во включаемом фай- ле. В данном случае она будет принадлежать этому модулю. К этому имени могут обращаться функции, расположенные внутри модуля, но не функции извне. Последнее Только что описанное преимущество имеет связь с объектно-ориентированным программированием. В более старых языках, наподобие языка С, использование раз- дельных модулей было единственным способом создания групп символов (то есть функции и переменные), в которых некоторые из них были закрытыми (private), а дру- гие - открытыми (public). Это полезно для больших, сложных проектов. Программист несет ответственность за реализацию открытых элементов, однако он (или она) может также написать свои собст- венные вспомогательные функции, которые, как предполагается, не будет использовать кто-либо еще. Закрытая часть модуля защищена извне, таким образом, программисту никогда не нужно беспокоиться о том, что другие программисты вызывают эти закры- тые функции (или обращаются к закрытым данным) и в процессе создания всех видов предположений о том, как они работают. Открытое/закрытое разграничение является способом достижения инкапсуляции (encap- sulation) - понятия, которое я рассмотрю гораздо более подробно в следующих главах.
234 C++ без страха Важным является следующее: инкапсуляция является одним из важнейших преиму- ществ классов в языке C++. Если вы поймете, как это работает в случае с модулями, вам будет проще понять классы в языке C++. Как только весь код написан, каждый из модулей может быть скомпилирован и скомпо- нован вместе с другими модулями. Если вы правильно настроите проект в вашей среде разработки, это процесс компиляции и компоновки будет автоматизирован. Обработка исключений Обработка исключений является усовершенствованным способом реакции на ошибки во время выполнения. Гуру языка C++ иногда делают значительный акцент на обработке исключений - это отличный способ выполнить восстановление после ошибок, возник- ших в середине сложны программ. В коммерческом программном обеспечении это прак- тически является необходимостью. Однако для небольших программ обработка ошибок является, в лучшем случае, необяза- тельной - если не совершенно избыточной. Если у вас есть небольшие программы, запо- лоненные ошибками времени выполнения, вы, возможно, рассмотрите вопрос добавле- ния обработки исключений. Но вообще говоря, эта возможность полезна только в том случае, когда вы начали разрабатывать сложные программы, включающие множество вызовов функций. Имея эти разъяснения в виду, следующие несколько разделов представляют обработку исключений в языке C++, начиная с более простых понятий. Поприветствуем исключения В главе 1 я констатировал, что существует два основных типа ошибок, о которых вам, как программисту, необходимо беспокоиться, и что эти ошибки отличаются, поскольку для каждого типа требуются различные ответные действия. Чтобы повторить, существуют: ✓ Синтаксические ошибки, которые требуют от вас исправления кода перед тем, как вы сможете успешно скомпилировать программу. ✓ Ошибки логики программы, которые обнаруживаются только после того, как про- грамма была скомпилирована и протестирована.
ГЛАВА 9. Некоторые более сложные приемы программирования 235 Также существует третий тип ошибки - хотя он относится ко второй категории. Этот третий тип ошибки является особым из-за его влияния на программу. Эта третья категория соответствует ошибкам времени выполнения, или исключениям. Термин «исключение» относится к явлению во время выполнения, которое является «исключительным», поскольку прерывает нормальный процесс выполнения програм- мы. Такое явление требует незамедлительной обработки и обычно вызывает ошибку. Программа должна отреагировать на ситуацию, завершиться или сделать и то, и другое. Примеры исключений включают (но этим не ограничиваются) следующее: ✓ Попытка деления на ноль. ✓ Использование нулевого указателя в выражении, требующем действительный указатель. ✓ Ошибка при выделении запрошенного объема памяти. ✓ Арифметическое переполнение в процессе вычислений. Основная проблема, возникающая из-за исключений, следующая: когда в вашей про- грамме возникает одна из этих ситуаций, вы желаете, чтобы программа гладко обрабо- тала ситуацию и затем приняла бы разумное решение о том, стоит ли продолжать вы- полнение операции или нет. Иногда реакцией на исключение может быть печать сооб- щения «Operation not supported» (Операция не поддерживается), после чего выполнение программы продолжится. Или можно выбрать гладкое завершение программы после освобождения системных ресурсов и печати понятного для пользователя сообщения. Поведение «по умолчанию» - которое является нежелательным, особенно в коммерче- ском программном обеспечении - это внезапное завершение программы. Обработка исключений: первая попытка В небольшой программе - скажем, включающей несколько вызовов функций - можно выполнить проверку на сбойные ситуации и обработать их там, где вы их обнаружите. Например, можно предохранить функцию поиска наибольшего общего делителя (gcf), отыскивая нулевой делитель и решая проблему на месте. Самостоятельное обнаружение этого условия предотвращает внезапное, неприятное завершение программы с сообще- нием об ошибке, которое вы не выбирали. Вот как может выглядеть функция gcf (из главы 4), если к ней добавить код для обра- ботки ошибки. int gcf(int a, int b) { if (b == 0) { cout << "ERROR! Attempt to divide by zero." << endl ; return -1; } if (a % b == 0) return b; else return gcf(b, a % b); }
236 C++ без страха Что должно случиться после того, как была обнаружена ошибка? Здесь используется подход, возвращающий код ошибки -1. Но в этом подходе есть проблемы. Во-первых, значение -1 может быть допустимым значением, возвращаемым функцией gcf во время ее нормальной работы; следовательно, значение -1 конфликтует с нормальным возвра- щаемым значением. На самом деле, возвращаемого значения может и не быть, что мож- но рассматривать как сигнал ошибки. Во-вторых, если функция вызывается в середине сложной программы, код ошибки дол- жен быть «передан наверх» функциям, после того как другая функция вернула код ошибки, пока ошибка, наконец, не будет сообщена функции main. Вы не желаете писать такой код передачи ошибки. В сложной программе это стало бы настоящей головной болью. Альтернативный подход заключается в реагировании на ошибку на месте, завершая про- грамму вызовом функции exit. Теперь никакой сигнал ошибки передавать не нужно, поскольку программа просто завершилась. int gcf(int a, int b) { if (b == 0) { cout « "ERROR! Attempt to divide by zero." « endl ; exit(-1); } if (a % b == 0) return b; else return gcf(b, a % b); } Этот подход может отлично подойти для небольших программ. Но он имеет недостаток в сложном программном обеспечении: данный подход не позволяет сосредоточить обра- ботку ошибок в одном месте. Вместо этого в данном подходе обработка ошибок должна быть разбросана по всему коду. Существуют также и другие проблемы. Этот подход негибок и не дает шанса отреагировать другим частям программы. Введение в обработку исключений с помошью блока try-catch Последние версии языка C++ (особенно все версии, которые соответствуют стандарту ANSI) поддерживают ключевые слова try, catch и throw для обработки исключений. Использование этих ключевых слов иногда называется структурной обработкой исклю- чений, поскольку обеспечивается более изощренный и структурированный подход для работы с исключениями, чем в методах из предыдущего раздела. Чаще всего люди про- сто называют эту возможность языка C++ «обработкой исключений». Наиважнейшая возможность обработки ошибок языка C++ заключается в том, что она защищает не просто блок кода, но и все функции, вызываемые в этом блоке, и все функ- ции, вызываемые функциями в данном блоке, и так далее. Данный аспект обработки исключений не может быть достаточно подчеркнут. Это будто генерал армии передает команду всем своим войскам, причем некоторые результаты должны быть немедленно доведены до его сведения, минуя обычную цепочку командо-
ГЛАВА 9. Некоторые более сложные приемы программирования 237 вания. Даже простому рядовому предоставляется прямой канал связи с генералом, когда рядовой найдет то, что ищет генерал. Подобным образом обработка исключений позво- ляет сосредоточить в одном месте всю обработку ошибок, пропуская промежуточные функции, и освобождает вас от написания кода для передачи ошибок; Синтаксис включает ключевые слова try и catch: за одним блоком try может следовать любое количество блоков catch. В простом случае (показанном ниже) есть только один блок catch. try { инетрукции_блока_try } catch (тип аргумент) { инструкции_блока_саЬсЬ } Когда встречается такая структура, инструкции_блока_1/у выполняются безусловно; то есть инструкции_блока_}гу выполняются всегда - конечно, пока не возникнет исключение. Если возникает исключение - либо в самих инструкциях_блока_1гу, либо в функции, вызванной в процессе выполнения этих инструкций, оно может быть перехвачено и об- работано в блоке catch. Действие блока catch заключается в следующем: если тип возникшего исключения со- ответствует указанному типу, управление передается в блок catch. Если тип не совпада- ет, проверяется аргумент следующего блока catch и так далее до тех пор, пока не будет проверен весь список блоков catch. Если ни один из блоков кода не перехватил исклю- чение, программа завершается. Элементы в скобках, следующие за ключевым словом catch, могут включать тип аргумент или сам тип. Аргумент является необязательным. Этот синтаксис поначалу сложен, однако следующий пример должен помочь сделать его понятным. Предположим, что программа натолкнулась на следующие инструкции: try { open_files(); read_files(); process_data(); } ' . ' catch (int err) { error_handler_l(); } catch (double err) { error_handler_2(); } Программа безусловно выполняет инструкции, расположенные внутри блока try. Эти инструкции вызывают три функции - open_files, read_files и process_data. Во время выполнения данных функций может случиться несколько вещей. Во-первых, программа может выполниться нормально. В этом случае ни один из блоков catch вы- полнен не будет. Но предположим, что возникло исключение. В данном случае, если исключение имеет тип int, вызывается функция error_handler_l. Если исключе- ние имеет тип double, вызывается функция error_handler_2. ’
238 C++ без страха В связи с этими правилами возникает очевидный вопрос: какой тип может иметь ис- ключение? Ответ заключается в том, что исключение может иметь почти любой тип. Это может быть внутренний тип, например int, double или char. Это также может быть тип, определенный пользователем; сюда включается и тип string, представленный в конце главы 7," и тип, определенный с использованием ключевого слова class (которое я представлю в главе 11). Если вы сами генерируете исключение, вы определяете его тип. Этот как раз то, что де- лает ключевое слово throw. throw объект_исключения} Действие этой инструкции заключается в генерации исключения. Нормальный ход управления программой прерывается. Исключение должно быть перехвачено соответст- вующим блоком catch. Если исключение не перехвачено, программа завершается. Например, следующая инструкция генерирует исключение и присваивает ему тип int: throw 12; // Генерация исключения с кодом ошибки 12 Существует едва различимый, но важный момент, касающийся типов исключений. Тип исключения определяется программным путем; он необязательно относится к природе исключения. Например, исключение типа int не обязательно означает, что была про- блема с целыми числами, а исключение типа double не обязательно означает, что была проблема в вычислениях с плавающей точкой. В любом случае, сами исключения, на самом деле, не имеют типа. Более правильно ска- зать, то, что генерируется (и перехватывается), является объектом исключения, пред- ставляющий пакет данных, который может включать информацию об исключении. Некоторые типы исключений генерируются системой автоматически; такие объекты исключений генерируются со специальным типом exception. Эти исключения можно перехватить, используя данный тип exception. try { // . . . } catch(exception &e) { cout << "EXCEPTION RAISED: " « e.what() << endl; } Функция-член what класса exception возвращает текст сообщения об ошибке. Пример 9.3. Обработка исключений при использовании функции GCF Этот пример после запуска находит наибольший общий делитель для пары чисел 12 и 18 и для пары чисел 125 и 45. Затем он пытается вызвать функцию, что приведет к делению на 0. Условие ошибки распознается в функции gcf, однако обрабатывается ошибка в функции main.
ГЛАВА 9. Некоторые более сложные приемы программирования 239 Листинг 9.3. gcf except.cpp ttinclude <iostream> using namespace std; int gcf(int a, int b); int main() { try { cout << "gcf(12, 18) = " << gcf(12, 18) << endl; cout << "gcf(125, 45) = " << gcf(125, 45) << endl; cout << "gcf (5, 0) = 11 << gcf (5, 0) << endl; return 0; } catch (int err) { cout « "EXCEPTION RAISED! " « endl; cout << "Error num: " << err << endl; return err; } } int gcf(int a, int b) { if (b == 0) throw 1; if (a % b == 0) return b; else return gcf(b, a % b); 2 Как это работает Большая часть данного примера похожа на пример 4.3. Вот три важных отличия: ✓ Все инструкции в функции main, за исключением блока catch, помещены в блок try. Эти инструкции выполняются безусловно до тех пор, пока не возникнет исключение. ✓ Блок catch используется для обработки любого исключения, имеющего тип int. ✓ Функция gcf проверяет, было ли передано значение 0 в аргументе Ь; если это так, функция генерирует исключение и присваивает ему тип int. Использование типа int является отчасти произвольно выбранным. Тип int полезен, однако, для возврата обратно кода ошибки. Помните, что тип объекта исключения - это просто способ передачи информации из одной части программы в другую. Функция gcf проверяет, было ли в аргументе Ь передано значение 0. Если это так, код генерирует исключение, чтобы избежать деления на 0. if (b == 0) throw 1;
240 C++ без страха Данное исключение сгенерировано как целое число (в данном случае число 1). Управле- ние немедленно переносится из блока try, поскольку программа ищет соответствующий блок catch. Исключение перехватывается блоком catch в функции main, который обра- батывает любое исключение, имеющее тип int. Следовательно, выполняется следую- щий блок. catch (int err) { cout << "EXCEPTION RAISED! " <<-endl; cout << "Error num: " << err << endl; return err; } Последнее, что делает данный код, - это выполняет возврат (из функции main), переда- вая значение кода ошибки обратно операционной системе. Одна из наиболее важных особенностей данного примера заключается в том, что код, генерирующий исключение, находится в другой функции, чем код, обрабатывающий его. Это самое главное в обработке исключений в языке C++. Исключение, сгенериро- ванное в определенной функции, может быть обработано с помощью блока try-catch либо в этой же функции, либо в любом месте выше ее в иерархии вызовов функции. Упражнения Упражнение 9.3.1. Если ваш компилятор поддерживает тип string, описанный в кон- це главы 7, перепишите данный пример таким образом, чтобы он передавал объект ис- ключения, используя тип string вместо типа int. Этот тип исключения может быть более полезным, поскольку назад можно передать сообщение об ошибке. Примечание: можно преобразовать строку типа char* в объект типа string буквально на лету, используя код наподобие следующего: throw string("Division by error.");- Вставка Могу ли я использовать множество блоков try-catch? Можно использовать блоки try-catch так часто, как вы пожелаете. Возможно даже сделать так, что каждая функция будет иметь свой собственный блок try-catch. Однако это разрушит цель обработки исключений. Преимущество обработки исклю- чений в языке C++ заключается в том, что она позволяет сосредоточить обработку ошибок всего в нескольких местах. Часто может случаться, что исключение сгенерировано функцией в глубине иерар- хии вызовов функций; другими словами, функция main вызывает функцию, которая вызывает функцию, которая вызывает функцию, и так далее.,, которая, наконец, вы- зывает текущую функцию. Если возникает исключение, система будет искать соот- ветствующий блок catch по всей иерархии вызовов функций. Если существует несколько блоков try-catch в иерархии вызовов функций, управление передается ближайшему такому блоку. Например, блок try-catch в текущей функции или в функции, непосредственно вызвавшей ее, имеет преимущественное значение перед блоком try-catch, который полностью находится «по инстанции» в функции main.
ГЛАВА 9. Некоторые более сложные приемы программирования 241 По аналогии, рядовой, получающий приказы от своего сержанта, пойдет сначала к сержанту перед тем, как пойти к генералу. Если он не получал приказа от своего сер- жанта, рядовой пойдет вверх по лестнице, пока не найдет офицера, который ответит. Этот механизм реализован для максимальной гибкости. В функции main можно ус- тановить общие процедуры для обработки ошибок, но, как только будут вызываться другие функции, они могут добавить или изменить поведение по обработке ошибок соответствующим образом. Резюме Вот основные вопросы главы 9: ✓ Чтобы получить доступ к аргументам командной строки, объявляйте саму функцию main с двумя аргументами, argc и argv: int main(int argc, char *argv[]) { // . . . ' } ✓ Аргумент argc содержит количество аргументов командной строки, введенных поль- зователем, включая имя самой программы. ✓ Аргумент argv представляет собой массив указателей на строки, в котором каждая строка содержит аргумент командной строки, начиная с имени программы. Например: cout < < argv[0]; // Напечатать имя программы. cout < < argv[l]; // Напечатать сл е дующий э л еме н т // командной строки. cout < < argv[2]; // Напечатать следующий элемент // следующий за этим. ✓ Перегрузка функций позволяет писать различные версии одной и той же функции, используя тип аргументов для проведения различия между ними. Например, можно иметь различные версии функции swap: void swap (int *pl, int *p2) '{ int temp = *pl; * pl = *p21; * p2 = temp; } void swap(double *pl, double *p2); double temp = *pl; * pl = *p21; * p2 = temp; } ✓ Компилятор во время компиляции точно определяет, какую функцию вызвать, про- веряя тип аргументов. В данном примере тип переменных а и b определяет, какую версию функции swap использовать.
242 C++ без страха swap(а, b) ; ✓ Хотя, по- определению, перегрузка функции использует одно и то же имя функции, в ее результате создаются отличные функции. Для каждой из таких функций требуется отдельное объявление и определение. Несмотря на то, что они используют одно и то же имя - и тот факт, что они могут выполнять похожие действия, функции, на самом деле; отличаются друг от друга. ✓ Цикл do-while имеет следующий синтаксис. Эта управляющая структура похожа на цикл while за исключением того, что данная инструкция гарантирует, что действие будет выполнено, по крайней мере, один раз до того, как будет вычислено условие. do инструкция while [условие)} ✓ Использование синтаксиса составной инструкции чрезвычайно полезно в инструкции do-while. Например: do { i + + ; } while (card_drawn[i]) 1/ Если у вас есть более чем одна функция или глобальные объявления данных, можно подразделить вашу программу на несколько исходных файлов. Каждый из таких ис- ходных файлов является модулем. ✓ Помимо других преимуществ, использование нескольких модулей позволяет од- новременно работать более чем одному программисту над крупным программ- ным проектом. ✓ Функция может вызывать функцию, определенную в другом модуле, однако только в том случае, если вызываемая функция имеет прототип. По этой причине распростра- ненным способом является помещение прототипов всех общих функций в начало каждого модуля. Например: void calc(); int get_int(int); double get_dbl(double) ; 1/ Удобный подход для управления прототипами общих функций заключается в поме- щении прототипов в отдельный файл,; называемый «включаемым» файлом. Этот файл затем может быть прочитан в любой исходный файл при помощи директивы ttinclude. ttinclude "myproj.h" ✓ Для переменных, совместно используемых в программе, состоящей из нескольких модулей, в каждом модуле необходимо использовать объявление extern. Помимо объявления extern, каждая переменная должна быть определена - в одном и только одном модуле - при помощи синтаксиса стандартного объявления переменной. •/ Обработка исключений является методом, позволяющим сосредоточить обработку ошибок времени выполнения. Это будет наиболее полезно в сложном программном обеспечении. Ключевое слово try определяет блок инструкций, которые выполняют- ся безусловно. Блок catch или блоки, следующие за блоком try, могут обработать
ГЛАВА 9. Некоторые более сложные приемы программирования 243 сгенерированное исключение - либо инструкциями в блоке try, либо функцией, вы- званной во время выполнения этих инструкций. Одним из великолепных преиму- ществ данной возможности - особенно для сложных программ - является то, что об- работка исключений избавляет вас от необходимости написания кода, «передающего ошибку» из функции на нижнем уровне обратно до функции main. ✓ Некоторые типы исключений возникают автоматически (и генерируются как объек- ты, имеющие специальный тип exception). Однако можно также генерировать ис- ключения самим, используя ключевое слово throw. Этим исключениям можно при- сваивать любой допустимый тип.
ГЛАВА 1Q Станьте объектно-ориентированными Наиважнейшей разработкой в мире программирования за последние пятнадцать лет ста- ла разработка систем объектно-ориентированного программирования, ООП (OOPS - Object-Oriented Programming Systems). Это основная характерная особенность, отли- чающая язык C++ от его предшественника, языка С. Книги по программированию иногда заходят слишком далеко в утверждениях, сделан- ных для объектно-ориентированного программирования: оно решит любую проблему в программировании, которая возникала у вас когда-либо, освежит ваше дыхание, сделает вас популярным, улучшит экономику..словом, вы поняли мою мысль. Но система OOPS не всегда дает существенные преимущества при ее применении в не- больших программах. Это просто еще один набор инструментов, полезных для органи- зации большого количества программного кода в логические порции. Некоторые убеж- денные последователи OOPS утверждают, что эта система предоставляет лучшие мето- ды для моделирования объектов в реальном мире, и в этой идее есть доля истины. Сис- тема OOPS подходит для создания объектов, взаимодействующих с какой-либо сложной системой, - например, сетью или графическим интерфейсом пользователя. Тем не менее, если вы прорабатывали предыдущие главы этой книги, то вы уже исполь- зовали некоторые объекты - особенно объекты cin и cout (для консольного ввода и вывода), а также объекты файловых потоков. И это только начало. Зачем становиться объектно-ориентированным? Можно с уверенностью сказать, что наиважнейшая причина ошибок при программиро- вании заключается в том, что между различными частями программы существует слиш- ком много внутренних соединений, связей. Проблема возникает, когда множество данных используется совместно всеми функция- ми программы (типичная ситуация для больших проектов). Одна функция предполагает, что некая переменная имеет неизменяемое значение, однако другая функция изменяет его. Я демонстрировал пример такой ситуации в главе 4, в которой одна скромная пере- менная, i, приводит к серьезной ошибке, если ее сделать глобальной. Так как же ограничить доступ к глобальным переменным? В больших проектах в этом отношении существует определенная анархия. Локальные данные являются закрытыми (слава Богу!), однако все функции имеют доступ к глобальным переменным в програм- ме, или, по крайней мере, в том же модуле.
ГЛАВА 10. Станьте объектно-ориентированными 245 Объектно-ориентированное программирование вносит порядок в этот хаос. Вместо того, чтобы предоставлять неограниченный доступ к глобальным данным всем функциям, вы определяете различные классы, которые обладают привилегией доступа к определенным данным. К данным класса могут иметь доступ только функции этого класса. (В действи- тельности можно выбирать, делать ли каждое поле данных открытым или закрытым (public or private), но пока давайте предположим, что просто делаем его закрытым.) Предположим, что вы разработали объект Circle (Окружность), предоставляющий опре- деленные сервисы для пользователя данного объекта: окружность можно перемещать, изменять ее размер или перерисовывать. Эти сервисы реализуются как функции, кото- рым необходим доступ к общему банку данных, например, к положению и размеру ок- ружности, однако вы не желаете, чтобы кто-то извне класса имел возможность доби- раться до этих данных и непосредственно изменять их. Этого можно достичь путем размещения связанных функций и данных в общем классе. Любой может вызывать функции класса Circle, однако извне получить доступ к внут- ренним данным невозможно. Класс Circle Эта важная особенность объектно-ориентированного программирования называется ин- капсуляцией (encapsulation) - необычное слово для обозначения сокрытия подробностей. Строковый анализатор В главе 7 я использовал функцию get_a_string для разбора строки: при каждом вызове функции данные читались до следующей запятой и помещались в результирующую строку. for (i = 0; i < 10; i++) { pos = get_a_string(buffer, strs[i], pos); if (pos == -1) break; ) Этот код не самый простой для понимания и требует от вызывающей функции объявле- ния и хранения дополнительной переменной pos.
246 C++ без страха Было бы хорошо вместо этого использовать объект, который бы поддерживал свои соб- ственные данные. Все, что вам потребовалось бы сделать для выполнения работы в дан- ном случае, - это создать объект и затем вызвать одну из его трех функций: Функция Описание get(char *s) Получает следующую подстроку и помещает ее в результирую- щую строку s. Возвращает значение true, если остались данные для чтения; значение false в противном случае. more() Возвращает значение true, если остались данные для чтения; зна- чение false в противном случае. reset() Возвращает указатель текущей позиции на начало строкового буфера. Мысленно, можно представить класс объекта, как показано на следующем рисунке. Класс StringParser Неуклюжий цикл функции get_a_string, показанный ранее, может быть заменен этим более элегантным кодом: i = 0; while (parser.more()) parser.get(strs[i++]); Или можно воспользоваться возвращаемым значением функции «get» (которое равняет- ся true, если во входной строке остались данные для чтения), чтобы написать еще более сжатый код: i = 0; while (parser.get(strs[i])) i + +;
ГЛАВА 10. Станьте объектно-ориентированными 247 ------------------------------------------------------------w---------------- Использование данного объекта анализатора вносит в ваш код лишь скромное улучше- ние. Но это начинает наводить на мысль об особенности объектно-ориентированного программирования. Важный момент: вы можете скрывать подробности. Большая часть такой же функциональности обеспечивается стандартной функцией библиотеки языка C++, strtok. Однако это решение менее объект- но-ориентированное; с одной стороны, за один раз эта функция может приме- няться только над одной строкой, в отличие от объекта String-Parser, опи- санного в главе 14 этой книги. Помимо этого, класс Stringparser является эффективным примером, даже если большую часть его функциональных воз- можностей дублирует функция strtok. Объекты в сравнении с классами Так что же такое класс и что такое объект? Чем они отличаются? Класс является элементом организации программы: он содержит код и информацию о типе для одного типа, определенного пользователем. Класс объявляется посредством определения всех его компонентов - сюда включаются данные-члены (data members), составляющие содержимое класса, и функции-члены (member functions), определяющие, что делают объекты класса. После объявления класса (или включения объявления класса, написанного кем-то еще), можно создать один или более объектов. Объект - это экземпляр одного класса. Это от- ношение «один ко многим». Основные правила следующие: Класс - это тип данных, определенный пользователем, который может иметь * как данные, так и код функций. Объект - это экземпляр типа; число экземпляров может быть любым. Важное, что нужно запомнить: класс является типом, определенным пользователем. В качестве аналогии: существует один тип int, но можно иметь любое число перемен- ных типа int. Подобное отношение существует между классами и объектами. Для лю- бого заданного класса можно объявить любое число объектов. Другой пример: класс Fraction На некотором уровне использование объектов на самом деле касается использования языка для расширения понятия о типе данных. Можно использовать язык С++«для опре- деления фундаментально новых типов, которые, с точки зрения синтаксиса, по большей части работают так же, как стандартные типы, например int, char, float и double. Примером является гипотетический класс Fraction (Дробь). Одно ограничение типов с плавающей точкой (типы float, double) заключается в том, что они не могут хранить большинство дробных чисел с абсолютной точностью. Неважно, сколько разрядов после
248 C++ без страха десятичной точки вы имеете, вы никогда не сможете точно представить число 1/3 в двоич- ной (или, если на то пошло, в десятичной) системе. Даже если у вас будет сто тысяч разрядов точности справа от десятичной точки, всегда будет какая-то поврешность, хотя и небольшая! Используя методы, описанные в данной книге, вы можете создать настоящий тип дан- ных Fraction (некоторые люди могут называть его типом «дробное число»), который хранит отношение любых двух целых чисел с абсолютной точностью. Сначала нужно объявить класс, после чего объявить объекты типа Fraction точно так же, как вы делаете это с другими типами данных. Fraction а, Ь, с; После объявления этих объектов их можно также проинициализировать. Трюк здесь за- ключается в том, что для инициализации объекта используется список аргументов. Это одно из очень немногих разтличий, с точки зрения синтаксиса, между классами и объек- тами и внутренним типом языка C++ (например, типом int). Fraction а(1, 2); // а = 1/2 Fraction Ь(1, 3); // b = 1/3 Для классов можно также определять операторные функции (operator function), по- зволяющие объектам данного класса работать с любым оператором языка C++, кото- рым пожелаете. С числовым классом, наподобие класса Fraction, общепринято реализовывать поддержку основных числовых операций ( + , -, *, /), присваивания (=) и потокового оператора (<<) для использования с объектом cout и другими по- токами файлового вывода. Fraction а(1, 2); // а = 1/2 Fraction b(l, 3); // b = 1/3 Fraction с; с=а+Ь; //с=1/2+1/3 cout « с; // Print "5/6". Все это может выглядеть, как волшебство, однако в языке C++ это работает. Создание и уничтожение объектов Защита данных не является пустяком в сложных программах или даже в программах среднего размера, взаимодействующих с чем-то сложным, например, с операционной системой Microsoft Windows. В случае с классом Fraction вы можете добавлять его в свой проект, не беспокоясь о том, сможет ли оставшаяся часть программы нечаянно из- менить данные объекта Fraction. Класс создает отдельное пространство имен. Кроме того, классы в языке C++ делают даже больше, чем только это. Процесс создания, инициализации и уничтожения объектов автоматизирован. Можно даже разработать не- сколько способов, позволяющих пользователю создать и проинициализировать объект. Например, для класса строкового анализатора: // Создать анализатор без начальных значений. // (Проинициализировать позднее.) StringParser parserl;
ГЛАВА 10. Станьте объектно-ориентированными 249 // Указать входную строку, использовать разделитель по // умолчанию (", ") . StringParser parser2("Me, Myself, and I"); // Указать и входную строку, и разделитель. StringParser parser3("I:Thee:Thou", Для каждого типа списка аргументов класс должен определять отдельный конструктор (constructor). Как вы увидите в главе 12, конструкторы свободно используют перегрузку функций языка C++, что является причиной того, почему в языке C++ важна перегрузка. Иногда при создании или уничтожении объекта должны быть выполнены особые дейст- вия. Помимо инициализации, конструкторы могут выполнять любую необходимую вспомогательную операцию, например выделение памяти или открытие файла. Подоб- ным образом деструкторы (destructors) могут выполнять любую необходимую очистку при уничтожении объекта. Наследование, или создание подклассов Хотите - верьте, хотите - нет, но большую часть объектно-ориентированных возможно- стей, описанных до настоящего времени, на самом деле можно реализовать на старом языке С - хотя для этого потребуется гораздо больше работы (в основном, через исполь- зование отдельных модулей) - если не считать пары исключений: ✓ В большинстве языков не поддерживаются операторные функции. Однако многие из программистов не считают это смертельным дефектом; можно выполнить любое действие, вызывая функции-члены вместо использования операторов. Перегрузка операторов является приятным бонусом, но она не нужна каждому. ✓ Автоматизация создания, уничтожения и инициализации объекта не может быть ав- томатизирована в традиционном языке. Пользователь, использующий тип, опреде- ленный пользователем, на языке С (структуру) вынужден явно вызывать любые функции, необходимые' для создания или уничтожения. Например, пришлось бы соз- дать структуру Circle, после чего вызвать функцию lnit_Circle. И снова это не смер- тельно, просто представляет некоторое неудобство. Теперь мы подошли к возможности классов языка C++, которая совсем не поддержива- ется традиционными языками, например, С... этой возможностью является наследова- ние, или, более формально,, создание подклассов. Позвольте объяснить, почему это мо- жет быть полезным. Для создания повторно используемого кода не существует волшебных формул. Ваши функции, модули и классы будут повторно используемыми, только если вы включите в них достаточную функциональность, при этом сделав их достаточно гибкими для ис- пользования во множестве ситуаций. Наследование адресуется к вопросу о гибкости. В качестве аналогии рассмотрим, как автомобильные дилеры процветают на продаже изделий по заказу: - О, это очень хорошая машина, но если бы -можно было добавить кондиционер и люк. - Да, сэр! И я продам вам их по моей цене - плюс небольшой гонорар.
250 C++ без страха Также существуют возможности, называемые ^рынком запчастей» (after-market), благо- даря которым владелец добавляет к машине детали, о которых производитель даже не знает. Моя точка зрения следующая: делая серьезные покупки, пользователь, вероятнее всего, будет удовлетворен, если он win она сможет переделать объект для удовлетво- рения своих персональных нужд. Подобным образом класс, вероятнее всего, будет полезен, если пользователи этого клас- са смогут добавить любые возможности, которые они пожелают, включая возможности, о которых создатель класса никогда не думал. Наследование упрощает это - несколькими способами. В случае с классом Fraction он может быть полезен, однако может понадобиться другая возможность: например, возможность автоматического получения эквивалентного дроби значения с плавающей точкой (или, точнее, ее ближайшего приближения). Для выполнения этого необходимо объявить новый класс и добавить всего одну новую функцию. class FloatFraction : public Fraction { public: double get_float(); }; После этого необходимо написать определение функции. (Пока не беспокойтесь о син- таксисе - я раскрою его в следующей главе.) double FloatFraction::get_float() { double x = get_num(); double у = get_den(); return x / y; ) Здесь я сделал предположение, что функции get_num и get_den возвращают числи- тель и знаменатель объекта Fraction соответственно. При наличии этих функций, как вы можете заметить, написать функцию get_f loat легко. Но это притирка. Чтобы написать действительно полезные расширения существующего класса, необходимо знать, что происходит внутри класса, - вам может понадобиться войти внутрь и внести беспорядок. И если кто-то другой написал класс, он может быть вынужден раскрыть большую часть внутренней структуры класса, чтобы вы по- настоящему смогли подогнать его для своих нужд. Это немного противоречит смыслу инкапсуляции, или сокрытия данных. Тем не менее наследование может быть полезным. В этом разделе вы увидели, как с по- мощью нескольких строк кода был создан новый класс, который делает не только все то, что и класс Fraction, но и многое помимо этого. Теперь можно объявлять объекты FloatFraction и использовать новую функцию get_f loat: FloatFraction а; a.set(3, 4); // а = 3/4 cout << а.get_float(); // Печать "0.75"
ГЛАВА 10. Станьте объектно-ориентированными 251 Создание обших интерфейсов Наследование важно не только само по себе, но и потому, что в языке C++ оно явля- ется единственным способом реализации возможности, считающейся совершенно необходимой в объектно-ориентированном программировании: общие интерфейсы (shared interface). Термин интерфейс имеет много значений. В данном контексте я подразумеваю набор сервисов (или функций), который различные объекты могут свободно реализовывать своим собственным способом. Это тонкий, но ключевой момент. Класс объекта определяет код всех его функций - та- ким образом, все объекты одного и того же класса ведут себя одинаково. Но объекты других, но связанных классов могут определять свои собственные ответные действия на общий интерфейс. Например, предположим, что у нас есть общий класс Shape, определяющий несколько функций: move, redraw и size (только три, для простоты). Сам класс Shape не имеет кода, определяющего функции; он используется в качестве общего интерфейса {абст- рактного класса, если использовать терминологию языка C++). Единственное, что дела- ет интерфейс, - это устанавливает информацию о типе для каждой функции. Важным моментом здесь является то, что различные подклассы (например Circle, Square и Polygon) реализуют одни и те же три функции - однако каждый класс отвечает на эти функции по-разному. И это важно, в свою очередь, поскольку любой может вызвать общую функцию класса Shape (например redraw) без необходимости заранее знать, каким будет результирую- щее поведение. Теперь красота этой системы заключается в том, что можно использовать указатель на общий тип (Shape) для вызова любого из этих сервисов. Точный тип объекта будет оп- ределен позднее, во время выполнения. pShape->redraw(); Это нотация языка C++ для: (*pShape).redraw();
252 C++ без страха Другими словами: получите объект, на Который указывает переменная pShape, а затем пошлите ему сообщение: «Нарисуй себя!» Но если все, что имеет программа, это указатель на общий тип (Shape), то как выполня- ется правильный код функции для определенного типа (например Circle или Square)? Это тема следующего раздела. Полиморфизм: настоящая независимость объектов Описывая, как работают общие интерфейсы, я, в конце концов, пришел к понятию поли- морфизма. Это наиболее абстрактное понятие в данной книге, однако это то, что множе- ство людей считает наиболее важным. И это правда, несмотря на то, что в небольших программах или программах среднего размера вы можете никогда не столкнуться с практической ситуацией применения по- лиморфизма. Однако в любом коде, взаимодействующем с большей, более сложной сис- темой (скажем, операционной системой Microsoft Windows или сетью), полиморфизм является необходимым; он неявно присутствует во всем, что вы делаете. Вы не интересовались, почему программисты ссылаются на языки, наподобие языка C++, как на объеюлло-ориентированные, а не как на класс-ориентированные? Для этого есть причина. Объектно-ориентированное программирование, в конечном счете, направ- лено на передачу управления от основной программы объекту. Другими словами, это можно сформулировать, как: Знания о том, как выполнить действие (также называемое функцией или * сервисом), находятся в самом объекте, а не у пользователя данного объекта. Это сущность полиморфизма, которая - более буквально - означает, что реализация от- дельной функции может принимать различные формы. Почему это так важно? Хорошо, рассмотрим, как выполняются действия в традицион- ном программировании. Можно создать тип видов, определенный пользователем - в языке С для этого используется объявление struct с последующим перечислением ряда открытых полей данных. Одно из таких полей может содержать целое число, указываю- щее на определенный тип объекта: например окружность, квадрат или многоугольник. Теперь каждый из этих определенных видов фигур должен быть нарисован по-разному. Таким образом, пользователь типа Shape вынужден будет проверять определенный тип объекта и выполнять различные действия в зависимости от этого значения. if (the_shape.type == CIRCLE) draw_circle (the__shape) ; else if (the_shape.type == SQUARE) draw_square(the_shape); else if (the_shape.type == POLYGON) draw_polygon(the_shape); / / . . . Или используя синтаксис switch-case, представленный в главе 9: switch(the_shape.type) { case CIRCLE:
ГЛАВА 10. Станьте объектно-ориентированными 253 draw_circle(the_shape); break; case SQUARE: • draw_square(the_shape) ; break; case POLYGON: draw_polygon(the_shape); break; // . . . Если существует много таких подтипов, для написания этого блока кода потребуется много работы. Но есть другая, гораздо более фундаментальная проблема. Пользователь типа Shape должен знать заранее все типы, которые будут поддерживаться. Как только этот код написан и скомпилирован, данный список подтипов будет закрыт. Невозможно будет добавить новые типы без возврата назад и переписывания основной программы. И не важно, как много вариантов подтипов вы попытаетесь собрать, существует еще одно довольно серьезное ограничение: в будущем невозможно будет добавить но- вые типы... даже переписав и скомпилировав заново программу, использующую данный объект. Выступая в роли изобретателя нового подтипа объекта, вам не хотелось бы быть вынуж- денным говорить вашим пользователям: «Вы можете добавить этот новый подтип, одна- ко вам придется вернуться обратно, переписать и скомпилировать заново весь код, ссы- лающийся на этот тип, добавив дополнительные метки case во всей программе». И будет еще хуже. Вы можете предоставлять сервис в сети или сервис, взаимодейст- вующий с интерфейсом, наподобие операционной системы Microsoft Windows. И пользо- ватель объекта в дальнейшем вовсе не сможет переписать и скомпилировать код. Вы не сможете просить компании Microsoft создать новую версию операционной системы Win- dows, например, каждый раз после написания нового Windows-приложения. Операцион- ная система Windows должна иметь возможность посылать общие сообщения, например «перерисуй себя», заранее не зная всех ответных реакций, которые могут быть. Полиморфизм и виртуальные функции Ограниченный тип полиморфизма может быть достигнут при использовании перегрузки функций и операторов. Это не настоящее решение, однако в некоторых случаях его ока- зывается достаточно. Например, одной из приятных возможностей языка C++ является то, что можно определить новый тип объекта (класс), а также определить, как он будет под- держивать потоковый оператор (<<), используемый с потоками вывода, например cout. Fraction fractl(l, 2); cout « fractl; ‘ // Печать "1/2" На первый взгляд, объект cout кажется полиморфным; то есть вы можете использовать его для печати бесконечного множества типов. Поскольку можно определить оператор- ную функцию « для каждого нового создаваемого типа, для любого класса объекта все- гда можно заставить работать следующую инструкцию: cout << my_object;
254 C++ без страха Ограничение здесь заключается в том, что язык C++ должен иметь возможность опреде- лить точный тип объекта во время компиляции. Настоящего полиморфизма достигнуть не удается. Если программа передала указатель на общий тип объекта по сети, например, объект cout не будет знать, как напечатать его. Следующая инструкция некорректно разрешает операцию: cout << *pObject; // Печать какого типа объекта??? Существует способ для разрешения такой ситуации. Можно создать общий тип «печа- таемого объекта», который корректно взаимодействует с объектом cout (между про- чим, так же, как и с любым другим потоком вывода). Решение в данном случае и во всех похожих заключается в использовании виртуальных функций. Красота ключевого слова virtual в том, что оно заставляет функцию в вашей программе, как и код, вызывающий ее, делать все правильно. Все обрабатывается для вас с тем, чтобы вызов виртуальной функции выглядел точно так же, как вызов любой другой функции-члена. Ключевое слово virtual говорит следующее: «Неважно, как получена ссылка или указа- тель на объект, позвольте объекту самому решить, как выполнить вызов функции». Это означает, что адрес реализации этой функции в объекте отыскивается во время выпол- нения. Этот процесс называется поздним связыванием (late binding), поскольку реальный адрес функции не определяется до тех пор, пока не выполнен вызов функции. И в этом заключена сущность объектов в языке C++. Несмотря на тот факт, что по этой теме написаны толстые тома, на самом деле сущность заключается в создании объектов данных, которые «знают», как предоставлять набор сервисов - в которых знания о том, как выполнять эти сервисы, хранятся в самих объектах. Это похоже на то, что каждый объект работает, как миниатюрный компьютер, независи- мая сущность, которая поддерживает свои собственные данные, посылает и принимает сообщения, отвечая соответствующим образом. Основной программе никогда не прихо- дится сообщать объекту, каким образом выполнять его работу. Вставка Полиморфизм и традиционные языки Я утверждал, что полиморфизм необходим при написании приложения для операци- онной системы Windows или при предоставлении сервиса в сети. Однако множество людей в прошлом писали такие приложения, не имея доступа к объектно- ориентированному языку. Тем не менее я придерживаюсь своего утверждения. В традиционном языке, например С, существует способ создания полиморфного эф- фекта, хотя этот подход является громоздким, подверженным ошибкам и менее эле- гантным, чем подход в языке C++. Метод языка С регистрирует, функцию обратного вызова (call-back function): это включает предоставление пользователю объекта (или, в случае операционной систе- мы Windows, администратору приложений) адреса одной или более функций. Ис- пользуя этот адрес, пользователь объекта может выполнить косвенный вызов функ- ции во время выполнения, чтобы передать управление предоставленной функции.
ГЛАВА 10. Станьте объектно-ориентированными 255 Результат на уровне машинного кода такой же, как и при использовании виртуальной функции. Так в чем же тогда недостаток подхода, используемого в языке С? На самом деле, их два: ✓ Как и для множества объектно-ориентированных возможностей, можно воспроиз- вести такие же результаты и в языке С, однако для этого потребуется гораздо больше работы. В данном случае дополнительная работа будет включать регист- рацию адреса одной или более функций и выполнение косвенных вызовов функ- ций (требующих синтаксиса указателей). Виртуальные функции определенно проще в использовании. ✓ Подход языка С обеспечивает гораздо меньшую проверку типов. Вам ничего не помешает передать функцию с неправильным списком аргументов. Подход языка C++ гораздо более строгий, поскольку он принудительно выполняет строгую про- верку типов для всех функций. Язык C++ также требует от класса реализации всех функций интерфейса, если он вообще собирается поддерживать интерфейс. Общая ситуация с не объектно-ориентированными языками, например С, следующая: да, вы можете создавать графические приложения, сетевые решения и приложения для графического пользовательского интерфейса, например, операционных систем Macintosh или Microsoft Windows. Однако язык C++ с его возможностью определять независимые объекты лучше подходит для таких задач. Как насчет возможности повторного использования? Некоторые настойчиво твердят, что основная мотивация объектно-ориентированного программирования заключается в том, что оно предоставляет наилучшие возможности для написания повторно используемого кода. Эту мысль нельзя сбрасывать со счета. Как я уже отмечал в главе 4, основная цель в ис- тории методов программирования заключалась в том, чтобы можно было легко исполь- зовать уже написанный код для новый целей, использовать таким образом, чтобы про- граммистам не приходилось писать одни и те же инструкции снова и снова. В действительности основным инструментом в данном отношении является использова- ние функций. Существование стандартной библиотеки языка C++ - доказательство этого факта. Вам не придется когда-либо писать инструкции для вычисления квадратного кор- ня, например. Все, что нужно сделать - это вызвать функцию sqrt: ttinclude <math.h> double sq2 = sqrt(2); Функция sqrt не только экономит время, которое потребовалось бы для написания ко- да вычисления квадратного корня, она также экономит время, необходимое для изуче- ния, как вычислить квадратный корень самому. Это не просто механическая работа по написанию предоставленного кода функции sqrt; это также является экспертными зна- ниями. Если бы вам пришлось написать функцию самому, вы, вероятно, вынуждены были бы искать алгоритм для нахождения квадратных корней. (Подобным образом, если вы покупаете машину, вы покупаете не просто гору металла; вы приобретаете достиже- ния веков инженерного прогресса.)
256 C++ без страха По аналогии, любой прогресс в человеческой индустрии основан на следующей предпосылке: существование старых инструментов позволяет создавать еще более современные инструменты, поскольку (так как уже есть старое) нет необходимости каждый раз заново изобретать колесо. Идея повторно используемого кода безусловно не принадлежит системам ООП. Однако эти системы делают попытку взять возможность повторного использования, получае- мую при помощи функций, и расширить ее. Вот как объекты могут потенциально сделать ваш код более пригодным для повторного использования: V Класс является удобным способом компоновки тесно связанных кода и данных. Воз- можность скрыть большую часть класса полезна для создания повторно используе- мого кода, поскольку это предотвращает как ошибки, связанные с конфликтом имен, так и ошибки, которые образуются в результате обращения других программистов, не имеющих права вносить беспорядок, к внутренним элементам класса. V Наследование (создание подклассов) помогает сделать классы более пригодными для повторного использования, позволяя пользователям класса настраивать его. К сожа- лению, эта возможность немного противоречит цели сокрытия данных (инкапсуля- ции), поскольку чем более открытым вы делаете класс, тем менее защищены его внутренние члены. V Разработка общих интерфейсов предоставляет возможность создания кода, более пригодного для повторного использования, принимая стандартный набор сервисов. Мысленно вы создаете ситуацию, в которой множество поставщиков сервисов и множество клиентов сервисов могут взаимодействовать при помощи общего набора функций. Поскольку интерфейсы являются полиморфными, клиентское программное обеспечение может даже получить указатель на новый тип объекта во время выпол- нения и использовать его с выгодой - хотя этот новый тип даже не существовал, ко- гда было создано клиентское программное обеспечение. Последнее преимущество потенциально является наиболее захватывающим. Разработка стандартных интерфейсов означает, что новое программное обеспечение может разрабаты- ваться непрерывно и без помех взаимодействовать со старым программным обеспечением - все это без необходимости переписывания и повторной компиляции кода. (Избежать такого переписывания, на самом деле, это важнейшая задача повторного использования кода.) Но разработка стандартов - это человеческий проект, требующий документации, тща- тельного проектирования и - если можно позаимствовать термин из религии - еванге- лизма. Объектно-ориентированное программирование предоставляет для этого ряд по- лезных инструментов, однако их нужно применять с осторожностью и тщательным пла- нированием. В нашей жизни ничего не гарантировано. Резюме Вот основные моменты главы 10: ✓ Одной из важнейших проблем при разработке программного обеспечения всегда яв- лялось сложное взаимодействие между всеми функциями программы и всеми ее гло- бальными данными. Одной из основных задач объектно-ориентированного програм- мирования является уменьшение количества всюду доступных глобальных данных.
ГЛАВА 10. Станьте объектно-ориентированными 257 ✓ Вместо этого, тесно связанные данные и функции вместе группируются в класс. За- тем можно сделать эти данные закрытыми для всех, кроме данного класса. (Или, как вы увидите в следующей главе, закрытыми можно сделать столько полей, сколько пожелаете.) Это сокрытие частей класса называется инкапсуляцией. V Функция, определенная внутри класса, называется функцией-членом (member- function). V Поле данных класса называется данными-членами (data member). t/ Класс, как я отмечал выше, является единицей кода, определяющей новый тип. V Каждый экземпляр класса является объектом. Отношение между классами и объек- тами такое же, как между типом int и отдельными переменными типа int. Напри- мер, если был определен класс Fraction, можно объявить любое число перемен- ных типа Fraction. Они являются объектами. Fraction fl, f2, f3; ✓ При определении нового класса можно создать подкласс существующего класса (это также называется наследованием от этого другого класса). Существующий класс на- зывается базовым классом. Эта возможность потенциально является великолепным способом, экономящим время, поскольку уже определенные общие возможности класса не нужно определять снова. По существу вы создаете подкласс, основанный на старом классе, и объявляете только новые возможности. class FloatFraction : public Fraction { public: double get_float(); ); V Возможно, наиболее важным аспектом создания подклассов является то, что это единственный способ в языке C++ определить общие интерфейсы. Интерфейс в дан- ном контексте - это стандартный набор сервисов, реализованный в виде совокупно- сти функций, которые могут быть вызваны пользователем класса. . ✓ Иерархия наследования - в которой каждый подкласс из любого числа подклассов наследуется от общего интерфейса (абстрактного класса) - позволяет языку C++ осуществлять проверку типов для всех функций и аргументов. Однако каждый под- класс может свободно отвечать на вызов функции своим собственным способом. ✓ Вызовы функций класса по данным условиям являются полиморфными, если функ- ции объявлены с ключевым словом virtual. Полиморфизм означает «множество форм»: вызов одной функции может иметь различные реализации. ✓ Важным принципом в идее полиморфизма в языке C++ является следующий: неваж- но, как получена ссылка или указатель на объект, выполняется правильный код функции, хотя вы можете и не знать точный тип объекта. Это происходит потому, что знания о том, как предоставить сервис, хранятся в каждом объекте, а не в коде, использующем эти объекты. ✓ Объектно-ориентированное программирование может помочь в поддержке разработ- ки повторно используемого кода, но, в конечном счете, это всего лишь набор инст- рументов. Большая часть работы по созданию повторно используемого кода требует человеческих усилий. 9 - 6248
ГЛАВА 11 Класс Fraction Некоторые говорят об объектно-ориентированном программировании, как о чем-то про- стом: взмахнули волшебной палочкой языка C++ - и все проблемы исчезли. Однако для написания полезного класса требуется серьезное размышление и планирование. Чтобы класс стал повторно используемым - иными словами, предложить столько возможно- стей, что другие программисты воспользуются им, - необходимо вложить в него серьез- ную функциональность. Но, с другой стороны, однажды разработав, написав и протестировав класс, который действительно отвечает вашим требованиям, вы создаете мощное дополнение к языку C++. В качестве альтернативы можно использовать классы и объекты как способ упа- ковки вместе кода и данных. Поскольку задача связана с написанием реального класса, в нескольких следующих гла- вах я собираюсь сфокусировать внимание, в основном, на одном классе: классе Frac- tion, который (когда мы его создадим) будет решать практическую задачу, предостав- ляя новый способ для хранения и обработки чисел. Класс Point: простой класс Перед тем как перейти к классу Fraction, давайте рассмотрим более простой класс. Однако, для начала, вот синтаксис ключевого слова class языка C++: class имя_класса { объявления ' Кроме того случая, когда вы пишете подкласс, синтаксис не сложнее, чем этот. Объяв- ления могут включать объявления данных, объявления функций или и тех, и других. Вот простой пример, в котором объявляются только данные. .5,;. class Point { int х, у; // закрытые — не могут быть доступны Но, по умолчанию, члены являются закрытыми (private), что означает, что они не могут быть доступны. Таким образом, первая попытка объявления класса Point бесполезна. Чтобы от класса была хоть какая-то польза, необходимо, чтобы он имел хотя бы одйн открытый (public) член. class Point { public: int x, у; }; Это уже лучше. Теперь класс на самом деле может быть использован. Имея объявление класса для класса Point, можно объявлять отдельные объекты типа Point, например pt1. Point ptl;
ГЛАВА 11. Класс Fraction 259 После этого можно присваивать значения отдельным полям данных (называемых дан- ными-членами, data members): ptl.x =1; В этом примере, не содержащем функции-члены, класс Point можно считать просто, как набор полей данных. Каждый элемент, объявленный с типом Point, имеет члены х и у, и, поскольку они являются целочисленными, их можно использовать точно так же, как и любую другую целочисленную переменную. cout << ptl.y +4; // Напечатать сумму двух целых чисел. Перед тем как оставить простую версию класса Point, стоит прокомментировать один аспект синтаксиса. Объявление класса завершается точкой с запятой. class Point { public: int x, у; }; Когда вы только начинаете писать код на языке C++, точка с запятой станет тем объ- ектом, на котором легко будет споткнуться. После закрывающей фигурной скобки (}) при объявлении класса запятая необходима, тогда как при определении функции такое же использование точки с запятой недопустимо (то есть вы получите синтак- сическую ошибку). Помните следующее важное правило: Объявление класса, данных или данных-членов всегда заканчивается точ- ♦♦♦ кой с запятой, без исключений. Это верно даже тогда, когда присутствует закрывающая фигурная скобка (}). Таким образом, за закрывающей фигурной скобкой при объявлении класса ставится точ- ка с запятой, а при определении функций - нет. Вставка Для программистов на языке С: структуры и классы В языке C++ ключевые слова struct и class эквивалентны, за исключением того, что члены структуры (struct) по умолчанию являются открытыми. Однако оба ключевых слова создают классы в языке C++. (Это означает, что общий термин «класс» и клю- чевое слово class не совсем одинаковы.) В языке С, когда вы объявляете тип struct, приходится повторно использовать клю- чевое слово struct всякий раз, когда применяете структуру в качестве имени типа. Например: struct Point ptl, pt2, pt3; 9*
260 C++ бёз страха Но в языке C++ это никогда не нужно делать. Как только класс Point был объ- явлен в качестве нового типа (при помощи либо ключевого слова struct, либо class), его можно непосредственно использовать. Поэтому после переписывания кода, написанного на языке С, на языке C++ можно заменить приведенные объяв- ления данных следующим: Point ptl, pt2, pt3; Интерпретация ключевого слова struct в языке C++ возникает из необходимости об- ратной совместимости. В коде на языке С часто применяется свободное использова- ние ключевого слова struct: struct Point { int x, у; }; В языке С нет ключевых слов public или private, и пользователь типа struct должен иметь доступ ко всем членам. Следовательно, для поддержания совместимости чле- ны типа struct по умолчанию должны быть открытыми (public). В то же время задача разработки заключалась в том, чтобы сделать структуры языка С легко расширяемы- ми. Таким образом, если захотите, в типы struct можно добавлять функции-члены. Нужно ли тогда в языке C++ ключевое слово class? Технически нет. Однако ключе- вое слово class выполняет функцию самодокументирования, так как цель класса обычно заключается в добавлении функций-членов. Назначение класса закрытым по умолчанию является хорошей практикой. В объектном ориентировании открытые члены создаются только при сознательном выборе программиста. Закрытые данные: допуск только для членов клуба! (зашита данных) В предыдущем разделе класс Point разрешал непосредственный доступ к своим дан- ным-членам, поскольку они были объявлены открытыми. Что вы делаете, когда желаете предотвратить непосредственный доступ к данным- членам? Вы, возможно, например, захотите убедиться, что данные, присвоенные двум членам (х, у), находятся в определенном диапазоне. В языке C++ это можно сделать спо- собом предотвращения непосредственного доступа к этим переменным и потребовать от пользователя класса вызывать определенные функции. Следующая версия класса Point предотвращает доступ к членам х и у извне класса, но разрешает непрямой доступ при помощи нескольких функций-членов. class Point { • private: // Данные-члены (закрытые) int х, у; public: // Функции-члены void set(int new_x, int new_y); int get_x(); int get_y(); };
ГЛАВА 11. Класс Fraction 261 Это объявление класса все еще довольно простое. Обратите внимание, что объявляется три открытых функции-члена - set, get_x и get_y - а также два закрытых данных-члена. Теперь, после объявления объектов типа Point, значениями можно манипулировать, только вызывая одну из открытых функций: Point pointl; pointl.set(10 , 20); cout « pointl.get_x() « ", " << pointl.get у(); Это напечатает: 10, 20 Если вы попытаетесь непосредственно обратиться к данным-членам, компилятор поме- тит эту попытку как ошибку: pointl.x = 10; // ОШИБКА! Три функции-члена, конечно же, не определят сами себя; они должны быть определены где-то в программе. Определения функций могут находиться в любом месте - они даже могут быть помещены в отдельный модуль или скомпилированы и добавлены к стан- дартной библиотеке языка C++. В любом случае информация о типе для функций долж- на быть предоставлена... однако определение класса предоставляет прототипы функций. Следовательно, необходимо только, чтобы объявление класса располагалось перед ко- дом, использующим этот класс. Префикс Point: : выясняет область видимости этих определений, так что компилятор знает, что они применяются к функциям, объявленным в классе Point. Это важно, по- скольку каждый класс может иметь свою собственную функцию set, и вы также можете иметь глобальную функцию set. void Point::set(int new_x, int new_y) { x = new_x; у = new у; } int Point::get_x() { return x; } int Point::get_y() { return y; } Префикс области видимости Point: : применяется к имени функции. Возвращаемый тип (тип void или int, в зависимости от обстоятельств) по-прежнему находится в сво- ей обычной позиции, в начале первой строки определения. Синтаксис определений функций-членов может быть представлен как: - тип имя_класса::имя_функции (список_аргументов) { инструкции }
262 C++ без страха Имея эти функции, вы получаете контроль над данными. Можно, например, переписать функцию Point: : set таким образом, чтобы входные отрицательные значения преоб- разовывались в положительные. void Point::set(int new_x, int new_y) { if (new_x < 0) new_x * = -1; i f (new v < 0) ne w_y * = -1; x = new_x; у = new v; } Здесь я использую оператор умножения с присваиванием (*=); выражение new_x *= -1 имеет такой же результат, как и выражение new_x = new_x * -1. Хотя код функции вне класса не может непосредственно обращаться к закрытым дан- ным-членам х и у, определения функций класса могут обращаться к членам класса без ограничений, являются они закрытыми или нет. х = new_x; Мысленно можно представить класс Point следующим образом. Каждый объект типа Point (то есть переменная, объявленная с именем класса Point) имеет одина- ковую структуру. Класс Point
ГЛАВА 11. Класс Fraction 263 Пример 11.1. Тестирование класса Point После создания класса Point можно использовать имя «Point» так же, как вы исполь- зуете имя любого стандартного типа - int, float, double и так далее. Не нужно уточнять обращения к типу «Point» какими-либо другими ключевыми словами. Следующая программа выполняет несколько простых тестов над классом Point, ис- пользуя его для присвоения и получения некоторых данных. Новый код выделен полужирным шрифтом; остальная часть программы - существующий код из главы. Листинг 11.1. Pointl.cpp #include <iostream> using namespace std; class Point { private: // Данные-члены (закрытые) int x, у; public: // Функции-члены void set(int new_x, int new_y); int get_x(); int get_y(); }; int main() { Point ptl, pt2; ptl.set(10, 20); cout << "ptl is " << ptl.get_x(); cout << ", " << ptl.get_y() << endl; pt2.set(-5, -25); cout << "pt2 is " << pt2.get_x(); cout << ", " << pt2.get_y() << endl; return 0; } void Point::set(int new_x, int new_y) { if (new_x < 0) new_x * = -1; i f (new_y < 0) new у * = -1; ' x = new_x; у = new_y; } int Point::get_x() { return x; } int Point::get_y() { return y; j
264- C++ без страха Как это работает Это простой пример. Класс Point должен быть объявлен в самом начале, чтобы он мог быть использован в функции main. Имея это объявление, в функции main можно объя- вить пару различных объектов типа Point: Point ptl, pt2; Функции set, get_x и get_y могут быть применены как к объекту pt1, так и к объекту pt2. Следующие три инструкции вызывают функции класса Point через объект pt1: ptl.set(10, 20); cout « "ptl is " « ptl.get_x(); cout « ", " « ptl.get_y() « endl; А данные три инструкции вызывают функции класса Point через объект pt2: pt2.set(-5, -25); cout « "pt2 is " « pt2.get_x(); cout « ", " « pt2.get_y() « endl; Упражнения Упражнение 11.1.1. Модифицируйте функцию set таким образом, чтобы она устанавли- вала верхний предел, равный 100, для значений членов х и у; если введено значение, большее 100, оно уменьшается до значения 100. Измените функцию main, чтобы про- тестировать такое поведение. Упражнение 11.1.2. Напишите две новые функции для класса Point: функции set_x и set_y, которые устанавливают значения членов х и у по отдельности. Не забудьте инвер- тировать отрицательное значение, если такое появится, как это сделано в функции set. Введение в класс Fraction Один из наилучших способов рассматривать объектно-ориентированное программиро- вание - это Считать его методом для определения новых типов данных. Класс является расширением самого языка. Хорошим примером является класс Fraction (который также можно назвать классом «рациональное число»), хранящий два числа, представ- ляющих соответственно числитель и знаменатель. Для долларов и центов задача касается не единиц измерения, а хранения чисел, кратных одной сотой части доллара без ошибки в двоичной системе. Число 0,5 может быть точно сохранено как двоичное число с плавающей точкой (по- скольку число 1/2 является степенью числа 2), однако число 0,51 уже не может быть точно сохранено таким способом, поскольку числа 1/10 и 1/100 не явля- ются степенью двух. Если вы напишете простые тесты, включающие подоб- ные числа в программе на языке C++, сначала и, возможно, некоторое время по- сле этого может показаться, что переменная типа double, проинициализиро- ванная значением 0,51, работает корректно. Но при этом существует незри- мая крошечная погрешность, и обычно из таких незаметных погрешностей образуются большие ошибки.
ГЛАВА 11. Класс Fraction 265 Класс Fraction будет полезен, если вам когда-либо понадобится хранить дроби напо- добие 1/3 или 2/7, причем хранить их точные значения. Этот класс также можно исполь- зовать даже для хранения цены в долларах и центах, например $1,57. При создании класса Fraction чрезвычайно важно ограничить доступ к данным- членам по нескольким причинам. Во-первых, никогда нельзя позволять использовать знаменатель, равный 0, поскольку деление 1/0 является в математике недопустимым. И даже с допустимыми операциями важно упрощать дроби, чтобы было уникальное выражение для каждого рационального числа. Например, рассмотрим следующие отношения: 3/3 2/4 6/2 -1/-1 2/-1 Они должны быть упрощены до: 1/1 1/2 3/1 1/1 -2/1 В следующих разделах я разработаю функции, которые автоматически выполняют всю эту работу. Для пользователя завершенного класса Fraction красота заключается в том, что все детали работы с дробями скрыты. Если класс написан правильно, програм- мисты, никогда не видевшие исходный код, могут использовать класс для создания лю- бого количества объектов типа Fraction, и операции, наподобие следующих, будут выполнять правильные действия автоматически: Fraction а(1, 6) ; // а = 1/6 Fraction Ь(1, 3); // b = 1/3 if (а + b == Fractionfl, 2)) cout « "1/6 + 1/3 equals 1/2"; Разработка полной версии класса Fraction займет несколько глав. Давайте начнем с наипростейшей возможной версии. class Fraction { private: int num, den; // Числитель и знаменатель. public: void setfint n, int d) ; int get_num(); int get_den(); private: void normalize(); // Преобразовать дробь к стандартному I/ виду. int gcf(int a, int b); // Наибольший общий делитель, int lcm(int a, int b); // Наименьшее общее кратное. 1; Данное объявление класса состоит из трех частей: ✓ Закрытые данные-члены. Сюда относятся члены num и den, которые хранят числи- тель и знаменатель соответственно. (Вспомните из школы, что в дроби 1/3, например, 1 - это числитель, а 3 - знаменатель.) ✓ Открытые функции-члены. Они обеспечивают непрямой доступ к данным класса.
266 C++ без страха ✓ Закрытые функции-члены. Это вспомогательные функции, которые мы будем ис- пользовать позднее в этой главе. Сейчас же они просто возвращают нулевые значения. С этими объявленными функциями можно использовать класс для выполнения простых операций наподобие следующих: Fraction fract; fract.set(1,2); cout << fract.get_num(); cout << "/"; cout « fract.get_den(); Пока это не очень интересно, поскольку класс не делает ничего более сложного, чем класс Point. Но это место для начала. Класс Fraction можно представить следующим способом. Как всегда, функции, объявленные в классе - неважно, открытые или закрытые - долж- ны быть где-то определены. Объявление класса предоставляет ряд прототипов функций, поэтому, за исключением класса и определений функций, эти функции нет необходимо- сти объявлять где-то еще. void Fraction::set(int n, int d) { num = n; den = d; } int Fraction:;get_num(){ return n; }
ГЛАВА 11. Класс Fraction 267 int Fraction::get_den(){ return d; } // ФРАГМЕНТ БУДЕТ ДОДЕЛАН В ДАЛЬНЕЙШЕМ ... // Эти оставшиеся функции синтаксически правильны, // однако пока не делают ничего полезного. // Мы заполним их содержимым позднее. void Fraction::normalize(){ return; } int Fraction::gcf(int a, int b){ return 0; } int Fraction::1cm(int a, int b) { return 0; } Встраиваемые функции Три функции класса Fraction выполняют простые действия: устанавливают или полу- чают данные. Даже в более усовершенствованных версиях класса эти функции не станут сложными. Следовательно, они являются отличными кандидатами для встраивания (inlining). Когда функция встроена (inline), компилятор обрабатывает вызов функции по-другому, чем вызов обычной функции. Вызов не передает управление на новое местоположение в программе. Вместо этого компилятор заменяет вызов функции самим телом функции. Например, предположим, что функция set встроена, как показано ниже: void set() {num = n; den = d; } Теперь всякий раз, когда в коде программы встречается следующая инструкция: fract.set(1, 2) ; компилятор вставляет машинные инструкции для функции set. По существу, результат будет таким же, как если бы в программу был вставлен следующий код на языке C++: {fract.num = 1; fract.den =2;} И, если функция get_num встроена, выражение fract.get_num() заменяется одной или двумя машинными инструкциями, получающими значение члена fract.num. Но в последнем случае вы можете спросить: «Зачем вообще писать функцию get_num, если она просто будет заменена обращением к члену fract.num? Почему про- сто не позволить члену num быть открытым?»
268 C++ без страха Ответ заключается в том, что, имея функцию get_num, вы контролируете доступ к дан- ному-члену num, хотя функция является встроенной. Если бы член num был сделан от- крытым данным-членом, пользователь класса Fraction смог бы читать и записывать значение члена num непосредственно - что нарушило бы нашу концепцию класса. Сделать функции встроенными можно, поместив их определения в само объявление класса. Помните, что за определением функции точка с запятой не ставится. Ниже, модифицированные строки выделены полужирным шрифтом. class Fraction { private: int num, den; // Числитель и знаменатель. public: void set(int n, int d) {num = n; den = d; normalize() ;} int get_num() {return num;} int get_den() {return den;} private: void normalized; // Преобразовать дробь к стандартному // виду. int gcf(int a, int b); // Наибольший общий делитель, int 1cm(int a, int b); // Наименьшее общее кратное. } ; Поскольку три закрытые функции не являются встроенными, их определения должны быть включены в код отдельно: 'void Fraction::normalize(){ return; } int Fraction::gcf(int a, int b){ return 0; } int Fraction::1cm(int a, int b){ return 0; } В чем же преимущество создания встроенной функции? Это просто. Если действие функции эквивалентно всего лишь нескольким машинным инструкциям (например, пе- ремещение данных из единственной определенной ячейки памяти в другую), можно улучшить эффективность программы, написав функцию как встроенную. При вызове настоящей функции тратится определенное количество времени во время выполнения (состоящей из нескольких машинных инструкций), и когда действие функции составляет меньше работы, чем это количество времени, она должна быть встроена. Однако функции, состоящие из более чем нескольких простых инструкций, не должны встраиваться. Помните, что везде, где происходит вызов встроенной функции, компиля- тор помещает все тело функции в код; поэтому, если встроенная функция вызывается часто, в результате получается программа, занимающая больше места, чем необходимо.
ГЛАВА 11. Класс Fraction 269 Встроенные функции также имеют некоторые дополнительные ограничения, которые не применяются к другим функциям. Они, например, не могут использовать рекурсию. Три вспомогательные функции - normalize, gcf и Icm - в ходе нашей дальнейшей рабо- ты станут больше размером, поэтому мы не будем делать их встроенными. Нахождение наибольшего общего делителя Все действия внутри класса Fraction, описываемые в следующих главах, основыва- ются на двух понятиях теории чисел: наибольший общий делитель (НОД - GCF) и наи- меньшее общее кратное (НОК - LCM). Первый раз я описал функцию получения наибольшего общего делителя в главе 4. Если вспомните, наибольший общий делитель - это наибольшее число, на которое делятся без остатка два другие числа. Например: Числа Наибольший общий делитель 12, 18 6 12, 10 2 25,50 25 50, 25' 25 20,21 1 Функция gcf в главе 4 использует операцию деления по модулю (%);которая делит од- но целое число на другое и возвращает остаток от деления. Полное решение использует элегантный рекурсивный алгоритм. int gcf(int a, int b) { if (a % b == 0) return b; else return gcf(b, a % b) ; } Мы можем почти использовать эту функцию как есть и вставить ее в класс Fraction как необходимую вспомогательную функцию. Но существует проблема: в этой версии функции gcf делается предположение, что оба введенных числа являются положительными целыми числами. В данный момент нам необходимо обдумать, что может пойти не так. Во-первых, что случится, если один из аргументов равен 0? Если поэкспериментировать с различными значениями, можно обнаружить, что значе- ние аргумента а, равное 0, не приводит к проблеме. Для значения аргумента а, равного 0, и значения аргумента Ь, равного 5, например, результат равен 5. Это происходит потому, что сразу же достигается терминальный, конечный случай. Результатом выражения 0 % 5 является 0 (поскольку при делении нуля на любое другое число не образуется остатка). Следовательно, функция успешно возвращает значение 5.
270 C++ без страха Однако значение аргумента Ь, равное 0, заставляет функцию выполнить попытку деле- ниянаноль; это приводит к ошибке (runtime error), которая завершает программу. Но когда мы напишем функцию normalize - еще одна вспомогательная функция класса Fraction - в следующей главе, мы будем уверены, что значения аргументов а и Ь за- даны как следует, перед любым вызовом функции gcf. Функция normalize будет изме- нять любую дробь, содержащую 0, на дробь 0/1. В результате (благодаря тому, как напи- сана оставшаяся часть кода) аргумент Ь никогда не будет равен значению 0. I Помните, что одной из основных целей объектно-ориентированного програм- мирования является управление доступом к функциям. Сделав функцию gcf закрытой, мы убедимся, что только вспомогательные функции класса Frac- tion могут вызывать функцию gcf; следовательно, благодаря аккуратному программированию, мы можем убедиться, что аргумент b никогда не будет равен значению 0. Другое опасение вызвано следующим: что произойдет, если будут переданы отрица- тельные числа? Поэкспериментировав с различными значениями, вы обнаружите, что знаки минус не изменяют абсолютное значение результата; результат выражения gcf (25, 35) равен 5, но такой же результат и у выражения gcf (25, -35). Однако существует одна проблема: знак результата становится трудно предсказать. Для целей данного класса проще получить корректные результаты, если мы выразим все знаменатели и общие множители положительными числами. Мы можем сделать это в функции gcf, всегда возвращая положительное число в терминальном случае. int gcf(int a, int b) { . if (a % b == 0) return abs(b); else return gcf(b,' a % b) ; F' ' В приведенном коде я выделил полужирным шрифтом одно выражение, которое отлича- ется от предыдущей версии функции gcf. Это простое изменение, заключающееся в замене b на выражение abs (b). Функция abs является функцией стандартной библио- теки языка C++, которая возвращает абсолютное значение числа. Абсолютное значение всегда неотрицательное. Нахождение наименьшего общего кратного Как только функция gcf определена, написать оставшуюся часть класса Fraction будет нетрудно. Другая вспомогательная функция, которая пригодится, - это функция, полу- чающая наименьшее общее кратное. Эта функция будет использовать функцию gcf. НОК (LCM) - это наименьшее число, которое является кратным обоих введенных чисел. Оно является понятием, обратным наибольшему общему делителю. Таким образом, на- пример, НОК чисел 200 и 300 является число 600. Наибольший общий делитель между тем равен 100.
ГЛАВА 11. Класс Fraction 271 Хитрость для нахождения наименьшего общего кратного заключается, во-первых, в от- делении наибольшего общего делителя, а затем умножении на этот делитель только один раз. Поскольку в противном случае, когда вы умножаете числа А и В, вы крсвен- ным образом включаете один и тот же делитель дважды. Поэтому общий делитель дол- жен быть удален и из числа А, и из числа В. Другими словами, для двух введенных чисел А и В найдите наибольший общий дели- тель. Разделите число А на это число и число В на это же число. Затем перемножьте по- лученные значения и умножьте их произведение на полученный делитель один раз. Формула такова: n = GCF(a, b) LCM(A, В) = п * (а / п) * (Ь / п) Вторая строка упрощается до: LCM(A, В) = (а / n) * b Теперь функцию нахождения наименьшего общего кратного просто написать, int 1cm(int a, int b) { return (a / gcf(a, b)) * b; } ' ,< •. . Вы можете легко проверить, что это работает. Например, возьмите случай с числами 200 и 300. Наибольший общий делитель равен 100. Формула нахождения наименьшего об- щего кратного, следовательно, в результате дает значение 600: LCM = 200 /100 * 300 = 2 * 300 = 600. И число 600 на самом деле является наименьшим общим кратным чисел 200 и 300. Вы можете проверить это на любом числе из их пары. Функция верна. Пример 11.2. Вспомогательные функции класса Fraction Теперь, когда мы знаем, как написать функции gcf и 1cm, добавить этот код в класс Frac- tion не составит труда. Ниже приведен код первой работающей версии класса. Также я добавил код для функции normalize, которая упрощает дроби после каждой операции. Новый код или внесенные изменения в код из ранних версий выделены полужирным шрифтом. Листинг 11.2. Fractl.срр ' ’ : »- ttinclude <stdlib.h> class Fraction { private: int num, den; // Числитель и знаменатель, . public: void set(int n, int d) {num = n; den = d; normalize () ;} . int get_num() {return num;}
272 C++ без страха int get_den() {return den;} private: void normalizeO; // Добавление дроби в стандартную форму int gcf(int a, int b); // Наибольший общий делитель int 1cm(int a, int b); // Наименьшее общее кратное }; // Нормализация: преобразовать дробь к стандартному // виду, уникальному для каждого математически отличающегося // значения // void Fraction::normalize(){ // Обработать случаи со Значением О if (den == 0 ]j num == 0) { num = 0; den = 1; } // Оставить отрицательный знак только в числителе. if (den < 0) { num * = -1; den *= -1; } // Извлечение наибольшего общего делителя из числителя и // и знаменателя. int n = gcf(num, den); num = num / n; den = den / n; } // Наибольший общий делитель 11 int Fraction::gcf(int a, int b) { if (a % b == 0) return abs(b); else return gcf(b, a % b); } // Наименьшее общее кратное И int Fraction::1cm(int a, int b){ return (a / gcf(a, b)) * b; } '
ГЛАВА 11. Класс Fraction 273 Как это работает Вначале включается файл stdlib.h для поддержки функции abs, используемой в опреде- лении функции Fraction::gcf. ttinclude <stdlib.h> Код для функций gcf и 1cm может быть скопирован из двух предыдущих разделов, с одним изменением. В заголовке функции перед именем должен использоваться префикс Fraction: :. Обратите внимание, что это должно быть сделано только в заголовке: int Fraction::gcf(int a, int b) { if (a % b == 0) return abs(b); else return gcf(b, a % b); } Когда функция вызывает саму себя в рекурсивном вызове функции return gcf(b, а % b); использование префикса Fraction: : необязательно. Это допускается, поскольку внутри функции класса предполагается область видимости этого класса. Подобным образом при вызове функцией Fraction: : 1cm функции gcf снова пред- полагается область видимости класса. Другими словами, предполагается, что функции Fraction: : 1cm вызывает функцию Fraction: : gcf. int Fraction::1cm(int a, int b){ return (a / gcf(a, b)) * b; } В общем, каждый раз, когда компилятор языка C++ наталкивается на имя переменной или функции, он ищет объявление этого имени в следующем порядке: В пределах той же функции (в случае локальных переменных). ✓ В пределах того же класса (если функция является функцией-членом). Если объявление на уровне функции или класса не найдено, компилятор выполняет поиск глобальных объявлений. Новым кодом здесь является только функция normalize. Первое, что делает функция, это обработка случаев с нулем. Знаменатель, равный значению 0, является недопусти- мым значением, поэтому дробь заменяется на 0/1. К тому же все значения с числителем, равным значению 0, являются эквивалентными: 0/1 0/2 0/5 0/-1 0/25 Все они записываются в стандартном виде 0/1. Одной из основных задач класса Frac- tion является гарантия того, что все математически равные значения представляются абсолютно одинаковым способом. (Это упростит жизнь, когда придет время реализовы- вать оператор проверки на равенство.) Одна проблема возникает из-за отрицательных чисел. Эти два выражения представляют такое же значение:
274 C++ без страха -2/3 2/-3 что и такие выражения: 4/5-4Z-5 Простейшим решением является проверка знаменателя; если его значение меньше О, изменить знак и числителя, и знаменателя. Данное решение позаботится об обоих про- блематичных случаях, описанных выше, одним махом. if (den < 0) { num *= -1; den * = -1; } Оставшаяся часть функции понятна: найти наибольший общий делитель, после чего раз- делить на это число как числитель, так и знаменатель. int n = gcf(num, den); num = num / n; den = den / n; Например, возьмем дробь 30/50. Наибольший общий делитель равен 10. Функция nor- malize выполняет необходимое деление и получается дробь 3/5. Функция normalize важна не только по этой единственной причине. Во-первых, как я уже отмечал ранее, важно, чтобы эквивалентные значения выражались абсолютно оди- наковым способом. Во-вторых, когда мы начнем определять арифметические операции для класса Fraction, в числителе и знаменателе будут накапливаться большие числа. Чтобы избежать ошибок переполнения во время выполнения программы, важно упро- щать выражения класса Fraction, когда есть возможность. Упражнения Упражнение 11.2.1. Существуют ли другие комбинации значений числителя и знамена- теля дроби, которые приведут к ошибкам, или определение класса предупреждает все возможные проблемы? Упражнение 11.2.2. Перепишите функцию normalize таким образом, чтобы в ней использовался оператор деления с присваиванием (/=). Помните, что эта операция: а /= Ь эквивалентна следующей: а = а / Ъ Пример 11.3. Тестирование класса Fraction После завершения объявления класса его необходимо протестировать, используя его для объявления одного или более объектов, а затем использовать эти объекты. Следующий код позволяет пользователю ввести значения для дроби, а затем прочитать эти значения после упрощения дроби. Цикл повторяет действия любое число раз.
ГЛАВА 11. Класс Fraction 275 Листинг 11.3. Fract2.cpp #include <stdlib.h> ttinclude <iostream> using namespace std; class Fraction { private: int num, den; // Числитель и знаменатель, public: void set(int n, int d) {num = n; den = d; normalize();} int get_num() {return num,-} int get_den() {return den;} private: , void normalized; // перевести дробь в // стандартную форму int gcf(int a, int b); // Наибольший общий делитель int 1cm(int a, int b); // Наименьшее общее // кратное }; int main () { int a, b; char str[81]; Fraction fract; while (1) ( cout << "Enter numerator: "; cin >> a; cout << "Enter denominator: "; cin >> b; fract.set(a, b); cout << "Numerator is " << fract.get_num() < < endl; cout << "Denominator is " << fract.get_den() << endl; cout << "Do again? (Y or N) "; cin >> str; if (!(str[0] == 'Y' || str[0] == 'y')) break; ) ) // ---------------------------------------------------- // ФУНКЦИИ КЛАССА FRACTION // Нормализация: преобразовать дробь к стандартному // виду, уникальному для каждого математически отличающегося // значения // void Fraction::normalize(){ ’
276 C++ без страха // Обработать случаи со значением О if (den == 0 || num == 0) { num = 0; den = 1; } // Оставить отрицательный знак только в числителе. if (den < 0) { num * = -1; den *= -1; } // Извлечение наибольшего общего делителя из числителя и //и знаменателя. int n = gcf(num, den); num = num / n; den = den / n ; } ' // Наибольший общий делитель // int Fraction::gcf(int a, int b) { if (a % b == 0) return abs(b); else- return gcf(b, a % b); } // Наименьшее общее кратное // int Fraction::lcm(int a, int b){ return (a / gcf(a, b)) * b; }' . Как это работает Самое важное, на что нужно обратить внимание относительно данной функции: объяв- ление класса должно идти в самом начале, перед тем как в функции main происходит обращение к классу или к его функциям. После того как класс был объявлен, функции (включая функцию main) могут размещаться в любом порядке. Обычно объявления классов, как и любые другие необходимые объявления и директивы, помещаются в заголовочный файл. Безусловно, вы можете применить этот подход и здесь. Допустив, что имя этого заголовочного файла - Fraction.h, вам необходимо добавить следующую инструкцию в программу, использующую класс Fraction: ttinclude "Fraction.h" ч
ГЛАВА 11. Класс Fraction 277 Определения функций, не являющихся встроенными, должны быть размещены где-то в программе... или, иначе, они должны быть отдельно скомпилированы и скомпонованы с проектом. Третья строка функции main создает непроинициализированный объект Fraction: Fraction fact; Затем остальные инструкции в функции main инициализируют объект и считывают об- ратно его значение. Обратите внимание, что вызов функции set присваивает значение, а также вызывает функцию normalize, которая упрощает дробь, как необходимо. fract.set(а, Ь); cout << "Numerator is " << fract.get_num() << endl; cout « "Denominator is " « fract.get_den() << endl; Упражнение Упражнение 11.3.1. Напишите программу, использующую класс Fraction, для установ- ки ряда значений, вызывая функцию set: 2/2, 4/8, -9/-9, 10/50, 100/25. Программа должна напечатать результаты для проверки, что каждая дробь была упрощена правильно. Вставка Новый вид директивы #include В последнем примере вы могли обратить внимание, что я представил вам новый син- таксис для директивы ttinclude. Помните, что для включения поддержки области библиотеки языка C++ предпочтительным методом является использование угловых скобок: ttinclude <iostream> Однако, чтобы включить объявления из ваших собственных файлов проекта, необхо- димо использовать кавычки: ttinclude "Fraction.h" В чем же различие? Эти два вида директивы ttinclude выполняют почти одно и то же, но в случае с синтаксисом, использующем кавычки, компилятор языка C++ направляется на поиск заголовочного файла сначала в текущей папке и только после этого в стандарт- ной папке заголовочных файлов (которая, например, в операционной системе MS-DOS устанавливается через использование переменной окружения INCLUDE). В зависимости от того, какая версия компилятора языка C++ есть у вас, возможно, вам понадобится использовать синтаксис с кавычками (то есть второй вид) как для библиотечных файлов, так и для файлов проекта. Однако это плохая мысль; для со- вместимости со стандартами, которые будут реализованы во всех будущих версиях языка C++, необходимо придерживаться стандартной практики использования угло- вых скобок для включения возможностей стандартной библиотеки языка C++.
278 C++ без страха Пример 11.4. Арифметика с дробями: функции add и mult Следующим шагом в создании функционирующего класса Fraction является добавле- ние некоторых простых арифметических функций, add и mult. Данные функции сами по себе не реализуют операторы для класса; однако, после того, как мы добавим их в класс, настоящие операторные функции будет легко написать. Сложение является самой сложной операцией, но вы можете вспомнить метод из на- чальной школы. Рассмотрим сложение двух дробей: А/В + C/D Хитрость заключается в нахождении наименьшего общего знаменателя (LCD - Lowest Common Denominator) - путем нахождения наименьшего общего кратного для чисел В и D: LCD = LCM(B, D). К счастью, у нас есть удобная вспомогательная функция, 1cm, которая делает именно это. После этого, дробь A/В должна быть преобразована к дроби, использующей най- денный наименьший общий знаменатель: А * LCD/B В * LCD/B После этого мы получаем дробь, знаменатель которой равен наименьшему общему зна- менателю. Подобным образом для дроби C/D: С * LCD/D D * LCD/D После выполнения этих операций умножения две дроби будут иметь общий знаменатель (LCD) и могут быть сложены вместе. Результирующая дробь будет следующей: (А * LCD/В) + (С * LCD/D) LCD Таким образом, алгоритм будет следующий: > Вычислить наименьший общий знаменатель (LCD), равный значению выражения LCM(B, D). > Установить значение переменной Quotientl равным значению выражения LCD/B. > Установить значение переменной Quotient2 равным значению выражения LCD/D. > Установить числитель новой дроби равным А * Quotientl + С * Quotient2. > Установить знаменатель новой дроби равным значению LCD. Умножение проще: > Установить числитель новой дроби равным значению выражения А * С. > Установить знаменатель новой дроби равным значению выражения В * D.
ГЛАВА 11. Класс Fraction 279 Имея эти алгоритмы, мы теперь можем продолжить и написать код, определяющий и реализующий две новые функции, а также протестировать класс. Как и раньше, строки, выделенные полужирным шрифтом, представляют новые или измененные строки; все остальное остается таким же, как и в предыдущем примере. Листинг 11.4. Fract3.cpp #include <iostream> using namespace std; class Fraction { private: int num, den; // Числитель и знаменатель. public: void set(int n, int d) {num = n; den = d; normalize();} int get_num() {return num,-} int get_den() {return den;} Fraction add(Fraction other); Fraction mult(Fraction other); private: void normalize(); // Put fraction into standard // form. int gcf(int a, int b) ; // Greatest Common Factor. int 1cm(int a, int b); // Lowest Common // Denominator. }; int main() { Fraction fractl, fract2, fract3; fractl.set(1, 2); fract2.set(1, 3); fract3 = fractl.add(fract2); cout << "1/2 plus 1/3 = cout << fract3.get_num() << "/" << fract3.get_den(); // --------------------------------------------------- // ФУНКЦИИ КЛАССА FRACTION // Нормализация: преобразовать дробь к стандартному // виду, уникальному для каждого математически отличающегося // значения // void Fraction::normalize(){ // Обработать случаи со значением О
280 C++ без страха if (den == 0 || num == 0) { num = 0; den = 1; } // Оставить отрицательный знак только в числителе. if (den < 0) { num * = -1; den *= -1; } // Извлечение наибольшего общего делителя из числителя и // и знаменателя, int n = gcf(num, den); num = num / n; den = den / n; } // Наибольший общий делитель // int Fraction::gcf(int a, int b) { if (a % b == 0) return abs(b); else return gcf(b, a % b) ; } // Наименьшее общее кратное // int Fraction::1cm(int a, int b) { return (a / gcf(a, b)) * b; } Fraction Fraction::add(Fraction other) { Fraction fract; int led = lcm(den, other.den); int quotl = led/den; int quot2 = led/other.den; fract.set(num * quotl + other.num * quot2, led); fract.normalize(); return fract; } Fraction Fraction::mult(Fraction other) { Fraction fract; fract.set(num * other.num, den * other.den); fract.normalize(); return fract; }
ГЛАВА 11. Класс Fraction 281 Как это работает Функции add и mult реализуют алгоритмы, описанные мной ранее. Они также исполь- зуют новую сигнатуру типа: каждая из этих функций принимает аргумент типа Frac- tion, "а также возвращает значение типа Fraction. Рассмотрим объявление типа функции add. Fraction Fraction::add (Fraction other); Каждое вхождение слова Fraction в это объявление имеет различное назначение. > Использование слова Fraction в начале объявления сообщает о том, что функция возвращает объект типа Fraction. > Префикс Fraction: : перед именем сообщает о том, что функция add объявлена внутри класса Fraction. > В скобках слово Fraction обозначает, что есть один аргумент, other, имеющий тип Fraction. Хотя данные три слова Fraction обычно используются вместе, логически не требуется делать именно так. Например, можно иметь функцию, принимающую аргумент типа int, возвращающую объект типа Fraction, но не объявленную в классе Fraction. Объявление выглядело бы следующим образом: Fraction my_func(int n) ; Поскольку функция Fraction: :add возвращает объект типа Fraction, она сначала должна создать новый объект. Fraction fract; После этого функция применяет алгоритм, описанный ранее: int led = 1cm(den, other.den); int quotl = led/den; int quot2 = led/other.den; fract. set (num * quotl + other, num * quot2 , led),; И, наконец, после установки всех значений нового объекта типа Fracion (fract) функция возвращает этот объект. return fact; Для функции mult основная процедура, по существу, такая же. Упражнения Упражнение 11.4.1. Перепишите функцию main таким образом, чтобы она складывала две любые введенные дроби и печатала результаты.
282 C++ без страха Упражнение 11.4.2. Перепишите функцию main таким образом, чтобы она умножала две любые введенные дроби и печатала результаты. Упражнение 11.4.3. Напишите функцию add для класса Point, представленного ранее. Функция должна сложить значения х, чтобы получить новое значение х, а также сло- жить значения у, чтобы получить новое значение у. Упражнение 11.4.4. Напишите функции sub и div для класса Fraction вместе с ко- дом в функции main, для тестирования данных функций. (Алгоритм для функции sub похож на алгоритм для функции add, хотя вы можете написать даже более простую функцию, умножив числитель аргумента на -1, а затем просто вызвать функцию add.) Резюме Вот основные моменты главы 11: ✓ Объявление класса имеет следующий вид: class имя_класса { объявления if ✓ В языке C++ ключевое слово struct синтаксически эквивалентно ключевому слову class, но с одним важным отличием: члены класса, объявленного с использованием ключевого слова struct, по умолчанию являются открытыми (public); члены класса, объявленного с использованием ключевого слова class, по умолчанию являются за- крытыми (private). ✓ Поскольку члены типа class по умолчанию являются закрытыми, необходимо ис- пользовать ключевое слово public для объявления хотя бы одного открытого члена. Каждый член, следующий за выражением public:, затрагивается этим ключевым сло- вом; действие ключевого слова распространяется до конца функции или до следую- щего ключевого слова private. Например: class Fraction { private: int num, den; public: void set(int n, int d) ; int get_num(); int get_den(); private: void normalize!); int gcf(int a, int b) ; int 1cm(int a, int b); } ; ✓ Объявления классов и объявления данных-членов завершаются точкой с запятой (даже после закрывающей фигурной скобки) без исключения. ✓ После того как класс был объявлен, его можно использовать в качестве имени типа точно так же, как вы используете типы int, float, double и так далее. Следова-
ГЛАВА 11. Класс Fraction 283 тельно, если тип Fraction был объявлен как класс, можно объявить ряд объектов ти- па Fraction: Fraction a, b, с, my_fraction, fractl; ✓ Функции класса могут обращаться к другим членам класса (неважно, закрытым или нет) без уточнения. ✓ Любая функция-член должна быть где-то определена. ✓ Чтобы разместить определение функции-члена за пределами объявления класса, ис- пользуйте следующий синтаксис: тип имя_классаг ;имя_функции (список—аргументов) { инструкции ✓ Если вы разместили определение функции-члена внутри объявления класса, функция является встроенной (inline), что означает, что при вызове такой функции нет затрат производительности. Вместо этого, машинные инструкции, реализующие эту функ- цию, помещаются прямо в тело оставшейся части кода. ✓ Объявление класса должно предшествовать использованию этого класса. Определе- ния функций могут быть размещены в любом месте программы (или даже в отдель- ном модуле). ✓ Функции (независимо от того, являются они функциями-членами или нет) могут ис- пользовать классы в качестве типов аргументов, а также в качестве возвращаемых типов. Если возвращаемый тип функции является классом, это значит, что функция должна возвратить объект. Чтобы сделать это, функция сначала должна создать объ- ект, объявив его в качестве локальной переменной. После этого функция должна воз- вратить данную переменную.
ГЛАВА 12. Конструкторы: если вы их создаете... Одной из основных тем, обсуждаемых в данной книге, является способность объектно- ориентированного программирования быть путем создания нового фундаментального типа данных. Я буду возвращаться к этой мысли в течение нескольких глав. Но я еще не рассмотрел, хотя и обещал, следующее - что можно сделать классы такими же удобными, как и стандартные типы данных. Одна из наиболее удобных возможно- стей таких типов, как int, float, double и т.д., заключается в том, что вы можете инициализировать их при объявлении. (В этом смысле язык C++ является отчасти более свободным, чем язык С; инициализацию можно проводить любым значением, а не толь- ко константами.) Пейчас вы увидите, как легко реализовать поддержку инициализации также и для объек- тов. Добро пожаловать в строительный цех языка C++. Введение в конструкторы Термин конструктор - это просто выражение языка C++, обозначающее функцию ини- циализации (initialization function). Под «функцией инициализации» я подразумеваю функцию, которая сообщает компилятору, как интерпретировать объявления, похожие на следующее: Fraction а(1, 2) ; // а = 1/2 Вспомнив описание класса Fraction, вы можете предположить, что это объявление должно иметь такой же эффект, как и для следующих инструкций: Fraction а,- а.set(1, 2) ; И фактически в данной главе мы собираемся сделать так, чтобы класс вел себя именно таким образом. Однако компьютер не имеет понятия о том, что вы хотите сделать, каким бы очевидным это не могло показаться. Вам необходимо сказать компилятору, как вы- полнить инициализации. Это как раз то, для чего предназначены конструкторы. Конструктор является особой функцией-членом (и, как таковая, она должна быть объяв- лена внутри класса). Он имеет следующий синтаксис: имя__класса(список_аргументов') { инструкции } Такая функция выглядит странно. У нее нет возвращаемого типа (даже типа void)! Имя класса, в некотором смысле, является возвращаемым типом - или, вернее, заменяющим возвращаемый тип. Роль конструктора заключается в создании объекта данного класса. Вот пример, как может быть объявлен конструктор (то есть его прототип): Fraction(int n, int d);
ГЛАВА 12. Конструкторы: если вы их создаете... 285 В контексте класса это объявление выглядит следующим образом: class Fraction { public: / / . . Fraction(int n, int d) ; / / . . . } ; Конечно, это только объявление. Как и любая другая функция, конструктор должен быть где-то определен. Определение можно разместить за пределами объявления класса, но в этом случае необходимо использовать префикс Fraction: : для указа- ния области видимости: Fraction::Fraction(int n, int d) { set(n, d); } Конструктор, определенный за пределами объявления класса, имеет следующий синтаксис: имя_класса~. :имя_класса(список_аргументов) { инструкции } Первый раз имя_класса используется в префиксе имени (имя_класса : :) и означает, что данная функция является частью класса (или, вернее, имеет область видимости клас- са). Второй раз имя_класса в приведенной последовательности используется в каче- стве имени самой функции. Вначале это может сбить с толку, но запомните, что имя конструктора всегда совпадает с именем его класса. Конструктор также можно сделать встроенным. Определение данного конструктора ко- роткое, поэтому конструктор является хорошим кандидатом для встраивания. class Fraction { public: / / . . . Fractiontint n, int d) {set(n, d);} // ... } ; Несколько конструкторов (перегрузка) Перегрузка функций (function overloading)- повторное использование одного и того же имени функции в различных контекстах - оказывается необходимой для написания кон- структоров. Если вспомнить материал из главы 9, одно и то же имя функции можно ис- пользовать для создания нескольких различных функций, позволяя компилятору языка C++ различать их, основываясь на типах в списках аргументов. Например, можно объявить несколько конструкторов для класса Fraction - один без аргументов, второй с двумя аргументами и третий всего лишь с одним аргументом. class Fraction { public:
286 C++ без страха И ... Fraction(); Fraction(int n, int d); ’Fraction(int n) ; II... }; Каждый из этих конструкторов может быть определен как встроенная функция. И вы, если пожелаете, также можете создать гораздо более длинный список конструкто- ров, чем этот. Например, вы можете написать конструктор, принимающий один строко- вый аргумент, вдобавок к конструктору, принимающему один целочисленный аргумент. Компилятор может узнать из контекста, какой из этих конструкторов применить. Fraction(int n); Fraction(char *str); Однако для случая с классом Fraction на самом деле необходимо иметь всего лишь два конструктора: один, который принимает два целочисленных аргумента, и второй, который вовсе не имеет аргументов. Этот последний конструктор называется конструк- тором по умолчанию (default constructor), и он является достаточно важным и заслужи- вает отдельного внимания - о чем и пойдет речь в следующем разделе. Конструктор по умолчанию... и предупреждение Каждый раз, когда вы пишете класс (если это не тривиальный класс, для которого вовсе не нужны конструкторы), вы должны всегда писать конструктор по умолчанию - это конструктор, не принимающий аргументов. Для этого общего правила существует достаточное основание. Оно базируется на сле- дующей особенности языка C++, которая может удивить вас: Если вы не напишете конструкторы, компилятор автоматически добавит ♦$» конструктор по умолчанию. Но если вы напишете какой-либо конструктор, компилятор уже не добавит конструктор по умолчанию. Хорошо, это странно, однако это является ключевым моментом. Позвольте мне повто- рить это снова, медленно. Во-первых, скажем, что вы определили класс, не имеющий никаких конструкторов. class Point { private: int x, у; public: set(int new_x, int new_y); int get_x(); int get_y(); ); Поскольку вы написали класс, не имеющий конструкторов, компилятор делает вам одолжение, автоматически добавив один конструктор: конструктор по умолчанию. Это конструктор, не принимающий аргументы. Так как этот конструктор был добавлен для вас, вы можете двигаться вперед и использовать класс для объявления объектов.
ГЛАВА 12. Конструкторы: если вы их создаете...287 Point а, Ь, с; Пока все хорошо: если вы не напишете конструкторы, один конструктор будет добавлен автоматически, так что пользователь класса сможет объявлять объекты. Но давайте по- смотрим, что случится, как только вы определите конструктор. class Point { private: int x, у; public: Point(int new_x, int new_y) {set(new_x, new_y);} set(int new_x, int new_y); int get_x(); int get_y(); }; Имея в распоряжении этот конструктор, теперь можно объявлять объекты следующим способом: Point а(1, 2), Ь(10, -20); Однако теперь вы получите ошибку, если попытаетесь объявить объекты без аргументов! Point с; // ОШИБКА! Конструктора по умолчанию больше нет Что случилось? Проблема заключается в поведении, о котором я упомянул ранее. Если вы определили хотя бы один конструктор, компилятор не добавит конструктор по умолчанию. Автоматический конструктор по умолчанию, на который вы полагались, был грубо выброшен вон! Когда вы только начинаете писать классы, это странное поведение компилятора может удивить вас. Вы продолжаете использовать класс без явного определения конструкто- ров, позволяя пользователем класса объявлять объекты следующим способом: Point а, Ь, с; Но этот код, выглядящий простым, сломается, как только вы напишите конструктор, отличный от конструктора по умолчанию. Чтобы избежать данной проблемы, всегда пишите сами конструктор по умолчанию, ко- торый не будет зависеть от компилятора. Этот конструктор по умолчанию, который вы напишете, может быть настолько простым, насколько пожелаете. Фактически он может не иметь инструкций. Point() { } Поведение конструктора по умолчанию, добавляемого компилятором, заключается в установке всех данных-членов в ноль. В данном случае во все положения строки типа char помещаются нулевые байты (если строковые данные содержатся прямо внутри класса), а всем указателям присваиваются нулевые значения (это означает, что они ни- куда не указывают). Такое поведение отлично подходит для множества классов, однако для класса Fraction это неправильно. Это еще одна причина для написания конструк- тора по умолчанию - убедиться, что выполняются правильные действия.
288 C++ без страха Вставка Язык C++ всеми силами пытается ввести вас в заблуждение при помоши конструктора по умолчанию? Может показаться странным, что язык C++ работает таким образом: успокаивает вас ложным чувством безопасности, добавляя конструктор по умолчанию (повторюсь, это конструктор, не принимающий параметров) и затем убирая его, как только вы написали какой-либо другой конструктор. Конечно, это странное поведение. Это одно из ухищрений, которые имеет язык C++, так как он должен быть как объектно-ориентированным языком, так и языком, разра- ботанным для обратной совместимости с языком С (хотя и не полностью, поскольку язык С разрешает некоторые неточные объявления, запрещенные в языке C++). В частности, ключевое слово struct служит причиной для некоторых ухищрений. Язык C++ считает тип struct классом (как я уже отмечал), но он также должен рабо- тать таким образом, чтобы код на языке С, наподобие приведенного ниже, успешно компилировался и на языке C++: struct Point { int х, у; in- struct Point a; a . x = 1 ; Язык С не имеет ключевых слов public или private, поэтому этот код может быть скомпилирован только в том случае, если ключевое слово struct (в отличие от клю- чевого слова class) будет создавать тип, в котором все члены по умолчанию являют- ся открытыми. Другая проблема заключается в том, что в языке С нет понятия конст- руктора; поэтому, если данный код будет компилироваться с использованием компи- лятора языка C++, компилятор должен автоматически добавить конструктор по умолчанию, позволяя компилироваться инструкциям, наподобие следующей: struct Point а; Это, между прочим, является эквивалентом следующей инструкции на языке C++: Point а; Поэтому, для поддержания обратной совместимости с языком С, язык C++ должен автоматически добавлять конструктор по умолчанию. Однако, если вы напишете ка- кой-либо конструктор, он предположит, что вы пишете настоящий код на языке C++ и что вы знаете все о функциях-членах и конструкторах. В этом случае, ваше оправдание - что вы не знаете ничего о конструкторах - исклю- чается, и язык C++ предполагает, что вы должны суметь написать все необходимые функции-члены, включая конструктор по умолчанию.
ГЛАВА 12. Конструкторы: если вы их создаете... 289 Пример 12.1. Конструкторы класса Point Этот пример возвращает нас к классу Point из предыдущей главы, в который добавля- ется пара простых конструкторов: конструктор по умолчанию и конструктор, прини- мающий два аргумента. Далее эти конструкторы проверяются в простой программе. ' Листинг 12.1. Point2.cpp ttinclude <iostream> using namespace std; class Point { private: // Данные-члены (закрытые) int x, у; public: // Конструкторы Point() {} Point(int new_x, int new v) {set(new_x, new v) ; 1 // Остальные функции-члены void setfint new_x, int new_y); int get_x(); int get_y(); int main() { Point ptl, pt2; Point pt3(5, 10); cout << "The value of ptl is "; cout << ptl.get_x() << ", "; cout << ptl.get_y() << endl; cout << "The value of pt3 is "; cout << pt3.get_x() << ", "; cout << pt3.get_y() << endl; return 0; } ' void Point::set(int new_x, int new_y) { if (new_x < 0) new_x *= -1; if (new_y < 0) new_y * = -1; x = new_x; у = new_y; } int Point::get_x() { 10-6248
290 C++ без страха return х; } int Point::get_y() { return у; j Как это работает Здесь были внесены небольшие добавления в объявление класса. Две строки в объявле- нии добавляют конструкторы. Поскольку они написаны в виде встроенных функций, больше никаких добавлений в код класса не требуется. public: // Конструкторы Point() {} Pointfint new_x, int new_y) {set(new_x, new_y);} Обратите внимание, что оба конструктора объявлены в открытой части класса. Если бы они были объявлены как закрытые, пользователь класса Point не смог бы получить к ним доступ и, следовательно, самое главное (что и было) было бы утрачено. Конструктор по умолчанию сначала может показаться странным. Для данного определе- ния не используются никакие инструкции, поэтому на самом деле он ничего не выполняет. Point() {} Код функции main два раза использует конструктор по умолчанию (для объектов pH и pt2) и один раз использует другой конструктор (для объекта pt3). Point ptl, pt2; Point pt3(5,10); Упражнения Упражнение 12.1.1. Добавьте код в два конструктора класса Point, который бы сооб- щал об их использовании. Конструктор по умолчанию должен печатать «Using default constructor», а другой конструктор должен печатать «Using (int, int) constructor». (Совет: если вы желаете, чтобы эти функции оставались встроенными, определения функций при необходимости можно размещать на нескольких строках.) Упражнение 12.1.2. Добавьте третий конструктор, принимающий всего лишь один це- лочисленный аргумент. Этот конструктор должен присваивать переменной х значение указанного аргумента, а переменной у - значение 0. Пример 12.2. Конструкторы класса Fraction Этот пример немного отличается от примера 12.1, поскольку конструктор по умолчанию класса Fraction должен выполнять немного больше действий. Если в конструкторе нет инструкций, члены инициализируются значением 0. Это неприемлемое поведение, посколь- ку знаменатель (как я объяснял в предыдущей главе) никогда не может быть установлен в значение 0. Поэтому здесь конструктор по умолчанию устанавливает значение дроби в 0/1.
ГЛАВА 12. Конструкторы: если вы их создаете..291 Как всегда, код, выделенный полужирным шрифтом, представляет новые или изменен- ные строки кода. Все остальное осталось прежним, как в последней версии класса Fraction из главы 11. Листинг 12.2. Fract4.cpp ttinclude <iostream> using namespace std; class Fraction { private: int num, den; // Числитель и знаменатель. public: Fraction() {set(0, 1);} Fraction(int n, int d) {set(n, d);} void set(int n, int d) {num = n;.den = d; normalize();} int get_num() {return num;} int get_den() {return den;} Fraction add(Fraction other); Fraction mult(Fraction other); private: void normalize!); // Преобразование дроби к // стандартному виду. int gcf(int a, int b) ; // Наибольший общий делитель int 1cm(int a, int b); // Наименьшее общее кратное. int main() { Fraction fl, f2; Fraction f3(l, 2); cout << “The value of fl is "; cout << fl.get_num() << cout << f1.get_den() < < endl; cout << "The value of f3 is "; cout << f 3 .get_nwn() << ; cout << f3.get_den() << endl; return 0; // -------------------------------------------------- // ФУНКЦИИ КЛАССА FRACTION // Нормализация: преобразовать дробь к стандартному // виду, уникальному для каждого математически отличающегося // значения. / / ю*
292 C++ без страха void Fraction::normalize(){ // Обработать случаи со значением О if (den == 0 || num == 0) { num = 0; den = 1; } // Оставить отрицательный знак только в числителе. if (den < 0) { num * = -1; den * = -1; } // Извлечение наибольшего общего делителя из числителя и // знаменателя. int n = gcf(num, den); num = num / n; den = den / n; } // Наибольший общий делитель // int Fraction::gcf(int a, int b) { if (a % b == 0) return abs(b) ; else return gcf(b, a % b) ; } // Наименьшее общее кратное // int Fraction::1cm(int a, int b){ return (a / gcf(a, b)) * b; } Fraction Fraction::add(Fraction other) { Fraction fract; int led = lcm(den, other.den); int quotl = led/den; int quot2 = led/other.den; fract.set(num * quotl + other.num * quot2, led); fract.normalize(); return fract; } Fraction Fraction::mult(Fraction other) {
ГЛАВА 12. Конструкторы: если вы их создаете...293 Fraction tract; tract.set(num * other.num, den * other.den); tract.normalize(); return tract; 2 Как это работает Если вы следили за примером 12.1, этот пример будет для вас простым. Единственная уловка заключается в том, что конструктор по умолчанию должен устанавливать значе- ние знаменателя в 1, а не оставлять его равным 0. Fraction() {set(0, 1);} Этот пример, между прочим, демонстрирует достаточное основание для написания кон- структора по умолчанию: допустимость присваивания каждому члену класса значения 0 (в зависимости от конкретного класса) не всегда может быть приемлема. Код функции main три раза использует конструкторы. Объявления объектов Т1 и f2 вызы- вают конструктор по умолчанию. Объявление объекта f3 вызывает другой конструктор. Упражнения Упражнение 12.2.1. Перепишите конструктор по умолчанию таким образом, чтобы вме- сто вызова set (0 , 1), он присваивал значения членам-данным, num и den, непосред- ственно. Это является более эффективным или менее? Необходимо ли вызывать функ- цию normalize? Упражнение 12.2.2. Напишите третий конструктор, принимающий всего лишь один аргумент типа int. Его реакция заключается в присваивании переменной num значения данного аргумента, а переменной den - значения 1. Переменные и аргументы ссылочного типа (&) Перед тем как продолжить изучение еще одного специального конструктора, называемо- го конструктором копирования (copy constructor), необходимо немного отклониться от темы и узнать о ссылках в языке C++. Эта тема, возможно, окажется новой для вас, если вы являетесь программистом на языке С. Хорошие новости заключаются в том, что с помощью ссылок становится проще, а не тяжелее, программировать определенные вещи. И, как вы увидите в следующем разделе, ссылки оказываются необходимыми для выполнения определенных задач в языке C++. Проще говоря, ссылка в языке C++ реализует поведение указателя без синтаксиса указа- телей. Это важный момент, поэтому разрешите сформулировать мысль снова. Переменная, аргумент или возвращаемое значение ссылочного типа ведут ♦♦♦ себя, как указатели, однако без необходимости использования синтаксиса указателей.
294 C++ без страха Самый непосредственный способ обращения с переменной, конечно, заключается в не- посредственном воздействии: int П; п = 5 ; Другим способом обращения с переменной - если вспомнить главу 6 - является исполь- зование указателя. int п, *р; р = &п; // Позволить переменной р указывать на // переменную п. *р = 5; // Присвоить ТОМУ, НА ЧТО УКАЗЫВАЕТ // ПЕРЕМЕННАЯ р, значение 5. Здесь переменная р указывает на переменную п, поэтому присваивание выражению *р значения 5 эквивалентно присваиванию переменной п значения 5. Ссылка выполняет почти те же самые действия, хотя избегает синтаксиса указателей. Во-первых, объявляется переменная (п) и ссылка на нее (г). int П; int &г = п; Вы можете возразить, что амперсанд (&) - это точно такой же символ, который исполь- зуется для обозначения оператора взятия адреса. Это верно. Отличие заключается в том, что здесь амперсанд используется в объявлении. В данном контексте амперсанд создает переменную ссылочного типа г, которая ссылается на переменную п. Это означает, что изменения переменной г приведут к изменениям переменной п. г = 5; // Аналогично присваиванию переменной п // значения 5. Эта операция аналогична использованию указателя р для присваивания значения пере- менной п, но для переменной г не нужен оператор «at» (оператор разыменования) (*). Вспомните, что для работы с использованием указателя р необходим этот оператор: *Р = 5 ; В отличие от переменных-указателей, целевой объект переменной ссылочного типа мо- жет быть установлен только один раз, во время инициализации. Простые переменные ссылочного типа - интересно, что они, тем не менее, могут суще- ствовать - мало используются в языке C++. Гораздо более полезными являются аргу- менты ссылочного типа. Помните функцию swap из главы 6, в которой необходимо бы- ло использовать указатели? Вы можете сделать то же самое, с более коротким синтакси- сом, используя аргументы ссылочного типа. void swap_ref(int &а, int &b) { int temp = a; a = b; b = temp; J
ГЛАВА 12. Конструкторы: если вы их создаете...295 Может показаться, что этот пример нарушает то, что я сказал в главе б - о том, что для подобного примера необходимы указатели - но, конечно же, это не так, поскольку аргу- менты ссылочного типа реализуют поведение указателей без использования синтаксиса указателей. Аргументы а и b не являются копиями входных данных для функции, а ссы- лаются на них. Во время выполнения программа использует указатели, однако этот факт не отображается в исходном коде. Поэтому, функция ведет себя таким образом, как если бы она была написана в следую- щем виде: void swap_ptr(int *а, int *b) { int temp = ‘a; *a = *b; *b = temp; Существует также отличие и в том, как эти функции вызываются, поскольку компилятор проверяет типы аргументов. (Между прочим, возможно обмануть компилятор, исполь- зуя несколько модулей и изменив информацию в заголовках, однако для чего вам это может понадобиться?) Версия функции swap, использующая ссылки, вызывается сле- дующим образом: int big = 100; int little = 1; swap_ref(big, little); // функция swap, использующая // ссылки При вызове функции swap_ref неявно передаются адреса, хотя это и не отражается в исходном коде. Поэтому оператор взятия адреса (&) не используется, хотя на самом деле передаются адреса. Однако версия функции swap, использующая указатели, вызывается следующим обра- зом. Обратите внимание, что использование оператора взятия адреса (&) для версии с использованием указателей обязательно. int big = 100; int little = 1; swap_ptr(&big, &little); // функция swap, использующая // указатели Конструктор копирования Ранее я представил особый конструктор: конструктор по умолчанию. Другим особым конструктором является конструктор копирования (copy constructor) '. Конструктор копирования является особым по двум причинам: во-первых, этот конст- руктор вызывается в ряде общих ситуаций независимо от того, осведомлены ли вы о его существовании или нет. Во-вторых, если вы не напишете конструктор копирования, компилятор автоматически добавит его. Компилятор более снисходителен к данному конструктору, чем к конструк-
296 C++ без страха тору по умолчанию. Он не убирает автоматический конструктор копирования просто потому, что вы решили написать собственные конструкторы. Ниже представлены ситуации, в которых конструктор копирования вызывается ав- томатически: ✓ Когда возвращаемое значение функции имеет тип класса. (Мы уже видели это на примере функций add и mult в главе 11.) Функция создает копию объекта и передает ее обратно вызывающей функции. Когда аргумент имеет тип класса. Создается копия аргумента и затем передается в функцию. Когда один объект используется для инициализации другого объекта. Например: Fraction а(1, 2); Fraction Ь(а); Конструктор копирования не вызывается, когда передается указатель на объект. Он вы- зывается только тогда, когда необходимо создать новую копию существующего объекта. Синтаксис для объявления конструктора копирования следующий: имя_класса(имя_класса const ^источник) Здесь новым является ключевое слово const. Это ключевое слово гарантирует, что аргу- мент не может быть изменен функцией - что имеет смысл, поскольку при создании ко- пии чего-либо оригинал никогда не должен искажаться. Новым в этом синтаксисе также является использование аргумента ссылочного типа. Использование такого аргумента означает, что функция на самом деле получает указа- тель, хотя в исходном коде синтаксис указателей не используется. Вот пример для класса Point. Во-первых, конструктор копирования должен быть объ- явлен в объявлении класса. class Point { // . . . public: // Конструкторы Point(Point const &src); // . . . }; Поскольку определение функции не является встроенным, оно должно быть сделано за пределами объявления класса. Point::Point(Point const &src) { x = src.x; у = src.y; } Зачем же писать конструктор копирования, если компилятор сам добавляет его? На са- мом деле, в этом случае - и даже в случае класса Fraction - это не является действи- тельно необходимым. Поведение конструктора копирования, добавляемого компилято- ром, заключается в создании побитовой копии каждого из членов-данных.
ГЛАВА 12. Конструкторы: если вы,их создаете...297 В главе 14 я продемонстрирую пример класса (а именно, класса String), для правильной работы которого требуется определенный программистом конструктор копирования. Вставка Конструктор копирования и ссылки Одна из основных причин, из-за которой язык C++ вынужден поддерживать ссылки, заключается в том, чтобы вы могли написать конструктор копирования. Без такого синтаксиса задача была бы невыполнимой. Рассмотрим, что бы случилось, если бы мы объявили конструктор копирования данным способом: Point(Point const src) Компилятор не позволит сделать это, и следующее небольшое рассуждение объяс- нит, почему. Когда аргумент, наподобие аргумента src, передается в функцию, должна быть сделана копия этого объекта и помещена в стек. Но это означало бы, что для работы конструктора копирования необходимо создать копию объекта - таким образом, в результате необходимо вызвать сам конструктор копирования! Это приве- ло бы к бесконечной регрессии, и поэтому такой конструктор копирования был бы абсолютно нерабочим. Что, если конструктор копирования был объявлен таким способом? Point(Point const *src) В этом объявлении нет ничего синтаксически неправильного и, на самом деле, он является годным конструктором. Проблема в том, что он не может быть конструкто- ром копирования, поскольку в синтаксисе данного объявления подразумевается, что аргументом является указатель, а не объект. Использование ссылки позволяет написать функцию-член, которая корректно рабо- тает в качестве конструктора копирования. Синтаксически, аргумент является объ- ектом, а не указателем. Однако, поскольку в реализации вызова используются ука- затели (то есть неявно передается указатель), никакой бесконечной регрессии не происходит. Point(Point const &src) Пример 12.3. Конструктор копирования класса Fraction Следующий переработанный код демонстрирует класс Fraction, включающий конст- руктор копирования, определенный программистом. Этот пример печатает сообщение каждый раз, когда вызывается конструктор копирования. Как всегда, код, выделенный полужирным шрифтом, представляет новые или изменен- ные строки, которые отличаются от предыдущих версий кода класса Fraction.
298 C++ без страха Листинг 12.3. Fract5.cpp ♦include <iostream> using namespace std; class Fraction { private: int.num, den; // Числитель и знаменатель. public: FractionO {set(0, 1);} Fraction(int n, int d) {set(n, d) ; } Fraction(Fraction const &src); void set(int n, int d) {num = n; den = d; normalize();} int get_num() {return num;} int get_den() {return den;} Fraction add(Fraction other); Fraction mult(Fraction other); private: void normalize(); // Преобразование дроби к // стандартному виду. int gcf(int a, int b); // Наибольший общий делитель, int lcm(int a, int b); // Наименьшее общее кратное. }; int main() { Fraction fl(3, 4); Fraction f2(fl); Fraction f3 = fl.add(f2); cout << "The value of f3 is "; cout << f3.get_num() <<"/"; cout << f3.get_den() << endl; return 0; } // ------------------------------------------------- // ФУНКЦИИ КЛАССА FRACTION Fraction::Fraction(Fraction const &src) { cout << "Now executing copy constructor." << endl; num = src.num; den = src.den; } // Нормализация: преобразовать дробь к стандартному // виду, уникальному для каждого математически отличающегося // значения. //
ГЛАВА 12. Конструкторы: если вы их создаете...299 void Fraction::normalize(){ // Обработать случаи со значением О if (den == 0 || num == 0) { num = 0; den =1; } // Оставить отрицательный знак только в числителе. if (den < 0) { num *= -1; - den *= -1; } // Извлечение наибольшего общего делителя из числителя и // знаменателя. int n = gcf(num, den); num = num / n; den = den / n; } // Наибольший общий делитель // int Fraction::gcf(int a, int-b) { if (a % b == 0) return abs(b); else return gcf(b, a % b) ; } // Наименьшее общее кратное // int Fraction::1cm(int a, int b){ return (a / gcf(a, b)) * b; J Fraction Fraction::add(Fraction other) { Fraction fract; int led = lcm(den, other.den); int quotl = led/den; int quot2 = led/other.den; fract.set(num * quotl + other.num * quot2, led); fract.normalize(); return fract; } Fraction Fraction::mult(Fraction other) { Fraction fract; fract.set(num * other.num, den * other.den); fract.normalize(); ' return fract; 2:
300 C++ без страха Как это работает В данном примере не очень много нового. Все, что он делает - печатает сообщение, ко- гда вызывается конструктор копирования. В нем даже нет новой функциональности, поскольку, если вы не напишете конструктор копирования, компилятор автоматически добавит свой. Конструктор копирования, добавляемый компилятором, делает то же самое, что и дан- ная версия, за исключением печати сообщения. Fraction::Fraction(Fraction const &src) { cout << "Now executing copy constructor." << endl; num = src.num; den = src.den; } При выполнении программы вы увидите, что происходят повторяющиеся вызовы конст- руктора копирования. Следующая инструкция', очевидно, приводит к вызову этого кон- структора: Fraction f2('fl); Но следующая инструкция приводит к трем вызовам конструктора копирования: один раз, когда объект (2 передается в качестве аргумента, второй раз, когда новый объект передается обратно в качестве возвращаемого значения, и еще раз, когда объект копиру- ется в объект f3. Fraction f3 = fl.add(f2); Происходит много операций копирования - возможно, даже больше, чем вы желаете. Некоторого копирования можно избежать, сделав так, чтобы функция add принимала аргумент ссылочного типа, как это сделано для самого конструктора копирования. Это задача, которой мы займемся в следующей главе. Упражнения Упражнение 12.3.1. Перепишите конструктор копирования класса Fraction, сделав его встроенным. Не включайте инструкцию, печатающую сообщение. Упражнение 12.3.2. Вместо присваивания значений переменным num и den по отдель- ности вызовите функцию set. Это более или менее эффективно? Объясните почему. Резюме Вот основные моменты главы 12: ✓ Конструктор является простой функцией инициализации для класса. Он имеет сле- дующий вид: имя_класса(список_аргументов}
ГЛАВА 12. Конструкторы: если вы их создаете... 301 ✓ Если конструктор не является встроенным, определение функции конструктора име- ет следующий вид: имя—класса::имя_класса(список_аргументов) { инс трукции ✓ У вас может быть любое число различных конструкторов. Они все имеют одно и то же имя функции (которое является именем класса). Единственное требование заклю- чается в том, чтобы каждый конструктор уникально идентифицировался числом или типом аргументов. Конструктор по умолчанию - это конструктор, не принимающий никаких аргумен- тов. Он имеет Следующее объявление: имя—класса() ✓ Конструктор по умолчанию вызывается в том случае, когда объект объявляется без списка аргументов. Например: Point а; ✓ Если вы не объявите конструкторы, компилятор автоматически добавит конструктор по умолчанию. Этот автоматический конструктор устанавливает все данные-члены в ноль (указателям присваивается значение null). Однако, если вы напишете какой- нибудь конструктор, компилятор не добавит конструктор по умолчанию. ✓ Таким образом, для безопасного программирования, всегда пишите свой конструктор по умолчанию. Если вы пожелаете, он может не содержать никаких инструкций. Например: Point а() {} ✓ В языке C++ ссылка является переменной или аргументом, объявленными с ампер- сантом (&). В результате во время выполнения используются указатели, однако отпа- дает необходимость использовать синтаксис указателей. Создается впечатление, что программа передает значение, хотя, на самом деле, передается указатель. ✓ Конструктор копирования класса вызывается всякий раз, когда объект должен быть скопирован. Сюда относятся ситуации, когда объект (а не указатель на этот объект) передается в функцию, или когда функция в качестве возвращаемого значения воз- вращает объект. ✓ Конструктор копирования использует аргумент ссылочного типа вместе с ключевым словом const, которое предотвращает изменение аргумента. Конструктор копирова- ния имеет следующий синтаксис: имя__класса (имя_класса const Ьисточник) ✓ Если вы не напишете конструктор копирования, компилятор всегда добавит его для вас. Он выполняет простое побитовое копирование.
ГЛАВА ia Функции операторов: реализация при помощи классов Прочитав главу 12, вы узнали, как писать классы (типы объектов), которые работают как стандартные типы данных, не так ли? Да, но только в некоторой степени. С наиболее важными функциями стандартных типов данных, таких как int, float, double и даже char, вы уже можете производить оп- ределенные операции. По сути, без этих операторов было бы очень сложно осуществить какие-либо вычисления в языке C++. C++ позволяет вам определить, как выполнять те же самые операции (такие как +, -, * и /) с • объектами вашего собственного класса. Вы также можете описать работу функции про- верки на равенство, что позволит вам проверить, являются ли два числа равными. Преимущество C++ состоит в том, что этот язык позволяет объявлять новые классы, ко- торые почти для всех задач работают так же, как основные типы данных. Введение в функции операторов для класса Основной синтаксис для записи функций оператора для класса прост, поэтому, когда вы овладеете им, вы сможете использовать столько операторов, сколько захотите. геturn_type operator©(argumen Применяя такой синтаксис, вы заменяете символ @ разрешенным оператором C++, на- пример +, -, * и /. Кроме того, вы не ограничены использованием только этих четырех операторов, на самом деле вы можете использовать в этом случае любой символ опера- тора, поддерживаемый стандартными типами в C++. Обычные правила ассоциативности и приоритетов выполняются для этих символов соответствующим образом (смотрите Приложение А). Вы можете определить функцию оператора либо как функцию-член, либо как глобаль- ную функцию (то есть не функцию-член). И Если вы объявляете функцию оператора как функцию-член, то объект, через который эта функция вызывается, соответствует левому операнду. ✓ Если вы объявляете функцию оператора как глобальную функцию, то оба операнда соответствуют аргументу. Это значительно понятнее на примерах. Ниже приведен пример, в котором показано, как можно объявить функции операторов сложения и вычитания (+и -) как часть класса Point: class Point { // . . . public: Point operator+(Point pt); Point operator-(Point pt);
ГЛАВА 13. Функции операторов: реализация при помощи классов 303 Имея эти объявления, вы можете применять операции к объекту Point: Point pointl, point2, point3; pointl = point2 + point3; Компилятор интерпретирует это выражение, вызывая функцию operator+ через ле- вый операнд - в этом случае point2. Правый операнд - в этом случае points - становится аргументом этой функции. Визуально это отношение представлено на рисунке ниже: point2 + points i operator (Point pt) Что происходит с операндом point2? Его значение игнорируется? Нет. Функция рас- сматривает point2 как «этот объект», так что неквалифицированное использование ко- ординат х и у приводит к обращению к копии координат х и у объекта point2. Point Point:: operators- (Point pt) { Point-new_pt; new_pt.x = x + pt.x; new_pt.y = у + pt.y; return new_pt; } Неквалифицированное использование членов данных х и у обращается к значениям в левом операнде (в этом случае point2). Выражения pt.x и pt.y обращаются к значениям в правом операнде (в этом случае point3). Функции оператора здесь объявляются с типом возвращаемого значения Point. Это означает, что они возвращают объект Point. И это правильно: если сложить две точки, то вы получите третью точку; а также если вы отнимаете одну точку от другой, то вы должны получить третью точку. Но C++ позволяет вам указывать любой действитель- ный тип возвращаемого значения функции оператора. Список аргументов также может содержать любой тип. Здесь возможна перегрузка опе- раций: вы можете объявить функцию оператора, которая взаимодействует с типом int, другая функция будет взаимодействовать с типом double и так далее. В случае с клас- сом Point, возможно, имеет смысл разрешить умножение на целое число. Объявление функции оператора будет выглядеть следующим образом: Point operator*(int n); Определение функции будет выглядеть следующим образом: Point Point::operator*(int n) { Point new_pt; new_pt.x = x * n; new_pt.y = у * n; return new_pt; } •
304 C++ без страха Функция опять же возвращает объект Point, хотя вы могли бы заставить ее возвращать любой тип значения по вашему выбору. В качестве альтернативного примера вы можете создать функцию оператора, которая рассчитывает расстояние между двумя точками и возвращает результат в формате с пла- вающей точкой (double). Для этого примера я выбрал оператор %, но вы можете вы- брать любой другой бинарный оператор, предусмотренный в C++ (смотрите Приложе- ние А). Здесь важно то, что вы можете выбрать любой тип возвращаемого значения, со- ответствующий операции, которую вы выполняете. ttinclude <math.h> double Point::operator%(Point pt) { int dl = pt.x - x; int d2 '= pt.у - у; return sqrt((double) (dl * dl + d2 * d2)); } При таком определении функции следующий код корректно выведет расстояние между точками (20, 20) и (24, 23) равное 5,0. Point ptl(20, 20); Point pt2(24, 23); cout « "Distance between points is. : " « ptl%pt2; Функции операторов как глобальные функции В предыдущем разделе я указывал, что вы можете объявлять функции операторов как глобальные функции. Однако есть недостаток такого объявления. В этом случае у вас не будет всех необходимых функций в объявлении класса. Но в некоторых случаях (я сей- час опишу их) использование такого подхода становится необходимым. Глобальная функция оператора объявляется вне класса. Типы в списке аргументов опре- деляют, какие типы операндов использует функция. Например, функция оператора сло- жения для класса Point может быть переписана как глобальная функция. А вот объяв- ление (прототип), которое должно появиться до вызова функции: Point operator+(Point ptl, Point pt2); Ниже приведено определение функции: Point operator+(Point ptl, Point pt2) { Point new_jpt; new_pt.x = pt1.x + pt2.x; new_pt.y = ptl.у + pt2.y; return new_jpt; } Вы можете представить себе вызов этой функции следующим образом: point2 + point3______ ' у operator (Point ptl, Point pt2)
ГЛАВА 13. Функции операторов: реализация при помощи классов 305 Сейчас оба операнда интерпретируются как аргументы функции. Левый операнд (в этом случае point2) передает свое значение первому аргументу pH. Правый аргумент (в этом случае point3) передает свое значение второму аргументу pt2. Концепция «этот объект» отсутствует, и все ссылки на объекты данных класса Point должны быть уточнены. Это может вызвать проблему. Если объекты данных не объявлены открытыми, то эта функция не может получить к ним доступ. Решением может быть использование вызо- вов функции, если таковые имеются, для получения доступа к данным. Point operator+(Point ptl, Point pt2) { Point new_pt; int a = ptl.get_x() + pt2.get_x(); int b = ptl.get_y() + pt2.get_y(); new__pt. set (a, b) ; return new_pt; } Но это не очень хорошее решение, кроме того, для некоторых классов этот вариант мо- жет не работать. Например, у вас может быть такой класс, в котором приватные члены данных полностью недоступны, а вы все равно хотите иметь возможность написания функции операторов. Лучшим решением может быть объявление функции как дружест- венной функции, что означает, что функция является глобальной, но у нее есть доступ к приватным членам класса. В данном случае функция объявляется как дружественная функция для класса Point. class Point { J J... public: friend Point operator+(Point ptl, Point pt2); }; Теперь определение функции имеет непосредственный доступ ко всем членам класса Point, даже если они являются приватными. Point operator+(Point ptl, Point pt2) { Point new_.pt ; int a = ptl.x + pt2.x;- int b = ptl.у + pt2.y; new_pt.set(a, b) ; return new_pt; } Иногда необходимо задать функции Операторов как глобальные функции. В функции- члене левый операнд интерпретируется как «этот объект» в определении функции. А что если левый операнд не объектного типа? Что если вы хотите поддержать подоб- ную операцию? pointl = 3 * point2; Проблема в данном случае заключается в том, что левый операнд имеет тип int, а не Point. Но вы не можете писать новые операции для типа int, как для класса. Единст- венным способом поддержать эту операцию является написание глобальной функции.
306 C++ без страха Point operator*(int n, Point pt) { Point new_pt; new_pt.x = pt.x * n; new_pt. у = pt.y * n; return new_pt; } Как и раньше, для получения доступа к приватным членам данных вам, возможно, пона- добится сделать функцию «другом» класса: class Point { // . . . • public: friend Point operator*(int n, Point pt); }; Вызов этой функции можно представить визуально следующим образом: ' 3 * point2_____ operator*(int n, Point pt) Повышение эффективности при помоши ссылок Очевидным способом осуществления операций над объектами является использование простых объектных типов (классов) в качестве аргументов. Но как было указано в гла- ве 12, каждый раз, когда объект реализуется или возвращается в виде значения, осуще- ствляется вызов копии конструктора. Более того, всякий раз, когда создается объект, программа должна запросить память сис- темы для создания нового объекта. Все это происходит скрыто, но тем не менее влияет на эффективность программы. Вы можете повысить эффективность своей программы, записывая классы таким обра- зом, чтобы они минимизировали процесс создания объектов. Для этого есть простой способ: использовать ссылочные типы (reference types). Ниже описана функция сложения для класса Point, а также функция оператора сложения (+), которая ее вызывает. Эта функция написана без использования ссылочных типов. class Point { // . . . public: Point add(Point pt) ; Point operator+(Point pt) ; }; Point Point::add(Point pt) { Point new_pt; , new_pt.x = x + pt.x; new_pt.y = у + pt.y; return new_pt;
ГЛАВА 13. Функции операторов: реализация при помощи классов 307 } Point Point:: operators- (Point pt) return add(pt); } Это очевидный способ написания этих функций, но обратите внимание на то, насколько выражение, такое как ptl + pt2, приводит к созданию нового объекта. ✓ Правый операнд передается функции operator+. Создается копия pt2 и передает- ся этой функции. ✓ Функция operator* вызывает функцию сложения add. Теперь должна быть соз- дана и передана еще одна копия pt 2. ✓ Функция сложения add создает новый объект - new_pt, который вызывает конструк- тор по умолчанию. Когда функция возвращает значение, программа создает копию объекта new_pt и возвращает ее вызывающему оператору (функция operator+). ✓ Функция operator* возвращается вызывающему оператору, требуя создания еще одной копии объекта new_pt. Так много копирования! Создается пять новых объектов, что приводит к одному вызо- ву конструктора по умолчанию и четырем вызовам конструкторов копирования. Это неэффективно. Сегодня при наличии высокоскоростных процессоров вы можете возразить, что эффективность не является настолько критичным фактором. Если говорить о таком простом классе, как Point, то может понадобиться выполнение ты- сяч повторяющихся операций (или даже миллионов!), чтобы почувствовать за- метную временную задержку, если ваша программа работает не очень эффек- тивно. Однако вы не можете быть полностью уверены в том, как именно бу- дет использован ваш класс. Поэтому, если все же существует простой способ повышения эффективности вашего кода, вам следует им воспользоваться. Вы сможете избежать использования двух из вышеуказанных операций копирования, используя ссылочные аргументы. Вот исправленная версия программы, измененные строки которой выделены полужирным шрифтом: class Point { / / . . . public: Point add(const Point &pt); Point operator*(const Point &pt); }; Point Point::add(const Point &pt) { Point new_pt; new_pt.x = x * pt.x; new_pt.y = у + pt. у; return new_pt; } Point Point::operator*(const Point &pt) return add(pt);
308 C++ без страха Одно из преимуществ использования ссылочных типов, таких как Point&, в том, что изменяется осуществление вызовов функций, но при этом не требуются другие измене- ния исходного кода. Помните, что когда вы передаете ссылку, то функция принимает ссылку на оригинальные данные, но без синтаксиса указателей. Я также буду использовать тут ключевое слово const, которое не допускает изменений передаваемого аргумента. Когда функция получает свою собственную копию аргумента, то она не может изменить значение оригинальной копии, вне зависимости от того, какие операции выполняются. Но ссылочный аргумент, такой как указатель, потенциально может изменить оригинальную копию. Ключевое слово const восстанавливает защиту данных, так что функция не может случайно изменить значение аргумента. Изменение устраняет две операции копирования объекта. Но каждый раз после возврата значений этими функциями создается копия объекта. Вы можете сократить количество этих копий, сделав одну или обе. эти функции встраиваемыми. Функция operator+, которая просто вызывает функцию сложения add, является хорошим претендентом на то, чтобы стать встраиваемой. class Point { // . . . public: Point add(const Point &pt) ; Point operator+(const Point &pt) {return add(pt);} }; Когда функция operator+ встраивается таким способом, то операции, такие как ptl + pt2, транслируются непосредственно в вызовы функции сложения add. Пример 13.1. Операторы класса Point Теперь у вас есть все необходимые инструменты для написания эффективных и полез- ных функций операторов для класса Point. Нижеуказанный код показывает полное объявление класса Point, а также код, который тестирует его, объявляя и выполняя операции над объектами. Код, который остался неизменным из главы 12, написан обычным шрифтом, а новый и измененный код выделен полужирным шрифтом. Листинг 13.1. Point3.cpp ttinclude <iostream> using namespace std; class Point { private: // Data members (private) int x, y; public: // Constructors Point() {} Point(int new_x, int new_y) {set(new_x, new_y);} Point(const Point &src) (set(src.x, src.y);} // Операции Point add(const Point &pt);
ГЛАВА 13. Функции операторов: реализация при помощи классов 309 Point sub(const Point &pt); Point operator*(const Point &pt) {return add(pt);} Point operator-(const Point &pt) {return sub(pt);} // Другим функции-члены void set(int new_x, int new_y); int get_x() const {return x;} int get_y() const {return y;} }; int mainf) { Point pointl(20, 20); Point point2(0, 5); Point point3(-10, 25); Point point4 = pointl + point2 + point3; cout « "The point is " « point4.get_x(); cout << ", " << point4.get_y() « « endl; return 0; } void Point::set(int new_x, int new_y) { if (new_x < 0) new_x * = -1; if (new_y < 0) new_y * = -1; x = new__x ; у = new_jy ; } Point Point::add(const Point &pt) { Point new pt; new_pt.x = x + pt.x; newypt.y = у + pt.y; return new pt; } Point Point::sub(const Point &pt) { Point new_pt; newjt.x = x - pt.x; new_pt.y = у - pt.y; return new_pt; } Как это работает В этом примере к классу Point добавляется серия функций-членов: Point add(const Point &pt) ; Point sub(const Point &pt); Point operator*(const Point &pt) {return add(pt);} Point operator-(const Point &pt) {return sub(pt);}
310 C++ без страха Функции add и sub выполняют операции сложения и вычитания координат, таким об- разом, вы можете записать выражение следующего вида: Point pointl = point2.add(point3); В этом выражении объекты point2 и point3 складываются для получения нового объекта класса Point. Функция operator+ является встраиваемой функцией, которая транс- лирует такие выражения, как представлено ниже, в вызов функции сложения. Point pointl = point2 + points; При такой записи функция выполняется с минимальными вычислительными затратами, потому что данная функция является встраиваемой и используется ссылка на параметр (const Point &). Выражение point2 + points транслируется в вызов функции operator+, которая, в свою очередь, вызывает функцию add. Функция add, в свою очередь, создает новый объект класса point (new_.pt), ини- циализируя его путем добавления координаты «этого объекта» к координатам объектно- го аргумента. «Этот объект» - это объект, через который происходит вызов функции. Другими словами, это объект point2 в следующем выражении: point2.add(points); Функции operator - и sub работают по такому же принципу. Также в этом примере к объявлениям функций get_x и get_y прибавляется ключевое слово const. Ключевое слово добавляется после оставшейся части объявления, но перед открывающейся фигурной скобкой ({). В этом контексте ключевое слово const озна- чает, что «функция не разрешает изменение каких-либо объектов данных». int get_x() const {return x;} int get_y() const {return y;} Данное изменение полезно по ряду причин. Так вы предотвращаете нежелательные из- менения объектов данных, позволяете выполнять вызов функций через другие const функции, а также разрешаете вызов функций через функции, в которых разрешается не изменять объект Fraction (так как они имеют аргумент const объекта Fraction). Упражнения Упражнение 13.1.1. Напишите тестовую программу, чтобы увидеть сколько раз вызы- ваются конструктор по умолчанию и конструктор копирования. (Подсказка: вставьте выражения, которые передадут вывод объекту cout; вы можете использовать несколько строк, если необходимо, при условии, что описания функции синтаксически правильны). Потом запустите программу в таком виде, а затем запустите ее со ссылочными аргумен- тами (const Point&), замененными на обычные аргументы (Point). Насколько эффективным является старый подход? Упражнение 13.1.2. Напишите и протестируйте расширенный класс Point, который поддерживает умножение объекта Point на целое число.- Используйте глобальную функцию, поддерживаемую дружественными объявлениями friend, как это описано в предыдущем разделе.
ГЛАВА 13. Функции операторов: реализация при помощи классов 311 Пример 13.2. Операторы класса Fraction В этом примере используется техника, аналогичная технике примера 13.1, для расшире- ния поддержки базовых операторов для класса Fraction. Как и раньше, в коде для эффективности используются ссылочные аргументы (const Fraction &). Листинг 13.2. fract6.cpp ttinclude <iostream> using namespace std; class Fraction { private: int num, den; // Числитель и знаменатель, public: Fraction() {set(0, 1);} Fraction(int n, int d) {set(n, d);} Fraction(const Fraction &src); void set(int n, int d) {num = n; den = d; normalize();} int get_num() const {return num;} int get_den() const {return den;} Fraction add(const Fraction &other); Fraction mult(const Fraction bother); Fraction operator*(const Fraction bother) {return add(other);} Fraction operator*(const Fraction bother) {return mult(other);} private: void normalize(); // Перевести дробь в стандартную форму, int gcf(int a, int b) ; // Наибольший общий делитель, int 1cm(int a, int b); // Наименьший общий // знаменатель. }; int main() { Fraction fl(l, 2); Fraction f2(l, 3); Fraction f3 = fl + f2; cout « "1/2 + 1/3 = "; cout << f3.get_num() << "/"; cout << f3.get_den() << "." << endl; return 0; } // --------------------------------------------------- // ФУНКЦИИ КЛАССА FRACTION
312 C++без страха Fraction::Fraction(Fraction const &src) { num = src.num; den = src.den; } // Нормализация: перевести дробь в стандартную форму, // уникальную для каждого математически отличного значения. // void Fraction::normalize(){ // Проверка на равенство О if (den == 0 || num =- 0) { num = 0; den = 1; } // Поставить знак минус только в числителе. if (den < 0) { num * = -1; den * = -1; } // Вынести за скобки наибольший общий делитель в // числителе и знаменателе, int n.= gcf(num, den); num = num / n; den = den / n; } // Наибольший общий делитель // int Fraction::gcf(int a, int b) { if (a % b == 0) return abs(b); else return gcf(b, a % b) ; } // Наименьший общий множитель // int Fraction::lcm(int a, int b){ return (a / gcf(a, b)) * b; } Fraction Fraction::add(const Fraction bother) { Fraction fract; int led = lcm(den, other.den); int quotl = led/den; int quot2 = led/other.den; fract.set(num * quotl + other.num * quot2, led);
ГЛАВА 13, Функции операторов: реализация при помощи классов 313 fract.normalize(); return fract; } Fraction Fraction::mult(const Fraction bother) { Fraction fract; fract.set(num * other.num, den * other.den); fract.normalize(); return fract; 2 Как это работает Функции add и mult перенесены из ранее существующего кода в класс Fraction. Все, что мы сделали, - это изменили тип аргумента, поэтому каждая из этих функций использует ссылочные аргументы, обеспечивая более эффективное выполнение. Fraction add(const Fraction &other); Fraction mult(const Fraction &other); При изменении объявлений данных функций, описания функций также должны быть изменены, чтобы отобразить измененный тип аргумента. Но это изменение задевает только заголовок функции (ниже выделено жирным шрифтом). Остальное описание не изменяется. Fraction Fraction::add(const Fraction bother) { Fraction fract; int led = 1cm(den, other.den); int quotl = led/den; int quot2 = led/other.den; fract.set(num * quotl + other.num * quot2, led); fract.normalize(); return fract; } . Fraction Fraction::mult(const Fraction bother) { Fraction fract; fract.set(num * other.num, den * other.den); fract.normalize(); return fract; }. Чтобы понять, как работают эти функции, вы, возможно, захотите кратко просмотреть главу 11. В любом случае, функции операторов класса Fraction выполняют только вызов соот- ветствующей функции-члена (в нашем случае add или mult) и возвращают значение. Например, если компилятор видит выражение fl + f2 он транслирует это выражение путем вызова следующей функции: fl.operator+(f2)
314 C++ без страха Функция operator+ класса Fraction - это встраиваемая функция, описанная сле- дующим образом: Fraction operator+(const Fraction sother) {return add(other);} Поэтому вызов функции транслируется, в конечном итоге, так: fl.add(f2) Операции умножения обрабатываются таким же способом. Выражения в функции main тестируют код функции оператора путем объявления дро- бей, их суммирования и вывода результатов. Fraction fl(1, 2); Fraction f2(l, 3); Fraction f3 = fl + f2; cout « "1/2 + 1/3 = "; cout << f3.get_num() << "/" ; cout « f3.get_den() « 11 . " « endl; Упражнения Упражнение 13.2.1. Измените функцию main в примере 13.1 так, чтобы она запраши- вала последовательность дробных значений,-а цикл ввода прекращался при вводе 0 в качестве знаменателя. Напишите программу так, чтобы она вычисляла сумму всех дро- бей, которые вводятся, и выводила результат на экран. ' Упражнение 13.2.2. Напишите функцию operator- (вычитание) для класса Fraction. Упражнение 13.2.3. Напишите функцию operator/ (деление) для класса Fraction. Работа с другими типами Благодаря перегрузке, вы можете написать много различных функций для каждого из операторов, в которых каждая функции работает с различными типами. Напри- мер, вы можете написать несколько версий функции operator+, которая работает с классом Fraction: class Fraction { //. . . public: operator+(const Fraction Sother); friend operator+(int n, const Fraction &fr); friend operator-*- (const Fraction &fr, int n) ; } Каждая из этих функций (которые, кстати, должны быть где-нибудь описаны) работает с различными комбинациями операндов типов int и Fraction, позволяя вам записы- вать выражения следующего вида: Fraction fractl = 1 + Fraction(l, 2) + Fraction (3, 4) + 4;
ГЛАВА 13. Функции операторов: реализация при помощи классов 315 Но существует более легкий способ поддержки операций с целыми числами. Что вам действительно необходимо, - это функция для преобразования целых чисел в объекты класса Fraction. Если бы такая операция использовалась, тогда от вас бы потребова- лось написание только одной версии функции operator+. В выражении, таком как следующее, компилятор преобразует число 1 в формат класса Fraction, а затем вызо- вет функцию Fraction: : operatorт, чтобы сложить две дроби. Fraction fractl = 1 + Fractiond, 2) ; Оказывается, что такую функцию преобразования легко написать - она поставляется кон- структором класса Fraction, который принимает один аргумент типа int! Это простой конструктор и его можно эффективно использовать как встраиваемую функцию. Fraction(int n) {setfn, 1);} Если есть такое объявление, то все операции, объявленные для двух объектов класса Fraction, автоматически расширяются, включая в себя операции, касающиеся объекта Fraction и целых чисел. Функция присваивания класса (=) Когда вы пишете класс, компилятор C++ автоматически обеспечивает вас тремя специ- альными функциями-членами. Пока я вас познакомил с двумя из них. ✓ Конструктор по умолчанию. Работа автоматической версии (поставляемой компиля- тором) состоит в присваивании каждому члену инициального значения, равного 0. Необходимо отметить, что компилятор не будет использовать этот конструктор, если вы напишете свой собственный... поэтому вам следует всегда писать свой собствен- ный конструктор по умолчанию, даже если он ничего не делает. ✓ Конструктор копирования. Работа автоматической версии состоит в выполнении простого копирования всех членов исходного объекта. ✓ Функция оператора присваивания (=). Это новая функция. Функция оператора присваивания является специальной функцией, потому что компи- лятор сам ее поставляет, если вы не делаете этого. Поэтому мы могли выполнять такие операции, как: Fraction fl; fl = f2 + f 3; Работа функции operator по умолчанию очень похожа на работу конструктора копирования: она также выполняет простое копирование всех членов. У вас может возникнуть вопрос: является ли функция оператора присваивания тоже конструкто- ром копирования? Нет, не является,-хотя на первый взгляд кажется именно так. В обоих случаях все значе- ния одного объекта копируются (по умолчанию) в другой. Различие состоит в том, что конструктор копирования инициализирует новый объект, в то время как оператор при- сваивания копирует значения в уже существующий объект. В некоторых случаях (на- пример, классы, которые включают запрос к памяти или открытие файла) конструктору
316 C++ без страха копирования, возможно, придется выполнить большую работу, чем функция оператора присваивания. Когда вы пишете вашу собственную функцию оператора присваивания, используйте следующий синтаксис: class_name &operator=(const class_name &source_arg) Такое объявление обладает интересной уловкой: оно похоже на конструктор копирова- ния, но функция operator= должна возвращать ссылку на объект класса, а также при- нимать ссылочный аргумент. Здесь функция operator=, возможно, похожа на класс Fraction: class Fraction { // . . . public: Fraction &operator=(const Fraction &src) { set(src.num, src.den); return *this; }; }; В этом коде используется новое ключевое слово this. Я объясню использование ключе- вого слова this и других непонятных вещей функции оператора присваивания в сле- дующей главе. Между тем достаточно знать, что для класса, такого как этот, вам не нужно писать функцию оператора присваивания вообще. Работы по умолчанию здесь вполне достаточ- но, и компилятор всегда поставляет эту функцию оператора, если вы этого не делаете. Функция проверки равенства (==) Следующим вопросом для рассмотрения является оператор проверки равенства. Компилятор не поддерживает автоматически функцию operators для вашего класса, поэтому сле- дующий код, например, не будет работать, если вы не напишете необходимую функцию: Fraction f1(2, 3) ; Fraction f2(4, 6); if (fl == f 2) cout << "The fractions are equal."; else cout << "The fractions are not equal."; Этот код должен выводить сообщение о том, что дроби равны, даже если были введены различные числа (2/3 версия 4/6). Будет ли операция выполняться правильно, если вы просто сравните числители и знаме- натели (num и den)? Да, с учетом способа, которым мы создали этот класс. Мы написали этот класс так, что после того, как установлены значения, Fraction сокращается до однозначного математического выражения (например, преобразование -10/-20 в 1/2). Таким образом, написать тест на равенство (operator==) не сложно. Если числители и знаменатели равны, тогда и дроби равны.
ГЛАВА 13. Функции операторов: реализация при помощи классов 317 int Fraction::operator==(const Fraction bother) { if (num == other.num && den == other.den) return true; else return false; } Определение этой функции может быть упрощено путем возвращения результата самого условия. int Fraction::operator==(const Fraction bother) { return (num == other.num bb den == other.den); } Определение функции теперь достаточно короткое, чтобы ее можно было сделать встраиваемой. class Fraction { // . . . public: int operator==(const Fraction bother) { return (num == other.num bb den == other.den); }; ' } ; Использование типа ссылочного аргумента (const Fraction b) приводит к более эффективной работе программы. Поэтому, кстати, он тут и используется. Вставка Немного о булевом типе (bool) Самые последние версии языка C++ поддерживают специализированный булевский тип, bool. Этот тип аналогичен типу int, за исключением одного важного отличия: хотя вы можете присвоить любое значение булевой переменной, булевы значения автоматически становятся true (1), если они не отрицательны. Если булевский тип поддерживается вашей версией компилятора, то использование его преимуществ в этой ситуации является хорошей программистской практикой. Булева величина предназначена для хранения как истинного, так и ложного значения, таким образом, этот тип выполняет самодокументирующую функцию. Булева версия функции выглядит так же, как и булев возвращаемый тип. Больше ни- чего менять не надо. public Fraction { // . . . public: bool operator==(const Fraction bother) { return (num == other.num bb den == other.den); }; };
318 C++ без страха Функция «Print» класса Необходим еще один штрих для того, чтобы получить практически полную версию класса Fraction. Многократное написание практически одних и тех же строк кода для выведения содержимого дроби надоедает: cout « f3.get_num() « "/"; cout « f3.get_den() « << endl; Если вы читаете главу с самого начала, вы наверняка заметили, что все примеры кода содержат одинаковые выражения. Избавление от необходимости постоянно писать одни и те же выражения - это то, что можно назвать возможностью повторного использования. Очевидным способом реализовать эту возможность является написание функции. Но, так как каждый класс имеет свой собственный формат данных, то в идеале каждый класс должен иметь свою функцию «print». Вы можете так и назвать функцию-член «print», так как это слово не является ключевым в C++. void Fraction::print() { cout << num. << "/"; cout << den; }; Но, каким бы хорошим ни было это решение, оно не является лучшим. Более объектно- ориентированное решение учитывает тот факт, что cout является объектом. Идеальная функция «print» должна взаимодействовать с объектом cout (так же как и с другими объектами ostream, такими как выходные файлы). Наилучшим решением было бы такое, которое рассматривало бы класс Fraction как любой другой основной тип данных (как я и обещал в этой главе). Это то, что вы долж- ны уметь делать: cout « fract; Чтобы поддерживать такие выражения, необходимо написать функцию operator<<, которая взаимодействовала бы с родительским классом объекта cout, ostream. Функ- ция должна быть глобальной, потому что левый операнд является объектом класса ostream и у нас нет доступа для обновления или изменения кода ostream. Функция должна быть объявлена как друг класса Fraction для того, чтобы иметь дос- туп к его приватным членам. class Fraction { //... public: friend ostream &operator«(ostream &os, Fraction &fr) ; }; Обратите внимание на то, что функция возвращает ссылку на объект ostream. Это необ- ходимо для того, чтобы выражения, подобные следующему, работали корректно: cout « "The value of the fraction is " « fract «
ГЛАВА 13. Функции операторов: реализация при помощи классов 319 endl ; И, наконец, работающее определение функции operator«: stream &operator«(ostream &os, Fraction &fr) { os « fr.num « "/" « fr.den; return os; } Красота этого решения заключается в том, что вывод класса Fraction корректно направ- ляется для дальнейшей пересылки любому заданному объекту ostream. Это могут быть, та- кие файловый потоковые объекты, как cout. Например, если выходной файл является объ- ектом вывода текстового файла, вы можете использовать его для вывода дроби в файл. outfile « fact; Пример 13.3. Завершенный класс Fraction Хотя класс Fraction может быть значительно расширен (особенно поддержкой вычи- тания и деления, которые были определены ранее в виде упражнения), его можно счи- тать относительно полным. В этом примере я даю его в достаточно полной форме для широкого использования. Заметьте, что вам не нужно включать весь этот код в каждую программу, которая ис- пользует этот класс. Независимые функции класса (те, которые не встроены) могут быть помещены в свой отдельный модуль, в котором их нужно откомпилировать всего один раз. Результирующий объектный файл (файл с расширением .о) затем может быть связан с любым проектом, в котором он потребуется. Вам также придется поместить объявле- ние класса в отдельный заголовочный файл (Fract.h), а затем вставить следующее выра- жение в начало любой программы, которая будет использовать этот класс. ttinclude "Fract.h" Ниже приведена полная версия класса Fraction, а также код для его проверки. Как и ранее, полужирным выделены только новые участки кода. Листинг 13.3. Fract7.cpp ttinclude <iostream> using namespace std; class Fraction { private: int num, den; // Числитель и знаменатель’, public: Fraction() {set(0, 1);} Fraction(int n, int d) {set(n, d);} Fractionfint n) {set(n, 1);} Fraction(const Fraction &src); void set(int n, int d) {num = n; den = d; normalize();}
320 C++ без страха int get_num() const {return num;} int get_den() const {return den;} Fraction addfconst Fraction bother); Fraction mult(const Fraction'bother) ; Fraction operators- (const Fraction bother) {return add(other);} Fraction operator*(const Fraction bother) {return mult(other);} int operator==(const Fraction bother); friend ostream boperator<<(ostream bos, Fraction bfr) ; private: void normalize int gcf(int a, int 1cm(int a, }; 0 ; int int .// // b) ; b) ; Перевести дробь в форму. // Наибольший // Наименьший // знаменатель стандартную общий делитель общий int main () { Fraction fl(l, Fraction f2(1, 2) ; 3) ; } Fraction f3 = cout << "1/2 + return 0; fl + 1/3 f2 + + 1 1; = " << f3 << endl // -------------------------------------------------- // ФУНКЦИИ КЛАССА FRACTION Fraction::Fraction(Fraction const bsrc) { num = src.num; den = src.den; } // Нормализация: перевести дробь в стандартную форму, // уникальную для каждого математически окличного значения-. // void Fraction:inormalize(){ // Проверка на равенство О if (den == 0 || num == 0) { num = 0; den = 1; }
ГЛАВА 13. Функции операторов: реализация при помощи классов 321 // Поставить знак минус только в числителе. if (den < 0) { num *= -1; den *= -1; } // Вынести за скобки наибольший общий делитель в // числителе и знаменателе. int n = gcf(num, den); num = num / n; den = den I n; } // Наибольший общий делитель // int Fraction::gcf(int a, int b) { if (a % b == 0) return abs(b) ; else return gcf(b, a % b); } // Наименьший общий множитель // int Fraction::1cm(int a, int b){ return (a / gcf(a, b)) * b; }' Fraction Fraction::add(const Fraction &other) { Fraction fract; int led = lcm(den, other.den); int quotl = led/den; int quot2 = led/other.den; fract.set(num * quotl + other.num * quot2, led); fract.normalize(); return fract; } Fraction Fraction::mult(const Fraction mother) { Fraction fract; fract.set(num * other.num, den * other.den); fract.normalize(); return fract; } int Fraction?:operator==(const Fraction bother) { return (num == other.num && den == other.den); 11 - 6248
322С-^^без страуа-; } // ---------------------------------------------: И ДРУЖЕСТВЕННАЯ ФУНКЦИЯ КЛАССА FRACTION; ostream &operator<<(ostream &os, Fraction &fr) { os << fr.num << "/" << fr.den; return os; } Как это работает В этом примере к классу Fraction добавлены еще несколько новых возможностей: ✓ Конструктор, который принимает один аргумент типа int. ✓ Функция оператора, поддерживающая оператор проверки равенства (==). ✓ Глобальная функция, поддерживающая вывод объектов Fraction в объекты ostream, такие как cout. Новый конструктор достаточно прост, поэтому может быть встроен (определен в объяв- лении функции). Как я говорил ранее, одним из преимуществ этого конструктора явля- ется то, что он определяет, как конвертировать целое значение в объект Fraction, если возникает такая необходимость. То есть при наличии этого конструктора вам не нужно добавлять целую группу функций, описывающих способы обработки целочисленных аргументов; вместо этого программа автоматически'конвертирует целые числа в объек- ты Fraction, когда это необходимо. Fraction(int n) {set (n, 1) ;} ' • Функция здесь должна использовать любое число, определенное как числитель, а в каче- стве знаменателя использовать 1. Это значит, что 1 конвертируется в 1/1,2 конвертиру- ется в 2/1, 5 конвертируется в 5/1 и т.д. Эта операция математически полностью соответствует данной ситуации. Когда целое число, например 5, конвертируется в 5/1, значение остается тем же, но уже в формате Fraction. Это как раз то, что нам нужно. Еще одним дополнением класса Fraction является код, приведенный в предыдущем разделе. Сначала объявление класса пополняется объявлением двух новых функций: int operator==(const Fraction &other); friend ostream &operator«(ostream &os, Fraction &fr) ; Первая функция - operator== является истинной функцией-членом класса Frac- tion; она определяется за пределами класса как Fraction: : operator== для более четкого определения его области видимости. Так как это функция-член, то она вызывается через специальный объект. int Fraction::operator==(const Fraction &other) { return (num == other.num && den == other.den); } M-
ГЛАВА 13. Функции операторов: реализация при помощи классов 323 Запомните, что неквалифицированные ссылки на num и den в данном случае ссылаются на члены «этого объекта», другими словами - на левый операнд. Выражения other.num и other.den ссылаются на значения правого операнда. Объявление функции operator<< означает, что эта функция является глобальной, а также является дружественной функцией класса Fraction. Поэтому у нее есть доступ к приватным данным (а именно к num и den). Это перегруженная функция. Компилятор зависит от списка аргументов, чтобы однозначно определить его. Ниже приведено само объявление. ostream &operator<<(ostream &os, Fraction &fr) { Os << fr.num << "/" << fr.den; return os; } Упражнения Упражнение 13.3.1. Измените функцию operator<< упражнения 13.4 таким образом, чтобы она выводила числа в формате «(n, d)», где п и d - числитель и знаменатель (члены num и den) соответственно. Упражнение 13.3.2. Напишите функции «больше» (>) и «меньше» (<) и измените функ- цию main Упражнения 13.4 так, чтобы проверить эти функции. Например, проверьте больше ли 1/2 + 1/3 чем 5/9. (Подсказка: помните, что A/В больше С/D, если A *D > В * С). Упражнение 13.3.3. Напишите функцию operator«, которая посылала бы содержи- мое объекта Point объекту ostream (такому как cout). Допустим, функция была объявлена как дружественная функция класса Point. Напишите определение функции. Резюме Отметим основные вопросы, рассмотренные в главе 13: ✓ Объявление функции оператора для класса имеет следующий вид, где @ - любой дей- ствительный оператор C++. return_type operator© (argument_list) v' Функция оператора может быть объявлена как функция-член или глобальная функ- ция. Если она является функцией-членом, то (для бинарного оператора) она имеет один аргумент. Например, функция operator+ для класса Point может иметь та- кое объявление и описание: class Point { // . . . public: Point operator+(Point pt); }; Point Point::operator+(Point pt) { Point new_pt; и*
324. ЛТ 1 - L;r;' 1 . C++ без страха new_pt.x = х + pt.x,- ' " new_pt.y = у + pt.y; return new_pt; } \ f ✓ Имея этот код, компилятор теперь знает, как интерпретировать знак сложения, при- мененный к двум объектам класса. pointl + point2 ✓ Когда функция оператора используется таким образом, левый операнд становится объектом, через который вызывается функция, а правый оператор передается как ар- гумент. То есть в описании operator+, приведенном выше, неквалифицированные ссылки на х и у относятся к значениям левого операнда. ✓ Функции операторов также могут быть объявлены как глобальные функции. В случае бинарного оператора функция имеет два аргумента. Например: Point oper-ator+ (Point ptl, Point pt2) { Point new_pt; new_pt.x = ptl.x + pt2.x; new_pt.y = ptl.у + pt2.y; return new_pt; } ✓ Одним из недостатков такого способа написания функции оператора является то, что теряется доступ к приватным членам. Чтобы избежать этой проблемы, объявите гло- бальную функцию другом класса. Например: class Point { // . . . public: friend Point operator+(Point ptl, Point pt2); }; ✓ Если аргумент принимает объект, но не должен изменять его, в большинстве случаев вы можете повысить эффективность функции, изменив ее так, чтобы она использова- ла ссылочный аргумент - например, изменив аргумент типа Point на тип const Points. ✓ Конструктор с одним аргументом обеспечивает функцию преобразования. Например, следующий конструктор делает возможным автоматическое преобразование цело- численных данных в формат класса Fraction. Fraction(int n) {set(n, 1);} ✓ Если вы не напишете функцию оператора присваивания (=), компилятор автоматиче- ски предоставит вам такую функцию. Такая неподдерживаемая компилятором версия должна выполнить простое копирование всех членов объекта. ✓ Компилятор не предоставляет функцию проверки равенства (==), так что вам при- дется написать собственную функцию, если вы хотите сравнивать объекты. Непло- хой идеей является использование возвращаемого булевого типа, если ваш компиля-
ГЛАВА 13. Функции операторов: реализация при помощи классов 325 тор поддерживает такой тип. В противном случае используйте возвращаемый тип int для этой функции. ✓ Лучший способ написания функции «print» для класса - это написать версию функ- ции operator«, которая является глобальной функцией, но (как друг) имеет дос- туп к приватным данным класса. Первый аргумент должен иметь тип ostream, что- бы потоковый оператор («) поддерживался для cout и для всех других классов, считающих объект ostream своим основным классом. Сначала вы должны объявить эту функцию как дружественную вашему классу. Например: class Point { //. . . public: friend ostream &operator«(ostream &os, Fraction &fr) ; }; ✓ В описании функции выражения должны передавать данные от правого оператора (fr в данном случае) аргументу ostream. Затем функция должна вернуть сам аргумент ostream. Например: ostream &operator«(ostream &os, Fraction &fr) { os << fr.num « "/" « fr.den; return os; }
ГЛАВА 14. Что такое «new»? Класс StringParser Одной из тем этой книги является объяснение того, что нет ничего загадочного и маги- ческого в понятии класса и объектно-ориентированном программировании. В отличие от некоторых других эта книга объясняет, что сам по себе класс не более полезен, чем ка- кое-либо устройство, состоящее из беспорядочно собранных железок. Класс становится полезен, когда он предоставляет набор взаимосвязанных сервисов, которые решают об- щие задачи программирования. Одна из наиболее распространенных таких задач - это получение данных на входе и их последующий анализ. В этой главе описывается один из классов - класс StringParser, который используется для разделения входной строки данных на несколько подстрок,, каждая из которых содержит одно слово. Большинство функциональности этого класса также предоставляется функ- цией библиотеки strtok..., но вам все же будет полезно узнать, как написать этот класс. Одна из функций, которую невозможно выполнить при помощи биб- лиотечной функции strtok, - это возможность работы с несколькими строками одновременно при объектно-ориентированном подходе, но это не проблема. В процессе обсуждения этого класса в этой главе также рассматривается зарезервиро- ванное слово new, являющееся одним из наиболее важных зарезервированных слов в C++ для работы с классами. Использование этого зарезервированного слова позволяет вам получать доступ к памяти и в действительности выделять память под новые пере- менные, когда вам это необходимо. Оператор «new» До данного момента я описывал операции для работы с указателями (pointer operations), разделяя их на два всем известных этапа: размещение данных путем объявления пере- менной, а затем присвоение адреса этой переменной указателю. Например: int п ; int *р = &п; Эта процедура идеально подходит для простых программ. Но C++ предоставляет способ объединения этих двух действий в один шаг, и (как я покажу в этой главе) этот подход является в некоторых случаях единственным рабочим вариантом. Этот подход преду- сматривает использование зарезервированного слова new, синтаксис которого следующий: new type Когда компилятор C++ обрабатывает это выражение, он размещает в памяти новый эле- мент данных с определенным типом и возвращает указатель на этот элемент. Это озна- чает, что вы можете записать выражения следующего типа: int *р; р = new int;
ГЛАВА 14. Что такое «new»? Класс String Parser 327 Или вы можете объединить эти два выражения в одно. int *р = new int; В результате обработки этого выражения создается неименованная целочисленная пере- менная, доступная только через указатель, р. Целочисленный элемент данных, размещаемый в памяти в процессе работы программы int *р = new int; Используя указатель, вы можете (обычно) изменять значение целого числа и использо- вать его в выражениях. *р = 5; *р = *р + 1; cout « "The value of the integer is " << *p; Но какова же цель создания целого числа, доступного только через указатель? Почему бы не воспользоваться обычным объявлением целого числа, как мы это делали ранее? int п; В данном случае мы добиваемся того, что значение целого числа (опять же, доступного через указатель р) не объявляется в программе. Оно создается «на лету». Значение цело- го числа размещается в памяти во время выполнения программы; оно не размещается (как в случае многих переменных) при загрузке программы в память. Это предоставляет программе свободу действий при создании нового пространства дан- ных, а фактически при создании новых переменных, когда это необходимо. Оператор new имеет «напарника» - оператор, который возвращает память, запрашивае- мую оператором new. Это оператор delete, синтаксис которого следующий: delete pointer; Этот синтаксис описывает выражение, в котором указателем является адресное выраже- ние. В результате реализации этого выражения разрушается элемент данных, на который указывает указатель, возвращая используемую указателем память операционной системе. Следующий пример кода выполняет такие три действия: > размещение целого числа «на лету»; > использование указателя для изменения значения целого числа; > возврат занимаемой целым числом памяти операционной системе. int *р = new int; *р = 5; *р = *р + 3 ; cout « "The value of the integer is " « *p; delete p;
328 C++ без страха Опять же, действительно ли так удобно использовать все эти дополнительные шаги (вы- деление и освобождение памяти)? Для этого случая, возможно, и нет, но будьте начеку и не забывайте об этой возможности. Объекты и оператор «new» До настоящего момента я лишь бегло упоминал важную концепцию объектного ориен- тирования: указатели на объекты (pointers to objects). Эта концепция имеет решающее значение среди более сложных концепций объектно-ориентированного программирования. Когда программа взаимодействует с другими программами в графическом интерфейсе пользователя или сетевой среде, она обычно передает или получает указатели на объек- ты. В предыдущей главе я показал, что работа с объектами может быть неэффективной: каждый раз, когда объект копируется, в памяти размещаются новые данные и осуществ- ляется вызов конструктора копирования. Если объект передается несколько раз, посто- янное копирование может привести к большому количеству дополнительной работы. Для минимизации этой неэффективности, системные.приложения и библиотеки обычно передают указатели на объекты. Как правило, программа создает объект и затем предос- тавляет другой программе указатель на этот объект. Program 1 Program 2 Есть еще несколько преимуществ использования указателей на объекты, как вы увидите в главе 17. Указатель на объект может иметь обычный тип указателя (это может быть указатель на абстрактный класс или интерфейс), тогда как объект имеет более специ- фичный тип. Это именно тот факт, который придает объектной ориентации особую гиб- кость. Но об этом поговорим позже. Оператор new работает с объектными типами (классами), как и с фундаментальными типами данных. На самом деле, оператор new был добавлен в основном для работы с классами, хотя он может быть очень полезным для работы с типами, такими как int и double. Например, вы можете использовать оператор new для размещения объекта Fraction и для получения указателя на него. Fraction *pFract = new Fraction; Это выражение создает объект Fraction и вызывает конструктор по умолчанию, так как не определены аргументы. Но здесь вы можете определить аргументы так, как если бы вы делали это при объявлении объекта Fraction в качестве перемен- ной в программе.
ГЛАВА 14. Что такое «new»? Класс String Parser 329 Fraction *pFract = new Fraction(1, 2); // Начальное значение // объекта 1/2 Синтаксис использования оператора new для размещения объекта со списком аргумен- тов следующий: new class_name(argument_list) Это выражение размещает объект определенного класса, вызывает соответствующий конструктор (как определено при помощи argumentjist) и возвращает адрес объекта. Если у вас есть указатель на объект, как вы можете его использовать? Как вы можете обращаться к членам объекта? Очевидным способом является разыменовывание указа- теля и последующее получение доступа к члену. Например: Fraction *pFract = new Fraction(1, 2); cout « "The numerator is " « (*pFract).get_num(); Эта операция - разыменовывание указателя и последующее получение доступа к членам объекта, на который установлен указатель, - является настолько обычной операцией, что C++ предоставляет отдельный оператор, чтобы сделать код более кратким. Вот синтак- сис этой операции: poiпter->member pointer->member() Этот синтаксис описывает доступ к члену данных и члену функции соответственно. Эти два выражения являются эквивалентными: (* pointer).member (★pointer).member() Указанный выше пример, в котором используется указатель для вызова функции get_num, может быть записан следующим образом: cout « "The numerator is " « pFract->get_num(); Вот другой пример, который вызывает функцию множества объекта для присвоения дроби новых значений: pFract->set(2, 5); // Установить указатель на дробь, // присвоив значение 2/5. Размещение массива данных Пока я продемонстрировал, как динамически распределить память в процессе рабочего цикла программы - как запросить большее количество памяти, чем требовалось про- грамме изначально. Звучит неплохо, но насколько это практично? Простая и достаточно распространенная ситуация такова: вам необходимо выделить место для массива, но вы заранее не знаете, насколько большим будет этот массив. Одним из очевидных решений в этом случае является использование массивов такого размера, который наверняка не будет превышен, в надежде на то, что все будет работать (и на самом деле, я использовал похожий подход в предыдущих главах этой книги для
330 C++ без страха работы со строками). Но также очевидно, что это не лучший из возможных подходов. Он часто приводит к возникновению ошибок. C++ позволяет вам объявлять блок памяти во время работы программы, используя опе- ратор new при помощи следующего синтаксиса: new type[size] Это выражение размещает количество элементов, равное size, каждый из которых имеет заданный тип type; затем определяется адрес первого элемента. Тип может быть либо одним из стандартных типов (int, double, char и т.д.), либо классом. Значение size это целое число. Эта версия выражения возвращает указатель на указанный тип, как и другие версии new. Например: int *р = new int[50]; ' р[0] = 1; р[9] = 100; Важным моментом этого синтаксиса является то, что определенный размер, size, должен быть константой. Вы можете определить потребности в памяти в процессе работы про- граммы. Очевидным способом применения этой функции является запрос пользователя о размере массива. int п; cout « "How many elements?"; cint » n; int *p = new int[n]; Это полезный прием, и я буду использовать его в следующем примере. Когда вы используете оператор new в какой-либо из его форм, вы берете на себя ответ- ственность за выделение и освобождение памяти. Поэтому к концу программы вы долж- ны использовать оператор delete для уничтожения всех новых объектов в памяти, ко- торые вы создали. Если вы выделили блок памяти, используя синтаксис этой главы, ос- вободите его при помощи следующего выражения: delete [] pointer: Вставка Решение проблем выделения памяти Когда вы используете оператор new, программа осуществляет запрос к операцион- ной системе по поводу наличия свободной памяти. Система отвечает, проверяя имеющуюся память и выдавая информацию о том, имеется ли свободное место. На современных компьютерных платформах обычно доступно большое количество памяти. Если только вам не требуется исключительно большое количество памяти, вы почти всегда получите тот объем памяти, который вам необходим. Но все же есть вероятность и того, что требуемая память недоступна, поэтому программы следует разрабатывать, учитывая эту возможность.
ГЛАВА 14. Что такое «new»? Класс String Parser 331 Если запрашиваемая память недоступна, то оператор new вернет пустой указатель. Вы можете проверить наличие такой возможности и предпринять соответствующее действие. int *р = new int[1000]; if (Ip) { cout << "Insufficient memory."; exi t(0); } Еще одна проблема может быть связана с потерями ресурсов памяти. Если вы ус- пешно запрашиваете память с помощью оператора new, то операционная система резервирует блоки памяти до тех пор, пока вы не освободите их при помощи опера- тора delete. Если же вы закончите работу программы, не освободив все динамиче- ски выделенные блоки памяти, то система будет терять некоторое количество памяти при каждом запуске программы. И в конце концов у вашего компьютера останется значительно меньше используемой памяти. Компьютер, конечно, не теряет физическую память, и его перезагрузка восстановит память в полном объеме. Но пользователям не хотелось бы делать этого - по крайней мере, не слишком часто. Во избежание этой проблемы, убедитесь, что вы использовали оператор delete для освобождения динамически выделяемой памяти перед окончанием программы. Не- которые системы программирования - например Java и Microsoft Visual Basic - со- держат процесс, называемый «сборщиком мусора», который работает в фоновом ре- жиме, находя и удаляя блоки памяти, которые уже не используются. C++ не имеет такой функции, частично в связи с тем, что она занимает время цикла процессора, а также в связи с тем, что, как и С, C++ предполагает, что вы знаете, что делаете. Пример 14.1. Динамическая память в действии В этом примере показано простое использование динамической памяти: распределение массива, размер которого устанавливается пользователем. Этот массив впоследствии используется для хранения введенных пользователем значений, для их вывода, сумми- рования, а также вывода среднего значения. Листинг 14.1. newl.cpp ttinclude <iostream> using namespace std; int main() { int sum = 0; int n; int *p; cout << "Enter number of items: cin >> n;
332 C++ без страха р = new int[n]; // Выделение памяти под п целых чисел for (int i = 0; i < n; i++) { cout << "Enter item #" << i << "; cin >> p [ i ] ; sum += p[i]; } cout << "Here are the items: "; for (int i = 0; i < n; i++) cout « p[i] << ", cout << endl; cout « "The total is: " « sum << endl; cout « "The average is: " << (double) sum / n ’ « endl; delete [] p; // Освобождение памяти n целых чисел, return 0; 2 Как это работает Наиболее интересной особенностью данного примера является то, что в нем использует- ся оператор new для динамического распределения памяти. Сначала программа запра- шивает у пользователя число: cout << "Enter number of items: "; cin » n; Полученное значение передается переменной п, а затем программа распределяет память для п элементов. р = new int[n]; // Выделение памяти под п целых чисел Указатель р (который ранее был объявлен как тип char*) хранит адрес первого элемен- та. Вы можете использовать его как базовый адрес для индексации, так же, как бы вы использовали имя массива. Остальной код продолжает делать то же самое до последнего выражения, которое использует синтаксис delete [] для освобождения всех элемен- тов, распределенных оператором new. delete [] р; // Освобождение памяти п целых чисел. Обратите внимание на то, что здесь требуются скобки ([ ]), потому что блок памяти был создан для хранения множественных элементов. В противном случае может быть доста- точно простой формы оператора delete. Упражнение Упражнение 14.1.1. Измените упражнение 14.1 так, чтобы программа создавала массив чисел с плавающей запятой (тип double). Обратите внимание на то, что большинство переменных должны изменяться, чтобы использовать базовый тип double, несмотря на то, что тип переменной п должен оставаться целочисленным.
ГЛАВА 14. Что такое «new»? Класс String Parser 333 Разработка синтаксического анализатора (лексического анализатора) Основным предназначением инструмента программирования является, конечно, реше- ние практических задач. Преимущество оператора new состоит в том, что он позволяет вам создавать структуры данных на специальном базисе - создавая их по необходимости их появления. Давайте рассмотрим, как это может работать внутри полезного класса. Большинство функций класса, разработанного здесь (StringParser), пре- доставляются функцией strtok («строковый токенайзер») из стандартной библиотеки C++, несмотря на то, что ее работа менее объектно- ориентированна. Истинной целью описания класса StringParser является ил- люстрация определенных технологий программирования для написания классов. В начале главы я упоминал, что одной из наиболее общих задач программирования яв- ляется синтаксический анализ - или, скорее, лексический анализ. Вы можете убедиться в этом на примере класса Fraction. Возможно, вы захотите, чтобы пользователь вводил дроби в такой форме: 3/4 ' : Вместо необходимости выталкивания из стека этой строки по частям и попытки опреде- лить, где начинается одна подстрока, и оканчивается другая, было бы неплохо иметь объект, который бы делал это за вас. А именно, вам нужно создать объект путем определения двух строк. StringParser parser("3/4", "/"); В первой строке содержатся дайные, которые необходимо проанализировать - это ти- пичная входная строка. Вторая строка - это строка-разделитель; в этой строке содер- жаться символы, используемые для различения одной подстроки (или «слова») от дру- гой. Любой из этих символов может служить для указания места, в котором одна под- строка оканчивается и начинается другая. Например, если есть такое выражение: StringParser parser("3/Д/55@10", "/©"); Объект parser распознает подстроки «3», «4», «55» и «10». Возвращаясь назад к исходному примеру, полагаем, что входная строка содержит стро- ковые данные «3/4», а синтаксический анализатор создан при помощи выражения: StringParser parser{input_string, "/"); Теперь мы можем выделить подстроки «3» й «4», вызвав функцию get: char *pl, *р2; pl = parser.get{); р2 = parser.get(); Преимущество такого подхода состоит в том, что все, что вам надо сделать, - это сказать «извлечь следующую строку» (что и делает функция «get»)- Вам не надо беспокоиться
334 C++ без страха о поддержке индикатора текущей позиции внутри строки input_string. Это хорошая ил- люстрация одной из главных причин использования классов - сокрытия данных. Конечно же, мы не скрываем много данных: только индикатор текущей позиции вместе с входной строкой и строкой-разделителем, которые необходимо определять только единожды. Действительно сильным классом может быть класс, который скрывает много данных. Или им может быть класс (такой как класс String, который мы обсудим в сле- дующем разделе), который имеет только один элемент данных, но управляет им слож- ными способами. Но вернемся обратно к классу StringParser. Функция get создает совершенно но- вую строку, оканчивающуюся символом конца строки, и возвращает указатель на эту строку: эти две строчки кода создают две строки. pl = parser.get(); р2 = parser.get() ; Как происходит размещение строковых данных? Конечно же, при помощи оператора new (о котором я расскажу, когда буду описывать реализацию класса). Так как новые строковые данные создаются путем использования оператора this, данные, в итоге, должны быть изменены или вы рискуете создать дырки в памяти, как я описывал ранее в разделе «Решение проблем выделения памяти». delete [] pl; delete [] р2; Так как функция get возвращает новую строку при каждом ее вызове и не сохраняет внутри информацию о ней, то вызывающий оператор отвечает за использование de- lete для освобождения строкового пространства. Возможно, вы уже заметили некоторые недостатки данного подхода. Функция get возвращает указатель на абсолютно новую строку, и вам не нужно беспо- коиться о том, как именно распределяется пространство для этой строки. Но теперь ответственность за ликвидацию этой строки ложиться непосредст- венной на того, кто использует данный объект. Возможно, было бы лучше (и это на самом деле так), чтобы функция get копировала строковые данные в существующую строку, для которой вызывающий оператор выделил про- странство. Вы сделаете это в упражнении следующего раздела. Иногда, конечно же, вы захотите получить достаточно длинную подстроку, чтобы пре- образовать ее в число. Мы можем добавить эту возможность также путем создания функции get_int. int d = parser.get_int{); int n = parser.get_int(); Реализация этих выражений проста, как вы убедитесь позже. Функция get_int требует только вызова функции get (полагая, что она доступна), а затем вызова библиотечной функции atoi, которая считывает строковые данные для генерации целого числа. Данному классу необходима поддержка функции «Есть ли еще что-то?», которая воз- вращает значение true (1), если конец строки не был достигнут. Ниже показано, как вы можете в программе использовать эту функцию, названную more, вместе с функцией get.
ГЛАВА 14. Что такое «new»? Класс String Parser 335 ttinclude <iostream> using namespace std; // . . . char *p[10]; for (int i = 0; parser.more() && i < 10; i++) p[i] = parser.get(); for (int j = 0; j < i; j++) { cout « p[j] << endl; delete [] p[j]; } Объявление создает массив из десяти указателей, каждый из которых имеет тип char*: Обратите внимание, что здесь нет собственно строковых данных - только указатели - потому что строковые данные будут предоставляться объектом синтаксического анализа parser. В указателях этого массива хранятся адреса. char *р[10]; 1 Первый цикл использует объект parser. В цикле вызывается функция more внутри ус- ловия цикла, а затем вызывается функция get для извлечения новой строки при каждом повторении цикла. Эти две функции-члены просты в использовании. for (int i = 0; parser.more() && i < 10; i++) p[i] = parser.get(); Во втором цикле выводятся на экран все строки, поэтому вы можете видеть, что функ- ции класса работают корректно. Обратите внимание, что оператор delete [] должен использоваться один раз для каждого возвращаемого указателя, чтобы удалить строки до окончания выполнения программы. Давайте подведем итог по всем функциям, которые должен поддерживать класс StringParser. Во-первых, он должен поддерживать пару конструкторов. Табл. 14.1 Функции конструктора, поддерживаемые классом StringParser Вызов функции Описание StringParser(input—str, delimiter—str) StringParser(input—str) Присвоить входной строке и строке-разделителю определенные значения Присвоить входной строке определенное значе- ние и использовать по умолчанию строку- разделитель «,». Класс также должен поддерживать следующие функции-члены. Табл. 14.2. Функции-члены, поддерживаемые классом StringParser Вызов функции Описание get О Найти следующую подстроку. Создать новую строку и скопиро- вать содержимое подстроки в новую строку.
336 C++ без страха Вызов функции Описание get_int() Возвратить значение следующей подстроки, преобразуя ее номи- нальное значение в целое число. (Например, возвратить значе- ние 5, если следующая подстрока «5»). more() Возвращать значение true до тех пор, пока остаются данные во входной строке. reset() Переместить внутренний индикатор текущей позиции в начало входной строки. Остается еще одна вещь, которая нужна, чтобы начать написание и тестирование класса: алгоритмы для каждой из этих функций. Возможно, наиболее перспективной для написания функцией является функция get. Запутывает тот факт, что функцию get можно реализовать многими способами. Напри- мер, эта функция, в том числе, должна «уничтожать» разделяющие символы - это озна- чает, что она должна считывать их, не копируя в выходную строку. Должны ли раздели- тели (если таковые имеются) удаляться до или после чтения подстроки? Я использую следующий алгоритм для функции get: Определяем место для новых строковых данных. Если текущий символ является разделителем, Пропускаем этот символ и продвигаемся на следующую позицию. Если текущий символ не является разделителем или пустым символом, Копируем этот символ в новую строку и Продвигаемся на следующую позицию. Добавляем пустой символ к новой строке. Возвращаем указатель на новую строку. Функция get_int является более простой, потому что она многократно использует функцию get для выполнения большей части своей работы. Ниже приведен список все- го, что эта функция должна делать: ✓ Вызов функции get для извлечения следующей подстроки. Присвоение переменной п целочисленного значения этой подстроки путем вызова функции atoi. ✓ Удаление новой строки (освобождение ее памяти). ✓ Возврат п. Последние две функции еще проще. Функция more должна выполнять только следующее: ✓ Возвращать false (0), если текущая позиция соответствует нулевому символу (при- знак конца строки). ✓ В противном случае она должна возвращать 1. Наиболее простой является функция reset: ✓ Устанавливает текущую позицию в 0.
ГЛАВА 14. Что такое «new»? Класс String Parser 337 Данный класс также должен отслеживать закрытые данные: входную строку, строку- разделитель и индикатор текущей позиции pos. Ниже представлен итоговый проект класса: Класс StringParser Пример 14.2. Класс StringParser В этом разделе реализуется и проверяется класс StringParser. При запуске програм- ма выводит следующую подсказку: Enter input line: Как пользователь, вы должны ввести любые символы. Вы можете включать любое коли- чество косых черт (/) и запятых (,) - они обозначают начало подстрок. Например, вы ввели следующую строку: 2/3/35//5,1„„22 Программа выведет следующее: 2 3 35 5 1 22 Как вы заметили,' последовательность разделителей (, , , ,) обрабатывается как один 'разделитель. Это делается согласно проекту класса. Можно также по-другому написать функции класса, чтобы последовательность разделителей обрабатывалась программой как последовательность пустых строк. Вы, наверное, заметили, что конструктор по умолчанию отсутствует для класса String- Parser. Это также согласуется с проектом класса. Объект синтаксического анализато- ра, который не был инициализирован с входной строкой, также не будет полезен. Листинг 14.2. parsel.cpp ttinclude <iostream> ttinclude <stdlib.h> ttinclude <string.h>
338 C++ без страха using namespace std; class StringParser { private: int pos; char *input_str; char *delimiters; public: StringParser(char *inp, char *delim) {input_str = inp; delimiters = delim; pos =0; } StringParser(char *inp) {input_str = inp; delimiters = ","; pos = 0; } char *get(); int get_int(); int more() {return input_str[pos] != '\0 1 ; } void reset() {pos =0;} }; int main() { char input_str[100]; char *p; cout << "Enter input line: "; cin.getline(input_str, 99); StringParser parser(input_str, "/,"); while (parser.more()) { p = parser.get(); // Присвоить указатель новой строке cout << р << endl; // Вывод строки delete [] р; //•Освобождение строковой памяти } return 0; } //------------------------------------------------ • // ФУНКЦИИ КЛАССА STRINGPARSER char *StringParser::get() { int j = 0; char *new_str; new_str = new char[100]; // Удаляет инициальные разделители, если таковые имеются while (strchr(delimiters, input_str[pos]))
ГЛАВА 14. Что такое «new»? Класс String Parser 339 pos++; // Копирует символы, пока не встретит символ // разделителя или конца строки (пустой символ) while (input_str[pos] != '\0' && I strchr(delimiters, input_str[pos])) new_str[j++] = input_str[pos++]; // Доходит до конца строки и возвращает ее. new_str[j] = '\01; return new_str; } int StringParser::get_int() { char *p = get(); return atoi(p); delete [] p; J Как это работает Первое, что делает программа, - это подключает некоторые файлы. Как правило, необ- ходима подключать iostream, чтобы можно было использовать объекты cin и cout. Файлы stdlib.h и string.h должны быть подключены для поддержки используемых клас- сом StringParser библиотечных функций atoi и strchr. ttinclude <iostream> ttinclude <stdlib.h> ttinclude <string.h> Функция main проверяет класс StringParser, используя объект для анализа входной строки, вводимой пользователем. cout << "Enter input line: "; cin.getline(input_str, 99); StringParser parser(input—str, "/,"); while (parser.more()) { p = parser.get(); // Присвоить указатель новой строке cout << р << endl; // Вывод строки delete [] р; // Освобождение строковой памяти } Класс StringParser объявляет три закрытых члена: pos, input_str и delimiters. Об- ратите внимание на то, что последние два из них являются просто указателями, а не массивами строк. Их можно было бы реализовать как строковые массивы, в которых данные о регистре копировались бы путем вызова функции strcpy, а не путем хра- нения адреса (как это сделано в примере). Данный код просто предполагает, что ис- ходные строковые данные (то есть входная строка) не будут уничтожены до того, как с их помощью будет создан класс. Но будьте осторожны: если input_str исчезнет из об-
340 C++ без страха ласти видимости раньше объекта синтаксического анализатора, то указатель объекта будет ссылаться на неверные данные. int pos; char *input_str; char *delimiters; Класс объявляет все функции, описанные ранее в этой главе. Большинство из них совсем небольшие, поэтому я решил встроить их. StringParser(char *inp, char *delim) {input_str = inp; delimiters = delim; pos = 0;} StringParser(char *inp) {input_str = inp; delimiters = pos = 0;} char *get() ; int get_int(); int more() {return input_str[pos] != '\0';} void reset!) {pos = 0;} Функция get является настоящей «рабочей лошадкой» этого класса, и она, конечно же, не встроенная, так как содержит слишком много кода. Первое, что делает функция get, - это размещает новую строку. Размер 100 - произвольный, я выбрал его потому, что думаю, что он не будет превышен. Это, конечно, может быть не совсем верным до- пущением в некоторых ситуациях, но всегда есть способы адресации этого ограничения; о них я расскажу позже. new_str = new char[100]; Следующее, что делает функция get, - уничтожает все разделители, которые только может найти. Это необходимо, так как предыдущая операция get (если таковая имела место), покидает текущую позицию, индексируя символ разделителя. (Во время первого вызова функции get в начале строки может не быть символов разделителя; в таком слу- чае код ничего не делает.) while (strchr(delimiters, input_str[pos])) pos++; Это выражение содержит вызов библиотечной функции strchr, которая возвращает нулевое значение, если символ, определяемый вторым аргументом, не может быть най- ден в первом аргументе - строке. Если же символ был найден в первом аргументе (de- limeiters), функция strchr возвращает отличное от нуля значение и, таким образом, выражение считается истинным. Другими словами, этот код означает, что: Пока input_str [pos] совпадает с одним из символов строки разделителей, значение pos увеличивается на 1. Переменная pos - это индикатор текущего положения, следовательно, input_str [pos] - «текущий символ». Следующее действие функции - это считывание символов в новую строку до тех пор, пока текущий символ не является разделителем или символом конца строки. while (input_str[pos] != '\0' && ! strchr (delimiters, input__str [pos] ) ) new_str[j++] = input_str[pos++];
ГЛАВА 14. Что такое «new»? Класс String Parser 341 И, наконец, функция завершает новую строку и возвращает ее. new_str[j] = 1\0'; return new_str; Усовершенствование кода В коде StringParser есть как минимум один дефект: он предполагает, что каждая под- строка всегда содержит не более 99 символов. В противном случае функция get превышает размер динамически выделенной строки (new_str), что приводит к трудно отлаживаемым ошибкам, так как функция get перезаписывает участки памяти, чего не должна делать. Как программист C++, вы всегда должны быть бдительны насчет такого рода ошибок. Если есть хотя бы минимальная вероятность того, что цикл или функция перезапишут участки памяти, которые перезаписывать не должны, примите меры по предотвращению этого. Очевидным решением является запрет записи в строку, которая уже содержит 99 симво- лов. Возможный вариант кода: while (input_str[pos] != 1\0' && ! strchr(delimiters, input_str[pos])) new_str[j++] = input_str[pos++]; Вы можете решить проблему, вставив условие if в середину выражения. Я выделил новый код полужирным шрифтом: while (input_str[pos] != 1\0' && ! strchr(delimiters, input_str[pos])) if (j < 100) new_str[j++] = input_str[pos++]; Более гибкое решение - определить заранее, сколько места потребуется для new_str. Это, конечно, потребует большей работы для написания функции get, но реализуется относительно просто с использованием библиотечной функции strcspn. Эта функция возвращает индекс первого символа строки s1 (первый определенный ар- гумент), который совпадает с любым символом второго аргумента. Если ни один из символов не совпадает, функция возвращает индекс символа конца строки s1. int substring_size = strcspn(input_str + pos, delimiters); char *new_str = new char[substring_size]; Для правильной работы программы этот код должен быть вставлен непосредственно после цикла, который удаляет символы инициальных разделителей. Упражнения Упражнение 14.2.1. Измените упражнение 14.2 так, чтобы оно вызывало функцию get_int, а не функцию get. Это означает, что считываться будут только числа, при этом некоторая часть кода функции main упростится. ' Упражнение 14.2.2. В класс StringParser добавьте функцию get_dbl, которая считывала бы число с плавающей точкой и возвращала бы значение типа double. (Под- сказка: этот код похож на код get_int, но вызывает функцию atof вместо atoi).
342 C++ без страха Упражнение 14.2.3. Перепишите функцию get, чтобы она копировала подстроку в су- ществующую строку, которая должна быть определена как аргумент. Объявление функ- ции get станет таким: get(char *dest); Ответственность за размещение строковых данных (для хранения подстрок) переклады- вается на пользователя объекта. Вы должны изменить функцию main так, чтобы она выделяла память для хранения этой целевой строки. Упражнение 14.2.4. Добавьте функцию set_size в класс StringParser, чтобы поль- зователь объекта мог задавать максимальное число считываемых символов подстроки. Если необходимо, измените функцию так, чтобы она использовала эту настройку. Резюме Вот основные моменты главы 14; ✓ Оператор new динамически выделяет новые ячейки памяти во время выполнения программы. Он имеет следующий синтаксис, который описывает выражение, воз- вращающее указатель на запрашиваемую память. Этот указатель имеет тип *type. new type ✓ Если тип является объектным (то есть классом), вы можете опционально определять аргументы для вызова необходимого конструктора. new type(argument_l1st) ✓ Вы также можете использовать new для определения последовательности элементов (блок памяти). Как и в случае с другими версиями new, этот синтаксис описывает выражение, которое возвращает значение типа *type. new type[number_of_elements1 ✓ До завершения программы ответственность за освобождение всей динамически вы- деленной памяти лежит на вас. Для удаления одного элемента, созданного с исполь- зованием new, используйте это выражение: delete pointer; ✓ Для удаления блока памяти (любого размера), созданного с использованием new, используйте это выражение: delete [] pointer; ✓ Ниже приведен простой пример использования new и delete. Fraction *pFract = new Fraction[10]; • / / . . . delete [] pFract; ✓ Оператор -> упрощает работу с указателями на объекты. Например: pFract->set(1, 2); Это равносильно следующему: (*pFract).set(1, 2);
ГЛАВА 15. Что такое «this»? Класс String По мере погружения в искусство написания классов вам рано или поздно придется столкнуться с проблемами управления ресурсами. Или более определенно: когда появляется определенный тип объектов, программный код должен сделать запрос системе на размещение некоторых ресурсов. Наиболее об- щим видом ресурса является память. Как вы увидите далее в этой главе, управление ре- сурсами создает некоторые проблемы, для разрешения которых, к счастью, в языке C++ есть специальные языковые функции. В этой главе рассказывается, как написать высокоуровневый класс String, который является простой, но полной демонстрацией решения проблем управления ресурсами. Целью данного класса, как и большинства других классов, является скрытие деталей. Класс String освобождает пользователя класса от проблем распределения и освобож- дения памяти для строк. В двух словах, класс String инкапсулирует символьные мас- сивы char и операции над ними, превращая строку в простой тип данных, а не оставляя ее сложной структурой, как оно есть на самом деле Представленный здесь класс String является упрощенной версией строково- го класса, представленного в главе 7. Если в вашем компиляторе есть библио- теки для строкового класса, то вам не нужно использовать определенный здесь класс, но этот класс все равно является очень полезным примером некоторых важных аспектов объектно-ориентированного программирования в C++. В частно- сти, в этой главе на примере класса String иллюстрируется использование заре- зервированного слова «this», деструкторов и детального копирования. Введение в класс String Программисты С и C++ любят/ненавидят работать со строками в стиле С... Такое отноше- ние можно лучше описать, как переход от ненависти к «использованию из-под палки». Положительным моментом является то, что строки в стиле языка С (то есть использую- щие символ конца строки) предоставляют прямой доступ к данным и не навязывают никаких скрытых служебных данных. Вы получаете то, что видите. Но при работе с таким типом данных вам придется остерегаться всех типов ошибок, о которых (начиная с главы 7) я пытался вас предупредить. Большинство этих проблем связаны с распределением пространства; если вы не выделите достаточно места для хра- нения строковых данных, то вы можете несанкционированно перезаписать другие об- ласти памяти, что вызовет ошибки программы, которые сложно отследить. Такие ошиб- ки, в свою очередь, приведут к тому, что вы потратите много времени на бесполезные попытки исправить их. И это неправильно. Строка C/C++ - это составной тип данных, состоящий из последовательности элементов. char name[] = "C++ Without Fear";
344 C++ без страха Это выражение создает не просто элемент данных, а массив из 17 элементов (включая 16 элементов строковых данных и 1 элемент для символа конца строки). Было бы неплохо иметь тип данных String, в котором вы могли бы обращаться к стро- кам как к отдельным объектам, не задумываясь об их внутреннем устройстве. Это было бы действительно здорово, если весь процесс выделения пространства для строковых данных, его разрушения и перераспределения был бы полностью автоматизирован, что- бы вы больше никогда не задумывались над вопросом о том, выделили ли вы достаточно пространства. Тогда вы могли бы создавать строки и управлять ими, как это показано ниже: String strl, str2, str3; strl = "To be, "; str2 = "or not to be." ; str3 = strl + str2; В этой главе демонстрируется, как реализовать эти функции и многое другое. Введение в деструкторы класса Рассмотрим сначала простую версию класса, которая автоматизирует основные задачи создания и разрушения. Однако до этого я познакомлю вас с новым синтаксисом - с де- структором. Это функция-член со следующим объявлением: ~class_name() Другими словами, деструктор для класса имеет синтаксис, похожий на синтаксис для конструктора, кроме символа тильды (~) в начале имени и пустым списком аргументов. Например, объявление деструктора для класса Fraction может иметь следующий вид: class Fraction { //. . . public: -Fraction() ; }; Я не рассказывал вам раньше о том, как использовать деструкторы, поскольку вам это просто не было нужно. Деструктор класса предназначен для очистки ресурсов перед непосредственным разру- шением объекта. Это происходит каждый раз при эксплицитном разрушении объекта, когда вы используете любую форму оператора delete: Fraction *pF = new Fraction; // . . . delete pF; // Разрушить объект, на который указывает pF. Объект также разрушается, когда он объявляется как переменная, а затем выходит из области видимости. void aFunction() { // . . .
ГЛАВА 15. Что такое «this»? Класс String 345 Fraction fractl(1, 2); // Объект fractl создан, cout << fractl +1; } // Объект fractl разрушен. Итак, что же происходит при разрушении объекта? Зачастую вообще ничего не проис- ходит. Память, занимаемая объектом, освобождается и, таким образом, ее можно ис- пользовать для других элементов данных. Именно перед тем, как это происходит, вызы- вается деструктор класса, если таковой имеется. Например, с классом Fraction вообще не надо ничего делать. При разрушении объ- екты данных класса Fraction - а именно, num и den - просто исчезают. И не надо закрывать и освобождать никакие другие дополнительные системные ресурсы. Другое дело - класс String. Класс String должен содержать один объект данных - указатель на строковые данные. class String { private: ptr ; // . . . }; Все конструкторы класса String, как вы увидите, добавляют фактические строковые данные, на которые указывает объект данных ptr, к памяти, которая занята самим объек- том. Работа деструктора заключается в освобождении этих строковых данных. -String() {delete [] ptr;} Без этого деструктора использование класса String может приводить к постоянным утечкам памяти... что, на первый взгляд, не кажется проблемой, но любая программа, в которой по неосторожности используется такой класс, просто «съест» всю память - по- ка, в конце концов, пользователь не обнаружит, что он (или она) не располагают доста- точным количеством памяти для работы и необходимо перезагрузить компьютер. Неудачно написанный класс может стать ^причиной разочарования и недовольства пользователя. А это нехорошо. Пример 15.1. Простой класс String В этом разделе я покажу простую версию класса String, как и обещал. В этой версии есть достаточно функций для того, чтобы назвать ее полезной. В класс входят два конст- руктора, один деструктор и функция оператора проверки равенства (==). объект данных (закрытый) Ptr_____________ StringQ_________ String(char*) -StringQ operator==() operator char*() функции-члены '(открытые) Класс String
346 C++ без страха Ниже показан код, который реализует и тестирует этот класс. Листинг 15.1. string!.срр ttinclude <iostream> ttinclude <string.h> using namespace std; class String { private: char *ptr; public: String(); String(char *s); -String(); int operator==(const String &other); operator char*() {return ptr;} }; int main() { String a("STRING 1") ; String b (11 STRING 2") ; cout « "The value of a is: " « endl; cout « a << endl; cout « "The value of b is: " « endl; cout « b; } // ------------------------------------ // ФУНКЦИИ КЛАССА STRING String::String() { ptr = new char[l]; ptr[0] = 1\0 1 ; } String::String(char *s) { int n = strlen(s); ptr = new char[n + 1] ; strcpy(ptr, s); } String::-String() { delete [] ptr; } int String:: operator==(const String Mother) { return (strcmptptr, other.ptr) == 0); }
ГЛАВА 15. Что такое «this»? Класс String 347 Как это работает С одной стороны, этот класс очень прост. Он содержит только один объект данных - ptr. private: char *ptr; Этот объект объявлен закрытым, потому что он будет устанавливать и сбрасывать функ- ции класса, которые занимают блоки памяти. Важно, чтобы код вне класса не мог изме- нить этот указатель. Сложность класса состоит в его поведении. Наиболее важным принципом этого класса является то, что строковые данные всегда присваиваются объекту String; строковые данные должны быть распределены для него, а указателю ptr должен быть присвоен ад- рес определенного элемента данных. Это справедливо даже для случая пустой строки, присвоенной объекту String. Поэто- му конструктор по умолчанию должен выделить один байт памяти для строковых дан- ных и скопировать символ конца строки. String::String() { ptr = new charfl]; ptr[0] = 1\0' ; } Простого присваивания нулевого значения (адрес = 0) указателю ptr здесь недостаточно. Зачастую вам может понадобиться использовать объект String там, где строка C/C++ (char*) действительна; в таких случаях вам надо будет передавать пустые строки, то есть строки в один байт, содержащий символ конца строки. ptr -----------------> '\0' Менее загадочным является конструктор String (char*): понятно, что он должен копировать строковые данные из аргумента типа char*. Данный конструктор выделяет достаточное количество памяти для хранения всех строковых данных, которое опреде- ляется при помощи функции strlen. Конструктор должен также выделить один до- полнительный байт памяти для хранения символа конца строки (так как функция strlen сама по себе не считывает символ признака конца строки). String::String(char *s) { int n = strlen(s); ptr = new char[n + 1]; strcpyfptr, s) ; } Деструктор, как я уже говорил в предыдущем разделе, должен освобождать строковые данные до разрушения объекта. String::-String() { delete [] ptr; }
348 C++ без страха Функция operator== проста для написания, хотя вам необходимо обратить внимание на то, что простое сравнение значений указателя ptr не выдаст корректный результат: // НЕКОРРЕКТНАЯ ВЕРСИЯ - НЕ ИСПОЛЬЗОВАТЬ! int String:: operator==(const String &other) { return (ptr == other.ptr); } Проблема этой версии функции operator== состоит в том, что она возвращает значе- ние true (1) только в том случае, если текущий объект и другой объект (объект, соот- ветствующий правому операнду) указывают на один и тот же адрес в памяти. Но, пред- ставим ситуацию, когда два объекта указывают на строку "cat", но каждый имеет свою собственную копию этой строки. Вот этот случай: String strl("cat"); String str2("cat"); Если две строки имеют одинаковое содержимое, то данная функция должна возвращать значение true, даже если строки занимают разные ячейки памяти. Поэтому простое сравнение двух указателей для проверки равенства является очень ограниченным тестом. strl str2 strcmp = О В качестве решения можно вызвать библиотечную функцию strcmp. Эта функция при- нимает два строковых адреса и выполняет сравнение двух строк, на которые указывают указатели. Она возвращает 0, если содержимое двух строк одинаково. Именно это вам необходимо в данном случае. int String:: operator==(const String Mother) { return (strcmp(ptr, other.ptr) == 0) ; } Функция main выполняет простой тест функций класса String. Первые два выраже- ния функции main вызывают конструктор String (char*). int main() { String a("STRING 1"); String b("STRING 2"); cout << "The value of a is: " « endl; cout « a « endl;
ГЛАВА 15. Что такое «this»? Класс String 349 cout « "The value of b is: " « endl; cout « b; } Обратите внимание, что строковые объекты (а и Ь) являются «выводимыми» - то есть могут быть переданы объекту cout как выход с использованием потокового оператора «. Вы можете выполнить это чудесное действие, так как класс String объявляет функцию преобразования char*. Если эта функция объявлена, вы можете использовать объект класса String везде, где ожидаются данные типа char*. operator char*() {return ptr;} Функция преобразования возвращает адрес строковых данных, которые содержатся в объекте данных ptr. Упражнения Упражнение 15.1.1. Перепишите функцию main из упражнения 15.1. так, чтобы она использовала и проверяла конструктор по умолчанию для класса String. Упражнение 15.1.2. Напишите функции operator> и operator< для класса String. (Подсказка: функция strcmp возвращает значение, большее или меньшее ну- ля соответственно, если первая строка идет позже или раньше в списке по алфавиту. Таким образом, строка «аЬс» «меньше чем строка» «xyz».) Детальное копирование и конструктор копирования На данный момент класс String работает, но если вы не можете добавить к нему больше никакой функциональности, то не имеет смысла заменять стандартный тип C/C++ char* со всеми его недостатками. Класс становится более полезным, когда мы можем использовать его для копирования одного объекта String в другой. Однако с этого момента все становится немного сложнее. Очевидный способ копировать один объект в другой - это сделать то же, что делает ав- томатический (обеспечиваемый компилятором) конструктор копирования: выполнить последовательное копирование членов. Значение ptr копируется непосредственно так, чтобы новый объект указывал на ту же ячейку памяти, что и первый объект. Это так на- зываемое поверхностное копирование. str1 str2
350 C++ без страха Этот способ хорошо работает для некоторых классов, но есть одна проблема: что будет, если что-то случится со строковыми данными, на которые указывает str2? В частности, если объект str2 впоследствии исчезнет из области видимости или будет удален по неко- торой причине, объект strl также станет недействительным. Вот что происходит на самом деле, когда вы пытаетесь присвоить strl новое строковое значение в следующем коде: String strl("Hello; strl = "cat"; Если вы прямо сейчас добавите этот код в пример 15.1, то обнаружите, что он не совсем успешно передает строку «cat» в переменную объекта strl - по крайней ме- ре, ненадежно. На моем компьютере работа кода заканчивается стиранием значения strl, в результате чего переменная содержит пустую строку. Вы получите неудовлетворительные резуль- таты, если захотите вывести значение strl. cout « strl; Почему так происходит? Рассмотрим, что происходит, когда программа выполняет выражение strl = "cat"; По умолчанию, компилятор поставляет функцию оператора присваивания типа opera- tor (const Strings), которая копирует из одного объекта того же типа в другой. Поэтому чтобы присвоить значение из строки «cat», компилятор интерпретирует это выражение так, как если бы оно было записано следующим образом: strl = String("cat"); Программа выполняет следующие два действия: > Вызывает конструктор String (char*) для создания временного объекта String. > Вызывает функцию operator (const Strings), чтобы присвоить значение из одного объекта String в другой. На первый взгляд, это должно работать. Но рассмотрим, как же работает поставляемая компилятором версия функции оператора присваивания: она выполняет простое после- довательное копирование членов. Значение объекта данных ptr становится значением ptr временного объекта String; это, в свою очередь, означает, что два объекта указывают на одну и ту же ячейку памяти.
ГЛАВА 15. Что такое «this»? Класс String 351 Но временный объект String быстро устраняется сразу же после выполнения выраже- ния. Для временного объекта String вызывается деструктор, и память для хранения строковых данных возвращается. Что происходит потом? Объект данных strl .ptr все так же указывает на удаленную ячейку памяти! Для избежания таких проблем используйте детальное копирование. Это такой подход к копированию объекта, который не просто копирует значения, а полностью воспроизво- дит содержимое объекта. В случае с классом String это означает, что целевому объек- ту предоставляется его собственная копия строковых данных. strl str2 Мы можем начать с того, что заставим конструктор копирования использовать метод детального копирования. Данный код использует библиотечную функцию strlen (ко- торая вычисляет текущую длину строки до символа конца строки, но не включая его) и функцию strcpy (которая копирует строковые данные из одной области памяти в другую). String::String(const String &src) { int n = strlen(src.ptr); ptr = new char[n + 1] ; strcpy(ptr, src.ptr); } Правильно написанная функция оператора присваивания будет использовать такой же код. Но сначала вы должны познакомиться с еще одним зарезервированным словом язы- ка C++ : this. Зарезервированное слово «this» На первый взгляд, зарезервированное слово this кажется странным созданием. Неко- торые программисты используют его часто, а некоторые могут и вовсе не использовать, за исключением случая с оператором присваивания. В этом случае, как вы увидите, без него не обойтись. Проще говоря, зарезервированное слово this является указателем на текущий объект, через который вызывается функция-член. Зарезервированное слово this является значи- мым только внутри функций-членов класса. При вызове функции-члена компилятор C++ на самом деле передает скрытый аргумент, который, как вы догадываетесь, является указателем this. Рассмотрим следующий вызов функции-члена класса Fraction.
352 C++ без страха fractl.set(1, 3); Вызов функции транслируется в следующий вид, хотя в исходном коде это не отображается: Fraction::set(&fractl, 1, 3); Другими словами, указатель на fractl передается как скрытый первый аргумент. Этот аргумент доступен в функции-члене как this. | this |------> [ num | den Fraction object Ниже приведено описание функции set класса Fraction. void set(int n, int d) {num = n; den = d; normalize();} Функция работает так, как если бы она была написана следующим образом: void set(int n, int d) {this->num = n; this->den = d; normalize();} Фактически вы можете написать функцию set таким способом. Однако Это не обяза- тельно, так как предполагается, что неквалифицированные ссылки на объект данных являются ссылками через указатель this. В большинстве случаев использование этого указателя для ссылки на объекты данных не обязательно, хотя допустимо. Кстати, вызов normalize передает скрытый указатель this, хотя (напомню еще раз) скрытый аргумент не отображен в исходном коде. normalize(this); Это, на самом деле, не совсем верное выражение, так как указатель this хотя и передает- ся как аргумент, но должен оставаться скрытым. Правильнее будет написать вызов сле- дующим образом, хотя (как и в случае с объектами данных) использование this не обяза- тельно. this->normalize(); Вернемся к оператору присваивания Что же теперь должен делать указатель this с функцией оператора присваивания? Чтобы понять это, вы должны сначала узнать, как происходит присваивание в языке C++. Пом- ните, что оператор присваивания (=) генерирует выражение так же, как и другие опера- торы, и должен возвращать значение, хотя и имеет один серьезный побочный эффект (а именно, задание значения левого операнда). Значение, генерируемое выражением присваивания (например, х = у); возвращает то значение, которое было присвоено, другими словами, новое значение левого операнда.
ГЛАВА 15. Что такое «this»? Класс String 353 Затем это значение может быть использовано в больших выражениях. Например, рас- смотрим такое выражение: х = у = 0; ' ' Это выражение эквивалентно следующему выражению, в котором у = 0 возвращает новое значение у (которое равно 0) и передает его далее: X = (У = 0) ; Таким образом, это выражение присваивает значение 0 как х, так и у. В C++ функции оператора присваивания должны выводить значение, а именно значение левого операнда. В функции оператора класса (которую вы повторно вызовете) левый операнд является объектом, через который вызывалась функция. Но как объект возвращает сам себя? Правильно: он использует указатель this. Применяя оператор разыменования (*) к указателю this, вы получаете не указатель на текущий объект, а сам объект. Иначе говоря, это выражение return *this; говорит «Возвращаю себя!». Это может быть тяжелым испытанием для логики, но вам нужно запомнить всего лишь одно важное правило: ♦♦ В определении любой функции оператора присваивания (=) последним вы- ♦ ражением должно быть «return *this». Вот и все, что вы должны помнить о зарезервированном слове this. Зарезервированное слово также может быть полезно в случае, когда объекту необходимо вызвать глобаль- нук> функцию и явно передать указатель на себя. Но в большинстве случаев вы будете использовать его в функциях оператора присваивания. Для облегчения понимания давайте сначала создадим функцию сру, которая копирует строковые данные из аргумента char*. Эта функция освобождает текущую ячейку па- мяти со строковыми данными, выделяет нужное количество памяти для новой строки, а затем выполняет копирование путем вызова strсру. void String::сру(char *s) { delete [] ptr; int n = strlen(s) ; ptr = new charfn + 1]; strcpy(ptr, s); } А теперь совсем просто написать функцию оператора присваивания. String^ String::operator=(const String &src) { cpy(src.ptr); return *this; } 12-6248
354 C++ без страха Обратите внимание, что функция возвращает ссылку на объект, String (тип «String&»), а не сам объект String. Этот возвращаемый тип предотвращает ненуж- ное использование конструктора копирования. Запомните, что использование ссылки передает указатель (в скрытом виде), но избегает синтаксиса указателя. Вы монете также легко написать функцию оператора присваивания, которая принимает аргумент char*. Хотя добавление этой функции к классу не является обязательным (потому что один из конструкторов обеспечивает преобразование типа char*), но она делает обработку некоторых выражений более эффективной. String& String::operator=(char *s) { cpy(S); return *this; } Обе эти функции достаточно короткие, чтобы их можно было встроить. Если вы сравните описания конструктора копирования (см. стр. 355) и функции оператора присваивания (которая вызывает функцию-член сру), то увидите, что они делают почти то же самое. Но есть одно важно отличие: функция- член сру допускает, что текущий объект уже создан и инициализирован. Сле- довательно, он использует оператор delete для освобождения памяти, на которую в данный момент указывает ptr. Конструктор копирования не делает такого допущения, если объект новый. Написание функции конкатеннии (сращивания) Класс String гораздо более полезен. Той же самой строке, например, могут много- кратно присваиваться различные значения, при этом вам не нужно беспокоиться о пре- вышении памяти, зарезервированной для строки. В данном случае переменной str1 снача- ла присваивается более короткая строка («the»), а затем - более длинная строка («the cat»). String strl, str2, str3; strl = "the"; cout << strl << endl; strl = "the cat"; । cout << strl « endl; str2 = "the cat"; if (strl == str2) cout « "strl and str2 hold the same data." << endl; Но мы можем сделать класс String еще лучше. Возможно, вы хотите иметь возмож- ность писать выражения следующего типа: String а("the "); String b("end."); String c = "This is " + b + c; Для этого необходимо написать функцию operator+ для класса String. Для просто- ты начнем с написания функции-члена cat (сокращенно от «concatenation» (конкатена- ция)). После этого вы сможете легко написать функцию operator+. Основная пробле-
ГЛАВА 15. Что такое «this»? Класс String 355 ма, которую необходимо решить - как и в случае с большинством функций-членов String - это количество выделяемой для строки памяти. К счастью, это не так уж и сложно, так как все, что вам необходимо сделать, это использовать библиотечную функ- цию strlen для определения длин строк, а затем суммировать их. Таким образом, алгоритм для функции cat следующий: > Установите N в качестве длины текущей строки плюс длина строки данных, которая будет добавлена. > Выделите новый блок памяти char размером N+1 и установите указатель Р1 на этот блок. > Скопируйте текущие строковые данные в этот блок памяти (на который указывает Р1), а затем конкатенируйте новые строковые данные с уже существующими. > Удалите старый блок памяти, на который указывает ptr. > Установите объект данных ptr на тот же адрес, что и Р1. Этот алгоритм работает так, что старый блок памяти (на который указывает ptr) не уда- ляется до тех пор, пока его данные не будут скопированы в блок памяти. Это означает, что вы не можете сразу же использовать оператор delete и что вы должны временно хранить новый адрес в другом указателе: Р1. Ниже приведен код на языке C++, который реализует этот алгоритм. void String::cat(char *s) { // Выделение достаточного количества памяти для новых // строковых данных int n = strlen(ptr) + strlen(s); char *pl = new char[n + 1]; // Копирование данных в этот новый блок памяти. strcpy(pl, ptr); strcat(pl, s); // Освобождение старого блока памяти и обновление ptr. delete [] ptr; ptr = pl; } Теперь написание функции operator+ является легкой задачей. Вызов функции cat делает почти всю работу. String String::operator+(char *s) { String new_str(ptr); new_str.cat(s) ; return new_str; } Это достаточно простая функция, благодаря существованию функции-члена cat. Но обратите внимание, что в отличие от других функций оператора класса String, эта функция не может вернуть ссылку. Это связано с тем, что оператор «+» создает новый объект, который не является эквивалентом ни одного из операндов, и весь объект (а не только ссылка) должен быть возвращен вызывающему оператору функции. 12*
356 C++ без страха Действующее правило следующее: Если есть необходимость возврата существующего объекта функцией- * членом (как в случае функции оператора присваивания), то достаточным ♦ является ссылочный возвращаемый тип. Но возврат ссылки недостаточен, если функция должна вернуть новый объект. Пример 15.2. Класс String в полном объеме Сейчас легко представить класс String в полном объеме, хотя у вас остается широкое пространство для добавления своей собственной функциональности. Для того, чтобы завершить рассмотрение класса String, нам необходимо добавить функции-члены, описываемые в нескольких последних разделах. Как и ранее, строки, выделенные полу- жирным шрифтом, содержат выражения, добавленные к предыдущему примеру 15.1. Листинг 15.2. string2.cpp #include <iostream> ttinclude <string.h> using namespace std; class String { private: char *ptr; public: String(); String(char *s); String(const String ssrc); -String!); Strings operator=(const String ssrc) (cpy(src.ptr); return *this;} Strings operator=(char *s) (cpy(s); return *this;} String operator*(char *s); int operator==(const String Mother); operator char*() {return ptr;} void cat(char *s); void cpy(char *s); }; int main() { String a, b, c; a = "I n; b = "am "; c = "so "; String d=a+b+c+ "very happy!\n"; cout << d; return 0; }
ГЛАВА 15. Что такое «this»? Класс String 357 // ----------------------------------- // Функция класса STRING String::String() { ptr = new char[l]; ptr[0] = 1\0'; } String::String(char *s) { int n = strlen(s); ptr = new charfn + 1]; strcpy(ptr, s); } String::String(const String &src) { int n = strlen(src.ptr); ptr = new char[n + 1]; strcpy(ptr, src.ptr); } String::-String() { delete [] ptr; } int String::operator==(const String Mother) { return (strcmp(ptr, other.ptr) == 0) ; } String String::operator+(char *s) { String new_str(ptr); newstr.cat(s); return new_str; } // ору _ Функция копированйя строки И void String::сру(char *s) { delete [] ptr; int n = strlen(s); ptr = new char[n +1]; strcpy(ptr, s); } 11 cat - Функция конкатенации строк И void String::cat(char *s) { // Выделение достаточного места в памяти для новых // строковых данных. int n = strlen(ptr) + strlen(s); char *pl = new char[n +1]; // Копирование данных в этот новый блок памяти. strcpy(pl, ptr);
358 C++ без страха strcat(pl, s); // Освобождение старых блоков памяти и обновление ptr. delete [] ptr; ptr = pl; 2 Как это работает Все функции этого примера были описаны в предыдущих главах, поэтому я не буду уде- лять им внимание в этом разделе. В этом примере, однако, есть один аспект, который может вызвать дискуссию. Вы можете заметить, что есть только один вариант функции operator+. String String::operator+(char *s) { String new_str(ptr); new_str.cat(s) ; return new_str; } Эта функция предполагает, что правый операнд имеет тип char*. Поэтому она поддер- живает операции такого типа: String а("King "); String b = а + "Kong"; Но как же этот класс поддерживает операции, аналогичные следующей, в которых оба операнда являются объектами String? String a("King "), b("Kong"); String b = a + b; 1 Ответ на этот вопрос таков: хотя класс прямо и не поддерживает операцию + между двумя объектами String, но на помощь приходит удобная функция преобразования. operator char*() {return ptr;} Классу известно, как конвертировать объект String в тип char* там, где такое преобразование будет единственным способом легально обработать выражение. В вышеописанном выражении "Kong" объект b конвертируется в строку char*, и выражение выполняется, как будто оно было записано следующим образом: String b = а + "Kong"; Таким образом, мы можем решить проблему, имея лишь одну функцию operator+. Но все же существуют некоторые ограничения, связанные со способом написания этой функции. Так как функция operator+ является функцией-членом, а не глобальной функцией, то объект функции operator+ должен выступать в качестве правого опе- ранда. Это означает, что вы не можете выполнить выражения, аналогичные следующему: String b = "Му name is " + а + "Kong.";
ГЛАВА 15. Что такое «this»? Класс String 359 Единственным способом поддержки этих выражений, в которых строка char* выступа- ет левым операндом самой левой операции сложения (+), является повторное написание функции operators- в качестве глобальной дружественной функции (global friend func- tion), Эту ситуацию оставим на упражнения. Упражнения Упражнение 15.2.1. Перепишите функцию operator+ в виде глобальной дружествен- ной функции, как предложено выше. Просмотрите главу 13, если это необходимо. (Под- сказка: в коде не следует производить никаких других изменений, кроме способа объяв- ления функции). Упражнение 15.2.2. Во всех функциях-членах, имеющих аргумент char*, измените их так, чтобы использовать аргумент типа cons t char *. Код функции main все еще работает? Упражнение 15.2.3. Добавьте функцию convert-to-integer, которая вызывает биб- лиотечную функцию atoi, для преобразования номинального значения чисел в строках (если таковые имеются) в целые числа. Также запишите функцию convert-to- double, вызывающую библиотечную функцию atof. (Примечание: не забудьте под- ключить файл stdlib.h.). Упражнение 15.2.4. Запишите конструктор String(int n), инициализирующий стро- ковые данные, включая п пробелов, где п - определенное число. Запишите функцию operator= (int n), которая осуществляет аналогичное присваивание. Упражнение 15.2.5. Запишите функцию operator!] для класса String, чтобы у вас была возможность непосредственно симулировать индексацию элементов массива, и чтобы вам не приходилось извлекать строку char* для получения доступа к отдельным символам. Эта функция должна возвращать ссылку на char, чтобы вы могли использо- вать индексацию элементов массива для получения значения 1 (это выражение, которое может находиться в левой части присваивания). Эта функция должна иметь следующее объявление: char& operator[](const int i) ; Резюме Вот основные моменты главы 15: ✓ Деструктор класса вызывается непосредственно перед разрушением объекта. Напи- сание деструктора необходимо, если объект занимает собственную область памяти или присутствуют некоторые другие системные ресурсы, которые необходимо за- крыть. Объявление деструктора имеет следующий вид: ~class_name{) ✓ Деструктор вызывается при эксплицитном (явном) разрушении объекта с использо- ванием оператора delete или при выходе объекта из области видимости.
360 C++ без страха V Поверхностное копирование (shallow сору)- это простое копирование членов одно- го объекта в другой. В случае простых классов такого копирования, как правило, достаточно. Детальное копирование (deep сору) воссоздает содержимое одного объекта и дубли- рует его в другой. Такой тип копирования требует от автора класса написания конст- руктора копирования и функции оператора присваивания; детальное копирование часто необходимо для классов, управляющих системными ресурсами, такими как па- мять и доступ к файлам. ✓ Зарезервированное слово this может использоваться в функции-члене класса: оно преобразуется в указатель на текущий объект (то есть в объект, через который вызы- вается функция). ✓ Неквалифицированные ссылки на член внутри функции-члена эквивалентны ссылкам через указатель this. Например, в классе String вызов функции normalize экви- валентен следующему выражению: this->normalize(); ✓ Все версии функции оператора присваивания должны заканчивать работу возвраще- нием ссылки на текущий объект. return *this; ✓ Функции-члены, возвращающие существующий объект, должны иметь возвращае- мый ссылочный тип (такой как String^ вместо String). Например, все функции оператора присваивания должны возвращать ссылку. String^ String::operator=(const String &src) { cpy(src.ptr); return *this; } ✓ Функции-члены, такие как operator+., возвращающие новый объект, не должны иметь возвращаемый ссылочный тип. Вместо этого, они создают новый объект, уста- навливают его значения, а затем возвращают его. String String::operator+(char *s) { String new_str(ptr); new_str.cat(s); return new_str; }
ГЛАВА 16. Наследование: что в наследство? Одной из наиболее заметных особенностей классов является возможность создания под- классов, при этом один класс наследует члены ранее определенного класса. Эта функция очень важна по нескольким причинам. Первой из причин является то, что создание подклассов позволяет вам настроить сущест- вующее программное обеспечение. Вы можете взять класс, созданный кем-то другим, и добавить в него свои собственные функции. Также вы можете переписать любую функ- цию этого класса. Поэтому классы являются наращиваемыми и многократно используе- мыми (я обещаю больше не упоминать эти надоедливые модные термины в этой главе). Все это звучит чудесно ... и иногда так и есть, но (по техническим причинам, которые я раскрою в этой главе) все не так просто. Например, вам нужно будет модифицировать все конструкторы. Создание подклассов также является важной причиной необходимости работы с иерар- хией наследования и создания интерфейсов, о чем мы поговорим более детально в гла- ве 17 (ой, извините - я опять упомянул два модных термина. Но я обещаю далее исполь- зовать обычный язык, насколько это возможно). Создание подклассов: совмещаем полезное с приятным Подкласс содержит все члены другого класса (который называется базовым классом) вместе с новыми членами, которые вы определили. Ниже показан синтаксис объявления подкласса. class class_name : public base_class { declarations }; class_name - в данном контексте, это индивидуальное имя нового класса. Он насле- дует все члены базового класса (кроме конструкторов - более подробно об этом я рас- скажу позже). declarations - это комбинация объектов данных, функций-членов или и тех, и дру- гих - так же, как и в стандартном объявлении класса. Эти объявления представляют но- вые члены, которые добавляются к существующим членам (унаследованным от базового класса). В объявлениях (declarations) можно также определить один или более членов, которые уже объявлены в базовом классе. При помощи этого приема объявление существующего члена базового класса подменяется, обходится, и поэтому старое объявление игнориру- ется в пользу нового. Как правило, чтобы избежать путаницы, вам следует использовать функцию подмены только для предоставления новых описаний для существующих функций. Это важная техника программирования, которая станет центральной частью главы 17.
362 C++ без страха Ниже приведен пример, в котором создается новый класс, базирующийся на классе Fraction. class FloatFraction : public Fraction { public: double get_float(); }; Этот код иллюстрирует простую мысль: он объявляет класс FloatFraction как под- класс существующего уже класса Fraction. Это означает, что каждый объект класса FloatFraction содержит все члены, объявленные для класса Fraction. Кроме того, каждый объект класса FloatFraction имеет функцию-член get_f loat. Однажды объявив класс, вы можете использовать его как имя типа, так же как и любой другой класс. FloatFraction fl; fl. set(1, 4); cout « "The decimal representation is " « f1.get_float(); Полагая, что функция get_float объявлена должным образом, этот код должен вы- вести результат «0,25». Ниже представлен пример, в котором создается новый класс, базирующийся на классе String. Этот пример немного сложнее. class ExtString : public String { private: int nullTerminated; public: int length; int isNullTerminated() {return nullTerminated != 0;} }; Этот код объявляет класс ExtString как подкласс класса String; поэтому он вклю- чает члены класса String. Кроме того, он включает закрытый объект данных nullTer- minated. Так как этот член закрытый, то код, определенный вне данного класса, не мо- жет к нему обращаться, но внешний код может получить доступ к двум открытым чле- нам: length (объект данных) и isNullTerminated (функция). Ниже представлен еще один пример наследования класса Fraction, но в данном слу- чае уже через промежуточный класс (FloatFraction). class ProperFraction : public FloatFraction { public: int get_whole(); int get_num(); // ПОДМЕНЕННАЯ ФУНКЦИЯ I void pr_proper(ostream &os); }; Базовым классом здесь является класс FloatFraction. Класс ProperFraction - это «внук» класса, который содержит все члены класса FloatFraction и, кроме того,
ГЛАВА 16. Наследование: что в наследство?363 все члены класса Fraction (потому что класс FloatFraction сам по себе является подклассом класса Fraction). Такие объявления создают иерархию, в которой класб ProperFraction - это непря- мой подкласс класса Fraction. В конечном счете, ниже представлен другой класс, который является прямым наследни- ком класса Fraction. class Fractionunits : public Fraction { public: String units; }; Класс String включается в объявление класса Fractionunits, но не как базовый класс или подкласс. Вы можете сказать, что каждый объект Fractionunits имеет объект String и является вариантом класса FloatFraction - это более специализи- рованный тип объекта Fraction. Взгляните на это с такой стороны: если А является подклассом В, то можно говорить, что А более специализированный вариант В. Собака - это подкласс класса «млекопи- тающие», который является подклассом класса «животные» и т.д. В то же время класс «собака» содержит класс «зубы», класс «хвост» и т.д. class Dog : public Mammal { public: Teeth dog_teeth; Tail dog_tail; //. . . };
364 C++ без страха Вставка Почему базовые классы являются открытыми (public)? В синтаксисе, который я представил ранее для объявления подклассов, вы, должно быть, заметили использование ключевого слова public для определения базового класса: в этом контексте ключевое слово определяет уровень доступа к базовому классу. class class_name : public base_class { declarations } ; Технически вы можете опускать ключевое слово public, воспользовавшись непосред- ственным объявлением: class FloatFraction : Fraction { // . . . }; Проблема в том (как и в случае объявлений членов класса), что по умолчанию доступ к базовому классу будет закрытым (private). А закрытый доступ - это не то, что вам необходимо в этом контексте. В частности, закрытый доступ к базовому классу озна- чает, что все члены, наследуемые от базового класса, в новом классе становятся за- крытыми. Так, например, функция get_num может наследоваться, но она станет за- крытой, и поэтому она становится недоступной для внешнего кода. FloatFraction aFract; cout << aFract.get_num(); // Ошибка! Неверно, // если функция get_num закрытая. С другой стороны, открытый доступ к базовому классу означает, что все члены базо- вого класса наследуются непосредственно, как они есть. Это будет нужно вам почти всегда, поэтому вам было бы неплохо приобрести привычку в объявлении перед име- нем базового класса писать ключевое слово public. Ситуации, в которых имеет смысл устанавливать уровень доступа к базовому классу иным, нежели открытый, не очень распространены. Это один из тех хитроумных ас- пектов синтаксиса языка C++ (есть еще пара других), который на самом деле не так уж и полезен для большинства людей и чье происхождение непонятно. Но, к счастью, решение здесь достаточно простое. Просто устанавливайте ключевое слово public в начало базового класса. Пример 16.1. Класс FloatFraction Следующий пример является отправной точкой этой книги. Вместо того, чтобы все по- местить в один файл, я допускаю, что исходный код хранится в нескольких отдельных файлах - в нашем случае, Fract.h и Fractcpp, которые я подключаю здесь для удобства. Во-первых, ниже представлен код, который реализует и тестирует класс FloatFrac- tion. Обратите внимание, что в нем присутствует директива ttinclude для импорти- рования информации о типе базового класса Fraction.
ГЛАВА 16. Наследование: что в наследство? 365 Листинг 16.1. FloatFractl.срр ttinclude <iostream> ttinclude "Fract.h" using namespace std; class FloatFraction : public Fraction { public: double get_float() { return (static_cast<double>(get_num())/get_den();} }; int main() { FloatFraction fractl; fractl.set(1, 2); cout « "Value of 1/2 is " « fractl.get_float() << endl; fractl.set(3, 5) ; cout << "Value of 3/5 is " << fractl.get_float() « endl; return 0; 2 Этот файл имеет небольшой размер, потому что он включает только код, необходимый для подкласса FloatFraction. Этот класс содержит значительный объем кода на C++, но этот код включен в код базового класса Fraction. Он используется классом FloatFraction. Объявление класса Fraction находится в файле Fract.h. Листинг 16.2. Fract.h ttinclude <iostream> using namespace std; class Fraction { * private: int num, den; // Числитель и знаменатель, public: Fraction() {set(0, 1);} Fraction(int n, int d) {set(n, d);} Fraction(int n) {set(n, 1);} Fraction(const Fraction bsrc) {set(src.num, src.den);} void set(int n, int d) {num = n; den = d; normalize();} int get_num() const {return num;} int get—den() const {return den;} Fraction add(const Fraction bother); Fraction mult(const Fraction bother); Fraction operator+(const Fraction bother) {return add(other);}
366 C++ без страха Fraction operator*(const Fraction Mother) (return mult(other);} int operator==(const Fraction mother); friend ostream &operator«(ostream &os, Fraction &fr) ; private: void normalize(); // Приведение дроби к нормальной форме, int gcf(int a, int b) ; // Наибольший общий множитель. int 1cm(int a, int b); // Наименьший общий знаменатель. }; Код, реализующий функции-члены класса Fraction (не встроенные), находятся в фай- ле Fract.cpp. Обратите внимание на то, то этот файл должен быть добавлен к текущему проекту. Посмотрите на меню Project (Проект) и File (Файл) вашей среды разработки и найдите команду Add New Item (Добавить новый элемент). Листинг 16.3. Fract.cpp #include "Fract.h" // ------------:-------------------------------------- // ФУНКЦИИ КЛАССА FRACTION // normalize: приведение дроби к стандартной форме, // единственной для каждого математически различного значения. // void Fraction:normalize О{ // Обработка случаев с О if (den == 0 || num == 0) { num = 0; den = 1; } // Присвоение только числителю отрицательного значения. if (den < 0) { num *= -1; den * = -1; } // В числителе и в знаменателе выносим за скобки наибольший // общий множитель. int n = gcf(num, den); num = num / n; den = den / n; } // Наибольший общий множитель // int Fraction::gcf(int a, int b) {
ГЛАВА 16. Наследование: что в наследство?367 if (а % b == 0) return abs(b); else return gcf(b, a % b) ; } // Наименьшее общее кратное ll int Fraction::lcm(int a, int b){ return (a / gcf(a, b)) * b; } Fraction Fraction::add(const Fraction bother) { Fraction fract; int led = lcm(den, other.den); int quotl - led/den; int quot2 = led/other.den; fract.set(num * quotl + other.num * quot2, led); fract.normalize(); return fract; } Fraction Fraction::mult(const Fraction bother) { Fraction fract; , fract.set(num * other.num, den * other.den); fract.normalize(); return fract; } int Fraction::operator==(const Fraction bother) { return (num == other.num bb den == other.den); } // --------------------------------------------- // ДРУЖЕСТВЕННАЯ ФУНКЦИЯ КЛАССА FRACTION ostream boperator<<(ostream bos, Fraction bfr) { os << fr.num « "/" « fr.den; return os; 2 Если вы используете среду разработки Microsoft Visual Studio, не забывайте, что каждый .срр файл должен начинаться с директивы #include <stdafx.h>. Убедитесь, что вы вставили эту директиву в начало файла Float.срр, а также в другие .срр файлы.
368 C++ без страха Как это работает Примечательной особенностью этого примера является небольшой размер его кода в файле Fractl .срр. Этот подкласс добавляет только одну функцию get_float. Описание функции (кото- рое является достаточно коротким, чтобы эта функция была встраиваемой) содержит одну уловку: код изменяет тип данных выражения get_num перед выполнением деле- ния. Выполнение этого приводит к тому, что программа использует деление с плаваю- щей точкой и получает результат с плавающей точкой. double get_float() { return static__cast<double>(get_num())/get_den();} Смотря на эту функцию, вы, возможно, удивитесь: почему код использует функции get_num и get_den, чтобы получить значения num и den соответственно? Разве это не члены и нельзя получить доступ к их значениям напрямую? Если бы вы писали код для класса Fraction, то это было бы справедливо. Но это не код класса Fraction - это код класса FloatFraction.-И к num и den, которые яв- ляются закрытыми членами, нельзя получить доступ напрямую через другие классы,... включая даже собственные подклассы класса Fraction! Поэтому код класса Float- Fraction просто вынужден использовать функции get_num и get_den, чтобы полу- чить значения этих членов. Функция main использует преимущество новой функции-члена FloatFraction (get_f loat), а также унаследованной функции-члена (set). fractl. set^( 1, 2); cout << "Value of 1/2 is " << fractl.get_float() « endl; fractl.set(3, 5); cout << "Value of 3/5 is " << fractl.get_float() << endl; Упражнения Упражнение 16.1.1. Измените пример 16.1 так, чтобы он подключал функцию-член set_float к классу FloatFraction. Функция должна принимать аргумент типа double и использовать его для установки значений num и den. Один из способов сде- лать это: (1) умножить значение на 100 и округлить его до целого значения, (2) присво- ить num полученное значение, (3) присвоить den значение 100 и (4) вызвать функцию normalize. Примечание: если вы используете функцию set для выполнения шагов (2) и (3), то шаг (4) выполняется автоматически. Подсказка: для округления до целого числа используйте приведение данных к типу int: new_value = static_cast<int>(value * 100.0); Упражнение 16.1.2. Напишите конструктор для класса FloatFraction, который при- нимает один аргумент типа double. Это будет для вас не сложно, если вы выполнили Упражнение 16.1.1.
ГЛАВА 16. Наследование: что в наследство?369 Проблемы с классом FloatFraction Вы можете создать подкласс почти так же легко, как я сделал это в предыдущем разделе. К сожалению, когда вы поработаете с объектами класса FloatFraction, то вы увиди- те, что класс имеет некоторые ограничения. Например, следующий невинный на вид код будет делать то, что вы хотите, только при наличии полного класса Fraction: Fraction fl(l, 2); fl = fl + Fractiond, 3); if (fl == Fraction(5, 6)) cout « "1/2 + 1/3 = 5/6"; Но если вы используете класс FloatFraction, который, как я уже говорил, должен поддерживать те же функции, что и класс Fraction, то каждое из этих выражений вы- даст ошибку. FloatFraction fl(l, 2); // Ошибка! fl = fl + FloatFraction(1, 3); // Ошибка! if (fl == FloatFraction(5, 6)) // Ошибка! cout « "1/2 + 1/3 = 5/6"; ✓ В текущей версии класса FloatFraction присутствуют два источника проблем, каждый из которых достаточно легко устранить: ✓ Первая заключается в том, что подклассы не наследуют конструкторы. |/ Вторая - в том, что многие функции класса Fraction возвращают объекты (или принимают аргументы) типа Fraction, а не FloatFraction. Давайте рассмотрим каждую из них в отдельности. Проблема с конструкторами на- столько значительна, что может быть вынесена в отдельное правило: » Подкласс не наследует конструкторы (поэтому не полагайтесь на конструк- * торы базового класса). Может показаться несправедливым, что C++ не позволяет наследование конструкторов, но в этом исключении есть доля здравого смысла. Представьте, что у вас есть подкласс, который добавил один или более объектов данных к базовому классу. Например: class FloatFraction2 : public Fraction { public: double float_amt; int whole; } ; Конструкторы Fraction присваивают значения переменным num и den путем вызова функции.set, которая, в свою очередь, вызывает функцию normalize для того, чтобы изменять по необходимости эти два значения. Но что в таком случае делать с новыми объектами данных float_amt и whole? 13-6248
370 C++ без страха Философия языка C++ такова, что вы должны писать свои собственные конструкторы даже при создании подкласса и инициализировать каждый член. Так как конструктор базового класса может не подходить (потому что, возможно, не может инициализиро- вать новые члены), C++ считает, что вы должны писать полностью новую группу конст- рукторов для каждого подкласса. Однако существуют некоторые исключения даже из этого правила. Я отмечал уже в дру- гих главах, что компилятор поставляет три специальных функции-члена, если автор класса этого не делает. Ситуация с подклассами немного усложняется, но к ним приме- нимы аналогичные правила. Каждая из этих функций заканчивается использованием базового класса. Конструкторы по умолчанию для подклассов Компилятор автоматически поставляет конструктор по умолчанию, если автор вообще не пишет конструкторов для данного класса. Так как текущая версия класса Float- Fraction не содержит объявлений собственных конструкторов, то компилятор пре- доставляет конструктор по умолчанию. Общее правило для автоматического конструктора по умолчанию (принимая во внимание и подклассы) заключается в том, что сначала он вызывает конструктор по умолчанию ба- зового класса. Затем он обнуляет каждый из новых объектов данных этого подкласса. j Если член сам по себе является объектом, то (в этой ситуации) вызывается j собственный конструктор по умолчанию этого объекта. Конструкторы копирования для подклассов Как я уже упоминал ранее, компилятор поставляет автоматический конструктор копиро- вания, если в самом коде класса конструктор копирования не объявлен. Эта поставляе- мая компилятором версия сначала вызывает конструктор копирования базового класса. Затем она выполняет простое копирование членов каждого из новых объектов данных. Функция присваивания для подклассов Вы можете догадаться, что делает эта функция: автоматическая функция присваивания (предоставляемая компилятором, если вы не написали собственную) сначала вызывает функцию присваивания базового класса. Затем она выполняет простое копирование чле- нов каждого из новых объектов данных. Добавление недостающих конструкторов Недостающие конструкторы для класса FloatFraction легко написать. Они должны делать не более того, что делают соответствующие конструкторы класса Fraction. FloatFraction() {set(0, 1);} FloatFraction(int n, int d) {set(n, d) ; } FloatFraction(int n) {set(n, 1);) FloatFraction(const FloatFraction &src) {set(src.get_num(), src.get_den());}
ГЛАВА 16. Наследование: что в наследство?371 Этот код я получил путем копирования и вставки кода конструкторов Fraction, в ко- торых изменил «Fraction» на «FloatFraction», что очень просто сделать в тексто- вом редакторе или процессоре. Но мне пришлось внести еще одно изменение. Вам наверняка хотелось бы, чтобы опи- сание конструктора копирования оставалось простым и выглядело следующим образом: FloatFraction(const FloatFraction &src) {set(src.num, src.den);} // ОШИБКА! Закрытый! Проблема в том, что num и den являются закрытыми членами класса Fraction. Это означает, что они недоступны для других классов, включая даже подклассы Fraction! (К этой проблеме я вернусь в этой главе). Поэтому в коде Float-Fraction вы должны ис- пользовать открытые функции get_num и get_den. Разрешение конфликтов типа с базовым классом Если вы серьезно поэкспериментируете с классом FloatFraction, то обнаружите, что большинство операций не работает. Функция set является важным исключением; это, наверное, единственная работающая унаследованная функция. Но язык C++ не настолько необоснован, как это может показаться на первый взгляд. Например, рассмотрим, как объявляется функция Fraction: : add. Fraction add(const Fraction &other); Эта функция полностью унаследована и поддерживается в классе FloatFraction. Но посмотрим, что делает эта функция: она принимает аргумент типа Fraction (не FloatFraction) и возвращает аргумент типа Fraction (но не FloatFraction). Следовательно, вы можете использовать функцию add в выражениях следующего вида: Fraction fl, f2; FloatFraction ff; fl = ff.add(f 2) ; Код корректно использует все типы, так как использование класса Fraction для пере- дачи и получения значений достаточно трудоемко. Но его полезность ограничена. Что вам действительно хотелось бы сделать и чего на данный момент вы сделать не мо- жете, так это создавать выражения, которые совмещают объекты FloatFraction с другими объектами FloatFraction: FloatFraction fO, fl, f 2; fl = f0.add(f2); К счастью, существует простое решение для данной категории проблем. Все, что вам необходимо сделать - это добавить еще один конструктор: преобразователь из класса Fraction. FloatFraction(Fraction fract) {set(fract.get_num(), fract.get_den());} Теперь все проблемы решены. Функция add возвращает объект типа Fraction, но это значение можно легко преобразовать обратно в тип FloatFraction. Этот подход ра- 13*
372 C++ без страха ботает так хорошо, потому что FloatFraction не содержит новых объектов данных, так что ничего больше не нужно делать для того, чтобы операции класса Fraction ра- ботали корректно. Но (как вы увидите позже в примере 16.4 этой главы) если присутст- вуют новые объекты данных, то вам придется подменять некоторые функции-члены класса Fraction. Пример 16.2. Завершенный класс FloatFraction Чтобы заставить класс FloatFraction работать так, как вы хотите, нужно предоста- вить все необходимые конструкторы, а также конструктор типа FloatFrac- tion (Fraction). Ниже приведен полный код. Листинг 16.4. FloatFract2.cpp #include <iostream> #include "Fract.h" using namespace std; class FloatFraction : public Fraction { public: FloatFraction() {set(0, 1);} ' FloatFraction(int n, int d) {@et(n, d);} FloatFraction(int n) {set(n, 1);} FloatFraction(const FloatFraction isrc) {set(src.get_num(), src.get_den());} FloatFraction(const Fraction &src) {set(src.get_num(), src.get_den());} double get_float() { return static_cast<double>(get_num())/get_den(); } }; int main() { FloatFraction.fl(1, 2), f2(l, 3), f3 ; f3 = fl + f2; cout « "Value of f3 is " << f3 << endl; cout « "Float value of f3 is " << f3.get_float() « endl; return 0; 2 Как это работает Этот пример должен быть понятен. Измененное объявление FloatFraction включает все необходимые конструкторы, так что объявления, аналогичные следующему, сейчас полностью поддерживаются: FloatFraction fl(l, 2), f2(l, 3), f3;
ГЛАВА 16. Наследование: что в наследство? 373 Класс также включает конструктор, который преобразует тип Fraction в Float- Fraction: FloatFraction(const Fraction &src) {set(src.get_num(), src.get_den() ) ; } Благодаря последнему конструктору, все операции, объявленные в классе Fraction теперь работают гладко с классом FloatFraction; например, то, что функция op- erators- возвращает объект Fraction, уже не является проблемой, так как этот объ- ект корректно преобразуется обратно в тип FloatFraction. Следовательно, следую- щее выражение работает «без сучка, без задоринки»: f3 = fl + f 2; Упражнения Упражнение 16.2.1. Измените функцию main примера ^6.2 так, чтобы она демонстрировала успешное использование операторов проверки равенства (==), сложения и умножения. Упражнение 16.2.2. Ответьте на вопрос: можете ли вы свободно смешивать объекты FloatFraction и Fraction в любых контекстах, имея полную версию FloatFraction? Пример 16.3. Класс ProperFraction В этом разделе демонстрируется, как можно расширить наследование, создав подкласс подкласса. Не всегда нужно писать классы таким образом; вместо этого, вы можете де- лить базовый класс на любое количество подклассов (создавая «плоскую» иерархию классов). Но полезно знать, что подкласс может, в свою очередь, становиться базовым классом для некоторых других подклассов. Каждый раз, создавая подкласс и еще один уровень в иерархии, вы добавляете больше возможностей и/или больше емкостей для хранения данных. Как я и предполагал ранее, в случае с примером иерархии классов «собака» - «млекопитающие» - «животные», подкласс в идеале должен рассматриваться как более специализированная версия базо- вого класса. Ниже приведено важнейшее правило: В общем случае вы должны добавлять подклассы к существующему классу ♦♦♦ для того, чтобы добавить более специализированные возможности или каче- ства к этому классу. Повторим, если А является подклассом В, то А должен рассматриваться как вариант В, так же как собака является млекопитающим. Но, откровенно говоря, все это относится к теории объектно-ориентированного программи- рования. Иногда вы добавляете подклассы, потому что это самый простой способ заставить что-либо работать. Предположим, что для аргумента, который вы уже имеете, объявлен класс FloatFraction и вы хотите добавить еще несколько функций для получения класса ProperFraction. Этот пример показывает, как вы можете добавить подкласс к Float- Fraction, который сам по себе уже является подклассом Fraction.
374 C++ без страха Листинг 16.5. PropFractl.срр ttinclude <iostream> ttinclude "Fract.h" using namespace std; class FloatFraction : public Fraction { public: FloatFraction() {set(0, 1);} FloatFraction(int n, int d) {set(n, d);} FloatFraction(int n) {set(n, 1);} FloatFraction(const FloatFraction &src) {set(src.get_num(), src.get_den());) FloatFraction(const Fraction &src) {set(src.get_num(), src.get_den());} double get_float() { return static_cast<double>(get_num())/get_den();} }; class ProperFraction : public FloatFraction { public: ProperFraction() {set(0, 1);} ProperFraction(int n, int d) {set(n, d);} ProperFraction(int n) {set(n, 1);} ProperFraction(const ProperFraction &src) {set(src.Fraction::get_num(), src.get_den() ) ; } ProperFraction(const FloatFraction &src) {set (src . Fraction: : get_num() , src . get_den () )’; } ProperFraction(const Fraction &src) {set(src.Fraction::get_num(), src.get_den());} void pr_proper(ostream &os); int get_whole(); int get_num(); // ПОДМЕНЕННАЯ ФУНКЦИЯ! }; int main() { . ProperFraction fl(l, 2), f2(5 , 6), f3 ; f3 = fl + f 2; cout << "Value of f3 is "; f3.pr_proper(cout); cout « endl; cout << "Float value of f3 is " « f3.get_float() << endl; return 0;
ГЛАВА 16. Наследование: что в наследство? 375 // ФУНКЦИИ PROPERFRACTION //------------------------------------------------------ // Вывод функции правильной дроби ProperFraction: // Использование определенного выходного потока (оз) для вывода // объекта в формате «1 Уз» // void ProperFraction::pr_proper(ostream &os) { if (get_whole() != 0) os << get_whole() « " os « get_num() << "/" << get_den(); } // Функция получения целой части // Возвращение целой части с использованием целочисленного // деления int ProperFraction::get_whole() { int n = Fraction::get_num(); return n / get_den(); } // Функция получения числителя(ПОДМЕНЕННАЯ) // Возвращение числителя правильной дроби. // с использованием деления по модулю (остаток). // int ProperFraction::get_num() { int n = Fraction::get_num(); return n % get_den(); } Как это работает В этом примере представлена пара новых уловок: ✓ Функция get_num, которая является одной из наиболее часто используемых функ- ций класса Fraction, подменяется классом ProperFraction. Это немного ус- ложняет код, вынуждая вас специфицировать версию функции get_num, которая будет использоваться. ✓ Схема наследования немного сложнее. Чтобы убедиться в том, что все операции класса работают корректно, вы должны обеспечить преобразование из класса Fra- tion, а также прямое преобразование из базового класса FloatFraction. Функция get_num' здесь подменяется, так как эта функция должна вести себя по- другому. Предполагается, что класс должен работать следующим образом: предполо- жим, что объект хранит значение 5/2, что эквивалентно правильной дроби 2 1/2. Для объекта Fraction следующий код должен выводить на печать «5/2». Fraction fract(5, 2); cout « fract.get_num() <<
376 C++ без страха . cout « fract.get_den(); Но для объекта ProperFraction этот же код должен выводить на печать значение «1/2», потому что это значение является частью следующих выражений, выводящих на печать значение «2 Уг». ProperFraction fract(5, 2); cout << fract.get_whole() « " cout << fract.get_num() « cout << fract.get_den(); В правильной дроби, которая отделяет целочисленную часть, числитель должен выво- дится как 1, а не как 5. Объект ProperFraction хранит данные внутри, как и объект Fraction. Предполо- жим, вы присваиваете им одинаковые значения: Fraction fractl(5,2); ProperFraction fract2(5, 2) ; Оба объекта ProperFraction и Fraction хранят значение 5 в качестве числителя и 2 в качестве знаменателя. Различие между этими объектами состоит в том, как они ведут себя, когда пользователь объекта получает значение. Когда вы вызываете функцию get_num, объект Fraction возвращает 5, тогда как объект ProperFraction должен вернуть 1. То есть функция get_num должна вести себя по-другому. Сложным также является тот факт, что для реализации объекта ProperFraction функции get_num вам необходимо непосредственно получить значение num. Но един- ственным способом для этого является вызов оригинальной версии функции get_num, которая была подменена! К счастью, C++ предоставляет простое решение: даже если вы подменили член, вы мо- жете сослаться на версию базового класса, используя для этого оператор области види- мрсти ( : : ). При помощи этой нотации вы можете вызвать оригинальную версию функции get_num. int ProperFraction: :get_num(). { int n = Fraction::get_num(); return n % get_den(); } Эта запись работает, даже несмотря на то, что объект Fraction является непрямым базовым классом объекта ProperFraction. Остальная часть кода достаточно очевидна. Необходимо предоставить конструкторы для класса ProperFraction, включая конструкторы, которые осуществляют преобразова- ние из прямого базового класса FloatFraction и его базового класса Fraction. ProperFraction() {set(0, 1);} ProperFraction(int n, int d) {set(n, d);} ProperFraction(int n) (set(n, 1);} ProperFraction(const ProperFraction isrc) {set(src.Fraction::get_num(), src.get_den());} ProperFraction(const FloatFraction &src) {set(src.Fraction::get_num(), src.get_den());}
ГЛАВА 16. Наследование: что в наследство?377 ProperFraction(const Fraction &src) {set(src.Fraction::get_num(), src.get_den());} Опять же, здесь необходимо использовать оператор области видимости ( : :) для опре- деления версии базового класса get_num. Упражнения Упражнение 16.3.1. Запишите функцию operatorcc, которая работает с классом ProperFraction, позволяющую вам непосредственно выводить значение в cout (и другие выходные потоки). Просмотрите еще раз технологию записи кода в главе 13, ес- ли это необходимо. Помните, что эта функция должна быть глобальной функцией, кото- рая объявлена как дружественная функция. ProperFraction pf; //. . . cout << pf << endl; Упражнение 16.3.2. Запишите конструктор для класса ProperFraction, который принимает три аргумента типа int - w, п и d, представляющие целочисленную часть, числитель и знаменатель соответственно. Например, вы можете инициализировать объ- ект со значением 4 2/3, используя входные данные 4,2 и 3. ProperFraction pf(4, 2, 3); Закрытые и защищенные члены В большей части этой главы рассматриваемая проблема получения доступа к num и den усложняла дискуссию. Подклассы FloatFraction и ProperFraction на самом деле наследуют два объекта данных num и den. Однако в связи с тем, что эти члены яв- ляются закрытыми, они недоступны в коде (кроме как, конечно, в коде самого класса Fraction). Даже код подкласса не может сослаться на закрытые члены. Они находятся в коде, но являются невидимыми. Соответственно, в коде определения функции подкласса вы можете обращаться к функ- циям get_num и get_den для получения значений num и den. Все становится еще сложнее, как мы видели, когда одна из этих функций (get_num) подменяется. Можно упростить себе задачу, если подкласс может ссылаться непосредственно на num и den. Тогда эта функция: int ProperFraction::get_num() { int n = Fraction::get_nufti(); return n / get_den{); } может быть записана таким образом: int ProperFraction::get_num() { return num / den;
378 C++ без страха Эта функция может стать настолько короткой, что может быть встроенной: int get_num() {return num / den;} Кажется, разумно было бы позволить подклассам ссылаться на num и den, не так ли? Идеально было бы позволить коду подкласса получать доступ к данным, ограничивая доступ из кода, будь то код класса Fraction или код любого подкласса. И есть способ сделать это. C++ поддерживает третий уровень доступа - защищенный, который является промежуточным между закрытым и открытым уровнями. Если у num и den будет этот уровень доступа (путем использования ключевого слова protected), то все подклассы смогут непосредственно ссылаться на них. class' Fraction { protected: int num, den; // Числитель и знаменатель. //. . . Отметим, что к членам, объявленным при помощи ключевого слова protected все же существует ограниченный доступ. Только сам класс Fraction и его (прямые и непря- мые) подклассы могут ссылаться на защищенные члены. Итак, является ли изменение объявления класса Fraction решением проблемы? Вероятно, но в некоторых случаях это невозможно. Если класс Fraction уже был от- компилирован и предоставлен вам другим программистом, то вы не сможете изменить этот класс. В этом случае вам придется обойти ограничения закрытых данных как мож- но более удачным способом. Есть еще один, более глубокий вопрос: должны ли члены класса объявляться защищен- ными, а не закрытыми? Хотя достаточно понятно, какие из членов должны быть откры- тыми, однако решение об объявлении членов защищенными или закрытыми не всегда настолько очевидно. Когда вы объявляете какой-либо член закрытым, вы заявляете, что не хотите, чтобы кто-либо менял или обращал какое-либо внимание на него (даже вы сами) при записи подкласса. Возможно, имеет смысл сделать, например, функции под- держки (led, gcf) закрытыми, потому что никто не должен менять эти функции. Они вы- зываются только функцией normalize, и в действительности это единственная функ- ция, которая должна использовать их. Другие члены могут быть объявлены защищенными. Сама функция normalize то- же подходит для этого, так как она обычно очень полезна. Авторы подклассов не отказались бы иметь возможность ссылаться на объекты данных num и den, как вы уже видели. Однако могут возникнуть проблемы, если автор подкласса попытается установить num и den прямо, не полагаясь на функцию «set». Поэтому, возможно, есть смысл сохране- ния num и den закрытыми, в зависимости от того, насколько вы доверяете тем, кто по- пытается записать подкласс. Ниже кратко описаны три уровня доступа в C++.
ГЛАВА 16. Наследование: что в наследство? 379 Табл. 16.1. Уровни доступа в C++ Уровень доступа Описание Открытый (public) Защищенный (protected) Закрытый (private) Член может быть доступен из любой функции, вне зави- симости от того, является ли она частью класса или нет Член может быть доступен из функции, только если это член класса или подкласса Член может быть доступен из функции, только если это член класса Пример 16.4. Содержащиеся члены: FractionUnits Я заканчиваю эту главу вариацией, содержащей новый объект данных, units. Новые элементы данных, добавленные подклассом, могут иметь любой действительный тип; здесь я использую объектный тип, то есть другой класс. Как я указывал ранее, этот дру- гой тип - String, не является частью иерархии класса, но содержится (contained) в классе, который использует его. Как я уже делал в нескольких последних примерах, я полагаю, что весь код для базового класса Fraction помещается в файлы Fract.h и Fract.срр, которые необходимо доба- вить к проекту. Здесь я предполагаю то же самое для класса String, который должен поддерживаться файлами StringClass.h и StringClass.срр. Для запуска следующего примера сначала создайте эти файлы из кода главы 15. Кроме того, вы можете использо- вать встроенный класс C++ - string. Смотрите замечание в конце этого листинга. Листинг 16.6. FractionUnitsl.cpp #include <iostream> #include <string.h> ttinclude "Fract.h" ttinclude <string> using namespace std; class Fractionunits : public Fraction { public: string units;. FractionUnits() {set(0, 1);} Fractionunits(int n, int d) {set(n, d) ;} FractionUnits(int n) {set(n, 1);} FractionUnits(const Fraction &src) {set(src.get_num(), src.get_den());} FractionUnits(const FractionUnits &src) {set(src.get_num(), src.get_den()); units = src.units;} // ПОДМЕНЕННЫЕ ФУНКЦИИ FractionUnits add(const FractionUnits &other);
380 C++ без страха Fractionunits operator+(const Fractionunits Mother) {return add(other);} int operator==(const Fractionunits Mother); friend ostream &operator<<(ostream &os, Fractionunits &fr); }; int main() { Fractionunits fl(l, 2), f2(4, 3); fl.units = "feet"; f2.units = "feet"; Fractionunits f3 = fl + f2; cout << "The length of the item is " << f3 << endl; return 0; } // Функции FRACTIONUNIT //--------------------------------------------- Fractionunits Fractionunits::add(const Fractionunits Mother) { Fractionunits fr = Fraction::add(other); if (units -- other.units) fr.units = units; return fr; } int Fractionunits::operator;^(const Fractionunits Mother) { return Fraction::operator==(other) && units == other.units; } ostream &operator«(ostream &os. Fractionunits &fr) { os << fr.get_num() << "/" « fr.get_den(); if (fr.units.size() > 0) os « " " « fr.units; return os; } Если ваш компилятор поддерживает новый строковый класс string, вы мо- жете запустить этот пример посредством 1) замены ttinclude "String- Class. h" на ttinclude <string>, 2) объявления модулей с классом string, а не String и 3) в функции operator« расчетом длины строки при помощи fr.unit.size(), а не strlen(fг.units). Как это работает Как и в предыдущих примерах в этой главе, класс FractionUnit объявляет собствен- ные конструкторы (запомните, что это связано с тем, что конструкторы не наследуются). Fractionunits() {set(0, 1);}
ГЛАВА 16- Наследование: что в наследство?381 Fractionunits(int n, int d) {set(n, d);} Fractionunits(int n) (set(n, 1);} Fractionunits(const Fraction &src) {set(src.get_num(), src.get_den());} Fractionunits(const Fractionunits &src) {set (src.get_num(), src.get_den() ) ; units = src.units;} Кроме того, класс подменяет несколько функций. Целью класса Fractionunit явля- ется позволить пользователю сохранить имя некоторых единиц измерения (например, «feet», «inches», «pounds» или «meters»). Он объявляет новые объекты данных - units, делая их открытыми, так что пользователь объекта Fractionunit может прямо установить этот член. Класс реализует определенное поведение, что облегчает работу с единицами измерения. (Это действительно фундаментальная концепция в объектно-ориентированном програм- мировании: структура данных и поведение должны быть спроектированы и скомпонова- ны вместе.) V Когда вы складываете два объекта, содержащие одинаковые единицы, эти единицы измерения необходимо сохранить. Например, сложение 1/2 дюйма и 1/3 дюйма должно привести к результату 5/6 дюйма. Таким образом, функции add и орега- tor+ подменяются. Когда вы сравниваете два объекта Fractionunits, они должны считаться равными только в том случае, если содержат одинаковые единицы измерения. Например, 1/4 фута равно 1/4 фута, но не 1/4 мили. Функция operator== при этом подменяется. ✓ Когда вы выводите объект Fractionunits на консоль, то должны выводиться единицы измерения, если таковые имеются. Например, Fractionunits fr(l, 2); fг.units = "miles"; cout << fr; В заключение следует отметить, что класс должен подменить функции add, opera- tor== и operator+ и поддержать новую версию функции operator«. Fractionunits add(const Fractionunits Mother); Fractionunits.operator+(const Fractionunits Mother) {return add(other);} int operator==(const Fractionunits Mother); Определения функции здесь используют код базового класса, насколько это возможно следуя принципу объектного ориентирования, согласно которому многократное исполь- зование является хорошим правилом. Например, подмененная функция add вызывает версию базового класса (Fraction: : add) для выполнения большей части задачи. Fractionunits FractidnUnits::add(const Fractionunits &other) { Fractionunits fr =.Fraction::add(other); , if (units == other.units) fr.units = units;
382 C++ без страха return fr; } Функция operator+ является интересным случаем. Хотя похоже на то, что встроенное определение делает то же самое, что делает версия класса Fraction, но в действитель- ности это не так. Обе версии вызывают функцию add, но каждая из них вызывает вер- сию функции add, определенную в своем собственном классе. Если бы эта функция не была подменена, то operator+ вызывал бы Fraction:: add, а не Fraction- Units : : add. FractionUnits operator+(const FractionUnits &other) {return add(other);} И наконец, функция operator<<, говоря в техническом смысле, не была подменена (так как это не функция-член), потому что код предоставляет версию, взаимодействую- щую с классом Fractionunits. В связи с перегрузкой функции этот код не конфлик- . тует с версией класса Fraction функции operator<<. friend ostream &operator<<(ostream &os, FractionUnits &fr); Упражнение Упражнение 16.4.1. Реализуйте умножение для класса FractionUnits путем подме- ны функций mult и operator*. Необходимо позволить любую комбинацию единиц измерений. Если один объект содержит в -качестве единицы измерения строку s, а дру- гой в качестве единицы измерения содержит пустую строку, то единицы измерения не- обходимо установить в s. Если один объект содержит строку s1, а другой содержит стро- ку s2, то результирующая единица измерения должен быть установлен в $1 * s2. Напри- мер, умножение 1/2 фута на 3/4 секунды в результате должно дать 3/8 фута в секунду. Резюме Вот основные моменты главы 16: Для объявления подкласса существующего класса используйте следующий синтаксис: class class_name : public base_class { declarations }; ✓ Ключевое слово public в этом контексте не требуется синтаксисом, но настоятельно рекомендуется (особенно если вы только изучаете C++). Если базовый класс имеет закрытый уровень доступа, то все члены базового класса становятся закрытыми, если они наследуются подклассом. ✓ Все члены базового класса наследуются подклассом (кроме конструкторов). ✓ Объявления в подклассе могут определить новые члены, которые становятся членами класса вместе с унаследованными членами, как и существующие члены базового класса. Это приводит к подменам объявления базового класса. Обычно имеет смысл подменять функции-члены для обеспечения изменения функционирования.
ГЛАВА 16. Наследование: что в наследство?383 ✓ По умолчанию, имя члена класса ссылается на версию в том конкретном классе, так что (например) если «foo» является подмененной функцией, то использование «too» ссылается на новую версию, а не на версию базового класса. Однако вы можете опре- делить версию базового класса, используя оператор области видимости (: :). Например, в следующем определении, функция get_num, определенная в классе ProperFrac- tion вызывает версию, определенную в классе Fraction (в базовом классе). int ProperFraction::get_num() { int n = Fraction::get_num(); return n % get_den(); } ✓ Помните, что конструкторы не наследуются подклассами. Каждый подкласс должен объявлять свои собственные конструкторы. ✓ Как всегда, компилятор предоставляет автоматическую версию конструктора по умолчанию, конструктора'копирования и функции operators как описано в пре- дыдущих главах. Для подклассов каждая из этих функций, предоставляемых компи- лятором, вызывает версию базового класса. Например, конструктор по умолчанию вызывает конструктор по умолчанию базового класса и затем устанавливает каждый новый член подкласса в ноль или в пустое значение. (Но, обратите внимание на то, что, как всегда, компилятор предоставляет конструктор по умолчанию, только если вы не написали другой конструктор). ✓ Часто написание конструктора, преобразующего объект базового класса в объект подкласса, является полезным приемом. Это может разрешить конфликты типов, ко- гда функции-члены наследуются. Например: class FloatFraction : public Fraction { public: II... FloatFraction(const Fraction &src) {set(src.get_num(), src.get_den());} ✓ Закрытые члены базового класса наследуются подклассом, но недоступны в коде подкласса. Для объявления членов, доступных при помощи кода в каком-либо под- классе или во всех подклассах, но не при помощи кода вне иерархии класса, необхо- димо объявить их защищенными (protected). (Эти члены также доступны при помощи кода в непрямых подклассах вниз на любую глубину наследования.) protected: int num, den; i ✓ Классы (подклассы, так же как и обычные классы) могут содержать объекты как чле- ны, что означает, что один класс может содержать экземпляры другого класса.
ГЛАВА 17. Полиморфизм: независимость объекта Чтобы использовать объекты с максимальной пользой, им будет необходимо предоста- вить независимость. Каждый объект должен действовать так, как будто он является ми- ниатюрным компьютером, который может отсылать сообщения и отвечать на них. Если новый объектный тип использует подходящий интерфейс, вам нужно уметь подключать его в существующее программное обеспечение. Буквально, «полиморфизм» означает «много форм». На базовом уровне это означает возможность реализации одной операции несколькими способами. Такое утверждение является тривиальным. Но факт в том, что, не изменяя что-либо в коде, который исполь- зует объект, вы можете заменить его тип на другой объектный тип, а программа все рав- но будет работать. Рассмотрим аналогию. Ваш текстовый процессор может успешно взаимодействовать с любым типом приобретенного вами принтера. Вы можете даже пять лет спустя удалить старый принтер и подключить принтер, который сегодня даже не существует. Людям, которые сегодня пишут текстовый процессор, не надо знать о всех типах принтеров, ко- торые будут разработаны в будущем. Такова же идея полиморфизма: сделать так, чтобы объекты программного обеспечения работали как части аппаратного обеспечения, которые могут взаимодействовать друг с другом и заменяться при необходимости. Старое программное обеспечение может ус- пешно взаимодействовать с более новым программным обеспечением... в этом заключа- ется возможность многократного использования. Другой подход к классу FloatFraction Понимание полиморфизма начинается с понимания виртуальных функций и ситуаций, в которых они необходимы. Я начну с проблемы программирования, которая демонстри- рует простое использование виртуальных функций. Класс FloatFraction, описанный в главе 16, порождает значение с плавающей точ- кой (double) «на лету», то есть он рассчитывает значение с плавающей точкой всякий раз, когда пользователь данного объекта запрашивает это значение путем вызова функ- ции get_float. Это корректная и в большинстве случаев достаточная техника. Но расчет значения с плавающей точкой занимает драгоценное процессорное время. Возможно, вы захотите обойти это вычисление, используя вместо него постоянное значение с плавающей точ- кой. Это лучший способ работы, если клиентский код (код, использующий класс) обра- щается к значению с плавающей точкой много раз в секунду и вы хотите сделать эти обращения более эффективными. Для реализации этого способа вам необходимо выполнить следующие шаги: ✓ Объявить новый объект данных float_val, в котором будет храниться значение с пла- вающей точкой для класса. Так вы сделаете значение с плавающей точкой постоян- ным, в результате чего оно никогда не будет пересчитываться, кроме случаев, когда это будет вам необходимо.
ГЛАВА 17. Полиморфизм: независимость объекта"385 ✓ Подменить функцию normalize, в результате чего она будет рассчитывать значе- ние с плавающей точкой наряду с выполнением других задач. Сделать это вы може- те, если, конечно, у вас есть доступ к функции normalize; предполагается, что функция normalize объявлена с ключевым словом protected. Красота этой стратегии заключена в ее простоте. Подмена функции normalize являет- ся исчерпывающим способом, чтобы убедиться в том, что объект данных float_val рас- считывается только в необходимых случаях. Код класса Fraction, так же как и код всех подклассов, всегда присваивает значения данных путем вызова функции-члена set, который, в свою очередь, вызывает функцию normalize после установки значений num и den. Таким образом, вы должны только лишь подменить функцию normalize, чтобы убедиться в том, что float_val пересчитывается всякий раз, когда появляется новое значение. Ниже представлена подмененная версия функции normalize: void FloatFraction::normalize() { Fraction::normalize(); float_val = static_cast<double>(get_num()) / get_den(); } Больше ничего внутри описания этой функции делать не нужно. Обратите внимание, что вам не нужно переписывать функцию normalize полностью... большая часть работы вы- полняется путем вызова версии базового класса Fraction: normalize. В коде есть только одна новая строка, которая устанавливает значение объекта данных float_val. К сожалению, здесь возникает одна проблема. (Теперь у нас есть две версии функции nor- malize - Fraction: :normalize и FloatFraction: :normalize. Какую из версий вызывает функция set? Для проверки приведем описание функции set в классе Frac- tion: void set(int n, int d) [num = n; den = d; normalize();} Вы можете подумать, что при вызове функции normalize вызывается любая версия этой функции, которая определена в классе текущего объекта (через который осуществ- ляется вызов функции). Вы можете так думать, но вы ошибаетесь. Когда компилятор C++ читает определение функции set в классе Fraction, он выну- жден принять решение о том, как вызывать функцию (во время компиляции). И чтобы сделать это, он вынужден исправить адрес функции. Таким образом, функция set мо- жет быть написана в таком виде: void set(int n, int d) {num = n; den = d; Fraction::normalize();} В результате функция set никогда не вызовет подмененную версию функции normal- ize. И если вам пришла мысль о подмене самой функции set, подумайте еще раз; функ- ции set нужен доступ к двум закрытым переменным - num и den , но подклассы не име- ют такого доступа. Поэтому подкласс не может успешно подменить функцию set.
386 C++ без страха Вместо этого вам на самом деле необходим способ выполнения гибкого вызова функ- ции, то есть вызова функции, чей фактический адрес не определяется до выполнения программы. (Это называется динамическим связыванием.) В этом случае вызов функции normalize будет означать следующее: «вызов реализации функции normalize объ- екта this». После чего при вызове функции normalize будут автоматически выпол- няться правильные действия. В подклассе, таком как Fractionunits, функция set будет вызывать новую версию функции normalize, а не старую. Вызов адреса функции, которая все еще должна быть определена, может быть гибким способом вызова функции. Вы даже можете сказать, что так выполняется вызов вирту- альной функции. Виртуальные функции, на выручку! Таким образом, решением является сделать функцию normalize виртуальной. Для этого нужно использовать ключевое слово virtual. Конечно, такой подход не всегда возможен. Чтобы все работало корректно, вам надо возвратиться назад (с этой точки) и изменить код класса Fraction. В этом коде необ- ходимо установить около функции normalize ключевое слово protected, а также объ- явить ее виртуальной - virtual. Сделать это просто, если вы решили переписать класс Fraction. Поставьте перед строкой объявления функции слово protected: и поместите ключевое слово virtual в начало объявления функции. class Fraction { / / . . . protected: virtual void normalize(); // Привести к стандартной /I форме. //. . . }; В отличие от изменений, которые были описаны в предыдущем разделе, это единствен- ное изменение, которое необходимо выполнить. Если функция объявлена виртуальной, она будет виртуальной всегда, во всех контекстах и во всех подклассах. Вам не придется снова использовать ключевое слово virtual с этой функцией. Еще лучше вызывать виртуальную функцию так же, как и любую другую. Но незаметно для вас язык C++ все-таки вынужден использовать специальную процедуру для вызова виртуальной функции. Но это никак не отражается в исходном коде. Приведем несколько ограничений: ✓ Вы можете сделать виртуальными только функции-члены. ✓ Встраиваемые функции нельзя сделать виртуальными. ✓ Конструкторы нельзя сделать виртуальными, но, как этр не парадоксально, деструк- торы могут быть виртуальными.
ГЛАВА 17. Полиморфизм: независимость объекта 387 Какие из функций вам бы следовало сделать виртуальными? Почему бы ни сделать все доступные функции виртуальными? На самом деле, при вызове виртуальных функций падает эффективность работы программы, но этим можно пренебречь. Поэтому приве- дем полезное общее правило: > Любую функцию-член, которую можно подменить подклассом, следует объ- * являть как виртуальную. Это справедливо и для подклассов, подменяющих не-виртуальные функции, но при этом существует риск того, что в определенном контексте правильная функция не будет вы- звана (как мы видели на примере функции normalize в предыдущем разделе). Если вы знаете, что функцйя-член будет подменена, то сделайте ее виртуальной. Вставка Что за «дополнительные затраты» на виртуальные функции? Хотя совсем необязательно знать, как именно происходит вызов виртуальной функ- ции компилятором C++, но полезно знать кое-что о следующем компромиссе: вирту- альные функции являются более гибкими, но за это преимущество вам приходится платить. Если вы полностью уверены, что определенная функция никогда не будет подменена, то нет никаких оснований делать ее виртуальной, особенно если ваша программа должна быть как можно более эффективной. Однако этот недостаток невелик, особенно на фоне впечатляющих скоростей и объе- мов памяти сегодняшних компьютеров. По сути, существует два недостатка, которые тесно связаны: по производительности и по пространству. Когда программа осуществляет стандартный вызов функции, то выполняется то, о чем я бегло рассказывал в главе 4: программа временно передает управление определен- ному адресу, а затем возвращает его после завершения выполнения функции. Эту простую операцию можно визуально изобразить следующим образом: normalize() { Выполнение виртуальной функции является более сложным. Каждый объект содер- жит скрытый указатель «viable», указывающий на таблицу всех виртуальных функ- ций отдельного класса. Например, все объекты класса FloatFraction содержат указатель viable, указывающий на таблицу виртуальных функций для класса Float- Fraction. (Между прочим, если в классе нет виртуальных функций, то его объектам не нужен указатель viable, в результате чего экономится некоторое пространство).
388 C++ без страха Чтобы вызвать виртуальную функцию, программа использует указатель viable для выполнения непрямого вызова. В действительности это процесс поиска корректного адреса функции в рабочем цикле. (Помните, этот процесс выполняется скрыто, поэто- му он абсолютно не отображается в исходном коде C++.) Эту операцию визуально можно представить следующим образом: Так как каждый объект содержит указатель viable, вы можете утверждать, что знание технологии выполнения действия заложено в самом объекте, как я говорил в начале этой главы. Указатель viable позволяет каждому объекту «обладать» этим знанием, потому что он указывает на реализации функции, каждая из которых является специ- фичной для его собственного класса. Понятно, что здесь есть несколько дополнительных затрат, однако небольших. Неко- торое падение производительности возникает из-за того, что на непрямой вызов функции требуется немного больше времени в сравнении с прямым вызовом (несмот- ря на то, что эта разница измеряется в микросекундах). Причина возникновения ис- пользования дополнительного пространства заключена в том, что на хранение указа- теля viable и самой таблицы необходимо выделять память (несмотря на то, что это количество является незначительным в сравнении с доступными сегодня объемами памяти). Отсюда следует вывод: делайте функции виртуальными, если существует вероятность их подмены. Дополнительные затраты при этом незначительны. Пример 17.1. Исправленный класс FloatFraction В этом примере рассматривается класс FloatFraction. Ниже приведенный код под- разумевает, что описание функции содержится в файле Fract.cpp (который должен добавляться во все проекты, которые используют класс Fraction). Несмотря на то» что я не привожу здесь файл Fract.cpp, я показываю, какие именно изменения должны быть внесены в объявление класса Fraction на файле Fract.h. Ниже приведена новая версия FloatFraction, которая содержит новый объект дан- ных float_val и подменяет функцию normalize. Добавленные строки и измененные строки старой версии FloatFraction (из главы 16) выделены полужирным шрифтом.
ГЛАВА 17, Полиморфизм: независимость объекта 389 Листинг 17.1. FloatFract3.cpp ttinclude <iostream> ttinclude "fract.h" using namespace std; class FloatFraction : public Fraction { public: double float—val; FloatFraction() [set(0, 1);} FloatFraction(int n, int d) {set(n, d);} FloatFraction(int n) {set(n, 1);} FloatFraction(const FloatFraction Ssrc) (set(src.get_num(), src.get_den());} FloatFraction(const Fraction &src) {set(src.get_num(), src.get_den());} void normalize(); // ПОДМЕНЕННАЯ } ; void FloatFraction::normalize() { Fraction:znormalize(); float_val = static_cast<double>(get_num()) / get—den(); } int main() { FloatFraction fractl(1, 4), fract2(l, 2) ; FloatFraction fract3 = fractl + fract2; cout « "1/4 + 1/2 = " « fract3 << endl; cout << "Floating pt value is = " << fract3.float_val << endl; } Ниже приведена измененная версия файла Fract.h, которая содержит объявления класса Fraction. Строка, которую нужно изменить, выделена полужирным шрифтом. Листинг 17.2. Fract.h ttinclude <iostream> using namespace std; class Fraction { private: int num, den; // Числитель и знаменатель. public: Fraction() {set(0, 1);} Fractionfint n, int d) {set(n, d);} Fraction(int n) {set(n, 1);}
390 C++ без страха Fraction(const Fraction &src) {set(src.n, src.d);} void set(int n, int d) {num = n; den = d; normalize();} int get_num() const {return num;}• int get_den() const {return den;} Fraction add(const Fraction &other); Fraction mult(const Fraction &other); Fraction operator+(const Fraction Mother) {return add(other);} Fraction operator*(const Fraction &other) {return mult(other) ;} int operator==(const Fraction mother); friend ostream &operator<<(ostream &os, Fraction &fr) ; protected: virtual void normalized; // Приведение к стандартной // форме. private: int gcf(int a, int b); // Наибольший общий делитель. int lcm(int a, int b); // Наименьший общий знаменатель. }; Как это работает Эта программа следует описанной выше стратегии. Значение с плавающей точкой дела- ется постоянным и пересчитывается только в случае необходимости. class FloatFraction : public Fraction { public: double float_val; //... Ключевым моментом этого кода является подмененная функция normalize. Новая версия функции normalize пересчитывает значение float_val, поэтому значение float_value обновляется при любом изменении объекта. void FloatFraction:mormalize() { Fraction::normalize(); float_val = static_cast<double>(get_num()) / get_den(); } Функция set наследуется от базового класса (Fraction) и вызывает функцию nor- malize. void set(int n, int d) {num = n; den = d; normalize();}
ГЛАВА 17. Полиморфизм: независимость объекта 391 Эта функция корректно вызывает новую версию функции normalize (FloatFrac- tion: : normalize), даже если она является функцией-членом класса Fraction , которая ничего не знает ни о каком существовании подклассов! Это работает, потому что функция normalize объявляется с использованием ключевого слова virtual в классе Fraction. Это ключевое слово означает: «Не определяйте, какую версию этой функ- ции вызывать до начала рабочего цикла программы». protected: virtual void normalized; // Приведение к стандартной // форме. Обратите внимание, что эта функция вначале была объявлена как защищенная и вирту- альная, поэтому сейчас ее не нужно изменять или компилировать заново. Функция main тестирует класс FloatFraction путем объявления объектов класса FloatFraction, а затем обращения к новому члену float_val. FloatFraction fractl(1, 4), fract2(l, 2); FloatFraction fract3 = fractl + fract2; cout « "1/4 + 1/2 = " « fract3 « NL; cout « "Floating pt value is = " << fract3.float_val; Если все работает правильно, этот код должен вывести значение «0.75». Усовершенствование кода Хотя я сделал float_val открытым членом, это не обязательно самый лучший подход. Общедоступность float_val имеет некоторые недостатки; некоторые из них те же, что и в случае, когда num и den являются открытыми. Нет ничего плохого в том, что пользова- тель может непосредственно получать значение float_val, но могут возникнуть ошибки, если позволить пользователю изменять это значение. Это еще один случай, когда доступ к данным должен быть контролированным, и это является одной из основных целей ис- пользования функций-членов. Но вам хотелось бы получать это значение без значительных затрат ресурсов на вызов функции. Одним из способов осуществить это - встроить функцию, как это сделано в случае с функциями get_num и get_den. Ниже приведена полная измененная версия Fraction, измененные строки выделены полужирным шрифтом. Листинг 17.3. FloatFract2.cpp ttinclude <iostream> ttinclude "fract.h" using namespace std; class FloatFraction : public Fraction { private: double float_val; public:
392 C++ без страха FloatFraction() {set(0, 1);} FloatFraction(int n, int d) {set(n, d);} FloatFraction(int n) {set(n, 1);} FloatFraction(const FloatFraction &src) {set(src.get_num(), src.get_den());} FloatFraction(const Fraction &src) {set(src.get—num(), src.get_den());} double get_float(); {return float_val;} void normalized; // ПОДМЕНЕННАЯ }; void FloatFraction::normalize() { Fraction::normalize(); float—val = (double) get_num) / get_den; } int main() { FloatFraction fractl(1, 4), fract2(l, 2); FloatFraction fract3 = fractl + fract2; cout « "1/4 + 1/2 = " « fract3 « endl; cout << "Floating pt value is = " << fract3.get_float() ; } Упражнение Упражнение 17.1.1. В подмененную версию функции normalize (FloatFrac- tion: :normalize) добавьте следующее выражение: cout << "I am now in FloatFraction:inormalize!" << endl; Выведенное сообщение говорит вам о том, что выполняется подмененная версия функ- ции normalize, а не версия базового класса. Заново соберите и запустите программу, при этом обратите внимание, сколько раз выводится сообщение. Затем измените Fract.h, удалив из объявления' функции ключевое слово virtual. В результате сообщение не будет больше выводиться, подтверждая, что ключевое слово virtual необходимо для получения правильной реализации для выполнения. (Вы также должны обнаружить, что член float_val содержит «мусор».) «Чистые» виртуальные функции и другие загадки С трудом, но мне удалось убедить вас в том, что стоит использовать виртуальные функ- ции. Проблема в том, чтобы всегда получать правильное описание для выполнения, даже если функция подменена. Запомните, что вы можете выбирать для выполнения версию функции базового класса, если она вам подходит, определив ее в коде. Fraction::normalize();
ГЛАВА 17. Полиморфизм: независимость объекта 393 Но если вы не используете это определение, то это будет означать, что вы хотите, чтобы объект выполнял собственную версию функции, а не версию базового класса. Вот в та- ких случаях и нужны виртуальные функции. Проблема в том, чтобы заставить вызовы функции «выполнять правильные действия», даже если базовый класс не знает, каким способом подкласс может реализовать функцию. Реализация такой возможности сложнее, чем это может показаться на первый взгляд. Иерар- хии наследования глубоко встроены в системы разработки, такие как Microsoft Foundation Classes (используемые с Visual C++) и библиотеки Java. (Хоть язык Java и отличается от C++, но он основан на концепциях C++ и заимствовал много из его синтаксиса.) Виртуальные функции критичны к таким иерархиям наследования. В таких системах, как Visual C++, Java или даже Visual Basic, вы делите на подклассы общие классы Form, Window, или Document для создания собственной формы или документа. Операционная система вызывает Этот объект для выполнения определенных задач - Repaint (Перери- совка), Resize (Изменение размера), Move (Перемещение) и т.д. Все эти действия реали- зуются как виртуальные функции (хотя в Visual Basic это не так уж и очевидно). Приме- нение виртуальных функций гарантирует вам, что ваши функции будут вызываться с использованием вашей реализации кода. My_Form Ключевым моментом этой идеи является концепция интерфейса, который явно является частью языка Java, но реализуется путем наследования класса в C++. Перед тем как начать рассмотрение интерфейсов, я должен рассказать об одной стран- ной концепции, о «чистых» виртуальных функциях. Чистая виртуальная функция - это такая функция, которая вообще не имеет реализации, по крайней мере, в базовом классе. Виртуальная функция определяется выражением «= 0». Например, класс должен опре- делять функцию normalize следующим образом: class Number { protected: virtual void normalize() = 0; }; В данном случае функция normalize является чистой виртуальной функцией. В объ- явлении отсутствует описание функции.
394 C++ без страха Какова цель таких объявлений? Все это делается для работы с абстрактными классами, о которых я расскажу позже. Абстрактные классы и интерфейсы Абстрактный класс - это класс, который содержит одну или более чистую виртуальную функцию, то есть функцию без реализации. Таким классом является класс Number пре- дыдущего раздела. Существует важное правило: абстрактный класс не может быть реализован. Это устра- шающее правило означает, что вы просто не можете использовать класс для объявления объектов. Number а, Ъ, с; // Ошибка! Number является абстрактным // классом, потому что содержит чистую // виртуальную функцию. // Поэтому, а, Ь, и с не могут быть // созданы. Но абстрактный класс может быть полезен как общий шаблон для своих подклассов. Предположим, у вас есть иерархия наследования для разработки окон, которая включает абстрактный класс Form. Вы можете разделить этот класс на подклассы для создания отдельных конкретных форм. Forml Form2 Form3 Перед тем как вы сможете использовать подкласс для реализации (т. е. для создания) значе- ний объектам, подкласс должен обеспечить описания функций для всех чистых виртуальных функций. Класс, в котором хотя бы одна их этих функций не реализована, является абст- рактным и поэтому не может быть использован для присваивания значений объектам. Однако все это может быть полезным, так как предоставляет вам способ определения и усиления общего набора сервисов (который мы можем назвать интерфейсом, хоть это и не Java). Необходимо отметить несколько важных принципов, касающихся интерфейсов: Каждый подкласс может реализовывать все эти сервисы (то есть чистые виртуальные функции) любым угодным ему способом.
ГЛАВА 17. Полиморфизм: независимость объекта 395 ✓ Каждый сервис должен быть реализован, в противном случае классу не может быть присвоено значение. Каждый класс должен четко отслеживать тип информации - возвращаемый тип и тип каждого из аргументов. Это помогает соблюдать дисциплину в иерархии наследования, так что явно неправильные действия (например, передача неправильных данных) могут быть замечены компилятором. Автор подкласса знает, что он (или она) должны реализовывать сервисы, определенные в интерфейсе, такие как Repaint (Перерисовка), Move (Перемещение) и Load (Загруз- ка) - но в пределах самих сервисов могут делать все что угодно. И потому что все эти функции виртуальные, правильная реализация всегда выполняется независимо от того, каким образом осуществляется доступ к объекту. Следующий раздел показывает пример того, как можно это выгодно использовать. Почему cout не является истинно полиморфным Давайте вернемся к cout и потоковому оператору (<<). Как я отмечал в главе 10, это случай частичного, но не полного полиморфизма. Преимуществом использования cout для вывода результата (в отличие от функции printf языка С) является то, что эту функцию можно заставить работать с любым классом. Например, вспомните, как она может работать с классом Fraction. Ниже приведено описание функции, поддерживающее такое поведение. ostream &operator<<(ostream &os, Fraction &fr) { os << fr.num « "/" « fr.den; return os; } Теоретически можно утверждать, что для любого данного объекта, независимо от типа, следующее утверждение может работать: cout << "The value of the object is " « an_object; Однако - хотя это и не очевидно - существует важное ограничение для утверждения, которое я привел выше. Функция cout может работать с объектом только в том случае, если его тип известен во время компиляции. Это означает, что код клиента должен знать все о типе объекта. Но всегда ли это так? Как можно ссылаться на объект, тип которого определен не пол- ностью? Фактически ссылаться на объект, тип которого определен не полностью, можно. Одним из способов сделать это - использовать указатель void, который является указателем на любой тип объекта. Но если вы используете такой указатель и разыменовываете его, cout не будет «знать», как выводить объект. void *р = &an_object; cout << *р; // ОШИБКА! *р не может быть выведен
396 C++ без страха В идеале вы должны иметь возможность делать что-либо подобное. Вы должны иметь возможность разыменовывать указатель на объект (то есть выражения, такие как *р) и выводить на печать объект в правильном формате. Иными словами: информация о том, в каком виде выводить объект, должна быть встроена в нем самом. И это важно, в свою очередь, из-за возможностей системного программирования. Вы можете получить указатель на новый тип объекта через Интернет или из другой про- граммы. Вам следует знать, что, пока класс объекта соблюдает соответствующий интер- фейс, вы все еще можете выводить его в правильном формате. Чтобы сделать это, мы можем объявить абстрактный класс Printable, который объяв- ляет чистую виртуальную функцию print_me. В следующем примере я покажу, как можно корректно вывести любой подкласс, который создает подклассы Printable и реализует эту функцию, с помощью cout (или любого другого класса ostream), даже если потоку передан только общий указатель. Следующее утверждение будет работать, даже если о классе an_object вообще ничего не известно, кроме того, что он создает подкласс Printable. Printable *р = &an_object cout « *р; // Это будет выведено в корректном виде, /I что определяется классом an_object. Существует важное правило, которое делает возможным использование этого кода. Объект типа подкласс может передаваться там, где ожидается объект базового класса. И указатель на объект типа подкласс может быть передан указателю типа базового клас- са. Или, проще говоря: ♦♦ Что-либо определенное (подкласс или указатель) всегда может быть переда- * но чему-либо общему (объекту базового класса или указателю). Обратное утверждение не всегда справедливо (передача указателя базового класса ука- зателю подкласса), если только не существует функция преобразования, поддерживаю- щая такую передачу. Пример 17.2. Истинный полиморфизм: класс Printable Следующий пример демонстрирует способ работы с классом cout (и другими классами ostream), который является истинно полиморфным. -При рассмотрении общего интер- фейса, который в данном случае реализуется как абстрактный класс Printable, вы мо- жете корректно выводить любой объект... даже если точный тип этого объекта неизвестен во время компиляции. Поначалу это может показаться невозможным. Но я имею в виду именно то, о чем я говорю. Вы можете вывести объект, не зная его тип, потому что вам (то есть клиентскому коду) вообще не нужно знать, как выводить объект. Информация о том, как корректно выводить объект, встраивается в сам объект. Все, что вам необходимо знать, это то, что объект реализует соответствующий интерфейс. Листинг 17.4 Printme.cpp ttinclude <iostream> using namespace std;
ГЛАВА 17. Полиморфизм: независимость объекта 397 class Printable { virtual void print_me(ostream ios) = 0; friend ostream &operator«(ostream,&os, Printable &pr) ; }; // Функция Operator<<: // Это действие только приводит к тому, что вызывается // виртуальная функция print—те. // ostream &operator<<(ostream &os, Printable &pr) { pr.print_me(os); ' return os; }; // КЛАССЫ, ЯВЛЯЮЩИЕСЯ ПОДКЛАССАМИ КЛАССА PRINTABLE //------------------------------------------ class P_int : public Printable { public: int n; P_int() {}; . P_int(int new_n) {n = new_n;} void print—me(ostream Sos); }; class P_dbl : public Printable { public: double val; P_dbl() {}; P_dbl(double new_val) {val = new_val;} void print—me(ostream &os) ; }; // РЕАЛИЗАЦИЯ PRINT—ME //------------------------------------------ void P_int::print—me(ostream &os) { os « n; } void P_dbl::print—me(ostream Sos) { os << " " << val; } // ГЛАВНАЯ ФУНКЦИЯ //------------------------------------------ int main() { Printable *p;
398 C++ без страха P_int numl(5); P_dbl num2(6.25); p = &numl; cout << "Here is a number: " << *p << endl; p = &num2; cout << "Here is another: " << *p << endl; return 0; } Как это работает Код этого примера состоит из трех основных частей: ✓ Абстрактный класс Printable. ✓ Подклассы P_int и P_dbl, которые содержат целочисленное значение и значение с плавающей точкой соответственно и которые сообщают, как вывести объект. ✓ Основная функция, которая тестирует все эти классы. Класс Printable является абстрактным классом, который вы также можете предста- вить как интерфейс, который определяет единственную функцию: виртуальную функ- цию print_me. class Printable { virtual void print_me(ostream &os) = 0; friend ostream &operator<<(ostream &os, Printable &pr) ; }; Идея класса проста: подклассы класса Printable реализуют функцию print_me для определения того, как они посылают данные в выходной поток (то есть в любой объект класса ostream). Класс Printable также объявляет глобальную дружественную функцию. Эта функция преобразует выражение, аналогичное следующему cout << an_object в'вызов собственной функции объекта print_me. an_object.print_me(cout) Так как функция print_me является виртуальной, то всегда вызывается корректная версия print_me, вне зависимости от того, как осуществляется доступ к объекту. Как показано в коде примера, вы можете использовать обычный указатель на объект: Printable *р = &an_object; // . . . cout << *р;
ГЛАВА 17. Полиморфизм: независимость объекта 399 Соответственно, объект, на который стоит указатель, будет выводиться в корректном формате для класса этого объекта, хотя точный класс может быть и неизвестен во время компиляции. При этом достигается истинный полиморфизм. Фактические реализации функции print_me немного делают в этом конкретном при- мере, но это не так важно. Целочисленные значения и значения с плавающей точкой вы- водятся легко. Я ввожу незначительное различие между ними - вывожу несколько лиш- них пробелов для реализации плавающей точки; это необходимо для обозначения того, что вызывается другая версия print_me. void P_int::print_me(ostream &os) { os « n; } void P_dbl::print_me(ostream &os) { os « " " << val; } Реализация функции print_me для других классов может быть значительно более ин- тересной. Ниже приведен пример возможной реализации функции print_me для клас- са Fraction: void Fraction::print_me(ostream &os) { os << get_num() << "/" << get_den(); } Упражнение Упражнение 17.2.2. Модифицируйте класс Fraction так, чтобы он был подклассом класса Printable и реализовывал функцию print_me. Затем проверьте результат при помощи следующего кода для вывода объекта Fraction. Fraction fractl(3, 4); // . . . Printable *p = Sfractl; cout << "The value is " << *p; Если все работает корректно, вам следует определить, что объект Fraction выводится в корректном формате. (Подсказка: для обеспечения возможности того, что объявление класса Printable не компилируется дважды, вам может понадобиться переместить объявление класса Printable в начало файла Fract.h, который вы затем подключите. Также помните, что необходимо использовать ключевое слово public при определении подклассов.) Альтернативно вы можете создать интерфейсный класс для класса Fraction, анало- гичный интерфейсным классам для целых чисел и чисел с плавающей точкой (Prjnt и Pr_dbl).
400 C++ без страха Последнее слово (или два) Когда в 80-х я впервые узнал об объектно-ориентированном программировании, я раз- работал идею, утверждающую, что объектно-ориентированное программирование за- ключается в создании отдельных, автономных сущностей, которые взаимодействуют посредством посылки сообщений друг другу. Объект1 Объект2 ОбъектЗ Я до сих пор думаю, что эта схема - неплохой способ овладеть основными концепция- ми. Отдельные, автономные сущности имеют тенденцию защищать свое содержимое, то есть они содержат инкапсуляцию - возможность сохранения их данных закрытыми от внешнего мира (private). Я не знаю наверняка, насколько хорошо наследование продемонстрировано на этой мо- дели, однако вы можете настроить ее. Если каждый отдельный объект аналогичен мик- ропроцессору или микросхеме (если рассуждать в терминах аппаратного обеспечения), то тогда в идеале вы должны иметь возможность вынуть микросхему, сделать в ней оп- ределенные изменения или улучшения и вставить ее обратно. Прежде всего, модель «независимых сущностей, посылающих друг другу сообщения» - это хороший способ иллюстрации сути полиморфизма и виртуальных функций. Вспомните, что я говорил немного раньше об интерфейсе Printable в описании при- мера 17.2. Здесь я перефразирую это в общих терминах: Вы можете использовать объект, не зная его точного типа или местонахож- ♦♦♦ дения кода функции, потому что информация о том, как осуществить функ- ции объекта, встроена в сам объект, а не в пользователя объекта. Этот принцип соответствует идее независимых объектов, взаимодействующих посред- ством посылки сообщений. Пользователь объекта не должен указывать объекту, как осу- ществлять его задачу. Что происходит внутри другого объекта, является тайной. Вы просто посылаете сообщение, зная, что объект ответит соответствующим образом. По сути, объекты - независимые модули кода или данных - освобождаются от рабской зависимости от внутренней структуры других объектов. Но результат - это не анархия. Системы объектно-ориентированного программирования требуют дисциплины в отношении контроля типов. Если вы хотите поддержать интер- фейс, вам необходимо реализовать все сервисы (то есть виртуальные функции) этого интерфейса и найти точное соответствие типов в списке аргументов. Интерфейсы - точ- ки, в которых различные классы и объекты могут взаимодействовать, - строго контро- лируются в объектно-ориентированном программировании.
ГЛАВА 17. Полиморфизм: независимость объекта 401 Но пока вы следите за соблюдением соответствующего интерфейса, вы можете реализо- вать функцию тем способом, который еще не был записан, когда писался клиентский код. Следующий код всегда будет работать корректно, его не следует модифицировать и компилировать повторно, даже если изменится специфичный тип an_object. Printable = &an_object; cout << *p; Или даже лучше, указатель р может указать на объект, предоставляемый другой частью системы. Операция вывода (cout << *р) будет работать, хотя программа и не знает вовсе, на что указывает р, кроме того факта, что объект, на который стоит указатель, реализует интерфейс Printable. Самое последнее слово Но что все это значит? Почему полиморфизм имеет такое значение? Связано ли это с тем, что он позволяет многократное использование кода? В общем-то, да. Но я не уве- рен, что только с этим. Объектно-ориентированное программирование действительно затрагивает систе- мы... графические системы, сетевые системы и другие аспекты технологии, которая с каждым днем все сильнее опутывает нас. Элементы графического пользователь- ского интерфейса или сети действительно действуют как независимые объекты, по- сылая сообщения друг другу. Традиционные методики программирования были разработаны для другого мира - в котором триумфом становилось уже само предоставление набора перфокарт и возмож- ность увидеть то, что у вашей программы есть успешное начало, середина и конец вме- сто затычек и ухищрений. В этом мире вы полагали, что вы были единственным игроком в городе и контролировали все. Такой мир был ограниченным, но более простым. Современные программы стали более сложными, но при этом они стали богаче. Успех Microsoft Windows, например, отчасти связан с богатством набора его компонентов. И модель этих компонентов не так уж и легко реализовать при помощи традиционных способов программирования. Такая система должна быть сконструирована таким обра- зом, чтобы обеспечить возможность работы существующего программного обеспечения со все более новыми компонентами. Эта потребность хорошо удовлетворяется применением полиморфизма и независимых объектов. В конечном счете, данный способ рассмотрения проблем ближе к реальности огромного мира. Одно из преимуществ объектного ориентирования в том, что «оно более точно моделирует реальный мир». Это напыщенное высказывание, но оно содержит значи- тельную долю правды. Мы действительно живем в сложном мире. Мы действительно взаимодействуем с людьми и предметами, сами того не подозревая. Нам действительно приходится признавать, что другие люди имеют какие-либо специализированные зна- ния, которых нет у нас. Иронически говоря, реальное значение выражения «объектно- ориентированное программирование» состоит в рассмотрении структуры данных как чего-то большего, чем просто объекты, то есть рассмотрении их как независимых агентов. Функции иногда описываются как «подпрограммы» (на самом деле, предыдущая версия Microsoft Basic использовала такую терминологию, пока она не была изменена на «под- 14-6248
402 C++ без страха процедуры»). Для сравнения, объект - это не просто независимая программа, а незави- симый микрокомпьютер, со своим внутренним состоянием (данными) и со своим собст- венным ответным кодом. Как и части системы, объекты могут посылать друг другу со- общения, не задумываясь ни о чем другом, кроме их соединений (то есть интерфейсов). Система объектно-ориентированного программирования и полиморфизм помогают ос- вободить объекты, делая их независимыми. И, возможно, если бы нам удалось освободить программные объекты, предоставив им независимость и свободу делать то, что каждый из них может делать наилучшим обра- зом, то это помогло бы и нам почувствовать себя более свободными. Резюме Вот основные моменты главы 17: ✓ Полиморфизм означает, что знание о том, как реализовывать сервис, встроено в сам объект, а не в клиентский код (то есть в код, который использует объект). В резуль- тате разрешение отдельной функции или операции может принимать множество раз- личных форм. Тип объекта может меняться, что приводит к новой реакции, даже если клиентский код не был изменен или заново откомпилирован. ✓ Полиморфизм возможен благодаря виртуальным функциям. ✓ Адрес виртуальной функции не определяется до начала рабочего цикла (это также называется динамическим связыванием.) Следовательно, точный класс объекта - ко- торый определяется во время рабочего цикла программы - определяет, какая из реа- лизаций виртуальной функции выполняется. ✓ Чтобы сделать функцию виртуальной, перед ее объявлением в классе вставьте клю- чевое слово virtual. Например: protected: virtual void normalize О; ✓ Если функция была объявлена виртуальной, то она является виртуальной во всех подклассах и во всех контекстах. Вам не нужно повторно использовать ключевое слово virtual для функции. ✓ Нельзя сделать конструктор или встроенную функцию виртуальными. ✓ Однако деструктор может быть виртуальным. ✓ При объявлении функции виртуальной существуют некоторые дополнительные за- траты по производительности и пространству памяти. Однако эти потери эффектив- ности ничтожно малы для современных мощных компьютеров. ✓ Согласно общему правилу, любую функцию-член, которая может быть подменена, следует объявлять виртуальной. ✓ Чистые виртуальные функции не имеют реализации (т.е., определения функции) в классе, в котором они объявляются. Виртуальная функция объявляется с использова- нием выражения «= 0». Например: virtual void print_me() = 0;
403 ГЛАВА 17. Полиморфизм: независимость объекта ✓ Класс, содержащий хотя бы одну чистую виртуальную функцию, является абстракт- ным. Такой класс не может быть использован для присвоения значений объектам, то есть не может быть использован для объявления объектов. Number а, Ь, с; // ОШИБКА! ✓ Однако абстрактные классы полезны как средства для создания стандартных интер- фейсов - списка сервисов, которые обеспечивает подкласс путем реализации всех виртуальных функций. ✓ В конечном счете, полиморфизм является способом освобождения объектов от раб- ской зависимости друг от друга (так как знание о том, как реализовывать какой-либо сервис, заложено в самом объекте). Именно эта особенность придает ориентирован- ности объекта свой особый характер и делает ее объектно-, а не чисто классово- ориентированной.
Приложение A. Операторы С++ Таблица 1 этого раздела представляет собой перечисление операторов языка C++; при- ведены их приоритет, ассоциативность, описание и синтаксис. Уровни приоритета - ко- торым я присвоил номера - не имеют особого значения, кроме того факта, что все опе- раторы на одном уровне имеют одинаковый приоритет. Ассоциация может быть слева направо (Л-П) или справа налево (П-Л). Это различие имеет значение в ситуациях, когда у двух операторов одинаковый уровень приоритета. Например, в выражении *р+ + операторы * и ++ имеют одинаковый уровень приоритета (уровень 2), поэтому порядок вычисления определяется по ассоциативности - в этом случае справа налево. Таким об- разом, выражение вычисляется так, если бы оно было записано в следующем виде: *(Р++) подразумевая, что инкрементируется сам указатель р (а не объект, на который он указывает). Обратите внимание на то, что к операторам 2-го уровня в этой таблице Отнесены унар- ные операторы, имеющие только один операнд. Большинство других операторов явля- ются бинарными (с двумя операндами). У некоторых операторов (таких как *) есть как унитарная, так и бинарная версии, которые в различных случаях выполняют различные операции. Элементы в столбце синтаксиса представляют несколько типов выражений: t/ ехрг - любое выражение t/ num - любое число (также элементы типа char) tZ int - целое число tZ ptr - указатель (то есть адресное выражение) tZ member - член класса ✓ lvalue - элемент, который может законно появляться в левой части присваивания; сюда относятся переменные (которые не объявлены с зарезервированным словом const), элементы массива, ссылки или полностью разыменованные указатели. Кон- станты сюда не включены. Табл. 1. Таблица операторов C++ Уро- вень Ассо- циац. Опера- тор Описание Синтаксис 1 Л-П 0 Вызов функции func(args) 1 Л-П [ ] Доступ к элементу массива array[int] 1 Л-П -> Доступ к члену класса ptr->member
Приложение А. Операторы C++ 405 Уро- вень Ассо- циац. Опера- тор Описание . Синтаксис 1 Л-П Доступ к члену класса obj ect.member 1 Л-П Определение области видимости class::name :name 2 п-л 1 Логическое отрицание ! expr 2 п-л — Поразрядное отрицание -expr 2 п-л ++ Инкремент ++num num++ 2 п-л -- Декремент --num num— 2 п-л - Изменение знака -num 2 П-Л * Извлечь содержимое (разымено- ванный указатель) . *ptr 2 П-Л & Извлечь адрес Sclvalue 2 П-Л sizeof Найти размер данных в байтах sizeof(expr) 2 п-л new Разместить данные объекта (ов) new type new type[int] new type(args) 2 п-л delete Удалить данные объекта (ов) delete ptr delete [] ptr 2 П-Л cast Изменить тип данных (type) expr 3 Л-П * Указатель на член (редко исполь- зуется) obj.*ptr_mem 3 Л-П _>* Указатель на член (редко исполь- . зуется) ptr->*ptr_mem 4 Л-П * Умножение num * num 4 Л-П / Деление num / num 4 Л-П % Модуль (остаток) int % int 5 Л-П + Прибавить num + num ptr + int int + ptr 5 Л-П - Отнять num. - num ptr - int ptr - ptr
406 C++ без страха Уро- вень Ассо- циац. Опера- тор Описание Синтаксис 6 Л-П « Сдвиг влево (поразрядно; также потоковый оператор) expr « int 6 Л-П >> Сдвиг вправо (поразрядно; также потоковый оператор) expr >> int 7 Л-П < Меньше num < num ptr < ptr 7 Л-П < = Меньше или равно num < = num ptr <= ptr 7 Л-П > Больше num > num ptr > ptr 7 Л-П >= Больше или равно num > = num ptr >= ptr 8 Л-П = = Проверка на равность num == num ptr == ptr 8 Л-П I = Проверка на неравность num I= num ptr !='ptr 9 Л-П & Поразрядное И int & int 10 Л-П Поразрядное исключающее ИЛИ int * int 11 Л-П 1 Поразрядное ИЛИ int | int 12 Л-П Логическое И expr && expr 13 Л-П 1 1 Логическое ИЛИ expr || expr 14 п-л Условный оператор: вычисляет exprl; если ненулевой результат, то вычисляет и возвращает ехрг2; в противном случае вычисляет и возвращает ехргЗ exprl ? expr2 : ехргЗ 15 п-л = Присвоить lvalue = expr 15 п-л + = Прибавить и присвоить lvalue += expr 15 П-Л - = Вычесть и присвоить lvalue -= expr 15 П-Л Умножить и присвоить lvalue *= expr 15 п-л /= Разделить и присвоить lvalue /= expr 15 п-л %= Разделить по модулю и присвоить lvalue %= int 15 п-л »= Сдвинуть вправо и присвоить lvalue >>= int 15 п-л <= Сдвинуть влево и присвоить lvalue <= int
Приложение А. Операторы C++407 Уро- вень Ассо- циац. Опера- тор Описание Синтаксис 15 П-Л &= Поразрядное И и присвоить lvalue &= int 15 п-л л = Поразрядное исключающее ИЛИ и присвоить lvalue А= int 15 П-Л 1 = Поразрядное ИЛИ и присвоить lvalue |= int 16 Л-П / Соединение (вычислить оба вы- ражения и возвратить ехрг2) exprl, expr2
Приложение Б. Встроенные типы данных Таблица 1 в данном приложении приводит перечисление типов данных, поддерживае- мых всеми версиями C++ с типичными диапазонами. Хотя в самой спецификации C++ эти диапазоны данных не перечислены (она описывает только отношения между ними), они практически являются универсальными для современных 32-битных систем. Если 64-битные системы станут стандартом, то эти диапазоны необходимо будет пересмотреть. Типы без знака не могут хранить отрицательные числа, но имеют более широкий диапа- зон положительных чисел. Вы можете использовать само зарезервированное слово un- signed; оно интерпретируется как unsigned int (целое число без знака). Тип int более своеобразен; он представляет «натуральный» размер целых чисел на лю- бом компьютере, в то время как double является «натуральным» типом с плавающей точкой. Выражения меньших типов преобразуются в один из этих форматов во время операций с целыми числами или числами с плавающей точкой. Меньшие типы (short или float) используется редко, за исключением случаев, когда необходимо записать большое количество записей данных в файл для хранения. Здесь я использую миллиард в американском смысле, имея в виду тысячу миллионов (1 000 000 000). Табл. 1. Типы данных, поддерживаемые C+ + Тип Описание Типичный диапазон char 1-байтное целое число (используется для хране- ния значений символов ASCII) от -128 до 127 unsigned char 1-байтное целое число без знака от 0 до 255 signed char 1-байтное целое число со знаком от -128 до 127 short 2-байтное целое число от -32768 до 32767 unsigned short 2-байтное целое число без знака от 0 до 65535 int 4-байтное целое число (может быть таким же, как и short на 16-битных системах) Приблизительно ±2 billion (2 х 109) unsigned int 4-байтное целое число без знака (может быть таким же, как и unsigned short) 0 до приблизитель- но 4 миллиардов long ' 4-байтное целое число Приблизительно ±2 миллиарда unsigned long 4-байтное целое число без знака 0 до приблизитель- но 4 миллиардов float Число с плавающей точкой с одинарной точностью ±3.4 х 1038 double Число с плавающей точкой с двойной точностью ±1.8 х 10308
Приложение Б. Ссылки на тематику DVD 409 Компиляторы, полностью совместимые с ANSI C++, также поддерживают следующие типы: Табл. 2. Типы данных, поддерживаемые компиляторами, совместимыми с ANSI C++ Тип Описание Типичный диапазон bool Булева величина true (1) или false (0) long double Экстра-широкий с двойной точностью не меньше чем double wchar_t Широкий символ такой же, как и unsigned int Замечания по поводу типов float и double: Величины в формате float могут точно хранить значение 0.0. Они также могут хра- нить очень маленькие значения близкие к нулю, такие как 1.175 х 1О'38' i/ Величины в формате double могут точно хранить значение 0.0. Они также могут хранить очень маленькие значения близкие к нулю, такие как 2.225074 х 1О'308. ✓ Литерные константы с десятичной запятой (такие как 5.0) автоматически хранятся в формате double. Однако константы могут быть переведены в формат float путем добавления суффикса «F». Например: «5.0F». ✓ Вы можете по желанию записывать значения с плавающей точкой в экспоненциаль- ном виде. Например: 3.5е4 // 3.5 умножить на 10 в 4-й степени // = 35000 2е-5 // 2.0 умножить на 10 в -5-й степени // = 0.00002
Приложение В. Краткий обзор синтаксиса С++ Это приложение, в котором кратко излагаются основные моменты синтаксиса языка C++, является сжатой справкой по языку. Тут отсутствует исчерпывающее описание всех аспектов C++, хотя в некоторых местах информация достаточно полная. Пожалуй- ста, используйте также Приложение А: «Операторы C++» и Приложение Б: «Встроенные типы данных». Литерные константы Литерные константы в C++ могут быть представлены в нескольких формах: integer_number floating_pt_number 'ASCII_symbol' "literal_stringn { constant, constant, ... } Последняя форма является рекурсивной, так что вы можете иметь множества внутри множеств, что имеет смысл при осуществлении инициализации многомерного массива. matrix[10][10] = {{1, 2, 3], {4, 5, 6} }; Этот пример инициализирует первые три элемента в каждой из первых двух строк мат- рицы. Другие элементы остаются неинициализированными. Синтаксис элементарного выражения Выражением в языке C++ является все, что имеет значение. (Но обратите внимание на исключения в конце этого раздела.) Это определение покрывает следующие аспекты: например, цифра 5 - это выражение, как и х + 5 - sqrt (2.0). Касательно выраже- ний, необходимо понимать, что меньшее выражение может быть частью большего вы- ражения. И эта иерархия может быть настолько сложна, насколько этого хочет программист. Еще одним важным моментом выражений в C++, который необходимо понять, является то, что некоторые выражения имеют один или более побочных эффектов. Например: х = 3 * --J Это выражение имеет два побочных эффекта, так как оно меняет значение как х, так и j. Это выражение выводит значение (а именно, значение, присвоенное х). Таким образом, оно может быть повторно использовано в большем выражении. Как и все выражения в C++, оно не становится утверждением, пока в конце не ставится точка с запятой: х = 3 * --j; Выражение на C++ может быть литерной константой, символическим именем (таким как переменная) или выражениями, соединенными операторами. Последний случай может включать в себя бинарный либо унарный оператор. Ниже приведено несколько выражений: literal_constant name
Приложение В. Краткий обзор синтаксиса C++411 expression op expression op expression expression op C++ также поддерживает тринарный оператор: условный оператор. На странице 406 можно найти более детальную информацию об этом операторе: expression ? expression : expression Синтаксис выражений является рекурсивным. При помощи компоновочных блоков ли- терных констант, символических имен и операторов вы можете создать выражение лю- бого размера. Например: b = (х + 24) / z * strlen("the pits") Хотя, как правило, выражения имеют значение, существуют также пустые выражения типа void, которые являются действительными, но не имеют значения. Наиболее распространенным примером является вызов функции с возвращаемым типом void. Синтаксис основного утверждения Утверждение в C++ грубо можно считать аналогом предложения естественного челове- ческого языка. Его также можно считать чем-то похожим на указание или команду, если не учитывать тот факт, что утверждение может вызывать несколько действий. Единст- венное, что вы можете с уверенностью сказать об утверждениях, это то, что для созда- ния тела определения функции необходима последовательность из' одного или более утверждений, и такое определение, в свою очередь, определяет задачу. Наиболее распространенная форма утверждения в C++ - это выражение, после которого стоит точка с запятой: expression; Также возможно наличие утверждения без выражения (пустое утверждение), являющее- ся холостой командой: Г Любое количество утверждений может быть сгруппировано вместе для образования со- ставного утверждения (иногда называемого «блоком утверждений»). Помните, что со- ставное утверждение имеет силу везде, где действительно одиночное утверждение. { statements } Каждая управляющая структура (описываемая в следующем разделе) также определяет действительное утверждение. Синтаксис управляющих структур включает в себя одно или два меньших утверждения, таким образом, управляющие структуры могут быть вложены на любом уровне. Управляющие структуры Условный оператор if имеет две формы. Первая форма имеет вид: if (condition_expr) statement
412 C++ без страха В этом виде condition_ехрг является выражением. Программа оценивает это выра- жение, и если возвращается любое отличное от нуля значение, то условие считается «ис- тинным» и утверждение выполняется. (Булевы выражения, как а < Ь, имеют значении 1, если условие истинное, и 0, если оно ложное, то есть они ведут себя, как и ожидалось. Обратите внимание, что а == Ь, а не а = Ь является проверкой на равенство). Условный оператор if может иметь и else-оператор. if (condition_expr} statement else statement Стандартный оператор цикла while имеет следующий синтаксис. Если condi- tion_expr имеет отличное от нуля значение, то утверждение выполняется, как в ус- ловном операторе if. Но процесс повторяется после каждого выполнения утверждения. Цикл прерывается, если выражение condition_expr не становится истинным (отлич- ным от нуля), когда оно проверяется в верхней части цикла. while (condition_expr) statement Вариация с обусловленным продолжением do-while аналогична, но она гарантирует, что вложенное утверждение выполняется как минимум один раз. do statement while (condition_expr) ; Оператор цикла for обеспечивает сжатый способ использования трех утверждений для контроля выполнения цикла. for ( init_expr; condition_expr; incr_expr) statement Эта управляющая структура почти такая же, как и следующий while-цикл. Любое из трех утверждений можно опустить, а результатом в этом случае будет холостая команда или (в случае с condition_expr) не ограниченный условиями результат «истинно- сти» (то есть цикл всегда выполняется). init_expr; while (condition_expr) { statement incr_expr; Различие между этими вариантами использования for и while в том, что внутри ут- верждения зарезервированное слово continue вызывает другой эффект (смотрите сле- дующий раздел). Оператор цикла for также позволяет использование init_exp для объ- явления переменной «на лету», которая затем становится локальной для оператора цикла for. Например: for (int i = 0; i < ARRAY-LENGTH; i++) a[i] = i;
Приложение В. Краткий обзор синтаксиса C++ 413 И, наконец, оператор switch-case является альтернативой использования if-else. Оператор switch имеет следующий синтаксис: switch (target__expr) { statements } Внутри утверждений вы можете разместить любое количество утверждений, помечен- ных зарезервированным словом case. Утверждение case имеет следующий синтаксис: case constantz statement Это следует из рекурсивной природы синтаксиса,'в котором единичное утверждение может иметь несколько меток: ' case 1 а1 case 1 е 1 case 1 i 1 case 1 о 1 case 'u1 cout < "is a vowel"; Вы можете также включить опционально метку default. default: statement Действием switch является оценка выражения target_expr. Затем контроль переда- ется оператору case, если таковой имеется, константное значение которого совпадает со значением target_expr. Если эти значения не совпадают и есть метка default, то контроль передается туда. Если нет совпадающих значений и нет метки default, то кон- троль передается в конец утверждения switch (первое утверждение после конца блока). Например, следующий пример выводит «is a vowel», «may be a vowel» или «is not a vowel» в зависимости от значения с. switch (с) { case 1 а' : case 'е': case 'i1: case 'o': case 'u': cout < "is a vowel"; break; case 1 у' : cout < "may be a vowel"; break; default: cout < "is not a vowel"; } Если контроль передается любому утверждению внутри блока, то выполнение проходит успешно, переходя к следующему случаю, пока не встретится оператор break.
414 C++ без страха Специальные управляющие операторы Несколько специальных операторов могут влиять на действия внутри управляющей структуры. Во-первых, оператор break приводит к остановке выполнения ближайших утверждений while, for или switch. Во всех таких случаях выполнение передается первому утверждению после окончания управляющей структуры (или, если управляю- щая структура является последним утверждением в функции, функция возвращается). break; Утверждение continue является действительным внутри утверждений while или for. Эффектом является продвижение выполнения в верхнюю часть следующего цикла; это означает, что выполнение текущего утверждения (или блока утверждений) приоста- навливается и программа опять переходит на оценивание condition_expr. continue; Внутри for-цикла continue приводит к оценке incr_expr (выражение приращения) перед переходом в верхнюю часть следующего цикла, что является основным отличием между циклами for и while. Утверждение goto вызывает безусловный переход управления к определенному утверждению. goto label; Для того чтобы метка стала действительной, она должна ссылаться на утверждение внутри текущей функции, помеченной следующим образом: label: statement Объявления данных Объявление данных - это утверждение, которое создает один или более элементов дан- ных (или объектов). Объявление данных может объявить один элемент. type var_decl; Или оно может объявить один или более элементов, разделенных запятыми. type var_decl, var_decl, ... ; Каждое объявление переменной (var_decl) может быть простым, как имя переменной: variable Или оно может включать опциональное инициализирующее выражение. В отличие от классического С, C++ не требует константы в качестве инициализирующего выражения. variable = init_expr В объявлении имя переменной может также быть уточнено операторами, такими как [ ], *, () и &; они создают массивы, указатели, указатели на функции и ссылки соответст- венно. Для определения того, какой элемент был объявлен, задайте себе вопрос о том, что представляет собой элемент, если он появляется в выполняемом коде. Например, объявление данных int **ptr;
Приложение В. Краткий обзор синтаксиса C++415 означает, что **ptr, появляющееся в коде, является элементом типа int; a ptr сам по себе является указателем на указатель на целое число. Объявления функций Перед тем как функция может быть вызвана другой функцией, она должна быть объяв- лена. Сначала ей может быть предоставлено объявление простого типа (прототип). Пол- ное объявление, содержащее определение, может быть позже размещено в любом месте исходного файла или определено в другом модуле. Прототип функции имеет следующий синтаксис: тип имя_функции(список_аргументов); Тип указывает на тип значения, возвращаемого функцией. Функция может опционально иметь специальный тип void, указывающий на то, что она не возвращает значение. Список аргументов содержит одно или более объявлений аргументов, разделенных запя- тыми. Список аргументов можно оставить пустым, показывая, что функция не имеет аргументов. (В отличие от С, C++ не позволяет использование пустого списка аргумен- тов, чтобы указать, что список не определен и будет заполнен позже.) Каждый элемент списка аргументов имеет следующую форму. Синтаксис объявления имеет некоторые другие детали, упоминаемые в последнем разделе (с опциональным инициализирующим выражением, указывающим значение по умолчанию), исключая то, что каждый type и var_decl должны быть идентичными. type var_decl Таким образом, более сложный синтаксис прототипа функции следующий: type function_name(type var_decl, type var_decl, ...); Синтаксис полного объявления функции (включая определение функции) такой же, за исключением того, что он включает одно или более утверждений. type function_name{argument_list') { statements } Определение функции не заканчивается точкой с запятой (;) после последней закры- вающейся скобки. Также обратите внимание на то, что имена аргументов (но не типы) могут быть опущены в прототипе, но не в определении функции. Объявления класса После объявления класса его имя может быть использовано непосредственно как имя типа, также как встроенный тип данных, такой как int, double, float-и т.д. Основ- ной синтаксис объявления класса следующий: class class_name { declarations
416 C++ без страха В отличие от определения функции, объявление класса всегда заканчивается точкой с запятой (;) после закрывающейся скобки. Объявления могут включать любое количество данных и/или объявлений функции. В рамках объявления могут встречаться зарезервированные слова public, protected и private, оканчивающиеся двоеточием (:) для указания уровня доступа объявлений, которое следует за ними. Например, в следующем объявлении класса элементы данных а и Ь являются закрытыми, а элемент данных с, как и функция fl, - открытым. class my_class { private: int a, b; public: int c; void fl(int a); }; Внутри объявления класса конструкторы и деструкторы имеют следующее специальное объявление. У вас может быть любое количество конструкторов, отличающихся спи- ском аргументов. У вас может быть максимум один деструктор. class_name (argument_list) // Конструктор ~class_name() // Деструктор Синтаксис объявления подкласса включает в себя имя основного класса. Хотя использо- вание зарезервированного слова public в данном случае и не требуется синтаксисом, но настоятельно рекомендуется (иначе унаследованные члены становятся закрытыми). class class__name : public base_class { declarations }; Большинство версий C++ также поддерживают множественно наследование, в котором вы перечисляете базовые классы, разделяя их запятыми. Например, чтобы унаследовать от двух классов: class class__name : public base_class, public base_class { declarations I Синтаксис, приведенный здесь, применяется к зарезервированным словам j struct и union, а также к class. Поэтому для объявления класса struct необходимо заменить зарезервированное слово class на слово struct. Класс struct идентичен классу, определенному словом class, кроме случаев, когда члены являются открытыми по умолчанию, а не закрытыми. Члены класса un- ion открытые по умолчанию, но, кроме того, объединения имеют другие | функции, которым не уделяется внимание в этой книге. Основная идея объеди- нения в том, что его члены имеют один и тот же начальный адрес в памяти, что обычно означает, что только один член класса union используется в оп- ределенный момент времени.
Приложение Г. Коды ASCII Эта таблица включает первые 127 стандартных кодов ASCII. Она не включает расши- ренные коды ASCII (более 127) или набор широких (16-битных) символов. В этой табли- це двух- или трехзначное число представляет численное значение, а справа от него ото- бражается соответствующий ему символ. Некоторые из этих символов являются специальными (непечатающимися). Они включают: ✓ NUL - нулевое значение ✓ АСК - сигнал подтверждения приёма (используется в сетевых коммуникациях) ✓ BEL - звонок ✓ BS - возврат на один символ ✓ LF -переход на новую строку ✓ FF - подача страницы (новая страница) ✓ CR - возврат каретки ✓ NAK - нет приема i/ DEL - удаление Табл. 1. Коды ASCII 00 NUL 26 52 4 78 N 104 h 01 27 53 5 79 0 105 i 02 28 FS 54 6 80 P 106 j 03 29 GS 55 7 81 Q 107 к 04 30 RS 56 8 82 R 108 1 05 31 US 57 9 83 S 109 m 06 АСК 32 пробел 58 : 84 T HOn 07 BEL 33 ! 59 ; 85 U 111 0 08 BS 34 " 60 < 86 V 112 p 09 35# 61 = 87 W 113 q 10 LF 36$ 62 > 88 X 114 r И 37 % , 63 ? 89 Y 115 s 12 FF 38 & 64 @ 90 Z 116 t 13 CR 39 ' 65 A 91 [ 117 u 14 40 ( 66 В 92 \ 118 v
418 C++ без страха 15 41) 67 С 93] 119 w 16 42* 68 D 94 л 120 х 17 43 + 69 Е 95 _ 121 у 18 44, 70 F 96' 122 z 19 45- 71 G 97 а 123 { 20 46. 72 Н 98 b 124| 21 NAK 47/ 73 1 99 с 125 } 22 SYN 48 0 74 J 100 d 126- 23 49 1 75 К 101 е 127 DEL 24 50 2 76 L 102 f 25 51 3 77 М . 103 g
Приложение Д. Основные библиотечные функции Библиотека C++ слишком велика, чтобы ее можно было описать на нескольких страни- цах. Однако наиболее часто используемые функции можно поделить всего на несколько категорий: строковые функции, функции преобразования данных, односимвольные функции, математические функции и функции рандомизации (так как они используются в этой книге). В этом приложении представлено краткое описание этих категорий. Строковые функции Для использования этих функций подключайте файл string.h. Эти функции используют традиционные для языка С строки char*, но не новый автоматизированный строковый класс C++ string. Последний описан в конце главы 7. Таблица 1, приведенная ниже, содержит s, si и s2 - строки char*, заканчивающиеся символом конца строки (фактически каждый из этих аргументов содержит адрес стро- ки); п - целое число и ch - один символ. Каждая из этих функций возвращает первый строковый аргумент (который, как я отмечал, является адресом), если не указано иное. Табл. 1. Основные библиотечные строковые функции Функция Действие strcat(sl, s2) Конкатенирует содержимое s2 в конец si. strchr(s, ch) Возвращает указатель на первый ch в строке; возвращает NULL, если не находит символ ch. strcmp(sl, s2) Выполняет сравнение содержимого si и s2, возвращает отрицательное целое число, 0 или положительное целое число в зависимости от того, появляется строка si перед s2 в алфавитном порядке, идентична s2 или появляется после si в алфавитном порядке. strcpy(sl, s2) Копирует содержимое s2 в si, замещая существующее содержимое. strcspn(sl, s2) Ищет в si символы, совпадающие с символами s2; воз- вращает индекс первого совпадающего символа si; воз- вращает длину' si, если совпадений не найдено. strlen(s) Возвращает длину s (не включая нулевой байт). strncat(sl, s2, n) Выполняет то же, что и strcat, но копирует максимум п .символов. strncmp(sl, s2, n) Выполняет то же, что и strcmp, но сравнивает максимум п символов.
420 C++ без страха Функция Действие strncpy(sl, s2, n) Выполняет то же, что и strcpy, но копирует максимум п символов. strpbrk(sl, s2) / Ищет в si какие-либо символы из s2; возвращает указа- тель на первый совпадающий символ si; возвращает NULL, если совпадений не найдено. strrchrfs, ch) Выполняет то же, что и strpbrk, но ищет с конца si. strspn(sl, s2) Ищет в. si первый символ, не совпадающий ни.с одним из символов s2; возвращает индекс этого символа; возвра- щает длину si, если ни один символ не найден. strstr(sl, s2) Ищет в si первое совпадение с подстрокой s2; возвраща- ет указатель на подстроку, найденную в si; возвращает NULL, если такая строка не найдена. strtok(sl, s2) Возвращает указатель на первую метку-(подстроку) в si, использующую разделители, определенные в s2. После- дующие вызовы этой функции с NULL в качестве первого аргумента находят следующую метку в текущей строке, то есть ранее установленное значение si. Определение ненулевого значения для si возобновляет процесс разде- ления с новой строки. Функции преобразования данных Для использования этих функций подключайте файл stdlib.h. Табл. 2. Основные библиотечные функции преобразования данных Функция Действие atof (s) Считывает текстовую строку s типа char* как численную строку с плавающей точкой и возвращает ее эквивалент в формате double. Функция пропускает межстрочные пробелы и прекращает считывание после первого символа, который не может быть частью формата с пла- вающей запятой (например «1.5» или «2е12»), atoi(s) Считывает текстовую строку s типа char* как численную строку ти- па int и возвращает ее целочисленный эквивалент. Функция пропус- кает межстрочные пробелы и прекращает считывание после первого символа, который не может быть частью целочисленного представле- ния (такое как «-33»).
Приложение Д. Основные библиотечные функции 421 Односимвольные функции Для использования какой-либо из функций данного раздела подключите файл ctype.h. Каждая из функций данной категории тестирует односимвольное значение (например 'X' или my_string[5 ]) и возвращает результат в булевой форме: true (1) или false (0). Файл ctype.h также включает объявления для следующих двух функций, каждая из ко- торых возвращает целочисленное значение, содержащее символ ASCII. Табл. 3. Односимвольные функции Функция Действие Isalnum(ch) Является ли символ буквенно-цифровым (буква или цифра)? Isalpha(ch) Является ли символ буквой? Iscntrl(ch) Является ли символ контрольным? (К контрольным символам относятся символ возврата, новой строки, новой страницы, табу- ляции и другие, например непечатаемые знаки, которые выпол- няют действия). Isdigit(ch) Входит ли символ в диапазон от 0 до 9? Isgraph(ch) Является ли символ видимым? (К ним относятся печатаемые пробелы.) Islower(ch) Является ли символ прописным? isprint(ch) Является ли символ печатаемым? (К ним относятся символы пробела.) ispunct(ch) Является ли символ знаком пунктуации? isspace(ch) Является ли символ пробелом? (Пробелом, кроме простого сим- вола пробела, считается символ табуляции, новой строки и новой страницы.) isupper(ch) Является ли символ заглавной буквой? isxdigit(ch) Является ли символ шестнадцатеричным числом? К ним относятся числа в диапазоне от 0 до 9, а также от А до Е и от а до е. Математические функции Табл. 4. Односимвольные функции Функция Действие ' tolower(ch) Возвращает прописной символ, если ch является строчным; в про- тивном случае возвращает ch в таком же виде. toupper(ch) Возвращает строчный символ, если ch является прописным; в про- тивном случае возвращает ch в таком же виде.
422 C++ без страха Для использования этих функций подключите файл math.h. Если не отмечено иначе, каждая из этих функций принимает аргумент типа double и возвращает результат типа double. Все эти функции возвращают результат; ни одна из них не изменяет аргумент. Примечание: в этом списке я опустил некоторые непонятные и избыточные математиче- ские функции. Табл. 5. Математические функции. Функция Действие abs(п) Возвращает абсолютное значение целого числа п. (Результат типа int.) acos(х) Арккосинус х. asin(х) Арксинус х. atan(х) Арктангенс х. ceil(х) Округляет х до ближайшего большего целого значения. (Но результат возвращает в формате double). cos(х) Косинус X. cosh(х) Гиперболический косинус х. exp(х) Возводит математическую константу е в степень х. fabs(х) Возвращает абсолютное значение х. floor(х) Округляет х до ближайшего меньшего целого числа. (Но возвращает результат double). log(х) Натуральный логарифм (основа е) х. loglO(х) Логарифм (основа 10) х. pow(x, у) Возводит х в степень у. sin(х) Синус X. sinh(х) Гиперболический синус х. sqrt(^) Квадратный корень х. tan(х) Тангенс х. tanh(х) Гиперболический тангенс х.
Приложение Д. Основные библиотечные функции 423 Рандомизация Чтобы использовать все эти функции, необходимо подключить файлы stdlib.h и time.h. Табл. б. Функции рандомизации Функция Действие rand() Возвращает следующее число (типа int) в последовательности слу- чайных чисел. Эта последовательность должна быть задана с помощью вызова srand. Возвращаемое число должно быть в диа- пазоне от 0 до RAND_MAX (определяемого в stdlib.h). srand(seed) Принимает начальное число - типа int без знака - в качестве пер- вого числа последовательности случайных чисел, используемой для вызовов rand. time(NULL) Возвращает системное время. Вызов этой функции является хоро- шим способом для получения начального числа для последующей передачи в srand. (Примечание: функция времени также имеет другие применения; не все из них используют аргумент NULL.)
Приложение Е. Глоссарий В этом разделе приведен краткий обзор терминологии, которая используется в этой книге. Abstract class (Абстрактный класс) - класс, который нельзя использовать для создания объектов, но который может быть полезен как общий шаблон (то есть интерфейс) для других классов. В абстрактном классе есть хотя бы одна чисто виртуальная функция. Address (Адрес) - нумерованная область в памяти, в которой хранится элемент данных или код программы. Эту' область называют «физическим местом» в памяти переменной или функции, однако не рекомендуется предоставлять компьютеру возможность поиска этого места. Обычно адреса отображаются в шестнадцатеричной системе (основа разря- да- 16) и не несут особого смысла, если не рассматриваются в контексте программы. Если бы все в мире жили на одной улице, можно было бы обозначить ваш адрес просто числом (то есть «12300» вместо «12300 Главная улица» и т.д.). В этом и заключена суть программного адреса: просто номер, который определяет, где именно находится пере- менная или функция. ANSI - Национальный институт стандартизации США. ANSI C++ - это спецификация, которая необходима компиляторам C++ для поддержки полных, обновленных и кор- ректных версий языка C++. ANSI C++ включает определенное количество функциональ- ных возможностей, таких как обработка исключительных ситуаций, шаблоны и булевы типы, которых не было в ранних версиях C++. Argument (Аргумент) - значение, которое передается функции. Array (Массив) - структура данных, состоящая из нескольких элементов, которые имеют одинаковый тип. Доступ к элементам осуществляется через индексный номер. Напри- мер, объявление массива int агг [ 5 ] означает, что массив состоит из пяти значений (целочисленных, int), доступ к которым осуществляется через индексные номера 0, 1, 2, 3 и 4. Индексные номера в языках, базирующихся на С, пробегают значения от 0 до п - 1. Неудивительно, что между массивами и адресами существует тесная связь, так как и те и другие используют числа для размещения данных. Эта связь особенно сильна в С и C++. Associativity (Ассоциативность) - правило (или слева направо, или справа налево), ко- торое определяет, как вычислять выражение, состоящее из двух или более операторов с одним и тем же уровнем приоритета. Например, в выражении *р++ операторы ассоции- руются слева направо, поэтому выражение эквивалентно следующему *(р++)• Base class (Базовый класс) - класс, от которого вы наследуете, объявляя подкласс. Bit (Бит) - одиночное число, которое хранится в ЦПУ (Центральном процессорном уст- ройстве) или в памяти. Каждый бит имеет значение 1 или 0. Boolean (Булево выражение) - истинное/ложное значение или истинная/ложная опера- ция. В полной версии ANSI Q++ поддерживается специальный тип bool. Если этот тип не доступен, вы можете использовать тип int для хранения значений истина/ложь. C++ описывает истину и ложь значениями 1 и 0 соответственно, но любое ненулевое значе- ние интерпретируется как истина.
Приложение Е. Глоссарий 425 Byte (Байт) - группа из восьми бит. Память в компьютере организована байтами, поэто- му каждый байт имеет уникальный адрес. Cast (Приведение) - операция, которая изменяет тип данных выражения. Например, ес- ли вы приводите тип данных целого числа 10 в тип double, то число преобразуется в формат с плавающей точкой. (Обратите внимание на то, что формат с плавающей точкой включает в себя иное бинарное представление, чем целочисленное, даже если у числа нет дробной части.) Вы можете выполнить приведение, используя приведение данных C++, такое как stapi.c_ca.st< тип> (поражение), или используя приведение старомодного стиля языка С: (тип) выражение. Этот синтаксис еще работает на современных компиляторах C++, но использовать его не рекомендуется. Class (Класс) - определенный пользователем тип данных (или тип данных, определен- ный в библиотеке). В C++ класс можно объявлять, используя ключевые слова class, struct или union. В традиционном программировании такой тип данных может содер- жать любое количество полей данных (называемых объектами данных в C++); объектно- ориентированное программирование добавляет возможность объявлять также функции- члены. Функции-члены, в свою очередь, определяют операции над данными такого типа. Code (Код) - еще один синоним слова «программа». Слово «код» подразумевает скорее программистский взгляд на программу, а не взгляд пользователя, который видит только конечный результат рабочего цикла программы. Когда программисты говорят о «коде», они обычно подразумевают исходный код, то есть набор выражений C++, составляющих программу. Compiler (Компилятор) - языковой транслятор, который считывает вашу C++ программу и выдает машинный код и, в конечном счете, исполняемый файл, который фактически может быть запущен на компьютере. Constant (Константа) - значение, которое нельзя изменить. Constructor (Конструктор) - функция-член, которая вызывается при создании объекта. Конструктор «объясняет», как инициализировать объект. Конструктор носит такое же имя, как и класс, в котором он объявлен, и не имеет возвращаемого значения. (Но неявно конструктор возвращает экземпляр класса). Control structure (Управляющая конструкция) - способ управления последовательно- стью действий в программе. Управляющие структуры могут принимать решения (хотя и ограниченные), повторять операции или передавать выполнение новому месту нахожде- ния программы. Выражения if, while, for и switch являются примерами управ- ляющих структур. Copy constructor (Конструктор копирования) - специальный вид конструктора, в кото- ром происходит инициализация объекта от другого объекта того же типа. Компилятор предоставляет конструктор копирования для каждого класса, если вы не написали свой конструктор.
426 C++ без страха Data member (Данное-член) - поле данных класса. Если данное-член не объявлен стати- ческим static (об этом ключевом слове не рассказывалось в этой книге), то каждый объ- ект получает свою собственную копию объекта данных. Declaration (Объявление) - выражение, предоставляющее информацию о типе перемен- ной, класса или функции. Объявление функции может быть или прототипом (который содержит только информацию о типе), или описанием (в котором описывается, что именно делает функция). В C++ каждая переменная и функция, кроме функции main, должна быть объявлена до ее использования. Default constructor (Конструктор по умолчанию) - специальный конструктор, который не имеет аргументов; этот конструктор указывает, как инициализировать объект, если он объявлен без аргументов. Компилятор предоставляет конструктор по умолчанию, но только если вы сами не написали какой-либо конструктор. Если вы сами написали кон- структор, компилятор будет игнорировать автоматический конструктор по умолчанию, в результате чего нельзя будет создавать объекты без списка аргументов. Вы можете из- бежать этого неприятного сюрприза, написав свой собственный конструктор по умолчанию. Definition, function (Описание, функция) - последовательность выражений, описываю- щих работу функции. Во время выполнения функции управление программой передает- ся этим выражениям. См. также функция. Destructor (Деструктор) - не такой смертоносный, как звучит его название. Деструк- тор - это функция-член, выполняющая очистку и завершение действий при разрушении объекта. Объявление деструктора следующее ~class_name (). Не всем классам нужен деструктор, но он необходим в тех случаях, когда объекты этого класса занимают сис- темные ресурсы (такие как память), которые необходимо освободить при разрушении объекта. Directive (Директива) - общая команда компилятору. Директива отличается от выраже- ния тем, что она не объявляет переменных и не создает выполняемый код. Например, директива #include нужна для того, чтобы компилятор подключил в проект содержи- мое другого исходного файла. Encapsulation (Инкапсуляция) - возможность скрыть или защитить содержимое. Одним из преимуществ объектно-ориентированного программирования является то, что оно позволяет вам определять модули (классы и объекты), которые являются «черными ящиками» для пользователей и внутреннее содержимое которых нельзя изменить. Сде- лать это можно, объявив члены закрытыми (private). Exception (Исключение) - необычное событие, возникшее в процессе выполнения про- граммы, обычно вследствие ошибки, которое требует немедленной обработки програм- мой. Примером такого события является арифметическое переполнение. Если програм- ма не может справиться с исключением, то она сразу же по умолчанию прекращает ра- боту. При обработке исключений в C++ (не функция классического C++, а функция, ко- торая сейчас считается стандартной функцией современных компиляторов) используют- ся ключевые слова try, catch и throw для централизации обработки ошибок работы про- граммы. Expression (Выражение) - один из фундаментальных составляющих блоков программы на C++. В общем, выражением является все, что имеет значение. Однако выражения ти- па void не имеют значений. Выражения могут быть совсем простыми, как число или
Приложение Е. Глоссарий 427 переменная, или, при помощи использования операторов, вы можете сгруппировать меньшие выражения в большие. Когда вы заканчиваете выражение точкой с запятой (;), оно становится утверждением (statement). Более детальную информацию о синтаксисе выражения можно найти в Приложении С. Floating point (Плавающая точка) - формат данных, который может хранить как дроб- ные части чисел, так и сами числа в более широком диапазоне, чем целочисленные фор- маты (int, char, short, long). В компьютере числа с плавающей точкой хранятся в двоичном формате (основа 2) и отображаются в десятичном виде. Следовательно, воз- можны ошибки округления. Многие дроби целых чисел (например 1/3) не могут точно хранится в формате с плавающей точкой, хотя и могут быть аппроксимированы с опре- деленной точностью. Основной тип с плавающей точкой в C++ - это double, который соответствует «двой- ной точности». Function (Функция) - группа утверждений, выполняющих какую-либо задачу. Количе- ство действий, которые может выполнять функция, не ограничено, но в идеале все дей- ствия, осуществляемые определенной функцией, должны быть направлены на решение одной какой-либо задачи. Если функция определена и объявлена, вы можете выполнить ее в любом месте про- граммы. Это называется вызовом функции. Вы должны только один раз определить функцию или не делать этого вовсе, если функция определена в библиотеке или в дру- гом модуле, но вы можете многократно ее выполнять, просто вызывая ее. Поэтому ис- пользование функций - это наиболее фундаментальная технология написания кода, при- годного для многократного использования. GCF (greatest common factor) (НОД (Наибольший общий делитель)) - наибольшее це- лое число, на которое делятся без остатка два числа. Например, наибольшим общим де- лителем чисел 12 и 18 является 6, а НОД чисел 300 и 400 - это 100. Global variable (Глобальная переменная) - переменная, используемая более чем одной функцией в модуле, т.е. функциями одного исходного файла. Вы можете объявить гло- бальную переменную в C++, объявив ее вне любой функции. (В многомодульной про- грамме вы можете даже использовать глобальную переменную во всех функциях про- граммы, используя объявления extern.) Глобальная переменная видна с того места, где она объявлена, до конца исходного файла. Header file (Заголовочный файл) - это файл объявлений и (опционально) директив; он должен быть включен (при помощи использования директивы ttinclude) в остальные файлы. Это прием для сохранения времени; он помогает программистам избежать вне- сения отдельных объявлений непосредственно в каждый исходный файл проекта. Пом- ните, что все переменные, классы и функции должны быть объявлены в C++ перед их использованием; поэтому заголовочный файл очень полезен. IDE (integrated development environment) (Интегрированная среда разработки) - тек- стовый редактор, из которого вы можете запускать компилятор. Он помогает вам писать, компилировать и запускать программу в одном и том же приложении. Implementation (Реализация) - это слово имеет множество различных значений в разных контекстах; но в C++ это слово обычно относится к определению функции для вирту- альной функции. Иногда я также использую слово «реализованный» для описания того, как C++ компилятор генерирует машинный код для осуществления действий программы.
428 C++ без страха Indirection (Непрямой (доступ)) - доступ к данным, через указатель. Например, если ука- затель ptr указывает на переменную amount, то утверждение *ptr = 10; изменяет значе- ние этой переменной путем непрямого доступа. Inheritance (Наследование) - возможность предоставлять классу атрибуты другого, ра- нее объявленного класса. Это осуществляется посредством создания подклассов. Новый класс автоматически содержит все члены, объявленные в базовом классе. (Исключение: класс не наследует конструкторы). Inline function (Встраиваемая функция) - это функция, утверждения которой вставляют- ся в код функции, которая ее вызывает. В нормальном вызове функции управление про- граммы перескакивает на новое место и затем возвращается, когда заканчивается вы- полнение. Этого не происходит со встраиваемой функцией. Когда вы объявляете функцию-член и размещаете ее определение внутри объявления класса, то она автоматически становится встраиваемой. Instance/lnstantiation (Реализовать/Реализация) - слово реализация (или экземпляр) яв- ляется почти синонимом слова объект. Любое отдельное значение или переменная яв- ляются реализацией (т.е. экземпляром) какого-либо типа. Число 5 является экземпляром int, а 3,1415927 - экземпляром double. Каждый объект является реализацией (экземп- ляром) какого-либо класса. Когда класс допускает реализацию, то он используется для создания объекта. Integer (Целое число) - это число без дробной части. Сюда относятся числа 1, 2, 3 и так далее, а также 0 и отрицательные числа -1, -2, -3 и так далее. Interface (Интерфейс) - это слово имеет много различных значений в разных контекстах. В этой книге я использовал его для обозначения общего набора сервисов, реализуемых различными подклассами определенным способом. В C++ вы используете абстрактные классы для определения интерфейса. Literal constant (Литерная константа) - численная или строковая константа (как 5 или "Mary had a little lamb"), в отличие от символического имени, которое может стать кон- стантой (при помощи использования ключевого слова const). Local variable (Локальная переменная) - переменная, которая является закрытой (private) для определенной функции или блока утверждения. Ее преимущество состоит в том (например), что каждая функция может иметь собственную переменную х, но изме- нения х внутри одной функции не будет путаться со значением х другой функции. Эта особенность - вариант права функции на закрытость - является ключевым моментом современных языков программирования. Lvalue (L-значение) - это «левое значение», т. е. значение, которое может появиться в левой части присвоения. Переменные являются 1-значениями, а константы не яв- ляются. Кроме того, 1-значениями являются члены массивов и полностью разымено- ванные указатели. Member (Член) - элемент, объявленный в классе (определенный пользователем тип). Члены данных эквивалентны полям записи или структуры. Функции-члены определяют операции, встроенные в класс.
Приложение Е. Глоссарий 429 Member function (Функция-член) - функция, объявленная внутри класса. Функции- члены (иногда называемые «методами» в других языках) - это важный аспект объектно- ориентированного программирования. Они определяют операции над объектами класса. Nesting (Вложенность) - размещение одной управляющей структуры внутри,другой. Newline (Новая строка) - сигнал монитору, обозначающий «переход на новую строку текста». Object (Объект) - модуль тесно взаимосвязанных данных, который может иметь опреде- ленное поведение (в форме функции-члена), как и данные. Данная концепция происхо- дит от старой концепции «запись данных» - например, вся информация о работниках компании может содержаться в одной записи. Концепция объекта похожа, но является более гибкой. Записывая функции-члены, вы можете предоставить объектам возмож- ность поддержки операций; а также в связи с работой полиморфизма в C++ предоставить объекту информацию о способе выполнения операции, которая встроена в сам объект, а не в код, который ее использует. Тип объекта - это его класс, а для данного класса вы можете объявить любое количество объектов. Проще говоря, объект - это интеллекту- альная структура данных. Object code (Объектный код) - это термин, не имеющий никакого отношения к объек- там или объектно-ориентированному программированию. Объектный код - это машин- ный код, генерируемый компилятором. Затем этот код привязывается к библиотечному коду для создания исполняемого файла (файл с расширением «. ехе» в Windows и MS- DOS), который можно в действительности запустить. Object-oriented programming (Объектно-ориентированное программирование) - подход к разработке и написанию программ, который придает объектам данных более цен- тральную роль, позволяя вам определить объекты данных по тем действиям, которые они выполняют, а также по их содержимому. Объектно-ориентированный подход начи- нается с объявления классов, являющихся гибкими определенными пользователем ти- пами. Затем класс может быть использован для объявления любого количества объектов. Три признака истинного объектного ориентированного подхода - это инкапсуляция, наследование и полиморфизм. OOPS - крик пользователя, обнаружившего, что он удалил содержимое своего жесткого диска. Также акроним для объектно-ориентированных систем программирования. Operand (Операнд) - выражение, участвующее в операции. Один или более операндов объединяются для формирования более сложных выражений. Например, в выражении х + 5, х и 5 являются двумя операндами. Operator (Оператор) - символ, который объединяет одно или более выражений в более сложное выражение. Некоторые операторы являются унарными; это означает, что они могут быть применены только к одному операнду. Существуют также бинарные опера- торы; это означает, что они могут объединять два операнда. В выражении х + *р знак «плюс» (+) является бинарным оператором, а звездочка (*) в этом случае - унарным. Overloading (Перегрузка) - повторное использование имени или символа для различ- ных - хотя часто связанных между собой - значений. В C++ перегрузка может прини- мать две формы. Перегрузка функции позволяет вам многократно использовать одно и то же имя для определения различных функций, требуя только, чтобы каждая функция
430 C++ без страха имела отдельный список аргументов; компилятор разрешает обращение к имени функ- ции, проверяя типы аргументов. Перегрузка оператора позволяет вам повторно исполь- зовать ойераторы (такие как *, + или <) определяя, как эти операторы будут работать с объектами ваших собственных классов. Pointer (Указатель) - переменная, содержащая адрес другой переменной. (Указатель также может быть установлен на NULL, в этом случае он ни на что не указывает.) Ино- гда я использую выражение «указатель на что-либо», при этом я имею в виду «адрес чего-либо». Указатели C++ применяются для различных целей, некоторые из которых описаны в главе 6. В общем случае указатели достаточно полезны, так как предоставля- ют вам способ передачи дескриптора порции данных без необходимости копировать сами данные. Polymorphism (Полиморфизм) - самое устрашающее слово в объектно- ориентированном программировании. На самом деле оно обозначает, что знание о том, как выполнять какое-либо действие, встроено в сам объект, а не содержится в коде, ко- торый использует этот объект. Поэтому общие операции (такие как cout << *р в Гла- ве 17) могут быть выполнены бесчисленным количеством способов, причем без необхо- димости изменять или повторно компилировать выражение cout « *р в вызываю- щей программе. Если тип объекта указывает на изменения, то действие выражения из- меняется, даже если главный модуль программы не был изменен или перекомпилирован. Тот факт, что выполнение отдельного выражения может принимать различные формы, и объясняет использование термина «polymorphism» (от греческого «много форм»). Precedence (Приоритет) - правила, определяющие, какие операции должны выполнять- ся первыми в сложном выражении. Например, в выражении 2 + 3*4, умножение (*) выполняется первым, т.к. операция умножения имеет больший приоритет, чем операция сложения. См. Приложение А для получения информации о приоритете операторов в C++. Prototype (Прототип) - объявление функции, в котором указывается только информация о типе. (Это не объявление.) Pure virtual function (Чистая виртуальная функция) - функция, не имеющая реализации (то есть определения) в классе, в котором она объявлена. При этом создается общая функция, которая должна быть реализована в подклассе. Reference (Ссылка) - переменная или аргумент, который служит дескриптором другой переменной или аргумента. Ссылочная переменная является альтернативным именем другой переменной. Ссылочный аргумент аналогичен переменной, но реализуется скры- то путем передачи указателя. Если вы разобрались в указателях, то понять ссылки вам будет достаточно легко: они ведут себя как указатели, но без использования синтаксиса указателей. RHIDE - интегрированный редактор для разработки, который можно скачать с бесплат- ным компилятором GNU C++ (который включен в компакт-диск к этой книге). См. также IDE, Интегрированная среда разработки. Source file (Исходный файл) - текстовый файл, содержащий выражения C++ (и директи- вы, опционально). Statement (Утверждение) - основной элемент синтаксиса программы C++. Утверждение C++ можно считать аналогом команды или предложения естественного языка, такого как
Приложение Е. Глоссарий 431 английский. Как и длина предложения, длина выражения C++ не фиксирована. Оно мо- жет быть прервано в любой момент, как правило, точкой с запятой. Определение функ- ции состоит из последовательности утверждений. String (Строка) - последовательность текстовых символов, которую можно использовать для представления слов и фраз или даже полных предложений. В компьютерном про- граммировании люди используют строки для работы с буквенно-цифровыми данными. Языки С и C++ поддерживают строки как массивы типа char - тип char, позволяет хранить достаточно большие целые числа кода ASCII, соответствующие одному тексто- вому символу. Символ конца строки (ASCII 0) обозначает конец строки. Кроме того, по- следние версии компиляторов C++ поддерживают более простой в использовании стро- ковый класс string, с которым программист может не беспокоиться о количестве вы- деляемой для каждой строки емкости запоминающего устройства. String literal (Строковый литерал) - текстовая строка, заключенная в кавычки и пред- ставляющая собой постоянную строковую величину. Когда компилятор C++ видит стро- ковый литерал, он сохраняет символы в сегменте данных, а затем при обработке кода заменяет строковый литерал адресом данных. Таким образом, в результате написания строкового литерала получается адресное выражение типа char*. ( Subclass (Подкласс) - класс, который наследуется от другого класса. Подкласс автома- тически включает все члены базового класса, кроме его конструкторов. Любые явные объявления в подклассе создают дополнительные или подмененные члены. Text string (Текстовая строка) - см. Строка. Variable (Переменная) - именованная ячейка для хранения данных программы. Каждой переменной соответствует уникальная ячейка в памяти программы (ее адрес), в которой хранятся данные. Кроме адреса и памяти, каждая переменная имеет определенный тип (такой как int, double, char или string), который определяет формат ее данных. Virtual function (Виртуальная функция) - функция, адрес которой не определяется до начала работы программы (так называемое динамическое связывание(1а1е binding)). Эта особенность придает виртуальным функциям - которые в остальном выглядят, как стан- дартные функции - особую гибкость. Вы можете безопасно подменять виртуальную функцию в подклассе, зная, что независимо от того, каким образом осуществляется дос- туп к объекту, всегда будет вызываться правильная версия функции. Например, вызов функции ptr->vfunc () будет вызывать собственную версию функции vfunc объек- та, а не версию базового класса. Виртуальные функции тесно связаны с концепцией по- лиморфизма. В Древнем Риме virtu означало «мужественность»; в современном языке virtual означает «иметь качество чего-либо». У этого примера (не иначе как прискорбного) шовинизма есть одна подкупающая особенность: предполагается, что для получения какого-либо места в обществе нужно иметь верховенствующее поведение, то есть нужно обладать определенным правильным качеством. В компьютерной технологии если что-либо вир- туально, оно имитирует поведение реального прототипа и может использоваться как нечто реальное.
Брайан Оверленд C++ БЕЗ СТРАХА Отдел распространения издательской группы «ТРИУМФ» («Издательство Триумф», «Лучшие книги», «Только для взрослых», «Технолоджи - 3000», «25 КАДР») Телефон: (095) 720-07-65, (095) 772-19-56. E-mail: opt@triumph.ru Интернет-магазин: www.3st.ru КНИГА-ПОЧТОЙ: 125438, г.Москва, а/я 18 «Триумф». E-mail: post@triumph.ru ОТВЕТСТВЕННЫЕ ЗА ПЕРЕГОВОРЫ: Региональные магазины - директор по развитию Волошин Юрий Московские магазины - главный менеджер Малкина Елена Оптовые покупатели - коммерческий директор Марукевич Иван Редактор перевода Б. Б. Матвеев. Перевод: Т. В. Грищук, А. Ю. Климович. Корректор Е. В. Горбачева. Верстка О. В. Новикова. Дизайн обложки И. Г. Колмыкова. ООО «Издательство ТРИУМФ». Россия, 125438, г. Москва, а/я 18. Лицензия серия ИД № 05434 от 20.07.01 г. Подписано в печать с оригинал-макета 09.03.2005 г. Формат 70х 1 OO'/ig- Печать офсетная. Печ, л. 27. Заказ № 6248. Тираж 3 500 экз. Отпечатано в полном соответствии с качеством предоставленных диапозитивов в ОАО «Можайский полиграфический комбинат» 143200, г. Можайск, ул. Мира, 93