МИНИСТЕРСТВО ОБРАЗОВАНИЯ РОССИЙСКОЙ ФЕДЕРАЦИИ ВОЛЖСКИЙ ПОЛИТЕХНИЧЕСКИЙ ИНСТИТУТ (филиал) ВОЛГОГРАДСКОГО ГОСУДАРСТВЕННОГО ТЕХНИЧЕСКОГО УНИВЕРСИТЕТА КАФЕДРА «АВТОМАТИКА, ЭЛЕКТРОНИКА И ВЫЧИСЛИТЕЛЬНАЯ ТЕХНИКА» Д. Н. Лясин, С. Г. Саньков Объектно-ориентированное программирование на языке С++ РПК «Политехник» Волгоград 2004 УДК 681.31 Рецензенты: к.т.н., доцент, зав. каф. прикладной математики и информатики ВГИ (филиал) ВолГУ Мирецкий И.Ю. к.т.н., доцент, зам. директора ОЦНИТ Деревенсков С.О. Лясин Д.Н., Саньков С.Г. Объектно-ориентированное программирование на языке С++: Учебное пособие./ Волгоград.гос.техн.ун-т,- Волгоград, 2004. – 83 с. ISBN 5-230 – В учебном пособии подробно рассматриваются вопросы программирования с использованием объектно-ориентированной технологии на языке С++. Изложены основные принципы объектно-ориентированного программирования, а также синтаксические правила объявления классов и объектов в программах на С++ и особенности их использования. Рассмотрены такие аспекты объектно-ориентированного программирования, как дружественные функции и классы, перегрузка операций, множественное наследование, виртуальные функции и классы, абстрактные классы. Приведено большое количество примеров программ на С++, использующих объектно-ориентированную технологию. Предназначены для студентов, обучающихся по направлению 5528 "Информатика и вычислительная техника" и специальности 2201 "Вычислительные машины, комплексы, системы и сети" всех форм обучения. Библиогр. - 5 назв. Печатается по решению редакционно-издательского совета Волгоградского государственного технического университета © Волгоградский государственный технический университет, 2004 2 Оглавление 1. Обзор стилей программирования 4 1.1. Процедурное программирование 4 1.2. Структурное программирование 5 1.3. Функциональное программирование 6 1.4. Логическое программирование 7 1.5. Объектно-ориентированное программирование 7 2. Основные принципы объектно-ориентированного 10 программирования 3. Объектно-ориентированное программирование на 15 языке С++ 3.1. Объявление классов и объектов 15 3.2. Конструкторы и деструкторы 18 3.3. Область видимости компонент класса 23 3.4. Определение компонентных функций класса 26 3.5. Статические компоненты классов 27 3.6. Дружественные функции 31 3.7. Перегрузка операций 40 4. Наследование классов 45 4.1. Повторное использование классов: наследование и агрегиро- 45 4.2. Объявление наследования классов в С++ 47 4.3. Множественное наследование 60 4.4. Виртуальные классы 66 4.5. Виртуальные функции. Полиморфизм 70 4.6. Абстрактные классы 77 вание Список литературы 82 3 1.Обзор стилей программирования Технология программирования - это совокупность методов и средств разработки (написания) программ и порядок применения этих методов и средств. В настоящее время технологии программирования разделяют по используемым стилям программирования на процедурное (структурное), функциональное, логическое и объектно-ориентированное программирование. Они различаются по уровню абстракции данных, используемым моделям вычислений, классам решаемых задач. Ниже дана характеристика перечисленных стилей программирования. 1.1. Процедурное программирование Процедурное (императивное) программирование является отражением архитектуры традиционных ЭВМ, предложенной фон Нейманом в 40-х годах. Процедурная программа состоит из последовательности операторов и предложений, управляющих последовательностью их выполнения. Типичными операторами являются операторы присваивания и передачи управления, операторы ввода/вывода и специальные предложения для организации циклов. Из них можно составлять фрагменты программ и подпрограммы. В основе такого программирования лежит взятие значения какой-то переменной, совершение над ним действия и сохранение нового значения с помощью оператора присваивания, и так до тех пор пока не будет получено (и, возможно, напечатано) желаемое окончательное значение. Знакомый многим пример неимперативного программирования - электронная таблица. В ней значения ячеек задаются выражениями, а не командами, определяющими, как вычислять это значение. Нигде также не задается порядок вычисления значений ячеек, гарантируется, что вычисления будут вы4 полнены в правильном порядке с учетом зависимости ячеек друг от друга. В электронной таблице не используется присваивание, то есть указание изменить текущее значение ячейки. Если мы не управляем сами последовательностью вычислений, то мы и не знаем, когда произойдет присваивание, а поэтому от него мало пользы. К процедурным языкам относятся Basic, Cobol, Fortran, Pascal, C и Ada. 1.2. Структурное программирование Структурный подход к разработке ИС заключается в ее декомпозиции (разбиении) на автоматизируемые функции: система разбивается на функциональные подсистемы, которые в свою очередь делятся на подфункции, подразделяемые на задачи и подзадачи. Структурное программирование основано на следующих принципах: - программирование должно осуществляться сверху-вниз; - весь проект должен быть разбит на модули с одним входом и одним выходом ; - логика алгоритма и программы должна допускать только три основные структуры - последовательное выполнение, ветвление и повторение. Недопустим оператор передачи управления в любую точку программы; - при разработке документация должна создаваться одновременно с программированием, в виде комментариев к программе. Цель структурного программирования - повышение надежности программ, обеспечение сопровождения и модификации, облегчение и ускорение разработки. Идеи структурного программирования появились в начале 70-годов в компании IBM, в их разработке участвовали такие известные ученые как Э. Дейкстра, Х. Милс, Э. Кнут, С. Хоор. 5 1.3. Функциональное программирование Программа, разработанная с использованием функционального (аппликативного) стиля, состоит из совокупности определений функций. Функции, в свою очередь, представляют собой вызовы других функций и предложений, управляющих последовательностью вызовов. Вычисления начинаются с вызова некоторой функции, которая в свою очередь вызывает функции, входящие в ее определение и т. д. в соответствии с иерархией определений и структурой условных предложений. Функции часто либо прямо, либо опосредованно вызывают сами себя. Каждый вызов возвращает некоторое значение в вызвавшую его функцию, вычисление которой после этого продолжается; этот процесс повторяется до тех пор, пока запустившая вычисления функция не вернет конечный результат пользователю. "Чистое" функциональное программирование не признает присваиваний и передач управления. Разветвление вычислений основано на механизме обработки аргументов условного предложения. Повторные вычисления осуществляются через рекурсию, являющуюся основным средством функционального программирования Первый функциональный язык программирования (Лисп) был разработан американским ученым Дж. Маккарти (J. McCarthy) в 1958-1961 гг. на основе алгебры списочных структур, лямбда-исчисления и теории рекурсивных функций. К настоящему времени созданы такие функциональные языки программирования как Scheme, Рефал, Haskell, Sisal. 6 1.4. Логическое программирование Логическое (реляционное) программирование исходит из того, что компьютер должен уметь работать по логическим построениям, присущим человеку. Например, в логическом программировании разрешена конструкция типа "Определить фирму, имеющую самую высокую в городе среднюю зарплату сотрудников", которой достаточно, чтобы получить ответ. Программа в таких языках представляет собой совокупность правил (определяющих отношения между объектами) и цели (запроса). Процесс выполнения программы трактуется как процесс установления общезначимости логической формулы по правилам, установленным семантикой того или иного языка. Результат вычислений является побочным продуктом процедуры вывода. Такой метод являет собой полную противоположность программирования на каком-либо из процедурных языков. В реляционном программировании нужно только специфицировать факты, на которых алгоритм основывается, а не определять последовательность шагов, которые необходимо выполнить. Примером логического языка программирования можно назвать PROLOG язык, предназначенный для программирования приложений, использующих средства и методы искусственного интеллекта, создания экспертных систем и представления знаний. 1.5. Объектно-ориентированное программирование Объектно-ориентированная технология разработки программ состоит из объектно-ориентированного анализа, объектно-ориентированного проектирования и объектно-ориентированного программирования. Объектно-ориентированный анализ состоит в объектной декомпозиции предметной области, т.е. информационная система представляется не набором 7 функций, а совокупностью объектов, взаимодействующих друг с другом. Декомпозиция – это разделение сложной программной системы на все меньшие и меньшие подсистемы, каждую из которых можно совершенствовать независимо[1]. Структурное проектирование предполагает алгоритмическую декомпозицию, понимаемую как разделение алгоритмов, где каждый модуль системы выполняет один из этапов общего процесса. На рис.1 показан фрагмент функциональной схеВыполнить заказ Занести в план Оформить накладную Отпустить товар Изменить количество товара на складе Проверить наличие товара на складе Включить товар в накладную Передать товар заказчику Рисунок 1. – Фрагмент функциональной схемы системы складского учета. мы для системы складского учета. Эту схему можно рассматривать как пример алгоритмической декомпозиции. Приведенной на рис. 1 декомпозиции существует альтернатива, представленная на рис.2. Здесь предметная область представлена как совокупность некоТовар Заказ принять оформить включить Накладная Заказчик передать Рисунок 2.-Объектно-ориентированная декомпозиция 8 Проверить наличие Изменить количество Склад торых автономных объектов, которые взаимодействуют друг с другом, чтобы обеспечить функционирование всей системы в целом. Объекты обладают поведением, состоянием, свойствами, которые в программе реализуются в виде подпрограмм (функций). Таким образом, объектноориентированная технология включает в себя возможности структурного подхода, но объектно-ориентированное проектирование в большей степени реализует модель реального мира и соответствует естественной логике человеческого мышления. По мнению автора С++, Бьерна Страуструпа[2], различие между процедурным и объектно-ориентированным стилями программирования заключается примерно в следующем: программа на процедурном языке отражает "способ мышления" процессора, а на объектно-ориентированном - способ мышления программиста. Отвечая требованиям современного программирования, объектно- ориентированный стиль программирования делает акцент на разработке новых типов данных, наиболее полно соответствующих концепциям выбранной области знаний и задачам приложения. Сравнивая объектно-ориентированный и процедурный стиль программирования (подробно остановимся на этих двух технологиях, поскольку для остальных характерна некоторая функциональная ограниченность, не позволяющая использовать их для решения широкого круга задач), необходимо выбрать критерий сравнния. Основной критерий в оценке программных продуктов – сложность[1] , а основными требованиями к методологиям разработки являются: удобство сопровождения, возможность безболезненного наращивания уже существующей программы, способность разработанных программных объектов к повторному использованию. При этом на второй план отступает такое требование, как быстрое проектирование первоначальной версии программы, потому что его воплощение обычно не позволяет соблюсти все остальные условия. Дело в том, что процесс разработки программного обеспечения не заканчивается первой версией. Он сводится к итеративному расширению предыдущих версий, что, в некоторой степени, 9 и помогает решать проблему сложности. В борьбе с проблемами, определяемыми сложностью программ, дальше всех продвинулась объектно-ориентированная технология, которая и получила наибольшее распространение. В настоящее время она успешно развивается по самым разным направлениям, затрагивая как анализ и проектирование программных систем, так и написание самих программ. Последнее определяется как объектно-ориентированное программирование и связано с использованием соответствующих объектно-ориентированных языков. В качестве примера языков, поддерживающих объектно-ориентированный стиль программирования, можно привести С++, Object Pascal, Smalltalk, Ada, Eiffel. Развитие ООП практически вытеснило процедурное программирование из разработки сложных программных систем. 2. Основные принципы объектно-ориентированного программирования Каждый из перечисленных выше стилей программирования имеет некоторые основополагающие принципы, определяющие способ решения задачи, представление данных в программе и способ их обработки. Для объектноориентированного стиля программирования такими принципами являются: абстагирование инкапсуляция наследование полиморфизм Абстрагирование подразумевает собой процесс изменения уровня детализации программы. Основная его роль – выделение существенных характеристик некоторого объекта, отличающие его от всех других видов объектов и, таким образом, четкое определение его концептуальных границ с точки зрения наблюдателя. 10 Когда мы абстрагируемся от проблемы, мы предполагаем игнорирование ряда подробностей с тем, чтобы свести задачу к более простой. Задача абстрагирования и последующей декомпозиции типична для процесса создания программ. Декомпозиция используется для разбиения программ на компоненты, которые затем могут быть объединены, позволив решить основную задачу, абстрагирование же предлагает продуманный выбор таких компонент. Последовательно выполняя то один, то другой процесс можно свести исходную задачу к подзадачам, решение которых известно. Для одного и того же моделируемого в программе объекта в зависимости от решаемой задачи необходимо учитывать различные свойства и характеристики, то есть рассматривать его на различных уровнях абстракции. Например, если мы будем рассматривать объект «Файл» в контексте разработки текстового редактора, то нас в первую очередь будут интересовать такие параметры объекта, как тип представления информации в файле, методы чтения и записи информации из/в файл, используемые промежуточные буферы для хранения информации. Иными словами, для данной предметной области интерес представляет внутреннее содержимое файла. Если же тот же объект «Файл» рассматривать в контексте разработки файлового менеджера, то на первый план выходят свойства объекта, характеризующие его как элемент файловой системы (имя файла, путь к файлу, атрибуты, права доступа и т.п.). Выбор правильного набора абстракций для заданной предметной области представляет собой главную задачу объектноориентированного проектирования. Инкапсуляция есть объединение в едином объекте данных и кодов, оперирующих с этими данными. В терминологии объектно-ориентированного программирования данные называются членами данных (data members) объекта, а коды объектными методами иди функциями-членами (methods, member functions) Инкапсуляция является важным принципом ООП, организующим защиту информации от ненужных и случайных модификаций, что обеспечивает целостность данных и упрощает отладку программного кода после изменений. 11 Все компоненты объекта разделяются на интерфейс и внутреннюю реализацию. Интерфейс - это лицевая сторона объекта, способ работы со стороны его программного окружения – других объектов, модулей программы. В интерфейсной части описывается, что умеет делать объект. Это похоже на ручку регулировки громкости у телевизора. Разработчик создал сложный объект (телевизор), и ручка регулировки громкости является тем интерфейсом, посредством которого окружение этого объекта (телезритель) может влиять на его внутреннее состояние (громкость звучания). В противоположность интерфейсу, внутренняя реализация объекта представляет собой те компоненты класса, которые по замыслу разработчика класса не должны быть доступны извне. Реализация - это изнанка объекта, она определяет, как он выполняет задание, поступающее от интерфейсных компонент. Продолжая аналогию с телевизором, можно сказать, что, очевидно, существует внутренняя реализация этого сложного технического объекта в виде совокупности электронных устройств, объединенных в сложные электрические схемы. Использование интерфейсного элемента (ручки регулировки) приводит в действие механизмы внутренней реализации, которые и обеспечивают в конечном итоге изменение громкости звучания прибора. При этом главным требованием принципа инкапсуляции, повторимся, является изоляция внутренней реализации объекта от окружения. Этим достигается целостность объекта при любых возможных внешних воздействиях на него. Для телевизора эта изоляция заключается в наличии защитных панелей, которые скрывают от пользователя детали внутреннего устройства и не позволяют изменять громкость, например, путем подкручивания отверткой каких-нибудь элементов электронной схемы. Для программной системы использование защитной панели, конечно, неприменимо, однако, идея изоляции внутреннего содержимого объекта от окружения реализуется посредством специальных средств объектно-ориентированных языков программирования. 12 Таким образом, инкапсуляция реализует в объектно-ориентированном программировании принципы, предложенные Д. Парнасом, которые гласят: 1. Разработчик программы должен предоставлять пользователю всю информацию, которая нужна для эффективного использования приложения, и ничего кроме этого. 2. Разработчик программного обеспечения должен знать только требуемое поведение объекта и ничего кроме этого. Наследование состоит в процессе создания новых объектов (потомков) на основе уже имеющихся объектов (предков) с передачей их свойств и методов по наследству. Наследование позволяет модифицировать поведение объектов и придает объектно-ориентированному программированию исключительную гибкость. Идея наследования заимствована у природы, где потомство, наследуя основные характеристики предков, обладает некоторыми оригинальными отличительными особенностями. Программный объект также может унаследовать от объекта-предка некоторые свойства и методы, а добавленные к этим унаследованным атрибутам собственные свойства и методы позволяют расширить функциональность по отношению к объекту-предку. Преимущество принципа наследования заключается в повторном использовании кода, когда каждый новый объект не создается с нуля, а строится на фундаменте уже существующего объекта. При этом уменьшается как размер кода, так и сложность программы. Благодаря использованию принципа наследования в современные системы программирования включены библиотеки классов, которые представляют собой многоуровневые иерархические системы классов, описывающих элементы программного или пользовательского интерфейса прикладной программы. В основе подобных систем лежат базовые классы, которые обычно очень просты и являются обобщением свойств всех остальных классов библиотеки. Классы-потомки базовых дополняются собственными свойствами, приобретая дополнительную функциональность. Они, в свою очередь, становятся основой для классов следующих уровней иерархии. Таким образом формируется гибкая и стройная система 13 классов. Примерами библиотек классов, построенных по такому принципу, можно назвать Turbo Vision, Objects Windows Library или Visual Component Libraries фирмы Inprise (Borland) или Microsoft Foundation Classes фирмы Microsoft. Достоинством библиотек классов помимо того, что они представляют готовый «строительный материал» для программиста в виде стабильно работающих объектов, является и то, что программист получает возможность создавать собственные классы, не определяя их с нуля, а всего лишь доопределив ряд недостающих свойств для какого-либо стандартного класса библиотеки, выбранного в качестве базового. Полиморфизм – это свойство родственных объектов (то есть тех объектов, классы которых являются производными от одного родителя) вести себя поразному в зависимости от ситуации, возникающей в момент выполнения программы. Слово полиморфизм происходит от греческих слов poly (много) и morphos (форма) и означает множественность форм методов объектов. Если в объекте-потомке и объекте-родителе определены одноименные методы, имеющие разную кодовую реализацию (это называется перегрузкой метода в объекте-потомке), то вызов данного метода может быть привязан к его конкретной реализации в одном из родственных объектов только в момент выполнения программы. Это называется поздним связыванием, а методы, реализующие позднее связывание – полиморфными или виртуальными. Принцип полиморфизма можно проиллюстрировать примером из реального мира, когда родственные объекты «птица», «рыба» и «животное» по-разному реализуют операцию «перемещение», летая, плавая и бегая соответственно. Таким образом, если предположить наличие базового класса «живое существо», обобщающего свойства этих трех объектов, то метод «перемещение» потребует полиморфного объявления. Это позволит избежать ситуаций, когда вызов метода «перемещение» для объекта типа «рыба» приведет к тому, что объект реализует операцию «бежать». Другой пример, который ближе к практике реального программирования: для иерархии объектов – графи14 ческих фигур (окружность, квадрат, треугольник и т.п.) можно определить виртуальную функцию draw(), отображающую фигуру. Объявление функции draw() виртуальной позволит обеспечить надлежащий отклик на событие с требованием отобразить ту или иную фигуру. Более подробно, с примерами реализации, принцип полиморфизма будет рассмотрен в главе 4.5. 3. Объектно-ориентированное программирование на языке С++ Язык С++ был разработан на основе С в 80-е годы и поначалу назывался "С с классами". С++ практически включает язык С и дополнен средствами объектно-ориентированного программирования. Рабочая версия С++ появилась в 1983 г. С тех пор язык продолжает развиваться, и опубликовано несколько версий проекта стандартов С и С++. Ключевыми понятиями языка С++ с точки зрения объектно-ориентированного программирования являются понятия класса и объекта. 3.1. Объявление классов и объектов Класс – это определяемый пользователем тип данных, описывающий с точки зрения объектно-ориентированного программирования некоторый объект из предметной области решаемой задачи. Объект – это экземпляр класса, переменная типа, описываемого классом. Таким образом, разница между понятиями класс и объект схожа с разницей между понятиями «тип данных» и «экземпляр типа». Объект существует в памяти, класс же является шаблоном, по которому создается объект. Определение класса в программе можно сделать одним из трех способов с использованием ключевых слов class, struct или union: сlass имя класса { список членов}; 15 struct имя класса { список членов}; union имя класса { список членов}; Здесь имя класса – имя определяемого пользовательского типа данных, список членов – список свойств и методов класса, сделанных согласно синтаксису языка С++. Различия между тремя представленными объявлениями класса заключаются, во-первых, в разных правах доступа, присваиваемых компонентам класса по умолчанию, а также в способе расположения компонент класса в памяти. Для классов, объявленных с использованием ключевых слов srtuct и union, компоненты класса по умолчанию являются доступными для окружения. Для классов, объявленных через class, компоненты по умолчанию недоступны вне класса. Рассмотрим пример объявления класса, описывающего работу с одномерным массивом целых чисел. //Листинг 1. Определение класса – «массив целых чисел» struct array { int *mas; // указатель на начало массива int n; //количество элементов в массиве void InitMas(int k) //функция инициализации - выделения динамической памяти //под массив {if (k>0) {n=k; mas=new int[n]; }} void DelMas() //функция уничтожения массива {n=0; delete []mas; } void ReadMas() //функция ввода массива в клавиатуры { cout<<"Вывод массива"; for (int i=0;i<n;i++) cin>>mas[i]; } void WriteMas() //функция вывода элементов массива на экран { cout<<"Вывод массива"; for (int i=0;i<n;i++) cout<<mas[i]; cout<<'\n'; }}; В определении класса описаны два компонентных члена данных: n и mas. Они хранят некоторые значения, описывающие параметры класса-массива. Также 16 в классе определены компонентные функции или методы класса: InitMas, DelMas, ReadMas и WriteMas, назначение которых – обрабатывать данные, хранящиеся в членах данных класса. Согласно принципу инкапсуляции, методы класса являются его интерфейсной частью, посредством использования этих методов необходимо работать с массивом. Набор методов класса невелик, он позволяет лишь выделять и освобождать динамическую память под массив, а также вводить с клавиатуры и выводить на экран значения элементов массива. Расширить набор методов, например, функциями сортировки массива или поиска максимума или минимума читатель сможет самостоятельно. Теперь рассмотрим пример использования определенного выше класса. //Листинг2. Пример использования объектов класса «массив целых чисел»из листинга 1 main() { array m,m1; m.InitMas(4); m1.InitMas(5); m.ReadMas(); m.WriteMas(); m1.ReadMas(); m1.WriteMas(); m1.DelMas(); m.DelMas(); } Общий синтаксис определения объекта класса не отличается от определения обычной переменной: имя_класса имя_объекта; В приведенном примере сначала определяются два объекта класса array с именами m и m1. При этом в памяти выделяется по 6 байт на каждый объект: 2 под n и 4 под mas (при условии использования far указателей). При создании объекта память выделяется только под компонентные данные, методы класса существуют в памяти в единственном экземпляре и все объекты используют их совместно. Далее для каждого из объектов вызываются методы класса. Обращение к методам и компонентным данным возможно через имя уже определенного объекта по следующему синтаксису: 17 имя_объекта.имя_члена_данных имя_объекта.имя_метода(список_фактических_параметров) Необходимо отметить, что метод класса нельзя вызвать независимо от объекта. Если рассмотреть, например, тело функции InitMas, то можно заметить, что эта функция изменяет значения компонентных данных n и mas. Однако нигде внутри тела метода не определяется, с компонентами какого именно объекта должна работать функция. Очевидно, что в теле класса это и невозможно определить, поскольку данный метод будет обрабатывать компонентные данные различных объектов. Привязка метода класса к конкретному объекту осуществляется в момент вызова метода. Компонентные функции при их вызове неявно получают дополнительный аргумент - указатель на переменную объектного типа, для которой вызвана функция, и в теле функции можно обращаться ко всем компонентам объекта. Так, например, для вызова m.InitMas(4) компонентная функция InitMas будет работать с компонентами объекта m, а для вызова m1.InitMas(5)- с компонентами объекта m1. Память под объект можно выделять динамически, что иллюстрируется следующим примером: //Листинг3. Обращение к компонентам класса при динамическом выделении памяти //под объект main() { array *ptm; ptm=new array; ptm->InitMas(4); //можно также (*ptm).InitMas(4) ptm->ReadMas(); ptm->WriteMas(); prm->DelMas(); } 3.2. Конструкторы и деструкторы Приведенный в листинге 1 пример класса-массива обладает рядом недостатков. В частности, возможна такая работа с объектом: 18 //Листинг 4.Пример неверного обращения к методам класса «массив целых чисел» //из листинга 1 main() { array m; m.ReadMas(); m.WriteMas(); } Проблема здесь заключается в том, что для класса не предусмотрена защита от некорректных вызовов методов, и метод чтения массива ReadMas может быть вызван еще до инициализации массива, то есть без выделения памяти под него. Это обязательно в дальнейшем приведет к потере данных. Таким образом, можно сказать, что для данного класса не продуман как следует интерфейс, который бы обеспечивал целостность объекта при любых операциях с ним. Решением проблемы могло бы стать введение дополнительного члена данных, который своим значением определял, проинициализирован ли массив или нет. Переопределим класс array: //Листинг 5. Решение проблемы некорректности интерфейса класса введением дополнительного //компонентного данного struct array { int *mas, n; int present; void InitMas(int k) {if (!present) {if (k>0) { n=k; mas=new int[n]; present=1; } } else cout<<”Память уже выделена”; } void DelMas() //функция уничтожения массива {if (present) { delete []mas; present=0; n=0; } else cout<<”Память не была выделена”; } void ReadMas() //функция ввода массива в клавиатуры {if(present) 19 { cout<<"Ввод массива"; for (int i=0;i<n;i++) cin>>mas[i]; } else cout<<”Ошибка! Память под массив не выделена”; } }; В программе из листинга 5 в класс введен дополнительный компонент present, который принимает единичное значение, когда память под массив выделена, и нулевое – в противном случае. При такой реализации методов класса их можно вызывать в программе в любой последовательности. Необходимо только позаботиться, чтобы при определении класса начальное значение свойства present было равно нулю. Начальная инициализация члена данных может быть осуществлена аналогично инициализации полей структуры. array m={NULL, 0, 0}; Однако, такой способ инициализации компонентных данных не всегда удобен, поскольку при создании объекта зачастую необходимо не просто присвоить некоторые начальные значения компонентным данным, но и выполнять ряд действий: выделить динамическую память, открыть файл и т.п. В рассматриваемом примере с классом-массивом, например, при создании объекта было бы полезно сразу выделить под него динамическую память, что позволит избавиться от проблемы работы с неинициализированным объектом без введения дополнительной компоненты present. Для этих целей в класс вводится специальная компонентная функция, называемая конструктором. Конструктор – это метод класса, имя которого совпадает с именем класса. Конструктор вызывается автоматически после выделения памяти для переменной и обеспечивает инициализацию компонент-данных. Конструктор не имеет никакого типа (даже типа void) и не возвращает никакого значения в результате своей работы. Конструктор нельзя вызывать как обычную компонентную функцию в программе. Вызов конструктора в программе выглядит следующим образом: 20 имя_класса имя_объекта ( фактические_параметры_конструктора ); имя_класса * имя_указателя = new имя_класса(фактические_ параметры_ конструктора ); Для класса может быть объявлено несколько конструкторов, различающихся числом и типами параметров. При этом даже если для объектного типа не определено ни одного конструктора, компилятор создает для него конструктор по умолчанию, не использующий параметров, а также конструктор копирования, необходимый в том случае, если переменная объектного типа передается в конструктор как аргумент. В этом случае создаваемый объект будет точной копией аргумента конструктора. //Листинг 6. Конструкторы по умолчанию struct MyClass {//конструкторы по-умолчанию (создаются компилятором) MyClass() // без параметров {…} MyClass(MyClass &copy) //конструктор копирования {…} }; main() { MyClass m; //вызов конструктора без параметров MyClass m1(m);//вызов конструктора копирования } Для класса array вместо метода InitMas необходимо определить конструктор, который выделял бы динамически память под массив. //Листинг 7. Переопределение класса «массив целых чисел» с использованием //конструктора struct array {… array(int k) {if(k>0) { n=k; mas=new int[n]; } else {cout<<Неверный размер массива”; n=0; } } … 21 }; main() { array m(5); //вызов конструктора. Память выделяется под 5 элементов массива m.ReadMas(); //ввод элементов массива с клавиатуры … } Описание конструктора можно упростить, если компонентные данные принадлежат к базовым типам или являются объектными переменными, имеющими конструктор. При описании конструктора после заголовка функции можно поставить двоеточие и за ним список инициализаторов вида имя_компонента (выражение) Например, для класса array можно было определить конструктор так: class array ( ..... public: array ( int k) : n(k) {mas=new int[n] ; } }; Еще одним специальным методом класса является деструктор. Деструктор вызывается перед освобождением памяти, занимаемой объектной переменной, и предназначен для выполнения дополнительных действий, связанных с уничтожением объектной переменной, например, для освобождения динамической памяти, закрытия, уничтожения файлов и т.п. Объявление деструктора в классе выглядит следующим образом: ~имя_класса() {тело деструктора} Деструктор всегда имеет то же имя, что и имя класса, но перед именем записывается знак ~ (тильда). Деструктор не имеет параметров и подобно конструктору не возвращает никакого значения. Таким образом, деструктор не может быть перегружен и должен существовать в классе в единственном экземпляре. Деструктор вызывается автоматически при уничтожении объекта. Таким образом, для статически определенных объектов деструктор вызывает22 ся, когда заканчивается блок программы, в котором определен объект (блок в данном случае – составной оператор или тело функции). Для объектов, память для которых выделена динамически, деструктор вызывается при уничтожении объекта операцией delete. //Листинг 8. Вызов деструктора объекта main() {MyClass m; //создание объекта статически MyClass *ptm=new MyClass; //создание объекта динамически … delete ptm; //вызов деструктора для динамического объекта … //вызов деструктора для статического объекта } Определим деструктор для класса array. struct array {… ~array() { if(n>0) delete []mas; } … }; Деструктор в отличие от конструктора допускает явный вызов вида: имя_обекта.~имя_класса() адрес_объекта->~имя_класса() 3.3. Область видимости компонент класса У рассмотренного выше класса array есть еще один существенный недостаток – все компоненты этого класса являются общедоступными. Это означает, что к любому компонентному данному объекта можно обратиться, например, из функции main(), минуя вызов методов класса. //Листинг 9. Нарушение принципа инкапсуляции main() { array m(10); delete m.mas; for(int i=0;i<m.n;i++) 23 cin>>m.mas[I]; } Таким образом, проигнорировав интерфейс объекта, программист может работать с компонентными данными, и целостность объекта не может быть гарантирована. Выход из создавшегося положения уже известен – необходимо выделить в объекте интерфейс и внутреннюю реализацию и запретить доступ из окружения объекта к компонентам, составляющим его внутреннюю реализацию. Для каждого компонента класса устанавливается область видимости либо явно - указанием уровня доступа одним из ключевых слов public, protected или private с двоеточием, либо неявно - по умолчанию. Указание области видимости относится ко всем последующим компонентам класса, пока не встретится указание другой области видимости. Область видимости public разрешает доступ к компонентам класса из любого места программы, в котором известна переменная этого класса. Такие компоненты называются общедоступными. Область видимости private разрешает доступ к компонентам класса только из методов этого класса. Компоненты, определенные с такой областью видимости, называются частными. Область видимости protected определяется для защищенных компонент, она имеет смысл только в иерархической системе классов и разрешает доступ к компонентам этой области из методов производного класса. В теле класса каждое из ключевых слов public, protected, private может указываться неоднократно, то есть в классе может быть два и более блока общедоступных компонент, частных компонент, защищенных компонент. По умолчанию для всех компонент класса типа struct принимается область видимости public, но можно явно задавать и другие уровни доступа, область видимости к компонентам класса типа class по умолчанию private, явно можно определять и другие уровни, для класса типа union область видимости public и не может быть изменен. // Определение областей видимости компонент для класса, объявленного через struct struct MyClass { … //здесь определяются общедоступные компоненты класса private: 24 … //здесь определяются частные компоненты класса protected: … //здесь определяются защищенные компоненты класса public: … //здесь определяются общедоступные компоненты класса }; //Определение областей видимости компонент для класса, объявленного через class class MyClass { … //здесь определяются частные компоненты класса protected: … //здесь определяются защищенные компоненты класса public: … //здесь определяются общедоступные компоненты класса private: … //здесь определяются частные компоненты класса }; Изменим тело класса array так, чтобы он полностью удовлетворял требованиям принципа инкапсуляции. //Листинг 10.Переопределение класса «массив целых чисел» с ограничением прав доступа к //внутренней реализации объекта class array { int *mas, n; public: array(int k) {…} //конструктор и деструктор должны быть общедоступными. ~array() {…} // Иначе нельзя будет создать и уничтожить объект класса ReadMas() {…} WriteMas(){…} }; main() {array m(5); m.n=10; //Ошибка!!! Компонент n частный и доступ к нему из функции main запрещен m.ReadMas(); // Метод RadMas – общедоступный, что допускает обращение к нему из // внешней функции main() } После определения класса приведенным в последнем примере способом, обращение к компонентам n и mas разрешено только из методов класса. Функция main() не является методом класса и обращаться в ней к частным компонентам класса запрещено, что и отмечено в примере. Попытка откомпилировать данный пример приведет к ошибке на этапе компиляции программы «array::n is not accessible» 25 В языке С++ существует возможность обращения к частным компонентам класса из функций, не являющихся методом данного класса, такие функции называются дружественными, подробнее речь о них пойдет в главе 3.6. 3.4. Определение компонентных функций класса Компонентные функции класса могут быть определены как в теле класса (такой способ использован во всех приведенных примерах), так и за телом класса на внешнем уровне программы. Для методов класса, определенных внутри тела класса, компилятор применяет модификатор inline, то есть объявляет такие методы встраиваемыми (подставляемыми) функциями. Таким образом, на эти функции накладываются ограничения, аналогичные ограничениям для inline-функций (не могут содержать циклов, оператор-переключатель и оператор безусловного перехода, не могут быть рекурсивными и т.д.). Если метод класса определен в теле класса и не удовлетворяет какому-либо из перечисленных условий (например, содержит оператор цикла for), то компилятор транслирует данный метод как обычную, не подставляемую функцию, при этом выводя предупреждение времени компиляции (в BC++ v5.02 «Functions containing for are not expanded inline»). Для компонентных функций, определенных вне тела класса, никаких ограничений не существует, подставляемыми по умолчанию они не объявляются и предупреждений при их компиляции не возникает. При объявлении метода вне тела класса внутри класса помещается лишь прототип метода, а на внешнем уровне программы вслед за объявлением класса помещается определение функции в виде: тип_функции имя_класса :: имя_метода (список_формальных_параметров) {тело метода} 26 Для класса array на внешнем уровне можно определить функции ReadMas и WriteMas, поскольку они содержат циклы. // Листинг 11. Определение метода класса на внешнем уровне class array {… public: …. void ReadMas(); void WriteMas(); }; void array::ReadMas() { cout<<"Ввод массива"; for (int i=0;i<n;i++) cin>>mas[i]; } Хотелось бы отметить, что определение функций вне тела класса не несет дополнительных возможностей (если не учитывать возникновения некритичных предупреждений времени компиляции). Однако, при внешнем определении методов класса определение класса остается неизменным, а в определения функций можно вносить изменения. 3.5. Статические компоненты классов Для изучения особенностей объявления компонент класса в языке С++ рассмотрим еще один класс, назовем его «клиент банка». В этом классе опишем основные характеристики, важные с точки зрения функционирования некоторой информационной системы по учету клиентов банка, а также основные операции, которые выполняются над данными о клиенте. //Листинг 12. Класс «клиент банка» class client { char *name; //ФИО клиента char numb[10]; //номер счета клиента float value; //сумма на счету float percent; //величина процентной ставки для клиента public: client(char* s="Без имени",char *n="N0000",float x=0) //конструктор 27 { int k=strlen(s); name=new char[k+1]; strcpy(name,s); strcpy(numb,n); value=x; percent=0.1; } void PrintClient() //функция вывода информации о клиенте на экран { cout<<"\nВывод инф-ии о клиенте\n"<< name<<'\t'<<numb<<'\t'<<value; } void ReadClient() //функция ввода информации о клиенте с клавиатуры { cout<<"\nВвод инф-ии о клиенте\n"; cin>>name>>numb>>value; } void ChangePercent(float p) //функция изменения процентной ставки { percent=p; } void Add(float dx) //функция изменения суммы на счету { if (value+dx>0) value+=dx; else cout<<”Нельзя снять такую сумму со счета”; } void AddPercent() //функция вывода начисления процентов на вклад { value*=percent+1; } ~client() { delete [] name;} //деструктор }; main() {client c(“Иванов И.И.”,”N12345”,1000); //определяем объект класса client c.PrintClient(); //выводим информацию л клиенте на экран c.Add(2000); //добавляем на счет клиента 2000 c.PrintClient(); c.ChangePercent(0.05); //изменяем процентную ставку c.AddPercent(); //начисляем проценты на сумму вклада c.PrintClient(); } В последнем примере для класса «клиент банка» определены ряд наиболее существенных характеристик, которые объявлены частными компонентными данными, а также несколько общедоступных методов, позволяющих обрабатывать эти характеристики. Если не принимать в расчет иллюстративный характер приведенного примера, а, следовательно, и недостаточное количество компонентных данных и методов в классе для его практического использования, у класса client 28 есть еще один недостаток, который делает эту программную модель не вполне соответствующей реальному субъекту – клиенту банка. Если обратить внимание на компонент percent, то можно отметить, что процентная ставка в реальности вводится не для каждого клиента по отдельности, а является характеристикой банка и для всех клиентов конкретного банка является одинаковой. В связи с этим необходимо определить компонент percent таким образом, чтобы он хранился в памяти в единственном экземпляре при любом количестве объявленных объектов класса client, а его значение было бы общим для всех объектов. Этого можно добиться, объявив данный компонент статическим. Для этого в теле класса необходимо определить компонент следующим образом: static тип имя_статического_компонентного_данного; Ключевое слово static определяет компонент статическим. Основное свойство статического компонентного данного заключается в том, что память под него выделяется единственный раз в программе, причем еще до определения объектов данного класса. Для выделения памяти под статический элемент данных класса необходимо вне класса на внешнем уровне программы поместить инициализатор статического компонента вида тип имя_класса :: имя_статического_компонентного_данного=выражение; Встретив такой инициализатор, компилятор выделяет память под объект указанного типа данных и заносит в него значение инициализирующего выражения. При создании объектов класса память под статический элемент данных не выделяется, все объекты работают с единственным статическим компонентом. Обращаться к статическому компоненту класса можно как к обычному компоненту через имя уже существующего объекта, словно он является частью этого объекта. При этом необходимо учитывать, что, обращаясь к компонентному данному через имя одного объекта, мы изменяем его значение и для всех остальных объектов того же класса. 29 Так как память под статический элемент данных выделяется еще до определения объектов, существует еще один способ обращения к нему: имя_класса :: имя_статического_компонентного_данного Такой способ используется для изменения значения статического члена данных в тот момент, когда недоступен ни один из объектов класса. Необходимо также отметить, что на статические компоненты класса действуют ограничения по области видимости, задаваемые ключевыми словами public, protected и private в теле класса. Объявим компонент percent класса client статическим. //Листинг 13. Использование статических компонент в класса class client {… public: static float percent; … }; float client::percent=0.1; main() {client::percent=0.2; //работать со статическими компонентными данными можно еще до //определения объектов класса… client c(“Иванов”,”N12345”,200); c.percent=0.05; //…а можно и через имя уже определенного объекта } Статическими могут быть объявлены не только компонентные данные, но и методы класса. Очевидно, что объявление метода класса статическим носит иное назначение, нежели чем компонентного члена данных, поскольку методы класса, как уже говорилось, и так существуют в памяти в единственном экземпляре. Статические методы класса обычно выступают в качестве интерфейса для работы с частными или защищенными статическими компонентными данными. Если, например, объявить компонент percent в классе client частным, то изменять его значение можно будет только с использованием метода ChangePercent. Однако вызвать метод ChangePercent можно только для конкретного объекта класса, тогда как изменять статические компонентные данные можно и без наличия созданных объектов. Статические методы класса позволяют работать со статическими ком30 понентными данными без использования объектов данного класса. Объявляются такие методы в теле класса через ключевое слово static: static тип имя_статического_метода( список_формальных_параметров ); Вызывать статическую компонентную функцию класса можно, используя имя уже определенного объекта, а также через имя класса: имя_класса :: имя_статического_метода ( список_фактических_параметров ) Рассмотрим пример с классом «клиент банка». //Листинг 14. Использование статических компонент в класса class client {… static float percent; public: static void ChangePercent(float p) {percent=p; } … }; float client::percent=0.1; main() { //client::percent=0.2; Такое обращение запрещено, т.к. percent- частный компонент client::ChangePercent(0.1); client c(“Иванов”,”N12345”,200); c.ChangePercent=0.05; } 3.6. Дружественные функции Для разделения интерфейса класса и его внутренней реализации различным компонентам класса присваивается разная область видимости с использованием ключевых слов public, protected и private. Компоненты, объявленные в секции private являются частными, то есть недоступными из окружения объекта, и работать с такими компонентами могут только компонентные функции класса. Однако, зачастую возникает необходимость обращаться к частным компонентам объекта из функций, не являющихся методами класса. Это, конечно, происходит не от желания программиста нарушить принцип инкапсуляции и обеспечить доступ извне к 31 внутренней реализации класса. Просто в некоторых случаях необходимо организовать взаимодействие нескольких объектов разных классов, и функция, обеспечивающая взаимодействие, должна иметь доступ к частным компонентам одновременно нескольких объектов. Объявить функцию методом одновременно нескольких классов невозможно, поэтому в стандарте языка С++ предусмотрена возможность объявлять внешнюю по отношению к классу функцию дружественной данному классу. Для этого необходимо в теле класса описать некоторую внешнюю по отношению к классу функцию с использованием ключевого слова friend. friend имя_функции ( список_формальных_параметров); Функция - не член класса, - имеющая доступ к его закрытой части, называется другом этого класса.[Страуструп] Дружественная функция имеет доступ ко всем компонентам класса вне зависимости от их области видимости. Дружественная функция класса может быть обычной внешней функцией программы, а может – компонентной функцией другого класса. Дружественная функция становится расширением интерфейса класса, и этот интерфейс реализует взаимодействие объекта с другими объектами программы. Рассмотрим простой пример. //Листинг 15. Дружественные функции классов class ClassB; class ClassA { int x; … friend void func(ClassA,ClassB); //объявляем функцию дружественной классу ClassA … }; class ClassB { int y; … friend void func(ClassA,ClassB); //объявляем функцию дружественной классу ClassВ … }; void func(ClassA a,ClassB b) { cout <<a.x+b.y;//дружественная функция имеет доступ к частным компонентам обоих // классов 32 } main() { ClassA a; ClassB b; func(a,b); } В последнем примере определены два класса ClassA и ClassB, а также внешняя функция func, объявленная дружественной в обоих классах, благодаря чему func имеет доступ к частным компонентам x и y классов. При этом функция func является внешней по отношению к классу функцией и объекты класса должны либо передаваться ей в качестве параметров, либо объявляться в теле функции. Неявной передачи адреса объекта в дружественную функцию при вызове, как это осуществляется для компонентных функций класса, не происходит. Дружественная классу функция может быть компонентной функцией другого класса. Рассмотрим пример, в котором помимо уже известного класса «клиент банка», определен класс «банк», и организуется взаимодействие этих классов. //Листинг 16. Дружественные функции из других классов. #include <iostream.h> #include <conio.h> #include <string.h> class bank; class client { char * name; char numb[10]; float value; char BankName[20]; //название банка, в котором хранится вклад static float percent; public: client(char* ,char *,float); void PrintClient() { cout<<"\nВывод инф-ии о клиенте\n"<< name<<'\t'<<numb<<'\t'<<value;} void ReadClient() { cout<<"\nВвод инф-ии о клиенте\n"; cin>>name>>numb>>value;} static void ChangePercent(float p) { percent=p; } void Add(float dx) { value+=dx; } void AddPercent() { value*=percent+1; } ~client() { delete [] name;} void SetBank(bank&); //функция записи клиента в список клиентов банка }; float client::percent=0.1; 33 client::client(char* s="Без имени",char *n="N0000",float x=0) { int k=strlen(s); name=new char[k+1]; strcpy(name,s); strcpy(numb,n); value=x; } //класс «банк» class bank { int count; //количество клиентов банка char Name[20]; //название банка client *spisok[10]; //массив клиентов банка public: bank(char *bankName){strcpy(Name,bankName);count=0;} //конструктор friend void client::SetBank(bank &); //дружественная функция из другого класса void PrintAll(); //функция вывода на экран информации о клиентах банка ~bank(); //деструктор }; bank::~bank() { if(count) for(int i=0;i<count;i++) delete spisok[i]; } void client::SetBank(bank& b) { b.spisok[b.count]=this; //обращение к частной компоненте spisok класса bank b.count++; //обращение к частной компоненте count класса bank strcpy(BankName,b.Name); //обращение к частной компоненте Name класса bank } void bank::PrintAll() { for (int i=0;i<count;i++) spisok[i]->PrintClient(); cout<<'\n'; } main() { bank SB("Сбербанк"); //создаем объект класса bank int n; do{ cout<<"Введите количество клиентов"; cin>>n; }while(n>10||n<1) client *c; for(int i=0;i<n;i++) { c=new client; //создаем новый объект «клиент» c->ReadClient(); //вводим информацию о клиенте c->SetBank(SB); //записываем клиента в список банка } SB.PrintAll(); //выводим на экран информацию обо всех клиентах банка 34 } В последнем примере определен новый класс bank, который содержит такие компоненты как количество клиентов банка, название банка, список клиентов банка. При этом классу client добавлено новое компонентное данное - название банка, в котором хранится его вклад. Взаимодействие между клиентом и банком устанавливается в функции SetBank, в которой адрес клиента заносится в массив клиентов банка spisok, увеличивается количество клиентов банка в компоненте count, а также для клиента заполняется компонент BankName – название банка, в котором хранится вклад. Таким образом, компонентная функция класса client должна иметь доступ к частным компонентам другого класса (класса bank). Для того, чтобы это стало возможным, функция SetBank объявляется как дружественная классу bank: friend void client :: SetBank (bank &); В общем виде объявление выглядит следующим образом: friend тип имя_класса_функции :: имя_функции ( список_форм_параметров ); В функции SetBank для записи адреса объекта типа client в список клиентов банка используется константа this. Константный указатель this может использоваться только в нестатических компонентных функциях класса, причем использовать его необходимо без предварительного определения – он определяется транслятором автоматически. Указатель this хранит адрес объекта, для которого произведен вызов метода класса. Поддержка указателя this осуществляется транслятором путем переопределения компонентных функций класса и их вызовов в программе. На первом этапе преобразования каждая нестатическая функция-член преобразуется в функцию с уникальным именем и дополнительным параметром - константным указателем на объект класса. Затем преобразуются обращения к нестатическим данным-членам в операторах функции-члена. Они переопределяются с учётом нового параметра. В C++ при подобном преобразовании для обозначения дополнительного параметра35 указателя (константного указателя) и постфиксного выражения с операциями обращения для обращения к нестатическим данным-членам используется одно и то же имя this. Вот как могла бы выглядеть функция-член SetBank после её переопределения: void client::client_SetBankl(client const *this, bank& b) { b.spisok[b.count]=this; b.count++; strcpy(this->BankName,b.Name); } На втором этапе преобразуются вызовы функций-членов. К списку значений параметров выражения вызова добавляется выражение, значением которого является адрес данного объекта. Это вполне корректное преобразование. Дело в том, что нестатические функции-члены всегда вызываются для конкретного объекта. И потому не составляет особого труда определить адрес объекта. Например, вызов функции-члена SetBank для объекта c, который имеет вид c.SetBank(SB); после преобразования принимает вид: client_SetBank(&c,SB); Первый параметр в вызове новой функции является адресом конкретного объекта. В результате такого преобразования функция-член приобретает новое имя и дополнительный параметр типа указатель на объект со стандартным именем this и типом, а каждый вызов функции-члена приобретает форму вызова обычной функции. Причина изменения имени для функций-членов класса очевидна. В разных классах могут быть объявлены одноименные функции-члены. В этих условиях обращение к функции-члену класса непосредственно по имени может вызвать конфликт имён: в одной области действия имени одним и тем же именем будут обозначаться различные объекты - одноименные функции- 36 члены разных классов. Стандартное преобразование имён позволяет решить эту проблему. Таким образом, благодаря преобразованиям транслятора, любая нестатическая компонентная функция может использовать в своем теле указатель this. При этом выражение this представляет адрес объекта, вызвавшего функцию, а выражение *this представляет сам объект. В языке С++ для класса кроме дружественной функции можно объявить дружественной класс. Все компонентные функции дружественного класса имеют доступ к частным и защищенным компонентам того класса, в котором он объявлен другом. Для того чтобы предоставить некоторому классу (назовем его класс1) свойства друга другого класса (класс2) необходимо в теле класса2 поместить следующую строку: friend class имя_класса1; После такого определения любая компонентная функция класса1 будет иметь доступ к любой компоненте класса2 независимо от присвоенной ей области видимости. Рассмотрим пример взаимодействия дружественных классов. //Листинг 17. Дружественный класс #include <iostream.h> #include <conio.h> #include <string.h> class bank; class client { friend class bank; //объявляем класс bank дружественным классу client client *next; //адрес следующего клиента в списке клиентов банка char * name; char numb[10]; float value; static float percent; client(char* ,char *,float); void PrintClient() { cout<<"\nВывод инф-ии о клиенте\n"<< name<<'\t'<<numb<<'\t'<<value;} void ReadClient() { cout<<"\nВвод инф-ии о клиенте\n"; cin>>name>>numb>>value;} static void ChangePercent(float p) { percent=p; } void Add(float dx) { value+=dx; } 37 void AddPercent() { value*=percent+1; } ~client() { delete [] name;} }; float client::percent=0.5; client::client(char* s="Без имени",char *n="N0000",float x=0) { int k=strlen(s); name=new char[k+1]; strcpy(name,s); strcpy(numb,n); value=x; } class bank { int count; char Name[20]; client *first; //адрес первого клиента в списке клиентов(клиенты записываются в //динамический список) client *head; //адрес последнего клиента в списке клиентов public: bank(char *bankName) {strcpy(Name,bankName);count=0;first=head=NULL;} void AddClient(); void PrintAll(); ~bank(); }; bank::~bank() //деструктор уничтожает список клиентов банка { if(count) { client *c=first; while(c) { c=first->next; delete first; first=c; } } } void bank::AddClient() //функция добавления нового клиента в список клиентов { if (!count) {first=new client; head=first;head->next=NULL; head->ReadClient(); count=1; } else { head->next=new client; head=head->next; head->ReadClient(); head->next=NULL; count++; }} void bank::PrintAll() //функция вывода информации обо всех клиентах на экран { client *c; c=first; 38 while(c) {c->PrintClient(); c=c->next; }} main() { //client cl; Ошибка! Все компоненты client частные bank SB("Сбербанк"); char c=0; while(c!=27) //цикл до нажатия клавиши ESC { SB.AddClient(); //ввод информации об очередном клиенте c=getch(); } SB.PrintAll(); } В программе вновь определены два класса: bank и client. Класс client хранит информацию о клиенте банка, а также необходимые интерфейсные методы. Класс банк хранит список всех клиентов, хранящих свои вклады в данном банке. Список клиентов оформлен в виде динамического односвязного списка, что позволяет не ограничивать максимально допустимое количество клиентов банка. В связи с этим в классе client появляется новый компонент – next, хранящий адрес следующего клиента в списке, а в классе bank – компонентные данные first и head, хранящие адрес первого и последнего клиента в списке. Соответственно, функции класса bank создают, просматривают, уничтожают динамический односвязный список объектов класса клиент. При этом класс bank должен быть обязательно дружественен классу client, поскольку методы AddClient, PrintAll и деструктор класса bank работают с частными компонентами класса client (в частности, с компонентами next, ReadClent, PrintClient, конструктором и деструктором). Необходимо обратить внимание на то, что в последнем примере все компоненты класса client (и компонентные данные, и методы класса, и даже конструктор с деструктором) являются частными. Таким образом, в любой внешней по отношению к классу функции (например, в функции main) запрещен доступ к компонентам класса client, более того, запрещено даже создание объекта данного класса в main, поскольку определе39 ние объекта сопровождается вызовом конструктора, а он в данном примере также объявлен частным. В такой ситуации единственным способом работы с объектами класса client является использование дружественных функций или дружественных классов. 3.7. Перегрузка операций Классы вводят в программу новые пользовательские типы данных. Такие типы могут использоваться наравне со стандартными ( базовыми): они могут использоваться для определения новых переменных, могут входить в списки параметров функций и определять тип возвращаемого значения. В условиях фактического равноправия производных и основных типов данных должна существовать возможность сохранения привычной структуры выражений при работе с данными производных типов. Это означает, что выражение для вычисления суммы двух слагаемых уже известного нам типа array по своей структуре не должно отличаться от соответствующих выражений для слагаемых типа int или float. Но большинство операций языка C++ определены лишь для основных типов данных. Использование в качестве операндов операций выражений производных типов вызывает ошибки трансляции. int a,b,c; … c=a+b; // использование операндов базовых типов для операции сложения разрешено array m1(4),m2(4),m3(4); … // m1=m2+m3; ошибка!!! использование операндов производных типов для //операции сложения запрещено И всё же возможность сохранения привычной структуры выражений для производных типов в C++ существует. Для этого в программе необходимо перегрузить операцию для нестандартного типа данных. Перегрузка операции заключается в 40 определении специальной функции, которая будет вызываться при использовании операции языка С++ с нестандартными типами данных. Функция, перегружающая операцию в С++ называется «операцияфункция» и должна выглядеть следующим образом: тип operator знак_операции (список формальных_параметров) {тело_функции } Здесь тип – тип возвращаемого функцией значения, operator- ключевое слово, определяющее, что данная функция перегружает операцию, знак_операции – знак той операции языка С++, которую перегружает данная функция. Количество параметров функции определяется количеством операндов у соответствующей операции, а также способом определения функции. Операция-функция может быть как компонентной функцией некоторого класса, так и внешней функцией. Рассмотрим пример перегрузки операций для класса array2 (двумерный массив целых чисел). //Листинг 18. Перегрузка операций для класса «двумерный массив целых чисел» #include <iostream.h> #include <conio.h> class array2 { int **mas; //указатель на массив int n,m; //количество строк и столбцов public: array2(int,int); //конструктор void ReadMas(); //ввод массива с клавиатуры void WriteMas(); //вывод элементов массива на экран ~array2(); //деструктор operator int(); //перегрузка операции приведения типа friend array2& operator*(array2&,array2&); //дружественная функция перегрузки //операции умножения void operator=(array2&); //перегрузка операции присвоения }; array2::array2(int a,int b){n=a;m=b; //динамически выделяем память под двумерный массив mas=new int *[n]; for (int i=0;i<n;i++) mas[i]=new int[m]; } void array2::ReadMas(){ 41 cout<<"Введите массив \n"; for (int i=0;i<n;i++) for (int j=0;j<m;j++) cin>>mas[i][j];} void array2::WriteMas() { for (int i=0;i<n;i++) { for (int j=0;j<m;j++) cout<<mas[i][j]<<'\t'; cout<<'\n'; } } array2::~array2(){for (int i=0;i<n;i++) delete [] mas[i]; delete[]mas; } array2& operator*(array2& m1,array2& m2) //внешняя функция перегрузки операции //умножения { if(m1.m==m2.n) {array2 *pta; int s; pta=new array2(m1.n,m2.m); for(int i=0;i<m1.n;i++) { for(int j=0;j<m2.m;j++) {s=0; for(int k=0;k<m1.m;k++) s+=m1.mas[i][k]*m2.mas[k][j]; pta->mas[i][j]=s; } } return *pta; } else cout<<"Error"; } array2::operator int() { int sum=0; for(int i=0;i<n;i++) for(int j=0;j<m;j++) sum+=mas[i][j]; return sum; } void array2::operator=(array2& m2) { if(n==m2.n&&m==m2.m) for(int i=0;i<n;i++) for(int j=0;j<m;j++) mas[i][j]=m2.mas[i][j]; else cout<<"Error"; } main() { array2 m(2,3),m1(3,4),m2(2,4); m.ReadMas(); 42 m1.ReadMas(); m2=m*m1; //перемножаем массивы по правилу перемножения матриц m2.WriteMas(); int c=int(m2); //получаем сумму элементов массива cout<<c; getch(); } В приведенном примере для класса «двумерный массив целых чисел» перегружены три операции: операция умножения ‘*’, выполняющая умножение массива по правилам перемножения матриц, операция приведения типа ‘int()’, которая применительно к массиву вычисляет сумму элементов массива, а также операция присвоения ‘=’, которая поэлементно копирует содержимое одного массива в другой. Рассмотрим подробнее операцию-функцию operator * . Данная операция-функция определена как внешняя по отношению к классу функция с правами друга, так как она работает с частными компонентами класса n, m, mas. Функция имеет два параметра – это операнды операции умножения. После такого объявления функции любое использование в программе выражения типа a*b при условии, что а и b являются объектами класса array2, приводит к вызову operator *(a,b). Подобный вызов операции-функции можно непосредственно поместить в текст программы, но он уступает в понятности и наглядности использованию обычной операции умножения. Таким образом, при определении операции-функции как внешней функции количество ее параметров должно совпадать с арностью перегружаемой операции, поскольку операнды перегруженной операции становятся фактическими параметрами соответствующей операции-функции. Для перегрузки операции приведения типа ‘int()’ определена операцияфункция как метод класса array2. При подобном определении один из операндов операции становится тем объектом, для которого вызывается операцияфункция, а остальные операнды (если они есть) передаются как фактические параметры. Таким образом, для вызова операции приведения типа с параметром – объектом класса array2 в последнем примере: 43 int(m2) будет вызвана операция-функция в следующем варианте: m2.operator int() Если бы операция умножения была перегружена методом класса array2 , а не внешней функцией, то для нее необходимо было бы определить лишь один формальный параметр – через него в функцию передавался бы лишь второй операнд операции умножения. Первый операнд в этом случае становится тем объектом, для которого вызывается операция функция: //Листинг 19. Определение операции-функции как метода класса class array2 { … array2& operator*(array2& m2) //функция перегрузки операции //умножения как метод класса { if(m==m2.n) {array2 *pta; int s; pta=new array2(n,m2.m); for(int i=0;i<n;i++) { for(int j=0;j<m2.m;j++) {s=0; for(int k=0;k<m;k++) s+=mas[i][k]*m2.mas[k][j]; pta->mas[i][j]=s; } } return *pta; } else cout<<"Error } … }; main() { array2 m(2,3),m1(3,4),m2(2,4); … m2=m*m1;//можно записать в виде m2=m.operator *(m1) } Перегрузка операции присваивания необходима для класса array2 в связи с тем, что обычное копирование компонентных данных из одного объекта в другой, которое производит операция присваивания по умолчанию, не подхо44 дит для поставленной задачи. Если написать выражение m1=m2, не переопределяя операцию присваивания, то компонент mas одного объекта будет скопирован в компонент mas другого, что приведет к использованию обоими объектами одного и того же динамического массива в дальнейшем. Переопределив же операцию присваивания, мы при присваивании объектов копируем элементы одного динамического массива в другой. При перегрузке операций существует ряд ограничений: нельзя перегружать некоторые операции (‘.’, ’?:’, ’::’, ‘sizeof’, ‘##’, ‘#’, ‘.*’), нельзя вводить новые знаки операций, нельзя изменять приоритеты операций, для некоторых операций (‘=’,’[]’,’->’) операцию-функцию можно определять только как нестатическую компонентную функцию класса. 4. Наследование классов 4.1. Повторное использование классов: наследование и агрегирование Наследование - один из основополагающих принципов объектноориентированного программирования. Под наследованием понимают возможность объявления производных типов на основе ранее объявленных типов. Свойство наследования присуще только классам. Как известно, в C++ существует фиксированное множество элементарных типов. Это абсолютно независимые типы и объявление одного элементарного типа на основе другого в принципе невозможно. Спецификации объявления unsigned int или long double нельзя рассматривать как модификации элементарных типов int и double. Это полноправные элементарные типы данных со своим собственным набором свойств. В C++ 45 также невозможно определить одну функцию на основе другой ранее определённой. Для класса в C++ реализуется возможность наследования. Наследование прежде всего является эффективным механизмом повторного использования классов, когда новые классы строятся при необходимости на базе уже существующих, а не с нуля. При этом необходимо различать понятия наследования и агрегирования. Наследование отражает отношения между классами «это есть». Примеры наследования: автомобиль есть транспортное средство, клиент банка есть человек, прямоугольник есть геометрическая фигура. Таким образом, при наследовании базовый и производный классы выступают как, соответственно, обобщение и конкретизация некоторого объекта реального мира. В случае наследования новый класс в буквальном смысле создаётся на основе ранее объявленного класса, наследует, а возможно и модифицирует его данные и функции. Объявленный класс может служить основой (базовым классом) для новых производных классов. Производные классы наследуют данные и функции своих базовых классов и добавляют собственные компоненты. Агрегирование предполагает возможность объявления в классе отдельных членов класса на основе ранее объявленных классов. Таким образом, агрегирование отражает отношение между классами “быть частью”. Примеры агрегирования: двигатель есть часть автомобиля, лепесток есть часть цветка, цветок есть часть растения. Ранее мы уже встречали примеры агрегирования классов – когда класс bank содержал список объектов класса client. При агрегировании классов агрегирующий класс также как и при наследовании получает возможность доступа к компонентным данным и методам агрегируемого класса (безусловно, с ограничениями, накладываемыми их областями видимости), но эти данные и методы не становятся собственностью объектов этого класса. Агрегируемый класс остается автономным объектом, что накладывает ряд ограничений на права доступа к его внутренней реализации (например, 46 при наследовании защищенные компоненты базового класса доступны в производном, а при агрегировании – нет). Возможность повторного использования классов важна не только и зачастую не столько из-за возможности уменьшения размера исходного текста программ. Построение систем классов с использованием механизмов наследования и агрегирования позволяет точнее описать в программе предметную область поставленной задачи, быстрее модифицировать код программы при необходимости, ускорить процесс проектирования и программирования. Любое понятие предметной области не существует изолированно, оно существует во взаимосвязи с другими понятиями, и мощность данного понятия во многом определяется наличием таких связей. Раз класс служит для представления понятий, встает вопрос, как представить взаимосвязь понятий. Понятие производного класса и поддерживающие его языковые средства служат для представления иерархических связей, иными словами, для выражения общности между классами. Например, понятия окружности и треугольника связаны между собой, так как оба они представляют еще понятие фигуры, то есть содержат более общее понятие. Чтобы представлять в программе окружности и треугольники и при этом не упускать из вида, что они являются фигурами, надо явно определять классы «окружность» и «треугольник» так, чтобы было видно, что у них есть общий класс – «фигура». Это можно сделать, объявив класс «фигура» базовым, а классы «окружность» и «треугольник»- унаследовать от него. 4.2. Объявление наследования классов в С++ Определение класса, наследуемого от некоторых, уже существующих классов, производится следующим образом: class имя_класса: список_базовых_классов 47 {//определение собственных компонент //переопределение унаследованных компонент базовых классов }; Здесь список_базовых_классов – это перечень (через запятую) тех классов, от которых будет унаследован определяемый класс. Эти классы к моменту определения производного класса должны быть определены. После подобного объявления все общедоступные и защищенные компоненты базовых классов становятся компонентами производного класса без дополнительного определения. В некоторых источниках базовый класс называют суперклассом, а производный подчиненным классом. Общие правила порождения классов: 1) количество базовых классов в списке порождения может быть любым; 2) один и тот же класс не может быть задан в списке порождения дважды; 3) базовый класс к моменту определения производного должен быть опре- делен или описан; 4) ни базовый, ни порожденный класс не могут быть определены с помо- щью ключевого слова union; Рассмотрим использование механизма наследования в С++ на конкретном примере. Определим классы А, В и С, находящиеся в отношениях наследования: //Листинг 20. Пример простого наследования классов struct A {private: int a1; public: int a2; void funcA() }; struct B:A //наследуем класс В от А {private: int b1; public: int b2; void funcB() 48 }; struct C:B //наследуем класс С от В {Iprivate: int c1; public: int c2; void funcC() }; В приведенном примере класс С унаследован от класса В, а тот в свою очередь унаследован от класса А. При наследовании различают прямые и косвенные базовые классы. Прямой базовый класс упоминается в списке баз производного класса. Косвенным базовым классом считается класс, который является базовым для одного из классов, упомянутых в списке прямых баз данного производного класса. Класс А является прямым базовым классом для В и косвенным базовым (непрямым базовым) для С. Для удобства анализа иерархических систем классов, их отображают графически с использованием направленных ациклических графов, в вершинах которых располагаются названия классов, а ребра отображают отношения классов «базовый»-«производный», причем стрелка всегда направлена от производного класса к базовому. В результате сделанных в программе объявлений получаем структуру А В С Рисунок 3.- Пример графического отображения иерархии классов класса С, отображенную на рисунке 4. 49 A() int a2 funcA() B() int b2 funcB() C() int c1 int c2 funcC() Рисунок 4. - Структура производного класса при наследовании Класс С можно разделить на 3 части – часть, косвенно унаследованную от А, часть, унаследованную от В, а также собственные компоненты класса С. Соответственно, структура класса В состоит из двух частей – унаследованной от А и собственных компонент класса. Объект-представитель класса C является единым блоком объектов и включает собственные данные-члены класса C, а также данные-члены классов B и A. При создании объектов класса С в памяти будет выделяться 8 байт под компонентные данные объекта (4 компонента типа int). Для объектов класса С будут доступны методы базовых классов, при создании этих объектов будут вызываться конструктор как непосредственно класса С, так и объектов его базовых классов. При этом вызов конструкторов строго регламентирован – сначала вызываются конструкторы базовых классов, затем – конструкторы агрегированных в класс объектов (объектов других классов, которые являются компонентами данного класса) и в последнюю очередь – конструктор производного. Если конструкторы базовых классов имеют формальные параметры, то при определении конструктора производного класса необходимо предусмотреть вызов конструкторов базовых классовых с необходимыми фактическими параметрами: конструктор (список_форм_параметров) : конструктор_базового_класса_1 (спи-сок_факт_параметров), … , конструктор_базового_класса_n (список_ факт _параметров) {тело_конструктора} 50 При вызове деструктора производного класса вызываются деструкторы всех базовых классов, причем вызов производится в порядке, обратном вызову конструкторов. Рассмотрим содержимое функции funcC: void C::funcC() {a2=0; //также возможные варианты обращения A::a2=0; B::a2=0; B::A::a2=0; C::a2=0 b2=0; //можно также B::b2=0; C::b2=0; Однако, нельзя A::b2=0 c2=0;// можно также C::c2=0; Однако, нельзя A::c2=0; B::c2=0 funcA(); //можно A::funcA(); B::funcA(); C::funcA() } Последний пример иллюстрирует способы обращения к унаследованным компонентам класса. К ним можно обращаться как к собственным компонентам класса, а можно использовать так называемое квалифицированное имя компонента, которое в общем виде записывается следующим образом: имя_класса :: имя_компонента Использование квалифицированного имени удобно тогда, когда в базовом и производном классах определены одноименные компоненты. Например, если бы в классе А было определено компонентное данное c2, то в функции funcC() выражение с2=5 изменяло бы значение компонента, определенного непосредственно в классе С, а для того, чтобы изменить значение унаследованного от А компонента, необходимо было бы использовать выражение A::c2=5 или B::c2=5. Таким образом, можно сделать вывод о том, что, во-первых, имена компонент, определенных в производном классе могут совпадать с именами унаследованных от базовых классов компонент, а во-вторых, такие одноименные компоненты существуют независимо друг от друга, могут быть одновременно доступны в классе с использованием квалифицированных имен компонент. Квалифицированное имя компонента класса может быть использовано и вне его компонентных функций. Для доступа в таком случае можно использовать выражение вида имя_объекта . имя_класса :: имя_компонента //Листинг 21. Пример использования квалифицированного имени компонент класса из 51 //его окружения: main() { C c; c.C::c2=0; c.B::funcB(); c.A::a2=2; c.C::funcA(); } Поиск компонента при обращении к нему всегда идет "снизу-вверх". Для вызова с.С::funcA() транслятор сначала проверит наличие функции funcA в классе С и, если такой имеется, занесет адрес метода этого класса для вызова. Если в классе С нет метода с таким названием, будет рассмотрен прямой базовый класс для С (в нашем случае – класс В) и поиск метода продолжится в нем. Если метод с таким именем будет найден – его адрес будет помещен на место вызова, иначе – поиск будет продолжен на следующем уровне иерархии классов (в классе А для рассматриваемого примера). Еще одно важное свойство базовых и производных классов иллюстрируется в листинге 22. //Листинг 22. Приведение указателей производного класса к базовому main() {A *pta; C c; pta=&c; pta->funcA(); }. В программе определен указатель на базовый класс A и объект производного класса С. При этом присвоение указателю pta адреса объекта c не потребовало операций приведения типа. Данный пример показывает, что указатель на базовый класс может ссылаться на объекты производных классов. При этом обратное преобразование недопустимо синтаксисом языка. Когда указателю на базовый класс присвоен адрес объекта производного класса, через этот указатель можно обращаться только к той части производного класса, которая унаследована от базового. Ошибку содержит следующий фрагмент: main() {A *pta; 52 C c; pta=&c; // pta->funcС(); Ошибка !!! Указатель pta адресует только ту часть объекта с, которая //унаследована от класса А ((С*)pta)->funcC(); //правильный вызов } Указанное свойство указателей на базовый класс очень удобно, например, при организации массивов разнотипных, но родственных объектов (объектов, имеющих общего предка). Для того чтобы объединить подобные объекты в один массив, необходимо объявить тип элементов массива как указатели на базовый класс. Так, объявив массив A *mas[100]; можно заносить в него адреса объектов классов A, В, С. Более подробно организация подобных массивов будет рассматриваться в главе 4.5. При наследовании классов компоненты базового класса становятся доступны из производного. При этом компонентам базового класса в производном присваивается некоторая область видимости, которая может и не совпадать с областью видимости, заданной для него при определении базового класса. Это иллюстрирует следующий пример. //Листинг 23. Изменение области видимости компонент при наследовании class A {… public: int x; … }; class B: A {… }; main() {A a; B b; a.x=5; //в классе А комп. данное х общедоступное //b.x=1; ошибка!!! В классе B унаследованный от А компонент х – частный } Из последнего примера можно сделать вывод, что один и тот же компонент класса в своем собственном классе и будучи унаследованным в производном классе имеет различную область видимости: компонентное данное, определенное в 53 классе А как общедоступное, в классе В становится частным. Область видимости компонент базового класса в производном при их наследовании зависит от: 1) области видимости компонента в базовом классе; 2) способа определения производного класса (через class или struct); 3) спецификации доступа, указанной в списке базовых классов при объявлении наследования. Пояснения требует третий пункт: при объявлении списка наследования для каждого базового класса можно указать спецификатор доступа в виде уже известных нам ключевых слов public, protected, private. В этом случае список наследования может выглядеть, например, следующим образом: class A: public B, private C, protected D {…}; Правила, по которым изменяются области видимости компонент класса при наследовании, приведены в таблице 1. Таблица 1. Изменение области видимости компонент базового класса в производном Область видимости в базовом классе Спецификатор доступа в списке порождения Область видимости в производном классе производный производный класс объявлен класс объявлен через struct через class public нет public private public public public public public protected protected protected public private private private protected нет public private protected public protected protected protected protected protected protected protected private private private private * не доступны Следующий пример иллюстрирует приведенные в таблице правила трансформации области видимости компонент при наследовании. // Листинг 24. Примеры изменения области видимости компонент при наследовании class A 54 { public: int x; … }; class B {protected: int y; … }; class C {public: int z; … }; class D: public A, private B, C {… }; main() {D d; d.x=5; //компонент х в классе D имеет область видимости public (2-е правило //в таблице 1) //d.y=1; Ошибка!!! компонент y в классе D имеет область видимости private // (8-е правило в таблице 1) //d.z=0; Ошибка!!! компонент z в классе D имеет область видимости private // (1-е правило в таблице 1) } Рассмотрим пример создания и использования иерархии классов с использованием механизма наследования. //Листинг 25. Программа “база данных по учету студентов”, использующая механизм //наследования #include <iostream.h> #include <string.h> class Subject //класс, описывающий свойства некоторого субъекта { protected: char name[20]; //имя субъекта int age; //возраст char adress[30]; //адрес public: void Read(); //функция ввода информации о субъекте с клавиатуры void Write(); //функция вывода информации о субъекте на экран }; class Student:public Subject //класс, описывающий свойства студента { char group[7]; //название группы, в которой учится студент char numb[8]; //номер его зачетной книжки int balls[10]; //оценки, полученные на экзамене static int n; //количество экзаменов в сессию protected: 55 float rait; //рейтинг студента (среднее по баллам, полученным на экзаменах) public: void Exam(); //функция ввода баллов по предметам void CalcRait(); //функция вычисления рейтинга студента void ReadSt(); // функция ввода информации о студенте с клавиатуры void WriteSt(); // функция вывода информации о студенте на экран }; class DayStud:public Student //класс, описывающий свойства студента дневной // формы обучения {int stip; //стипендия студента public: void CalcStip(); //функция вычисления стипендии студента void WriteSt(); //переопределенная функция вывода информации о студенте }; // Определение методов классов void Subject::Read() { cout<<" Введите информацию\n Имя"; cin>>name; cout<<"\n Возраст"; cin>> age; cout<<"\nАдрес"; cin>>adress; } void Subject::Write() { cout<<" Имя "<<name<<" Возраст "<<age<<" Адрес "<<adress;} int Student::n=4; void Student::ReadSt() { Read(); cout<<"\nНомер зач.книжки"; cin>>numb; cout<<"\nГруппа"; cin>>group; } void Student::WriteSt() { Write(); cout<<"Номер зач.книжки "<<numb<<"Группа "<<group<<" Рейтинг "<<rait<<"\n"; } void Student::CalcRait() { rait=0; for(int i=0;i<n;i++) rait+=balls[i]; rait/=n; } void Student::Exam() { for (int j=0;j<n;j++) { cout<<"\nПредмет N"<<j+1; cin>>balls[j]; }} 56 void DayStud::CalcStip() {if (rait>=90) stip=300; else if (rait>=76) stip=200; else stip=0; } void DayStud::WriteSt() { Student::WriteSt(); cout<<"Стипендия"<<stip; } //пример использования определенных выше классов main() { const int m=10; //будем работать с 10-ю студентами int i; DayStud gr[m]; for(i=0;i<m;i++) gr[i].ReadSt(); //вводим информацию о каждом студенте for(i=0;i<m;i++) { cout<<"Экзамены"<<i+1<<" студента"; gr[i].Exam(); //проводим экзамены (вводим информацию о баллах, полученных //каждым студентом на экзаменах } for(i=0;i<m;i++) gr[i].CalcRait(); //вычисляем рейтинг каждого студента for(i=0;i<m;i++) gr[i].CalcStip(); //вычисляем стипендию каждого из студентов for(i=0;i<m;i++) gr[i].WriteSt(); //выводим информацию о каждом студенте на экран } В приведенном выше примере определены три класса. Класс Subject является корнем всей системы классов, в нем объединены свойства и методы, описывающие «субъекта», то есть свойства, присущие каждому человеку: имя, возраст, адрес, и методы, позволяющие обрабатывать эту информацию (вводить с клавиатуры, выводить на экран). От класса Subject порожден класс Student, в котором определены свойства, присущие каждому студенту: номер зачетки, название группы, в которой учится студент, оценки, полученные им на экзаменах, рейтинг студента, вычисленный по результатам сессии. В классе также определен ряд методов, позволяющих изменять перечисленные свойства: вводить с клавиатуры, рассчитывать, выводить на экран. При этом, некоторые методы класса Student вызывают методы, унаследо57 ванные от родительского класса Subject. Так, например, для ввода информации о студенте в классе определена функция ReadSt, в которой непосредственно вводятся с клавиатуры лишь те компонентные данные, которые определены в классе Student. Для ввода значения компонент, унаследованных от Subject (очевидно, что для каждого студента необходимо хранить имя, возраст, адрес) вызывается унаследованный метод Read. Третий класс называется DayStud является конкретизацией класса Student в плане описания свойств студента дневного отделения. В частности, для студента Subject Student DayStud Рисунок 5.- Иерархия классов программы ”база данных по учету студентов”. дневного отделения определено компонентное данное stip (стипендия), значение которого вычисляется в компонентной функции этого же класса CalcStip в зависимости от текущего рейтинга студента. Схема иерархии классов программы изображена на рис.5. Может показаться несущественным отличие класса DayStud от класса Student, и возникнуть желание объединить их в одном классе. Однако, предложенная схема иерархии классов позволяет легко модифицировать программу, добавлять в нее новые классы, отличающиеся от уже определенных небольшими деталями реализации без значительных усилий со стороны программиста. Так, например, можно определить класс EvnStud , описывающий студента-вечерника просто унаследовав его от класса Student, так как все компоненты этого класса в полной мере относятся и к студентам вечерней формы обучения. При этом в класс EvnStud 58 можно добавить некоторые компонентные данные, присущие только студентамвечерникам (например, место постоянной работы). Можно пойти дальше и определить класс Teacher, описывающий преподавателя, и опять этот класс может появиться не на «ровном месте», а быть унаследован от класса Subject, так как все перечисленные для «субъекта» свойства и методы имеют отношение и к преподавателям. Возможная схема иерархии спроектированной нами (хоть и поверхностно) информационной системы ВУЗа приведена на рис. 6. Таким образом, посредством механизма наследования строятся гибкие си- Subject Student Teacher EvnStud DayStud Рисунок 6. -Возможная иерархия классов для программы ”информационная система ВУЗа ”. стемы классов, которые становятся мощным инструментом программиста, предоставляя ему готовые программные «кирпичи» для строительства программы, а также позволяя создавать собственные классы на основе уже имеющихся с некоторыми особенностями функционирования, с оригинальным содержимым. При проектировании систем классов, подобных приведенной на рис.6, необходимо помнить о природе отношения наследования. Для всех приведенных классов это отношение соблюдается – мы можем сказать, что студент и преподаватель – это субъекты, а студент-дневник и студент-вечерник являются студентами, то есть описываемые классами сущности вступают в отношения обобщенияконкретизации. Однако, если мы захотим добавить в программу класс Group, описывающий учебную группу, то в отношение наследования с классом Student такой класс вступить не сможет: мы не можем сказать, что группа-это студент. Класс Student правильно будет агрегировать в класс Group (напомним – для агрегирова59 ния отношения классов проверяются словосочетанием «состоит из»: группа состоит из студентов). Отношения классов Group и Student похожи на отношения классов client и bank, рассмотренных в листингах 16 и 17, и правильным будет включить в класс Group массив или динамический список объектов класса Student. 4.3. Множественное наследование Как уже отмечалось, в С++ производный класс может быть порождён из любого числа непосредственных базовых классов. Наличие у производного класса более чем одного непосредственного базового класса называется множественным наследием. Синтаксически множественное наследование отличается от единичного наследования списком порождения, состоящим более чем из одного класса. //Листинг26. Пример множественного наследования class A {int a1; public: int a2; void funcA() }; class B {int b1; public: int b2; void funcB() }; class C: public A, public B //наследуем класс С от A и B {int c1; public: int c2; void funcC() }; 60 Схема иерархии классов, определенных в последнем примере, изображена на рис.7 A B С Рисунок 7. - Множественное наследование классов Структура объекта класса будет аналогична изображенной на рис.4. Однако, если в списке базовых классов поменять местами объявление классов А и В, то есть определить класс С следующим образом: class C: public B, public A { … }; то порядок следования компонент в объекте класса С изменится – в младших адресах будут располагаться компоненты объекта класса В, затем – объекта класса А. При множественном наследовании один и тот же класс не может быть дважды указан как прямой базовый, однако, косвенным базовым классом один и тот же класс может быть и более одного раза. class A {public: int x; void funcA(); …}; class B: public A {…}; class D: public A{…}; class C: public B, public D {…}; 61 А А В D С Рисунок 8. - Множественное наследование с дублированием косвенного базового класса Дублирование косвенного базового класса приводит к включению в производный класс нескольких объектов базового класса. Для класса С в последнем примере это означает, что компонентное данное x будет существовать в объектах данного класса в двух экземплярах – один унаследован через класс В, другой – через класс D. Структура объекта класса С изображена на рис. 9. объект класса А, унаследованный через класс В int x объект класса В объект класса А, унаследованный через класс D int x объект класса D объект класса С Рисунок 9. - Структура производного класса при множественном наследовании с дублированием косвенного базового класса. При множественном наследовании зачастую возникает проблема неоднозначности при доступе к дублирующимся компонентам класса: неясно, какой из одноименных компонент изменится при следующем обращении main() { C c; c.x=6; // Ошибка!!! 62 } Попытка доступа к члену данных x для объекта с приводит к ошибке транслятора “Member is ambiguous A::x and A::x”. Эта ошибка означает, что транслятор не может определить, какому из двух компонент x класса необходимо присвоить новое значение. Неразрешимыми именами для транслятора будут также следующие с.C::x и c.A::x. Решением проблемы является использование квалифицированных имен компонент с использованием имен классов B и D. Для транслятора однозначно различаются следующие имена компонент: с.B::x (компонента, унаследованная через класс В) и c.D::x (компонента, унаследованная через класс D). Рассмотрим пример программы, реализующей множественное наследование. В программе реализованы класс Window, описывающий окно в текстовом режиме, и класс Text, описывающий буфер для хранения текстовой информации, а на их основе определен класс WinText, описывающий окно в текстовом режиме с возможностью отображения в нем текста. //Листинг 27. Программа, использующая множественное наследование классов #include<stdio.h> #include<string.h> #include <conio.h> class Window //класс «окно в текстовом режиме» { protected: int x,y; //координаты левого верхнего угла окна int dx,dy; //размеры окна int color,backcolor; //основной и фоновый цвета окна public: Window(int xb=10, int yb=10, int a=60,int b=20) //конструктор { x=xb;y=yb;dx=a;dy=b; backcolor=1;color=15;} void draw(); //функция изображения окна на экране }; void Window::draw() { textbackground(backcolor); for(int i=x;i<x+dx;i++) for (int j=y;j<y+dy;j++) { gotoxy(i,j); cprintf(" "); }} 63 class Text //класс «текстовый буфер» { protected: int n,UsedN; //n-количество строк в буфере, UsedN – количество реально используемых //строк буфера char **str; //указатель на начало буфера в памяти public: Text(int ); //конструктор ~Text(); //деструктор void Read(char *filename); //функция чтения информации из файла в буфер void Write(); //функция вывода информации из буфера на экран }; Text::Text(int k) //конструктор динамически выделяет память под k строк… { n=k;UsedN=0; str=new char*[n]; for(int i=0;i<n;i++) str[i]=new char[80]; //…в каждой строке 80 символов } Text::~Text() //деструктор освобождает динамическую память из под буфера { for(int i=0;i<n;i++) delete [] str[i]; delete [] str; } void Text::Read(char * filename) { UsedN=0; FILE * fp; if ((fp=fopen(filename,"r"))!=NULL) //открываем файл { while(!feof(fp)&&UsedN<n) //пока не конец файла или не заполнен весь буфер… { fgets(str[UsedN],80,fp); //считываем очередную строку из файла в буфер UsedN++; } for(int i=0;i<UsedN;i++) for (int j=0;j<80;j++) if (str[i][j]=='\n') {for(int k=j;k<80;k++) str[i][k]=' '; //пробелами заполняем неиспользуемую часть буфера break;} fclose(fp); } else {strcpy(str[0],"Ошибка открытия файла"); //если открыть указанный файл //не удалось записываем информацию об этом в буфер for(int k=strlen(str[0]);k<80;k++) str[0][k]=' '; UsedN=1; }} void Text::Write() //функция постраничного вывода информации из буфера на экран {if (UsedN) {clrscr(); int i=0,ii=1; while(i<UsedN) 64 {for(int j=1;j<80;j++) {gotoxy(j,ii); printf("%c",str[i][j-1]); } ii++;i++; if (ii==25){getch(); clrscr(); ii=1;} } getch(); }} class WinText:public Window ,public Text //класс «окно для отображения текста» { int DeltaX,DeltaY; //величина прокрутки текста в окне по вертикали и горизонтали public: WinText(int numb=25,int xb=10,int yb=10,int a=60,int b=20): //конструктор Window(xb,yb,a,b),Text(numb) //вызов конструкторов базовых классов {DeltaX=0;DeltaY=0;} void draw(); //переопределяем функцию отображения текста так, чтобы текст //отображался в окне char Control(); //функция, реализующая реакцию на нажатия клавиш }; void WinText::draw() { wind::draw(); textcolor(color); for(int i=0;i<dy&&i+DeltaY<UsedN;i++) for (int j=0;j<dx;j++) {gotoxy(j+y,i+x); printf("%c",str[i+DeltaY][j+DeltaX]); //отображаем текст в окне с учетом прокрутки }} char WinText::Control() { char ch; ch=getch(); if (!ch) { ch=getch(); switch(ch) //обработка нажатия клавиш-стрелок {case 72:if (DeltaY>0) {DeltaY--;draw();}break; case 80:if (DeltaY<UsedN){DeltaY++;draw();}break; case 75:if (DeltaX>0) {DeltaX--;draw();}break; case 77:if (DeltaX<80-dy) {DeltaX++;draw();} }} return ch; } main() {textbackground(0); clrscr(); WinText w(50,2,2,10,15); //определяем объект – окно с буфером на 50 строк // размером 10 на 15 с координатами верхнего левого угла 2,2 w.Read("lect9.cpp"); //считываем в буфер объекта содержимое файла lect9.cpp w.draw(); //отображаем содержимое буфера в окне 65 while(w.Control()!=27); //пока не нажата клавиша ESC, просматриваем текст в окне } 4.4. Виртуальные классы В некоторых случаях дублирование компонент непрямого базового класса необходимо устранить. Например, перед нами стоит задача определить классы, описывающие поведение шахматных фигур. //Листинг 27. Определение классов «шахматные фигуры» class Figure //класс «фигура» { protected: int hor; //позиция фигуры по горизонтали char vert; //позиция фигуры по вертикали int color; //цвет фигуры public: Figure(char x, int y, int z) //конструктор : vert(x), hor(y), color(z) {} }; class Castle : public Figure //класс ладья { public: Сastle(char x, int y, int z): Figure (x, y, z) //конструктор {} int Move(char x, int y) //функция, реализующая ход ладьи на поле [x y ] { if ( ((x == vert) && (y != hor)) || ((x != vert) && (y == hor))) { hor = y; vert = x; return 1; } return 0; } }; class Bishop : public Figure //класс слон { public: Bishop(char x, int y, int z): Figure (x, y, z) //конструктор {} 66 int Move(char x, int y) { if (abs((x - vert) == abs(y - hor)) && (x != vert)) { vert= x; y=hor; return 1; } return 0; } //функция, реализующая ход слона на поле [x y ] }; class Queen: public Bishop, public Castle { public: Queen(char x, int y, int z): //конструктор Castle (x, y, z), Bishop (x, y, z) {} int Move(char x, int y) { return Castle::Move(x,y) || Bishop::Move (x,y); }}; В программе определены 4 класса. Класс Figure является абстрактным обобщением свойств всех шахматных фигур, поэтому он содержит такие компонентные данные, как позиция фигуры на доске, определяемая по вертикали буквой vert и по горизонтали цифрой hor, а также цвет фигуры color. Классы Castle и Bishop описывают, соответственно, ладью и слона. Для этих классов определена функция int Move(char x, int y), проверяющая, может ли данная фигура пойти на поле с указанными в параметрах функции координатами, и если может – сделать этот ход. Самым интересным классом является класс Queen, описывающий поведение ферзя. Каждый, кто знаком с правилами шахмат, знает, что ферзь объединяет в себе свойства ладьи и слона (в том смысле, что может ходить как по диагонали, как слон, так и по Figure Figure Bishop Castle Queen Рисунок 10. - Схема иерархии классов 67 программы «Шахматы» вертикали и горизонтали, как ладья). Поэтому класс Queen объявлен потомком двух классов: Castle и Bishop. Таким образом, имеется иерархия классов, изображенная на рис.10. Приведенный пример реализации класса Queen содержит ошибку. Дело в том, что для класса Queen дублируются компонентные данные vert, hor и color, в то время как у реального ферзя всего одна позиция на доске и один цвет. Таким образом, встала задача предотвратить дублирование компонент непрямого базового класса в производном. Решить эту проблему можно, объявив класс Figure виртуальным. Для того, чтобы определить непрямой базовый класс виртуальным, необходимо при объявлении этого класса базовым в списке порождения указать ключевое слово virtual. Спецификатор virtual способствует минимизации структуры производного класса. Главная особенность виртуальных базовых классов - они не тиражируются. Изменим определение классов шахматных фигур. class Figure {…}; class Castle: public virtual Figure {…}; class Bishop: public virtual Figure {…}; class Castle: public Castle, public Bishop{…}; Теперь схема иерархии классов выглядит так, как показано на рис.11, и компонентные данные vert, hor, color не будут дублироваться в объектах класса Queen. Figure Вishop Castle Queen Рисунок 11. - При использовании виртуальных классов схема иерархии классов принимает ромбовидную форму 68 При использовании виртуальных классов необходимо обратить внимание на особенность вызова конструкторов базовых классов. Конструктор класса Queen был определен следующим образом: Queen(char x, int y, int z): Castle (x, y, z), Bishop (x, y, z) {} В конструкторе предусмотрен вызов конструкторов прямых базовых классов, которые создают в памяти экземпляры классов Castle и Bishop . В свою очередь, конструкторы классов Castle и Bishop вызывают конструктор своего базового класса (Figure), вследствие чего и создавалось два экземпляра класса Figure. Если класс Figure объявлен виртуальным, конструктор класса Queen необходимо переопределить: Queen(char x, int y, int z): Castle (x, y, z), Bishop (x, y, z), Figure(x,y,z) {} Если Figure – виртуальный класс, то конструкторы классов Castle и Bishop не будут вызывать конструктор класса Figure, а для того, чтобы один экземпляр этого объекта все таки был создан, вызов конструктора класса Figure необходимо поместить непосредственно в определение класса Queen. При множественном наследовании существует возможность определять один и тот же базовый класс как косвенный базовый для другого класса несколько раз, причем и как виртуальный, и как невиртуальный. Рассмотрим следующий пример: class A {…}; class B: virtual public A {…}; class C: virtual public A {…}; class D: public A{…}; class E: public A {…}; class F: public B, public C, public D, public E {…}; Схема иерархии для такой системы классов в графической форме приведена на рис.12. 69 А А E А D C B F Рисунок 12. - Использование одного и того же класса как виртуальной, так и невиртуальной базы В данном случае объект класса F будет включать три экземпляра класса A: один виртуальный, совместно используемый классами B и С, и два невиртуальных, относящихся к классам E и D. 4.5. Виртуальные функции. Полиморфизм Рассмотрим еще один пример. //Листинг 29. Проблема статического связывания функций class Base { public: int func1(int x) {return x*x;} int func2(int x){return func1(x)/2;} }; class Child: public Base {public: int func1(int x) {return x*x*x;} }; main() { Child c; cout<<c.func2(5); //на экран выводится 12 Base *ptb=new Child; cout<<ptb->func1(2); //на экран выводится 4 cout<<c.func1(2); // на экран выводится 8 } В классе Base определены две функции func1 и func2, причем вторая функция вызывает первую. В классе Child переопределена функция func1, а функция func2 просто наследуется. При вызове функций результаты их работы оказываются для многих неожиданными. Так, вызов c.func2(5) дает результат 70 12 вместо ожидаемых 62, а вызов ptb->func1(2) дает результат 4, а не 8. Дело в том, что в обоих случаях будет вызвана функция func1 базового класса, а не переопределенная в производном классе. Такое поведение объектов связано со статическим (ранним) связыванием функций при трансляции программы. Когда транслятор в процессе обработки программы встречает вызов какойлибо функции, то на место вызова он подставляет в текст оттранслированной программы адрес вызываемой функции. Таким образом, компилируя тело компонентной функции func2 класса Base, транслятор на место вызова функции func1 подставит адрес компонентной функции func1 из класса Base, так как только эта функция с подобным именем ему известна (содержимое класса Child транслируется позже). В итоге функция Base::func2 всегда будет вызывать функцию Base::func1, как бы ни был оформлен вызов самого метода func2. Аналогично, компилируя тело функции main и встретив вызов ptb>func1(2), транслятор должен подставить на место вызова адрес функции, которой будет передано управление в данной точке программы. К этому моменту транслятору известны две функции с именем func1: Base::func1 и Child::func1. Так как вызов метода осуществляется для указателя на объект Base, транслятор подставит на место вызова адрес функции именно этого класса (определить, что в указатель ptb записан адрес объекта класса Child, и поэтому вызвать метод Child::func1, транслятор не может). Таким образом, можно сформулировать проблему: необходимо обеспечить полиморфное поведение некоторой функции, определенной в базовом классе и переопределенной в производных. Под полиморфным поведением понимается реализация функции таким образом, чтобы всякий раз при вызове функции вызывалась именно та ее реализация, которая отражает поведение того объекта, для которого осуществлялся вызов. Полиморфное поведение функций обеспечивается поздним связыванием функций, когда определение 71 того экземпляра одноименных функций, которому будет передано управление, определяется на этапе выполнения программы. Для того, чтобы компонентная функция обеспечивала полиморфное поведение, ее необходимо объявить виртуальной. Для этого в определении функции в классе необходимо указать ключевое слово virtual. virtual тип имя_функции (список_формальных параметров) {тело функции } Если мы изменим определение функции func1 , объявив ее виртуальной, поведение объектов программы изменится. //Листинг 30. Использование виртуальных функций class Base {… virtual int func1(int x) {return x*x;} }; class Child: public Base {… virtual int func1(int x) {return x*x*x;} }; main() { Child c; cout<<c.func2(5); //на экран выводится 62 Base *ptb=new Child; cout<<ptb->func1(2); //на экран выводится 8 } В языке С++ позднее связывание реализуется путем поддержки для каждого объекта таблицы виртуальных функций. Таблица виртуальных функций представляет собой массив указателей на реализации виртуальных функций, доступные для данного объекта. Структура объекта при использовании им виртуальных функций, изображена на рис.13. 72 Указатель на таблицу виртуальных методов (vtbl) Компонентные данные объекта Адрес виртуальной функции 1 (&func1) Адрес виртуальной функции 2 (&func2) … Адрес виртуальной функции n (&funcN) Рисунок 13. Структура объекта, использующего виртуальные функции Вызов виртуальной функции в тексте программы транслятор преобразует в обращение к соответствующей строке таблицы виртуальных функций. Предположим, что pObj – это указатель, в который записан адрес объекта, структура которого отображена на рисунке 13. В таком случае вызов виртуальной функции func2 для этого объекта вида pObj->func2() преобразуется транслятором в следующий вызов: (*(pObj->vptr[1])) (pObj) При таком вызове функции нет жесткой привязки вызова к какой-то конкретной реализации компонентной функции в одном из классов, как это происходило при статическом связывании. Теперь будет вызываться та функция, адрес которой записан в элемент с индексом 1 таблицы виртуальных функций объекта. Позднее связывание обеспечивается тем, что заполнение таблицы виртуальных функций объекта происходит уже на этапе выполнения программы. Это делает конструктор, занося в каждую строку таблицы адрес той реализации виртуального метода, который правильно описывает поведение объекта. Указатель на таблицу виртуальных функций обязательно включается в самый "верхний" базовый фрагмент объекта производного класса. В таблицу указателей включаются адреса функций-членов фрагмента самого "нижнего" уровня, 73 содержащего объявления этой функции. Такая дополнительная функциональность конструктора обеспечивается транслятором. Использование позднего связывания не отрицает возможности вызова из производного класса экземпляра виртуальной функции базового. Просто для подобного использования необходимо указывать при вызове полное квалифицированное имя функции. Пример для программы из листинга 30: main() { Base * ptb=new Child; cout<<ptb->Base::func1(2); //на экран выводится 4 } При определении виртуальных функций существует ряд синтаксических особенностей. Если какая-то функция определена в базовом классе как виртуальная, а в производном классе переопределена с тем же прототипом (то есть в производном классе в точности совпадают тип, имя и список параметров компонентной функции), то в производном классе ключевое слово virtual можно опустить – функция останется виртуальной по умолчанию. Если сигнатура (список формальных параметров) функции в производном классе изменится, то полиморфное поведение функций базового и производного классов станет невозможным. Если же в производном классе будет определена компонентная функция с тем же названием и сигнатурой, но с другим типом возвращаемого значения, то транслятор выдаст сообщение об ошибке. Виртуальной функцией может быть только нестатическая компонентная функция класса, глобальная функция программы не может быть виртуальной. Рассмотрим пример программы, использующей виртуальные функции для правильного отображения графических фигур, каждая из которых описывается собственным классом. //Листинг 31. Пример использования виртуальных функций #include <conio.h> #include <iostream.h> #include <dos.h> 74 #include <graphics.h> #include <stdlib.h> class Point { public: int x,y; int color; Point(int xb=0,int yb=0) {x=xb;y=yb;color=15;} }; class Shape { protected: Point base; public: Shape(int xp,int yp) {base.x=xp;base.y=yp; } virtual void show() {putpixel(base.x,base.y,base.color);} void move(int xn,int yn) {setcolor(0); show(); base.x+=xn;base.y+=yn; setcolor(base.color); show(); } }; class Circle:public Shape { int radius; public: Circle(int xc,int yc,int r):Shape(xc,yc) {radius=r;} void show() {circle(base.x,base.y,radius);} }; class Rectangle:public Shape { int dx,dy; public: Rectangle(int xc,int yc,int a,int b):Shape(xc,yc) {dx=a;dy=b;} void show() {rectangle(base.x,base.y,base.x+dx,base.y+dy);} }; main() { int gd=DETECT,gm; initgraph(&gd,&gm,"c:\\borlandc\\bgi"); if (graphresult()==grOk) { Shape *mas[10]; for(int i=0;i<10;i++) {mas[i++]=new Circle(random(600),random(400),random(100)+10); 75 mas[i]=new Rectangle(random(600),random(400),random(100)+10,random(50)+10); } for(i=0;i<10;i++) mas[i]->show(); getch(); for(int j=0;j<3;j++) {for(i=0;i<10;i++) mas[i]->move(random(100)-50,random(100)-50); getch(); } closegraph(); } else { cout<<"error";getch();} } В программе из листинга 31 определены классы Circle и Rectangle, описывающие графические фигуры, окружность и прямоугольник соответственно. Совпадающие свойства этих геометрических фигур вынесены в базовый класс Shape. Этими свойствами являются –базовая точка фигуры base, определяющая местоположение фигуры на экране, цвет фигуры color, метод move, перемещающий фигуру по экрану. Необходимо обратить внимание на реализацию метода move. Для того чтобы переместить фигуру, ее сначала отображаем цветом фона, изменяем координаты базовой точки фигуры, а затем отображаем цветом фигуры. Такая процедура перемещения подходит для работы с любой фигурой -–и окружностью, и прямоугольником, поэтому метод move наследуется классами Circle и Rectangle. Для отображения фигуры метод move использует метод show. В классе Shape метод move просто отображает на экране базовую точку фигуры (вообще то, метод move в классе Shape не должен ничего отображать, поскольку этот класс с точки зрения предметной области является абстракцией, служащей основой для создания производных классов, но об абстрактных классах речь пойдет в следующей главе). В классах Circle и Rectangle метод show отображает соответствующую геометрическую фигуру. Для правильной работы программы метод show должен быть объявлен виртуальным, поскольку вызов метода move для объектов классов Circle и Rectangle требует полиморфного поведения метода show. Необходимо также обратить внимание на использование определенных классов в функции 76 main. Для того чтобы в одном массиве можно было хранить объекты разных (но родственных, то есть имеющих общего предка) классов, объявлен массив из элементов типа указатель на базовый класс (Shape в нашем случае): Shape *mas[10]; Элементы этого массива могут хранить адреса объектов как класса Circle, так и Rectangle. Однако, когда далее в тексте программы для объектов, хранящихся в массиве mas, вызывается метод show: for(i=0;i<10;i++) mas[i]->show(); только полиморфное поведение этого метода обеспечивает правильное отображение геометрических фигур. Если бы метод show не был бы объявлен виртуальным, то для всех объектов был бы вызван метод из класса Shape. 4.6. Абстрактные классы При проектировании иерархических систем классов программисты обычно стремятся выделить некоторые обобщающие свойства некоторой группы описываемых сущностей в базовый класс (в предыдущих главах такими классами были Subject, Figure, Shape). Зачастую созданные по такому принципу классы носят абстрактный смысл, не описывая какой-то реально существующий объект, а лишь являясь некоторой основой, на которой строятся действительно необходимые классы. Это приводит к некоторому несоответствию спроектированной системы классов предметной области: оказывается, что программист может определить в программе объекты, реально к предметной области не имеющие никакого отношения. Для того, чтобы предотвратить возможность использования объектов таких классов в программе, их объявляют абстрактными. Абстрактным называется класс, в котором определена 77 хотя бы одна чистая виртуальная функция. Чистая виртуальная функция определяется следующим образом: virtual тип имя_функции( список_формальных_параметров )=0; Чистая виртуальная функция не имеет реализации, ее нельзя вызвать в программе, она служит лишь как основа для дальнейшего полиморфного переопределения в производном классе. Соответственно, абстрактный класс не может иметь объектов, так как в нем не определены операции над объектами (или, по крайней мере, хотя бы одна операция, реализуемая чистой виртуальной функцией). Рассмотрим пример. //Листинг 32. Использование абстрактных классов #include <stdio.h> #include <iostream.h> #include <string.h> #include <conio.h> class File //абстрактный класс { protected: char **str; //адрес буфера для временного хранения информации из файла char Name[30]; //имя файла int n; //количество строк в буфере virtual int ReadFile()=0; //чистая виртуальная функция чтения информации из файла в буфер public: File(char*,int); ~File(); void display(); //метод, отображающий содержимое буфера на экране }; File::File(char * FileName,int k) //конструктор { strcpy(Name,FileName); n=k; str=new char*[n]; //выделяем память под буфер for(int i=0;i<n;i++) str[i]=new char[80]; } File::~File() //деструктор {for(int i=0;i<n;i++) delete []str[i]; //освобождаем память delete [] str; } void File::display() { clrscr(); int k=ReadFile(); //считываем информацию из файла в буфер 78 for(int i=0;i<k;i++) cout<<str[i]; getch(); // построчно выводим содержимое буфера на экран } struct info //тип информации, хранящейся в файле {char name[20]; char numb[10]; float value; }; class InfoFile:public File //класс «файл с базой данных» { int ReadFile(); //переопределяем функцию чтения информации из файла public: InfoFile(char* st,int k):File(st,k) {} //конструктор вызывает конструктор базового класса void WriteFile(int ); //метод записи информации в файл }; int InfoFile::ReadFile() { FILE *fp; int i=0; info x; if((fp=fopen(Name,"r"))!=NULL) //открываем файл { while(!feof(fp)) //пока не конец файла { fread(&x,sizeof(info),1,fp); //считываем очередную запись из файла sprintf(str[i],”Запись N %d %s %s %f\n",i+1,x.name,x.numb,x.value); //заносим очередную запись в буфер файла i++; } fclose(fp); return i-1; //функция возвращает количество считанных из файла записей } return 0;} void InfoFile::WriteFile(int k) { info x; FILE *fp; if((fp=fopen(Name,"w+"))!=NULL) { for(int i=0;i<k;i++) {cout<<”Введите "<<i+1<<" -ю запись"; cin>>x.name>>x.numb>>x.value; //вводим с клавиатуры очередную запись fwrite(&x,sizeof(info),1,fp); //записываем ее в файл } fclose(fp); }} class HelpFile:public File //класс «файл с помощью» { int ReadFile(); //переопределяем функцию чтения информации из файла public: HelpFile():File("help.dat",25){} //имя файла и его размер фиксированы }; int HelpFile::ReadFile() { int i=0; FILE *fp; 79 if((fp=fopen(Name,"r"))!=NULL) { while(!feof(fp)) {fgets(str[i],80,fp); //считываем содержимое файла в буфер i++; } fclose(fp); return i-1; } return 0;} main() { HelpFile hp; //объект класса «файл с помощью» hp.display(); //выводим содержимое файла на экран InfoFile If("info",40); //создаем объект класса «файл с информацией» If.WriteFile(3); //записываем в файл 3 записи If.display(); //выводим содержимое файла на экран } Программа, приведенная в листинге 32, работает с файлами двух типов – информационным файлом (класс InfoFile), предназначенным для хранения простейшей базы данных – нескольких структур типа info, а также файлом помощи (класс HelpFile), который хранит текстовую информацию, предположительно – File HelpFile InfoFile Рис. 14. Схема иерархии классов программы из листинга 32 справку о самой программе. Общая сущность двух этих типов файлов, выражающаяся в имени файла Name, буфере str для временного хранения информации из файла, размере используемого файлом буфера n, а также метода display вывода считанной из файла информации на экран, выделена в родительский класс (класс File). Схема иерархии классов программы приведена на рис.14. Метод display класса File считывает информацию из файла методом ReadFile в буфер и построчно выводит ее на экран. Однако, метод ReadFile в 80 самом классе File не может быть полноценно определен, поскольку этот класс в терминах предметной области является абстрактной основой для двух других классов (InfoFile и HelpFile) и для него не известен, например, тип хранящейся в файле информации. Метод ReadFile определен в классах InfoFile и HelpFile , причем в первом данный метод считывает из файла информацию в виде экземпляров структуры info, преобразует ее в текстовую форму и записывает в буфер, а во втором - информация непосредственно считывается в виде текстовых строк, которые записываются в буфер str. Таким образом, метод ReadFile не надо определять в классе File по логике представления предметной области, однако обязательно необходимо определить по правилам синтаксиса языка С++ (так как метод display использует этот метод). При этом необходимо обеспечить полиморфное поведение метода ReadFile для того, чтобы при вызове метода display объектом класса InfoFile в теле метода display была вызвана реализация метода ReadFile для соответствующего класса (аналогичная ситуация рассматривалась в листинге 31). Поэтому метод ReadFile объявлен в классе File как чистая виртуальная функция: virtual int ReadFile()=0; Таким образом, класс File является абстрактным классом, для которого запрещено создание объектов, то есть ошибкой будет такое объявление: File MyFile(“file.txt”,50); Класс File может быть только основой для дальнейшего наследования другими классами. Подводя итог, можно сказать, что абстрактные классы используются для спецификации интерфейсов операций (методы, реализующие эти операции впоследствии определяются в производных классах абстрактного класса). Абстрактные классы удобны на фазе анализа требований к системе, так как они позволяют выявить аналогию в различных, на первый взгляд, операциях, определенных в анализируемой системе. 81 Список литературы 1. Буч Г. Объектно-ориентированный анализ и проектирование. - К.: Бином, Спб. 1998 2. Страуструп Б. «Язык программирования С++». – М.;СПб. : «Издательство БИНОМ»-«Невский диалект», 2001г.- 1099с.,ил. 3. Подбельский В.В. «Язык С++: учебное пособие».М., «Финансы и статистика», 2001 4. Легалов А.И. «Разнорукое программирование» www.softcraft.ru, 2001г. 5. Грис Д. Наука программирования. - Г.: Мир. 1994. 6. Марченко А.Л. «C++. Бархатный путь». – М.: Горячая линия - Телеком. –400с 82