Текст
                    С помощью этого руководства вы
освоите объектно-
ориентированное программиро-
вание на языке C++ и узнаете
•	что такое абстракция, сокрытие реализации, ин-
терфейсы, пространства имен, классы, объекты,
инкапсуляция, наследование, наследственная ие-
рархия, полиморфизм, абстрактные классы и ме-
тоды, виртуальные функции, шаблоны, обобщен-
ные алгоритмы, контейнеры, библиотека базовых
классов Microsoft (MFC)
•	как применять объектно-ориентированный под-
ход при разработке больших программных систем
на языке С++
•	как правильно применять в программах инкапсу-
ляцию, наследование и полиморфизм
•	как создавать шаблоны классов и функций
•	что такое стандартная библиотека шаблонов STL
и как ее использовать
•	как не угодить в ловушки полиморфизма
•	как создавать и обрабатывать динамические
структуры данных
•	как создать Windows-приложение с помощью
Visual Studio .NET
•	как вычислить миллион (а то и два!) верных деся-
тичных цифр основания натуральных логарифмов
дидлЕктикд
Я.К. Шмидский

САМОУЧИТЕЛЬ ^4^
рограммирование на языке Г i i Я.К. Шмидский ИлцдлЕЮпика Москва • Санкт-Петербург • Киев 2004
ББК 32.973.26-018.2.75 Ш73 УДК 681.3.07 Компьютерное издательство “Диалектика” Зав. редакцией А. В. Слепцов По общим вопросам обращайтесь в издательство “Диалектика” по адресу: info@dialektika.com, http://www.dialektika.com Шмидский, Я.К. Ш73 Программирование на языке C++. Самоучитель. :— М. : Издатель- ский дом “Вильямс”, 2004. — 368 с. : ил. ISBN 5-8459-0535-4 (рус.) Эта книга — самоучитель и практическое руководство по объектно-ориенти- рованному программированию на языке C++. В ней на профессиональном уров- не изложены все средства объектно-ориентированного программирования на этом языке: технология применения инкапсуляции, наследования и полимор- физма, абстрактные классы и методы, виртуальные функции, шаблоны, обоб- щенные алгоритмы, контейнеры, библиотека стандартных шаблонов STL. Рас- смотрено также создание Windows-приложений с помощью Visual Studio .NET и библиотеки базовых классов Microsoft (MFC). Все базовые понятия и конструк- ции языка демонстрируются на большом количестве детально разобранных при- меров. Книга написана на понятном, доступном языке. Она рассчитана на школь- ников, студентов, аспирантов, а также всех, кто хочет освоить объектно-ориенти- рованное программирование на языке C++ и уже знаком с языком С. ББК 32.973.26-018.2.75 Все названия программных продуктов являются зарегистрированными торговыми марками соответствующих фирм. Никакая часть настоящего издания ни в каких целях не может быть воспроизведена в какой бы то ни было форме и какими бы то ни было средствами, будь то электронные или механиче- ские, включая фотокопирование и запись на магнитный носитель, если на это нет письменного разрешения издательства “Диалектика”. Copyright © 2004 by Dialektika Computer Publishing. АП rights reserved including the right of reproduction in whole or in part in any form. ISBN 5-8459-0535-4 (pyc.) © Компьютерное изд-во “Диалектика”, 2004
Оглавление ВВЕДЕНИЕ 15 ГЛАВА 1. ИСТОРИЯ ЯЗЫКА C++, ЕГО ВЕРСИИ И ОТЛИЧИЯ ОТ ЯЗЫКА С 17 ГЛАВА 2. КРАТКИЙ ОБЗОР C++ 27 ГЛАВА 3. ПРАКТИКА ОБЪЕКТНО-ОРИЕНТИРОВАННОГО ПРОГРАММИРОВАНИЯ 41 ГЛАВА 4. ООП. КЛАССЫ И ОБЪЕКТЫ 103 ГЛАВА 5. ИНКАПСУЛЯЦИЯ 115 ГЛАВА 6. ПРОСТРАНСТВА ИМЕН 127 ГЛАВА 7. НАСЛЕДОВАНИЕ 151 ГЛАВА 8. ПОЛИМОРФИЗМ 219 ГЛАВА 9. ШАБЛОНЫ И СТАНДАРТНАЯ БИБЛИОТЕКА ШАБЛОНОВ STL 245 ГЛАВА 10. ИСКЛЮЧЕНИЯ 285 ГЛАВА 11. ЗНАКОМСТВО С VISUAL C++. СОЗДАНИЕ ПЕРВОГО ВИЗУАЛЬНОГО ПРИЛОЖЕНИЯ В СРЕДЕ VISUAL C++ 295 ПРИЛОЖЕНИЕ. ОТВЕТЫ И РЕШЕНИЯ ЗАДАЧ И УПРАЖНЕНИЙ 314 ПРЕДМЕТНЫЙ УКАЗАТЕЛЬ 357
Содержание ВВЕДЕНИЕ 15 Как пользоваться этой книгой 15 Использование шрифтов 16 Пиктограммы 16 Задачи и упражнения 16 ГЛАВА 1. ИСТОРИЯ ЯЗЫКА C++, ЕГО ВЕРСИИ И ОТЛИЧИЯ ОТ ЯЗЫКА С 17 Почему, как и когда возник язык C++ 17 Отличие технологии программирования на C++ от технологии программирования на С 19 Отличия C++ от С 22 Разновидности C++ 23 Стандарты языка C++ 23 Резюме 24 Задачи и упражнения 25 ГЛАВА 2. КРАТКИЙ ОБЗОР C++ 27 Пример программы 28 Общий вид программы: два стиля — традиционный и новый 29 Виды комментариев в C++ 29 Вывод данных на экран: объект cout 30 Пространства имен 32 Классы 32 Описание класса 33 Создание объектов 34 Доступ к членам объекта 35 Доступ к членам объекта по имени объекта 35 Доступ к членам объекта с помощью указателя на объект 36 Пример создания и использования объектов: программа сложения двух целых чисел 36 Конструкторы и деструкторы 37 Конструкторы классов 37 Деструкторы классов 38 Резюме 38 Задачи и упражнения 39 ГЛАВА 3. ПРАКТИКА ОБЪЕКТНО-ОРИЕНТИРОВАННОГО ПРОГРАММИРОВАНИЯ 41 Постановка задачи 41 Технология нисходящего программирования 42 Представление данных 42 Выбор алгоритма 43 Выбор основания системы счисления 43 Вычисление размера массива 44 Реализация алгоритма деления столбиком 44
Печать десятичной дроби 45 Тестирование программы 45 Обсуждение полученных результатов 48 Усовершенствование программы 48 Итерация 1: пополнение набора функций 49 Итерация 2: усовершенствование организации данных 50 Испытание усовершенствованной программы 52 Обсуждение результатов и технологии программирования 55 Философия технологии программирования. Отличие технологии программирования от геометрии 56 Применение объектно-ориентированного программирования 60 Объективный взгляд на мир: объектно-ориентированный анализ (ООА) 60 Как воплотить результаты объектно-ориентированного анализа в программе? 61 Чего же мы достигли? Обсуждение результатов и добавление новых функциональных возможностей 67 Деление десятичной дроби на натуральное число 67 Виды полиморфизма 68 Обсуждение результатов и новая итерация 69 Новая итерация: начнем с объектно-ориентированного анализа 69 Философское отступление: все сначала? Итерации — благо или проклятие? 70 Продолжение объектно-ориентированного анализа 70 Обсуждение достижений и пополнение набора методов 86 Новое применение: вычисление числа е — основания натуральных логарифмов 87 Резюме 102 Задачи и упражнения 102 ГЛАВА 4. ООП. КЛАССЫ И ОБЪЕКТЫ 103 История объектно-ориентированного программирования 104 Технологии, предшествовавшие объектно-ориентированному программированию 104 Объектно-ориентированное программирование 107 Взаимосвязь вещей и понятий: классы и объекты 107 Объектно-ориентированное программирование 109 Свойства объектно-ориентированных программ ПО Естественность программ ПО Надежность программ ПО Повторное использование 111 Удобство сопровождения 111 Возможность усовершенствования программ 111 Периодический выпуск (издание) новых версий 111 Базовые понятия объектно-ориентированного программирования 112 Резюме 112 Задачи и упражнения 112 ГЛАВА 5. ИНКАПСУЛЯЦИЯ 115 Инкапсуляция, черные ящики и их интерфейсы 115 Что такое инкапсуляция, черный ящик, интерфейс и реализация 115 Реализация инкапсуляции в языках программирования. Уровни доступа 116 Содержание 7
Зачем нужна инкапсуляция? 117 Абстракция: учимся думать и программировать абстрактно 117 Что такое абстракция? 117 Правильное применение абстракции 118 Абстракция: советы, как избежать типичных ошибок 119 Абстракция и инкапсуляция. Нужен ли для них объектно- ориентированный язык? 120 Сокрытие реализации 120 Защита объекта с помощью абстрактного типа данных 121 Создание абстрактного типа данных в объектно-ориентированном языке 122 Защита пользователей с помощью сокрытия реализации 123 Советы по сокрытию реализации 124 Распределение ответственности: заниматься своим делом 124 Резюме 126 Задачи и упражнения 126 Инкапсуляция и абстракция: достижение целей объектно- ориентированного программирования 126 ГЛАВА 6. ПРОСТРАНСТВА ИМЕН 127 Пространства имен — зачем они нужны? 127 Создание пространства имен 130 Объявление и определение типов 130 Объявление функций вне пространства имен 131 Добавление новых членов 131 Вложение пространств имен 132 Использование пространства имен 132 Ключевое слово using 135 Ключевое слово using как директива 135 Область действия директивы using 136 Ключевое слово using в объявлениях 137 Пример разрешения неоднозначности: указание пространства имен 138 Пример определения класса в пространстве имен 139 Заголовочный файл класса в пространстве имен 140 Файл реализации класса в пространстве имен 141 Программа-приложение 144 Псевдонимы пространства имен 146 Неименованные пространства имен 146 Стандартное пространство имен std 147 Резюме 148 Задачи и упражнения 149 ГЛАВА 7. НАСЛЕДОВАНИЕ 151 Что такое наследование? 151 Что происходит при наследовании 152 Доступ к наследуемым членам 153 Основные правила наследования открытых, закрытых и защищенных членов 153 Защищенное и закрытое наследование 153 Перегрузка и переопределение функций-членов 154 8 Содержание
Сокрытие метода базового класса 156 Родительские связи: вызов метода базового класса 158 Отношения “является” (“Is-a”) и “содержит” (“Has-a”): когда использовать наследование 159 Наследственная иерархия 161 Пример использования наследования для построения иерархии 162 Царство животных 163 Вызов конструкторов и деструкторов при создании и уничтожении экземпляров производного класса 166 Передача параметров в конструкторы базового класса 168 Одиночное и множественное наследование 172 Одиночное наследование 172 Множественное наследование 178 Смесь одиночного и множественного наследования: классы возможностей, классы-мандаты, или миксины 191 Механика наследования 191 Подмененные методы и свойства 192 Новые методы и свойства 193 Рекурсивные методы и свойства 193 Типы наследования 194 Наследование реализации 194 Проблемы наследования реализации 194 Наследование для отличия 195 Специализация 196 Наследование для подмены типов 196 Технология применения наследования 197 Абстрактные классы и методы 198 Концепция абстрактных классов 198 Определение абстрактного класса 199 Создание потомков абстрактного класса 199 Передача абстрактных классов 199 Зачем объявлять чисто виртуальные функции 200 Пример использования абстрактных базовых классов 202 Виртуальные методы 204 Как работают виртуальные функции 208 Нельзя брать там, находясь здесь 208 Отсечение, или дробление объекта 209 Виртуальные деструкторы 211 Виртуальный конструктор-копировщик 211 Цена виртуальности методов 214 Как с помощью наследования достичь целей объектно-ориентированного подхода? 214 Резюме 216 Задачи и упражнения 216 ГЛАВА 8. ПОЛИМОРФИЗМ 219 Понятие полиморфизма 220 Полиморфизм полиморфизма: формы полиморфизма 222 Полиморфизм включения 222 Параметрический полиморфизм 223 Параметрические методы 223 Содержание 9
Параметрические типы 224 Переопределение 224 Перегрузка 224 Приведение типов 225 Технология применения полиморфизма 226 Правила применения полиморфизма 226 Полиморфизм и проверка условий 227 Как исправить условные выражения 228 Правила применения условных операторов 229 Ошибки и ловушки при использовании полиморфизма 230 Ловушка 1: вынос поведения на слишком высокий уровень иерархии 230 Ловушка 2: непроизводительные издержки, или потеря эффективности 231 Ловушка 3: ограничение интерфейсом базового класса 231 Реализация полиморфизма 231 Виртуальные функции и динамическое, или позднее, связывание как форма полиморфизма 232 Правила использования виртуальных функций в C++ 233 Виртуальные функции и полиморфические кластеры 234 Полиморфизм и проверка вызовов виртуальных функций 239 Сколько стоит полиморфизм: техническая реализация виртуальных функций 240 Преимущества позднего связывания 241 Как с помощью полиморфизма достичь целей объектно-ориентированного подхода 242 Резюме 243 Задачи и упражнения 244 ГЛАВА 9. ШАБЛОНЫ И СТАНДАРТНАЯ БИБЛИОТЕКА ШАБЛОНОВ STL 245 Что такое шаблон 246 Создание шаблона 246 Параметризованные функции 246 Параметризованные классы 251 Применение шаблона для определения класса Vector 252 Создание экземпляра шаблона 253 Объявление шаблона 253 Шаблон класса Array 254 Использование имени шаблона 255 Простая программа с шаблоном массива 255 Объявление функций, типы параметров которых являются параметрическими 258 Шаблоны и друзья 258 Дружественные классы и функции, не являющиеся шаблонными 259 Общий шаблон для дружественного класса или функции 262 Использование экземпляров шаблона 265 Специализированные функции 270 Статические члены в шаблонах 275 Стандартная библиотека шаблонов 278 Примеры применения стандартной библиотеки шаблонов 280 Пример использования шаблонов объектов-функций 280 Объединение и пересечение мультимножеств 281 10 Содержание
Резюме 283 Задачи и упражнения 283 ГЛАВА 10. ИСКЛЮЧЕНИЯ 285 Зачем нужен механизм обработки исключений 287 Механизм обработки исключительных ситуаций 289 Исключение: что это такое и как его вбрасывать 291 Резюме 293 Задачи и упражнения 293 ГЛАВА 11. ЗНАКОМСТВО С VISUAL C++. СОЗДАНИЕ ПЕРВОГО ВИЗУАЛЬНОГО ПРИЛОЖЕНИЯ В СРЕДЕ VISUAL C++ 295 Обзор среды разработки Visual C++ .NET 296 Solution Explorer — проводник, или обозреватель решений 297 Область вывода 297 Область редактирования 298 Строки меню 298 Изменение конфигурации среды 298 Ваш первый проект 299 Создание рабочей области проекта 299 Генерация оболочки приложения с помощью мастера создания приложений Application Wizard 300 Как работают Windows-приложения 303 Проектирование окна приложения 305 Добавление нового кода в приложение 307 Последний штрих 309 Создание пиктограммы приложения 310 Добавление кнопок Maximize (Развернуть) и Minimize (Свернуть) 312 Дальнейшее усовершенствование приложения Hello 312 Резюме 313 Задачи и упражнения 313 ПРИЛОЖЕНИЕ. ОТВЕТЫ И РЕШЕНИЯ ЗАДАЧ И УПРАЖНЕНИЙ 314 Глава 1 314 Глава 2 315 Глава 3 315 Глава 4 334 Глава 5 336 Глава 6 336 Глава 7 338 Глава 8 342 Глава 9 344 Глава 10 350 Глава 11 355 ПРЕДМЕТНЫЙ УКАЗАТЕЛЬ 357 Содержание 11
Посвящение Моей любимой маме Шмидской (Башенко) Тамаре Ивановне посвящается
Благодарности Чтобы научиться программировать, необходимы и книги, и компьютеры. Поэтому я хотел бы выразить огромную признательность авторам всех книг, по которым я учился математике, программированию и другим наукам, — без этих книг я не мог бы разобраться в компьютерах и программировании. К сожалению, едва ли это осущест- вимо — напечатать почти полный перечень авторов книг по компьютерам, издавав- шихся в издательствах — советских, зарубежных и постсоветских. Но даже изучить все эти книги и разобраться в компьютерах — еще совсем не значит уметь донести свои знания студентам и читателям. Овладеть всеми необходимыми знаниями и навыками мне помогали многие люди. Всем им моя глубокая благодарность. Я помню всех, но перечислить могу лишь немногих. Выражаю свою глубокую благодарность Александру Александровичу Минько — за то, что учил меня основам редактирова- ния книг и всевозможным типографским терминам; Сергею Николаевичу Тригубу — за то, что учил меня всем тонкостям редактиро- вания книг, за советы и программы, которые ему пришлось написать для меня, а также за бесконечное терпение, когда у меня что-то не получалось и я без- божно срывал все сроки. Если бы не его помощь, эта книга никогда бы не была написана; Вадиму Владимировичу Александрову — за помощь в редактировании книг и за сверхангельское терпение, когда я в очередной раз задерживал выход книги из редакции, поскольку считал, что она все еще не удовлетворяет строгим крите- риям издательства; Людмиле Николаевне Важениной, Татьяне Петровне Кайгородовой, Ирине Алексе- евне Поповой, Ирине Александровне Шишкиной и Екатерине Петровне Пере- стюк — за то, что учили меня правильно применять правила русского языка, стоически исправляли все мои опечатки и переводили мои многоэтажные про- граммистские конструкции на понятный русский язык; Ольге Викторовне Мишутиной, Ларисе Алексеевне Гордиенко и Зое Викторовне Александровой — за то, что ухитрялись прочитывать мой почерк и сразу же от- сылали исправленные главы, чтобы я опять мог в них вставлять свои каракули; Сергею Аркадьевичу Чернокозинскому и Владимиру Георгиевичу Павлютину — за то, что научили меня выполнять рисунки так, чтобы их можно было печатать даже на газетной бумаге; Анатолию Николаевичу Полинчику и Михаилу Александровичу Удалову — за то, что научили меня понимать проблемы верстки; Андрею Андреевичу Линнику, Ольге Викторовне Линник и Александру Андреевичу Линнику (Линнику мл.) — за то, что катастрофические сбои аппаратуры никогда не приводили к катастрофическим последствиям, а также за то, что нужная информация была всегда под рукой (на сервере); Владимиру Яковлевичу Грицкову — за то, что учил меня не только работать за пультами машин второго и третьего поколений, но и хладнокровно, без пани- ки, разбираться в бесконечных дампах и находить ошибки аппаратуры. К сожа- лению, в те далекие времена отладка машин и системных программ выполня- лась в ночные смены за пультом ЭВМ. К счастью, те машины исчезли, зато появились интегрированные среды разработки программ и виртуальные маши- ны, благодаря которым отладка стала сущим удовольствием. Кроме того, ог- ромная благодарность Владимиру Яковлевичу за плодотворные обсуждения концепций аппаратно-программной реализации языков высокого уровня. Без этих обсуждений писать было бы не о чем;
Виктору Николаевичу Штонде, Геннадию Петровичу Петриковцу и Алексею Юрь- евичу Орловичу — за то, что после распада СССР основали фирму, благодаря которой все постсоветские программисты получили возможность знакомиться с лучшими зарубежными книгами по компьютерной тематике. Без этого данная книга не могла бы быть написана; Полине Николаевне Мачуге — за литературное редактирование; Александру Вадимовичу Слепцову — за менеджмент проекта; Елене Владиславовне Михайличенко — за исключительно внимательное сопрово- ждение проекта; всем читателям этой книги — за то, что вы есть!!! Если бы не вы, незачем было бы и писать. 14 Содержание
Введение Пользуясь Вашим любезным разрешением, я злоупотребляю... цитатами. Мишель Монтень. Опыты. Том 2 Заведи себе небольшую и удобную тетрадь и делай в ней запи- си о прочитанном, но только для памяти, а не для того, чтобы с педантической точностью приводить цитаты. Филип Стенхоп Честерфилд. Письма к сыну Цель данной книги — помочь читателю освоить объектно-ориентированное про- граммирование на языках C++ и Visual C++. В основе книги лежит практический подход и все важные положения демонстрируются большим количеством примеров — от самых простых до вполне профессиональных. Формально знание школьного курса информатики не предполагается, однако читатель должен хорошо владеть навыками практического программирования на языке С. Знание других языков программирования необязательно (хотя и помешать оно, конечно, никак не может). Все необходимые пред- варительные знания можно почерпнуть, например, из книги Программирование на языке C/C++. Самоучитель, выпущенной издательством “Диалектика” в 2003 году. Что действительно нужно, так это желание научиться объектно-ориентированному программированию на языках C++ и Visual C++ и компьютер, на котором можно транслировать и выполнять программы, написанные на этих языках. Требования к компьютеру определяются возможностью установки компилятора. В принципе, для чтения первых глав подойдет любой компилятор для языка C++, поэтому первые гла- вы можно освоить даже с компьютером на базе процессора Pentium с тактовой часто- той 100 МГц с оперативной памятью 32 Мбайт и диском объемом 2 Гбайт. Но лучше, конечно, сразу установить современную интегрированную среду разработки, напри- мер Visual Studio. В последней главе понадобится Visual Studio .NET. Очень важно, чтобы компьютер был всегда под рукой. Как пользоваться этой книгой Вы никогда не программировали, но хотите освоить объектно-ориентированное программирование на языках C++ и Visual C++? Тогда вам нужно прочесть сначала ранее упомянутую книгу Программирование на языке C/C++. Самоучитель. Там есть все, что нужно знать, чтобы приступить к чтению данной книги. У вас уже есть опыт составления программ на языке С? Тогда эта книга научит вас применять объектно-ориентированное программирование на языках C++ и Visual C++ для разработки средних и больших программ. Особое внимание обратите на тех- нологию объектно-ориентированного программирования на языках C++ и Visual C++. В книге, конечно, нет листингов в миллион строк, но есть подробно разобран- ные примеры применения объектно-ориентированного подхода к составлению про- грамм на языке C++; в этих примерах демонстрируются технологические приемы, используемые для конструирования больших программ. Вы руководили и успешно завершили несколько проектов объемом в десятки мил- лионов строк и между делом преподаете C++ в Академии Программирования? Ну, тогда, вероятно, ваши студенты обнаружили пару несущественных описок в коммен- тариях к вашим программам, и вы купили книгу, чтобы доказать, что даже книги не
застрахованы от подобного рода случайностей. Пишите, что вам понравилось, а что нет, чего не хватает, а что можно опустить. С благодарностью приму все ваши поже- лания по адресу: smith@dialektika.com. Как найти в книге нужную информацию? Оглавление и предметный указатель по- могут вам сориентироваться в предлагаемом материале. Использование шрифтов Несколько слов о шрифтах. Когда в книге вводится новый термин, он выделяется курсивом. Наиболее важные положения выделяются полужирным шрифтом. Чтобы об- легчить чтение листингов, в них ключевые слова выделены полужирным шрифтом, а курсивом — комментарии. Листинги набраны моноширинным шрифтом. Пиктограммы Для того чтобы читателям было проще ориентироваться, текст книги отмечен осо- быми значками, или пиктограммами. Эти пиктограммы обращают ваше внимание на дополнительную или важную информацию. Задачи и упражнения Чтобы освоить объектно-ориентированное программирование, нужно писать про- граммы, поэтому в конце каждой главы предлагаются задачи и упражнения. Решения вы найдете в приложении в конце книги, но не спешите их читать. Попробуйте ре- шить задачи самостоятельно. Все они в основном простые. Конечно, есть и такие, в которых требуется написать (и отладить) небольшую программу. Возможно, придется заглянуть и в справочник (ведь программисту постоянно приходится учиться). Не по- мешает и смекалка. Что касается программ, приведенных в решениях, то они, как правило, будут чем-то отличаться от ваших собственных. Это не беда. А чтобы вы могли сравнить результаты, в решениях, как правило, приводится хотя бы часть выда- чи. Кроме того, имейте в виду, что не всегда в решениях приводится самая лучшая программа. В решении приводится программа, которая примерно соответствует дос- тигнутому уровню изученного материала. Сколько же времени нужно потратить на решение задач? Это зависит от задачи. На одни задачи достаточно пяти минут, на другие — несколько часов. (Честно признаюсь, решение задачи из главы 3 может отнять день-два, а то и неделю.) Но посмотреть решение можно лишь после того, как вы сами пытались решить задачу в течение нескольких дней. Если же вы решили за- дачу самостоятельно, хотя бы бегло просмотрите решение, где может содержаться до- полнительная информация. Желаю удачи! Здесь мы достигаем пункта, которому предыдущие соображе- ния служили введением. Герберт Спенсер. Опыты научные, политические и философские. Том 1 < < < 16 Введение
Глава 1 История языка C++, его версии и отличия от языка С В этой главе... Почему, как и когда возник язык C++ 17 Отличие технологии программирования на C++ от технологии программирования на С 19 Отличия C++ от С 22 Резюме 24 Задачи и упражнения 25 Сравнительные исторические исследования по крайней мере могут пролить дополнительный свет на нынешние проблемы, так что история становится источником эвристики для совре- менной теории и практики научно-технической политики. Р. Коэн. Социальные последствия современного технического прогресса Почему, как и когда возник язык C++ Язык царствами ворочает. Русская поговорка С течением времени человек все больше осваивает окружающий его мир и потому неустанно расширяет сферу применения своих инструментов, и в особенности таких, как компьютеры. Поэтому и проблемы, стоящие перед программистами, меняются. Двадцать лет назад программы создавались в основном для обработки больших объе- мов данных. При этом зачастую как те, кто писал программы, так и те, кто их исполь- зовал, были программистами-профессионалами. Сегодня многое изменилось. С ком- пьютером нередко работают те, кто даже понятия не имеет о его аппаратном и про- граммном обеспечении. Компьютеры стали инструментом, который используется людьми, заинтересованными в решении своих деловых проблем, а вовсе не в глубо- ком освоении компьютера. Однако чтобы облегчить новому поколению пользователей работу с программами, нужно было значительно повысить интеллектуальный уровень программ. Поэтому не удивительно, что сложность самих программ значительно возросла. Канули в лету те дни, когда пользователи вводили “таинственные знаки” (т.е. команды) в ответ на по- нятные только посвященным подсказки-приглашения, в результате получая поток “сырых”, т.е. совершенно необработанных данных. В современных программах ис-
пользуются высокоорганизованные, дружественные по отношению к пользователю интерфейсы, оснащенные многочисленными окнами, меню, диалоговыми окнами и мириадами визуальных графических средств, с которыми все уже хорошо знакомы. Благодаря этому пользователь думает не о командах, а об объектах, которыми он управляет. Программы, написанные для поддержки этого нового уровня взаимодейст- вия человека с компьютером, гораздо сложнее написанных всего лишь десять лет на- зад просто потому, что они позволяют управлять совокупностями довольно сложных объектов, а не только имитировать нажатие кнопок на панели управления одного ка- кого-либо прибора. Однако для разработки программ, удовлетворяющих новым требованиям, нужны были новые инструменты. Усиленно разрабатывались новые парадигмы программиро- вания, такие как объектно-ориентированное программирование. Не удивительно, что появились и новые языки программирования, облегчавшие применение ООП. Наибо- лее распространенным стал язык C++, но, подобно С, он не появился на голом мес- те. Основой для создания языка C++ послужил процедурно-ориентированный язык системного программирования С, позволяющий разрабатывать эффективные модуль- ные программы для решения широкого класса научно-технических задач. Эффектив- ность написанных на языке С программ обусловлена возможностью учета в ней архи- тектурных и аппаратных особенностей ЭВМ, на которой программа реализуется. Язык программирования C++ был разработан в начале 80-х годов сотрудником AT&T Bell Laboratories Бьярном Страуструпом (Bjarene Stroustrup). При создании C++ использо- вались определенные концепции языков Симула-67 и Алгол-68 и преследовались сле- дующие цели: обеспечить поддержку абстрактных (определяемых пользователями) типов дан- ных; предоставить средства для объектно-ориентированного программирования; улучшить существующие конструкции языка С. В программе, написанной на языке C++, можно создать пользовательские типы данных на базе уже существующих. Их использование значительно облегчает обработ- ку сложных структур данных. Объектно-ориентированное программирование представляет собой технологию программирования, которая базируется на классификации и абстракции объектов. Язык C++ позволяет применять три основные концепции объектно-ориентирован- ного программирования: инкапсуляцию, наследование и полиморфизм. Инкапсуляция позволяет объединить данные и обрабатывающие их функции в од- ном объекте. Цель инкапсуляции — автономность модулей; она позволяет локализо- вать последствия изменения структур данных конкретного модуля. Если модуль по- настоящему автономный, то его изменения незаметны для остальных модулей. Язык C++ позволяет использовать все преимущества наследования путем модели- рования иерархии классов, или типов объектов. Благодаря наследованию можно при- менять разработанные ранее классы, что обеспечивает существенное сокращение про- цесса разработки программных модулей. Полиморфизм — это использование одного имени для обозначения действий, об- щих для родственных классов. При этом выполняемые действия зависят от типа обра- батываемых данных. Примером полиморфизма в C++ является перегрузка функций, позволяющая в рамках иерархии классов иметь несколько версий одной и той же функции. И только в ходе выполнения программы принимается решение, какая именно версия будет выполнена. 18 Глава 1
Отличие технологии программирования на C++ от технологии программирования на С Больше мастерства нет драгоценности. Калмыцкая поговорка Лучше день подумать, чем целую неделю трудиться впустую. Таджикская поговорка При большой поспешности в работе будут погрешности. Бенгальская поговорка Применение концепций объектно-ориентированного программирования весьма заманчиво, так как оно позволяет: защитить объекты от кода других частей программы; сократить сроки разработки программы; исключить избыточные коды. Собственно, чтобы достичь этих целей, как раз и потребовались новые возможно- сти в языках программирования. Однако с изменением требований к программирова- нию претерпели изменение не только языки программирования, но и технология на- писания программ. Хотя в истории эволюции программирования есть много интерес- ного, мы даже не будем пытаться объять необъятное, а остановимся на переходе от процедурного программирования к объектно-ориентированному. До недавних пор программы рассматривались как последовательности процедур, выполнявших некоторые действия над данными. Процедура, или функция, представ- ляет собой набор определенных команд, выполняемых друг за другом. Данные были отделены от процедур, и главным в программировании было проследить, какая функ- ция какую вызывает и какие (и как) данные при этом изменяются. Именно для этого и было разработано структурное программирование. Основная идея структурного программирования вполне соответствует принципу “разделяй и властвуй”. В соответствии с ним программа просто последовательно ре- шает набор задач. Любая сколько-нибудь сложная задача разбивается на несколько более мелких, и это разбиение продолжается до тех пор, пока задачи не станут доста- точно простыми. Возьмем классический пример — вычисление средней заработной платы всех слу- жащих компании. Это не такая уж простая задача, поэтому в соответствии с техноло- гией структурного программирования ее следует разбить на ряд подзадач. 1. Вычисляем, сколько зарабатывает каждый служащий. 2. Подсчитываем количество служащих в компании. 3. Суммируем все зарплаты. 4. Делим суммарную зарплату на количество служащих в компании. Суммирование зарплат тоже нужно разбить на несколько этапов (подзадач). 1. Вначале выполняем подготовительные действия, например: полагаем общую сумму равной нулю, открываем файл зарплаты служащих и т.п. 2. Затем для каждого служащего прибавляем значение его зарплаты к общей сумме. История языка C++, его версии и отличия от языка С 19
Как и первое, второе действие, в свою очередь, нужно разбить на еще более мел- кие подоперации. Так следует поступать до тех пор, пока, наконец, не будет решена каждая подзадача. Конечно, структурное программирование позволяет довольно успешно решать да- же очень сложные задачи. Однако его недостатки к концу 1980-х годов стали слишком очевидными. Во-первых, не было реализовано естественное желание думать о данных (напри- мер, записях служащих) и действиях над ними, (сортировке, редактировании и т.п.) как о едином целом. Процедурное программирование, наоборот, отделяло структуры данных от функций, которые обрабатывали эти данные. Во-вторых, программисты обнаружили, что, несмотря на наличие обширных биб- лиотек стандартных программ, они постоянно заняты решением давно решенных проблем. Вместо этого хотелось иметь возможность на уровне языка программирова- ния повторно использовать уже имеющиеся решения. Ведь конструктору радиопри- емника не нужно каждый раз изобретать диоды и транзисторы. Он просто использует стандартные, заранее подготовленные радиодетали. Почему же разработчики про- граммных продуктов должны изобретать колесо? Появление дружеского пользовательского интерфейса с рамочными окнами, меню и экранными кнопками определило новый подход к программированию. Программы стали не столько выполняться последовательно от начала до конца, сколько реагиро- вать на события. При возникновении определенного события (например, щелчка на кнопке или выбора из меню команды) программа должна отреагировать на него. И эту интерактивность программ приходилось все более учитывать при их разработке. В хорошо разработанных программах пользователю сразу предоставляется возмож- ность выбора нужного варианта; пользователь вовсе не обязан выполнять длинную последовательность команд, чтобы выполнить необходимое ему действие. Объектно-ориентированное программирование предоставляет не только техноло- гию разработки сколь угодно сложных управляющих элементов, но и средства для многократного использования построенных по этой технологии программных компо- нентов! Объектно-ориентированное программирование — и в этом состоит его суть — учит обращаться с данными и процедурами, которые выполняют действия над этими дан- ными, как с единым объектом, т.е. самодостаточным элементом, который в чем-то идентичен другим таким же объектам, но в то же время отличается от них определен- ными уникальными свойствами. Однако чтобы без лишних усложнений реализовать эту концепцию в программах, нужен язык, в котором предусмотрены средства поддержки объектно-ориентирован- ного программирования. Иными словами, необходимы средства поддержки инкапсу- ляции, наследования и полиморфизма. Все это есть в C++. Для поддержки инкапсуляции, например, в языке C++ предусмотрены средства создания нестандартных (пользовательских) типов данных, называемых классами. Хо- рошо определенный класс действует как полностью инкапсулированный объект, т.е. его можно использовать в качестве целого программного модуля. Настоящая же внут- ренняя работа класса должна быть скрыта. Пользователям хорошо определенного класса не нужно знать, как этот класс работает; им нужно знать только одно — как его использовать. Язык C++ поддерживает также и наследование. Это значит, что можно объявить новый тип данных (класс), который является расширением существующего. Таким образом созданный новый подкласс называется наследником класса-родителя, или производным от него. Это подобно созданию новой модели (модификации) какого- нибудь изделия (например автомобиля, самолета, кофемолки или стиральной маши- ны), которая в отличие от базовой имеет дополнительные возможности (свойства). 20 Глава 1
Поддерживается в языке C++ и полиморфизм: различные функции (с различной сигнатурой) могут иметь одно и то же имя. Потому одно и то же имя (или один и тот же знак операции) может обозначать различные методы, или различные реализации поведения. Конкатенацию строк-классов можно, например, обозначить знаком +, но это совсем не тот +, который складывает числа! Учитывая все это, мы можем уверенно констатировать следующее. Язык C++ справедливо называют продолжением языка С и любая работоспособная программа (если она не использует те немногочисленные конструкции, семантика которых отли- чается в языках С и C++) на языке С должна компилироваться компилятором C++. Однако несмотря на это, при переходе от С к C++ был сделан весьма существенный скачок. Язык C++ выигрывал от своего родства с языком С, поскольку программисты могли использовать привычные для них синтаксические конструкции в программах на C++. Однако многие программисты обнаружили, что для того, чтобы в полной мере воспользоваться преимуществами языка C++, им нужно отказаться от некоторых сво- их прежних представлений и изучить новые способы построения концептуальных мо- делей и их применения к решению проблем. Из-за этого даже разгорелась дискуссия: нужно ли сначала изучить язык С? Действительно, у многих возник вопрос: “Хотя C++ и является продолжением языка С, нужно ли сначала осваивать С, если потом все равно необходимо изучать новую технологию?” Конечно, многие поспешили заявить, что это не только не нуж- но, но и вредно. И знаете, они были правы. Дело в том, что по сложившейся тради- ции в курсах программирования зачастую преподается не программирование, а син- таксические правила записи операторов какого-нибудь модного (иногда это может быть даже С) в академических кругах языка программирования. Технология структур- ного программирования, упоминаемая где-то в конце такого курса, представляется как последнее слово науки, к тому же весьма необязательное для практического при- менения. Это примерно то же, что на протяжении всего вводного курса математики усиленно тренировать студентов в сложении одноцифровых чисел, а в конце курса заявить, что не все числа одноцифровые и над числами можно выполнять не только сложение, но знание этого факта не будет проверяться на экзамене. Конечно, в этом случае у студентов сложится впечатление, что математика заканчивается где-то сразу после изучения сложения одноцифровых чисел. Если на следующем курсе студентам придется заняться математикой вплотную, то выяснится, что знания, полученные на предыдущем курсе, ничтожны, а представления о математике этих студентов вредны. В этом случае действительно было бы лучше, если бы предыдущий курс “математики” студенты не изучали. Легче было бы начать с азов. Однако здесь дело в неправильном построении вводного курса, а не во вреде знаний самих по себе. Вводные курсы как раз для того и предназначены, чтобы не столько научить (хотя бы и простым) второ- степенным деталям, сколько дать представление о главном. Если же говорить о техно- логии программирования на C++, то главное ее отличие от технологии программиро- вания на С состоит в том, что не столь уж важно записать программу без синтаксиче- ских ошибок (их найдет компилятор!), сколь важно правильно ее спроектировать. Иными словами, по сравнению с технологией программирования на С в технологии программирования на C++ значительно возрастает роль того этапа, который часто на- зывается подготовительным и еще чаще рассматривается как ненужная трата времени на построение концептуальной модели. Однако нужно признать, что язык C++, возможно, больше любого другого требует от программиста проведения подготовительного этапа до написания программы. Этот этап заключается в том, чтобы спроектировать программу до ее написания. При ре- шении тривиальных проблем, рассматриваемых в некоторых курсах программирова- ния, можно обойтись и без проектирования. Однако профессиональным программи- стам приходится решать сложные проблемы из реальной жизни, а не из задачников к вводным курсам. Такие проблемы не решаются записью нескольких операторов на История языка C++, его версии и отличия от языка С 21
салфетке за ужином в ресторане под оглушительную музыку. Программы для их ре- шения действительно требуют предварительного проектирования, и чем тщательнее оно будет проведено, тем более вероятно, что программа сможет их решить, причем с минимальными затратами времени и денежных средств. При добросовестно прове- денном проектировании создается программа, которую легко отладить и изменять в будущем. Ведь стоимость отладки (если она потребуется!) и настройки может в 10, а то и в 20 раз превзойти стоимость остальных работ по разработке программного про- дукта. Удачно выполненное проектирование может значительно уменьшить эти расхо- ды, а значит, и стоимость всего проекта. Сначала нужно построить концептуальную модель. Для этого прежде всего нужно ответить на следующий вопрос: “В чем состоит проблема?” Каждая программа должна иметь четкую, ясно сформулированную цель, причем это относится даже к простей- шим программам. Далее следует поставить вопрос: “Может быть, решение проблемы можно свести к применению уже имеющихся программ?” Возможно, для решения проблемы доста- точно воспользоваться своими старыми программами, ручкой и бумагой или купить у кого-то уже готовую программу. Часто такое решение может оказаться лучше, чем создание абсолютно новой программы. Построив концептуальную модель и наметив решение проблемы в терминах пред- метной области, можно приступать к непосредственному проектированию программы. Иными словами, создание любого приложения требует тщательного анализа про- блемы и проектирования ее эффективного решения. Хотя эти этапы логически пред- варяют этап написания программы, все же лучше еще в процессе проектирования по- думать о выразительных средствах языка программирования, которые понадобятся для записи решения. Чтобы облегчить знакомство с технологией объектно-ориентирован- ного программирования, давайте сначала хотя бы бегло ознакомимся с нашим основ- ным инструментом — языком программирования. Вы уже освоили язык С, поэтому чтобы составить представление о C++, лучше всего узнать, чем же он отличается от С. Отличия C++ от С Язык камни рушит. Осетинская поговорка Язык твой — лев: если удержишь его — защитит тебя, если выпустишь — растерзает. Арабская поговорка Когда назрела идея объектно-ориентированного анализа, проектирования и про- граммирования, Бьярн Страуструп (Bjam Stroustrup) взял язык С (наиболее популяр- ный для разработки коммерческих программных продуктов) и расширил его, обогатив средствами, необходимыми для объектно-ориентированного программирования. Ниже перечислены средства языка C++, которые отличают его от С: новые операции доступа к глобальным объектам и управления динамической памятью; дополнительный вид комментариев; объектно-ориентированные средства ввода-вывода; прототипы функций для согласования типов параметров и аргументов; более свободное размещение в программе операторов объявления переменных; обработка исключительных (ошибочных) ситуаций; 22 Глава 1
динамическая (во время выполнения» программы) идентификация типов объек- тов и их преобразование; параметризированные функций и классы — шаблоны; манипуляторы потоков ввода-вывода; новые базовые (встроенные) типы данных bool (булев, логический) и wchar_t (двухбайтовый символ); стандартные производные типы данных и стандартные шаблоны; пространства имен — средство повышения модульности программы и средство разрешения конфликтов между одноименными переменными; упрощенный синтаксис объявления переменных производных типов, опреде- ляемых пользователем; упрощенный синтаксис подключения заголовочных файлов стандартной биб- лиотеки (не требуется указывать расширение .h). Безусловно, такое количество отличий не могло быть реализовано сразу, поэтому не удивительно, что существуют не только многочисленные версии языка C++, но и несколько стандартов и даже разновидностей C++. Разновидности C++ C++ в настоящее время можно считать господствующим языком разработки ком- мерческих продуктов. Конечно, многие используют Java, и даже в свое время измени- ли C++, но потом опомнились и вернулись к своей прежней привязанности. Впро- чем, не судите их строго: C++ и Java совпадают на 90%. То же самое относится и к “совсем новому” языку С#, недавно разработанному компанией Microsoft. Этот язык имеет ряд принципиальных отличий от C++ и за ним большое будущее, но многие программисты рассматривают его как разновидность C++. Удивитесь ли вы после всего сказанного тому, что было несколько попыток стандартизировать C++? Ну, а если вы этому не удивились, то наверняка догадались, что для языка C++ было раз- работано несколько стандартов. Стандарты языка C++ Первый стандарт языка был основан (и это совсем неудивительно) на исходной разработке Бьярна Страуструпа, его использовали программисты на протяжении доб- рого десятка лет. Второй стандарт был создан тоже Бьярном Страуструпом, но на этот раз совместно с комитетом по стандартизации ANSI1 и международной организацией по стандартам ISO. Именно язык, удовлетворяющий последнему стандарту, и назы- вают часто Standard C++. Обе версии языка очень близки, но Standard C++ содержит несколько расширений традиционного языка C++, и потому многими рассматривает- ся как его надмножество (надязык). Конечно, принятие стандарта — отнюдь не еди- новременный акт, и поправок к нему может быть гораздо больше, чем к Американ- ской Конституции, но все же можно отметить, что именно в августе 1998 года про- изошло знаменательное событие — был принят стандарт языка C++ ISO/IEC 144882. Можно считать, что именно тогда благодаря Аккредитованному комитету стандар- тов (Accredited Standards Committee), действующему под руководством Американского национального института стандартов (American National Standards Institute — ANSI), язык C++ обрел свой международный стандарт. 1 Аббревиатура ANSI обычно произносится как "анси". История языка C++, его версии и отличия от языка С 23
Однако стандарт языка C++ создавался* не только в Международной организации по стандартизации ISO (International Standards Organization). Стандарт языка C++ раз- рабатывал также и Национальный комитет по стандартам на информационные техно- логии NCITS (National Committee for Information Technology Standards), причем разра- ботка была начата еще тогда, когда этот комитет назывался ХЗ. Стандарт ANSI — это попытка гарантировать, что язык C++ будет аппаратно неза- висимым (т.е. программы будут переносимыми с компьютера на компьютер). Это значит, что программа, написанная в соответствии со стандартом ANSI для компиля- тора одного производителя, например компании Microsoft, будет компилироваться без ошибок с использованием компилятора другого производителя. Более того, ANSI- совместимые программы должны компилироваться и выполняться на всех тех компью- терах, для которых разработаны компиляторы, совместимые со стандартом ANSI. Ины- ми словами, такие программы должны выполняться на любой платформе — Mac, Win- dows или Alpha, если только она имеет компилятор, совместимый со стандартом ANSI. Именно по этой причине так важно придерживаться требований данного стандар- та. К сожалению, большинство изучающих язык C++ уделяют внимание далеко не всем тонкостям стандарта ANSI. Как же тогда проверить соответствие программного продукта общепринятым стандартам ANSI, которое так важно для профессиональных программистов? Для этого часто лучше всего воспользоваться каким-нибудь хорошим верификатором — специальной утилитой, которая проверяет программы, в том числе и на ANSI-совместимость. Однако несмотря на наличие международного стандарта, и по сей день продолжа- ют существовать стандарты де-факто. Определяются они производителями компилято- ров. Наиболее распространены компиляторы двух производителей: Microsoft и Borland. Кроме того, есть версии, настолько отличные от C++, что даже называются иначе, например С—. Программируя в среде .NET, ориентироваться, конечно, лучше всего на продукцию фирмы Microsoft, но если ваш компьютер для Windows Server 2003 (и для платформы .NET) пока еще слабоват, начальные сведения о C++ вполне можно осилить и с компилятором от другой фирмы. Стандарты приходят и уходят, а умение программировать — вечно. Нужно только начать. Резюме C++ в настоящее время считается господствующим языком, используемым для разработки коммерческих программных продуктов. Разработка программ на языке C++ ведется с помощью специальных комплексов программ, которые называются системами программирования или интегрированными средами разработки программ. Системы программирования позволяют создавать про- граммы на определенной реализации языка, однако системы программирования даже одного производителя имеют различные версии, которые отражают развитие техно- логии программирования и эволюцию среды выполнения программ. Поэтому жела- тельно максимально использовать стандартные средства языка для того, чтобы сни- зить затраты на модификацию программ при изменении среды выполнения или при переходе на другую версию языка. Такие стандартные средства описаны в междуна- родном стандарте языка, созданном Аккредитованным комитетом стандартов (Accre- dited Standards Committee), действующим под руководством Американского нацио- нального института стандартов (American National Standards Institute — ANSI). Вместе с тем многие важные аспекты языка определяются в реализации и не опи- сываются стандартом. К их числу относится машинное кодирование символов, число- вых и логических значений. Стандарт не определяет порядок создания программы для среды выполнения. Детали процесса построения исполняемой программы описаны в документации на систему программирования. Хотя знание стандартов и деталей реа- 24 Гпава 1
лизации языка играет немаловажную роль, еще важнее уметь программировать, т.е. применять современную технологию программирования на практике. Поэтому чтобы создавать объектно-ориентированные программы, необходимо научиться строить кон- цептуальные модели и освоить все средства языка C++, позволяющие выражать ре- зультаты такого построения в программах. Задачи и упражнения 1. Что такое интегрированная среда разработки программ? 2. Назовите основные компоненты современных систем программирования. История языка C++, его версии и отличия от языка С 25

Глава 2 Краткий обзор C++ В этой главе... Пример программы 28 Общий вид программы: два стиля — традиционный и новый 29 Виды комментариев в C++ 29 Вывод данных на экран: объект cout 30 Пространства имен 32 Классы 32 Резюме 38 Задачи и упражнения 39 Едва ли нужно говорить, что общий обзор предмета всегда предшествует с пользой основательному изучению как целого предмета, так и всякой его части. Беспорядочное блуждание по области, не имеющей для нас ни определенных границ, ни внутренних межевых знаков, всегда имеет своим последствием некоторую смутность мысли. Изучение какой-либо отдельной части предмета, при отсутствии сведений касательно ее связей со всем остальным, ведет к неверным понятиям. Герберт Спенсер. Опыты научные, политические и философские. Том 1 В этом беглом обзоре столь обширного поля я, без сомнения, просмотрел многое из того, что следовало бы включить в него. Герберт Спенсер. Опыты научные, политические и философские. Том 1 Чтобы научиться программировать, нужно писать программы, а для этого необхо- димо изучить язык программирования. Конечно, изучение языка можно начать со знакомства с алфавитом, далее перейти к освоению словарного запаса, грамматики, стилистики и т.д. — такая традиционная методика используется для длительного изу- чения иностранного языка. Родной язык осваивают не так. Даже не зная букв, пяти- летние дети говорят на родном языке быстрее, чем иностранец, серьезно занимав- шийся по традиционной методике в течение пяти лет. Можно считать доказанным, что примеры и практика на начальном этапе позволяют овладеть языком более эф- фективно, чем “академическое” заучивание правил. Именно поэтому знакомство с языком C++ мы начнем с краткого обзора, в котором конструкции языка будут про- демонстрированы в коротких детских стишках, простите, я хотел сказать фрагментах программ.
Пример программы Начнем со сравнения программ. Первая программа на языке C++, как вы догады- ваетесь, выводит на экран слова Hello World или что-нибудь подобное и потому по традиции называется HELLO.cpp. Вот она: #include <iostream.h> int main() { cout « "Hello World!\n"; return 0; } В строке #include <iostream.h> выполняется включение файла iostream.h в текущий файл. Трудно читать текст программы даже про себя, если не знаешь, как произно- сить специальные символы и ключевые слова. Советую читать строку #include <iostream.h> так: “паунд инклуд (# — символ фунта) ай-оу-стрим- дот (или точка)-эйч”. А строку cout « " Hello World! \п" читайте как “си- аут-Hello world!". Обратите внимание также на то, что в программе нет явного вызова функции printf, — вместо него используется оператор перенаправления потока данных <<. Конечно, даже программа, которая всего лишь выводит строку на экран, может быть написана многими способами. Вот еще один вариант этой программы: #include <iostream.h> // большинство компиляторов не требует этой строки int main(); int main() { cout « Hello World!\n"; return 0; } Здесь, как вы догадались, добавлен прототип функции main. Как отмечено в ком- ментарии, для большинства компиляторов это не требуется. Теперь разберем строку #include <iostream.h>. Она играет ту же роль, что и строка в #include <stdio.h> в программах на языке С. Приведенный здесь вид этой строки в настоящее время используется весьма широко, хотя и считается уста- ревшим. Современные компиляторы допускают более простое написание: #include <iostream>. Иными словами, вместо имен файлов в операторе #include можно ука- зывать заголовки новых стандартных библиотек. Файл iostream.h (input-output- stream — поток ввода-вывода) используется объектом cout, который обслуживает процесс вывода данных на экран. Выполнение программы, как вы несомненно догадались, начинается с вызова функции main(). Каждая программа на языке C++ содержит функцию main(). Обычно функции вызываются другими функциями, но main() — особая функция: она вызывается автоматически при запуске программы. Функция main(), подобно всем другим функциям, должна объявить тип возвра- щаемого значения. В программе HELLO.CPP функция main() возвращает значение типа int (от слова integer — целый), а это значит, что по окончании работы данная функция возвратит операционной системе целочисленное значение. В данном случае будет возвращено целое значение 0, как показано в строке return 0; 28 Глава 2
Ранее допускалось объявлять функцию main() таким образом,- чтобы она возвращала значение типа void. Этого нельзя делать в C++. Функция main () должна возвращать значение типа int и поэтому перед выходом из этой функции выполняйте оператор return о,;. Используйте значение 0 как флаг (признак) нормального завершения функции. Все действия нашей простейшей программы выполняются в строке cout « " Hello World!\n";. Объект cout используется для вывода сообщений на экран. Два объекта, cin и cout, используются в языке C++ для организации соответственно ввода данных (например, с клавиатуры) и их вывода (например, на экран). Вот как используется объект cout: пишем слово cout, за которым ставим оператор перенаправления выходного потока « (далее будем называть его оператором вывода). Хотя оператор « состоит из двух символов “меньше” (<), компилятор C++ воспри- нимает их как один оператор. Все, что следует за этим оператором, будет выводиться на экран. Тем не менее, это еще не все. Дело в том, что в современном стиле сразу после списка включаемых файлов принято указывать пространства имен. Так как почти всегда это будет стандартное пространство имен, то сразу после списка включаемых файлов последует строка using namespace std; Таким образом, мы можем сделать вывод: программируя на C++, программы можно писать в двух стилях — традиционном (старом) и новом. Общий вид программы: два стиля — традиционный и новый В традиционном стиле программа имеет следующий вид: /★ Программа на C++ в традиционном стиле ★/ #include <iostream.h> /★ другие операторы #include в том же стиле - с расширением .h*/ /★ код программы ★/ В новом же стиле программа будет иметь такой вид: /★ Программа на C++ в новом стиле '★/ #include <iostream> /★ другие операторы #include в том же стиле - без расширения .h* / using namespace std; /★ код программы ★/ Обсудив стили программ, перейдем к одному из наиболее важных, если не самому важному элементу программ, — к комментариям. Виды комментариев в C++ Кек известно, в языке C++ есть два вида комментариев: с двойным слешем (//) и “скобочные” — начинаются они со слеша и звездочки (/*) и завершаются звездочкой Краткий обзор C++ 29
и слешем (*/). Комментарий в стиле C++ начинается двойным слешем (//) и закан- чивается в конце текущей строки. Комментарий в стиле С подобен скобкам потому, что он начинается специальной комбинацией — слешем и звездочкой (/*) и завершается также специальной комби- нацией: звездочка и слеш (/*). Каждой открывающей паре символов /* должна соот- ветствовать закрывающая пара символов */ — это и есть своеобразные скобки. Комментарии в стиле С используются также и в языке C++, а вот двойной слеш в языке С не воспринимается как символ комментария. В настоящее время программисты для выделения комментариев в основном ис- пользуют символы двойного слеша, а комментарии в стиле С — для временного от- ключения больших блоков программы. Впрочем, двойной слеш часто используется и для временного отключения отдельных строк программного кода. Считается хорошим тоном предварять блоки функций и саму программу коммен- тариями, из которых должно быть понятно, что делает эта функция и какое значение она возвращает. Такие предварительные комментарии часто пишутся в стиле С, одна- ко программисты пишут их далеко не всегда. Дело в том, что комментарии в заголовке программы быстро устаревают, поскольку программист часто забывает или ленится об- новить эти комментарии при обновлении текста программы. Проще использовать дру- гой принцип: функции должны иметь такие имена, чтобы у вас не оставалось ни тени сомнения в том, что они делают, в противном случае имя функции нужно изменить. Впрочем, лучше всего использовать понятные имена и дополнительно вносить краткие разъяснения с помощью комментариев. Вывод данных на экран: объект cout Для вывода данных на экран существует несколько способов. Проще всего вос- пользоваться объектом cout. Для этого нужно написать слово cout, а за ним опера- тор вывода («), который состоит из двух символов “меньше” (<). После оператора вывода укажите выводимые данные. Приведенная ниже программа демонстрирует применение объекта cout и оператора «. // Применение объекта cout и оператора « для вывода данных на экран. #include <iostream.h> int main() { cout « "Hello there.\n"; cout << "Here is 5: " << 5 << "\n"; cout « "The manipulator endl writes a new line to the screen."; cout « endl ; cout « "Here is a very big number:\t" << 70000 << endl; cout « "Here is the sum of 8 and 5:\t" << 8+5 << endl; cout « "Here's a fraction:\t\t" <<(float) 5/8 << endl; cout « "And a very very big number:\t"; cout << (double) 7000 * 7000 << endl; char * Iwi 11 = "I will be "; cout << Iwi 11 <<"the best C++ programmer!\n"; return 0; } Вот что получилось в результате прогона этой программы: Hello there. Here is 5: 5 The manipulator endl writes a new line to the screen. Here is a very big number: 70000 30 Глава 2
Here is the sum of 8 and 5: 13 Here's a fraction: 0.625 And a very very big number: 4.9e+07 I will be the best C++ programmer! Возможно, при компиляции этой программы возникнут ошибки или предупреж- дения, поскольку некоторые компиляторы требуют, чтобы математические вы- ражения в случае использования после объекта cout заключались в круглые скобки. В этом случае строку cout << "Here is the sum of 8 and 5:\t" << 8+5 << endl; нужно изменить следующим образом: cout << "Here is the sum of 8 and 5:\t" << (8+5) << endl; Может возникнуть вопрос: откуда в программе берется объект cout? Ведь мы его не описывали! Вот ответ: по команде #include <iostream.h> препроцессор вставил со- держимое файла iostream.h в исходный текст программы, а во вставляемом файле и был описан нужный нам объект! Вот почему необходимо включать файл iostream.h, если в программе используется объект cout и связанные с ним функции-члены. В строке cout « "Hello there.\n"; демонстрируется простейший вариант использования объекта cout: вывод строки символов. Символ \п — это специальный символ форматирования, который указыва- ет объекту cout на необходимость вывода на экран символа новой строки (он произ- носится “слэш-эн” или просто “разрыв строки”). В строке cout << "Here is 5: " << 5 « "\п"; объекту cout передаются три значения, и каждое из них отделяется оператором выво- да. Первое значение представляет собой строку "Here is 5: ". Затем объекту cout с помощью оператора вывода передается значение 5, а за ним — символ разрыва строки (этот символ всегда должен быть заключен в двойные или в одинарные кавычки). При выполнении этого оператора на экране появится строка Here is 5: 5 Так как после первого значения нет символа разрыва строки, следующее значение выводится сразу за предыдущим. Далее на экран выводится информационное сообщение, а затем символ формати- рования endl —символ разрыва (конца) строки. Символ endl расшифровывается как end line (конец строки) и читается как “энд-эл”, а не “энд-один” (иногда букву I принимают за единицу). В строках cout « "Here is a very big number:\t" « 70000 « endl; cout « "Here is the sum of 8 and 5:\t" « 8+5 « endl; cout « "Here's a fraction:\t\t" <<(float) 5/8 « endl; cout « "And a very very big number: \t"; показано, как для выравнивания выводимой информации использовать еще один символ форматирования — символ табуляции \t. Строка cout << "Here is a very big number:\t" << 70000 << endl; демонстрирует возможность вывода значений типа long int. Краткий обзор C++ 31
В строке cout « "Here is the sum of 8 and 5:\t" « 8 + 5 << endl; показано, что объект cout может выводить результат математической операции. В строке cout « "Here's a fraction:\t\t" <<(float) 5/8 « endl; объект cout выводит результат другой математической операции — 5/8. Конструкция (float) указывает объекту cout, что результат должен выводиться как дробное число. Строка cout <<(double) 7000 * 7000 « endl; содержит подобный пример: объекту cout передается выражение 7000 * 7000, при- чем (double) указывает, что результат должен выводиться в экспоненциальном пред- ставлении. Строка cout « Twill <<"the best C++ programmer!\n"; всего лишь демонстрирует уже разобранные способы применения объекта cout, но она — самая важная! Ведь здесь утверждается, что вы станете самым лучшим профес- сиональным программистом, хотя в этом и так нет никаких сомнений. Даже компью- тер это знает! Пространства имен Иногда полезно обозначать те или иные объекты идентификаторами. Например, в предыдущих программах для перехода на новую строку использовался не только сим- вол \п, но и идентификатор endl. А как быть, если такой же идентификатор нужно использовать для обозначения несколько иного объекта? До введения пространств имен удовлетворительного решения этой проблемы не было. Сейчас все просто: один и тот же идентификатор может иметь разный смысл в разных пространствах имен. Впрочем, чаще всего в программе используется стандартное пространство имен std. Именно на это и указывает оператор using namespace £td; который в новом стиле программ следует сразу после операторов включения заголов- ков библиотек. Однако если вы используете другое пространство имен, и там тоже определен идентификатор endl, то в переделах вашего пространства имен он будет иметь уста- новленный вами смысл. Но даже в переделах этого пространства вы можете использо- вать стандартный идентификатор endl, однако перед ним нужно будет указать его пространство имен std. Это делается так: std: :endl. Классы Теперь мы готовы вкратце обсудить главное, чем C++ отличается от С — классы, а также одно из важнейших свойств классов — инкапсуляцию. Ведь согласно концеп- ции объектно-ориентированного программирования именно классы являются тем ти- пом объектов, в которых инкапсулируются данные и обрабатывающие их функции. Именно инкапсуляция позволяет рассматривать программу как множество автоном- ных модулей, т.е. таких, изменение структуры которых не влияет на остальные моду- ли. Инкапсуляция при описании класса обеспечивается заданием спецификаторов доступа для членов класса. 32 Глава 2
Описание класса Класс можно рассматривать как определяемый программистом абстрактный тип данных, который создается на основе существующих типов. Члены класса — это дан- ные и функции, часто называемые методами. Классическое описание класса имеет следующий формат: class имя—класса {список_членов}; (Впрочем, вместо ключевого слова class можно использовать ключевое слово struct или даже union.) В этом описании ключевое слово (class, struct или union) указывает на начало описания класса, определяет используемый по умолчанию уровень доступа к членам класса, а также влияет на возможности наследования свойств этого класса. Металингви- стическая переменная имякласса представляет собой идентификатор, а список_членов — перечень объявлений элементов данных и описаний методов класса. Каждый член класса имеет присущий ему один из трех уровней доступа: public (общедоступный, открытый), private (приватный, собственный), protected (защи- щенный). При необходимости одно из этих ключевых слов (public (общедоступный, открытый), private (приватный, собственный), protected (защищенный)) указыва- ется там, где может начинаться оператор, а за ним следует двоеточие. Эти ключевые слова называются спецификаторами доступа. Действие спецификатора доступа на члены класса распространяется от места, где он указан, до нового спецификатора дос- тупа или до конца описания класса. Спецификатор доступа private (приватный, собственный) используется для за- крытия доступа к данным. Собственные данные доступны только для методов своего класса. Спецификатор доступа public (общедоступный, открытый) используется для открытия доступа к членам класса, например методам класса, которые организуют связь объекта данного класса с внешним миром. Уровень доступа protected (защи- щенный) открывает члены класса для его производных классов. При отсутствии про- изводных классов уровень доступа protected (защищенный) эквивалентен уровню private (приватный, собственный). Если вместо ключевого слова class в определении класса использовать ключевое слово struct или union, то все члены класса будут по умолчанию общедоступными, т.е. открытыми. Едва ли это отвечает представлениям пуристов о классах. Так что класс лучше определять классически — с помощью ключевого слова class — тогда все его члены будут собственными, т.е. недоступными извне. Чтобы изменить уровень доступа к членам классов, описанных с помощью ключевых слов class и struct, не- обходимо использовать спецификаторы уровня доступа. Классы, описанные с помо- щью ключевого слова union, не могут использоваться в качестве базовых. Кроме того, для членов экземпляров таких классов выделяется общее место в памяти. Уровень доступа членов у таких классов изменить нельзя. Можно ли после этого считать их настоящими классами? Едва ли. Это хорошо знакомые нам объединения. Ниже приведен пример описания класса Sum, который суммирует два целых числа. Членами класса будут слагаемые х и у, сумма s, а также методы getx(), gety()n summa(). Методы getx() и gety() предназначены для инициализации членов х и у, а метод summa () — для вычисления суммы и вывода ее на экран. Слагаемые х и у, а также сумму s определим как приватные (собственные), а методы — общедоступные. Тогда описание класса может выглядеть следующим образом: //по умолчанию private class Sum { // слагаемые х и у, сумма s int xz у, s; public: Краткий обзор C++ 33
// описание метода void getx(int xl) { x = xl; } // описание метода void gety(int yl) { у = yl; } } // прототип метода void sununa () ; // Описание метода: void Sum :: summa() { s = x + у; // вычисление суммы cout << "\n Сумма "<< x << "и " << у « " равна:" « s; } В классе Sum члены класса по умолчанию х, у и s являются приватными, а мето- ды getx (), gety () и summa () сделаны общедоступными. Методы getx () и getx() полностью описаны в классе, а метод summa() пред- ставлен лишь своим прототипом. Методы getx() и getx() предназначены для ввода членов х и у, соответственно. Эти методы понадобились потому, что доступ к приват- ным членам класса можно обеспечить только с помощью методов класса. Другим функциям эти члены недоступны, так как они имеют уровень доступа private (приватный, собственный). Описания методов класса можно размещать как внутри, так и вне класса. Если ме- тод описан внутри класса, он будет встроенным. Это значит, что тело метода будет размещено в самом классе подобно макрорасширению — таким образом удается сэ- кономить время на вызов функции и выход из нее. Именно такими мы сделали мето- ды getx() и gety(), но так описывать следует лишь небольшие функции. Чаще внутри класса записывается только прототип метода, а само описание метода разме- щается в подходящем месте программы вне тела класса. В приведенном примере класса так описан метод summa (). Мы разобрали описание классов. Но классы (по крайней мере в C++) — это толь- ко сущности, а не сами объекты (экземпляры классов). А чтобы та или иная сущность получила существование, она должна стать причастной к бытию или, как сказали бы некоторые средневековые философы, должна быть сотворена Божественной волей. Именно теперь пришло время приобщиться к секретам сего таинства (или фокуса), если вы, конечно, не занимались им ранее. Создание объектов А секретов-то не один, а несколько. Вот сейчас... Впрочем, сначала постарайтесь не пропустить ни одного слова — я имел в виду секрета, потому что один я вам толь- ко что выболтал, а вы, наверное, этого и не заметили. Догадались какой? Конечно, самый главный: объекты — экземпляры классов! Ну, а второй? Второй тоже очень важный, но его я выболтал еще раньше: классы подобны типам данных. Ну, а третий?! Третий, как вы понимаете, самый практичный, и следует он из первых двух. По- скольку классы подобны типам данных, а объекты — экземпляры классов, то объекты создаются из классов так же, как данные — из типов! Иными словами, описания объек- тов синтаксически подобны описанию обычных переменных. Описав класс, можно 34 Глава 2
создать сколько угодно экземпляров этого класса. Вот как можно создать экземпляры класса Sum: Sum k, 1, m, z; И впрямь можно сказать, что здесь созданы объекты k, 1, m, z типа, простите, я хотел сказать класса Sum. Теперь-то, надеюсь, вы твердо запомнили основные секреты таинства творения объектов. Но что толку из созданных объектов, если вы не можете получить доступ к их членам. Вот где настоящие секреты! Доступ к членам объекта Да, на этот раз шутки в сторону, это уж действительно настоящие секреты, ТОР SECRETS, как сказал бы Агент 007. Шутка ли, научиться получать доступ к докумен- там (возможно, приватным), то есть к членам класса! Бьюсь об заклад, вам не разга- дать даже Первый секрет. Он состоит в том, что секретов опять три, причем третий — логическое следствие первых двух. (Ну, признайтесь честно, подозревали ли вы, что именно в этом и состоит Самый Главный Секрет Доступа к Членам Объекта?) Второй секрет я хорошо спрятал — я повторял его столько раз, что вы и не подумали, что это ключ для доступа к членам объекта. Теперь-то вы знаете, что нужно просто вспом- нить одну ключевую фразу, к тому же набившую оскомину. Но какую именно? Ну ладно, вот она (не перечитывать же книгу с самого начала): вместо ключевого слова class можно использовать ключевое слово struct. А раз так, значит, к членам объек- тов можно получать доступ тем же способом, что и к членам структур! (Это и есть тре- тий секрет.) Поэтому есть два способа доступа к членам объекта: с помощью имени объекта (в этом случае после имени объекта следует знак операции точка .) или с помощью ука- зателя на объект (в этом случае после имени объекта следует знак операции доступа по указателю ->). Доступ к членам объекта по имени объекта Теперь, когда вы знаете секрет, вы отлично понимаете, что для доступа к членам объекта по имени объекта используется один из следующих форматов: имя—объекта.имя_класса::имя_члена ИЛИ имя—объекта.имя—члена Например, запись k. Sum: :х означает обращение к члену х объекта к типа (класса) Sum. (Конечно, получите ли вы доступ, зависит от того, разрешен ли он.) Если однозначно определяется, к какому классу принадлежит объект, можно имя класса (и следующий за ним оператор разрешения видимости) опускать. (Но если имеются методы с одинаковыми именами, описанные в разных базовых классах, воз- никает неоднозначность.) Вот как можно обращаться к членам х, у и s объекта к: к.х, к.у и k.s. Вызов методов записывается так же, но в круглых скобках указыва- ются параметры. Например, k.getx(3) есть вызов метода getx() объекта к с аргу- ментом 3. Вызов k.getx(2) ; присваивает члену х объекта к значение 8, а в результате вызо- ва k.gety (5) ; члену у объекта к присваивается значение 5. Краткий обзор C++ 35
Доступ к членам объекта с помощью указателя на объект Поскольку вы знаете, что доступ всегда, в том числе и с помощью указателя, осу- ществляется так же, как и в случае структуры, то сами можете догадаться, что для дос- тупа к членам объекта используется следующий формат: указатель—на_экземпляр_класса -> имя_объекта Предположим, мы объявили и инициализировали указатель Ъ следующим образом: Sum *b=&z;. Тогда к членам х и у объекта z можно обратиться так: Ь -> х и Ь -> у. Чтобы члену х объекта z присвоить значение 6, вызов метода можно записать таким образом: b -> getx(6) Ну а теперь, когда вы умеете создавать объекты и обращаться к их членам, вы мо- жете писать программы с классами. Пример создания и использования объектов: программа сложения двух целых чисел Напишем программу, вычисляющую сумму двух целых чисел. Для этого определим класс (тип) Sum, членами которого являются слагаемые х, у, их сумма s и три метода getx(), gety() и summa(). // Сумма двух целых чисел #include <iostream.h> class Sum { //по умолчанию private // слагаемые и сумма int х, у, s; public : // описание метода void getx(int xl) { // присваивание значения первому слагаемому х = xl ; } // описание метода void gety(int yl) { // присваивание значения второму слагаемому у = У1; } // прототип метода void summa ( ); }; // вычисление сумм void Sum::summa ( ) { s = х + у; cout << "\n Сумма " << x << "и " << у << " равна: " << s; } void main () { // создание объекта z типа Sum и указателя b на // указатель инициализируется адресом объекта z Sum z, *b = &z; // слагаемые int x2, y2; cout << "\n Введите первое слагаемое:"; объекты типа Sum 36 Глава 2
// ввод первого слагаемого cin » х2; cout « "\п Введите второе слагаемое:"; // ввод второго слагаемого cin » у2; // присваивание значения первому слагаемому z.getx(x2) ; // присваивание значения второму слагаемому z.gety(y2); // cout « " \п Сумма ” « z.x « " и " « z.y << " равна:" « z.s; // вычисление суммы b->summa(); } В функции main () создан объект z типа Sum и указатель Ь на объекты типа Sum, инициализированный адресом объекта z. Обращения к методам объекта z z.getx(x2) ; и z.gety(y2) ; присваивают значения х2 и у2 членам х и у объекта z. Сумма слагаемых х и у (членов объекта z) вычисляется с помощью вызова метода summa (), причем вызов записан так: b->summa () ;. Этот же метод выполняет и вывод результата на экран. Ранее в комментарии был записан оператор, который выглядит так же, как и опе- ратор вывода в методе summa (). Данный оператор, если его раскомментировать, дол- жен бы выдать на экран информацию о значении членов объекта z, но в этом случае компилятор обнаружит ошибку. Почему? Да потому, что доступ к используемым в нем членам класса Sum закрыт, так как уровень доступа у данных private (приват- ный, собственный). А к таким данным имеют доступ только методы своего класса. Конструкторы и деструкторы Не сомневаюсь, что вы еще не забыли самый главный секрет классов: классы — это типы. А типы можно инициализировать. А как инициализировать классы? Оказы- вается, в языке C++ каждый класс имеет две специальные функции (или два мето- да) — конструктор и деструктор. Конструкторы классов Конструкторы предназначены для инициализации объектов. Описание конструк- тора имеет следующий вид: [имя_класса::]имя_класса (список_параметров) {тело_конструктора} Таким образом, имя конструктора совпадает с именем класса. Тип возвращаемого значения для конструктора не указывается. Недопустим даже void! Вот как может выглядеть описание конструктора класса Sum: // конструктор класса Sum Sum (int х2 = О, int у2 = 0) { // инициализация первого слагаемого х = х2 ; // инициализация второго слагаемого у = у2; } Конструктор может иметь значения параметров по умолчанию, которые задаются в списке параметров. В приведенном описании конструктора Sum параметрам х2 и у2 задано значение 0 по умолчанию. Краткий Обзор C++ 37
Вызов конструктора по сравнению с другими методами имеет следующие особен- ности: • если конструктор не вызывается явно, то он вызывается при создании объекта автоматически, причем в этом случае используются заданные по умолчанию значения параметров; • если конструктор не описан явно, то он генерируется транслятором автомати- чески. Как же инициализировать объект с помощью конструктора? Это можно сделать двумя способами. Первый: имя_класса имя_объекта = имя_конструктора (список_аргументов); Второй, более короткий, способ вызова конструктора имеет вид: имя_класса имя_объекта (список_аргументов); Имя конструктора, конечно, совпадает с именем класса. Например, конструктор класса Sum можно вызвать двумя способами: Sum А = Sum (1, 2) ; и Sum А (1,2) ;. В обоих случаях создается объект А, причем слагаемым х и у присваиваются начальные значения 1 и 2. В нашем примере вызов конструктора можно записать без аргументов, например так: Sum АО ;. В этом случае слагаемые получат нулевые значения по умолчанию. Класс может иметь несколько конструкторов, имена которых, конечно же, должны совпадать с именем класса, а сигнатуры — различаться. Это — полиморфизм. Завершая предварительное обсуждение конструкторов, укажем, что конструктор, как и любой метод, может быть довольно сложным. Но тогда ведь и перед уничтоже- нием объекта может потребоваться выполнить довольно сложные действия. Кто же это делает? Деструкторы классов Деструкторы уничтожают объекты класса и освобождают занимаемую этими объ- ектами память. Деструктор представляет собой метод, причем имя деструктора совпа- дает с именем класса, перед которым стоит символ тильда (-)• Деструктор не может иметь ни параметров, ни типа возвращаемого значения, поэтому описание деструкто- ра имеет следующий формат: ~имя_класса() { операторы_тела_деструктора } Например, для класса Sum описание деструктора может выглядеть так: ~Sum() {} Деструктор вызывается явно или неявно. Чтобы уничтожить объект, деструктор нужно вызвать явно, т.е. как обычную функцию. Если этого не сделать, то, когда бу- дет осуществляться выход из блока, в котором объявлен этот локальный объект, вызов деструктора для этого локального объекта будет выполнен неявно (и автоматически). Однако если осуществляется выход из блока, в котором объявлены указатели на объ- екты, неявный вызов деструкторов для таких объектов не происходит, поэтому для разрушения таких объектов необходимо явным образом выполнить операцию delete. Резюме Сложность изучения программирования состоит в том, что большая часть изучае- мого материала во многом опирается на знания, которыми вам еще только предстоит овладеть. Поэтому при изучении программирования так важно получить представле- ние об изучаемом предмете в целом. Кроме того, программирование отличается от искусства сражения с драконами тем, что ни одно понятие нельзя изучить понаслыш- 38 Глава 2
ке. Поэтому очень важно не откладывать выход на машину к концу курса, а знако- миться с изучаемыми понятиями, пропуская демонстрационные программы, совер- шенствуя их и придумывая свои. Конечно, вы познакомились с языком C++ не на- столько основательно, чтобы сразу же приняться за крупный проект, но составили представление о языке и программах на нем, уже можете читать программы на C++ и сравнивать стили программирования на С и C++, а также пытаться переводить свои программы на C++. Именно этим мы и собираемся заняться в следующей главе. Задачи и упражнения 1. Отладка. Найдите ошибки в приведенной ниже программе. #include <iostream.h> int main() { cout << Is there a bug here?"; } Краткий обзор C++ 39

Глава 3 Практика объектно-ориентированного программирования В этой главе... Постановка задачи 41 Технология нисходящего программирования 42 Применение объектно-ориентированного программирования 60 Резюме 102 Задачи и упражнения 102 Чтобы узнать вкус обеда, нужно его съесть. Народная мудрость Все познается в сравнении. Народная мудрость Теперь, когда вы ознакомились немного с языком C++ и объектно-ориентиро- ванным программированием, наступило самое время применить свои знания не в ла- боратории, а на практике. Конечно, реальные примеры не столь рафинированы, как лабораторные образцы, тщательно подготовленные армиями лаборантов под командо- ванием генералитета педантичных профессоров, стремящихся скрыть какие бы то ни было трудности огранки драгоценных камней. Поэтому в данной главе мы на практи- ке займемся объектно-ориентированным программированием на языке C++ и попы- таемся применить его для разработки простой, но реальной программы, а также срав- ним объектно-ориентированный подход с традиционным структурным. И конечно, ответим на вопрос, дает ли объектно-ориентированное программирование какие-либо преимущества на практике, и какие именно, если ответ на первый вопрос окажется утвердительным. Постановка задачи Рапсодия — это вариации на известные темы. Музыкальный словарь Предположим, вы разработали простой и очень удобный калькулятор, который сразу понравился пользователям. Но оказалось, что в некоторых случаях необходимо
вычислять значения обыкновенных дробей — (считая числитель и знаменатель этой N дроби небольшими натуральными числами, представимыми встроенным типом, на- пример, int) с точностью, более высокой, чем та, которая обеспечивается для встро- енных типов. Пусть, например, вычисления требуется провести с точностью не менее 19, 100 или даже 1000 десятичных цифр. Вы решили встроить эту возможность в оче- редную версию калькулятора, но как приступить к решению поставленной задачи? Технология нисходящего программирования Поскольку это новая задача, в соответствии с технологией нисходящего програм- мирования нужно сначала создать (и всесторонне испытать) модуль, который решает ее. Модуль — это, конечно, программа. Ну а программа = алгоритмы + структуры дан- ных. Алгоритм в данной задаче хорошо знаком еще со школы. Значит, в первую оче- редь нужно принять решение о представлении данных. Представление данных Выразите число бесконечной десятичной дробью, вычислив семь ее цифр. Задача 147.1 из учебника математики для 5-6 классов (автор Л. Н. Шеврин и др.) Понятно, что в нашей задаче основополагающим является выбор представления числа. Поскольку точность превосходит ту, которая может быть обеспечена встроен- ными типами, понятно, что цифры результата придется записывать в массив или спи- сок. Так как точность задана до начала вычисления, выбор можно остановить на мас- сиве. (Это проще: нет проблем с добавлением очередного звена для цифры.) Осталось решить, как мы будем записывать цифры результата в массив-результат с. Предполо- жим, в с[1] мы запишем целую часть результата, а в с [2], ..., с [к] — дробную часть результата деления. Само число к при необходимости можно будет записать в с[0]. Вот что у нас получилось: /* С: ДЕСЯТИЧНАЯ ДРОБЬ: */ const integerindex, MaxLen =100; /★ число цифр, достаточное для хранения десятичной дроби ★/ integerdigit c[MaxLen+l]; /★ цифры дроби - с[1..MaxLen] ★/ с[0] = MaxLen; Теперь нужно уточнить некоторые технические детали вычислений. В частности, нужно решить, в какой системе счисления будем вести вычисления. Поскольку в. конце концов полученные результаты придется распечатывать, то, чтобы избежать проблем перевода из одной системы счисления в другую, в качестве основания системы счис- ления удобно выбрать степень десяти: М = 10"'. Разобравшись с данными, можем приступить к выбору алгоритма. 42 Глава 3
Выбор алгоритма ...чтобы обыкновенную дробь записать десятичной дробью, на- до числитель разделить на знаменатель. Учебник математики для 5-6 классов (автор Л. Н. Шеврин и др.) Конечно, алгоритмов деления в начале третьего тысячелетия известно великое множество. Однако поскольку мы делим небольшие натуральные числа, представимые встроенными типами, в реализации самым простым будет хорошо известный с на- чальной школы алгоритм деления столбиком. Осталось показать, как применить его к нашим данным. Пусть N — знаменатель вычисляемой дроби. Частное от деления чис- лителя / на ТУдает целую часть результата с[1]. Остаток, умноженный на М, в свою очередь делится на 7V, и частное этого деления дает первые т цифр дробной части ре- зультата; они записываются в с [2]. Остаток снова умножается на М, полученное произведение в свою очередь делится на N, и частное этого деления дает следующие т цифр дробной части результата; они записываются в с[3]. Продолжаем таким об- разом, пока не получим все требуемые цифры. Пусть, например, т = 6, I = 1, N = 19. Тогда М = 1 000 000 и мы получим: 1/19: с[1] = 0; R = 1; RXM/19: с[2] = 052 631; R = 11; RXM/19: с[3] = 578 947; R = 7; RXM/19: с[4] = 368 421; R = ... Поэтому 1/19 = 0,052 631 578 947 368 421... Теперь остается уточнить две детали в реализации этого алгоритма: правильный подбор основания системы счисления, в которой ведутся вычисления, и количество элементов массива, необходимое для хранения десятичной дроби. Очевидно, что ко- личество элементов массива, необходимое для хранения десятичной дроби, зависит от основания системы счисления, в которой ведутся вычисления, поэтому сначала нужно выбрать основание системы счисления. Выбор основания системы счисления Десятичная система построена — довольно неразумно, конеч- но, — в соответствии с людскими обычаями, а вовсе не требо- ваниями естественной необходимости, как склонно думать большинство людей. Блез Паскаль (ок. 1658 г.) Из только что рассмотренного примера выполнения деления мы видим, что М ис- пользуется в качестве операнда в операции умножения на знаменатель дроби. Поэто- му М нужно выбрать так, чтобы при этом не возникало переполнения: integer Mbase(integer denominator) { /★ начальное значение М ★/ integer М = 1000000; while (denominator >= LONG_MAX/M) M /= 10; if UM < 2) { printf("\n*** Знаменатель %d слишком велик.\n", denominator); M = 10; } return M; } Практика объектно-ориентированного программирования 43
Написав программу, определяющую основание системы счисления, можем вос- пользоваться ею для определения размера массива, в котором хранится десятичная дробь. Вычисление размера массива Теперь осталось вычислить нужное количество элементов в массиве М-ичных цифр. Сначала рассмотрим пример. Пусть требуется провести вычисления с точно- стью 100 десятичных знаков при т = 6. Тогда достаточно вычислить 18 элементов массива, в котором каждый элемент представляет собой 1 000 000-ичную цифру. Дей- ствительно, так как 6x17 = 102, то в 17 элементах мы получим 102 десятичных знака после запятой и еще один элемент понадобится для целой части. Теперь несложно разобрать и общий случай. Для вычисления дробной части в об- щем случае потребуется столько элементов, сколько нужно для хранения заданного количества десятичных знаков. Это число можно определить делением известной точ- ности DecPrec на т — количество десятичных цифр в одном элементе массива, кото- рое легко определить по ранее вычисленному основанию системы счисления. В слу- чае, когда деление нацело не выполняется, результат, естественно, придется округлить до ближайшего большего целого числа. Кроме того, к полученному результату нужно добавить 1, чтобы учесть элемент, в котором хранится целая часть десятичной дроби. /★ количество тех элементов массива, в которые записываются base-ичные цифры ★ / integer Kw(integer DecPrec, integer base) { /* количество десятичных цифр в элементе массива ★/ integer m = numdigit(base-1, 10); div_t d = div(DecPrec, m); /* количество элементов массива для записи десятичных цифр ★/ integer w = d.quot + 1 + (d.rem? 1 : 0); return w; } Теперь, когда все уточнения сделаны, можно приступать к реализации алгоритма деления столбиком. Реализация алгоритма деления столбиком Представьте обыкновенную дробь в. виде десятичной... Задача 633 из учебника математики для 5-6 классов (автор В. К. Соваиленко) Фактически нам осталось лишь выполнить деление натуральных чисел с произ- вольной точностью и напечатать дробь 1/N и соответствующее ей число, состоящее из целой части и необходимого количества цифр после десятичной точки. Сначала на- пишем программу деления натуральных чисел с произвольной точностью. /* деление натуральных чисел с произвольной точностью: ★/ integer divn(integer а, /★ числитель ★/ integer b, /★ знаменатель ★/ integerdigit с[/*MaxLen+l*/], integer base /* основание системы счисления ★/) { div_t d; /★ структура для хранения частного и остатка ★/ integerindex i;. /* i - номер обрабатываемой цифры ★/ for(i = 1; i <= с[0]; i++) { 44 Глава 3
d = div(a, b); /* вычисление частного и остатка ★/ с[i] = d.quot; /★ запоминаем частное и остаток ★/ а = d.rem * base; /★ очередное делимое ★/ } return а; } Теперь все необходимые алгоритмы реализованы — осталось лишь увидеть резуль- таты работы наших функций. Для этого проще всего напечатать вычисленные деся- тичные дроби. Печать десятичной дроби Наконец, можем приступить к реализации инструкции печати числа. Чтобы напечатать число, нужно последовательно напечатать его цифры, хранящиеся в массиве с. Это не- трудная и часто встречающаяся задача последовательной обработки элементов массива: /* НАПЕЧАТАТЬ ЧИСЛО (с): */ void print(integerdigit с[/*MaxLen+l*/], /★ массив цифр дроби ★/ /★ процедура печати цифры ★/ void outputdigit(integerdigit digit, /★ цифра дроби ★/ int w, /★ ширина поля ★/ int base /★ основание сист. счисл. */), int base /★ основание системы счисления ★/) { integerindex i; /* параметр цикла ★/ int w; /★ количество десятичных позиций для М-ичной цифры ★/ - numdigit (base-1, 10); printf("%2d", c[l]); /* целая часть */ printf("."); /* десятичная точка */ for (i = 2; i <= c[0] ; i++) /* дробная часть */ { outputdigit(с[i], w, 10); /* очередная цифра дробной части ★/ } printf("\n"); } Мы реализовали все необходимые функции и можем приступить к тестированию программы. Тестирование программы Наконец, нужно придумать тесты. Если пренебречь возможностью неположитель- ных значений числителя и знаменателя, то в качестве теста можно напечатать обрат- ные целых чисел, например от 2 до 51, а также разобрать более сложный пример: что- нибудь вроде 3210/93=34.516129032258064519 (здесь выписано 19 цифр дробной части). (Обратные целых чисел были выбраны потому, что их значения с довольно внуши- тельным количеством десятичных знаков часто приводятся в математических табли- цах, и, значит, йх не придется вычислять вручную.) Осталось лишь написать главную программу, содержащую эти тесты. Ниже приведена вся программа полностью, ttinclude <stdio.h> #include <math.h> #include <stdlib.h> typedef int integer; typedef int integerdigit; typedef int integerindex; Практика объектно-ориентированного программирования 45
7* количество цифр в числе digit при записи в системе счисления с основанием base */ int numdigit(integerdigit digit, integer base) { int к = 1; while (digit /= base) k++; return k; } void outputdigit(integerdigit digit, int w, int base) { int к = w - numdigit(digit, base); while (k-- > 0) printf("0"); printf("%d”, digit); } /* НАПЕЧАТАТЬ ЧИСЛО(с): */ void print(integerdigit c[/*MaxLen+l*/], /* массив цифр дроби *7 /* процедура печати цифры */ void outputdigit(integerdigit digit, /★ цифра дроби *7 int w, /* ширина поля ★/ int base /* основание сист. счисл. */), int base /* основание системы счисления ★/} { integerindex i; /★ параметр цикла *7 int w; /* количество десятичных позиций для М-ичной цифры ★/ w = numdigit(base-1, 10); printf("%2d", c[l]); /* целая часть ★/ printf("."); /* десятичная точка ★7 for (i = 2; i <= c[0]; i++) /* дробная часть ★/ { outputdigit(c[i], w, 10); /* очередная цифра дробной части ★/ } printf("\n"); } /* деление натуральных чисел с произвольной точностью: *7 integer divn(integer а, /* числитель *7 integer b, /* знаменатель ★/ integerdigit с[/*MaxLen+l*/] , integer base) { div_t d; /★ структура для хранения частного и остатка *7 integerindex i; /* i - номер обрабатываемой цифры *7 for(i = 1; i <= с[0]; i++) { d = div(a, b); c[i] = d.quot; a = d.rem * base; } return a; } 46 Глава 3
int main() { /* С: ДЕСЯТИЧНАЯ ДРОБЬ: */ const integerindex MaxLen =100; /★ число цифр, достаточное для хранения десятичной дроби ★/ integerdigit c[MaxLen+l]; /★ цифры дроби - с[1..MaxLen] ★/ с[0]= MaxLen; integer N = 2; /* знаменатель ★/ integer base = 1000000; /★ основание системы счисления ★/ integer а; /★ числитель ★/ integer b; /★ знаменатель ★/ do { divn(a =1, /★ числитель ★/ b = N, /★ знаменатель ★/ с, /★ массив цифр дроби ★/ base /* основание системы счисления */); printf("%4d/%2d = ", а, b); print(c, outputdigit, base); /★ печать дроби ★/ } while (N++ < 51) ; divn(a = 3210, /★ числитель ★/ b = 93, /★ знаменатель ★/ с, /★ массив цифр дроби ★/ base /* основание системы счисления ★/); printf("%4d/%2d = ", а, b) ; print(c, outputdigit, base); /* печать дроби ★/ printf("ХпКонец выполнения программы!"); return 0; } Вот что вывела эта программа (правый край обрезан, после десятичной точки со- хранено лишь 54 цифры): 1/ 2 = 0.500000000000000000000000000000000000000000000000000000 1/ 3 = 0.333333333333333333333333333333333333333333333333333333 1/ 4 = 0.250000000000000000000000000000000000000000000000000000 1/ 5 = 0.200000000000000000000000000000000000000000000000000000 1/ 6 = 0.166666666666666666666666666666666666666666666666666666 1/ 7 = 0.142857142857142857142857142857142857142857142857142857 1/ 8 = 0.125000000000000000000000000000000000000000000000000000 1/ 9 = 0.111111111111111111111111111111111111111111111111111111 1/10 = 0.100000000000000000000000000000000000000000000000000000 1/11 = 0.090909090909090909090909090909090909090909090909090909 1/12 = 0.083333333333333333333333333333333333333333333333333333 1/13 = 0.076923076923076923076923076923076923076923076923076923 1/14 = 0.071428571428571428571428571428571428571428571428571428 1/15 = 0.066666666666666666666666666666666666666666666666666666 1/16 = 0.062500000000000000000000000000000000000000000000000000 1/17 = 0.058823529411764705882352941176470588235294117647058823 1/18 = 0.055555555555555555555555555555555555555555555555555555 1/19 = 0.052631578947368421052631578947368421052631578947368421 1/20 = 0.050000000000000000000000000000000000000000000000000000 1/21 = 0.047619047619047619047619047619047619047619047619047619 Практика объектно-ориентированного программирования 47
1/22 = 0.045454545454545454545454545454545454545454545454545454 1/23 = 0.043478260869565217391304347826086956521739130434782608 1/24 = 0.041666666666666666666666666666666666666666666666666666 1/25 = 0.040000000000000000000000000000000000000000000000000000 1/26 = 0.038461538461538461538461538461538461538461538461538461 1/27 = 0.037037037037037037037037037037037037037037037037037037 1/28 = 0.035714285714285714285714285714285714285714285714285714 1/29 = 0.034482758620689655172413793103448275862068965517241379 1/30 = 0.033333333333333333333333333333333333333333333333333333 1/31 = 0.032258064516129032258064516129032258064516129032258064 1/32 = 0.031250000000000000000000000000000000000000000000000000 1/33 = 0.030303030303030303030303030303030303030303030303030303 1/34 = 0.029411764705882352941176470588235294117647058823529411 1/35 = 0.028571428571428571428571428571428571428571428571428571 1/36 = 0.027777777777777777777777777777777777777777777777777777 1/37 = 0.027027027027027027027027027027027027027027027027027027 1/38 = 0.026315789473684210526315789473684210526315789473684210 1/39 = 0.025641025641025641025641025641025641025641025641025641 1/40 = 0.025000000000000000000000000000000000000000000000000000 1/41 = 0.024390243902439024390243902439024390243902439024390243 1/42 = 0.023809523809523809523809523809523809523809523809523809 1/43 = 0.023255813953488372093023255813953488372093023255813953 1/44 = 0.022727272727272727272727272727272727272727272727272727 1/45 = 0.022222222222222222222222222222222222222222222222222222 1/46 = 0.021739130434782608695652173913043478260869565217391304 1/47 = 0.021276595744680851063829787234042553191489361702127659 1/48 = 0.020833333333333333333333333333333333333333333333333333 1/49 = 0.020408163265306122448979591836734693877551020408163265 1/50 = 0.020000000000000000000000000000000000000000000000000000 1/51 = 0.019607843137254901960784313725490196078431372549019607 3210/93 = 34.516129032258064516129032258064516129032258064516129032 Конец выполнения программы! Обсуждение полученных результатов Если смотреть в масштабе вечности, мы только что вылупились из яйца. Зодчий из мультипликационного познавательного фильма “Букет из сада геометрии ” В результате прогона мы убедились, что все вычисленные результаты правильны. Однако прогон теста показал не только работоспособность построенной системы, но и сложность использования разработанных функций. И это не удивительно, так как мы построили решение задачи скорее в стиле Фортрана-66, чем С. Главная функция main неоправданно громоздка. Структуры данных использованы слабо, поэтому спи- ски параметров довольно длинны. Так что полученное нами решение все же стоит усовершенствовать. Усовершенствование программы С чего же начать усовершенствование программы? Можно, например, попытаться заново построить более совершенную программу, что означало бы отказ от ранее про- тестированных кодов. В лучшем случае мы могли бы использовать отдельные фраг- менты (едва ли их удастся состыковать один с другим — это противоречит концепции 48 Глава 3
модульности программы). Классическая технология программирования говорит: “разделяй и властвуй”. Поэтому прежде всего нужно решить одну из проблем: упро- стить главную функцию main или упростить списки параметров. Легче всего решить задачу упрощения главной функции main: для этого нужно всего лишь пополнить на- бор имеющихся функций. Итерация 1: пополнение набора функций Итак, предпримем первую (несколько робкую) попытку исправить положение. Чтобы упростить главную функцию main, достаточно создать вспомогательную функ- цию fract, которая выполняет все необходимые подготовительные действия и фак- тически создает дробь: integerdigit * fract(integer numerator, /* числитель ★/ integer denominator, /* знаменатель ★/ integer DecPrec /* точность ★/) { integer base = Mbase(denominator); integer k = Kw(DecPrec, base); integerdigit * c = new integerdigit[k+1]; c[0] = k; divn(numerator, /* числитель ★/ denominator, /* знаменатель ★/ с, /* массив цифр дроби ★/ base /* основание системы счисления ★/); printf("%4d/%2d = ", numerator, denominator); print(c, outputdigit,’base); /* печать дроби */ delete [] c; return c; } Теперь функция main будет выглядеть гораздо проще: int main() { integer N = 2; /★ знаменатель */ integer a; /* числитель ★/ integer b; /* знаменатель */ do { fract(a = 1 /* числитель */, b = N /★ знаменатель ★/, 100); } while (N++ < 51) ; fract(a = 3210 /* числитель ★/, b = 93 /* знаменатель ★/, 20); printf("ХпКонец выполнения программы!"); return 0; } Тестирование, конечно, провести необходимо. В данном случае оно подтвердит, что программа выдает правильные результаты. Однако по-прежнему нас беспокоит громоздкость списков параметров. Практика объектно-ориентированного программирования 49
Итерация 2: усовершенствование организации данных Приступая ко второму этапу усовершенствования программы (упрощение списков параметров), мы понимаем, что необходимо уменьшить количество параметров разра- ботанных нами функций. Один из путей — заменить некоторые параметры глобаль- ными переменными. Однако это разрушит модульную структуру программы и потому категорически не рекомендуется технологией структурного программирования. Зна- чит, в соответствии с технологией структурного программирования остается только один путь: усовершенствование организации данных. В языке С для этого предусмот- рено классическое средство — структуры. Введение структур данных Сотри случайные черты, и ты увидишь — мир прекрасен. Александр Блок Поскольку фактически функциям программы передаются десятичные дроби (и от- носящиеся к ним величины вроде основания системы счисления), то, чтобы упро- стить списки параметров, нам необходима структура, представляющая всю дробь це- ликом: ее числитель, знаменатель, массив цифр и количество элементов в нем, а так- же основание системы счисления, в которой записана дробь. struct sDecimal /* С: ДЕСЯТИЧНАЯ ДРОБЬ: */ { integer numerator; /* числитель */ integer denominator; /* знаменатель ★/ integer DecPrec; /* точность ★/ integer base; /* основание системы счисления ★/ /★ цифры дроби хранятся в массиве с, в с[0] - кол-во цифр ★/ integerdigit * с; }; typedef struct sDecimal Decimal; typedef Decimal * pDecimal; Определив нужную структуру, мы позаботились (в двух последних строках) также об удобстве ее использования. Так что можем приступить к упрощению функций. Упрощение функций Разработанные нами функции не используют только что определенную структуру данных, поэтому их нужно усовершенствовать. Но бояться нечего. Почти во всех функциях изменения будут носить чисто косметический характер и это не удивитель- но — ведь эти функции успешно прошли испытания. Однако есть одно весьма серь- езное исключение: функция fract. Действительно, в настоящем виде эта функция выполняет подготовительные действия, создает дробь, вычисляет, распечатывает ее и затем... уничтожает! Конечно, освободить занимаемую дробью память необходимо, но только после того, как сама дробь не нужна. В нашей программе дробь не нужна сра- зу после распечатки, но в реальном калькуляторе не исключено ее повторное исполь- зование. Поэтому функция fract должна возвращать созданную дробь для дальней- шего использования. В качестве параметров функция fract должна иметь только числитель, знаменатель и точность представления дроби. pDecimal fгасt(integer numerator, /* числитель ★/ integer denominator, /★ знаменатель ★/ integer DecPrec /★ точность */) { pDecimal pFract = new Decimal; 50 Глава 3
/★ основание системы счисления ★/ pFract -> base = Mbase(denominator); integer к = Kw(DecPrec, pFract -> base); pFract -> numerator = numerator; /* числитель ★/ pFract -> denominator = denominator; /★ знаменатель ★/ /★ цифры дроби хранятся в массиве с, в с[0] - к-во цифр ★/ pFract -> с = new integerdigit[к+1]; pFract -> с[0]= к; divn(pFract); /* вычисление цифр дроби */ return pFract; } Теперь можно существенно упростить список параметров функции divn. В качест- ве параметра ей достаточно передать лишь указатель на созданную структуру. /* деление натуральных чисел с произвольной точностью: */ integer divn(pDecimal pFract) { div_t d; /★ структура для хранения частного и остатка */ integerindex i; /* i - номер обрабатываемой цифры ★/ integer а = pFract -> numerator; /★ числитель ★/ integer b = pFract -> denominator; /* знаменатель ★/ integer k = pFract -> c[0]; /* количество цифр ★/ integer base = pFract -> base; /* основание системы счисления */ for(i = 1; i <= k; i++) /* вычисление k цифр дроби */ { d = div(a, b); /* вычисление очередного частного и остатка ★/ pFract -> c[i] = d.quot; /★ вычисление очередной цифры ★/ а = d.rem * base; /★ вычисление очередного делимого ★/ } return а; /* возвращаем делимое для следующего шага */ } Наконец, упрощения коснутся и программ печати дроби. Прежде всего упростим программу печати десятичной дроби: /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ: */ void printDecFract(pDecimal pFract) { integerindex i; /★ параметр цикла ★/ int w; /* количество десятичных позиций для М-ичной цифры ★/ int k = pFract -> с[0]; /* количество цифр ★/ w =•numdigit((pFract -> base) - 1, 10); printf("%2d", pFract -> c[l]); /★ целая часть ★/ printf("."); /* десятичная точка */ for (i = 2; i <= k; i++) / * печать дробной части ★/ { outputdigit(pFract -> c[i], w, 10); /★ цифра дробной части ★/ } printf("\n"); } Теперь совсем несложно написать программу печати обыкновенной дроби и рав- ной ей десятичной: /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ И РАВНУЮ ЕЙ ДЕСЯТИЧНУЮ ДРОБЬ: */ void printFract(pDecimal pFract) Практика объектно-ориентированного программирования 51
{ printf("%4d/%2d = ", pFract -> numerator, /* числитель ★/ pFract -> denominator /* знаменатель ★/); printDecFract(pFract); /★ печать десятичной дроби ★/ } Теперь мы можем не только создать несколько дробей, но и распечатать их сколь- ко угодно раз и в каком угодно порядке. Однако в конце концов память, занятую не- нужными дробями, следует возвратить операционной системе. Поэтому необходима подпрограмма, уничтожающая дроби. void deleteFract(pDecimal pFract) { /* сначала освобождаем память, занятую массивом цифр ★/ delete [].pFract -> с; /* затем освобождаем память, занятую структурой ★/ delete pFract; } Фактически у нас все готово для испытания новой версии программы. Испытание усовершенствованной программы Осталось все подпрограммы собрать в одну. #include <stdio.h> #include <limits.h> #include <math.h> #include <stdlib.h> typedef int integer; typedef int integerdigit; typedef int integerindex; struct sDecimal /* С: ДЕСЯТИЧНАЯ ДРОБЬ: */ { integer numerator; /* числитель ★/ integer denominator; /★ знаменатель ★/ integer DecPrec; /★ точность ★/ integer base; /* основание системы счисления ★/ /★ цифры дроби хранятся в массиве с, в с[0] - к-во цифр ★/ integerdigit * с; }; typedef struct sDecimal Decimal; typedef Decimal * pDecimal; /★ количество цифр в числе digit при записи в системе счисления с основанием base */ int numdigit(integerdigit digit, integer base) { int k = 1;. while (digit /= base) k++; return k; } void outputdigit(integerdigit digit, int w, int base) 52 Глава 3
{ int к = w - numdigit(digit, base); while (k-- > 0) printf("0"); printf("%d", digit); } /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ: */ void printDecFract(pDecimal pFract) { integerindex i; /* параметр цикла */ int w; /★ количество десятичных позиций для М-ичной цифры ★/ int к = pFract -> с[0]; /* количество цифр ★/ w = numdigit((pFract -> base) - 1, 10); printf("%2d", pFract -> c[l]); /★ целая часть ★/ printf("."); /★ десятичная точка * / for (i = 2; i <= к; i + + ) /* печать дробной части ★/ { outputdigit(pFract -> c[i], w, 10); /* цифра дробной части */ } printf("\n"); } /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ И РАВНУЮ ЕЙ ДЕСЯТИЧНУЮ ДРОБЬ: */ void printFract(pDecimal pFract) { printf("%4d/%2d = ", pFract -> numerator, /* числитель ★/ pFract -> denominator /* знаменатель ★/); printDecFract(pFract); /* печать десятичной дроби */ } /* деление натуральных чисел с произвольной точностью: */ integer divn(pDecimal pFract) { div_t d; /* структура для хранения частного и остатка ★/ integerindex i; /* i - номер обрабатываемой цифры ★/ integer а = pFract -> numerator; /★ числитель ★/ integer b = pFract -> denominator; /* знаменатель ★/ integer к = pFract -> c[0]; /* количество цифр */ integer base = pFract -> base; /* основание системы счисления ★/ for(i = 1,- i <= к; i + +) /* вычисление k цифр дроби ★/ { d = div(a, b); /★ вычисление очередного частного и остатка ★/ pFract -> с[i] = d.quot; /★ вычисление очередной цифры ★/ а = d.rem * base; /★ вычисление очередного делимого ★/ } return а; /★ возвращаем делимое для следующего шага ★/ } /★ количество тех элементов массива, в которые записываются base-ичные цифры ★/ integer Kw(integer DecPrec, integer base) { /★ количество десятичных цифр в элементе массива ★/ Практика объектно-ориентированного программирования 53
integer m = numdigit(base-1, 10); div_t d = div(DecPrec, m); /* количество элементов массива для записи десятичных цифр ★/ integer w = d.quot + 1 + (d.rem? 1 : 0); return w; } integer Mbase(integer denominator) { /* начальное значение M ★/ integer M = 1000000; while (denominator >= LONG_MAX/M) M /= 10; if (M < 2) { printf("\n*** Знаменатель %d слишком велик.Xn", denominator); M = 10; } return M; } pDecimal fract(integer numerator, /★ числитель ★/ integer denominator, /★ знаменатель ★/ integer DecPrec /★ точность ★/} { pDecimal pFract = new Decimal; /★ основание системы счисления */ pFract -> base = Mbase(denominator); integer k = Kw(DecPrec, pFract -> base); pFract -> numerator = numerator; /★ числитель ★/ pFract -> denominator = denominator; /★ знаменатель ★/ /★ цифры дроби хранятся в массиве с, в с[0] - к-во цифр ★/ pFract -> с = new integerdigit[k+1]; pFract -> c[0]= k; divn(pFract); /★ вычисление цифр дроби ★/ return pFract; } void deleteFract(pDecimal pFract) { /★ сначала освобождаем память, занятую массивом цифр ★/ delete [] pFract -> с; /★ затем освобождаем память, занятую структурой ★/ delete pFract; int main() { integer N = 2; /★ знаменатель */ integer a; /* числитель ★/ integer b; /★ знаменатель ★/ pDecimal pFract; 54 Глава 3
do { pFract = fract(a = 1 /* числитель ★/, b = N /* знаменатель */, 100 /★ точность ★/); printFract(pFract); /★ печать дроби ★/ deleteFract(pFract); /* уничтожение дроби ★/ } while (N++ < 51) ; pFract = fract(a = 3210 /★ числитель ★/, b = 93 /* знаменатель */, 20 /★ точность */); printFract(pFract); /* печать дроби ★/ deleteFract(pFract); /* уничтожение дроби ★/ printf("ХпКонец выполнения программы!"); return 0; } Осталось запустить собранную нами программу и проверить результаты прогона. Обсуждение результатов и технологии программирования Проделана большая работа и наградой нам будет совпадение результатов тестового прогона полученной программы с результатами прогона предыдущей версии про- граммы. И потому сейчас можно отвлечься от самой программы и проанализировать процесс ее разработки. Конечно, мы использовали нисходящую технологию. Но тем не менее, не все было сделано за один присест — фактически у нас получилось не- сколько версий программы. Первая версия хоть и работала правильно, была несколь- ко неуклюжа (помните эти бесконечные списки параметров?). Воспользоваться полу- ченным набором функций в калькуляторе было бы затруднительно. Причина — слишком узкая специализация функций и нечеткая организация данных. Поэтому пришлось приступить к разработке усовершенствованной версии программы, причем технология ее разработки имела характерную особенность: итеративный подход к процессу разработки. Преимущества итеративного подхода и условия его применения Была ли начата разработка усовершенствованной версии программы с самого на- чала? Нет. Как мы помним, разработка носила итеративный характер — это неотъем- лемая черта современной технологии программирования. Вместо того чтобы начать проектирование программы заново, нужно попытаться усовершенствовать уже рабо- тающий программный продукт. Однако для того чтобы это было возможно, имеющая- ся программа должна удовлетворять определенным требованиям. Конечно, прежде всего она должна быть модульной. Ведь в противном случае изменения затронут всю программу. Если же программа модульная, то усовершенствовать можно отдельные ее модули. Собственно, мы так и поступили. Например, если принять формулу мо- дуль = функция, то, как вы помните, нам пришлось переделывать далеко не все функ- ции. Действительно, на первой итерации мы существенно изменили только функцию main — разгрузили ее от необходимых подготовительных действий, перенеся их в специально разработанную подпрограмму. На второй итерации мы существенно усо- вершенствовали организацию данных и благодаря этому удалось повысить не только модульность программы, но и универсальность подпрограмм-функций. Правда, на этот раз переделка затронула гораздо большее число функций, но во многих из них, к тому моменту уже отлаженных, переделка носила чисто косметический характер (тривиальное упрощение списков параметров и не менее тривиальная замена отдель- Практика объектно-ориентированного программирования 55
ных элементов данных членами структуры). Именно итеративный характер разработ- ки позволяет избежать кардинальных переделок, благодаря чему удается поддерживать работоспособную версию программы. Поскольку уж мы углубились в обсуждение тех- нологии, самое время изучить некоторые вопросы технологии программирования. Философия технологии программирования. Отличие технологии программирования от геометрии Различные ветви геометрии находятся в тесных и часто неожи- данных взаимоотношениях друг с другом. Давид Гильберт Видимо, мыслима какая-то новая кристаллография, которую с полным правом, по образцу неевклидовой геометрии, можно назвать нефедоровской. А. А. Любищев Заблуждения, заключающие в себе некоторую долю правды, самые опасные. Адам Смит Не сомневаюсь, что вы еще до того, как открыли эту книгу, слышали определение технологии и, несомненно, знакомы с этим понятием не только на словах. Тем не менее, возможно именно сейчас стоит обсудить (и осудить!) некоторые заблуждения, связанные с понятием технологии программирования. Заблуждение 1: технология программирования представляет собой нечто вроде свода правил чистописания, соблюдение которых неописуемо радует университетских профессо- ров, но при необходимости некоторыми из этих правил можно пожертвовать. Я не стану уговаривать вас соблюдать технологию программирования только потому, что за это ратуют университетские профессора. Чтобы опровергнуть заблуждение 1, я просто укажу контрпример. В качестве контрпримера можно взять разработку нашей про- граммы. Если бы мы пренебрегли принципом модульности, мы не могли бы приме- нить итеративный подход. А вот кляксы, несомненно, соскакивавшие с пера Ньюто- на, не помешали ему создать теорию тяготения. Так что технология программирова- ния представляет собой скорее целостную систему понятий, нечто вроде Евклидовой геометрии. И отсюда вытекает одно очевидное первое следствие, впрочем, которого обычно не придерживаются ни неопытные программисты, ни их преподаватели: при- менению технологии программирования нужно учиться подобно тому, как учатся решать геометрические задачи. Возможно, вы считаете, что я несколько преувеличил насчет тотального пренебрежения к этому следствию. Тогда сопоставьте количество задачни- ков по программированию на всевозможных языках (оно, наверное, сопоставимо с количеством сборников геометрических задач) с количеством задачников по технологии программирования. (Сколько вы можете вспомнить таковых?) Несколько менее очевид- ным является второе следствие: подобно тому, как равноправно существуют неевклидовы геометрии, могут существовать и различные технологии программирования. Это утвержде- ние в настоящее время представляется многим не менее спорным, чем существование неевклидовых геометрий во время создания геометрии Лобачевского Ф. Швейкартом, Ф. Тауринусом, К. Ф. Гауссом, Я. Больяи и, конечно, Н. И. Лобачевским. И подобно тому, как противники неевклидовой геометрии с почти религиозным фанатизмом травили ее создателей, некоторые программисты отстаивают единственность правиль- ной технологии программирования. Современная классическая технология програм- мирования не допускает использования безусловного оператора передачи управления 56 Глава 3
(этого ужасного goto). Его недопустимости посвящено огромное число работ. Однако один из величайших теоретиков и практиков современного программирования, Д. Кнут, написал не одну статью в защиту применения этого оператора при опреде- ленных условиях. Его бессмертный трехтомник Искусство программирования для.ЭВМ, с неизменным успехом переиздающийся с 1968 года (и с тех пор мы неустанно ждем его очередные четыре тома), содержит беспрецедентное количество вполне техноло- гичных (и, несомненно, работающих и полезных) программ с “этим ужасным goto”. Просто технология другая. И здесь опять просматривается аналогия с геометрией. По- добно тому, как декартово произведение R" может служить в качестве носителя различ- ных геометрий, один и тот же язык программирования предоставляет средства для реа- лизации программ в соответствии с различными технологиями программирования. Однако попытка вычислить площадь комнаты с помощью геометрии Лобачевского, хотя и не приведет к ошибке, будет выглядеть несколько неестественной. Так что отказ от при- вычной технологии программирования в каждом конкретном случае допустим, но требует хотя бы обоснования. Наделяя один и тот же носитель разными геометриями, вы фактически получаете пространства с различными свойствами, а применяя различные технологии для построения программ, решающих одну и ту же задачу на одном и том же языке программирования, вы получите программы, отличающиеся своими свойствами. Однако геометрия существует несколько тысячелетий, а технология программирова- ния существенно моложе, причем ее теоретическая база значительно сложнее и из- вестна еще менее чем геометрия Лобачевского. Поэтому в геометрии совершенно не- возможно, например, следующее “решение” задачи о вычислении площади треуголь- ника: “Поскольку я не знаю геометрии Лобачевского, я не смог вычислить пло- щадь этого треугольника и потому решил, что он очень большой, а, значит, его пло- щадь бесконечно велика”. Даже если вы не знаете, что в геометрии Лобачевского площадь треугольника не превосходит некоторой константы, вы все равно не сочтете такое решение верным. Однако в программировании вполне обычна ситуация, когда “программист”, не желающий слышать ни о какой технологии программирования, “написал” программу и возмущается, почему же она не работает. Кто работал систем- ным программистом, и потому в силу своих обязанностей должен был консультиро- вать пользователей компиляторов, знает, что 90% консультаций приходится именно на таких “программистов”. Вы, надеюсь, знаете, что различные геометрии не просто мирно сосуществуют, а дополняют и обогащают друг друга. Классическим примером в этом отношении могут служить взаимосвязи геометрий Евклида и Лобачевского. Средствами геометрии Евклида можно (притом многими способами) интерпретиро- вать геометрию Лобачевского. Примером такой интерпретации может служить интер- претация Пуанкаре, весьма полезная для теории функции комплексного переменного и потому подробно обсуждаемая в полных курсах ТФКП. С другой стороны, в про- странстве Лобачевского есть поверхность (орисфера, или предельная сфера), на кото- рой выполняется геометрия Евклида. И этим обстоятельством мастерски воспользо- вался Лобачевский для вывода тригонометрических формул открытой им геометрии. А много ли вы знаете случаев, когда сторонники различных технологий программиро- вания хотя бы попытались понять друг друга? Завершая обсуждение заблуждения 1, можем сделать следующий вывод: технология программирования представляет собой не свод правил, а логическую систему, примене- ние которой требует не менее тщательного обоснования, а также подчас изобрета- тельности и искусства, чем применение геометрических теорем. Теперь можем перейти к обсуждению второго весьма распространенного заблуж- дения относительно технологии программирования. Заблуждение 2: современная технология программирования предназначена только для того, чтобы облегчить процесс создания первой работающей версии программы. Именно так подчас думают некоторые начинающие программисты. Однако это не совсем Практика объектно-ориентированного программирования 57
справедливо. Действительно, технология программирования существенно облегчает процесс создания первой работающей версии программы. Более того, без технологии программирования создать работающую версию программы зачастую просто невоз- можно. Однако если бы мы хотели только создать работающую версию программы, мы бы закончили процесс проектирования на самой неуклюжей версии и никакие итерации не потребовались бы. Дело же обстоит в некотором смысле совсем наобо- рот: цель технологии состоит в том, чтобы получить программу с заданными свойст- вами (структурированную, модульную, безошибочную и притом максимально удоб- ную в сопровождении). Ценность технологии как раз в том, что она позволяет при- близиться к идеалу. (Используя технологию, мастер создает шедевр, но, любуясь этим шедевром в Лувре, посетители не спрашивают, каких усилий художнику стоило не- укоснительное соблюдение технологии.) Иными словами, ценность технологии состо- ит в том, что она позволяет построить первое (или нулевое) приближение и затем превратить его в высокотехнологичный продукт. И эта вторая часть — превращение первого (или нулевого) приближения в высокотехнологичный продукт — не менее важна, чем первая. Но именно эта вторая часть требует дополнительной работы. Рабо- ты, которую новички (да и просто нерадивые программисты) считают слишком обре- менительной. Но если такая работа не выполняется, заказчик получает сырой, лабора- торный, а не промышленный программный продукт. Посмотрим, например, что произошло бы, если бы мы прекратили разработку по- сле получения первой работающей программы. Предположим, что в калькуляторе возникла необходимость делить десятичные дроби на небольшие натуральные числа. Самого такого объекта как десятичная дробь у нас нет, и пришлось бы вспоминать, из чего же состоит дробь. Да, из массива цифр, конечно. Но есть еще и количество эле- ментов в нем, и основание системы счисления, в которой эта дробь была вычислена. А если бы понадобилось напечатать дробь? Где искать ее числитель и знаменатель, да и сохранились ли они вообще? Ничего такого в программе не было. И встала бы ди- лемма: или начать разработку с самого начала, или разобраться в имеющейся про- грамме от начала до конца и переработать ее так, чтобы получить нужное нам реше- ние. Первый подход (начать разработку с самого начала) означает, что мы опять изо- бретаем велосипед, и пользы из имеющегося решения не извлекаем. Второй же подход (разобраться в программе от начала до конца) еще более трудоемкий, посколь- ку разбираться в первоначально полученной программе труднее, чем написать ее за- ново. Ведь первоначально полученная нами программа, представляющая собой некий скорее лабораторный, но никак не промышленный объект, скорее демонстрировала принципиальную возможность вычисления десятичной дроби с произвольной точно- стью, чем действительно создавала десятичную дробь, пригодную для дальнейшей об- работки в программе. Поэтому-то в первоначальной версии программы дробь сразу же распечатывалась, и программа приступала к демонстрации возможности вычисле- ния следующей дроби. Это похоже на фокус, показываемый в университетской хими- ческой лаборатории: демонстрируется принципиальная возможность получения веще- ства в результате таких-то и таких-то химических реакций; с помощью тонких мето- дов вроде спектрального анализа удается даже обнаружить следы полученного вещества, а вот получить вещество в количестве, имеющем промышленное значение, никак не удается; Для того чтобы получить вещество в количестве, имеющем про- мышленное значение, нужна другая, не лабораторная, а промышленная установка. Так и в программировании: чтобы получить дроби в виде, пригодном для “промыш- ленного” использования, нам пришлось усовершенствовать программу, притом не од- нажды! Но есть и отличие: химикам проще. Действительно, те, кто собирается синте- зировать новые вещества, учатся в университетах, а те, кто собирается получать веще- ства в промышленно значимых количествах — в химико-технологических институтах. Химики-технологи используют результаты, полученные хи ми кам и-теоретикам и. Но для того, чтобы химики-технологи могли воспользоваться результатами, полученными 58 Глава 3
химиками-теоретиками, последние должны оформлять свои результаты в форме, пригодной для дальнейшего использования коллегами. Например, в виде статей, мо- нографий, лабораторных установок (или их описаний). В программировании ситуация несколько иная. Программист обычно должен знать и уметь все! (В том числе и хи- мию, притом в объеме курса и университетов, и химико-технологических институтов, если его программа связана с автоматизацией управления химическим процессом!) Один и тот же программист должен довести программу от “лабораторного” вида до промышленного. А поскольку это один и тот же человек, возникает соблазн отказать- ся от описания “лабораторной” формы. На деле это приводит к тому, что подготовка документации откладывается, или вместо документации создаются трудно читаемые и не соответствующие программе отписки. Кроме того, часто возникает соблазн объя- вить первую работающую версию окончательной. Этот соблазн отсутствием финанси- рования зачастую усиленно поддерживает скупой заказчик. Кстати, знаете сколько раз платит скупой заказчик? Многие думают, что дважды. Это заблуждение. Скупой за- казчик, помимо оплаты первого (обычно сырого) продукта, платит за каждую после- дующую итерацию, притом размер “взноса” обычно возрастает в геометрической прогрессии. Если бы усовершенствование калькулятора вам заказал скупой заказчик, то за добавление десятичных дробей ему пришлось бы платить не менее трех раз! Но не думайте, что для программиста выгодно иметь такого заказчика. Дело в том, что скупой заказчик, даже обладая суммами, значительно превосходящими реальную стоимость заказа, никогда сразу ее не выложит. Он постарается найти тех, кто возь- мется сделать нужный ему продукт за наиболее низкую цену. И тут его поджидают всевозможные жулики, готовые взяться за работу на любых условиях, ведь они не со- бираются выполнять работу вообще! Вместо этого они просто получат денежки, под- сунув какой-либо суррогат. Конечно, им тоже хочется, чтобы денежек было поболь- ше, но они согласятся и на меньшее; просто чтобы выманить кругленькую сумму, де- нежки получать им придется чаще. А это не такое уж неприятное занятие! Так что скупой заказчик, скорее всего, останется и без программного продукта, и без денежек. Для программиста такой заказчик абсолютно бесполезен, ему нужен заказчик, кото- рый понимает реальную цену и готов ее заплатить. И в этом случае программист дол- жен изготовить высококачественную и высокотехнологичную программу, чтобы при изменении требований со стороны заказчика можно было оперативно их удовлетво- рить. Именно для разработки таких программ и предназначена технология програм- мирования. Теперь рассмотрим с этой точки зрения полученный нами программный продукт. Конечно, он еще не готов для передачи заказчику — нужно оформить всю необходи- мую документацию в виде документов (электронных или бумажных), а не ограни- читься короткими заметками. Но если рассматривать полученную нами программу как текст с точки зрения структурного программирования, то мы практически исчер- пали возможности этой технологии. Почему “практически”? Дело в том, что возмож- ности технологии зависят также от инструментов, используемых при изготовлении продукта. Главнейшим инструментом для изготовления программ является язык про- граммирования (точнее, компилятор), поэтому возможности технологии программи- рования в значительной степени зависят от наличия определенных средств в языке программирования. Вы наверняка знаете, что язык С не относится к языкам с блоч- ной структурой. A Pascal, например, все необходимые средства имеет. Поэтому если бы мы написали программу на языке Pascal, мы могли бы повысить модульность программы средствами языка, локализовав одни функции внутри других. В языке С, как вы знаете, такое вложение невозможно. Для очередной серии итераций, направ- ленных на существенное продвижение по пути к идеалу, необходим другой подход, возможно даже новая парадигма программирования. Практика объектно-ориентированного программирования 59
Применение объектно-ориентированного программирования Теперь, достигнув вершин структурного программирования, давайте посмотрим, какие новые возможности открывает перед нами ООП. Прежде всего, чтобы эффек- тивно использовать объектно-ориентированный подход, нужно перейти к объектно- ориентированному языку, к C++. На самом деле, хотя мы и не акцентировали на этом внимание, в последней версии программы мы использовали операции new и delete, так что фактически мы уже перешли к объектно-ориентированному языку C++, хотя и не использовали его объектно-ориентированные средства. И вот сейчас появится Маг, Волшебник и Великий Престидижитатор с таким загадочным ино- странным именем OOP... И по Вашему Хотенью, Магову Веленью вы вознесетесь на самые высокие вершины и все ваши проблемы разрешатся по одному прикосновению к волшебной палочке... Ничего подобного. Чтобы увидеть пейзаж с высоты Эвереста, сначала нужно взой- ти на Эверест, а чтобы программа стала объектно-ориентированной, нужно приме- нить объектно-ориентированный подход. (Это вы, конечно, знали и раньше.) Фокус (или магия) объектно-ориентированного подхода как раз и состоит в том, что он по- зволяет сделать программу объектно-ориентированной, а совсем не в том, что про- грамма преобразуется, подобно Царевне-лягушке, в Василису Прекрасную по одному вашему хотенью. (По аналогии с восхождением на Эверест: чтобы взойти, нужно при- ложить усилия и умения, но с одним снаряжением восхождение возможно, а с другим вершина принципиально недостижима при любых затратах.) Хотя мы еще к применению объектно-ориентированного подхода не приступали, уже можно сделать вывод: если в программе на языке С используются только конст- рукции, совместимые (синтаксически и семантически) с C++, то для перехода к C++ в программе никаких изменений не понадобится. Конечно, лучше всего программу писать на C++ с самого начала, но если вы пишете на С, то старайтесь пользоваться только совместимыми конструкциями. Это облегчит переход в дальнейшем, если он потребуется. Объективный взгляд на мир: объектно-ориентированный анализ (ООА) Гораздо легче найти ошибку, чем истину. Гете Теперь пора приниматься за дело. И поскольку объектно-ориентированная про- грамма состоит из взаимодействующих объектов, браться придется не за волшебную палочку, а за объектно-ориентированный анализ (ООА). Именно объектно-ориентиро- ванный анализ позволяет определить, какие типы объектов нам нужны, и как они должны взаимодействовать. Фактически объектно-ориентированный анализ мы уже провели: ведь мы знаем, что в программе нужны следующие объекты: дроби и нату- ральные числа. Мы также выяснили, как должны взаимодействовать эти объекты. (Иными словами, мы знаем; какие операции должны выполнять объекты.) Понадоби- лось не так уж много операций: создание и уничтожение объектов, а также их распе- чатка, поэтому можно считать, что с такой частью объектно-ориентированного подхо- да, как объектно-ориентированный анализ, мы уже справились. Теперь осталось во- плотить результаты объектно-ориентированного анализа в программе, т.е. выразить на языке программирования (в нашем случае это, конечно, C++). 60 Гпава 3
Как воплотить результаты объектно-ориентированного анализа в программе? Прежде всего отметим, что язык C++ не является чистым с точки зрения объект- но-ориентированного программирования — в нем некоторые объекты не рассматри- ваются как таковые. Возьмем, например, небольшие натуральные числа. Хотя с точки зрения ООП они могут рассматриваться в качестве объектов, в самом C++ для этого никаких средств не предусмотрено. (Нужно отметить, что с точки зрения пуристов это недопустимое грехопадение, но никаких серьезных проблем из-за такого грехопаде- ния обычно не возникает.) Да и в нашей программе вполне достаточны те средства (операции), которые предусмотрены в языке. Так что реализацию небольших нату- ральных чисел (тип integer) изменять не будем. Что же касается дробей, то здесь си- туация совершенно противоположная: для одного лишь их создания понадобились специальные процедуры. Значит, это настоящие объекты (в том числе и с точки зре- ния языка программирования). Возможно, вы знаете, что сущностью объектов явля- ются классы. Философы это могли бы переформулировать так: причиной существова- ния объектов являются классы. Но дроби (объекты) в программе существуют. Значит, существует и причина их существования. Но где же класс, представляющий дроби? Ведь слово class в программе не встречается. Зато, наверное, есть конструкция, эк- вивалентная ему. Если вы еще не отгадали, какая это конструкция, вспомните, что в C++ структуры эквивалентны классам с открытыми членами! Так что в нашей про- грамме классы фактически уже используются, но в несколько скрытой форме. И не- смотря на то, что форма этого использования скрытая, члены-данные этого класса от- крыты. Давайте задумаемся: а зачем? И действительно, они нужны только классу и потому их лучше защитить. Для этого придется использовать другой, явный синтаксис определения класса. class Decimal /* С: ДЕСЯТИЧНАЯ ДРОБЬ: */ { integer numerator; /★ числитель ★/ integer denominator; /* знаменатель ★/ integer DecPrec; /* точность ★/ integer base; /* основание системы счисления ★/ /* цифры дроби хранятся в массиве с, в с[0] - к-во цифр ★/ integerdigit * с; public: Decimal(integer numerator, /* числитель ★/ // конструктор integer denominator, /★ знаменатель ★/ integer DecPrec /★ точность */); -Decimal(); // деструктор void printDec(); /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ */ /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ И РАВНУЮ ЕЙ ДЕСЯТИЧНУЮ ДРОБЬ: */ void Decimal::print(); protected: /★ деление натуральных чисел с произвольной точностью: ★/ void divn(); /* количество тех элементов массива, в которые записываются base-ичные цифры ★/ integer Kw(integer DPrec, integer base); integer Mbase(integer denom); /★ основание системы счисления ★/ }; typedef Decimal * pDecimal; Практика объектно-ориентированного программирования 61
Decimal::Decimal(integer num, /★ числитель ★/ integer denom, /★ знаменатель ★/ integer DPrec /★ точность */) { base = Mbase(denom); /★ основание системы счисления ★/ DecPrec = DPrec; /* точность ★/ integer к = Kw(DecPrec, base); numerator = num; /★ числитель ★/ denominator = denom; /★ знаменатель ★/ /★ цифры дроби хранятся в массиве с, в с[0] - к-во цифр ★/ с = new integerdigit[к+1]; с[0] = к; divn(); /★ вычисление цифр дроби ★/ } /★ деление натуральных чисел с произвольной точностью: ★/ void Decimal::divn() { div_t d; /★ структура для хранения частного и остатка ★/ integerindex i; /★ i - номер обрабатываемой цифры ★/ integer а = numerator; /* числитель ★/ integer b = denominator; /★ знаменатель ★/ integer к = с[0]; /★ количество цифр ★/ for(i = 1; i <= к; i++) /* вычисление к цифр дроби ★/ { d = div(а, b); /★ вычисление очередного частного и остатка ★/ c[i] = d.quot; /* вычисление очередной цифры ★/ а = d.rem * base; /★ вычисление очередного делимого ★/ } } /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ: */ void Decimal::printDec() { integerindex i; /★ параметр цикла ★/ int w; /★ количество десятичных позиций для М-ичной цифры ★/ int к = с[0]; /★ количество цифр ★/ w = numdigit(base - 1, 10); printf("%2d", c[l]); /★ целая часть ★/ printf("."); /★ десятичная точка ★ / for (i = 2; i <= к; i++) /★ печать дробной части ★/ { outputdigit(с[i], w, 10); /★ цифра дробной части ★/ } printf("\n"); } /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ И РАВНУЮ ЕЙ ДЕСЯТИЧНУЮ ДРОБЬ: */ void Decimal:-.print () { printf("%4d/%2d = ", numerator, /★ числитель */ denominator /★ знаменатель ★/); 62 Глава 3
printDec(); /* печать десятичной дроби ★/ /★ количество тех элементов массива, в которые записываются base-ичные цифры ★/ integer Decimal::Kw(integer DPrec, integer base) ♦ { /★ количество десятичных цифр в элементе массива ★/ integer m = numdigit(base-1, 10); div_t d = div(DPrec, m); /★ количество элементов массива для записи десятичных цифр ★/ integer w = d.quot + 1 + (d.rem? 1 : 0); return w; } integer Decimal::Mbase(integer denom) { /★ начальное значение M ★/ integer M = 1000000; while (denom >= LONG_MAX/M) M /= 10; if (M < 2) { printf("\n** * Знаменатель %d слишком велик.\n", denom); M = 10; } return M; } Decimal::-Decimal() { /★ освобождаем память, занятую массивом цифр ★/ delete [] с; } Как видим, благодаря явному введению класса дробей, существенно упростились механизмы доступа к данным и вызовы методов. Во многих методах список парамет- ров оказалось возможным опустить. Аналогичные упрощения нужно сделать и в глав- ной программе main. После этого останется только собрать все в одну программу: ttinclude <stdio.h> #include <limits.h> ttinclude <math.h> #include <stdlib.h> typedef int integer; typedef int integerdigit; typedef int integerindex; /★ количество цифр в числе digit при записи в системе счисления с основанием base ★ / int numdigit(integerdigit digit, integer base); void outputdigit(integerdigit digit, int w, int base); Практика объектно-ориентированного программирования 63
/★ количество цифр в числе digit при записи в системе счисления с основанием base ★/ int numdigit(integerdigit digit, integer base) { int к = 1; while (digit /= base) k++; return k; } void outputdigit(integerdigit digit, int w, int base) { int к = w - numdigit(digit, base); while (k-- > 0) printf("0"); printf("%d", digit); } class Decimal /* С: ДЕСЯТИЧНАЯ ДРОБЬ: */ { integer numerator; /★ числитель ★/ integer denominator; /★ знаменатель ★/ integer DecPrec; /★ точность ★/ integer base; /★ основание системы счисления */ /* цифры дроби хранятся в массиве с, в с[0] - к-во цифр */ integerdigit * с; public: Decimal(integer numerator, /* числитель */ // конструктор integer denominator, /* знаменатель */ integer DecPrec /* точность */) ; ~Decimal(); // деструктор void printDec(); /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ */ /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ И РАВНУЮ ЕЙ ДЕСЯТИЧНУЮ ДРОБЬ: */ void Decimal:: print () ; protected: /★ деление натуральных чисел с произвольной точностью: ★/ void divn(); /* количество тех элементов массива, в которые записываются base-ичные цифры ★/ integer Kw(integer DPrec, integer base); integer Mbase(integer denom); /* основание системы счисления ★/ }; typedef Decimal * pDecimal; Decimal::Decimal(integer num, /* числитель */ integer denom, /★ знаменатель */ integer DPrec /★ точность */} { base = Mbase(denom); /★ основание системы счисления ★/ DecPrec = DPrec; /★ точность ★/ integer k = Kw(DecPrec, base); 64 Глава 3
numerator = num; /★ числитель */ denominator = denom; /★ знаменатель */ /★ цифры дроби хранятся в массиве с, в с[0] - к-во цифр ★/ с = new integerdigit[к+1]; с[0] = к; divn(); /* вычисление цифр дроби ★/ } /* деление натуральных чисел с произвольной точностью: */ void Decimal::divn() { div_t d; /* структура для хранения частного и остатка */ integerindex i; /* i - номер обрабатываемой цифры ★/ integer а = numerator; /★ числитель ★/ integer b = denominator; /* знаменатель ★/ integer к = с[0]; /* количество цифр ★/ for(i =1; i <= к; i++) /* вычисление к цифр дроби ★/ { d = div(а, Ъ); /* вычисление очередного частного и остатка ★/ c[i] = d.quot; /* вычисление очередной цифры */ а = d.rem * base; /* вычисление очередного делимого */ } } /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ: */ void Decimal::printDec() { integerindex i; /* параметр цикла */ int w; /* количество десятичных позиций для М-ичной цифры */ int к = с[0]; /* количество цифр */ w = numdigit(base - 1, 10); printf("%2d", c[l]); /* целая часть */ printf/* десятичная точка ★/ for (i = 2; i <= к; i++) /* печать дробной части */ { outputdigit(с[i], w, 10); /★ цифра дробной части ★/ } printf("\n"); } /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ И РАВНУЮ ЕЙ ДЕСЯТИЧНУЮ ДРОБЬ: */ void Decimal::print() { printf("%4d/%2d = ", numerator, /★ числитель ★/ denominator /★ знаменатель */) ; printDec(); /* печать десятичной дроби ★/ } /★ количество тех элементов массива, в которые записываются base-ичные цифры ★/ Практика объектно-ориентированного программирования 65
integer Decimal::Kw(integer DPrec, integer base) { /★ количество десятичных цифр в элементе массива */ integer m = numdigit(base-1, 10); div_t d = div(DPrec, m) ; /★ количество элементов массива для записи десятичных цифр ★/ integer w = d.quot + 1 + (d.rem? 1 : 0) ; return w; } integer Decimal::Mbase(integer denom) { /★ начальное значение M ★/ integer M = 1000000; while (denom >= LONG_MAX/M) M /= 10; if (M < 2) { printf("\n** * Знаменатель %d слишком велик.\n", denom); M = 10; } return M; } Decimal::-Decimal() { /★ освобождаем память, занятую массивом цифр ★/ delete [] с; } int main() { integer N = 2; /★ знаменатель */ integer a; /★ числитель ★/ integer b; /★ знаменатель */ do { Decimal Fract(a = 1 /★ числитель */, b = N /★ знаменатель */, 100 /* точность ★/); Fract.print(); /★ печать дроби */ } while (N++ < 51) ; Decimal Fract(a = 3210 /★ числитель */, b = 93 /★ знаменатель ★/, 20 /★ точность ★/} ; Fract.print(); /★ печать дроби ★/ printf(”ХпКонец выполнения программы!"); return 0; } 66 Гпава 3
Чего же мы достигли? Обсуждение результатов и добавление новых функциональных возможностей ...это только говорят: венец творенья, венец творенья... А давно ли мы перестали бегать на четвереньках?! Зодчий из мультипликационного познавательного фильма “Букет из сада геометрии ” Упрощения, конечно, налицо. Но так ли уж они важны? Действительно ли сопро- вождение и модификация этой версии значительно легче, чем предыдущей? Можно ли в этом убедиться на практике? Пользователи вашего калькулятора — любители вы- числений с десятичными дробями — едва ли ограничат свои требования возможно- стью вычисления обыкновенных дробей с произвольной точностью. Предположим, они выдвинули очередной ультиматум: калькулятор должен с произвольной точностью вычислять результат деления десятичной дроби на небольшие натуральные числа. Деление десятичной дроби на натуральное число ...чтобы разделить десятичную дробь на натуральное число, на- до делить ее так же, как натуральное число, а запятую в част- ном поставить сразу, как только кончится деление целой части. Учебник математики для 5-6 классов (автор Л. Н. Шеврин и др.) Посмотрим, можно ли удовлетворить их очередное ультимативное требование и если можно, то каким образом это сделать. Фактически к классу дробей нужно доба- вить только один метод: деление десятичной дроби на натуральное число. Конечно, этот метод нужно объявить как общедоступный в классе Decimal, а также добавить его вызов в тестирующую программу. /★ деление десятичной дроби на натуральное число п с произвольной точностью: ★/ void Decimal::divn(integer n /★ делитель ★/) { div_t d; /* структура для хранения частного и остатка */ integerindex i; /* i - номер обрабатываемой цифры ★/ integer а = 0; /★ делимое ★/ integer к - с[0]; /★ количество цифр ★/ denominator *= п; /★ знаменатель ★/ for(i = 1; i <= к; i++) /★ вычисление к цифр дроби */ { d = div(a+c[i], n); /★ вычисление очередного частного и остатка ★/ с [i] = d.quot; /* вычисление очередной цифры ★/ а = d.rem * base; /★ вычисление очередного делимого ★/ } } Как-то так само собой получилось, что имя divn принадлежит двум разным функ- циям, имеющим разные сигнатуры. Во многих языках программирования это недо- пустимо, но только не в объектно-ориентированных! В объектно-ориентированных языках это явление называется полиморфизмом. Практика объектно-ориентированного программирования 67
Виды полиморфизма Полиморфизм достигается с помощью перегрузки. (В последней версии програм- мы мы перегрузили функцию divn — это пример перегрузки функций.) Перегрузка функции приводит к тому, что в программе используется полиморфизм функций. Одна- ко перегружать можно не только функции, но и операторы (знаки операций). Посту- пив так, мы бы получили полиморфизм операторов. (Здесь под операторами понимают- ся, конечно, знаки операций.) Давайте же теперь разберемся, извлекли ли мы пользу из полиморфизма функций? Полезность перегрузки функций Надеюсь, вы еще помните, что в последней версии программы мы воспользова- лись полиморфизмом функций: имя divn принадлежит двум разным функциям, имеющим разные сигнатуры. (Одна функция вообще без параметров, а вторая имеет один параметр типа integer.) Вроде бы и ничего особенного, а не пришлось выду- мывать новое имя, не пришлось запоминать, какое имя что означает, в чем разница в обращении к этим функциям и т.п. Более того, к одному из методов вы можете обра- титься, а другой — скрыт. Перегрузка операций Сейчас мы пойдем еще дальше и устраним необходимость запоминания имени до- бавленного метода. Тогда разработчику вообще ничего запоминать не придется, за ис- ключением того, что в качестве знака операции деления используется косая черта /! Иными словами, мы перегрузим знак операции /, т.е. воспользуемся полиморфизмом операций. Для этого придется изменить определение класса. class Decimal /* С: ДЕСЯТИЧНАЯ ДРОБЬ: */ { integer numerator; /★ числитель ★/ integer denominator; /* знаменатель ★/ integer DecPrec; /* точность ★/ integer base; /★ основание системы счисления ★/ /★ цифры дроби хранятся в массиве с, в с[0] - к-во цифр ★/ integerdigit * с; public: Decimal(integer numerator, /★ числитель */ // конструктор integer denominator, /★ знаменатель ★/ integer DecPrec /★ точность ★/); -Decimal(); // деструктор void printDecO; /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ */ /★ НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ И РАВНУЮ ЕЙ ДЕСЯТИЧНУЮ ДРОБЬ: */ void Decimal::print(); /★ деление десятичной дроби на натуральное число п с произвольной точностью: ★/ // Перегрузка знака операции / относительно класса Decimal void operator / (integer n /★ делитель ★/); // Dec / (integer n) /★ деление десятичной дроби на натуральное число п с произвольной точностью: ★/ void divn(integer n /★ делитель */); protected: /★ деление натуральных чисел с произвольной точностью: ★/ void divn (); 68 Глава 3
/★ количество тех элементов массива, в которые записываются base-ичные цифры ★/ integer Kw(integer DPrec, integer base); integer Mbase(integer denom); /* основание системы счисления ★/ }; Далее нужно определить перегрузку знака операции / относительно класса Decimal. Это совсем несложно: // Перегрузка знака операции / относительно класса Decimal void Decimal::operator / (integer n /★ делитель */) ( divn((integer) n); } Теперь в главной программе можно писать, например, такие выражения: Decimal Fractlfa = 1 /★ числитель ★/, b = 3 /★ знаменатель ★/, 20 /* точность ★/} ; Fractl.print();/* печать дроби ★/ Fractl / 3; /★ делим дробь на 3 ★/ Fractl.print();/★ печать дроби ★/ Fractl / 2; /★ делим дробь на 2 ★/ Fra,ctl .print (); /* печать дроби ★/ Fractl / 10; Fractl.print();/★ печать дроби ★/ Прогнав эти тесты, можем обсудить полученные результаты. Обсуждение результатов и новая итерация После выполнения этих операторов будут получены следующие результаты: 1/ 3 = 0.333333333333333333333333 1/ 9 = 0.111111111111111111111111 1/18 = 0.055555555555555555555555 1/180 = 0.005555555555555555555555 Результаты вычислений нас вполне удовлетворяют, а вот полученная программа... Не нужно ли выполнить очередную итерацию? Новая итерация: начнем с объектно-ориентированного анализа И действительно, мы замечаем, что класс Decimal сильно перегружен ...и вспоми- наем об объектно-ориентированном анализе. В самом деле, класс Decimal содержит довольно много таких объектов, которые имеют самостоятельное значение. Значит, он фактически состоит из объектов, для определения которых нужно использовать другие классы. Действительно, в классе Decimal имеется не только десятичная дробь (последовательность цифр), но и обыкновенная дробь, в результате вычисления кото- рой была получена десятичная дробь. Правда, обыкновенная дробь в классе Decimal явно не упоминается, но фактически она там содержится, поскольку класс Decimal содержит ее числитель и знаменатель. Так что теперь самое время упростить и класс Decimal, и его методы. Практика объектно-ориентированного программирования 69
Философское отступление: все сначала? Итерации — благо или проклятие? Чтобы упростить класс, как мы знаем, необходимо выполнить объектно-ориенти- рованный анализ. Но мы ведь уже делали это! Мы допустили ошибки, так как прове- ли его не до конца? Вполне возможно, что руководитель, если он некомпетентный, именно так и будет рассматривать сложившуюся ситуацию. Ведь вы должны были объять необъятное!!! И не пытайтесь оправдаться, что вы не могли! Никто и не гово- рит, что вы могли. Просто некомпетентные руководители, ничего не слышавшие об итерационном характере разработки программ, считают, что вы должны были, даже если не могли! А теперь давайте вспомним, что объектно-ориентированный анализ на- чинается с построения концептуальной модели. А концептуальная модель почти всегда не является точной копией изучаемой предметной области. Кстати, точная копия изу- чаемой предметной области зачастую гораздо менее полезна, чем приближенная мо- дель. Вот вам пример: глобус — лишь приближенная модель планеты Земля. Он со- вершенно непригоден для проживания, но если мне нужно измерить расстояние меж- ду Кейптауном и Нью-Йорком, лучше воспользоваться глобусом! Ведь чтобы измерить это расстояние на планете Земля, нужно идти через раскаленные пустыни, тропические леса, плыть через океан, а также тащить за собой тяжелое снаряжение и сложные инструменты для определения географических координат! А на глобусе я сделаю это за несколько минут. То же самое и в нашем случае. Если бы мы попыта- лись построить всеобъемлющую модель числовой системы, еще неизвестно, где бы мы застряли: при обсуждении бесконечности множества натуральных чисел, вещест- венных иррациональностей, комплексных чисел, р-адических полей или матричных колец. А построенная ранее концептуальная модель позволила нам создать работоспо- собную программу. Так что хотя результаты выполненного нами объектно-ориентиро- ванного анализа нельзя признать исчерпывающими (а вы вообще когда-нибудь виде- ли результаты, которые были единогласно признаны исчерпывающими?), но они бы- ли вполне достаточными и правильными для того этапа, на котором мы приступали к разработке программы. Достаточными полученные результаты необходимо признать потому, что их хватило для создания программы, а правильными — потому, что по- строенная с их помощью программа оказалась правильной. Вот критерий истины, до сих пор в странах СНГ зачастую приписываемый диалектике: “Практика — критерий истины”. Наша программа на практике доказала свою полезность. “Полезность — критерий истины”. Этот критерий истины приписывается прагматикам (и, в частно- сти американскому логику Ч. Пирсу). Наша программа полезна, потому что ее можно использовать, например, в калькуляторе. “Прекрасное есть жизнь”. Это высказыва- ние — основа эстетического учения Н. Г. Чернышевского. Поскольку наша программа может применяться в реальной жизни, она прекрасна. Если ваш начальник не согла- сен с этим, он может кусать локти... или написать лучшую программу. Именно этим мы сейчас и займемся. Для этого нам придется продолжить объектно-ориенти- рованный анализ. Продолжение объектно-ориентированного анализа На этой итерации мы выполним более тонкий объектно-ориентированный анализ. Из текста программы ясно, что методы класса Decimal выполняют действия над объ- ектами четырех типов (классов): последовательностями цифр (цифры принадлежат некоторой системе счисления, а длина последовательности известна), десятичными дробями (фактически это последовательности цифр, над которыми выполняются опе- рации), обыкновенными дробями (пары натуральных чисел, каждая пара состоит из числителя и знаменателя) и неких агрегатов, каждый из которых состоит из обыкно- венной и десятичной дроби. 70 Гпава 3
Последовательности цифр Самым простым из этих классов является, несомненно, последовательность цифр. Поэтому дадим его определение в первую очередь. class Digits /★ Последовательность цифр ★/ { public: integer k; /* количество цифр ★/ integer base; /* основание системы счисления */ /* цифры дроби хранятся в массиве с, в с[0] - к-во цифр ★/ integerdigit * с; Digits(); // конструктор -Digits(); // деструктор /★ выделить память для массива ★/ integer allocate(integer nz /★ количество цифр ★/ integer b /★ основание системы счисления */}; /★ Напечатать последовательность цифр с[т .. п] ★/ void print(integer m /★ начальный индекс ★/, integer n /★ конечный индекс */); integer Mbase(integer denom); /* основание системы счисления ★/ }; Осталось реализовать методы этого класса. Возникает соблазн организовать выде- ление памяти для массива цифр в конструкторе, однако это решение усложняет ис- пользование данного класса. Действительно, если данный класс будет рассматриваться в качестве базового, то именно его конструктор будет вызываться ранее конструктора производного класса. А ведь только производный класс “знает” необходимое количе- ство цифр в десятичной дроби, и, следовательно, только после этого может быть вы- полнено распределение памяти для массива цифр. Поэтому в конструкторе данного класса лучше всего указатель с обнулить, а распределение памяти выполнить с помо- щью специального метода allocate. При этом метод allocate не составит труда на- писать так, чтобы его можно было вызывать для перераспределения памяти. Однако в деструкторе и в других методах нужно предусмотреть случай нулевого указателя с, т.е. тот случай, когда память для массива не выделена. Digits::Digits() // конструктор { base = 1000000; /* основание системы счисления ★/ к = 18; /* количество цифр ★/ с = 0 ; } integer Digits::allocate(integer nz /★ количество цифр ★/ integer b /★ основание системы счисления */} { base = b; /★ основание системы счисления ★/ к = п; /★ количество цифр ★/ if (с) delete [] с; с = new integerdigit[к+1]; if (с) с[0]= к; return с; } Практика объектно-ориентированного программирования 71
/★ Напечатать последовательность цифр с[к .. 1]: */ void Digits::print(integer m /* начальный индекс ★/, Integer n /* конечный индекс ★/} { integerindex i; /* параметр цикла ★/ integerindex first = m>=0? m:l; /* начальный индекс ★/ integerindex last = n<k? n:k; /* конечный индекс ★/ int w; /* количество десятичных позиций для М-ичной цифры ★/ w = numdigit(base - 1, 10); if (с) for (i = first; i <= last; i++) /* печать дробной части ★/ { outputdigit(с[i], w, 10); /* цифра дробной части ★/ } } integer Digits::Mbase(integer denom) /* основание системы счисления ★/ { /* начальное значение М ★/ integer М = 1000000; while (denom >= LONG_MAX/M) М /= 10; if (М < 2) { printf("\п*** Знаменатель %d слишком велик.\n", denom); M = 10; } return M; } Digits::-Digits() { /★ освобождаем память, занятую массивом цифр ★/ if (с) delete [] с; } Вот тестовая программа для только что созданного класса: tinclude <stdio.h> #include <limits.h> #include <math.h> #include <stdlib.h> typedef int integer; typedef int integerdigit; typedef int integerindex; /* количество цифр в числе digit при записи в системе счисления с основанием base ★ / int numdigit(integerdigit digit, integer base); void outputdigit(integerdigit digit, int w, int base); /* количество цифр в числе digit при записи в системе счисления с основанием base ★ / int numdigit(integerdigit digit, integer base) 72 Глава 3
{ int к = 1; while (digit /= base) k++; return k; } void outputdigit(integerdigit digit, int w, int base) { int к = w - numdigit(digit, base); while (k-- > 0) printf("0"); printf("%d", digit); } class Digits /* Последовательность цифр */ { public: integer к; /* количество цифр ★/ integer base; /* основание системы счисления ★/ /★ цифры дроби хранятся в массиве с, в с[0] - к-во цифр ★/ integerdigit * с; Digits(); // конструктор -Digits О; // деструктор /★ выделить память для массива ★/ integerdigit * allocate(integer n, /★ количество цифр ★/ integer b /* основание системы счисления */}; /★ Напечатать последовательность цифр с[т .. п] */ void print(integer т /* начальный индекс ★/, integer п /★ конечный индекс */); integer Mbase(integer denom); /★ основание системы счисления ★/ }; Digits::Digits() // конструктор { base = 1000000; /* основание системы счисления ★/ к = 18; /★ количество цифр ★/ с = 0 ; integerdigit * Digits::allocate(integer n, /★ количество цифр */ integer b /★ основание системы счисления ★/} { base = b; /★ основание системы счисления ★/ к = п; /★ количество цифр ★/ if (с) delete [] с; с = new integerdigit[к+1]; if (с) с[0]= к; return с; } /★ Напечатать последовательность цифр с[к .. 1]: */ Практика объектно-ориентированного программирования 73
void Digits::print(integer m /* начальный индекс ★/, integer n /* конечный индекс ★/) { integerindex i; /* параметр цикла */ integerindex first = m>=0? m:l; /* начальный индекс ★/ integerindex last = n<k? n:k; /* конечный индекс ★/ int w; /* количество десятичных позиций для М-ичной цифры */ w = numdigit(base - 1, 10); if (с) for (i = first; i <= last; i++) /* печать дробной части */ { outputdigit(с[i], w, 10); /* цифра дробной части */ } } integer Digits::Mbase(integer denom) /* основание системы счисления ★/ { /* начальное значение М ★/ integer М = 1000000; while (denom >= LONG_MAX/M) М /= 10; if (М < 2) { printf("\n** * Знаменатель %d слишком велик.Xn", denom); M = 10; } return M; } Digits::-Digits() { /* освобождаем память, занятую массивом цифр */ if (с) delete [] с; int main() { integer N = 2; /* количество цифр */ do { Digits DD; DD.allocate(N /* количество цифр ★/, 1000000 /* основание системы счисления ★/); DD.print(1, N); /★' печать последовательности цифр ★/ printf("\n"); } while (N++ < 51); printf("ХпКонец выполнения программы!"); return 0; } Поскольку программа завершается корректно, считаем тестирование данного клас- са успешным, теперь можно приступить к определению класса обыкновенных дробей. 74 Глава 3
Обыкновенные дроби Обыкновенная дробь состоит из числителя и знаменателя. Из операций над обык- новенными дробями, кроме печати, нам понадобится лишь деление обыкновенной дроби на натуральное число. Так что можем дать следующее определение класса обыкновенных дробей ©Fraction. class ©Fraction /* Обыкновенная дробь: */ { integer numerator; /* числитель ★/ integer denominator; /* знаменатель ★ / public: ©Fraction(integer num, /* числитель ★/ 11 конструктор integer denom /* знаменатель ★/}; void printf); /* напечатать обыкновенную дробь */ /* деление обыкновенной дроби на натуральное число п */ void operator / (integer n /* делитель ★/) ; }; Теперь не составит труда реализовать и методы класса обыкновенных дробей ©Fraction. ©Fraction::©Fraction(integer num, /* числитель ★/ 11 конструктор integer denom /* знаменатель ★/) { numerator = num; /* числитель */ denominator = denom; /* знаменатель ★/ } /* деление обыкновенной дроби на натуральное число */ void ©Fraction::operator / (integer n /* делитель */) { denominator *= n; /* знаменатель ★/ } /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ ДРОБЬ: */ void ©Fraction::print() { printf("%4d/%2d", numerator, /★ числитель */ denominator /★ знаменатель ★/); } Теперь нужно (и это совсем несложно) составить тестовую программу: #include <stdio.h> #include <limits.h> #include <math.h> #include <stdlib.h> typedef int integer; typedef int integerdigit; typedef int integerindex; class ©Fraction /★ Обыкновенная дробь: ★/ { Практика объектно-ориентированного программирования 75
integer numerator; /★ числитель ★/ integer denominator; /★ знаменатель ★/ public: □Fraction(integer num, /★ числитель ★/ // конструктор integer denom /* знаменатель */); void print(); /* напечатать обыкновенную дробь */ /* деление обыкновенной дроби на натуральное число п ★/ void operator / (integer n /* делитель */); }; ©Fraction::OFraction(integer num, /★ числитель ★/ 11 конструктор integer denom /* знаменатель */) { numerator = num; /★ числитель ★/ denominator = denom; /★ знаменатель ★/ } /★ деление обыкновенной дроби на натуральное число */ void OFraction: .-operator / (integer n /★ делитель */) { denominator *= n; /★ знаменатель ★/ } /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ ДРОБЬ: */ void ©Fraction::print() { printf("%4d/%2d", numerator, /★ числитель ★/ denominator /★ знаменатель */); } int main() { integer N = 2; /★ знаменатель ★/ integer a; /★ числитель ★/ integer b; /★ знаменатель ★/ do { □Fraction Fract(a = 1 /★ числитель ★/, b = N /★ знаменатель ★/); Fract.print(); /* печать дроби ★/ printf("\n"); } while (N++ < 51) ; □Fraction Fract(a = 3210 /★ числитель ★/, b = 93 /* знаменатель ★/); Fract.print(); /★ печать дроби */ printf("ХпКонец выполнения программы!"); return 0; 76 Глава 3
Вот результаты тестового прогона (конец обрезан): 1/ 2 1/ 3 1/ 4 1/ 5 1/ 6 Никаких сложностей, но приятно, когда нет ошибок. Наконец, можем заняться нашим основным классом — десятичными дробями. Десятичные дроби Сначала приступим к описанию класса десятичных дробей DFraction. Для этого заметим, что десятичная дробь представляет собой последовательность цифр в неко- торой системе счисления, записываемую в массиве, причем необходимое количество элементов массива можно вычислить, исходя из основания системы счисления и нужного количества десятичных знаков. Отсюда можно заключить, что нам понадо- бятся методы и данные класса Digits. Поэтому класс десятичных дробей DFraction можно считать производным от класса Digits. class DFraction /★ ДЕСЯТИЧНАЯ ДРОБЬ: ★/ : public Digits { integer DecPrec; /* точность ★/ public: DFraction(integer numerator, /★ числитель */ // конструктор integer denominator, /★ знаменатель ★/ integer DecPrec /★ точность ★/); /★ Функция Kw вычисляет количество тех элементов массива, в которые записываются base-ичные цифры */ integer Kw(integer DPrec, integer base); void print(); /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ */ /★ деление десятичной дроби на натуральное число п с произвольной точностью: ★/ void operator / (integer n /★ делитель ★/); protected: /★ деление натуральных чисел с произвольной точностью: */ void divn(integer num, /★ числитель ★/ integer denom /* знаменатель ★/); }; DFraction::DFraction(integer num, /★ числитель ★/ // конструктор integer denom, /* знаменатель ★/ integer DPrec /* точность ★/) { base =Mbase(denom); /★ основание системы счисления ★/ DecPrec = DPrec; /* точность ★/ k = Kw(DecPrec, base); /★ количество цифр ★/ /★ выделяем память под массив с, в котором хранятся цифры дроби ★/ allocate(к /★ количество цифр ★/, base/* основание системы счисления ★/); /★ вычисление цифр дроби ★/ divn(num /* числитель ★/, denom /* знаменатель ★/) ; } Практика объектно-ориентированного программирования 77
/★ Функция Kw вычисляет количество тех элементов массива, в которые записываются base-ичные цифры ★/ integer DFraction::Kw(integer DPrec /* точность ★/, integer base /* основание системы счисления */) { /* количество десятичных цифр в элементе массива */ integer m = numdigit(base-1, 10); div_t d = div(DPrecz m); /★ количество элементов массива для записи десятичных цифр */ integer w = d.quot + 1 + (d.rem? 1 : 0); return w; } /* деление натуральных чисел с произвольной точностью: ★/ void DFraction::divn(integer num, /* числитель ★/ integer denom /* знаменатель ★/} { div_t d; /★ структура для хранения частного и остатка */ integerindex i; /* i - номер обрабатываемой цифры */ integer а = num; /★ числитель ★/ integer b = denom; /★ знаменатель ★/ if (с) { for(i = 1; i <= к; i + +) /* вычисление к цифр дроби ★/ {/* вычисление очередного частного и остатка ★/ d = div(а, b); с [i] = d.quot; /* вычисление очередной цифры ★/ а = d.rem * base; /* вычисление очередного делимого ★/ } } } /* деление десятичной дроби на натуральное число п с произвольной точностью: ★/ void DFraction:-.operator / (integer n /* делитель ★/) { div_t d; /★ структура для хранения частного и остатка ★/ integerindex i; /★ i - номер обрабатываемой цифры */ integer а = 0; /★ делимое ★/ if (с) { for(i = 1; i <= к; i++) /* вычисление к цифр дроби ★/ { /★ вычисление очередного частного и остатка */ d = div(а + с[i], п); с[i] = d.quot; /* вычисление очередной цифры */ а = d.rem * base; /* вычисление очередного делимого */ } } } 78 Гпава 3
void DFraction::print() /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ: */ { printf("%2d", c[l]); /* целая часть ★/ printf/* десятичная точка ★/ Digits::print(2, к); /* печать дробной части */ } Теперь, чтобы протестировать созданный класс десятичных дробей, проще всего создать класс Fraction, содержащий обыкновенную дробь и равную ей с точностью до указанного количества десятичных знаков десятичную. Правда, здесь есть один нюанс. В класс Fraction лучше включить не экземпляры классов OFraction и DFraction, а указатели на них. Тогда удастся избежать проблем с передачей парамет- ров конструкторам. (Ведь параметры в момент создания объектов еще не вычислены.) class Fraction /* ДРОБЬ: ★/ { OFraction * pOFract; /★ обыкновенная дробь ★/ DFraction * pDFract; /★ десятичная дробь ★/ public: Fraction(integer numerator, /★ числитель ★/ // конструктор integer denominator, /★ знаменатель ★/ integer DecPrec /★ точность */); -FractionO; // деструктор /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ И РАВНУЮ ЕЙ ДЕСЯТИЧНУЮ ДРОБЬ: */ void print(); /★ деление десятичной дроби на натуральное число п с произвольной точностью: ★/ void operator / (integer n /★ делитель ★/}; }; Осталось реализовать методы класса Fraction. Fraction::Fraction(integer num, /* числитель ★/ // конструктор integer denom, /★ знаменатель ★/ integer DPrec /★ точность ★/} { /★ обыкновенная дробь ★/ pOFract = new OFraction(num, /★ числитель */ denom /* знаменатель ★/) ; /* десятичная дробь */ pDFract = new DFraction(num, /* числитель ★/ denom, /* знаменатель */ DPrec /* точность ★/); } Fraction::-Fraction() // деструктор { /* освобождаем память, занятую обыкновенной дробью */ if (pOFract) delete pOFract; /* освобождаем память, занятую десятичной дробью ★/ if (pDFract) delete pDFract; } /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ И РАВНУЮ ЕЙ ДЕСЯТИЧНУЮ ДРОБЬ: */ void Fraction::print() Практика объектно-ориентированного программирования 79
pOFract -> print(); /* напечатать обыкновенную дробь ★/ printf(" = "); /* напечатать знак равенства = */ pDFract -> print(); /* печать десятичной дроби */ void Fraction::operator / (integer n /* делитель */) { (*pOFract) / n; /* разделить обыкновенную дробь ★/ (*pDFract) / n; /* разделить десятичную дробь ★/ } Теперь можем добавить тестирующие программы и собрать все в одну программу. #include <stdio.h> #include <limits.h> #include <math.h> #include <stdlib.h> typedef int integer; typedef int integerdigit; typedef int integerindex; /* количество цифр в числе digit при записи в системе счисления с основанием base */ int numdigit(integerdigit digit, integer base); void outputdigit(integerdigit digit, int w, int base); /* количество цифр в числе digit при записи в системе счисления с основанием base ★/ int numdigit(integerdigit digit, integer base) { int k = 1; while (digit /= base) k++; return k; } void outputdigit(integerdigit digit, int w, int base) { int к = w - numdigit(digit, base); while (k-- > 0) printf("0"); printf("%d", digit); } class Digits /* Последовательность цифр */ { public: integer к; /* количество цифр */ integer base; /★ основание системы счисления ★/ /★ цифры дроби хранятся в массиве с, в с[0] - к-во цифр */ integerdigit * с; Digits(); // конструктор 80 Глава 3
-Digits(); // деструктор /* выделить память для массива ★/ integerdigit * allocate(integer n, /* количество цифр */ integer b /* основание системы счисления ★/); /★ Напечатать последовательность цифр с[т .. п] */ void print(integer т /* начальный индекс */, integer п /* конечный индекс ★/); integer Mbase(integer denom); /* основание системы счисления */ }; Digits::Digits() // конструктор { base = 1000000; /* основание системы счисления */ к = 18; /* количество цифр */ с = 0 ; integerdigit * Digits::allocate(integer n, /* количество цифр */ integer b /* основание системы счисления ★/) { base = b; /* основание системы счисления */ к = п; /* количество цифр */ if (с) delete [] с; с = new integerdigit[к+1]; if (с) с[0] = к; return с; } /* Напечатать последовательность цифр с[к •• 1]: */ void Digits::print(integer m /* начальный индекс */, integer n /★ конечный индекс ★/) { integerindex i; /* параметр цикла */ integerindex first = m>=0? m:l; /* начальный индекс ★/ integerindex last = n<k? n:k; /★ конечный индекс ★/ int w; /* количество десятичных позиций для М-ичной цифры */ w = numdigit(base - 1, 10); if (с) for (i = first; i <= last; i++) /* печать дробной части */ { outputdigit(c[i], w, 10); /★ цифра дробной части */ } } integer Digits:rMbase(integer denom) { /* начальное значение M */ integer M = 1000000; while (denom >= LONG_MAX/M) M /= if (M < 2) { printf("\n** * Знаменатель %d /* основание системы счисления */ Ю; слишком велик.Xn", denom); Практика объектно-ориентированного программирования 81
м 10; } return М; } Digits::-Digits() { /* освобождаем память, занятую массивом цифр */ if (с) delete [] с; } class OFraction /* Обыкновенная дробь: */ { integer numerator; /* числитель */ integer denominator; /* знаменатель */ public: OFraction(integer num, /* числитель */ // конструктор integer denom /* знаменатель */); void print(); /* напечатать обыкновенную дробь */ /★ деление обыкновенной дроби на натуральное число п */ void operator / (integer n /* делитель ★/); }; OFraction::OFraction(integer num, /* числитель */ // конструктор integer denom /* знаменатель ★/) { numerator = num; /* числитель */ denominator = denom; /* знаменатель */ } /* деление обыкновенной дроби на натуральное число */ void OFraction::operator / (integer n /★ делитель */) { denominator *= n; /* знаменатель */ } /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ ДРОБЬ: */ void OFraction::print() { printf("%4d/%2d", numerator, /* числитель */ denominator /* знаменатель */); } class DFraction /* ДЕСЯТИЧНАЯ ДРОБЬ: */ : public Digits { integer DecPrec; /* точность */ public: DFraction(integer numerator, /* числитель */ // конструктор 82 Гпава 3
integer denominator, /* знаменатель ★/ integer DecPrec /* точность ★/); /* Функция Kw вычисляет количество тех элементов массива, в которые записываются base-ичные цифры */ integer Kw(integer DPrec, integer base); void print(); /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ */ /* деление десятичной дроби на натуральное число п с произвольной точностью: */ void operator / (integer n /* делитель */); protected: /* деление натуральных чисел с произвольной точностью: ★/ void divn(integer num, /★ числитель */ integer denom /* знаменатель ★/); }; DFraction::DFraction(integer num, /* числитель */ // конструктор integer denom, /* знаменатель */ integer DPrec /* точность ★/) { base = Mbase(denom); /★ основание системы счисления ★/ DecPrec = DPrec; /★ точность ★/ k = Kw(DecPrec, base); /* количество цифр ★/ /★ выделяем память под массив с, в котором хранятся цифры 'дроби ★/ allocate(к /★ количество цифр ★/, base/* основание системы счисления ★/); /* вычисление цифр дроби */ divn(num /* числитель ★/, denom /* знаменатель ★/) ; } /* Функция Kw вычисляет количество тех элементов массива, в которые записываются base-ичные цифры */ integer DFraction::Kw(integer DPrec /* точность */, integer base /* основание системы счисления ★/) { /* количество десятичных цифр в элементе массива */ integer m = numdigit(base-1, 10); div_t d = div(DPrec, m) ; /* количество элементов массива для записи десятичных цифр */ integer w = d.quot + 1 + (d.rem? 1 : 0); return w; } /* деление натуральных чисел с произвольной точностью: */ void DFraction::divn(integer num, /* числитель */ integer denom /* знаменатель ★/) { div_t d; /* структура для хранения частного и остатка */ integerindex i; /* i - номер обрабатываемой цифры ★/ integer а = num; /* числитель ★/ integer b = denom; /* знаменатель ★/ Практика объектно-ориентированного программирования 83
if (с) { for(i = 1; i <= к; i++) /* вычисление к цифр дроби */ {/* вычисление очередного частного и остатка ★/ d = div(а, Ь); с [i] = d.quot; /* вычисление очередной цифры ★/ а = d.rem * base; /* вычисление очередного делимого ★/ } } } /★ деление десятичной дроби на натуральное число п с произвольной точностью: ★/ void DFraction::operator /(integer n /★ делитель ★/) { div_t d; /★ структура для хранения частного и остатка ★/ integerindex i; /★ i - номер обрабатываемой цифры ★/ integer а = 0; /★ делимое ★/ if (с) { for(i =1; i <= к; i++) /* вычисление к цифр дроби ★/ { /★ вычисление очередного частного и остатка */ d = div(а+ с[i], п); с [i] = d.quot; /* вычисление очередной цифры ★/ а = d.rem * base; /★ вычисление очередного делимого ★/ } } } void DFraction::print() /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ: */ { printf("%2d", с[1]); /★ целая часть ★/ printf(".”); /* десятичная точка ★/ Digits::print(2, к); /★ печать дробной части ★/ } class Fraction /★ ДРОБЬ: { OFraction * pOFract; DFraction * pDFract; */ /★ обыкновенная дробь ★/ /★ десятичная дробь */ pxlblic: Fraction(integer numerator, /* числитель */ // конструктор integer denominator, /* знаменатель */ integer DecPrec /* точность */); ~Fraction(); // деструктор /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ И РАВНУЮ ЕЙ ДЕСЯТИЧНУЮ ДРОБЬ: */ void print(); /* деление десятичной дроби на натуральное число п с произвольной точностью: */ void operator / (integer n /* делитель ★/); 84 Глава 3
Fraction::Fraction(integer num, /* числитель */ // конструктор integer denom, /* знаменатель */ integer DPrec /* точность ★/) { /* обыкновенная дробь ★/ pOFract = new OFraction(num, /* числитель ★/ denom /★ знаменатель ★/); /★ десятичная дробь */ pDFract = new DFraction(num, /★ числитель */ denom, /★ знаменатель ★/ DPrec /★ точность ★/); } Fraction::-Fraction() // деструктор { /★ освобождаем память, занятую обыкновенной дробью */ if (pOFract) delete pOFract; /★ освобождаем память, занятую десятичной дробью */ if (pDFract) delete pDFract; } /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ И РАВНУЮ ЕЙ ДЕСЯТИЧНУЮ ДРОБЬ: */ void Fraction::print() { pOFract -> print(); /★ напечатать обыкновенную дробь ★/ printf(" = "); /★ напечатать знак равенства = ★/ pDFract -> print(); /★ печать десятичной дроби ★/ } void Fraction::operator / (integer n /★ делитель ★/) { (*pOFract) / n; /* разделить обыкновенную дробь */ (*pDFract) / n; /* разделить десятичную дробь */ } int testOlO { integer N = 2; /★ знаменатель ★/ integer a; /* числитель */ integer b; /* знаменатель */ do { Fraction Fract(a = 1 /* числитель */, b = N /* знаменатель ★/, 100 /★ точность ★/); Fract.print(); /* печать дроби ★/ printf("\n"); } while (N++ < 51); Практика объектно-ориентированного программирования 85
Fraction Fract(a = 3210 /* числитель ★/, b = 93 /* знаменатель ★/, 20 /★ точность ★/); Fract.print(); /★ печать дроби ★/ return 0; int test02() { integer N = 2; /★ знаменатель ★/ integer a; /* числитель ★/ integer b; /* знаменатель ★/ do { Fraction Fract(a = 1 /* числитель */, b = 1 /* знаменатель */, 100 /* точность */); Fract / N; Fract.print(); /* печать дроби ★/ printf("\n"); } while (N++ < 51); Fraction Fract(a = 3210 /★ числитель */, b = 1 /★ знаменатель ★/, 20 /★ точность ★/) ; Fract / 93; Fract.print(); /★ печать дроби ★/ return 0; } int main() { testOl(); printf("\п\пКонец теста l!\n\n"); test02(); printf("\п\пКонец теста 2!\n\n"); printf("ХпКонец выполнения программы!"); return 0; } Обсуждение достижений и пополнение набора методов Прогон тестов подтверждает правильность новой версии программы, поэтому са- мое время задать себе вопрос: чего мы достигли? Ну, во-первых, мы создали систему, решающую поставленную задачу (вычисление дробей с произвольным количеством десятичных знаков). Во-вторых, возможности этой системы вполне можно использо- вать в других программах (например, в калькуляторе). В-третьих, наконец мы получи- ли систему, использование которой выглядит просто и естественно. Это очень важно. Действительно, благодаря этой системе в программе можно иметь, например, объек- ты, являющиеся десятичными приближениями (с произвольной точностью!) обыкно- венных дробей. Чтобы разделить десятичную дробь DFract на натуральное число N, не нужно вспоминать имя соответствующей подпрограммы и ее список параметров, достаточно написать DFract/N. Но еще существеннее, пожалуй, то, что прочитать и 86 Глава 3
понять такое выражение намного легче, чем что-нибудь вроде divn(с, N), за кото- рым следуют еше какие-то манипуляции с числителем и знаменателем, идентифика- торов которых я уже и не помню! Однако можно ли назвать наш класс функционально полным? Иными словами, есть ли у класса десятичных дробей DFraction все необходимые методы? И как уз- нать, какие методы необходимы? А вот как: поскольку экземпляры класса DFraction представляют собой (конечные) десятичные дроби, то, чтобы этот класс был функ- ционально полным, его методы должны реализовывать все действия над десятичными дробями, а не только деление на натуральные числа и печать. Поэтому понятно, что реализованный нами класс весьма далек от функциональной полноты, ему не хватает даже таких простых методов, как сложение с натуральным числом. Так что использо- вать класс DFraction, например, для вычисления числа е (основания натуральных логарифмов)2 в том виде, в каком этот класс реализован в данный момент, весьма за- труднительно. Что уж тут говорить о вычислении значений экспоненты в рациональ- ных точках! Иными словами, мы не готовы вычислить корни /z-й степени из нату- ральных степеней числа е, т.е. выражения вида Ve™ = exp(n/m), но ценность реали- зации в виде системы классов состоит в том, что добавлять новые функциональные возможности гораздо легче и безопаснее, чем в первую версию программы. Напри- мер, чтобы реализовать сложение конечной десятичной дроби с натуральным числом, достаточно перегрузить знак операции +. Для этого необходимо в определение класса добавить декларацию void operator + (integer n /* слагаемое */); Нужно, конечно, не забыть реализовать эту операцию: /★ сложение десятичной дроби с натуральным числом п: ★/ void DFraction::operator +(integer n /* слагаемое */) { if (с) c[1] += n; /* вычисление целой части десятичной дроби ★/ } Вот и все. А тест? Ну, для тестирования полученной версии класса десятичных дробей DFraction попытаемся применить ее для вычисления основания натуральных логарифмов — числа, которое по предложению Эйлера обозначается буквой е. Новое применение: вычисление числа е — основания натуральных логарифмов Терпеть не могу складывать. Нет большей ошибки, чем назы- вать арифметику точной наукой. Существуют... тайные законы, управляющие числами, постичь которые может лишь ум типа моего. Например, если вы находите сумму, складывая числа столбиком сначала снизу вверх, а затем сверху вниз, вы всегда получаете разный результат. Госпожа Ла Туш (XIX в.) 2 Это число часто называется Неперовым или Эйлеровым. Однако Непер вычислял логарифмы по основанию, близкому к - , хотя и не имел понятия об основании системы логарифмов. Близкое к е е основание имеют логарифмы его современника Бюрги. Эйлер же в 1727 г. предложил обозначать это число буквой е и вычислил его с 23 десятичными знаками. Числом (чаще константой) Эйлера назы- вают также предел С=/=ПтУл ,--1пл. Кроме того, существуют еще числа Эйлера, играющие п - 1 / важную роль не только в анализе, но и в комбинаторике. Практика объектно-ориентированного программирования 87
Число е, конечно, не столь древнее, как л, однако оно было известно Неперу, От- реду и другим математикам семнадцатого столетия. Несмотря на относительную мо- лодость, число е встречается в математике, вероятно, даже чаше, чем древнее и зна- менитое л, и, несомненно, заслуживает отдельного класса. Для вычисления этого чис- ла Эйлер, по-видимому, воспользовался факториальным рядом: _ V- 1 _ , 1 1 1 ^n=0nl 1! 2! 3! Ряды — любимая стихия госпожи Ла Туш. Ведь она свято верит, что это не что иное, как бесконечные суммы. А что может быть прекраснее, чем складывать беско- нечное количество чисел столбиком... и затем искать концы бесконечного числа бес- конечных столбиков! Да, здесь есть где приложить недюжинные умственные способ- ности госпожи Ла Туш! Но наш ряд с монотонно убывающими членами обладает за- мечательным свойством: его остаток Rfl при п > 3 меньше его п-го члена. Кроме того, этот ряд представляет собой результат подстановки х = 1 в ряд Тейлора функции ех, что совсем неудивительно, поскольку е1 = е. Но это значит, что частичные суммы3 этого ряда являются результатом подстановки х = 1 в многочлен, получающийся из ряда Тейлора в результате отбрасывания остатка. Многочлен — это слово, изгоняю- щее госпожу Ла Туш гораздо быстрее, чем ладан черта, это магическое заклинание, по которому немедленно появляется древняя волшебная книга, называемая на Востоке Китаб алъ-джебр валъ-мукабала\ Впрочем, в Европе эта волшебная книга называется короче: Алгебра. Для вычисления многочленов там имеется специальный магический прием, который широко применяли математики средневекового Китая и который час- то используют современные программисты. В Европе этот способ вычисления много- членов открыл П. Руффини в 1802 году, а затем, несколько позже — в 1819 году — переоткрыл Уильям Джордж Горнер. В настоящее время этот прием называется схе- мой Горнера. Прием столь прост, что нам остается лишь правильно расставить скобки: Теперь мы видим, что, — к большому сожалению госпожи Ла Туш, — сложение столбиком нам не понадобится! Более того, все необходимые методы в классе деся- тичных дробей DFraction уже реализованы! Осталось просто описать класс EulerE и его методы. class EulerE /* ОСНОВАНИЕ НАТУРАЛЬНЫХ ЛОГАРИФМОВ: */ { DFraction * pDFract; /★ десятичная дробь */ integer denom; public: EulerE(integer DecPrec /* точность */, // конструктор integer n /* знаменатель последнего члена n! */ ); ~EulerE(); // деструктор /* НАПЕЧАТАТЬ ОСНОВАНИЕ НАТУРАЛЬНЫХ ЛОГАРИФМОВ: */ void print(); /* деление основания натуральных логарифмов на натуральное число п с произвольной точностью: ★/ void operator / (integer n /* делитель */); }; 3 Упоминание частичных сумм, конечно, огорчает госпожу Ла Туш, но она не из тех, кто сда- ется из-за таких неприятностей! 88 Гпава 3
EulerE::EulerE(integer DecPrec /* точность ★/, // конструктор integer n /* последний член 1/n! ★/ ) { denom = 1; /* десятичная дробь ★/ pDFract = new DFraction(1, /* числитель */ n, /* знаменатель ★/ DPrec /★ точность */); for(integerindex i = n-1; i >= 2; i—) { (*pDFract) +1; /* увеличить десятичную дробь на 1 ★/ (*pDFract) / i; /* разделить десятичную дробь ★/ } (*pDFract) + 2; /* увеличить десятичную дробь на 1 ★/ } EulerE::-EulerE() // деструктор { /★ освобождаем память, занятую десятичной дробью ★/ if (pDFract) delete pDFract; /* НАПЕЧАТАТЬ ОСНОВАНИЕ НАТУРАЛЬНЫХ ЛОГАРИФМОВ: */ void EulerE : .-print () { printf("е"); /★ напечатать обозначение числа е ★/ if (denom-1) printf("/%d", denom"); /★ напечатать / и делитель ★/ printf(" = "); /★ напечатать знак равенства = ★/ pDFract -> print(); /* печать десятичной дроби ★/ } void EulerE::operator / (integer n /★ делитель ★/} { (*pDFract) / n; /★ разделить десятичную дробь ★/ } А вот и тесты: int main() { integer i; EulerE e4(4 /★ точность */, 6 /★ последний член 1/n! */); e4.print; printf("\ne = 1.718 - известный результат\п\п"); EulerE el0(9 /★ точность ★/, 10 /★ последний член 1/n! ★/) ; elO.print; printf("\ne = 2.71828181 - известный результат\п\п"); for (i = 1; i <= 20; i++) { printf("%6d ", i); EulerE e(4 /★ точность ★/, i /★ знаменатель */); e.print; printf("\n") ; } Практика объектно-ориентированного программирования 89
for (i = 3; i <= 70; i++) { printf("%2d ", i); EulerE e(110 /★ точность ★/, i /* знаменатель */); e.print; printf("\n"); EulerE e600(608 /* точность ★/, 400 /* знаменатель */); ебОО.print; printf("--- точность 590 знаков\п"); printf("ХпКонец выполнения программы!"); return 0; } Теперь нужно все это собрать в одну программу: #include <stdio.h> #include <limits.h> #include <math.h> #include <stdlib.h> typedef int integer; typedef int integerdigit; typedef int integerindex; /* количество цифр в числе digit при записи в системе счисления с основанием base ★ / int numdigit(integerdigit digit, integer base); void outputdigit(integerdigit digit, int w, int base); /* количество цифр в числе digit при записи в системе счисления с основанием base ★ / int numdigit(integerdigit digit, integer base) { int k = 1; while (digit /= base) k++; return k; } void outputdigit(integerdigit digit, int w, int base) { int k = w - numdigit(digit, base); while (k-- > 0) printf("0"); printf("%d", digit); } class Digits /* Последовательность цифр ★/ { public: integer k; /* количество цифр ★/ integer base; /* основание системы счисления ★/ 90 Глава 3
/★ цифры дроби хранятся в массиве с, в с[0] - кол-во цифр ★/ integerdigit * с; Digits(); // конструктор -Digits(); // деструктор /★ выделить память для массива ★/ integerdigit * allocate(integer n, /★ количество цифр ★/ integer b /* основание системы счисления ★/} ; /★ Напечатать последовательность цифр с[т .. п] ★/ void print(integer т /* начальный индекс */, integer п /★ конечный индекс */); integer Mbase(integer denom); /* основание системы счисления */ }; Digits::Digits() // конструктор { basb = 1000000; /* основание системы счисления ★/ к = 18; /★ количество цифр ★/ с = 0; } integerdigit * Digits::allocate(integer n, /★ количество цифр ★/ integer b /★ основание системы счисления */) { base = b; /★ основание системы счисления */ к = п; /* количество цифр ★/ if (с) delete [] с; с = new integerdigit[к+1]; if (с) с[0] = к; return с; } /★ Напечатать последовательность цифр с[к .. 1]: ★/ void Digits::print(integer m /★ начальный индекс ★/, integer n /★ конечный индекс */) { integerindex i; /★ параметр цикла ★/ integerindex first = m>=0? m:l; /* начальный индекс ★/ integerindex last = n<k? n:k; /* конечный индекс ★/ int w; /★ количество десятичных Позиций для М-ичной цифры ★/ w = numdigit(base - 1, 10); if (с) for (i = first; i <= last; i++) /* печать дробной части ★/ { outputdigit(с[i], w, 10); /* цифра дробной части ★/ } } integer Digits::Mbase(integer denom) /* основание системы счисления ★/ { /★ начальное значение М ★/ integer М = 1000000; Практика объектно-ориентированного программу 91
while (denom >= LONG_MAX/M) M /= 10; if (M < 2) { printf("\n** * Знаменатель %d слишком велик.\n", denom); M = 10; } return M; Digits::-Digits() { /★ освобождаем память, занятую массивом цифр */ if (с) delete [] с; class OFraction /★ Обыкновенная дробь: ★/ { integer numerator; /★ числитель ★/ integer denominator; /★ знаменатель ★/ public: OFraction(integer num, /★ числитель ★/ H конструктор integer denom /★ знаменатель */); void print(); /★ напечатать обыкновенную дробь ★/ /* деление обыкновенной дроби на натуральное число п ★/ void operator / (integer n /★ делитель ★/} ; }; OFraction::OFraction(integer num, /★ числитель */ // конструктор integer denom /★ знаменатель */) { numerator = num; /* числитель */ denominator = denom; /* знаменатель ★/ } /* деление обыкновенной дроби на натуральное число ★/ void OFraction::operator / (integer n /★ делитель */) { denominator *= n; /* знаменатель ★/ } /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ ДРОБЬ: */ void OFraction::print() { printf("%4d/%2d", numerator, /★ числитель ★/ denominator /* знаменатель */); } class DFraction /* ДЕСЯТИЧНАЯ ДРОБЬ: */ : public Digits { integer DecPrec; /* точность ★/ 92 Глава 3
public: DFraction(integer numerator, /* числитель ★/ // конструктор integer denominator, /* знаменатель ★/ integer DecPrec /* точность ★/); /★ Функция Kw вычисляет количество тех элементов массива, в которые записываются base-ичные цифры */ integer Kw(integer DPrec, integer base); void print(); /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ */ /* деление десятичной дроби на натуральное число п с произвольной точностью: ★/ void operator / (integer n /* делитель ★/) ; void operator + (integer n /* слагаемое */); protected: /* деление натуральных чисел с произвольной точностью: ★/ void divn(integer num, /* числитель */ integer denom /★ знаменатель */); }; DFraction::DFraction(integer num, /★ числитель ★/ // конструктор integer denom, /* знаменатель */ integer DPrec /★ точность ★/} { base =Mbase(denom); /★ основание системы счисления ★/ DecPrec = DPrec; /* точность ★/ k = Kw(DecPrec, base); /★ количество цифр ★/ /★ выделяем память под массив с, в котором хранятся цифры дроби ★/ allocate(к /★ количество цифр ★/, base/* основание системы счисления */); /★ вычисление цифр дроби ★/ divn(num /* числитель ★/, denom /* знаменатель */); } /* Функция Kw вычисляет количество тех элементов массива, в которые записываются base-ичные цифры */ integer DFraction::Kw(integer DPrec /* точность ★/, integer base /* основание системы счисления ★/) { /* количество десятичных цифр в элементе массива ★/ integer m = numdigit(base-1, 10); div_t d = div(DPrec, m) ; /* количество элементов массива для записи десятичных цифр ★/ integer w = d.quot + 1 + (d.rem? 1 : 0); return w; } /* деление натуральных чисел с произвольной точностью: ★/ void DFraction::divn(integer num, /* числитель ★/ integer denom /* знаменатель */) { div_t d; /* структура для хранения частного и остатка ★/ integerindex i; /* i - номер обрабатываемой цифры ★/ Практика объектно-ориентированного программирования 93
integer a = num; /* числитель ★/ integer b = denom; /* знаменатель ★/ if (c) { for(i =1; i <= k; i++) /* вычисление к цифр дроби ★/ {/* вычисление очередного частного и остатка */ d = div(а, b); с[i] = d.quot; /* вычисление очередной цифры ★/ а = d.rem * base; /★ вычисление очередного делимого */ } } } /★ деление десятичной дроби на натуральное число п с произвольной точностью: ★/ void DFraction:-.operator /(integer n /* делитель ★/) { div_t d; /★ структура для хранения частного и остатка ★/ integerindex i; /★ i - номер обрабатываемой цифры ★/ integer а = 0; /* делимое ★/ if (с) { for(i =1; i <= к; i++) /* вычисление к цифр дроби */ { /* вычисление очередного частного и остатка ★/ d = div(a+ с[i], n); с[i] = d.quot; /★ вычисление очередной цифры ★/ а = d.rem * base; /★ вычисление очередного делимого ★/ } } } /★ сложение десятичной дроби с натуральным числом п: ★/ void DFraction: -.operator +(integer n /* слагаемое ★/} { if (с) c[1] +=n; /* вычисление целой части десятичной дроби ★/ } void DFraction:-.print () /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ: */ { printf("%2d", с[1]); /* целая часть */ printf; /* десятичная.точка */ Digits::print(2, к); /★ печать дробной части */ } class EulerE /* ОСНОВАНИЕ НАТУРАЛЬНЫХ ЛОГАРИФМОВ: */ { DFraction * pDFract; /* десятичная дробь ★/ integer denom; 94 Глава 3
public: EulerE(integer DPrec /* точность ★/, // конструктор integer n /* знаменатель последнего члена n! ★/ } ; -EulerEO; // деструктор /* НАПЕЧАТАТЬ ОСНОВАНИЕ НАТУРАЛЬНЫХ ЛОГАРИФМОВ: */ void print ()•; /* деление основания натуральных логарифмов на натуральное число п с произвольной точностью: ★/ void operator / (integer n /* делитель ★/); }; EulerE::EulerE(integer DPrec /* точность ★/, // конструктор integer n /* последний член 1/n! ★/ ) { denom = 1; /* десятичная дробь ★/ pDFract = new DFraction(1, /* числитель ★/ n, /* знаменатель */ DPrec /* точность ★/); for(integerindex i = n-1; i >= 2; i —) { (*pDFract) +1; /* увеличить десятичную дробь на 1 ★/ (*pDFract) / i; /★ разделить десятичную дробь ★/ } (*pDFract) +2; /★ увеличить десятичную дробь на 1 */ } EulerE::-EulerE() // деструктор { /★ освобождаем память, занятую десятичной дробью ★/ if (pDFract) delete pDFract; } /* НАПЕЧАТАТЬ ОСНОВАНИЕ НАТУРАЛЬНЫХ ЛОГАРИФМОВ: */ void EulerE::print() { printf("е"); /* напечатать обозначение числа е ★/ if (denom-1) printf("/%d", denom); /* напечатать / и делитель ★/ printf(" = "); /* напечатать знак равенства = ★/ pDFract -> print(); /* печать десятичной дроби */ } void EulerE: .-operator / (integer n /* делитель ★/) { (*pDFract) / n; /* разделить десятичную дробь ★/ } int main() { integer i; EulerE e4(4 /* точность ★/, 6 /* последний член 1/n! */); Практика объектно-ориентированного программирования 95
e4.print(); printf("\ne = 2.718 - известный результат\п\п"); EulerE el0(9 /* точность ★/, 10 /* последний член 1/n! ★/); el0.print(); printf("\ne = 2.71828181 - известный результат\п\п"); for (i = 1; i <= 20; i++) { printf("%6d ", i); EulerE e(20 /* точность ★/, i /* знаменатель ★/) ; e.print(); printf("\n"); } for (i = 3; i <= 70; i++) { printf("%2d ", i); EulerE e(110 /* точность ★/, i /* знаменатель */) ; e.print(); printf("\n"); } EulerE e600(608 /* точность */, 400 /* знаменатель ★/); ебОО.print(); printf("--- точность 590 знаков\п"); printf("ХпКонец выполнения программы!"); return 0; } Результат прогона, естественно, получился несколько длинный, но по причинам, которые станут ясны позже, он заслуживает того, чтобы привести его полностью. Од- нако читать сразу весь полученный результат было бы несколько утомительно, поэто- му прочитаем его “по частям”, с комментариями. Вот начало: е = 2.718055 е = 2.718 - известный результат Ничего неожиданного. Просто подтверждение вычислений, выполненных вручную при и = 6. Читаем следующую порцию: е = 2.718281801146 е = 2.71828181 - известный результат Точность возросла, но никаких неожиданностей нет. Это подтверждение вычисле- ний, выполненных вручную при п = 10. В целом ничего особенного, но приятно от- метить совпадение результатов в пределах погрешностей вычислений. Дальше идет вычисление при n = 1, 2, 3, ... 20. В общем-то никаких неожиданностей, но любопыт- но понаблюдать стабилизацию десятичных знаков (верные знаки выделены полужир- ным шрифтом): 1 е = 3.000000000000000000000000 2 е = 2.500000000000000000000000 3 е = 2.666666666666666666666666 4 е = 2.708333333333333333333333 5 е = 2.716666666666666666666666 6 е = 2.718055555555555555555555 7 е = 2.718253968253968253968253 96 Глава 3
8 е = 2.718278769841269841269841 9 е = 2.718281525573192239858906 10 е = 2.718281801146384479717813 11 е = 2.718281826198492865159531 12 е = 2.718281828286168563946341 13 е = 2.718281828446759002314557 14 е = 2.718281828458229747912287 15 е = 2.718281828458994464285469 16 е = 2.718281828459042259058793 17 е = 2.718281828459045070516047 18 е = 2.718281828459045226708117 19 е = 2.718281828459045234928752 20 е = 2.718281828459045235339784 Хотя результат Эйлера (23 правильных десятичных знака) еще не достигнут, при- ближение к нему налицо. Следующую часть выдачи и в книге, и на экране читать труднее. В силу слишком длинной дробной части ее пришлось разбить на строки, причем я сделал это вручную. (Как и ранее, для удобства верные знаки выделены полужирным шрифтом.) 3 е = 2.666666666666666666666666666666666666666666666666666666666 666666666666666666666666666666666666666666666666666666666 4 е = 2.708333333333333333333333333333333333333333333333333333333 333333333333333333333333333333333333333333333333333333333 5 е = 2.716666666666666666666666666666666666666666666666666666666 666666666666666666666666666666666666666666666666666666666 6 е = 2.718055555555555555555555555555555555555555555555555555555 555555555555555555555555555555555555555555555555555555555 7 е = 2.718253968253968253968253968253968253968253968253968253968 253968253968253968253968253968253968253968253968253968253 8 е = 2.718278769841269841269841269841269841269841269841269841269 841269841269841269841269841269841269841269841269841269841 9 е = 2.718281525573192239858906525573192239858906525573192239858 906525573192239858906525573192239858906525573192239858906 10 е = 2.718281801146384479717813051146384479717813051146384479717 813051146384479717813051146384479717813051146384479717813 11 е = 2.718281826198492865159531826198492865159531826198492865159 . 531826198492865159531826198492865159531826198492865159531 12 е = 2.718281828286168563946341724119501897279675057452835230613 008390786168563946341724119501897279675057452835230613008 13 е = 2.718281828446759002314557870113425668981224536780092335647 891203446759002314557870113425668981224536780092335647891 14 е = 2.718281828458229747912287594827277366959906642446324986007 525690065372605055144737684420224102763785303467843150382 15 е = 2.718281828458994464285469576474867480158485449490740496031 501322506613511904517195522486527777533068538359543650548 16 е = 2.718281828459042259058793450327841862233396624931016465407 999799534191068582602974137365671757206148740540274931809 17 е = 2.718281828459045070516047795848605061178979635251032698900 735004065225042504843314055887974344245741693609729713059 18 е = 2.718281828459045226708117481710869683342623135824366934094 775848761393596611634444051361435599081274635446921645351 19 е = 2.718281828459045234928752728335199400298604372696647683315 514840587507731038307661419544249349335776369227826483893 20 е = 2.718281828459045235339784490666415886146403434540261720776 551790178813437759641322287953390036848501455916871725820 Практика объектно-ориентированного программирования 97
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 98 е = 2.718281828459045235359357431729807147377251008913767151131 839263968875614270181020424544301498158631221949683404007 е = 2.718281828459045235360247110869052204705925898658017397966 170512777514804111569188521662070200945455302223902116652 е = 2.718281828459045235360285792570758511546303067777332626089 402306203977377582933891482406321014110099827453215973723 е = 2.718281828459045235360287404308329607664652116490637427261 203630930079984810907420772437331464658626682671104051101 е = 2.718281828459045235360287468777832451509386078439169619308 075683919124089100026361944038571882680567756879819574196 е = 2.718281828459045235360287471257428714734183538514113165156 032301341779631572684782758330927283373719336657077863546 е = 2.718281828459045235360287471349265613372138999998370333520 771435320396503516116576121823236742658650876648828170559 е = 2.718281828459045235360287471352545502609208837908522375248 083547248204248942667711599090819223347398431648533538667 е = 2.718281828459045235360287471352658602238073315077837962893 852930418128653957376371443134528964060803519751971654808 е = 2.718281828459045235360287471352662372225702130983481815815 378576523792800791199993437935985955417917022688752925346 е = 2.718281828459045235360287471352662493838206286335276778812 847145753007773269710432857123129729332662619557681353428 е = 2.718281828459045235360287471352662497638597041190020371406 518038541420741159663884088972727972267498419459835366806 е = 2.718281828459045235360287471352662497753760397397739874212 386853474402952307844291702059079434174614655820506700545 е = 2.718281828459045235360287471352662497757147554933261036059 618289207725958518084891925973383888936588662772291151537 е = 2.718281828459045235360287471352662497757244330862847354969 539187371535187266948909075228078301929787920113770707280 е = 2.718281828459045235360287471352662497757247019083113641605 925878987196554732195131773818486480068487899484367361606 е = 2.718281828459045235360287471352662497757247091737715433136 639032814646861960985570225131740755153317628656545649560 е = 2.718281828459045235360287471352662497757247093649678638176 920957915369238467006371237008405341339760516266339815033 е = 2.718281828459045235360287471352662497757247093698703335742 056391892310837864596648186030883920472746231333257614148 е = 2.718281828459045235360287471352662497757247093699928953181 184777741734377849536405109756445884951070874209930559126 е = 2.718281828459045235360287471352662497757247093699958846289 456201786842269068681277229847313249938347085011800630954 е = 2.718281828459045235360287471352662497757247093699959558030 129330930773409335803774185087571996723758423364226108855 е = 2.718281828459045235360287471352662497757247093699959574582 238008352725296318760111323581531502462954035884049957178 е = 2.718281828459045235360287471352662497757247093699959574958 422296475951475568372755349456394218502481208895864135549 е = 2.718281828459045235360287471352662497757247093699959574966 781947323134279551697480772253613389970026257185015561735 е = 2.718281828459045235360287471352662497757247093699959574966 963678863290427464378453064053552937175842453886953636218 е = 2.718281828459045235360287471352662497757247093699959574966 967545491804388058265282261751423991371710883604016148441 е = 2.718281828459045235360287471352662497757247093699959574966 967626046565095570637924536703462971667458142556454950779 Глава 3
49 е = 2.718281828459045235360287471352662497757247093699959574966 967627690539803887216958052518810705959208086616708803887 50 е = 2.718281828459045235360287471352662497757247093699959574966 967627723419298053548538722835117660645043085497913880950 51 е = 2.718281828459045235360287471352662497757247093699959574966 967627724063994017594255990880535444070255536456368882461 52 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076392016902827476804485786059201929744031478644 53 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076625941418083542576635792511823559806062848383 54 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630273353551247498342274112798034436841207082 55 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630352116014296678736937414633933975582631785 56 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353522486851128386842116452432181631585798 57 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353547161808223994735181396616360685076219 58 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353547587238518746595406654274708599791571 59 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353547594449201708491342675590951784786747 60 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353547594569379757856274942612889171203333 61 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353547594571349889813077110924724210324916 62 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353547594571381666134961016865237678697845 63 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353547594571382170521022666165880749624400 64 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353547594571382178402054879436203297607627 65 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353547594571382178523301528871131336807369 66 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353547594571382178525138599317115094977062 67 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353547594571382178525166018278995449576610 68 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353547594571382178525166421499023101850132 69 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353547594571382178525166427342791618549749 Этот результат можно подтвердить вычислением по программам, которые были встроены в некоторые ЭВМ. Например, в книге Программирование и работа на ЭВМ “Проминь” и “Мир ” (авторы П. Г. Богач, Л. В. Решодько, В. В. Кальныш) приводится следующий результат вычисления с разрядностью 100 числа е на ЭВМ Мир-1: е = 2.71828 18284 59045 23536 02874 71352 66249 77572 47093 69995 95749 66967 62772 40766 30353 54759 45713 82178 52516 6427391 Отсюда видно, что объявленные 100 значащих цифр совпадают, а различия в за- пасных свидетельствуют о применении разных алгоритмов. И действительно, на ЭВМ Мир-1 элементарные функции вычисляются с помощью прямого или обратного рекур- рентного соотношения, которое порождает последовательность, сходящуюся к значению элементарной функции при заданном значении аргумента. В частности, вычисление Практика объектно-ориентированного программирования 99
функции ах проводится по формуле ах = 10lA,gfl,(l + у0), причем у0 вычисляется по , где i=n, п~\, ..., 1, уп = {xlgfl} • In 10 . Число шагов п, после которых исключается систематическая ошибка и ошибка начальных данных при счете с т десятичными разрядами, вычисляется по формуле п > 3,3/я + 0,058. Так что алгоритмы действительно совершенно различны: в нашем алгоритме вычисляется значение частичной суммы ряда по схеме Горнера, а в машине Мир-1 реализован итеративный алгоритм. Совпадение результатов счета по столь раз- личным алгоритмам, конечно, не гарантирует отсутствие ошибок в программе, но яв- ляется убедительным аргументом в пользу правильности программы. Поскольку наша программа на этом не останавливается, мы можем читать дальше: 70 е = 2.718281828459045235360287471352662497757247093699959574966 967627724076630353547594571382178525166427426274025931172 рекуррентной формуле у Конечно, даже в этом результате не все десятичные знаки верные, но верных сре- ди них сто один! Это уже значительно превосходит не только то, что сделал Эйлер, но и то, что обычно приводится в математических справочниках. В таблицах числовых величин, прилагаемых к бессмертному Искусству программирования для ЭВМ, Дональд Кнут приводит, например, только 40 десятичных знаков. Интересно отметить, что для того чтобы вычислить 8 десятичных знаков, пришлось взять п = 10, при п = 35 полу- чается уже 40 верных десятичных знаков, при п = 70 — 100, а при п = 400 — более 590. (В последнем мы убедимся несколько дальше.) Таким образом, налицо тенденция возрастания количества верных десятичных знаков, приходящихся на очередную ите- рацию. Это не удивительно, так как знаменатели факториального ряда растут сущест- венно быстрее любой геометрической прогрессии. Осталось обсудить последний ре- зультат. Как убедиться в его справедливости? Ведь провести вычисления вручную, на- верное, даже Эйлер не рискнул бы без серьезного на то повода! Может быть, найти какую-нибудь книгу, где приведены результаты подобного вычисления, и сравнить их с нашими? Да, это, кажется, вполне подходящий путь. Такая книга есть. Это Основы математического анализа (часть I) В. А. Ильина и Э. Г. Позняка. (Я пользуюсь треть- им, исправленным и дополненным изданием 1971 г.) В этом замечательном учебнике для физиков (математики больше любят трехтомный Курс дифференциального и инте- грального исчисления Григория Михайловича Фихтенгольца) немалое внимание уделя- ется вычислительным аспектам математического анализа. В главе 8 “Основные теоре- мы о непрерывных функциях” имеется § 16 “Примеры приложений формулы Макло- рена”, в котором пункт 1 посвящен алгоритму вычисления числа е. В этом пункте приводится факториальный ряд и оценка его остаточного члена. (Оценка, правда, бо- лее грубая, чем та, которой пользовались мы.) Применение схемы Горнера для реали- зации алгоритма не обсуждается, и потому реализовать приведенный там алгоритм было бы сложнее, так как понадобился бы дополнительно метод сложения в классе десятичных дробей. Но следующий, пункт так и называется: “Реализация (алгоритма) вычисления числа е на электронной машине”. Там же приведена программа вычисле- ния числа е с 590 десятичными знаками после запятой и само число е с указанным количеством десятичных знаков, надо полагать, верными. Какая удача! Чтобы све- рить, нужно только отформатировать наш результат так, как в книге, т.е. разбить дробную часть на грани по 6 цифр и сравнить 590 цифр после запятой (я их выделил полужирным шрифтом). Вот что получилось: 360287 076630 817413 763233 88^167 е 2.718281 959574 466391 738132 930702 828459 966967 932003 328627 154089 045235 627724 059921 943490 149934 471352 353547 596629 829880 509244 662497 594571 043572 753195 761460 757247 382178 900334 251019 668082 093699 525166 295260 011573 264800 427427 595630 834187 168477 100 Глава 3
411853 742345 442437 107539 077744 331384 583000 752044 933826 560297 443747 047230 696977 209310 141692 111252 389784 425056 953696 770785 931636 889230 098793 127736 178215 895193 668033 182528 869398 496465 362509 443117 301238 197068---- 992069 551702 761838 606261 606737 113200 709328 709127 836819 025515 108657 463772 449969 967946 864454 905987 424999 229576 351482 208269 105820 939239 829488 793320 точность 590 знаков Конец выполнения программы! Совпадение результатов счета по разным программам — весьма убедительное сви- детельство в пользу правильности программы. Программы, да и, строго говоря, алго- ритмы, действительно разные — наша написана на языке С и реализует схему Горне- ра, а программа, приведенная в упомянутом учебнике, написана на языке Алгол— БЭСМ6, вариант 10—12—69 и реализует суммирование членов ряда, что, несомненно, утешает госпожу Ла Туш. (Правда, авторы учебника вели счет, несомненно, по другой программе, поскольку напечатанная в учебнике содержит многочисленные синтакси- ческие ошибки. Возможно, эти ошибки являются результатом советской системы вер- стки книг, так как оформление листинга, как и в большинстве советских изданий, ужасное. Кроме того, напечатанная в учебнике программа имеет многочисленные ля- пы, характерные для студентов-непрофессионалов, и не содержит ни одного коммен- тария!) Из-за того, что авторы не воспользовались схемой Горнера, приведенная в учебнике Основы математического анализа программа содержит (к большой радости госпожи Ла Туш!) три массива а, Ь, е[0:601] (для сложения столбиком) и потому допускает троекратный перерасход памяти, а из-за нерационального представления десятичных дробей этот перерасход при п = 400, например, возрастает еще в 6 раз! В результате эта программа допускает восемнадцати кратный перерасход памяти! И это на машине, где всего было около 30 килослов — абсолютный мизер по сегодняшним меркам! Впрочем, на момент проведения авторами учебника вычисления машина БЭСМ-6 имела самое высокое (официально 1 млн операций в секунду) быстродейст- вие среди машин, построенных в СССР, и еще долгое время (до появления ЕС-1060) заслуженно считалась флагманом советской вычислительной техники. На проведение всех вычислений по этой программе, по свидетельству авторов, ушло около одной минуты машинного времени. Это и не удивительно, ведь данная программа должна была выполнять операций в более чем в 50 раз больше, чем написанная нами. (Три прохода по массивам, каждый из которых примерно в 18 раз длиннее нашего: 3x18 = 54.) Конечно, мне не удалось отыскать БЭСМ-6, на которой бы имелся ком- пилятор с C++, но я все же (не без труда) нашел старенький Pentium 100 1996 года выпуска и убедился, что даже на нем наша программа выполняется менее секунды! (На самом деле примерно 0,025 секунды, как вы убедитесь позже.) Видимо, недоста- ток выразительных средств языка программирования не позволил авторам сосредото- читься на задаче, и они просмотрели схему Горнера, о которой знают даже школьни- ки! Авторы должны были сосредоточиться на программе, и это была действительно одна из первых программ (я думаю, что все-таки первая!) подобного типа, опублико- ванная в столь популярной книге. Книга эта действительно весьма популярна — ти- раж третьего (!) издания в 1971 году составил 97 000 экземпляров, и учебник продол- жает переиздаваться и в настоящее время! И такая популярность уже сама по себе свидетельствует об умелом, тщательном подходе к отбору материала, доходчивости из- ложения, достаточно высоком уровне строгости (для инженеров и физиков), блестя- щих методических приемах и почти полному отсутствию опечаток! Но вот к интере- сующей нас программе в этом учебнике перечисленные выше эпитеты совсем не применимы, применимы скорее их антонимы. Причина: невозможность создания (в данном случае на языке Алгол-60) адекватных структур данных и невозможность строить программу в терминах предметной области, невозможность непосредствен- Практика объектно-ориентированного программирования 101
ного воплощения в программе концептуальной модели, перегруженность программы деталями манипуляций над данными. А нам при разработке программы уделять вни- мание всей этой чепухе просто не было необходимости. Мы занимались построением концептуальной модели, а не обсуждением того, как и в каком массиве получать' оче- редную цифру очередного члена ряда и с какой цифрой промежуточной суммы (Господи, опять забыл, где она!) ее складывать и что после этого делать с переносом, если он возникнет. От всего этого избавило нас объектно-ориентированное програм- мирование! Резюме Объектно-ориентированное программирование — новая современная технология программирования, в настоящее время заслуженно получившая широкое признание и ставшая, можно сказать, уже классической. Она не отрицает удачные черты ранее разработанных технологий, а, наоборот, гармонично сочетает их и тем самым позво- ляет извлекать дополнительную выгоду из применения этих технологий. Вместе с тем объектно-ориентированное программирование нельзя назвать только удачной инте- грацией ранее разработанных технологий, оно имеет свою, присущую только ему ме- тодологию проектирования программ. Инкапсуляция, наследование и полимор- физм — вот три кита, лежащих в основе объектно-ориентированного программирова- ния. Применение каждого из этих понятий на практике может дать неоценимую выгоду. Но самым главным преимуществом объектно-ориентированного программи- рования является то, что оно позволяет строить программу по концептуальной моде- ли, т.е. в терминах предметной области, а не в терминах языка программирования. Однако для естественного преобразования концептуальной модели в программу необ- ходим объектно-ориентированный язык программирования. Вместе с тем объектно-ориентированное программирование представляет собой не свод определенных правил, а логическую систему, подобную многим математическим теориям, поэтому его применение не сводится к простому перечитыванию некоего талмуда, а требует творческого подхода и изобретательности. Просто декларировать применение объектно-ориентированного программирования недостаточно, необходи- мо учиться его применять на практике. Задачи и упражнения 1. Построенный нами конструктор класса EulerE EulerE::EulerE(integer DecPrec /* точность ★/, // конструктор integer n /* последний член 1/n! */ ) имеет два параметра: точность (DecPrec) и число п, которое определяет количест- во членов факториального ряда. Наличие обоих параметров избыточно: при заданной точности необходимое количество членов ряда можно вычислить. К тому же наличие обоих параметров неудобно: обычно известна только точность, а количество членов факториального ряда приходится вычислять, и это несколько огорчает. (Попробуйте, например, определить количество членов факториального ряда, необходимое для вы- числения е с точностью 1000 знаков после запятой.) Перегрузите конструктор так, чтобы можно было задавать только точность, и вычислите основание натуральных логарифмов с точностью 1000 знаков после запятой. < < < 102 Гпава 3
Глава 4 ООП. Классы и объекты В этой главе... История объектно-ориентированного программирования Объектно-ориентированное программирование Резюме Задачи и упражнения 104 109 112 112 Хотя любовной практике предаются гораздо больше, чем тео- рии, однако считают, что не следует пренебрегать и последней. Ведь так часто бывает в жизни, что надо перехитрить бдитель- ность матери, обмануть ревность мужа, усыпить подозрения любовника, а для этого нужно заранее изучить всю эту науку. И многие в аллее цветов заслуживают в этом отношении больших похвал. Дени Дидро. Прогулка скептика, или аллеи В мире существует слишком много готовности не только к действиям без надлежащего предварительного обдумывания, но также к случайным действиям, на которые мудрый человек не пошел бы. Люди демонстрируют такое свое пристрастие раз- личными любопытными способами. Мефистофель говорил юному студенту': “Суха теория, мой друг, а древо жизни пышно зеленеет”, — и каждый цитирует это так, как будто это было мнение Гете, хотя он просто предполагал, что мог бы сказать дьявол студенту-выпускнику. Гамлет представляется как ужас- ное предостережение против мысли без действия, но никто не представляет Отелло как предостережение против действия без мысли. Бертран Рассел. Бесполезное “знание”. Публикация 1941 г. Пример из предыдущей главы показал не только полезность объектно-ориенти- рованного подхода, но и необходимость его изучения. Именно этим сейчас мы и со- бираемся заняться. Иными словами, сейчас нам предстоит не столько наслаждаться видом “пышно зеленеющих деревьев жизни”, сколько изучать “сухую” теорию. Впро- чем, Мефистофелю, как вы помните, необходимо было заполучить душу юного сту- дента, а не научить премудростям программирования. А так как обмануть незнаю- щего человека легче, то, возможно, Мефистофель просто коварно обманывал юно- шу, называя теорию “сухой”?! Чтобы узнать ответ на этот вопрос, давайте обратимся к истории.
История объектно-ориентированного программирования История науки, подобно истории всех человеческих идей, есть история безотчетных грез, упрямства и ошибок. Однако наука представляет собой один из немногих видов человеческой дея- тельности — возможно, единственный, — в котором ошибки подвергаются систематической критике и со временем доволь- но часто исправляются. Карл Поппер. Логика и рост научного знания Объектно-ориентированные языки возникли очень давно, они употребляются с 60-х годов прошлого столетия. Однако только в последние 10 лет мы являемся свидетелями беспрецедентного роста их применения в производстве программного обеспечения. Первое время популярностью они не пользовались, а объектно-ориентированные техно- логии не упоминались даже в университетских учебниках программирования. И это не случайно. Ведь объектно-ориентированный подход не был придуман за один день — он стал очередной ступенькой в естественном развитии технологии программирования. Объектно-ориентированное программирование эффективно сочетает в себе наиболее удачные, проверенные временем методологии. Объектно-ориентированный подход включает в себя все стилевые усовершенствования технологии программирования, ба- зирующиеся на понятии “объект” — понятии, которое многие люди (в частности, фи- лософы) обозначают словом “вещь”. Можно сказать, что объектно-ориентированный подход — это способ мышления, а также способ восприятия окружающего мира с точки зрения теории объектов. Поэтому объектно-ориентированный подход можно применять не только к программированию, но и к другим этапам проектирования (причем не только программ’), например к анализу и дизайну. Иными словами, объ- ектно-ориентированный подход можно применить ко всему тому, что можно описать в терминах теории объектов. В соответствии с парадигмой объектно-ориентированного подхода всякая вещь, простите объект, характеризуется своими свойствами и поведением. Иными словами, объект представляет собой вещь, состоящую из некоей пассивной части (данных) и активной части, способной проявлять поведение (процедур). Однако первоначально на это единство пассивной (данных) и активной (процедур) частей просто не обраща- ли внимания. Поэтому-то объектно-ориентированное программирование, столь есте- ственное в наши дни, и не могло возникнуть сразу, в один день. Технологии, предшествовавшие объектно- ориентированному программированию Причина ошибки — незнание лучшего. Демокрит Конечно, эпизодически программисты замечали, что для решения некоторых задач удобно в программе иметь вещи, обладающие собственными данными и поведением, однако считалось, что такой подход приносит пользу в основном в имитационном мо- делировании. В обычных же программах основное внимание уделялось так называе- мой обработке данных, под которой понималось применение алгоритмов к данным. Более того, в силу психологических причин активная (процедурная) часть программи- рования была разработана гораздо глубже еще до появления вычислительных машин. Действительно, человека еще в древние времена привлекали любые предметы, демон- 104 Глава 4
стрировавшие хоть какую-нибудь, пусть самую примитивную, деятельность. Поэтому не удивительно, что в классической теории алгоритмов в первую очередь уделялось внимание построению процедур. Данные же если и рассматривались, то, как правило, с целью доказать, что все, что машины могут выполнить с данными самой сложной структуры, они могут выполнить и при примитивном представлении данных в дву- значном алфавите, т.е. фактически в виде нулей и единиц. Основная вычислительная машина классической теории алгоритмов — машина Тьюринга — имела множество модификаций, но все они различались, как правило, количеством лент и (в лучшем случае) способами доступа к ячейкам на этих лентах. Основные же усилия классиче- ской теории машин Тьюринга были направлены на то, чтобы доказать, что все, что может выполнить какая-нибудь из таких многочисленных модификаций, может вы- полнить и некая довольно простая универсальная машина Тьюринга. Для программи- ста это фактически означало, что основной фокус искусства программирования состо- ял не в том, чтобы решить задачу в естественных терминах, а в том, чтобы предста- вить решение задачи как обработку двоичных данных. И если оказывалось, что в исходной постановке задачи допущена ошибка (или просто нечеткая постановка не- правильно была истолкована программистом), то исправлять приходилось именно не поведение объектов, а процедуры обработки примитивных данных. Понятие исполни- теля фактически отсутствовало, так как в качестве единственного исполнителя высту- пала только сама ЭВМ. Иными словами, рассматривался исполнитель, все действия которого сводятся к обработке слов, записанных в ОЗУ. (Например, говорилось, что вот эта команда запишет вот в эту ячейку сумму чисел из такой-то и такой-то ячеек, а вон та команда прочтет в сумматор двоичный код нужной для данной программы фи- зической константы.) Языки программирования должны были облегчать написание программ, разработанных в рамках этой парадигмы, поэтому не удивительно, что поч- ти все языки высокого уровня поначалу были процедурными. В основном они были ориентированы на облегчение записи процедур. Процедурные языки дают программисту возможность разбить программу обработки информации на несколько процедур более низкого уровня. Иными словами, процедурные языки позволяют объединить процедуры более низкого уровня в единую программу. Конечно, такая парадигма программирования была прогрессивной по сравнению с парадигмой программирования на* машинном языке, так как в нее было добавлено главное средство структурирования поведения ЭВМ — процедуры. Более мелкие функции не только проще понять, но также проще отладить. Однако данные и проце- дуры их обработки были разделены, так что каждая процедура должна была знать, что делать с данными. Процедура, которая ведет себя плохо, может испортить данные. Кроме того, каждая процедура должна была дублировать механизмы доступа к дан- ным, так что изменение представления данных приводило к изменениям всех тех операторов программы, в которых этот доступ осуществлялся. Даже маленькая по- правка могла привести к целому каскаду изменений во всей программе. Нельзя сказать, что не предпринимались попытки устранить недостатки, обнару- женные в процедурном программировании. Скорее как раз наоборот. Технология структурного программирования, например, упорядочивает использование управляю- щих структур, чем существенно упрощает создание качественных процедур. Тем са- мым она позволяет создавать довольно качественные проекты объемом до 50 000 строк. (Да в принципе и миллион строк для такой технологии — вполне преодолимый предел, однако разработка проектов такого объема требует времени и мастерства ис- полнителей.) Важно отметить также, что повсеместное распространение структурного программирования дало второе дыхание идее модульных программ. В частности, поя- вились такие новые языки модульного программирования, как Modula2. Эти языки позволяли не просто создавать модульные программы, но и строить их с учетом всех достижений структурного программирования. Кроме того, в отличие от процедурного программирования, которое разделяет данные и процедуры, эти языки программиро- ООП. Классы и объекты 105
вания позволяли объединить данные и процедуры в модулях. В соответствии с техно- логией модульного программирования программа разбивается на ряд составляющих компонентов, или модулей. Модуль состоит из самих данных и процедур для обработ- ки данных. Когда другим частям программы нужно использовать модуль, они просто обращаются к интерфейсу модуля, причем вся внутренняя информация модуля скры- вается от остальных частей программы. Поэтому у модулей появилось состояние, ко- торое они могли хранить в своих внутренних переменных. (Внутренняя переменная — это величина, хранимая внутри объекта.) Состояние модуля можно было изменять, обращаясь к интерфейсу модуля. (Состояние объекта — это совокупность значений внутренних переменных объекта.) Однако модульное программирование имеет свои недостатки. Модуль еще не стал полноценным объектом, так как слишком узок был набор операций, выполняемых над модулями. В частности, модули не расширяемы, а это означает невозможность произво- дить пошаговые изменения модуля без непосредственного доступа к коду и его прямого изменения. Кроме того, при разработке одного модуля нельзя использовать другой, ина- че как через передачу (делегирование) функций. И хотя в модуле можно определить тип, один модуль не может использовать тип, определенный в другом модуле. В модульных и процедурных языках у структурированных и неструктурированных данных есть свой “тип”. Тип, как вы знаете, можно определить как множество значе- ний и операций над ними. Однако в рамках модульных и процедурных языков тип легче всего было определить как формат данных в оперативной памяти. (Иными сло- вами, в определении типа опускались операции, т.е. процедуры, поэтому типы были лишены своей активной части и рассматривались лишь как способ представления данных.) В языках со строгим контролем типов каждый объект должен иметь кон- кретный определенный тип. Однако способов расширения типа нет, если не считать создание других типов с помощью метода, называемого агрегированием. Рассмотрим, например, два типа данных в С: typedef struct { int а; int b; } aBaseType; /★ базовый тип ★/ typedef struct { aBaseType Base; /★ агрегирование базового типа ★/ int с; } aDerivedType; /* производный тип ★/ В этом примере производный тип aDerivedType базируется на aBaseType, одна- ко со структурой aDerivedType нельзя обращаться так же, как со структурой aBaseType. Можно лишь обращаться к элементу Base структуры aDerivedType. За- метьте, что о передаче кода (процедур) от базового типа производному и речи быть не может, поскольку активная часть типа (операции над значениями) просто исключена из определения типа. Так что модульное программирование — это гибридная процедурно ориентирован- ная технология, при следовании которой программа разбивается на ряд процедур. Но есть и прогресс: теперь процедуры не выполняют действий над необработанными данными, а управляют модулями. Можно сказать, что теперь исполнителем выступает не ЭВМ, а модуль. Фактически это позволило рассматривать программу как совокуп- ность модулей (исполнителей). Хотя это был значительный шаг вперед, понадобилась еще одна логическая ступень, чтобы подняться на более высокий концептуальный уровень, на котором можно было бы управлять объектами-исполнителями более эф- фективно. Такой ступенью и стало объектно-ориентированное программирование. 106 Глава 4
Объектно-ориентированное программирование Чем же отличается объектно-ориентированное программирование от развитого мо- дульного? Тем, что в объектно-ориентированном программировании программа рас- сматривается не как совокупность модулей, а как совокупность взаимодействующих объектов. Конечно, все бы свелось просто к переименованию переменных, как любил выражаться Давид Гильберт, если бы объекты рассматривались так же, как и модули. Однако понятие объекта отличается от понятия модуля именно тем, что оно позволя- ет более полно отразить взаимосвязь вещей (в философском значении этого слова) в реальном мире. Строго говоря, это утверждение следует уточнить. Те “воинствующие” агностики, которые отрицают какую бы то ни было (хотя бы и частичную) познавае- мость мира, могут даже протестовать против него. Более точно это утверждение сле- довало бы выразить так: понятие объекта отличается от понятия модуля тем, что оно позволяет более полно отразить взаимосвязь наших понятий о вещах. Однако те, кто отрицает какую бы то ни было (хотя бы и частичную) познаваемость мира, отрицает и возможность понять что-либо вообще, и обсуждаемое нами положение в частности. Но разве можно говорить, что обсуждаемое нами положение неверно, если вообще ничего не понимаешь? Да и зачем вообще что-нибудь говорить, если понять ничего невозможно? Да и никто, в том числе и “воинствующие” агностики сами — с их точ- ки зрения — тоже ведь не может понять ихние речи. Так что я пишу не для “воин- ствующих” агностиков, а для программистов. А программисты не ведут бесполезные споры о тотальном непонимании, а используют взаимосвязь вещей (да и понятий) в своих программах. Поэтому вместо того, чтобы обсуждать сложность процесса пищеварения, лучше перейти непосредственно к обеду, т.е. к обсуждению того, какие же именно взаимо- связи вещей и понятий позволяют моделировать объектно-ориентированный подход. Взаимосвязь вещей и понятий: классы и объекты Прежде всего следует отметить, что объектно-ориентированный подход позволяет понять сущность объектов, из которых устроен моделируемый в программе мир (и выразить свое понимание этой сущности в программе). Сущность объекта выражается в его определении, в понятии объекта. С уверенностью можно сказать, что существо- вание объекта подтверждается в результате контакта, а сущность постигается разумом, т.е. в результате создания логической модели объекта. Хотя объектно-ориентиро- ванное программирование и не дает окончательного ответа на философский вопрос о совпадении бытия (существования) и сущности, оно позволяет поставить этот вопрос для моделируемой в программе предметной области и разрешить его одним из спосо- бов, предусмотренных в конкретном объектно-ориентированном языке программиро- вания. Боэций (ок. 480 — 524), например, четко различает понятия сущности и суще- ствования и считает, что они совпадают только в простой субстанции — в Боге. Он также считал, что остальные объекты не просты, а сложны, и прежде всего это выра- жается в том, что их бытие и сущность не тождественны. Этой точки зрения следуют те объектно-ориентированные языки программирования, в которых предусмотрены классы. Такие языки программирования позволяют считать объекты простыми пред- метами, сущность которых выражается в классах. А чтобы та или иная сущность полу- чила существование, она должна стать причастной к бытию или, проще говоря, долж- на быть сотворена Божественной волей. Ведь Бог, как вы помните, — творец Вселен- ной; правда, Лапласу эта гипотеза не понадобилась. Однако, в отличие от Вселенной, миры, моделируемые в программах, имеют своего создателя, поэтому он (програм- мист) должен предусмотреть создание всех нужных объектов. В программе объекты создаются с помощью классов. Так как сущности объектов в программе представлены классами, то классы — это некие универсалии, которые программист может рассмат- ООП. Классы и объекты 107
ривать как шаблоны (чертежи, идеи), по которым он может создать (сконструировать) конкретные объекты. Поэтому-то сами объекты часто называются экземплярами клас- сов. Класс — это некий дух, который превращает бездушные вентили (память компь- ютера) в реально действующих исполнителей (объекты). Правда, при таком подходе существование объектов идет после существования классов. Иными словами, программист сначала должен создать классы, и лишь потом он сможет создать нужные ему объекты. Этот подход позволяет при наличии ресурсов (памяти) по одному шаблону (классу) без труда (простым применением операции new к классу) создать любое нужное количество объектов — экземпляров класса. Труд- ность же заключается в том, что сначала должны быть созданы более общие поня- тия — классы, и лишь затем более конкретные веши — объекты. Иными словами, план объекта должен быть готов до создания объекта. Трудности подготовки такого плана были осознаны еще средневековыми номина- листами. Крайние номиналисты, к которым принадлежал, например, выдающийся философ XI—XII веков Росцелин, даже доказывали, что общие понятия — это не бо- лее чем звуки человеческого голоса; реально лишь единичное, а общее — только ил- люзия, не существующая даже в человеческом уме. Даже сам термин “номинализм” указывает на эту трудность — он происходит от латинского слова “nomen”, что значит “имя”. Номинализм рассматривает общие понятия только как имена, не обладающие никаким самостоятельным существованием вне и помимо единичных вещей: они об- разуются нашим умом путем абстрагирования признаков, общих для целого ряда эм- пирических вещей и явлений. Так, например, мы получаем понятие “объект”, когда отвлекаемся от особенностей отдельных объектов и оставляем только то, что является общим для них всех. А поскольку все объекты имеют свойства и методы, то, стало быть, в понятие “объект” входят именно эти признаки: объект есть то, что обладает свойствами и методами. Поэтому согласно учению номиналистов универсалии суще- ствуют не до, а после вещей. Однако в программе классы нужно создать до создания объектов. Для этого программист должен отвлечься от индивидуальных свойств от- дельных объектов, все еще не существующих в программной модели! Процесс абст- рагирования, направляемый волей, а не разумом, привел бы к созданию классов, ко- торые не выражали бы сущностей реальных объектов. Это были бы просто произ- вольные имена, т.е. идентификаторы — последовательности букв и цифр, — не связанные с реальными объектами. Конечно, наряду с номинализмом существовало и противоположное направле- ние — реализм, которое подчеркивало приоритет разума над волей. Согласно этому средневековому учению подлинной реальностью обладают только общие понятия, или универсалии, а не единичные предметы, существующие в эмпи- рическом мире. Для программиста это означает, что подлинно полезной сможет стать лишь та программа, в которой тщательно продумана система классов. Согласно кон- цепциям средневекового реализма, универсалии существуют до вещей, и только бла- годаря этому человеческий разум в состоянии познавать сущность вещей. Однако для программиста эта концепция, понимаемая буквально, не конструктивна, так как до создания программы классы еще не определены. Правда, поскольку для реалистов, например для Ансельма Кентерберийского (1033— 1109), познание возможно лишь с помощью разума, ибо лишь разум способен постигать общее, эта концепция указыва- ет и инструмент для построения системы классов: разум. Но это указание столь не- конкретно, что едва ли его можно считать конструктивным. Для программиста оно лишь означает, что реализация классового подхода без теории неизбежно привела бы к хаосу. Чтобы избежать хаотического творения классов, нужна технология. В на- стоящее время такая технология разработана в объектно-ориентированном анализе. Только после долгих лет забвения и тяжелой борьбы с прочно установившимися традициями технология объектно-ориентированного программирования (ООП) в сво- ем развитии достигла того уровня, когда люди, наконец, смогли увидеть ее потенци- 108 Глава 4
альные возможности. Именно успех таких языков, как C++, Java и CORBA и привлек внимание к технологии разработки объектно-ориентированных программ. Раньше программистам приходилось упрашивать своего начальника, чтобы он разрешил им использовать объектно-ориентированный язык. Сегодня же употребление такого язы- ка обязательно во многих компаниях. Так что не будет преувеличением сказать, что только после долгих лет блуждания в пышно зеленеющих (и таящих в себе бесчис- ленные опасности) джунглях практики бессистемного программирования, где Мефи- стофель без труда заполучил душу не одного юного программиста, наконец-то сдела- ны правильные выводы. Мы же, не отказываясь от удовольствия созерцать “пышно зеленеющие деревья жизни”, должны основательно познакомиться с объектно- ориентированным программированием, которое позволит нам избежать многих опас- ностей “асфальтовой топи технологии программирования”, как часто называл Ф. П. Брукс-младший джунгли бессистемного программирования. Объектно-ориентированное программирование Парадигма объектно-ориентированного программирования коренным образом от- личается от своих предшественников именно тем, что в явном виде (в программе!) учитывает необходимость уяснения разработчиком сути задачи. Действительно, в классической теории алгоритмов этому никакого внимания не уделялось, а все вни- мание концентрировалось на работе машины Тьюринга — фактически на стирании и записи единичек в клетках бесконечной ленты. В процедурном программировании основное внимание уделялось разбиению программы на модули — для этого нужно было, конечно, уяснить суть задачи, но в программе это отражалось только в разбие- нии на модули да в операциях над данными. В структурном программировании ос- новное внимание уделялось структурированию программы, иными словами, правиль- ному применению управляющих структур. Конечно, постоянно подчеркивалось, что в программе необходимо отразить суть задачи, но за отсутствием объектно-ориентиро- ванных языков эта самая суть могла быть отражена лишь... в комментариях к про- грамме. Вначале даже важность комментирования программы не была осознана в полной мере. Синтаксис флагмана процедурно ориентированных языков — Алго- ла-60 — хотя и допускал комментарии в программе, но в отношении комментариев был настолько неуклюж, что в одном официальном руководстве по этому языку гово- рилось, что в готовую программу можно вставить комментарии и приводились прави- ла определения тех мест, куда можно вставлять комментарии. Это подобно развеши- ванию новогодних украшений на елку. Но едва ли кто-то всерьез станет утверждать, что в новогодних елочных украшениях отражается мудрость и опыт лесничего, прояв- ленные им при посадке елового леса! Точно так же нельзя было ожидать и того, что комментарии, вставляемые в программу после ее завершения, будут отражать те зна- ния о предметной области, которые понадобились программисту (а чаше коллективу программистов) для создания программы. Объектно-ориентированная программа является записью (на языке программиро- вания) событий, происходящих в предметной области. Иными словами, объектно- ориентированная программа как раз и представляет собой модель того мира, про- граммную модель которого создает программист, и ничего больше! Конечно, это на- поминает тавтологию. Но ведь это тавтология только в случае объектно-ориенти- рованных программ! По классической парадигме программирования эту модель при- шлось бы расщепить на данные и процедуры. А сможете ли вы по разлетающимся щепкам узнать лес? В случае объектно-ориентированного подхода вы создаете объек- ты (например лес, деревья) и используете их в программе, а трошить лес на шепки ООП. Классы и объекты 109
вам не нужно. Однако все это делается не частым произнесением заклинаний о то- тальном применении ООП, а в результате уяснения сути задачи, т.е. в результате по- строения концептуальной модели (для этого нужно применить объектно-ориентиро- ванный анализ). В самый раз спросить, ради достижения каких целей следует приме- нять ООП и какие преимущества мы получаем от его применения? Иными словами, какими свойствами должны обладать объектно-ориентированные программы? Свойства объектно-ориентированных программ ООП — не самоцель. Программное обеспечение, разработанное в соответствии с парадигмой ООП, должно иметь определенные свойства. Вот главнейшие из них: • естественность программ; • надежность программ; • возможность повторного использования; • удобство сопровождения; • возможность усовершенствования программ; • удобство периодического выпуска (издания) новых версий. Рассмотрим, какие преимущества дает каждое из этих свойств. Естественность программ В настоящее время центром — некоторые сказали бы основа- нием — аналитической философии является философия языка. Главный ее вопрос: что такое значение, каким образом слова означают то, что они означают? Б. Страуд. Аналитическая философия и метафизика В естественных программах используются не программистские термины (вроде мас- сив, ячейка, переменная, бит), а термины из той предметной области, к которой отно- сится решаемая задача. Поэтому при создании программы не нужно вникать во все детали, связанные с компьютером. Вместо того чтобы подгонять разрабатываемую программу под компьютерный язык, ООП дает возможность пользоваться термино- логией конкретной предметной области. Объектно-ориентированный язык позволяет смоделировать решение задачи на функциональном уровне, а не на уровне реализации. Чтобы использовать определен- ную составляющую программы, не нужно знать, как она работает; достаточно знать, что она делает. Надежность программ Хорошо разработанная и аккуратно написанная объектно-ориентированная про- грамма не менее надежна, чем моделируемый в ней мир. Модульная природа объек- тов позволяет производить изменения программы по частям. В такой программе ин- формацией владеют именно те объекты, которым она нужна, причем и ответствен- ность несут объекты, выполняющие нужные функции. Так что объектно-ориентированный подход позволяет сосредоточить в одном месте информацию и выполнение функций, а значит, и изолировать их от других частей системы. А это, в свою очередь, позволяет проверить каждый компонент в отдельно- сти. Проверив компонент, вы можете спокойно использовать его повторно. 110 Гпава 4
Повторное использование Чтобы построить дом, не нужно изобретать новый вид кирпичей. Если задача ре- шена, можно многократно использовать ее решение. Так почему же программист должен “изобретать колесо”? Как и модули, профессионально разработанные объектно-ориентированные клас- сы можно повторно использовать в различных программах. В отличие от модулей, для усовершенствования классов можно использовать наследование, а для написания на- страиваемого кода — полиморфизм. Однако создание хороших классов — это искусство, доступное подчас лишь профес- сионалам; оно требует внимательного подхода к абстракциям при выделении основных признаков. Программисты хорошо знают, что овладеть этими качествами непросто. Но зато ООП позволяет воплощать довольно абстрактные идеи и использовать их для решения конкретных задач. Удобство сопровождения Жизненный цикл программы не заканчивается после ее разработки. Более того, разработка обычно занимает всего лишь 20% жизненного цикла. Правильно разработанная объектно-ориентированная программа удобна в обслу- живании. Если изменится концептуальная модель, нужно будет лишь внести измене- ния в те классы, которые отражают новое содержание модели; все другие объекты ав- томатически начинают пользоваться преимуществами усовершенствования, кроме того, текст естественной программы понятен для других разработчиков — а все это позволяет не только исправлять ошибки, но и усовершенствовать программы. Возможность усовершенствования программ Ни одно изобретение не может сразу стать совершенным. Марк Туллий Цицерон Часто к системе нужно добавить новые функции, поэтому в ООП предусмотрено много способов расширения функциональности объектов. Среди них наследование, полиморфизм, переопределение, делегирование и множество шаблонов, которые можно использовать не только в процессе разработки, но и для своевременного вы- пуска (издания) новых, усовершенствованных версий программ. Периодический выпуск (издание) новых версий Не бойтесь совершенства. Вам его не достичь. Тем более, что в совершенстве нет ничего хорошего. Сальвадор Дали Поскольку естественность дает возможность сосредоточиться на решаемой задаче, она не только упрощает разработку сложных систем и повышает надежность, но и со- кращает цикл разработки программного обеспечения. Ведь после разбиения програм- мы на ряд объектов разработку каждой отдельной части программы можно вести па- раллельно с другими. Например, классы могут разрабатываться несколькими разра- ботчиками независимо друг от друга. А такой параллелизм в разработке сокращает ее время. Кроме того, благодаря ООП программы могут использоваться повторно, по- этому цикл разработки программ удалось существенно сократить и теперь жизненный цикл современного программного изделия может измеряться неделями. Иными сло- вами, всего лишь через несколько недель после выхода очередной версии программ- ного продукта вы сможете представить пользователям его новую, усовершенствован- ООП. Классы и объекты 111
ную версию. Но чтобы научиться писать столь быстро модифицируемые программы, нужно на практике овладеть теорией объектно-ориентированного программирования. А всякая теория, как известно, имеет свои понятия. Именно с ними нам и предстоит ознакомиться. Базовые понятия объектно-ориентированного программирования Всякую теорию можно сравнить с башней, только башня строится из строитель- ных материалов, а теория — из понятий. Это сравнение можно продолжить и дальше: у всякой башни, как и у теории, есть основа. Основа башни состоит из крупных, прочных, я бы сказал фундаментальных блоков, а основа теории — из базовых поня- тий. Если из основания башни удалить блок, все здание рухнет. Точно так же и тео- рию нельзя освоить, не уяснив ее фундаментальных (базовых) понятий. Считается, что в основе ООП лежат три базовых понятия (инкапсуляция, наследова- ние и полиморфизм) и все они должны быть представлены в развитом объектно- ориентированном языке. Чтобы применять объектно-ориентированный подход при создании программ, необходимо хорошо разобраться в базовых понятиях ООП. Резюме Довольствуйся настоящим, но стремись к лучшему. Исократ Мы ознакомились с объектно-ориентированным программированием и рассмотре- ли развитие основных парадигм программирования, а также некоторые базовые поня- тия ООП. К этому моменту вы должны иметь представление о таком важном понятии объектно-ориентированного подхода, как класс, а также о том, как объекты обмени- ваются информацией. Определения важны, однако не следует, застряв в методах решения, забывать о той задаче, которую мы пытаемся решить с помощью объектно-ориентированного подхо- да. Используя объектно-ориентированное программирование, мы хотим достичь шес- ти основных целей (их же можно будет рассматривать в качестве преимуществ про- грамм, разработанных с помощью объектно-ориентированного подхода): • естественность; • надежность; • возможность повторного использования; • удобство в сопровождении; • способность к расширению; • удобство периодического выпуска (издания) новых версий. Никогда не забывайте об этих целях. Задачи и упражнения Приведенные ниже вопросы имеют целью закрепить теоретические знания, полу- ченные вами в процессе проработки главы, и предостеречь вас от типичных ошибок. 112 Глава 4
1. (Отличие ООП от объектно-ориентированного языка.) Нет ничего проще, чем ос- воить ООП. Достаточно написать программу на каком-нибудь объектно-ориен- тированном языке. Так ли это? 2. (Страх повторного использования.) Я не лентяй, умею выделять текст, копиро- вать его в буфер и вставлять в нужное место. Поэтому мне проще написать нужный метод, а не использовать чужой, да еще не известно как запрограмми- рованный. Кроме того, я не люблю повторное использование потому, что оно втискивает меня в кем-то (иногда мной же на несколько недель раньше) уста- новленные рамки. Поэтому гораздо проще скопировать нужный мне метод и внести в него незначительные коррективы, чем использовать чужой (да хотя и бы и свой собственный!) шаблон. Правильно ли это рассуждение? 3. (000 — это не лекарство от всех болезней.) Нет такой проблемы в мире про- граммирования, которую нельзя было бы решить с помощью ООП. Согласны ли вы с этим? 4. (Эгоцентрическое программирование.) Мне наплевать на то, что мои классы и методы непонятны другим программистам, — ведь создаю я их для себя и по- тому документацию писать не собираюсь! Можно ли с этим согласиться? ООП. Классы и объекты 113

Глава 5 Инкапсуляция В этой главе... Инкапсуляция, черные ящики и их интерфейсы Абстракция: учимся думать и программировать абстрактно Сокрытие реализации Резюме Задачи и упражнения 115 117 120 126 126 ...все веши сокрыты для нас, нет ни одной, о которой мы в со- стоянии были бы установить, что она такое. Эмпедокл Теперь, зная, какими качествами должны обладать объектно-ориентированные программы, мы будем учиться достигать поставленных целей (по крайней мере, в программировании). Иными словами, мы будем изучать теорию ООП: будем осваи- вать понятия и методы объектно-ориентированного подхода. Начнем с самого про- стого базового понятия — с инкапсуляции — не только потому, что она имеет, пожа- луй, более богатую историю, чем остальные (и потому ее теоретические основы разра- ботаны подробнее остальных), а по той причине, что освоив инкапсуляцию, легче понять наследование и полиморфизм. Инкапсуляция, черные ящики и их интерфейсы Все эти веши сокрыты от нас... Плиний Старший. Естественная история Как вы знаете, модуль должен быть замкнутым, а его реализация скрыта от осталь- ной части программы. Именно это свойство в модульном программировании называется модульностью. Однако в связи с тем, что в ООП средства достижения модульности су- щественно богаче, в ООП чаще применяется синоним этого понятия: инкапсуляция. Что такое инкапсуляция, черный ящик, интерфейс и реализация Инкапсуляция — это объектно-ориентированная характеристика модульности. Иными словами, инкапсуляция — это механизм, который объединяет данные и методы их обра-
ботки, а также защищает их (данные и методы) от внешнего вмешательства и неправиль- ного использования. Если некоторый программный объект инкапсулирован, то его можно рассмат- ривать как черный ящик. Что делает черный ящик, вы знаете лишь постольку, по- скольку видите его внешний интерфейс (т.е. кнопки на его панели управления). Чтобы заставить черный ящик сделать что-либо, нужно послать ему сообщение (т.е. нажать кнопку). Совершенно не важно, что именно происходит внутри чер- ного ящика; важно лишь, что он выполняет нужную вам работу. Интерфейс — это своего рода контракт с внешним миром, в котором указано, какие запросы внешние объекты могут посылать данному объекту. Контракт, в ООП часто называемый также спецификацией, — набор четко определенных усло- вий, регулирующих отношения между классом-сервером (supplier) и его клиентами (client), или пользователями', он включает индивидуальные контракты для всех экспортируемых членов класса, представленные пред- и постусловиями, а также глобальные свойства класса, выраженные в инварианте класса. Предусловие, или входное условие, как и постусловие, или выходное условие, — это тоже часть контрак- та. Образно говоря, интерфейс — это пульт управления объектом. Именно в интерфейсе сообщается, как обратиться к компоненту. Но интерфейс не сообщает, как компонент выполняет свою работу. Назначение интерфейса, подобно пульту управления, как раз и состоит в том, чтобы скрыть реализацию от внешнего мира. А ведь именно это дает возможность изменять реализацию компонента в любое время. Но если интерфейс остается неизменным, то, несмотря на изменение реализа- ции, нет необходимости производить какие-либо изменения в остальной части про- граммы, использующей обновленный класс. А вот изменение интерфейса неизбежно влечет изменения в программе, использующей данный интерфейс. Теперь, когда мы знаем, что пульт управления черным ящиком называется интер- фейсом, может возникнуть вопрос: а как же называется то, что внутри черного ящи- ка? Содержимое черного ящика называется реализацией. Реализация — это алгоритм исполнения компонентом определенного задания. Реализация определяет внутренние де- тали компонента. Понятие интерфейса настолько важно, что есть языки, например Java, в кото- рых для интерфейсов предусмотрены специальные синтаксические конструк- ции. В этих конструкциях часто используется ключевое слово interface. Од- нако синтаксические конструкции C++ имеют настолько общий смысл, что не- обходимость в ключевом слове interface отсутствует. Реализация инкапсуляции в языках программирования. Уровни доступа Поскольку инкапсуляция защищает данные и методы от внешнего вмешательства и неправильного использования, то она обеспечивается, естественно, с помощью средств разграничения доступа. У различных языков эти средства, конечно, различны. Наряду с такими традиционными средствами, как блочная структура, функции и под- программы, раздельная компиляция, в объектно-ориентированных языках используются пространства имен, абстракция и уровни доступа. Наиболее часто в объектно-ориенти- рованных языках предусматриваются следующие три уровня доступа: общедоступный — public (разрешен доступ для всех объектов), защищенный — protected (разрешен доступ для данного экземпляра и всех его потомков) и частный — private (разрешен доступ только для данного экземпляра). 116 Глава 5
Зачем нужна инкапсуляция? Именно инкапсуляция позволяет обращаться с объектами как со сменными ком- понентами. Для использования объекта достаточно использовать общедоступный ин- терфейс этого объекта. Такая независимость от деталей реализации дает три значи- тельных преимущества. При необходимости объект можно использовать повторно. Ведь при тщатель- ной инкапсуляции объекты не привязаны к определенной программе. Их мож- но использовать везде, где это имеет смысл. Нужно просто воспользоваться ин- терфейсом объекта. Благодаря инкапсуляции изменения объекта невидимы для других объектов, если только интерфейс не изменяется. Поэтому инкапсуляция позволяет усовершенствовать компонент, повысить эффективность реализации и уст- ранить ошибки, не затрагивая другие объекты программы. Пользователи объекта выигрывают от любых усовершенствований автоматически. Защита объекта исключает возможность каких-либо непредсказуемых взаимодей- ствий между объектом и остальной частью программы. Изолированный объект мо- жет взаимодействовать с остальной частью программы только через свой интерфейс. Итак, подведем итог: именно инкапсуляция позволяет создавать модульные про- граммы. Три характерных признака эффективной инкапсуляции таковы: абстракция; сокрытие реализации; разделение ответственности. Чтобы научиться правильно выполнять инкапсуляцию, рассмотрим более детально каждый из этих признаков. Абстракция: учимся думать и программировать абстрактно Утонченные логические абстракции ведут к такому восприятию жизни, которое хотя и поражает поначалу, но является именно тем. что притуплено в нас привычностью и повторением. Оно как бы срывает с жизненной сцены разрисованную завесу. Перси Биши Шелли. О жизни Эффективная инкапсуляция не возникает автоматически в результате приме- нения объектно-ориентированного языка; такой язык лишь обеспечивает средства инкапсуляции. Правильная инкапсуляция — это результат тщательной разработ- ки, применения абстракции и опыта. Чтобы правильно применять инкапсуляцию, необходимо научиться использовать абстракцию и связанные с ней концепции. Что такое абстракция? Абстракция — это процесс упрощения сложной задачи. Решая определенную задачу, нужно учитывать не все детали, а только существенные. Например, в модели дорож- ного движения должны быть классы светофоров, машин, шоссе, двухсторонних и од- носторонних улиц, погодных условий и всего того, что влияет на движение транспор- та. В качестве применения абстракции в программировании можно привести абст- Инкапсуляция 117
рактные типы данных, такие как очереди с различными дисциплинами обслуживания, стеки, графы и т.д. Абстракция дает два преимущества. Во-первых, она упрощает решение задачи. А кроме того (и это еще более важно!), благодаря абстракции компоненты программного обеспечения можно использовать повторно. Старайтесь избегать чрезмерной специали- зации компонентов программы. Ведь чрезмерно специализированные компоненты рассчитаны на решение какой-либо узкой задачи и часто столь взаимозависимы, что использовать их повторно почти невозможно. Поэтому старайтесь создавать объекты, которые будут решать целый ряд задач. Именно абстракция позволяет использовать решение одной задачи для решения других задач из данной предметной области. Грань между чрезмерной специализацией и недостаточной детализацией очень тонка. Только опыт помогает распознавать ее. Однако необходимо нау- читься применять эту важную концепцию. Правильное применение абстракции Каждый из двух антагонистов... вправе упрекнуть другого, что тот не отбрасывает второстепенные и вместе с тем пренебрега- ет существенными чертами. Вейль Г. Топология и абстрактная алгебра Теперь мы можем сформулировать несколько правил для эффективного примене- ния абстракции. Старайтесь рассматривать общий случай, а не какой-то частный. Постарайтесь найти то общее, что присуще различным задачам. Старайтесь уло- вить основной принцип, а не отдельный случай. Не увлекайтесь абстракцией настолько, чтобы забыть саму решаемую задачу. Ведь цель — решить задачу. Абстракция же — средство, а не конечная цель. Ув- лекаясь абстракцией, вы рискуете пропустить сроки и создать неправильную абстракцию. Иногда абстракцию применять не следует. Некоторые программи- сты практикуют следующее хорошее эвристическое правило: абстракцию следу- ет применять лишь к тем задачам, которые вы уже решали сходными между со- бой способами не менее трех раз. Только приобретя опыт, вы сможете быстро выделять абстрактные компоненты. Абстракция не всегда очевидна. Решая задачу, иногда трудно распознать абст- рактные компоненты с первого, со второго и даже с третьего раза. В соответст- вии с этим правилом иногда задачу приходится решать несколько раз. Различие в ситуациях помогает выделить абстрактные компоненты, однако даже в этом случае необходимо приложить немало усилий. Чтобы научиться применять аб- стракцию, потребуется время. Не воспринимайте неудачу как личную трагедию. Ведь невозможно написать такую абстрактную программу, которая подходила бы для каждой ситуации. Поскольку абстрактный компонент предназначен для решения ряда задач, а не ка- кой-то одной специфической задачи, его легче использовать повторно. Однако при этом очень важно научиться скрывать внутренние детали реализации. Применение абстрактных типов данных способствует действенному применению инкапсуляции. 118 Глава 5
Абстракция: советы, как избежать типичных ошибок Здесь начинается поворот, на котором абстракция сбивается с пути понятия и покидает истину. Ее более высокое и наивыс- шее всеобщее, до которого она возвышается, это лишь поверх- ность, становящаяся все более бессодержательной, а презирае- мая ею единичность есть та глубина, в которой понятие по- стигает само себя. Георг Вильгельм Фридрих Гегель. Наука и логика Чрезмерное увлечение абстракцией может создать проблемы при определении класса. Практически невозможно написать абсолютно абстрактный класс, подхо- дящий всем пользователям и во всех ситуациях. Допустим, нужно создать класс чисел для небольшого калькулятора. Этот класс будет значительно отличаться от класса чисел в мощной системе аналитических вычислений, допускающей опера- ции над р-адическими числами. Чрезмерное увлечение абстракцией опасно. Применение абстракции не гарантиру- ет абсолютную универсальность разрабатываемого элемента, поэтому он может рабо- тать не во всех ситуациях. Очень трудно (и часто просто невозможно) написать класс, который удовлетворит всем потребностям пользователя. Если вы будете преследовать недостижимую цель, вы зациклитесь на абстракции. Чтобы избежать этого, в первую очередь нужно решать поставленную задачу. Все в конечном счете сводится к решению конкретной задачи. Включить все детали, необходимые классу чисел во всех мыслимых контекстах, очень сложно. Если попытаться сделать это, утратится основное преимущество абстракции — простота. Так что фактически класс уже не будет абстрактным. Чтобы не утратить упрощений, которые предлагает абстракция, не следует помещать в класс больше, чем необходимо для решения задачи. Не решайте сразу все задачи, сосредоточьтесь на какой-то одной. И лишь решив задачу, можно попытаться применить абстракцию к тому, что уже сделано. Старайтесь сохранить простоту классов, не перегружайте их невероятным количе- ством методов. Чем больше функций у объекта, тем он сложнее и тем сложнее его поддерживать. Класс — это фактически новый тип. Помня об этом, сосредоточьтесь на выполне- нии основного задания. Решая задачу, используйте термины, связанные с классами и их взаимодействиями, а не с данными и методами. Настоящая абстрактная программа не возникает в результате того, что програм- мист просто решил создать повторно используемый объект. Она должна основываться на потребностях реальной жизни. Ведь изобретения рождаются тогда, когда появляет- ся потребность в них. Этот же принцип действует и при создании классов. С первого раза написать действительно абстрактный, повторно используемый класс невозможно. Обычно повторно используемые классы создаются в процессе совершенствования программы после того, как в процессе эксплуатации она подвергалась многочислен- ным усовершенствованиям. Инкапсуляция 119
Абстракция и инкапсуляция. Нужен ли для них объектно-ориентированный язык? Сговора держись. Тайн не выдавай. Периандр Особенно опасна откровенность дружеская: сообщил свои тай- ны другому — стал его рабом... Итак, тайн не выслушивай и сам не сообщай. Грасиан-и-Моралес Бальтасар Возможно, вы думаете, что для абстракции и инкапсуляции при разработке про- граммы вовсе не обязательно применять объектно-ориентированный подход. И знае- те, вы правы. Абстрактные типы данных сами по себе не являются объектно- ориентированными. Применять инкапсуляцию, как уже было сказано ранее в разделе “Реализация инкапсуляции в языках программирования. Уровни доступа”, позволяет практически любой язык. Однако есть одна проблема. Если язык не является объектно-ориентированным, то часто необходимо создавать свой собственный дополнительный механизм инкапсуля- ции. А если в языке нет ничего такого, что бы помогало вам отслеживать вами же ус- тановленные стандарты, надо быть внимательным и самому неусыпно следить за со- блюдением этих стандартов. Это хотя и не радует, но иногда вполне приемлемо для одного разработчика. А вот если разработчиков будет двое? Десять? Большая группа? Чем больше разработчиков, тем сложнее выдержать единый стиль. Настоящий объектно-ориентированный язык предоставляет свой развитый механизм инкапсуляции, поэтому вам не приходится его изобретать. Можно смело утверждать, что язык инкапсулирует детали механизма инкапсуляции от пользователя. Ведь в объектно- ориентированном языке для этого предусмотрен набор ключевых слов. Программист только использует эти ключевые слова, а обо всех деталях заботится компилятор. Если программисты используют средства языка, то все они применяют один и тот же механизм, так что вопрос о совместимости механизмов, используемых разными разработчиками, даже не возникает! Сокрытие реализации Эту тайну надлежало сохранить во что бы то ни стало... Жан Поль Сартр. Бодлер С людьми ты тайной не делись своей, Ведь ты не знаешь, кто из них подлей. Как сам ты поступаешь с Божьей тварью, Того же жди себе и от людей. Омар Хайям Абстракция — это только одна из составляющих эффективной инкапсуляции. Абст- рактная программа может быть не защищена от внешних воздействий. Именно поэтому необходимо скрывать внутреннюю реализацию объекта. Сокрытие реализации дает два преимущества: защищает объекты от пользователей; защищает пользователей от объектов. Рассмотрим первое преимущество — защиту объектов. 120 Глава 5
Защита объекта с помощью абстрактного типа данных В глухом безлюдье льют растенья Томительный, как сожаленья, Как тайна, сладкий аромат. Жан Поль Сартр. Бодлер Абстрактные типы данных, как и объектно-ориентированный подход, появились в языке программирования Simula, разработанном в 1966 году. Понятие абстрактного типа данных — одно из ключевых в объектно-ориентированном подходе. Понятие ти- па очень важно, так как без него невозможно применить настоящую инкапсуляцию. Подлинная инкапсуляция обеспечивается на уровне языка с помощью встро- енных языковых конструкций. Все другие формы инкапсуляции просто пред- ставляют своего рода джентльменское соглашение, на самом деле их очень легко обойти. И хакеры не упустят возможность проникнуть внутрь объекта просто потому, что это возможно в принципе. Как известно, абстрактный тип данных — это набор данных и операций над ними. Тип переменной определяет, какие значения она может принимать, и какие над ней можно выполнять операции. Скрывая внутреннюю информацию и состояние за тщательно разработанным интерфейсом, абстрактные типы данных позволяют опре- делить в языке новые типы данных. В таком интерфейсе абстрактные типы данных представлены как неделимая целостность. Абстрактные типы данных облегчают применение инкапсуляции, так как благодаря им инкапсуляцию можно использовать без наследования и полиморфизма, а это позво- ляет сосредоточиться именно на инкапсуляции. Можно сказать, что объектно-ориен- тированный подход предлагает естественный способ расширения языка с помощью оп- ределения специализированных пользовательских типов. Классы, например, можно рас- сматривать как средства определения специализированных пользовательских типов. Чтобы пояснить эту мысль, давайте более подробно рассмотрим применение типов в программах. В программах создается ряд переменных и им присваиваются значения. Тип оп- ределяет область допустимых значений, которые может принимать переменная данного типа. Для положительных целых это числа без дробных частей, большие или равные нулю. Определение структурированных типов более сложное. К тому же тип определяет не только область допустимых значений, но и то, какие опера- ции можно выполнять над переменной, а также то, какого типа будут получаемые результаты. Типы в вычислениях выступают как независимые неделимые элементы. Возь- мем, к примеру, целое число. Складывая два целых числа, вы не думаете об опе- рациях над битами; вы всего лишь складываете два числа. Несмотря на то, что целое число в памяти компьютера представляется в виде битов, язык программи- рования дает программисту возможность работать с целыми числами. Скрывая двоичное представление, тип позволяет упростить сложную структуру, подняв ее на более понятный, концептуальный уровень, благодаря чему удается не вдаваться в из- лишние детали и при решении задачи можно всегда оставаться на уровне задачи, а не опускаться на уровень реализации. (А ведь для этого же предназначены и классы!) Но типы позволяют не только отвлечься (абстрагироваться) от излишних деталей; они гарантируют правильность, целостность и безопасность взаимодействия с объек- тами. Ограничения, налагаемые типом, предохраняют объекты от потери целостности и возможных деструктивных взаимодействий. Объявление типа предотвращает не- Инкапсуляция 121
преднамеренное или случайное его использование. Именно объявление типа гаранти- рует правильное применение типа. Для инкапсуляции абстрактные типы данных незаменимы, так как именно они позволяют определить новые безопасные в употреблении языковые типы. Подобно тому, как английский словарь ежегодно пополняется новой лексикой, абстрактный тип данных позволяет создавать новые слова программирования, когда возникает не- обходимость ввести новое понятие. Рассмотрим в качестве примера абстрактного типа данных абстрактную очередь. Существует ряд вариантов реализации очереди. Можно представить очередь в ви- де связанного списка, двусвязного списка или в виде массива. Однако базовая реализация не изменяет поведение очереди. Независимо от способа реализации, элементы продолжают двигаться в очереди согласно алгоритму “первым при- шел — первым обслужен”. Очередь можно использовать в виде абстрактного типа данных. Для использования очереди нет необходимости знать ее базовую реализацию. Но если очередь не пред- ставлена в виде абстрактного типа данных, то каждый объект, работающий с данными в очереди, должен будет знать, как она реализована и как правильно взаимодейство- вать с ней! Именно поэтому очередь нужно создать в виде абстрактного типа данных. Хорошо защищенная очередь в виде абстрактного типа данных гарантирует целост- ность данных и безопасность доступа к ним. Создавая абстрактный тип данных, нужно хорошо разобраться в том, какие опера- ции можно выполнять над переменными этого типа. Каждая из этих операций долж- на быть представлена в общедоступном интерфейсе класса. Интерфейс абстрактного типа данных ничего не сообщает о том, как хранятся данные, и не дает свободного доступа к внутренним данным. Все такие детали скрыты. Именно поэтому такой тип можно использовать в любой программе. Абстрактный тип данных следует рассматривать как единое целое. В этом-то и со- стоит мощь абстракции: вместо того, чтобы думать об указателях и списках, програм- мист может обдумывать решение задачи на более высоком уровне, используя терминоло- гию той предметной области, к которой относится решаемая задача. В абстрактном типе данных “очередь” автоматически заключены все детали, относящиеся к спискам и указа- телям, поэтому программисту не приходится думать о них. Он просто может использовать конструкцию высокого уровня с алгоритмом обслуживания “первым пришел — первым обслужен”. Тип, как и класс, может содержать другие типы. Благодаря этому скрываются дета- ли и увеличиваются выразительные возможности. Типы, содержащие другие типы, могут концентрировать в себе много понятий. Инкапсуляция не так проста, как может показаться на первый взгляд. Хотя мы уже достаточно много поговорили о ней, и даже обсудили абстрактные типы данных, пока что мы рассмотрели только одну сторону инкапсуляции — сокрытие реализации. Одна- ко важна и другая сторона медали — защита пользователей от объектов. Создание абстрактного типа данных в объектно-ориентированном языке ...тайны... — вечное напоминание о Нездешнем... Тайна прида- ет... легкость... бытие становится менее тягостным... Между тем тайна — это объективное бытие, которое может быть явлено в знаках, подслушано даже в немой сцене. Жан Поль Сартр. Бодлер В большинстве объектно-ориентированных языков совсем не сложно опреде- лить класс. Следовательно, решается задача создания абстрактного типа данных, по- 122 Глава 5
скольку класс подобен абстрактному типу данных. Использование класса даже имеет дополнительные преимущества, так как для абстрактных типов данных не предусмотрено наследование и полиморфизм. Внутри класса обычно находятся методы и внутренние переменные — данные. Доступ к этим переменным и методам предоставляется функциями доступа. Ин- терфейс абстрактного типа данных должен быть частью общедоступного интер- фейса класса. Защита пользователей с помощью сокрытия реализации Но даже если бы законы природы открыли нам все свои тай- ны, мы и тогда могли бы знать начальное положение только приближенно. Джеймс П. Кратчфилд, Дж. Дойн Фармер, Норман X. Пакка. Хаос Реализацию объекта можно скрыть с помощью интерфейса. Сокрытие реализации объ- екта защищает объект от непредвиденного и деструктивного использования — это очень важно не только для объекта, но и для пользователей объекта, так как они не обязаны учи- тывать реализацию объекта. Таким образом, сокрытие реализации объекта не только за- щищает объект, но также помогает избежать определенных неудобств тем, кто этот объект использует. Именно сокрытие реализации облегчает создание слабосвязанного кода. Слабосвязанный код — это код, не зависящий от реализации других компонентов. Силъносвязанный код, или код с непосредственными связями — это код, тесно свя- занныйьс реализацией других компонентов. Слабосвязанный код имеет значительные преимущества перед сильносвязанным. Действительно, если в общедоступном интерфейсе объекта появляется какое-либо свойство, то каждый пользователь этого объекта начинает зависеть от нового свойст- ва. Если свойство неожиданно пропадает, приходится вносить изменения в програм- му, которая стала зависимой от него. Зависимый код зависит от существования определенного типа. Полностью зависи- мости избежать нельзя, однако важно снизить ее степень до допустимой, поэтому нужно стараться ее минимизировать. Чтобы уменьшить зависимость, нужно правильно определить интерфейс. Пользователи могут зависеть только от интерфейса, но если реали- зация одного из объектов является частью интерфейса, пользователи объекта могут зави- сеть от этой реализации. Такие сильносвязанные коды мешают изменять реализацию объ- екта. Небольшое изменение реализации объекта приведет к ряду изменений всех пользо- вателей объекта. Обсудив достоинства сокрытия реализации, следует указать и на ее недостатки. Иногда нужную информацию не удается получить с помощью интерфейса. Вот самый простой пример. Предположим, в программе желательно использовать 64-битовые це- лые числа, так как действия выполняются над очень большими числами. Тогда нужно знать количество разрядов, отводимых под представление целых чисел. Поэтому в описании интерфейса важно отразить все необходимые детали реализации типов. Сокрытие реализации, как мы уже знаем, способствует созданию независимых, слабо связанных с другими компонентами программ. Слабосвязанные программы более устой- чивы, их легче модифицировать. А благодаря этому их проще использовать повторно и усовершенствовать, так как изменения в одной части системы не затрагивают остальных, независимых частей. Как же правильно скрывать реализацию и создавать слабосвязанные программы? Вот несколько советов: Инкапсуляция 123
Доступ к данным абстрактного типа должен осуществляться только через мето- ды интерфейса. Такой интерфейс обеспечивает сокрытие реализации. Исключите бесконтрольный доступ к структурам данных, что может случиться вследствие непреднамеренного возврата указателей или каких-либо ссылок. Получив ссылку, с ней можно сделать что угодно. Не стройте догадок об используемых типах. Если поведение не описано в ин- терфейсе или документации, не полагайтесь на свои догадки о нем. Старайтесь избегать случайного использования допущений и зависимостей. Советы по сокрытию реализации В этих Сочинениях было раскрыто много тайн, доселе неиз- вестных. Эммануил Сведенборг. Описание новой церкви господа Не всегда просто решить, что в интерфейсе оставлять на виду, а что прятать. Од- нако можно указать несколько общих правил. В общедоступном интерфейсе должны быть только те методы, которые необходимы другим пользователям. Методы, которые будет использовать только данный тип, должны быть спрятаны. Например, интерфейс очереди должен иметь общедоступные методы постановки в очередь и исключения из очереди, а вспомогательные методы, работающие с указателями или элементами мас- сива, надо спрятать. Если внутренние переменные открыты, то открыта и реализация, поэтому следует стремиться скрывать внутренние переменные, если они не являются константами. Желательно сделать так, чтобы доступ к ним имел только класс. Впрочем, если поль- зователи имеют доступ к методам и значениям, не зная, что имеют дело именно со значением, то в этом случае значение можно открыть. Тогда для пользователя от- крытая внутренняя переменная должна выглядеть так же, как метод без парамет- ров (это означает, что в языке для пользователей синтаксис обращения к пере- менным не должен отличаться от синтаксиса обращения к методам). К сожале- нию, немногие объектно-ориентированные языки одинаково обращаются с дан- ными и с методами (Delphi и Borland C++ обращаются с внутренними перемен- ными именно таким образом). И напоследок еще один совет: не создавайте интерфейс, в котором внутренние данные представлены под другим именем. Интерфейс должен содержать линии поведения (методы) высокого уровня. Распределение ответственности: заниматься своим делом Вопрос о распределении функций может быть разрешен в плос- кости вопроса о распределении по персонажам кругов действий. Владимир Пропп. Морфология волшебной сказки Сокрытие реализации помогает ослабить связи программы. Однако созданию сла- босвязанной программы помогает также правильное распределение ответственности (функциональных обязанностей) между объектами. При надлежащем распределении ответственности каждый объект выполняет одну функцию, за которую он несет ответ- ственность, и выполняет эту функцию хорошо. Это как раз и означает, что объект об- разует единое целое. Другими словами, нет смысла в инкапсуляции случайного набо- 124 Глава 5
ра функций и переменных (об этом часто свидетельствует наличие в программе слиш- ком больших классов). Между инкапсулируемыми объектами должна быть тесная концептуальная связь — все функции объекта должны выполнять общую задачу. Сокрытие реализации и ответственность (распределение функций) взаимосвязаны. Если реализация не скрыта, функции объекта может выполнять какой-нибудь код за его пределами. А ведь только объект должен знать, как решать свою задачу, т.е. алго- ритм выполнения своей задачи должен быть реализован только в объекте, и нигде больше. Открытую реализацию может использовать пользователь для того, чтобы вы- полнить то, что не входит в его функции, т.е. то, ответственность за что он не несет. Поэтому следует придерживаться принципа распределения ответственности’, знание конкретных деталей передается только ответственным за надлежащую часть обработки объектам. Из этого можно сделать вывод: сокрытие реализации подсказывает правильное распределение функций (так как делает свойства и методы недоступными тем, кому они на самом деле не нужны) и наоборот, правильное распределение функций способст- вует сокрытию реализации, так как после распределения функций можно закрыть дос- туп для всех функций, в которых эти элементы не используются. Впрочем, не впадай- те в депрессию, если ни то, ни другое (сокрытие реализации и оптимальное распреде- ление функций) не удается выполнить за/один раз: итеративность — неотъемлемая черта объектно-ориентированного подхода. Если одну и ту же функцию выполняют два объекта, значит, нет должного раз- деления функций. Если коды в программе повторяются, ее нужно переделать. Однако не огорчайтесь, ведь итерации (доработки) — это обычное явление при создании объектно-ориентированной программы. Помните, что объект должен всегда самостоятельно, без каких бы то ни было “ценных” указаний, выполнять все этапы работы, ибо в противном случае часть ответственности, которую должен нести объект, возлагается на пользователя. Это не лучше, чем раскрытие внутренней реализации, поэтому нужно обязательно убедиться, что разрабатываемый интерфейс не сводится к раскрытию реализации под различны- ми именами. Для абстрактной очереди, например, не нужны методы, работающие с указателями на ее элементы, так как эти методы раскрывают реализацию — они де- монстрировали бы поведение, специфическое для данной реализации. Вместо этого нужно скрыть реализацию на более высоком уровне поведения, предоставляемом ме- тодами постановки элементов в очередь и удаления их из нее, хотя реализация может обновлять указатели и добавлять объекты к списку. Ведь если реализация раскрыта, не обязательно вызывать метод объекта для выполнения операции. Вместо этого можно попытаться самостоятельно выполнить все необходимые вычисления. Если ответственность распределена между объектами неправильно, программа бу- дет процедурно-ориентированной, и основное внимание, естественно, будет уделено обработке данных. Слишком большое внимание обработке данных — признак ненад- лежащего распределения обязанностей между объектами. Чтобы выполнить задание, достаточно просто послать сообщение подходящему объекту — в этом как раз и за- ключается суть объектно-ориентированного программирования. На один объект следует возлагать ответственность за одну (во всяком случае, не- большое количество) задачу. Если на один объект возложена ответственность за большое количество задач, его реализация становится слишком сложной, ее будет трудно сопро- вождать и усовершенствовать. Изменять методы этого объекта будет рискованно, так как при этом, возможно, придется изменить и другие методы. В большом объекте концен- трируется слишком много информации, а ее следует распределять более равномерно. Большой объект фактически является самостоятельной программой; он может не только воспользоваться преимуществами процедурного программирования, но и угодить во все его ловушки. При наличии такого объекта вы столкнетесь со всеми проблемами, кото- рые возникают в программе, где инкапсуляция не используется вообще. Инкапсуляция 125
Если класс отвечает больше чем за одну задачу, нужно перенести часть функций в другой класс. В противном случае, несмотря на сокрытие реализации, эффективной инкапсуляции не получить. Без должного распределения ответственности в итоге по- лучится длинный список процедур. Все сказанное выше можно подытожить в сле- дующей формуле: Эффективная инкапсуляция = абстракция + сокрытие реализации + правильное распределение функций. В этой формуле важны все слагаемые, ни одно из них нельзя опустить. Если опус- тить абстракцию, программу нельзя будет использовать повторно. Если опустить со- крытие реализации, получится сильносвязанная программа. Если опустить правиль- ное распределение функций, то результатом будет процедурная, ориентированная на обработку данных, децентрализованная сильносвязанная программа. (Программа на- зывается децентрализованной, если обработка одних и тех же данных не сконцентри- рована в одном месте, а разбросана по всей программе.) Изучив древнее (по меркам истории программирования!) понятие инкапсуляции, мы обратимся к понятию еще более древнему, — самому древнему (в этом нет ника- ких сомнений), но одному из самых молодых в программировании — до появления ООП в программах оно игнорировалось — к понятию наследования. Резюме Три вещи нельзя скрыть: любовь, беременность и езду на верб- люде. Китайская поговорка Не будет преувеличением сказать, что понятие инкапсуляции, уходящее своими корнями в модульное программирование, в ООП поднялось на новый, более высокий уровень и стало еще более эффективным средством повышения качества программ. Правильное применение инкапсуляции способствует достижению всех целей объект- но-ориентированного программирования. Задачи и упражнения Инкапсуляция и абстракция: достижение целей объектно-ориентированного программирования 1. Почему инкапсуляция способствует созданию естественных программ? 2. Почему инкапсуляция способствует созданию надежных программ? 3. Почему инкапсуляция способствует созданию программ, удобных для сопрово- ждения? 4. Почему инкапсуляция облегчает усовершенствование программ? 5. Почему инкапсуляция облегчает периодический выпуск (издание) новых версий? 126 Глава 5
Глава 6 Пространства имен В этой главе... Пространства имен — зачем они нужны? 127 Создание пространства имен 130 Использование пространства имен 132 Ключевое слово using 135 Псевдонимы пространства имен 146 Неименованные пространства имен 146 Стандартное пространство имен std 147 Резюме 148 Задачи и упражнения 149 Они открыли пространство для чего-то, отличного от себя и, тем не менее, принадлежащего тому, что они основали. Мишель Поль Фуко. Что такое автор Несмотря на то, что даже в классических языках программирования предусмотре- ны разнообразные средства инкапсуляции, их часто оказывается недостаточно для решения следующей, казалось бы, такой простой проблемы. Предположим, в про- грамме применяются разные классы и функции, причем одно и то же имя желательно использовать для двух различных элементов программы в пределах одного блока. Что- бы решить эту проблему, в Стандарте ANSI было введено понятие пространства имен (namespace). Пространство имен — это множество определений имен (таких как опре- деления классов или объявления переменных). Пространства имен — зачем они нужны? Если два идентификатора будут объявлены с общими областями видимости в одном файле источника, то об ошибке сообщит компилятор. Примерно такое же сообщение появится в том случае, если вы попытаетесь описать идентификаторы с одинаковыми именами и перекрывающимися областями видимости. Например, компоновщик выдаст сообщение об ошибке при редактировании связей объектных модулей, полученных в ре- зультате компиляции следующих двух исходных модулей: // файл filel.cpp int integervalue = 0; int main( ) { int integervalue = 0 ; // код return 0 ; } // конец filel.cpp
// файл file2.cpp int integervalue = 0 ; // код // конец file2.cpp Компоновщик выдает сообщение о том, что внешнее имя integervalue уже было объявлено ранее. Кроме того, программист может столкнуться еще с одной про- блемой: объявление переменной integervalue внутри функции integervalue скры- вает глобальную переменную с тем же именем integervalue, поэтому программист не сможет без ухищрений получить доступ к этой переменной внутри функции main (). Чтобы использовать в функции main () глобальную переменную integervalue, объявленную вне main(), необходимо какое-нибудь средство, позволяющее указать, что данная переменная является глобальной по отношению к функции main(). Для этого предусмотрен оператор расширения видимости (: :Так, в следующем примере значение 10 будет присвоено глобальной переменной integervalue, а не перемен- ной с таким же именем, объявленной внутри main (): // файл filel.cpp int integervalue = 0; int main( ) { int integervalue = 0 ; :: integervalue = 10 ; //присваиваем 10 глобальной переменной integervalue // код return 0 ; } // конец filel.cpp Обратите внимание, что для указания глобальности переменной integervalue применяется оператор расширения видимости так как внутри функции объявлена переменная с тем же именем. Под областью видимости (иногда говорят просто видимостью) объекта, который может быть переменной, классом, функцией, понимают ту часть текста программы, в которой может использоваться данный объект. Например, переменная, объявленная и определенная вне какой-либо функции, в качестве области видимости имеет весь файл — она является глобальной. Ее область видимости начинается сразу после ее объявления и простирается до конца файла. Переменные, объявленные в теле функ- ции, являются локальными. Ниже показаны примеры объектов с различными облас- тями видимости: int globalScopelnt = 5 ; void f( ) { int localScopelnt = 10; } int main( ) { int localScopelnt =15; { int anotherLocal = 20; int localScopelnt = 30; } return 0 ; } 128 Глава 6
Целочисленная переменная GlobalScopelnt видна внутри обеих функций f () и main (). А вот переменная localScopelnt, объявленная в теле функции f (), локаль- на. Она видна только внутри объявления функции. Функция mainO, например, не может получить доступ к переменной localScopelnt, объявленной в теле функции f (). Как только завершается выполне- ние функции f (), переменная localScopelnt удаляется из памяти компьютера. Еще одно объявление переменной, также названной localScopelnt, располагается в теле функции main (). Эта переменная также локальна. Обратите внимание, что переменная localScopelnt функции main() не имеет ни- чего общего (кроме имени, разумеется) с одноименной переменной функции внутри f (). Видимость следующих двух переменных — anotherLocal и localScopelnt — также ограничена областью модуля. Другими словами, эти переменные видны от мес- та объявления до закрывающей фигурной скобки, ограничивающей блок, в котором эта функция была объявлена. Обратите внимание, что в программе объявляются две одноименные локальные переменные localScopelnt, причем одна из них объявляется в блоке, в который вложен блок, содержащий объявление другой локальной переменной localScopelnt. Переменная, объявленная во внутреннем блоке, будет скрывать внутри него перемен- ную из внешнего блока. После закрытия фигурной скобки внутреннего блока пере- менная localScopelnt из внешнего модуля вновь становится видимой. Однако все изменения значения localScopelnt, сделанные внутри вложенного блока, никоим образом не повлияют на значение переменной localScopelnt, объявленной во внешнем блоке. Теперь заметим, что в одной и той же программе можно использовать несколько пространств имен. Но что произойдет, если некоторое имя определено в нескольких доступных пространствах имен? Это приведет к ошибке. Одно и то же имя может быть определено в разных пространствах имен, но при обращении к нему должно быть указано, к какому из этих пространств оно относится. Предположим, например, что nsl и ns2 — два пространства имен, а my_function — это void-функция без аргументов, которая определена в обоих из них, но в каждом по-разному. При этом вполне корректен следующий код: { using namespace nsl; my_function(); { } using namespace ns2; my_function(); } В первом вызове будет использоваться определение функции my_function из про- странства имен nsl, а во втором — из пространства имен ns2. Директива using, стоящая в начале блока, действует только в пределах этого бло- ка, поэтому первая директива using действует только в первом блоке, а вторая — только во втором. Обычно в такой ситуации говорят, что областью видимости про- странства имен nsl является первый блок, а областью видимости пространства имен ns2 — второй блок. Директиву using часто используют в блоке тела; определения функции, но если поместить ее в начале файла (как часто делают для директивы using namespace std;), то она будет действовать во всем файле. Пространства имен 129
Создание пространства имен То, что действительно следовало бы сделать, так это опреде- лить пространство... Мишель Поль Фуко. Что такое автор Синтаксис объявления пространства имен аналогичен синтаксису объявления структур и классов. После ключевого слова namespace можно указать имя простран- ства имен (оно может и отсутствовать), а затем следует открывающая фигурная скоб- ка. Пространство имен завершается закрывающей фигурной скобкой без точки с за- пятой в конце. Вот пример: namespace Window { void move(int x, int y) ; } Имя Window идентифицирует пространство имен. Внутри одного файла (или в разных единицах трансляции) можно создавать несколько экземпляров именованных пространств имен. Чтобы поместить какой-нибудь код в пространство имен, его просто нужно поместить в группу пространства имен (namespace grouping), которая выглядит следующим образом: namespace Имя_пространства_имен { Какой—то—код } Включая в код одну из таких групп, вы указываете компилятору, что имена, оп- ределенные в коде Какой_то_код, нужно поместить в пространство имен Имя_пространства_имен. Эти имена (фактически, их определения) можно сделать доступными с помощью директивы using: using namespace Имя_пространства_имен; Основное назначение пространств имен состоит в группировании связанных эле- ментов в именованной области программы. Ниже в пространстве имен Window распо- ложено несколько файлов заголовков: // header1.h name space Window { void move( int x, int y); } // header2,h name space Window { void resize(int x, int y); } Объявление и определение типов Внутри пространства имен можно объявлять и определять типы и функции. Мож- но, например, написать следующий код: name space Window { // . . . другие объявления и определения переменных, void move(int х, int у) ; void resize(int x, int y) ; 130 Глава 6
// . . . другие объявления и определения переменных. void move( int х, int у ) { if( х < MAX_SCREEN_X && x > 0) if( у < MAX_SCREEN_Y && у > 0) platform.move( x, у); // специальная программа } void resize( int x, int у ) { if( x < MAX_SIZE_X && x > 0 ) if( у < MAX_SIZE_Y && у > 0 ) // специальная программа platform, resize( x, у ) ; // . . . продолжение определений } } Однако такую структуру программы нельзя признать оптимальной, поскольку в ней интерфейс программы (например, прототипы функций) не отделен от реализации (объявления функций), поэтому объявления функций рекомендуют помещать вне пространства имен. Объявление функций вне пространства имен Объявления функций следует вынести вне пространства имен. Это позволит избе- жать захламления пространства имен объявлениями функций. Кроме того, вынос объ- явлений функций позволяет поместить пространство имен и находящиеся в нем объяв- ления в заголовочном файле, а сами реализации — в файле реализации. Например: // заголовочный файл header,h namespace Window { void move(int x, int y) ; // другие объявления } // файл реализации impl. срр void Window::move(int x, int y) { // код перемещения окна } Добавление новых членов Но что это за элемент? Занимает он пространство или нет? Как он проникает туда или развертывается там, не двигаясь? Где был он? Что делал там или где-нибудь в другом месте? Дени Дидро. Разговор Даламбера с Дидро Что делать, если нужно добавить новый член в пространство имен? Можно ли, на- пример, написать так: // ряд объявлений namespace Window { } // код программ Пространства имен 131
int Window: -.newIntegerlnNamespace; // ошибка - здесь нельзя добавлять переменную newIntegerlnNamespace Увы, нет. Добавлять новый член можно только в теле пространства. Невозможно создавать новые члены пространства имен вне тела пространства. Последняя строка не- правильна, и компилятор сообщит об этом. Чтобы исправить ошибку, объявление пере- менной-члена newIntegerlnNamespace нужно перенести в тело пространства имен. Все члены в пространстве имен являются открытыми, поэтому нельзя писать и так: namespace Window { private: // это неправильно: все члены пространства имен являются // открытыми void move( int х, int у ) ; } Вложение пространств имен ...пространство, занимаемое сложной вещью, должно состоять из стольких же частей, из скольких состоит эта вещь. Георг Вильгельм Фридрих Гегель. Наука и логика Одно пространство имен можно вложить в другое пространство имен. Это позво- ляет сделать новые объявления внутри существующего пространства имен. Чтобы об- ратиться к члену внутреннего пространства имен, необходимо явно указать имена всех пространств имен, в которые он вложен. Так, в следующем примере одно про- странство имен Рапе объявляется внутри пространства имен Window: namespace Window { namespace Pane { void size( int x, int у ) ; } } Чтобы вызвать функцию size О за пределами пространства имен Window, нужно перед именем вызываемой функции через оператор расширения видимости : : указать имена пространств имен, внутри которых была объявлена функция size(). Напри- мер, функцию size () можно вызвать из функции main () так: int main( ) { Window::Pane::size( 10, 20 ) ; return 0 ; } Использование пространства имен Давайте рассмотрим пример использования пространства имен и оператора рас- ширения видимости. Сначала внутри пространства имен Window объявим все пере- менные и функции. Функции-члены, как мы знаем, лучше определить вне самого пространства имен. Чтобы вне пространства имен определить функцию, объявленную в пространстве имен, следует перед именем функции указать имя пространства имен и оператор расширения видимости. Вот как это делается: #include <iostream> namespace Window { const int MAX_X =30 ; 132 Глава 6
const int MAX_Y =40 ; class Pane { // класс Pane вложен в пространство имен Window public: Pane() ; -Pane() ; void size( int x, int у ) ; void move( int x, int у ) ; void show( ) ; private: static int ent ; // переменная объявлена в классе Pane int x ; int у ; } ; } int Window::Pane::ent = 0 ; Window::Pane::Pane() : x(0), y(0) { } Window::Pane::-Pane() { } void Window::Pane::size( int x, int у ) { if( x < Window::MAX—X && x > 0 ) Pane::x = x ; if( у < Window::MAX—Y && у > 0 ) Pane::y = у ; } void Window::Pane::move( int x, int у ) { if ( x < Window::MAX_X && x > 0 ) Pane::x = x ; if( у < Window::MAX—Y && у > 0 ) Pane::y = у ; } void Window::Pane::show( ) { std::cout « "x " « Pane::x ; std::cout « " у " « Pane::y « std::endl ; } int main( ) { Window::Pane pane ; pane.move( 20, 20 ) ; pane.show( ) ; return 0 ; } Эту программу можно скомпилировать и выполнить. Тогда получится следующий результат: х 20 у 20 Пространства имен 133
Впрочем, сообщения об ошибках могут появиться, даже если компилятор и воспринимает инструкции работы с пространствами имен. (Так будет, напри- мер, в случае использования компилятора Borland C++ версии 5.02.) Дело в том, что имя пространства имен std в старых версиях библиотек может быть не определено. В этом случае его нужно будет удалить в операторах std::cout « "х " << Рапе::х ; std::cout « " у " << Pane::y « std::endl ; и записать их так: cout « "х " « Рапе::х ; cout « " у " << Рапе::у « std::endl ; В этой программе при обращении к объектам класса Рапе их имена дополняются идентификатором Window::, так как класс Рапе вложен в пространство имен Window. Статическая переменная-член ent, объявленная в классе Рапе, определяется как обычно. Но при определении функции-члена Pane::size() и обращениях к перемен- ным-членам МАХ-Х и MAX_Y используется явное указание пространства имен: void Window::Pane::size( int x, int у ) { if( x < Window::MAX_X && x > 0 ) Pane::x = x ; if( у < Window::MAX_Y && у > 0 ) Pane::у = у ; } Дело в том, что статическая переменная-член определяется внутри класса Рапе, а определения других функций-членов (это же справедливо и для функции Pane: :move О) вынесены вне класса и вне пространства имен. Поэтому без явного указания пространства имен компилятор выдаст сообщение об ошибке. Кроме того, внутри определений функций-членов при обращении к переменным- членам класса явно указывается имя класса: Рапе: :х и Рапе: :у. Давайте выясним, зачем это делается. Почему нельзя определить функцию Pane: :move() следующим образом: void Window::Рапе::move( int х, int у ) { if( х < Window::МАХ_Х && x > 0 ) x = x ; if( у < Window::MAX_Y && у > 0 ) у = у ; } Теперь видите, в чем ошибка? А поскольку синтаксически все правильно, компи- лятор в этом определении никаких ошибок не заметит! Причина же ошибки состоит в том, что аргументы х и у скроют закрытые пере- менные-члены х и у, объявленные в классе Рапе, поэтому вместо присвоения значе- ний аргументов переменным-членам произойдет присвоение этих значений самим се- бе. Чтобы исправить эту ошибку, необходимо явно указать переменные-члены класса: Рапе::х = х ; Рапе::у = у ; Теперь значения аргументов действительно присваиваются переменным-членам х и у, объявленным в классе Рапе, а не аргументам х и у. 134 Глава 6
Ключевое слово using Ключевое слово using может использоваться и как директива (оператор), и в ка- честве спецификатора при объявлении членов пространства имен. Синтаксические конструкции в этих двух случаях, конечно, разные. Ключевое слово using как директива С помощью директивы using можно сделать видимыми все переменные какого- либо пространства имен. Иными словами, директива using расширяет область види- мости всех членов пространства имен. Благодаря этому на члены пространства имен можно ссылаться, не указывая имя пространства имен. Вот как, например, можно ис- пользовать директиву using: namespace Window { int values1= 20 ; int value2= 40 ; } Window::valuel =10 ; // здесь для обращения к переменной valuel // необходимо указывать пространство имен using namespace Window ; // теперь видимы все члены пространства имен Window value2 =30 ; Все члены пространства имен Window становятся видимыми, начиная от строки using namespace Window; и до конца соответствующего блока. Заметьте, что для об- ращения к переменной valuel до строки using namespace Window; следует указы- вать пространство имен, но в этом нет необходимости при обращении к переменной value2, поскольку директива using сделала видимыми все члены пространства имен Window. Директиву using можно использовать в любом блоке. Когда выполнение про- граммы выходит за этот блок, автоматически становятся невидимыми все члены про- странства имен, открытые в этом блоке. Вот типичный пример: namespace Window { int valuel =20 ; int value2 =40 ; } // . . . void f() { { using namespace Window ; value2 =30 ; } value2 = 20 ; // ошибка - здесь переменная value2 невидима! } При компиляции оператора value2 = 20 функции f () будет обнаружена ошибка, поскольку переменная value2 в этом месте стала невидимой. Ее видимость, заданная Пространства имен 135
директивой using, закончилась сразу за закрывающей фигурной скобкой в предыду- щей строке программы. В случае объявления внутри блока локальных переменных все одноименные пере- менные пространства имен, открытые в объемлющем блоке, будут скрыты. Это ана- логично сокрытию глобальных переменных локальными. Но что произойдет, если пе- ременная, объявленная в пространстве имен, будет открыта с помощью директивы using после объявления локальной переменной? Оказывается, локальная переменная все равно скроет имя из пространства имен. Это наглядно показано в следующем примере: namespace Window { int valuel =20 ; int value2 =40 ; } // . . . void f() int value2 =10 ; using namespace Window ; // переменная value2 из пространства имен Window // все равно остается невидимой std::cout « value2 << std::endl; // будет напечатано 10, а не 40 } При выполнении этой функции на экран будет выведено 10, а не 40, поскольку переменная value2 пространства имен Window скрывается переменной value2 функ- ции f (). Если все же требуется использовать переменную пространства имен, нужно явно указать имя пространства имен. Двусмысленность может также возникнуть, если идентификатор один раз объявлен как глобальный, а другой раз — внутри пространства имен. Рассмотрим например, следующий фрагмент программы: namespace Window { int valuel =20 ; } // . . . using namespace Window ; int valuel =10 ; void f( ) { valuel =10 ; } В данном примере неопределенность возникает внутри функции f (). Благодаря директиве using переменная Window: : valuel имеет глобальную область видимости. Однако в программе объявляется другая глобальная переменная с таким же именем. Какая из них используется в функции f О? Обратите внимание, что ошибка будет обнаружена не во время объявления одноименной глобальной переменной, а при об- ращении к ней в теле функции f (). Чтобы избежать такой двусмысленности, всегда явно указывайте имя пространства при вызове объекта Область действия директивы using Подводя итог сказанному, можем сделать вывод: областью действия директивы using является блок, в котором она находится (или, точнее, часть блока, располо- 136 Глава 6
женная между директивой using и концом блока). Если директива using находится за пределами всех блоков, то она применяется ко всей части файла, следующей по- сле нее. Ключевое слово using в объявлениях Объявление идентификатора с помощью ключевого слова using аналогично ис- пользованию директивы using с той лишь разницей, что видимым становится только одно имя, объявленное в пространстве имен, как показано в следующем примере: namespace Window { int valuel =20 ; int value2 =40 ; int value3 =60 ; } // . . . //открытие доступа к value2 в текущем блоке using Window::value2 ; //для valuel необходимо указание пространства имен Window::valuel =10 ; value2 = 30 ; // для value3 необходимо указание пространства имен Window::value3 =10 ; Итак, с помощью ключевого слова using в текущем блоке можно сделать доступ- ным отдельный идентификатор пространства имен, не повлияв на остальные иденти- фикаторы, заданные в этом пространстве. В приведенном примере к переменной value2 можно обратиться без явного указания пространства имен, а при обращении к valuel и value3 необходимо указывать имя пространства имен. Таким образом, ключевое слово using действует как объявление и обеспечивает контроль над види- мостью каждого отдельного идентификатора пространства имен. В этом и заключается отличие объявления от директивы using, открывающей доступ сразу ко всем иденти- фикаторам пространства имен. Видимость имени распространяется до конца блока, как и для любого другого объ- явления. С помощью ключевого слова using идентификаторы можно сделать как гло- бальными, так и локальными. Если в локальном блоке уже объявлено имя из пространства имен, то объявить то же имя еще раз невозможно, так как при компиляции будет выдано сообщение об ошибке. Сообщение об ошибке будет выдано и в том случае, если попытаться объя- вить идентификатор из пространства имен в блоке, где уже объявлено такое же имя. Это показано в следующем примере: namespace Window { int valuel = 20 ; int value2 =40 ; } // . . . void f() { int value2 =10 ; // ряд объявлений // ошибка - переменная value2 в этом блоке уже объявлена using Window::value2 ; Пространства имен 137
std::cout << value2 << std::endl } При компиляции объявления using Window: :value2 ;, находящегося в функции f (), будет обнаружена ошибка, поскольку переменная с именем value2 в этом блоке уже объявлена. Такая же ошибка будет обнаружена и в том случае, если объявление с ключевым словом using поместить перед объявлением локальной переменной value2. Однако имя пространства имен, объявленное в локальном блоке с помощью клю- чевого слова using, скрывает имя, объявленное в объемлющем блоке. Вот пример: namespace Window { int valuel =20 ; int value2 =40 ; } int value2 =10 ; // . . . void f() { using Window::value2; // скрывает глобальную переменную value2 std::cout << value2 << std::endl ; } Объявление переменной value2 с помощью ключевого слова using в функции f () скрывает глобальную переменную value2. Вывод: объявление с ключевым словом using позволяет контролировать области видимости отдельных идентификаторов пространства имен, в то время как директива using открывает доступ в локальной области ко всем идентификаторам, объявленным в пространстве имен. Поэтому чтобы в полной мере воспользоваться всеми преиму- ществами, предоставляемыми пространством имен, предпочтительней использовать объявления с ключевым словом using, а не директиву. Явное расширение области видимости для отдельных идентификаторов позволяет снизить вероятность возникно- вения конфликтов имен. Использование директивы using оправдано только тогда, когда нужно открыть доступ сразу ко всем идентификаторам пространства имен. Пример разрешения неоднозначности: указание пространства имен Предположим, у нас сложилась такая ситуация. Есть два пространства имен — nsl и ns2. Мы хотим использовать функцию funl, определенную в nsl, и функцию fun2, определенную в ns2. Сложность в том, что в обоих пространствах имен определена функция my_function. (Предположим, что в обсуждаемом случае используются толь- ко функции без аргументов, потому перегрузка не применяется.) Использование двух директив using namespace nsl; using namespace ns2; приводит к противоречию, возникающему в связи с наличием двух определений функции my_function. Нам необходимо указать, что из пространства имен nsl будет использоваться только функция funl, а из ns2 — функция fun2 и что больше из этих пространств имен мы не будем использовать ничего. Это можно сделать следующим образом: 138 Глава 6
using nsl::funl; using ns2::fun2; Директива вида using Пространство_имен::Одно_имя предоставляет доступ к определению имени Одно_имя из пространства имен Пространство_имен, оставляя все остальные его имена недоступными. Заметим, что оператору разрешения области видимости : : может предшествовать не только имя пространства имен, но и имя класса. Например, в следующем опреде- лении функции void DigitalTime::advance(int hours_added, int minutes_added) { hour = (hour + hours_added)%24; advance(minutes_added); } оператор разрешения области видимости:: означает, что определяется функция advance класса DigitalTime, а не функция advance (с тем же именем) других клас- сов. Аналогично, using nsl: :funl; означает, что мы используем функцию с именем funl из пространства имен nsl, а не из какого-либо другого, в котором также может быть определена функция с таким именем. Теперь предположим, что мы собираемся однократно (или всего лишь несколько раз) использовать определение имени funl из пространства имен nsl. В таком случае можно использовать имя функции (как и любое другое имя) вместе с именем про- странства имен и оператором разрешения области видимости: nsl::funl(); Такая форма записи часто используется и при задании типа параметра. Например, в следующем определении функции int get_number(std::istream input_stream) параметр input_stream принадлежит типу istream, определенному в пространстве имен std. Если из всего пространства имен std нам нужно только имя типа istream (либо все имена из этого пространства описаны при помощи квалификатора std::), то директива using namespace std; не нужна. Явное указание имени пространства имен перед оператором расширения области видимости может разрешить неоднозначность, связанную с наличием одного и того же имени в нескольких пространствах имен. Пусть, например, функция my_function определена в двух пространствах имен. Тогда к нужной функции my_function можно обратиться так: nsl: :my_f unction О; // Вызов my_function из nsl ns2::my_function(); // Вызов my_function из ns2 Пример определения класса в пространстве имен Теперь приведем пример использования пространства имен (назовем его MyNameSpacedtime) для определения класса DigitalTime. (Величины типа DigitalTime представляют время суток.) Заголовочный файл назовем dtime.h, а файл реализации класса DigitalTime — dtime.cpp. Пространства имен 139
Заголовочный файл класса в пространстве имен Интерфейс класса мы решили поместить в файл dtime.h. Поскольку файл интер- фейса всегда является заголовочным, он должен иметь расширение .h. Во все про- граммы, использующие класс DigitalTime, должна быть включена следующая дирек- тива include: #include "dtime.h" А вот и сам файл dtime.h: // Заголовочный файл dtime.h. // Здесь находится интерфейс класса DigitalTime. // Величины типа DigitalTime представляют время суток. #ifndef DTIME.H #define DTIME.H #include <iostream> using namespace std; namespace MyNameSpacedtime { class DigitalTime { public: friend bool operator ==(const DigitalTime& timel, const DigitalTime& time2); // Если timel и time2 представляют одно и то же время, // возвращает true, в противном случае возвращает false. DigitalTime(int the.hour, int the.minute); // Предусловие: 0 <= the_hour <= 23 и // 0 <= the_minute <= 59. // Инициализирует переменные the_hour и the_minute. DigitalTime(); // Инициализирует время значением 0:00 (полночь). void advance(int minutes.added); // Предусловие: объекту присвоено значение времени. // Постусловие: значение времени заменено тем, // которое наступит через minutes_added минут. void advance(int hours.added, int minutes.added); // Предусловие: объекту присвоено значение времени. // Постусловие: значение времени заменено тем, которое наступит // через hours_added часов и minutes_added минут. friend istream& operator >>(istream& ins, DigitalTime& the.object); // Перегружает оператор » для ввода величин типа DigitalTime. // Постусловие: если ins является потоком входного // файла, то он должен быть присоединен к файлу. friend ostream& operator <<(ostream& outs, const DigitalTime& the.object); // Перегружает оператор « для ввода величин типа DigitalTime. 140 Гпава 6
// Постусловие: если outs является потоком выходного // файла, то он должен быть присоединен к файлу. private: // Эта часть входит не в интерфейс, а в int hour; // реализацию, о чем свидетельствует int minute; // ключевое слово private. }; } // MyNameSpacedtime //DTIME_H #endif Файл реализации класса в пространстве имен Все программы, использующие абстрактный тип данных DigitalTime, должны содержать директиву include с именем заголовочного файла dtime.h. Этого доста- точно для компиляции программы, но недостаточно для того, чтобы данную про- грамму можно было запустить. Для запуска программы необходимо написать (и ском- пилировать) определения функций-членов и перегруженных операторов. Эти опреде- ления мы поместим в другой файл, который называется файлом реализации. Хотя в большинстве компиляторов это не требуется, файлу интерфейса и файлу реализации принято давать одно и то же имя (однако эти файлы должны иметь разные расшире- ния). Интерфейс разрабатываемого абстрактного типа данных мы поместили в файл с именем dtime.h, а реализацию этого абстрактного типа данных теперь должны по- местить в файл с именем dtime.срр. Следует заметить, что расширение имени файла реализации зависит от версии C++. (Обычно для файлов с программами на C++ ис- пользуется расширение .срр. Словом, стандарт не определяет, каким именно должно быть расширение файла с текстом программы.) Файл реализации нашего абстракт- ного типа данных DigitalTime приведен ниже. // Файл реализации dtime. срр абстрактного типа данных DigitalTime. // Интерфейс класса DigitalTime находится // в заголовочном файле dtime.h. #include #include #include #include using namespace std; <iostream> <cctype> <cstdlib> "dtime.h" namespace MyNameSpacedtime { // Эти прототипы нужны для определения перегруженного // оператора »: void read—hour(istream& ins, int& the_hour); // Предусловие: ввод в поток ins является временем. // Примеры обозначений: 9:45 или 14:45. // Постусловие: переменной the_hour присвоено значение, // соответствующее количеству часов; двоеточие также // считывается из потока, так что следующее чтение из // потока даст минуты. void read—minute(istream& ins, int& the_minute); // Читает из потока ins количество минут после того, как // количество1 часов уже считано функцией read_hour. Пространства имен 141
int digit_to_int(char c); // Предусловие: с является одной из цифр от 'О' до '9'. // Возвращает целое число, соответствующее указанной цифре; // так, digit_to_int ('3 ') возвращает 3. bool operator ==(const DigitalTime& timel, const DigitalTime& time2) { return (timel.hour == time2.hour && timel.minute == time2 .minute) ,- } //Использует i os tream и cstdlib: DigitalTime:-.DigitalTime (int the_hour, int the_minute) { if (the_hour < 0 || the_hour >23 || the_minute < 0 || the_minute >59) { cout « "Указан недопустимый аргумент" « " конструктора DigitalTime."; exit(1); } else { hour = the_hour; minute = the_minute; } } DigitalTime:-.DigitalTime () { hour = 0; minute = 0; } void DigitalTime::advance(int minutes_added) { int gross_minutes = minute + minutes_added; minute = gross_minutes%60; int hour_adjustment = gross_minutes/60; hour = (hour + hour_adjustment)%24; } void DigitalTime::advance(int hours_added, int minutes_added) { hour = (hour + hours_added)%24; advance(minutes_added); } istream& operator >>(istream& ins, DigitalTime& the_object) { read_hour(ins, the_obj ect.hour); read_minute(ins, the_object.minute); return ins; } ostreamic operator «(ostreamS outs, const DigitalTime& the_object) { outs « the_object.hour « ' : '; if (the_object.minute < 10) outs « 'O'; outs « the_object.minute; 142 Глава 6
return outs; int digit.to.int(char c) { return ( int(c) - int('0') ); void read.hour(istream& ins, int& the_hour) { char cl, c2; ins >> cl >> c2; if ( !( isdigit(cl) && (isdigit(c2) || c2 == ) ) ) { cout « "Введено недопустимое значение" « " переменной read.hour\n"; exit(1); } if (isdigit(cl) && c2 == { the_hour = digit—to_int(cl); } else { //(isdigit(cl) && isdigit(c2)) the_hour = digit.to_int(cl)*10 + digit.to_int(c2); ins >> c2; //сброс символа ':' if (c2 != ’ : ' ) { cout « "Введено недопустимое значение" « " переменной read.hour\n"; exit(1); } } if ( the.hour < 0 || the.hour > 23 ) { cout « "Введено недопустимое значение" « " переменной read.hour\n"; exit(1); } void read—minute(istream& ins, int& the.minute) { char cl, c2; ins » cl » c2; if (’(isdigit(cl) && isdigit(c2))) { cout « "Введено недопустимое значение" « " переменной read.minute\n"; exit(1); } the.minute = digit.to.int(cl)*10 + digit.to.int(c2); if (the.minute < 0 || the.minute >59) { cout << "Введено недопустимое значение" « " переменной read_minute\n"; exit(1); } } // MyNameSpacedtime Пространства имен 143
Программа-приложение Чтобы в программе можно было использовать абстрактный тип данных DigitalTime, необходимо применить директиву #include "dtime.h". Обратите внимание, что эта директива, в которой указано имя файла интерфейса, должна при- сутствовать и в файле реализации, и в файле с программой, использующей этот абст- рактный тип данных. Файл с программой (т.е. содержащий основную часть исходного текста программы) часто называют файлом приложения. Ниже приведен файл прило- жения, содержащий очень простую программу, в которой используется и демонстри- руется работа абстрактного типа данных DigitalTime. // Файл приложения timedemo.срр. // В программе продемонстрировано применение класса DigitalTime. #include <iostream> #lnclude "dtime.h" int main() { using namespace std; // доступ к пространству имен MyNameSpacedtime using namespace MyNameSpacedtime; DigitalTime clock, old_clock; cout « "Введите время в 24-часовых обозначениях: cin >> clock; old_clock = clock; clock.advance(15); if (clock == old—clock) cout « "Что-то не так.Хп"; cout « "Вы ввели " « old_clock « endl; cout << "Через 15 минут будет " << clock « endl; clock.advance(2, 15); cout « "Через 2 часа и 15 минут после этогоХп" « " будет " « clock « endl; return 0; } Пример диалога: Введите время в 24-часовых обозначениях: 11:15 Вы ввели 11:15 Через 15 минут время будет 11:30 Через 2 часа и 15 минут после этого будет 13:45 Чтобы запустить эту программу, разные части которой находятся в трех различных файлах, необходимо скомпилировать файлы реализации и приложения. Файл интер- фейса, которым в данном случае является dtime.h, компилировать не нужно, так как при компиляции каждого из двух других файлов компилятор автоматически вставляет его в эти файлы — ведь в каждом из них содержится директива #include "dtime.h". При компиляции программы автоматически вызывается препроцессор, который заме- 144 Глава 6
няет эту директиву текстом из файла dtime.h. Конечно, копирование файла dtime.h выполняется виртуально, т.е. компилятор действует так, как если бы все содержимое этого файла было скопировано в каждый файл, который имеет соответствующую ди- рективу include, но само содержимое файлов остается неизменным. Затем редактор связей (linker) компонует объектные файлы, полученные в резуль- тате компиляции файла реализации и файла приложения. После компоновки про- грамму можно запускать. Почти во всех интегрированных средах перечисленные действия выполняются ав- томатически или полуавтоматически. Отдельные части одной и той же программы мы разместили в трех различных файлах. Все три файла можно было бы соединить в один, а затем компилировать и запускать его, не заботясь обо всех этих директивах include и редактировании свя- зей. Для чтения в книге это было бы даже удобнее. Так зачем же возиться с тремя разными файлами? Оказывается, разбивая программу на отдельные файлы, мы полу- чаем ряд преимуществ. Поскольку определение и реализация класса DigitalTime на- ходятся в файлах, отличных от файла приложения, этот класс можно использовать во многих программах, не переписывая для каждой его определение; более того, файл реализации нужно скомпилировать только один раз, независимо от того, сколько программ будут использовать класс DigitalTime. Но и это еще не все. Поскольку интерфейс абстрактного типа данных DigitalTime отделен от его реализации, файл реализации можно менять, не затрагивая при этом ни одной использующей его про- граммы. По сути, эту программу не требуется даже перекомпилировать. Изменив файл реализации, нужно перекомпилировать только его, а затем скомпоновать этот файл с другими файлами программы. Экономия времени, которое потребовалось бы на перекомпиляцию, — это хорошо, но главное преимущество в том, что нет необхо- димости менять код программы! Реализацию абстрактного типа данных можно ме- нять, не переписывая ни одной программы, в которой этот тип используется. Теперь подробнее рассмотрим реализацию абстрактного типа данных. Большинст- во деталей реализации очевидны, но здесь нужно пояснить две вещи. Обратите вни- мание, что функция-член advance перегружена и у нее имеется два определения. За- метим также, что в определении перегруженного оператора ввода » используется две вспомогательные функции под названием read_hour и read_minute. Эти две функ- ции, в свою очередь, используют третью вспомогательную функцию, которая назы- вается digit_to_int. Теперь рассмотрим указанные особенности реализации под- робнее. В классе DigitalTime есть две функции-члена с одним и тем же именем advance. В одной из этих версий требуется один аргумент, который является целым числом, представ- ляющим количество минут временного интервала, на который сдвигается текущее время. В другой версии требуются два аргумента, один из них представляет количество часов, а другой — количество минут временного интервала, на который сдвигается текущее время. В определение версии функции advance с двумя аргументами входит вызов версии функ- ции с тем же именем, но с одним аргументом. Рассмотрим определение версии с двумя аргументами. Во-первых, время сдвигается на hours_added часов, после чего используется версия функции advance с одним аргументом для дополнительного сдвига времени на minutes_added минут. Для компилятора эти две функции с именем advance являются разными функциями; просто совершенно случайно их имена совпадают. (На самом деле, это, конечно, полиморфизм.) Рассмотрим теперь вспомогательные функции. Функции read_hour и read_minute читают по одному символу из входного потока и конвертируют введенные последователь- ности цифр (величины типа char) в целые числа, которые присваиваются переменным- членам hour и minute. В случае ввода неправильных данных выдается сообщение об Пространства имен 145
ошибке. Во вспомогательных функциях read_hour и read_minute, в свою очередь, ис- пользуется еще одна вспомогательная функция — digit_to_int. Она преобразует цифру в соответствующее ей число (например, символ ' 5 ' в число 5). Псевдонимы пространства имен Псевдоним пространства имен — это еще одно (дополнительное) имя пространства имен. Псевдонимы пространства имен служат для упрощения использования про- странства имен. Как правило, псевдоним короче оригинального имени и иногда ин- формативнее его. Это весьма эффективно, если имя пространства очень длинное. Вот как создаются и используются псевдонимы пространства имен: namespace American—National_Standards_Institute { int value ; // . . . } American—National—Standards—Institute:rvalue = 10 ; // Создание псевдонима namespace ANSI = American—National—Standards—Institute; TSC:rvalue =20 ; // использование псевдонима Конечно, если имя, выбранное для псевдонима, уже существует в области видимо- сти, то компилятор сообщит об ошибке, и псевдоним придется изменить. Неименованные пространства имен Пространства имен могут не иметь имени. Такие пространства имен называются неименованными. Наиболее часто они используются для устранения конфликтов гло- бальных имен. Каждый модуль имеет собственное уникальное неименованное про- странство. Все идентификаторы, объявленные внутри такого пространства имен, вы- зываются просто по имени без каких-либо префиксов. Ниже представлен пример соз- дания двух неименованных пространств имен, расположенных в двух разных файлах. // файл: опе.срр // создание неименованного пространства имен // для первого модуля (файла) namespace { int value ; char p( char *p ) ; // . . . } // файл: two.cpp // создание неименованного пространства имен // для второго модуля (файла) namespace { int value ; char p( char *p ) ; // . . . } 146 Гпава 6
int main( ) { char * ptr = "This is a string"; char c = p( ptr ) ; } В каждом файле объявляется переменная value и функция р (). И хотя файлы со- держат одноименные переменные и функции, конфликтов имен не возникает по- скольку для каждого файла создано свое неименованное пространство. Благодаря не- именованному пространству имен объявление глобальной переменной value равно- сильно следующему: static int value =10 ; Однако подобное использование ключевого слова static не рекомендуется коми- тетом по стандартизации. Для разрешения конфликтов имен как раз и предназначены пространства имен. Неименованные пространства имен как бы инкапсулируют гло- бальные переменные внутри файлов. Стандартное пространство имен std Вероятно, наилучший пример использования пространства имен — стандартная библиотека C++. Все функции, классы, объекты и шаблоны стандартной библиотеки объявлены внутри пространства имен std. Теперь уже стало модным начинать программу, например так: #include <iostream> using namespace std ; Давайте разберемся, что здесь написано. Директива using открывает доступ ко всем именам, объявленным в пространстве имен std. Так что эта директива нарушает основную рекомендацию по использованию пространств имен. Глобальное простран- ство будет буквально заполнено именами различных идентификаторов из файлов за- головков стандартной библиотеки, большая часть которых не используется в данной программе. (Ведь в пространство имен std входят все имена, определенные в используе- мых файлах стандартной библиотеки (таких как iostream и cstdlib). Например, если в начале файла поместить директиву #include <iostream>, то при этом все определения имен, содержащиеся в файле iostream (например, с in и cout), помещаются в простран- ство имен std. Поэтому если вы включите в программу несколько файлов заголовков и используете директиву using, все идентификаторы, объявленные в этих заголовоч- ных файлах, станут глобальными.) Если же директива using для пространства имен std не включена в программу, то имена с in и cout можно определить так, чтобы они имели какое-то отличное от стандартного значение (например,- вы можете переопре- делить потоки с in и cout, чтобы изменить их поведение по сравнению со стандарт- ным). Таким образом, при работе со стандартными библиотеками лучше избегать данной директивы. Однако это правило очень часто нарушается. Обычно это делается исключительно для краткости. Тем не менее все же лучше использовать в своих про- граммах объявления с ключевым словом using, как в следующем примере: tinclude <iostream> using std::cin ; using std::cout ; using std::endl ; int main( ) { int value = 0 ; cout « "So, how many eggs did you say you wanted?" « endl ; Пространства имен 147
cin >> value ; cout << value « "eggs, sunny-side up!" « endl ; return( 0 ) ; } В результате выполнения этой программы может получиться, например, вот что: So, how many eggs did you say you wanted? 4 4 eggs, sunny-side up! В качестве альтернативы можно явно указывать пространство имен перед иденти- фикаторами: #include <iostream> int main( ) { int value = 0 ; std::cout « "How many eggs did you want?" « std::endl ; std::cin » value ; std::cout « value « " eggs, sunny-side up!" « std::endl ; return( 0 ) ; } Такой подход вполне годится для небольшой программы, но в больших приложе- ниях будет довольно сложно проследить за всеми явными обращениями к идентифи- каторам пространства имен. Только представьте себе: вам придется добавлять std: : для каждого имени из стандартной библиотеки! Резюме Высь, ширь, глубь. Лишь три координаты. Мимо них где путь? Засов закрыт. Но живут, живут в N измерениях Вихри волн, циклоны мыслей, Те, кем смешны Мы с нашим детским зреньем, С нашим шагом по одной черте. Валерий Брюсов. Мир N измерений Создать пространство имен так же просто, как описать класс. Есть несколько раз- личий, но они весьма незначительны. Во-первых, после закрывающей фигурной скобки пространства имен не следует точка с запятой. Во-вторых, пространство имен всегда открыто, в то время как класс закрыт. Объявление пространства имен можно продолжить в других файлах или в разных местах одного файла. Вставлять в пространство имен можно все, что можно объявлять. Создавая биб- лиотеку классов, следует планировать использование пространства имен. Реализация функций, объявленных внутри пространства имен, должна быть вынесена за его пре- делы. Это позволит отделить интерфейс от реализации. Можно вкладывать одно пространство имен в другое. Однако при обращении к членам внутреннего пространства имен необходимо явно указывать имена как внеш- него, так и внутреннего пространств. Для открытия доступа ко всем членам пространства имен в текущем блоке исполь- зуется директива using. Однако в результате этого слишком много идентификаторов могут стать глобальными, что чревато конфликтами имен. Поэтому использование 148 Глава 6
данной директивы не рекомендуется, особенно при работе со стандартными библио- теками. Вместо этого лучше использовать ключевое слово using в объявлениях. Ключевое слово using в объявлении имени используется для открытия доступа в текущей области видимости только к отдельному идентификатору из пространства имен, а это существенно снижает вероятность конфликтов имен. Псевдонимы пространств имен похожи на синонимы типов, определяемые в опе- раторе typedef. С их помощью можно создавать дополнительные имена для имено- ванных пространств, что оказывается весьма полезным, если исходное имя длинное и неудобное. Неименованное пространство (пространство без имени) можно создать для каж- дого файла. Описав неименованное пространство имен с помощью ключевого слова namespace, можно одно и то же имя (с областью видимости файл) использовать в разных файлах программы. Благодаря неименованному пространству имена перемен- ных становятся локальными для текущего файла. Несмотря на то, что это эквивалент- но использованию ключевого слова static в описании, рекомендуется использовать неименованные пространства имен. В стандартной библиотеке C++ используется пространство имен std. Однако применять директиву using, открывающую доступ ко всем именам стандартной биб- лиотеки, далеко не всегда целесообразно. Лучше воспользоваться ключевым словом using в объявлениях. Задачи и упражнения 1. Обязательно ли использовать пространства имен? 2. В чем разница между директивой using и объявлением с ключевым словом using? 3. Что такое неименованные пространства имен и зачем они нужны? 4. Можно ли использовать идентификаторы, объявленные в пространстве имен, без применения ключевого слова using? 5. Назовите основные отличия между именованными и неименованными про- странствами имен. 6. Что такое стандартное пространство имен std? 7. Отладка. Найдите ошибки в следующем коде: #include <iostream> int main() { cout « "Hello world!" « end; return 0; } Укажите способы устранения ошибок в этом коде. Пространства имен 149

Глава 7 Наследование В этой главе... Что такое наследование? 151 Что происходит при наследовании 152 Отношения “является” (“Is-a”) и “содержит” (“Has-a”): когда использовать наследование 159 Наследственная иерархия 161 Типы наследования 194 Технология применения наследования 197 Абстрактные классы и методы 198 Виртуальные методы 204 Как с помощью наследования достичь целей объектно-ориентированного подхода? 214 Резюме 216 Задачи и упражнения 216 Когда биолог говорит о наследовании приобретенных призна- ков, то он имеет в виду лишь приобретенное изменение на- следственности, генома. Он совершенно не задумывается о том, что “наследование” имело — уже за много веков до Гре- гора Менделя — юридический смысл, и что это слово поначалу применялось к биологическим явлениям по чистой аналогии. Сегодня это второе значение слова стало для нас настолько привычным, что меня бы наверно не поняли, если бы я просто написал: “Только человек обладает способностью передавать по наследству приобретенные качества”. Конрад Лоренц. Агрессия (так называемое зло) Инкапсуляция предоставляет нам возможность писать правильно определенные модульные объекты. Благодаря инкапсуляции один объект с помощью сообщений, т.е. путем вызова методов, может использовать другой объект. Но использование — это только одно из отношений объектов, рассматриваемых в ООП. В объектно-ориенти- рованном программировании не менее важную роль играет и отношение наследова- ния между объектами. Что такое наследование? Придите, благословенные, наследуйте царство, уготованное вам от основания мира. Матф. 25:31-41
Наследование — это мощный механизм повторного использования кода. С помо- щью наследования может быть создана иерархия родственных типов, которые совме- стно используют код и интерфейсы. Наследование позволяет создать новый класс на основе уже существующего класса. Класс-потомок, созданный на основе базового класса, автоматически наследует все свойства, поведение и реализацию уже сущест- вующего класса. Таким образом, наследование — это механизм, который дает возможность создавать новый класс на основе уже существующего. Созданный класс называется потомком, наследником, дочерним, потомственным, порожденным или производным классом от ранее существовавшего класса, который называется родителем, предком, суперклассом, надклас- сом, родительским, порождающим или базовым классом. Производный класс, как мы уже знаем, наследует все свойства и поведение, включая и интерфейс базового класса. По- этому интерфейс родителя автоматически появляется в интерфейсе наследника. Многие полезные типы являются вариантами других, и часто бывает утомительно создавать для каждого из них один и тот же код. Производный класс наследует опи- сание базового класса; затем он может быть изменен добавлением новых членов, пе- реопределением существующих функций-членов и изменением прав доступа. В по- лезности такой концепции можно убедиться на примере того, как таксономическая классификация компактно резюмирует большие объемы знаний. Скажем, располагая сведениями о понятии “млекопитающие” и зная, что и слон, и мышь являются мле- копитающими, можно сделать их описания значительно более краткими. Основное базовое понятие содержит информацию о том, что млекопитающие — это теплокров- ные высшие позвоночные, вскармливающие своих детенышей молоком. Эта инфор- мация наследуется понятиями “мышь” и “слон”, но подробно она изложена лишь однажды — в базовом понятии. В терминах C++ и слон, и мышь являются производ- ными от базового класса млекопитающих. Предположим, вы создали класс, но вам нужен новый класс, который будет чем-то слегка отличаться от уже имеющегося. Или, допустим, некий класс отлично работает в каких-то ситуациях, но есть и другие случаи, когда этот класс должен работать не- сколько иначе. Как начинающие программисты обычно выходят из такого положе- ния? Копируют определение созданного класса, вставляют его в новое место, редак- тируют и затем дают новому классу новое имя. Таким образом, вы как бы повторно используете уже имеющийся класс. Однако, копируя и вставляя уже написанные ко- ды, вы не только раздуваете размеры программы, но и способствуете размножению ошибок, ведь если ошибка содержалась в скопированном коде, она многократно про- явится во всех частях программы, куда этот код будет вставлен. Даже если ошибок и не было, но вам придется что-либо изменить, то не исключено, что изменения при- дется сделать везде, где вы применяли такой “метод”. Именно объектно-ориентированное программирование позволяет избежать подоб- ных проблем. Вы можете просто создать класс-наследник и модифицировать его. И никакого копирования — вы просто повторно используете то, что уже создано и рабо- тает. Эта возможность называется наследованием реализации. Что происходит при наследовании ...генетически обусловлены не только органы, анатомические и психологические структуры, но также наследуются и поведен- ческие образцы. Герхард Фоллмер. Эволюционная теория познания Наследование особенно полезно при построении новых классов. Производные классы могут наследовать свойства базовых классов, а также могут име^ь модифици- 152 Глава 7
рованные или добавленные возможности. Кроме того, если вы обнаружите и исправи- те ошибку в базовом классе, эта же ошибка автоматически будет исправлена и для всех производных классов. Чтобы объявить новый класс наследником, в объявлении нового класса после его имени наберите двоеточие (:), затем при необходимости укажите модификатор досту- па (одно из ключевых слов public, protected или private) и, наконец, имя базо- вого класса: class имя—производного—класса : [public|protected|private] имя—базового—класса { // . . . }; Все функции-члены базового класса становятся функциями-членами производного класса. То же самое касается и членов данных. Таким образом, вам не нужно заново набирать для них коды. А чтобы добавить в новый класс какие-то новые члены, пере- числите их при его объявлении. Совокупность всех добавленных членов будет отли- чать новый класс от базового. Наследовать можно не только классы, написанные собственноручно. С тем же ус- пехом можно наследовать любые встроенные .NET-классы. Например, можно создать класс, производный от класса Реп: class МуРеп : public Реп { }; Доступ к наследуемым членам Быть наследником — это еще не все. Нужно еще уметь получить доступ к наслед- ству. А сделать это можно только зная правила наследования. Наиболее либеральны они в том случае, если при создании производного класса в качестве спецификатора доступа было явно указано ключевое слово public. (И очень часто программисты указывают именно его.) Поэтому правила наследования в этом случае часто называ- ются основными. Основные правила наследования открытых, закрытых и защищенных членов Давайте обратим внимание на те различия, которые возникают при наследовании членов классов с разными уровнями доступа. Вот основные правила, определяющие уровень доступа наследуемых членов. Открытые элементы (public) базового класса доступны для использования как в пределах производного класса, так и за его пределами. Закрытые элементы (private) базового класса невидимы для всей остальной программы (в том числе и для производного класса). Защищенные элементы (protected) базового класса доступны для производ- ного класса, но невидимы для всей остальной программы. Защищенное и закрытое наследование Основные правила, определяющие уровень доступа наследуемых членов, действуют лишь тогда, когда при создании производного класса в качестве спецификатора дос- тупа явно указывается ключевое слово public. Однако при наследовании можно управлять правами доступа к членам производного класса. Для этого, создавая произ- Наследование 153
водный класс, можно явно указать одно из ключевых слов public, private или protected. Ниже показано, какие права доступа приобретают элементы производ- ного класса при использовании перед именем базового класса одного из ключевых слов public, private или protected в описании производного класса. Влияние спецификатора доступа (ключевое слово public, private или protected) на уровень доступа наследуемых членов Спецификатор доступа Члены базового класса объявлены как... Члены производного класса наследуются как... public public public protected protected private Недоступны для наследования protected public protected protected protected private Недоступны для наследования private public private protected private private Недоступны для наследования Перегрузка и переопределение функций-членов При необходимости в производном классе можно изменить поведение наследо- ванных членов базового класса. Для этого в производном классе нужно с теми же са- мыми именами заново определить функции-члены, работа которых должна отличать- ся от работы одноименных функций-членов базового класса. Это одно из самых мощных и часто используемых средств языка C++ называется перегрузкой (overriding), переопределением, а иногда и замещением. Фактически переопределение функции базового класса — это подмена ее одно- именной функцией в производном классе. Иными словами, чтобы переопределить метод, в производном классе нужно создать функцию с той же сигнатурой, что и в ба- зовом. (Сигнатура — это та информация, которая задается в прототипе функции. Сю- да входит тип возвращаемого значения, типы параметров и, в случае использования, ключевое слово const.) Перегрузка и переопределение похожи и приводят почти к одинаковым резуль- татам. При перегрузке создается несколько вариантов метода с одним и тем же именем, но с разными сигнатурами. При переопределении же в производном классе создается метод с тем же именем и сигнатурой, что и в базовом. Приведем пример. Пусть, например, описание класса shapeobject выглядит так: __дс class Shapeobject { public: void Draw(Graphics *poG); void PrintStats(); private: int nUseCount; }; Тогда можно создать класс Lineobject, производный от базового класса Shapeobject и переопределить (перегрузить) метод Draw: <54 Глава 7
__gc class Lineobject : public Shapeobject { public: void Draw(Graphics *poG); }; Созданный таким образом класс Lineobject имеет те же свойства и методы, что и класс Shapeobject, за исключением метода Draw, который может отличаться от ме- тода Draw базового класса Shapeobject. Приведем теперь еще один, на этот раз более завершенный пример. Пусть, напри- мер, имеем в царстве животных какой-нибудь экземпляр класса Dog (Собака), произ- водного от класса Mammal (Млекопитающее). Этот экземпляр имеет доступ к любой функции-члену, объявление которой добавлено в класс Dog (Собака), например к функции виляния хвостом WagTail (), а также ко всем функциям-членам класса Mammal (Млекопитающее). Как мы знаем, функции базового класса могут быть пере- определены в производном классе. В частности, в классе Dog (Собака) можно переопределить функцию Speak (), объявленную в классе Mammal (Млекопитающее). // Переопределение метода базового класса в производном классе ttinclude <iostream.h> епшп BREED { GOLDEN, CAIRN, DANDIE, SHETLAND, DOBERMAN, LAB } class Mammal { public: // Конструктор Mammal() { cout « "Mammal constructor...\n"; } -Mammal() { cout « "Mammal destructor...\n"; } //Другие методы void Speak()const { cout « "Mammal sound!\n"; } void Sleep()const { cout « "shhh. I'm sleeping.\n"; } protected: int itsAge; int itsWeight; }; class Dog : public Mammal { public: // Конструктор Dog(){ cout « "Dog constructor... \n"; } -Dog(){ cout « "Dog destructor... \n"; } // Другие методы // В классе Dog (Собака) переопределяется метод базового класса // SpeakO, в результате чего в случае вызова этой функции экземпляром // класса Dog (Собака) на экран выводится Woof! void WagTail() const { cout « "Tail wagging... \n"; } void BegForFood() const { cout « "Begging for food...\n"; } void SpeakO const { cout « "Woof!\n"; } private: BREED itsBreed; } ; int main() { Наследование 155
// создается объект bigAnimal (большое млекопитающее) класса Mammal // (Млекопитающее), в результате чего вызывается конструктор класса // Mammal (Млекопитающее) Mammal bigAnimal; // создается объект fido класса Dog (Собака), что сопровождается // последовательным вызовом сначала конструктора класса Mammal // (Млекопитающее), а затем конструктора класса Dog (Собака). Dog fido; // объект класса Mammal (Млекопитающее) вызывает метод Speak() bigAnimal. Speak(); // объект класса Dog (Собака).обращается к этому же методу fido.Speak(); return 0; } Давайте прочитаем выдачу вместе. Сначала создается объект bigAnimal (большое млекопитающее) класса Mammal (Млекопитающее), в результате чего вызывается кон- структор класса Mammal (Млекопитающее) и на экране появляется первая строка: Mammal constructor... Затем создается объект fido класса Dog (Собака), что приводит к последователь- ному вызову сначала конструктора класса Mammal (Млекопитающее), а затем конст- руктора класса Dog (Собака). Соответственно на экран выводится еще две строки: Mammal constructor... Dog constructor... Наконец, объект класса Mammal (Млекопитающее) вызывает метод Speak (): Mammal sound! После этого уже объект класса Dog (Собака) обращается к этому же методу: Woof ! Как видим, информация при этом выводится разная, так как метод Speak () в классе Dog (Собака) переопределен. Наконец, программа передает управление за об- ласть видимости объектов и для их удаления вызываются деструкторы: Dog destructor... Mammal destructor... Mammal destructor... На этом выполнение программы завершается, однако не завершается ее обсужде- ние. Ведь здесь мы столкнулись с явлением сокрытия метода базового .класса. Сокрытие метода базового класса В предыдущей программе метод Speak () для экземпляра класса Dog (Собака) за- менил метод Speak (), объявленный в базовом классе. Казалось бы, это то, что нам нужно. Предположим теперь, что в классе Mammal (Млекопитающее) есть некоторый метод Move (), который замещается в классе Dog (Собака). Тогда можно сказать, что метод Move () класса Dog (Собака) скрывает метод с тем же именем в базовом классе. Однако в некоторых случаях это совсем не то, что нужно. Действительно, допустим теперь, что в классе Mammal (Млекопитающее) метод Move () перегружен трижды. Первая версия метода не имеет параметров, вторая имеет один целочисленный параметр (расстояние), а третья — два целочисленных параметра (скорость и расстояние). Пусть в классе Dog (Собака) переопределен только метод Move () без параметров. Тогда экземпляр класса Dog (Собака) все равно не сможет об- ратиться к двум другим версиям перегруженного метода класса Mammal (Млекопи- тающее). 156 Глава 7
// Сокрытие методов #include <iostream.h> class Mammal { public: // в классе Mammal (Млекопитающее) объявляются // перегружаемые методы MoveO void Move() const { cout « "Mammal move one step\n"; } void Move(int distance) const { cout « "Mammal move cout « distance «" steps.\n"; } protected: int itsAge; int itsWeight; } ; class Dog : public Mammal { public: // переопределение метода Move() без параметров в классе Dog (Собака) // Возможно, последует сообщение, что функция скрыта! void Move() const { cout « "Dog move 5 steps.\n"; } } ; int main() { Mammal bigAnimal; Dog fido; // Вызов перегруженного метода MoveO без параметров в классе Dog (Собака) bigAnimal.Move() ; bigAnimal.Move(2); // Вызов перегруженного метода MoveO без параметров в классе Dog (Собака) fido.Move(); // fido.Move(10) ; return 0; } Вот распечатка: Mammal move one step Mammal move 2 steps. Dog move 5 steps. Перегруженный в классе Dog (Собака) метод без параметров Move () вызывается для объектов разных классов, и информация, выводимая на экран, подтверждает, что переопределение метода прошло правильно. Логично предположить, что в классе Dog (Собака) можно использовать метод Move(int), поскольку переопределен был только метод MoveO. Однако вызов fido.Move(10) ; пришлось закомментировать, так как он вызывает ошибку компиля- ции error С2660: 'Dog::Move' : function does not take 1 parameters. Что- бы использовать метод Move(int), его также нужно переопределить в классе Dog (Собака). Если переопределен хоть один из перегруженных методов, скрытыми ока- зываются все версии этого метода в базовом классе. Чтобы в производном классе вы- звать другие версии перегруженного метода, их также нужно переопределить в производ- ном классе. Наследование 157
Часто в результате ошибки при переопределении метода он оказывается скрыт. Такая ошибка случается, например, из-за несовпадения сигнатур. Тогда после попыт- ки переопределить метод в производном классе данный метод оказывается недоступ- ным для класса. Причиной несовпадения сигнатур может быть, например, то, что программист забыл указать ключевое слово const, имеющееся в объявлении метода в базовом классе. А ведь слово const является частью сигнатуры, и несоответствие сиг- натур ведет к сокрытию базового метода, а не к его переопределению! Переопределение и сокрытие Переопределение виртуальных методов способствует полиморфизму, а со- крытие методов ограничивает полиморфизм. Родительские связи: вызов метода базового класса Иногда в производном классе возникает необходимость доступа к перегруженным функциям-членам базового класса. (Как дети иногда нуждаются в помощи родителей, так и производные классы обращаются к базовым с тем, чтобы получить дополни- тельные возможности.) Но даже если вы переопределили базовый метод, то все равно можете обратиться к нему, указав базовый класс, где хранится исходное объявление метода. Так что получить доступ к функциям-членам базового класса несложно. Для этого перед именем нужной функции через оператор разрешения доступа (два двоето- чия : :) следует указать имя базового класса (например, Mammal: :Move ()). Ниже в определении метода Draw производного класса Lineobject показан при- мер вызова метода Draw базового класса Shapeobject: void Lineobject::Draw(Graphics *poG) { ShapeObject::Draw(poG); // Вызов метода Draw базового класса Pen *poPen = new Pen(Color::Red); // Создание пера для рисования // Рисование линии poG->DrawLine(poPen, m_nXFrom, т_nYFrom, т_nXTo, т_nYTo); } Например, в вызове fido.Move(10) ; компилятор обнаруживает ошибку error С2660: 'Dog::Move' : function does not take 1 parameters., так как метод Move(int) базового класса Mammal (Млекопитающее) оказался скрыт. Ошибка будет устранена, если этот вызов переписать так: fido.Mammal::Move(); Такое обращение к методу базового класса называется явным. Приведем пример использования явного вызова в программе. // Явное обращение к методу базового класса #include <iostream.h> class Mammal { public: void Move() const { cout « "Mammal move one step\n"; } void Move(int distance) const { cout « "Mammal move " « distance; cout « " steps.\n"; } protected: int itsAge; 158 Глава 7
int itsWeight; class Dog : public Mammal { public: void Move() const; }; void Dog::Move() const { cout « "In dog move...\n" ; Mammal::Move(3); } int main() { // создается экземпляр bigAnimal (большое млекопитающее) // класса Mammal (Млекопитающее) Mammal bigAnimal; // создается экземпляр fido класса Dog (Собака) Dog fido; // вызывается метод Move (int) из класса, // базового для класса Dog (Собака) bigAnimal. Move(2); fido.Mammal::Move(6); // явное обращение return 0; } Вот выдача: Mammal move 2 steps. Mammal move 6 steps. В этой программе для класса Dog (Собака) доступен только один переопределенный метод Move (), в котором не задаются параметры. Поэтому для доступа к методу Move (int) базового класса потребовалось явное обращение: fido. Mammal: :Move (6) ;. Функциональные возможности класса можно расширить путем создания про- изводных классов. Однако переопределяя методы базового класса в произ- водном, будьте внимательны, так как при изменении сигнатуры возможно со- крытие функций базового класса. Отношения “является” (“Is-а”) и “содержит” (“Has-a”): когда использовать наследование Наследование реализации позволяет производным классам наследовать реализацию других классов. Но тот факт, что один класс может быть наследником другого, совсем не значит, что он им быть обязан! Как же узнать, когда надо использовать наследование? Существует признак, кото- рый указывает на целесообразность наследования: Наследование целесообразно только тогда, когда тип производного класса является подтипом базового. Иными словами, наследование целесообразно только тогда, когда производный и базовый классы (понятия) находятся в отношении является (Is-a). Наследование 159
Пусть X — базовый тип (класс), a Y — производный тип (класс). Тогда каждому из этих классов (понятий) соответствует некое множество. Обозначим эти множества че- рез X и Y. Тогда множество Y, соответствующее производному типу (классу), является подмножеством множества X, соответствующего базовому типу (классу): YcX. Приведем пример. С точки зрения современной биологии слон (производный класс) является млекопитающим (базовый класс). Множество слонов является под- множеством всех млекопитающих. Из-этого примера видно, что решение вопроса, на- ходятся ли классы в том или ином отношении, зависит от концептуальной модели. Однако часто два класса находятся в другом отношении — отношении содержит (Has-a). Класс X содержит класс Y тогда и только тогда, когда класс X содержит в се- бе экземпляр класса Y. В терминах теории множеств это означает, что всякий элемент множества X является множеством, содержащим в качестве своего элемента какой- нибудь элемент множества Y. Иными словами, УхеХ Зуе Y: уех. Вот пример: Автомобиль содержит мотор. Рассмотрим теперь классический Пример, на котором обычно демонстрируется разница между отношением является (Is-a) и отношением содержит (Has-a). Предпо- ложим, у нас есть базовый класс Canine (Псовые). Тогда собака (Dog) является псо- вым. (A dog Is a canine.) Поэтому класс Dog (собака) должен быть производным от класса Canine (Псовые). Вместе с тем собака (Dog) содержит хвост (Tail). (A dog Is a canine, but На$ a tail. — Собака является псовым, но содержит хвост.) Поэтому эк- земпляр класса Tail (хвост) можно поместить, например, в Canine. (Большинство псовых содержит хвост.) Из этого несколько биологического примера видно, что от- ношение является (Is-a) выражается в иерархии классов. Отношение же содержит (Has-a) между классами приводит к тому, что один класс содержится в другом. Класс, содержащий экземпляр данного, часто называется упаковщиком, или контейнером. Та- ким образом, упаковщик составлен (сформирован, если хотите) из своих составляю- щих (или “деталей”). При этом часто говорят, что контейнер получен в результате контейнеризации (составления, формирования, композиции) содержащихся в нем экзем- пляров других классов. Формирование означает, что класс реализуется с помощью внутренних переменных (так называемые переменные экземпляров), в которых хра- нятся экземпляры других классов. Вот еще один пример: поезд состоит из вагонов и паровоза, поэтому поезд содержит вагоны, он формируется из них. Можно сказать, что поезд — это контейнер для вагонов, сами вагоны никуда не едут, и чтобы пере- гнать их на другую станцию, из них нужно сформировать поезд. Предположим, вы хотите многократно использовать некоторую реализацию. Тогда наследование реализации не помогает. Однако в этом случае можно использовать формирование и передачу (делегирование) функций. Отношение содержит помогает сэкономить день. Более того, формирование можно считать формой многократного использования. Если нельзя использовать наследование, ничто не может помешать использовать эк- земпляр какого-то класса внутри нового класса. Чтобы использовать возможности ка- кого-то класса, просто используйте экземпляр этого класса в качестве составляющей части. Например, итератор класса Queue (Очередь) не наследует клаёс Queue (Очередь), а просто хранит экземпляр класса Queue (Очередь). А когда итератору нужно выдать элемент очереди или проверить, не пуста ли она, итератор просто передает (делеги- рует) эту функцию экземпляру класса Queue (Очередь). Если один класс содержит экземпляр другого класса (сформирован из экземпляров другого класса), можно выбирать, чем пользоваться. Через передачу (делегирование) функций можно открыть доступ к некоторым или всем возможностям составляющих объектов. Например, итератор класса Queue (Очередь) при вызове метода hasNext () может использовать метод isEmpty(), принадлежащий классу Queue (Очередь). 160 Глава 7
Вот две особенности, отличающие передачу (делегирование) функций от наследования. 1. С помощью наследования можно получить только один экземпляр объекта. По- скольку наследник становится внутренней частью нового класса, мы получим только один неделимый объект. 2. Передача (делегирование) функций в основном только предоставляет пользова- телям общедоступный интерфейс. Обычное наследование предоставляет больше возможностей доступа к внутренним частям унаследованных классов. Наследственная иерархия Что унаследовал от дедов ты, Усвой себе, чтобы владеть наследьем. Артур Шопенгауэр. Афоризмы и Максимы Отношения является и содержит (состоит из) чрезвычайно полезны. Они позволяют не злоупотреблять наследованием ради многократного использования реализации, а ус- тановить отношения между классами, соответствующие тем, которые существуют между понятиями, представляющими классы, в концептуальной модели. Производный класс должен находиться в таком отношении с базовым классом, чтобы соблюдалась наследст- венная иерархия понятий, установленная в концептуальной модели. Наследственная иерархия — это древовидное отображение отношений, которые уста- навливаются между классами в результате наследования. Наследование определяет новый класс — дочерний по отношению к старому, кото- рый в этой ситуации называется родителем. Это простейшие потомственно-родитель- ские отношения, ведь и в самом деле, каждая наследственная иерархия начинается с родителя и потомка. Конечно, у родителя может быть несколько потомков. Потомственный класс наследует у родительского класса свойства и поведения, причем он получает все те же свойства и поведения, которые родительский класс унаследовал от своего родительского класса. Смысл наследственной иерархии состоит в том, что потомки могут выполнять те же действия, что и родители. Потомственный класс может только модифицировать функции и добавлять новые, но он не может удалять функции. Если оказалось, что из потомственного класса необходимо удалить функцию, то это признак того, что в наследственной иерархии потомственный класс должен предшествовать родительскому. Как и биологические родители и дети, потомственный и родительский классы по- хожи между собой. Причем причины сходства тоже подобны: биологические родители и дети, а также потомственный и родительский классы совместно используют инфор- мацию, переданную через некий носитель. Разница лишь в этом носителе: для био- логических родителей и детей носителем информации выступает набор хромосом, где в конечном итоге как раз и находятся гены, а в случае классов таким носителем ин- формации выступает тип. Действительно, ведь классы используют информацию о ти- пе (эта информация для каждого из поддерживаемых объектом интерфейсов включает список методов и свойств интерфейса, а также описание параметров этих методов). Как и кровные дети, потомственные классы могут приобретать новые поведения и свойства. Например, ребенок может научиться играть на пианино, хотя родители ни- когда этого не делали. Ребенок также может изменить унаследованное поведение. На- пример, родители плохо знали математику. Ребенок же упорно учился и в результате добился успехов в математике. Наследование 161
Глубина наследования может быть любой. Именно поэтому наследование можно применять для формирования сложной иерархической структуры классов. Однако не следует стремиться к необоснованному углублению иерархии. Наоборот, желательно минимизировать глубину иерархии. Ведь чем глубже иерархия, тем труднее ее под- держивать. С иерархией наследования связано расширение понятий предка и наслед- ника. Предками, или предшественниками данного производного класса называются классы, которые находятся выше класса-родителя в иерархии наследования, а все классы, стоящие после этого класса в иерархии наследования, называются его потом- ками, наследниками или производными классами. Все потомки совместно используют свойства и методы своих предков. Корневой класс — это класс, который стоит на самом верху иерархии наследования. Лист — это класс без потомков. Листы часто называются также конечными классами. В некоторых языках программирования можно указать, что данный класс является ко- нечным, т.е. у него не может быть потомков. В потомках отражаются изменения, происходящие в предке. Если исправить ошибку в предке или повысить эффективность его реализации, то в выигрыше ока- жутся все потомки. Пример использования наследования для построения иерархии Теперь, когда мы научились создавать потомков, давайте разберем классический пример построения иерархии. Классический он не только потому, что его постоянно приводили создатели парадигмы ООП, но и потому, что в моделируемом в этом при- мере мире существует несколько естественных иерархий. Указывая на собаку как на представителя класса млекопитающих в одном из первых рекламных роликов, посвя- щенных ООП, Филипп Канн говорит, что она наследует все признаки, общие для класса млекопитающих. Поскольку собака — млекопитающее, мы знаем, что это жи- вотное дышит воздухом (ведь все млекопитающие дышат воздухом), мы также знаем, что помимо всех свойств млекопитающих, собака обладает способностью вилять хво- стом, бегать, лаять, когда хочется спать... Таким образом, в классе млекопитающих мы выделили подкласс собак. Однако классификацию можно продолжить и в подклассе собак: можно выделить подклассы (на самом деле это будут подподклассы) служебных, спортивных и охот- ничьих собак. Затем можно пойти дальше и ввести в рассмотрение породу собаки: ов- чарка, чау-чау, спаниель, лабрадор и т.д. Если мы знаем, что фокстерьер — это порода охотничьих собак, то мы знаем, что фокстерьеры обладают всеми признаками собак, в том числе и всеми признаками мле- копитающих. (Конечно, эта порода имеет и отличительные признаки фокстерьеров.) Чтобы отразить эту естественную иерархию в программе на C++, мы объявляем новый класс Dog (Собака) производным от класса Mammal (Млекопитающее). Други- ми словами, класс Mammal (Млекопитающее) является базовым для класса Dog (Собака). В описании вида псовых (Canine) указывается, что собаки — млекопитаю- щие (и потому имеют все признаки млекопитающих). Точно так же в программе класс Dog (Собака) наследует все методы и данные класса Mammal (Млекопитающее). Как правило, у базового класса есть несколько производных классов. Поскольку собаки, кошки и лошади — представители млекопитающих, то в программе на C++ естественно объявить всё эти классы производными от класса Mammal (Млекопитаю- щее). Тем самым мы сможем в программе смоделировать царство животных. 162 Глава 7
Царство животных Предположим, мы хотим построить некоторую программу, моделирующую пове- дение животных. Сначала нужно создать классы животных, включая лошадей, коров, собак, кошек, овец и т.д., предусмотрев в каждом классе методы, благодаря которым они смогут вес- ти себя на экране так, как ведут себя их биологические прообразы. Вначале, конечно, каждый метод будет представлять собой простую подыгрывающую функцию, выводя- щую на печать краткое сообщение. Это обычная практика разработки программ, когда сначала выполняется только определение необходимого набора методов, а детальная проработка методов откладывается на более поздний срок. Затем методы нужно будет доработать, чтобы все животные вели себя так, как запланировано в модели. Теперь можем объявить класс Dog (Собака) производным от класса Mammal (Мле- копитающее): // Простое наследование #include <iostream.h> enum BREED { GOLDEN, CAIRN, DANDIE, SHETLAND, DOBERMAN, LAB } class Mammal { // объявляется класс Mammal (Млекопитающее) public: Mammal();// Конструктор -Mammal () ; // Деструктор // Методы доступа к данным int GetAge()const; void SetAge(int); int GetWeight() const; void Setweight(); // Другие методы void Speak() const; void Sleep() const; protected: int itsAge; // Возраст экземпляра int itsWeight; // Вес экземпляра } ; class Dog : public Mammal { public: Dog();// Конструктор -Dog();// Деструктор // Методы доступа к данным BREED GetBreedO const; void SetBreed(BREED); // Другие методы WagTail(); BegForFood(); protected: BREED itsBreed; // Порода экземпляра } ; Наследование 163
Эта заготовка программы ничего не выводит на экран, так как пока здесь содер- жатся только объявления и установки классов. Никаких функций данная заготовка программы не выполняет. Класс Mammal (Млекопитающее) объявлен так, что он не является производным ни от какого другого класса, хотя в реальной жизни класс млекопитающих является производным от класса животных. Но ведь программа на C++ моделирует лишь неко- торую часть реального мира. Реальный мир (Вселенная) слишком сложен и разнооб- разен; его нельзя отобразить в одной, даже очень большой программе. Профессиона- лизм состоит в том, чтобы создать относительно простую модель, в которой можно воспроизвести объекты, которые ведут себя подобно своим реальным прообразам. Иерархическая структура реального мира берет свое начало неизвестно откуда, но в данной программе она начинается с класса Mammal (Млекопитающее), и тем самым мы абстрагируемся от сложного процесса эволюции (и надеемся избежать критики со стороны противников эволюционного учения). Поэтому некоторые переменные- члены представлены в объявлении базового класса, хотя в другой иерархии они были бы отнесены к другим классам. Например, все животные независимо от вида и поро- ды имеют возраст и вес. Если бы класс Mammal (Млекопитающее) был производным от класса Animals (Животные), то он бы наследовал эти атрибуты. (И, конечно же, атрибуты базового класса стали бы атрибутами производного класса.) Чтобы ограничить сложность модели (и облегчить работу с программой), в классе Mammal (Млекопитающее) представлено только шесть методов: четыре метода доступа, а также функции Speak () и Sleep (). Класс Dog (Собака) объявляется производным от класса Mammal (Млекопитающее). Хотя в объявлении класса Dog (Собака) не указаны переменные itsAge (Возраст эк- земпляра) и itsWeight (Вес экземпляра), все экземпляры класса Dog (Собака) имеют три переменные-члена: itsAge (Возраст экземпляра), itsWeight (Вес экземпляра) и itsBreed (Порода экземпляра), так как экземпляры класса Dog (Собака) унаследуют эти переменные из класса Mammal (Млекопитающее). (Из класса Mammal (Млеко- питающее) наследуются, конечно, и объявленные там методы за исключением копи- ровщика, конструктора и деструктора.) Эти члены класса доступны для наследования, поскольку они защищенные (ключевое слово protected). Если бы данные класса бы- ли определены как приватные (ключевое слово private (приватный, личный)), они были бы недоступны для наследования. Конечно, переменные-члены itsAge (Возраст экземпляра) и itsWeight (Вес экземпляра) можно было определить с помощью клю- чевого слова public (общедоступный), но тогда прямой доступ к этим переменным получили бы все классы программы. А ведь мы хотели сделать эти переменные-члены видимыми только для создаваемого класса и для всех производных от него классов. Именно поэтому мы и указали ключевое слово protected (защищенный). Ведь за- щищенные данные доступны только для самого класса и производных от него клас- сов, но недоступны для всех остальных классов. Конечно, функция-член класса может использовать наряду с защищенными данными любого класса-предка, объявленными как protected (защищенный), и все закрытые данные своего класса (объявленные как private (приватный, личный)). Так, в нашем примере функция Dog:-WagTail () может использовать значение защищенной переменной itsBreed (Порода экземпляра) и всех переменных класса Mammal (Млекопитающее), объявленных как public (общедоступный) и protected (защищенный). Даже если бы класс Dog (Собака) был произведен не от класса Mammal (Млекопитающее) непосредственно, а от какого-нибудь промежуточного класса (например, DomesticAnimals (Домашние животные)), все равно класс Dog (Собака) имел бы доступ к защищенным данным класса Mammal (Млекопитающее), правда 164 Глава 7
только в том случае, если бы в объявлении класса Dog (Собака) и всех промежуточных классов использовался спецификатор доступа public (общедоступный) или protected (защищенный). Теперь можем создать экземпляр класса Dog (Собака) и проверить его в действии. // Создание экземпляров производных классов #include <iostream.h> enum BREED { GOLDEN, CAIRN, DANDIE, SHETLAND, DOBERMAN, LAB } ; class Mammal { // объявление класса Mammal (Млекопитающее) public: Mammal():itsAge(2), itsWeight(5) { // Конструктор } -Mammal() { } // Методы доступа int GetAge() const { return itsAge; } void SetAge(int age) { itsAge = age; } int GetWeight() const { return itsWeight; } void Setweight(int weight) { itsWeight = weight; } // Другие методы void Speak()const { cout << "Mammal sound!\n"; } void Sleep()const { cout << "shhh. I'm sleeping.\n"; } protected: int itsAge; int itsWeight; }; // Объявление класса Dog (Собака), который является производным // от класса Mammal (Млекопитающее) class Dog : public Mammal { public: // Конструктор Dog():itsBreed(GOLDEN) { } -Dog(){ } // Методы доступа BREED GetBreedO const { return itsBreed; } void SetBreed(BREED breed) { itsBreed = breed; } // Другие методы void WagTailO const { cout << "Tail wagging... \n"; } void BegForFoodO const { cout << "Begging for food...\n"; } private: BREED itsBreed; }; int main() { Dog fido; fido.Speak(); fido.WagTail() ; cout << "Fido is " << fido. GetAge() « " years old\n"; return 0; } Наследование 165
Вот результат: Mamma1 s ound! Tail wagging... Fido is 2 years old В объявлении класса Mammal (Млекопитающее) тел а функций вставлены в объяв- ление класса. Так как класс Dog (Собака) является производным от класса Mammal (Млекопитающее), то экземпляру fido этого класса доступны как функция производ- ного класса WagTailO, так и функции базового класса Speak() и SleepO. Теперь, когда мы знаем, что происходит при наследовании с обычными функциями, давайте разберемся, как вызываются конструкторы и деструкторы при создании и уничтоже- нии экземпляров.производного класса. Вызов конструкторов и деструкторов при создании и уничтожении экземпляров производного класса Так как класс Dog (Собака) является производным от класса Mammal (Млеко- питающее), то экземпляры класса Dog (Собака) являются также экземплярами класса Mammal (Млекопитающее). Поэтому, чтобы создать объект fido — экземпляр класса Dog (Собака), — сначала вызывается конструктор базового класса Mammal (Млекопи- тающее). Затем создание объекта завершает конструктор производного класса Dog (Собака). Поскольку объекту fido параметры не передаются, в обоих случаях вызыва- ется конструктор без параметров. Фактически объект fido не существует до тех пор, пока он не будет создан обоими конструкторами — конструктором базового класса Mammal (Млекопитающее) и конструктором производного класса Dog (Собака). При удалении объекта fido из памяти компьютера сначала вызывается деструктор производного класса Dog (Собака), а затем деструктор базового класса Mammal (Млеко- питающее). Каждый деструктор удаляет ту часть объекта, которая была создана соот- ветствующим конструктором производного или базового классов. (Если объект боль- ше не используется, его обязательно (автоматически или явно) нужно удалить из па- мяти, так как в противном случае возможна одна из коварнейших и очень трудноуло- вимых ошибок — “утечка памяти”.) А теперь покажем все это — перепишем нашу программу так, чтобы вызов конст- руктора и деструктора сопровождался выводом сообщения: // Вызов конструкторов и деструкторов // при создании и уничтожении экземпляров производного класса. #include <iostream.h> enum BREED { GOLDEN, CAIRN, DANDIE, SHETLAND, DOBERMAN, LAB } class Mammal { public: Mammal();// конструктор -Mammal.() ; //Методы доступа int GetAge() const { return itsAge; } void SetAge(int age) { itsAge = age; } int GetWeightO const { return itsWeight; } void SetWeight(int weight) { itsWeight = weight; } 166 Глава 7
//Другие методы void Speak() const { cout << "Mammal sound!\n"; } void Sleep() const { cout << "shhh. I'm sleeping.\n"; } protected: int itsAge; int itsWeight; }; class Dog : public Mammal { public: Dog();// Конструктор -Dog() ; // Методы доступа BREED GetBreedO const { return itsBreed; } void SetBreed(BREED breed) { itsBreed = breed; } // Другие методы void WagTailO const { cout << "Tail wagging...\n"; } void BegForFoodO const { cout << "Begging for food...\n"; } private: BREED itsBreed; }; Mammal: : Mammal () : itsAge(1), itsWeight(5) { cout << "Mammal constructor...\n"; } Mammal: : -Mammal ( ) { cout << "Mammal destructor... \n";’ } Dog::Dog(): itsBreed(GOLDEN) { cout << "Dog constructor...\n"; } Dog::-Dog() cout << { "Dog destructor...\n"; int main() { Dog fido; fido.Speak(); fido.WagTail(); cout << "Fido is " « fido.GetAge() « " years old\n"; return 0; } Наследование 167
Вот результат прогона: Mammal constructor... Dog constructor... Mammal sound! Tail wagging... Fido is 1 years old Dog destructor... Mammal destructor... Как видно из распечатки, сначала вызывается конструктор базового класса Mammal (Млекопитающее), затем — конструктор производного класса Dog (Собака). Только после этого экземпляр класса Dog (Собака) начинает существование и можно исполь- зовать все его методы. Когда выполнение программы выходит за область видимости объекта fido, он уничтожается. Для этого (неявно) вызываются деструкторы: сначала деструктор производного класса Dog (Собака), а затем деструктор базового класса Mammal (Млекопитающее). Теперь мы знаем (и применили его на практике) порядок вызова конструкторов и деструкторов. И хотя с деструкторами вопрос исчерпан, с конструкторами остается невыясненной еще одна деталь: как передать параметры конструктору базового класса? Передача параметров в конструкторы базового класса Предположим, нужно перегрузить конструкторы, заданные по умолчанию в клас- сах Mammal (Млекопитающее) и Dog (Собака), таким образом, чтобы первый из них сразу присваивал новому объекту определенный возраст, а второй — породу. Как пе- редать конструктору класса Mammal (Млекопитающее) значения возраста и веса жи- вотного? Что произойдет, если вес не будет установлен конструктором класса Mammal (Млекопитающее), зато его установит конструктор класса Dog (Собака)? Чтобы инициализировать экземпляр базового класса, необходимо записать имя ба- зового класса, после чего указать параметры, принимаемые базовым классом. В при- веденной ниже программе показана перегрузка конструкторов в производных классах. // Перегрузка конструкторов в производных классах #include <iostream.h> enum BREED { GOLDEN, CAIRN, DANDIE, SHETLAND, DOBERMAN, LAB } class Mammal { public: // Конструктор по умолчанию Mammal ( ); // Следующий конструктор перегружает конструктор по умолчанию // класса Mammal (Млекопитающее) таким образом, // чтобы он принимал целое число (возраст животного). Mammal(int age); -Mammal(); // Методы доступа int GetAge() const { return itsAge; } void SetAge(int age) { itsAge = age; } int GetWeightO const { return itsWeight; } void SetWeight(int weight) { itsWeight = weight; } 168 Глава 7
//Другие методы void Speak() const { cout << "Mammal sound!\n"; } void Sleep() const { cout « "shhh. I'm sleeping.\n"; } protected: int itsAge; int itsWeight; } ; class Dog : public Mammal { public: // Конструкторы класса Dog (Собака) Dog () ; / / Конструктор по умолчанию // Этот конструктор принимает возраст, причем использует для этого тот // же параметр, что и конструктор класса Mammal (Млекопитающее) Dog(int age); // Этот конструктор принимает возраст и вес Dog(int age, int weight); // Этот конструктор принимает возраст и породу Dog(int age, BREED breed); // Этот конструктор принимает возраст, вес и породу Dog(int age, int weight, BREED breed); -Dog(); // Методы доступа BREED GetBreedO const { return itsBreed; } void SetBreed(BREED breed) { itsBreed = breed; } // Другие методы void WagTailO const { cout « "Tail wagging...\n"; } void BegForFoodO const { cout « "Begging for food...\n"; } private: BREED itsBreed; } ; Mammal: : Mammal () : itsAge(1), itsWeight(5) { cout << "Mammal constructor...\n"; } // Это реализация того конструктора, который перегружает конструктор // по умолчанию класса Mammal (Млекопитающее) таким образом, чтобы // он принимал целое число (возраст животного) . Этот конструктор // инициализирует переменную itsAge (Возраст экземпляра) значением, // переданным конструктору в качестве параметра, // а переменную itsWeight числом 5. Mammal::Mammal(int age): itsAge(age), itsWeight(5) { cout « "Mammal(int) constructor...\n"; } Наследование 169
Mammal: : -Mammal () { cout « "Mammal destructor...\n"; } Dog::Dog(): // конструктор по умолчанию класса Dog (Собака) вызывает конструктор по // умолчанию класса Mammal (Млекопитающее) . В этом нет необходимости, // но данный вызов документирует намерение вызвать именно базовый // конструктор, не содержащий параметров. Базовый конструктор будет // вызван в любом случае, но здесь это сделано явно. Mammal(), itsBreed(GOLDEN) { cout << "Dog constructor...\n"; } // Реализация конструктора класса Dog (Собака), // который принимает одно целочисленное значение. Dog::Dog(int age) : // Сначала инициализируется базовый класс класса Dog (Собака); // возраст базовому классу передается в виде параметра Mammal(age), // Затем присваивается значение породы itsBreed(GOLDEN) { cout « "Dog(int) constructor...\n"; } // ================================================================== // Этот конструктор класса Dog (Собака) принимает два параметра. // Первый параметр вновь передается конструктору базового класса // и тем самым он используется для его инициализации. // Значение второго параметра присваивается переменной базового // класса itsWeight (Вес экземпляра) самим конструктором класса // Dog (Собака) . // Дело в том, что присваивание значения переменной базового класса // нельзя выполнить при инициализации конструктора производного // класса, так как в классе Mammal (Млекопитающее) нет конструктора, // присваивающего значение этой переменной. Именно по этой причине // присваивание значения выполняется в теле конструктора // производного класса Dog (Собака). Dog::Dog(int age, int weight): Mammal(age), itsBreed(GOLDEN) { itsWeight = weight; cout « "Dog(int, int) constructor...\n"; } Dog::Dog(int age, int weight, BREED breed): Mammal(age), itsBreed(breed) { itsWeight = weight; cout << "Dog(int, int, BREED) constructor...\n"; } 170 Глава 7
Dog::Dog(int age, BREED breed): Mammal(age), itsBreed(breed) { cout « "Dog(int, BREED) constructor...\n"; } Dog::-Dog() { cout « "Dog destructor...\n"; } int main() { Dog fido; Dog rover(5); Dog buster(6,8); Dog yorkie (3, GOLDEN); Dog dobbie (4, 20, DOBERMAN); fido.Speak(); rover. WagTail(); cout << "Yorkie is " « yorkie.GetAge() << " years old\n"; cout « "Dobbie weighs "; cout « dobbie.GetWeight() « " pounds\n"; return 0; } Почитаем распечатку. Вначале, конечно, вызываются конструкторы. Первые две строки вывода соответствуют инициализации объекта fido с помощью конструкторов по умолчанию: Mammal constructor... Dog constructor... Далее создается объект rover: Mammal(int) constructor... Dog(int) constructor... Затем создается объект buster, Причем в этом случае из конструктора класса Dog (Собака) с двумя целочисленными параметрами вызывается конструктор класса Mammal (Млекопитающее), имеющий один целочисленный параметр: Mammal(int) constructor... Dog(int, int) constructor... Наконец, создаются и другие объекты: Mammal(int) constructor... Dog(int, BREED) constructor.... Mammal(int) constructor... Dog(int, int, BREED) constructor. После создания всех объектов они начинают действовать: Mammal sound! Tail wagging... Yorkie is 3 years old. Dobbie weighs 20 pounds. Когда же программа “устает” от них и управление передается за область видимо- сти этих объектов, они удаляются, причем удаление каждого объекта сопровождается обращением к деструктору класса Dog (Собака), после чего следует обращение к дест- руктору класса Mammal (Млекопитающее): Наследование 171
Dog destructor... Mammal destructor... Dog destructor... Mammal destructor... Dog destructor... Mammal destructor... Dog destructor... Mammal destructor... Dog destructor.. . Mammal destructor... Одиночное и множественное наследование Пегас. В древнегреческой мифологии крылатый конь Pegasos, родившийся из крови обезглавленной медузы, под ударом ко- пыта которого забил источник Ипокрена, вдохновляющий по- этов. Толковый словарь русского языка (под ред. Д. Н. Ушакова) Наследственная иерархия выглядит очень стройно, если потомственный класс мо- жет иметь только одного родителя. Однако в зависимости от реализации наследова- ния, в языке программирования потомственный класс может иметь только одного ро- дителя или же может допускаться более одного родителя у одного потомка. Если в языке классы могут иметь несколько родителей, то наследование называет- ся множественным, а если потомки могут иметь только одного родителя, то наследо- вание называется одиночным, или единичным. В некоторых языках, например в Java, реализацию можно наследовать только у од- ного родителя, но вместе с тем в этих языках предусмотрен механизм множественного наследования для интерфейсов. Таким образом, одиночное, или единичное наследование — форма наследования функций и свойств классами, при которой производный класс может иметь единст- венный базовый класс, а множественное наследование — это форма наследования функций и свойств классами, при которой производный класс может иметь любое число базовых. Одиночное наследование Пегас [гр. Pegasos < тгг|уг| источник] — в древнегреческой ми- фологии — крылатый конь Зевса. От удара Пегаса копытом на горе Геликон якобы забил чудесный источник Ипокрена, вода которого давала вдохновенье поэтам. Словарь иностранных слов (под ред. И. В. Лехина и Ф. Н. Петрова) Как известно из биологии, бесполое размножение (одиночное наследование) имеет определенные ограничения. Программисты обожают кошек, но на тигров предпочи- тают смотреть издалека, да и вообще их профессиональная деятельность (написание текстов) гораздо ближе к профессии писателя или поэта (листинги программ — чем не стихи для компьютера?), чем биолога. Поэтому программисты демонстрируют ог- раничения одиночного наследования не сложными теоретико-вероятностным и вы- кладками из популяционной генетики, а на примере (в учебниках по ООП он при- знан классическим) создания мифического крылатого коня, оседлать которого — меч- та всех поэтов. Конечно, вы догадались, что речь идет о Пегасе. Однако в отличие от многих писак, возомнивших себя Зевсами в литературе и потому пытающихся веко- 172 Глава 7
чить на коня Зевса (обычно это заканчивается нетяжелыми ушибами, правда, приво- дящими к тяжелым формам графомании), программисты подходят к этому вопросу строго научно. Прежде всего они вспоминают о том, что Пегас является лошадью, возможно, с большой буквы. Большая буква (а мимо такой существенной мелочи не может пройти ни один программист!) и отношение является наводят на мысль о клас- се Horse (Лошадь). Однако Пегас может не только бегать, но и летать — у него есть крылья, которых нет у обычных лошадей. Поэтому Пегас должен представлять собой усовершенствованную лошадь. Но таких лошадей во всем царстве животных (класс Animal (Животное)), а не только среди млекопитающих (класс Mammal (Млекопи- тающее)), нет! (Теперь понятно, почему попытки оседлать Пегаса так часто заканчи- ваются ушибами: бьюсь об заклад, вы не сможете сесть на несуществующий стул без ушибов, не говоря уже о том, чтобы оседлать Пегаса!) Поэтому Пегаса нужно сначала создать. Конечно, для этого можно воспользоваться иерархйей в царстве животных и наследованием! В царстве животных (класс Animal (Животное)) есть по крайней мере два нужных нам производных от этого класса: Bird (Птица) и Mammals (Млеко- питающие). Птицы (класс Bird (Птица)) имеют метод Fly() и потому могут совер- шать полеты. Класс Mammals (Млекопитающие) разбит на ряд подклассов, включая класс лошадей — Horse (Лошадь). Экземпляры этого класса умеют ржать (так как имеют метод whinny ()) и могут бегать галопом (так как имеют метод Gallop ()). Наш мифический объект — крылатый Пегас (Pegasus4) — всего лишь гибрид лоша- ди (класс Horse (Лошадь)) и птицы (класс Bird (Птица)). Сначала попытаемся создать гибрид, используя только одиночное наследование. Однако если объявить класс Pegasus (Пегас) как производный от класса Bird (Птица), то для него станут недоступными функции whinny() и GallopO. Если класс Pegasus (Пегас) объявить как производный от класса Horse (Лошадь), то ему станет недоступной функция Fly (). Конечно, можно скопировать метод Fly() в класс Horse (Лошадь), а потом соз- дать объект Pegasus (Пегас). Но тогда оба класса (Bird (Птица) и Horse (Лошадь)) будут содержать один и тот же метод Fly(), и в случае изменения метода в одном классе нужно будет внести изменения и в другой класс. Это метод “ножниц и клея”. Он не годится, потому что при последующих изменениях программы невозможно вспомнить, в какие классы нужно вносить изменения. Поэтому копировать метод не стоит. Перенос метода вверх по иерархии классов Чтобы сделать метод доступным возможно большему числу производных классов, при применении одиночного наследования очень часто приходится переносить объявление ме- тода вверх по иерархии классов. Давайте испытаем этот метод. Нам нужно просто оп- ределить метод Fly () в классе Horse (Лошадь), но так, чтобы лошади не смели меч- тать о полете. Проблема ведь состоит в том, что лошади, в большинстве своем, летать не умеют, поэтому для всех экземпляров этого класса, за исключением экземпляров класса Pegasus (Пегас), данный метод не должен ничего выполнять. Это решение показано в следующей программе. 4 Древние греки построили храм Аполлона на горе Геликон. Эта гора — та самая, на которой Пегас ударил копытом и где забил чудесный источник Ипокрена, вода которого давала вдохновенье поэтам, — по древнегреческой мифологии была местопребыванием муз. Это свидетельствует о по- клонении греков музам, искусствам и наукам. Однако практическим применением науки греки (в отличие от древних римлян) часто пренебрегали и считали его недостойным муз. Поэтому когда мы перешли от греческой мифологии к более практическим занятиям (генная инженерия), мы перешли от древнегреческого написания имени мифического летающего коня Pegasos к латинизированному имени класса: Pegasus. Наследование 173
// Умеют ли лошади летать. . . // Добавление метода Fly() в класс Horse #include <iostream.h> class Horse { public: void Gallop(){ cout « "Galloping...\n"; } // Редко используемый метод Fly() для экземпляров данного класса // констатирует факт, что лошади летать не умеют. virtual void Fly() { cbut « "Horses can't fly.Xn" ; } private: int itsAge; }; class Pegasus : public Horse { public: // в классе Pegasus (Пегас) метод замещается, чтобы экземпляры этого // класса умели летать. virtual void Fly() { cout « "I can fly! I can fly! I can fly’Xn"; } } ; const int NumberHorses = 5; int main() { // массив указателей на экземпляры класса Horse (Лошадь) Horse* Ranch[NumberHorses]; Horse* pHorse; int choice, i; for (i=0; i<NumberHorses; i++) { cout « "(1)Horse (2)Pegasus: "; cin >> choice; if (choice == 2) pHorse = new Pegasus; else pHorse = new Horse; Ranch[i] = pHorse; } cout « "\n"; for (i=0; i<NumberHorses; i++) { Ranch[i]->Fly(); delete Ranch[i]; } return 0; } Протокол сессии с программой свидетельствует, что программа работает, причем для разных объектов вызывается подходящий метод Fly (): (1)Horse (2)Pegasus: 1 (1)Horse (2)Pegasus: 2 (1)Horse (2)Pegasus: 1 (1)Horse (2)Pegasus: 2 (1)Horse (2)Pegasus: 1 174 Глава 7
Horses can't fly. I can fly! I can fly! Horses can't fly. I can fly! I can fly! Horses can't fly. I can fly! I can fly! Перенос метода вверх по иерархии классов Чтобы сделать метод доступным возможно большему числу производных классов, при применении одиночного наследования очень часто приходится переносить объявление метода вверх по иерархии классов. Но при этом в ба- зовый класс приходится добавлять методы, которые в принципе там не нужны. Программа становится громоздкой, причем получающаяся иерархия классов противоречит принципу, что производные классы должны дополнять своими функциями небольшой набор общих функций базового класса. Это противоречие выражается также и в том, что при переносе функции из производных классов вверх по иерархии в базовый класс трудно сохранить уникальность интерфейсов производных классов. Перенеся какой-нибудь спе- цифический метод в базовый класс, придется позаботиться о том, чтобы этот метод вызывался только в некоторых производных классах. Приведение указателя к типу производного класса В рамках парадигмы одиночного наследования эту проблему можно решить иначе. Нам нужно сделать так, чтобы метод Fly () вызывался, только если указатель адресует экземпляр класса Pegasus (Пегас). Для этого необходимо уметь во время выполнения программы определить тип объекта, на который указывает указатель. Такое средство в языке C++ называется RTTI (Runtime Type Identification — определение типа при вы- полнении). Правда, возможность определения типа при выполнении (RTTI) была до- бавлена только в последние версии компиляторов C++. Если компилятор не поддерживает определение типа при выполнении (RTTI), его можно реализовать самостоятельно, добавив в программу метод, который возвращает перечисление типов каждого класса. Возвращаемое значение можно анализировать во время выполнения программы и допускать вызов метода Fly() только в том случае, если возвращается значение, соответствующее типу Pegasus (Пегас). Злоупотребление определением типа при выполнении (RTTI) часто свиде- тельствует о том, что структура программы плохо продумана. Лучше приме- нить виртуальные функции, шаблоны или множественное наследование. Чтобы вызвать метод Fly(), необходимо во время выполнения привести тип ука- зателя, определив, что он адресует не экземпляр класса Horse (Лошадь), а экземпляр производного класса Pegasus (Пегас). Такое приведение типа, т.е. приведение типа базового класса к типу производного класса, называется приведением вниз. В нашей программе указатель на экземпляр базового класса Horse (Лошадь) приводится к ука- зателю на экземпляр производного класса Pegasus (Пегас). Такое приведение типа, хотя и с некоторой неохотой, официально признано в C++, и для его реализации добавлен новый оператор — dynamic_cast. Указатель на экземпляры базового класса может адресовать экземпляры производ- ного класса, причем такой указатель полиморфный. Чтобы вызвать метод производ- ного класса, нужно во время выполнения программы привести указатель на экземп- ляр базового класса к указателю на экземпляр производного класса с помощью опера- тора dynamic_cast. Если указатель не адресует экземпляр производного класса, то оператор dynamic_cast возвращает нулевой указатель. Наследование 175
Во время выполнения программы мы с помощью оператора dynamic_cast приве- дем указатель на экземпляр базового класса к указателю на экземпляр производного класса Pegasus (Пегас) и проверим его. Если окажется, что он адресует экземпляр производного класса Pegasus (Пегас), то мы вызовем метод Fly() производного класса. // Приведение вниз и оператор dynamic—cast // определение типа при выполнении: RTTI (Runtime // Type Identification - определение типа при выполнении) tinclude <iostream.h> ©num TYPE { HORSE, PEGASUS }; class Horse { public: virtual void Gallop() { cout « "Galloping...\n"; } private: int itsAge; }; class Pegasus : public Horse { public: virtual void Fly() { cout « "I can fly! I can fly! I can fly!\n"; } } ; const int NumberHorses = 5; int main() { Horse* Ranch[NumberHorses]; Horse* pHorse; int choice, i; for (i=0; i<NumberHorses; i++) { cout « "(l)Horse (2)Pegasus: cin >> choice; if (choice == 2) pHorse = new Pegasus; else pHorse = new Horse; Ranch[i] = pHorse; } cout « "\n"; for (i=0; i<NumberHorses; i++) { Pegasus *pPeg = dynamic_cast< Pegasus *> (Ranch[i]); if (pPeg). pPeg->Fly(); else cout « "Just a horse\n"; delete Ranch[i]; } return 0; } 176 Глава 7
Эта программа работоспособна. Вот результат: (1)Horse (2)Pegasus: 1 (1)Horse (2)Pegasus: 2 (1) Horse (2.) Pegasus: 1 (1)Horse (2)Pegasus: 2 (1)Horse (2)Pegasus: 1 Just a horse I can fly! I can fly! I can fly! Just a horse I can fly! I can fly! I can fly! Just a horse В классе Horse (Лошадь) нет метода Fly() и он не вызывается для обычных эк- земпляров этого класса. Метод Fly() вызывается только для экземпляров класса Pegasus (Пегас). Правда, для этого программа анализирует указатель и при необхо- димости приводит его к типу производного класса. Возможно, у вас выдача получилась не такая, или вообще не получилось ниче- го. Ведь старые компиляторы могут не поддерживать определение типа при выполнении. Однако и в Microsoft Visual Studio .NET программа могла выпол- няться не до конца. (Диалог прошел успешно, а вот дальше возникли пробле- мы с определением типа при выполнении.) Если компилятор не поддерживает определение типа при выполнении, нужно что-то менять: или программу, или компилятор. Если же компилятор поддерживает определение типа при выпол- нении, то, возможно, следует просто установить эту опцию. Конечно, все зави- сит от конкретного компилятора. В Microsoft Visual Studio .NET, например, сле- дует найти где-нибудь (обычно во вкладке Class View или Solution Explorer) свой проект, щелкнуть на нем правой кнопкой мыши и в контекстном меню вы- брать пункт Properties. После этого появится диалоговое окно— страница свойств вашего проекта. Раскройте в ней папку Configuration Properties, а в этой папке— подпапку C/C++ и найдите пункт Language. В таблице справа найдите строку Enable Run-Type Info и установите ее значение равным Yes (/GR). Теперь можете откомпилировать программу и снова запустить ее. Программы с приведением типа объектов выглядят несколько неуклюже и в них легко запутаться, так как полиморфизм виртуальных функций не использу- ется, поскольку для вызова метода необходимо во время выполнения про- граммы привести тип объекта. Ограничения одиночного наследования. Невозможность добавления объекта в два списка Мы объявили класс Pegasus (Пегас) как производный от типа Horse (Лошадь). Тем не менее, Пегасы — не обычные лошади: они умеют летать. Справедливости ради нужно признать, что всего этого мы достигли с помощью одиночного наследования! Однако птицы (экземпляры класса Bird (Птица)) тоже умеют летать, а включить Пе- гаса в список объектов класса Bird (Птица) мы не можем. И это из-за одиночного наследования! Применяя одиночное наследование, приходилось то переносить функ- цию Fly () вверх по иерархии классов, то выполнять приведение указателя, но так и не удалось составить список летающих объектов. Программа получилась не очень гибкая. Составить список летающих объектов, придерживаясь только одиночного наследо- вания, можно, если перенести все три функции — Fly(), Whinny() и Gallop() — в класс Animal (Животное), базовый для классов Bird (Птица) и Horse (Лошадь). В ре- Наследование 177
зультате вместо двух списков экземпляров классов Bird (Птица) и Horse (Лошадь) можно будет составить один список экземпляров класса Animal (Животное). При этом, однако, в базовый класс придется перенести слишком много функций. Факти- чески это будет перенос вверх по иерархии классов интерфейсов производных клас- сов. А переносить вверх по иерархии классов рекомендуется только общие функции. Правда, методы можно оставить там, где они есть, но тогда придется приводить типы экземпляров классов Horse (Лошадь), Bird (Птица) и Pegasus (Пегас). Резуль- тат будет еще хуже, поскольку нужно будет определять типы объектов во время вы- полнения программы! Чтобы не определять типы объектов во время выполнения про- граммы, используйте виртуальные методы, шаблоны и множественное наследование. Множественное наследование В языке C++ класс можно создать на основе нескольких базовых. Это и есть мно- жественное наследование. Однако создать класс, производный от нескольких базовых не просто, а очень просто: в объявлении производного класса нужно указать не один базо- вый класс, а список базовых классов (при необходимости вместе со спецификаторами доступа). (В списке базовые классы разделяются запятыми.) Класс Pegasus (Пегас), например, можно объявить так, чтобы он наследовал свойства двух базовых классов — Bird (Птица) и Horse (Лошадь). Именно так сделано в приведенной ниже программе. Заодно в ней, кстати, экземпляр класса Pegasus (Пегас) добавляется в списки экзем- пляров обоих базовых классов. // Множественное наследование, #include <iostream.h> // объявление класса Horse (Лошадь) class Horse { public: // конструктор Horse() { cout « “Horse constructor... "; } // деструктор virtual -Horse() { cout « "Horse destructor... } // метод Whinny() печатает Whinny! (И-го-го) . virtual void Whinny() const { cout « "Whinny!... } private: int itsAge; } ; // объявление класса Bird (Птица) // Класс Bird (Птица) имеет не только конструктор и деструктор, но // и два метода: Chirp () и Fly(), каждый из которых выводит на // экран свое сообщение. В реальных программах эти методы // могут воспроизводить звуковой файл и создавать // анимационные эффекты на экране. class Bird { public: Bird() { cout « "Bird constructor... } virtual -Bird() { cout « "Bird destructor... } virtual void Chirp() const { cout « "Chirp... } 178 Глава 7
virtual void Fly() const { cout « "I can fly! I can fly! I can fly! } private: int itsWeight; } ; // объявление класса Pegasus (Пегас) // Этот класс является производным сразу от двух базовых классов - // Bird (Птица) и Horse (Лошадь) . В классе замещается метод ChirpO // так, чтобы он вызывал метод WhinnyO, который унаследован этим // классом от класса Horse (Лошадь). class Pegasus : public Horse, public Bird { public: void ChirpO const { WhinnyO; } Pegasus() { cout « "Pegasus constructor... "; } -Pegasus() { cout « "Pegasus destructor... "; } } ; const int MagicNumber = 2; int main() { // список Ranch (конюшня) - список экземпляров класса Horse (Лошадь) Horse* Ranch[MagicNumber]; // список Aviary (птичник) - список экземпляров класса Bird (Птица) Bird* Aviary[MagicNumber]; Horse * pHorse; Bird * pBird; int choice, i; // в список Ranch (конюшня) добавляются два объекта - экземпляр // класса Horse (Лошадь) и экземпляр класса Pegasus (Пегас) for (i=0; i<MagicNumber; i++) { cout « "\n(l)Horse (2)Pegasus: "; cin » choice; if (choice == 2) pHorse = new Pegasus; else pHorse = new Horse; Ranch[i] = pHorse; } // в список Aviary (птичник) добавляются два объекта - экземпляр // класса Bird (Птица) и экземпляр класса Pegasus (Пегас) for (i=0; i<MagicNumber; i++) { cout « "\n(l)Bird (2)Pegasus: "; cin » choice; if (choice == 2) pBird = new Pegasus; else pBird = new Bird; Aviary[i] = pBird; } cout « "\n"; for (i=0; i<MagicNumber; i++) { cout « "\nRanch[" « i « "]: " ; Ranch[i]->Whinny(); delete Ranch[ i ] ; } Наследование 179
for (i=0; i<MagicNumber; i++) { cout « "\nAviary["<< i << "]: // метод Chirp() вызывается для всех объектов, указатели на которые // хранятся в массиве Aviary (птичник) Aviary[i]->Chirp(); Aviary[i]->Fly(); delete Aviary[i]; } return 0; } Как видно из программы, вызовы виртуальных методов с помощью указателей на классы Bird (Птица) и Horse (Лошадь) ничем не отличаются для экземпляра класса Pegasus (Пегас). Например, метод Chirp () вызывается последовательно для всех объектов, указатели на которые представлены в массиве Aviary (птичник). Поскольку этот метод в классе Bird (Птица) объявлен как виртуальный, для всех объектов в спи- ске вызывается нужный метод. Вот распечатка: (1)Horse (2)Pegasus: 1 Horse constructor... (1)Horse (2)Pegasus: 2 Horse constructor... Bird constructor... Pegasus constructor... (l)Bird (2)Pegasus: 1 Bird constructor... (l)Bird (2)Pegasus: 2 Horse constructor... Bird constructor... Pegasus constructor... Ranch[0]: Whinny!... Horse destructor... Ranch[1]j Whinny!... Pegasus destructor... Bird destructor../ Horse destructor... Aviary[0]: Chirp... I can fly! I can fly! I can fly! Bird destructor... Aviary[l]: Whinny!... I can fly! I can fly! I can fly! Pegasus destructor... Bird destructor... Horse destructor... Из распечатки видно, что при создании объекта Pegasus (Пегас) вызываются кон- структоры всех трех классов — Bird (Птица), Horse (Лошадь) и Pegasus (Пегас), ка- ждый из которых создает свою часть объекта. При удалении объекта также удаляются его части, относящиеся к классам Bird (Птица) и Horse (Лошадь), для чего деструк- торы этих классов объявлены как виртуальные. Состав экземпляра класса, производного от нескольких базовых Когда в памяти компьютера создается экземпляр класса, производного от несколь- ких базовых, конструкторы всех классов принимают участие в его построении. Так что объект, полученный в результате множественного наследования, будет содержать части, относящиеся к каждому из классов. Поэтому, как и в биологии, в случае множественного наследования может возник- нуть ряд непростых и весьма интересных вопросов. Например, что произойдет, если родители окажутся родственниками, т.е. будут иметь одинаковые гены? Простите, я хотел спросить: что произойдет, если оба базовых класса будут иметь одноименные виртуальные функции или данные? Как инициализируются конструкторы разных ба- зовых классов? Что произойдет, если базовые классы окажутся наследниками одного и того же родительского класса? Давайте теперь поищем ответы на эти вопросы. 180 Глава 7
Конструкторы классов, полученных в результате множественного наследования Предположим, что класс Pegasus (Пегас) является производным от двух базовых классов — Bird (Птица) и Horse (Лошадь), причем каждый из них имеет конструкто- ры с параметрами. Тогда при создании экземпляра класса Pegasus (Пегас) этим кон- структорам можно передать параметры. Как это делается, показано в приведенной ниже программе. // Создание объектов при множественном наследовании ttinclude <iostream.h> typedef int HANDS; enum COLOR { Red, Green, Blue, Yellow, White, Black, Brown }; // Объявление класса Horse (Лошадь). class Horse { public: // Конструктор этого класса имеет два параметра. Horse(COLOR color, HANDS height); virtual -Horse() { cout « "Horse destructor...\n"; } virtual void Whinny()const { cout << "Whinny!... "; } virtual HANDS GetHeight() const { return itsHeight; } virtual COLOR GetColorf) const { return itsColor; } private: HANDS itsHeight; COLOR itsColor; }; // Реализация конструктора класса Horse (Лошадь) Horse::Horse(COLOR color, HANDS height) : itsColor (color), itsHeight (height) { // сообщение о работе конструктора класса Horse (Лошадь) cout « "Horse constructor...\n"; } // Объявление класса Bird (Птица). class Bird { public: Bird(COLOR color, bool migrates); virtual -BirdO { cout « "Bird destructor... \n"; } virtual void Chirp()const { cout « "Chirp... "; } virtual void Fly()const { cout << "I can fly! I can fly! I can fly! "; } virtual COLOR GetColor( )const { return itsColor; } virtual bool GetMigration() const { return itsMigration; } private: COLOR itsColor; bool itsMigration; }; // Реализация конструктора класса Bird (Птица) // Конструктор этого класса принимает два параметра. Bird::Bird(COLOR color, bool migrates): itsColor (color), itsMigration (migrates) { Наследование 181
cout « "Bird constructor...\n"; } // Объявление класса Pegasus (Пегас) class Pegasus : public Horse, public Bird { public: void Chirp () const { WhinnyO; } Pegasus(COLOR, HANDS, bool, long); -Pegasus() { cout « "Pegasus destructor... \n";} virtual long GetNumberBelievers() const { return itsNumberBelievers; } private: long itsNumberBelievers; }; // конструктор класса Pegasus (Пегас) Pegasus:: Pegasus( COLOR aColor, // цвет HANDS height, // рост bool migrates, long NumBelieve) : // Инициализация экземпляра класса Pegasus (Пегас) // конструктор класса Horse (Лошадь) инициализирует цвет и рост Horse(aColor, height), // конструктор класса Bird (Птица) инициализирует // цвет перьев и логическую переменную Bird(aColor, migrates), // инициализация переменной-члена itsNumberBelievers, // относящейся к классу Pegasus (Пегас) itsNumberBelievers (NumBelieve) { cout « "Pegasus constructor...\n"; } int main() { // создается указатель на экземпляр класса Pegasus (Пегас) Pegasus *pPeg = new Pegasus(Red, 5, true, 10); // созданный указатель на экземпляр класса Pegasus (Пегас) // используется для получения доступа к функциям-членам базовых // объектов pPeg->Fly(); pPeg->Whinny(); cout << "\nYour Pegasus is " « pPeg->GetHeight(); cout << " hands tall and "; if (pPeg->Ge^Migration()) cout « "it does migrate."; else cout « "it does not migrate."; cout « "\nA total of " « pPeg->GetNumberBelievers(); cout << " people believe it exists.\n"; delete pPeg; return 0; } 182 Глава 7
Вот результат: Horse constructor... Bird constructor... Pegasus constructor... I can fly! I can fly! I can fly! Whinny!... Your Pegasus is 5 hands tall and it does migrate. A total of 10 people believe it exists. Pegasus destructor... Bird destructor... Horse destructor... Обратите внимание, что конструкторы обоих классов Horse (Лошадь) и Bird (Птица) имеют два параметра и принимают перечисления цветов, с помощью которых в программе можно установить цвет лошади или цвет перьев у птицы. Поэтому при попытке установить цвет Пегаса может возникнуть проблема. Устранение коллизий имен Оба класса — Horse (Лошадь) и Bird (Птица) — имеют метод GetColor (). Что же произойдет, если мы захотим узнать цвет объекта Pegasus (Пегас)? Отгадайте, какой из двух унаследованных методов выберет компилятор? Ведь методы, объявленные в обоих базовых классах, имеют одинаковые имена и сигнатуры. Если написать вот так: COLOR currentcolor = pPeg->GetColor(); то компилятор обнаружит ошибку: error С2385: ambiguous access of 'GetColor* in 'Pegasus' (Неоднозначность доступа к 'GetColor' в 'Pegasus'). Как мы знаем, эту неоднозначность можно разрешить, явно указав имя необходи- мого базового класса перед именем функции-члена или переменной: COLOR currentcolor = pPeg->Horse::GetColor(); Если же в классе Pegasus (Пегас) данная функция будет замешена, то проблема решится сама собой, так как в этом случае вызывается функция-член класса Pegasus (Пегас). Определить ее можно, например, так: virtual COLOR GetColor()const { return Horse::GetColbr(); } Если же нужно будет вызвать метод базового класса, то это можно сделать, напри- мер, так: COLOR currentcolor = pPeg->Bird::GetColor(); Наследование от общего базового класса, или решение проблемы родственных браков С коллизией имен справиться оказалось несложно, но вот как разрешить проблему родственных браков? Что произойдет, если оба базовых класса сами являются произ- водными от одного и того же (базового для них) класса? В царстве животных, напри- мер, классы Bird (Птица) и Horse (Лошадь) могут быть производными от класса Animal (Животное). Что тогда случится с Пегасом? Тогда окажется, что оба класса, базовые для класса Pegasus (Пегас), сами являются производными от одного и того же (базового для них) класса Animal (Животное). Мо- жет быть, компилятор будет рассматривать классы Bird (Птица) и Horse (Лошадь) как производные от двух одноименных базовых классов? Это, как мы только что убедились, может привести к очередной коллизии имен. Пусть, например, в классе Animal (Животное) объявлены переменная-член itsAge и функция-член GetAgeO. Какая функция будет вызвана, если в программе записан вызов pGet->GetAge()? Та функция Get Аде (), которая была унаследована классом Bird (Птица) от класса Animal Наследование 183
(Животное) или та, которая была унаследована классом Horse (Лошадь) от того же ба- зового класса? Где же найти ответы на эти вопросы?! Давайте посмотрим программу. // Общий базовый класс #include <iostream.h> typedef int HANDS; enum COLOR { Red, Green, Blue, Yellow, White, Black, Brown } // объявление класса Animal (Животное) // класс Animal . (Животное) имеет переменную-член itsAge и два // метода - GetAgeO и Set Аде () // общий базовый класс для классов Horse и Bird class Animal { public: Animal(int); virtual -Animal() { cout « "Animal destructor...\n"; } virtual int GetAge() const { return itsAge; } virtual void SetAge(int age) { itsAge = age; } private: int itsAge; } ; Animal: .-Animal (int age) : itsAge(age) { cout « "Animal constructor...\n"; } // объявление класса Horse (Лошадь) производным от // класса Animal (Животное) class Horse : public Animal { public: Horse(COLOR color, HANDS height, int age); virtual -Horse() { cout « "Horse destructor...\n"; } virtual void Whinny()const { cout « "Whinny!... "; } virtual HANDS GetHeight() const { return itsHeight; } virtual COLOR GetColorO const { return itsColor; } protected: HANDS itsHeight; COLOR itsColor; } ; Horse::Horse(COLOR color, HANDS height, int age): // С помощью параметра age конструктор класса Horse (Лошадь) // инициализирует переменную itsAge, унаследованную классом // Horse (Лошадь) от класса Animal (Животное). Animal(age), // Затем инициализируются две переменные-члена // класса Horse (Лошадь) - itsColor и itsHeight. itsColor(color), itsHeight(height) { cout « "Horse constructor...\n"; } 184 Глава 7
// объявление класса Bird (Птица) производным от // класса Animal (Животное) class Bird : public Animal { public: Bird(COLOR color, bool migrates, int age); virtual ~Bird() { cout « "Bird destructor...\n"; } virtual void Chirp()const { cout « "Chirp... } virtual void Fly()const { cout « "I can fly! I can fly! I can fly! } virtual COLOR GetColor()const { return itsColor; } virtual bool GetMigration() const { return itsMigration; } protected : COLOR itsColor; bool itsMigration; }; Bird::Bird(COLOR color, bool migrates, int age): // параметр age используется для инициализации переменной-члена, // унаследованной классом Bird (Птица) от класса Animal (Животное). Animal(аде) , itsColor (color), itsMigration (migrates) { cout « "Bird constructor...\n"; } class Pegasus : public Horse, public Bird { public: void Chirp()const { Whinny(); } Pegasus(COLOR, HANDS, bool, long, int); virtual -Pegasus() { cout « "Pegasus destructor...\n"; } virtual long GetNumberBelievers( ) const { return itsNumberBelievers; } virtual COLOR GetColor()const { return Horse::itsColor; } virtual int GetAge() const { return Horse::GetAge(); } private: long itsNumberBelievers; }; // Конструктор класса Pegasus (Пегас) имеет пять параметров: цвет // крылатого коня, его рост (в футах); логическую переменную, // которая определяет, мигрирует сейчас это животное или мирно // пасется на пастбище; число людей, верящих в существование // Пегаса, и возраст животного. Pegasus::Pegasus( COLOR aColor, HANDS height, bool migrates, long NumBelieve, int age) : Наследование 185
// Конструктор класса Pegasus (Пегас) инициализирует переменные, // определенные в классе Horse (Лошадь): цвет, рост и возраст. Horse(aColor, height, age), // Конструктор класса Pegasus (Пегас) инициализирует переменные, // определенные в классе Bird (Птица): цвет, миграция и возраст. Bird(aColor, migrates, age), // инициализируется переменная itsNumberBelievers - член класса // Pegasus (Пегас). itsNumberBelievers (NumBelieve) { cout « "Pegasus constructor...\n"; } int main() { Pegasus *pPeg = new Pegasus(Red, 5, true, 10, 2); int age = pPeg->GetAge(); cout « "This pegasus is " « age « " years old.\n"; delete pPeg; return 0; } В программе содержится ряд интересных решений. Так, конструктор класса Horse (Лошадь) теперь имеет третий параметр аде, который передается в базовый класс Animal (Животное). В классе Horse (Лошадь) метод GetAge () не замещается, а про- сто наследуется. Обратите внимание, что нечто аналогичное происходит и в классе Bird (Птица). Конструктор класса Bird (Птица) также содержит параметр аде, с помощью которого инициализируется базовый класс Animal (Животное). Метод GetAge ( ) также насле- дуется этим классом без замещения. Класс Pegasus (Пегас) является производным от двух базовых классов Horse (Лошадь) и Bird (Птица), поэтому с исходным базовым классом Animal (Животное) он связан двумя линиями наследования. Чтобы для экземпляра класса Pegasus (Пегас) вызвать метод GetAge (), можно либо указать базовый класс этого метода, ли- бо заместить метод GetAge () в классе Pegasus (Пегас). В приведенной выше программе метод GetAge () замещается в классе Pegasus (Пегас) таким образом, что тело этого метода состоит из явного вызова одноименного метода базового класса Horse (Лошадь). Замещение функции путем записи в теле метода явного вызова метода базового класса позволяет решить две проблемы. Во-первых, преодолевается неопределенность обращения к базовым классам; во-вторых, функцию можно заместить таким образом, что в производном классе до (или после) вызова этой функции будут выполняться до- полнительные операции, которых не было в базовом классе. Причем если эти допол- нительные операции будут выполняться после вызова функции базового класса, то они могут использовать результаты, полученные функцией базового класса, а если до вызова, то они могут выполнить необходимую подготовку. Интересно отметить, что значение параметра цвета экземпляра класса Pegasus (Пегас) используется для инициализации переменных-членов обоих базовых классов, Bird (Птица) и Horse (Лошадь). Параметр аде также используется для инициализа- ции переменной itsAge, которая унаследована обоими этими классами от базового класса Animal (Животное). Теперь в виртуальном царстве животных мы нашли решение проблемы родствен- ных браков, и свидетельство тому — выдача: Animal constructor... Horse constructor... 186 Глава 7
Animal constructor... Bird constructor... Pegasus constructor... This pegasus is 2 years old. Pegasus destructor... Bird destructor... Animal destructor... Horse destructor... Animal destructor... Еще одно решение проблемы родственных браков: виртуальное наследование В предыдущей программе мы нашли решение проблемы родственных браков. Мы научились указывать, из какого из двух базовых классов класс Pegasus (Пегас) дол- жен унаследовать функцию Get Аде (). Однако в действительности ведь этот метод бе- рется из общего базового класса Animal (Животное). И в C++ можно указать, что мы имеем дело не с двумя одноименными классами, а с одним общим базовым классом. Такое наследование называется виртуальным. Для этого общий базовый класс (в нашей программе это Animal (Животное)) нужно объявить виртуальным базовым классом для его производных классов (в нашей программе это классы Horse (Лошадь) и Bird (Птица)). Общий базовый класс (в на- шей программе это Animal (Животное)) при этом не изменяется. В производных классах (классы Horse (Лошадь) и Bird (Птица)) изменения состоят лишь в том, что в их объявлении указывается виртуальность наследования от базового класса (в нашей программе это-класс Animal (Животное)). Зато существенно изменяется класс, кото- рый является производным от производных классов. В нашей программе в качестве такого класса выступает класс Pegasus (Пегас). Конструктор класса обычно инициализирует только собственные переменные и переменные-члены базового класса. Однако в случае виртуального наследования из этого правила делается исключение. Чтобы упростить формулировку нашего нового правила-исключения, назовем основным тот базовый класс, который является общим предком. Кроме того, для формулирования нашего нового правила-исключения удоб- но рассматривать не весь граф иерархии, а только тот его подграф, который получает- ся в результате отбрасывания всех вершин и дуг, не относящихся к рассматриваемому виртуальному наследованию. (Специалисты по теории графов сказали бы, что этот граф представляет собой ограничение графа иерархии на множество вершин, задейст- вованных в виртуальном наследовании.) Имея в виду этот граф, можем сформулиро- вать нужное нам правило-исключение: переменные основного базового класса инициа- лизируются конструкторами листьев (т.е. не следующих производных от него классов, а тех, которые являются последними в иерархии классов). В нашем царстве животных, например, класс Animal (Животное) инициализируется не конструкторами классов Horse (Лошадь) и Bird (Птица), а конструктором класса Pegasus (Пегас). Правда, конструкторы промежуточных классов (в нашей программе это классы Horse (Лошадь) и Bird (Птица)) также содержат команды инициализации базового класса (класс Animal (Животное)), но при создании экземпляра класса-листа (экземпляр класса Pegasus (Пегас)) эта инициализация перекрывается конструктором класса- листа. В приведенной ниже программе мы воспользуемся преимуществами виртуального наследования. // Виртуальное наследование tinclude <iostream.h> Наследование 187
typedef int HANDS; enum COLOR { Red, Green, Blue, Yellow, White, Black, Brown } / // общий базовый класс для двух производных классов Horse и Bird class Animal { public: Animal(int); virtual -Animal() { cout « "Animal destructor...\n"; } virtual int GetAge() const { return itsAge; } virtual void SetAge(int age) { itsAge = age; } private: int itsAge; } ; Animal::Animal(int age): itsAge(age) { cout « "Animal constructor...\n"; } // класс Horse (Лошадь) объявляется виртуальным производным // от класса Animal (Животное) class Horse : virtual public Animal { public: Horse(COLOR color, HANDS height, int age); virtual -Horse() { cout « "Horse destructor...\n"; } virtual void Whinny()const { cout « "Whinny!... "; } virtual HANDS GetHeight() const { return itsHeight; } virtual COLOR GetColor() const { return itsColor; } protected: HANDS itsHeight; COLOR itsColor; } ; Horse::Horse(COLOR color, HANDS height, int age): Animal(age), itsColor( color), itsHeight( height) { cout « "Horse constructor...\n"; } // класс Bird (Птица) объявляется виртуальным производным // от класса Animal (Животное) class Bird : virtual public Animal { public: Bird(COLOR color, bool migrates, int age); virtual -BirdO { cout « "Bird destructor...\n"; } virtual void ChirpOconst { cout « "Chirp... "; } virtual void Fly()const { cout « "I can fly! I can fly! I can fly! "; } virtual COLOR GetColor()const { return itsColor; } virtual bool GetMigration() const { return itsMigration; } 188 Глава 7
protected: COLOR itsColor; bool itsMigration; Bird: .-Bird(COLOR color, bool migrates, int age) : Animal (age) , itsColor (color), itsMigration (migrates) { cout « "Bird constructor...\n"; } class Pegasus : public Horse, public Bird { public: void Chirp () const { WhinnyO; } Pegasus(COLOR, HANDS, bool, long, int); virtual -Pegasus() { cout « "Pegasus destructor...\n";} virtual long GetNumberBelievers() const { return itsNumberBelievers; } virtual COLOR GetColor()const { return Horse::itsColor; } private: long itsNumberBelievers; }; Pegasus::Pegasus( COLOR aColor, HANDS height, bool migrates, long NumBelieve, int age) : Horse(aColor, height, age), Bird(aColor, migrates, age), Animal(age*2), itsNumberBelievers( NumBelieve) { cout « "Pegasus constructor...\n"; } int main() { Pegasus *pPeg = new Pegasus(Red, 5, true, 10, 2); int age = pPeg->GetAge(); cout « "This pegasus is " « age « " years old.Xn"; delete pPeg; return 0; } В данной программе конструкторы обоих классов Horse (Лошадь) и Bird (Птица) по-прежнему инициализируют класс Animal (Животное). Однако как только создает- ся объект Pegasus (Пегас), конструктор этого класса заново инициализирует класс Animal (Животное), заменяя прежние результаты инициализации. Убедиться в этом можно по выдаче: Animal constructor... Horse constructor... Bird constructor... Pegasus constructor... This pegasus is 4 years old. Наследование 189
Pegasus destructor... Bird destructor... Horse destructor... Animal destructor... При первой инициализации переменной itsAge присваивается значение 2, но конструктор класса Pegasus (Пегас) удваивает это значение. Поэтому выводится 4. Проблемы с неопределенностью наследования метода GetAge () в классе Pegasus (Пегас) больше не возникает, поскольку теперь этот метод наследуется непосредст- венно из класса Animal (Животное). В то же время для вызова метода GetColor () по-прежнему необходимо явно указывать базовый класс, так как этот метод объявлен в обоих классах, Horse (Лошадь) и Bird (Птица). Указание виртуального наследования при объявлении класса Чтобы быть уверенным, что класс-наследник унаследует только один экземпляр членов класса-предка, виртуальность наследования необходимо указать в объявлениях всех промежуточных классов. Рассмотрим классический пример: class Horse : virtual public Animal class Bird : virtual public Animal class Pegasus : public Horse, public Bird Как видите, здесь виртуальность наследования указана в объявлениях всех проме- жуточных классов (промежуточными классами здесь являются Horse (Лошадь) и Bird (Птица)). Вот еще один пример: class Schnauzer : virtual public Dog class Poodle : virtual public Dog class Schnoodle : public Schnauzer, public Poodle Здесь промежуточными классами являются Schnauzer и Poodle. Поэтому вирту- альность наследования нужно указать в объявлениях обоих этих классов. Проблемы с множественным наследованием Часто множественное наследование дает ряд преимуществ по сравнению с оди- ночным, однако некоторые программисты с неохотой используют его. Многие старые компиляторы C++ не поддерживают множественное наследование. Иногда все, что достигается с помощью множественного наследования, можно достичь и без него. В некоторых случаях множественное наследование чрезмерно усложняет программу, и его преимущества не оправдываются полученным результатом. Поэтому не используй- те множественное наследование, если можете обойтись одиночным наследованием. Однако если в классе-наследнике необходимо использовать данные и методы, объяв- ленные в разных классах, применение множественного наследования может оказаться оп- равданным. Чтобы как можно элегантнее разрешить коллизии, связанные с неопределен- ностью источника наследования метода или данных, применяйте виртуальное наследова- ние. Не забывайте в случае виртуального наследования инициализировать часть объекта, относящуюся к исходному базовому классу, конструктором класса, наиболее удаленного от исходного базового по иерархии классов. Польза множественного наследования для ООП блюстителями нравственной чистоты классов оспаривается. Они утверждают, что пользы от множественно- сти- го наследования нет никакой, что оно лишь усложняет понимание программ, их разработку и сопровождение. Те же, кто подобно Георгу Кантору, считает, что миф о непорочном зачатии только портит Евангелие, клянутся, что без множе- ственного наследования язык программирования выглядит незавершенным. 190 Глава 7
Как хорошо известно из биологии, половое размножение связано с рядом трудностей, но вместе с тем дает и неоспоримые преимущества виду. Так же обстоит дело и в программировании: с применением множественного насле- дования связано много проблем, но при правильном и корректном применении оно может принести огромную пользу. Смесь одиночного и множественного наследования: классы возможностей, классы-мандаты, или миксины Поскольку средства одиночного наследования не всегда являются достаточно мощными, а применение множественного наследования не всегда признается оправ- данным, были разработаны промежуточные формы наследования. Так, в языке SCOOPS впервые появились так называемые миксины (mixin), чаще называемые клас- сами-мандатами, или классами возможностей (capability class). (Вообще-то миксин — это название десерта, представляющего собой смесь пирожного с мороженым, поли- тую сверху шоколадной глазурью. Этот десерт продавался в супермаркетах Sommerville в штате Массачусетс.) Многие считают, что классы-мандаты — это разумное и вполне благопристойное расширение одиночного наследования, другие же указывают, что это попытка прикрыть динозавра множественного наследования крошечной иголкой с новогодней елки. Давайте разберемся, что же такое класс-мандат. Классом-мандатом называется класс, открывающий доступ к ряду методов, но не содержащий никаких данных (или, по крайней мере, содержащий минимальный на- бор данных). Так, класс Horse (Лошадь) можно объявить производным от двух базо- вых классов — Animal (Животное) и Displayable (Отображаемый объект), причем последний добавляет только некоторые методы отображения объектов на экране. Методы класса-мандата передаются в производные классы с помощью обычного наследования. Единственное отличие классов-мандатов от других классов состоит в том, что они практически не содержат никаких данных. Но что значит “практически не содержат никаких данных"? Ведь восприятие этого выражения довольно субъектив- ное. Чаще всего это интерпретируют так: добавление в классы функциональных воз- можностей не должно усложнять программу. Однако классы-мандаты часто очень по- лезны, так как помогают разрешить неопределенности, связанные с использованием в производном классе данных, унаследованных от других базовых классов. Пусть, например, класс Horse (Лошадь) является производным от двух классов — Animal (Животное) и Displayable (Отображаемый объект), причем последний до- бавляет только новые методы, но не содержит данных. Тогда все наследуемые данные класса Horse (Лошадь) происходят только от одного базового класса Animal (Живот- ное), а методы наследуются от обоих классов. Механика наследования НАСЛЕДСТВЕННОСТЬ — свойство организмов повторять в ряду поколений сходные типы обмена веществ и индивидуаль- ного развития в целом. Обеспечивается самовоспроизведением материальных единиц наследственности — генов, локализован- ных в специфических структурах ядра клетки (хромосомах) и цитоплазмы. Вместе с изменчивостью наследственности обес- печивает постоянство и многообразие форм жизни и лежит в основе эволюции живой природы. Словарь терминов Наследование 191
Производный класс наследует реализацию, поведения и свойства базового класса. Это значит, что все методы и свойства родительского интерфейса будут переданы в ин- терфейс потомка. Методы и свойства производного класса можно разбить на три типа: подмененные, или перегруженные: производный класс не просто наследует метод или свойство родительского класса, но и дает ему новое определение; новые: производный класс прибавляет совершенно новый метод или свойство; рекурсивные: новый класс просто наследует метод или свойство родитель- ского класса. Большинство объектно-ориентированных языков программирования не позво- ляют подменить свойство. Подмененные методы и свойства Наследование позволяет переопределить уже существующий метод или свойст- во. Переопределение метода позволяет изменить поведение объекта при вызове данного метода. Подмененный метод или свойство присутствует и в родительском, и в наследст- венном классе. Подмена метода также называется переопределением метода. Потомственный класс просто имеет собственный вариант реализации метода. Новая реализация обеспечива- ет новое поведение метода. Подмена, или переопределение — это способ изменить поведение метода, сущест- вующего в родительском классе. Откуда же объект знает, какое именно определение нужно использовать? Ответ на этот вопрос зависит от реализации объектно-ориентированного языка. Большинство объектно-ориентированных систем сначала ищут определение в том объекте, которому передается сообщение. Если там определения найти не удается, то поиск поднимается по иерархии, пока не найдется какое-то определение. Именно так происходит обработка сообщений и именно благодаря этому и работает процесс под- мены. Определение, найденное в потомке, как раз и будет вызвано потому, что оно найдено первым. Этот же механизм используется для рекурсивных методов и свойств. Если не принимать во внимание использование, то потомственный класс мо- жет подменить не все методы и свойства. Ведь в большинстве объектно-ориенти- рованных языков программирования предусмотрено несколько средств управле- ния доступом. Ключевые слова, предназначенные для контроля доступа, опреде- ляют, позволено ли просматривать методы и свойства и пользоваться ими. Как вы знаете, существуют три уровня доступа: private (частный): уровень доступа, ограничивающий доступ пределами класса. Иными словами, использование члена класса, специфицированного как private, ограничено классом, в котором он определен, а также друзьями (friend) этого класса; protected (защищенный): уровень доступа, ограничивающий доступ пределами класса и его потомками. Иными словами, использование члена класса, специ- фицированного как protected, ограничено классом, в котором он определен, а также друзьями (friend) этого класса и классами, производными от данного; public (общедоступный, публичный или открытый): уровень доступа, который ничего не ограничивает. Иными словами, член класса, специфицированный как public, доступен для всеобщего использования; он может быть использован любым пользователем класса. 192 Глава 7
К защищенным методам или свойствам доступ могут получить только подклассы. Если доступ к методам или свойствам необходим лишь для подклассов, то не делайте такие методы общедоступными. Все свойства, не являющиеся константами, а также все методы, предназначен- ные только для класса, следует сделать частными. Частный уровень доступа за- прещает вызывать метод всем объектам, за исключением того, в котором этот ме- тод определен. Не делайте без необходимости защищенными методы, которые должны быть частными. Специфицируйте как защищенные только те методы, о которых заранее известно, что ими будут пользоваться подклассы. В остальных случаях указывайте частный или общедоступный уровень доступа. Конечно, это довольно жесткие правила. Поэтому вполне возможно, что позднее придется про- смотреть программу и изменить уровни доступа к методам. Однако такие правила полезны потому, что позволяют создать более надежную программу, чем в случае открытия доступа ко всему, включая и подклассы. С точки зрения наследования наиболее важными являются защищенные и частные ме- тоды и свойства. Пересмотр и изменение уровня доступа может оказаться плохой приметой. И все же, в иерархии наследования не может быть случайностей. Вне сомнений, иерархия должна строиться так же естественно, как и программа. Нет ничего постыдного в том, что иногда иерархию приходится разлагать на элементар- ные части и воссоздавать снова. Настоящее ООП представляет собой итера- тивную технологию. Правило, согласно которому уровень доступа ко всем объектам должен быть частным, является эмпирическим. В некоторых случаях это правило не прине- сет пользы — все зависит от того, что вы программируете. Предположим, вы разрабатываете библиотеку параметризованных классов, причем в комплект поставки исходный код включаться не будет. (Параметризованный класс часто называется также родовым классом, полиморфным классом, шаблонным, классом и шаблоном. Это класс, имеющий по крайней мере один формальный параметр-тип. Такой класс порождает обычный класс только в результате за- дания значений всех параметров-типов.) Тогда, вероятнее всего, по умолча- нию следует установить уровень защиты “защищенный”, чтобы клиенты могли использовать наследование для расширения классов. В некоторых языках ООП даже предусмотрен протокол наследования. Новые методы и свойства Новым методом (или свойством) называется метод (или свойство), который появляется в потомственном классе, но не существует в родительском. Потомст- венный класс прибавляет в свой интерфейс новый метод или свойство. В интер- фейс потомственного класса можно добавить новую функцию путем прибавления новых методов и свойств. Рекурсивные методы и свойства Рекурсивные методы и свойства определяются в родительском классе или другом классе-предке, но не определяются в потомственном классе. Чтобы получить доступ к нужному методу или свойству, сообщение поднимается по наследственной иерархии до тех пор, пока не найдет определение метода. Здесь используется тот же механизм, что и при подмене методов и свойств. Подмененные методы также могут стать рекурсивными. Несмотря на то, что подмененный метод появляется в потомственном классе, в большинстве объект- Наследование 193
но-ориентированных языков программирования предусмотрен механизм, с помо- щью которого подмененные методы могут вызывать родительскую (или какого-то другого предка) версию метода. Эта возможность позволяет поднять версию над- класса во время определения нового поведения подкласса. В C++ для этого мож- но использовать оператор расширения видимости. В языке программирования Java ключевое слово super дает доступ к реализации родительского класса. Если же в языке программирования не предусмотрен механизм, который по- зволяет подмененным методам вызывать родительскую (или какого-то другого предка) версию метода, необходимо особо тщательно следить за правильностью инициализации унаследованных кодов. Ведь неправильная ссылка на унаследованный класс может стать неуловимым источником ошибок. Типы наследования Наследственность — свойство родителей передавать свои при- знаки потомкам, следующему поколению. Это свойство не аб- солютно, дети никогда не бывают точными копиями родите- лей, но кошка всегда приносит на свет котят, а из семян пше- ницы вырастает только пшеница. Энциклопедический словарь юного би олога Вообще говоря, наследование применяют в трех главных случаях: • для многократного использования реализации; • для отличия; • для подмены типов. Сразу замечу, что некоторые типы многократного использования употреблять предпочтительнее, чем другие. Рассмотрим подробно применение каждого типа. Наследование реализации Наследование позволяет новому классу многократно использовать реализацию старого класса. Вместо копирования и вставки кода или создания экземпляра класса, наследование делает код автоматически доступным, т.е. доступ к нему осуществляется так, как если бы он был частью нового класса. Магия наследова- ния состоит в том, что новый класс рождается вместе с функциями. Однако выбирать родителей нужно с большой осторожностью. Обязательно взвесь- те все “за” и “против” многократного использования реализации. Впрочем, класс- наследник может переопределить защищенные методы с целью изменения реализа- ции. Подмена может уменьшить влияние плохой или неподходящей реализации. Проблемы наследования реализации Возможно, вам показалось, что наследование реализации не имеет недостатков. Тем не менее, это не так: если что-то при поверхностной проверке кажется полезным техниче- ским приемом, оно может быть опасно при практическом применении. На самом деле на- следование реализации — слабейшая форма наследования и обычно ее нужно избегать. Многократное использование кажется простым, но реализуется оно очень большой ценой. Чтобы понять недостатки, разберемся с типами. Когда один класс наследует дру- гой, он автоматически принимает тип базового класса. Так что до разработки иерар- хии классов обязательно следует правильно определить наследование типов. 194 Глава 7
Рассмотрим пример с очередью (класс Queue) и ее итератором (класс Iterator). Когда итератор Iterator объявляется наследником Queue (Очередь), он сам стано- вится Queue (Очередью). Это значит, что с классом iterator можно обходиться так, как если бы он имел тот же тип, что и класс Queue (Очередь). А поскольку класс Iterator стал также типом Queue (Очередь), то он получил и все функции, которые были представлены в Queue (Очередь). Это значит, что методы, подобные enqueue () и dequeue () также стали частью общедоступного интерфейса класса iterator. Хотя с первого взгляда это не представляет никакой проблемы, именно здесь со- держится подвох. Дело в том, что в итераторе должно быть определено всего-навсего два метода. Один из них предназначен для выдачи элемента, а другой — для проверки того, остались ли в итераторе какие-либо элементы. По определению, к итератору прибавить элемент невозможно; но в Queue (Очередь) определен метод enqueue () именно на этот случай. Кроме того, вы не можете выбрать элемент, оставляя его в то же время внутри итератора. А ведь опять же, в Queue (Очередь) определен метод реек () именно для этого случая. Теперь понятно, что использовать Queue (Очередь) при наследовании в качестве базового класса для итератора невыгодно, потому что при этом проявляются поведения, которые не должны принадлежать итератору. В некоторых языках программирования классы могут просто наследовать реа- лизацию без наследования типа. Если в языке программирования такой вид наследования разрешается, то проблемы, указанные в примере с итератором очереди, не возникнут. Однако в большинстве языков нельзя разделить ин- терфейс и реализацию при наследовании (правда, в некоторых языках это разделение делается автоматически). Но есть языки (к ним относится и C++), в которых разделение поддерживается, но должно быть указано в явном виде при программировании класса. Здесь кроется опасность: о требовании разде- ления реализации и типа легко забыть, поэтому будьте очень осторожны в та- ких случаях. Программы с ‘‘плохой наследственностью” представляют собой “монстр Фран- кенштейна”, сконструированный из частей, которые не подходят одна к другой. Если применение наследования имеет только одну цель — многократное использование реализации, то в результате может получиться именно такой монстр. Результаты ген- ной инженерии необходимо тщательно планировать. Наследование для отличия Программирование отличий позволяет специфицировать только отличия между классом-потомком и его родительским классом. (Под программированием отличий подразумевается, что к классу-наследнику добавляются только коды, которые делают новый класс отличным от наследуемого класса.) Программирование отличий — чрезвычайно мощное средство. Оно позволяет ограничиться только прибавлением некоторого кода для описания различий меж- ду родительским классом и классом-потомком. Программировать можно пошаго- вым методом. Небольшой объем кодирования и повышенная управляемость кода облегчают разработку проекта. А поскольку приходится писать меньше строчек кода, чем при других методологиях программирования, то в соответствии с теорией, умень- шается количество добавляемых ошибок. Поэтому такое пошаговое программиро- вание позволяет написать намного более качественный код, притом быстрее, чем обычно. Как и при наследовании реализации, пошаговые изменения можно де- лать без изменения ранее написанного кода. Наследование 195
Наследование предоставляет два способа программирования отличий: добавле- ние новых методов и свойств, а также переопределение старых методов и свойств. Оба эти способа называются специализацией. Давайте присмотримся к специали- зации внимательнее. Специализация Специализация — это способ определения производного класса, при котором ука- зываются его отличия от родительского класса. Таким образом, в результате специа- лизации производный класс содержит в себе только те элементы, которые отличают его от родительского класса. Класс-потомок является специализацией базового класса, если в его интерфейсе определены новые методы или свойства либо переопределены свойства и методы ба- зового класса. В результате добавления новых методов или переопределения свойств и методов базового класса класс-потомок может вести себя иначе, чем класс-родитель. Специализация позволяет только прибавлять или переопределять поведения и свойства, унаследованные от класса-родителя. Специализация не позволяет удалять у класса-потомка унаследованные поведения и свойства. Стало быть, в результате спе- циализации в производном классе нельзя реализовать избирательное наследование. В некотором смысле понятия обобщения и специализации противоположны. Чем выше степень обобщения, тем больше классов могут считаться специализацией дан- ного класса. А чем выше степень специализации, тем меньше классов удовлетворяют всем критериям, которым должны удовлетворять классы, которые могут быть отнесе- ны к данному уровню специализации. Специализация — это не ограничение функциональности, это ограничение типов, рассматриваемых как множества значений. Наследование для подмены типов Последний вид наследования — наследование для подмены типов. Подмена типов оз- начает подмену отношений. Что же это такое — подмена отношений? Потомок находится в отношении является (“ Is-a”) с родителем. Поэтому классу- потомку можно посылать те же сообщения, что и классу-родителю. Так что с клас- сом-потомком можно обращаться так, как если бы он подменял класс-родитель. (Именно поэтому нельзя удалять поведения при создании класса-потомка.) И как раз такая подмена позволяет прибавлять к программе подтипы. Если в программе используется предок, то можно использовать и потомки. Ведь отношения предков можно перенести на потомков. Поэтому везде, где используется тип, можно ис- пользовать и подтип. (Подтип — это расширение другого типа с помощью наслед- ственности.) Подмена отношений позволяет только подниматься по иерархии наследова- ния. Опускаться же по ней нельзя. Иными словами, отношения потомков нель- зя перенести на предков. Нельзя, например, вместо потомка передать предка. Зато вместо какого-нибудь объекта можно передать любого его наследника. Возможность подмены облегчает многократное использование кода. Например, в контейнере для хранения экземпляров определенного класса можно хранить и эк- земпляры любого наследника этого класса. Возможность замены позволяет также писать родовой код. Вместо того, чтобы иметь множество операторов выбора или проверок if-else для определения подтипа, можно просто предполагать, что объ- ект имеет нужный тип. 196 Гпава 7
Технология применения наследования Боги выполнили неправедные молитвы Эдипа для того, чтобы жестоко покарать его за них. Он молил о том, чтобы дети его силою оружия решили между собою спор о наследовании его престола, и имел несчастье быть пойманным на слове. Не о том следует просить, чтобы все шло по нашему желанию, а о том, чтобы все шло согласно требованиям разума. Мишель Монтень. Опыты. Том 1 Наследование следует применять обдуманно. В противном случае вы рискуете за- путаться окончательно. Приведенные далее советы помогут эффективно использовать наследование. Как правило, наследование применяется для многократного использования интер- фейса, а также для подмены отношений. Наследование можно также применять для расширения реализации, но только если новый класс является подтипом старого. Для многократного использования реализации старайтесь применять формирова- ние, а не наследование. Наследование можно применять только в случае, если получающаяся иерархия классов будет согласована с концептуальной моделью. Не стоит применять наследование ради интенсивного многократного использо- вания реализации. Всегда проверяйте, является ли новый класс подтипом старого. Подходящая иерархия наследования не образуется сама по себе. В процессе разра- ботки программы она может изменяться. Когда такое случается, приходится дораба- тывать код. Но не позволяйте хвосту вилять собакой. Иногда приходится осознанно принимать решение переработать иерархию, в этом случае обязательно следуйте опре- деленным принципам. Помните, что неглубокая иерархия предпочтительнее, чем глубокая. Тщательно проектируйте иерархию наследования, причем унифицированные эле- менты вынесите в абстрактные базовые классы. Абстрактные базовые классы по- зволяют определить метод без реализации. Поскольку в базовых классах реали- зация не специфицирована, то нельзя создать их экземпляры. (Иными словами, из абстрактного класса нельзя напрямую получить объект.) В то же время меха- низм абстракции требует, чтобы класс-потомок представил реализацию. Абст- рактные классы чрезвычайно полезны для планирования иерархии — они помо- гают разработчику понять, что именно нужно реализовать. Если язык программирования не имеет нужных механизмов абстракции, соз- дайте пустые методы (заглушки, куклы, пустышки, подыгрывающие функции) и задокументируйте тот факт, что в подклассах эти методы должны быть реали- зованы полностью. Классы часто используют код совместно. Нет смысла иметь множество копий кода. Общий код нужно переместить и изолировать его в отдельном классе- родителе, но не нужно перемещать код по иерархии слишком высоко. Размес- тите его на уровне, предшествующем тому, на котором он понадобится. Не пытайтесь заранее спланировать окончательную иерархию. Перед написани- ем кода трудно определить, какие элементы удастся унифицировать. Обычно унифицированные элементы выявляются тогда, когда придется несколько раз написать один и тот же код. Обнаружив унифицированный элемент, доработайте Наследование 197
ваши классы. Такая доработка часто называется переразложением на элементар- ные операции (refactoring). Помните, Что инкапсуляция играет такую же роль в отношениях между классами родителей и потомков, как и между неродственными классами. Нужно быть очень ос- торожным с инкапсуляцией, когда применяется наследование. На практике важна правильность определения интерфейса не только для классов, связанных отношением родитель-потомок, но и для неродственных классов. Ни в коем случае не разрушайте ин- капсуляцию при наследовании. Дальнейшие советы помогут выполнить это требование. Тщательно определяйте интерфейс между классами родителей и потомков, а также между неродственными классами. Защищайте прибавляемые методы подклассов, чтобы использовать их могли только подклассы. Старайтесь избегать открытия внутренней реализации классов. Если вы нарушите это правило, то может возникнуть зависимость подклассов от реализации, а это в свою очередь повлечет за собой все проблемы, связанные с нарушением инкапсу- ляции. В заключение приведем еще несколько советов по эффективному применению насле- дования. Главной целью является подмена типов. Даже если интуиция вам подсказывает, что объект “хорошо вписывается” в иерархию, это еще не значит, что именно так и должно быть на самом деле. Если интуиция подсказывает, что что-то можно сделать, и вы видите, что вы можете это сделать, то это еще не значит, что вы должны это сделать. Программируйте отличия, чтобы сохранить управляемость кодом. Для многократного использования реализации всегда отдавайте предпочтение фор- мированию, а не наследованию. Ведь, вообще говоря, классы, из которых сфор- мирован новый класс, изменить легче. Абстрактные классы и методы Обсуждая технологию применения наследования, мы убедились, что некоторые классы удобно сделать абстрактными. Теперь обсудим эту концепцию подробнее. Концепция абстрактных классов Давайте познакомимся с понятием абстрактного класса на примере из мира жи- вотных. Наблюдая разные особи теплокровных и живородящих, вы можете заключить, что они все являются млекопитающими. На Земле огромное количество млекопи- тающих: кошачьи, псовые, мартышки, люди. Однако нет такого млекопитающего, ко- торое бы не было какой-либо специализацией понятия “млекопитающее”, т.е. такого млекопитающего, которое бы не обладало никакими дополнительными свойствами. Другими словами, в классе млекопитающих нет особей, не являющихся специализа- цией понятия “млекопитающее”. Млекопитающее — это абстрактное понятие. И не- смотря на отсутствие реализации абстрактного млекопитающего в чистом виде, это абстрактное понятие доказало свою полезность в концептуальной модели фауны, по- строенной современной биологией. Точно так же и в программировании могут ис- пользоваться концептуальные модели, в которых важную роль играют абстрактные понятия. Абстрактным понятиям концептуальных моделей соответствуют абстрактные классы. 198 Гпава 7
Абстрактные классы отличаются от обычных тем, что напрямую создать экземпляр абстрактного класса нельзя, так как в абстрактном классе некоторые методы остаются неопределенными. Определение абстрактного класса Абстрактный класс — это такой класс, который можно реализовать только через подклассы. Конкретный класс — тот, который не является абстрактным. Но как же отличить абстрактный класс от конкретного? Оказывается, в программе, как и в жизни, у абстрактного класса есть нечто абстрактное, пока не реализованное. Это “нечто” называется абстрактным методом. Описанный, но нереализованный метод называется абстрактным методом. Абст- рактные методы могут быть только у абстрактных классов. Именно наличие абстракт- ных методов является необходимым и достаточным признаком абстрактного класса. Таким образом, можно сказать, что абстрактные классы — это те классы, среди мето- дов которых есть абстрактные. Но как отличить абстрактный метод от обычного? Дело в том, что абстрактные ме- тоды в C++ (и многих других языках программирования) реализуются в виде чисто виртуальных функций. Чисто виртуальная функция — это функция-член без тела функции. Чисто виртуальные функции часто называются также чистыми виртуальными функциями. Однако по очень давней традиции, сложившейся еще в конце 50-х годов прошлого столетия, в программировании чистая функция означает ре- ентерабельная функция. Таким образом, абстрактный класс — это класс с одной или несколькими чисто виртуальными функциями. Создание потомков абстрактного класса Подкласс абстрактного класса остается абстрактным, пока в нем не переопределе- ны все чисто виртуальные функции. Хотя создать экземпляр абстрактного класса нельзя, можно создать экземпляры потомков абстрактного класса. Ну, а те потомки, которые станут конкретными классами, смогут стать причастными к бытию — из них можно создавать объекты. Таким образом абстрактные методы предков будут реализо- ваны потомками. Когда-то (по меркам компьютерной индустрии давным-давно) требовалось, чтобы каждая чисто виртуальная функция была переопределена в каждом подклассе другой, хотя бы и снова чисто виртуальной функцией. Конечно, из- за этого возникало огромное количество писанины, которая, хотя и делалась с помощью ножниц и клея, но все же портила концепцию наследования. Однако в конечном счете здравый смысл победил и это глупое требование отменили. Тем не менее старые компиляторы могут требовать выполнения этого условия. Передача абстрактных классов Поскольку создать экземпляр абстрактного класса нельзя, возможность создавать указатели на абстрактные классы на первый взгляд выглядит несколько странно. За- чем бы это понадобились такие указатели? Да и на что же они реально могут указы- вать? Конечно, на объект. Но ведь объекта как раз и нет... Однако нужно помнить, что основной целью наследования классов является подмена типов, и потомки могут Наследование 199
замешать предков (это, конечно, можно рассматривать как проявление полиморфиз- ма). А поскольку потомки могут быть конкретными классами, то из таких потомков можно создавать объекты. Так что все это не так уж глупо, как кажется поначалу. Предположим, что Account - абстрактный класс, a Checking и Savings — его конкретные потомки. Тогда можно написать следующий фрагмент кода: // это допустимо void fn(Account *pAccount); void otherFn() { Savings s; Checking c; fn(£s); // Savings ЯВЛЯЕТСЯ Account fn(£c); // Checking тоже ЯВЛЯЕТСЯ Account } В этом примере pAccount объявлен как указатель на абстрактный класс Account. Разумеется, при вызове функции ей будет передаваться адрес какого-то экземпляра конкретного класса, например Checking или Savings. Более того, все объекты, полу- ченные функцией f п (), будут экземплярами конкретного класса (например Checking, Savings или другого конкретного подкласса абстрактного класса Account). Можно с уверенностью заявить, что вы никогда не передадите этой функции экземпляр класса Account, поскольку никогда не сможете создать экземпляр данного класса. Ведь та- кие объекты просто не могут существовать! Зачем объявлять чисто виртуальные функции Чтобы класс считался абстрактным, в нем достаточно объявить одну чисто виртуальную функцию. Объявление функции состоит из ее имени и списка аргументов, заключенного в скобки. Не много. Но все же стоит ли заниматься этой писаниной для всех функций, если достаточно объявить только одну? Ведь остальные можно объявить (и определить) в клас- сах-потомках, причем это даже упростит класс-предок — он будет изящнее! Во многих случаях именно так и делают, но лучше таким примерам не подражать. Продемонстрируем негативные последствия такого упрощения на примере. Пред- положим, что Account - абстрактный класс, a Checking и Savings — его конкрет- ные потомки. Предположим также, что с целью упрощения в классе Account опуше- но объявление виртуальной функции withdrawal О. Тогда нельзя написать следую- щий фрагмент кода: class Account { // абстрактный класс, в котором опущено // объявление виртуальной функции withdrawal() }; class Savings : public Account { public: virtual void withdrawal(float amnt); }; void fn(Account *pAcc) { pAcc->withdrawal(100.OOf); // снять некоторую сумму // этот вызов НЕДОПУСТИМ, поскольку withdrawal () // не является членом класса Account } int main() { Savings s; // открыть счет 200 Глава 7
fn(&s); // продолжение программы } Действительно, можно создать (открыть) сберегательный счет s. Затем его адрес можно передать функции fn (), которая в качестве аргумента может принять адрес, указывающий на любой объект-потомок класса Account (так как его тип является подтипом типа Account). Программист, который кодировал эту функцию, знал, что у всех конкретных подклассов класса Account, передаваемых этой функции в качестве аргумента, будет метод withdrawal О, и потому записал его вызов. Но компилятор об этом не знает, и когда он обнаружит вызов функции withdrawal (), то сгенериру- ет сообщение об ошибке. Это случится именно потому, что функция withdrawal О теперь не является членом класса Account — она ведь там не объявлена! Правда, в некоторых языках проверка того, что функция определена, осуществля- ется во время выполнения программы. В таком случае приведенный выше фрагмент кода будет работать следующим образом: main() вызовет fn() и передаст ей объ- ект s. Когда fn(), в свою очередь, вызовет функцию withdrawal (), программа уви- дит, что withdrawal () действительно определена в переданном ей объекте. Цена та- кой гибкости — ошибки во время выполнения программы. Например, если передать объект, который является потомком класса Account, но для него функция withdrawal () не определена, то программа аварийно прекратит работу, если не смо- жет определить, что делать с вызовом этой функции. Конечно, этого допустить нель- зя, ошибку следует перехватить, поскольку ни банк, ни его клиенты не должны по- страдать. Следовательно, в исполняемой программе должен быть код перехвата ис- ключения, а это — накладные расходы во время выполнения программы! А объявление чисто виртуальной функции помогает решить эту проблему еще на этапе компиляции: class Account { // абстрактный класс, причем на этот раз в нем имеется // объявление виртуальной функции withdrawal() virtual void withdrawal(float amnt) = 0; }; class Savings : public Account { public: virtual void withdrawal(float amnt); }; void fn(Account *pAcc) { // снять некоторую сумму // теперь этот код будет работать рАсс->withdrawal(100.00f); } int main() { Savings s; // открыть счет fn(&s); // продолжение программы } Теперь в классе Account объявлена чисто виртуальная функция-член withdrawal (), и ее вызов законен, поскольку для каждого объекта, передаваемого этой функции, определен метод withdrawal (). Почему? Да хотя бы потому, что вы даже не сможете создать объект, для которого эта функция не определена. (Ведь лю- Наследование 201
бой потомок будет абстрактным классом, пока в нем есть хотя бы одна чисто вирту- альная функция.) Никакого кода перехвата исключений! Итак, если описать абстрактные методы, то подклассы базового класса необходимо будет специализировать, добавляя реализацию абстрактных методов. Делая базовый класс абстрактным и создавая абстрактные методы, вы заранее указываете, что будет переопределено в подклассе. С помощью абстрактных классов определяется контракт, условиям которого долж- ны удовлетворять подклассы базового класса. Разработчик, взглянув на абстрактный базовый класс, может сразу точно определить, что нужно специализировать при соз- дании наследников. Можно специализировать не только абстрактные методы, но и другие элементы. Если у базового класса много методов, может быть довольно сложно выяснить, ка- кие из них нужно переопределить. Абстрактные классы дают подсказку — в этом и состоит их предназначение. Пример использования абстрактных базовых классов Не всегда применение наследования, даже если классы находятся в отношении яв- ляется (Is-a), столь просто, как это может показаться на первый взгляд. Предполо- жим, например, что вы разрабатываете графическую программу, которая, как предпо- лагается, рисует, кроме всего прочего, окружности и эллипсы. Окружность — частный случай (специализация) эллипса; это эллипс, длины осей которого равны. Следова- тельно, все окружности являются эллипсами, и это обстоятельство приводит к мысли объявить класс Circle (Окружность) производным от класса Ellipse (Эллипс). Но при такой реализации возникнут проблемы, так как класс Ellipse (Эллипс) должен включать члены данных, которые не нужны для окружности. Пусть, например, этот класс включает координаты центра эллипса, главную полуось (половину длинного диаметра), малую полуось (половину короткого диаметра) и угол ориентации, т.е. угол между горизонтальной осью координат и главной полуосью эллипса. Класс также мог бы включать методы для перемещения эллипса, для вычисления площади эллипса, для вращения эллипса и для изменения длин главной и малой полуосей: class Ellipse { private: double x; // координата x центра эллипса double у; // координата у центра эллипса double а; // главная полуось double b ; // малая полуось double angle; // угол ориентации в градусах // ... public: // ... void Move(double nx, double ny) { x = nx; у = ny; } virtual double Area() const { return 3.14159 * a * b; } virtual void Rotate(double nang) { angle = nang; } virtual void Scale(double sa, double sb) { a *= sa; b *= sb; } 202 Глава 7
// ... } Теперь попробуем сделать класс circle (Окружность) производным от класса Ellipse (Эллипс): class Circle : public Ellipse { // . .. } Хотя окружность — это эллипс, полученный класс на самом деле для наших целей не подходит. Например, для описания размера и формы окружности требуется только единственное значение — радиус вместо двух — главной полуоси (а) и малой полуоси (Ь). Конечно, конструктор Circle (Окружность) может присвоить одно и то же зна- чение главной полуоси (а) и малой полуоси (Ь), но тогда одна и та же информация будет представлена дважды. Параметр angle и метод Rotate О для окружности во- обще не нужны, а метод Scale () может превращать окружность в эллипс, присваивая двум осям разные значения. Можно попытаться поправить положение дел с помощью таких приемов, как помещение переопределенного метода Rotate () в приватном разделе класса Circle (Окружность), чтобы Rotate() нельзя было использовать для окружности. Но в целом, похоже, лучше определить класс circle (Окружность), не используя наследование: class Circle { private: // наследование не применяется double х; // координата х центра окружности double у; // координата у центра окружности double г; // радиус // ... public: void Move(int nx, ny) { x = nx; у =ny; } double Area() const { return 3.14159 * r * r; } void Scale(double sr) { r *= sr; } // ... } Теперь класс содержит только те элементы, в которых он нуждается. Все же это решение также не кажется самым лучшим. Ведь классы Ellipse (Эллипс) и Circle (Окружность) имеют много общего, а при раздельном их определении общие свойства не используются. Приведем другое решение, при котором общие свойства классов Ellipse (Эллипс) и Circle (Окружность) помешаются в абстрактный базовый класс (BaseEllipse (БазовыйЭллипс)). Затем следует определить классы Circle (Окружность) и Ellipse (Эллипс) как производные от класса BaseEllipse (БазовыйЭллипс). После этого, например, можно использовать указатели на базовый класс для управления экземпля- рами классов Ellipse (Эллипс) и Circle (Окружность). (Здесь, конечно, использует- ся полиморфизм.) В данном случае общими для обоих классов являются координаты центра кривой второго порядка. Метод Move () работает одинаково для обоих классов, а метод Area () — по-разному. Действительно, метод Area () даже не может быть реа- лизован для BaseEllipse (БазовыйЭллипс), поскольку он не имеет необходимых членов данных. В C++ нереализованную функцию следует объявить чисто виртуаль- ной. Для объявления чисто виртуальной функции в конце ее объявления следует до- писать последовательность символов = 0, как показано ниже: Наследование 203
class BaseEllipse {// абстрактный базовый класс private: double x; // координата x центра double у ; // координата у центра // ... public: BaseEllipse (double xO = 0, double yO = 0) : x(x0), y(y0) { } virtual -BaseEllipse() { } void Move(double nx, double ny) { x = nx; у = ny; } virtual double Area() const = 0; // чисто виртуальная функция // ... } Как вы уже знаете, если объявление класса содержит чисто виртуальную функцию, нельзя создать экземпляр этого класса: классы с чисто виртуальными функциями су- ществуют исключительно для того, чтобы служить базовыми классами. Теперь «можно объявить классы Ellipse (Эллипс) и Circle (Окружность) произ- водными от класса BaseEllipse (БазовыйЭллипс) и добавить в них недостающие члены. Класс Circle (Окружность) всегда представляет окружности, а класс Ellipse (Эллипс) — эллипсы, которые могут оказаться окружностями. Однако у окружности класса Ellipse (Эллипс) можно изменить полуоси и она может стать эллипсом, а ок- ружность класса Circle (Окружность) всегда будет оставаться окружностью. В нашей графической программе можно создавать объекты Ellipse (Эллипс) и Circle (Окружность), но не объекты BaseEllipse (БазовыйЭллипс). Поскольку классы Circle (Окружность) и Ellipse (Эллипс) имеют один и тот же базовый класс, экземпляры классов могут адресоваться указателями на BaseEllipse (БазовыйЭллипс). Мы видим, что в ходе проектирования наследственной иерархии для нашего гра- фического приложения пришлось создать абстрактный базовый класс BaseEllipse (БазовыйЭллипс). Этот класс содержит чисто виртуальную функцию Area (). Для реа- лизации же этой функции в конкретном производном классе применяются обычные виртуальные функции. Виртуальные методы Наследование неразрывно связано с виртуальными функциями, которые в свою очередь связаны с полиморфизмом. Полиморфизм позволяет, например, объявить множество классов окон разных типов, включая диалоговые, прокручиваемые окна и поля списков, а затем создавать в программе сами окна с помощью единственного виртуального метода draw(). Создав указатель на базовое окно и присвоив этому ука- зателю адреса экземпляров производных классов, можно обращаться к методу draw () независимо от того, с каким из объектов в данный момент связан указатель. Ведь всегда будет вызываться вариант метода, предназначенный для класса выбранного объекта. Это, конечно, полиморфизм в действии. Вот как можно использовать полиморфизм в царстве животных. Как вы уже знае- те, экземпляры класса Dog (Собака) одновременно являются экземплярами класса Mammal (Млекопитающее). Это означает, что объекты класса Dog (Собака) наследуют все атрибуты (данные) и возможности (методы) базового класса. Но в языке C++ для полиморфизма такая иерархическая связь классов означает нечто большее. 204 Глава 7
Полиморфизм в C++ используется столь широко, что допускается присвоение ука- зателям на базовый класс адресов объектов производных классов, как в следующем примере: Mammal* pMammal = new Dog; Этот оператор создает в динамической области памяти новый экземпляр класса Dog (Собака), причем указатель на этот экземпляр присваивается переменной, кото- рая является указателем на класс Mammal (Млекопитающее). (Это вполне логично, так как собака — представитель млекопитающих.) Создайный указатель можно использо- вать для вызова любого метода класса Mammal (Млекопитающее), причем если метод был переопределен для производного класса, скажем, для класса Dog (Собака), то при обращении к методу через указатель будет вызываться именно версия производного класса — так работают виртуальные функции. Давайте теперь на практическом при- мере проследим, как работает виртуальная функция и что происходит с обычной (невиртуальной) функцией. // Использование виртуальных методов #include <iostream.h> class Mammal { public: Mammal():itsAge(1) { cout « "Mammal constructor... \n"; } virtual -Mammal() { cout « "Mammal destructor...\n"; } void Move() const { cout « "Mammal move one step\n"; } // объявляется виртуальный метод SpeakO класса Mammal (Млекопитающее). // Предполагается, что данный класс может быть базовым для других // классов. Вероятно также, что данная функция может быть // переопределена в производных классах. virtual void SpeakO const { cout « "Mammal speak! \n"; } protected: int itsAge; } ; class Dog : public Mammal { public: Dog() { cout « "Dog Constructor...\n"; } virtual -Dog() { cout « "Dog destructor...\n"; } void WagTail() { cout « "Wagging Tail...\n"; } void Speak() const { cout « "Woof’Xn"; } void Move О const { cout « "Dog moves 5 steps...\n"; } } ; int main() { // создается указатель pDog на класс Mammal, но ему присваивается // адрес нового экземпляра производного класса Dog (Собака). // Поскольку собака является млекопитающим, это вполне логично. Mammal *pDog = new Dog; pDog->Move(); pDog->Speak(); return 0; } Вот результат: Наследование 205
Mammal constructor... Dog Constructor. . . Mammal move one step Woof! Из этой распечатки (и текста программы) мы видим, что в программе создается указатель pDog на класс Mammal, но ему присваивается адрес нового экземпляра про- изводного класса Dog (Собака). Поскольку собака является млекопитающим, это вполне логично. Созданный указатель затем используется для вызова функции Move (). Поскольку pDog известен компилятору как указатель класса Mammal (Млекопитающее), результат получается таким же, как при обычном вызове метода Move () из экземпляра класса Mammal (Млекопитающее). Затем с помощью указателя pDog вызывается метод Speak (). Но метод Speak () объявлен как виртуальный, и поэтому вызывается версия функции Speak (), переоп- ределенная в классе Dog (Собака)! Это волшебство! Ведь компилятор знает, что указатель pDog принадлежит классу Mammal (Млекопитающее), а происходит вызов версии функции, объявленной в дру- гом (производном) классе! Получается, что можно создать массив указателей на эк- земпляры базового класса, каждый из которых будет указывать на экземпляр своего производного класса, но с помощью указателей из данного массива можно вызывать версию переопределенного метода! Это показано в приведенной ниже несложной программе, где сначала определяется четыре класса — Dog (Собака), Cat (Кошка), Horse (Лошадь) и Pig (Свинья), которые являются производными от базового класса Mammal (Млекопитающее). Виртуальная функция Speak () класса Mammal (Млекопи- тающее) замещается во всех производных классах. Затем пользователю предоставляет- ся возможность выбрать объект любого производного класса, после чего создается эк- земпляр выбранного класса и указатель на вновь созданный объект добавляется в мас- сив указателей на класс Mammal (Млекопитающее). // Вызов виртуальных функций с помощью указателей на экземпляр // базового класса, каждый из которых указывает на экземпляр своего // производного класса #include <iostream.h> class Mammal { public: Mammal():itsAge(1) { } virtual -Mammal() { } // объявляется виртуальная функция Speak() // класса Mammal (Млекопитающее) virtual void Speak() const { cout « "Mammal speak!\n"; } protected: int itsAge; } ; class Dog : public Mammal { // Виртуальная функция Speak() класса Mammal (Млекопитающее) // замещается в производном классе. void Speak()const { cout « "WoofIXn"; } } ; class Cat : public Mammal { public: 206 Глава 7
// Виртуальная функция Speak () класса Mammal (Млекопитающее) // замещается в производном классе. void Speak()const { cout "Meow!\n"; } } ; class Horse : public Mammal { public: // Виртуальная функция Speak() класса Mammal (Млекопитающее) // замещается в производном классе. void Speak()const { cout « "Whinnie!\n"; } } ; class Pig : public Mammal { public: // Виртуальная функция Speak() класса Mammal (Млекопитающее) // замещается в производном классе. void Speak()const { cout « "Oink!\n"; } } ; int main() { Mammal* theArray[5]; Mammal* ptr; int choice, i; // пользователю предоставляется возможность выбрать объект любого // производного класса, а затем создается экземпляр выбранного // класса и указатель на вновь созданный объект добавляется в массив // указателей на класс Mammal (Млекопитающее). for ( i = 0; i<5; i++) { cout « "(l)dog (2)cat (3)horse (4)pig: cin >> choice; switch (choice) { case 1: ptr = new Dog; break; case 2: ptr = new Cat; break; case 3: ptr = new Horse; break; case 4: ptr = new Pig; break; default: ptr = new Mammal; break; } theArrayfi] = ptr; } for (i=0;i<5;i++) theArray[i]->Speak(); return 0; } Вот результат диалога: (l)dog (2)cat (3)horse (4)pig: 1 (l)dog (2)cat (3)horse (4)pig: 2 (l)dog (2)cat (3)horse (4)pig: 3 (l)dog (2)cat (3)horse (4)pig: 4 (l)dog (2)cat (3)horse (4)pig: 5 Наследование 207
Woof ! Meow! Whinny! Oink! Mammal speak! Хотя во время компиляции неизвестно, какой класс выберет пользователь и какая именно версия метода SpeakO будет вызвана, как видно из распечатки, программа выполняется правильно, потому что указатель связывается со своим объектом только во время выполнения программы. Такое связывание указателя с объектом называется динамическим, отложенным или поздним, в отличие от статического связывания, про- исходящего во время компиляции программы. Предположим, функция-член была объявлена как виртуальная в базовом классе, следует ли повторно указывать виртуальность при объявлении этой функции в производном классе? Нет. Если метод уже был объявлен как вирту- альный, то он будет оставаться таким, несмотря на замещение его в произ- водном классе. Однако для облегчения чтения программы имеет смысл (но не требуется) и в производных классах продолжать указывать виртуальность данного метода с помощью ключевого слова virtual. Как работают виртуальные функции При создании экземпляра производного класса сначала вызывается конструктор базового, а затем — производного класса. Можно считать, что экземпляр производ- ного класса состоит из двух частей, причем в памяти эти части прилегают одна к другой. Одна из этих частей создается конструктором базового класса, а другая — конструктором производного класса. Если в классе определена виртуальная функция, то большинство компиляторов создает таблицу виртуальных функций, называемую также v-таблицей. Такие таблицы создаются для типа данных, причем каждый экземпляр такого класса содержит указа- тель на таблицу виртуальных функций для своего класса (типа). Этот указатель назы- вается vptr или v-указателем. Детали связывания виртуальных функций, конечно, могут отличаться в разных компиляторах, но сами виртуальные функции работают совершенно одинаково. Можно считать, что каждый объект имеет указатель vptr, который адресует таблицу виртуальных функций, содержащую указатели на все виртуальные функции. Сначала указатель vptr для экземпляра производного класса инициализируется при создании той части объекта, которая принадлежит базовому классу. Однако после вызова кон- структора производного класса указатель vptr изменяется, теперь он будет указывать на таблицу, в которой хранятся адреса замещающих версий виртуальных функций (если таковые имеются). Поэтому-то и получается, что хотя используется указатель на базовый класс, указатель vptr адресует ту таблицу виртуальных функций, которая со- ответствует реальному типу объекта. Следовательно, при вызове виртуального метода выполняется та функция, которая определена в производном классе. Нельзя брать там, находясь здесь Предположим, у экземпляра производного класса имеется метод F, который не принадлежит базовому классу. Тогда с помощью указателя на базовый класс этот ме- тод вызвать невозможно, если, конечно, этот указатель не преобразовать явно в указа- тель на производный класс. Если функция F не является виртуальной и не принадле- жит базовому классу, то доступ к ней можно получить только из экземпляра произ- водного класса или с помощью указателя на производный класс. Поскольку любые 208 Глава 7
преобразования типа чреваты ошибками, в C++ допускаются только явные преобра- зования типов. Всегда можно преобразовать любой указатель на базовый класс в ука- затель на производный класс, но увлекаться такими преобразованиями не следует, так как есть более надежные и безопасные способы вызова метода F. (В этих способах используется множественное наследование или шаблоны.) Отсечение, или дробление объекта Теперь проверим, проявляется ли магия виртуальных функций, если передать объ- ект как значение. Для этого определим классы Mammal (Млекопитающее), Dog (Соба- ка) и Cat (Кошка). Затем объявим три функции: PtrFunction(), RefFunction() и ValueFunction(). В качестве параметра эти функции будут иметь соответственно указатель на класс Mammal (Млекопитающее), ссылку на класс Mammal (Млекопитаю- щее) и экземпляр класса Mammal (Млекопитающее). Все они выполняют одно и то же действие — вызывают метод Speak(). В главной программе предложим пользователю выбрать объект класса Dog (Собака) или класса Cat (Кошка), после чего создается указатель соответствующего типа. Вот нужная нам программа: // Отсечение, или дробление объекта при передаче его как значения #include <iostream.h> class Mammal { public: Mammal() :itsAge(1) { } virtual -Mammal() { } virtual void Speak() const { cout « "Mammal speak! \n"; } protected: int itsAge; class Dog : public Mammal { public: void Speak()const { cout « "Woof!\n"; } }; class Cat : public Mammal { public: void Speak()const { cout « "Meow!\n"; } }; void ValueFunction (Mammal); void PtrFunction (Mammal*); void RefFunction (Mamma1&); int main() { Mammal* ptr=0; int choice; while (1) { bool fQuit = false; cout « "(l)dog (2)cat (O)Quit: "; cin » choice; switch (choice) { case 0: fQuit = true; break; case 1 : ptr = new Dog; break; Наследование 209
case 2: ptr = new Cat; break; default: ptr = new Mammal; break; } if (fQuit) break; PtrFunction(ptr) ; RefFunction(*ptr); ValueFunction(*ptr); } return 0; void ValueFunction (Mammal MammalValue) { MammalValue.Speak(); } void PtrFunction (Mammal * pMammal) { pMammal->Speak(); } void RefFunction (Mammal & rMammal) { rMammal.Speak(); } Попробуем провести сеанс с программой. Первый раз выберем класс Dog (Собака). Программа создаст экземпляр этого класса в свободной области памяти. Затем экзем- пляр класса Dog (Собака) будет передан в три функции с помощью указателя, с по- мощью ссылки и как значение. (l)dog (2)cat (O)Quit: 1 Если адрес объекта в функцию передается с помощью указателя или ссылки, вы- полняется функция-член Dog->Speak (). В распечатку дважды выводится сообщение, соответствующее выбранному нами экземпляру класса: Woof ! Woof ! Разыменованный же указатель передает объект как значение. В этом случае функ- ция ожидает экземпляр класса Mammal (Млекопитающее) и компилятор видит только ту часть объекта, которая была создана конструктором класса Mammal (Млекопи- тающее). Экземпляр класса Dog (Собака) как бы раздроблен — часть, построенная конструктором производного класса, не видна. В таком случае вызывается версия ме- тода Speak(), которая была объявлена для класса Mammal (Млекопитающее): Mammal speak! Те же действия и с тем же результатом были выполнены затем и для экземпляра класса Cat (Кошка): (l)dog (2)cat (O)Quit: 2 Meow! Meow! Mammal speak! (l)dog (2)cat (0>Quit: 0 210 Глава 7
Магия виртуальных функций проявляется только при обращении к ним с по- мощью указателей и ссылок. Если передать объект как значение, то виртуаль- ную функцию вызвать не удастся. Виртуальные деструкторы Как вы уже знаете, указатель на объект производного класса на практике часто пе- редается туда, где ожидается указатель на экземпляр базового класса. Это вполне до- пустимо. Но что же произойдет при удалении экземпляра производного класса с по- мощью такого указателя? Если деструктор объявлен как виртуальный, то будет вызван деструктор производного класса, а затем деструктор производного класса автоматиче- ски вызовет деструктор базового класса, и объект будет удален целиком. Поэтому все действия будут выполнены правильно. Объект не будет раздроблен. Легко догадаться, что если в классе объявлены виртуальные функции, то планируется (во всяком случае допускается) передача указателя на объект производного класса туда, где ожидается указатель на экземпляр базового класса. Отсюда следует правило: если в классе объявлены виртуальные функции, то и дест- руктор должен быть виртуальным. Виртуальный конструктор-копировщик Как легко догадаться, конструкторы не могут быть виртуальными, поэтому не мо- жет быть и виртуального конструктора-копировщика. Однако иногда требуется пере- дать указатель на экземпляр базового класса и с его помощью скопировать экземпляр производного класса. Для этого в базовом классе можно создать виртуальный метод Clone (). Метод clone () должен создавать и возвращать копию экземпляра произ- водного класса. Почему же метод Clone () может делать это? Дело в том, что в производных клас- сах метод Clone () замещается, так что в результате его вызова создаются копии эк- земпляра производного класса. В приведенной ниже программе показано, как опреде- лить такой метод. Для этого в классе Mammal (Млекопитающее) объявлен виртуаль- ный метод Clone (). Этот метод вызывает конструктор-копировщик, передавая ему в качестве параметра *this (разыменованный указатель на себя). Иными словами, этот метод вызывает конструктор-копировщик, передавая ему в качестве параметра себя, точнее, свой объект. Данный метод возвращает полученный указатель на новый эк- земпляр класса Mammal (Млекопитающее). Метод Clone () замещается в обоих производных классах — Dog (Собака) и Cat (Кошка). Экземпляры этих классов, инициализируя свои данные, передают самих се- бя для копирования в свои собственные конструкторы-копировщики (т.е. в конструк- торы копий производных классов). Поскольку функция clone () является виртуаль- ной, то в результате будут созданы виртуальные конструкторы-копировщики. Программа работает так: сначала пользователю предлагается выбрать объект класса Dog (Собака), Cat (Кошка) или Mammal (Млекопитающее). Затем создается объект выбранного типа и указатель на новый объект добавляется в массив. После этого вы- полняется цикл, в котором для каждого объекта массива вызываются методы Speak () и Clone (). В результате выполнения функции возвращается указатель на копию объ- екта, которая сохраняется в другом массиве. // Виртуальный конструктор-копировщик #include <iostream.h> class Mammal { Наследование 211
public: Mamma l ( ) : itsAge(1 ) { cout « "Mammal constructor... \n"; } virtual -Mammal() { cout « "Mammal destructor...\n"; } Mammal (const Mammal & rhs); virtual void Speak() const { cout « "Mammal speak!\n"; } virtual Mammal* Clone() { return new Mammal(*this); } int GetAge()const { return itsAge; } protected : int itsAge; }; Mammal :: Mammal (const Mammal & rhs) : itsAge(rhs.GetAge()) { cout « "Mammal Copy Constructor..An"; class Dog : public Mammal { public: Dog() { cout « "Dog constructor...\n"; } virtual ~Dog() { cout « "Dog destructor...\n"; } Dog (const Dog & rhs); void Speak()const { cout « "Woof’Xn"; } virtual Mammal* Clone() { return new Dog(*this); } }; Dog :: Dog (const Dog & rhs): Mammal(rhs) { cout « "Dog copy constructor...\n"; } class Cat : public Mammal { public: Cat() { cout « "Cat constructor...\n"; } ~Cat() { cout « "Cat destructor...\n"; } Cat (const Cat &); void Speak()const { cout « "MeowAn"; } virtual Mammal* Clone() { return new Cat(*this); } }; Cat::Cat(const Cat & rhs) : Mammal(rhs) { cout « "Cat copy constructor...\n"; } enum ANIMALS { MAMMAL, DOG, CAT}; const int NumAnimalTypes = 3; int main() { Mammal *theArray [NumAnimalTypes]; Mammal* ptr; int choice, i; // создание объекта выбранного типа for ( i = 0; i<NumAnimalTypes; i++) { cout « "(l)dog (2)cat (3)Mammal: "; cin » choice; switch (choice) { 212 Глава 7
case DOG: ptr = new Dog; break; case CAT: ptr = new Cat; break; default: ptr = new Mammal; break; } // указатель на новый объект добавляется в массив данных theArray[i] = ptr; } Mammal *OtherArray[NumAnimalTypes]; for (i=0; i<NumAnimalTypes; i++) { // в цикле для каждого объекта массива // вызываются методы SpeakO и Clone () theArray[i]->Speak(); // создание виртуальных конструкторов копий: // здесь возвращается указатель на копию объекта, // которая сохраняется в массиве OtherArray[i] = theArray[i]->Clone() ; } for (i=0; i<NumAnimalTypes; i++) OtherArray[i]->Speak(); return 0; } Разберем выдачу, полученную в результате сессии с данной программой. Сначала пользователь выбрал опцию 1 — решил создать экземпляр класса Dog (Собака): (l)dog (2)cat (3)Mammal: 1 Для создания этого объекта были вызваны конструкторы базового и производного классов: Mammal constructor... Dog constructor... Затем все это повторилось для экземпляров классов Cat (Кошка) и Mammal (Млекопитающее): (l)dog (2)cat (3)Mammal: 2 Mammal constructor... Cat constructor... (l)dog (2)cat (3)Mammal: 3 Mammal constructor... / После этого был выполнен метод Speak () для экземпляра класса Dog (Собака). Поскольку функция Speak () объявлена как виртуальная, то была вызвана ее версия, определенная в производном классе: Woof ! Затем следует обращение к виртуальной функции clone (). Поскольку она вирту- альная, то при вызове из экземпляра класса Dog (Собака) запускаются конструктор класса Mammal (Млекопитающее) и конструктор копий класса Dog (Собака): Mammal Copy Constructor... Dog copy constructor... To же самое повторяется для экземпляра класса Cat (Кошка): Meow! Mammal Copy Constructor... Cat copy constructor... ...и для экземпляра класса Mammal (Млекопитающее): Наследование 213
Mammal speak! Mammal Copy Constructor... В результате создается массив объектов, для каждого из которых вызывается своя версия функции Speak (): Woof ! Meow! Mammal speak! Теперь, когда мы научились применять виртуальные функции, может возникнуть вопрос: какова же цена виртуальности методов? Цена виртуальности методов Поскольку для объектов с виртуальными методами необходима v-таблица, то при- менение виртуальных функций может потребовать дополнительного объема памяти и привести к снижению быстродействия программы. Поэтому если известно, что дан- ный небольшой класс не является базовым для других классов, то в нем нет никакого смысла объявлять виртуальные методы. Объявив виртуальный метод, платить приходится не только за v-таблицу (учитывая объемы ОЗУ современных ПК она занимает не так уж много места), но и за создание виртуального деструктора. Поэтому следует подумать, имеет ли смысл преобразовы- вать методы программы в виртуальные, а если да, то какие именно. Используйте виртуальные методы только в том случае, если программа со- держит базовый и производные классы. Если в классе есть виртуальные ме- тоды, сделайте деструктор виртуальным. Виртуальных конструкторов не существует, не пытайтесь их создать. Как с помощью наследования достичь целей объектно-ориентированного подхода? Если вы хотите что-то изменить в ребенке, сначала задумай- тесь — не лучше ли будет измениться самому. Карл Густав Юнг. Интеграция личности Наследование позволяет разрабатывать программное обеспечение, которое: • естественно; • надежно; • может использоваться повторно; • удобно в сопровождении; • пригодно к усовершенствованию; • позволяет периодически выпускать (издавать) новые версии. Попытаемся разобраться, как достичь этих свойств с помощью наследования. Естественность. Наследование позволяет создавать более естественные модели мира. Оно позволяет моделировать сложные иерархические отношения между классами. Ведь людям свойственно стремление группировать и систематизиро- 214 Гпава 7
вать объекты, из которых состоит концептуальная модель. С помощью наследо- вания эти отношения из концептуальной модели переносятся в программы. Наследование также позволяет программистам избежать повторной работы. Надежность. Наследование помогает создавать надежный код. Наследование сокращает и упрощает программы. При программировании отли- чий присоединяется лишь код, который описывает различия между классами родителей и потомков. А уменьшение объема кодирования приводит к умень- шению количества ошибок. Наследование позволяет многократно использовать тщательно протестированный и проверенный временем код как базу для создания нового класса. Конечно, лучше многократно использовать проверенный код, а не писать новый. Наконец, сам по себе механизм наследования очень надежен. Поскольку этот ме- ханизм встроен в язык, программисту не приходится конструировать свой соб- ственный механизм наследования, а потом убеждаться, что и другие разработ- чики применяют его. Впрочем, само наследование не является совершенным. При наследовании можно неумышленно разрушить инкапсуляцию, поэтому применяйте наследование с большой осторожностью. Многократное использование. Наследование содействует многократному исполь- зованию. Сама природа наследования способствует использованию старых классов при конструировании новых. Иногда наследование позволяет много- кратно применять классы так, как человек, который писал класс, никогда и представить не мог! При переопределении или программировании отличий можно изменять поведение существующих классов и использовать их для дос- тижения новых целей. Удобство сопровождения. Наследование облегчает сопровождение. Многократ- ное использование протестированного кода уменьшает вероятность ошибки в •новом коде. А после исправления ошибки в некотором классе все его подклас- сы будут автоматически использовать исправленный код. Вместо того чтобы за- стревать в разборке кода и непосредственном прибавлении к нему новых воз- можностей, программист может при помощи наследования взять уже сущест- вующий код в качестве основы для создания нового класса. Все методы, свойства и информация о типах становятся частью нового класса. Это выгоднее копирова- ния и вставки, так как нужно сопровождать только одну копию. Благодаря насле- дованию уменьшается общий объем сопровождаемого кода. При непосредст- венном же изменении существующего кода можно повредить базовый класс, а также повлиять на части системы, которые используют этот класс. Возможность усовершенствования. Наследование позволяет расширять возмож- ности класса и специализировать его. Программирование отличий и наследова- ние с целью подмены типов способствуют расширению классов. Удобство периодического выпуска (издания) новых версий. Наследование предос- тавляет возможность писать такой код, который позволяет своевременно, с за- данной периодичностью выпускать (издавать) новые версии. Ведь многократное использование сокращает время разработки. А программирование отличий по- могает сократить время, необходимое для написания класса. Подмена типов позволяет прибавлять новые возможности без значительного изменения уже существующего кода. Наследование также облегчает проверку, поскольку проверять нужно только но- вые функции и все их взаимодействия со старыми функциями. Наследование 215
Резюме В объектно-ориентированном программировании особое внимание уделяется мо- делированию двух отношений: отношения использует (use) между объектами и отно- шения является (Is-a) между классами. Каждое отношение представляет собой форму многократного использования. Простое создание экземпляра и его использование часто ограничивают гибкость класса. При простом многократном использовании невозможно расширить возможно- сти класса. Простое создание экземпляра подобно копированию и вставке. Наследо- вание позволяет преодолеть эти недостатки путем применения встроенных механиз- мов защиты и многократного использования реализации. Наследование реализации отличается от копирования и вставки тем, что имеется только одна копия кода. Но как бы то ни было, наследование реализации ради мно- гократного использования кода является признаком близорукости при разработке проектов. Программирование отличий позволяет применять пошаговый метод, а также по- зволяет указывать, чем создаваемые классы отличаются от базовых. Иными словами, программируются только свойства, отличающие производный класс от родителя. Наконец, наследование с целью подмены типов позволяет программировать пара- метрически. Ведь подклассы можно подставлять вместо базового класса, причем код для этого изменять не нужно. Это свойство обеспечивает необходимую гибкость про- граммы для удовлетворения требованиям, которые могут возникнуть в будущем. Технически наследование позволяет создавать производные классы на основе базо- вых классов. Благодаря наследованию в производные классы передаются все открытые и защищенные данные и функции базового класса. Защищенные данные базового класса открыты для всех производных классов, но закрыты для всех других классов программы. Но даже производные классы не могут получить доступ к закрытым дан- ным и функциям базового класса. Для инициализации объектов вызываются конструкторы, причем сначала вызыва- ются конструкторы предков, а затем — потомков. При этом конструкторам базового класса можно передать параметры. Функции, объявленные в базовом классе, можно переопределить в производных классах. При этом виртуальные функции производного класса можно вызвать с по- мощью указателя на экземпляр базового класса или ссылки. Существует также явный вызов метода базового класса, когда сначала указывается имя базового класса с двумя символами двоеточия после него. Например, если класс Derive — производный от класса Base, то метод F базового класса можно явно вы- звать так: Base : : F (). Если в классе есть виртуальные методы, то деструктор также следует объявить вир- туальным. Это позволит гарантировать удаление той части объекта, которая построена конструктором производного класса, даже если удаление объекта выполняется с по- мощью указателя на экземпляр базового класса. Виртуальных конструкторов не суще- ствует, тем не менее можно создать виртуальный конструктор копий. Задачи и упражнения 1. Наследование применяется по разным причинам. Являются ли эти причины взаимоисключающими, или же встречаются случаи, когда они проявляются вместе? Например, можно ли применить наследование для реализации, приме- няя его одновременно ради программирования отличий? 216 Глава 7
2. Является ли многократное использование главной причиной применения объ- ектно-ориентированного программирования? 3. Какие недостатки имеет простое многократное использование? 4. Что такое наследование? 5. Назовите виды наследования. 6. В чем состоит опасность наследования? 7. Что такое программирование отличий? 8. Какие три типа методов и свойств может иметь класс-наследник? 9. В чем состоит выгода от применения программирования отличий? 10. Что такое наследование для подмены типов? И. Как наследование может разрушить инкапсуляцию? Как применить инкапсуля- цию при наследовании? 12. Наследуются ли данные и функции-члены базового класса в последующие по- коления производных классов? Скажем, если класс Dog (Собака) является про- изводным от класса Mammal (Млекопитающее), а класс Animals (Животные) является базовым для класса Mammal (Млекопитающее), то унаследует ли класс Dog (Собака) данные и функции класса Animals (Животные)? 13. Предположим, класс Dog (Собака) является производным от класса Mammal (Млекопитающее), а класс Animals (Животные) является базовым для класса Mammal (Млекопитающее), причем в классе Mammal (Млекопитающее) переоп- ределена функция, описанная в классе Animals (Животные). Какой вариант функции получит класс Dog (Собака)? 14. Можно ли в производном классе сделать закрытой (private) функцию, кото- рая в базовом классе является общедоступной (public)? 15. Следует ли все функции класса делать виртуальными? 16. Предположим, что некоторая функция без параметров описана в базовом клас- се как виртуальная, а затем перегружена таким образом, что принимает один или два целочисленных параметра. Затем в производном классе был переопре- делен вариант функции с одним целочисленным параметром. Что произойдет, если с помощью указателя на экземпляр производного класса вызвать версию функции с двумя параметрами? 17. Зачем нужны v-таблицы и что в них хранится? 18. Какой деструктор может быть объявлен виртуальным? Зачем некоторые дест- рукторы объявляются виртуальными? 19. Как объявить виртуальный конструктор? 20. Как создать виртуальный конструктор-копировщик? 21. Может ли экземпляр производного класса вызвать функцию базового класса, если в производном классе эта функция была замещена? 22. Предположим, в базовом классе функция объявлена как виртуальная, а в про- изводном классе виртуальность функции не указана. Будет ли функция вирту- альной в потомках производного класса? 23. Доступны ли для функций-членов производных классов защищенные (ключе- вое слово protected) члены? 24. Приведите пример объявления виртуальной функции Func, которая в качестве параметра принимает одно целочисленное значение и имеет тип результата void. Наследование 217
25. Приведите пример объявления класса Square (квадрат), производного от класса Rectangle (прямоугольник), который, в свою очередь, является производным от класса Shape (форма). 26. Пусть класс Square (квадрат) является производным от класса Rectangle (прямоугольник), который, в свою очередь, является производным от класса Shape (форма). Предположим, что конструктор класса Shape (форма) не имеет параметров, конструктор класса Rectangle (прямоугольник) принимает два па- раметра (length и width), а конструктор класса Square (квадрат) — один па- раметр (length). Напишите пример конструктора класса Square (квадрат). 27. Приведите пример виртуального конструктора копий для класса Square. 28. Что означает перенос функций вверх по иерархии классов? 29. В каких случаях имеет смысл перенос функций вверх по иерархии классов? В каких случаях такой перенос нецелесообразен? 30. Что плохого в приведении типа объектов? 31. В каких случаях деструкторы объявляются виртуальными? 4- 4- О 218 Глава 7
Глава 8 Полиморфизм В этой главе... Понятие полиморфизма 220 Полиморфизм полиморфизма: формы полиморфизма 222 Технология применения полиморфизма 226 Полиморфизм и проверка условий 227 Ошибки и ловушки при использовании полиморфизма 230 Реализация полиморфизма 231 Как с помощью полиморфизма достичь целей объектно-ориентированного подхода 242 Резюме 243 Задачи и упражнения 244 Существует одно обстоятельство, связанное с индивидуальны- ми различиями и крайне загадочное; я разумею существование так называемых “многообразных", или “полиморфных", родов, в которых виды представляют необычный объем вариации. От- носительно большинства этих форм едва ли два натуралиста сойдутся во мнении, признать ли их как виды или как разно- видности... В большинстве полиморфных родов некоторые виды имеют фиксированные и определенные признаки. Роды, поли- морфные в одной стране, за малыми исключениями полиморф- ны и в других странах; то же применимо... и к организмам предшествовавших эпох. Эти факты крайне загадочны... Чарлз Дарвин. О происхождении видов путем естественного отбора Что это — белые птицы на темном фоне или черные — на свет- лом? Знать этого нельзя, но можно вообразить и птиц, и оба го- рода, и весь мир. И когда я понимаю это, я думаю: пусть наш глаз несовершенен, пусть наша рука немощна, но зато нам дано нечто большее: дар фантазии. Зодчий из мультипликационного познавательного фильма “Букет из сада геометрии ” Как вы убедились, объектно-ориентированное программирование немыслимо без инкапсуляции и наследования. А поскольку основной целью наследования является подмена типов, т.е. замена предков потомками, то, как вы уже, наверное, догадались, невозможно правильно применять наследование без учета полиморфизма. Конечно, с помощью инкапсуляции можно создать модульные компоненты программного обес- печения, а с помощью наследования можно повторно использовать и расширять эти компоненты. Однако работа над программой не заканчивается с началом ее продаж,
так как постоянно изменяются требования к программному обеспечению. Обычно это связано с потребностью расширить функциональные возможности программ, с обна- ружением ошибок или необходимостью переноса программы в новую среду. Про- грамма должна быть такой, чтобы ее можно было легко изменить в соответствии с но- выми требованиями. И это нужно учитывать еще на этапе проектирования. И поэтому программа должна проектироваться так, чтобы ее было легко приспо- сабливать к вновь возникающим требованиям. Иными словами, она должна проекти- роваться так, чтобы ее можно было легко изменять, и чтобы прибавлять к ней новые функции также было нетрудно. Вы уже убедились, что правильное применение ин- капсуляции и наследования должно быть направлено именно на достижение этих це- лей, достичь которых без полиморфизма невозможно. Вы уже не раз встречали это понятие ранее, а обсуждение наследования просто невозможно без него. Поскольку именно с помощью полиморфизма значительно упрощается адаптация программы к изменяющимся требованиям, он заслуживает самого пристального внимания. Сначала вы ознакомились с инкапсуляцией — объектно-ориентированной ха- рактеристикой модульности. Затем вы узнали, что воспользоваться преиму- ществами инкапсуляции позволяет наследование. А для наследования, как вы сами убедились в предыдущей главе, нужен полиморфизм. Таким образом, в этой книге полиморфизм следует после наследования. В большинстве других (но не всех!) книг порядок изложения такой же, и вы можете подумать, что это единственно возможный логический порядок введения этих понятий. Совсем нет. Инкапсуляция идет впереди лишь для того, чтобы подчеркнуть преемст- венность идеи модульности — понятия, которое начинающие программисты подробно изучают задолго до знакомства с ООП. Что касается наследования, то обычно это понятие предшествует понятию полиморфизма для того, чтобы побыстрее избавить читателя от необходимости копировать и вставлять фрагменты программ. Полиморфизм же в этой последовательности появляет- ся как необходимое следствие наследования. Но вполне возможен и другой порядок, в котором сначала для облегчения модификации программ вводится полиморфизм. Тогда в ходе изложения становится понятно, что без наследо- вания не может быть и полноценного полиморфизма. Вспомните: все три ба- зовые понятия ООП лежат в основе фундамента, из которого нельзя вынуть ни одного камня! Итак, сейчас мы приступаем к основательному изучению полиморфизма. Мы обсудим: понятие полиморфизма; типы полиморфизма; технологию применения полиморфизма; распространенные ошибки при использовании полиморфизма; способы достижения целей объектно-ориентированного подхода с помощью полиморфизма. Понятие полиморфизма Для влюбленных сено пахнет иначе, чем для лошадей. Станислав Ежи Лец (Stanislaw Jerzy Lee). Непричесанные мысли Полиморфизм — это столь же универсальное, радикальное средство, как инкапсу- ляция и наследование, эти три понятия неразрывно взаимосвязаны, так что без поли- морфизма ООП не будет эффективным. Не овладев мастерством полиморфизма, нельзя эффективно применять ООП. 220 Глава 8
Термин полиморфизм означает “много форм”, или “многоформенность”. В языках программирования он означает, что одно имя класса или метода может представлять различный, выбранный автоматическим механизмом код. Таким образом, одно и то же имя может принимать много форм и, так как оно может представлять различный код, одно и то же имя может обозначать различные поведения (методы). Встречается ли полиморфизм в жизни? Несомненно. Это загадочное понятие являет- ся предметом пристального внимания биологов. Если хотите, не менее пристально его изучают и астрономы — вспомните о квазарах, пульсарах, звездах с переменной свети- мостью. Встречается полиморфизм и в лингвистике, и в повседневной жизни. Слова, которые выражают поведение, обычно являются глаголами. Однако, что, например, можно сказать о таком обыденном глаголе, как открыть? Можно открыть дверь, короб- ку, окно (во двор, в Европу, в программе), счет в банке, остров, материк, континент (например, Австралию), или даже Америку. Глагол открыть можно использовать в раз- личных жизненных ситуациях. Каждый объект, с которым используется это слово, при- дает ему особое значение. (Например, даже словосочетание “открыть Америку” в пред- ложениях “В 1492 году Колумб открыл Америку!” и “Тоже мне, открыл Америку!” име- ет разный смысл.) Однако во всех случаях для описания действия используется одно и то же слово открыть. Не во всех языках программирования можно использовать полиморфизм. Язык, в ко- тором допускается использование полиморфизма, называется полиморфным языком. В мо- номорфном языке, напротив, полиморфизм использовать нельзя. В таком языке все привя- зано к одному и только одному поведению, поскольку каждое имя статично привязано к своему методу. Более того, для применения определенных видов полиморфизма необ- ходимо наследование. Ведь именно наследование позволяет подменять предков их по- томками. А это и есть разновидность полиморфизма С полиморфизмом в языках со строгим контролем типов связана следующая про- блема. Полиморфная переменная — это переменная, которая может хранить данные различных типов. Однако в языках со строгим контролем типов абсолютно поли- морфные переменные не допускаются, такие переменные могут содержать только значения, которые относятся к подтипам базового типа. Именно для таких значений допускается подмена типов. В языках с динамическим контролем типов полиморфная переменная может содержать любое значение. С помощью полиморфизма можно в любое время добавить к системе дополни- тельные функции. Можно добавлять новые классы с функциональностью, которая даже не предполагалась при написании программы. А поскольку полиморфизм позво- ляет оставить старые названия методов, то все это можно осуществить без каких-либо изменений первоначальной программы. Вот что значит программное обеспечение, легко приспосабливающееся к новым требованиям. Но это только одна из многих форм полиморфизма. Полиморфизм сам по себе полиморфный! Полиморфизм 221
Полиморфизм полиморфизма: формы полиморфизма Неоспоримо, что разновидности подобной сомнительной природы далеко не малочисленны. Сравните флоры Великобритании, Франции или Соединенных Штатов, составляемые различными ботаниками, и вы изумитесь числу форм, которые одними ботани- ками признаются за хорошие виды, а другими — только за разно- видности. М-р Г. Ч. Уотсон (Н. С. Watson), которому я много обя- зан за оказанную мне всякого рода помощь, отметил для меня 182 британских растения, которые обычно рассматриваются как разно- видности; но все они ботаниками признаны за виды; при состав- лении этого списка он не включил в него много незначительных разновидностей, которые тем не менее некоторыми ботаниками признавались в качестве видов, и совершенно опустил несколько высокополиморфных родов. К родам, включающим наиболее по- лиморфные формы, м-р Бабингтон (Babington) относит 251 вид, а м-р Бентем (Bentham) — всего 112 (разница в 139 сомнительных форм!). Среди животных, спаривающихся для каждого деторожде- ния и очень подвижных, сомнительные формы, признаваемые од- ним зоологом за виды, а другим — за разновидности, редко встре- чаются в пределах одной страны, но обычны в различных областях. Какое множество птиц и насекомых, встречающихся в Северной Америке и в Европе и мало отличающихся друг от друга, было признано одним выдающимся натуралистом за несомненные виды, а другим — за разновидности или, как их часто называют, геогра- фические расы! Чарлз Дарвин. О происхождении видов путем естественного отбора Поскольку полиморфизм сам полиморфен, то и не удивительно, что среди специа- листов по объектно-ориентированному подходу довольно много разногласий во взгля- дах на полиморфизм. Давайте сначала рассмотрим следующие четыре формы поли- морфизма: полиморфизм включения, или чистый полиморфизм; параметрический полиморфизм; переопределение; перегрузку. Полиморфизм включения Полиморфизм включения иногда еще называют чистым полиморфизмом. Эта форма полиморфизма позволяет написать один метод для работы со всеми типами объектов, являющимися потомками какого-то класса. Действительно, применяя подмену типов, методу можно передавать объект любого подтипа, а потом, используя полиморфизм, для конкретного экземпляра вызывать тот ме- тод, который соответствует фактическому, а не формальному типу переданного объекта. Полиморфизм включения сокращает объем кода, который нужно написать. Вместо того чтобы писать метод для каждого конкретного подтипа, можно написать один ме- тод, который будет работать со всеми подтипами. Полиморфизм включения упрощает процедуру добавления к программе новых подтипов, так как уже не нужно добавлять конкретный метод для каждого нового ти- па. Можно многократно использовать обобщенный метод. 222 Глава 8
А кроме того, полиморфизм включения интересен тем, что благодаря ему различ- ные экземпляры — потомки базового класса — могут вести себя по-разному. Получа- ется, что работа обобщенного метода зависит от получаемых им данных. (Удиви- тельного в этом, конечно, ничего нет. Удивительно то, что в самом обобщенном ме- тоде какие бы то ни было явные проверки типа данных отсутствуют, и по самому тек- сту метода о них даже догадаться нельзя.) Аккуратно используя полиморфизм вклю- чения, можно изменить поведение системы, вводя новые подклассы. Главное пре- имущество состоит в том, что можно создать новое поведение, не изменяя первоначальной программы. Как вы помните, повторное использование реализации ни в коем случае не следу- ет автоматически ассоциировать с наследованием. Ведь для этого естественнее вос- пользоваться полиморфизмом, а наследование нужно использовать прежде всего для того, чтобы добиться полиморфного поведения с помощью подмены типов. Если пра- вильно определить подмену типов, то она непременно автоматически повлечет по- вторное использование. Полиморфизм включения позволяет повторно использовать базовый класс, любого потомка, а также методы базового класса. Итак, мы разобрались, как работает механизм полиморфизма включения, и узна- ли, что полиморфизм включения влияет на наше восприятие объекта. Теперь давайте рассмотрим другой вид полиморфизма — тот, который влияет на используемые методы. Этот вид полиморфизма называется параметрическим полиморфизмом. Параметрический полиморфизм Параметрический полиморфизм позволяет создать родовые (универсальные) мето- ды и родовые (универсальные) типы. Подобно полиморфизму включения, родовые методы и типы позволяют написать программу, которая может работать с аргумента- ми разных типов. Параметрические методы С помощью параметрического полиморфизма можно создать родовые методы, от- кладывая объявления типа параметра до времени выполнения. Пусть мы имеем сле- дующий метод для сложения целых чисел: // вычисление суммы двух целых чисел int add(int a, int b) Метод add () принимает в качестве параметров два целых числа и подсчитывает их сумму. В качестве параметров этого метода можно передать только два целых числа. Этому методу нельзя передать, например, два действительных числа или два матрич- ных объекта — ошибка будет обнаружена еще на этапе компиляции. Для того чтобы сложить два действительных числа или две матрицы, нужно соз- дать методы для каждого типа: // Матрица add_jnatrix (матрица а, матрица Ь) Matrix add_matrix(matrix a, matrix b) // тип Real - вещественный Real add_real(Real a, Real b) и так далее для каждого типа слагаемых. Однако будет гораздо удобнее, если не придется создавать так много методов. Во- первых, такое большое количество методов значительно увеличивает объем програм- мы. Для каждого типа нужен отдельный метод. Во-вторых, чем больше программа, тем больше в ней ошибок и тем сложнее ее сопровождать. А ведь нужно стремиться упростить сопровождение. В-третьих, необходимость писать отдельные методы не ес- Полиморфизм 223
тественна для функции add (), моделирующей сложение. Естественнее использовать add (), а не add_matrix и add_real (). Полиморфизм включения предлагает свое решение проблемы. Можно ввести но- вый тип, назовем его addable, который имеет метод добавления данного этого типа к другому экземпляру типа addable. Тогда нужный нам новый метод может выглядеть таким образом: addable add_addable(addable a, addable b) { return a.add(b) ; } Здесь мы, конечно, воспользовались функциональным полиморфизмом. Благодаря ему нужно лишь написать один метод для суммирования. Однако этот метод подходит только для аргументов типа addable. Необходимо также чтобы все аргументы, переда- ваемые методу, были одного и того же типа (а не просто типа addable). В результате невыполнения этого требования может возникать много ошибок. Кроме того, это условие никак не отражено в интерфейсе. Так или иначе, задача осталась нерешенной. Все равно нужно писать методы для каждого подтипа слагаемых. Фактически мы пришли вот к чему: если тип слагаемого отличается от addable, для него нужен отдельный метод сложения. Именно этого позволяет избежать параметрический полиморфизм — с его помощью можно написать только один метод для сложения слагаемых всех типов. При использо- вании параметрического полиморфизма описание типа аргумента откладывается. Параметрические типы В экстремальном случае параметрический полиморфизм может применяться к са- мим типам, тогда в качестве параметров могут выступать и сами типы. Например, вместо того, чтобы писать класс очереди для каждого типа объектов, который нужно поместить в очередь, следует просто задать типы элементов, которые должны хранить- ся в очереди во время выполнения программы. Тогда очередь (Queue) может быть очередью элементов любого типа, т.е. элементы очереди могут иметь любой тип. Теоретически параметрический полиморфизм действительно очень удобен, но он не поддерживается во многих языках программирования. Даже Java, на- пример, не имеет естественных средств поддержки параметрических типов, да и параметрического полиморфизма вообще. Правда, можно “подделать” па- раметрические типы, однако это значительно снизит эффективность програм- мы. В некоторых расширениях языка Java параметрический полиморфизм поддерживается, но ни одно из этих расширений официально не санкциониро- вано фирмой Sun. Переопределение Переопределение — это важный тип полиморфизма. Вы с ним уже хорошо знако- мы. Вот простейший пример этой разновидности полиморфизма: в каждом подклассе базового класса можно переопределить некоторый метод. Перегрузка Перегрузка — это частный случай полиморфизма. С помощью перегрузки одно и тоже имя может обозначать разные методы, отличающиеся только сигнатурой, т.е. ко- личеством или типом параметров. Перегрузка полезна в том случае, когда метод не зависит от типа его аргументов. На- пример, определение метода не меняется в зависимости от того, сравниваются ли числа 224 Глава 8
целые, с плавающей точкой, с удвоенной точностью или вообще рассматривается какой- либо абстрактный порядок во вполне упорядоченном множестве. Операция + представляет собой еще один пример перегруженного метода. Сложение целых чисел и сложение тензоров, вообще говоря, вещи разные (ведь в эти операции вкладывается разный смысл). Вместе с тем, можно абстрагироваться от смысла и по- ступать так, как поступают в общей (абстрактной) алгебре, изучая множества, над ко- торыми определена операция. Можно, например, назвать эту операцию сложением. Природа (тип) складываемых элементов для алгебры безразлична. Можно применять один и тот же знак операции + для обозначения операции сложения независимо от ее операндов. Если природа (тип) складываемых элементов во внимание не принимается, то на любом непустом множестве можно определить операцию и назвать ее сложением. Складывать можно элементы любого вида. Если не использовать перегрузку, то каждому методу пришлось бы присваивать от- дельное имя для каждого набора типов аргументов. Усложнилась бы работа програм- миста, так как ему пришлось бы удерживать в своей “оперативной” памяти больше деталей. А благодаря перегрузке программист может в своей “оперативной” памяти удерживать более важные детали. Именно полиморфизм позволяет всем методам дать одно и тоже имя, а нужные методы в зависимости от типа заданных параметров вы- зываются автоматически. Можно просто вызвать шах () или написать + и передать па- раметры. О том, чтобы вызвать подходящий метод, позаботится полиморфизм. Перегрузка немного больше ограничена, чем полиморфизм включения. Мощь полиморфизма включения состоит в том, что он работает независимо от коли- чества подклассов. В случае же перегрузки при добавлении нового подкласса необходимо прибавить и новый метод. Конечно, несколько дополнительных методов могут быть вполне приемлемы для маленькой иерархии, но при уве- личении числа подклассов, вероятно, придется переработать иерархию так, чтобы вы могли написать универсальный метод. Способ обработки вызова метода при полиморфизме зависит от конкретного языка. Некоторые языки разрешают вызов метода во время компиляции, а другие выполняют динамическое связывание вызова метода во время выпол- нения. Приведение типов Приведение типов и перегрузка часто используются вместе. Благодаря приведению типов метод может выглядеть как полиморфный. Приведение типов имеет место то- гда, когда аргумент одного типа неявно приводится к нужному типу. Рассмотрим следующее объявление: float add( float a, float b ); Метод add () складывает два аргумента типа float. Однако можно создать несколько целых переменных и вызывать метод add (): int iA = 1; int iB = 2; add(iA, iB) ; А ведь для метода add() требуется два аргумента типа float! Именно поэтому и используется приведение типов. В вызове add(iA, iB) компилятор приводит аргументы типа int к типу float. Это означает, что еще до передачи в метод add() аргументы типа int будут приведены к типу float. Именно такое преобразование типов и называется приведением (типов). Полиморфизм 225
Таким образом, благодаря приведению типов метод add() можно рассматривать как полиморфный, поскольку этот метод работает с аргументами типов float и int. Однако мы знаем, что метод сложения можно переопределить, например, так: int add(int a, int b); В этом случае в вызове add(iA, iB) приведение типов не происходит. Вместо этого будет вызван подходящим образом переопределенный метод add (). Технология применения полиморфизма ...власть добирается до самых тонких и самых индивидуальных поведений, ...ей удается пронизывать и контролировать повсе- дневное удовольствие, — и все это с помощью действий, кото- рые могут быть отказом, заграждением, дисквалификацией, но также и побуждением, интенсификацией, — короче, с помо- щью “полиморфных техник власти”. Мишель Фуко. Воля к истине: по ту сторону знания, власти и сексуальности Как и другие полезные в программировании средства, полиморфизм требует со- блюдения определенной технологии. Эффективность применения полиморфизма дос- тигается не за раз, а в несколько этапов. На первом необходимо правильно применить инкапсуляцию и наследование. Действительно, без инкапсуляции программа может стать зависимой от реализа- ции классов. Поэтому ни в коем случае не допускайте брешей в инкапсуляции, ведь если программа станет зависимой от реализации классов, то в подклассе исправить это будет невозможно. Хорошая инкапсуляция — это первый шаг к полиморфизму. Так что сначала нужно правильно определить интерфейс классов, т.е. совокупность всех сообщений, которые можно посылать экземпляру того или иного класса. Наследование — это важная составляющая полиморфизма включения. Необходимо всегда стремиться воспользоваться преимуществами подмены типов. Для этого нужно программировать на уровне, наиболее приближенном к базовому классу. Такая мето- дика увеличит количество типов объектов, обрабатываемых в программе одним и тем же кодом. Подмену типов облегчает хорошо продуманная иерархия. Общие части нужно выно- сить в абстрактные классы и программировать объекты таким образом, чтобы использо- вать абстрактные классы, а не их специализации — конкретные классы-потомки. В дальнейшем это упростит использование в программе новых классов-потомков. Вот и все. Так просто, не правда ли? Нам осталось лишь явно сформулировать вы- текающие из всего только что изложенного правила применения полиморфизма. Правила применения полиморфизма Для того чтобы эффективно использовать полиморфизм, нужно: Неукоснительно следовать технологии применения инкапсуляции и наследования. При программировании использовать интерфейс, а не реализацию. Ведь интер- фейс позволяет точно определить типы используемых в программе объектов. Благодаря полиморфизму такие объекты будут обрабатываться надлежащим об- разом. При обдумывании проекта и программировании широко применять абстракцию. О деталях должен заботиться полиморфизм. Если полиморфизм выполняет все 226 Глава 8
свои функции, то нет необходимости писать большой текст программы. Кон- кретные проблемы решаются полиморфизмом! Установить и использовать подмену типов, чтобы основную работу выполнял по- лиморфизм. С помощью подмены типов и полиморфизма к программе можно будет прибавлять новые подтипы, причем обработку этих подтипов будет вы- полнять подходящий код. Стремиться средствами языка полностью отделить интерфейс от реализации. Не всегда это возможно, но предпочитать нужно именно языковые средства, а не наследование. Строго разделяя интерфейс и реализацию, можно увеличить гиб- кость подмены типов и тем самым открыть дополнительные возможности при- менения полиморфизма. Для отделения интерфейса от реализации следует использовать абстрактные клас- сы. Все классы, не являющиеся конечными (т.е. листьями в дереве иерархии), должны быть абстрактными; при программировании нужно применять только эти абстрактные классы. В языках со строгим контролем типов, таких как Java и C++, нужно явно объявить тип переменных. Однако в некоторых объектно-ориентированных языках (например Smalltalk) это делать не обязательно. В таких языках типы задаются динамически. Ес- ли в языке допускается динамическое задание типа, то при создании переменной, яв- но объявлять ее тип не обязательно. Тип задается динамически во время выполнения. Таким образом, в таких языках каждая переменная по существу является абсолютно полиморфной. Поэтому применение полиморфизма в языках с динамическим заданием типов не- сколько проще. Переменные могут содержать любое значение и потому автоматиче- ски являются полиморфными. Если у объекта есть нужный метод, он может рассмат- риваться как полиморфный. Конечно, если попытаться вызвать несуществующий ме- тод, то программа завершится аварийно. В языке с динамическим заданием типов объект, в котором есть нужный метод, можно рассматривать как полиморфный. При этом объект не обязательно должен при- надлежать к конкретной иерархии наследования. А вот в языках со строгим контролем типов объект должен обязательно принадлежать к подходящей иерархии наследования. Однако эти две ситуации довольно похожи. Поведение ведь на самом деле опреде- ляет тип. В языке со строгим контролем типов должны быть определены все объявлен- ные поведения. Фактически же концепции, заложенные в полиморфизме, в языках со строгим контролем типов и в языках с динамическим определением типов, одинаковы: все сводится к тому, чтобы объект знал, как реализовать необходимое поведение. Однако зачастую языки со строгим контролем типов предпочтительнее. Сосредота- чивая внимание на строгом контроле типов, программист может сконцентрироваться на самом типе, а не углубляться в детали. А тот, кто умеет применять полиморфизм в языке со строгим контролем типов, конечно же, сумеет применить его и в языке без контроля типов. Обратное же утверждение может быть неправильным! К счастью, большинство основных объектно-ориентированных языков относятся к языкам со строгим контролем типов. Полиморфизм и проверка условий Для проверки условий и исполнения операторов, связанных с выполненными усло- виями, используются различные управляющие операторы (переключатели, лестницы if-else if и т.д.). С помощью логических операторов вы проверяете выполнение неко- торого условия для некоторой части данных. Если это условие выполнено, вы что-то де- Полиморфизм 227
лаете. Если выполнено другое условие, вы делаете что-то другое. Любому программисту, имевшему дело с процедурным программированием, хорошо известен такой подход. Однако едва ли найдется программист, которого могут обрадовать сложные услов- ные выражения, многостраничные переключатели и лестницы if-else if, по кото- рым можно взобраться на небо, если бы только хватило сил прочитать их! Объектно- ориентированный подход позволяет писать программы понятнее. Наличие операторов выбора рассматривается как плохой признак. На практике перегруженные оператора- ми выбора программы оказываются настолько плохими, что во многих объектно- ориентированных языках даже нет оператора выбора. Но все же применение условных операторов приносит одну несомненную выгоду: оно помогает вам обнаружить недос- татки проекта! Недостатки использования переключателей почти всегда являются следствием их природы. Однако условные выражения часто незаметно подкрадываются к вам, пото- му что, маскируясь, они входят во многие самые разнообразные конструкции языков. Чем же так плохи условные выражения? Дело в том, что условные выражения противоречат концепциям объектно-ориенти- рованного подхода. При объектно-ориентированном подходе не предполагается, что вы запрашиваете у объекта его данные, а затем делаете кое-что с этими данными. Вместо этого предполагается, что вы просите, чтобы объект сам выполнил нужную обработку своих данных. Предположим, вы получаете какие-то данные от некото- рого объекта. Но ведь вы не должны обрабатывать непроверенные данные! Вместо этого вы должны запросить у объекта нужный вам результат. Условные выражения вынуждают вас неправильно распределять обязанности и тем самым запутывают вас. Ведь всюду, где данные используются подобным образом, вам придется приме- нять те же самые условные операторы. Правда, иногда все-таки условные выражения абсолютно необходимы. Но как же тогда определить, какие условные выражения являются “плохими”? Если при добавлении нового подтипа каждый раз приходится модифицировать оператор выбора или лестницы if-else if, скорее всего, данное условное выра- жение является “плохим”. Мало того, что такое условное выражение свидетельст- вует о неправильном применении объектно-ориентированного подхода, в даль- нейшем оно превратится в эксплуатационный кошмар! Вы должны будете удосто- вериться, что модифицировали каждое условное выражение, в котором для переключения проверяются данные подобного рода. Потребуется много времени, чтобы гарантировать, что вы не забыли модифицировать еще что-то! Как исправить условные выражения Рассмотрим следующий метод: int calculate( String operation, int operandl, int operand2 ) { if ( operation.equals( "+" ) ) { return operandl + operand2; } else if ( operation.equals ( " * ")) { return operandl * operand2; } else if ( operation.equals ( " /")) { return operandl / operand2; } else if ( operation.equals( ) ) { return operandl - operand2; } else { 228 Глава 8
// недействительная операция printf( "invalid operation: " + operation ); return 0; } } Такой метод мог бы использоваться в калькуляторе. Метод calculate () в качест- ве параметров принимает операцию, а также два операнда. Затем он вычисляет ре- зультат. Как же устранить лестницу if-else if? Конечно, с помощью объектов! Чтобы устранить оператор выбора, начать нужно с данных, которые используются в переключателе. Обработкой данных должен заниматься объект. Слишком большое количество методов get (получить) и set (установить) ука- зывает на неправильное применение объектно-ориентированного подхода к проекту. Вообще говоря, очень редко требуется запросить у объекта его дан- ные. Вместо этого обычно нужно послать объекту запрос на обработку его данных. Конечно, “хороший объектно-ориентированный подход”— понятие от- носительное. Если вы пишете универсальные объекты, которые будут исполь- зоваться во многих различных ситуациях, возможно, к классу и придется при- бавить методы set (ycTaHOBHTb)/get (получить). В нашем примере нужно создать объекты для сложения, вычитания, умножения и деления. Все эти объекты — действия. Поэтому все они должны быть наследниками общего базового класса — назовем его Operation (Операция). Тогда подмена типов и полиморфизм позволят сделать с этими объектами нечто вполне разумное. Все объекты, которые вы должны создать — действия, так что вы знаете, что ну- жен базовый класс. Но что должен делать класс Operation (Операция)? Класс Operation (Операция) должен вычислять результат бинарной операции, если заданы два ее операнда! Поэтому нам нужно создать абстрактный класс Operation (Операция) с абстракт- ным методом calculate () и его подклассы для реализации каждой бинарной опера- ции. Тогда каждая операция может реализовать метод calculate О своим собствен- ным способом. После этого для каждой операции будет в наличии нужный объект и первоначальный метод calculate () можно будет переписать так: int calculate( Operation operation, int operandl, int operand2 ) { return operation.calculate( operandl, operand2 ); } Превратив операцию в объект, мы значительно увеличили гибкость программы. Ранее при добавлении новой операции приходилось модифицировать метод (удлинять лестницу if-else if). Теперь можно просто создать новую операцию и передать ее методу. Чтобы использовать новую операцию, не нужно изменять метод — просто са- ма операция работает за вас! А чтобы с самого начала все было так просто и понятно, давайте сформулируем правила применения условных операторов. Правила применения условных операторов Вот советы (или правила-предупреждения), которые позволяют минимизировать количество условных операторов (оператора выбора, всевозможных переключателей и высоких лестниц if-else if). • Старайтесь не использовать оператор выбора. • Скептически оценивайте высокие лестницы if-else if. Полиморфизм 229
• Остерегайтесь цепной реакции изменений. Если при изменении требуется из- менить много условных операторов, это может превратиться в проблему. • Операторы выбора и всевозможные лестницы if-else if находятся под по- дозрением, если не доказана их “невиновность”. • Прежде чем ввести переключатель, подумайте, не лучше ли превратить данные в объекты. • Если данные уже представляют собой объект, то прежде чем ввести переключа- тель, попробуйте добавить к объекту метод. • Избегайте проверок типа; вместо них используйте полиморфизм. Ошибки и ловушки при использовании полиморфизма При использовании полиморфизма встречаются три основные ошибки. Фактиче- ски это ловушки, в которые может угодить неопытный разработчик, нарушающий технологию применения полиморфизма или слепо придерживающийся некоторых со- ветов. Однако и опытный программист иногда задает себе вопрос: а не угодил ли я в одну из ловушек полиморфизма? Если чувствуете, что с полиморфизмом что-нибудь не так или что-то не получается, полезно свериться со списком ловушек. Тем более что их, как вы уже знаете, всего три. Ловушка 1: вынос поведения на слишком высокий уровень иерархии Очень часто неопытные разработчики с целью усиления полиморфизма стараются переместить поведение на слишком высокий уровень иерархии. Стремление как мож- но чаше использовать полиморфизм может ослепить разработчика, и он создаст плохо спроектированные иерархии. Если переместить поведение на слишком высокий уровень иерархии, то не каж- дый потомок сможет поддерживать это поведение. Следует помнить, что потомки не могут удалить функции своих предков. Не стоит разрушать тщательно спланированную иерархию наследования ради усиления полиморфизма. Если вы перемешаете поведение на более высокий уровень иерархии только ради усиления полиморфизма, остановитесь. Это опасно. Если иерархия имеет слишком много ограничений, ее нужно исправить. Перемес- тите общие элементы в абстрактные классы, а функции — в более подходящее место. Однако не стоит перемешать методы на слишком высокий уровень иерархии, пропус- кая уровни, где они нужны в первую очередь. Ни в коем случае не перемешайте пове- дения только лишь ради усиления полиморфизма. Для перемещения нужна более ос- новательная причина. Конечно, если вы все же не послушаетесь этого совета, катаст- рофа не обязательно наступит немедленно. Несколько раз может повезти, однако рано или поздно такие действия приведут к нежелательным результатам, а от плохой привычки в программировании избавиться трудно. Как же быть? Отказываться от усиления полиморфизма? Вовсе нет! Я, например, очень часто поступаю следующим образом. Если я хочу усилить полиморфизм, то это не достаточно основательная при- чина для того, чтобы переделывать проект — вместо этого нужно найти причину, по которой я хочу усилить полиморфизм. Что есть такого в концептуальной модели, что можно использовать для усиления полиморфизма? Может быть, ответив на этот во- 230 Гпава 8
прос, я лучше уясню суть задачи и построю лучшую концептуальную модель? Если так, то в новой модели усиление полиморфизма, возможно, будет оправданным! Создавая иерархии, важно учитывать потенциальное развитие классов во времени. Можно разбить иерархию на функциональные уровни. Со временем можно развивать иерархию, добавляя новые функциональные уровни по мере необходимости. Однако, принимая решения, нужно основываться только на тех требованиях, о которых из- вестно, что они будут предъявлены в будущем. Существует большое количество неоп- ределенных сценариев “Что — если”. Следует планировать только те возможности, о которых вы хорошо осведомлены. Ловушка 2: непроизводительные издержки, или потеря эффективности Иногда применение полиморфизма приводит к непроизводительным издержкам и потере эффективности. Полиморфизм не может конкурировать с методом, в котором тип аргументов задан статично. Ведь при использовании полиморфизма проверки могут потребоваться во время выполнения. При полиморфизме включения настоящая реализация объекта, которому посылается сообщение, определяется во время выпол- нения. Неизбежные при этом проверки требуют гораздо больше времени по сравне- нию с обработкой величин, типы которых заданы статично. Преимущества при сопровождении и гибкость программы должны возместить любые потери производительности. Однако при написании критической по времени выполне- ния прикладной программы полиморфизм следует применять с осторожностью — нуж- но учитывать производительность. Сначала создайте чисто объектно-ориентированную реализацию, затем — профиль реализации, это поможет обнаружить критические (в от- ношении времени выполнения) участки. Зная их, вы сможете оптимизировать произво- дительность. Ловушка 3: ограничение интерфейсом базового класса У полиморфизма включения есть недостаток. Хотя на самом деле методу можно пе- редать подкласс вместо базового класса, такой метод не может использовать преимуще- ства каких-либо новых методов, которые подкласс мог прибавить к своему интерфейсу. Ведь если метод запрограммирован для базового класса, он может обращаться только к интерфейсу базового класса. Итак, если добавить новые типы с помощью полиморфизма, то старый код не сможет использовать какие-либо новые методы. Однако новый код (или обновленный код) сможет использовать все, что есть в общедоступном интерфейсе. Эта ловушка опять обращает наше внимание на то, почему потомок не должен удалять поведение из класса-родителя. Метод, основанный на полиморфизме включе- ния, будет использовать только те методы, которые доступны для определенного в нем типа. Если же нужное для метода поведение удалить, метод работать не сможет. Эта ловушка также указывает на то, что вставка нового типа в уже существующую программу часто не столь проста, как может показаться. Реализация полиморфизма В различных объектно-ориентированных языках полиморфизм реализован по- разному. Полиморфизм 231
Во многих, если не во всех языках в какой-то степени поддерживается полиморфизм включения. Однако лишь в некоторых из них поддерживается настоящий параметриче- ский полиморфизм. Java, например, параметрический полиморфизм не поддерживает. А вот C++ претендует на то, что в нем этот вид полиморфизма реализован. В большинстве языков, в той или иной форме, используются переопределение и приведение типов. Однако точная реализация зависит от языка. Поэтому, применяя полиморфизм, задумайтесь, как его реализовать в программе. Ведь избежать ограничений языка реализации не удастся. В языке C++, например, для реализации полиморфизма часто используются виртуальные функции и динами- ческое связывание. Виртуальные функции и динамическое, или позднее, связывание как форма полиморфизма Рассмотрим следующий пример. Предположим, мы заняты проектированием графиче- ской программы, в которой можно использовать несколько типов фигур: прямоугольники, окружности, эллипсы и т.д. Каждая фигура будет представлена объектом своего класса, от- личного от других классов. Например, класс Rectangle (Прямоугольник) должен иметь переменные-члены для хранения высоты, ширины и центра, а класс Circle (Окруж- ность) — переменные-члены для хранения координат центра и радиуса. Все эти классы, вероятно, будут производными от одного базового класса, скажем, Figure (Фигура). Ко- нечно же, нужен метод, который отображает фигуру на экране. Отображение окружности отличается от отображения прямоугольника. Следовательно, для каждого класса нужен свой метод отображения на экране. Поскольку функции-члены принадлежат классам, все они могут иметь одно имя — draw (чертить). Пусть г — экземпляр класса Rectangle (Прямоугольник), ас — экземпляр класса Circle (Окружность), тогда r.drawO и с.draw() будут вызовами методов с различными реализациями. В нашей ситуации, как вы уже знаете, выгодно, чтобы родительский класс Figure (Фигура) имел функции, применимые ко всем фигурам, например функцию center (переместить в центр), которая перемешает фигуру в центр экрана, стирая ее и затем перерисовывая в центре. Функция Figure: .-center может использовать метод draw (чертить) для перерисовывания фигуры в центре экрана. Именно полиморфизм по- зволяет использовать такую функцию с объектами классов Rectangle (Прямо- угольник) или center (переместить в центр). Однако с точки зрения реализации по- лиморфизма в языке при этом возникает немало сложностей. И вот почему. Представим, что класс Figure (Фигура) написан и активно используется. Через ка- кое-то время мы добавляем класс для новой фигуры, например треугольника. Класс Triangle (Треугольник), конечно же, будет производным от класса Figure (Фигура). Поэтому метод center (переместить в центр) должен быть применим и к треугольни- кам. Однако метод center (переместить в центр) использует метод draw (чертить), ко- торый отличается для каждого типа фигур. Унаследованная функция center (переместить в центр) использует определение функции draw (чертить) из класса Figure (Фигура). Получается, что функция center (переместить в центр) может не- корректно работать с объектами класса Triangle (Треугольник)? Ведь чтобы унаследо- ванная функция center (переместить в центр) работала корректно, она должна вызы- вать не Figure: :draw, a Triangle: :draw! Но ведь класс Triangle (Треугольник) в тот момент, когда писался (и даже компилировался) код функции center (переместить в центр) (определенной в классе Figure (Фигура)), не был не то что разработан, а даже задуман! Как же сделать так, чтобы метод center (переместить в центр) корректно мог работать с классом Triangle (Треугольник)? Ведь компилятору ничего не было извест- но о классе Triangle (Треугольник) при компиляции* функции center (переместить в 232 Глава 8
центр)! Для этого в C++ предусмотрено специальное средство: виртуальные функции. В нашем примере виртуальной необходимо сделать функцию draw (чертить). Для этого в прототипе функции следует указать зарезервированное слово virtual. Виртуальная функция может (в некотором смысле) использоваться до ее определения. Когда в программе вызывается виртуальная функция, используется та ее реализа- ция, которая предоставляется экземпляром класса. Технология вызова функций, нуж- ная реализация которых фактически определяется во время выполнения, называется динамическим связыванием (dynamic binding) или поздним связыванием (late binding). ^Виртуальные функции — это средство языка, которое указывает, что к имени должно применяться динамическое связывание. Как же пользоваться этим средством? Правила использования виртуальных функций в C++ Чтобы функция была виртуальной, в ее прототипе должно быть указано зарезервиро- ванное слово virtual. Конечно, написать это слово не сложно, но возникает вопрос: где? Ведь (полиморфизм в действии!) одно и то же имя может иметь несколько прото- типов. Оказывается, чтобы сделать виртуальной функцию, имеющую разные определения в производном и в базовом классах, достаточно добавить слово virtual к ее прототипу в базовом классе. Добавлять это слово к прототипу в производном классе вовсе не обяза- тельно — виртуальная функция в базовом классе автоматически становится виртуальной в производном классе! Однако, несмотря на это, лучше явно указать виртуальность функ- ции и в прототипе в производном классе. Хотя такое указание и не требуется, оно облегча- ет чтение программы. Заметьте, что зарезервированное слово virtual добавляется только к прототипу функции, а не к ее определению. Если же функция не объявлена как виртуальная с помощью ключевого слова virtual, она таковой не является. Может возникнуть вопрос: а почему бы не сделать все функции виртуальными? Единственная причина, по которой все используемые функции не делаются виртуаль- ными, заключается в эффективности — ведь определение того, какая именно функция должна быть вызвана, происходит во время выполнения программы и тем самым снижа- ет быстродействие программы. Замещение и переопределение Если определение функции изменено в производном классе, то говорят, что опре- деление функции замещено, если функция виртуальная, и переопределено, если функция обычная, невиртуальная. Оба термина означают, что определение функции изменено в производном классе, но замещение относится к виртуальным функциям, а переопределение — к невиртуальным. Это отличие может показаться совсем незначительным, но компиляторы трактуют такие случаи по-разному, по- этому часто удобно различать понятия замещения и переопределения. Полиморфизм, динамическое (позднее) связывание и виртуальные функции В наиболее широком смысле в программировании термином полиморфизм обозначается возможность связать несколько смыслов с одним именем функции. Однако в программировании этот термин используется и в более узком смысле; тогда под ним понимается возможность связывания различных смыслов с одним именем функции посредством динамического связывания. Полиморфизм в этом узком смысле, динамическое (позднее) связывание и виртуальные функции представляют собой, по сути, разные проявления одной и той же сущности. Про- сто термин полиморфизм относится к методологии программирования, термин Полиморфизм 233
динамическое (позднее) связывание описывает механизм вызова функций, а термин виртуальная функция обозначает средство языка, предназначенное для реализации (одной из форм) полиморфизма. Виртуальные функции и полиморфические кластеры С поздним связыванием и реализацией полиморфизма через виртуальные функции связано понятие полиморфического кластера. Давайте разберемся, что это такое. Как вы знаете, позднее связывание, если оно применяется, охватывает ряд методов (функций-членов), которые реализуются с помощью виртуальных функций. Вирту- альная функция объявляется в базовом или производном классе, а затем переопреде- ляется в классах-потомках. Множество классов (подклассов), в которых определяется и переопределяется виртуальная функция, как раз и называется полиморфическим кла- стером, ассоциированным с данной виртуальной функцией, или просто полиморфическим кластером данной виртуальной функции. В полиморфическом кластере сообщение связы- вается с конкретной виртуальной функцией-членом во время выполнения программы. Конечно, обычную функцию-член также можно переопределить в классах-потом- ках. Однако без атрибута virtual такая функция-член будет связана с сообщением объекту на этапе компиляции. Только атрибут virtual гарантирует позднее связыва- ние в пределах полиморфического кластера. Рассмотрим пример создания полиморфического кластера виртуальной функции. // Полиморфический кластер #include <iostream> #include<string.h> class Parent { protected: char*lastName; public: Parent(void) { lastName = new chart 5 ]; strcpy( lastName, "None"); }; Parent( char *aLastName ) { lastName = new chart strlen( aLastName ) + 1 ]; strcpy( lastNamez aLastName ); }; // Конструктор копирования Parent( Parent& aParent ) { lastName = new chart strlen( aParent. lastName ) + 1]; strcpy( lastNamez aParent. lastName ); } // Методы доступа char* getLastName( void ) { return lastName; } void setLastName( char * aName ) { lastName = new chart strlen( aName ) + 1 ]; strcpy( lastName, aName ); } virtual void answerName( void ) { std::cout « "My last name is " « lastName « "\n"; 234 Глава 8
} // Деструктор -Parent( void ) { delete lastName; } }; class Child: public Parent { protected: char *firstName; public: Child( void ) { firstName = new char[ 5 ]; strcpy( lastName, "None"); } Child(char *aLastName, char *aFirstName ):Parent(aLastName ){ firstName = new char[ strlen( aFirstName ) +1 ]; strcpy( firstName, aFirstName ); } // Конструктор копирования Child(Child& aChild) { setLastName( aChild.getLastName()); firstName = new char[ strlen( aChild.firstName ) + 1 ]; strcpy( firstName, aChild. firstName ); } // Методы доступа char* getFirstName( void ){ return firstName; } void setFirstName (char * aName ) { firstName = new char[ strlen( aName ) + 1 ]; strcpy( firstName, aName ); } // Деструктор -Child( void ) { delete firstName; } virtual void answerName( void ) { Parent::answerName(); std::cout « "My first name is " « firstName « "\n"; } }; class GrandChild : public Child { private: char *grandFatherName; public: GrandChiId( char *aLastName, char *aFirstName, char *aGrandFatherName ) : Child( aLastName, aFirstName ) { grandFatherName = new char[strlen(aGrandFatherName) + 1]; strcpy( grandFatherName, aGrandFatherName ); } -GrandChild( void ) { Полиморфизм 235
delete grandFatherName; } virtual void answerName( void ) { Child::answerName(); std::cout « "My grandfather’s name is ’’ « grandFatherName « "\n"; } }; main() { Parent *family[ 3 ]; Parent *p = new Parent( "Jones" ); Child *c = new Child( "Jones", "Henry" ); Grandchild *g = new GrandChild( "Jones", "Cynthia", "Murray" ); family[0]=p; family[1]=g; family[2]=c; for( int index = 0; index < 3; index++ ) family[ index]->answerName(); } В этой программе объявлен массив family, в котором хранятся три указателя на экземпляры класса Parent. Сообщение answerName последовательно посылается ка- ждому экземпляру, адресуемому указателем, который хранится в family. Каждый объект интерпретирует сообщение по-своему. Вот что получается в результате выпол- нения программы: Му last name is Jones My last name is Jones My first name is Cynthia My grandfather’s name is Murray My last name is Jones My first name is Henry Как вы думаете, что произойдет, если опустить ключевое слово virtual перед функцией-членом производных классов ее полиморфического кластера (в нашем примере это классы Child и Grandchild)? Программа будет работать точно так же. Ведь ключевое слово virtual необходимо в полиморфическом кластере только перед виртуальной функцией самого верхнего уровня. Для всех остальных переопределений этой функции ключевое слово virtual необязательно. А что произойдет, если опустить ключевое слово virtual перед всеми функция- ми-членами answerName? В этом случае функции-члены answerName не будут вирту- альными, и потому для family[1] и family[2] будут распечатаны только поля lastName, доступ к которым осуществляется с помощью указателей на объекты g и с. Все это делается с помощью раннего связывания. Поэтому результат будет такой: Му last name is Jones My last name is Jones My last name is Jones Давайте теперь для реализации полиморфизма применим ссылки на объекты вме- сто указателей. Для этого нужно изменить только функцию main: 236 Глава 8
// Использование ссылок для реализации полиморфизма. main () { Parent р( "Jones"); Child с( "Jones", "Henry"); GrandChild g( "Jones", "Cynthia", "Murray"); Parent& fO = p; Parent& fl = g; Parent& f2 = c; f0.answerName(); f1.answerName(); f2.answerName(); } Результаты выполнения программы будут такие же, как и в случае использования массива. А что произойдет, если функцию main изменить следующим образом: main () { Parent& family[3]; Parent *р = new Parent( "Jones" ); Child *c = new Child( "Jones", "Henry" ); GrandChild *g = new GrandChild( "Jones", "Cynthia", "Murray" ); family[0] = p; family[1] = g; family[2] = c; for( int index = 0; index < 3; index++) family[index].answerName(); } Компилятор найдет ошибку: error C2234: ’family’ : arrays of references are illegal. А теперь рассмотрим пример, в котором исследуются тонкостй виртуальных функций. // программа с виртуальными функциями #include <iostream> using namespace std; class Parent { private: virtual void methodi( void) {cout« "Parent method methodl\n"; } void method2( void ) { cout « "\nParent method method2\n"; } void method3( void ) { methodi(); method2(); } void methods( void ) { cout « "Parent method methods\n"; } public: Parent( void ) {} void methods( void ) { method3(); } void method4( void ) { cout « "Parent method method4\n"; } void method?( void ) { methods(); } }; class Child : public Parent { private: Полиморфизм 237
void methodic void ){ cout« "Child method methodl\n,"; } void methods( void ) { cout << "Child method method5\n"; } public: Child ( void ){} void method4( void ) { cout << "Child method method4\n"; } void method?( void ) { methods(); } }; main() { Parent pop; Child me; cout« "Late binding polymorphism using virtual methods\n"; pop.methods();// метод Parent - methodi, метод Parent - method2 me.methods();// метод Child - methodi, метод Parent - method2 cout « "\n\nNow try individual class messages - static binding\n"; pop.method4();// метод Parent - method4 me.method4();// метод Child - method4 cout« "\n\nNext send normal polymorphic messages\n"; pop.method?();// метод Parent - methods me.method?();// метод Child - methods cout« "\n"; } В этой программе сначала объекту pop посылается сообщение methode (). Этот метод находится в открытой части протокола класса Parent. Из класса Parent вызы- вается methods (), который находится в закрытой части протокола класса Parent и вызывает закрытые методы methodi и method2 класса Parent. Затем сообщение methods () посылается объекту те. Этот метод унаследован от класса Parent. Как и в предыдущем случае, вызывается защищенный метод methods (), а затем защищенные методы methodi () и method2 (). В классе Parent защищенный метод methods () определен следующим образом: void methods(void) { methodi(); method2(); } Это сокращенная запись для void methods(void) { this->methodl(); this->method2(); } Поскольку в базовом классе Parent метод methodi () объявлен виртуальным, то на этапе выполнения программы вызов this->methodl () связывается с методом methodi () класса Child. Поэтому будет выведена строка Child method methodi. Вот что получится в результате выполнения программы: Late binding polymorphism using virtual methods Parent method methodi Parent method method2 Child method methodi Parent method method2 Now try individual class messages - static binding Parent method method4 Child method method4 238 Глава 8
Next send normal polymorphic messages Parent method methods Child method methods А вот что получится, если перед methodi убрать ключевое слово virtual: Late binding polymorphism using virtual methods Parent method methodi Parent method method2 Parent method methodi Parent method method2 Now try individual class messages-static binding Parent method method4 Child method method4 Next send normal polymorfic messages Parent method methods Child method methods Полиморфизм и проверка вызовов виртуальных функций Несмотря на то, что позднее связывание осуществляется на этапе выполнения программы, контроль сообщений (обращений к виртуальным функциям) может вы- полняться статически, т.е. при компиляции. Рассмотрим следующую программу: // Статическая проверка и позднее связывание finclude <iostream> class Parent { public: Parent( void) {} virtual void hello( void ) {} }; class Child : public Parent { public: Child(void){} virtual void hello( void ) { std::cout « "Hello world\n"; } }; int main() { Parent *p; Child c; P = &c; p->hello( "Hello" ); // ошибка } Компилятор найдет ошибку в вызове p->hello ("Hello") и определит, что вирту- альная функция hello не имеет параметров: error С2660: ' Parent: .-hello’ : function does not take 1 parameters. Ведь несмотря на то, что сообщение, быть может, во время выполнения программы связывается с методом hello класса Child, виртуальные функции, определенные в родительском или потомственном классе, имеют одинаковые списки параметров. Поэтому контроль типов параметров может осуществляться при компиляции, даже если будет применяться позднее связывание. Полиморфизм 239
Давайте теперь из описания класса Child уберем ключевое слово public (). Тогда компилятор найдет ошибку еще и в операторе р = &с;. Появится сообще- ние: error С2243: 'type cast' : conversion from 'Child *_______w64 ' to 'Parent *' exists, but is inaccessible. (Ошибка: преобразование (приведение типов) Child * к Parent * недоступно.) В точности то же произойдет, если класс Child объявить как private- производный класс от Parent. Таким образом, мы убедились, что указатель или ссылка на базовый класс совмес- тима со ссылкой или указателем на производный класс лишь при условии, что базо- вый класс Является открытым базовым классом. Сколько стоит полиморфизм: техническая реализация виртуальных функций Объект C++ (экземпляр класса) представляет собой непрерывный участок памяти. Указатель на такой объект адресует начало этого участка. Чтобы послать объекту со- общение, нужно вызвать функцию-член; вызов функции-члена транслируется в обычный вызов функции с дополнительным аргументом, который содержит указатель на объект. Пусть имеем, например, следующее объявление: ClassName *object; Тогда вызов функции-члена object->message(15); преобразуется в обычный вызов функции с дополнительным аргументом, — указа- телем на объект. Поэтому скомпилированный вызов будет примерно таким: ClassName_message(object,15); Адреса виртуальных функций, как вы уже знаете, хранятся в таблице виртуальных функций. Пусть имеем, например, следующие классы: class Parent { int value; public: virtual int methodi( float r ); virtual void method2( void ); virtual float method3( char *s ); }; class Childl: public Parent { public: void method2( void ); } class Child2: public Childl { public: float method3( char *s ); } Таблица виртуальных функций, virtual-table, содержит адреса функций-членов каждого класса полиморфического кластера. Все экземпляры классов и подклассов полиморфического кластера имеют указатель на эту таблицу. 240 Глава 8
Предположим далее, что для приведенных выше классов эта таблица выглядит так: virtual_table[] = { Parent::methodi, Childl::method2, Child2::method3 }; Тогда компилятор преобразует вызов виртуальной функции в косвенный вызов че- рез адрес, хранимый в virtualTable. Например, вызов Child2 *с; c->method3( "String"); преобразуется в (*(c->virtual_table[2]))( с, "String"); Преимущества позднего связывания В языках, где контроль типов отсутствует (Smalltalk и ранние версии Objective С и др.), позднее связывание можно рассматривать как средство создания обобщенных (родовых, универсальных) функций. В языке типа Smalltalk или Objective С в коллек- ции, например в экземпляре класса Array можно хранить любые объекты. Всем объ- ектам такой коллекции можно единообразно послать сообщения. Каждый объект проинтерпретирует сообщение согласно протоколу своего класса (или подкласса) и потому отреагирует на сообщение по-своему. Следовательно, для того чтобы реакция объектов на одно и то же сообщение отличалась, необходимо переопределить прото- колы соответствующих классов. Обработка сообщения, как мы видим, локализуется в протоколе каждого класса (подкласса). В C++ такой эффект можно получить в полиморфическом кластере. Действитель- но, можно создать коллекцию ссылок или указателей на объекты. Эти ссылки (указатели) должны быть объявлены как ссылки (указатели) на объекты базового класса или одного из производных классов в полиморфическом кластере, так как со- хранить указатель или ссылку на объект, не входящий в полиморфический кластер, не удастся, поскольку компилятор обнаружит несоответствие типов. (Ведь, как мы уже убедились, в переменных, объявленных как ссылки или указатели на экземпляры ба- зового класса, можно хранить только ссылки или указатели на объекты производного класса, причем базовый класс для него должен быть открыт (ключевое слово public в объявлении наследования)). Пусть, например, в массиве хранятся указатели (или ссылки) на объекты и каждо- му объекту, адресуемому с помощью элемента такого массива, передается сообщение answerName (). Тогда реакция каждого объекта будет зависеть от того, как переопре- делена его виртуальная функция answerName. Если язык поддерживает только раннее связывание, вызов требуемой версии мето- да (функции) нужно явно организовать в программе. Обычно для этого применяются операторы выбора, такие как switch и if — else в С или оператор CASE в Modula-2. Все это реализуется примерно такими лестницами: if (это объект такого-то класса) выполнить это, else if (это другой объект) выполнить нечто другое, else if... Подобные лестницы обычно многократно повторяются в приложении. Расширение функциональных возможностей программы^ содержащей такие лестницы, связано с большими трудностями, ведь каждый дополнительный выбор, вводимый в систему, необходимо включить во все операторы выбора. Так что изменять программу прихо- дится во многих местах. Если же язык поддерживает позднее связывание, то, чтобы расширить функцио- нальные возможности, нужно всего лишь создать новые производные классы. Допол- Полиморфизм 241
нительные возможности реализуются через виртуальные функции этих классов. Таким образом позднее связывание позволяет локализовать изменения программы. Именно поэтому основная цель наследования — использование преимуществ полиморфизма. Теперь, зная, какие средства реализации полиморфизма имеются в нашем распоря- жении, мы можем обсудить, как с их помощью достичь поставленных целей. Как с помощью полиморфизма достичь целей объектно-ориентированного подхода Полиморфизм позволяет разрабатывать программное обеспечение, которое: • естественно; • надежно; • может использоваться повторно; • удобно в сопровождении; • пригодно к усовершенствованию; • пригодно для периодического выпуска (Издания) новых версий. Разберем, как достичь этих свойств с помощью полиморфизма. Естественность. С помощью полиморфизма можно создать более естественную мо- дель окружающего мира. Благодаря полиморфизму можно писать программы не для каждого конкретного случая, а работать на более общем концептуальном уровне. Переопределение и параметрический полиморфизм позволяет моделировать объект или метод на концептуальном уровне. Следовательно, программист мо- жет сосредоточиться на том, что объект или метод делает, а не на типах переда- ваемых им параметров. Полиморфизм включения позволяет использовать типы объектов, а не конкретные подтипы реализаций. Родовые (универсальные) программы более естественны, поскольку дают воз- можность сосредоточиться на концепциях, а не на специфике реализаций. Надежность. При использовании полиморфизма код будет надежным. Во-первых, надежность — следствие того, что полиморфизм позволяет упро- стить код. Вместо того чтобы писать программу для каждого отдельного случая и для каждого используемого типа объекта, можно написать универсальную про- грамму — программу всего лишь для одного (как часто говорят общего) случая. Если бы в программировании нельзя было использовать полиморфизм, то пришлось бы обновлять программу каждый раз при добавлении нового под- класса. А при обновлении кода увеличивается вероятность внесения ошибок. Во-вторых, надежность повышается потому, что полиморфизм позволяет сокра- тить объем программы. А чем меньше программа, тем меньше (при прочих равных условиях) вероятность ошибки в ней. Кроме того, полиморфизм позволяет изолировать уже проверенные части кода от изменений в подклассах. Благодаря полиморфизму взаимодействие уже проверен- ных частей кода с изменениями в подклассах может быть ограничено только теми уровнями иерархии наследования, которые важны для их функционирования. Возможность повторного использования. Полиморфизм облегчает повторное ис- пользование. Чтобы один объект мог использовать другой объект, он должен знать только интерфейс второго объекта, ему совсем не обязательно знать дета- ли реализации. В результате этого упрощается повторное использование. 242 Глава 8
Удобство сопровождения. Полиморфизм облегчает сопровождение — он позво- ляет уменьшить объем кода, а чем меньше объем кода, тем легче его сопровож- дать. Более того, полиморфизм позволяет уменьшить (и существенно упро- стить) операторы выбора (всевозможные case и лестницы if-else if). (В программе может исчезнуть все, что относится к проверке типов.) А ведь имен- но эти операторы представляют наибольшую трудность при выполнении проце- дур сопровождения! Возможность усовершенствования. Что представляет собой легко модифицируе- мое надежное программное обеспечение? Дать определение этому понятию просто: легко модифицируемое надежное программное обеспечение — это программное обеспечение, которое легко приспосабливается к изменяющимся требованиям. Требования изменяются все время. Даже в самом начале, когда вы еще только намереваетесь приступить к разработке программы, требования могут изменяться, поскольку вы все глубже постигаете решаемую проблему. А после разработки программы пользователи будут ожидать новых версий и требовать, чтобы вы добавили новые возможности в разработанное вами программное обес- печение. Если вы создадите легко модифицируемое надежное программное обес- печение, вам не придется полностью переписывать его каждый раз, чтобы удовле- творить очередное новое требование. Создание легко модифицируемых программ немыслимо без полиморфизма. Ведь программу, в которой используется поли- морфизм, легче усовершенствовать. Полиморфизм включения позволяет добав- лять к системе новые подтипы, причем система будет использовать добавлен- ный подтип автоматически, без каких-либо изменений в остальной части сис- темы. С помощью переопределения можно добавлять новые методы, причем конфликты из-за совпадения имен не возникнут. И наконец, параметрический полиморфизм позволяет автоматически расширять классы для поддержки но- вых типов. Удобство периодического выпуска (издания) новых версий. Полиморфизм помога- ет написать такой код, имея который, можно своевременно, с заданной перио- дичностью выпускать (издавать) новые версии, ведь меньший по объему код можно написать быстрее. Полиморфизм позволяет применять абстракцию в программировании, а благодаря этому в программы можно добавлять новые типы практически мгновенно. В итоге процедуры сопровождения и доработки программ выполняются гораздо быстрее. Резюме Полиморфный объект имеет много форм. Полиморфизм — это механизм, позво- ляющий одному имени представлять различный код. Так как одно имя может пред- ставлять различный код, это имя может выражать различное поведение. С помощью полиморфизма можно легко написать многоликий код, т.е. код, который может де- монстрировать различное поведение. Сам полиморфизм тоже полиморфен. Существует несколько различных видов по- лиморфизма. Наиболее распространенными формами полиморфизма являются поли- морфизм включения, параметрический полиморфизм, переопределение и перегрузка. С помощью полиморфизма включения объект может демонстрировать различное поведение во время выполнения. Благодаря параметрическому полиморфизму объект или метод может работать с параметрами, которые могут относиться к ряду различных типов. Используя переопределение, можно переопределить метод, а благодаря полимор- физму всегда будет выбран подходящий метод. Полиморфизм 243
И наконец, перегрузка позволяет описать один и тот же метод несколько раз. Ка- ждое описание отличается лишь сигнатурой, т.е. количеством и типом аргументов. С помощью приведения типов метод становится полиморфным, так как аргументы преобразовываются к тому типу аргументов, который требуется для метода. Полиморфизм позволяет сделать программу короче и понятнее, а значит облегчает ее сопровождение. Правда, истинно объектно-ориентированное программирование требует различных подходов к осмыслению программного обеспечения. Сильные стороны объектно- ориентированного подхода полностью проявятся лишь тогда, когда вы освоите все ка- тегории полиморфизма, т.е. научитесь думать “полиморфно”. Задачи и упражнения 1. Если в программе не используются все три базовых понятия объектно- ориентированного программирования одновременно, значит ли это, что про- грамма не является объектно-ориентированной? 2. В чем проявляется полиморфность полиморфизма? 3. Для чего предназначен полиморфизм включения? 4. Каким образом с помощью переопределения и параметрического полиморфиз- ма можно создать более естественную модель реального мира? 5. Почему при программировании следует опираться на интерфейс, а не на реали- зацию? 6. Как взаимодействуют полиморфизм и переопределение? 7. Можно ли перегрузку рассматривать как частный случай полиморфизма? 8. Для чего предназначена перегрузка? 9. Для чего предназначен параметрический полиморфизм? 10. Какие ловушки связаны с полиморфизмом? 11. Как инкапсуляция и наследование влияют на полиморфизм включения? 12. Кажется, что полиморфизм включения в применении более удобен, чем пере- грузка, поскольку нужно написать только один метод и сделать так, чтобы он работал со многими различными типами. Зачем же тогда использовать пере- грузку? 13. Как устранить условные операторы? 14. В чем состоит преимущество полиморфизма включения перед перегрузкой? 15. Следует ли с точки зрения объектно-ориентированного подхода запрашивать у объектов данные? 16. Каковы признаки неправильного применения условных выражений (признаки “плохих” условных выражений). 17. Объясните понятие полиморфизма своими собственными словами. < < < 244 Глава 8
Глава 9 Шаблоны и стандартная библиотека шаблонов STL В этой главе... Что такое шаблон 246 Создание шаблона 246 Параметризованные функции 246 Параметризованные классы 251 Стандартная библиотека шаблонов 278 Резюме 283 Задачи и упражнения 283 Полиморфизм сам по себе полиморфный! Из предыдущей главы От рождения до смерти, от субботы до субботы, с утра до вече- ра — все проявления жизни заданы заранее и подчинены шаб- лону. Как может человек, захваченный в эту сеть шаблона, не забыть, что он человек, уникальный индивид, тот единствен- ный, кому дан его единственный шанс прожить жизнь, с на- деждами и разочарованиями, с печалью и страхом, со стремле- нием любить и ужасом перед уничтожением и одиночеством? Эрих Фромм. Искусство любить Кто писал программы на языках со строгим контролем типов, тот знает, как часто приходится переписывать алгоритмы только потому, что имеющаяся в программе функция принимает данные типа, к которому нельзя привести тип данных, подлежа- щих обработке. И такое происходит постоянно — от субботы до субботы, с утра до ве- чера. Как же может программист, захваченный в эту сеть шаблона, не забыть, что он именно тот человек, уникальный индивид, который призван творить, а не заниматься рутинным копированием и вклеиванием да еще с заменой типов параметров? Что можно ответить на это? Едва ли вас устроит такой ответ: будьте выше мелочей, стои- чески выполняйте все эти копирования и вставки между завтраком и обедом, обедом и ужином, а также все оставшееся время. Конечно, щелкнуть мышью или нажать полдюжины клавиш — это мелочи, но, повторяемые с утра до вечера, такие мелочи лишают человека радостей жизни и являются источником многочисленных ошибок в программах. Так что нужен другой ответ. И вы его знаете: параметрический полимор- физм. В языке C++ он реализуется с помощью шаблонов. Бьярн Страуструп решил: кесарю — кесарево..., простите, я хотел сказать машине — шаблоны, а человеку — ра- дость творчества.
Разработав поддержку шаблонов в языке C++, Бьярн Страуструп назвал их пара- метризованными типами. Типами — потому что каждый шаблон класса или функции изменяется так, чтобы обрабатывать данные нужных типов. Параметризованными — потому что они являются параметрами в определении шаблона. Фактический тип оп- ределяется позже. Впрочем, впоследствии Бьярн Страуструп заменил название параметризованный тип на более общее — шаблон. Имея одно определение шаблона, компилятор может по нему автоматически создать нужные версии (они называются экземплярами) функции или класса. Что такое шаблон Шаблон представляет собой специальное описание родовой (параметризованной) функции или родового класса, т.е. такой функции или класса, которые могут обраба- тывать данные различных типов. Типы обрабатываемых данных как раз и являются параметрами шаблона. Шаблон и есть шаблон — по нему компилятор может создать класс или функцию, которая обрабатывает данные нужного типа. Тогда, понятно, программировать каждую версию функции или класса заново не придется. Компилятор на основе шаблона автоматически порождает (иногда говорят: созда- ет экземпляр, генерирует, экземпляризирует или инстанцирует) все необходимые вер- сии классов или функций, которые требуются для обработки данных используемых типов. Шаблоны особенно полезны при создании функций, реализующих обобщенные алгоритмы обработки, а также конструировании контейнерных классов. (Контейнером называется класс, который используется как структура данных, содержащая набор элементов (некоторого другого типа). Массивы, множества, списки, очереди, хэш- таблицы — все это примеры контейнеров.) Иногда шаблоны удобно рассматривать как “интеллектуальные” макросы, которые способны сообщить компилятору о каждом случае использования шаблона для обра- ботки данных новых типов. Создание шаблона Шаблон создается при помощи ключевого слова template, за которым в угловых скобках следует список параметров, а после него — описание класса или функции: template <class имя1, class имя2, ... > определение ; Вместо ключевого слова class можно использовать также ключевое слово typename. Параметризованные функции ...каждый день нам то и дело предлагаются новые государст- венные механизмы, устроенные по шаблону старых. Герберт Спенсер. Опыты научные, политические и философские. Том 3 Пусть необходимо создать функцию шах(х, у), возвращающую большее из значе- ний двух аргументов х и у, которые могут принадлежать к любому типу. Для решения этой проблемы можно использовать макрос: 246 Глава 9
#define max(x, у) (((x) > (у))? (x) : (у)) Конечно, использование #define обходит механизм контроля типов. Однако есте- ственно требовать, чтобы функция шах(х, у) сравнивала только совместимые типы. К сожалению, использование макроса допускает любое сравнение, например, между целочисленным значением и структурой. Второй недостаток использования макро- подстановки заключается в том, что подстановка будет выполнена везде. Поэтому та- кое использование макроопределений почти вышло из употребления в C++. Второе решение состоит в том, чтобы создать не макрос, а шаблон. Шаблон — это фактически заготовка для семейства связанных перегруженных функций или классов, причем тип данных передается ей как параметр: template <class Т> Т max(T х, Т у) { return (х > у) ? х : у; }; Здесь тип данных представлен параметром шаблона <class т>, где т — параметр (фиктивное имя типа данных). При использовании функции max в приложении ком- пилятор сгенерирует ее код для того типа данных, который используется в вызове функции. Рассмотрим простенький пример применения родовой (параметризованной) функ- ции для произвольного типа данных. #include <iostream> template <class T> T max(T x, T y) { return (x > у)? x : у; }; void main() { int i = 25; // аргументы являются целыми int j = max( i, 5); cout << " j= " << j << endl; // аргументы имеют тип double double d = max(1.7, 1952.0717); cout << " d= " << d ; } После выполнения программы получается следующий результат: 3 = 25 d= 1952.07 Не все компиляторы обрабатывают эту простенькую программу одинаково. Borland C++, например, позволяет построить объектный модуль и получить приведенный выше результат. А вот для MS Visual C++ .NET требуется еще добавить оператор using namespace std;. Однако после добавления этого оператора оказывается, что в операторе int j = max( i, 5); компилятор MS Visual C++ .NET обнаруживает ошибки error C2667 : 'max' : none of 3 overloads have a best conversion и error C2668: 'max' : ambiguous call to overloaded function. Те же самые ошибки этот ком- пилятор обнаруживает и в операторе double d = max(1.7, 1952.0717);. Это происходит из-за более строгого контроля сигнатур. Шаблоны и стандартная библиотека шаблонов STL 247
В подстановке <class т> может использоваться любой тип данных, а не толь- ко класс. Компилятор сам позаботится о вызове соответствующего оператора сравнения (для этого, конечно, должен быть при необходимости определен operator>()), так что определенную таким образом функцию можно приме- нить к аргументам любого типа, для которого определен оператор сравнения. Как видим из этого примера, при использовании шаблонов следует проявлять ос- торожность. Дело в том, что логика работы созданной параметризованной функции для определенных типов данных может отличаться от того, что обычно ожидает про- граммист. Следующий пример (программа “Кто в доме хозяин?”) иллюстрирует именно такой неожиданный эффект использования шаблона. // программа "Кто в доме хозяин?" // использование шаблона - неожиданный эффект. #include <string.h> #include <iostream> template <class т> T max (T x, T y) { return (x > y) ? x : y; }; int main() { int a = 5, b=30 , c=15, d = 0; float pi = 3.141596, e = 2.1718281828; d = max (a, max (b, c)) ; cout << " Наибольшее из трех целых:" « d << endl; cout << " Наибольшее из двух целых:" « max(d, а) << endl; cout << " max(pi, e) = " << max(pi, e) << endl; char m[] = "Мама", p[] = "Папа", *s; // Внимание! Функция max сравнит два // указателяf а не содержимое строк! s = тах(р, т); cout << "Кто в доме хозяин? :" << s << endl; char pl[8], ml[8], т2[8], р2[8]; strcpy(pl, р); strcpy(ml, т); s = max(pl, ml); cout « "Так кто в доме хозяин? :" « s « endl; strcpy(p2, р); strcpy(m2, т); s = тах(р2, m2); cout « "Так кто все-таки в доме хозяин? :" « s << endl; } После выполнения программы может получиться следующий результат: Наибольшее из трех целых:30 Наибольшее из двух целых:30 max(pi, е) = 3.1416 Кто в доме хозяин? :Мама Так кто в доме хозяин? :Папа Так кто все-таки в доме хозяин? :Мама Такое непостоянство! И это несмотря на то, что в кодировке ASCII справедливо отношение "Папа" > "Мама"! Дело в том, что в последнем случае программа сравни- вает не две строки (посимвольно), а указатели! А вы знаете, что результаты такого сравнения зависят от адресов, по которым в памяти расположены строки символов. Так что программа “Кто в доме хозяин?” содержит ошибку. Но ошибка не синтакси- ческая и потому компилятор (это был Borland C++) ее не заметил. Ошибка семанти- ческая: результат сравнения не может быть адекватно интерпретирован в предметной области, поскольку в концептуальной модели порядку расположения строк в памяти не придается никакого смысла. Давайте теперь разберемся, почему так получилось. 248 Глава 9
Как мы знаем, экземпляр функции создается каждый раз, когда компилятор находит вызов функции с такой сигнатурой, для которой функция еще не определена. Поэто- му для программы “Кто в доме хозяин?” компилятор автоматически сгенерировал (скроил) по шаблону следующие три перегруженные функции: int max (int х, int у) {return (х > у) ? х : у;} float max (float х, float у) {return (х > у) ? х : у;} char* max (char * х, char * у) {return (х > у) ? х : у;} Отсюда видно, что в третьей версии сравниваются не строки, а указатели. Попыта- емся теперь исправить программу так, чтобы бессмысленное сравнение двух указате- лей не проводилось, а сравнивались сами строки. Для этого функцию max можно яв- но перегрузить для строк следующим образом: char *max(char *х, char *y) { return(strcmp(x, y) > 0) ? x : y; } Получается следующая программа: // программа "Кто в доме хозяин?" -- ИСПРАВЛЕННАЯ // неожиданный эффект от использования шаблона предотвращается // перегрузкой функции шах для строк. #include <string.h> #include <iostream> char *max(char *x, char *y) { return(strcmp(x, y) > 0) ? x : y; } template <class T> T max (T x, T y) { return (x > y) ? x : y; }; int main() { int a = 5, b=30, c=15, d = 0; float pi = 3.141596, e = 2.1718281828; d = max (a, max (b, c)) ; cout << " Наибольшее из трех целых:" << d << endl; cout << " Наибольшее из двух целых:" << max(dz а) << endl; cout « " max(pi, e) = " << max(pi, e) << endl; char m[] = "Мама", p[] = "Папа", *s; // Внимание! Функция max сравнит не два // указателя, а содержимое строк! s = max(р, ш); cout « "Кто в доме хозяин? :" « s << endl; char pl [ 8 ] , ml[8], m2[8], р2[8]; strcpy(pl, р); strcpy(ml, m); s = max(pl, ml); cout << "Так кто в доме хозяин? :" << s << endl; strcpy(p2, p); strcpy(m2, m); s = max(p2, m2); cout << "Так кто все-таки в доме хозяин? :" << s << endl; } А вот и “исправленные” результаты: Наибольшее из трех целых:30 Наибольшее из двух целых:30 max(pi, е) = 3.1416 Кто в доме хозяин? :Папа Шаблоны и стандартная библиотека шаблонов STL 249
Так кто в доме хозяин? :Папа Так кто все-таки в доме хозяин? :Папа В исправленной программе компилятор не генерирует функцию по шаблону для аргументов типа char*, а использует уже имеющуюся. Впрочем, есть и другое реше- ние проблемы — использовать классы, для которых определена операция сравне- ния >, например, класс string (string в Borland C++). Тогда программа получится следующая: // программа "Кто в доме хозяин?" — ИСПРАВЛЕННАЯ // использование шаблона - для класса string. #include <iostream> #include <string> template <class T> T max (T x, Ту) { return (x > y) ? x : y; }; int main() { int a = 5, b=30 , c=15, d = 0; float pi = 3.141596, e = 2.1718281828; string m = "Мама", p = "Папа", s; d = max (a, max (b, c)) ; cout << " Наибольшее из трех целых:" « d « endl; cout << " Наибольшее из двух целых:" « max(d, а) << endl; cout << " max(pi, e) = " << max(pi, e) « endl; // Внимание! Функция max сравнит не два // указателя, а содержимое строк! s = max(р, ш); cout << "Кто в доме хозяин? :" << s << endl; string pl, ml, m2, p2; pl = p; ml = m; s = max(pl, ml); cout << "Так кто в доме хозяин? :" << s << endl; р2 = р; m2 = m; s = max(p2, m2); •cout « "Так кто все-таки в доме хозяин? :" « s « endl; } Результат, конечно, будет тот же, что и в предыдущей “исправленной” версии. Следует иметь в виду следующую особенность параметризованных функций: для них приведение (преобразование) типов аргументов по умолчанию не выполняется. И это еще не все. Дело в том, что компилятор для генерации тела функции должен определить фактические типы, замещающие формальные параметры шаблона. По этой причине сигнатура вызова параметризованной функции должна быть такой, чтобы по ней можно было сопоставить фактические типы всем формальным параметрам шаблона. Фактически это означает, что в вызове параметризованной функции используются все параметры шаблона. Просматривая программу, компилятор подчас неявно генерирует нужные ему вер- сии функции. Однако некоторые компиляторы, когда встречают вызов такой функции с другим типом операндов, вспомнить о ней ничего не могут. Давайте разберем сле- дующий пример, в котором для использования шаблона нужно выполнить приведение (преобразование) типов аргументов: template <class Т> Т min(T а, Т Ь) { return (а < b) ? а : Ь; }; void f (int i, char c) { 250 Глава 9
mind, i) ; // вызов min(int, int ) mind, с) ; // вызов min (char, char) mind, с) ; // нет совпадений для min(int, char) ! mind, i) ; // нет совпадений для min (char, int) ! } Такие компиляторы в вызовах функции min (int, char) и min (char, int) ус- мотрят ошибку, хотя тип char запросто приводится к int. Чтобы напомнить такому “забывчивому” компилятору об имеющейся версии функции, необходимо явно объя- вить функцию так, чтобы компилятор смог выполнить приведение (преобразование) типов. Для этого достаточно добавить в указанную программу только объявление функции, а тело будет создано компилятором по шаблону! Так, в рассмотренную вы- ше программу для этого достаточно добавить следующее объявление: int min(int, int) ; Может возникнуть вопрос, куда нужно вставить такое объявление функции? Луч- ше всего сделать это до вызова функции: template cclass Т> Т min(T а, Т Ь) { return (а < b) ? а : Ь; }; int min(int, int); void f (int i, char c) { mind, i) ; // вызов min (int, int ) mind, с) ; // вызов min (char, char) mind, с) ; // вызов min (int, int ) ! mind, i) ; // вызов min (int, int ) 1 } Параметризованные классы ...современный человек живет в состоянии иллюзии, будто он знает, чего хочет; тогда как на самом деле он хочет того, чего должен хотеть в соответствии с общепринятым шаблоном! Эрих Фромм. Бегство от свободы Надеюсь, вы еще помните, что данная глава посвящена реализации одной из форм полиморфизма — параметрическим типам. И хотя термин “полиморфизм” использу- ется в программировании довольно давно (более десятилетия!), сам полиморфизм значительно старше. Ему более чем полвека! Ведь как только в языках программиро- вания появился знак “+”, появилась и полиморфная операция. Обсудив параметризо- ванные функции, частным случаем которых являются полиморфные операции, мы перейдем к обсуждению параметризованных классов. Хотя этот термин и звучит вполне современно, сама сущность, обозначаемая им, пожалуй, не менее стара, чем полиморфизм, связанный с использованием знака “+” в языках программирования. Ведь уже в самых ранних языках программирования были предусмотрены средства доступа к отдельным элементам данных, которые хранятся в специальных регулярных структурах данных — массивах. Наиболее распространенным видом массива является, пожалуй, одномерный, часто называемый вектором. А вектор позволяет хранить биты, целые, вещественные, комплексные числа; да и вообще почти во всех (даже самых первых) языках высокого уровня предусмотрена возможность создания регулярного типа, такого как вектор, на основе любого базового. А это означает, что в языке зало- жен (возможно, неявно) шаблон, по которому на основе базового типа создается нуж- ный вам регулярный тип (например вектор). Независимо от того, какие элементы Шаблоны и стандартная библиотека шаблонов ST! 251
хранятся в векторе — целые, вещественные, комплексные числа или данные любого другого типа, возможно даже векторы или многоуровневые структуры, — основные операции, которые позволяет выполнять вектор, — одни и те же (инициализация, по- лучение элемента по индексу и т.п.). А вы хорошо знаете, что любой тип подобен классу. Фактически понятие класса — это логическое обобщение понятия типа. И на- верное, многие программисты мечтали, чтобы в языках программирования было сред- ство, позволяющее на основе некоей схемы (например стека, очереди, дека и т.д.) и некоего уже имеющегося типа (да хотя бы и класса) создавать новый тип, например стек, очередь, дек для хранения объектов нужного типа. Тогда, например, если бы нам не понравился массив, мы могли бы создать свою версию вектора! И такое сред- ство в C++ есть — это шаблоны. С их помощью можно создавать не только родовые функции, но и классы, а значит, и новые типы данных, ведь класс фактически и есть тип данных. Давайте, например, прямо сейчас создадим класс Vector (одномерный массив), ведь у вас уже есть практика работы с шаблонами. Так что для определения такого класса, естественно, используем шаблон. Аналогично тому, как компилятор по шаб- лону функции генерирует функции, при подстановке фактического типа в шаблон класса Vector он автоматически создает класс. Применение шаблона для определения класса Vector // Применение шаблона для определения класса Vector ttinclude <iostream> template <class T> class Vector { T Mata; int size; public: Vector (int) ; -Vector ( ) { delete [ ] data; } T& operator [ ] (int i) { return data[i]; } }; template cclass T> Vector <T> :: Vector (int n) { // конструктор data = new T[n] ; size = n; }; int main ( ) { Vector<int> x(10); // создаем вектор из 10 элементов типа int for (int i = 0; i < 10; ++i) x[i] = i * i; // присваиваем значения Vector<char> c (5); // создаем вектор из 5 элементов типа char for (char ic = 0; ic < 5; ++ic) c[ic] = ic + 'a'; // присваиваем значения // for (int i = 2; i < 7; i++ ) cout << x[i] « " "; return 0; } Вот результат: 4 9 16 25 36 Как мы помним, в программе “Кто в доме хозяин?” нам пришлось запретить ге- нерацию параметризованной функции для типа char *. Точно так же как и парамет- 252 Глава 9
ризованную функцию, класс Vector можно “перегрузить” для определенного типа данных, например char *: class Vectorcchar *>{...}; Фактически это запрещает автоматическую генерацию классов по шаблону, поэто- му вектор строк всегда будет таким, каким вы его объявили, задав тип char * в угло- вых скобках. Итак, параметризованный класс Vector создан. Конечно, мы немного торопились, и некоторые ключевые моменты остались “за кадром”. Теперь, когда общая картина создания и применения параметризованных классов уже прояснилась, разберем соз- дание шаблонных классов еще раз, подробно рассматривая все ключевые этапы. Итак, давайте приступим к созданию шаблона класса Array... Создание экземпляра шаблона Создание экземпляра (instantiation) параметризованного класса — это фактически создание определенного типа из шаблона. Созданные классы, конечно, называются экземплярами шаблона. Параметризованный шаблон (parameterized template) представ- ляет собой общий, или родовой класс; чтобы построить по нему конкретный экземп- ляр, шаблону в качестве параметров нужно передать типы данных. Для этого, конеч- но, нужно предварительно объявить шаблон. Объявление шаблона Объявление шаблона класса, как следует из синтаксиса определения общего шаб- лона, отличается от объявления шаблона функции только тем, что вместо определе- ния функции в нем должно быть записано определение класса. Таким образом, в на- чале каждого объявления и определения класса-шаблона необходимо записать ключе- вое слово template, за которым располагаются параметры шаблона. Параметры, естественно, — это то, что замещается в экземпляре шаблона. Поскольку мы создаем шаблон массива, один экземпляр шаблона может представлять собой массив целых чисел, а другой — массив экземпляров какого-нибудь класса, например Animals. По- этому тип объектов, сохраняемых в массиве, нужно сделать параметром шаблона. В качестве идентификатора этого параметра естественно использовать т (тип). Список параметров, как мы помним, заключается в угловые скобки. Начать список парамет- ров можно с ключевого слова class, за которым следует идентификатор т. Это клю- чевое слово означает, что параметром является тип. Идентификатор т в остальной части определения шаблона будет указывать параметризованный тип. В одном экзем- пляре этого класса вместо идентификатора т повсюду может стоять, например, тип int, а в другом — тип Cat. Вот так может в общем выглядеть объявление шаблона Array: template <class Т>// объявляем шаблон и параметр class Array { // параметризуемый класс public: Array(); // здесь должно быть полное определение класса } ; А вот как можно объявить экземпляры параметризованного класса Array для ти- пов int и Cat: Array <int> anlntArray; Array <Cat> aCatArray; Шаблоны и стандартная библиотека шаблонов STL 253
Объект anintArray представляет собой массив целых чисел, а объект aCatArray — массив элементов типа Cat. Теперь тип Array<int> можно использо- вать везде, где допустим какой-либо тип — перед определением функции для указа- ния возвращаемого функцией типа, для указания типа параметра функции и т.д. Ко- нечно, пока мы создали только набросок шаблона Array. Шаблон класса Array Теперь дополним объявление шаблона Array: // Шаблон класса Array // Шаблон класса массивов #include <iostream> const int Defaultsize = 10; // Определение шаблона начинается с ключевого слова template, // за которым следует параметр. Идентификатору параметра // предшествует ключевое слово class, поэтому он рассматривается как // тип. Таким образом, идентификатор Т обозначает параметр-тип. // объявляем шаблон и параметр template <class Т> // параметризуемый класс — объявление класса class Array { public: // конструкторы Array(int itsSize = Defaultsize); Array(const Array &rhs); -Array() { delete [] pType; } // операторы Array& operator=(const Array&); T& operator[](int offset) { return pType[offset]; } // методы доступа int getSize() { return itsSize; } private: T *pType; int itsSize; } ; Конечно, это еще не программа, а только определение шаблона. Объявление класса в определении шаблона отличается от обычного определения класса только тем, что везде, где обычно должен стоять тип объекта, используется идентификатор Т. Например, operator [ ] должен возвращать ссылку на объект в мас- сиве, поэтому он объявлен как возвращающий ссылку на идентификатор типа (т). Если создать экземпляр целочисленного (int) массива, перегруженный оператор присваивания этого класса возвратит ссылку на тип int. А если создать экземпляр массива элементов типа Animal, оператор присваивания возвратит ссылку на объект типа Animal. 254 Глава 9
Использование имени шаблона Внутри объявления класса слово Array может использоваться без спецификаторов. В других местах программы этот класс следует упоминать как Аггау<т>. Например, если конструктор вынести вне объявления класса, то его нужно определить так: // Определение конструктора вне объявления класса /7 в качестве параметра тип данных (class Т). template <class Т> Array<T>::Array(int size): itsSize = size { pType = new T[size]; for (int i = 0; i<size; i++) pTypeti] = 0; } В программе вне объявления класса именем шаблона является Аггау<т>, а име- нем функции-члена — Array(int size). В целом же функция имеет такой же вид, как и любая другая. Именно так рекомендуется создавать классы и его функции — объявить их до включения в шаблон. Простая программа с шаблоном массива Интерфейс класса-шаблона Чтобы класс-шаблон Array был полноценным классом, необходимо создать кон- структор-копировщик и перегрузить оператор присваивания (operator=). Класс Array будет содержать два конструктора, один из которых будет иметь параметр (размер массива). По умолчанию значение параметра устанавливается равным значе- нию целочисленной константы Defaultsize. Кроме того, объявим операторы присваивания и индексирования, причем для опе- ратора индексирования объявим как константную (с ключевым словом const), так и неконстантную (без ключевого слова const) версии. В качестве единственного метода доступа служит функция Getsize (), которая возвращает размер массива. Этот интерфейс, конечно, далеко не полный. Ведь для любой программы, в кото- рой серьезно используются массивы, представленный здесь вариант интерфейса будет недостаточным. Как минимум, необходимо добавить операторы, предназначенные для удаления элементов, для распаковки и упаковки массива и т.д. (Все это предусмотрено в классах контейнеров, которые имеются в библиотеке стандартных шаблонов STL.) Закрытые данные класса-шаблона Раздел закрытых данных содержит переменные-члены — размер массива и указа- тель на массив объектов. Текст программы с шаблоном массива // программа с шаблоном массива #include <iostream.h> const int Defaultsize = 10; // обычный класс Animal для // создания массива животных // создание класса Animal class Animal { Шаблоны и стандартная библиотека шаблонов STL 255
public: Animal (int) ; Animal(); -Animal() { } int GetWeightO const { return itsWeight; } void Display() const { cout « itsWeight; } private: int itsWeight; }; Animal::Animal(int weight): itsWeight (weight) О Animal::Animal() : itsWeight(0) {} // объявляется шаблон, параметром для которого является тип, // обозначенный идентификатором Т. // объявляем шаблон и параметр template <class Т> // параметризованный класс class Array { public: // конструктор Array(int itsSize = Defaultsize); Array(const•Array &rhs); -Array() { delete [] pType; } // оператор Array& operator=(const Array&); T& operator[](int offset) { return pType[offSet]; } const T& operator[](int offset) const { return pType[offSet]; } // методы доступа int GetSizeO const { return itsSize; } private: T *pType; int itsSize; }; // реализация... // реализация конструкторов template <class T> Array<T>::Array(int size): itsSize(size) { pType = new T[size]; for (int i = 0; i<size; i++) pType[i] = 0 ; } 256 Глава 9
// конструктор-копировщик textplate <class T> Array<T>::Array(const Array &rhs) { itsSize = rhs.GetSize(); pType = new T[ itsSize]; for (int i = 0; i<itsSize; i++) pType[i] = rhs[ i ] ; } // оператор присваивания -- operator= template <class T> Array<T>& Array<T>::operator=(const Array &rhs) { if (this == &rhs) return *this; delete [] pType; itsSize = rhs.GetSize(); pType = new T[itsSize]; for (int i = 0; i<itsSize; i++) pType[i] = rhs[ i ] ; return *this; // главная программа int main() { // массив целых Array<int> theArray; // массив животных Array<Animal> theZoo; Animal *pAnimal; // заполняем массив for (int i = 0; i < theArray.GetSize(); i++) { theArray[i] = i * 2; pAnimal = new Animal(i*3); theZoo[i] = *pAnimal; delete pAnimal; } // выводим на печать содержимое массивов for (int j = 0; j < theArray.GetSize(); j++) { cout « "theArray[" « j « "]:\t"; cout « theArray[j] « "\t\t"; cout « "theZoo[" « j << "]:\t"; theZoo[j]. Display(); cout « endl; } return 0; } Вот результат: theArray[0]: 0 theZoo[0]: 0 theArray[1]: 2 theZoo[1]: 3 theArray[2]: 4 theZoo[2]: 6 theArray[3]: 6 theZoo[3]: 9 theArray[4]: 8 theZoo[4]: 12 Шаблоны и стандартная библиотека шаблонов STL 257
theArray[5]: 10 theArray[6]: 12 theArray[7]: 14 theArray[8]: 16 theArray[9]: 18 theZoo[5]: 15 theZoo[6]: 18 theZoo[7]: 21 theZoo[8]: 24 theZoo[9]: 27 Объявление функций, типы параметров которых являются параметрическими Функции передается объект, т.е. конкретный экземпляр, а не шаблон. Поэтому объявление функции SomeFunction (), принимающей в качестве параметра целочис- ленный массив, выглядит так: void SomeFunction(Array<int>&);// правильно А вот такое “объявление” // ошибка! void SomeFunction(Array<T>&); недопустимо, поскольку из него не понятно, что представляет собой выражение <т>&. Недопустимо и такое “объявление”: void SomeFunction(Array &);// ошибка! поскольку объекта класса (типа) Array не существует — есть только шаблон и его эк- земпляры. Но если у вас есть объекты, созданные на основе шаблона, лучше всего объявить и функцию с помощью шаблона: template <class Т> void MyTemplateFunction(Array<T>&); // верно Функции, объявленные с помощью шаблонов, могут иметь параметры, тип кото- рых может быть задан не только как параметрический, но и как экземпляр шаблона. Вот пример: template <class Т> void MyOtherFunction(Array<Т>&, Array<int>&); // верно Эта функция объявлена как принимающая два массива, причем тип первого задан как параметрический, а второго — как экземпляр шаблона (массив целых чисел). По- этому первый параметр может быть массивом любых объектов, а второй — только массивом целых чисел. Шаблоны и друзья В С++ с помощью ключевого слова friend функцию (или класс) можно объявить дружественной классу. Дружественный объект получает доступ к любым (в том числе и закрытым (private)) членам класса. Конечно, это может запросто разрушить ин- капсуляцию, поэтому использование ключевого слова friend пуристами строго осу- ждается. Тем не менее, в шаблонах классов могут быть объявлены три типа друзей: • дружественный класс или функция, не являющиеся шаблонными; • дружественный шаблон класса или функция, входящая в шаблон; • дружественный шаблон класса или шаблонная функция определенного типа данных. 258 Глава 9
Дружественные классы и функции, не являющиеся шаблонными Любой класс или функцию можно объявить дружественными шаблонному классу. Тогда каждый экземпляр шаблонного класса будет считать объявленную функцию (или класс) дружественной точно так, как будто объявление друга было сделано в этом конкретном экземпляре шаблонного класса. Давайте в определении шаблона- класса Array объявим дружественной несложную функцию Intrude (), а в главной программе вызовем эту функцию. В качестве дружественной функция intrude () по- лучит доступ к закрытым членам-данным, переменным-членам и функциям-членам экземпляра класса Array. Но поскольку эта функция не является шаблонной, то ее можно вызывать только для массива заданного типа (в нашем примере для массива целых чисел). // Дружественная функция, не являющаяся шаблонной. #include <iostream> const int Defaultsize = 10; // объявляем простой класс Animal, чтобы // создать массив животных class Animal { public: Animal (int) ; Animal(); -Animal() { } int GetWeight() const { return itsWeight; } void Display() const { cout « itsWeight; } private: int itsWeight; }; Animal: :Animal (int weight) : itsWeight(weight) {} Animal:: Animal () : itsWeight (0) {} texnplate<class T> // объявляем шаблон и параметр class Array { // параметризованный класс public: // конструктор Array(int itsSize = Defaultsize); Array(const Array &rhs); -Array() { delete [] pType; } // операторы Array& operator=(const Array&); T& operator[](int offset) { return pType[offSet]; } const T& operator[](int offset) const { return pType[offSet]; } // методы доступа int GetSize() const { return itsSize; } // дружественная функция friend void Intrude(Array<int>); Шаблоны и стандартная библиотека шаблонов STL 259
private: T *pType; int itsSize; }; // Поскольку дружественная функция не является шаблонной, ее можно // применять только для массивов целых чисел! Однако она получает // доступ к закрытым данным любого экземпляра шаблонного класса. void Intrude(Array<int> theArray) { cout « "\n*** Intrude ***\n"; // Функция Intrude() получает прямой доступ к переменной-члену itsSize for (int i = 0; i < theArray.itsSize; i++) // Функция Intrude() получает прямой доступ к переменной-члену pType cout << "i: " « theArray.pType[i] « endl; cout << "\n"; } // реализация... // реализация конструктора template <class T> Array<T>::Array(int size): itsSize(size) { pType = new Tfsize]; for (int i = 0; i<size; i++) pType[i] = 0; } // конструктор-копировщик template <class T> Array<T>::Array(const Array &rhs) { itsSize = rhs.GetSize(); pType = new T[itsSize]; for (int i = 0; i<itsSize; i++) pType[i] = rhs[ i ] ; } // перегрузка оператора присваивания (=) textplate <class T> Array<T>& Array<T>::operator=(const Array &rhs) { if (this == &rhs) return *this; delete [] pType; itsSize = rhs.GetSize(); pType = new TfitsSize]; for (int i = 0; i<itsSize; i++) pType[i] = rhs[i]; return *this; // главная программа int main() { 260 Глава 9
Array<int> theArray; // массив целых Array<Animal> theZoo; // массив животных Animal *pAnimal; // заполняем массивы for (int i = 0; i < theArray.GetSize(); i++) { theArray[i] = i * 2; pAnimal = new Animal(i*3); theZoo[i] = *pAnimal; } int j ; for (j = 0; j < theArray .GetSize () ; j++) { cout « "theZoo[" « j « theZoo[j].Display(); cout « endl; } cout « "Now use the friend function to "; cout « "find the members of Array<int>"; Intrude(theArray); cout « "\n\nDone.\n"; return 0; Вот результат: theZoo[0]: 0 theZoo[1]: 3 theZoo[2]: 6 theZoo[3]: 9 theZoo[4]: 12 theZoo[5]: 15 theZoo[6]: 18 theZoo[7]: 21 theZoo[8]: 24 theZoo[9]: 27 Now use the friend function to find the members of Array<int> * * * Intrude * * * i: 0 i: 2 i: 4 i: 6 i: 8 i: 10 i: 12 i: 14 i: 16 i: 18 Done. Функция intrude () получает прямой доступ к переменным-членам itsSize и pType. Поскольку класс Array предоставляет открытые методы доступа к этим данным, можно было бы вполне обойтись без использования дружест- венной функции. Шаблоны и стандартная библиотека шаблонов STL 261
Общий шаблон для дружественного класса или функции В класс Array было бы весьма полезно добавить оператор вывода данных. Это можно сделать путем объявления оператора вывода для каждого возможного типа массива, но лучше воспользоваться имеющимся шаблоном для класса Array. При таком подходе нам необходим оператор вывода, работающий независимо от типа экземпляра массива: ostream& operator*:< (ostream&, Array<T>&) ; Чтобы этот оператор работал для всех типов экземпляра массива, нужно его объя- вить как шаблонную функцию: template <class Т> ostream& operator<< (ostream&, Array<T>&) Теперь operator<< является шаблонной функцией и его можно использовать в реализации класса. В приведенной ниже программе показано, как объявление шабло- на Array можно дополнить объявлением функции-оператора вывода operator«. В шаблоне класса Array шаблонная функция operator<< () объявляется дружест- венной. А поскольку operator<< () реализован в виде шаблонной функции, то каж- дый экземпляр типа параметризованного массива (класса Array) будет автоматически иметь функцию operator<< () для вывода данных типа, являющегося экземпляром класса Array. В реализации оператора operator<< () каждый член массива распеча- тывается в порядке очереди. // Использование оператора вывода tinclude <iostream.h> const int Defaultsize = 10; class Animal { public: Animal(int); Animal(); -Animal() { } int GetWeight() const { return itsWeight; } void Display() const {' cout « itsWeight; } private: int itsWeight; } ; Animal: :Animal (int weight) : itsWeight(weight) { } Animal: .-Animal () : itsWeight(0) { } // объявляем шаблон и параметр template <class T> class Array {// параметризованный класс public: // конструктор Array(int itsSize = Defaultsize); Array(const Array &rhs); -Array() { delete [] pType; } 262 Глава 9
// оператор Arrays operator=(const Arrays); TS operator[](int offset) { return pType[offSet]; } const TS operator[](int offset) const { return pType[offSet]; } // методы доступа int GetSize() const { return itsSize; } // в шаблоне класса Array // функция operator«() объявляется дружественной friend ostreamS operator<< (ostreamS, Array<T>S); private: T *pType; int itsSize; } ; // Реализация оператора operator« () . template <class T> ostreamS operator« (ostreamS output, Array<T>S theArray) { for (int i = 0; i<theArray.GetSize(); i++) output « "[" « i « "] " << theArray[i] « endl; return output; } // Реализация... // реализация конструктора template <class T> Array<T>::Array(int size): itsSize(size) { pType = new T[size]; for (int i = 0; i<size; i++) pType[i] = 0; } // конструктор-копировщик template <class T> Array<T>::Array(const Array Srhs) { itsSize = rhs.GetSize(); pType = new T[itsSize]; for (int i = 0; i<itsSize; i++) pType[i] = rhs[ i ] ; } // перегрузка оператора присваивания = template <class T> Array<T>S Array<T>::operator=(const Array Srhs) { if (this == Srhs) return *this; delete [] pType; itsSize = rhs.GetSize(); pType = new T[itsSize]; Шаблоны и стандартная библиотека шаблонов STL 263
for (int i = 0; i<itsSize; i++) pType[i] = rhs[ i ] ; return *this; int main() { bool Stop = false; // признак (флаг) для цикла int offset, value; Array< int > theArray; while (IStop) { cout « "Enter an offset (0-9) "; cout « "and a value. (-1 to stop): " ; cin » offset » value; if (offset < 0) break; if (offset > 9) { cout « "***Please use values between 0 and 9.***\n"; continue; theArray[offset] = value; } cout « "XnHere's the entire array :\n"; cout « theArray « endl; return 0; } Вот результат: Enter an offset (0-9) and a value. (-1 to stop): 1 10 Enter an offset (0-9) and a value. (-1 to stop): 2 20 Enter an offset (0-9) and a value. (-1 to stop): 3 30 Enter an offset (0-9) and a value. (-1 to stop): 4 40 Enter an offset (0-9) and a value. (-1 to stop): 5 50 Enter an offset (0-9) and a value. (-1 to stop): 6 60 Enter an offset (0-9) and a value. (-1 to stop): 7 70 Enter an offset (0-9) and a value. (-1 to stop): 8 80 Enter an offset (0-9) and a value. (-1 to stop): 9 90 Enter an offset (0-9) and a value. (-1 to stop): 10 I 10 ★★★Please Enter an j use values offset (0-9) between 0 and and a value. 9. (-1 t ★ to stop): -1 . -1 Here’s the entire array : [0] 0 [1] 10 [2] 20 [3] 30 [4] 40 [5] 50 [6] 60 [7] 70 [8] 80 [9] 90 264 Глава 9
Использование экземпляров шаблона Экземпляры шаблона класса можно использовать так же, как и любые другие типы данных. В частности, они могут быть типами параметров, передаваемых в функции, типами ссылок на параметры и типами возвращаемых значений (типами результатов функций) или типами ссылок на них. И конечно же, объекты соответствующих типов могут быть фактическими параметрами функций. Некоторые способы использования экземпляров шаблона показаны в приведенной ниже программе. В этой программе объявлен шаблон класса Array и обычный класс Animal. Класс Animal предельно прост, хотя и содержит собственный оператор вывода «, позво- ляющий выводить на экран элементы массива типа Animal. Поскольку при добавлении объекта в массив для данного объекта используется конструктор по умолчанию, то в классе Animal должен быть объявлен конструктор по умолчанию (стандартный конструктор, или конструктор без параметров). Кроме того, в программе объявлены функции intFillFunction() и AnimalFillFunction(), предназначенные для заполнения массивов. Параметр функции intFillFunction () — целочисленный массив. Эта функция не является шаблонной, поэтому она в качестве параметра может принять только массив целочис- ленных значений. А функция AnimalFillFunction () принимает в качестве парамет- ра массив объектов типа Animal. Эти функции реализованы по-разному, поскольку заполнение массива целых чисел отличается от заполнения массива объектов Animal. // Передача функции объектов, имеющих тип экземпляра шаблона tinclude <iostream> const int Defaultsize = 10; // Обычный класс, из объектов которого будет состоять массив // объявление класса Animal class Animal { public: // конструктор Animal(int); Animal(); -Animal(); // методы доступа int GetWeight() const { return itsWeight; } void SetWeight(int theWeight) { itsWeight = theWeight; } // дружественные операторы friend ostreamfc operator« (ostreamk, const Animalfc); private: int itsWeight; }; // оператор вывода объектов типа Animal ostream& operator« (ostreamic theStream, const Animalfc theAnimal) { theStream « theAnimal.GetWeight(); return theStream; } Шаблоны и стандартная библиотека шаблонов STL 265
Animal: :Animal (int weight) : itsWeight(weight) { // cout « "Animal(int) \n"; } Animal::Animal(): itsWeight(0) { // cout « "Animal () \n"; } Animal::-Animal() { // cout « "Destroyed an animal...\n"; } // объявление шаблона и параметра template <class T> // параметризованный класс class Array { public: Arraydnt itsSize = Defaultsize); Array(const Array &rhs); -Array() { delete [] pType; } Array& operator=(const Array&); T& operator[](int offset) { return pType[offSet]; } const T& operator[](int offset) const { return pType[offSet]; } int GetSize() const { return itsSize; } // дружественная функция friend ostreamfc operator« (ostreamfc, const Array<T>&); private: T *pType; int itsSize; } ; template <class T> ostreamic operator<< (ostream& output, const Array<T>& theArray) { for (int i = 0; i<theArray.GetSize(); i++) output « "[" « i « "] " « theArray[i] « endl; return output; // реализация... // реализация конструктора template <class T> Array<T>::Array(int size): itsSize(size) { 266 Гпава 9
pType = new T[size]; for (int i = 0; i<size; i++) pType[i] = 0; // конструктор-копировщик template <class T> Array<T>: -.Array(const Array &rhs) { itsSize = rhs.GetSize(); pType = new T[itsSize]; for (int i = 0; i<itsSize; i++) pType[i] = rhs[i]; } // объявление функции IntFillFunction(), параметр которой — // целочисленный массив. Эта функция не является шаблонной, поэтому // она в качестве параметра может принять только массив // целочисленных значений. void IntFillFunction(Array<int>& theArray); // объявление функции AnimalFill Function(), которая в качестве // параметра принимает массив объектов типа Animal. void AnimaIFi1lFunction(Array<Animal>& theArray); int main() { Array<int> intArray; Array<Animal> animalArray; IntFillFunction(intArray); AnimalFillFunction(animalArray); cout « "intArray...\n" « intArray; cout « "\nanimalArray...\n" « animalArray « endl; return 0; } void IntFillFunction(Array<int>& theArray) { bool Stop = false; int offset, value; while (’Stop) { cout « "Enter an offset (0-9) "; cout « "and a value, (-1 to stop): " ; •cin » offset » value; if (offset < 0) break; if (offset > 9) { cout « "***Please use values between 0 and 9.***\n"; continue; } theArray[offset] = value; } } void AnimalFillFunction(Array<Anima1>& theArray) { Animal * pAnimal; Шаблоны и стандартная библиотека шаблонов STL 267
for (int i = 0; i<theArray.GetSize(); i++) { pAnimal = new Animal; pAnimal->SetWeight(i*100); theArray[i] = *pAnimal; delete pAnimal; // копия была помещена в массив } } Вот что получилось: Enter an offset Enter an offset Enter an offset Enter an offset Enter an offset Enter an offset Enter an offset Enter an offset Enter an offset Enter an offset (0-9) and a value (0-9) and a value (0-9) and a value (0-9) and a value (0-9) and a value (0-9) and a value (0-9) and a value (0-9) and a value (0-9) and a value (0-9) and a value ★★★Please use values between 0 and 0 10 20 30 40 50 60 70 80 90 Enter an offset (0-9) and a value, intArray... [0] [1] [2] [3] [4] [5] [6] [7] [8] [9] (-1 to stop): 1 10 (-1 to stop): 2 20 (-1 to stop): 3 30 (-1 to stop): 4 40 (-1 to stop): 5 50 (-1 to stop): 6 60 (-1 to stop): 7 70 (-1 to stop): 8 80 (-1 to stop): 9 90 (-1 to stop): 10 । 10 9. * * * (-1 to stop): -1 . -1 animalArray... [0] 0 [1] 100 [2] 200 [3] 300 [4] 400 [5] 500 [6] 600 [7] 700 [8] 800 [9] 900 А теперь раскомментируем операторы вывода в конструкторах и деструкторе клас- са Animal и прогоним программу еще раз. Вот что получится: Animal() Animal() Animal() Animal() Animal() Animal() Animal() Animal() Animal() Animal() Animal(int) 268 Глава 9
Destroyed an animal.. Animal(int) Destroyed an animal.. Animal(int) Destroyed an animal.. Animal(int) Destroyed an animal.. Animal(int) Destroyed an animal.. Animal(int) Destroyed an animal.. Animal(int) Destroyed an animal.. Animal(int) Destroyed an animal.. Animal(int) Destroyed an animal.. Animal(int) Destroyed an animal.. Enter an offset (0-9) and a value, (-1 to stop): 1 10 Enter an offset (0-9) and a value, (-1 to stop): 2 20 Enter an offset (0-9) and a value, (-1 to stop): 3 30 Enter an offset (0-9) and a value, (-1 to stop): 4 40 Enter an offset (0-9) and a value, (-1 to stop): 5 50 Enter an offset (0-9) and a value, (-1 to stop): 6 60 Enter an offset (0-9) v and a value, (-1 to stop): 7 70 Enter an offset (0-9) and a value, (-1 to stop): 8 80 Enter an offset (0-9) and a value, (-1 to stop): 9 90 Enter an offset (0-9) and a value, (-1 to stop): 10 ' 10 ★★★Please use values between 0 and 9.* * ★ Enter an offset (0-9) and a value, (-1 to stop): -1 . -1 Animal() Destroyed an animal.. Animal() Destroyed an animal.. Animal() Destroyed an animal.. Animal() Destroyed an animal.. Animal() Destroyed an animal.. Animal() Destroyed an animal.. Animal() Destroyed an animal.. Animal() Destroyed an animal.. Animal() Destroyed an animal.. Animal() Destroyed an animal.. intArray... [0] 0 [1] 10 [2] 20 [3] 30 [4] 40 [5] 50 Шаблоны и стандартная библиотека шаблонов STL 269
[6] 60 [7] 70 [8] 80 [9] 90 animalArray... [0] 0 [1] 100 [2] 200 [3] 300 [4] 400 [5] 500 [6] 600 [7] 700 [8] 800 [9] 900 Destroyed an animal... Destroyed an animal... Destroyed an animal... Destroyed an animal... Destroyed an animal... Destroyed an animal... Destroyed an animal... Destroyed an animal... Destroyed an animal... Destroyed an animal... Да уж, что и говорить, конструктор и деструктор объектов Animal вызываются значительно чаще, чем того хотелось бы. При добавлении объекта в массив вызывается стандартный конструктор объекта. Однако конструктор класса Array также используется для присвоения нулевых значе- ний каждому члену массива. Рассмотрим, например, оператор вида someAnimal = (Animal) 0;, в котором вы- зывается стандартный оператор operator= для класса Animal. В результате создается временный экземпляр класса Animal с помощью конструктора, который принимает целое число (нуль). Этот временный экземпляр играет роль правого операнда в опе- рации присваивания, после чего он удаляется деструктором. Но это же крайне расто- чительно, поскольку экземпляр класса Animal уже инициализирован должным обра- зом. В то же время часто такой оператор (или подобный ему) удалить нельзя, потому что именно благодаря ему выполняется инициализация. В приведенной выше про- грамме, например, именно благодаря операторам такого вида при создании массива целочисленные переменные автоматически инициализируются нулями. Где же выход? Выход состоит в том, чтобы использовать для создания массива объектов специализи- рованные функции. Специализированные функции Если вы считаете, что некоторая функция, например конструктор, вызывается слишком часто и при этом выполняет слишком много лишней работы, вы можете создать специализированную функцию, которая будет выполнять только необходимую работу. В предыдущей программе использование стандартного конструктора класса Animal было заложено в самом шаблоне. Теперь давайте изменим шаблон так, чтобы при создании массива экземпляров класса Animal (именно в этих случаях выполняет- ся ненужная работа) он использовал не стандартный конструктор класса Animal, а специализированный конструктор. 270 Глава 9
Нужная нам реализация класса Animal показана в следующей программе. Здесь для упрощения анализа выдачи значение Defaultsize уменьшено, теперь оно равно 3. Все конструкторы и деструкторы класса выводят на экран сообщения, сигнализи- рующие об их вызове. // Специальные реализации шаблона #include <iostream.h> const int Defaultsize = 3; // Обычный класс, из экземпляров которого создается массив «class Animal { public: // конструктор Animal(int); Animal(); -Animal(); // методы доступа int GetWeight() const { return itsWeight; } void Setweight(int theWeight) { itsWeight = theWeight; } // дружественные операторы friend ostream& operator« (ostream&, const Animal&); private: int itsWeight; } ; // оператор вывода объектов типа Animal ostream& operator« (ostreamfc theStream, const Animal& theAnimal) { theStream « theAnimal.GetWeight(); return theStream; } Animal::Animal(int weight): itsWeight(weight) { cout « "animal(int) "; } Animal: .-Animal () : itsWeight(0) { cout « "animal() "; } Animal::-Animal() { cout « "Destroyed an animal..."; } // конструктор шаблона // объявляем шаблон и параметр template cclass Т> Шаблоны и стандартная библиотека шаблонов STL 271
// параметризованный класс class Array { public: Array(int itsSize = Defaultsize); Array(const Array &rhs); -Array() { delete [] pType; } // оператор Array& operator=(const Array&); T& operator[](int offset) { return pType[offSet]; } const T& operator[](int offset) const { return pType[offSet]; } // методы доступа int GetSize() const { return itsSize; } // дружественная функция friend ostreamfc operator« (ostreamSc, const Array<T>&) ; private: T *pType; int itsSize; }; // объявление конструктора класса Array template cclass T> Array<T>: .-Array (int size = Defaultsize): itsSize(size) { // создание временных объектов Animal pType = new T[size]; for (int i = 0; i<size; i++) pType[i] = (T)0; } template cclass T> Array<T>& Array<T>::operator=(const Array &rhs) { if (this == &rhs) return *this; delete [] pType; itsSize = rhs.GetSize(); pType = new T[itsSize]; for (int i = 0; icitsSize; i++) pType[i] = rhs[i]; return *this; } template cclass T> Array<T>::Array(const Array &rhs) { itsSize = rhs.GetSize(); pType = new T[itsSize]; for (int i = 0; icitsSize; i++) pType[i] = rhs[i]; } 272 Глава 9
template cclass T> ostream& operator« (ostream& output, const Array<T>& theArray) { for (int i = 0; ictheArray.GetSize(); i++) output « "[" « i « "] " « theArray[i] « endl; return output; } !! ВНИМАНИЕ!!! При втором прогоне программы закомментируйте !! специализированный конструктор класса Array. Для этого удалите !! следующие два символа, закрывающие этот комментарий ★/ // Специализированный конструктор Array, используемый при создании // массива элементов типа Animal. В этом специализированном // конструкторе не делается никаких явных присвоений и исходные // значения для каждого объекта Animal устанавливаются стандартным // конструктором. Array<Animal>::Array(int AnimalArraySize): itsSize(AnimalArraysize) { pType = new Animal[AnimalArraySize]; } /***** конец специализированного конструктора Array *************/ void IntFillFunction(Array<int>& theArray); void AnimalFillFunction(Array<Animal>& theArray); int^ main() { Array<int> intArray; Array<Animal> animalArray; IntFillFunction(intArray); AnimalFillFunction(animalArray); cout « "intArray...\n" « intArray; cout « "XnanimalArray...\n" « animalArray « endl; return 0; } void IntFillFunction(Array<int>& theArray) { bool Stop = false; int offset, value; while (’Stop) { cout « "Enter an offset (0-2) and a value. "; cout « "(-1 to stop): " ; cin » offset » value; if (offset < 0) break; if (offset > 2) { cout « "***Please use values between 0 and 9.***\n"; continue; } theArray[offset] = value; } } Шаблоны и стандартная библиотека шаблонов STL 273
void AnimalFillFunction(Array<Animal>& theArray) { Animal * pAnimal; for (int i = 0; i<theArray.GetSize(); i++) { // В динамической области памяти создается временный объект Animal pAnimal = new Animal(i*10); // его значение используется для модификации объекта Animal в массиве theArray[i] = *pAnimal; // временный объект Animal удаляется delete pAnimal; } } Вот что у нас получилось: animal() animal() animal() Enter an offset (0-2) and a value. (-1 to stop): 0 0 Enter an offset (0-2) and a value. (-1 to stop): 0 1 Enter an offset (0-2) and a value. (-1 to stop): 1 2 Enter an offset (0-2) and a value. (-1 to stop): 2 3 Enter an offset (0-2) and a value. (-1 to stop): - 1 -1 animal(int) Destroyed an animal... animal(int) Destroyed an animal...animal(int) Destroyed an animal...intArray... [0] 1 [1] 2 [2] 3 animalArray... [0] 0 [1]' 10 [2] 20 Destroyed an animal...Destroyed an animal... Destroyed an animal... Теперь обсудим полученные результаты. Сначала выводится ряд сообщений. Это сообщения трех стандартных конструкто- ров, вызванных при создании массива. Затем пользователь вводит четыре числа, кото- рые помещаются в массив целых чисел. После этого управление получает функция AnimalFillFunction (), которая соз- дает в динамической области памяти временный объект Animal, а его значение ис- пользует для модификации объекта Animal в массиве. Затем функция удаляет вре- менный объект Animal. Этот процесс она повторяет для каждого элемента массива: animal(int) Destroyed an апдта1... animal(int) Destroyed an animal... animal(int) Destroyed an animal...intArray... В конце выполнения программы созданные массивы удаляются, а при вызове их деструкторов также удаляются и все их объекты. Процесс удаления выдачей сообще- ний деструкторов: Destroyed an animal...Destroyed an animal...Destroyed an animal... Теперь давайте выполним программу еще раз, но перед трансляцией закомменти- руем специализированный конструктор класса Array. Тогда при выполнении про- граммы для создания массива объектов Animal будет вызываться конструктор шабло- на и мы увидим, что для каждого элемента массива создается временный объект Animal: animal() animal() animal() animal(int) Destroyed an animal...animal(int) Destroyed an animal...animal(int) Destroyed an animal... Enter an offset (0-2) and a value. (-1 to stop): 0 0 274 Глава 9
Enter an offset (0-2) and a value. (-1 to stop): 1 1 Enter an offset (0-2) and a value. (-1 to stop): 2 2 Enter an offset (0-2) and a value. (-1 to stop): 3 3 ***Please use values between 0 and 9.*** Enter an offset (0-2) and a value. (-1 to stop): -1 -1 animal(int) Destroyed an animal... animal(int) Destroyed an ani- mal... animal (int) Destroyed an animal...intArray... [0] 0 [1] 1 [2] 2 animalArray... [0] 0 [1] 10 [2] 20 Destroyed an animal...Destroyed an animal...Destroyed an animal... Как видим, результаты выполнения этих двух программ похожи, существенно они отличаются лишь тем, в каких местах появляются сообщения конструктора шаблона. Статические члены в шаблонах В шаблоне можно объявлять статические переменные-члены. Тогда каждый эк- земпляр шаблона будет иметь собственный набор статических данных. Например, ес- ли добавить статическую переменную-член в шаблон Array (для подсчета количества созданных массивов), то в программе будут созданы статические переменные-члены для подсчета массивов каждого типа данных, использованного для создания экземп- ляра шаблона. (Таким образом, для подсчета количества массивов, содержащих эле- менты типа Animal, и массивов целых чисел будут созданы различные переменные.) Теперь добавим статическую переменную-член и статическую функцию в шаблон Array. Сначала в раздел закрытых членов класса Array добавим статическую переменную itsNumberArrays. Поскольку эта перемененная закрыта, необходимо добавить еще и открытый статический метод доступа GetNumberArrays (). Конструкторы и деструктор класса Array нужно изменить таким образом, чтобы они отслеживали количество имеющихся в данный момент массивов. Доступ к статической переменной, заданной в шаблоне, можно получить так же, как и при работе со статическими переменны ми-членам и обычного класса: либо с помощью метода доступа, вызванного для экземпляра класса, либо путем явного об- ращения к переменной класса. Однако в последнем случае при обращении к статиче- ской переменной-члену необходимо указать еще и тип массива, так как для каждого типа создается отдельная статическая переменная-член. // Статические переменные-члены и функции в шаблоне ttinclude <iostream> const int Defaultsize = 3; // Обычный класс, экземпляры которого хранятся в массиве class Animal { public: // конструктор Animal(int); Animal(); -Animal(); Шаблоны и стандартная библиотека шаблонов STL 275
// методы доступа int GetWeight() const { return itsWeight; } void SetWeight(int theWeight) { itsWeight = theWeight; } // дружественные операторы friend ostreamk operator« (ostream&, const Animal&); private: int itsWeight; }; // оператор вывода объектов типа Animal ostream& operator<< (ostream& theStream, const Animal& theAnimal) { theStream « theAnimal.GetWeight(); return theStream; Animal::Animal(int weight): itsWeight(weight) { // cout « "animal(int) } Animal:: Animal(): itsWeight(0) { //cout « "animal() "; } Animal::-Animal() { // cout « "Destroyed an animal..."; } // объявляем шаблон и параметр template <class T> // параметризованный класс class Array { public: // конструктор Array(int itsSize = Defaultsize); Array(const Array &rhs); -Array() { delete [] pType; itsNumberArrays--; } // оператор Array& operator=(const Array&); T& operator[](int offset) { return pType[offSet]; } const T& operator[](int offset) const { return pType[offSet]; } // методы доступа int GetSize() const { return itsSize; } // открытый статический метод доступа GetNuiriberArrays () static int GetNumberArrays() { return itsNumberArrays; } 276 Гпава 9
// дружественная функция friend ostream& operator« (ostreamk, const Array<T>&); private: T *pType; int itsSize; // В разделе закрытых членов класса Array добавлена статическая // переменная itsNumberArrays static int itsNumberArrays; }; // Инициализация статической переменной-члена template <class Т> int Array<T>::itsNumberArrays = 0; template <class T> Array<T>::Array(int size = Defaultsize) : itsSize(size) { pType = new Tfsize]; for (int i = 0; i<size; i++) pType[i] = (T)0; itsNumberArrays++; } template <class T> Array<T>& Array<T>::operator=(const Array &rhs) { if (this == &rhs) return * thi s; delete [] pType; itsSize = rhs.GetSize(); pType = new TfitsSize]; for (int i=0; i<itsSize; i++) pType[i] = rhs[i]; } template <class T> Array<T>: .-Array (const Array &rhs) { itsSize = rhs .GetSize () ; pType = new T[itsSize]; for (int i = 0; i<itsSize; i++) pType[i] = rhs[i]; itsNumberArrays++; } template <class T> ostreamfc operator« (ostream& output, const Array<T>& theArray) { for (int i = 0; i<theArray.GetSize(); i++) output « "[" « i « "] " << theArray[i] « endl; return output; } Шаблоны и стандартная библиотека шаблонов STL 277
int main() { cout « Array<int>::GetNumberArrays() « cout « Array<Animal>::GetNumberArrays() cout « " animal arrays\n\n"; Array<int> intArray; Array<Animal> animalArray; integer arrays\n"; cout « intArray.GetNumberArrays() « " cout « animalArray.GetNumberArrays(); cout « ” animal arrays\n\n"; integer arrays\n"; Array<int> *p!ntArray = new Array<int>; // Доступ к статической переменной, заданной в шаблоне, // с помощью метода доступа, вызванного для экземпляра класса cout « Array<int>::GetNumberArrays() « " integer arrays\n"; cout « Array<Animal>::GetNumberArrays(); cout « " animal arrays\n\n"; delete plntArray; cout « Array<int>::GetNumberArrays() « " integer arrays\n"; cout « Array<Animal>::GetNumberArrays(); cout << я animal arrays\n\n"; return 0; Вот результаты прогона программы: 0 integer arrays 0 animal arrays 1 integer arrays 1 animal arrays 2 integer arrays 1 animal arrays 1 integer arrays 1 animal arrays При необходимости в шаблонах можно использовать статические члены. Дос- туп к статическим членам, заданным в шаблоне, можно получить так же, как и к статическим членам обычного класса. Для этого можно, например, вызвать метод доступа для экземпляра класса. Однако в случае явного обращения к статической переменной класса, определенной в шаблоне, необходимо ука- зать еще и тип, так как для каждого типа создается отдельная статическая пе- ременная-член. Стандартная библиотека шаблонов Как только в языках программирования была разработана концепция подпрограмм и модулей, сразу появились обширные библиотеки объектных модулей, предназна- ченные для реализации численных методов решения самых разнообразных задач. А поскольку шаблоны по сравнению с объектными модулями предоставляют гораздо большие возможности для реализации алгоритмов и создания новых классов и функ- ций, то совсем не удивительно, что были созданы библиотеки шаблонов. 278 Гпава 9
В стандартных библиотеках шаблонов содержится большой и исчерпывающий на- бор структур данных и функций. Такое расширение стандартных библиотек часто на- зывается стандартной библиотекой шаблонов (Standard Template Library, STL). В стан- дартной библиотеке шаблонов содержатся шаблоны классов для представления мно- жеств, списков, стеков, очередей (в том числе и очередей с приоритетами) и пр. Благодаря такой стандартизации не только повышается мобильность и надежность программ, но и ускоряется создание надежных приложений, а также упрощается их сопровождение. Все классы стандартной библиотеки шаблонов STL разработаны так, что они со- вместимы с большим набором обобщенных алгоритмов (шаблонов функций общего ви- да для манипулирования контейнерами произвольной природы). Разделение функций (контейнеры хранят данные, алгоритмы их обрабатывают) позволило существенно со- кратить объем библиотеки, причем во многих случаях такая реализация оказывается более эффективной по сравнению с реализациями, основанными на полной инкапсу- ляции данных и операций. Далеко не все, что есть в стандартной библиотеке шаблонов STL, предназначено для объектно-ориентированного программирования. Более того, разработчики, кото- рые привыкли к преимуществам объектно-ориентированного программирования, при отсутствии объектно-ориентированных средств должны будут отказаться от привыч- ных стереотипов. Тем не менее зачастую именно такой подход позволяет уменьшить объем исходного кода и расширить область использования алгоритмов, в которых ос- новным средством доступа к данным являются указатели. Правда, небрежное использование стандартной библиотеки шаблонов повышает вероятность ошибки, так как локализация ошибок в шаблонах более трудоемка. Кро- ме того, шаблоны — это “интеллектуальные” макросы, поэтому при неаккуратном применении библиотеки код может оказаться неожиданно большим. К тому же раз- ные компиляторы могут по-разному трактовать один и тот же шаблон. Для новичков определенную трудность освоения библиотеки шаблонов представляет и ее объем, од- нако разобраться в ней не так уж и сложно. Оказывается, нужно всего лишь освоить несколько основных понятий и концепций, использованных для построения STL. Впрочем, два из этих важнейших понятий — контейнер и обобщенный алгоритм — вам уже знакомы. В контейнерах, как вы помните, хранятся объекты. А ко всем хранимым запасам необходимы средства доступа. (Ведь если нет средств доступа, запасами поль- зоваться нельзя, а потому нет смысла и хранить их.) Средства доступа в библиотеке шаблонов называются итераторами. Наряду с контейнером и обобщенным алгорит- мом это одно из важнейших понятий библиотеки. Итератор представляет собой класс, обеспечивающий доступ к данным другого класса. Итератор обобщает понятие указателя: он предоставляет доступ к элементам контейнера, причем для получения доступа нет необходимости инкапсулировать кон- тейнер. Поэтому итераторы могут использоваться аналогично обычным указателям, притом для самых различных целей. Например, итератор может указывать на кон- кретный объект, а пара итераторов может определять диапазон объектов в последова- тельности (начало — конец). В самой последовательности элементы могут быть упо- рядочены логически, и их логический порядок может не иметь ничего общего с их физическим расположением в памяти. Физическая реализация выборки элементов из последовательности возлагается на контейнеры. Как уже отмечалось, для доступа в библиотеке стандартных шаблонов широко ис- пользуются указатели. А поскольку в библиотеке стандартных шаблонов обобщенные алгоритмы зачастую реализованы в виде функций, то в большинстве обобщенных ал- горитмов библиотеки стандартных шаблонов STL указатели на функции передаются в качестве параметров. Новичкам подчас это кажется несколько необычным, однако та- кое применение указателей на функции встречается уже в классической программе интегрирования функции на заданном промежутке. Неаккуратная передача указателей Шаблоны и стандартная библиотека шаблонов STL 279
на функции чревата самыми серьезными ошибками, поэтому чтобы повысить надеж- ность разрабатываемых программ, следует проверять передаваемые значения. А для этого необходимо, в свою очередь, вместо указателей на функции передавать объекты требуемого типа. Поэтому в библиотеке стандартных шаблонов STL понятие функции расширено до понятия объекта-функции, или функционального объекта. Объект- функция — это такой объект, для которого определен оператор “скобки” (). Иными словами, это такой объект, для которого можно записать вызов. Такие объекты зачас- тую позволяют повысить производительность, поскольку они могут быть встроенны- ми, т.е. для них может использоваться inline-подстановка (а она ведь невозможна при передаче указателя на функцию). Рассмотрим теперь примеры использования стандартной библиотеки шаблонов. Примеры применения стандартной библиотеки шаблонов Приведенные ниже примеры призваны проиллюстрировать приемы применения стандартной библиотеки шаблонов, и потому шаблоны в этих примерах используются подчас неожиданным для новичка способом. Поэтому не пугайтесь, если не все пой- мете с первого раза. На тех, кто только начинает изучать библиотеку шаблонов, эти примеры производят такое же впечатление, как фокусы иллюзионистов на зрителей в цирке: шарики появляются и исчезают с необычайной легкостью, а вот как это про- исходит — не понятно. Чтобы отгадать фокус, требуется время, а подчас и некоторые технические ухищрения (например видеосъемка в инфракрасном свете). Чтобы стать фокусником, нужно научиться не только отгадывать фокусы, но и придумывать свои, а это включает и изобретение необходимого оборудования. В общем вы поняли — чтобы полностью изучить стандартную библиотеку шаблонов, потребуется время, тер- пение, практика и... отдельная книга. Сейчас же наслаждайтесь примерами использо- вания стандартной библиотеки шаблонов так, как в цирке зрители наслаждаются вы- ступлениями иллюзионистов. Пример использования шаблонов объектов-функций В приведенной ниже программе создается функциональный объект, вычисляющий факториал числа. Затем в главной программе создается дек (двусторонняя очередь), в который ставятся целые числа от 1 до 7, после чего создается вектор для хранения факториалов целых чисел, записанных в очередь. Само вычисление факториалов всех чисел из дека и сохранение их (факториалов) в векторе выполняется единственным оператором — без каких бы то ни было циклов. Затем распечатываются заданные числа и их факториалы. Оператор цикла в программе встречается лишь однажды — в функциональном объекте, предназначенном для вычисления факториала. // Использование шаблонов объектов-функций. ttinclude <functional> ttinclude <deque> #include <vector> #include <algorithm> #include <iostream> using namespace std; // Создаем новый функциональный объект на основе unary_function template<class Arg> class factorial : public unary_function<Arg, Arg> { 280 Гпава 9
public: Arg operator () (const Arg& arg) { Arg a = 1; for (Arg i = 2; i <= arg; i++) a * = i ; return a; } }; int main() { // Инициализируем двустороннюю очередь массивом целых int init[7] = {1, 2 ,3 ,4, 5, 6, 1}; deque<int> d(init, init+7); //Создаем пустой вектор для хранения значений факториала vector<int> v( (size_t) 7 ); // Трансформируем числа в двусторонней очереди в их факториал // и сохраняем их в векторе transform(d.begin() , d.end(), v.begin {), factorial<int> () ) ; // печатаем результат cout « "Данные числа: " << endl << " copy(d.begin() , d.endO, ostream_iterator<int> (cout, " ")); cout « endl « endl; cout « "имеют факториалы: " << endl « " "; copy(v.begin(), v.end(), ostream_iterator<int> (cout, " ")); return 0; } В результате выполнения программы получаем следующее: Данные числа: 1 2 3 4 5 6 7 имеют факториалы: 1 2 6 24 120 720 5040 Объединение и пересечение мультимножеств Этот пример иллюстрирует работу базовых алгоритмов библиотеки стандартных функций. В примере находятся общие элементы двух объектов типа мультимножест- во, а также объединение мультимножеств. // Использование контейнера типа мультимножество // Объединение и пересечение мультимножеств. ttinclude <set> #include <algorithm> #include <iostream> using namespace std; typedef multiset<int, less<int> > set_type; ostreamic operator<< (ostreamk out, const set_type& s) { copy (s.begin() ,s.end(), ostream_iterator<set_type::value__type> (cout, " ")); return out; } Шаблоны и стандартная библиотека шаблонов STL 281
int main (void) { // создаем мультимножество целых величин set_type si; int i ; for (int j = 0; j < 2; j++) { for(i = 0; i < 10; ++i) { // инициализируем его значениями от 1 до 10, si.insert (si.begin(), i); } } // вывод на печать cout « si << endl; set_type si2, siResult; for (i = 0; i < 10; i++) si2.insert (i+5) ; cout « si2 « endl; // пример работы нескольких алгоритмов // получим объединение двух множеств и поместим его в siResult set—union(si.begin(), si.end(), si2.begin(), si2.end(), inserter(siResult, siResult.begin ())); cout « "Объединение:" « endl << siResult << endl; siResult.erase(siResult.begin(), siResult.end ()); // получим пересечение двух множеств и поместим его в siResult set—intersection(si.begin(), si.end(), si2.begin(), si2.end(), inserter(siResult, siResult.begin())); cout << "Пересечение:" << endl << siResult << endl; return 0; } Результат работы программы: 00112233445566778899 5 6 7 8 9 10 11 12 13 14 Объединение: 00112233445566778899 10 11 12 13 14 Пересечение: 5 6 7 8 9 Не все компиляторы работают с шаблонами одинаково. В только что приведен- ной программе, например, использовался оператор typedef multiset<int, less<int> > set-type;. В Borland C++ его можно записать и так: typedef multiset<int, less<int» set.type;, т.е. без пробела между знаками > >. (Компилятор, правда, выдаст предупреждение: Use ’> >' for nested templates instead of ' , но программа будет выполнена.) Компилятор же MS Visual C++ .NET выдаст сообщение об ошибке error С2947: expecting ' >' to terminate template-argument-list, found ' >>', после чего найдет в программе еще около четырех десятков других ошибок, по сообщениям о которых можно будет только догадываться, что где-то в про- грамме имеется “серьезная” абракадабра. Приведенные примеры не исчерпывают всего многообразия применений стан- дартной библиотеки шаблонов. Более того, они демонстрируют хотя и типичные, но далеко не самые загадочные фокусы. Я ведь стремился отобрать только те фокусы, секрет которых вы можете отгадать и сами. Так что попробуйте, если хотите. 282 Глава 9
Резюме Шаблоны в языке C++ используются для создания параметризованных типов, т.е. типов, которые изменяют свое поведение в зависимости от параметров, переданных при создании класса. Таким образом, шаблоны — это не просто параметрический по- лиморфизм, но и многократное использование программного кода. В определении шаблона указывается тип-параметр. Каждый экземпляр шаблона представляет собой реальный объект, который можно использовать так же, как и лю- бой другой подобный ему объект. В частности, экземпляр шаблона может использо- ваться для указания типов и для создания объектов. Созданные типы, например, мо- гут указывать типы параметров функций и типы возвращаемых значений, а объекты могут выступать в качестве параметров функций и возвращаемых значений и т.д. В шаблонах классов можно объявить три типа дружественных функций: нешаб- лонные, шаблонные и специализированные для конкретного типа. В шаблоне можно объявить и статические члены. В этом случае каждый экземпляр шаблона будет иметь собственный набор статических данных. Если для некоторого типа экземпляр шаблона (функция или класс) ведет себя не так, как нужно, можно запретить генерацию (из шаблона) экземпляра для этого кон- кретного типа. Для этого соответствующую функцию или класс нужно переопреде- лить для этого конкретного типа. Задачи и упражнения 1. В чем разница между шаблоном и макросом? 2. Почему предпочтительнее использовать шаблоны, а не макросы? 3. Чем отличается параметр шаблона от параметра функции? 4. В чем разница между параметризованными типами шаблонной функции и па- раметрами обычной функции? 5. Когда следует применять шаблоны, а когда наследование? 6. Предположим, в шаблоне объявлена дружественная функция. Для всех ли эк- земпляров шаблона она будет дружественной? 7. Для чего дружественные шаблонные классы или функции специализируются по типу? 8. Чем отличается обычный дружественный шаблонный класс от дружественного шаблонного класса, специализированного по типу? 9. Можно ли запретить генерацию экземпляра шаблона для определенного типа? Как это сделать для функции? 10. Сколько создается статических переменных-членов, если поместить один ста- тический член в определение класса-шаблона? 11. Что такое итератор9 12. Что такое функциональный объект? 13. Создайте шаблон на основе данного класса List: class List { private: public: List() :head(0)z tail(0)z theCount(O) { } Шаблоны и стандартная библиотека шаблонов STI 283
virtual -List(); void insert( int value ); void append( int value ); int is_present( int value ) const; int is_empty() const { return head == 0; } int count() const { return theCount; } private: class Listcell { public : ListCell(int value, ListCell *cell = 0) :val(value), next(cell){ } int val; ListCell *next; } ; ListCell *head; ListCell *tail; int theCount; } ; 14. Напишите обычную (не шаблонную) реализацию класса List. 15. Напишите теперь шаблонную версию реализации класса List. 16. Объявите три списка объектов: типа string, типа Cat и типа int. 17. (Отладка.) Выше мы определили шаблон класса List. Предположим, что Cat — это также ранее определенный класс. Рассмотрим следующий фрагмент программы: List<Cat> Cat_List; Cat Felix; CatList.append( Felix ); cout << "Felix is " << ( Cat_List.is_present( Felix ) ) ? "" : "not " << "present\ n"; Нужно ли в классе Cat определить оператор operator==? 18. Объявите дружественный оператор operator== в классе List. 19. Напишите реализацию дружественного оператора operator== для класса List. 20. Реализуйте шаблонную функцию swap (обмен), обменивающую значения двух переменных. 284 Гпава 9
Глава 10 Исключения В этой главе... Зачем нужен механизм обработки исключений 287 Механизм обработки исключительных ситуаций 289 Исключение: что это такое и как его вбрасывать 291 Резюме 293 Задачи и упражнения 293 Ко многим индуктивным заключениям, считавшимся чем-то само собой разумеющимся, были найдены исключения... Герхард Фоллмер. Эволюционная теория познания К противоречию ведет, например, правило: нет правил без ис- ключений. Ибо, если бы оно было правильным, то должно бы- ло бы действовать по отношению к самому себе, т.е. допускать исключения. Тогда имелось бы по меньшей мере одно правило без исключений и это правило было бы ложным. Герхард Фоллмер. Эволюционная теория познания ...альтернатива... состоит в том, что позволяют себе исключе- ния из правила. Вильгельм Якобс. Происхождение зла и человеческая свобода или трансцендентальная философия и метафизика Я знаю, как трудно с этим смириться, но факт остается фактом: иногда происходит то, что предусмотреть в программе нужно, но уж очень не хочется. Например, нехват- ку памяти, ошибки во вводимых данных, неожиданные результаты вычислений, слишком большие или слишком маленькие аргументы функций, неожиданные от- ключения или поломки устройств, которыми управляет ваша программа, да и Бог весть что еше. Функция, которая обнаружила нечто подобное, должна оповестить вы- зывающую ее программу. Традиционно вызывающей программе сообщается об ошиб- ке посредством возвращаемого функцией значения. Однако в языке C++ предусмот- рен новый, улучшенный механизм выявления и обработки ошибок с помощью исклю- чительных ситуаций, или исключений (exceptions). В философском смысле исклю- чение — это отступление от общего правила, т.е. случай, когда то или иное правило либо принцип неприменимы. Конечно, можно дать и другие определения. Можно, например, сказать, что исключение — это неожиданное (и, надо полагать, зачастую нежелательное) состояние, которое возникает во время выполнения программы. Можно даже сказать, что исключение — это состояние, при котором обычное (нормальное) продолжение если и возможно, то только после определенной коррек- тировки.
В языке C++ (и многих других) для управления исключениями предусмотрены ключевые слова try (попытаться), throw (бросить) и catch (поймать). В обших чер- тах этот механизм работает так: С-машина пытается выполнить код. Если при этом происходит исключительная ситуация (например ошибка), она генерирует (иногда го- ворят: вызывает, запускает, бросает или вбрасывает) исключение (например сообще- ние об ошибке), которое должна поймать (иногда говорят перехватить или обрабо- тать) вызывающая функция. Как видите, это не сложнее, чем игра в бейсбол: один вбрасывает, другой перехватывает (если может). Прежде чем заняться вбрасыванием мяча, простите, я хотел сказать исключения, давайте понаблюдаем за игрой, т.е. за работой программы. Сначала напишем функ- цию, для которой явно должны быть предусмотрены исключения. В качестве такой функции вполне подойдет, например, факториал. (Эта функция, как вы помните, оп- ределена только для неотрицательных целых чисел. Даже ее естественное продолже- ние на комплексную плоскость — гамма-функция — имеет полюса в отрицательных целых числах (т.е. в точках z = -1, -2, -3, ...). Поэтому передача функции отрица- тельного целого числа в качестве аргумента действительно представляет собой исклю- чительную ситуацию.) Таким образом, нам нужно в функции, вычисляющей фактори- ал, предусмотреть проверку аргумента и вбрасывание исключения, если этот аргумент отрицательный. В главной функции попробуем вычислить факториалы нескольких чисел, причем сначала попытаемся вычислить факториал — 1. Кроме того, в главной программе предусмотрим перехват исключения и посмотрим, что из этого получится. Вот полный текст программы: ttinclude <iostream.h> //factorial - вычисляет факториал int factorial(int n) { // Поскольку аргумент n функции факториал не может быть // отрицательным, мы сразу проверяем допустимость аргумента. if (п < 0) { throw "Отрицательный аргумент"; } // теперь вычислим факториал int accum = 1; while(n >1) { accum *= n; n—- ; } return accum; } int main(int argcs, char* pArgs[]) { try { // Эта строка генерирует исключение cout « "Factorial of -1 is " « factorial(-1) « endl; // Управление никогда не дойдет до этой строки cout « "Factorial of 10 is " « factorial(10) « endl; } // Управление будет передано сюда catch(char* pError) { cout « "Возникла ошибка: } return 0; << pError << endl; 286 Глава 10
Как видим, функция main() начинается с блока, выделенного ключевым словом try. В этом блоке выполнение кода ничем не отличается от выполнения вне блока. В данном случае main() пытается вычислить факториал отрицательного числа. Однако функцию factorial () не так легко одурачить, поскольку она достаточно умно напи- сана и обнаруживает, что запрос некорректен. При этом она генерирует исключение (сообщение об ошибке) с помощью ключевого слова throw. Управление передается оператору, находящемуся сразу за закрывающей фигурной скобкой блока try и отве- чающему за перехват исключения (сообщения об ошибке). Поэтому в результате вы- полнения программы будет получена следующая выдача: Возникла ошибка: Отрицательный аргумент А вот если заменить —1 на 1, то исключения не возникнет, и выполнятся все опе- раторы вывода: Factorial of 1 is 1 Factorial of 10 is 3628800 Зачем нужен механизм обработки исключений Очень часто случается, что законы, в отношении которых мы верим, что они не имеют исключений, открываются путем первичных обобщений, которые применяются к большинству случаев, но не ко всем. Бертран Рассел. Человеческое познание Однако исключение не отменяет правила. Зигмунд Фрейд. Введение в психоанализ Но ведь вполне законно было бы защититься от неудач, тща- тельно исключая такие случаи. Зигмунд Фрейд. Введение в психоанализ Конечно, механизм обработки исключений (во всяком случае ошибок) должен быть во всякой реализации развитого языка программирования. Ведь должно же что- то сигнализировать о делении на ноль, о попытке извлечения квадратного корня из отрицательного числа или о попытке вычислить логарифм нуля. Иногда даже этот ме- ханизм был доступен для пользователя. В языке FORTRAN IBM/360 (в СССР он на- зывался Фортран ЕС) доступ к такому механизму осуществлялся путем вызова не- скольких системных функций. Однако этот механизм работал достаточно сложно, и воспользоваться им могли только настоящие гуру. Поскольку же самим языком такой механизм предусмотрен не был (а, значит, в других версиях языка был недоступен для пользователя), то пользователи применяли совсем другой метод: функция возвращала код ошибки. Что же плохого в методе возврата кода ошибки, подобном так часто применяемому в языке FORTRAN? Поскольку факториал не может-быть отрицатель- ным, функция factorial (), обнаружив ошибку, могла бы возвращать отрицательное число (код ошибки). Код ошибки и указывал бы на источник проблемы. Чем же плох такой метод? Ведь часто делали именно так. Однако при таком подходе возникает несколько проблем. Во-первых, код ошибки должен отличаться от любого возможного значения функции. Как быть с тангенсом или котангенсом? Можете ли вы сразу сказать, какое число подойдет в качестве кода ошибки? Возможно, вы думаете, что для тангенса исключительных аргументов не бы- Исключения 287
вает, поскольку л трансцендентно? А что делать, если аргумент настолько близок к л/2, что возникает переполнение порядка или если аргумент настолько велик, что его невозможно привести к интервалу (—п/2, л/2) из-за недостаточной точности аргумен- та? Так что в этом отношении большинству функций повезло гораздо меньше, чем факториалу. Во-вторых, код — целое число — может передать не слишком много информа- ции — ровно столько битов, сколько двоичных разрядов отведено для его представле- ния. Как, например, передать значения аргументов, если они несут информации больше, чем может вместить машинное представление целого числа? Понятно, что одним целым числом тут не обойтись. Лучше, чтобы это был какой-нибудь более “интеллектуальный” объект, например экземпляр класса. В-третьих, при возврате результата (или кода ошибки) необходима его проверка. Иными словами, нужно проверять значение, возвращаемое функцией. Ведь если программа, вызвавшая нужную функцию, не будет проверять возвращаемое значе- ние, то код ошибки будет использоваться как значение функции! Над ним просто будут выполняться какие-нибудь операции вроде сложения, вычитания, деления, умножения, вычисления квадратного корня... А вы представляете, что такое тангенс от логарифма нуля? А косинус синуса от единицы, деленной на нуль? А если к это- му еще прибавить нечто вроде переполнения порядка, умноженного на потерю точ- ности при вычитании косинуса от экспоненты квадратного корня из числа минус два, деленного на сумму единицы и минус единицы, из синуса куба разности бес- конечности (полученной как код ошибки на более раннем этапе вычисления) и единицы? Ну как? Это вам не нестандартный анализ с его бесконечно большими и бесконечно малыми. Новая математика или математика сумасшедших? То-то же. Однако даже если вызывающая функция проверяет код ошибки и знает об ошибке в вызванной функции, что она может с ней (ошибкой) сделать? Пожалуй, только вы- вести сообщение об ошибке и вернуть другой код ошибки своей вызывающей функ- ции, которая, скорее всего, вынуждена будет повторить весь этот процесс. В результа- те почти все функции (а потому и вся программа) будут переполнены проверками вроде следующих: errRtn = someFunc(); if (errRtn == errcodesomeFunc) { errorOut("Ошибка при вызове someFuncО"); return MY_ERROR_1; }; errRtn = someOtherFunc(); if (errRtn == errcodesomeOtherFunc) { errorOut("Ошибка при вызове someOtherFn()"); return MY_ERROR_1; } Чтобы сложить синус с косинусом, потребуется, по крайней мере, три проверки. Сначала нужно будет присвоить некоторым переменным значения синуса и косинуса и проверить, не коды ли ошибок это, а затем третьей переменной нужно будет при- своить значение суммы и проверить, не код ли это ошибки, возникшей при сложе- нии. Вычисление сложных (да и вообще любых) выражений таким способом, мягко выражаясь, проблематично. Этот механизм имеет ряд недостатков: • изобилует повторениями; • заставляет программиста отслеживать множество разных ошибок и писать код для обработки всех возможных вариантов; • смешивает код, отвечающий за обработку ошибок, с обычным кодом, что не добавляет ясности программе... Эти недостатки приводят к тому, что обработке ошибок приходится уделять 90% кода. 288 Глава 10
Механизм исключительных ситуаций позволяет отделить код обработки ошибок от обычного кода и таким образом обойти эти проблемы. Конечно, можно игнорировать ошибки, которые вы не в состоянии обработать. Ведь если ваша функция не обраба- тывает сгенерированное исключение, управление передается далее по цепочке вызы- вающих функций, пока C++ не найдет функцию, которая обработает вызванное ис- ключение. (Такая функция может оказаться системной.) Механизм обработки исключительных ситуаций Теперь подробнее рассмотрим, как программа обрабатывает исключительную си- туацию. Итак, предположим, вбрасывается (оператором throw) исключение. Нужно найти того, кто перехватит это исключение. Поиск выполняется следующим образом. При вызове исключения (оператором throw) сгенерированный объект (это и есть ис- ключение) первым делом копируется в некоторое нейтральное место. После этого ищется конец текущего блока try. Если блок try в данной функции не найден, управление передается вызывающей функции, где опять, ищется конец текущего блока try (фактически это значит, что осуществляется поиск обработчика исключения). Если и здесь нет конца блока try, процесс повторяется далее, вверх по стеку вызывающих функций. Этот процесс назы- вается разворачиванием стека. Важная особенность разворачивания стека состоит в том, что на каждом его этапе все объекты, которые выходят из области видимости, уничтожаются так же, как если бы функция выполнила команду return. Это позволяет не думать об освобождении ресурсов — все ненужные автоматически выделенные ресурсы будут освобождены также автоматически. Когда найден необходимый конец блока try, программа ищет первый блок catch (который должен находиться сразу за закрывающей скобкой блока try). Именно этот блок catch и рассматривается в качестве кандидата в обработчики данного исключе- ния. Если тип сгенерированного объекта совпадает с типом аргумента, указанным в блоке catch, этот блок назначается обработчиком и ему передается управление; если же типы не совпадают, проверяется следующий блок catch. Если подходящий блок найти не удастся, поиск будет продолжен на следующем, более высоком уровне, и так вплоть до самого высокого уровня (функция main О). Если же подходящий блок catch не будет найден и на самом высоком уровне, программа завершится аварийно. Рассмотрим приведенный ниже пример. #include <iostream.h> #include <stdio.h> class Obj { public: Obj(char c) { label = c; cout « "Конструируем объект " « label « endl; } ~Obj() { cout « "Ликвидируем объект " « label « endl; } protected: char label; Исключения 289
void f1(); void f 2 () ; int main(int, char*[]) { Obj a ( ' a ' ) ; try { Obj b(’b’); f 1 () ; } catch(float f| { cout « "Исключение float" « endl; } catch(int i) { cout « "Исключение int" « endl; } catch (...) { cout « "Исключение..." « endl; } return 0; } void fl() { try { Obj c('c'); f 2 () ; } catch(char* pMsg) { cout « " Исключение char*" « endl; } void f 2 () { Obj d ( ’ d' ) ; throw 10; } В результате работы этой программы на экран будет выведен следующий текст: Конструируем объект а Конструируем объект b Конструируем объект с Конструируем объект d Ликвидируем объект d Ликвидируем объект с Ликвидируем объект b Исключение int Ликвидируем объект а Как видите, прежде чем в функции f 2 () произойдет исключение int, конструи- руются четыре объекта - а, Ь, с и d. Поскольку в f 2 () блок try не определен, стек вызовов функций разворачивается. При удалении из стека дисплея функции f 2 () ли- квидируется объект d. В функции fl () определен блок try, но его блок catch пере- хватывает только исключение типа char*, а тип вброшенного объекта — int. По- скольку типы не совпадают, просмотр продолжается, и стек разворачивается дальше. При удалении из стека дисплея функции f 1 () ликвидируется объект с. Наконец, поиск происходит на более высоком уровне — на уровне функции main(). Здесь находится еще один блок try. Когда осуществляется выход из этого 290 Глава 10
блока, объект Ь становится невидимым (а, значит, и недоступным). Первый за этим блоком try блок catch принимает исключение типа float, что вновь не совпадает с типом нашего объекта (int), поэтому пропускается и этот блок. Однако следующий блок catch наконец-то перехватывает исключение типа int, и потому управление переходит к нему. Последний блок catch, который может обработать любое исклю- чение (воспринимает объект любого типа), пропускается, поскольку необходимый блок catch уже найден и исключение обработано. Исключение: что это такое и как его вбрасывать ...об исключениях мы скажем ниже... Зигмунд Фрейд. Толкование сновидений Спрашивается только, происходит ли это лишь в исключитель- ных случаях или же... Зигмунд Фрейд. Толкование сновидений Одно время исключением из правил считались затмения...; од- нако вавилонские жрецы и их свели к закону. Бертран Рассел. Наука и религия За ключевым словом throw следует выражение, которое создает исключение — объект некоторого типа. Тип исключения может быть любым, а не только базовым вроде int. Так что при генерации исключения можно передать любое количество ин- формации. Давайте напишем универсальный класс исключения, экземпляру которого будет передаваться имя исходного файла и номер текущей строки в файле. Вот опре- деление нужного нам класса: #include <iostream.h> #include <string.h> // Exception - универсальный класс // обработки исключительных ситуаций class Exception { public: Exception(char* pMsg, char* pFile, int nLine) { strncpy(msg, pMsg, sizeof msg); msg[sizeof msg - 1] = ' \0'; strncpy(file, pFile, sizeof file); file[sizeof file - 1] = '\0'; lineNum = nLine; } virtual void display(ostream& out) { out « "Ошибка < " « msg « ">\n"; out « "обнаружена в строке #" « lineNum « ", файла" « file « endl; } protected: char msg[80]; //сообщение об ошибке //имя файла и строка, в которой возникла ошибка char file[80]; Исключения 291
int lineNum; }; При генерации исключения определенному нами классу нужно передать строку сообщения, номер текущей строки программы (макроопределение _line__) и имя исходного модуля (макроопределение_file__). Вот как все это выглядит: throw Exception("Отрицательный аргумент факториала", ___________________LINE___, __FILE__) ; Теперь довольно просто написать блок catch, который должен перехватывать ис- ключение типа Exception, и использовать встроенную функцию-член display () для отображения сообщения об ошибке. Вывод сообщения об ошибке направим в поток вывода для сообщений об ошибках (сегг): функции-члену display () в качестве па- раметра передадим поток вывода для сообщений об ошибках (сегг), а не поток cout. Поместим полученный блок catch в функцию myFunc (): void myFunc() { try { //...любой вызов } // перехват исключения - объекта типа Exception catch(Exception& x) { x.display(сегг); // используем встроенную функцию-член } } Класс Exception можно считать универсальным для сообщений об ошибках. При необходимости его, конечно, можно расширить. Для этого достаточно создать произ- водные от него классы. Например, чтобы хранить значение некорректного аргумента, а не только сообщение об ошибке, можно определить производный от него класс Inva 1 idArgumentExcep t i on: class InvalidArgumentException : public Exception { public: InvalidArgumentException(int arg, char* pFile, int nLine) :Exception("Неверный аргумент", pFile, nLine) { invArg = arg; } virtual void display(ostream& out) { Exception::display(out); out « "Аргумент равен " « invArg « endl; } protected: int invArg; }; Заметьте, что блок catch перехватывает исключения типа Exception, но ведь InvalidArgumentException ЯВЛЯЕТСЯ (Is-a) Exception, так что исключение InvalidArgumentException будет перехвачено этим блоком. Ну, а о вызове нужной версии функции-члена display () позаботится полиморфизм. Объектно-ориентиро- ванное программирование в действии! 292 Гпава 10
Резюме Есть всегда исключения из правила... Эмиль Дюркгейм. О разделении общественного труда ...исключительность исключительного удостоверяется его ред- костью. Луций Анней Сенека. Письма Этот закон имеет исключения... Бертран Рассел. Искусство философствования Исключения — это объекты, которые создаются для обработки ошибок или других исключительных ситуаций, возникающих во время выполнения программы. Блоки catch, расположенные в стеке вызовов на уровне, не ниже того, на котором вбрасы- вается исключение, перехватывают исключение и обрабатывают его. Исключения — это обычные объекты, которые можно передавать в функции по зна- чению или по ссылке. Они могут содержать данные и методы, а блок catch может ис- пользовать эти данные, чтобы определить, как справиться с возникшими проблемами. Можно написать несколько блоков catch, но следует учитывать, что, как только ис- ключение будет перехвачено каким-либо блоком catch, оно не будет передаваться по- следующим блокам catch. Поэтому так важно правильно упорядочить блоки catch. Задачи и упражнения 1. Что такое исключение? 2. Для чего нужен блок try? 3. Для чего используется оператор catch? 4. Какую информацию может содержать исключение? 5. Какой оператор создает объект-исключение? 6. Исключения нужно передавать как значения или как ссылки? 7. Пусть некоторый оператор catch перехватывает исключения определенного типа (класса). Сможет ли он перехватить производное исключение? (Производ- ное исключение — экземпляр производного класса.) 8. Предположим, есть два оператора catch, один из которых перехватывает базо- вое исключение, а второй — производное. Предположим также, что эти опера- торы расположены друг за другом. Что будет, если обработчик базового исклю- чения предшествует обработчику производного исключения? А если поменять местами обработчики? 9. Какие исключения перехватывает оператор catch (. . .) ? 10. Почему удобнее представлять исключения в виде объектов? Не проще ли пере- давать код ошибки? 11. Запишите блок try и оператор catch для простого исключения. 12. Придумайте исключение, добавьте в него переменную-член и метод доступа и используйте их в блоке catch. Исключения 293
13. Создайте производное исключение от придуманного вами базового исключе- ния. Напишите блок catch таким образом, чтобы он обрабатывал как произ- водное, так и базовое исключения. 14. Создайте производное исключение от придуманного вами базового исключе- ния. Напишите блок catch таким образом, чтобы он обрабатывал как произ- водное, так и базовое исключения. Сделайте так, чтобы получилось три уровня вызова функции. 15. Почему бы не использовать исключения не только для отслеживания исключи- тельных ситуаций, но и для выполнения обычных действий? Разве нельзя ис- пользовать механизм исключений для быстрого возврата по стеку вызовов к ис- ходному состоянию программы? 16. Обязательно ли перехватывать исключение сразу за блоком try, генерирующим это исключение? 294 Глава 10
Глава 11 Знакомство с Visual C++. Создание первого визуального приложения в среде Visual C++ В этой главе... Обзор среды разработки Visual C++ .NET 296 Ваш первый проект 299 Как работают Windows-приложения 304 Проектирование окна приложения 306 Добавление нового кода в приложение 308 Последний штрих 310 Резюме 314 Задачи и упражнения 314 Профессор Уотсон, как следует заключить, не владеет способ- ностью к визуализации и не склонен думать, что ею владеют другие. Бертран Рассел. О пропозициях — что они собой представляют и каким образом обозначают Образы слов могут быть локализованы во рту... Но визуаль- ные... находятся в совершенно ином положении. Бертран Рассел. О пропозициях — что они собой представляют и каким образом обозначают До сих пор вы изучали “чистый” C++ и учились создавать приложения. Несмотря на то, что C++ не является чистым объектно-ориентированным языком, вы профес- сионально освоили применение объектно-ориентированного подхода к разработке программ на C++. В принципе, вы можете создать любое, сколь угодно сложное при- ложение. Однако все созданные до сих пор приложения имели серьезный недостаток: бедный интерфейс в виде командной строки. Конечно, если даже на С можно подде- лать приложение в стиле Windows, то подделать его на C++ еще проще. Но не на- столько просто, чтобы этим занимались профессионалы. Профессионалы ведь потому и профессионалы, что у них для выполнения любой работы есть профессиональный инструмент. Для разработки приложений в стиле Windows, т.е. приложений, интер- фейс которых удовлетворяет требованиям, предъявляемым к приложениям в стиле Windows, как раз и предназначен Visual C++. Что это? Новый язык? Можно ответить
утвердительно, но нужно заметить, что как язык это все же C++-, отличие — возмож- ность использования дополнительных библиотек и средств их применения. С другой стороны, можно сказать, что это среда разработки приложений с Windows- интерфейсом на C++. Таких сред разработано, конечно, множество. И при желании вы вполне можете освоить, например, PowerBuilder или Microsoft Visual C++ 6.0. Од- нако помните: Visual C++ — это инструмент создания приложений с Windows- интерфейсом на C++. И как часто бывает в программировании, чем более позднюю версию инструмента вы используете, чем проще с ней работать и тем лучше результат. Поэтому чтобы не повторять путь пионеров, набцвших немало шишек на пути созда- ния приложений с Windows-интерфейсом именно из-за несовершенства инструмента и самой среды, в которой выполняется приложение, я советую выбрать Visual C++ .NET. Скажу сразу, что этот инструмент возник не на голом месте, его предшествен- ником можно считать Microsoft Visual C++ 6.0. Правда, эта среда имеет один весьма серьезный недостаток: по сравнению с первыми компиляторами С для ее установки требуется немало ресурсов. Даже не пытайтесь установить этот продукт на компьютер с процессором 80286, 80386, 80486 или даже Pentium 100 с диском 6,4 Гбайт. Нужен процессор с тактовой частотой не менее 400 МГц и диском не менее 10 Гбайт. (Это весьма скромные требования; для комфортной работы нужен гораздо более мощный компьютер.) Зато во всем остальном эта среда многократно окупает расходы, связан- ные с упомянутым выше недостатком — о многих проблемах (кошмар DLL, трудно- сти с установкой, с отслеживанием версий, невозможность одновременного использо- вания нескольких версий некоторых приложений, сложность цифрового подписания) вы даже не узнаете... И потому в самый раз познакомиться с этой средой. Как вы уже знаете, сам язык программирования Visual C++ не зависит от ин- тегрированной среды разработки. Однако описываемые в этой главе инстру- менты и редакторы относятся к интегрированной среде разработки (IDE) Visual Studio. В других средах аналогичные инструменты могут выглядеть иначе или вообще отсутствовать. С другой стороны, у всех языков программирования, представленных в Visual Studio, инструменты и редакторы общие. Так что ос- воив инструменты и редакторы Visual Studio, их можно использовать для соз- дания кода и на других языках программирования, а не только на Visual C++. Тогда программный код, генерируемый средствами Visual Studio, будет напи- сан на выбранном вами языке программирования. Конечно, для создания приложений в Visual C++ предусмотрено множество инст- рументов — гораздо больше, чем можно описать в одной главе, поэтому мы рассмот- рим только основные. Обзор среды разработки Visual C++ .NET Итак, запустите на своем компьютере Visual C++ .NET. После запуска вы увидите ок- но, изображенное на рис. 11.1. Каждая вкладка (область) этого окна имеет свое предназна- чение. Расположение этих областей можно изменить так, как вы считаете более удобным. Стартовое окно Visual Studio не во всех версиях Visual Studio .NET выглядит одинаково. В процессе усовершенствования его вид несколько раз изменялся. В стартовом окне броузера можно выполнять сценарии (скрипты, т.е. програм- мы на макроязыке). И если вместе с Visual Studio .NET или Visual C++ .NET на компьютере установлена антивирусная программа, то во время выполнения таких сценариев могут появиться предупреждающие сообщения о возможном наличии вирусов. 296 Глава 11
i?ew loc* YtTdow ^J2£l e, e* > - & - за** r Рис. 11.1. Стартовое диалоговое окно Visual C++ Solution Explorer — проводник, или обозреватель решений Область (точнее, стыкованная панель) в правой части интегрированной среды раз- работки (IDE) называется Solution Explorer (проводник, или обозреватель решений). Именно она позволяет просматривать отдельные части проектируемых вами приложе- ний. (Честно говоря, область Solution Explorer похожа на Project Explorer в предыду- щих версиях Visual Basic, Visual InterDev и Visual J++.) В этом окне три вкладки: Вкладка Class позволяет просматривать исходный код классов. Вкладка Resource позволяет найти и отредактировать любой ресурс в приложе- нии, включая пиктограммы и меню. Эта вкладка отображается не всегда. Вкладка Solution Explorer позволяет увидеть файлы приложения. Расположение вкладок (подокон) можно изменить. Их можно перетащить в другие окна интегрированной среды разработки, а также добавить дополни- тельные подокна. Область вывода Область вывода (вкладка Output) при первом запуске Visual C++ может и не ото- бражаться. Однако как только вы скомпилируете свое первое приложение, это окно появится в нижней части главного окна интегрированной среды разработки. Оно бу- дет оставаться открытым, пока вы его не закроете. В области вывода отображаются сообщения компилятора, а также другие предупреждения и сообщения об ошибках. В этом же окне отладчик отображает текущие значения переменных при пошаговом вы- полнении кода. Если закрыть область вывода или любую его вкладку (подокно), то при появлении нового сообщения эта вкладка откроется вновь. Знакомство с Visual C++. Создание первого визуального приложения... 297
Область редактирования В этой области (Editor area) отображается исходный текст; разумеется, его можно редактировать встроенным редактором Visual C++. Именно в этом окне отображается информация, генерируемая дизайнерами окон при разработке диалоговых окон. В об- ласти редактирования, например, при разработке пиктограмм приложения отобража- ются пиктограммы. Строки меню При первом запуске Visual C++ под главной строкой меню находится парочка па- нелей инструментов (в Visual C++ их, конечно, значительно больше; если же вам все же покажется мало, можете создать собственные панели инструментов). При первом запуске обычно отображаются следующие панели инструментов: Панель инструментов Standard (Стандартная). На этой панели находится большин- ство стандартных инструментов для открытия и сохранения файлов, вырезания, копирования и вставки. Панель инструментов. Состав инструментов, отображаемых на этой панели, зави- сит от действий, выполняемых в окне редактирования. В начале работы здесь рас- положены инструменты для навигации по документам Visual C++. При редактиро- вании программы на этой панели появляются инструменты для редактирования кода, а при проектировании диалогового окна отображаются инструменты для из- менения размеров и выравнивания элементов управления. Изменение конфигурации среды Изменить конфигурацию среды разработки несложно. Если щелкнуть правой кнопкой мыши в области панелей инструментов, откроется контекстное меню, в ко- тором можно включить или выключить отображение различных панелей инструмен- тов (рис. 11.2). Рис. 11.2. Меню выбора отображаемых панелей инструментов 298 Глава 11
Кроме того, подокно или полосу из двух линий, размещенную слева на панели ин- струментов, можно перетащить мышью в нужное место. Например, панель инстру- ментов можно сделать плавающей, если оттащить ее от панели, к которой она при- креплена. А чтобы прикрепить панель инструментов (или подокно), его нужно пере- тащить к тому краю, к которому вы хотите ее прикрепить. Ничего нового. Зато гораздо интереснее то, что можно разъединить различные вкладки одного по- докна и сделать эти вкладки самостоятельными подокнами. И конечно же, различные наборы вкладок можно объединить в одном подокне. Ваш первый проект Давайте теперь в качестве своего первого (в стиле Windows, конечно) приложения создадим простое приложение. Вы, конечно, догадались: это будет приложение- приветствие. Но приветствовать можно по-разному. Давайте сделаем так, чтобы при- ветствие появлялось после того, как пользователь щелкнет на кнопке. И, конечно же, понадобится кнопка для закрытия приложения. Таким образом, пользовательский ин- терфейс нашего приложения будет содержать две кнопки (рис. 11.3). Итак, по щелчку на первой кнопке должно появиться окно с простым сообщением-приветствием (рис. 11.4), щелчок на второй кнопке будет закрывать приложение. Создание нашего приложения включает следующие действия. 1. Сначала создаем рабочую область нового проекта, часто называемую также рабо- чей средой (проектирования), рабочим пространством и рабочей обстановкой. 2. С помощью мастера создания приложений Application Wizard нужно сгенериро- вать каркас приложения. 3. При необходимости изменяем внешний вид окон, автоматически генерируемых мастером создания приложений Application Wizard. 4. Добавляем код C++, который отображает приветствие. 5. Создаем пиктограмму приложения. Рис. 11.3. Ваше первое приложе- ние на Visual C++ Рис. 11.4. Если щелк- нуть на первой кнопке, то отобразится вот это окно с приветствием Создание рабочей области проекта Каждое приложение в Visual C++ имеет свою рабочую среду. Рабочая среда проек- та — это папки, в которых хранятся исходные и конфигурационные файлы. Чтобы создать рабочую среду нового проекта, выполните следующие указания: 1. В левой части начальной страницы (Start Page) выберите вкладку Get Started, а затем щелкните на кнопке New Project (Создать Новый Проект). Знакомство с Visual C++. Создание первого визуального приложения... 299
Чтобы открыть начальную страницу (Start Page), достаточно выбрать View^ Web Browser^Show Browser. Рис. 11.5. Мастер создания нового проекта (New Project Wizard) 2. В подокне Project Туре (Типы Проектов) выберите папку Visual C++ Projects. В подокне Templates (Шаблоны) выберите MFC Application (Приложение Биб- лиотеки Базовых Классов Microsoft). (Для просмотра набора шаблонов при не- обходимости используйте полосу прокрутки.) 3. В поле Name (Название Проекта) введите Hello — название вашего проекта. 4. Щелкните на кнопке ОК (Да). Мастер создания нового проекта создаст папку для нового проекта (эта папка указывается в поле Location), а затем запустит мастер создания приложений Application Wizard. Генерация оболочки приложения с помощью мастера создания приложений Application Wizard Мастер создания приложений библиотеки базовых классов Microsoft (MFC Appli- cation Wizard) задаст целую серию вопросов о типе создаваемого приложения и его функциональных возможностях. Эта информация используется для создания оболоч- ки приложения, которое затем сразу же можно скомпилировать и запустить. Фактиче- ски эта оболочка является базовой инфраструктурой (совокупностью основных ком- понентов, объединенных в систему), которая необходима для создаваемого приложе- ния. (Следовательно, у вас с самого начала будет работающая модель приложения с основными заглушками и подыгрывающими программами.) Чтобы подробнее разо- браться, как все это работает, выполните следующие действия: 1. В левой части Мастера выберите вкладку Application Туре (Тип приложения). Это позволит открыть вкладку, в которой можно указать опции, определяющие тип создаваемого приложения. Поскольку мы создаем диалоговое приложение, в правой части окна в качестве типа приложения выберите Dialog based (Диалоговое), как показано на рис. 11.6. 2. Теперь выберите вкладку User Interface Features (Свойства интерфейса пользо- вателя). В этой вкладке нужно выбрать вид главного окна приложения. В поле возле нижнего края окна отображается текст заголовка; при запуске приложе- ния этот текст отображается в строке заголовка приложения. По умолчанию 300 Глава 11
текст заголовка совпадает с названием проекта (в нашем случае Hello). Давайте изменим его на Му First Visual C++ Application, как показано на рис. 11.7. Application Туре Specfy Document/V*** architecture support, language. and nterface style options for your appkatwn. Overview I Application Type Compound Document su®cn Ckxxmertt Template Эгида i.'atitise xcoort User Interface Features Advanced Features Generated Classes Appkation type: Г Jngie document Г Mjfcpie documents <• 0atog based Г Use HTML dialog C top-level doccrrents Resource language: jEngbst. (Unted States) 3 Project style: C (• MFC standard Use of MFC: (• Use MFC in a shared C4J. C Usj MFC n a static Ibrary Рис. 11.6. Указание типа приложения Рис. 11.7. Вводим заголовок приложения 3. Теперь мастер создания приложений MFC Application Wizard может сгенериро- вать оболочку приложения. Для этого достаточно щелкнуть на кнопке Finish (Готово). Если же этой кнопки не видно, прокрутите окно — она внизу рядом с кнопками Cancel (Отмена) и Help (Справка). Далее может появиться вспомога- тельное окно — запрос изменений. Ответьте утвердительно. 4. Создав оболочку приложения, вы вернетесь в среду разработки. Однако теперь в области решений появится вкладка, в которой вы увидите дерево ресурсов созданной оболочки приложения (рис. 11.8). Если щелкнуть на ресурсе, он появится в области редактирования. Чтобы увидеть главное окно приложения, щелкните на ресурсе IDD_HELLO_DIALOG. Если хотите рассмотреть доступ- ные инструменты, задержите указатель мыши над панелью инструментов — она раскроется. Знакомство с Visual C++. Создание первого визуального приложения... 301
Рис. 11.8. Рабочая область проекта с деревом классов проекта в области решений 5. Чтобы скомпилировать приложение, выберите в меню Build^Compile. Сообще- ния о ходе компиляции и другие будут отображаться в окне для вывода сооб- щений — Output. Поскольку вы компилируете созданное мастером приложение, ошибок быть не должно, поэтому в окне сообщений высветится сообщение об отсутствии ошибок и предупреждений (рис. 11.9). Рис. 11.9. В окне вывода сообщений отсутствуют сообщения об ошибках при компиляции 302 Глава 11
6. Для запуска приложения выберите Debugs Start (Отладка^ Начать). Может поя- виться окно с запросом обновления — отвечайте утвердительно. После этого созданное вами приложение отобразится в виде окна с сообщением todo (что СДЕЛАТЬ) в центре и кнопками ОК (Да) и Cancel (Отмена), как показано на рис. 11.10. Обратите внимание на текст в строке заголовка — он совпадет с тем, который вы ввели в мастере создания приложений (если бы вы его не измени- ли, он совпал бы с названием приложения). Чтобы закрыть приложение, доста- точно щелкнуть мышкой на любой кнопке. Рис. 11.10. Первоначальная оболочка прило- жения Главное диалоговое окно вашего приложения может иметь несущественные отличия от показанного на рис. 11.10. Например, вместо текста TODO: Place dialog controls вы можете увидеть текст TODO: Place dialog controls here. He смущайтесь, эти отличия обусловлены отличиями версий среды разработки. Теперь, когда мы (честно говоря, мастер) создали работающее приложение, давай- те разберемся, как же оно все-таки функционирует. Как работают Windows-приложения Лучше один раз щелкнуть на кнопке, чем тысячу раз набрать на клавиатуре. Поговорка визуального программиста Наше приложение взаимодействует с Windows с помощью библиотеки базовых классов Microsoft (MFC). Библиотека базовых классов Microsoft (MFC) представляет собой набор классов C++, которые помогают упростить создание интерфейса в стиле Windows. Аналогичные средства есть и в других языках. В Visual Basic, например, на окно приложения можно перетащить командную кнопку, а затем задать ее свойства и тем самым определить ее вид и поведение. Подобное средство есть и в библиотеке ба- зовых классов Microsoft (MFC) — это класс CButton, который инкапсулирует функ- циональные возможности командной кнопки. Но как же приводятся в действие все экземпляры всех этих классов и как они взаимодействуют между собой? Как вы знаете, работа любой программы Windows начинается с выполнения функ- ции winMain. Если бы вам (упаси Боже!) пришлось писать Windows-программу на С или C++, не пользуясь каркасом приложения (каркас приложения — интегрирован- Знакомство с Visual C++. Создание первого визуального приложения... 303
ная среда, которая содержит библиотеки классов и определяет структуру разрабаты- ваемого приложения), то вам довелось бы создавать эту функцию самостоятельно. Функция winMain создает главное окно приложения, затем запускается цикл, в кото- ром получаются сообщения о событиях и передаются соответствующим функциям — обработчикам событий, которые управляют процессами в окне (рис. 11.11). 304 Глава 11
Библиотека базовых классов Microsoft (MFC) как раз и предназначена для созда- ния каркаса приложения, в ней предусмотрено автоматическое создание большинства компонентов Windows-приложений. Так, если вы пользуетесь библиотекой базовых классов Microsoft (MFC), вам не придется писать функцию WinMain — она там уже есть, хотя и спрятана в глубинах исходных кодов этой библиотеки. Если взглянуть на дерево классов приложения, то там можно найти три следующих класса: CAboutDlg, CHelloApp и CHelloDlg. Класс CHelloApp является производным от класса CWinApp. В данном классе инкапсулировано большинство функций WinMain, что значительно упрощает программирование Windows-приложения. Класс CWinApp также инкапсули- рует большинство общих функций приложения, которые на самом деле не принадле- жат ни элементам управления, ни другим более специализированным объектам. К та- ким функциям, например, относится чтение и запись конфигурационной информа- ции в информационную базу системного реестра и обработка параметров командной строки. Классы CAboutDlg и CHelloDlg — производные от класса CDialog, который в свою очередь является специализированным классом, производным от класса более общего назначения cwnd. В классе cwnd инкапсулированы функции окна, а класс CDialog предназначен для обслуживания окон диалогового типа. Проектирование окна приложения Теперь займемся разметкой окон нашего приложения. Для изменения макета окна приложения выполните следующие действия: 1. В рабочей области выберите вкладку Resource View (Ресурсы), показанную на рис. 11.12. 2. Чтобы отобразить названия всех окон, раскройте дерево ресурсов. Теперь два раза щелкните на имени диалогового окна idd_hello_dialog — оно откроется в области редактирования. 3. Щелкните на тексте в окне и удалите его с помощью клавиши Delete. Рис. 11.12. Вкладка Resource View в окне рабочей области Знакомство с Visual C++. Создание первого визуального приложения... 305
4. Перетащите кнопку Cancel (Отмена) в нижнюю часть, растянув при этом кноп- ку на всю ширину окна, как показано на рис. 11.13. 5. На вкладке Properties (она находится справа — сразу под вкладкой Solution Explorer) измените значение поля Caption (Заголовок) — укажите там &Close. Рис. 11.13. Позиционирование кнопки Cancel (Отмена) 6. Перетащите кнопку ОК на середину окна и измените ее размеры, как показано на рис. 11.14. 7. Во вкладке Properties (Свойства) измените значение ID (идентификатор) на idhello, а значение поля Caption (Заголовок) — на &Не11о. 8. Откомпилируйте и выполните приложение. Убедитесь, что оно выглядит так, как вы его только что разметили (рис. 11.15). Рис. 11.14. Позиционирование кноп- ки ОК Рис. 11.15. Работающее перепро- ектированное приложение При программировании Windows-приложений знак & в свойстве Caption (надпись, заголовок) указывает, что следующий за ним символ является кла- вишей быстрого доступа для этой кнопки, меню или элемента управления. Эта клавиша позволяет ускорить вызов функции кнопки— достаточно одновре- менно нажать клавишу Alt (при этом появится подчеркивание под символами, которым предшествует знак &) и символ, следующий за знаком &. После этого запустится функция кнопки (меню или другого элемента управления). 306 Глава 11
Испытаем наше приложение. Как вы уже заметили, кнопка Close (Закрыть) по- прежнему закрывает приложение, а вот кнопка Hello (Привет) уже ничего не выпол- няет, поскольку мы изменили идентификатор (ID) этой кнопки. Дело в том, что в ис- ходном коде приложений, построенных с помощью библиотеки базовых классов Mi- crosoft, содержится множество макросов, определяющих, какие функции необходимо вызвать. Причем вызываемые функции определяются по идентификаторам (ID) и со- общениям о событиях для каждого элемента управления в приложении. Так как иден- тификатор (ID) кнопки Hello изменился, то для данного идентификатора (ID) кноп- ки больше не существует макроса, указывающего функцию, которую нужно вызвать по щелчку на этой кнопке. Добавление нового кода в приложение ...логическое пространство является концептуальной проекцией определенных атрибутов визуальных чувственных впечатлений. Уилфрид Селларс. Научный реализм или “миролюбивый ” инструментализм? (Комментарий к статье Дж. Дж. Смарта) Итак, мы обнаружили, что одна кнопка нашего приложения “испортилась”. Более того известно, почему это произошло — мы изменили идентификатор (ID) кнопки. А ведь именно по идентификатору (ID) кнопки определяется вызываемая ею функция. Давайте воспользуемся полученной информацией и “исправим” кнопку. Нам нужно связать функцию с кнопкой Hello. Это делается с помощью подокна Properties (Свойства). Подокно Properties позволяет создать таблицу сообщений, получаемых приложением от Windows; в такой таблице указываются функции, вызываемые для обработки сообщений. Макросы библиотеки базовых классов Microsoft (MFC) уста- навливают соответствие между этими функциями и элементами управления окна. Да- вайте теперь установим соответствие между кнопкой Hello и ее функциями. Для этого выполните следующие действия: 1. Чтобы некоторой кнопке (например Hello) поставить в соответствие функции, выберите эту кнопку (в нашем случае Hello), а затем щелкните на кнопке с пиктограммой в виде молнии Control Events (Управление Событиями) во вкладке Properties (Свойства), показанной на рис. 11.16. 2. В списке сообщений выберите нужное событие. (Мы выберем bn_clicked — щелчок на кнопке.) Поле, в котором отображается идентификатор события, пре- вратится в раскрывающийся список. Щелкните на кнопке-стрелке — появится раскрывающийся список с предложением по названию функции. В нашем случае будет предложено название (имя) функции OnBnClickedHello (рис. 11.17). Да- вайте выберем его. Это имя появится в поле, в котором отображается идентифи- катор события (в нашем случае bn_clicked). Кроме того, откроется окно редак- тирования, в которое нужно ввести код функции OnBnClickedHello. 3. Добавьте следующий код: void CHelloDlg::OnBnClickedHello(void) { // TODO: Add your control notification handler code here // ЧТО СДЕЛАТЬ: Вставьте сюда ваш управляющий код программы // обработки сообщений // Скажем hello (привет) пользователю MessageBox("Hello. This is my first Visual C++ Знакомство c Visual C++. Создание первого визуального приложения... 307
Application’"); // MessageBox (" Привет. Это - мое первое Приложение на Visual C++! "); } там, где показано на рис. 11.18. Убедитесь, что ввели код без ошибок. IDOK (Button Control) IButtonEdtor _•] Group ..-fake_______ ГО [Control Events | Tabstop True Properties О/папмс Hefc ! Рис. 11.17. Предлагаемое имя функции для обработки события Рис. 11.16. Включите режим Control Events (Управление Со- бытиями) на вкладке Properties (Свойства) Когда вы вводите код, редактор пытается угадать, какую функцию, метод или свойство объекта вы набираете, и предлагает выбрать функцию, метод, свой- ство или объект из списка доступных функций, методов, свойств или объектов. Эта функция-подсказка редактора называется intellisense. Она помогает предупредить ошибки ввода. Когда введено имя функции и поставлена откры- вающая скобка, то отображается список параметров функции, причем вводи- мый параметр подсвечивается. Подсказка, указывающая доступную функцию и ее параметры, впервые была предложена в Visual Basic 5, а спустя пару лет появилась и в Visual C++ 6. Рис. 11.18. Область для редактирования исходного кода. Вместо выделен- ного комментария необходимо вставить код 308 Глава 11
4. После того как вы скомпилировали и запустили приложение, по щелчку на кнопке Hello откроется окно с сообщением, как показано на рис. 11.19. Если же вы допустили ошибки, например заменили в имени функции строчные буквы заглавными или пропустили точку с запятой в конце строки, то при компиля- ции кода получите сообщения об ошибках (рис. 11.20). Если дважды щелкнуть на ка- ком-либо сообщении об ошибке, то рядом со строкой кода, в которой содержится ошибка, появится маркер (рис. 11.21). Рис. 11.19. Теперь ваше приложение будет при- ветствовать вас Рис. 11.20. Сообщения об ошибках указывают на ошибки в коде, которые необходимо исправить Последний штрих Теперь приложение выполняет все задуманные нами функции. Однако для завер- шения проекта можно добавить несколько последних деталей: 1. Создать пиктограмму для приложения. 2. Добавить кнопки Minimize (Свернуть) и Maximize (Развернуть). Рис. 11.21. Если дважды щелкнуть на сообщении об ошибке, то рядом со строкой кода, в которой эта ошибка была обнаружена, появится маркер Знакомство с Visual C++. Создание первого визуального приложения... 309
Создание пиктограммы приложения Пиктограмма в левом верхнем углу окна вашего приложения выглядит в виде трех кубиков с литерами М, F и С. Что они означают? Конечно, MFC расшифровывается как Microsoft Foundation Classes (Библиотека Базовых Классов Microsoft). Собственно говоря, на основе этой библиотеки классов и было построено наше приложение. Но нужно ли это знать пользователю, который откроет приложение? Наверное, нет. По- этому лучше создайте другую пиктограмму приложения. Для этого выполните сле- дующие инструкции: 1. На вкладке Resources (Ресурсы) в окне Solution Explorer раскройте папку Icon (Пиктограмма) и дважды щелкните на пиктограмме id_mainframe — в окне редактирования отобразится пиктограмма приложения (рис. 11.22). 2. Теперь с помощью средств рисования изобразите свою пиктограмму приложе- ния. Для этого можете, например, воспользоваться окном выбора цветов. В не- которых версиях оно будет открыто автоматически, а в некоторых для его от- крытия придется щелкнуть на белом пространстве правой кнопкой мыши и вы- брать в контекстном меню Show Color Window (Отобразить Окно Цветов), как показано на рис. 11.23. В целом же пиктограмма может выглядеть так, как по- казано на рис. 11.24. Рис. 11.22. Стандартная пиктограмма MFC На самом деле создается не одна пиктограмма, а набор пиктограмм. Размер нарисованной пиктограммы равен 32x32 пикселя. Эта пиктограмма отобража- ется на рабочем столе и в информационном окне About (О программе). В ле- вом верхнем углу меню приложения, а также на панели задач Start (Пуск) ото- бражается вариант размером 16x16 пикселей. Остальные пиктограммы отли- чаются лишь набором цветов, они предназначены для отображения в различных ситуациях. Можно создать пиктограммы других размеров и выбрать количество цветов. Для того чтобы нарисовать пиктограмму другого типа, вос- пользуйтесь командами Image^Current Icon Image Types (Изображение^Тип Текущего Изображения). 310 Глава 11
Рис. 11.23. Открытие окна выбора цветов Рис. 11.24. Новая пиктограмма приложения 3. Теперь скомпилируйте и запустите приложение. В левом верхнем углу главного окна приложения будет красоваться нарисованная пиктограмма. Щелкните на ней левой или правой кнопкой мыши и в раскрывающемся меню выберите About Hello (О программе...). 4. В появившемся информационном окне About вы увидите свой шедевр (точнее, его большую версию), представленный на рис. 11.25. Знакомство с Visual C++. Создание первого визуального приложения... 311
Рис. 11.25. Информационное окно About приложения Добавление кнопок Maximize (Развернуть) и Minimize (Свернуть) Добавить кнопки Minimize (Свернуть) и Maximize (Развернуть) можно в редакторе диалоговых окон — там же, где вы проектировали окно приложения. Для этого вы- полните следующие действия: 1. Выберите окно приложения так, как вы делали это при изменении размеров. 2. Во вкладке Properties (Свойства) найдите свойства Minimize Box и Maximize Box. Установите значения обоих свойств равными true. После этого снова скомпилируйте и запустите приложение. В строке заголовка окна приложения появятся кнопки Maximize (Развернуть) и Minimize (Свернуть), как показано на рис. 11.26. Рис. 11.26. Окно приложения с кнопками Maximize (Развернуть) и Minimize (Свернуть) Дальнейшее усовершенствование приложения Hello Визуальные возможности Windows-приложений гораздо богаче, чем у приложений с интерфейсом командной строки. Ведь даже выразить приветствие Hello можно по- разному: меняя шрифт, цвет литер, цвет фона, вид диалогового окна, голос, тон, ани- мационные эффекты, например жесты диктора... Поэтому даже простейшую про- грамму-приветствие можно усовершенствовать бесчисленным числом способов. Очень легко, например, изменить заголовок окна сообщений. Ведь по умолчанию в качестве заголовка окна сообщений используется название приложения, а это так скучно! Что- бы изменить заголовок, нужно добавить вторую строку текста в вызов функции MessageBox. Первая строка текста представляет собой отображаемое сообщение, а вторая — заголовок окна. Например, в функции OnBnClickedHello можно написать следующий вызов: // Сказать hello (привет) пользователю MessageBox("Hello. This is my first Visual C++ Application!", // MessageBox (" Привет. Это - мое первое Приложение Visual C++! ", "Му First Application"); // " Мое Первое Приложение "); 312 Глава 11
Можно также дополнить информацию в окне About (О программе...), например, указав, что это именно вы создали приложение-приветствие. Для этого на вкладке Resources View (Ресурсы) рабочей области в папке Dialog выберите ресурс окна About (О программе...) — это пиктограмма idd_aboutbox. Если дважды щелкнуть на ней, то окно About (О программе...) отобразится в редакторе диалоговых окон, и вы сможете перепроектировать его так, как захотите. Резюме Для создания приложений на Visual C++ лучше всего применять подходящую сре- ду разработки, например Microsoft Visual Studio .NET. Мастера этой среды позволяют легко создать оболочку приложения и поставить функции в соответствие визуальным компонентам, размещаемым в окне приложения. Однако для освоения Visual C++ и подходящей среды разработки, например Microsoft Visual Studio .NET, нужна не одна глава, а несколько толстых книг. Задачи и упражнения 1. Как изменить надпись на кнопке? 2. Для чего предназначен MFC Application Wizard? 3. Как связать функцию со щелчком на кнопке? 4. Добавьте вторую кнопку в окно About (О программе...) вашего приложения. Пусть щелчок на этой кнопке вызывает появление сообщения с любыми до- полнительными сведениями о программе. Знакомство с Visual C++. Создание первого визуального приложения... 313
Приложение Ответы и решения задач и упражнений В этом приложении... Глава 1 314 Глава 2 315 Глава 3 315 Глава 4 334 Глава 5 336 Глава 6 336 Глава 7 338 Глава 8 342 Глава 9 344 Глава 10 350 Глава 11 ‘ 355 Г лава 1 1. Что такое интегрированная среда разработки программ? Ответ. Интегрированная среда разработки программ представляет собой програм- му, имеющую встроенный редактор текстов, подсистему работы с файлами, справоч- ную систему (Help-систему), встроенный отладчик, подсистему управления компиля- цией и редактирования связей. 2. Назовите основные компоненты современных систем программирования. Ответ. Основными компонентами современных систем программирования являются: • компиляторы; • оболочка интегрированной среды программирования; • редактор связей (компоновщик); • библиотеки заголовочных файлов; • стандартные и специальные библиотеки; • библиотеки примеров программ; • программы-утилиты (всевозможные конверторы, макрогенераторы, верифика- торы, отладчики и т.д.); • файлы документации.
Г лава 2 1. Отладка. Найдите ошибки в приведенной ниже программе. #include <iostream.h> int main() { cout « Is there a bug here?"; } Решение. Во-первых, здесь есть синтаксическая ошибка: перед строкой пропущена кавычка. Поэтому компилятор воспринимает Is не как начало строки, а как иденти- фикатор и пытается найти в программе то, чего там нет. Из-за этого иногда возника- ют побочные (наведенные) сообщения об ошибках, и такие сообщения могут даже сбивать с толку. Во-вторых, в этой программе отсутствует оператор return 0;. Хотя, впрочем, некоторые компиляторы вставляют его в подобных случаях автоматически. Тем не менее пользоваться этим считается неприлично. В-третьих, лучше писать программу не с чистого листа, а предварительно сгенерировать заготовку. Тогда заго- ловок функции main () выглядел бы иначе и содержал бы все параметры, которые могут понадобиться при дальнейшем усовершенствовании программы. Г лава 3 1. Построенный нами конструктор класса EulerE EulerE::EulerE(integer DecPrec /* точность ★/, // конструктор integer n /* последний член 1/n! */ ) имеет два параметра: точность (DecPrec) и число п, которое определяет коли- чество членов факториального ряда. Наличие обоих параметров избыточно: при заданной точности необходимое количество членов ряда можно вычислить. К тому же наличие обоих параметров неудобно: обычно известна только точность, а количество членов факториального ряда приходится вычислять, и это не- сколько огорчает. (Попробуйте, например, определить количество членов фак- ториального ряда, необходимое для вычисления е с точностью 1000 знаков по- сле запятой.) Перегрузите конструктор так, чтобы можно было задавать только точность, и вычислите основание натуральных логарифмов с точностью 1000 знаков после запятой. Решение. Анализ задачи. Очевидно, нужно найти такое натуральное л, что л!>10^сРгес. Конечно, с целью экономии количества вычисляемых членов ряда, лучше всего найти такое минимальное значение л, однако беды не будет, если остановиться и на чуть большем значении. Логарифмируя неравенство л!>10ЛсРг<!Г, получим: 1п(л!) > DecPrec In 10. Так как п\ неограниченно и монотонно возрастает с возрастани- ем л, то, удваивая л, можно найти какое-либо л, при котором л!>10л?сРгес. Затем мето- дом деления пополам не составит труда найти и наименьшее такое п. Сам же логарифм 1п(л!) лучше всего вычислить с помощью формулы Стирлинга: ___ . / 1 \ g 1п( л!) = In л/2я- + п + — In л - л +----------- I 2J 12л (где 0 < О < 1). Для наших целей достаточно в качестве приближенного значения (с недостатком) 1п(л!) взять In + ^л + In n - n . Несмотря на то, что это значение с недостатком и учитывая приближенность вы- числений, лучше все же найти л такое, что Ответы и решения задач и упражнений 315
In + —j In n - n > eps + DecPrec In 10. (Для наших целей вполне достаточно взять eps = 0.001.) Теперь сформулируем задачу нахождения подходящего значения п. (Конечно, эту за- дачу точнее было бы назвать подзадачей, так как это ведь еще не решение всей задачи.) Постановка подзадачи. Дано неравенство Дх) > и и такие числа а, b (а < Ь), что значение и является промежуточным междуДя) и/(/>), причем функция у = fix) опре- делена, по крайней мере, в точках отрезка [а, £] с целыми абсциссами и ее ограниче- ние на подмножество целых чисел отрезка [а, Ь] является функцией неубывающей. Тогда неравенство fix) > и имеет непустое множество целочисленных решений на от- резке [а, Ь], При этом функция f и число и имеют тип double, и вычисляются, есте- ственно, с некоторой погрешностью. Требуется найти наименьшее число в этом множестве. Иначе говоря, если v — наименьшее число в этом множестве, т.е. a<v<b и fv) > w, то при всех целых w < v выполнено неравенство flw) < и, а при всех целых w > v — неравенство fw) > и. В ходе выполнения программы значения функции fix) могут вычисляться компью- тером приближенно. (Это вполне естественно даже для таких функций, как In х и sin х.) Строго говоря, по приближенному значению fix) не всегда можно установить, справедливо ли неравенство fix)<u. Конечно, это касается случая, когда значение раз- ности fix) — и мало, а погрешность вычисления значения превосходит само это значе- ние. Пусть, например, в результате приближенного вычисления fix) — и получилось значение 0.0000015, а погрешность вычисления равна 0.000002. Истинное значение функции может быть равно 0.0000035, но может быть равно и -0.0000005, это и слу- жит причиной затруднений. Аналогично и проверка неравенства fic)<u затруднена, ес- ли значения fic) и и вычислены с ощутимой погрешностью. В вычислительной мате- матике в подобных случаях считают, что результаты приближенного вычисления fiс) и и позволяют правильно отвечать на вопрос, верно ли, что fc)<u. Однако возможны нежелательные последствия этого допущения. Поскольку не всегда можно алгоритми- чески установить справедливость неравенства fic)<u, метод последовательных делений отрезка пополам для решения уравнений вида fix) = и и неравенств видов fix) < и, fx) > и, fx) < и и fix) > и нельзя считать классическим алгоритмом. Чтобы избежать подобных трудностей, будем считать, что требование минимальности искомого числа весьма желательно, но гораздо более существенно гарантировать, что fix) > и. Так что в сомнительных случаях можно отказаться от минимальности искомого числа и ис- кать число, удовлетворяющее неравенству Дх) > и + eps. Требуется найти, в частности, этим способом наименьшее положительное целое л, удовлетворяющее неравенству 1п(л!) > DecPrec In 10, точнее неравенству 1п(л!) > > DecPrec In 10 + eps. Решение подзадачи. Для поиска нужного значения можно воспользоваться алго- ритмом деления отрезка пополам. Прежде всего можем считать концы отрезка [а, £] целочисленными. Взяв целочисленную “середину” отрезка [а, Ь], т.е. точку оси абс- цисс с координатой с = [(а + Z>)/2]5, можно, вообще говоря, сузить диапазон поиска: перейти от отрезка [а, Ь] к отрезку [а, с] или [с, £] в зависимости от знака fiс) — и: ес- ли fic) > и (точнее fic) > и + eps), то перейти к отрезку [а, с], если fic) < и (точнее fic) > и — eps), то перейти к отрезку [с, £]. Если затем найти целочисленную “сере- дину” меньшего отрезка и вычислить для нее значение функции fix), то можно будет вновь сузить диапазон поиска и т.д. Каждый такой шаг, грубо говоря, уменьшает в два раза длину отрезка, в границах которого заведомо имеется наименьшее целочисленное значение, удовлетворяющее неравенству fix) > и. После нескольких шагов получится отрезок, длина которого бу- 5 [х] здесь обозначает целую часть х 316 Приложение
дет не больше 1. Правый конец такого отрезка подходит в качестве решения постав- ленной задачи. Может случайно оказаться, что для с = [(а + Z>)/2] получится |/(с) — и\ < eps. По- этому зафиксируем следующее правило для выбора меньшего (заведомо содержащего целочисленное значение) отрезка, удовлетворяющее неравенству Дх) > и: если Дс) > и + eps, то выбираем отрезок [а, с], в противном случае — отрезок [с, £]. Иными словами, правый конец отрезка всегда гарантированно, независимо от возможных погрешностей, удовлетворяет неравенству Дх) > и. Напишем схему (набросок) программы, реализующей этот алгоритм. Здесь значения переменных а и b изменяются так, что остаются в силе неравенства а < b (или, иными словами, Ь - а > 0) и /(b) > и. Значением переменной fa является Да); с обозначает целочисленную “середину” отрезка [а, Ь], переменная fc принимает значение, равное Д с): ввести или явно определить a, b и другие данные задачи; integer делп(integer a, integer b, double eps, double f(integer x), double u) { integer c; double fa, fc; вычислить f(a), присвоив это значение переменной fa; while (b-a > 1) { c = (a + b)/2; вычислить f(c), присвоив это значение переменной fc; if (fc > u) b = c; else { a = c; fa = fc; }; }; return b; } To, что здесь написано, безусловно, не является готовой для ввода в компьютер программой, но эту схему можно легко превратить в настоящую программу, добавив функцию Дх). Искомое нами значение лежит на отрезке [1; Ь], где в качестве b можно взять лю- бое натуральное число, удовлетворяющее неравенству ln(Z>!) > DecPrec In 10 + eps. Для того чтобы найти искомое значение, полагаем в программе а = 1. Теперь осталось подготовить тесты. Хотя на первый взгляд в данной задаче это за- нятие может показаться несколько утомительным, на самом деле все не так плохо. Несложно ведь вычислить факториалы и подсчитать количество цифр в них. (Конечно, я это сделал на компьютере, не подозревайте меня в супервычислительных возможностях!) Теперь можно составить таблицу целых частей десятичных логариф- мов факториалов натуральных чисел. Для этого нужно только заметить, что целая часть десятичного логарифма натурального числа на единицу меньше количества цифр этого числа. Чтобы подготовить тестовые примеры, я вычислил факториалы на- туральных чисел от 1 до 70, а также факториалы чисел от 100 до 1000 с шагом 100, т.е. 100!, 200!, 300!,... 1000!. По составленной (на компьютере) таблице легко определить, например, что [lg(1000!)] = 2567. С другой стороны, это означает, что для того, чтобы последний член факториального ряда был числом, десятичная запись которого начи- нается 2567 нулями после запятой, он должен быть равен —5—, т.е. п должно быть 1000! не менее 1000. Но ведь именно п вычисляет наша программа, которая фактически должна обращать таблицу: по [lg(a!)J программа должна вычислять п. Теперь тесты можно построить так, чтобы вычислять п по известному значению [lg(/z!)]. Кроме того, нужно добавить тесты для некоторых достаточно больших значений параметра Ответы и решения задач и упражнений 317
DecPrec, чтобы убедиться в том, что и для них программа работает и вычисляет вполне приемлемые результаты. Я рассмотрел следующие достаточно большие значе- ния параметра DecPrec: 60 000, 100 265 и 1 000 000. Только для 100 265 я вычислил соответствующее значение п. Оно оказалось равным 25 266. Для остальных двух зна- чений параметра DecPrec мои вычисления, проведенные с помощью калькулятора, показали, что соответствующие значения п примерно равны 16 тысячам и 200 тыся- чам. Теперь осталось только написать программу. Вот она: /* поиск наименьшего целого х, удовлетворяющего неравенству f(x) > 0 методом деления пополам */ #include <stdio.h> #include <math.h> typedef int integer; #define LNSQRT2PI 0.918938533204672741780329 #define LN2 0.693147180559945309417232121458 #define LN10 2.302585092994045684017991454684 #define LNPI 1.144729885849400174143427351353 integer delpopolam(integer a, double eps integer fa(integer x); integer fb(integer x); double f(integer x); integer b, double f(integer x), double u); integer delpopolam(integer a, integer b, double eps, double f(integer x), double u) { integer c; double fa, fc; /* вычислить f(a), присвоив это значение переменной fa */ fa = f (a) ; while (b-a > 1) { c = (a + b) /2 ; /* вычислить f(c), присвоив это значение переменной fc */ fc = f (с) ; if (fc > u + eps) b = c; else { a = c; fa = fc; }; }; return b; double InFactorial(integer n); /* In (n!) с недостатком */ double InFactorial(integer n) /* In (n!) с недостатком ★/ { return LNSQRT2PI + (n + 0.5) * log(n) - n; } /* ввести или явно определить а, b и другие данные задачи; ★/ 318 Приложение
/* Dprec -- точность - количество десятичных знаков */ integer NumberNE(integer Dprec) { double eps = 0.001; /* допустимая погрешность вычислений */ return delpopolam(fa(Dprec), fb(Dprec), eps, f, LN10 * Dprec); } integer fa(integer x) { return 1; } integer fb(integer x) { double у = LN10 * x + 1; integer n = 6; while (InFactorial(n) < y) n *= 2; return n; double f(integer x) { return InFactorial(x); void test(integer Dprec /* точность ★/, integer result) { /* Количество членов факториального ряда */ integer n = NumberNE(Dprec); printf("\n Dprec = %6d; n = %6d", Dprec, n); if (result) { printf(" --- должно быть равно %6d;", result); if (result == n) printf(" -- OK"); else printf( " ---!!!! ОШИБКА !!!! * * *\n\n\n"); } int main(void) { integer a = 1, b = 1000, Dprec = 2500; for(Dprec = 1; Dprec < 102; Dprec++) { test(Dprec, 0); }; printf("\n"); test(Dprec = 600, 0); printf(" — не более 400"); Ответы и решения задач и упражнений 319
printf("\n“); test(Dprec = 157, 100) ; test(Dprec = 374, 200) ; test(Dprec = 614, 300) ; test(Dprec = 868, 400) ; test(Dprec = 1134, 500) ; test(Dprec = 1408, 600) ; test(Dprec = 1689, 700) ; test(Dprec = 1976, 800) ; test(Dprec = 2269, 900) ; test(Dprec = 2567, 1000); printf("\n"); test(Dprec = 60000, 0); printf(" -- примерно 16000“) test(Dprec = 100265, 0); printf(" — примерно 25266“) test(Dprec = 1000000, 0) ; printf(" return 0; -- примерно 200000" Вот выдача: Dprec = 1; n = 4 Dprec = 2; n = 5 Dprec = 3; n = 7 Dprec = 4; n = 8 Dprec = 5; n = 9 Dprec = 6; n = 10 Dprec = 7; n = 11 Dprec = 8; n = 12 Dprec = 9; n = 13 Dprec = 10; n = 14 Dprec = 11; n = 15 Dprec = 12 ; n = 15 Dprec = 13 ; n = 16 Dprec = 14; n = 17 Dprec = 15; n = 18 Dprec = 16; n = 19 Dprec = 17; n = 19 Dprec = 18; n = 20 Dprec = 19; n = 21 Dprec = 20; n = 22 Dprec = 21; n = 22 Dprec = 22 • n = 23 Dprec = 23; n = 24 Dprec = 24; n = 25 Dprec = 25; n = 25 Dprec = 26; n = 26 Dprec - 27; n = 27 Dprec = 28; n = 27 Dprec = 29; n = 28 320 Приложение
Dprec = 30; n = 29 Dprec = 31; n = 30 Dprec = 32; n = 30 Dprec = 33; n = 31 Dprec = 34; n = 32 Dprec = 35; n = 32 Dprec = 36; n = 33 Dprec = 37; n = 34 Dprec = 38; n = 34 Dprec = 39; n = 35 Dprec = 40; n = 35 Dprec = 41; n = 36 Dprec = 42; n = 37 Dprec = 43; n = 37 Dprec = 44; n = 38 Dprec = 45; n = 39 Dprec = 46; n = 39 Dprec = 47; n = 40 Dprec = 48; n = 41 Dprec = 49; n = 41 Dprec = 50; n = 42 Dprec = 51; n = 42 Dprec = 52; n = 43 Dprec = 53; n = 44 Dprec = 54; n = 44 Dprec = 55; n = 45 Dprec = 56; n = 45 Dprec = 57; n = 46 Dprec = 58; n = 47 Dprec = 59; ri = 47 Dprec = 60; n = 48 Dprec = 61; n = 48 Dprec = 62; n = 49 Dprec = 63; n = 50 Dprec = 64; n = 50 Dprec = 65; n = 51 Dprec = 66; n = 51 Dprec = 67; n = 52 Dprec = 68; n = 53 Dprec = 69; n = 53 Dprec = 70; n = 54 Dprec = 71; n = 54 Dprec = 72; n = 55 Dprec = 73; n = 55 Dprec = 74; n = 56 Dprec = 75; n = 57 Dprec = 76; n = 57 Dprec = 77; n = 58 Dprec = 78; n = 58 Dprec = 79; n = 59 Dprec = 80; n = 59 Dprec = 81; n = 60 Dprec = 82; n = 61 Dprec = 83; n = 61 Dprec = 84; n = 62 Dprec = 85; n = 62 Dprec = 86; n = 63 Dprec = 87; n = 63 Ответы и решения задач и упражнений 321
Dprec = 88; n = 64 Dprec = 89; n = 64 Dprec = 90; n = 65 Dprec = 91; n = 66 Dprec = 92; n = 66 Dprec = 93; n = 67 Dprec = 94; n = 67 Dprec = 95; n = 68 Dprec = 96; n = 68 Dprec = 97; n = 69 Dprec = 98; n = 69 Dprec = 99; n = 70 Dprec = 100; n = 70 Dprec = 101; n = 71 Dprec = 600; n = 295 — не более 400 Dprec = 157; n = 100 должно быть равно 157; ОК Dprec = 374; n = 2 00 должно быть равно 374; ОК Dprec = 614; n = 3 00 должно быть равно 614; ОК Dprec = 868; n = 400 должно быть равно 868; ОК Dprec = 1134; n = 500 должно быть равно 1134; ОК Dprec = 1408; n = 600 должно быть равно 1408; ОК Dprec = 1689; n = 7 00 должно быть равно 1689; ОК Dprec = 1976; n = 800 должно быть равно 197 6; ОК Dprec = 2269; n = 900 должно быть равно 2269; ОК Dprec = 2567; n = 1000 должно быть равно 2567; ОК Dprec = 60000; n = 15924 -- примерно 16000 Dprec = 100265; n = 25267 -- примерно 25266 Dprec = 1000000; n = 205023 — примерно 200000 Поразительное совпадение результатов! Подзадача решена!!! Теперь осталось перегрузить конструктор класса EulerE и вычислить основание натуральных логарифмов с точностью 1000 десятичных знаков после запятой. Вот как будет выглядеть его объявление: EulerE(integer DecPrec /★ точность ★/}; // конструктор А вот и его реализация: EulerE::EulerE(integer DPrec /* точность ★/ ) // конструктор { integer n = NumberNE(Dprec); /* последний член 1/n! ★/ denom = 1; /★ десятичная дробь ★/ pDFract = new DFraction(1, /★ числитель ★/ n, /★ знаменатель ★/ DPrec /★ точность ★/); for(integerindex i = n-1; i >= 2; i--) { (*pDFract) + 1; /* увеличить десятичную дробь на 1 ★/ (*pDFract) / i; /★ разделить десятичную дробь ★/ } (*pDFract) + 2; /★ увеличить десятичную дробь на 2 ★/ } Теперь можно собрать все в одну программу и предусмотреть в ней вычисление ос- нования натуральных логарифмов с точностью 1000 десятичных знаков после запятой. 322 Приложение
#include <stdio.h> #include <limits.h> #include <math.h> #include <stdlib.h> typedef int integer; typedef int integerdigit; typedef int integerindex; fdefine LNSQRT2PI 0.918938533204672741780329 fdefine LN2 0.693147180559945309417232121458 fdefine LN10 2.302585092994045684017991454684 fdefine LNPI 1.144729885849400174143427351353 integer delpopolam(integer a, integer b, double eps, double f(integer x), double u); integer fa(integer x); integer fb(integer x); double f(integer x); integer delpopolam(integer a, integer b, double eps, double f(integer x), double u) { integer c; double fa, fc; /★ вычислить f(a), присвоив это значение переменной fa ★/ fa = f(a); while (b-a > 1) { c = (a + b)/2; /★ вычислить f(c), присвоив это значение переменной fc ★/ fc = f (с) ; if (fc > u + eps) b = c; else { a = c; fa = fc; }; }; return b; double InFactorial(integer n); /★ In(n!) с недостатком ★/ double InFactorial(integer n) /★ In(n!) с недостатком ★/ { return LNSQRT2PI + (n + 0.5) * log(n) - n; } /* ввести или явно определить а, b и другие данные задачи; ★/ /★ Dprec -- точность - количество десятичных знаков ★/ integer NumberNE(integer Dprec) { double eps = 0.001; /★ допустимая погрешность вычислений ★/ return delpopolam(fa(Dprec), fb(Dprec), eps, f, LN10 * Dprec); } Ответь/ и решения задач и упражнений 323
integer fa(integer x) { return 1; } integer fb(integer x) { double у = LN10 * x + 1; integer n = 6; while (InFactorial(n) < y) n *= 2; return n; double f(integer x) { return InFactorial(x); } /★ количество цифр в числе digit при записи в системе счисления с основанием base ★/ int numdigit(integerdigit digit, integer base); void outputdigit(integerdigit digit, int w, int base); /* количество цифр в числе digit при записи в системе счисления с основанием base ★ / int numdigit(integerdigit digit, integer base) { int к = 1; while (digit /= base) k++; return k; } void outputdigit(integerdigit digit, int w, int base) { int к = w - numdigit(digit, base); while (k-- > 0) printf("0"); printf("%d", digit); } class Digits /* Последовательность цифр ★/ { public: integer k; /★ количество цифр ★/ integer base; /* основание системы счисления ★/ /★ цифры дроби хранятся в массиве с, в с[0] - к-во цифр ★/ integerdigit * с; Digits(); // конструктор -Digits(); // деструктор /★ выделить память для массива */ integerdigit * allocate(integer n, /★ количество цифр ★/ 324 Приложение
integer b /★ основание системы счисления ★/} ; /★ Напечатать последовательность цифр с [т .. п] ★/ void print(integer т /★ начальный индекс ★/, integer п /★ конечный индекс ★/} ; integer Mbase(integer denom); /★ основание системы счисления ★/ }; Digits :-.Digits () // конструктор { base = 1000000; /★ основание системы счисления ★/ к = 18; /★ количество цифр */ с = 0; } integerdigit * Digits::allocate(integer n, /★ количество цифр ★/ integer b /★ основание системы счисления ★/} { base = b; /★ основание системы счисления ★/ к = п; /★ количество цифр */ if (с) delete [] с; с = new integerdigit[к+1]; if (с) с[0]= к; return с; } /★ Напечатать последовательность цифр с[к .. 1]: ★/ void Digits: .-print (integer m /★ начальный индекс */, integer n /★ конечный индекс ★/} { integerindex i; /★ параметр цикла ★/ integerindex first = m>=0? m:l; /* начальный индекс ★/ integerindex last = n<k? n:k; /★ конечный индекс ★/ int w; /* количество десятичных позиций для М-ичной цифры ★/ w = numdigit(base - 1, 10); if (с) for (i = first; i <= last; i++) /* печать дробной части ★/ { outputdigit(с[i], w, 10); /★ цифра дробной части ★/ } } integer Digits::Mbase(integer denom) /* основание системы счисления */ { /★ начальное значение М ★/ integer М = 1000000; while (denom >= LONG_MAX/M) М /= 10; if (М < 2) { printf("\п*** Знаменатель %d слишком велик.\n", denom); M = 10; } return M; } Ответы и решения задач и упражнений 325
Digits::-Digits() { /* освобождаем память, занятую массивом цифр */ if (с) delete [] с; } class OFraction /* Обыкновенная дробь: */ { integer numerator; /* числитель ★/ integer denominator; /★ знаменатель */ public: OFraction(integer num, /* числитель */ // конструктор integer denom /* знаменатель ★/); void print(); /* напечатать обыкновенную дробь */ /* деление обыкновенной дроби на натуральное число п ★/ void operator / (integer n /* делитель */); }; OFraction::OFraction(integer num, /* числитель */ // конструктор integer denom /* знаменатель ★/) { numerator = num; /* числитель ★/ denominator = denom; /★ знаменатель ★/ } /* деление обыкновенной дроби на натуральное число ★/ void OFraction::operator / (integer n /* делитель ★/) { denominator *= n; /* знаменатель ★/ } /* НАПЕЧАТАТЬ ОБЫКНОВЕННУЮ ДРОБЬ: */ void OFraction::print() { printf("%4d/%2d”, numerator, /* числитель ★/ denominator /* знаменатель */} ; } class DFraction /* ДЕСЯТИЧНАЯ ДРОБЬ: */ : public Digits { integer DecPrec; /* точность ★/ public: DFraction(integer numerator, /* числитель ★/ // конструктор integer denominator, /* знаменатель */ integer DecPrec /★ точность ★ /} ; /* Функция Kw вычисляет количество тех элементов массива, в которые записываются base-ичные цифры ★/ integer Kw(integer Derek, integer base); void print(); /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ */ /★ деление десятичной дроби на натуральное число п с произвольной 326 Приложение
точностью: ★/ void operator / (integer n /★ делитель ★/}; void operator + (integer n /* слагаемое */); protected: /★ деление натуральных чисел с произвольной точностью: ★/ void divn(integer num, /* числитель */ integer denom /★ знаменатель ★/); }; DFraction::DFraction(integer num, /* числитель ★/ // конструктор integer denom, /* знаменатель ★/ integer DPrec /* точность ★/} { base =Mbase(denom); /* основание системы счисления */ DecPrec = DPrec; /* точность */ k = Kw(DecPrec, base); /* количество цифр */ /* выделяем память под массив с, в котором хранятся цифры дроби ★/ allocate(к /* количество цифр ★/, base/* основание системы счисления */); I /★ вычисление цифр дроби */ divn(num /* числитель */, denom /* знаменатель ★/); } /* Функция Kw вычисляет количество тех элементов массива, в которые записываются base-ичные цифры */ integer DFraction::Kw(integer DPrec /* точность */, integer base /* основание системы счисления */) { /* количество десятичных цифр в элементе массива */ integer m = numdigit(base-1, 10); div_t d = div(DPrec, m); /* количество элементов массива для записи десятичных цифр */ integer w = d.quot + 1 + (d.rem? 1 : 0); return w; } /* деление натуральных чисел p произвольной точностью: ★/ void DFraction::divn(integer num, /* числитель ★/ integer denom /* знаменатель ★/) { div_t d; /* структура для хранения частного и остатка ★/ integerindex i; /* i - номер обрабатываемой цифры ★/ integer а = num; /* числитель */ integer b = denom; /* знаменатель */ if (с) { for(i = 1; i <= к; i++) /* вычисление к цифр дроби ★/ {/* вычисление очередного частного и остатка */ d = div(а, b); с [i] = d.quot; /* вычисление очередной цифры */ а = d.rem * base; /* вычисление очередного делимого */ Ответы и решения задач и упражнений 327
/★ деление десятичной дроби на натуральное число п с произвольной точностью: ★/ void DFraction::operator /(integer n /* делитель ★/) { div_t d; /* структура для хранения частного и остатка */ integerindex i; /* i - номер обрабатываемой цифры */ integer а = 0; /* делимое ★/ if (с) { for(i =1; i <= к; i++) /* вычисление к цифр дроби ★/ { /* вычисление очередного частного и остатка ★/ d = div(a+ с[i], n); с [i] = d.quot; /* вычисление очередной цифры */ а = d.rem * base; /★ вычисление очередного делимого ★/ } } } /★ сложение десятичной дроби с натуральным числом п: ★/ void DFraction:roperator +(integer n /★ слагаемое ★/) { if (c) c [1] += n; /* вычисление целой части десятичной дроби ★/ } void DFraction: .-print () /* НАПЕЧАТАТЬ ДЕСЯТИЧНУЮ ДРОБЬ: */ { printf("%2d", с[1]); /★ целая часть ★/ printf("."); /★ десятичная точка ★/ Digits::print(2, к); /★ печать дробной части */ } class EulerE/* ОСНОВАНИЕ НАТУРАЛЬНЫХ ЛОГАРИФМОВ: */ { DFraction * pDFract; /* десятичная дробь ★/ integer denom; public: EulerE(integer DecPrec /* точность ★/}; // конструктор EulerE(integer DPrec /* точность ★/, // конструктор integer n /* знаменатель последнего члена n! */ ); -EulerE(); // деструктор /* НАПЕЧАТАТЬ ОСНОВАНИЕ НАТУРАЛЬНЫХ ЛОГАРИФМОВ: */ void print(); /* деление основания натуральных логарифмов на натуральное число п с произвольной точностью: */ void operator / (integer n /* делитель */); }; 328 Приложение
EulerE::EulerE(integer DPrec /* точность */ ) // конструктор { integer n = NumberNE(DPrec); /* последний член 1/n! ★/ denom = 1; /* десятичная дробь ★/ pDFract = new DFraction(1, /★ числитель ★/ n, /* знаменатель */ DPrec /* точность ★/); for(integerindex i = n-1; i >= 2; i--) { (*pDFract) +1; /* увеличить десятичную дробь на 1 ★/ (*pDFract) / i; /* разделить десятичную дробь ★/ } (*pDFract) +2; /* увеличить десятичную дробь на 1 ★/ } EulerE::EulerE(integer DPrec /* точность */, // конструктор integer n /* последний член 1/n! ★/ ) { denom = 1; /* десятичная дробь ★/ pDFract = new DFraction(1, /* числитель */ n, /* знаменатель ★/ DPrec /★ точность */}; for(integerindex i = n-1; i >= 2; i--) { (*pDFract) +1; /* увеличить десятичную дробь на 1 ★/ (*pDFract) / i; /* разделить десятичную дробь ★/ } (*pDFract) +2; /* увеличить десятичную дробь на 2 */ } EulerE::-EulerE() // деструктор { /★ освобождаем память, занятую десятичной дробью ★/ if (pDFract) delete pDFract; /★ НАПЕЧАТАТЬ ОСНОВАНИЕ НАТУРАЛЬНЫХ ЛОГАРИФМОВ: ★/ void EulerE::print() { printf("е"); /* напечатать обозначение числа е ★/ if (denom-1) printf("/%d", denom); /* напечатать / и делитель ★/ printf(" = "); /* напечатать знак равенства = ★/ pDFract -> print(); /* печать десятичной дроби ★/ } void EulerE::operator / (integer n /* делитель ★/} { (*pDFract) / n; /* разделить десятичную дробь */ } Ответы и решения задач и упражнений 329
int main() { EulerE e4(4/* точность */)\ e4.print(); printf("\ne = 2.718 - известный результат\п\п"); EulerE el0(9/* точность ★/); elO.print(); printf("\ne = 2.71828181 - известный результат\п\п"); EulerE e600 (608 /* точность ★/) ; e600.print(); printf("--- точность 590 знаков\п"); EulerE .elOOO (1000 /★ точность ★/) ; elOOO.print(); printf("--- точность 1000 знаков\п"); printf("ХпКонец выполнения программы!"); return 0; } А теперь прочитаем выдачу. Вот ее начало: е = 2.718278 е = 2.718 - известный результат е = 2.718281828446 е = 2.71828181 - известный результат Ничего особенного, но приятно, что нет неожиданностей. Далее идет основание натуральных логарифмов более чем с 600 знаками после запятой. Оно уже вам встре- чалось, и я опять отформатировал его, чтобы легче было сравнить с ранее получен- ным результатом. е = 2.718281 828459 045235 360287 471352 662497 757247 093699 959574 966967 627724 076630 353547 594571 382178 525166 427427 466391 932003 059921 817413 596629 043572 900334 295260 595630 738132 328627 943490 763233 829880 753195 251019 011573 834187 930702 154089 149934 884167 509244 761460 668082 264800 168477 411853 742345 442437 107539 077744 992069 551702 761838 606261 331384 583000 752044 933826 560297 606737 113200 709328 709127 443747 047230 696977 209310 141692 836819 025515 108657 463772 111252 389784 425056 953696 770785 449969 967946 864454 905987 931636 889230 098793 127736 178215 424999 229576 351482 208269 895193 668033 182528 869398 496465 105820 939239 829488 793320 362509 443117 301238 197067--- точность 590 знаков В пределах объявленной точности (и даже несколько дальше) расхождений нет. Но вот что беспокоит: последний знак отличается! Правда, всего лишь на одну единицу. Чтобы это могло значить? Трудноуловимая ошибка? Не беспокойтесь. Мы задали точность 608 знаков после запятой, а распечатали 612 знаков — столько, сколько хра- нили в массиве, поэтому расходиться могли даже четыре последних разряда, а не только один. Какой же результат точнее: предыдущий или только полученный? Ко- нечно предыдущий, ведь он был вычислен при п = 400, что значительно превосходит нужное количество членов ряда для достижения точности в 600 десятичных знаков после запятой. А в последней версии программы вычисления велись при значении п меньшем 300. Этим мы сэкономили более ста проходов по массиву цифр! И тем не менее все нужные цифры вычислены были верно! 330 Приложение
Расхождение же в запасных цифрах как раз и показывает, что наша программа почти точно определяет необходимое количество членов ряда! Так что расхождение, из-за которого вначале возникли подозрения, на самом деле подтверждает правиль- ность нашей программы. Конечно, метод печати можно было усовершенствовать так, чтобы распечатывалось только указанное количество десятичных знаков, но тогда мы не смогли бы убедиться в том, что необходимое количество членов ряда определяется практически почти точно. Так что можем спокойно читать дальше. Правда, на этот раз я опять отформатировал выдачу, причем разбил дробную часть на грани не по шесть цифр, как ранее, а по пять — этот способ для столь длинных чисел встречается чаще. е = 2.71828 18284 59045 23536 02874 95749 66967 62772 40766 30353 27466 39193 20030 59921 81741 59563 07381 32328 62794 34907 15738 34187 93070 21540 89149 82264 80016 84774 11853 74234 55170 27618 38606 26133-13845 67371 13200 70932 87091 27443 92836 81902 55151 08657 46377 77078 54499 69967 94686 44549 77361 78215 42499 92295 76351 28869 39849 64651 05820 93923 30123 81970 68416 14039 70198 53118 02328 78250 98194 55815 96181 88159 30416 90351 59888 87922 84998 92086 80582 57492 84875 60233 62482 70419 78623 49146 31409 34317 38143 64054 76839 64243 78140 59271 45635 01157 47704 17189 86106 87396 01--- точность 1000 знаков 71352 66249 77572 4709.3 69995 54759 45713 82178 52516 64274 35966 29043 57290 03342 95260 63233 82988 07531 95251 01901 93488 41675 09244 76146 06680 54424 37107 53907 77449 92069 83000 75204 49338 26560 29760 74704 72306 96977 20931 01416 21112 52389 78442 50569 53696 05987 93163 68892 30098 79312 48220 82698 95193 66803 31825 98294 88793 32036 25094 43117 37679 32068 32823 76464 80429 30175 67173 61332 06981 12509 85193 45807 27386 67385 89422 79610 48419 84443 63463 24496 20900 21609 90235 30436 99418 62531 52096 18369 08887 07016 49061 30310 72085 10383 75051 96552 12671 54688 95703 50354 Конец выполнения программы! В результате вычислено 1000 верных десятичных знаков. Правда, мы распечатали также два запасных знака, последний из которых опять неверный: 1 вместо 2. Это еще раз убедительно показывает, что программа практически почти точно определяет не- обходимое количество членов ряда! Задачу вычисления основания натуральных лога- рифмов с точностью 1000 десятичных знаков после запятой можем считать решенной. Теперь стоит задуматься: насколько профессионально решение? Можем ли мы с по- мощью этой программы вычислить 2000, 2500 или даже 3000 десятичных знаков? Что- бы ответить на этот вопрос, необходимо найти компьютер послабее и измерить время вычисления основания натуральных логарифмов с различной точностью. Но что зна- чат слова “компьютер послабее”? Нужно ли искать что-нибудь вроде МЭСМ или ЭНИАК? Конечно, нет! Просто нужно найти компьютер, который еще может реально использоваться. Вполне подойдет, например, Pentium 100, более слабого сейчас, на- верное, не найти, да и не имеет смысла искать, — едва ли кто-то будет искать музей- ный раритет ради вычисления основания натуральных логарифмов с суперастрономи- ческой точностью. Вычисление основания натуральных логарифмов с точностью 1000, 2000 и 3000 десятичных знаков выполняется практически без задержки. Все же инте- ресно измерить время, необходимое для вычисления основания натуральных лога- рифмов с различной точностью в реальной операционной обстановке. Я измерял это время под управлением Windows 98 (едва ли еще используются более старые версии) при нескольких загруженных приложениях и отключенном кэшировании памяти. Чтобы выполнить эти измерения, я внес два изменения в программу: во-первых, до- бавил оператор #include <time.h> в начало программы; во-вторых, заменил функ- цию main следующими двумя: Ответы и решения задач и упражнений 331
int etime(integer DPrec /* точность ★/) { clock_t start, end; start = clockf); EulerE e(DPrec /* точность ★/) ; end = clock(); printf("Количество десятичных цифр: %6d, время: %f\n", DPrec, (end - start) / CLK_TCK); return 0; } int main() { integer i = 1; ford = 1; i <101; i++) etime(i /* точность ★/); etime( 157 / * точность ★/) etime( 374 / * точность ★/) etime( 590 / * точность ★/) etime( 600 / * точность */) etime( 614 / * точность */) etime( 868 / * точность ★/) etime( 1000 / * точность ★/) etime( 1134 / * точность ★/) etime( 1408 / * точность ★/) etime( 1689 / * точность ★/) etime( 1976 / * точность ★/) etime( 2000 /* точность ★/) etime( 2269 / * точность */) etime( 2500 /* точность */) etime ( 2567 / * точность ★/) etime( 3000 / * точность ★/) etime( 10000 / * точность ★/) etime( 60000 / * точность ★/) etime( 100265 / * точность ★/) etime(1000000 / * точность ★/) printf("ХпКонец выполнения программы!"); return 0; } Вот начало распечатки: Количество десятичных цифр: 1, время: 0.000000 Количество десятичных цифр: 2, время: 0.000000 Количество десятичных цифр: 3, время: 0.000000 и т.д. до строки Количество десятичных цифр: 32, время: 0.005000 Почему такой скачок? Это выполнялись системные действия. Далее: Количество десятичных цифр: 33, время: 0.000000 332 Приложение
И опять так до Количество десятичных цифр: 46, время: 0.023000 Это снова выполнялись системные действия. Далее: Количество десятичных цифр: И опять так до Количество десятичных цифр: Количество десятичных цифр: Количество десятичных цифр: Количество десятичных цифр: Количество десятичных цифр: Количество десятичных цифр: Количество десятичных цифр: Количество десятичных цифр: 47, время: 0.000000 100, время: 0.000000 157, время: 0.000000 374, время: 0.010000 590, время: 0.030000 600, время: 0.029000 614, время: 0.025000 868, время: 0.070000 1000, время: 0.110000 Наконец, появилось кое-что существенное — как-никак, а 0,11 секунды! Конечно, далее время должно увеличиваться. Так и есть: Количество десятичных цифр: 1134, время: 0.115000 Количество десятичных цифр: 1408, время: 0.191000 Количество десятичных цифр: 1689, время: 0.270000 Количество десятичных цифр: 1976, время: 0.239000 Количество десятичных цифр: 2000, время: 0.245000 Количество десятичных цифр: 2269, время: 0.285000 Количество десятичных цифр: 2500, время: 0.345000 Количество десятичных цифр: 2567, время: 0.375000 Количество десятичных цифр: 3000, время: 0.475000 На вычисление 3000 десятичных знаков ушло менее полсекунды! Пользователь этого не заметит. Читаем дальше: Количество десятичных цифр: 10000, время: 5.590000 На вычисление 10 000 десятичных знаков уже ушло более 5 секунд! Это уже ощу- тимая задержка, если вы с нетерпением ожидаете результат. Можем читать дальше: Количество десятичных цифр: 60000, время: 166.775000 Вычисление же 60 000 цифр, для которого нужно взять примерно 16 000 членов факториального ряда, на Pentium 100 занимает почти три минуты! Терпеливый поль- зователь раздражается уже после 2-х минут, но все же в состоянии подождать 5 минут. Так что вполне возможно вычислить 60 000 десятичных знаков даже на Pentium 100! Смотрим следующую строчку: Количество десятичных цифр: 100265, время: 555.295000 Девять с четвертью минут! Ого! Вычисление 100 265 цифр, для которого нужно взять 25 266 членов факториального ряда, на Pentium 100 требует почти десяти минут! И это на вычисление всего лишь каких-то ста тысяч знаков! Можно ли вычислить миллион цифр? (Для этого потребуется примерно 200 000 членов ряда.) Чтобы решить эту задачу на таком компьютере, придется запастись сверхангельским терпением. (Потребуется почти день!) Как же повысить быстродействие программы? Для этого есть несколько способов. Первый способ очевиден: можно использовать процессор с более высоким быстродействием, например Pentium-4 с тактовой частотой 2 или 3 ГГц. Второй способ: использовать процессор с более высокой разрядностью, т.е. та- кой, в котором для представления целого числа отводится не 32 разряда, а больше, например 64. (Такие процессоры уже есть и скоро они станут доступны широким кругам пользователей.) Тогда можно будет, например, увеличить основание системы счисления, в которой ведутся расчеты. За одну операцию деления можно будет полу- чать, например, не 6 десятичных цифр, а больше, например, 10. Благодаря этому уменьшится также длина массива, в котором хранятся цифры десятичной дроби, а, Ответы и решения задач и упражнений 333
значит, сократится и количество операций, выполняемых телом цикла. Третий спо- соб: совместить преимущества первого и второго способов, т.е. выбрать процессор с более высокой частотой и разрядностью. Понятно, что тогда даже нетерпеливый поль- зователь сможет вычислить более двух миллионов десятичных знаков основания нату- ральных логарифмов! Чтобы подкрепить эти рассуждения конкретными цифрами, я выполнил замеры времени на персональном компьютере среднего уровня (процессор Intel Pentium-4 с частотой 2,4 ГГц, материнская плата Asus Р4Р800), причем намеренно параллельно решал несколько тяжелых вычислительных задач. (Ведь едва ли пользователь выделит отдельный компьютер для вычисления основания натуральных логарифмов.) Вот на- чало распечатки: Количество десятичных цифр: 1, 1689/ время: время: 0.000000 0.000000 И так до Количество десятичных цифр: Количество десятичных цифр: 1976/ время: 0.016000 Количество десятичных цифр: 2000/ время: 0.015000 Количество десятичных цифр: 2269/ время: 0.016000 Количество десятичных цифр: 2500/ время: 0.031000 Количество десятичных цифр: 2567/ время: 0.016000 Количество десятичных цифр: 3000/ время: 0.031000 Количество десятичных цифр: 10000/ время: 0.328000 Количество десятичных цифр: 60000/ время: 9.719000 Количество десятичных цифр: 100265/ время: 32.219000 Мы видим, что даже в таких тяжелых условиях время сократилось более чем в 17 раз! Хотя, честно говоря, 32 секунды — далеко не рекорд, но проведенные испытания подтвердили, что разработанную нами программу можно применять для вычисления основания натуральных логарифмов с очень высокой точностью! Г лава 4 1. (Отличие ООП от объектно-ориентированного языка.) Нет ничего проще, чем ос- воить ООП. Достаточно написать программу на каком-нибудь объектно- ориентированном языке. Так ли это? Ответ. Неопытные начинающие программисты иногда полагают, что ООП и объ- ектно-ориентированные языки — это одно и то же. Они уверены в том, что применя- ют объектно-ориентированный подход уже хотя бы потому, что используют объектно- ориентированный язык. Нет ничего более далекого от правды. Можно написать абсолютно не объектно-ориентированную программу на объектно- ориентированном языке. (И наоборот, даже на Ассемблере можно написать объектно- ориентированную программу, хотя это и нелегко.) ООП — это не только программирова- ние на объектно-ориентированном языке или знание набора определений, это образ мышления, при котором решение проблемы получается в виде группы объектов, причем для построения объектов нужно правильно применять инкапсуляцию, наследование и по- лиморфизм. Тем не менее и в настоящее время многие компании и программисты полагают, что, используя объектно-ориентированный язык, они получат все преимущества ООП. Когда их надежды не оправдываются, они обвиняют используемую технологию, не учитывая того факта, что это именно они не обучили должным образом своих подчиненных или же поль- стились на популярный метод программирования, не понимая до конца его принципов. 2. (Страх повторного использования.) Я не лентяй, умею выделять текст, копиро- вать его в буфер и вставлять в нужное место. Поэтому мне проще написать 334 Приложение
нужный метод, а не использовать чужой, да еще не известно как запрограмми- рованный. Кроме того, я не люблю повторное использование потому, что оно втискивает меня в кем-то (иногда мной же на несколько недель раньше) уста- новленные рамки. Поэтому гораздо проще скопировать нужный мне метод и внести в него незначительные коррективы, чем использовать чужой (да хотя и бы и свой собственный!) шаблон. Правильно ли это рассуждение? Ответ. Нет, это утверждение ошибочно. Нужно научиться повторно использовать программы. Правда лишь в том, что очень трудно научиться безошибочно применять повторное использование. Трудности обусловлены следующими причинами. Во-первых, программистам нравится творить и поэтому (особенно в начале освое- ния этого приема) создается впечатление, что повторное использование лишает вас удовольствия, присущего самостоятельному творческому процессу созидания про- граммы. Однако повторное использование ранее разработанных элементов позволяет создавать нечто большее, чем те элементы, которые вы используете повторно. По- вторное использование элемента может показаться не очень заманчивым, однако именно оно позволяет создать нечто лучшее. Шедевры архитектуры создаются из уже хорошо известных строительных материалов, а писатель не изобретает новый шрифт даже для самого захватывающего романа. Отказ от повторного использования — пе- режиток, давно преодоленный в архитектуре, литературе, науке; лишь у начинающих программистов бывают затяжные рецидивы этого пережитка. Во-вторых, многие программисты не доверяют тому, что было написано не ими. Если часть программного обеспечения хорошо проверена и подходит вам, вы не должны бояться использовать ее повторно. Это просто фантастическая удача: за вас уже выполнили часть работы! Не отказывайтесь от какого-либо компонента только потому, что его писали не вы. Именно повторное использование позволяет создать еще одну замечательную программу. 3. (000 — это не лекарство от всех болезней.) Нет такой проблемы в мире про- граммирования, которую нельзя было бы решить с помощью ООП. Согласны ли вы с этим? Ответ. Нет. Хотя ООП обладает множеством преимуществ, оно вовсе не является ле- карством от всех болезней в мире программирования. Иногда не следует применять объектно-ориентированный подход. Нужно уметь выбрать правильное средство для вы- полнения конкретной работы. Применение ООП совсем не гарантирует успех какого- либо проекта. Успех приходит только тогда, когда планирование, разработка и програм- мирование выполнены самым тщательным образом. 4. (Эгоцентрическое программирование.) Мне наплевать на то, что мои классы и методы непонятны другим программистам, — ведь создаю я их для себя и по- тому документацию писать не собираюсь! Можно ли с этим согласиться? Ответ. Можно жить, игнорируя десять заповедей, но что из этого получится? Программируя, не следует быть эгоцентристом. Уметь делиться создаваемой програм- мой не менее необходимо, чем уметь повторно использовать чужие. Это означает, что нужно создавать классы, которыми смогут воспользоваться другие разработчики, при- чем необходимо стремиться упрощать повторное использование этих классов. Тогда, возможно, кто-то и купит вашу библиотеку классов. Поэтому помните о других разра- ботчиках. Создавайте четкий, понятный интерфейс, пишите документацию. (Она по- надобится и вам, если потребуется что-то вспомнить.) Документируйте допущения (предусловия), документируйте параметры метода, документируйте как можно боль- ше. Люди не будут повторно использовать то, что невозможно найти или понять. За- ботясь о других, вы заботитесь о себе. Ответы и решения задач и упражнений 335
Глава 5 1. Почему инкапсуляция способствует созданию естественных программ? Ответ. С помощью инкапсуляции можно распределить ответственность таким спо- собом, который кажется естественным с точки зрения человека. Применяя абстрак- цию, решение задачи можно выразить в терминах той предметной области, к которой относится решаемая задача, а не в терминах реализации. Абстракция позволяет выде- лить в задаче главное. 2. Почему инкапсуляция способствует созданию надежных программ? Ответ. Инкапсуляция позволяет изолировать коды и скрывать реализацию, т.е. создавать независимые модули. Правильность независимого модуля можно проверить отдельно от других компонентов. А когда используются проверенные компоненты, можно более тщательно провести общую проверку системы, чтобы убедиться в том, что программа работает правильно. 3. Почему инкапсуляция способствует созданию программ, удобных для сопрово- ждения? Ответ. Инкапсуляция позволяет защитить программу. А защищенную программу сопровождать легче, так как можно вносить любые необходимые изменения в реали- зацию класса, не изменяя зависимый код. Эти изменения могут включать как изме- нения в реализации, так и добавление новых методов в интерфейс. Изменить зависи- мый код потребуется только по причине изменения семантики интерфейса. 4. Почему инкапсуляция облегчает усовершенствование программ? Ответ. Поскольку можно изменить реализацию, не разрушая программу, мож- но усовершенствовать функциональные характеристики и при этом сохранить ра- ботоспособность существующего кода. Даже более того, поскольку реализация скрыта, эксплуатационные характеристики кода, исйользующего усовершенство- ванный компонент, улучшаются автоматически — ведь код, хотя он и не изме- нился, будет использовать усовершенствованные компоненты! Однако после вне- сения изменений следует снова проверить модуль, так как изменение объекта может вызвать эффект домино во всем коде, использующем объект. 5. Почему инкапсуляция облегчает периодический выпуск (издание) новых вер- сий? Ответ. Так как инкапсуляция позволяет разбить программу на самостоятельные модули, задание по разработке кода можно распределить между несколькими раз- работчиками, и таким образом ускорить процесс разработки. Разработав и прове- рив модули, их не придется переделывать заново. Таким образом, можно просто по- вторно использовать эти модули, а не тратить время на создание их с “нуля”. Глава 6 1. Обязательно ли использовать пространства имен? Ответ. Нет, простые программы можно писать и без явного обращения к про- странствам имен, но тогда придется использовать старые стандартные библиотеки (например, ^include <string.h>), а не новые (например, ^include <cstring>). 2. В чем разница между директивой using и объявлением с ключевым словом using? Ответ. Ключевое слово using можно использовать как директиву и в описании. Но директива открывает доступ ко всем именам в пространстве имен, а в описании доступ можно открыть только для отдельных имен. 336 Приложение
3. Что такое неименованные пространства имен и зачем они нужны? Ответ. Неименованными называются пространства имен, для которых не задано собственное имя. Они используются для того, чтобы локализовать имена в разных файлах одной программы. Это позволяет избежать конфликтов имен. 4. Можно ли использовать идентификаторы, объявленные в пространстве имен, без применения ключевого слова using? Ответ. Да, имена, определенные в пространстве имен, можно свободно использо- вать в программе, если указывать перед ними идентификатор пространства имен. 5. Назовите основные отличия между именованными и неименованными про- странствами имен. Ответ. Имена неименованных пространств имен можно использовать без указания имени пространства имен. А вот чтобы получить доступ к именам обычных про- странств имен, необходимо либо указать перед ними идентификатор пространства имен, либо использовать директиву using или ключевое слово using в объявлении. Имена, определенные в обычном пространстве имен, можно использовать и вне исходного модуля, в котором объявлено данное пространство имен. Имена, опреде- ленные в неименованном пространстве имен, можно использовать только внутри того исходного модуля, в котором объявлено данное пространство имен. 6. Что такое стандартное пространство имен std? Ответ. Данное пространство определено в стандартной библиотеке C++ (C++ Stan- dard Library) и содержит объявления всех классов и функций стандартной библиотеки. 7. Отладка. Найдите ошибки в следующем коде: #include <iostream> int main() { cout « "Hello world!" « end; return 0; } Укажите способы устранения ошибок в этом коде. Решение. Современный стандартный заголовочный файл C++ iostream объявляет объекты cout и endl в пространстве имен std. Поэтому, вообще говоря, их нельзя использовать вне стандартного пространства имен std без какого-либо указания про- странства имен. (Правда, некоторые компиляторы, например Borland C++ 5.02, этого не заметят!) Но в любом случае опечатку придется исправить: вместо end нужно на- печатать endl. Исправить же ошибку, связанную с неопределенностью объектов cout и endl, можно несколькими способами. Например, можно добавить директиву using namespace std; Это приемлемо, но лучше добавить объявления using std::cout; using std::endl; Кроме того, можно явно указать имя пространства имен: std::cout « "Hello world!" « std::endl; Ответы и решения задач и упражнений 337
Глава 7 1. Наследование применяется по разным причинам. Являются ли эти причины взаимоисключающими, или же встречаются случаи, когда они проявляются вместе? Например, можно ли применить наследование для реализации, приме- няя его одновременно ради программирования отличий? Ответ. Конечно, причины наследования могут не быть взаимоисключающими. Вы можете применить наследование по какой-либо одной причине и в конечном итоге выполнить все остальные условия его применения. 2. Является ли многократное использование главной причиной применения объ- ектно-ориентированного программирования? Ответ. Многократное использование — это всего лишь одна из целей объектно- ориентированного программирования. ООП — это такой подход к программирова- нию, который позволяет моделировать решение задач более естественным способом: с помощью объектов. Хотя многократное использование играет большую роль, не сле- дует им увлекаться, необходимо не упускать из виду и другие цели ООП. Наследование реализации — только один из способов достижения многократного использования. Передача (делегирование) функций часто является наилучшим спосо- бом многократного использования реализации. Едва ли стоит применять наследова- ние ради многократного использования реализации. Наследование лучше применять для программирования отличий или для установления подмены типов. 3. Какие недостатки имеет простое многократное использование? Ответ. Простого способа многократного использования кода, за исключением соз- дания объекта, содержащего требуемый блок команд, не существует. Для того чтобы непосредственно использовать уже созданный код, придется просто скопировать его и вставить в свою программу. Подобный метод приводит к многократному повторению одних и тех же участков кода, различающихся лишь незначительными деталями. Код без наследования статичен. Он не может быть расширен. Более того, статичный код ограничивает использование типов. Статичный код не может настраиваться на ис- пользуемый тип, поэтому такое достоинство, как подмена типов, не реализуется. 4. Что такое наследование? Ответ. Наследование — встроенный механизм безопасного многократного исполь- зования кода и расширения существующих определений классов. Наследование по- зволяет реализовать отношение является (“Is-a”) между классами. 5. Назовите виды наследования. Ответ. Наследование реализации, наследование для определения различий, насле- дование для подмены типов 6. В чем состоит опасность наследования? Ответ. Наследование реализации может ослепить разработчика. Наследование реали- зации никогда не должно быть единственной целью разработчика. Наибольшее значе- ние при решении вопроса о наследовании должна иметь подмена типов. Неумелое ис- пользование наследования реализации приводит к бессмысленным иерархиям классов. 7. Что такое программирование отличий? Ответ. Программирование различий — одна из форм наследования. Суть этого ме- тода состоит в том, что при наследовании программируются лишь те возможности, которыми новый класс отличается от старого. Подобная практика приводит к умень- шению классов, так как необходимые возможности добавляются поэтапно. Уменьше- ние классов облегчает их отладку и использование. 338 Приложение
8. Какие три типа методов и свойств может иметь класс-наследник? Ответ. Существует три типа методов и атрибутов: переопределенные, новые и ре- курсивные (унаследованные). Переопределенный атрибут (свойство) или метод — та- кой атрибут или метод, который объявлен в родителе (или потомке), но заново реали- зован в дочернем классе. Потомки меняют поведение метода или определение атрибу- та. Новый метод или атрибут описан в дочернем классе, но отсутствует в его предках. Рекурсивный (унаследованный) атрибут или метод определен в предке, но не переопре- делен в потомке. Потомок просто наследует такой метод или атрибут. Когда происходит вызов такого метода или обращение к такому атрибуту, обращение просто передается вверх по иерархии до тех пор, пока не найдется класс, знающий, что с ним делать. 9. В чем состоит выгода от применения программирования отличий? Ответ. Программирование различий дает меньшие классы, в которых описывается меньшее количество методов. А это означает, что получающиеся классы содержат меньше ошибок, их легче отлаживать, изменять, сопровождать, да и проще понять, что и как в них реализовано. Программирование отличий позволяет применять по- шаговое программирование, в результате чего программы легче усовершенствовать. 10. Что такое наследование для подмены типов? Ответ. Наследование для подмены типов — это вид наследования, благодаря кото- рому предка можно заменить потомком, если используется какой-либо метод предка, не переопределенный потомком. 11. Как наследование может разрушить инкапсуляцию? Как применить инкапсуля- цию при наследовании? Ответ. Наследование может нарушить инкапсуляцию непреднамеренным предос- тавлением подклассу доступа к внутреннему представлению надкласса. Неумышленное нарушение инкапсуляции — одна из опасностей наследования. Наследование неявно дает дочерним классам более свободный доступ к реализации родителей. В результате, если не приняты достаточные меры предосторожности, дочерние классы могут полу- чить прямой доступ к реализации родительских классов. Прямой доступ потомков к реализации родительских классов не менее опасен, нежели прямой доступ любого другого класса. Ведь во многих таких случаях разработчика подстерегают одни и те же опасности. Чтобы избежать нарушения инкапсуляции, можно описать всю внутрен- нюю реализацию как приватную (private). Только те методы должны быть описаны как защищенные (protected), которые абсолютно необходимы подклассам. В боль- шинстве случаев дочерние классы должны просто выполнять общедоступные интер- фейсы родительских классов. 12. Наследуются ли данные и функции-члены базового класса в последующие по- коления производных классов? Скажем, если класс Dog (Собака) является про- изводным от класса Mammal (Млекопитающее), а класс Animals (Животные) является базовым для класса Mammal (Млекопитающее), то унаследует ли класс Dog (Собака) данные и функции класса Animals (Животные)? Ответ. Да. Если имеется конечная последовательность классов, в которой каждый следующий класс является производным от своего предшественника, то последний класс этой последовательности унаследует все данные и методы предшествующих ему предков. 13. Предположим, класс Dog (Собака) является производным от класса Mammal (Млекопитающее), а класс Animals (Животные) является базовым для класса Mammal (Млекопитающее), причем в классе Mammal (Млекопитающее) переоп- ределена функция, описанная в классе Animals (Животные). Какой вариант функции получит класс Dog (Собака)? Ответы и решения задач и упражнений 339
Ответ. Поскольку класс Dog (Собака) является производным от класса Mammal (Млекопитающее), то он получит функцию в том виде, в каком она существует в классе Mammal (Млекопитающее), т.е. переопределенную. 14. Можно ли в производном классе сделать закрытой (private) функцию, кото- рая в базовом классе является общедоступной (public)? Ответ. Можно. Функция может быть не только защищена в производном классе, но и закрыта. Она останется закрытой для всех наследников данного класса. 15. Следует ли все функции класса делать виртуальными? Ответ. Объявление одной функции виртуальной вызовет создание v-таблицы, а для этого потребуется память и дополнительное время для поиска в таблице. Добавление других виртуальных функций в таблицу уже не так критично. Некоторые начинающие программисты полагают, что если в программе есть уже одна виртуальная функция, то и все другие должны быть виртуальными. В действительности это не так. Функция должна объявляться виртуальной, если это помогает решить задачу. 16. Предположим, что некоторая функция без параметров описана в базовом клас- се как виртуальная, а затем перегружена таким образом, что принимает один или два целочисленных параметра. Затем в производном классе был переопре- делен вариант функции с одним целочисленным параметром. Что произойдет, если с помощью указателя на экземпляр производного класса вызвать версию функции с двумя параметрами? Ответ. Переопределение в производном классе версии функции с одним парамет- ром скроет от экземпляров данного класса все остальные версии этой функции. Так что компилятор будет считать, что доступна только версия с одним параметром. По- этому если с помощью указателя на экземпляр производного класса вызвать версию функции с двумя параметрами, компилятор выдаст сообщение об ошибке. 17. Зачем нужны v-таблицы и что в них хранится? Ответ. V-таблицы, или таблицы виртуальных функций используются для вызова виртуальных функций. V-таблицы создаются компилятором, в них хранятся списки адресов всех виртуальных функций. Во время выполнения программы эти адреса ис- пользуются для вызова нужной функции в зависимости от типа объекта. 18. Какой деструктор может быть объявлен виртуальным? Зачем некоторые дест- рукторы объявляются виртуальными? Ответ. Деструктор любого класса может быть объявлен виртуальным. Деструктор объявляется виртуальным, чтобы можно было применить операцию delete к указате- лю на экземпляр базового класса, даже если этот указатель адресует экземпляр произ- водного класса. Если деструктор виртуальный, то будет вызван деструктор производ- ного класса и объект будет удален полностью. 19. Как объявить виртуальный конструктор? Ответ. Никак. Ведь виртуальных конструкторов не существует. 20. Как создать виртуальный конструктор-копировщик? Ответ. В классе нужно создать виртуальный метод, который вызывает конструктор- копировщик. 21. Может ли экземпляр производного класса вызвать функцию базового класса, если в производном классе эта функция была замещена? Ответ. Может. Если Base — имя базового класса, a FunctionName — имя функ- ции, то явный вызов можно записать так: Base: : FunctionName () ;. 340 Приложение
22. Предположим, в базовом классе функция объявлена как виртуальная, а в про- изводном классе виртуальность функции не указана. Будет ли функция вирту- альной в потомках производного класса? Ответ. Да, виртуальность наследуется и не может быть отменена! 23. Доступны ли для функций-членов производных классов защищенные (ключе- вое слово protected) члены? Ответ. Да, защищенные члены (объявленные с использованием ключевого слова protected) доступны для функций-членов производных классов. 24. Приведите пример объявления виртуальной функции Func, которая в качестве параметра принимает одно целочисленное значение и имеет тип результата void. Ответ: virtual void Func (int) ; 25. Приведите пример объявления класса Square (квадрат), производного от класса Rectangle (прямоугольник), который, в свою очередь, является производным от класса Shape (форма). Ответ: class Square : public Rectangle {}; 26. Пусть класс Square (квадрат) является производным от класса Rectangle (прямоугольник), который, в свою очередь, является производным от класса Shape (форма). Предположим, что конструктор класса Shape (форма) не имеет параметров, конструктор класса Rectangle (прямоугольник) принимает два па- раметра (length и width), а конструктор класса Square (квадрат) — один па- раметр (length). Напишите пример конструктора класса Square (квадрат). Ответ: Square::Square(int length): Rectangle(length, length){} 27. Приведите пример виртуального конструктора копий для класса Square. Ответ: class Square { public: // . . . virtual Square * clone() const { return new Square(*this); } // . . . } 28. Что означает перенос функций вверх по иерархии классов? Ответ. Перенос функций вверх по иерархии классов означает перенос описаний общих функций-членов в базовые классы более высокого уровня. Если одна и та же функция описана в нескольких производных классах, то ее описание следует перемес- тить в базовый класс, общий для тех классов, в которых она описана. 29. В каких случаях имеет смысл перенос функций вверх по иерархии классов? В каких случаях такой перенос нецелесообразен? Ответ. Вверх по иерархии классов целесообразно продвигать только функции, со- вместно используемые несколькими классами, однако перенос специфичного интер- фейса производных классов в базовые нецелесообразен. Иными словами, если метод не может быть использован во всех производных классах, то нет смысла описывать его в базовом классе. В противном случае во время выполнения программы перед вы- зовом функции придется проверять тип объекта. Ответы и решения задач и упражнений 341
30. Что плохого в приведении типа объектов? Ответ. Приведение типов объектов к определенному типу данных довольно часто и эффективно используется в программах на C++. Но приводить тип для того, чтобы обойти заложенный в C++ контроль типов, ни в коем случае нельзя. Нельзя, напри- мер, приводить тип указателя к типу объекта, который определяется во время выпол- нения программы. Обычно это свидетельствует о плохом качестве программы. 31. В каких случаях деструкторы объявляются виртуальными? Ответ. Деструкторы следует объявлять виртуальными в том случае, если указатель на базовый класс может использоваться для доступа к членам подклассов. Существует одно простое правило: если в классе есть виртуальные функции, то деструкторы тоже должны быть виртуальными. Глава 8 1. Если в программе не используются все три базовых понятия объектно-ориен- тированного программирования одновременно, значйт ли это, что программа не является объектно-ориентированной? Ответ. Инкапсуляцию нужно применять всегда. Без инкапсуляции не удастся эф- фективно применить ни наследование, ни полиморфизм и нельзя реализовать объ- ектно-ориентированный подход вообще. Что же касается наследования и полиморфизма, то их нужно использовать только тогда, когда в этом есть смысл. Не стоит применять наследование и полиморфизм только для того, чтобы сказать, что они используются в программе. Отсутствие наследования и полиморфизма еще не означает, что программа не явля- ется объектно-ориентированной. Однако нужно внимательно просмотреть программу, чтобы убедиться, что в применении наследования и полиморфизма необходимости нет. 2. В чем проявляется полиморфность полиморфизма? Ответ. В том, что полиморфизм имеет много форм. К формам полиморфизма можно отнести, например, полиморфизм включения, параметрический полиморфизм, полиморфизм подмены типов и полиморфизм перегрузки. 3. Для чего предназначен полиморфизм включения? Ответ. Полиморфизм включения позволяет обращаться с одним объектом так, как если бы этот объект имел другой тип. В результате объект может вести себя различ- ным образом. 4. Каким образом с помощью переопределения и параметрического полиморфиз- ма можно создать более естественную модель реального мира? Ответ. Перегрузка и параметрический полиморфизм позволяют проводить модели- рование на концептуальном уровне. Можно создавать программный код, не особо беспокоясь о типах параметров, необходимых для работы реализуемых процессов. Благодаря этому программы могут быть более общими (универсальными), а в основу описаний методов и типов можно положить то, что они должны делать, а не на то, что от них могут потребовать другие. 5. Почему при программировании следует опираться на интерфейс, а не на .реали- зацию? Ответ. Интерфейс может быть реализован произвольным количеством способов. Если при программировании вы используете интерфейс, то программа не будет при- вязана к какой-либо конкретной реализации. Следовательно, программа сможет авто- матически использовать любую из возможных реализаций. Подобная независимость от реализации позволяет изменять поведение программы путем замены реализаций. 342 Приложение
6. Как взаимодействуют полиморфизм и переопределение? Ответ. При переопределении метода полиморфизм гарантирует, что будет вызвана подходящая версия метода. 7. Можно ли перегрузку рассматривать как частный случай полиморфизма? Ответ. Безусловно, да! 8. Для чего предназначена перегрузка? Ответ. Перегрузка позволяет определять имя метода несколько раз. Разные опре- деления при этом отличаются сигнатурой, т.е. количеством и типами аргументов. Пе- регрузка позволяет под одним именем скрывать различное поведение, так как в каж- дом случае в вызове вы используете одно и то же имя метода. Не требуется прилагать никаких усилий, чтобы вызвать нужную версию метода. Перегрузка позволяет описывать методы на концептуальном уровне, концентрируя внимание только на необходимых действиях. Полиморфизм перегрузки проявляется в учете аргументов. 9. Для чего предназначен параметрический полиморфизм? Ответ. Параметрический полиморфизм позволяет корректно писать универсальные (общие) методы и типы, откладывая определение типов до времени выполнения про- граммы. С помощью этого вида полиморфизма можно создавать подлинно естествен- ный код, так как появляется возможность программировать очень универсальные (общие) методы и типы на концептуальном уровне. Методы и типы при этом рас- сматриваются с точки зрения того, что они должны делать, а не как они это делают. Например, если реализуется метод сравнения compare< [т] а, [Т] Ь), то следует разработать концепцию сравнения объектов типа [Т]. Тип аргументов просто будет за- давать метод сравнения аргументов (с помощью оператора > или метода compare ()). Важно то, что создается один метод для сравнения объектов разной природы. 10. Какие ловушки связаны с полиморфизмом? Ответ. Во-первых, полиморфизм иногда приводит к снижению скорости выполне- ния программы. Некоторые виды полиморфизма требуют проверок или операций по- иска при выполнении программы. Эти проверки могут снизить скорость выполнения программы по сравнению с тем, что потенциально могло бы быть достигнуто в языке со статическим контролем типов. Во-вторых, полиморфизм иногда искушает разработчика нарушить иерархию на- следования. Никогда нельзя передавать по наследству возможности только для расши- рения поведенческого полиморфизма. В-третьих, при обращении с подтипом как с базовым типом теряется доступ к лю- бому поведению, добавляемому подтипом. Поэтому при создании нового подтипа на- до следить за тем, чтобы интерфейс базового типа включал все методы, необходимые для взаимодействия с новым подтипом. 11. Как инкапсуляция и наследование влияют на полиморфизм включения? Ответ. Инкапсуляция предохраняет объекты от привязки к определенной реализа- ции. Без инкапсуляции один объект часто становится зависимым от внутренней реа- лизации другого объекта. Подобная тесная зависимость делает подмену типов практи- чески невозможной. Эффективность наследования влияет на полиморфизм включе- ния. Для полноценного использования подмены типов, предоставляемой полимор- физмом подтипов, необходима правильная иерархия объектов. 12. Кажется, что полиморфизм включения в применении более удобен, чем пере- грузка, поскольку нужно написать только один метод и сделать так, чтобы он ра- ботал со многими различными типами. Зачем же тогда использовать перегрузку? Ответы и решения задач и упражнений 343
Ответ. Дело в том, что иногда лучше использовать перегрузку. Полиморфизм включения пригоден только для потомков. Перегрузка позволяет многократно ис- пользовать название метода в группе методов, причем их аргументы могут быть со- вершенно разными. А полиморфизм включения этого не позволяет. (Впрочем, иногда выгодно использовать полиморфизм включения вместе с перегрузкой.) 13. Как устранить условные операторы? Ответ. Если данные, используемые в условных выражениях, не являются объектом, следует преобразовать их в объект. Если они являются объектом — добавить в него метод, реализующий необходимое поведение. После этого необходимые действия должен выполнять сам объект, и данные не придется запрашивать у него. 14. В чем состоит преимущество полиморфизма включения перед перегрузкой? Ответ. При использовании полиморфизма включения метод может обрабатывать не только аргумент заданного типа, но и аргумент, относящийся к любому его подти- пу. Нет необходимости создавать свой метод для каждого из подтипов, достаточно од- ного метода — это упрощает добавление новых функций. 15. Следует ли с точки зрения объектно-ориентированного подхода запрашивать у объектов данные? Ответ. В объектно-ориентированном программировании не следует запрашивать у объекта его данные. Вместо этого нужно попросить объект самостоятельно вычислить нужный результат. 16. Каковы признаки неправильного применения условных выражений (признаки “плохих” условных выражений). Ответ. В “плохих” условных выражениях используются данные, запрашиваемые у объекта. А ведь при объектно-ориентированном программировании не следует запра- шивать у объекта его данные, вместо этого нужно попросить объект самостоятельно вычислить нужный результат. Нарушение этого правила приводит к неправильному распределению функций (ответственности), ибо каждый пользователь должен знать, что представляют собой его данные и как их обрабатывать. Кроме того, условные операторы создают проблему, если постоянно возникает необходимость в обновлении значительного количества условных операторов по мере добавления новых типов. Та- кая же проблема появляется и в том случае, когда приходится записывать один и тот же условный оператор во многих местах (или вызывать для этого метод, содержащий данный оператор). 17. Объясните понятие полиморфизма своими собственными словами. Ответ. Полиморфизм позволяет обращаться с подтипом так, как будто он является супертипом. Более того, он допускает использование поведения реального базового типа. Полиморфизм создает видимость, что супертип проявляет разные варианты по- ведения. Возможны, конечно, и другие объяснения. Говорят, что теща одного про- граммиста объясняла понятие полиморфизма так: “Моя сестра не мужик, просто у нее маленькая проблема с большими усами!” Глава 9 1. В чем разница между шаблоном и макросом? Ответ. Шаблоны — это средства языка C++, предназначенные для указания пара- метрических типов данных. Макросы же обрабатываются препроцессором и потому макроподстановки выполняются над текстами безотносительно к типам данных. 344 Приложение
2. Почему предпочтительнее использовать шаблоны, а не макросы? Ответ. Шаблоны встроены в язык и обеспечивают более безопасное использование разных типов. Кроме того, по ним нужный экземпляр шаблона генерируется только один раз, а макрогенерация происходит при каждом макровызове. 3. Чем отличается параметр шаблона от параметра функции? Ответ. Параметр шаблона используется для создания экземпляра шаблона для ка- ждого типа. Если данный шаблон компилятор признает подходящим для десятка раз- личных типов данных, то будут созданы десять различных классов или функций (экземпляров данного шаблона). Параметры функций только обозначают данные, пе- редаваемые в функцию при ее вызове. Поэтому даже если функция (обычная, а не inline-функция) вызывается десять раз с различными параметрами, то все равно код функции в объектный модуль будет записан только один раз. 4. В чем разница между параметризованными типами шаблонной функции и па- раметрами обычной функции? Ответ. Обычная (не шаблонная) функция принимает параметры установленных типов. В шаблонной же функции с помощью параметра шаблона задаются типы пере- даваемых функции параметров. Пусть, например, параметром функции F является массив. Тогда для обычной функции тип элементов массива жестко определен. Если же функция шаблонная, то при необходимости (отсутствии обычной функции с име- нем F, принимающей массив с элементами нужного типа) будет сгенерирован подхо- дящий экземпляр шаблона. 5. Когда следует применять шаблоны, а когда наследование? Ответ. Шаблоны лучше всего использовать тогда, когда реализации всех нужных классов отличаются только типом данных, используемых в классе. 6. Предположим, в шаблоне объявлена дружественная функция. Для всех ли эк- земпляров шаблона она будет дружественной? Ответ. Да. 7. Для чего дружественные шаблонные классы или функции специализируются по типу? Ответ. Чтобы установить между ними взаимно однозначное соответствие. Напри- мер, для того чтобы массиву array<int> поставить в соответствие итератор iterator<int>,а не iterator<Animal>. 8. Чем отличается обычный дружественный шаблонный класс от дружественного шаблонного класса, специализированного по типу? Ответ. Обычный дружественный шаблонный класс создает одну функцию для всех экземпляров параметризованного класса, а специализированный по типу дружествен- ный шаблонный класс создает специализированные по типу экземпляры функции для каждого экземпляра параметризованного класса. 9. Можно ли запретить генерацию экземпляра шаблона для определенного типа? Как это сделать для функции? Ответ. Да. Создайте для конкретного типа функцию, специализированную по ти- пу. Чтобы сделать это, например, для массивов целых чисел, помимо функции Array<t>: : SomeFunction () создайте также функцию Array<int>: : SomeFunction (). 10. Сколько создается статических переменных-членов, если поместить один ста- тический член в определение класса-шаблона? Ответ. По одной для каждого экземпляра класса. Ответы и решения задач и упражнений 345
11. Что такое итератор? Ответ. Итератор представляет собой класс, обеспечивающий доступ к данным другого класса. Фактически это обобщенный указатель. К итератору можно приме- нить операцию увеличения (инкрементирования), тогда он будет указывать на сле- дующий элемент последовательности. Если же к итератору применить операцию ра- зыменования, то получим значение того элемента, на который он указывает. 12. Что такое функциональный объект? Ответ. Это экземпляр класса, в котором определен перегруженный оператор вызо- ва функции (). Поэтому функциональный объект можно вызвать так же, как обыч- ную функцию. 13. Создайте шаблон на основе данного класса List: class List { private: public: List () :head(0), tail(O), theCount (0) { } virtual -List(); void insert( int value ); void append( int value ); int is_present( int value ) const; int is_empty() const { return head == 0; } int count() const { return theCount; } private: class ListCell { public : ListCell(int value, ListCell *cell = 0) :val(value), next(cell){ } int val; ListCell *next; } ; ListCell *head; ListCell *tail; int theCount; } ; Решение. Вот один из способов превращения этого класса в шаблон: template cclass Туре> class List { public: List():head(0), tail(0), theCount(0) { } virtual -List(); void insert(Type value ); void append(Type value ); int is_present(Type value ) const; int is_empty() const { return head == 0; } int count() const { return theCount; } private: class ListCell { public: 346 Приложение
ListCell(Type value, ListCell *cell = 0) :val(value), next(cell){} Type val; ListCell *next; }; ListCell *head; ListCell *tail; int theCount; }; 14. Напишите обычную (не шаблонную) реализацию класса List. Решение. Вот одно из решений: void List::insert(int value) { ListCell *pt = new ListCell( value, head ); /★ assert (pt != 0); ★/ // обработка хвоста списка if ( head == 0 ) tail = pt; head = pt; theCount++; } void List::append( int value ) { ListCell *pt = new ListCell( value ); if ( head == 0 ) head = pt; else tail->next = pt; tail = pt; theCount++; } int List::is_present( int value ) const { if ( head == 0 ) return 0; if ( head->val == value || tail->val == value ) return 1; ListCell *pt = head->next; for (; pt != tail; pt = pt->next) if ( pt->val == value ) return 1 ; return 0; 15. Напишите теперь шаблонную версию реализации класса List. Решение. Вот результат преобразования нешаблонной версии в шаблонную: template <class Туре> List<Type>::-List() { ListCell *pt = head; Ответы и решения задач и упражнений 347
while ( pt ) { ListCell *tmp = pt; pt = pt->next; delete tmp; } head = tail = 0; template cclass Type> void List<Type>::insert(Type value) { ListCell *pt = new ListCell( value, head ); assert (pt != 0); // обработка хвоста списка if ( head == 0 ) tail = pt; head = pt; theCount++; template <class Type> void List<Type>::append( Type value ) { ListCell *pt = new ListCell( value ); if ( head == 0 ) head = pt; else tail->next = pt; tail = pt; theCount++; template <class Type> int List<Type>::is_present( Type value ) const { if ( head == 0 ) return 0; if ( head->val == value || tail->val == value ) return 1; ListCell *pt = head->next; for (; pt != tail; pt = pt->next) if ( pt->val == value ) return 1; return 0; } 16. Объявите три списка объектов: типа string, типа Cat и типа int. Решение: List<String> string_list; List<Cat> Cat—List; List<int> int_List; 348 Приложение
17. (Отладка.) Выше мы определили шаблон класса List. Предположим, что Cat — это также ранее определенный класс. Рассмотрим следующий фрагмент программы: List<Cat> Cat_List; Cat Felix; CatList.append( Felix ); cout << "Felix is " << ( Cat—List.is_present( Felix ) ) ? "" : "not " << "present\ n"; Нужно ли в классе Cat определить оператор operator==? Решение. Если в классе Cat не определен оператор operator==, то во всех опера- торах, в которых сравниваются значения членов класса List, таких как is_present, будут обнаружены ошибки при компиляции. Чтобы уменьшить вероятность таких оши- бок, перед объявлением шаблона помешают обширный комментарий, в котором указы- вают, какие операторы следует определить в классе для выполнения всех его методов. 18. Объявите дружественный оператор operator== в классе List. Решение. friend int operator==( const Type& Ins, const Type& rhs ); 19. Напишите реализацию дружественного оператора operator== для класса List. Решение. template cclass Туре> int ListcType>::operator==( const Type& Ihs, const Type& rhs ) { // сначала сравниваем размеры списков if ( Ihs.theCount != rhs.theCount ) return 0; // списки различны ListCell *lh = Ihs.head; ListCell *rh = rhs.head; for (; Ih .’= 0; Ih = Ih.next, rh = rh.next ) if ( Ih.value ’= rh.value ) return 0; return 1; // если они не различны, значит совпадают } 20. Реализуйте шаблонную функцию swap (обмен), обменивающую значения двух переменных. Решение. // Шаблон функции swap: для класса Туре должен быть определен // оператор присваивания и конструктор копий template cclass Туре> void swap( Туре& Ihs, Туре& rhs) { Type temp( Ihs ); Ihs = rhs; rhs = temp; } Ответы и решения задач и упражнений 349
Глава 10 1. Что такое исключение? Ответ. Это объект, который создается выражением, записанным после ключевого слова throw. Вбрасывание этого объекта и есть вызов исключения; сам объект пере- дается в стек вызовов первого оператора catch, который может обработать это ис- ключение. 2. Для чего нужен блок try? Ответ. В блок try помешаются те выражения, которые могут вбрасывать исключения. 3. Для чего используется оператор catch? Ответ. Оператор catch указывает тип обрабатываемых им исключений. Оператор catch располагается сразу за блоком try и выполняет роль обработчика исключений, генерируемых внутри блока try. 4. Какую информацию может содержать исключение? Ответ. Исключение — это объект, поэтому через него может передаваться любая информация, которая определена в том классе, экземпляром которого является ис- ключение. 5. Какой оператор создает объект-исключение? Ответ. Объект-исключение создает оператор throw. 6. Исключения нужно передавать как значения или как ссылки? Ответ. Вообще исключения нужно передавать как ссылки. Чтобы предотвратить изменение объекта-исключения, передаваемую ссылку можно специфицировать с по- мощью ключевого слова const. 7. Пусть некоторый оператор catch перехватывает исключения определенного типа (класса). Сможет ли он перехватить производное исключение? (Производ- ное исключение — экземпляр производного класса.) Ответ. Да, если исключение будет передано как ссылка. 8. Предположим, есть два оператора catch, один из которых перехватывает базо- вое исключение, а второй — производное. Предположим также, что эти опера- торы расположены друг за другом. Что будет, если обработчик базового исклю- чения предшествует обработчику производного исключения? А если поменять местами обработчики? Ответ. В данном случае операторы catch проверяются в порядке их расположения в исходном коде. Причем если первый оператор catch перехватит исключение, то другие операторы catch уже вызываться не будут. Поэтому если обработчик базового исключения предшествует обработчику производного исключения, то обработчик производного исключения вызываться никогда не будет. Если же расположить их в обратном порядке, то обработчик производного исключения будет обрабатывать про- изводное исключение, а обработчик базового исключения — базовое. Поэтому-то ре- комендуется сначала располагать операторы catch, предназначенные для перехвата специфичных (производных) исключений, а после них — обработчики более общих (базовых) исключений. 9. Какие исключения перехватывает оператор catch (...)? Ответ. Оператор catch (...) перехватывает все исключения любого типа. 10. Почему удобнее представлять исключения в виде объектов? Не проще ли пере- давать код ошибки? 350 Приложение
Ответ. Объекты более универсальны, чем коды ошибок. Объекты могут передать больше информации, с ними связан конструктор и деструктор класса исключения и функции устранения ошибки. 11. Запишите блок try и оператор catch для простого исключения. Решение. #include <iostream> class OutOfMemory {}; int main() { try { int *mylnt = new int; if (mylnt == 0) throw OutOfMemory(); } catch (OutOfMemory) { cout « "Unable to allocate memory!\n"; } return 0; } 12. Придумайте исключение, добавьте в него переменную-член и метод доступа и используйте их в блоке catch. Решение. #include <iostream.h> #include <stdio.h> #include <string.h> class OutOfMemory { public: OutOfMemory(char *); char* GetStringO { return itsString; } private: char* itsString; }; OutOfMemory::OutOfMemory(char * theType) { itsString = new char[80]; char warning[] = "Out Of Memory! Can't allocate room for: "; strncpy(itsString, warning, 60); strncat(itsString, theType, 19); } int main() { try { int *mylnt = new int; if (mylnt == 0) throw OutOfMemory("int"); } catch (OutOfMemory& theException) { cout « theException.GetString(); } return 0; } Ответы и решения задач и упражнений 351
13. Создайте производное исключение от придуманного вами базового исключе- ния. Напишите блок catch таким образом, чтобы он обрабатывал как произ- водное, так и базовое исключения. Решение. #include <iostream.h> // Абстрактный тип исключений class Exception { public: Exception(){} virtual -Exception(){} virtual void PrintErrorO = 0; // Производный класс для обработки нехватки памяти // В этом классе память не выделяется class OutOfMemory : public Exception { public: OutOfMemory(){} -OutOfMemory(){} virtual void PrintError(); private: }; void OutOfMemory::PrintError() { cout « "Нет памяти !!\n"; // Производный класс для обработки ввода неправильных чисел class RangeError : public Exception { public: RangeError(unsigned long number){badNumber = number;} -RangeError(){} virtual void PrintError(); virtual unsigned long GetNumber() { return badNumber; } virtual void SetNumber( unsigned long number) {badNumber = number;} private: unsigned long badNumber; void RangeError::PrintError() { cout << "Number out of range. You used " « GetNumber() << "!!\n"; // прототип функции void MyFunction(); int main() { try { MyFunction(); } // Чтобы один оператор catch правильно обрабатывал оба исключения, // используем виртуальные функции 352 Приложение
catch (Exception& theException) { theException.PrintError(); } return 0; void MyFunctionO { unsigned int *mylnt = new unsigned int; long testNumber; if (mylnt == 0) throw OutOfMemory(); cout « "Enter an int: cin » testNumber; // эту проверку лучше заменить серией проверок введенных // пользователем данных if (testNumber > 3768 || testNumber < 0) throw RangeError(testNumber); *mylnt = testNumber; cout « "Ok. mylnt: " << *mylnt; delete mylnt; } 14. Создайте производное исключение от придуманного вами базового исключе- ния. Напишите блок catch таким образом, чтобы он обрабатывал как произ- водное, так и базовое исключения. Сделайте так, чтобы получилось три уровня вызова функции. Решение. #include <iostream.h> // Абстрактный тип исключения class Exception { public: Exception(){} virtual -Exception(){} virtual void PrintError() = 0; }; // Производный класс для обработки нехватки памяти // В этом классе память не выделяется! class OutOfMemory : public Exception { public: OutOfMemory(){} -OutOfMemory(){} virtual void PrintError(); private: }; void OutOfMemory::PrintError() { cout « "Нет памяти!!\n"; } // Производный класс для обработки ввода неправильных чисел class RangeError : public Exception { public: Ответы и решения задач и упражнений 353
RangeError(unsigned long number) {badNumber = number;} -RangeError(){} virtual void PrintError(); virtual unsigned long GetNumber() { return badNumber; } virtual void SetNumber(unsigned long number) {badNumber = number;} private: unsigned long badNumber; }; void RangeError::PrintError() { cout « " Number out of range. You used " << GetNumber() << ”!!\n"; } // прототипы функций void MyFunctionO ; unsigned int * FunctionTwo(); void FunctionThree(unsigned int *); int main() { try { MyFunction(); } // Чтобы один оператор catch обрабатывал все исключения, // применяем виртуальные функции. catch (Exceptions^ theException) { theException.PrintError(); } return 0; } unsigned int * FunctionTwo() { unsigned int *mylnt = new unsigned int; if (mylnt == 0) throw OutOfMemory(); return mylnt; } void MyFunctionO { unsigned int *mylnt = FunctionTwo(); FunctionThree(mylnt); cout « "Ok. mylnt: " « *mylnt; delete mylnt; void FunctionThree(unsigned int *ptr) { long tes tNumber; cout « "Enter an int: "; cin » testNumber; // эту проверку лучше заменить серией // проверок, выявляющих неверно введенные данные if (testNumber > 3768 || testNumber < 0) 354 Приложение
throw RangeError(testNumber); *ptr = testNumber; } 15. Почему бы не использовать исключения не только для отслеживания исключи- тельных ситуаций, но и для выполнения обычных действий? Разве нельзя ис- пользовать механизм исключений для быстрого возврата по стеку вызовов к ис- ходному состоянию программы? Ответ. Действительно, механизм исключений можно использовать не только для отслеживания исключительных ситуаций. Однако раскрутка стека, осуществляемая при поиске обработчика исключения, далеко не безопасна. Ведь если объект был соз- дан в области динамического распределения памяти, то при раскрутке стека он может быть и не удален, а это приведет к утечке памяти. Впрочем, хороший (но далеко не каждый!) компилятор позволяет этой проблемы избежать. Кроме того, чрезмерное ув- лечение исключениями может неоправданно усложнить программу. 16. Обязательно ли перехватывать исключение сразу за блоком try, генерирующим это исключение? Ответ. Вовсе нет, ведь при необходимости стек вызовов может быть раскручен для поиска обработчика исключения. Глава 11 1. Как изменить надпись на кнопке? Ответ. В редакторе макета окна выберите изменяемую кнопку. В подокне Properties (Свойства) измените значение в поле Caption (Надпись). 2. Для чего предназначен MFC Application Wizard? Ответ. Мастер создания приложений Application Wizard предназначен для создания оболочки приложения, зависящей от типа приложения и необходимых для приложе- ния функциональных возможностей. В создаваемую оболочку встраивается поддержка указанных программистом функциональных возможностей. 3. Как связать функцию со щелчком на кнопке? Ответ. В подокне Properties (Свойства) включите режим Control Events (Управ- ление событиями). Теперь можно создать функцию и связать ее с объектом, предна- значенным для обработки конкретного сообщения Windows. Затем можно использо- вать Class View (Просмотр классов) из рабочей области, чтобы просмотреть код функ- ции, и при необходимости изменить его. 4. Добавьте вторую кнопку в окно About (О программе...) вашего приложения. Пусть щелчок на этой кнопке вызывает появление сообщения с любыми до- полнительными сведениями о программе. Решение. Выполните следующие инструкции. 1. В подокне рабочей среды выберите вкладку Resource View (Просмотр ресурсов). 2. Раскройте дерево ресурсов и два раза щелкните на пиктограмме idd_aboutbox — в области редактирования откроется окно About (О программе...). 3. Перетащите мышью кнопку элемента управления с панели инструментов на подходящее место в окне About (О программе...). 4. В подокне Properties (Свойства) измените идентификатор и надпись на кнопке для описания сообщения, которое должно появляться по щелчку на этой кнопке. Ответы и решения задач и упражнений 355
5. Во вкладке Properties (Свойства) включите режим Control Events (Управление событиями). Для новой кнопки добавьте функцию, ответственную за сообще- ние о щелчке на кнопке (bn_clicked). 6. Для проверки работоспособности новой кнопки откомпилируйте и запустите приложение. 356 Приложение
Предметный указатель # #include <iostream.h>, 28 #include <iostream>, 28 #include <stdio.h>, 28 A ANSI, 24 c CAboutDlg, класс, 305 catch, ключевое слово, 286 CButton, класс, 303 CDialog, класс, 305 CHelloApp, класс, 305 cout, объект, 30 CWinApp, класс, 305 CWnd, класс, 305 D dynamic_cast, 175 E endl, символ, 31 I interface, ключевое слово, 116 iostream.h, файл, 28 M main(), функция, 28; 29 Modula2, 105 N new, операция, 108 p private, 192 private, спецификатор доступа, 33 private, уровень доступа, 116 protected. 192 protected, спецификатор доступа, 33 protected, уровень доступа, 116 public, 192 public, уровень доступа, 116 R Refactoring, 198 RTTI, 175 s std, пространство имен, 32; 147 struct, ключевое слово, 33 т throw, ключевое слово, 286 try, ключевое слово, 286 и union, ключевое слово, 33 using, ключевое слово, 135 V Visual C++ .NET editor area, 298 new Project Wizard, 300 solution Explorer, 297 вкладка Class, 297 Output, 297 Properties, 306 Resource, 297 Resource View, 305 Resource View (Ресурсы), 305 выбор вида главного окна приложения, 300 отображаемых панелей инструментов, 298 типа приложения, 301 генерация оболочки приложения, 301 с помощью мастера создания приложений Application Wizard, 300 главное диалоговое окно приложения, 303 добавление кнопок Maximize (Развернуть) и Minimize (Свернуть), 312 нового кода, 307 заголовок приложения, 301 запуск приложения, 303 изменение заголовка окна сообщений, 312 конфигурации, 298 компиляция, 302 мастер создания нового проекта, 300 меню, 298
название проекта, 300 область вывода, 297 область редактирования, 298 открытие окна выбора цветов, 311 ошибки, 309 проектирование окна приложения, 305 разметка окон приложения, 305 режим Control Events, 308 создание пиктограммы приложения, 310 рабочей области проекта, 299 среда разработки, 296 стандартная пиктограмма MFC, 310 стартовое окно, 296 тип создаваемого приложения, 300 управление событиями, 308 функция-подсказка редактора IntelliSense, 308 vptr, 208 V-таблица, 208 V-указатель, 208 w WinMain, функция, 303 A Абстрактные классы и методы, 198 Абстрактный метод, 199 тип данных, 121 Абстракция, 117 советы и ошибки, 119 Американский национальный институт стандартов, 24 Ансельм Кентерберийский, 108 Б Базовые понятия объектно- ориентированного программирования, 112 Боэций, 107 в Вид программы, 29 Видимость, 128 Виртуальная функция правила использования, 233 чисто виртуальная функция, 199 Виртуальный деструктор, 211 конструктор-копировщик, 211 метод, 204 Внутренняя переменная, 106 Входное условие, 116 Вывод данных, 30 Вызов конструкторов и деструкторов, 166 Выпуск (издание) новых версий, 111 Выходное условие, 116 д Деструкторы классов, 38 Дробление объекта, 209 Е Естественность программ, 110 3 Зависимый код, 123 Защищенный, спецификатор доступа, 33 и Издание новых версий, 111 Инкапсуляция, 115 выпуск (издание) новых версий, 126; 336 издание новых версий, 126; 336 создание надежных программ, 726; 336 усовершенствование программ, 126; 336 Интегрированная среда разработки программ, 24; 314 Интерфейс, 116 Исключение, 285; 291; 293; 294; 350; 355 Исключительная ситуация, 285 Использование, 757 Итератор, 279 к Класс CAboutDlg, 305 С Button, 303 С Dialog, 305 CHelloApp, 305 CWinApp, 305 CWnd, 305 абстрактный, 199 базовый, 752; 153 виртуальный базовый, 187 возможностей, 191 вызов метода базового класса, 158 доступ к наследуемым членам, 153 дочерний, 152 конкретный, 199 наследник, 752 описание, 33 основные правила наследования открытых, закрытых и защищенных членов, 153 порождающий, 752 порожденный, 752 потомок, 752 потомственный, 752 предок, 752 358 Предметный указатель
производный, 152; 153 родитель, 152 родительский, 152 Класс-мандат, 191 Класс-сервер, 116 Клиент, 116 Ключевое слово catch, 286 interface, 116 struct, 33 throw, 286 try, 286 union, 33 using, 135 Код с непосредственными связями, 123 Комментарии, 29 Композиция, 160 Компоненты системы программирования, 314 Компоновщик, 145 Конец строки, символ, 31 Конструктор передача параметров, 168 Конструкторы классов, 37 Контейнер, 160 Контейнеризация, 160 Контракт, 116 Корневой класс, 162 Крайние номиналисты, 108 л Лист, 162 м Миксин, 191 Модульное программирование, 106 Модульность, 115 Мономорфный язык, 221 н Надежность программ, ПО Надкласс, 152 Наследование, 152 виртуальное, 187 для отличия, 195 для подмены типов, 196 единичное, 172 механика, 191 множественное, /72; 178 новые методы и свойства, 193 одиночное, 172 подмененные методы и свойства, 192 реализации, 159, 194 рекурсивные методы и свойства, 193 специализация, 196 технология применения, 197 типы, 194 Наследственная иерархия, 161 Номинализм, 108 о Область видимости, 128', 129 Обработка событий в Windows-приложениях, 304 Обратные целых чисел, 45 Объединение мультимножеств, 281 Объект cout, 30 Объектно-ориентированное программирование базовые понятия, 112 отличие от объектно-ориентированного языка, 7/3; 334 Объектно-ориентированный подход, 104 Объект-функция, 280 Оператор вывода, 30 разрешения области видимости, 139 Операция new, 108 Описание класса, 33 Определение типа при выполнении, 175 Основание натуральных логарифмов, 87 Отношение Has-a, 160 Отсечение объекта, 209 п Параметр шаблона, 283', 345 Параметризованные классы, 251 типы, 246 функции, 246 Параметрические методы, 223 типы, 224 Параметрический полиморфизм, 223 Перегрузка, 154; 224 Перенос метода вверх по иерархии классов, 175 Переопределение метода, 192 Переразложение на элементарные операции, 198 Пересечение мультимножеств, 281 Повторное использование, 111 Подмена, 192 Подтип, 196 Позднее связывание, 233 Полиморфизм, 221 виртуальные функции, 232 включения, 222; 244\ 342 вызов виртуальных функций, 239 вынос поведения на слишком высокий уровень иерархии, 230 динамическое связывание, 232 естественность, 242 издание новых версий, 243 лестницы if-else if, 228 Предметный указатель 359
ловушки, 244; 343 надежность, 242 непроизводительные издержки, 231 ограничение интерфейсом базового класса, 231 ошибки и ловушки, 230 параметрический, 244; 343 перегрузка, 244', 343 переключатели, 228 периодический выпуск (издание) новых версий, 243 повторное использование, 242 позднее связывание, 232 полиморфность, 244; 342 потеря эффективности, 231 правила применения, 226 преимущества позднего связывания, 241 преимущество полиморфизма включения перед перегрузкой, 244', 344 проверка условий, 227 реализация, 231 реализация виртуальных функций, 240 сопровождение, 243 технология применения, 226 условные выражения, 228 операторы, 229; 244; 344 усовершенствование, 243 Полиморфический кластер, 234 Полиморфная переменная, 227 Полиморфный язык, 227 Пользователь, 116 Постусловие, 116 Предки, 162 Предусловие, 116 Предшественники, 162 Приватный, спецификатор доступа, 33 Приведение вниз, 175 типов, 225 указателя к типу производного класса, 175 Принцип распределения ответственности, 725 Производное исключение, 293; 350 Пространство имен, 32; 127 std, 32; 147 вложение, 132 добавление новых членов, 131 заголовочный файл класса, 140 использование, 132 неименованное, 146 объявление и определение типов, 130 определения класса, 139 псевдонимы, 146 создание, 130 стандартное, 147 файл реализации класса, 141 Процедурные языки, 105 р Разворачивание стека, 289 Реализация, 116 Реализм, 108 Редактор связей, 145 Рекурсивные методы, 193 Росцелин, 108 с Сильносвязанный код, 123 Символ endl, 31 конец строки, 31 табуляции, 31 Система программирования, 24 Слабосвязанный код, 123 Собственный, спецификатор доступа, 33 Сокрытие реализации недостатки, 123 Составление, 160 Состояние, 106 Специализация, 196 Спецификатор доступа, 33 private, 33 protected, 33 защищенный, 33 приватный, 33 собственный, 33 Спецификация, 116 Средства разграничения доступа, 116 Стандартная библиотека шаблонов, 278 Суперкласс, 752 т Таблица виртуальных функций, 208 Тип переменной, 727 У Удобство сопровождения, 777 Универсалии, 108 Упаковщик, 160 Уровень доступа private, 116 protected, 116 public, 116 Усовершенствование программ, 777 ф Файл iostream.h, 28 Формирование, 160 Формы полиморфизма, 222 Функциональный объект, 280 Функция main(), 28; 29 WinMain, 303 360 Предметный указатель
ч Черный ящик, 116 Число <?, 88 Чисто виртуальная функция, 199; 200 Чистый полиморфизм, 222 ш Шаблон, 246 дружественные классы и функции, не являющиеся шаблонными, 259 друзья, 258 использование имени, 255 использование экземпляров, 265 класса Array, 254 общий для дружественного класса или функции, 262 объявление, 253 создание, 246 создание экземпляра, 253 статические члены, 275 э Эффективная инкапсуляция, 126 Предметный указатель 361
Научно-популярное издание Яков Константинович Шмидский Программирование на языке C++. Самоучитель Литературный редактор Верстка Художественный редактор Корректоры П.Н. Мачуга А.Н. Полинчик В. Г. Павлютин Л.А. Гордиенко, Л. В. Чернокозинская Издательский дом “Вильямс” 101509, г. Москва, ул. Лесная, д. 43, стр. 1 Изд. лиц. ЛР № 090230 от 23.06.99 Госкомитета РФ по печати Подписано в печать с готовых диапозитивов 03.11.2003. Формат 70 х Ю01/]6. Гарнитура Times. Печать офсетная. Усл. печ. л. 29,67. Уч.-изд. л. 20,8. Тираж 4 000 экз. Заказ 234. ОАО «Санкт-Петербургская типография № 6». 191144, Санкт-Петербург, ул. Моисеенко, 10. Телефон отдела маркетинга 271-35-42.
еред вами - самоучитель и практическое руководство по объектно- ориентированному программированию на языке C++. Знание школьного курса информатики не предполагается. Для практического подхода, положенного в основу книги, вполне достаточно знакомства с языком С в объеме книги Программирование на языке C/C++. Самоучитель, выпущенной в 2003 году. Знание других языков программирования необязательно. Что действительно нужно, так это компьютер, на котором можно выполнять как приведенные в книге, так и написанные самостоятельно программы. Требования к компьютеру определяются возможностью установки компилятора языка C++. Для чтения начальных глав вполне подойдет даже компьютер на основе процессора Pentium с тактовой частотой 100 МГц и с оперативной памятью 32 Мбайт и диском объемом 6 Гбайт; однако для чтения последней главы придется установить Visual Studio .NET. Важнее всего, пожалуй, чтобы компьютер был всегда под рукой - только в этом случае можно освоить язык досконально. Доступно, подробно, на профессиональном уровне описаны все тонкости объектно-ориентированного программирования на языке C++: технология применения инкапсуляции, наследования и полиморфизма, абстрактные классы и методы, виртуальные функции, шаблоны, обобщенные алгоритмы, контейнеры, библиотека стандартных шаблонов STL. Из книги вы также узнаете о том, как создавать Windows-приложения с помощью Visual Studio .NET и библиотеки базовых классов Microsoft (MFC). Все базовые понятия и конструкции языка демонстрируются на большом количестве детально разобранных примеров. Несомненным (и таким редким!) достоинством книги является то, что изложение языка C++ построено как продолжение освоения языка С - это позволит читателям, уже знакомым с языком С, сосредоточиться именно на изучении C++, не теряя время на повторное перечитывание тривиальных сведений. Большое внимание уделяется технологии программирования и приводятся примеры ее использования для разработки программ. В первую очередь книга рассчитана на школьников, студентов, аспирантов, а также всех, кто хочет освоить объектно-ориентированное программирование на языке C++ и уже знаком с языком С. Посетите "Диалектику" в Internet по адресу: www.dialektika.com ИлиДПЕКШиКД