/
Текст
МИНОБРНАУКИ РОССИИ
ФЕДЕРАЛЬНОЕ ГОСУДАРСТВЕННОЕ
БЮДЖЕТНОЕ ОБРАЗОВАТЕЛЬНОЕ УЧРЕЖДЕНИЕ
ВЫСШЕГО ОБРАЗОВАНИЯ «ВОРОНЕЖСКИЙ
ГОСУДАРСТВЕННЫЙ УНИВЕРСИТЕТ»
Объектно-ориентированное
программирование
1 часть
Авсеева О.В., Чернышов М.К.
УЧЕБНО-МЕТОДИЧЕСКОЕ ПОСОБИЕ
по направлениям подготовки
Прикладная математика и информатика (01.03.02)
Фундаментальная информатика и информационные технологии
(02.03.02)
ВОРОНЕЖ
2020
Утверждено научно-методическим советом факультета ПММ
(16 ноября 2020 г., протокол № 3)
Составители: Авсеева Ольга Владимировна
Чернышов Максим Карнельевич
Рецензент: д.т.н., доцент кафедры ММИО Бондаренко Ю.В.
Учебно-методическое пособие подготовлено на кафедре математического обеспечения ЭВМ факультета ПММ Воронежского государственного
университета.
Материал основан на источниках, представленных в списке литературы, среди которых основную роль играет [1].
Рекомендуется для студентов 2 курса факультета ПММ, изучающих
курс «Объектно-ориентированное программирование», а также студентов,
специализирующихся по кафедре МО ЭВМ.
2
Предисловие
Целью курса является получение студентами представления об основных принципах технологии объектно-ориентированного программирования
(ООП) с возможностью применения данной технологии при создании программ на языке программирования C++. Основная задача данного курса –
подготовка специалистов, владеющих навыками разработки алгоритмов и
программ с использованием современных методов и средств их создания,
знакомых с современными технологиями программирования и умеющих их
применять при решении сложных прикладных задач. Курс лекций рассчитан
на студентов, имеющих подготовку по информатике и программированию на
языке C++.
1.
Объектно-ориентированный подход
в программировании
1.1 Технологии программирования
Технология программирования — это совокупность методов и средств
разработки программ, а также порядок применения этих методов и средств
на практике.
На ранних этапах развития программирования, когда программы писались в виде последовательностей машинных команд, какая-либо технология
программирования отсутствовала. Первые шаги в разработке технологии состояли в представлении программы в виде последовательности операторов.
Этому предшествовало составление операторной схемы, отражающей последовательность выполнения операторов и осуществление переходов между
3
ними. Операторный подход позволил разработать первые алгоритмы для автоматизации составления программ – в результате появились так называемые
составляющие программы.
С увеличением размеров программ разработчики стали выделять их
обособленные части и оформлять их в виде подпрограмм. Таким образом
было положено начало процедурному программированию - большая программа представлялась совокупностью процедур-подпрограмм, одна из подпрограмм являлась главной, с нее начиналось выполнение программы. Со
временем из наборов уже существующих подпрограмм стали формироваться
библиотеки.
В 1958 году были разработаны первые языки программирования —
Фортран (Fortran) и Алгол-58 (Algol-58). Программа на Фортране состояла из
главной программы и некоторого количества процедур — подпрограмм и
функций. Программа на Алголе-58 и его последующей версии Алголе-60
представляла собой единое целое, но имела блочную структуру, включающую главный блок и вложенные блоки подпрограмм и функций. Компиляторы для Фортрана обеспечивали раздельную трансляцию процедур и последующее их объединение в рабочую программу, первые компиляторы для Алгола предполагали, что транслируется сразу вся программа, раздельная
трансляция процедур не обеспечивалась.
Процедурный подход потребовал структурирования будущей программы, разделения ее на отдельные процедуры. При разработке отдельной
процедуры о других процедурах требовалось знать только их назначение и
способ вызова. Появилась возможность перерабатывать отдельные процедуры, не затрагивая остальной части программы, сокращая при этом затраты
труда и машинного времени на разработку и модернизацию программ.
Следующим шагом в углублении структурирования программ стало
так называемое структурное программирование, при котором программа в
4
целом и отдельные ее процедуры рассматривались как последовательности
канонических структур: линейных участков, циклов и разветвлений. Появилась возможность читать и проверять программу как последовательный
текст, что повысило производительность труда программистов при разработке и отладке программ. С целью повышения структурности программы
были выдвинуты требования к большей независимости подпрограмм, подпрограммы должны связываться с вызывающими их программами только путем передачи им аргументов, использование в подпрограммах переменных,
принадлежащих другим процедурам или главной программе, стало считаться
нежелательным.
Процедурное и структурное программирование затронули прежде
всего процесс описания алгоритма как последовательности шагов, ведущих
от варьируемых исходных данных к искомому результату. Для решения специальных задач стали разрабатываться языки программирования, ориентированные на конкретный класс задач: системы управления базами данных,
имитационное моделирование и так далее. При разработке трансляторов все
больше внимания стало уделяться обнаружению ошибок в исходных текстах
программ, обеспечивая этим сокращение затрат времени на отладку программ.
Применение программ в самых разных областях человеческой деятельности привело к необходимости повышения надежности всего программного
обеспечения. Одним из направлений совершенствования языков программирования стало повышения уровня типизации данных. Теория типов данных
исходит из того, что каждое используемое в программе данное принадлежит
одному и только одному типу данных. Тип данного определяет множество
возможных значений данного и набор операций, применимых к обработке
этих данных. Данное конкретного типа в ряде случаев может быть преобразовано в данное другого типа, но такое преобразование должно быть явно
5
представлено в программе. В зависимости от степени выполнения перечисленных требований можно говорить об уровне типизации того или иного
языка программирования. Стремление повысить уровень типизации языка
программирования привело к появлению языка Паскаль (Pascal), который
считается строго типизированным языком, хотя и в нем разрешены некоторые неявные преобразования типов, например, целого в вещественное. Применение строго типизированного языка при написании программы позволяет
еще при трансляции исходного текста выявить многие ошибки использования данных и этим повысить надежность программы. Вместе с тем строгая
типизация сковывала свободу программиста, затрудняла применение некоторых приемов преобразования данных, часто используемых в системном
программировании. Практически одновременно с Паскалем был разработан
язык Си (C), в большей степени ориентированный на системное программирование и относящийся к слабо типизированным языкам.
Все универсальные языки программирования, несмотря на различия в
синтаксисе и используемых ключевых словах, используют одни и те же канонические структуры: операторы присваивания, циклы и разветвления. Во
всех современных языках присутствуют предопределенные (базовые) типы
данных (целые и вещественные арифметические типы, символьный и, возможно, строковый тип), имеется возможность использования агрегатов данных, в том числе массивов и структур (записей). Для арифметических данных
разрешены обычные арифметические операции, для агрегатов данных
обычно предусмотрена только операция присваивания и возможность обращения к элементам агрегата. Вместе с тем при разработке программы для решения конкретной прикладной задачи желательна, возможно, большая концептуальная близость текста программы к описанию задачи. Например, если
решение задачи требует выполнения операций над комплексными числами
6
или квадратными матрицами, желательно, чтобы в программе явно присутствовали операторы сложения, вычитания, умножения и деления данных
типа комплексного числа, сложения, вычитания, умножения и обращения
данных типа квадратной матрицы. Решение этой проблемы достижимо несколькими путями:
- построением языка программирования, содержащего как можно
больше типов данных, и выбором для каждого класса задач некоторого подмножества этого языка. Такой язык иногда называют языком-оболочкой. На
роль языка-оболочки претендовал язык ПЛ/1 (PL/1), оказавшийся настолько
сложным, что построить его формализованное описание в итоге так и не удалось. Отсутствие формализованного описания, однако, не помешало широкому распространению ПЛ/1;
- построением расширяемого языка, содержащего небольшое ядро и
допускающего возможность дополнения языка типами данных и операторами, отражающими концептуальную сущность конкретного класса задач.
Такой язык называют языком-ядром. Как язык-ядро были разработаны
языки Симула (Simula) и Алгол-68, не получившие широкого распространения, но оказавшие большое влияние на разработку других языков программирования.
Дальнейшим развитием второго пути явился объектно-ориентированный подход к программированию, о котором пойдет речь далее.
1.2 Сущность объектно-ориентированного подхода
к программированию
Основные идеи объектно-ориентированного подхода опираются на
следующие положения:
- программа представляет собой модель некоторого реального процесса, части реального мира;
7
- данная модель может быть описана как совокупность взаимодействующих между собой объектов;
- объект задается набором параметров, значения которых определяют
состояние объекта, и набором операций (действий), которые может выполнять объект;
- взаимодействие между объектами осуществляется посылкой специальных сообщений от одного объекта к другому. Сообщение, полученное
объектом, может потребовать выполнения определенных действий, например, изменения состояния объекта;
- объекты, описанные одним и тем же набором параметров и способные выполнять один и тот же набор действий, представляют собой класс однотипных объектов.
Объект — некая сущность, которая достаточно четко проявляет свое
поведение. Состоит объект из следующих трех частей:
- имя объекта;
- состояние объекта;
- набор действий, выполняемых объектом.
Переменные, описывающие состояние объекта, называются его свойствами, переменными-членами.
Действия (операции), выполняемые объектом, называются его методами, функциями-членами.
Интерфейс объекта (то есть способ его взаимодействия с окружающим миром), полностью определен его методами, поскольку, как правило, к
его состоянию не предоставляется доступ извне, кроме как через методы.
Объект сохраняет свое состояние от обращения к обращению. Изменение состояния производится только через вызов методов этого объекта. Этим
существенно ограничивается возможность введение объекта в недопустимое
состояние и/или несанкционированное разрушение объекта. Возможность
8
управлять состояниями объекта через вызов методов в конечном итоге будет
определять поведение объекта.
Реализация методов (то есть, операций, выполняемых объектом), может быть задана различными способами. Однако это «внутреннее дело» объекта.
Объект может посылать сообщения другим объектам и принимать сообщения от них.
Сообщение является совокупностью данных определенного типа, передаваемых объектом-отправителем объекту-получателю, имя которого указывается в сообщении. Получатель реагирует или не реагирует (защищая таким образом свою целостность) на сообщение выполнением некоторой операции (метода), имя которого также может быть указано в сообщении.
Таким образом, по своему смыслу объект является представителем некоторой реальной сущности — реального объекта, процесса, ситуации, которая:
- поддается хранению и обработке;
- способна воздействовать на другие объекты и вычислительную
среду, посылая сообщения, и реагировать на принимаемые сообщения.
Совокупность объектов в системе ООП образует среду, в которой выполняются вычисления путем обмена сообщениями между объектами. Состояние вычислительной среды оказывается разделенным на состояния объектов, что в принципе отличает объектно-ориентированные вычисления от
вычислений, заданных на процедурно-ориентированных языках.
Объекты с одинаковыми свойствами, то есть с одинаковыми наборами
переменных состояния и методов, образуют класс.
С точки зрения языка программирования класс объектов можно рассматривать как тип данного, а отдельный объект — как переменную этого
9
типа. Определение программистом собственных классов объектов для конкретного набора задач должно позволить описывать отдельные задачи в терминах самого класса задач (при соответствующем выборе имен типов и имен
объектов, их параметров и выполняемых действий).
Каждый класс задается своим описанием, которое включает в себя информацию, необходимую для создания объектов данного класса и для их существования — это информация о переменных состояния и операциях, выполняемых объектом.
Таким образом, объектно-ориентированный подход предполагает, что
при разработке программы должны быть определены классы используемых
в программе объектов и построены их описания, затем созданы экземпляры
необходимых объектов и определено взаимодействие между ними.
Некоторые параметры объекта могут быть локализованы внутри объекта и недоступны для прямого воздействия извне объекта. Например, во
время движения объекта-автомобиля объект-водитель может воздействовать
только на ограниченный набор органов управления (рулевое колесо, педали
газа, сцепления и тормоза, рычаг переключения передач), но ему недоступен
целый ряд параметров, характеризующих состояние двигателя и автомобиля
в целом.
Все языки, основанные на поддержке технологии ООП, включая C++,
основаны на трех основополагающих концепциях, называемых инкапсуляцией, полиморфизмом и наследованием. Рассмотрим эти концепции.
Инкапсуляция — это механизм, который объединяет данные и методы, манипулирующие этими данными, и защищает и то, и другое от внешнего вмешательства или неправильного использования. Когда методы и данные объединяются таким способом, создается объект.
Внутри объекта данные и методы могут обладать различной степенью
открытости или доступности: от общедоступных до таких, которые доступны
10
только из методов самого объекта. Обычно отрытые члены класса используются для того, чтобы обеспечить контролируемое взаимодействие с его закрытой частью.
Таким образом, комбинирование структуры данных с действиями над
ними (функциями или методами), предназначенными для манипулирования
данными, называется инкапсуляцией.
Наследование — это процесс, посредством которого один объект может приобретать свойства другого. Точнее, объект может наследовать свойства другого объекта и добавлять к ним черты, характерные только для него.
Обычно, если объекты соответствуют конкретным сущностям реального мира, то классы являются абстракциями, выступающими в роли понятий. Между классами как между понятиями существует иерархическое отношение конкретизации, связывающее класс с надклассом. Это отношение реализуется в системах ООП механизмом наследования.
Исследователи в различных областях естествознания тратят много времени на классификацию объектов в соответствии с определенными особенностями. В этом часто помогает организация классификации в форме фамильного дерева с одной общей категорией в корне и подкатегориями, разветвляющимися на подкатегории и т.д.
Например, энтомологи классифицируют насекомых по следующей
схеме, изображенной на рисунке 1 (схема сильно упрощена):
Рисунок 1. Пример классификации насекомых
11
Тип насекомых делится на крылатых и бескрылых. В свою очередь
среди крылатых насекомых существует большее количество категорий: мотыльки, бабочки, мухи и т. д. Такой процесс классификации называется таксономией. Это хорошая начальная метафора для понимания механизма
наследования в ООП.
Пытаясь провести классификацию некоторых новых животных или
объектов, мы задаем следующие вопросы:
- В чем сходство этого объекта с другими объектами общего класса?
- В чем различия?
Каждый класс имеет набор поведений и характеристик, которые его
определяют. Мы начинаем с верхушки фамильного дерева образца и будем
спускаться по ветвям, задавая эти вопросы на протяжении всего пути. Более
высокие уровни являются более общими, а вопросы более простыми: есть
крылья или нет крыльев? Каждый уровень является более специфическим,
чем предыдущий уровень и менее общим.
Когда характеристика определена, все категории ниже этого определения включают эту характеристику. Поэтому, когда вы идентифицируете
насекомое как члена отряда двукрылых (мухи), то в этом случае уже не
нужно указывать, что муха имеет пару крыльев. Вид «муха» наследует эту
характеристику из своего отряда.
Другой пример: «дом» — это часть общего класса «строение», «строение» - часть класса «конструкция». Этот класс, в свою очередь, является частью еще большего класса объектов, который можно назвать «созданием рук
человека». Каждый раз порожденный класс наследует все, связанное с родителем, и добавляет к этому свои собственные определяющие характеристики.
Не описав такой иерархии, для каждого объекта пришлось бы задавать все
характеристики, которые бы его исчерпывающе определяли.
12
Объектно-ориентированное программирование — это процесс построения иерархии классов. Одним из наиболее важных свойств, которое C++ добавляет к C, является механизм, по которому типы классов могут наследовать характеристики из более простых, общих типов. Этот механизм называется наследованием. Наследование обеспечивает общность функций, допуская в то же время столько особенностей, сколько необходимо. Если класс B
является наследником класса A, то мы говорим, что B — это порожденный
класс, а A — основной класс.
Без сомнения, это довольно простая задача, однако установить идеальную иерархию классов для определенного случая бывает иногда очень
трудно. Скажем, таксономия насекомых развивалась сотни лет и до сих пор
подвергается изменениям и жарким дебатам.
По мере того, как расширяется применение того или иного класса, может оказаться, что необходимы новые классы, которые фундаментально изменяют всю иерархию классов. Растущее число изготовителей поставляют
C++ с совместимыми библиотеками классов. Иногда приходится сталкиваться с классом, который комбинирует свойства более чем одного предварительно определенного класса. Такой механизм, называемый многократным или множественным наследованием, посредством которого порожденный класс может наследовать от двух или более основных классов.
Приведем совет Б. Страуструпа, автора и разработчика языка программирования С++:
"... если у двух классов есть некая общая часть, пусть она будет базовым классом. В вашей программе многие классы будут иметь нечто общее;
создайте (почти) универсальный базовый класс - к его разработке отнеситесь тщательнее всего".
Таким образом, наследование выполняет в ООП несколько важных
функций:
13
- моделирует концептуальную структуру предметной области;
- экономит описания, позволяя использовать их многократно при
описании нескольких разных классов;
-
обеспечивает пошаговое программирование больших систем путем
многократной конкретизации классов.
Полиморфизм — это свойство, которое позволяет одно и тоже имя использовать для решения нескольких технически разных задач. Слово «полиморфизм» греческого происхождения, означающее «имеющий много форм».
Применительно к объектно-ориентированному программированию, целью
полиморфизма является использование одного имени для задания общих для
класса действий. На практике это означает способность объектов выбирать
внутреннюю процедуру (метод) исходя из типа данных, принятых в сообщении. Например, объект Print может получить сообщение, содержащее двоичное целое число, двоичное число с плавающей точкой или символьную
строку. Имея дело с объектно-ориентированным языком, мы вправе ожидать,
что объект выполнит верные операции (или, по крайней мере, вежливо откажется от их выполнения) даже в случае, когда в момент составления программы возможность появления сообщения данного типа не предполагалась.
В более общем смысле, концепцией полиморфизма является идея:
«один интерфейс, множество методов». Это означает, что можно создать общий интерфейс для группы близких по смыслу действий. Преимуществом
полиморфизма является то, что он помогает снижать сложность программ,
разрешая использование одного интерфейса для единого класса действий.
При этом, в зависимости от ситуации, ответственность за принятие конкретного решения возлагается в этом случае на компилятор.
Таким образом, полиморфизм означает присваивание действию одного
имени или обозначения, которое совместно используется объектами различ-
14
ных типов, при этом каждый объект реализует действие способом, соответствующим его типу.
Очевидно, для того чтобы продуктивно применять объектный подход
для разработки программ, необходимы языки программирования, поддерживающие этот подход, то есть позволяющие строить описание классов объектов, образовывать данные объектных типов, выполнять операции над объектами. Одним из первых таких языков стал язык SmallTalk, в котором все данные являлись объектами некоторых классов, а общая система классов строилась как иерархическая структура на основе предопределенных базовых
классов.
Опыт программирования показывает, что любой методологический
подход в технологии программирования не должен слепо применяться с игнорированием других подходов. Это относится и к объектно-ориентированному подходу. Существует ряд типовых проблем, для которых его полезность наиболее очевидна, к таким проблемам относятся, в частности, задачи
имитационного моделирования, программирование диалогов с пользователем. Существуют и задачи, в которых применение объектного подхода ни к
чему, кроме излишних затрат труда, не приведет. В связи с этим наибольшее
распространение получили объектно-ориентированные языки программирования, позволяющие сочетать объектный подход с другими методологиями.
Иначе говоря, объектно-ориентированный подход к программированию некоторой задачи, прежде всего, состоит в том, чтобы создать некоторый инструментарий, присущий решаемой задаче, а затем уже программировать в терминах этой задачи.
Практически все объектно-ориентированные языки программирования
являются развивающимися языками, их стандарты регулярно уточняются и
расширяются. Следствием этого развития являются неизбежные различия во
15
входных языках компиляторов различных систем программирования. Дальнейший материал в данном пособии излагается на языке программирования
C++. При этом особенностям его реализации в той или иной системе программирования уделено минимальное внимание.
2. Введение в классы
Одним из наиболее важных понятий ООП является класс. Класс представляет собой механизм для создания новых типов данных. Более детально
понятие класса мы обсудим позднее, а пока отметим, что данное понятие
произошло от двух других – структур и объединений.
2.1 Структуры и объединения
Тип структуры представляет собой упорядоченную совокупность
данных различных типов, к которой можно обращаться как к единому целому. Описание структурного типа строится по схеме:
struct идентификатор
{деклараторы членов} деклараторы_инициализаторы;
Такое объявление выполняет две функции: во-первых, объявляется
структурный тип, во-вторых, объявляются переменные этого типа.
Идентификатор после ключевого слова struct является именем структурного типа. Имя типа может отсутствовать, тогда тип будет безымянным,
и в других частях программы нельзя будет объявлять данные этого типа.
Деклараторы_инициализаторы объявляют конкретные переменные
структурного типа, т.е. данные описанного типа, указатели на этот тип и массивы данных. Деклараторы_инициализаторы могут отсутствовать, в этом
случае объявление описывает только тип структуры.
16
Структура, описывающая точку на плоскости, может быть определена
так:
struct Point_struct
{int x, y;}
// Имя структуры
// Деклараторы членов структуры
point1, *ptr_to_point, arpoint[3];
// Данные структурного типа
Члены (компоненты) структуры описываются аналогично данным соответствующего типа и могут быть скалярными данными, указателями, массивами или данными другого структурного типа. Например, для описания
структурного типа «прямоугольник со сторонами, параллельными осям координат» можно предложить несколько вариантов:
struct Rect1
{
Point p1;
// Координаты левого верхнего угла
Point p2;
// Координаты правого нижнего угла
};
struct Rect2
{
Point p[2];
};
struct Rect3
{
Point p;
// Левый верхний угол
int width;
// Ширина
int high;
// Высота прямоугольника
};
17
Поскольку при описании членов структуры должны использоваться
только ранее определенные имена типов, предусмотрен вариант предварительного объявления структуры, задающий только имя структурного типа.
Например, чтобы описать элемент двоичного дерева, содержащий указатели
на левую и правую ветви дерева, и указатель на некоторую структуру типа
Value, содержащую значение данного в узле, можно поступить так:
struct Value;
struct Tree_element
{
Value *val;
Tree_element *left, *right;
};
Членами структур могут быть так называемые битовые поля, когда в
поле памяти переменной целого типа (int или unsigned int) размещается
несколько целых данных меньшей длины. Пусть, например, в некоторой программе синтаксического разбора описание лексемы содержит тип лексемы
(до шести значений) и порядковый номер лексемы в таблице соответствующего типа (до 2000 значений). Для представления значения типа лексемы достаточно трех двоичных разрядов (трех бит), а для представления чисел от 0
до 2000 - одиннадцати двоичных разрядов (11 бит). Описание структуры, содержащей сведения о лексеме, может выглядеть так:
struct Lexema
{
unsigned int type_lex:3;
unsigned int num_lex:11;
}
18
Двоеточие с целым числом после имени члена структуры указывает,
что это битовое поле, а целое число задает размер поля в битах.
Объединение можно определить как структуру, все компоненты которой размещаются в памяти с одного и того же адреса. Таким образом, объединение в каждый момент времени содержит один из возможных вариантов
значений. Для размещения объединения в памяти выделяется участок, достаточный для размещения члена объединения наибольшего размера. Применение объединения также позволяет обращаться к одному и тому же полю памяти по разным именам и интерпретировать его как значения разных типов.
Описание объединения строится по той же схеме, что и описание
структуры, но вместо ключевого слова struct используется слово union,
например, объединение uword позволяет интерпретировать поле памяти либо
как unsigned int, либо как массив из двух элементов типа unsigned char.
union uword
{
unsigned int u;
unsigned char b[2];
}
Описания типов, объявляемых программистом, в том числе структур и
объединений, могут быть достаточно большими, поэтому в C/C++ предусмотрена возможность присваивания типам собственных имен (синонимов),
достигая при этом повышения наглядности программных текстов. Синоним
имени типа вводится с ключевым словом typedef и строится как обычное
объявление, но идентификаторы в определениях в этом случае интерпретируются как синонимы описанных типов. Синонимы имен типов принято за-
19
писывать прописными буквами, чтобы отличать их от идентификаторов переменных. Ниже приведено несколько примеров объявления синонимов
имен типов.
typedef struct {double re, im} COMPLEX;
typedef int* PINT;
После таких объявлений синоним имени может использоваться как
спецификатор типа:
COMPLEX ca, *pca;
// переменная типа COMPLEX и указатель
// на COMPLEX
PINT pi;
// указатель на int
Приведенное выше описание структур и объединений в основном соответствует их построению в языке C. В C++ структуры и объединения являются частными случаями объектных типов данных. Дополнительные сведения об этом будут приведены в соответствующих разделах при рассмотрении
объектно-ориентированных средств C++.
Подводя промежуточный итог, приведем цитату из работы Ч. Хоара «О
структурной организации данных»:
1. Тип определяет класс значений, которые могут принимать переменная или выражение.
2. Каждое значение принадлежит одному и только одному типу.
3. Тип значения константы, переменной или выражения можно вывести либо из контекста, либо из вида самого операнда, не обращаясь к значениям, вычисляемым во время работы программы.
4. Каждой операции соответствует некоторый фиксированный тип ее
операндов и некоторый фиксированный тип результата
20
5. Для каждого типа свойства значений и элементарных операций над
значениями задаются с помощью аксиом.
6. При работе с языками высокого уровня знание типа позволяет обнаруживать в программе бессмысленные конструкции и решать вопрос о методе представления данных и преобразования их в вычислительной машине.
7. Интересующие нас типы — это типы, хорошо известные математикам: прямые произведения, размеченные объединения, множества, функции,
последовательности.
2.2 Понятие класса
Синтаксис описания класса похож на синтаксис описания структуры.
class имя_класса
{
закрытые элементы - члены класса
public:
открытые элементы - члены класса
};
На что здесь следует обратить внимание?
1.
Имя_класса с этого момента становится новым именем типа дан-
ных, которое используется для объявления объектов класса.
2.
Члены класса — это переменные состояния и функции, объявлен-
ные внутри класса. Функции-члены класса будем называть методами этого
класса, а переменные-члены класса – его свойствами.
3.
По умолчанию, все функции и переменные, объявленные в классе,
становятся закрытыми (private), то есть они доступны только из других чле-
21
нов этого класса. Для объявления открытых членов класса используется ключевое слово public. Все функции-члены и переменные, объявленные после
слова public, доступны и для других членов класса, и для любой другой части программы, в которой содержится класс. В структурах по умолчанию все
члены являются отрытыми.
Таким образом, приведенные ниже примеры аналогичны:
struct Vector3D
{
double mod ();
double projection (Vector3D r);
private:
double x, y, z;
};
и
class Vector3D
{
double x, y, z;
public:
double mod ();
double projection (Vector3D r);
};
Отметим, что существование структур, вероятно, оправдано поддержанием совместимости с языком программирования С.
Хотя функции вычисления длины вектора mod() и нахождения проекции одного вектора на другой projection(Vector3D r) объявлены в Vector3D, они еще не определены. Для определения метода вне класса нужно
22
связать имя класса, частью которого является метод, с именем определяемой
функции. Это достигается путем написания имени функции вслед за именем
класса с двумя двоеточиями. Два двоеточия (::) называют операцией расширения области видимости.
double Vector3D::mod ()
{
return sqrt (x*x + y*y +z*z);
}
double Vector3D::projection (Vector3D r)
{
return (x*r.x + y*r.y + z*r.z) / mod();
}
...
// теперь можно организовать работу с классом,
// как с одним из встроенных типов данных
void main()
{
Vector3D a, b;
...
double dPro, dMod;
dMod = a.mod();
dPro = b.projection(a);
}
Свойства и методы, объявленные с модификатором public, образуют
открытый интерфейс класса. Именно с его помощью пользователи в дальнейшем получают доступ к объектам этого класса.
Как правило, все свойства класса делаются закрытыми (скрывая внутреннюю структуру объекта), а большинство методов – открытыми. В этом
23
случае для работы с данным классом нужно только знать, какие методы являются доступными для использования, какие аргументы они получают на
вход и какие значения возвращают. При этом нет необходимости знать, как
тот или иной объект класса реализован внутри.
Одним из преимуществ сокрытия свойств является то, что в этом случае их легче изменять в случае необходимости. Вернемся к рассмотрению
примера с классом Vector3D, объявив все переменные-члены с модификатором public:
class Vector3D
{
public:
double x, y, z;
};
void main()
{
Vector3D a;
a.x = 7;
cout << a.x << '\n';
}
Несмотря на то, что эта программа вроде бы работает нормально, что
произойдёт, если мы решим переименовать, скажем, x или изменить тип этой
переменной? В этом случае перестала бы работать не только данная программа, но и большая часть тех программ, которые использовали бы класс
Vector3D.
Использование спецификатора доступа private предоставляет возможность изменения способа реализации классов, не нарушая при этом работу всех программ, которые их используют. В данном примере мы запрещаем прямой доступ к свойству x класса, осуществляя его через методы:
24
class Vector3D
{
private:
double x, y, z;
public:
void setX(double newX) { x = newX; }
double getX() { return x; }
};
void main()
{
Vector3D a;
a.setX(7);
cout << a.getX() << '\n';
}
Теперь изменим реализацию класса:
class Vector3D
{
private:
double vec[3]; // здесь изменяем реализацию этого класса
public:
// Нам необходимо обновить содержимое методов для того,
// чтобы заработала новая реализация
void setX(double newX) {vec[0] = newX; }
double getX() { return vec[0]; }
};
void main()
{
// Однако программа продолжает работать, как и прежде
25
Vector3D a;
a.setX(7);
cout << a.getX() << '\n';
}
Поскольку обычно вектор задается сразу всеми координатами, следующая версия описания класса может выглядеть примерно так:
class Vector3D
{
double x, y, z;
public:
double mod()
{return sqrt (x*x + y*y +z*z);}
double projection(Vector3D r)
{return (x*r.x + y*r.y + z*r.z) / mod();}
void set (double newX, double newY, double newZ)
{
x = newX; y = newY; z = newZ;
}
};
Метод set() (и в данном случае только этот метод) позволяет присвоить некоторые начальные значения координатам вектора.
Заметим, что несмотря на то, что x, y и z теперь относятся к защищенным членам класса, явное обращение к этим элементам объекта, переданного
в качестве параметра (см. метод projection()), в методах класса по-прежнему допускается.
26
Обратите внимание: поскольку мы не изменяли прототипы каких-либо
функций в открытом интерфейсе нашего класса, программа, использующая
данный класс, продолжает работать без каких-либо проблем.
Рассмотрим еще несколько примеров, иллюстрирующих необходимость сокрытия свойств класса.
Допустим, требуется создать класс, содержащий в качестве одного из
полей список каких-либо имён. Данный список может быть реализован с использованием различных конструкций: динамического массива, строк Cstyle, std::array, std::vector, std::map, std::list или иной структуры данных. Однако, при использовании этого класса нам не нужно знать
детали его реализации. Это позволяет значительно снизить сложность написания программ, а также уменьшает количество возможных ошибок. Кроме
того, сокрытие свойств помогает защитить данные и предотвратить их неправильное использование.
Известно, что использование глобальных переменных опасно, так как
нет строгого контроля над тем, кто имеет к ним доступ и как их использует.
Классы с открытыми переменными-членами имеют ту же проблему, только
в меньших масштабах. Например, рассмотрим следующий класс, предназначенный для работы со строками:
class MyString
{
char* m_string;
// динамически выделяем строку
int m_length;
// используем переменную для
//отслеживания длины строки
};
Две переменные, являющиеся полями данного класса, связаны между
собой: значение m_length всегда должно соответствовать длине строки
m_string. Если бы поле m_length было открытым, в любой момент можно
27
было бы изменить длину строки без изменения содержимого строки
m_string (или наоборот). В этом случае это могло бы привести к появлению
проблем. Для предотвращения появления ошибок такого рода правильнее
сделать m_length и m_string закрытыми, предоставляя пользователю
только безопасные методы изменения полей.
В ряде случаев мы можем сами повысить степень защищенности
класса от неправильного использования. Рассмотрим класс с открытой переменной-членом, представленной в виде массива:
class IntArray
{
public:
int m_array[10];
};
В случае, если бы пользователи могли напрямую обращаться к элементам массива, они могли бы обратиться к несуществующему элементу, используя недопустимый индекс:
void main()
{
IntArray array;
array.m_array[16] = 2;
// использование неверного индекса, вследствие чего
// осуществляется попытка доступа к «чужой» памяти
}
Однако, если мы опишем массив в приватной части класса, то для изменения значения элемента массива пользователь будет вынужден использовать метод, который, первым делом, проверит корректность использования
индексов массива:
28
class IntArray
{
int m_array[10];
// пользователь не имеет прямого доступа к этому свойству
public:
void setValue(int index, int value)
{
// Если индекс недействителен, то не делаем ничего
if (index < 0 || index >= 10)
return;
m_array[index] = value;
}
};
Таким образом мы защитим целостность нашей программы.
2.3 Конструкторы и деструкторы
Тот, кто хоть раз писал какую-либо программу на C++, знает, что при
объявлении переменной ее, как правило, инициализируют. Язык C++ дает
возможность создать метод, который будет автоматически вызываться для
инициализации объекта данного типа при его создании. Такой метод называется конструктором. Конструктор определяет, каким образом будет создаваться новый объект, в случае необходимости может распределить под него
память и инициализировать ее, а также может использоваться для выполнения любых шагов настройки, необходимых для используемого класса
(например, открыть определённый файл или базу данных).
Конструкторы в языке C++ имеют имена, совпадающие с именем
класса. Конструктор может быть определен пользователем, или компилятор
29
сам сгенерирует конструктор автоматически. Конструктор может вызываться явно или неявно. Компилятор сам вызывает соответствующий конструктор там, где программист создает новый объект класса. Конструктор не
возвращает никакого значения, и при описании конструктора не используется ключевое слово void.
Конструктор, который не имеет параметров (или имеет параметры, все
из которых имеют значения по умолчанию), называется конструктором по
умолчанию. Он вызывается в ситуациях, когда пользователем не указаны
значения для инициализации полей класса. Например, в классе Vector3D
конструктор по умолчанию может задавать нулевой вектор и описываться
следующим образом:
Vector3D()
{
x=y=z=0;
}
Функцией, по своему смыслу обратной конструктору, является деструктор. Эта функция обычно вызывается при удалении объекта. Например, если при создании объекта для него динамически выделялась память, то
при удалении объекта ее нужно освободить. Локальные объекты удаляются
тогда, когда они выходят из области видимости. Глобальные объекты удаляются при завершении программы.
Для простых объектов (тех, которые содержат только инициализируемые в них значения обычных статических переменных-членов класса) деструктор не нужен, так как C++ выполнит очистку выделяемой для них памяти самостоятельно.
Однако в случае, если объект после его создания содержит любые
внешние ресурсы (например, динамически выделенную память или
30
файл/базу данных), или, если необходимо выполнить какие-либо действия до
того, как объект будет уничтожен, использование деструктора является идеальным решением, поскольку описанная в нем последовательность действий
– последнее, что происходит с объектом перед его окончательным уничтожением.
В языке C++ деструкторы имеют имена: «~имя_класса». Например:
~Vector3D();
Если деструктор явным образом не определен, компилятор автоматически создает пустой деструктор.
Описывать в классе деструктор явным образом требуется в случае, когда объект содержит указатели на память, выделяемую динамически – иначе
при уничтожении объекта память, на которую ссылались его поля-указатели,
не будет помечена как свободная.
Деструктор можно вызвать явным образом путем указания полностью
уточненного имени, например:
Vector3D A(1, 2, 3);
a.~Vector3D();
Это может понадобиться для объектов, которым с помощью перегруженной операции new выделяется конкретный адрес памяти. Без необходимости явно вызывать деструктор объекта не рекомендуется.
Конструктор и деструктор не могут быть описаны в закрытой части
класса.
class Vector3D
{
31
double x, y, z;
public:
Vector3D();
~Vector3D()
{
cout << “Работа деструктора Vector3D \n”;
}
double mod()
{return sqrt(x*x+y*y+z*z);}
double projection(Vector3D r)
{return (x*r.x+y*r.y+z*r.z)/mod();}
void set (double newX, double newY, double newZ)
{
x=newX; y=newY; z=newZ;
}
void print();
};
Vector3D::Vector3D() // конструктор по умолчанию
// класса Vector3D
{
x=y=z=0;
cout << “Работа конструктора Vector3D \n”;
}
void Vector3D::print()
{
cout << “(“ << x << “, “ << y << “, “<< z << “)\n “;
}
32
void main()
{
Vector3D A;
// создается объект A и происходит
// инициализация его элементов
// A.x=A.y=A.z=0;
A.print();
A.set (3,4,0);
// Теперь A.x=3.0, A.y=4.0, A.z=0.0
A.print();
cout << A.mod() << ”\n”;
}
Результат работы программы:
Работа конструктора Vector3D
(0, 0, 0)
(3, 4, 0)
5.0
Работа деструктора Vector3D
В предыдущем примере деструктор не выполнял никаких специфических действий по очистке памяти, поскольку выделение памяти для хранения переменных-членов выделялось на этапе компиляции. Как следствие, в этом примере можно было использовать деструктор по умолчанию. Однако есть случаи, когда деструктора по умолчанию недостаточно.
Рассмотрим простой класс для работы с динамическим массивом:
class Massiv
{
private:
int* m_array;
int m_length;
public:
33
Massiv(int length)
// конструктор
{
m_array = new int[length];
m_length = length;
}
~Massiv()
// деструктор
{
// Динамически удаляем массив,
// память под который выделили ранее
delete[] m_array ;
}
void setValue(int index, int value)
{
m_array[index] = value;
}
int getValue(int index)
{
return m_array[index];
}
int getLength()
{
return m_length;
}
};
void main()
{
Massiv arr(15);
// выделяем память для
// 15 целочисленных значений
for (int count=0; count < 15; ++count)
arr.setValue(count, count+1);
cout << "The value of element 7 is "
<< arr.getValue(7);
}
// Объект arr удаляется здесь,
34
// поэтому деструктор ~Massiv()вызывается тоже здесь
Результат работы программы:
The value of element 7 is 8
В первой строке функции main() создаётся новый объект класса
Massiv с именем arr и определяется его длина (length) 15. Это приводит к
вызову конструктора, который динамически выделяет память для массива
класса (m_array). Здесь необходимо использовать динамическое выделение
памяти, поскольку на момент компиляции длина массива неизвестна.
В конце функции main() объект arr выходит из области видимости.
Это приводит к вызову деструктора ~Massiv() и к удалению массива, память
под который ранее была выделена в конструкторе.
2.4 Конструкторы с параметрами, перегрузка
конструкторов
Хотя конструктор по умолчанию отлично подходит для обеспечения
инициализации полей класса значениями по умолчанию, часто может быть
необходимо, чтобы экземпляры того или иного класса содержали определённые значения полей, которые мы предоставим позже. Конструкторы могут
иметь параметры. Для этого просто нужно добавить необходимые параметры
в объявление и определение конструктора. Затем, при создании объекта, требуется задать параметры в качестве аргументов.
class Vector3D
{
double x, y, z;
35
public:
Vector3D ();
Vector3D (double initX, double initY, double initZ = 0);
...
};
Vector3D::Vector3D()
// конструктор класса Vector3D
{
x=y=z=0;
cout << “Работа конструктора Vector3D \n”;
}
Vector3D::Vector3D(double initX, double initY, double initZ)
// конструктор класса Vector3D с параметрами
{
x = initX;
y = initY;
z = initZ;
cout << “Работа конструктора Vector3D \n”;
}
void main()
{
Vector3D A;
// создается объект A и происходит
// инициализация его элементов
// A.x=A.y=A.z=0
A.set (3,4,0);
// Теперь A.x=3.0, A.y=4.0, A.z=0.0
Vector3D B(3,4,5);
// создается объект B и происходит
// инициализация его элементов
// B.x=3.0, B.y=4.0, B.z=5.0
}
Такой способ вызова конструктора является сокращенной формой записи выражения
36
Vector3D B = Vector3D (3,4,5);
В выражении Vector3D B(3,4,5) выполняется инициализация всех
координат вектора B. Однако стоит обратить внимание на то, что в конструкторе с параметрами для последнего входного параметра указано значение по
умолчанию, поэтому при создании объектов с использованием данного конструктора мы можем указать только два параметра, а третье значение будет
значением по умолчанию. Такое объявление объекта может использоваться,
например, для создания вектора, целиком лежащего в плоскости XoY:
Vector3D С(3,4);
Напомним, аргумент по умолчанию позволяет дать входному параметру значение по умолчанию в том случае, если при вызове функции соответствующий аргумент не задан.
Напомним, что все параметры по умолчанию должны находиться в
списке входных параметров правее аргументов, передаваемых обычным путем.
Данный механизм может быть использован в любом из методов класса,
в том числе и конструкторах с параметрами. Отметим, что аргументами по
умолчанию могут быть только именованные константы или глобальные переменные.
Применение аргумента по умолчанию является простейшей формой
перегрузки функций и одним из проявлений полиморфизма. Понятно, что
функция при этом используется одна, следовательно, и алгоритм, реализуемый при вызове этой функции, – один. Однако способов вызова данной функции в результате может быть несколько. Напомним, что при использовании
37
перегруженных функций мы можем наблюдать обратную ситуацию – функции могут описывать различные алгоритмы, однако вызываться схожим образом.
В отличие от конструктора, деструктор не может иметь параметров.
В рассмотренном выше примере сознательно вместе с новым объектом
B оставлен объект A, чтобы продемонстрировать различные способы созда-
ния объектов. Каждому способу объявления объекта класса должна соответствовать своя версия конструктора класса. Если это не будет обеспечено, то
при компиляции программы обнаружится ошибка.
На данном примере легко понять, чем может быть вызвана необходимость перегрузки конструкторов. Именно перегрузки, поскольку речь здесь
идет о функциях, имеющих одинаковые имена, но различные списки входных параметров. Главный смысл перегрузки конструкторов состоит в том,
чтобы предоставить программисту наиболее подходящий метод инициализации объекта.
В приведенном выше примере представлен наиболее распространенный вариант перегрузки конструкторов, в котором присутствует как конструктор по умолчанию, так и конструктор с параметрами. И действительно,
как правило, в программе бывают нужны оба эти вида конструкторов, однако
иногда, в случае необходимости, бывает удобно оставить только один конструктор со списком входных параметров, каждый из которых имеет значение по умолчанию. Однако при этом в рассматриваемом нами примере способ создания объектов остается единственным:
class Vector3D
{
double x, y, z;
public:
38
Vector3D (double initX = 0, double initY = 0, double
initZ = 0);
...
};
Vector3D::Vector3D(double initX, double initY, double initZ)
// конструктор класса Vector3D с параметрами
{
x = initX;
y = initY;
z = initZ;
cout << “Работа конструктора Vector3D \n”;
}
void main()
{
Vector3D A;
// создается объект A и происходит
// инициализация его элементов
// A.x=A.y=A.z=0;
Vector3D B(3,4,5);
// создается объект B и происходит
// инициализация его элементов
// B.x=3.0, B.y=4.0, B.z=5.0
}
Отметим, что в данном случае объект A, все параметры которого заданы значениями по умолчанию, может быть создан и по-другому – путем
явного вызова конструктора и последующего использования оператора присваивания:
Vector3D A = Vector3D ();
39
Хотя конструктор можно перегружать столько раз, сколько захочется,
лучше не стоит этим злоупотреблять. Конструктор стоит перегружать лишь
для наиболее часто встречающихся ситуаций.
2.5 Список инициализации членов класса
В предыдущем примере мы инициализировали члены класса в конструкторе через оператор присваивания. При этом вначале создаются x, y и
z, затем выполняется тело конструктора, в котором этим переменным при-
сваиваются значения:
class Vector3D
{
double x, y, z;
public:
Vector3D ();
...
};
Vector3D::Vector3D()
{
x = 0;
// Это операции присваивания
y = 0;
z = 0;
cout << “Работа конструктора Vector3D \n”;
}
Однако некоторые типы данных (например, именованные константы и
ссылки) должны быть инициализированы сразу, непосредственно при их
объявлении:
40
class Values
{
private:
const int value;
public:
Values()
{
value = 3;
// ошибка: константам нельзя присваивать значения
}
};
Для решения этой проблемы в C++ существует метод инициализации
переменных-членов класса через список инициализации членов (или «список инициализаторов членов») вместо присваивания им значений после
объявления.
Инициализировать переменные можно двумя способами – посредством копирующей или прямой инициализации:
double x = 3;
// копирующая инициализация
double y(4.5);
// прямая инициализация
Код с использованием списка инициализации может выглядеть следующим образом:
class Vector3D
{
double x, y, z;
public:
Vector3D():x(0), y(0), z(0) // напрямую инициализируем
// переменные-члены класса
41
{
// Нет необходимости использовать присваивание
cout << “Работа конструктора Vector3D \n”;
}
...
};
Список инициализации членов находится сразу же после параметров
конструктора. Он начинается с двоеточия (:), а затем значение для каждой
переменной указывается в круглых скобках. В этом случае нет необходимости выполнять операции присваивания в теле конструктора. Обратите внимание: после списка инициализаторов не ставится точка с запятой.
При использовании конструкторов с параметрами также можно использовать возможность передачи значений для инициализации свойств
класса:
class Vector3D
{
double x, y, z;
public:
// напрямую инициализируем переменные-члены класса
Vector3D (double val1, double val2, double val3):
x(val1), y(val2), z(val3)
{
// Нет необходимости использовать присваивание
cout << “Работа конструктора Vector3D \n”;
}
...
};
42
Мы можем использовать данный механизм и в случае присваивания
свойствам класса значений по умолчанию, если пользователь их не предоставил. Например, в рассмотренном выше случае, когда класс содержит именованную константу, являющуюся его свойством:
class Values
{
const int value;
public:
Values(): value(7) {} // напрямую инициализируем
// именованную константу
};
Данная форма записи является корректной, поскольку нам разрешено
инициализировать именованные константы (но не присваивать им значения
после объявления).
3. Использование классов на практике
3.1 Указатели и ссылки на объекты
До сих пор доступ к полям объекта осуществлялся с использованием
операции «.». Это правильно, если работа ведется непосредственно с самим
объектом. Однако доступ к свойствам и методам объекта можно осуществлять и через указатель на объект. В этом случае обычно применяется операция «->».
Указатель на объект объявляется точно так же, как и указатель на переменную любого типа.
43
Для получения адреса существующего объекта перед ним необходим
оператор «&». Для выделения памяти под новый объект используется оператор new.
void main()
{
Vector3D A(2,3,4);
Vector3D* pA;
pA = &A;
Vector3D* pB = new Vector3D(5,6,7);
double dM = pA->mod();
double dP = pA->projection(*pB);
}
В C++ есть элемент, родственный указателю – это ссылка. Ссылка является скрытым указателем и всегда работает как другое, дополнительное
имя переменной.
Наиболее важный способ использования ссылки – передача ее в качестве параметра. Чтобы разобраться в том, как работает ссылка, рассмотрим
для начала программу, использующую в качестве параметра указатель.
void ToZero(Vector3D* vec)
{
vec->set(0,0,0);
// используется "->"
}
void main()
{
Vector3D A(2,3,4);
ToZero(&A);
}
44
В С++ можно сделать то же самое, не используя указатели, с помощью
параметра-ссылки.
void ToZero(Vector3D& vec)
{
vec.set(0,0,0);
// используется "."
}
void main()
{
Vector3D A(2,3,4);
ToZero(A);
}
При применении параметра-ссылки, компилятор передает адрес переменной, используемой в качестве аргумента. При этом не только не нужно,
но и неверно использовать оператор «*». Внутри функции компилятор использует переменную, на которую ссылается параметр-ссылка.
В отличие от передачи в качестве параметра объекта, использование
ссылки-параметра предполагает, что функция, в которую передан параметр,
будет манипулировать не копией передаваемого объекта, а ссылкой на него,
то есть практически (с точки зрения расположения в памяти) самим передаваемым объектом.
Параметры-ссылки имеют некоторые преимущества по сравнению с
аналогичными альтернативными параметрами-указателями. Во-первых, нет
необходимости получать и передавать в функцию адрес аргумента – адрес в
этом случае передается автоматически. Во-вторых, ссылки предлагают более
понятный и элегантный интерфейс, чем неуклюжий механизм указателей.
Ссылки также могут использоваться в качестве возвращаемого значения функции.
45
Все эти случаи объединяет необходимость передачи измененного объекта через его адрес, а не путем возвращения копии такого объекта: использование в качестве возвращаемого значения ссылки гарантирует, что возвращаемым в качестве результата выполнения этой функции будет именно оригинальный объект, а не его копия.
3.2 Указатель this
Снова рассмотрим описанный выше класс Vector3D.
class Vector3D
{
double x, y, z;
public:
Vector3D(double initX = 0, double initY = 0,
double initZ = 0) :
x(initX), y(initY), z(initZ){}
double mod(){return sqrt(x*x+y*y+z*z);}
double projection(Vector3D r)
{return (x*r.x+y*r.y+z*r.z)/mod();}
};
Было бы неверно предполагать, что для каждого нового объекта класса
создается копия метода, реализующего ту или иную операцию. На самом
деле каждый метод класса представлен в оперативной памяти в единственном экземпляре и в момент вызова получает один скрытый параметр – указатель на экземпляр объекта, для которого данный метод вызван. Этот указатель имеет имя this. При этом доступ к самому объекту в этом случае осуществляется с помощью операции разыменования *this.
46
Создадим два объекта класса Vector3D и вызовем для одного из них
метод projection():
void main()
{
Vector3D a(1, 2, 3);
Vector3D b(-2, 0, 2);
...
double dPro;
dPro = b.projection(a);
}
При вызове b.projection(a) компилятор понимает, что в этом случае метод projection() вызывается для объекта b. Во время компиляции
вызов b.projection(a) конвертируется компилятором в следующей вызов:
projection(&b, a). Теперь это стандартный вызов функции, в котором
объект b (который ранее вызывал данную функцию и был особым объектом,
находящимся перед точкой) на самом деле передаётся по ссылке в качестве
дополнительного аргумента этой функции.
Далее, поскольку при вызове метода компилятор видит два входных
параметра, то и тело данного метода изменяется им автоматически так, чтобы
иметь возможность обработать уже два аргумента. В результате, следующий
метод:
double projection(Vector3D r)
{return (x*r.x+y*r.y+z*r.z)/mod();}
конвертируется компилятором в:
double projection(Vector3D* const this, Vector3D r)
47
{return (this->x*r.x + this->y*r.y +
this->z*r.z) / this->mod();}
Мы видим, что в процессе компиляции для объекта, вызывающего
тот или иной метод, компилятор неявно добавляет к этому методу дополнительный входной параметр – указатель this.
Внутри метода также автоматически обновляются все члены класса
(функции и переменные) таким образом, чтобы они ссылались на объект,
который вызывает этот метод. Это делается с помощью добавления префикса this-> к каждому из них. Таким образом, свойства класса x, y, z
в теле функции projection()будут конвертированы в this->x, this->y,
this->z. И когда this указывает на адрес объекта b, выражение this>x будет соответствовать b.x.
3.3 Перегружаемые операторы
Одной из ключевых черт полиморфизма в C++ является замещение или
перегрузка операторов и функций.
Если мы вспомним, что операторы по сути являются функциями, то мы
увидим, что язык C содержал в себе некоторое количество замещаемых операторов. Рассмотрим простой фрагмент программы на C:
int i1, i2, i3;
long int l1, l2, l3;
double f1, f2, f3;
i1=i2+i3;
l1=l2+l3;
f1=f2+f3;
48
Во всех трех случаях сложения данных используется оператор сложения «+». Но ведь компилятор в каждом конкретном случае сгенерирует различные наборы машинных инструкций в соответствии с типами операндов.
Если типы суммируемых данных являются различными, компилятор сначала
преобразовывает данные в соответствии с определенными правилами, а затем осуществляет сложение. Тем самым мы видим, что замещение оператора
«+» (как и многих других) присуще и языку С.
Фундаментальное отличие C++ от C, делающее возможным использование объектно-ориентированных технологий, состоит в том, что C++ не
только позволяет замещать операторы и функции, но и активно подталкивает
программиста к этому.
Встроенный оператор может замещаться для работы с новыми типами
данных. Делается это с помощью объявления формального прототипа оператора (аналогичного прототипу функции) с добавлением ключевого слова
operator и описания функции, определяющей новое поведение оператора.
Рассмотрим данную возможность на примере введенного выше трехмерного вектора:
class Vector3D
{
double x, y, z;
public:
Vector3D ();
Vector3D (double initX, double initY, double initZ);
...
Vector3D operator+(const Vector3D& b);
Vector3D& operator++();
Vector3D operator++(int);
};
49
Vector3D Vector3D::operator+(const Vector3D& b)
{
return Vector3D(x + b.x, y + b.y, z + b.z);
}
Vector3D& Vector3D::operator++()
{
++x; ++y; ++z;
return *this;
}
Vector3D Vector3D::operator++(int)
{
Vector3D b(x,y,z);
x++; y++; z++;
return b;
}
Теперь мы вправе написать следующий фрагмент программы:
Vector3D A(1.0, 1.0, 1.0), B, C;
B = A;
C = A+B;
// запись эквивалентна вызову C=A.operator+(B);
// operator +(...) - один из методов объекта A
++A;
А++;
С перегрузкой операторов автоувеличения и автоуменьшения (инкремента и декремента, ++ и --) возникают небольшие затруднения, поскольку
эти операторы должны вызывать разные функции в зависимости от того, расположены ли они до (префиксная запись) или после (постфиксная запись)
того объекта, с которым они работают. Проблема решается следующим образом: когда компилятор видит выражение ++А (префиксный инкремент), он
генерирует вызов функции Vector3D::operator++(), а для выражения А++
50
генерируется вызов Vector3D::operator++(int). Таким образом, компилятор различает эти две формы и вызывает для них разные перегруженные
функции. Чтобы создать другую сигнатуру для постфиксной версии, компилятор передает в дополнительном аргументе int фиктивную константу
(идентификатор этой константе не присваивается, потому что ее значение
нигде не используется).
Передавать и возвращать аргументы в операторах можно любым из
возможных способов. Однако в большинстве случаев следует руководствоваться следующей схемой:
- если объект, передаваемый в качестве аргумента, только читается,
но не изменяется оператором, его следует передавать как ссылку на константу. Обычные арифметические и логические операции не изменяют свои
аргументы, поэтому передача по ссылке на константу встречается чаще
всего. Только для комбинированных операций (таких, например, как сложение с присваиванием) и операции присваивания, изменяющих левосторонний аргумент (L-value), этот аргумент обычно не является константным изза возможной последующей модификации (например, как в выражении
А+=А), но он все равно передается в виде адреса;
-
тип возвращаемого значения выбирается в зависимости от пред-
полагаемого смысла оператора. Если оператор подразумевает формирование
нового объекта, то в качестве возвращаемого значения следует использовать
именно объект;
- операторы инкремента и декремента изменяют объект и поэтому не
могут интерпретировать его как константу. Префиксная версия возвращает
значение объекта после его изменения, поэтому для нее вполне логично возвращать *this в виде ссылки. Постфиксная версия должна возвращать значение объекта до изменения, поэтому в ней приходится создавать отдельный
объект, представляющий это значение, и изменять его. Следовательно, чтобы
51
обеспечить предполагаемое поведение постфиксной версии, необходимо
возвращать данные по значению.
Существует ряд ограничений на перегрузку операторов по сравнению
с перегрузкой обычных функций:
- оператор должен уже существовать в языке;
- нельзя переопределять действия встроенных в C++ операторов при
работе со встроенными типами данных (например, Вы не можете изменить
способ работы оператора «+» при сложении целых чисел);
- запрещено замещать операторы «.», «.*», «?:», «::» и символы
препроцессора «#».
Отметим напоследок, что при переопределении операторов нужно придерживаться простого правила - переопределенный оператор должен реализовывать некоторое действие, сходное по смыслу с уже определенными операторами, имеющими такое обозначение (впрочем, то же самое справедливо
вообще для переопределяемых функций). Конечно, ничто не мешает переопределить, например, оператор «+» для нового типа таким образом, чтобы
в момент его вызова на экран 18 раз выводилось сообщение «Спартак-чемпион». Однако очевидно, что использование такого оператора неминуемо
приведет к путанице и ошибкам.
3.4 Присваивание объектов
Один объект можно присвоить другому, если оба объекта имеют одинаковый тип. По умолчанию, когда объект A присваивается объекту B, то осуществляется побитовое копирование всех элементов-данных A в соответствующие элементы-данные B. Если объекты имеют разные типы, то компилятор
выдаст сообщение об ошибке. Важно понимать, одинаковыми должны быть
имена типов, а не их физическое содержание. Например, следующие два типа
несовместимы.
52
class ClassName1
{
int a, b;
public:
void set(int ia, int ib) {a=ia; b=ib;}
};
class ClassName2
{
int a, b;
public:
void set(int ia, int ib) {a=ia; b=ib;}
};
Так что попытка выполнить
ClassName1 c1;
ClassName2 c2;
c2=c1;
окажется неудачной.
Важно понимать, что при присваивании происходит побитовое копирование элементов данных, в том числе и массивов. Особенно внимательным
нужно быть при присваивании объектов, в описании типа которых содержатся указатели. Например,
class Pair
{
int a, *b;
53
public:
Pair() { a = 0; b = new int; }
void set(int ia, int ib) { a = ia; *b = ib; }
int getb() { return *b; }
int geta() { return a; }
};
void main()
{
Pair c1, c2;
c1.set(10, 11);
c2 = c1;
c1.set(100, 111);
cout << " c2.b = " << c2.getb() << endl;
}
После выполнения программы значение переменной *c2.b будет
равно 111, а не 11, как ожидалось.
Чтобы избежать такого рода недоразумений, используют перегруженный оператор присваивания, в которой явным образом описывается (то есть
контролируется) процесс присваивания переменных-членов одного объекта
соответствующим переменным-членам другого объекта. Конструктор копирования и оператор присваивания выполняют почти идентичную работу: оба
копируют значения из одного объекта в значения другого объекта. Однако,
конструктор копирования используется при инициализации новых объектов,
тогда как оператор присваивания заменяет содержимое уже существующих
объектов.
class Pair
{
int a, *b;
54
public:
Pair& operator=(const Pair& p)
{
if (this == &p) return *this;
a=p.a;
*b=*(p.b);
return *this;
}
...
};
Во все разновидности оператора присваивания (=, +=, *= и так далее)
часто включается проверочный код в случае самоприсваивания объекта. Однако иногда такая проверка не нужна, поскольку в этом случае в итоге будет
получен неверный результат.
Рассмотрим следующий пример, в котором оператор += используется
в выражении вида А+=А, прибавляя переменную А к самой себе:
class Pair {
int a, *b;
public:
Pair(int initA = 0, int initB = 0) {
a = initA;
b = new int;
*b = initB;
cout << "Конструктор: ";
print();
}
~Pair() {
cout << "Деструктор: ";
print();
55
delete b;
}
void print() {
cout << "a = " << a << " b = " << b << " *b = "
<< *b << endl;
}
Pair& operator+= (const Pair& p){
if (this == &p) return *this;
a += p.a;
// тот же прием, что и в operator=
*b += *p.b;
// приводит к неверному результату
return *this; // в случае самоприсваивания
}
};
void main()
{
Pair A(1, 2);
A.print();
A += A;
cout << "_____________________________" << endl;
A.print();
}
В результате выполнения A += A мы рассчитываем получить результат
(2, 4). Однако из-за проверки в начале выполнения операции += получим
иной результат. Программа в данном случае выведет следующее:
Конструктор: a = 1 b = 00B328E0 *b = 2
a = 1 b = 00B328E0 *b = 2
_____________________________
a = 1 b = 00B328E0 *b = 2
56
Проверка путем самоприсваивания объекта особенно важна для оператора =, поскольку в случае сложных объектов неправильное присваивание
может привести к катастрофическим последствиям. Рассмотрим следующий
пример:
class MyString
{
private:
char* m_data;
int m_length;
public:
MyString(const char* data = "", int length = 0) :
m_length(length)
{
if (!length)
m_data = 0;
else
m_data = new char[length];
for (int i = 0; i < length; ++i)
m_data[i] = data[i];
}
MyString& operator= (const MyString& str);
void print() {
cout << m_data << endl;
}
};
// Перегрузка оператора присваивания
// (неверный вариант перегрузки)
57
MyString& MyString::operator= (const MyString& str)
{
// Если m_data уже имеет значение, то удаляем это значение
if (m_data) delete[] m_data;
m_length = str.m_length;
// Копируем значение из str в m_data неявного объекта
m_data = new char[str.m_length];
for (int i = 0; i < str.m_length; ++i)
m_data[i] = str.m_data[i];
return *this;
}
void main()
{
MyString A("Anton", 7);
A = A;
A.print();
}
При выполнении данной программы либо произойдет ее аварийное завершение, либо переменная-член m_data объекта A будет содержать неверные «мусорные» данные (данные-мусор).
Рассмотрим, что происходит при выполнении операции присваивания,
когда неявный и переданный в качестве аргумента объекты оба являются
объектом A. В этом случае m_data равно str.m_data (т.е. Anton). Первое, что
произойдёт — функция перегрузки проверит, имеет ли неявный объект уже
строку Anton. Если имеет, то произойдёт удаление этого значения, чтобы не
случилась утечка памяти. Т.е. значение m_data неявного объекта удаляется,
58
но дело в том, что str.m_data имеет тот же адрес памяти (значение которого
удаляется). Это означает, что str.m_data станет висячим указателем.
Позже, когда мы будем копировать данные из параметра str функции
перегрузки в наш неявный объект, мы будем обращаться к висячему указателю str.m_data. Это приведёт к тому, что мы либо скопируем данные-мусор, либо попытаемся получить доступ к памяти, которую наше приложение
больше не имеет в распоряжении (произойдёт аварийное завершение программы).
Все операторы присваивания изменяют левосторонний операнд. Чтобы
результат присваивания мог использоваться в цепочках присваивания вида
a=b=c, предполагается, что оператор вернет ссылку на тот же левосторонний
операнд, который только что был модифицирован. Следовательно, возвращаемое значение для всех операторов присваивания должно быть ссылкой
на объект, не являющийся константой.
А вот как теперь будет выглядеть наш пример с трехмерным вектором:
class Vector3D
{
double x, y, z;
public:
Vector3D ();
Vector3D (double initX, double initY, double initZ);
…
Vector3D& operator= (const Vector3D& b);
Vector3D& operator+= (const Vector3D& b);
};
Vector3D& Vector3D::operator= (const Vector3D& b)
{
if(this == &b) return *this;
x = b.x;
59
y = b.y;
z = b.z;
return *this;
}
Vector3D& Vector3D::operator+= (const Vector3D& b)
{
x += b.x;
y += b.y;
z += b.z;
return *this;
}
3.5 Передача объектов в функцию и возвращение объекта.
Конструктор копирования
Объекты можно передавать в функции в качестве аргументов так же,
как и данные других типов. Мы этим уже неоднократно пользовались.
Напомним, что в языке C++ методом передачи параметров по умолчанию является передача объектов по значению. Это означает, что внутри
функции создается копия объекта-аргумента, и эта копия, а не сам объект,
используется функцией. Следовательно, изменение копии объекта внутри
функции не должно оказывать влияния на сам объект.
При передаче объекта в функцию появляется новый объект. Когда работа функции, которой был передан объект, завершается, то копия аргумента
удаляется. Рассмотрим следующий простой пример:
class Pair {
int a, b;
public:
Pair(int initA = 0, int initB = 0):a(initA), b(initB)
{
60
cout << "Конструктор: ";
print();
}
~Pair() {
cout << "Деструктор: ";
print();
}
void print() {
cout << "a = " << a << " b = " << b << endl;
}
};
void f(Pair obj) {
cout << "Функция f: " << endl;
}
void main()
{
Pair A(1, 2);
cout << "_____________________________" << endl;
f(A);
cout << "_____________________________" << endl;
}
Эта программа выполнит следующее
Конструктор: a = 1 b = 2
_____________________________
Функция f:
Деструктор: a = 1 b = 2
_____________________________
Деструктор: a = 1 b = 2
61
Конструктор вызывается только один раз. Это происходит при создании объекта A. Однако деструктор срабатывает дважды – один раз для копии
obj, второй раз для самого объекта A. Тот факт, что деструктор вызывается
дважды, может стать потенциальным источником проблем, например, для
объектов, деструктор которых высвобождает динамически выделенную область памяти.
Напомним, объект внутри функции представляет собой побитовую копию передаваемого объекта, а это значит, что в случае, если объект содержит
в себе, например, некоторый указатель на динамически выделенную область
памяти, то при копировании создается объект, указывающий на ту же область памяти. И как только вызывается деструктор копии, где, как правило,
принято высвобождать память, то высвобождается область памяти, на которую указывал объект-«оригинал», что приводит к разрушению исходного
объекта. Продемонстрируем сказанное на следующем примере:
class Pair {
int a, *b;
public:
Pair(int initA = 0, int initB = 0) {
a = initA;
b = new int;
*b = initB;
cout << "Конструктор: ";
print();
}
~Pair() {
cout << "Деструктор: ";
print();
delete b;
}
62
void print() {
cout << "a = " << a << " b = " << b << " *b = "
<< *b << endl;
}
};
void f(Pair obj) {
cout << "Функция f: " << endl;
}
void main()
{
Pair A(1, 2);
cout << "_____________________________" << endl;
f(A);
cout << "_____________________________" << endl;
A.print();
}
В итоге программа завершается с ошибкой:
Конструктор: a = 1 b = 008B01F0 *b = 2
_____________________________
Функция f:
Деструктор: a = 1 b = 008B01F0 *b = 2
_____________________________
a = 1 b = 008B01F0 *b = -572662307
Деструктор: a = 1 b = 008B01F0 *b = 7
Мы видим, что конструктор по-прежнему срабатывает один раз при создании объекта A. Затем при выходе из функции f() срабатывает деструктор,
63
который удаляет связь переменной-указателя b с ячейкой, в которой хранится значение *b. После завершения работы функции f значение *b уже
не определено. И попытка деструктора при завершении программы повторно
освободить память, выделенную под b, приводит к ошибке времени выполнения и аварийному завершению программы. К моменту выхода из программы ячейка, на которую ссылался указатель b, как мы видим, содержит
уже «чужие» данные *b = 7.
Похожая проблема возникает и при использовании объекта в качестве
возвращаемого значения. Для того чтобы функция могла возвращать объект,
нужно, во-первых, объявить функцию так, чтобы ее возвращаемое значение
имело тип класса, и, во-вторых, возвращать объект с помощью обычного оператора return. Однако если возвращаемый объект содержит деструктор, то
в этом случае возникают похожие проблемы, связанные с «неожиданным»
разрушением объекта.
class Pair {
int a, b;
public:
Pair(int initA = 0, int initB = 0):a(initA), b(initB)
{
cout << "Конструктор: ";
print();
}
~Pair() {
cout << "Деструктор: ";
print();
}
void print() {
cout << "a = " << a << " b = " << b << endl;
}
};
64
Pair f( ) {
cout << "Функция f: " << endl;
Pair obj(1, 2);
return obj;
}
void main()
{
Pair A;
cout << "_____________________________" << endl;
A = f();
cout << "_____________________________" << endl;
}
Эта программа выведет следующий результат:
Конструктор: a = 0 b = 0
_____________________________
Функция f:
Конструктор: a = 1 b = 2
Деструктор: a = 1 b = 2
Деструктор: a = 1 b = 2
_____________________________
Деструктор: a = 1 b = 2
Конструктор вызывается дважды: для объектов A и obj. Однако деструкторов здесь три. Как же так? Понятно, что один деструктор разрушает
объект A, еще один – объект obj. «Лишний» вызов деструктора (второй по
счету) вызывается для так называемого временного объекта, который является копией возвращаемого объекта. Формируется эта копия в тот момент,
когда функция возвращает объект. После того, как функция возвратила свое
значение, выполняется деструктор временного объекта. Понятно, что если
65
деструктор, например, высвобождает динамически выделенную память, то
разрушение временного объекта приведет к разрушению возвращаемого объекта.
Одним из способов обойти такого рода проблемы является создание
особого типа конструкторов – конструкторов копирования. Конструктор
копирования или конструктор копии позволяет точно определить порядок
создания копии объекта.
Любой конструктор копирования имеет следующую форму:
имя_класса (const имя_класса& obj)
{
...
// тело конструктора
}
Здесь obj – тот самый объект, чья копия будет создаваться. Конструктор копирования вызывается всякий раз, когда создается копия объекта. Мы
уже рассмотрели два таких случая. Во-первых, при передаче объекта в качестве параметра функции. Во-вторых, при создании временного объекта в случае, когда функция в качестве возвращаемого значения использует объект.
Есть еще один случай, когда полезен конструктор копирования, – это инициализация одного объекта другим.
class Pair {
int a, *b;
public:
Pair(int initA = 0, int initB = 0) {
a = initA;
b = new int;
*b = initB;
cout << "Конструктор: ";
print();
66
}
Pair(const Pair &obj) {
a = obj.a;
b = new int(*obj.b);
cout << "Конструктор копирования: ";
print();
}
~Pair() {
cout << "Деструктор: ";
print();
delete b;
}
void print() {
cout << "a = " << a << " b = " << b << " *b = "
<< *b << endl;
}
};
void f(Pair obj) {
cout << "Функция f: " << endl;
obj.print();
}
void main()
{
Pair A(1,2);
Pair B = A;
cout << "_____________________________" << endl;
A.print();
B.print();
cout << "_____________________________" << endl;
}
Результат работы программы:
67
Конструктор: a = 1 b = 0x00AD2E30 *b = 2
Конструктор копирования: a = 1 b = 0x00AD5E20 *b = 2
_____________________________
a = 1 b = 0x00AD2E30 *b = 2
a = 1 b = 0x00AD5E20 *b = 2
_____________________________
Деструктор: a = 1 b = 0x00AD5E30 *b = 2
Деструктор: a = 1 b = 0x00AD5E20 *b = 2
Рассмотрим пример, в котором необходимо использование конструктора копирования. В приведенной ниже программе создается простой «безопасный» вариант массива целых чисел, в котором предотвращается выход
за границы этого массива. Память для массива выделяется с использованием
оператора new, и в каждом объекте поддерживается работа с указателем на
выделенную память.
Поскольку место для массива выделено с помощью new, то для распределения памяти в случае, когда один объект используется для инициализации
другого, предоставляется конструктор копирования:
class Аrray
{
int *p;
int size;
public:
Аrray(int sz)
{
p = new int[sz];
if (!p) exit(1);
size = sz;
}
~Аrray() {delete [] p; }
68
Аrray(const Аrray &a);
// конструктор копирования
void put(int i, int j) { if (i>=0 && i<size) p[i] = j;}
int get (int i) {return p[i];}
};
Аrray::Аrray(const Аrray &a) // тело конструктора копирования
{
int i;
p = new int[a.size];
if (!p) exit(1);
for(i=0; i<a.size; i++) p[i] = a.p[i];
}
void main()
{
Аrray num(10);
int i;
for(i=0; i<10; i++) num.put(i, i);
for(i=9; i>=0; i--) cout << num.get(i);
cout << "\n";
// создание дополнительного массива и его инициализация
// значениями объекта num
Аrray x(num);
// вызов конструктора копирования
for (i=0; i<10; i++) cout << x.get(i);
}
Когда объект num используется для инициализации х, то вызывается
конструктор копирования, выделяющий новую память, начальный адрес которой помещается в х.р, а затем содержание массива num копируется в массив объекта х. Таким образом, объекты х и num содержат массивы с одинаковыми значениями, но массивы независимы друг от друга, и каждый из них
располагается в своей области памяти. Если бы конструктор копирования не
69
был создан, то использовалась бы инициализация по умолчанию путем побитового копирования, что привело бы к тому, что массивы х и num разделяли
бы между собой одну и ту же область памяти.
Замечание: по сути, конструктор копирования выполняет роль оператора присваивания в случае, если инициализация объекта с помощью оператора присваивания происходит в момент его объявления.
Теперь, при использовании конструктора копирования можно смело
передавать объекты в качестве параметров функций и возвращать объекты.
При этом количество вызовов конструкторов будет совпадать с количеством
вызовов деструкторов, и, поскольку процесс образования копий теперь стал
контролируемым, существенно снижается вероятность неожиданного разрушения объектов.
Отметим, что в некоторых случаях компилятору разрешается отказаться от вызова конструктора копирования, выполняя при этом прямую инициализацию того или иного объекта. Этот процесс называется элизией.
Напомним, что помимо создания конструктора копирования есть другой, более элегантный и более быстрый способ организации взаимодействия
между функцией и программой, передающей объект. Этот способ связан с
передачей функцией не самого объекта, а его адреса.
3.6 Встраиваемые функции
Вернемся к рассмотрению нашего примера с классом Vector3D.
class Vector3D
{
double x, y, z;
public:
double mod()
{return sqrt (x*x + y*y +z*z);}
70
double projection(Vector3D r)
{return (x*r.x + y*r.y + z*r.z) / mod();}
Vector3D operator + (const Vector3D &b);
};
Vector3D Vector3D::operator+(const Vector3D &b)
{
return Vector3D(x + b.x, y + b.y, z + b.z);
}
Необходимо обратить внимание на то, где мы описываем тело того или
иного метода. Методы mod() и projection() описаны вместе со своими телами непосредственно внутри тела класса. Но можно поступить иначе: поместить прототипы методов внутрь класса, а определение тела функции оставить вне его, как мы поступили с оператором «+».
Первый способ используется для простых и коротких методов, которые
в дальнейшем не предполагается изменять. Так поступают отчасти из-за того,
что описания классов помещают обычно в файлы заголовков, включаемые
затем в прикладную программу с помощью директивы #include. Кроме
того, при этом способе машинные инструкции, генерируемые компилятором
при обращении к этим функциям, непосредственно вставляются в оттранслированный текст. Это снижает затраты на их исполнение, поскольку выполнение таких методов не связано с вызовом функций и механизмом возврата,
увеличивая в свою очередь размер исполняемого кода (то есть такие методы
становятся inline или встраиваемыми). Второй способ предпочтительнее
для сложных методов. Объявленные таким образом функции автоматически
заменяются компилятором на вызовы подпрограмм, хотя при добавлении
ключевого слова inline могут подставляться в текст, как и в первом случае.
71
Кроме представленного выше способа создания встраиваемых функций (записать тело метода непосредственно в структуре), есть еще один способ – вставить спецификатор inline перед определением метода:
inline Vector3D Vector3D::operator+(const Vector3D &b)
{
return Vector3D(x + b.x, y + b.y, z + b.z);
}
Теперь оператор «+» станет встраиваемой функцией.
Встраиваемые функции действуют почти так же, как и макроопределения с параметрами, но имеют перед последними ряд преимуществ. Во-первых, inline методы обеспечивают более стройный способ встраивания в
программу короткой функции. Во-вторых, компилятор C++ гораздо лучше
работает со встраиваемыми функциями, чем с макроопределениями.
Важно понимать, что inline не является командой для компилятора,
это скорее просьба сделать метод встраиваемым. Если по каким-то причинам
(например, при наличии в теле функции операторов цикла, switch или goto)
компилятор не выполнит запрос, то функция будет откомпилирована как не
встраиваемая.
3.7 Статические компоненты класса
Описатель static в языке C++ имеет различное назначение в зависимости от контекста, в котором он применен.
Переменные и функции, объявленные вне класса и вне тела функции с
описателем static, имеют область действия, ограниченную файлом, в котором они объявлены.
72
Переменные, объявленные как static внутри функции, видимы только
внутри этой функции, но сохраняют свои значения после выхода из функции
и инициализируются только при первом обращении к функции.
Компоненты класса также могут объявляться с описателем static.
Статические компоненты-данные являются общими для всех объектов этого
класса и размещаются в памяти отдельно от данных, принадлежащих конкретным объектам класса.
В случае, если static компоненты-данные находятся в открытой области класса, доступ к ним возможен по имени, уточненному именем класса
или именем объекта этого класса, причем к static компонентам класса
можно обращаться до создания экземпляров объектов этого класса. Однако,
статическая переменная-член класса предварительно должна быть обязательно инициализирована вне описания класса:
class TBase
// базовый класс для массивов всех типов
{
static int nw;
int size,
// размер элемента
count,
// текущее число элементов
maxCount,
// размер выделенной памяти
delta;
//приращение памяти
/* Другие компоненты класса TBase */
};
int TBase::nw =1; /* Инициализация статической переменной
класса */
Обратите внимание: данная инициализация статической переменной
класса не подпадает под действия спецификаторов доступа – мы должны
73
определить и инициализировать nw, несмотря на то что переменная имеет модификатор private.
Если описание класса производится в заголовочном файле (например,
Anything.h), то инициализация статической переменной, являющейся чле-
ном класса, обычно помещается в файл, содержащий код с описанием методов класса (например, в Anything.cpp). Если же класс определен непосредственно в файле Anything.cpp, то инициализация статического члена
обычно производится непосредственно под классом, как в приведенном
выше примере. Это связано с тем, что в случае, если заголовочный файл подключается в нескольких файлах создаваемого проекта, мы получим многократное определение одного и того же static члена, что приведет к ошибке
компиляции.
В случае, если статические переменные-члены класса описаны в закрытой его области, для получения доступа к ним извне можно использовать
статические методы, которые также могут вызываться до создания экземпляров объектов этого класса и имеют доступ только к статическим данным
класса:
class X
{
int x1, x2;
static int sx1, sx2;
public:
static void fsx(int k, int l)
{
sx1 = k;
sx2 = l;
};
/* Другие компоненты класса X */
};
74
int X::sx1=1;
int X::sx2=2;
void main()
{
...
X::fsx(3, 4);
...
}
3.8 Композиция классов
Одной из привлекательных особенностей объектно-ориентированного
программирования является возможность многократного использования
программного кода. Чтобы заново использовать существующий код, программист создает новый класс, но не «с нуля», а на базе готовых классов,
построенных и отлаженных кем-то другим. Один из способов повторного использования кода состоит в том, чтобы создавать объекты существующих
классов внутри новых классов. Такое взаимодействие классов называется
композицией.
Допустим у нас есть следующий класс, который должен использоваться многократно:
class X
{
int i;
public:
X() { i = 0; }
void set(int ii) { i = ii; }
int read() { return i; }
int permute() { return i = i * 47; }
75
};
Свойства класса X объявлены закрытыми, поэтому мы можем при необходимости абсолютно безопасно включить объект типа X в новый класс Y
даже в качестве открытой (public) переменной-члена, что значительно упрощает интерфейс класса:
class Y {
int i;
public:
X x;
// внутренний объект
Y() { i = 0; }
void f(int ii) { i = ii; }
int g() { return i; }
};
void main() {
Y y;
y.f(47);
y.x.set(37);
// обращение к внутреннему объекту
}
Обращение к функциям внутреннего объекта (подобъекта) в данном
случае просто требует дополнительного уточнения имени объекта.
На практике внутренние объекты чаще объявляются закрытыми, доступ к данным, находящимся внутри объектов, осуществляется посредством
использования набора методов, имеющихся в соответствующем классе, что,
в свою очередь, дает возможность при желании изменять реализацию открытого интерфейса нового класса (называемого композитным). При этом
функции открытого интерфейса нового класса работают с внутренним объектом, но не обязательно повторяют его интерфейс:
76
class Y {
int i;
X x;
// внутренний объект
public:
Y() { i = 0; }
void f(int ii) { i = ii; x.set(ii); }
int g() { return i * x.read(); }
void permute() { x.permute(); }
};
void main() {
Y y;
y.f(47);
y.permute();
}
В приведенном примере функция внутреннего объекта permute() переносится в интерфейс композитного класса, а остальные функции класса X используются в функциях класса Y.
Композиция обычно применяется тогда, когда новый класс должен
поддерживать некоторые возможности существующего класса, но не его интерфейс. Иначе говоря, объект внедряется для реализации функциональности нового класса, но пользователь класса работает с ним не через интерфейс
исходного класса, а через интерфейс, определенный программистом.
Обычно это делается включением закрытых объектов существующих классов в новый класс.
Тем не менее в отдельных случаях бывает разумно предоставить пользователю класса прямой доступ к внутренним объектам, для чего внутренний
77
объект объявляется открытым. Внутренние объекты сами контролируют доступ к себе, поэтому такое объявление безопасно. С другой стороны, когда
пользователь знает, из каких частей состоит объект, ему бывает проще понять его интерфейс. Рассмотрим класс Car («автомобиль»):
class Engine
{
public:
void start() {}
void rev() {}
void stop() {}
};
class Wheel {
public:
void inflate(int psi){}
};
class Window
{
public:
void rollup() {}
void rolldown() {}
};
class Door
{
public:
Window window;
void open() {}
void close() {}
};
78
class Car
{
public:
Engine engine;
Wheel wheel[4];
Door left, right;
};
void main(){
Car car;
car.left.window.rollup();
car.wheel[0].inflate(72);
}
Поскольку композиция Car входит в концептуальное представление
проблемы, объявление внутренних объектов открытыми помогает прикладному программисту, которому в дальнейшем предстоит использовать данный класс, разобраться в том, каким образом осуществлять работу с ним, и,
в то же время, упрощает процесс программирования создателю класса.
Путем композиции выражаются связи между объектами типа «a содержит b». В данном примере машина (Car) содержит мотор (Engine), четыре
колеса (Wheel) и две двери (Door), а дверь, в свою очередь, содержит окно
(Window).
При этом связи типа «a является частным случаем b» выражаются путем наследования.
79
Библиографический список
1.
Павловская Т.А. C/C++. Процедурное и объектно-ориентирован-
ное программирование / Т.А. Павловская : учебник для вузов. Стандарт 3-го
поколения – СПб. : Питер, 2015. – 496 с.
2.
Шилдт Г. Самоучитель C++ / Г. Шилдт; пер. с англ. - СПб. : БХВ-
Петербург, 1997. - 512с.
3.
Бабэ Б. Просто и ясно о Borland C++. Версии 4.0 и 4.5 / Б. Бабэ;
пер. с англ. - М. : БИНОМ, 1994. - 400с.
4.
Страуструп Б. Язык программирования С++ / Б. Страуструп; пер.
с англ. - М. : Радио и связь, 1995. - 352с.
5.
Клочков Д.П. Введение в объектно-ориентированное программи-
рование / Д.П. Клочков, Д.А. Павлов : учеб.-метод. пособие. – Изд-во Нижегород. ун-та, 1995. - 70с.
6.
Элиас М. Справочное руководство по языку С++ с комментари-
ями / М. Элиас, Б. Страуструп; пер. с англ. - М. : Мир, 1992. – 371с.
7.
Керниган Б. Язык программирования Си / Б. Керниган, Д. Ритчи.
- М. : Радио и связь, 1989. – 428с.
8.
Ирэ П. Объектно-ориентированное программирование с исполь-
зованием C++ / П. Ирэ; пер. с англ. – Киев : НИПФ ДиаСофт Лтд, 1995. –
386с.
9.
Павловская Т.А. C/C++. Процедурное и объектно-ориентирован-
ное программирование / Т.А. Павловская : учебник для вузов. Стандарт 3-го
поколения – СПб. : Питер, 2015. – 496 с.
10.
Шилдт Г. Самоучитель C++ / Г. Шилдт; пер. с англ. - СПб. : БХВ-
Петербург, 1997. - 512с.
11.
Бабэ Б. Просто и ясно о Borland C++. Версии 4.0 и 4.5 / Б. Бабэ;
пер. с англ. - М. : БИНОМ, 1994. - 400с.
80
12.
Страуструп Б. Язык программирования С++ / Б. Страуструп; пер.
с англ. - М. : Радио и связь, 1995. - 352с.
13.
Клочков Д.П. Введение в объектно-ориентированное программи-
рование / Д.П. Клочков, Д.А. Павлов : учеб.-метод. пособие. – Изд-во Нижегород. ун-та, 1995. - 70с.
14.
Элиас М. Справочное руководство по языку С++ с комментари-
ями / М. Элиас, Б. Страуструп; пер. с англ. - М. : Мир, 1992. – 371с.
15.
Керниган Б. Язык программирования Си / Б. Керниган, Д. Ритчи.
- М. : Радио и связь, 1989. – 428с.
16.
Ирэ П. Объектно-ориентированное программирование с исполь-
зованием C++ / П. Ирэ; пер. с англ. – Киев : НИПФ ДиаСофт Лтд, 1995. –
386с.
81
Содержание
Предисловие .................................................................................................... 3
1.
Объектно-ориентированный подход в программировании ................ 3
1.1 Технологии программирования..................................................................... 3
1.2 Сущность объектно-ориентированного подхода к
программированию ............................................................................................ 7
2. Введение в классы ..................................................................................... 16
2.1 Структуры и объединения ............................................................................. 16
2.2 Понятие класса .................................................................................................. 21
2.3 Конструкторы и деструкторы ...................................................................... 29
2.4 Конструкторы с параметрами, перегрузка конструкторов .............. 35
2.5 Список инициализации членов класса ...................................................... 40
3. Использование классов на практике ....................................................... 43
3.1 Указатели и ссылки на объекты .................................................................. 43
3.2 Указатель this ..................................................................................................... 46
3.3 Перегружаемые операторы ........................................................................... 48
3.4 Присваивание объектов .................................................................................. 52
3.5 Передача объектов в функцию и возвращение объекта.
Конструктор копирования ............................................................................. 60
3.6 Встраиваемые функции .................................................................................. 70
3.7 Статические компоненты класса ................................................................ 72
3.8 Композиция классов ........................................................................................ 75
Библиографический список ......................................................................... 80
82