Сравнение объектно-ориентированных языков
C++
Множественное наследование
Типы переменных
Отсутствие интерфейсов
Статическая таблица виртуальных методов
Именование конструкторов, деструкторов
Конструктор копирования
Объявление динамических методов, абстрактных, постоянных
Личное и общее наследование
Структуры как классы
Переопределение операций
Шаблоны
Java
Динамическая таблица виртуальных методов
Синхронизированные методы
C#
call back функции
Лекция №2
1. Классы
Концепция модуля базируется на локализации определенного достаточно самостоятельного фрагмента программы и разделении его на две части – реализацию модуля и интерфейс к внутренним данным. Класс, являясь развитием концепции модуля, унаследовал от него схожую структуру. Интерфейс представляет набор функций, позволяющих пользователю класса управлять объектом такого типа.
Надо заметить при этом, что при правильной разработке программы класс (в отличие от модуля) является формализацией некоторой концепции (например, "матрица"), а не просто изолированным фрагментом программы.
Очень важной особенностью класса является то, что доступ к внутренним данным возможен лишь через интерфейс и никак больше. Это обеспечивает существенные преимущества. Любая ошибка, которая приводит к неверным результатам во внутренних данных, может находиться только в функциях, образующих интерфейс. Кроме того, не используя специальных средств, таких, как манипуляции с памятью, нельзя получить доступ к объектам, составляющим внутренние данные класса, что является надежной их защитой.
Еще одним преимуществом подобной организации класса является то, что пользователю класса для нормальной работы с ним в общем случае достаточно знать лишь декларации функций, составляющих интерфейс этого класса.
Класс является типом, созданным программистом. Доступ к внутренним данным класса возможен лишь с помощью функций, являющихся частью этого класса, или функций специального вида (привилегированных функций). И данные, и функции, составляющие класс, называются членами класса. Среди функций-членов класса могут присутствовать специальные функции, управляющие инициализацией объектов такого типа – конструкторы, и функции, управляющие уничтожением объектов – деструкторы.
Рассмотрим в качестве примера концепцию "строка символов".
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 с отличиями для производных классов, что будет рассмотрено позже.
Функции-члены класса могут вызываться только после того, как в программе создан объект соответствующего типа; они вызываются только для конкретного объекта, и выполняемые ими действия никак не могут влиять на состояние других объектов этого типа.
Декларации функций-членов класса должны быть расположены в теле класса. В зависимости от того, в какой части описания класса находится декларация, она становится личным, общим или защищенным членом класса. Если дефиниция функции-члена класса находится внутри декларации класса, то внешне она ничем не отличается от дефиниции обычной функции:
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++ предусмотрены специальные функции-члены класса, которые в большинстве случаев вызываются не программистом, а компилятором языка. Функции, выполняющие инициализацию объектов абстрактных типов, называются конструкторами, а функции, "уничтожающие" такие объекты – деструкторами. Невозможно ни создать объект абстрактного типа без вызова конструктора, ни уничтожить его без вызова деструктора. Таким образом, такой объект всегда будет инициализированным. Конструктор имеет имя, совпадающие с именем класса и не имеет возвращаемого значения. Имя деструктора начинается с символа ~, за которым следует имя класса. Деструктор также не имеет возвращаемого значения.
Хотя конструкторы и деструкторы являются функциями-членами класса, они имеют определенные отличия от обычных функций-членов класса:
а) Конструкторы и деструкторы имеют строго определенные имена.
б) Конструкторы и деструкторы не могут возвращать никаких значений: в их декларации не указывается никакой тип возвращаемого результата, даже void.
в) Конструкторы могут иметь аргументы, в том числе аргументы по умолчанию; деструкторы аргументов не имеют. Конструктор класса X не может иметь аргументы типа X, хотя аргументы типа X* и X& возможны.
г) Нельзя получить адрес ни конструктора, ни деструктора.
д) Вызов конструктора происходит автоматически во время декларации объекта абстрактного типа, никаким другим образом вызвать конструктор нельзя; вызов деструктора происходит автоматически при выходе объекта абстрактного типа из области своего существования, но деструктор может быть вызван и явно, но с обязательным указанием его полного имени.
Полное имя члена класса – это имя члена, которому предшествует имя класса и символ ::. Оно используется, когда необходимо отличить, например, член класса от глобального объекта с тем же именем. Перед символом :: может отсутствовать имя класса, тогда полное имя рассматривается как имя глобального объекта (или функции).
Если член класса объявлен как static, то он становится полноценным самостоятельным объектом; при этом его имя имеет область существования "класс". Все объекты, сколько бы их не было, используют заранее созданную одну-единственную копию своего статического члена. К статическим членам класса можно обратиться и как к обычным членам – по имени или указателю на созданный объект с использованием стандартной мнемоники обращения к члену класса, так и по его полному имени – даже в том случае, если в программе не создан ни один объект класса X. Статические члены класса могут иметь любой из трех уровней доступа.
Использование слова static применительно к именам функций имеет несколько другой смысл, чем его использование применительно к именам объектов. Статическая функция-член класса может быть вызвана для получения доступа к статическому данному-члену класса X, причем может не существовать объектов типа X. Для нее не определен указатель this.
2. Переопределение операций
Для того, чтобы переопределить одну из стандартных операций языка C++ для работы с операндами абстрактных типов, программист должен написать функцию с именем operator@, где @ - обозначение этой операции. При этом в языке существуют несколько ограничений:
a) программист не может придумывать свои символы операций;
b) не могут быть переопределены операции: ::, *, ?:
c) символ унарной операции не может быть использован для переопределения бинарной операции и наоборот;
d) переопределение операций не меняет ни их приоритетов, ни порядка выполнения;
e) при переопределении операции компилятор не делает никаких предположений о ее свойствах (коммутативность и т.п.);
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++ разрешает использование глобальных функций, у которых первый операнд имеет стандартный тип. Посредством функции-члена класса задание такой функции невозможно.
Функции, реализующие операции =, [], (), -> должны быть членом класса.
Лекция №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), а затем будет выполнен и конструктор класса 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:
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; // ошибка
Еще раз подчеркнем: механизм производных классов не может обеспечить доступ к личным членам класса. К личным членам любого класса доступ имеют только функции-члены этого класса и функции, привилегированные в этом классе.
Базовый класс не может быть защищенным базовым классом. Если базовый класс является личным базовым классом, то для некоторых его членов, но не для всех сразу, в производном классе можно восстановить (но не изменить) уровень доступа базового класса. Для этого их полное имя приводятся в соответствующей части декларации класса:
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: /* … */ };
Лекция №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 { /* … */ };
Теперь при создании объекта типа 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 { /* … */ };
то принципы преобразования очень просты; неявно может быть выполнено преобразование указателя типа Derived * к указателю типа Base *; обратное преобразование обязательно должно быть явным. Другими словами при общем наследовании объект производного типа может рассматриваться как объект базового типа.
В случае личного наследования:
class Base { /* … */ };
class Derived : Base { /* … */ };
преобразование указателя на производный класс к указателю на базовый класс необходимо производить явно.
Если в классе существует несколько функций с одним именем и разными списками аргументов, то может возникнуть проблема выбора одной из таких функций и соответствующих неявных преобразований ее аргументов. Правила языка здесь таковы: если нет точного соответствия списка формальных и фактических аргументов, то наивысший приоритет среди выполняемых преобразований имеют преобразования производного типа к базовому; это относится как к самому типу, так и к указателю на него. Только в том случае, если это невозможно, компилятор пробует выполнить другие преобразования (например, стандартные преобразования указателей).
Лекция №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;
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++ существует средство для решения обеих указанных проблем. Это средство – виртуальные функции, т.е. функции, которые, имея одно и то же имя и список аргументов, тем не менее выполняются по-своему для объектов различных типов даже в том случае, когда компилятор не может определить их истинный тип, как это часто происходит при работе с производными классами.
Применительно к нашему примеру естественно объявить виртуальными функции рисования геометрических фигур. Изменения, вносимые в этом случае в нашу программу, будут следующими: во-первых, изменится определение класса 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 дополнительных ссылок по памяти на один вызов (при использовании множественного наследования).
Язык не требует, чтобы в каждом классе, производном от класса с виртуальной функцией, была определена своя ее версия. Дополнительные затраты памяти имеют место только для существующих виртуальных функций; если в классе, производном от класса с виртуальной функцией, своя версия этой функции не определена, то вызов такой функции происходит обычным способом, без затрат памяти.
Поскольку при нормальной работе программы рисования геометрических фигур в ней не должно существовать объектов типа Figure, а только объекты, производные от него, то функция Figure::draw () могла быть определена для контроля ошибок следующим образом:
Figure::draw ()
{
cout<<"Ошибка: попытка нарисовать figure";
exit(1);
}
В C++ существует более удобный и надежный способ контроля того, чтобы объект типа Figure не использовался в явном виде в программе. Версия виртуальной функции, которая не должна никогда быть использована, называется чисто виртуальной функцией и объявляется через присваивание декларации функции значения 0:
class Figure
{
// …
virtual void draw ()=0;
};
Чисто виртуальные функции позволяют установить контроль компилятора за созданием объектов фиктивных типов. Класс, который содержит хотя бы одну чисто виртуальную функцию, называется "абстрактным классом"; правила языка запрещают создание объектов таких типов, хотя возможны указатели и ссылки на них.
Опишем правила использования виртуальных функций. Виртуальная функция обязательно должна быть членом класса. Для того, чтобы объявить виртуальную функцию, ее декларация должна содержать слово virtual. Если виртуальная функция впервые была объявлена в классе A, то в этом классе она должна быть либо определена, либо объявлена как чисто виртуальная. Все функции в классах, производных от класса A с некоторой виртуальной функцией, имеющие тот же самый тип, являются виртуальными вне зависимости от того, описаны они или нет явно с использованием слова virtual. Если функция в производном классе объявлена без virtual и при этом отличается от виртуальной только типом результата, то компилятор выдаст сообщение об ошибке. Если некая функция в производном классе объявлена как виртуальная и имеет то же имя, что и виртуальная функция в базовом классе, но при этом их аргументы отличаются, то виртуальная функция в производном классе и виртуальная функция в базовом классе – две различные виртуальные функции.
Статическая функция не может являться виртуальной. Конструктор не может быть виртуальной функцией, а деструктор может. Виртуальная функция может быть привилегированна в некотором классе. Производный класс не обязательно должен иметь свою версию виртуальной функции.
Лекция №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; }
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);