1

advertisement
1
Концепция класса
Ключевым
понятием
объектно-ориентированного
проектирования
и
программирования является понятие класса.
Класс представляет собой обобщение однородных объектов реального мира.
Другими словами, класс описывает общие свойства и поведение некоторой
совокупности объектов. Свойства класса представлены в виде переменных класса, а его
поведение – в виде методов (функций). Например, в виде класса может быть
представлена концепция человека (персоны). Переменными такого класса будут: ФИО,
текущие паспортные данные, номер свидетельства о рождении и т.п. В основу методов
может быть положено следующее поведение: поступление на работу, получение
заработной платы и т.д.
Класс описывает свойства и поведение объектов статическим способом. Это
значит, что содержание класса не может быть изменено без изменения и повторной
сборки программного обеспечения. Экземпляры классов (объекты) в ходе работы
программы создаются и удаляются в вычислительной среде динамически, в соответствии
с кодом программы. Если класс – это описание свойств объектов, то экземпляр класса
отражает конкретный объект реального мира. Например, класс «человек» описывает
свойства всех людей, а отдельный экземпляр этого класса – соответствует конкретному
человеку.
Переменные класса
Класс содержит описание двух аспектов – структурного аспекта и аспекта
поведения. Остановимся на первом. Структура данных класса представляет собой
последовательность переменных. Порядок следования переменных важен и определяет
их расположение в памяти при создании объектов.
Как и другие переменные, переменная класса относится к некоторому типу и
имеет имя (уникальное в рамках класса). Тип переменной характеризуется размером, то
есть количеством байт, занимаемых каждым значением. В качестве типа переменной
могут быть использованы другие классы объектов. В этом случае нижестоящий объект
(являющийся переменной класса) входит как часть в вышестоящий объект. Приведем
пример класса на языке C++:
class Person {
int number;
-- Индивидуальный номер человека
char name[10];
-- Имя
int age;
-- Возраст
float height;
-- Рост
};
В соответствии с размером переменных на стадии компиляции для каждого класса
формируется описание расположения переменных в памяти относительно начала
объектов, например, следующего вида:
Имя
№
Тип переменной Название
Смещение
Размер
класса переменной
переменной
от начала
переменной
в классе
объекта
Person 1
int
Number
0
4
2
Person
Person
Person
2
3
4
Иллюстративно
следующим образом:
char[]
Name
int
Age
float
Height
структуру объектов класса в
4
10
14
4
18
4
памяти можно представить
0
number
4
name
14
age
18
height
22
Все объекты, принадлежащие одному классу, имеют одинаковый размер, равный
сумме размеров всех входящих переменных. Этот размер определяется на стадии
компиляции и остается постоянным до тех пор, пока в структуру данных класса не будут
внесены изменения.
Описанный аспект класса очень близок к понятию «структура» структурного
программирования. Но есть и отличия, которые будут изложены при обсуждении
управления доступом к переменным класса.
Методы класса
Второй аспект, описание которого содержит класс – это аспект поведения.
Поведение класса (точнее всех объектов, принадлежащих классу) описывается
совокупностью методов. Каждый метод представляет собой функцию, действующую в
контексте класса. Этот контекст используется компилятором в ходе поиска переменной
по ее имени. Когда компилятор встречает имя некоторой переменной в методе,
- сначала он ищет ей соответствие среди переменных, объявленных в рамках метода,
- затем среди переменных, объявленных в рамках того класса, к которому относится
данный метод,
- и только затем среди глобальных переменных:
Глобальные переменные
Класс
Метод
Рисунок 1 Последовательность поиска переменных в рамках метода по их имени
3
Как и функция структурного языка, метод объектно-ориентированного
языка может принимать ряд переменных (являющихся параметрами), и может
возвращать одну переменную (являющуюся результатом). Совокупность всех этих
переменных представляет собой сигнатуру метода.
В объектно-ориентированном программировании методы классифицируются по
различным критериям. Начнем рассмотрение с классификации по принципу
статичности. Так, все нестатические методы принимают в качестве неявного параметра
тот объект, в контексте которого они работают. Для таких методов использование
переменной класса подразумевает обращение к переменной именно того объекта,
который передан в качестве неявного параметра. Эти методы могут быть вызваны только
в контексте некоторого объекта. Невозможно вызвать нестатический метод «вообще», не
указав конкретный объект. Статические методы, наоборот, вызываются только вне
контекста какого-либо объекта. Они не имеют неявных параметров.
Приведем пример объявления нестатического метода в C++:
class Person {
float aMethod (int param1, float param2, char * param 3);
};
Такой метод будет обязательно иметь неявный аргумент – указатель на тот объект, для
которого он вызывается:
вход
Person*
int
float
char*
float
выход
В случае, если метод является статическим, к его объявлению добавляется
специальное ключевое слово static:
class Person {
static float aMethod (int param1, float param2, char * param 3);
};
Такой метод не будет иметь неявного аргумента:
вход
int
float
char*
float
выход
Переменные класса также классифицируются по критерию статичности.
Статической переменной считается переменная, объявленная в рамках класса, но не
включаемая в каждый объект этого класса. Статические переменные существуют в
единственном экземпляре для всех объектов класса. Даже если в памяти нет ни одного
экземпляра класса, статическая переменная все равно уже существует. Статические
переменные создаются в начале работы всей программы в целом и удаляются в конце ее
4
работы. Так как статические методы работают вне контекста конкретного
объекта, то они могут обращаться только к статическим переменным класса.
Так, если мы в любое место класса «персона» добавим статическую переменную
«количество персон»:
class Person {
int number;
-- Индивидуальный номер человека
static int personCnt; -- Количество персон
char name[10];
-- Имя
int age;
-- Возраст
float height;
-- Рост
};
то представление каждого объекта в памяти не изменится и останется тем же, что и без
статических переменных:
0
number
4
name
14
age
18
height
22
Добавленная статическая переменная будет создана в единственном экземпляре для всех
объектов в сегменте данных программы во время ее загрузки.
Следующим критерием классификации методов является их постоянство. Метод
считается постоянным, если в нем не происходит изменения значений переменных того
объекта, для которого он вызван. Приведем пример объявления постоянного метода в
C++:
class Person {
float aMethod (int param1, float param2, char * param 3) const;
};
Другие виды методов мы опишем после изложения принципа наследования
классов в объектно-ориентированном программировании.
Наследование
Механизм наследования является ключевым в концепции объектноориентированного программирования и сводится к следующему тезису: произвольный
класс (класс-потомок, класс-наследник) может быть объявлен как наследник другого
класса (класса-предка). При этом, потомок наследует все переменные и методы предка.
Таким образом, если мы объявим класс «водитель», как наследника класса «персона»,
5
class Driver: public Person {
char drivingLicence[10]; -- Номер водительского удостоверения
};
то в памяти каждый объект класса «водитель» будет представлять собой расширение
класса «персона» по составу переменных:
0
number
4
name
14
age
18
height
22
drivingLicence
32
То же самое происходит с методами этих классов: методы класса «персона» будут
входить в состав методов всех его наследников, в том числе класса «водитель».
В принципе, класс-потомок может быть наследован от нескольких классовпредков одновременно. Хотя в языках Java и C# на этот счет существуют ограничения,
которые мы обсудим позднее.
Динамические методы
Еще одним критерием классификации методов класса является свойство
динамичности. При обсуждении проблемы динамических методов, затронем тесно
связанный с этим вопрос динамического связывания.
Существует два вида связывания (вызова) методов класса: динамический и
статический. При статическом связывании компилятор указывает (в объектном коде
программы) непосредственно адрес вызываемого метода. Например, следующим
образом:
M1
M2
M3
M4
M5
Рисунок 2 Вызов методов при статическом связывании
где стрелки – вызов соответствующего метода по фиксированному адресу.
При динамическом связывании – адрес вызываемого метода вычисляется в
зависимости от того, к какому классу принадлежит тот объект, для которого он
вызывается. В этом случае на стадии компиляции в объектном коде фиксируется лишь
индекс (номер) динамического метода в рамках так называемой таблицы виртуальных
методов. Таблица виртуальных методов создается компилятором для каждого класса,
6
содержащего динамические методы, и содержит указатели на все динамические
методы этого класса (каждый по своему индексу). Таблица виртуальных методов
загружается в момент инициализации программы из ее объектного кода.
Для того, чтобы определить ту таблицу виртуальных методов, которая должна
использоваться при вызове методов конкретного объекта, объекты всех классов,
содержащих динамические методы, включают дополнительную переменную,
указывающую на таблицу виртуальных методов. Например, если класс «персона» имеет
динамические
методы, то все его объекты будут представлены в памяти с
использованием дополнительной переменной vmtPtr (указатель на таблицу виртуальных
методов), не объявленную в явном виде среди переменных класса:
0
number
4
name
14
age
18
height
22
vmtPtr
30
Эта дополнительная переменная заполняется каждый раз при создании в памяти
очередного объекта. Код по ее заполнению встраивается в программу на стадии
компиляции автоматически, прозрачно для программиста. Эта функция компилятора
основана на том, что зная, к какому классу относится конкретный создаваемый объект,
компилятор может определить адрес соответствующей таблицы виртуальных методов,
создаваемой им самим же. В результате, каков бы ни был тип указателя на объект,
переменная vmtPtr будет содержать указатель на таблицу виртуальных методов
именного того класса, объект которого был создан. Например, мы можем создать класс
«водитель» и присвоить указатель на него типу «указатель на персону»:
Person * p = new Driver;
Несмотря на то, что на объект указывает переменная p типа «указатель на персону»,
скрытая переменная vmtPtr этого объекта будет содержать указатель на таблицу
виртуальных методов класса «водитель».
При динамическом связывании компилятор автоматически встраивает в код
программы следующий способ вызова динамического метода на основании его индекса:
- по указателю на объект, в контексте которого вызывается метод, берется указатель на
таблицу виртуальных методов (с помощью дополнительной переменной, описанной
выше);
- по индексу динамического метода берется указатель на вызываемый динамический
метод;
- вызывается метод по полученному указателю.
7
Выше мы изложили понятие динамического связывания, теперь дадим
определение динамическому методу. Динамический метод – это метод класса, к
которому применяется метод динамического связывания, если явно не указано обратное
– необходимость использовать статическое связывание. Все динамические методы
класса присутствуют в его таблице виртуальных методов. Конечный метод является
противоположностью динамического метода в том смысле, что к нему динамическое
связывание никогда не применяется. Конечные методы отсутствуют в таблице
виртуальных методов. Разницу между динамическим и конечным методам можно
проиллюстрировать на следующем примере на языке C++:
Person * p = new Driver;
p->aMethod();
Допустим, что в классе «персона» и классе «водитель» объявлено два метода aMethod с
одинаковой сигнатурой. В том случае, если методы aMethod являются динамическими в
обоих классах, будет вызван метод класса «водитель» в соответствии с правилом
динамического связывания. Если же методы являются конечными, то будет вызван
метод класса «персона» в соответствии с правилом статического связывания (так как
переменная p указывает на объект класса «персона»).
Возможность использования индекса метода в таблице виртуальных методов в
качестве ссылки на него основана на том, что если динамические методы в базовом и
производном классах имеют одинаковую сигнатуру, то в таблицах виртуальных методов
для обоих классов эти методы будут иметь одинаковый индекс. Например, для классов
«персона» и «водитель» таблицы виртуальных методов будут содержать методы aMethod
(если они динамические) с одинаковыми индексами:
Person VMT
…
…
23 Person::aMethod
…
…
M1
M2
Driver VMT
…
…
23 Driver::aMethod
…
…
Person::aMethod Driver::aMethod
В нашем примере одновременно используется динамическое и статическое связывание
методов. M1 и M2 связываются статически. M1 и Person::aMethod, Driver::aMethod
связываются динамически. На стадии компиляции в объектный код M1 встраивается
вызов динамического метода по индексу 23. Так как все такие методы имеют
одинаковую сигнатуру, то на процессе передачи параметров динамическое связывание
никак не сказывается. На стадии выполнения программы, в зависимости от того, на
какую таблицу виртуальных методов ссылается конкретный объект, вызывается либо
динамически метод Person::aMethod, либо Driver::aMethod.
При этом, для динамического метода может быть указана необходимость
использования статического связывания в явном виде. Например, на языке C++ для
вышеприведенного примера это будет выглядеть так:
Person * p = new Driver;
8
p->Person::aMethod();
В этом случае, какими бы не были методы aMethod – динамическими или конечными, –
будет вызван метод класса «персона». Например, даже если методы aMethod являются
динамическими, будет использовано статическое связывание:
Person VMT
…
…
23 Person::aMethod
…
…
M1
M2
Driver VMT
…
…
23 Driver::aMethod
…
…
Person::aMethod Driver::aMethod
Существует частный случай динамического метода – абстрактный метод.
Абстрактный метод отличается тем, что он не имеет реализации (тела), для него задается
только объявление (заголовок). Класс, содержащий абстрактные методы, также является
абстрактным. В программе нельзя создать объекты абстрактного класса – будет выдана
ошибка на стадии компиляции, – но можно объявлять указатели на абстрактные классы.
Заметим, что существование указателя на абстрактный класс никак не подразумевает
создание объекта абстрактного класса, так как указатель на базовый класс может
указывать на объект производного класса. Например, если класс «персона» является
абстрактным, то
Person * p = new Driver;
допустимо, а
Person * p = new Person;
недопустимо.
Абстрактные классы используются для описания обобщенных концепций, для
содержательного использования которых необходимо программировать реализацию
конкретных динамических методов. Если на стадии написания программы ясно, что
создавать объекты некоторого класса не имеет смысла, то такой класс целесообразно
объявить абстрактным. Но такой шаг не запрещает создавать массивы указателей на
абстрактные классы и обрабатывать их унифицированным образом, вне зависимости от
того, объект какого именно класса располагается по каждому из указателей массива.
Например, допустим класс «персона» является абстрактным и у него есть два
производных класса: «водитель» и «спортсмен». Тогда мы можем объявить массив
указателей на объекты класса «персона»,
Person * p[];
каждому указателю которого может быть присвоен как адрес объекта класса «водитель»,
так и адрес объекта класса «спортсмен»:
9
Person*
Driver 4
Person*
Sportsman 1
Person*
Sportsman 3
Person*
Driver 1
Person*
Driver 2
В результате, массив персон может обрабатываться унифицированным образом вне
зависимости от истинного типа объекта, расположенного в памяти. Объекты классов
«водитель» и «спортсмен» умышленно изображены на рисунке разного размера, чтобы
подчеркнуть тот факт, что в памяти они могут занимать разное количество байт.
Конструктор и деструктор
Конструктор представляет собой нестатический конечный метод, вызываемый в
момент создания объекта для его инициализации. Особенностью конструктора является
то, что он не может иметь возвращаемой переменной. Код вызова конструктора
вставляется в объектный код программы компилятором автоматически, прозрачно для
программиста, каждый раз, когда выполняется команда создания нового объекта (будь
то в куче или стеке, см. ниже про виды памяти).
Класс может иметь несколько конструкторов, отличающихся сигнатурой. Если в
ходе создания объекта в его конструктор не передаются параметры, то вызывается
конструктор без параметров, называемый конструктором по умолчанию. Если для класса
не определен конструктор по умолчанию, то объект создается в памяти без выполнения
инициализации, что приводит к случайным значениям его переменных (кроме неявного
указателя на таблицу виртуальных функций – он заполняется верным значением в
любом случае). Например, в следующем случае будет вызван конструктор по
умолчанию, если он объявлен в классе «водитель»:
Person * p = new Driver;
Если в ходе создания объекта параметры конструктора указаны явно, например,
Person * p = new Driver (10);
то будет вызван конструктор, сигнатура которого соответствует передаваемым
параметрам. Если такой конструктор не будет найден, или возникнет неоднозначность,
будет выдана соответствующая ошибка компилятора.
В случае наследования, до вызова конструктора производного класса обязательно
вызывается конструктор базового класса. При этом, если конструкторов базового класса
несколько, то в объектно-ориентированном языке существует возможность указать
какой именно конструктор вызывать. Например, на языке C++ это можно сделать
следующим образом:
class Driver: public Person {
Driver ():Person(0){}
};
если для класса «персона» объявлен конструктор, принимающий целое число в качестве
параметра.
10
Если же не указан ни один из конструкторов, то вызывается конструктор
по умолчанию. Даже если в производном классе не объявлен конструктор, конструктор
по умолчанию базового класса все равно будет вызван. Например, в случае
class Driver: public Person {
};
при создании объекта класса «водитель» всегда будет вызываться конструктор по
умолчанию для класса «персона», если он объявлен.
Деструктор представляет собой нестатический метод, вызываемый в момент
уничтожения объекта. Деструктор, как и конструктор, не имеет возвращаемой
переменной. К тому же, деструктор не имеет параметров, и класс может иметь только
один деструктор. Код вызова деструктора вставляется в объектный код программы
компилятором прозрачно для программиста каждый раз, когда некоторый объект
прекращает свое существование (будь то в стеке или куче). Если для класса деструктор
не задан, то память, выделенная под объект, освобождается без каких-либо
дополнительных действий. В случае, если объект захватил некоторые ресурсы
(например, память или файловые дескрипторы), отсутствие деструктора приведет к
тому, что они будут потеряны для дальнейшего использования в программе. Поэтому,
для корректной работы программы, классы, захватывающие ресурсы, необходимо
дополнять деструктором, освобождающим эти ресурсы.
В случае наследования, компилятор для уничтожения объекта помещает в
объектный код программы вызов как деструктора производного класса, так и
деструктора базового. При этом, сначала выполняется вызов деструктора производного
класса. Только после вызова деструкторов всех предков, вызывается деструктор самого
класса, после чего память, выделенная под объект, освобождается.
Деструктор может быть динамическим. В этом случае, в ходе удаления объекта,
будет выполнена последовательность вызовов деструкторов именно для того объекта,
который располагается в памяти, а не того объекта, на который показывает указатель.
Эту особенность можно проиллюстрировать на следующем примере:
Person * p = new Driver;
delete p;
В этом примере, если деструктор класса «персона» виртуальный, то в ходе уничтожения
объекта, в соответствии с правилом динамического связывания и правилом вызова
деструкторов, будет вызван сначала деструктор класса «персона», затем деструктор
класса «водитель». Если деструктор класса «персона» не является виртуальным, то в
ходе уничтожения объекта, в соответствии с правилом статического связывания и
правилом вызова деструкторов, будет вызван только деструктор класса «персона», так
как удаляемый указатель показывает на объект класса «персона».
Виды памяти и создание объектов
Во время работы программы переменные (в том числе объекты) могут создаваться
в различных местах оперативной памяти: в сегменте данных, в стеке и в куче. Эти виды
памяти отличаются принципом создания переменных в них.
В сегменте данных создаются переменные, существующие все время от начала
работы программы до завершения. В эту категорию попадают все статические
11
переменные (в том числе глобальные). С точки зрения программиста эти
переменные всегда существуют и могут быть доступны.
Стек используется для передачи параметров в методы и для создания внутренних
переменных методов, существующих только во время выполнения метода или его части.
Создание переменных в сетке встраивается в объектный код программы компилятором и
происходит прозрачно для программиста. Обращение программиста к переменным
метода компилятор преобразует в обращение к стеку по фиксированному смещению от
вершины (вглубь стека).
При вызове метода ее параметры помещаются на вершину стека. Если в методе
объявлены другие переменные, то они также помещаются на вершину стека. В момент
компиляции команды с обращением к той или иной переменной компилятор вычисляет
по какому смещению располагается эта переменная относительно вершины стека и
встраивает в объектный код программы ссылку на позицию в стеке по фиксированному
смещению от вершины. При завершении работы метода или вложенного блока
операторов, все переменные, объявленные в этом методе (или блоке), снимаются с
вершины стека. Например, если в программе объявлены следующие функции:
void methodA (int x, float y)
{
int c;
methodB (x, y, c);
…
-- Точка X
}
void methodB (int x, float y, int d)
{
int c;
…
-- Точка Y
}
то в точке Y в стеке будут существовать следующие переменные:
methodB::c
methodB::d
methodB::y
methodB::x
methodA::c
methodA::y
methodA::x
А в точке X следующие:
methodA::c
methodA::y
methodA::x
Использование кучи зависит от языка программирования. Например, в C++
создание переменных в куче полностью управляется программистом. После создания
12
переменной программисту доступен указатель на ее начало. За удаление
переменной в конце ее жизненного цикла отвечает сам программист, что служит
причиной большого количества ошибок, приводящих к утечке памяти. В Java, наоборот,
использование кучи прозрачно для программиста: если переменная представляет собой
сложный объект, то она автоматически размещается в куче. Когда последняя ссылка на
такой объект удаляется, он помечается неиспользуемым. В Java-машине существует
регулярный процесс, называемый сборщиком мусора, который и отвечает за
освобождение памяти из-под неиспользуемых объектов.
Если переменная представляет собой сложный объект, то во время ее создания
вызывается конструктор, а во время уничтожения – деструктор, где бы она не
находилась: в сегменте данных, в стеке или в куче.
Управление доступом к переменным и методам класса
Для переменных и методов класса может быть задан уровень доступа,
регламентирующий доступ к переменным и методам одного класса из методов другого
класса. Наиболее жестким является личный уровень доступа, дающий разрешение на
доступ к переменным и методам только для методов самого класса. Наименее жестким –
общий уровень доступа, дающий разрешение на доступ из любой точки программы.
Средним вариантом является личный защищенный уровень доступа, когда доступ
разрешен только для методов самого класса и его наследников. Уровень доступа в
классе-потомке к переменной или методу класса-предка может быть изменен в сторону
ужесточения. В зависимости от языка программирования промежуточные уровни
доступа могут различаться. Так, например, в Java существует два дополнительных
уровня доступа. В случае нарушения программистом регламента доступа к переменным
во время компиляции программы будет выдано сообщение об ошибке. Приведем пример
задания уровней доступа для переменных класса на языке C++:
class Person {
private: -- Личный уровень доступа
int number;
-- Индивидуальный номер человека
public: -- Общий уровень доступа
char name[10];
-- Имя
protected: -- Личный защищенный уровень доступа
int age;
-- Возраст
float height;
-- Рост
};
В объектно-ориентированных языках программирования существуют механизмы
описания исключений из перечисленных правил доступа. Первый механизм заключается
в объявлении дружественности класса. Если некоторый класс A объявлен как
дружественный некоторому классу B, то все методы A могут обращаться ко всем
методами и переменным класса B, независимо от их уровня доступа. Например,
class B {
…
friend class A; -- Объявление дружественного класса.
};
13
Второй механизм позволяет объявлять лишь некоторые методы класса A как
дружественные классу B. Это приводит к возможности обращаться ко всем переменным
и методам класса B, независимо от заданного для них уровня доступа, только в рамках
этих методов. Например,
class B {
…
friend A::methodX; -- Объявление дружественного метода
};
Интерфейс
Интерфейс представляет собой частный случай абстрактного класса, все методы
которого являются абстрактными, и который не имеет переменных класса. Синтаксис
объявления интерфейсов различается в разных языках программирования. Так, в C++
вообще нет специального синтаксиса для интерфейсов и они объявляются подобно
другим классам. А в Java, наоборот, существует специальное ключевое слово interface
для объявления интерфейсов. При этом в Java и C# налагается ограничение, что у класса
может быть только один предок, не являющийся интерфейсом, и произвольное
количество предков-интерфейсов. Также в этих языках существует ограничение, что
предками интерфейса могут быть только интерфейсы (предков может быть несколько). В
C++ подобных ограничений нет. Приведем простой пример интерфейса на языке C++:
class Interchanger {
virtual int get ()=0;
virtual void put ()=0;
};
Исключения
В объектно-ориентированном языке существует два механизма сообщения об
ошибке, возникшей в методе. Первый, традиционно используемый в структурном
программировании, заключается в кодировании всех возможных ошибок и возврате кода
результата в качестве выходной переменной метода. Недостатками этого подхода
являются:
- Необходимость поддержания списка констант со всеми возможными кодами
результата.
- Необходимость при вызове каждого метода проверять возвращаемое им значение.
При этом, программа превращается в наслоение множества вложенных if или switchоператоров, теряется ее читабельность.
Этот подход можно проиллюстрировать на таком примере:
if (methodA()<>ERROR) {
if (methodB()<>ERROR) {
…}
else {}
else {}
От перечисленных недостатков свободен второй механизм сообщения о
критических ошибках – механизм исключений. Исключение представляет собой
14
механизм обработки ошибочных ситуаций без использования кодов возврата из
метода и включает в себя два аспекта: аспект описания обработки исключений и аспект
генерации исключений.
Для описания обработки исключений программист определяет в рамках метода
три типа блоков операторов: блок испытания, блок обработки исключений, финальный
блок. Блок испытания объявляется с использованием ключевого слова try.
Непосредственно за блоком испытаний следует один или несколько блоков обработки,
объявляемых ключевым словом catch и, при необходимости, один финальный блок,
объявляемый ключевым словом finally. Блок обработки может иметь одну переменную в
качестве параметра. При генерации исключения обязательно указывается переменная,
значение которой является параметром исключения. Генерация исключения
осуществляется при помощи ключевого слова throw. Приведем пример описания
обработки исключений на языке С++:
try {
methodA();
methodB();
}
catch (Error e) {…}
finally {…}
Если сравнить этот пример с предыдущим, то наглядно видно, что второй механизм
обработки исключений приводит к более читабельному коду программы. Особенно, если
блок try достаточно большой (при использовании первого механизма мы получили бы
операторы ветвления очень большой вложенности).
Выполнение совокупности блоков исключений происходит следующим образом.
Начинается выполнение с блока испытания: операторы исполняются стандартным
образом до момента генерации исключения при исполнении одного из операторов (этим
оператором может быть явная генерация исключения throw, вызов некоторого метода
или математическая ошибка, к примеру, деление на ноль). В момент генерации
исключения по типу переменной-параметра определяется блок обработки, которому и
передается управление. После выполнения блока обработки в обязательном порядке
выполняется финальный блок, если он задан; затем выполнение метода продолжится с
оператора, расположенного после совокупности блоков исключений. Проиллюстрируем
эту последовательность переходов между блоками на диаграмме:
try
catch 1
catch 2
catch 3
finally
В случае, если блок обработки с типом переменной сгенерированного исключения
не найден, выполнение текущего блока испытания будет прервано, выполнится
финальный блок, после чего необработанное исключение будет передано для обработки
в вышестоящем блоке испытаний, в рамках которого произошел вызов данного блока.
15
try
catch 1
catch 2
catch 3
finally
Пунктирная линия показывает логический переход к вышестоящему блоку испытания в
результате генерации исключения.
Если некоторый блок является корневым, то есть для него не существует
вышестоящих, то будет выполнен обработчик исключений по умолчанию, который
выдаст содержимое параметра исключения и завершит работу программы.
try
catch 1
catch 2
catch 3
finally
default handler
Поиск блока обработки по типу параметра исключения происходит
последовательно, в порядке их описания. Поэтому блоки обработки, входные
переменные которых имеют тип классов-потомков, должны идти раньше блоков
обработки классов-предков. Иначе они никогда не будут выполняться. Последним
блоком обработки может идти блок без входной переменной. Такой блок обрабатывает
все исключения с любыми параметрами.
Во всех объектно-ориентированных языках существует специальный синтаксис
оператора throw, позволяющий в рамках любого обработчика исключений повторить
генерацию обрабатываемого исключения. В этом случае не требуется явно указывать
переменную исключения в качестве параметра – автоматически будет использована
текущая переменная. При этом перед тем, как сгенерированное исключение будет
передано вышестоящему блоку испытания, обязательно будет выполнен финальный
блок для текущего блока испытания. Проиллюстрируем это.
try
catch 1
catch 2
catch 3
finally
Если в ходе исполнения блока испытания ни один из операторов не привел к
генерации исключения – будет выполнен финальный блок из совокупности блоков
исключений, а затем выполнение метода продолжится с того оператора, который следует
после совокупности блоков исключений.
try
catch 1
catch 2
catch 3
finally
Программный код, реализующий описанную логику, встраивается в объектный
код программы компилятором прозрачно для программиста. При этом используются
такие понятия, как фрейм исключения и стек фреймов. Фрейм исключения существует
для каждого блока испытания и представляет собой совокупность переменных стека,
которые были созданы в рамках данного блока испытания.
16
В момент начала выполнения вложенного блока испытания срабатывает
встраиваемый код, отмечающий в стеке начало следующего фрейма исключения. В
момент завершения выполнения – эта метка снимается. В результате образуется стек
фреймов путем логического разделения стека переменных на части. Например, для
следующего программного кода на языке C++:
try { -- Начало фрейма X
int x, y, z;
try { -- Начало фрейма Y
int a, b, c;
… -- Точка P
} catch (…) {…}
} catch (…) {…}
в точке P стек переменных и фреймов будет представлять собой следующее:
f
e
d
c
b
a
…
фрейм Y
метка начала
фрейма Y
стек
фреймов
фрейм X
метка начала
фрейма X
Когда выполнение некоторого блока испытания прерывается исключением, все
переменные входящие в его фрейм удаляются из стека, что приводит к автоматическому
откату всех методов, вызванных в этом блоке (любой степени вложенности). Далее на
вершину стека помещается параметр исключения и по специальным структурам
осуществляется поиск необходимого блока обработки (соответствующего текущему
блоку испытания, данные о котором хранятся в метке фрейма исключения, снятого с
вершины стека). Если блок обработки найден – передается управление ему, если не
найден – с вершины стека снимается очередной фрейм. В конце каждого обработчика
встраивается код, выполняющий поиск и выполнение финального блока для текущего
блока испытания, а также переход к оператору метода, следующему за совокупностью
блоков исключений. Одновременно, в конце каждого блока испытания встраивается
вызов финального блока и переход на оператор метода, следующий после блоков
исключений. Это приводит к тому, что при выполнении блока испытания без генерации
исключений исполняется финальный блок и работа метода продолжается.
Шаблоны
В объектно-ориентированном программировании существует два вида шаблонов:
шаблон класса и шаблон функции. Начнем рассмотрение с шаблона класса. Шаблон
класса представляет собой описание структуры класса (как структуры данных, так и
методов), используемое компилятором для автоматического конструирования новых
классов. Шаблон класса определяется одним или несколькими параметрами шаблона и
имеет структуру, полностью совпадающую со структурой класса. Параметры шаблона
используются в шаблоне тем же образом, что и типы переменных в классе. Это
17
позволяет компилятору в процессе конструирования нового класса подставлять
вместо параметров шаблона конкретные типы, получая работоспособный класс,
принципиально не отличающийся от классов, явно объявленных программистом. В
качестве примера шаблона класса приведем следующий шаблон:
template <class A, class B>
class Entity {
A var1;
B var2;
};
Шаблоны классов используются так же, как и классы, с тем отличием, что для них
обязательно указываются типы переменных, которые необходимо использовать в
качестве параметров шаблона. Например, для того, чтобы объявить переменную e класса
Entity с параметрами типа int и float необходим следующий программный код:
Entity<int, float> e;
Новый класс конструируется в ходе компиляции в тот момент, когда
использование шаблона с заданным сочетанием параметров встречается в первый раз.
Для каждого сочетания параметров шаблона будет создан свой собственный класс.
Далее, когда данный шаблон с тем же сочетанием параметров будет встречен
компилятором повторно, будет использован ранее сгенерированный класс. Таким
образом, в процессе компиляции для шаблона Entity может быть создано большое
количество классов:
Шаблон класса
Классы
Entity <int, float>
Entity <float, int>
Entity <A, B>
Entity <int, int>
Entity <int, Person>
Entity <Person, Person>
Далее рассмотрим второй вид шаблонов: шаблон функции. Шаблон функции, как
и шаблон класса, имеет один или несколько параметров, которые могут использоваться в
теле функции подобно типам. В ходе компиляции из шаблона функции конструируются
функции – по одной для каждого сочетания параметров шаблона, встреченного в
исходном тексте программы. При этом параметры шаблона заменяются на конкретные
типы по всему телу функции. Например, шаблоном функции на языке C++ является:
template <class T>
T Mid (T x, T y) { return (x+y)/2; }
Данный шаблон может быть использован посредством указания конкретного типа в
качестве его параметра:
float x=Mid<float> (a, b);
В ходе компиляции для этого шаблона могут быть созданы, например, следующие
функции:
18
Шаблон функции
Функции
Mid <float>
Mid <int>
Mid <T>
Mid <Light>
Mid <Point>
Mid <Point3d>
Сравнение объектно-ориентированных языков
C++
Множественное наследование
Типы переменных
Отсутствие интерфейсов
Статическая таблица виртуальных методов
Именование конструкторов, деструкторов
Конструктор копирования
Объявление динамических методов, абстрактных, постоянных
Личное и общее наследование
Структуры как классы
Переопределение операций
Шаблоны
Java
Динамическая таблица виртуальных методов
Синхронизированные методы
C#
call back функции
Лекция №2
1. Классы
Концепция модуля базируется на локализации определенного достаточно
самостоятельного фрагмента программы и разделении его на две части – реализацию
модуля и интерфейс к внутренним данным. Класс, являясь развитием концепции модуля,
унаследовал от него схожую структуру. Интерфейс представляет набор функций,
позволяющих пользователю класса управлять объектом такого типа.
Надо заметить при этом, что при правильной разработке программы класс (в
отличие от модуля) является формализацией некоторой концепции (например,
"матрица"), а не просто изолированным фрагментом программы.
Очень важной особенностью класса является то, что доступ к внутренним данным
возможен лишь через интерфейс и никак больше. Это обеспечивает существенные
19
преимущества. Любая ошибка, которая приводит к неверным результатам во
внутренних данных, может находиться только в функциях, образующих интерфейс.
Кроме того, не используя специальных средств, таких, как манипуляции с памятью,
нельзя получить доступ к объектам, составляющим внутренние данные класса, что
является надежной их защитой.
Еще одним преимуществом подобной организации класса является то, что
пользователю класса для нормальной работы с ним в общем случае достаточно знать
лишь декларации функций, составляющих интерфейс этого класса.
Класс является типом, созданным программистом. Доступ к внутренним данным
класса возможен лишь с помощью функций, являющихся частью этого класса, или
функций специального вида (привилегированных функций). И данные, и функции,
составляющие класс, называются членами класса. Среди функций-членов класса могут
присутствовать специальные функции, управляющие инициализацией объектов такого
типа – конструкторы, и функции, управляющие уничтожением объектов – деструкторы.
Рассмотрим в качестве примера концепцию "строка символов".
class Str
{
char str[80];
unsigned char att;
int row, col;
public:
void setStr (char*);
void setAtt (unsigned char);
void setCoord (int, int);
void printStr (int=0, int=0);
};
Метка public, которая может присутствовать в декларации класса, в нашем
примере делит его тело на две части – "личную" (private) и "общую" (public). Доступ к
данным-членам класса и функциям-членам класса, находящимся в личной части,
возможет лишь через функции-члены класса, находящиеся в общей части.
В декларации класса могут присутствовать также метки private и protected. Они
делят тело класса в общем случае на три части, различающиеся по уровню доступа. К
членам класса, находящимся в личной части, доступ возможен только с помощью
функций-членов класса и так называемых привилегированных (friend) функций, к общим
же членам класса можно обратиться из любой функции программы. Метка protected
ведет себя подобно private с отличиями для производных классов, что будет рассмотрено
позже.
Функции-члены класса могут вызываться только после того, как в программе
создан объект соответствующего типа; они вызываются только для конкретного объекта,
и выполняемые ими действия никак не могут влиять на состояние других объектов этого
типа.
Декларации функций-членов класса должны быть расположены в теле класса. В
зависимости от того, в какой части описания класса находится декларация, она
становится личным, общим или защищенным членом класса. Если дефиниция функции-
20
члена класса находится внутри декларации класса, то внешне она ничем не
отличается от дефиниции обычной функции:
class Str
{
// …
void setStr (char *s) { strcpy (str, s); }
// …
};
Иначе обстоит дело, если в описании класса находится только декларация
функции-члена класса, а ее дефиниция приведена в другом месте программы. В этом
случае в связи с тем, что различные классы могут иметь функции-члены класса с
одинаковыми именами, в заголовке указывается имя класса, к которому относится
данная функция-член класса:
class Str
{
// …
void setStr (char *s) ;
// …
};
void Str::setStr (char*)
{
strcpy (str, s);
}
Доступ к функциям-членам класса осуществляется точно также, как и к даннымчленам класса, т.е. с использованием стандартной мнемоники обращения к элементу
структуры, принятой в языке C. Точно так же, как в C различные структуры могли
содержать элементы с одинаковыми именами, в C++ различные классы могут иметь
одноименные члены.
Данные-члены класса почти аналогичны элементам структур языка C. Различия
состоят только в следующем: во-первых, с именем члена класса сопоставлен один из
трех уровней доступа, а во-вторых, в C++ могут существовать статические данныечлены класса.
Сама по себе декларация класса не приводит к резервированию места в памяти.
Место в памяти выделяется при создании объектов указанного класса. При вызове
функции-члена класса ей в качестве неявного аргумента передается указатель на тот
объект, для которого она вызвана; он всегда имеет имя this и его не нужно (и
невозможно) определять явно.
В C++ предусмотрены специальные функции-члены класса, которые в
большинстве случаев вызываются не программистом, а компилятором языка. Функции,
выполняющие
инициализацию
объектов
абстрактных
типов,
называются
конструкторами, а функции, "уничтожающие" такие объекты – деструкторами.
Невозможно ни создать объект абстрактного типа без вызова конструктора, ни
уничтожить его без вызова деструктора. Таким образом, такой объект всегда будет
инициализированным. Конструктор имеет имя, совпадающие с именем класса и не имеет
21
возвращаемого значения. Имя деструктора начинается с символа ~, за которым
следует имя класса. Деструктор также не имеет возвращаемого значения.
Хотя конструкторы и деструкторы являются функциями-членами класса, они
имеют определенные отличия от обычных функций-членов класса:
а) Конструкторы и деструкторы имеют строго определенные имена.
б) Конструкторы и деструкторы не могут возвращать никаких значений: в их декларации
не указывается никакой тип возвращаемого результата, даже void.
в) Конструкторы могут иметь аргументы, в том числе аргументы по умолчанию;
деструкторы аргументов не имеют. Конструктор класса X не может иметь аргументы
типа X, хотя аргументы типа X* и X& возможны.
г) Нельзя получить адрес ни конструктора, ни деструктора.
д) Вызов конструктора происходит автоматически во время декларации объекта
абстрактного типа, никаким другим образом вызвать конструктор нельзя; вызов
деструктора происходит автоматически при выходе объекта абстрактного типа из
области своего существования, но деструктор может быть вызван и явно, но с
обязательным указанием его полного имени.
Полное имя члена класса – это имя члена, которому предшествует имя класса и
символ ::. Оно используется, когда необходимо отличить, например, член класса от
глобального объекта с тем же именем. Перед символом :: может отсутствовать имя
класса, тогда полное имя рассматривается как имя глобального объекта (или функции).
Если член класса объявлен как static, то он становится полноценным
самостоятельным объектом; при этом его имя имеет область существования "класс". Все
объекты, сколько бы их не было, используют заранее созданную одну-единственную
копию своего статического члена. К статическим членам класса можно обратиться и как
к обычным членам – по имени или указателю на созданный объект с использованием
стандартной мнемоники обращения к члену класса, так и по его полному имени – даже в
том случае, если в программе не создан ни один объект класса X. Статические члены
класса могут иметь любой из трех уровней доступа.
Использование слова static применительно к именам функций имеет несколько
другой смысл, чем его использование применительно к именам объектов. Статическая
функция-член класса может быть вызвана для получения доступа к статическому
данному-члену класса X, причем может не существовать объектов типа X. Для нее не
определен указатель this.
2. Переопределение операций
Для того, чтобы переопределить одну из стандартных операций языка C++ для
работы с операндами абстрактных типов, программист должен написать функцию с
именем operator@, где @ - обозначение этой операции. При этом в языке существуют
несколько ограничений:
a) программист не может придумывать свои символы операций;
b) не могут быть переопределены операции: ::, *, ?:
c) символ унарной операции не может быть использован для переопределения бинарной
операции и наоборот;
d) переопределение операций не меняет ни их приоритетов, ни порядка выполнения;
e) при переопределении операции компилятор не делает никаких предположений о ее
свойствах (коммутативность и т.п.);
22
f) для переопределенных операций ++ и -- префиксная форма не отличается от
постфиксной;
g) никакая операция не может быть переопределена для операнда стандартных типов.
Функция operator@ () является самой обыкновенной функцией, которая может
содержать от 0 до 2 явных аргументов. Эта функция может быть, а может и не быть
функцией-членом класса. При этом, если операция оформлена функцией-членом класса,
то в нее подается указатель на объект this в качестве неявного аргумента.
Для выполнения переопределенной унарной операции @x (или x@), где x –
объект некоторого абстрактного типа Class, компилятор попробует найти либо функцию
Class::operator@(void), либо ::operator@(Class); если найдены одновременно оба
варианта, то выдается сообщение об ошибке. Интерпретация выражения @x
осуществляется либо как x.operator@(void), либо как operator@(x).
Для выполнения переопределенной бинарной операции x@y, где x обязательно
является объектом абстрактного типа Class, компилятор ищет либо функцию
Class::operator@(type y), либо функцию ::operator@(Class x, type y), причем type может
быть как абстрактным, так и стандартным типом. Интерпретируется выражение x@y
либо как x.operator@(y), либо как operator@(x,y).
Как для унарной, так и для бинарной операций число аргументов (явных и
неявных) функции operator@ () точно должно соответствовать числу операндов этой
операции – быть не больше и не меньше.
C++ разрешает использование глобальных функций, у которых первый операнд
имеет стандартный тип. Посредством функции-члена класса задание такой функции
невозможно.
Функции, реализующие операции =, [], (), -> должны быть членом класса.
23
Лекция №3
1. Введение в наследование
Применение абстрактных типов, хорошо согласующихся с особенностями
конкретной решаемой проблемы, дает существенные преимущества по сравнению с
использованием только типов данных, связанных с организацией ЭВМ, а не с задачей,
над которой в данный момент работает программист. Достаточно ли того, что мы знаем
об абстрактных типах, для разработки сложных универсальных программных средств.
Рассмотрим следующий случай. Вы создали класс и использовали его в
нескольких программах, некоторые функции могут быть даже помещены в библиотеку
объектных модулей. Взявшись за новую проблему, вы увидели, что желательно добавить
новый член класса. Если вы это сделаете, то вам придется перекомпилировать все
функции-члены этого класса и все функции, использующие данный класс.
Таким образом, для сохранения работоспособности ранее написанных программ
(без их перекомпиляции) вам придется не изменять ранее созданный класс, а добавлять
новый, мало чем отличающийся от предыдущего. Кроме того, что такая работа
утомительна, для создания новой версии класса необходимо иметь исходные тексты
функций-членов этого класса. При таких ограничениях задача создания универсального
класса превращается в проблему.
Для решения подобного рода вопросов в парадигму языка C++ добавлена
концепция наследования.
2. Создание производного класса.
Рассмотрим простой класс с конструктором и деструктором:
class Base {
int *baseMember;
public:
Base (int arg=0) { baseMember = new int [arg]; }
~Base () { delete baseMember; }
};
Предположим, что нам нужно изменить этот класс так, чтобы объект такого типа
содержал не один, а два указателя. Вместо того, чтобы изменять класс Base, мы
построим на его основе другой класс, который назовем Derived:
class Derived : public Base {
int *derivedMember;
public:
Derived (int arg) { derivedMember = new int [arg]; }
~Derived () { delete derivedMember; }
};
Запись вида class Derived : public Base говорит о том, что класс Derived строится
на основе класса Base; при этом он наследует все свойства класса Base. Класс Derived
называют производным от класса Base, а класс Base называют базовым для класса
Derived. Если в программе будет создан объект типа Derived, то он будет содержать два
указателя на две области динамической памяти – baseMember и derivedMember.
Процесс создания объекта типа Derived будет проходить в два этапа: сначала
будет создан подобъект типа Base (посредством конструктора класса Base), а затем будет
24
выполнен и конструктор класса Derived. Вызов деструкторов осуществляется в
обратном порядке.
Поскольку конструктор класса Base может требовать наличия одного аргумента
при обращении к нему, то этот аргумент необходимо передать. Чтобы передать список
аргументов конструктору базового класса, этот список должен быть помещен в
дефиниции (а не в декларации) конструктора производного класса:
Derived::Derived (int arg) : Base(arg)
{
derivedMember = new int (arg);
}
Если конструктор базового класса не имеет аргументов или использует аргументы
по умолчанию, помещать пустой список в конструктор производного класса не надо:
Derived::Derived (int arg)
{
derivedMember = new int (arg);
}
Важно отметить, что построение производного класса не требует никаких
действий над базовым классом.
3. Защищенные (protected) члены класса
Для решения широкого круга задач недостаточно двух уровней доступа: private и
public. Например, ни пользователи класса Derived, ни даже функции-члены этого класса
не могут получить доступ к элементу baseMember, хотя он является членом класса
Derived:
void Derived::printMembers (ostream& s)
{
s<<*baseMember<<endl; // ошибка доступа
s<<*derivedMember<<endl; // все верно
}
Если разрешить функциям-членам производного класса обращаться к личным
членам базового класса, то вся система защиты данных теряет смысл, т.к. нельзя будет
гарантировать, что к личным членам класса Base обращаются только функции-члены
этого класса или привилегированные в нем функции. Если же сделать baseMember
общим членом класса Base, то доступ к нему получат не только функции-члены класса
Derived, но и пользователи классов Base и Derived. Для решения подобных проблем в
C++ был добавлен еще один модификатор уровня доступа – protected.
Если класс A не служит базовым ни для какого другого класса, то его
защищенные члены ничем не отличаются от личных – доступ к ним имеют только
функции-члены данного класса или привилегированные в этом классе функции. Если же
класс B является производным от класса A, то пользователи как класса A, так и B не
имеют доступа к защищенным членам A, но такой доступ могут иметь функции-члены
класса B и функции привилегированные в классе B:
class Base {
private:
int privateMember;
protected:
25
int protectedMember;
};
class Derived : public Base {
memberFunc () {
cout<<privateMember;
// ошибка
cout<<protectedMember; // все верно
}
};
// …
Base base;
cout << base.protectedMember; // ошибка
Derived derived;
cout << derived.protectedMember; // ошибка
4. Управление уровнем доступа к членам класса
Может ли программист при создании производного класса менять уровень
доступа к членам класса базового, и если может, то каким образом? В предыдущих
примерах базовый класс являлся общим базовым классом:
class Derived : public Base { /* … */ };
Если базовый класс является личным базовым классом, то личные члены базового
класса по-прежнему недоступны ни в производном классе, ни для пользователя
производного класса, а защищенные и общие члены базового класса становятся
личными членами производного класса:
class Base
{
private:
int privateMember;
protected:
int protectedMember;
public:
int publicMember;
};
class privateDerived : Base {
public:
void f () { cout << privateMember; } // ошибка
void g () { cout << protectedMember; }
void h () { cout << publicMember; }
};
privateDerived derived;
derived.privateMember=1;
derived.protectedMember=2;
derived.publicMember=3;
// ошибка
// ошибка
// ошибка
26
Еще раз подчеркнем: механизм производных классов не может
обеспечить доступ к личным членам класса. К личным членам любого класса доступ
имеют только функции-члены этого класса и функции, привилегированные в этом
классе.
Базовый класс не может быть защищенным базовым классом. Если базовый класс
является личным базовым классом, то для некоторых его членов, но не для всех сразу, в
производном классе можно восстановить (но не изменить) уровень доступа базового
класса. Для этого их полное имя приводятся в соответствующей части декларации
класса:
class Derived : Base {
public:
Base::publicMember;
Base::protectedMember; // ошибка
protected:
Base::protectedMember;
Base::publicMember;
// ошибка
};
Структуры (struct) могут использоваться подобно классам, но с одной
особенностью: если производным классом является структура, то ее базовый класс
всегда является общим базовым классам, т.е. объявление вида
struct B : A { /* … */ };
эквивалентно
class B : public A { /* … */ };
Если производный класс строится на основе структуры, все происходит точно так
же, как и при использовании в качестве базового обычного класса. Если и базовым, и
производным классами являются структуры, то запись вида
struct B : A { /* … */ };
эквивалентна
class B : public A { public: /* … */ };
27
Лекция №4.
1. Множественное наследование классов
Класс может служить базовым сразу для нескольких производных классов. Во
многих случаях взаимодействие концепций, привлекаемых к решению задачи
(формализацией чего является взаимодействие классов в программе), требует отношений
более общего вида. Такие отношения могут быть получены в случае, когда производный
класс строится на основе нескольких базовых; при этом говорят о множественном
наследовании классов.
Внешне построение производного класса на основе нескольких базовых выглядит
так: вместо имени одного базового класса (вместе с его атрибутом – public или private)
используется список имен, разделенных запятыми. Например:
class A { /* … */ };
class B { /* … */ };
class C: public A, private B { /* … */};
Как обычно, атрибут private может быть опущен. Передача аргументов
конструкторам базовых классов из конструктора производного класса производится так
же, как и в случае без множественного наследования:
C::C (int a, char * str):A(a),B(str) { /* … */ }
Но реализация множественного наследования привела к появлению целого ряда
проблем, главными из которых являются три:
а) как поступить, если в объект производного типа будут входить более одного объекта
одного и того же базового типа;
б) как выбрать нужный член класса, если его имя присутствует более чем в одном
базовом классе, и что при этом считать неопределенностью;
в) в каком порядке должно происходить создание и уничтожение подобъектов.
При построении производного класса с использованием множественного
наследования упоминание в списке базовых классов одного и того же класса более
одного раза запрещено:
class B : public A, public A { /* … */ }; // ошибка
Тем не менее, один и тот же базовый класс может использоваться при построении
производного класса более одного раза следующим образом:
class Base { /* … */ };
class A : public Base { /* … */ };
class B : public Base { /* … */ };
class Derived : public A, public B { /* … */ };
В этом случае объект типа Derived будет содержать два различных подобъекта
типа Base. Существуют ситуации в которых необходимо, чтобы подобъект типа Base в
объекте типа Derived появился только один раз. Язык C++ позволяет программисту
реализовать оба варианта.
Обеспечить создание только одного подобъекта базового типа можно, описав
данный класс как виртуальный базовый класс:
class Base { /* … */ };
class A : public virtual Base { /* … */ };
class B : public virtual Base { /* … */ };
class Derived : public A, public B { /* … */ };
28
Теперь при создании объекта типа Derived будет создан только один подобъект
типа Base, и его члены будут использоваться функциями-членами как класса A, так и B.
Правила создания подобъектов при использовании множественного наследования
можно сформулировать следующим образом:
а) При отсутствии виртуальных базовых классов сначала происходит создание
подобъектов базовых типов, причем эти подобъекта создаются в том же порядке, в
котором соответствующие классы появились в списке базовых классов для данного
производного класса; после этого происходит инициализация данных – членов
производного класса в том порядке, в котором они появились в определении этого
класса; и, наконец, выполняется конструктор производного класса. Деструкторы
вызываются в обратном порядке.
б) При наличии виртуальных базовых классов они создаются до любого из своих
производных классов; виртуальные базовые классы создаются ранее невиртуальных
базовых классов; виртуальные базовые классы из списка создаются в порядке их
появления в списке.
До сих пор все, что говорилось о производных классах, сводится к следующему:
концепция производных классов – просто более удобный способ построения нового
класса. Но преимущество концепции производных классов не исчерпывается удобством
создания. Важным является то обстоятельство, что производный класс рассматривается
не как совершенно самостоятельный класс, а как свой базовый класс с некоторыми
дополнительными возможностями.
2. Преобразование типов
Логика преобразования типов основана на следующем достаточно простом
положении: объект производного типа может рассматриваться как объект его базового
типа; обратное утверждение неверно. Компилятор может неявно выполнить
преобразование объекта производного типа к объекту типа базового:
class Base { /* … */ };
class Derived : public Base { /* … */ };
Derived derived;
Base base = derived;
Обратное преобразование должно быть определено программистом:
Derived tmp = base;
// ошибка, если для Derived
// не определен конструктор Derived(Base&)
На практике в таких случаях значительно удобнее иметь дело не с самими
объектами, а с указателями (ссылками) на них. Гораздо чаще, чем преобразование типов,
встречается преобразование указателей (ссылок) на них.
В ходе преобразования типов указателей не происходит никаких изменений в
представлении самого указателя; преобразование типа касается не самого указателя, а
некоторой информации, характеризующей указатель на стадии компиляции программы.
Существуют два типа преобразования указателей – явное и неявное. Явное
преобразование будет выполнено всегда (в том числе и тогда, когда это может привести
к путанице и неприятностям), неявное – только в определенных случаях. Если мы имеем
дело с отношением вида
class Base { /* … */ };
class Derived : public Base { /* … */ };
29
то принципы преобразования очень просты; неявно может быть выполнено
преобразование указателя типа Derived * к указателю типа Base *; обратное
преобразование обязательно должно быть явным. Другими словами при общем
наследовании объект производного типа может рассматриваться как объект базового
типа.
В случае личного наследования:
class Base { /* … */ };
class Derived : Base { /* … */ };
преобразование указателя на производный класс к указателю на базовый класс
необходимо производить явно.
Если в классе существует несколько функций с одним именем и разными
списками аргументов, то может возникнуть проблема выбора одной из таких функций и
соответствующих неявных преобразований ее аргументов. Правила языка здесь таковы:
если нет точного соответствия списка формальных и фактических аргументов, то
наивысший приоритет среди выполняемых преобразований имеют преобразования
производного типа к базовому; это относится как к самому типу, так и к указателю на
него. Только в том случае, если это невозможно, компилятор пробует выполнить другие
преобразования (например, стандартные преобразования указателей).
30
Лекция №5.
1. Виртуальные функции
Знакомство с виртуальными функциями начнем издалека – сначала рассмотрим
задачу, которая может привести к их использованию в программе, и напишем такую
программу без их применения. Сформулируем задачу следующим образом: написать
программу, которая позволила бы хранить и рисовать на экране терминала разноцветные
геометрические фигуры – линии, многоугольники и круги, которые могут быть
раскрашены в различные цвета.
Легко видеть, что хотя круг и треугольник не очень-то похожи друг на друга, но у
них можно выделить общие черты, а именно – цвет и толщина линии их контура, а также
цвет внутренней области. Эти характеристики, свойственные любой нашей фигуре
можно собрать в одном базовом классе, который назовем Figure:
class Figure {
protected:
int lineColor;
int lineThickness;
int areaColor;
public:
FigureType type;
Figure ( int color1, int color2, int width=NORM_WIDTH);
void drawElement ();
};
Определим тип фигур:
enum FigureType { LINE, POLYGON, CIRCLE };
Для удобства, создадим структуру, указывающую координаты точки на экране:
struct Coord { int x,y; };
Реальные фигуры составят классы, производные от класса Figure:
class Line : public Figure
{
Coord beg, end;
public:
Line (Coord b, Coord e, int color1, int color2);
void drawLine ();
};
class Polygon : public Figure
{
int numOfVertices;
int * vertices;
public:
Polygon (int num, int * coord, int color1, int color2);
void drawPolygon ();
};
class Circle : public Figure
{
Coord centre;
31
int radius;
public:
Circle (Coord c, int rad, int color1, int color2);
void drawCircle ();
};
Хранить все элементы композиции фигур будем в списке указателей на Figure. К
сожалению, после того как в такой список будет помещен элемент, компилятор уже не
будет знать какого типа этот элемент на самом деле. При этом очевидно, что знать тип
надо для правильной прорисовки фигуры. Поэтому кажется естественным выход:
хранить для каждого объекта информацию о его истинном типе, для чего мы и
предусмотрели поле type в классе Figure.
Реализация всех функций-членов классов достаточно очевидна. Рассмотрим
дефиницию важной для нас функции:
void Figure::drawElement ()
{
switch (type)
{
case LINE:
((Line*)this)->drawLine();
break;
case POLYGON:
((Polygon*)this)->drawPolygon();
break;
case CIRCLE:
((Circle*)this)->drawCircle();
break;
}
}
Первый недостаток созданной иерархии классов состоит в том, что необходимо
хранить информацию об истинном типе фигуры в поле type. Плохо здесь то, что
компилятор не может контролировать, правильно ли занесена информация в это поле и
правильно ли она используется. При написании программ такого рода ошибок почти не
бывает, зато очень легко внести ошибку при ее модификации, особенно если программа
не так проста, как в нашем примере, да еще и написана кем-то другим.
Второй недостаток заключается в следующем: класс Figure получился
неуниверсальным в том смысле, что при добавлении нового типа геометрической
фигуры придется изменять и перекомпилировать функцию Figure::drawElement (), а
также файл, содержащий определение перечисления FigureType. Хорошо, если вы
являетесь и разработчиком и пользователем класса Figure, но как быть, если этот класс
создан как составная часть универсального программного средства и поставляется
только в виде библиотек объектных модулей?
В C++ существует средство для решения обеих указанных проблем. Это средство
– виртуальные функции, т.е. функции, которые, имея одно и то же имя и список
аргументов, тем не менее выполняются по-своему для объектов различных типов даже в
32
том случае, когда компилятор не может определить их истинный тип, как это
часто происходит при работе с производными классами.
Применительно к нашему примеру естественно объявить виртуальными функции
рисования геометрических фигур. Изменения, вносимые в этом случае в нашу
программу, будут следующими: во-первых, изменится определение класса Figure, т.к.
нет больше необходимости в поле type типа FigureType (как и в самом этом типе), а
вместо функции
void drawElement ();
появится функция
virtual void draw () {}
Служебное слово virtual означает, что функция draw () может иметь свои версии
для различных классов, производных от Figure. В классе Figure функция draw () ничего
делать не должна, так как непонятно, что собой представляет объект типа Figure. Теперь
создадим свои варианты функции draw() для классов, производных от Figure. Эти
варианты заменят функции drawLine(), drawPolygon(), drawCircle():
class Line : public Figure
{
// …
virtual void draw ();
}:
class Polygon : public Figure
{
// …
virtual void draw ();
}:
class Circle : public Figure
{
// …
virtual void draw ();
}:
Все версии виртуальной функции draw () должны иметь один и тот же тип, то есть
одно и то же имя, список аргументов и тип результата. В производных классах
служебное слово virtual не обязательно, хотя для ясности его лучше не опускать. Кратко
отличие виртуальной функции от обычной можно сформулировать так: при вызове через
указатель или ссылку обычная функция определяется типом указателя или ссылки, а
виртуальная – истинным типом объекта, на который указывает указатель или ссылка.
Если вы не хотите использовать механизм виртуальных функций, то при обращении к
нужной функции необходимо использовать ее полное имя, например, Circle::draw ().
Естественно, обеспечение вызова нужной виртуальной функции требует
некоторых дополнительных затрат памяти. Строструпом приводятся цифры: 5-6
дополнительных ссылок по памяти на один вызов (при использовании множественного
наследования).
Язык не требует, чтобы в каждом классе, производном от класса с виртуальной
функцией, была определена своя ее версия. Дополнительные затраты памяти имеют
место только для существующих виртуальных функций; если в классе, производном от
33
класса с виртуальной функцией, своя версия этой функции не определена, то
вызов такой функции происходит обычным способом, без затрат памяти.
Поскольку при нормальной работе программы рисования геометрических фигур в
ней не должно существовать объектов типа Figure, а только объекты, производные от
него, то функция Figure::draw () могла быть определена для контроля ошибок
следующим образом:
Figure::draw ()
{
cout<<"Ошибка: попытка нарисовать figure";
exit(1);
}
В C++ существует более удобный и надежный способ контроля того, чтобы
объект типа Figure не использовался в явном виде в программе. Версия виртуальной
функции, которая не должна никогда быть использована, называется чисто виртуальной
функцией и объявляется через присваивание декларации функции значения 0:
class Figure
{
// …
virtual void draw ()=0;
};
Чисто виртуальные функции позволяют установить контроль компилятора за
созданием объектов фиктивных типов. Класс, который содержит хотя бы одну чисто
виртуальную функцию, называется "абстрактным классом"; правила языка запрещают
создание объектов таких типов, хотя возможны указатели и ссылки на них.
Опишем правила использования виртуальных функций. Виртуальная функция
обязательно должна быть членом класса. Для того, чтобы объявить виртуальную
функцию, ее декларация должна содержать слово virtual. Если виртуальная функция
впервые была объявлена в классе A, то в этом классе она должна быть либо определена,
либо объявлена как чисто виртуальная. Все функции в классах, производных от класса A
с некоторой виртуальной функцией, имеющие тот же самый тип, являются
виртуальными вне зависимости от того, описаны они или нет явно с использованием
слова virtual. Если функция в производном классе объявлена без virtual и при этом
отличается от виртуальной только типом результата, то компилятор выдаст сообщение
об ошибке. Если некая функция в производном классе объявлена как виртуальная и
имеет то же имя, что и виртуальная функция в базовом классе, но при этом их аргументы
отличаются, то виртуальная функция в производном классе и виртуальная функция в
базовом классе – две различные виртуальные функции.
Статическая функция не может являться виртуальной. Конструктор не может быть
виртуальной функцией, а деструктор может. Виртуальная функция может быть
привилегированна в некотором классе. Производный класс не обязательно должен иметь
свою версию виртуальной функции.
34
Лекция №6.
1. Виртуальные деструкторы
Рассмотрим простой пример:
class Base
{
public:
Base ();
~Base ();
};
class Derived : public Base
{
char * str;
public:
Derived (int arg){ str=new char [arg]; }
~Derived (){ delete str; }
};
А теперь рассмотрим следующий фрагмент программы:
// …
Base * bp= new Derived (10);
delete bp;
Очевидно, что при выполнении операции delete будет вызван деструктор для
класса Base, а фрагмент динамической памяти попадет в так называемый "мусор". Для
того, чтобы избежать такого рода неприятностей, деструктор класса Base должен быть
описан как виртуальный:
class Base
{
// …
virtual ~Base (){ /* … */ }
};
В этом случае деструктор будет вызван для нужного объекта вне зависимости от
типа используемого указателя. Деструкторы классов, производных от класса с
виртуальным деструктором, также являются виртуальными.
2. Шаблоны
Предположим, что нам необходимо написать функцию, которая возвращает
"среднее" (в некотором смысле) значение двух своих аргументов. Назовем эту функцию
Mid (). В С++ существует три способа решения этой задачи:
а) Определить макрос вида
#define Mid(x,y) (((x)+(y))/2)
Такой путь плох потому, что макроподстановку осуществляет не компилятор, а
препроцессор. Отсутствие контроля со стороны компилятора часто приводит к
неожиданным эффектам и странным сообщениям. Поиск ошибок в программе,
использующей макроподстановки, затруднен.
б) Для каждого из возможных сочетаний типов операндов определить свою версию
функции Mid(). Например:
int Mid ( int x, int y){ return (x+y)/2; }
35
int Mid ( float x, float y){ return (x+y)/2; }
int Mid ( Light x, Light y){ return (x+y)/2; }
Мы предполагаем, что операции + и / переопределены для типа Light. Этот способ
обеспечивает полную безопасность использования функций Mid(), но необходимость
написание трех идентичных вариантов может спровоцировать программиста на
применение макроподстановки. К тому же, если появляется необходимость в четвертой
функции, оперирующей с аргументами другого типа, то программист должен
скопировать код функции с изменением типов операндов.
в) C++ позволяет создать настраиваемый на различные типы шаблон функции Mid ():
template <class Type> Type Mid (Type a, Type b) { return (a+b)/2; }
Имя типа Type как бы становится параметром макроподстановки, но все необходимые
действия при этом выполняются компилятором. Существует и еще одно отличие от
макроподстановки: обработка template-функции не приводит к включению кода функции
в код программы при каждом обращении. Вместо этого генерируется обычное
обращение к подпрограмме с предварительным помещением аргументов в стек.
Параметрами шаблона могут быть несколько имен типов:
template <class T1, class T2> T1& myFunction (T1&, T2&);
В качестве примера настраиваемого класса рассмотрим концепцию "массив с
заданным диапазоном изменения индекса".
template <class Data> class Array {
Data* a;
int size;
int lowerBound;
public:
Array (int sz, int lb=0);
~Array();
Data& operator[] (int);
};
Объявление класса Array как template-класса с параметром Data говорит о том, что
элементами массива типа Array могут быть объекты любого типа. Встает вопрос: как
отличить в программе класс-шаблон, созданный для различных типов? Для этого
необходимо использовать модификацию имени класса: наименование типа в угловых
скобках после имени класса. Объявление функций-членов класса Array осуществляется
следующим образом:
template <class Data>
Array<Data>::Array (int sz, int lb=0)
{
a = new Data [size=sz];
lowerBound = lb;
}
template <class Data>
Array<Data>::~Array (){ delete a; }
Используется Array при создании конкретного объекта следующим образом:
Array<int> theArray (5,1);
Download