C++. Бархатный путь. Часть 2

ОГЛАВЛЕНИЕ

Продолжение книги Марченко А.Л. "C++. Бархатный путь".


Класс. Объявление класса

Класс - это тип. Этот производный тип вводится в программу с помощью специального оператора объявления класса. В объявлении класса используется ранее описанный инструментальный набор средств для построения и преобразования производных типов.

Очередное множество форм Бэкуса-Наура определяет синтаксис объявления класса.

Объявление ::= [СписокСпецификаторовОбъявления] [СписокОписателей];
СписокСпецификаторовОбъявления
::= [СписокСпецификаторовОбъявления] СпецификаторОбъявления
СпецификаторОбъявления ::= СпецификаторТипа
::= *****
СпецификаторТипа ::= СпецификаторКласса
::= УточнённыйСпецификаторТипа
::= *****
УточнённыйСпецификаторТипа ::= КлючевоеСловоКласса ИмяКласса
::= КлючевоеСловоКласса Идентификатор
::= enum ИмяПеречисления
КлючевоеСловоКласса ::= union
::= struct
::= class
ИмяКласса ::= Идентификатор
СпецификаторКласса ::= ЗаголовокКласса {[СписокЧленов]}
ЗаголовокКласса
::= КлючевоеСловоКласса [Идентификатор] [СпецификацияБазы]
::= КлючевоеСловоКласса ИмяКласса [СпецификацияБазы]
КлючевоеСловоКласса ::= union
::= struct
::= class
ИмяКласса ::= Идентификатор

Спецификатор класса представляет то, что называется объявлением класса. Уточнённый спецификатор типа объявляет расположенный за ним идентификатор именем класса. Уточнённый спецификатор обеспечивает неполное предварительное объявление класса и перечисления.

Назначение и смысл необязательного нетерминального символа СпецификацияБазы будут обсуждаться позже, в разделах, посвящённых наследованию.

Предварительное объявление обеспечивается уточнённым спецификатором типа и является своеобразным прототипом класса или перечисления. Его назначение - сообщение транслятору предварительной информации о том, что существует (должно существовать) объявление класса (или перечисления) с таким именем. Идентификатор, используемый в контексте уточнённого спецификатора имени становится именем класса (или именем перечисления).

Класс считается объявленным даже тогда, когда в нём полностью отсутствует информация о членах класса (пустой список членов класса). Неименованный класс с пустым множеством членов - уже класс!

Имя класса можно употреблять как имя (имя типа) уже в списке членов этого самого класса.

Класс может быть безымянным.

Следующая последовательность операторов объявления

class {}; /* Объявлен пустой неименованный класс.*/
class {};
class {};
class {};
/* Это всё объявления. Их количество ничем не ограничивается. */
struct {};
/* Структура - это класс, объявленный с ключевым словом struct.
Опять же пустой и неименованный.*/

не вызывает у транслятора никаких возражений.

На основе класса, пусть даже неименованного, может быть объявлен (вернее, определён) объект-представитель этого класса. В таком контексте объявление неименованного (пусть даже и пустого!) класса является спецификатором объявления. Имена определяемых объектов (возможно с инициализаторами) составляют список описателей.

class {} Obj1, Obj2, Obj3;/* Здесь объявление пустого класса.*/
class {} Obj4, Obj5, Obj6;/* Просто нечего инициализировать.*/
class {} Obj1;
/* ^ Ошибка. Одноименные объекты в области действия имени.*/

Неименованные классы также можно применять в сочетании со спецификатором typedef (здесь может быть объявление класса любой сложности - не обязательно только пустой). Спецификатор typedef вводит новое имя для обозначения безымянного класса. Описанное имя типа становится его единственным именем. 

Сочетание спецификатора typedef с объявлением безымянного класса подобно объявлению класса с именем:

class MyClass {/*…*/};
typedef class {/*…*/} MyClass;

Правда в первом случае класс имеет собственное имя класса, а во втором - описанное имя типа. Использование описанного имени типа в пределах области действия имени делает эквивалентными следующие определения (и им подобные):

class {} Obj1;
MyClass Obj1;

Класс считается объявленным лишь после того, как в его объявлении будет закрыта последняя фигурная скобка. До этого торжественного момента информация о структуре класса остаётся неполной.

Если можно ОБЪЯВИТЬ пустой класс, то можно ОПРЕДЕЛИТЬ и объект-представитель пустого класса. Эти объекты размещаются в памяти. Их размещение предполагает выделение объекту участка памяти с уникальным адресом, а это означает, что объекты пустого класса имеют ненулевой размер.

Действительно, значения выражений sizeof(MyClass) и sizeof(MyObj1) (это можно очень просто проверить) отличны от нуля.

А вот пустое объединение (ещё одна разновидность класса - класс, объявленный с ключевым словом union) не объявляется:

union {}; /* Некорректное объявление объединения. */

При объявлении объединения требуется детальная информация о внутреннем устройстве этого объединения.


 

Синтаксис объявления членов класса

Мы продолжаем формальное определение класса. Теперь рассмотрим синтаксис объявления членов класса.

СписокЧленов ::= ОбъявлениеЧленаКласса [СписокЧленов]
::= СпецификаторДоступа : [СписокЧленов]
ОбъявлениеЧленаКласса ::= [СписокСпецификаторовОбъявления]
[СписокОписателейЧленовКласса];
::= ОбъявлениеФункции
::= ОпределениеФункции [;]
::= КвалифицированноеИмя;
СписокОписателейЧленовКласса ::= ОписательЧленаКласса
::= СписокОписателейЧленовКласса,
ОписательЧленаКласса
ОписательЧленаКласса ::= Описатель [ЧистыйСпецификатор]
::= [Идентификатор] : КонстантноеВыражение
ЧистыйСпецификатор ::= = 0
КвалифицированноеИмяКласса ::= ИмяКласса
::= ИмяКласса :: КвалифицированноеИмяКласса
СпецификаторДоступа ::= private
::= protected
::= public

Список членов определяет полный набор членов данного класса. В этом списке объявляются все члены класса. Таковыми могут быть данные, функции-члены, ранее объявленные классы, перечисления, битовые поля, дружественные функции и даже имена типов. Некоторые из перечисленных понятий нам уже знакомы, о других речь ещё впереди. Этот список не подлежит модификации. Он формируется за один раз.

В соответствии с синтаксическими правилами, членами класса могут быть как определения функций, так и их прототипы. Действительно:

ОбъявлениеЧленаКласса ::= 
[СписокСпецификаторовОбъявления] [СписокОписателейЧленовКласса]; ::=
СпецификаторОбъявления ОписательЧленаКласса; ::=
СпецификаторТипа Описатель; ::=
void Описатель (СписокОбъявленийПараметров); ::=
void ff (void);

С другой стороны,

ОбъявлениеЧленаКласса ::= 
ОпределениеФункции [;] ::=
Описатель (СписокОбъявленийПараметров) ТелоФункции ::=
ff (void) {int iVal = 100;}

В соответствии с синтаксическими правилами, членами класса могут быть как определения функций, так и их прототипы. Действительно:

ОбъявлениеЧленаКласса ::= 
[СписокСпецификаторовОбъявления] [СписокОписателейЧленовКласса]; ::=
СпецификаторОбъявления ОписательЧленаКласса; ::=
СпецификаторТипа Описатель; ::=
void Описатель (СписокОбъявленийПараметров); ::=
void ff (void);

С другой стороны,

ОбъявлениеЧленаКласса ::= 
ОпределениеФункции [;] ::=
Описатель (СписокОбъявленийПараметров) ТелоФункции ::=
ff (void) {int iVal = 100;}

Точка с запятой после определения функции является декоративным элементом. Ни один член класса не может входить в список членов класса дважды. Поэтому определяемая в теле класса функция оказывается без прототипа. Если класс содержит прототип функции в качестве члена класса, функция располагается за пределами класса. Как мы скоро увидим, всё разнообразие объявлений и определений функций-членов транслятор приводит к единому стандартному виду.

Функции-члены могут определяться вне списка членов класса. При определении функции-члена класса за пределами данного класса, в списке членов класса размещается прототип функции-члена. А при определении функции-члена используется квалифицированное имя. Квалифицированное имя состоит из последовательности имён классов, разделённых операциями разрешения области видимости. Эта последовательность имён завершается именем определяемой функции. Последовательность имён классов в квалифицированных именах определяется степенью вложенности объявлений классов.

Наличие функций-членов делает объявление класса подобным определению (как и любые функции, функции-члены определяются). Как сказано в Справочном руководстве по C++, "Если бы не исторические причины, объявление класса следовало называть определением класса".

Данные-члены класса не могут объявляться со спецификаторами auto, extern, register.

Ни при каких обстоятельствах не допускается объявление одноименных членов. Имена данных-членов должны также отличаться от имён функций-членов. Использование одноимённых функций, констант и переменных в выражениях в пределах одной области действия имён приводит к неоднозначности. Как известно, имя функции, как и имя константы и переменной, является выражениями. Если допустить объявление одноимённых переменных, констант и функций, то в ряде случаев просто невозмо будет определить, о чём в программе идёт речь.

Объявляемые в классе данные-члены, которые являются представителями классов, должны представлять ранее объявленные классы. Транслятор должен знать заранее о структуре подобных данных-членов. 

Описатель члена класса в объявлении класса не может содержать инициализаторов (это всего лишь объявление).

Структура является классом, объявленным с ключевым словом класса struct. Члены такого класса и базовые классы по умолчанию обладают спецификацией доступа public.

Назначение спецификаторов доступа будет обсуждаться в разделах, посвящённых управлению доступом. Пока будет достаточно в объявлении класса указать спецификатор public. В этом случае члены класса оказываются доступны (к ним можно будет свободно обращаться) из любого оператора программы.

Объединение является классом, объявленным с ключевым словом класса union. Его члены также по умолчанию обладают спецификацией доступа public. В каждый момент исполнения программы объединение включает единственный член класса. В этом его специфика. Именно поэтому не может быть пустого объединения. Позже мы вернёмся к объединениям.

Если функция-член определяется вне тела класса, в список членов класса включается прототип функции. Определение функции сопровождается квалифицированным именем, которое указывает транслятору на принадлежность определяемой функции-члена классу. Последняя часть квалифицированного имени (собственно имя функции) должна совпадать с именем прототипа функции-члена, объявленного ранее в классе.

Подобно определению данных основных типов, в программе могут быть определены объекты ранее объявленного типа. В ходе определения объекта-представителя класса выделяется память для размещения данных-членов класса. При этом непосредственно в этой области памяти размещаются все данные-члены, за исключением данных, объявленных со спецификатором static (об этом спецификаторе будет сказано ниже).


 

Разбор структуры класса

Разбор структуры класса осуществляется транслятором в несколько этапов.

На первом этапе исследуется список данных-членов класса. Именно этот список и определяет общую структуру класса. До окончания этой стадии разбора класса, а фактически до завершения объявления класса, его имя в объявлении данных-членов может быть использовано лишь в таком контексте, где не используется информация о размерах класса. Это объявления указателей, ссылок и статических членов класса (о них после).

Таким образом, объект-представитель класса не может быть членом собственного класса, поскольку объект-представитель класса может быть объявлен как член класса лишь после того, как завершено объявление этого класса.

Функция-член класса существует в единственном экземпляре для всех объектов-представителей данного класса. Переобъявление и уточнение структуры класса в С++ недопустимо.

Серия простых примеров демонстрирует, что можно, а что нельзя делать при объявлении данных-членов класса.

class C1
{
C1 MyC;
// Это ошибка. В классе не допускается объявления данных-членов
// объявляемого класса.
C1* pMyC;
// А указатель на класс объявить можно.
};

Для объявления таких указателей или ссылок на объекты объявляемого класса достаточно неполного предварительного объявления класса. Указатели и ссылки имеют фиксированные размеры, которые не зависят от типа представляемого объекта.

class C2;
class C1
{
C1* pMyC1;
C2* pMyC2;
};
C2* PointOnElemOfClassC2;

Назначение неполного объявления подобно прототипу функции и используется исключительно в целях предварительного информирования транслятора. Очевидно, что создание объектов на основе предварительного неполного объявления невозможно. Однако это не снижает ценности уточнённого спецификатора.

На втором проходе трансляции объявления класса осуществляется проверка списков параметров в объявлениях функций-членов класса, и определяется размер класса. К этому моменту транслятору становится известна общая структура класса. И потому, как ни странно это выглядит, в классе может быть объявлена функция-член класса, которая возвращает значение объявляемого класса и содержит в списке параметров параметры этого же класса:

class C2;
class C1
{
C1 F1(C1 par1) {return par1;};
//Объявить данные-члены класса C1 нельзя, а функцию - можно!
C1* pMyC1;
C2* pMyC2;
// C1 MyC;
};
C2* PointOnElemOfClassC2;

Где бы ни располагалась объявляемая в классе функция-член, транслятор приступает к её разбору лишь после того, как он определяет общую структуру класса.

В соответствии с формальным определением создадим наш первый класс:

СпецификаторКласса ::= ЗаголовокКласса { [СписокЧленов] };        ::=
КлючевоеСловоКласса Идентификатор { ОбъявлениеЧленаКласса
ОбъявлениеЧленаКласса }; ::=
class FirstClass { СпецификаторОбъявления ОписательЧленаКласса;
ОписаниеФункции; }; ::=
class FirstClass { СпецификаторОбъявления ОписательЧленаКласса;
int FirstClassFunction(void);}; ::=
class FirstClass {
long int* PointerToLongIntVal;
int FirstClassFunction(void);
};

За исключением квалифицируемого имени синтаксис определения функции-члена класса вне класса ничем не отличается от определения обычной функции:

int FirstClass::FirstClassFunction(void) 
{
int IntVal = 100;
return IntVal;
};

Вот таким получилось построенное в соответствии с грамматикой C++ определение (или объявление) класса.

Заметим, что в C++ существует единственное ограничение, связанное с расположением определения функции-члена класса (конечно, если оно располагается вне тела класса): определение должно располагаться за объявлением класса, содержащего эту функцию. Именно "за объявлением"! Без каких-либо дополнительных ограничений типа "непосредственно за" или "сразу за".

Более того, в ряде случаев, например, когда требуется определить функцию-член, изменяющую состояние объекта другого класса, данная функция-член должна располагаться за объявлением класса, состояние объекта которого она изменяет. И это понятно. При разборе такой функции-члена транслятор должен иметь представление о структуре класса.

Допускается и такая схема расположения объявлений, при которой первыми располагаются неполные объявления классов, следом соответствующие объявления классов и лишь затем определения функций-членов. Подобные определения мы будем называть отложенными определениями. Позже мы рассмотрим пример программы, в которой отложенный вариант определения функции-члена является единственно возможным вариантом определения.

Класс - это то, что делает C++ объектно-ориентированным языком. На основе классов создаются новые производные типы и определяются функции, которые задают поведение типа. 

Рассмотрим несколько строк программного кода, демонстрирующих свойства производных типов.

class Class1 {int iVal;};
class Class2 {int iVal;};
/*
Объявление производных типов Class1 и Class2. Эти объявления
вводят в программу два новых производных типа. Несмотря на
тождество их структуры, это разные типы.
*/
void ff(Class1);
/* Прототип функции с одним параметром типа Class1.*/
void ff(Class2);
/*
Прототип функции с одним параметром типа Class2. Это совместно
используемые (или перегруженные) функции. Об этих функциях мы
уже говорили.
*/
Class1 m1; /* Объявление объекта m1 типа Class1. */
Class2 m2; /* Объявление объекта m2 типа Class2. */
int m3;
m1 = m2;
m1 = m3;
m3 = m2;
/*
Последние три строчки в данном контексте недопустимы.
Неявное преобразование с участием производных типов в C++
невозможно. Транслятор не имеет никакого понятия о том, каким
образом проводить соответствующее преобразование. При объявлении
классов необходимо специально определять эти алгоритмы.
*/
void ff (Class1 pp)
// Определение первой совместно используемой функции...
{
:::::
}
void ff (Class2 pp)
// Определение второй совместно используемой функции...
{
:::::
}
ff(m1);//Вызов одной из двух совместно используемых функций...
ff(m2);//Вызов второй функции...

Ещё один пример объявления класса.

class ClassX
{
ClassX Mm; //Здесь ошибка. Объявление класса ещё не завершено.
ClassX* pMm; //Объект типа "Указатель на объект". Всё хорошо.
ClassX FF(char char,int i = sizeof(ClassX));
/*
Прототип функции. Второму параметру присваивается значение по
умолчанию. И напрасно! Здесь ошибка. В этот момент ещё неизвестен
размер класса ClassX.
*/
// А вот вполне корректное определение встроенной функции.
int RR (int iVal)
{
int i = sizeof(ClassX);
return i;
}
/*
Полный разбор операторов в теле функции производится лишь после
полного разбора объявления класса. К этому моменту размер класса
уже будет определён.
*/
}


 

Битовые поля

Битовое поле - это последовательность битов. Минимальная длина битового поля, естественно, равняется 1 (одному биту), максимальная длина зависит от реализации. Битовое поле длинной в восемь бит - не байт. Байт - это минимальная адресуемая область памяти ЭВМ, битовое поле - языковая конструкция. Среди форм Бэкуса-Наура, посвящённых объявлению класса, напомним соответствующую БНФ:

ОписательЧленаКласса ::= [Идентификатор] : КонстантноеВыражение

Вот такой описатель члена класса и задаёт битовое поле. Битовое поле может существовать исключительно как элемент класса. Идентификатор (необязательный!) задаёт имя поля, константное выражение - размеры этого поля в битах. Согласно ранее приведённым БНФ, подобному описателю должны предшествовать спецификаторы объявления. Как известно, они специфицируют тип объявляемого члена класса.

В C++ существует ограничения на тип битового поля. Это всегда целочисленный тип. Вполне возможно, что тип знаковый. По крайней мере, в Borland C++, максимально допустимый размер поля равняется длине (количеству бит), объекта соответствующего типа.

Рассмотрим пример объявления битового поля:

ОбъявлениеЧленаКласса ::= [СписокСпецификаторовОбъявления] [СписокОписателейЧленовКласса]; ::= СпецификаторОбъявления ОписательЧленаКласса; ::= int [Идентификатор] : КонстантноеВыражение; ::= int MyField:5; А вот как объявления битовых полей выглядят в контексте объявления класса: struct BitsFields { int IntField : 1; char CharField : 3; int : 3 unsigned UnsignedField : 1; };

Неименованное битовое поле также является членом класса. Существует множество ситуаций, в которых оправдано использование неименованных битовых полей. В конце концов, они ничем не хуже неименованных параметров функций. Неименованные поля могут использоваться для заполнения соответствующей области памяти. Если это поле является полем нулевой длины (для неименованного поля возможно и такое), оно может задавать выравнивание следующего битового поля по границе очередного элемента памяти. Хотя и здесь, в конечном счёте, многое зависит от реализации.

К неименованным битовым полям нельзя обратиться по имени, их невозможно инициализировать и также невозможно прочитать их значение путём непосредственного обращения к битовому полю.

Именованные битовые поля инициализируются подобно обычным переменным. И значения им можно присвоить любые (разумеется, в пределах допустимого для данного типа диапазона значений).

BitsFields QWE; ::::: QWE.CharField = 100; QWE.IntField = 101; QWE.UnsignedField = 1;

Но фактически значения в битовом поле ограничиваются размерами битового поля. Было поле объявлено размером в три бита - диапазон его значений и будет ограничен этими самыми тремя битами:

cout << QWE.CharField << "....." << endl; cout << QWE.IntField << "....." << endl; cout << QWE.UnsignedField << "....." << endl; :::::

В Borland C++ у битового поля знакового типа, независимо от размеров этого поля, один из битов остаётся знаковым. В результате, однобитовое знаковое поле способно принимать только одно из двух значений: либо -1, либо 0.

В ряде книг утверждается, что битовые поля способствуют "рациональному использованию памяти". В "Справочном руководстве по C++" на этот счёт высказывается мнение, что подобные усилия "наивны и вместо цели (экономии памяти) могут привести к лишним тратам памяти". Даже если в конкретной реализации и удастся упаковать несколько маленьких элементов в одно слово, то извлечение значения битового поля может потребовать дополнительных машинных команд.

Здесь экономия "по мелочам" на деле может обернуться большими потерями. Однако, если битовые поля существуют, значит, кому-то могут быть необходимы или, по крайней мере, удобны.


 

Функции-члены: прототипы и определения

При трансляции объявления класса и иножества обычных функций транслятор использует различные методы. Следующий пример подтверждает это:

// функции-члены класса объявлены без прототипов. class xClass { void f1() {f2();} // Функция-член f1 содержит вызов ещё неизвестной функции f2. void f2() { } }; // Следующие функции также объявляются без прототипов. void f1() {f2();} // Здесь будет зафиксирована ошибка. // Транслятор ничего не знает о функции f2(). void f2() { } void main() {f1();}

Определяемая непосредственно в теле класса функция-член класса оказывается без прототипа. За счёт дополнительного прохода по объявлению класса, транслятор самостоятельно строит прототип такой функции. При этом определение встроенной функции преобразуется к определению обычной функции-члена с квалифицированным именем и располагаемой вне объявления класса. В результате в классе всё равно не остаётся ни одного определения функции. Все они оказываются за пределами тела класса. Непосредственно в классе остаются лишь прототипы. Построение прототипа функции-члена по её определению при условии нескольких проходов по объявлению класса не самая сложная задача для транслятора. И только после этого, на основе восстановленного списка прототипов функций-членов транслятор приступает к разбору самих функций.

Новые алгоритмы разбора порождают дополнительные ограничения на структуру объявления класса. Прототип функции не может располагаться в теле класса вместе с определением функции. Из-за этого в классе не допускается более одного прототипа для каждой функции-члена. Однако допускается поэтапная инициализация параметров: часть из них можно проинициализировать в прототипе, часть непосредственно при определении функции:

class QQQ { //int www(int, int); int www(int, int = 0); }; int QQQ::www(int key1 = 100, int key2){ return key2;}


 

Определение и инициализация объекта-представителя класса

Определение объекта предполагает выделение области памяти, достаточное для размещения данных-членов объекта и организацию ссылки на объект.

В C++ существует множество способов определения (создания) объектов.

В частности, объект может быть создан:

  • как глобальная переменная,
  • как локальная переменная,
  • как элемент области активации при вызове функции,
  • при явном обращении к конструктору,
  • в результате выполнения выражения размещения,
  • как временная переменная.

В каждом из этих случаев в определении объекта принимают участие конструкторы, передача управления которым при создании объекта обеспечивается транслятором, как правило, без участия программиста.

Особенности объявления конструктора в C++ и его свойства делают синтаксически неразличимыми выражения преобразования и обращения к конструктору. В ряде случаев можно утверждать, что передача управления конструктору ("вызов" конструктора) является лишь побочным эффектом выполнения выражения преобразования.

Для изучения свойств конструктора мы объявим новый класс - класс комплексных чисел. Это благодарный пример для изучения объектно-ориентированного программирования. В дальнейшем мы не раз будем обращаться к этому классу.

class ComplexType
{
public:
double real, imag;
/* Действительная и мнимая часть комплексного числа. */
};
/*Это было объявление класса, а сейчас - определения объекта.*/
ComplexType GlobalVal;
/* Как глобальная переменная.*/
void main ()
{
ComplexType MyVal;/* Как локальная переменная.*/
ComplexType *pVal = new(ComplexType);
/* В результате выполнения выражения размещения*/
}

Если объект создаётся в результате выполнения выражения размещения, он располагается в динамической памяти и остаётся безымянным, поскольку значениями выражений размещения является значение указателя на выделенную область памяти. В этом случае обращение к объекту возможно только по указателю, означенному в результате выполнения этого выражения.

В объявлении класса невозможно указать начальные значения данных-членов (это всё-таки объявление). И поэтому после создания объекта эти значения оказываются неопределёнными. Объекты приходится дополнительно инициализировать, специально присваивая значения данным-членам класса.

В принципе нет ничего предосудительного в поэтапном определении и модификации объектов. Для этого достаточно определить несколько управляющих функций-членов класса, которые можно вызывать "от имени новорожденного объекта" для задания соответствующих значений данным-членам класса. Однако в C++ существует возможность совмещения процесса определения и инициализации. Дело в том, что у оператора определения объекта сложная семантика. C++ позволяет совмещать обязательные работы по размещению объекта в памяти, выполняемые специальным программным кодом, который автоматически подставляется транслятором на стадии генерации и работы по инициализации значений данных-членов.

Для программиста это может означать только одно: он может самостоятельно включить собственные операторы в особый список операторов, после чего транслятор гарантирует, что эти операторы будут выполняться в нужное время. Нам остаётся выяснить, куда следует встраивать эти операторы, и когда они будут выполняться.

Тот самый список операторов, который выполняется при определении объекта, и называется конструктором.

Основное назначение конструктора - определение объектов. Если программист не вмешивается в процесс построения объекта, транслятор свмостоятельно формирует стандартный конструктор, который невидим для программиста. Как и когда он используется, и что при этом он делает - об этом известно только транслятору.

Программист может объявить и определить в классе собственные версии конструктора. Собственная версия конструктора - это собственная последовательность операторов. Эти операторы выполняются непосредственно после прогаммного кода, который обеспечивает регламентные работы по созданию объекта.

Существуют строгие правила оформления подобных альтернативных конструкторов, поскольку транслятор должен понимать, что он имеет дело именно с конструктором, а не с какой-либо функцией. Правилам построения и особенностям конструкторов посвящается следующий раздел.

 

Конструкторы. Основные свойства

Сначала несколько форм Бэкуса-Наура.

Объявление ::= ОбъявлениеФункции
::= ОпределениеФункции
::= *****
ОбъявлениеФункции ::=
[СписокСпецификаторовОбъявления]
Описатель
[СпецификацияИсключения];
ОпределениеФункции ::=
[СписокСпецификаторовОбъявления]
Описатель
[ctorИнициализатор]
[СпецификацияИсключения]
ТелоФункции
Описатель ::= Описатель ([СписокОбъявленийПараметров])
::= dИмя
dИмя ::= ИмяКласса

Используя это множество БНФ, можно строить объявления весьма странного вида:

ОбъявлениеФункции ::=
Описатель; ::=
Описатель (); ::=
dИмя (); ::=
ComplexType ();

Объявление… без спецификатора объявления.

ОпределениеФункции ::=
Описатель ТелоФункции ::=
Описатель () {} ::=
dИмя () {} ::=
ComplexType () {}

А это определение. Оно построено в соответствии с правилами построения функций. Не важно, что у него в теле нет ни одного оператора! Важно, что у него нет спецификатора объявления.

Именно так и выглядит конструктор, альтернативный тому, который строится транслятором без участия программиста. Множество операторов (возможно пустое), оформленное в виде блока, с заголовком специального вида (ни слова о возвращаемых значениях) - нечто подобное функции-члену. Подобным образом организованная и весьма напоминающая своим синтаксисом обыкновенную функцию последовательность операторов и отвечает за создание объектов данного класса. 

Отметим одно очень важное обстоятельство. Имя конструктора всегда совпадает с именем класса, членом которого является объявляемый конструктор. Ни одна функция-член класса не может называться именем класса. Ни одна функция-член класса не может быть объявлена и определена без спецификатора объявления. Характерное имя и отсутствие спецификации объявления отличает конструктор от функций-членов класса.

Отсутствие спецификаторов объявления означает, что конструктор не имеет абсолютно никакого отношения к вызову и возвращению значений. Конструктор не является функцией.

Так что объявления функций-членов класса ComplexType

void ComplexType();
ComplexType ComplexType();

не являются объявлениями конструктора. Для транслятора это всего лишь некорректные объявления функций-членов с пустыми списками параметров. Подобные объявления в классе ComplexType воспринимаются транслятором как ошибки.

А вот построенное нами объявление действительно является объявлением конструктора:

ComplexType();

И наше определение действительно является определением конструктора:

ComplexType(){}

Это ничего, что конструктор такой простой, зато он от начала и до конца правильный!

Как известно, в классе может быть не объявлено ни одного конструктора. В таком случае транслятор без участия программиста самостоятельно строит стандартный конструктор. Не существует классов без конструкторов, хотя классы с автоматически создаваемыми конструкторами, как ни странно, называются классами без конструкторов.

В классе может быть объявлено (и определено) несколько конструкторов. Их объявления должны различаться списками параметров. Такие конструкторы по аналогии с функциями называются перегруженными (или совместно используемыми). Транслятор различает перегруженные конструкторы по спискам параметров. В этом смысле конструктор не отличается от обычной функции-члена класса:

ComplexType(double rePar, double imPar); /* Объявление… */
ComplexType(double rePar, double imPar){/*…*/} /*Определение…*/

И ещё один вариант конструктора для класса ComplexType - на этот раз с одним параметром (его помощью, например, можно задавать значение мнимой части):

ComplexType(double imPar); /* Объявление… */
ComplexType(double imPar){/*…*/} /*Определение…*/

Здесь мы сознательно опять оставили пустыми тела конструкторов. Необходимо сначала выяснить, какие операторы могут, а какие не могут располагаться в конструкторе.

Отсутствие спецификации возвращаемого значения не означает запрета на использование оператора return в теле конструктора. В конце концов, это всего лишь оператор перехода. Но использование этого оператора в сочетании с выражением, задающим возвращаемое значение, например,

return NULL;
либо
return MyVal;
либо
return 125;

и т.д., недопустимо. Возвращаемое значение специфицируется по типу, а как раз про тип возвращаемого конструктором значения в объявлении конструктора ничего и не сказано. Поэтому то, что обычно называется выражением явного вызова конструктора, вызовом, по сути, не является.


 

Выражение вызова (обращения к конструктору)

Часто вообще невозможно сказать что-либо определённое по поводу того, что обеспечивает передачу управления конструктору - так называемое выражение вызова (или обращения к конструктору), либо выражение, которое используется для преобразования типа (постфиксный вариант выражения преобразования типа). Соответствующая БНФ уже приводилась ранее. Напомним её:

ПосфиксноеВыражение ::= ИмяПростогоТипа ([СписокВыражений])

ИмяПростогоТипа и имя конструктора совпадают. Поэтому имя простого типа можно рассматривать как имя конструктора. При вычислении значения выражения приведения для производных типов управление действительно передаётся одноименному конструктору. Без участия конструктора невозможно определить значение соответствующего выражения:

(ComplexType) 25;
/* В этом случае мы имеем дело с выражением преобразования. При
вычислении его значения производится обращение к конструктору
ComplexType(double). */
(float) 25;
/* Здесь нет никаких обращений к конструктору. Базовый тип float
классом не является и конструкторов не имеет. Перед нами оператор,
состоящий из выражения приведения (целочисленное значение приводится
к типу float). */
float x = float(25);
/* В этом случае для определения значения выражения явного
преобразования типа, записанного в функциональной форме, также не
требуется никаких обращений к конструктору. */
ComplexType (25);
/* Казалось бы, здесь мы также имеем дело с функциональной формой
выражения явного преобразования типа - оператором на основе постфиксного
выражения. Для вычисления значения этого выражения необходимо обратиться
к конструктору ComplexType(double). */

На последнее предложение следует обратить особое внимание. Дело в том, что аналогичный оператор на основе постфиксного выражения для основных типов языка C++ воспринимается транслятором как ошибка:

float (25);
/* Это некорректный оператор! Для любого из основных типов C++
здесь будет зафиксирована ошибка. */

Возникает, на первый взгляд, очень странная ситуация. С одной стороны, мы можем построить операторы на основе любых выражений, в том числе и на основе выражения явного приведения типа. При этом тип, к которому приводится конкретное выражение, не влияет на корректность оператора. Для производного типа принципиально лишь наличие объявления соответствующего класса. С другой стороны, оператор, построенный на основе функциональной формы выражения приведения, оказывается некорректным. Похоже, что перед нами единственный случай, при котором важно наличие соответствующего конструктора.

Обращение к грамматике языка C++ позволяет объяснить подобное поведение транслятора. Он воспринимает предложения, которые начинаются с имени основного типа (в этом случае нет речи ни о каких конструкторах), как начало объявления. При этом следом за именем основного типа в объявлении может располагаться лишь один из вариантов описателя (возможно, что заключённый в круглые скобки). При анализе структуры объявления мы уже встречались с такими описателями. Заключённое в круглые скобки число (а возможно и имя ранее объявленной в каком-либо другом объявлении переменной), в контексте объявления может восприниматься лишь как альтернативная форма инициализатора, но не как описатель.

Таким образом, оператор

float (25);

(и ему подобные операторы для основных типов) представляется транслятору объявлением с пропущенным описателем и альтернативной формой инициализатора. Чем-то, напоминающим следующую конструкцию:

float = 25;

при разборе подобного предложения транслятор, естественно, не находит ожидаемого описателя и сообщает об ошибке в объявлении.

В случае производного типа, подобное выражение воспринимается как явное обращение к конструктору, в результате которого создаются безымянные объекты, время жизни которых ограничивается моментом их создания.


Условное выражение на основе выражения явного преобразования

В C++ можно построить условное выражение на основе выражения явного преобразования к одному из основных типов. Основные типы имеют простую структуру, а потому значение такого выражения определить очень просто:

if (char(charVal)) {/*…*/}
if (float(5)) {/*…*/}
if ((int)3.14){/*…*/}
if (double (0)){/*…*/}

Включение в условия условных операторов выражений, вычисление значений которых приводит к передаче управления конструкторам, требует дополнительных усилий со стороны прораммиста. У порождаемых конструкторами объектов сложная структура и неизвестные транслятору способы определения значений, представляемых такими объектами. Кроме того, определённый в языке набор операций приспособен исключительно для работы со значениями основных типов. Транслятор не имеет абсолютно никакого представления о том, каким образом следует, например, сравнивать значения того же самого ComplexType.

Однако, C++ располагает специальными средствами, которые позволяют создавать иллюзию условных выражений с объектами-операндами производных типов. Чуть позже мы рассмотрим так называемые операторные функции (или перегруженные операции), с помощью которых можно будет всё-таки сформулировать условия, подобные тем, которые формулируются относительно значений основных типов:

if (ComplexType()){/*…*/}
if (ComplexType() > 10 && ComplexType() <= 25 ){/*…*/}

Правда, в данном контексте за символами операций сравнения и даже за выражением "явного вызова конструктора" скрываются так называемые сокращённые формы вызова операторных функций, а не обычные операции C++.

А какое условие можно сформулировать в терминах операций, пригодных для работы исключительно со значениями основных типов по поводу значения безымянного объекта производного типа, который, к тому же и погибает сразу же после своего рождения?

В C++ невозможно сформулировать условие относительно сложного объекта "в целом", используя при этом стандартный набор операций, но легко можно определить значения данных-членов этого объекта. Для этого используется операция выбора компонента:

if (ComplexType().real && !ComplexType().imag){/*…*/}

Вот мы и узнали кое-что о свойствах объекта. Правда, объектов в условии целых два. У первого безымянного объекта мы поинтересовались значением данного-члена real, после чего он благополучно отошёл "в мир иной", у второго объекта выяснили значение данного-члена imag.

Выражения вызова функций типа void так же недопустимы в контексте условия, поскольку функции void "возвращают" пустые значения. Например,

void MyProc();
:::::
void MyProc() {/*…*/}
:::::
if (MyProc()) {/*…*/} /* Здесь ошибка */
for ( ; MyProc(); ) {/*…*/} /* Здесь ошибка */
if (ComplexType()){/*…*/} /* Это тоже ошибка */

Выражение явного преобразования типа можно расположить справа от символа операции присвоения в операторе присвоения.

ComplexType MyVal = ComplexType ();
ComplexType MyVal = ComplexType (25);
ComplexType MyVal = (ComplexType) 25;

И опять перед нами так называемый явный вызов конструктора. Но, как сказано в справочном руководстве по C++, "явный вызов конструктора означает не то же самое, что использование того же синтаксиса для обычной функции-члена". Конструктор вызывается не для объекта класса, как другие функции-члены, а для области памяти. Для её преобразования ("превращения") в объект класса.

На самом деле, здесь конструктор вызывается дважды. В первый раз при создании переменной MyVal, второй - в ходе выполнения операции явного преобразования значения, возможно, что пустого. При этом создаётся временный безымянный объект, значения данных-членов которого присваиваются переменной MyVal. Нам ещё предстоит выяснить, как работает операция присвоения на множестве производных типов, в частности, в сочетании с выражением явного преобразования типа, которое приводит к вызову конструктора. И если можно ещё как-то представить пустое значение, которое используется для начальной инициализации данных-членов вновь создаваемого объекта, то присвоение пустого значения леводопустимому выражению в принципе невозможно. Поэтому выражение вызова функции с void спецификатором в операторе присвоения недопустимо:

int MyVal = MyProc(); /* Ошибка */
int MyVal = (void)MyProc(); /* Ошибка */

И ещё одно сравнение между конструктором и void-процедурой. Поскольку тип void - это всё же тип, мы можем объявить указатель на void-процедуру.

void MyFunction (void);
:::::
void (*MyFunctionPointer) (void);

Указатель на функцию можно настроить на адрес конкретной функции. Для этого существует операция взятия адреса:

MyFunctionPointer = MyFunction; /* Можно так. */
MyFunctionPointer = &MyFunction; /* А можно и так. */

С конструктором всё по-другому. Мы можем определить адрес создаваемого конструктором объекта. Всё то же выражение явного преобразования типа обеспечивает обращение к конструктору, который создаёт в памяти безымянный объект, чей адрес и определяется операцией взятия адреса:

if (&ComplexType()) {/*…*/}

Но вот объявить указатель на конструктор и определить адрес конструктора невозможно. Объявление указателя на функцию требует стандартной спецификации типа функции. Операция взятия адреса возвращает значение определённого типа. Конструктор же не обладает стандартной спецификацией, а потому невозможно определить для него указатель и определить соответствующее значение.


 

Инициализация объекта: параметры и инициализаторы

Совместно используемые функции различаются списками параметров. В этом смысле конструкторы подобны функциям. Рассмотрим определение конструктора с параметрами. Мы расположим его за пределами класса. При этом в классе располагается прототип конструктора, а его имя при определении заменяется квалифицированным именем:

class ComplexType {
:::::
public:
ComplexType(double keyReal,
double keyImag,
char keyCTcharVal,
int keyX);
:::::
};
:::::
ComplexType::ComplexType(double keyReal,
double keyImag,
char keyCTcharVal,
int keyX)
{
cout << "This is ComplexType("
<< keyReal << ","
<< keyImag << ","
<< (int)keyCTcharVal << ","
<< keyX << ")" << endl;
real = keyReal;
imag = keyImag;
CTcharVal = keyCTcharVal;
x = keyX;
};

А вот и подходящее определение. Мы расположим его в функции main:

ComplexType CDw2(100,100,0,0);
/* Создаётся объект типа ComplexType под именем CDw2 с
определёнными значениями. */
int iVal(10);
/* Аналогичным образом может быть определён и проинициализирован
объект основного типа */

Заметим, что к такому же результату (но только окольными путями) приводит и такая форма оператора определения:

ComplexType CDw2 = ComplexType(100,100,0,0);

И снова мы встречаем случай определения объекта посредством постфиксного выражения. Здесь опять можно говорить о явном обращении к конструктору с передачей ему параметров. Выражения явного приведения типа здесь построить невозможно, поскольку за заключённым в скобочки именем типа должно стоять унарное выражение.

Заметим, что не может быть операторов определения переменных с пустым списком инициализаторов:

ComplexType CDw1(); // Это ошибка!
int xVal(); // Это тоже не определение.

Независимо от типа определяемой переменной, подобные операторы воспринимаются транслятором как прототипы функций с пустым списком параметров, возвращающие значения соответствующего типа.

При объявлении и определении функций C++ позволяет производить инициализацию параметров. Аналогичным образом может быть модифицирован прототип конструктора с параметрами:

ComplexType(double keyReal = 0,
double keyImag = 0,
char keyCTcharVal = 0,
int keyX = 0);

Но при этом программист должен быть готовым к самым неожиданным ситуациям. Последняя модификация прототипа вызывает протест со стороны транслятора. Он не может теперь однозначно соотнести оператор определения объекта с одним из вариантов конструктора. Перед нами тривиальный случай проявления проблемы сопоставления. Мы закомментируем определение самого первого конструктора (конструктора без параметров) и опять всё будет хорошо. Теперь вся работа по определению и инициализации объектов обеспечивается единственным конструктором с проинициализированными параметрами.

Конструктор, управление которому передаётся в результате выполнения оператора определения без параметров, называется конструктором умолчания. К конструкторам умолчания относятся следующие конструкторы:

  • конструктор, автоматически создаваемый транслятором,
  • определяемый программистом конструктор с пустым списком параметров,
  • конструктор с проинициализированными по умолчанию параметрами.

Внесём ещё одно изменение в текст нашей программы. На этот раз мы добавим спецификатор const в объявление данного-члена класса x:

class ComplexType
{
:::::
const int x;
:::::
}

И опять возникают новые проблемы. На этот раз они связаны с попыткой присвоения значения константе.


 

ctorИнициализатор

Как известно, объявление данного-члена класса не допускает инициализации, а для того, чтобы константный член класса в процессе создания объекта всё же мог получить требуемое значение, в C++ используется так называемый ctorИнициализатор (именно так называется эта конструкция в справочном руководстве по C++ Б.Строуструппа). Мы не будем гадать, в чём заключается смысл этого названия, а лучше заново воспроизведем несколько форм Бэкуса-Наура.

ОпределениеФункции ::= [СписокСпецификаторовОбъявления]
Описатель
[ctorИнициализатор]
ТелоФункции
ctorИнициализатор ::= : СписокИнициализаторовЧленовКласса
СписокИнициализаторовЧленовКласса ::= ИнициализаторЧленаКласса
[, СписокИнициализаторовЧленовКласса]
ИнициализаторЧленаКласса ::= ПолноеИмяКласса([СписокВыражений])
::= Идентификатор([СписокВыражений])
ПолноеИмяКласса ::= КвалифицированноеИмяКласса
::= :: КвалифицированноеИмяКласса

Для исследования свойств ctorИнициализатора, подвергнем нашу программу очередной модификации. Мы закомментируем все ранее построенные объявления и определения конструкторов и те из операторов определения объектов класса ComplexType, которые содержали значения, определяющие начальные значения данных-членов. И сразу же начинаем определение новых вариантов конструкторов.

ComplexType():x(1)
{
cout << "Здесь ComplexType():x(" << x << ")" << endl;
};

Перед нами конструктор с ctorИнициализатором. Эта конструкция позволяет решать проблемы начальной инициализации константных данных-членов. При работе с данными-членами класса транслятор рассматривает операцию присвоения как изменение начального значения члена. Инициализатор же отвечает непосредственно за установку этого САМОГО ПЕРВОГО значения.

В список инициализаторов разрешено включать все нестатические членам класса (объявленным без спецификатора static), но не более одного раза. Так что следующий вариант конструктора будет восприниматься как ошибочный:

ComplexType():x(1), x(2) // Ошибка.
{
:::::
}

Нетерминальный символ ПолноеИмяКласса определяет синтаксис инициализации нестатических объектов так называемого базового класса (об этом позже). В этом случае список выражений как раз обеспечивает инициализацию членов базового класса.

Добавим в объявление нашего класса объявление массива. Инициализация массива-члена класса при определении объекта не вызывает особых проблем (здесь следует вспомнить раздел, посвящённый массивам-параметрам). Однако в C++ отсутствует возможность инициализации нестатического константного массива-члена класса. Так что можно не стараться выписывать подобные объявления:

const int xx[2]; // Бессмысленное объявление.

всё равно массив xx[2] невозможно проинициализировать. Все варианты инициализации константного нестатического массива будут отвергнуты.

ComplexType():xx(1,2) {/*…*/};
ComplexType():xx({1,2}) {/*…*/};
ComplexType():xx[0](1), xx[1](2) {/*…*/};

Согласно БНФ, в состав инициализатора могут входить только имена или квалифицированные имена. Для обозначения элемента массива этого недостаточно. Как минимум, здесь требуется выражение индексации, которое указывало бы номер элемента массива.

И всё же выход из такой ситуации существует. Можно объявить константный указатель на константу, которому в выражении инициализации можно присвоить имя ранее определённого массива:

:::::
const int DefVal[2] = {1,2};
class ComplexType
{
:::::
const int const * px;
/* Объявили константный указатель на константу. */
:::::
ComplexType():px(DefVal) {/*…*/};
:::::
};

Окольными путями мы всё же достигаем желаемого результата. Константный указатель на константу контролирует константный массив.

Услугами инициализатора могут пользоваться не только константные члены, а инициализирующие значения можно строить на основе самых разных выражений. Главное, чтобы используемые в этих выражениях имена располагались в соответствующих областях видимости:

ComplexType():px(DefVal),
x(px[0]), // Транслятор уже знает, что такое px.
CTcharVal(32),
real(100),
imag(real/25) // И здесь тоже всё в порядке.
{
// Здесь располагается тело конструктора.
:::::
}



Конструктор копирования

Если определить переменную основного типа и присвоить ей значение, то выражение, состоящее из имени переменной, получит соответствующее значение. Имя означенной переменной можно расположить справа от знака операции присвоения. В результате выполнения этой операции присвоения, леводопустимое выражение окажется равным значению ранее объявленной и проинициализированной нами переменной. Произойдёт копирование значений объектов.

int iVal1;
int iVal2;
iVal1 = 100;
iVal2 = iVal1;

Это нам давно известно. Это тривиально. Менее тривиальным оказывается результат выполнения операции присвоения для объектов-представителей класса.

Вернёмся к старой версии конструктора (её проще повторно воспроизвести, чем описывать словами) и снова модифицируем main процедуру нашей программы. Мы определяем новый объект, используем операцию присвоения и наблюдаем за результатами:

ComplexType()
{
real = 0.0;
imag = 0.0;
CTcharVal = 0;
x = 0;
cout << "Здесь ComplexType() конструктор!" << endl;
}
:::::
void main()
{
ComplexType CDw1;
ComplexType CDw2 = CDw1;
cout << "(" << CDw1.real << ", " << CDw1.imag << "i)" << endl;
cout << (int)CDw1.CTcharVal << ", " << CDw1.x << "…" << endl;
cout << "(" << CDw2.real << ", " << CDw2.imag << "i)" << endl;
cout << (int)CDw2.CTcharVal << ", " << CDw2.x << "…" << endl;
}

Наша программа состоит из двух операторов определения, один из которых содержит описатель-инициализатор, и двух пар операторов вывода, которые сообщают о состоянии новорожденных объектов.

В программе определяется два объекта. Можно предположить, что у этих объектов окажутся одинаковые значения данных-членов. Было бы странно, если бы результат операции присвоения для основных типов по своему результату отличался бы от операции присвоения для данных производных типов.

Действительно, судя по поступающим сообщениям, оба объекта успешно были созданы и существуют с одинаковыми значениями данных-членов. При этом мы имеем дело с разными объектами, которые располагаются по разным адресам. В этом можно убедиться, если добавить оператор вывода в конец функции main:

if (&CDw1 != &CDw2) cout << "OK!" << endl;
/* Сообщить о разных адресах.*/

И всё же выполнение этой тривиальной программы приводит к неожиданному результату: создавая два объекта, мы наблюдаем всего одно сообщение о работе конструктора.

Остаётся предположить, что за процесс создания объекта с одновременным копированием значений данных-членов другого объекта, отвечает конструктор ещё неизвестного нам типа.

Так и есть! Такой конструктор существует и называется конструктором копирования. Вместе с конструктором умолчания, конструктор копирования входит в обязательный набор конструкторов для любого класса. Реализация механизма копирования значений для транслятора не является неразрешимой задачей. Конструктор копирования всего лишь создаёт копии объектов. Этот процесс реализуется при помощи стандартного программного кода. И построить такой код транслятор способен самостоятельно.

Здесь и далее, в примерах нами будет применяться операция присвоения = . В определённом смысле эта операция подобна конструктору. Реализующий эту операцию код автоматически создаётся на этапе трансляции для любого класса. Как и генерация кода стандартных конструкторов, это не самая сложная задача.

Подобно конструктору умолчания, конструктор копирования наряду с уже известной нам формой вызова

ComplexType CDw2 = CDw1;

имеет несколько альтернативных, приводящих к аналогичному конечному результату вызовов:

ComplexType CDw2(CDw1);
ComplexType CDw3 = ComplexType(CDw1);

Обе альтернативные формы вызова напоминают нам уже известные формы вызова конструкторов с параметрами. Чтобы восстановить структуру заголовка конструктора копирования, мы должны лишь определить тип его параметра.

На первый взгляд, здесь всё просто. В качестве значения параметра конструктору передаётся имя объекта, значит можно предположить, что тип параметра конструктора копирования соответствует данному классу. Так, в нашем случае, конструктор копирования класса ComplexType должен был бы иметь параметр типа ComplexType. Однако это не так. И вот почему.

В C++ конструктор копирования является единственным средством создания копий объекта.

С другой стороны, конструктор копирования - это конструктор, который поддерживает стандартный интерфейс вызова функций. Это означает, что параметры при обращении к конструктору, подобно параметрам функции передаются по значению. Если выражение вызова содержит значения параметров, то в ходе его реализации в области активации функции создаётся копия этих значений.

В таком случае, вызов конструктора копирования сопровождался бы построением в области активации конструктора копии объекта. Для этого пришлось бы использовать конструктор копирования как единственное средство построения копии объекта. Таким образом, вызов подобного конструктора копирования сопровождался бы бесконечной рекурсией.

Итак, КОНСТРУКТОР КОПИРОВАНИЯ КЛАССА X НЕ МОЖЕТ ИМЕТЬ ПАРАМЕТР ТИПА X. Это аксиома.

На самом деле, в конструкторе копирования класса X в качестве параметра используется ссылка на объект этого класса. Причём эта ссылка объявляется со спецификатором const. И в этом нет ничего странного. Как известно, выражение вызова функции с параметром типа X ничем не отличается от выражения вызова функции, у которой параметром является ссылка на объект типа X. При вызове такой функции не приходится копировать объекты как параметры. Передача адреса не требует копирования объекта, а значит, при этом не будет и рекурсии.

Конструктор копирования - обязательный элемент любого класса. Он также может быть переопределён подобно конструктору умолчания. При этом работа со ссылками в конструкторе копирования не требует явного использования операции разыменования. А спецификатор const (конструктор копирования работает с адресом объекта) предохраняет объект-параметр от случайной модификации в теле конструктора.


 

Переопределение конструктора копирования

Упомянутая нами в предыдущем разделе аксиома о конструкторе копирования имеет одно интересное следствие.

В классе X в принципе не может быть объявлено конструктора с ЕДИНСТВЕННЫМ параметром типа X. Это происходит из-за того, что выражение вызова такого конструктора просто невозможно будет отличить от выражения вызова конструктора копирования. Не бывает совместно используемых функций с неразличимыми выражениями вызова.

А определение функций, которым в качестве параметров передаются значения объектов данного класса, возможно. При этом в реализации вызова подобных функций конструкторам копирования отводится значительная роль. Они отвечают за создание копии объекта в области активации вызываемой функции.

Итак, конструктор копирования предназначается для копирования объектов. Он также участвует в реализации механизма передачи параметров при вызове функций.

Мы можем построить собственную версию конструктора копирования. По традиции мы начинаем с ничего не делающего конструктора. Наш новый встроенный конструктор копирования лишь сообщает о собственном присутствии.

ComplexType(const ComplexType& ctVal)
{
cout << "Здесь конструктор копирования" << endl;
} ;
//^ В теле класса ComplexType мы имеем право на эту точку с запятой…

Несмотря на пустое тело, перед нами настоящий конструктор копирования. Всякий конструктор, параметром которого является ссылка на объект-константу, представляющий данный класс, называется конструктором копирования. Даже если этот конструктор ничего не копирует.

Переопределение конструктора копирования является чрезвычайно ответственным поступком. Явное определение конструктора копирования вызывает изменения в работе программы. Пока мы не пытались переопределить конструктор копирования, исправно работал конструктор, порождаемый транслятором. Этот конструктор создавал "фотографические" копии объектов, то есть копировал значения абсолютно всех данных-членов, в том числе и ненулевые значения указателей, представляющие адреса динамических областей памяти. 

С момента появления переопределённой версии конструктора копирования, вся работа по реализации алгоритмов копирования возлагается на программиста. Переопределённый конструктор копирования может вообще ничего не копировать (как и наш новый конструктор). Впрочем, заставить конструктор копирования копировать объекты совсем несложно:

ComplexType(const ComplexType& ctVal)
{
cout << "Здесь конструктор копирования" << endl;
real = ctVal.real;
imag = ctVal.imag;
CTcharVal = ctVal.CTcharVal;
x = ctVal.x;
}

Но конструктор, создающий подобные копии объектов, скорее всего, окажется непригодным для работы с объектами, содержащими указатели или ссылки. Не самым удачным решением является ситуация, при которой данные-члены типа char*, их нескольких объектов, возможно расположенных в различных сегментах памяти, в результате деятельности конструктора копирования настраиваются на один и тот же символьный массив.

В переопределяемом конструкторе копирования (а в классе он может быть только один) можно реализовывать разнообразные алгоритмы распределения памяти. Здесь всё зависит от программиста.


Указатель this

Продолжаем определение класса ComplexType. Теперь объявим и определим функцию-член PrintVal, которая будет выводить значение чисел-объектов.

Прототип функции разместим в классе:

void PrintVal();

При определении функции используется квалифицированное имя:

void ComplexType::PrintVal()
{
cout << "(" << real << ", " << imag << "i)" << endl;
cout << (int)CTcharVal << ", " << x << "…" << endl;
}

Значения данных-членов объекта выводятся при выполнении выражения вызова функции PrintVal:

CDw1.PrintVal();

Объекты класса имеют свои собственные экземпляры данных-членов. Данные-члены имеют свои собственные специфические значения. Вместе с тем, все объекты используют единый набор функций-членов, с помощью которого можно получить доступ к значениям данных-членов во всех объектах класса.

Среди операторов функции-члена PrintVal() нет ни одного оператора, который позволял бы определить, какому объекту принадлежат данные-члены. И, тем не менее, вызов этой функции для каждого из определённых и различным образом проинициализированных объектов, в том числе и для безымянного объекта, который создаётся в результате непосредственного вызова конструктора:

ComplexType(0.0,0.0, 1).PrintVal(); ,

а также вызов функции для объекта, адресуемого указателем:

pCD->PrintVal();

сопровождается сообщением о значениях собственных данных-членов. Заметим, что "собственные" данные-члены объектов, как и те функции-члены класса, с которыми мы уже успели познакомиться, считаются нестатическими данными и функциями-членами класса. Существуют также и статические члены класса, к изучению свойств которых мы обратимся в недалёком будущем.

Автоматическое определение принадлежности данных-членов конкретному объекту характерно для любой нестатической функции-члена класса. Объекты являются "хозяевами" нестатических данных и потому каждая нестатическая функция-член класса должна уметь распознавать "хозяйские" данные.

Вряд ли алгоритм распознавания хозяина данных очень сложен. Здесь проблема заключается совсем в другом: этот алгоритм должен быть реализован практически для каждой нестатической функции-члена класса. Он используется везде, где производится обращение к данным-членам объектов, а это означает, что на программиста может быть возложена дополнительная обязанность по кодированию. Несколько обязательных строк для каждой функции-члена? Да никогда…

К счастью, C++ освобождает программистов от утомительной и однообразной работы кодирования стандартного алгоритма распознавания. В C++ вообще многое делается без их участия. Функции-члены определяются как обычные функции. Транслятор переопределяет эти функции, обеспечивая при этом стандартными средствами связь между объектами и их данными. Эта связь реализуется благодаря специальному преобразованию исходного кода программы. Мы опишем это преобразование, условно разделив его на два этапа.

На первом этапе каждая нестатическая функция-член преобразуется в функцию с уникальным именем и дополнительным параметром - константным указателем на объект класса. Затем преобразуются обращения к нестатическим данным-членам в операторах функции-члена. Они переопределяются с учётом нового параметра. В C++ при подобном преобразовании для обозначения дополнительного параметра-указателя (константного указателя) и постфиксного выражения с операциями обращения для обращения к нестатическим данным-членам используется одно и то же имя this. Вот как могла бы выглядеть функция-член PrintVal после её переопределения:

void ComplexType::ComplexType_PrintVal(ComplexType const *this)
{
cout << "(" << this->real << "," << this->imag << "i)" << endl;
cout << int(this->CTcharVal) << "," << x << "…" << endl;
}

На втором этапе преобразуются вызовы функций-членов. К списку значений параметров выражения вызова добавляется выражение, значением которого является адрес данного объекта. Это вполне корректное преобразование. Дело в том, что нестатические функции-члены всегда вызываются для конкретного объекта. И потому не составляет особого труда определить адрес объекта. Например, вызов функции-члена PrintVal() для объекта CDw1, который имеет вид

CDw1.PrintVal();

после преобразования принимает вид:

ComplexType_PrintVal(&CDw1);

А вызов функции-члена безымянного объекта, адресуемого указателем pCD

pCD->PrintVal();

преобразуется к виду

ComplexType_PrintVal(&(*pCD));

что эквивалентно следующему оператору:

ComplexType_PrintVal(pCD);

Первый (и в нашем случае единственный) параметр в вызове новой функции является адресом конкретного объекта.

В результате такого преобразования функция-член приобретает новое имя и дополнительный параметр типа указатель на объект со стандартным именем this и типом, а каждый вызов функции-члена приобретает форму вызова обычной функции.

Причина изменения имени для функций-членов класса очевидна. В разных классах могут быть объявлены одноименные функции-члены. В этих условиях обращение к функции-члену класса непосредственно по имени может вызвать конфликт имён: в одной области действия имени одним и тем же именем будут обозначаться различные объекты - одноименные функции-члены разных классов. Стандартное преобразование имён позволяет решить эту проблему.

Указатель this можно использовать в теле функции-члена без его дополнительного объявления. В частности, операторы функции ComplexType::PrintVal() могут быть переписаны с использованием указателя this:

void ComplexType::PrintVal()
{
cout << "(" << this->real << "," << this->imag << "i)" << endl;
cout << int(this->CTcharVal) << "," << x << "…" << endl;
}

Явное употребление this указателя не вызывает у транслятора никаких возражений, что свидетельствует об эквивалентности старого и нового вариантов функции. В этом случае указатель this считается не именем (имя вводится объявлением), а первичным выражением. Напомним, что имя, как и первичное выражение this являются частными случаями выражения. 

В ряде случаев при написании программы оправдано явное использование указателя this. При этом выражение

this

представляет адрес объекта, а выражение

*this

представляет сам объект:

this->ВЫРАЖЕНИЕ
(*this).ВЫРАЖЕНИЕ

(здесь нетерминальный символ ВЫРАЖЕНИЕ обозначает член класса). Эти выражения обеспечивают доступ к членам уникального объекта, представленного указателем this с целью изменения значения данного, входящего в этот объект или вызова функции-члена.

Следует помнить о том, что this указатель является константным указателем. Это означает, что непосредственное изменение его значение (перенастройка указателя, например, this++) недопустимо. Указатель this с самого начала настраивается на определённый объект.

При описании this указателя мы не случайно подчёркивали, что этот указатель используется только для нестатических функций-членов. Использование этого указателя в статических функциях-членах класса (о них речь впереди) не имеет смысла. Дело в том, что эти функции в принципе не имеют доступа к нестатическим данным-членам класса.

В объявлении нестатической функции-члена this указателю можно задавать дополнительные свойства. В частности, возможно объявление константного this указателя на константу. Синтаксис языка C++ позволяет сделать это. Среди БНФ, посвящённых синтаксису описателей, есть и такая форма:

Описатель ::=
Описатель (СписокОбъявленийПараметров) [СписокCVОписателей]
::= *****
CVОписатель ::= const
::= *****

Так что небольшая модификация функции-члена PrintVal, связанная с добавлением cvОписателя const:

void PrintVal() const;

в прототипе и

void ComplexType::PrintVal() const
{
:::::
}

в определении функции обеспечивает относительную защиту данных от возможной модификации.

CVОписатель const в заголовке функции заставляет транслятор воспринимать операторы, которые содержат в качестве леводопустимых выражений имена данных-членов, возможно, в сочетании с this указателем, как ошибочные. Например, следующие операторы в этом случае оказываются недопустимы.

this->CTcharVal = 125;
real = imag*25;
imag++;

cvОписатель const в заголовке функции не допускает непосредственной модификации значений принадлежащих объекту данных.

Заметим также, что this указатель включается также в виде дополнительного параметра в список параметров конструктора. И в этом нет ничего удивительного, поскольку его значением является всего лишь область памяти, занимаемая объектом.


 

Конструкторы и деструкторы: заключительные замечания

В общих чертах, мы закончили описание конструкторов и деструкторов - важных элементов любого класса. Хотя в дальнейшем нам придётся ещё несколько раз обратиться к этому вопросу, главное уже позади.

И всё же следует сделать несколько замечаний.

Конструктор превращает фрагмент памяти в объект. Посредством операции обращения непосредственно "от имени" объекта можно вызвать функции-члены класса.

Мы можем модифицировать известный нам класс комплексных чисел, определив новую функцию-член reVal(), предназначенную для вывода значения действительной части комплексного числа:

class ComplexType
{
public:
:::::
// Пусть это будет встроенная функция.
void reVal(){cout << real << endl;};
:::::
};

И после определения объекта CDw1, мы можем вызывать эту функцию-член класса. В результате выполнения функции будет выведено значение действительной части объекта CDw1. Важно, что объект используется как точка вызова функции:

CDw1.PrintVal();

А вот аналогичного выражения, обеспечивающего неявный вызов конструктора из объекта, как известно, не существует.

CDw1.ComplexType(CDw1); 
// Неудачная попытка неявного вызова конструктора копирования.
// НЕ ОБЪЕКТ ДЛЯ КОНСТРУКТОРА, А КОНСТРУКТОР ДЛЯ ОБЪЕКТА!

По аналогии с конструкторами копирования и преобразования в C++ можно использовать функциональную форму операторов определения переменных основных типов. Синтаксис этих операторов напоминает операторы, содержащие выражения, вычисление которых обеспечивает вызов конструкторов копирования и преобразования:

ComplexType CDw1(125);
ComplexType CDw2(CDw1);
int iVal1(25); // Соответствует int iVal1 = 25;
int iVal2(iVal1); // Соответствует int iVal2 = iVal1;

Конечно же, это не имеет никакого отношения к классам. Но вместе с тем, здесь мы можем наблюдать, как меняется грамматика при введении в язык новых типов: корректное выражение для производных типов по возможности ничем не должно отличаться от выражения для основного типа. Синтаксис операторов определение и инициализации объектов производных типов влияет на синтаксис операторов определения основных типов.

Последнее, что нам осталось сделать - это выяснить причины, по которым в C++ так различаются синтаксис объявления, определения и вызова конструкторов и деструкторов и обычных функций-членов класса.

Причина сокрытия кода регламентных работ по созданию объекта в конструкторе очевидна. Конструктор выполняет сложную работу, связанную с распределением глобальной, локальной и, как будет скоро показано, динамической памяти и превращением фрагмента памяти в объект. Это основания языка. Содержание этого процесса просто обязано быть скрытым от пользователя (программиста) подобно тому, как скрыт программный код, который реализует, например, операцию индексации, сравнения, сдвига, вызов функций и прочие языковые конструкции.

Также скрыты от нас и особенности реализации деструкторов. Не существует даже средства стандартной эффективной проверки результата выполнения деструктора: в некоторых реализациях можно обратиться к функциям-членам объекта даже после уничтожения этого объекта деструктором.

Отсутствие спецификации возвращаемого значения и запрещение операции взятия адреса для конструктора и деструктора также имеют свои объективные причины.

Если бы в их объявлениях присутствовала спецификация возвращаемого значения (неважно, какого) и было бы разрешено применение операции взятия адреса, то можно было бы в программе определять указатели на конструкторы и деструкторы как на обычные функции.

Как известно, указатель на функцию характеризуется типом возвращаемого значения и списком параметров функции. Очевидно, что имя функции в этом случае не играет никакой роли. Но как раз имя конструктора и деструктора и позволяет транслятору различать функции, конструкторы и деструкторы. При использовании указателей для вызова функций, деструкторов и конструкторов транслятор в ряде случаев просто не сможет определить, что, собственно, хочет сделать программист в данном контексте: вызвать функцию или определить новый объект.

Дополнительные ограничения при объявлении и использовании конструкторов полностью устраняют недоразумения, которые могут возникнуть при вызове функций и конструкторов.



Наследование

Наследование - один из основополагающих принципов объектно-ориентированного программирования. Под наследованием понимают возможность объявления производных типов на основе ранее объявленных типов. Как известно, в C++ существует фиксированное множество элементарных типов. Это абсолютно независимые типы и объявление одного элементарного типа на основе другого в принципе невозможно.

Спецификации объявления unsigned int или long double нельзя рассматривать как модификации элементарных типов int и double. Это полноправные элементарные типы данных со своим собственным набором свойств. В C++ также невозможно определить одну функцию на основе другой ранее определённой (правда, в C++ существует понятие шаблона функции, и мы обязательно обратимся к этому вопросу).

И вот, наконец, для класса, в C++ реализуется возможность наследования. Прежде всего, следует различать наследование и встраивание. Встраивание предполагает возможность объявления в классе отдельных членов класса на основе ранее объявленных классов. В классе можно объявлять как данные-члены основных типов, так и данные-члены ранее объявленных производных типов.

В случае же наследования новый класс в буквальном смысле создаётся на основе ранее объявленного класса, НАСЛЕДУЕТ, а возможно и модифицирует его данные и функции. Объявленный класс может служить основой (базовым классом) для новых производных классов. Производный класс наследуют данные и функции своих базовых классов и добавляют собственные компоненты.

В C++ количество непосредственных "предков" производного класса не ограничено. Класс может быть порождён от одного или более классов. В последнем случае говорят о множественном наследовании. Наследование в C++ реализовано таким образом, что наследуемые компоненты не перемещаются в производный класс, а остаются в базовом классе. Производный класс может переопределять и доопределять функции-члены базовых классов. Но при всей сложности, наследование в C++ подчиняется формальным правилам. А это означает, что, во-первых, существует фиксированный набор алгоритмов, которые позволяют транслятору однозначно различать базовые и производные компоненты классов, а во-вторых, множество вариантов наследования ограничено.

 

Базовые и производные классы

Синтаксис наследования задаётся необязательным элементом заголовка класса, который называется спецификацией базы и описывается следующим множеством форм Бэкуса-Наура:

СпецификацияБазы ::= : СписокБаз
СписокБаз ::= [СписокБаз,] ОписательБазы
ОписательБазы ::= ПолноеИмяКласса
::= [virtual] [СпецификаторДоступа] ПолноеИмяКласса
::= [СпецификаторДоступа] [virtual] ПолноеИмяКласса

Нам ещё предстоит выяснить назначение элементов описателя базы, но уже очевидно, что спецификация базы представляет собой список имён классов. Поскольку производный класс наследует данные и функции базового класса, базовые классы обязательно должны быть объявлены до объявления производного класса.

Для начала рассмотрим пример объявления нескольких классов. В этом примере задаются отношения наследования между тремя классами (классы A, B, C). При этом C наследует свойства класса B, который, в свою очередь, является наследником класса A. В этом примере все члены классов объявляются со спецификатором public, к которому мы пока относимся (пока!) как к должному. В этих классах мы объявим (просто обозначим) самые простые варианты конструкторов и деструкторов. В настоящий момент нам важно исключительно их существование.

#include <iostream.h>
class A {
public:
A(){};
~A(){};
int x0;
int f0 () {return 1;};
};
class B : public A {
public:
B(){};
~B(){};
int x1;
int x2;
int xx;
int f1 () {return 100;};
int f2 () {return 200;};
};
class C : public B {
public:
C(){};
~C(){};
int x1;
int x2;
int x3;
int f1 () {return 1000;};
int f3 () {return 3000;};
};
void main () {C MyObject;}

Перед нами пример простого наследования. Каждый производный класс при объявлении наследует свойства лишь одного базового класса. В качестве базового класса можно использовать лишь полностью объявленные классы. Неполного предварительного объявления здесь недостаточно. Для наглядного представления структуры производных классов используются так называемые направленные ациклические графы. Узлы этого графа представляют классы, дуги - отношение наследования.

Вот как выглядит направленный ациклический граф ранее приведённого в качестве примера производного класса C:

A
B
C

Структуру производного класса можно также представить в виде таблицы (или схемы класса), отображающей общее устройство класса:

A
B
C

В C++ различаются непосредственные и косвенные базовые классы. Непосредственный базовый класс упоминается в списке баз производного класса. Косвенным базовым классом для производного класса считается класс, который является базовым классом для одного из классов, упомянутых в списке баз данного производного класса.

В нашем примере для класса C непосредственным базовым классом является B, косвенным - A. Следует иметь в виду, что порядок "сцепления" классов, образующих производный класс, зависит от реализации, а потому все схемы классов и объектов имеют характер имеют чисто иллюстративный характер.

Дополним нашу схему, включив в неё объявления всех членов классов, включая, конструкторы и деструкторы.

В результате мы получаем полную схему производного класса со всеми его компонентами, вместе с его непосредственными базовыми классами, а также и косвенными базовыми классами.

A
A();
~A();
int x0;
int f0 ();
B
B();
~B();
int x1;
int x2;
int xx;
int f1();
int f2();
C
C();
~C();
int x1;
int x2;
int xx;
int f1();
int f2();

Это схема класса, а не объекта. Образно говоря, наша схема подобна схеме многоэтажного бункера, разделённого на три уровня. На схеме эти уровни разделяются двойными линиями. Класс C занимает самый нижний уровень. Именно этот класс имеет неограниченные (по крайней мере, в нашей версии объявления производного класса) возможности и полномочия доступа к элементам базовых классов. Именно с нижнего уровня можно изменять все (опять же, в нашей версии объявления класса) значения данных-членов класса и вызывать все (или почти все) функции-члены класса. 

 

Объект-представитель класса C является единым блоком объектов и включает собственные данные-члены класса C, а также данные-члены классов B и A. Как известно, функции-члены классов, конструкторы и деструкторы не включаются в состав объекта и располагаются в памяти отдельно от объектов. Так что схему объекта-представителя класса можно представить, буквально удалив из схемы класса функции-члены, конструкторы и деструкторы.

Следует также иметь в виду, что на схеме класса располагаются лишь объявления данных-членов, тогда как схема объекта содержит обозначения определённых областей памяти, представляющих данные-члены конкретного объекта.

Итак, выполнение оператора определения

C MyObj;

приводит к появлению в памяти объекта под именем MyObj. Рассмотрим схему этого объекта. Её отличие от схемы класса очевидно. Здесь мы будем использовать уже известный нам метасимвол ::= (состоит из). На схеме объекта информация о типе данного-члена будет заключаться в круглые скобки.

MyObj::=
A
(int)x0
B
(int)x1
(int)x2
(int)xx
C
(int)x1
(int)x2
(int)xx

Перед нами объект сложной структуры, в буквальном смысле собранный на основе нескольких классов. В его создании принимали участие несколько конструкторов. Порядок их вызова строго регламентирован. Вначале вызываются конструкторы базовых классов. Следом вызываются конструкторы производных классов.

Благодаря реализации принципа наследования, объект представляет собой цельное сооружение. Из объекта можно вызвать функции-члены базовых объектов. Эти функции наследуются производным классом от своих прямых и косвенных базовых классов. Непосредственно от объекта возможен доступ ко всем данным-членам. Данные-члены базовых классов также наследуются производными классами.

Если переопределить деструкторы базовых и производных классов таким образом, чтобы они сообщали о начале своего выполнения, то за вызовом деструктора производного класса C непосредственно из объекта MyObj:

MyObj.~C();

последует серия сообщений о выполнении деструкторов базовых классов. Разрушение производного объекта сопровождается разрушением его базовых компонентов. Причём порядок вызова деструкторов противоположен порядку вызова конструкторов.

А вот вызвать деструктор базового класса из объекта производного класса невозможно:

MyObj.~B(); // Так нельзя. Это ошибка!

Частичное разрушение объекта в C++ не допускается. БАЗОВЫЕ ДЕСТРУКТОРЫ НЕ НАСЛЕДУЮТСЯ. Таков один из принципов наследования.

Если бы можно было вызывать конструктор непосредственно из объекта, аналогичное утверждение о наследовании можно было бы сделать и по поводу конструкторов.

Однако утверждение о том, что базовый конструктор не наследуется так же корректно, как и утверждение о том, что стиральная машина не выполняет фигуры высшего пилотажа. Стиральная машина в принципе не летает. НИ ОДИН КОНСТРУКТОР (ДАЖЕ КОНСТРУКТОР ПРОИЗВОДНОГО КЛАССА) НЕ ВЫЗЫВАЕТСЯ ИЗ ОБЪЕКТА.

К моменту начала разбора структуры производного класса, транслятору становятся известны основные характеристики базовых классов. Базовые классы включаются в состав производных классов в качестве составных элементов. Это означает, что в производном классе (в его функциях) можно обращаться к данным-членам и вызывать функции-члены базовых классов. Можно, если только этому ничего не мешает (о том, что может этому помешать - немного позже).

Как раз в нашем случае в этом смысле всё в порядке, и мы приступаем к модификации исходного кода нашей программы.

Прежде всего, изменим код функции с именем f1, объявленной в классе C. Мы оставим в классе лишь её объявление, а саму функцию определим вне класса, воспользовавшись при этом её квалифицированным именем.

Проблемы, связанные с одноименными членами класса решаются с помощью операции разрешения области видимости. Впрочем, нам это давно известно:

int C ::f1()
{
A::f0();
/*Вызов функции-члена класса A.*/
f0();
/*
Для вызова этой функции можно не использовать специфицированного
имени. Функция под именем f0 одна на все классы. И транслятор
безошибочно определяет её принадлежность.
*/
A::x0 = 1;
B::x0 = 2;
C::x0 = 3;
x0 = 4;
/*
К моменту разбора этой функции-члена, транслятору известна структура
всех составных классов. Переменная x0 (как и функция f0) обладает
уникальным именем и является общим достоянием базовых и производных
классов. При обращении к ней может быть использовано как её собственное
имя, так и имя с любой квалификацией. Аналогичным образом может быть
также вызвана и функция f0().
*/
B::f0();
C::f0();
/* Изменение значений данных-членов. */
//A::x1 = 1;
/* Ошибка! Переменная x1 в классе A не объявлялась.*/
B::x1 = 2;
C::x1 = 3;
x1 = 4;
/*
Переменная x1 объявляется в двух классах. Транслятор определяет
принадлежность данных-членов по квалифицированным именам. В последнем
операторе присвоения транслятор считает переменную x1 собственностью
класса C, поскольку этот оператор располагается "на территории" этого
класса. Если бы класс C не содержал объявления переменной x1, последние
три оператора были бы соотнесены с классом B.
*/
//A::xx = 1;
/* Ошибка! Переменная xx в классе A не объявлялась.*/
B::xx = 2;
C::xx = 3;
xx = 4;
/*
Аналогичным образом обстоят дела с переменной xx, объявленной
в классе B. Хотя xx не объявлялась в классе C, транслятор
рассматривает эту переменную как элемент этого класса и не
возражает против квалифицированного имени C::xx.
В последнем операторе транслятор рассматривает переменную xx как
член класса B.
*/
return 150;
}
Теперь переопределим функцию-член класса B.
При её разборе (даже если определение этой функции располагается
после объявления класса C), транслятор воспринимает лишь имена
базовых классов. В это время транслятор забывает о существовании
класса C. А потому упоминание этого имени воспринимается им как ошибка.
int B ::f1()
{
A::f0();
A::x0 = 1;
B::x0 = 2;
//C::x0 = 3;
/* Ошибка. */
x0 = 4;
B::f0();
//C::f0();
/* Ошибка. */
/* Изменение значений данных-членов. */
//A::x1 = 1;
/* Ошибка. Переменная x1 в классе A не объявлялась.*/
B::x1 = 2;
//C::x1 = 3;
/* Ошибка. */
x1 = 4;
//A::xx = 1;
/* Ошибка! Переменная xx в классе A не объявлялась.*/
B::xx = 2;
//C::xx = 3;
/* Ошибка. */
xx = 4;
return 100;
}

 

Нам остаётся рассмотреть, каким образом транслятор соотносит члены класса непосредственно в объекте. Для этого переопределим функцию main():

void main ()
{
C MyObj;
MyObj.x0 = 0;
MyObj.B::x0 = 1;
MyObj.C::x0 = 2;
MyObj.f0();
MyObj.A::f0();
MyObj.C::f0();
/*
Поиск "снизу-вверх" является для транслятора обычным делом.
Транслятор способен отыскать нужные функции и данные даже у
косвенного базового класса. Главное, чтобы они были там объявлены.
И при было бы возможным однозначное соотнесение класса и его члена.
*/
MyObj.x1 = 777;
MyObj.B::x1 = 999;
cout << MyObj.A::x1 << "-" << MyObj.B::x1;
/*
Процесс соотнесения осуществляется от потомков к предкам. Не
специфицированный член класса x1 считается членом "ближайшего"
производного класса, о чём и свидетельствует последняя тройка операторов.
*/
MyObj.B::f2();
MyObj.C::f2();
/*
И опять успешное соотнесение благодаря поиску "снизу-вверх". Недостающие
элементы в производном классе можно поискать по базовым классам. Важно,
чтобы они там были.
*/
// MyObj.A::f1();
// MyObj.A::f2();
// MyObj.A::f3();
// MyObj.B::f3();
/*
А вот "сверху вниз" транслятор смотреть не может. Предки не отвечают
за потомков.
*/
}

Таким образом, корректное обращение к членам класса в программе обеспечивается операцией разрешения области видимости. Квалифицированное имя задаёт область действия имени (класс), в котором начинается (!) поиск данного члена класса. Принципы поиска понятны из ранее приведённого примера.


 

Друзья класса

Три спецификатора доступа обеспечивают в C++ управление доступом. Эти спецификаторы являются основанием принципа инкапсуляции - одного из трёх основных принципов объектно-ориентированного программирования. Соблюдение правил доступа повышает надёжность программного обеспечения.

Спецификаторы доступа способны обеспечить многоуровневую защиту функций и данных в наследуемых классах. Порождаемые на основе "инкапсулированных" классов объекты способны поддерживать жёсткий интерфейс. Они подобны "чёрным" ящикам с чётко обозначенными входами и выходами. Вместе с тем, следует признать, что система управления доступом, реализованная на основе трёх спецификаторов, не является гибкой. С её помощью может быть реализована защита по принципу "допускать ВСЕХ (члены класса, объявленные в секции public) или не допускать НИКОГО (члены класса, объявленные в секциях protected и private)". В C++ существует возможность организации более гибкой защиты. Здесь можно также объявлять функции, отдельные функции-члены классов и даже классы (в этом случае речь идёт о полном множестве функций-членов класса), которые получают доступ к защищённым и приватным членам данного класса. Что означает реализацию системы управления доступом принципу "не допускать НИКОГО, КРОМЕ". Такие функции и классы называют дружественными функциями и классами. Объявление дружественных классов и функций включается в объявление данного класса вместе со спецификатором объявления friend. Здесь нам потребуется всего одна форма Бэкуса-Наура для того, чтобы дополнить синтаксис объявления.

СпецификаторОбъявления ::= friend
::= *****

Рассмотрим небольшой пример использования дружественных функций и классов, а затем сформулируем основные правила работы с друзьями классов. В программе объявлены два класса, один из которых является другом другого класса и всеобщая дружественная функция.

#include <iostream.h>
class XXX;
/*
Неполное объявление класса. Оно необходимо для объявления типа
параметра функции-члена для следующего класса.
*/
class MMM
{
private:
int m1;
public:
MMM(int val);
void TypeVal(char *ObjectName, XXX& ClassParam);
};
MMM::MMM(int val)
{
m1 = val;
}
/*
Определение функции-члена TypeVal располагается после объявления
класса XXX. Только тогда транслятор узнаёт о структуре класса, к
которому должна получить доступ функция MMM::TypeVal.
*/
class XXX
{
friend class YYY;
friend void MMM::TypeVal(char *ObjectName, XXX& ClassParam);
friend void TypeVal(XXX& ClassParamX, YYY& ClassParamY);
/*
В классе объявляются три друга данного класса: класс YYY, функция-член
класса MMM, простая функция TypeVal. В класс XXX включаются лишь
объявления дружественных функций и классов. Все определения
располагаются в других местах - там, где им и положено быть - в своих
собственных областях видимости.
*/
private:
int x1;
public:
XXX(int val);
};
XXX::XXX(int val)
{
x1 = val;
}
void MMM::TypeVal(char *ObjectName, XXX& ClassParam)
{
cout << "Значение " << ObjectName << ": " << ClassParam.x1 << endl;
}
/*
Отложенное определение функции-члена MMM::TypeVal.
*/
class YYY
{
friend void TypeVal(XXX& ClassParamX, YYY& ClassParamY);
private:
int y1;
public:
YYY(int val);
void TypeVal(char *ObjectName, XXX& ClassParam);
};
YYY::YYY(int val)
{
y1 = val;
}
void YYY::TypeVal(char *ObjectName, XXX& ClassParam)
{
cout << "Значение " << ObjectName << ": " << ClassParam.x1 << endl;
}
void TypeVal(XXX& ClassParamX, YYY& ClassParamY);
void main()
{
XXX mem1(1);
XXX mem2(2);
XXX mem3(3);
YYY disp1(1);
YYY disp2(2);
MMM special(0);
disp1.TypeVal("mem1", mem1);
disp2.TypeVal("mem2", mem2);
disp2.TypeVal("mem3", mem3);
special.TypeVal("\n mem2 from special spy:", mem2);
TypeVal(mem1, disp2);
TypeVal(mem2, disp1);
}
void TypeVal(XXX& ClassParamX, YYY& ClassParamY)
{
cout << endl;
cout << "???.x1 == " << ClassParamX.x1 << endl;
cout << "???.y1 == " << ClassParamY.y1 << endl;
}

В этом примере все функции имеют одинаковые имена. Это не страшно. Это даже полезно, поскольку становится очевидным факт существования разных областей действия имён.

В заключение раздела перечислим основные правила пользования новыми средствами управления доступа - дружественной системой защиты.

  • Друзья класса не являются членами класса. Они должны определяться вне класса, для которого они объявляются друзьями, а об особых отношениях между ними и данным классом свидетельствует лишь специальное объявление(!) со спецификатором объявления friend. Объявления дружественного класса означает, что в дружественном классе доступны все компоненты объявляемого класса.
  • Дружественные данному классу функции не являются членами этого класса. Поэтому они не могут быть вызваны из объекта-представителя класса, для которого была объявлена другом данная функция, при помощи операции доступа к члену класса.
  • Дружественная функция может быть функцией-членом другого ранее объявленного класса. Правда, при этом само определение дружественной функции приходится располагать после объявления класса, другом которого была объявлена данная функция. Это не очень удобно и красиво, но зато работает.
  • Дружественная функция не имеет this указателя для работы с классом, содержащим её объявление в качестве дружественной функции. Дружба - это всего лишь дополнение принципа инкапсуляции и ничего более.
  • Дружественные отношения не наследуются. Дружественные функции не имеют доступа к членам производного класса, чьи базовые классы содержали объявления этих функций. Дети не отвечают за отношения своих родителей.

 


Указатели на объекты

Рассмотрим простой пример.

#include <iostream.h>
class A
{
};
class AB: public A
{
};
class AC: public A
{
};
void main ()
{
A *pObj;
A MyA;
pObj = &MyA;
cout << "OK A" << endl;
AB MyAB;
AC MyAC;
pObj = &MyAB;
cout << "OK AB" << endl;
pObj = &MyAC;
cout << "OK AC" << endl;
}

Это очень простой пример. Пустые классы, простое наследование… Единственно, что важно в объявлении этих классов - спецификаторы доступа в описании баз производных классов. Базовый класс (его будущие члены) должен быть абсолютно доступен в производном классе. Первый оператор функции main() - объявление указателя на объект класса A. Затем следует определение объекта-представителя класса A, следом - настройка указателя на этот объект. Естественно, при этом используется операция взятия адреса. Всё это давно известно и очень просто. Следующие две строки являются определениями пары объектов, которые являются представителями двух разных производных классов…

За объявлениями объектов в программе располагаются строки, которые позволяют настроить указатель на базовый класс на объект производного класса. Для настройки указателя на объект производного класса нам не потребовалось никаких дополнительных преобразований. Здесь важно только одно обстоятельство. Между классами должно существовать отношение наследования. Таким образом, проявляется важное свойство объектно-ориентированного программирования: УКАЗАТЕЛЬ НА БАЗОВЫЙ КЛАСС МОЖЕТ ССЫЛАТЬСЯ НА ОБЪЕКТЫ - ПРОИЗВОДНЫХ КЛАССОВ. Подобное, на первый взгляд, странное обстоятельство имеет своё объяснение.

Рассмотрим схемы объектов MyA, MyAB, MyAC:

MyA::=
A
MyAB::=
A
AB
MyAC::=
A
AC

Все три объекта имеют общий элемент (объекты производных классов - фрагмент) - представитель базового класса A. Исключительно благодаря этому общему элементу указатель на объект класса A можно настроить на объекты производных классов. Указателю просто присваивается адрес базового фрагмента объекта производного типа. В этом и состоит секрет подобной настройки. Как мы увидим, для указателя pObj, настроенного на объект производного класса, вообще не существует фрагмента объекта, представленного производным классом.

pObj
A
AC

Ниже пунктирной линии - пустота. Для того чтобы убедиться в этом, мы усложним структуру класса A, определив в нём функцию Fun1. Конечно же, эта функция ничего не будет делать. Но у неё будет спецификация возвращаемого значения и непустой список параметров. Нам от неё большего и не требуется. Лишь бы сообщала о собственном вызове…

class A
{
public:
int Fun1(int);
};
int A::Fun1(int key)
{
cout << " Fun1( " << key << " ) from A " << endl;
return 0;
}

Аналогичной модификации подвергнем производные классы AB и AC (здесь предполагаются вызовы функций-членов непосредственно из функции main(), а потому надо помнить о спецификаторе public), а затем продолжим опыты.

class AB: public A
{
public:
int Fun1(int key);
};
int AB::Fun1(int key)
{
cout << " Fun1( " << key << " ) from AB " << endl;
return 0;
}
class AC: public A
{
public:
int Fun1(int key);
int Fun2(int key);// В этом классе мы объявим вторую функцию.
};
int AC::Fun1(int key)
{
cout << " Fun1( " << key << " ) from AC " << endl;
return 0;
}
int AC::Fun2(int key)
{
cout << " Fun2( " << key << " ) from AC " << endl;
return 0;
}


 

Теперь мы займёмся функцией main(). Первая пара операторов последовательно из объекта запускает функцию-член производного класса, а затем - подобную функцию базового класса. С этой целью используется квалифицированное имя функции-члена.

MyAC.Fun2(2);    //Вызвана AC::Fun2()…
MyAC.Fun1(2); //Вызвана AC::Fun1()…
MyAC.A::Fun1(2); //Вызвана A::Fun1()…

Следующие строки посвящены попытке вызова функций-членов по указателю на объект базового типа. Предполагается, что в данный момент он настроен на объект MyAC.

pObj->Fun1(2); //Вызвана A::Fun1()…

И это всё, что можно способен указатель на объект базового типа, если его настроить на объект производного типа. Ничего нового. Тип указателя на объект - базовый класс. В базовом классе существует единственная функция-член, она известна транслятору, а про структуру производного класса в базовом классе никто ничего не знает. Так что следующие операторы представляют пример того, что не следует делать с указателем на объекты базового класса, даже настроенного на объект производного класса.

//pObj->Fun2(2);
//pObj->AC::Fun1(2);

То ли дело указатель на объект производного типа! И опять здесь нет ничего нового и неожиданного. С "нижнего этажа бункера" видны все "этажи"!

AC* pObjAC = &MyAC;
pObjAC->Fun1(2);
pObjAC->Fun2(2);
pObjAC->AC::Fun1(2);
pObjAC->Fun1(2);
pObjAC->A::Fun1(2);

И, разумеется, указатель на объект производного класса не настраивается на объект базового.

//pObjAC = &MyA;

Основной итог этого раздела заключается в следующем: указатель на объект базового класса можно настроить на объект производного типа. Через этот указатель можно "увидеть" лишь фрагмент объекта производного класса - его "базовую" часть - то, что объект получает в наследство от своих предков. Решение о том, какая функция должна быть вызвана, принимается транслятором. В момент выполнения программы всё уже давно решено. Какая функция будет вызвана из объекта производного типа - зависит от типа указателя, настроенного на данный объект. В этом случае мы наблюдаем классический пример статического связывания.



Виртуальные функции

Очередная модификация базового класса приводит к неожиданным последствиям. Эта модификация состоит в изменении спецификатора функции-члена базового класса. Мы (впервые!) используем спецификатор virtual в объявлении функции. Функции, объявленные со спецификатором virtual, называются виртуальными функциями. Введение виртуальных функций в объявление базового класса (всего лишь один спецификатор) имеет столь значительные последствия для методологии объектно-ориентированного программирования, что мы лишний раз приведём модифицированное объявление класса A:

class A
{
public:
virtual int Fun1(int);
};

Один дополнительный спецификатор в объявлении функции и больше никаких (пока никаких) изменений в объявлениях производных классов. Как всегда, очень простая функция main(). В ней мы определяем указатель на объект базового класса, настраиваем его на объект производного типа, после чего по указателю мы вызываем функцию Fun1():

void main ()
{
A *pObj;
A MyA;
AB MyAB;
pObj = &MyA;
pObj->Fun1(1);
AC MyAC;
pObj = &MyAC;
pObj->Fun1(1);
}

Если бы не спецификатор virtual, результат выполнения выражения вызова

pObj->Fun1(1);

был бы очевиден: как известно, выбор функции определяется типом указателя.

Однако спецификатор virtual меняет всё дело. Теперь выбор функции определяется типом объекта, на который настраивается указатель базового класса. Если в производном классе объявляется нестатическая функция, у которой имя, тип возвращаемого значения и список параметров совпадают с аналогичными характеристиками виртуальной функции базового класса, то в результате выполнения выражения вызова вызывается функция-член производного класса.

Сразу надо заметить, что возможность вызова функции-члена производного класса по указателю на базовый класс не означает, что появилась возможность наблюдения за объектом "сверху вниз" из указателя на объект базового класса. Невиртуальные функции-члены и данные по-прежнему недоступны. И в этом можно очень легко убедиться. Для этого достаточно попробовать сделать то, что мы уже однажды проделали - вызвать неизвестную в базовом классе функцию-член производного класса:

//pObj->Fun2(2);
//pObj->AC::Fun1(2);

Результат отрицательный. Указатель, как и раньше, настроен лишь на базовый фрагмент объекта производного класса. И всё же вызов функций производного класса возможен. Когда-то, в разделах, посвящённых описанию конструкторов, нами был рассмотрен перечень регламентных действий, которые выполняются конструктором в ходе преобразования выделенного фрагмента памяти в объект класса. Среди этих мероприятий упоминалась инициализация таблиц виртуальных функций.

Наличие этих самых таблиц виртуальных функций можно попытаться обнаружить с помощью операции sizeof. Конечно, здесь всё зависит от конкретной реализации, но, по крайней мере, в версии Borland C++ объект-представитель класса, содержащего объявления виртуальных функций, занимает больше памяти, нежели объект аналогичного класса, в котором те же самые функции объявлены без спецификатора virtual.

cout << "Размеры объекта: " << sizeof(MyAC) << "…" << endl;

Так что объект производного класса приобретает дополнительный элемент - указатель на таблицу виртуальных функций. Схему такого объекта можно представить следующим образом (указатель на таблицу мы обозначим идентификатором vptr, таблицу виртуальных функций - идентификатором vtbl):

MyAC::=
vptr
A
AC
vtbl::=
&AC::Fun1

На нашей новой схеме объекта указатель на таблицу (массив из одного элемента) виртуальных функций не случайно отделён от фрагмента объекта, представляющего базовый класс лишь пунктирной линией. Он находится в поле зрения этого фрагмента объекта. Благодаря доступности этого указателя оператор вызова виртуальной функции Fun1

pObj->Fun1(1);

можно представить следующим образом:

(*(pObj->vptr[0])) (pObj,1);

Здесь только на первый взгляд всё запутано и непонятно. На самом деле, в этом операторе нет ни одного не известного нам выражения.

Здесь буквально сказано следующее:

ВЫЗВАТЬ ФУНКЦИЮ, РАСПОЛОЖЕННУЮ ПО НУЛЕВОМУ ИНДЕКСУ ТАБЛИЦЫ ВИРТУАЛЬНЫХ ФУНКЦИЙ vtbl (в этой таблице у нас всего один элемент), АДРЕС НАЧАЛА КОТОРОЙ МОЖНО НАЙТИ ПО УКАЗАТЕЛЮ vptr.

В СВОЮ ОЧЕРЕДЬ, ЭТОТ УКАЗАТЕЛЬ ДОСТУПЕН ПО УКАЗАТЕЛЮ pObj, НАСТРОЕННОМУ НА ОБЪЕКТ MyAC. ФУНКЦИИ ПЕРЕДАЁТСЯ ДВА (!) ПАРАМЕТРА, ПЕРВЫЙ ИЗ КОТОРЫХ ЯВЛЯЕТСЯ АДРЕСОМ ОБЪЕКТА MyAC (значение для this указателя!), ВТОРОЙ - ЦЕЛОЧИСЛЕННЫМ ЗНАЧЕНИЕМ, РАВНЫМ 1.


 

Вызов функции-члена базового класса обеспечивается посредством квалифицированного имени.

pObj->A::Fun1(1);

В этом операторе мы отказываемся от услуг таблицы виртуальных функций. При этом мы сообщаем транслятору о намерении вызвать функцию-член базового класса. Механизм поддержки виртуальных функций строг и очень жёстко регламентирован. Указатель на таблицу виртуальных функций обязательно включается в самый "верхний" базовый фрагмент объекта производного класса. В таблицу указателей включаются адреса функций-членов фрагмента самого "нижнего" уровня, содержащего объявления этой функции.

Мы в очередной раз модифицируем объявление классов A, AB и объявляем новый класс ABC.

Модификация классов A и AB сводится к объявлению в них новых функций-членов:

class A
{
public:
virtual int Fun1(int key);
virtual int Fun2(int key);
};
:::::
int A::Fun2(int key)
{
cout << " Fun2( " << key << " ) from A " << endl;
return 0;
}
class AB: public A
{
public:
int Fun1(int key);
int Fun2(int key);
};
:::::
int AB::Fun2(int key)
{
cout << " Fun2( " << key << " ) from AB " << endl;
return 0;
}
Класс ABC является производным от класса AB:
class ABC: public AB
{
public:
int Fun1(int key);
};
int ABC::Fun1(int key)
{
cout << " Fun1( " << key << " ) from ABC " << endl;
return 0;
}

В этот класс входит объявление функции-члена Fun1, которая объявляется в косвенном базовом классе A как виртуальная функция. Кроме того, этот класс наследует от непосредственной базы функцию-член Fun2. Эта функция также объявляется в базовом классе A как виртуальная. Мы объявляем объект-представитель класса ABC:

ABC MyABC;

Его схему можно представить следующим образом:

MyABC::=
vptr
A
AB
ABC
vtbl::=
&AB::Fun2
&ABC::Fun1

Таблица виртуальных функций сейчас содержит два элемента. Мы настраиваем указатель на объект базового класса на объект MyABC, затем вызываем функции-члены:

pObj = &MyABC;
pObj->Fun1(1);
pObj->Fun2(2);

В этом случае невозможно вызвать функцию-член AB::Fun1(), поскольку её адрес не содержится в списке виртуальных функций, а с верхнего уровня объекта MyABC, на который настроен указатель pObj, она просто не видна. Таблица виртуальных функций строится конструктором в момент создания объекта соответствующего объекта. Безусловно, транслятор обеспечивает соответствующее кодирование конструктора. Но транслятор не в состоянии определить содержание таблицы виртуальных функций для конкретного объекта. Это задача времени исполнения. Пока таблица виртуальных функций не будет построена для конкретного объекта, соответствующая функция-член производного класса не сможет быть вызвана. В этом легко убедиться, после очередной модификации объявления классов.

Программа невелика, поэтому имеет смысл привести её текст полностью. Не следует обольщаться по поводу операции доступа к компонентам класса ::. Обсуждение связанных с этой операцией проблем ещё впереди.

#include <iostream.h>
class A
{
public:
virtual int Fun1(int key);
};
int A::Fun1(int key)
{
cout << " Fun1( " << key << " ) from A." << endl;
return 0;
}
class AB: public A
{
public:
AB() {Fun1(125);};
int Fun2(int key);
};
int AB::Fun2(int key)
{
Fun1(key * 5);
cout << " Fun2( " << key << " ) from AB." << endl;
return 0;
}
class ABC: public AB
{
public:
int Fun1(int key);
};
int ABC::Fun1(int key)
{
cout << " Fun1( " << key << " ) from ABC." << endl;
return 0;
}
void main ()
{
ABC MyABC; // Вызывается A::Fun1().
MyABC.Fun1(1); // Вызывается ABC::Fun1().
MyABC.Fun2(1); // Вызываются AB::Fun2() и ABC::Fun1().
MyABC.A::Fun1(1); // Вызывается A::Fun1().
A *pObj = &MyABC; // Определяем и настраиваем указатель.
cout << "==========" << endl;
pObj->Fun1(2); // Вызывается ABC::Fun1().
//pObj->Fun2(2); // Эта функция через указатель недоступна !!!
pObj->A::Fun1(2); // Вызывается A::Fun1().
}


 

Теперь в момент создания объекта MyABC

ABC MyABC;

из конструктора класса AB (а он вызывается раньше конструктора класса ABC), будет вызвана функция A::Fun1(). Эта функция является членом класса A. Объект MyABC ещё до конца не сформирован, таблица виртуальных функций ещё не заполнена, о существовании функции ABC::Fun1() ещё ничего не известно. После того, как объект MyABC будет окончательно сформирован, таблица виртуальных функций заполнится, а указатель pObj будет настроен на объект MyABC, вызов функции A::Fun1() через указатель pObj будет возможен лишь с использованием полного квалифицированного имени этой функции:

pObj->Fun1(1);    // Это вызов функции ABC::Fun1()!
pObj->A::Fun1(1); // Очевидно, что это вызов функции A::Fun1()!

Заметим, что вызов функции-члена Fun1 непосредственно из объекта MyABC приводит к аналогичному результату:

MyABC.Fun1(1);    // Вызов функции ABC::Fun1().

А попытка вызова невиртуальной функции AB::Fun2() через указатель на объект базового класса заканчивается неудачей. В таблице виртуальных функций адреса этой функции нет, а с верхнего уровня объекта "посмотреть вниз" невозможно.

//pObj->Fun2(2); // Так нельзя!

Результат выполнения этой программки наглядно демонстрирует специфику использования виртуальных функций. Всего несколько строк…

Fun1(125) from A.
Fun1(1) from ABC.
Fun1(5) from ABC.
Fun2(1) from AB.
Fun1(1) from A.
==========
Fun1(2) from ABC.
Fun1(2) from A.

Один и тот же указатель в ходе выполнения программы может настраиваться на объекты-представители различных производных классов. В результате в буквальном смысле одно и то выражение вызова функции-члена обеспечивает выполнение совершенно разных функций. Впервые мы сталкиваемся с так называемым ПОЗДНИМ или ОТЛОЖЕННЫМ СВЯЗЫВАНИЕМ.

Заметим, что спецификация virtual относится только к функциям. Виртуальных данных-членов не существует. Это означает, что не существует возможности обратиться к данным-членам объекта производного класса по указателю на объект базового класса, настроенному на объект производного класса.

С другой стороны, очевидно, что если можно вызвать замещающую функцию, то непосредственно "через" эту функцию открывается доступ ко всем функциям и данным-членам членам производного класса и далее "снизу-вверх" ко всем неприватным функциям и данным-членам непосредственных и косвенных базовых классов. При этом из функции становятся доступны все неприватные данные и функции базовых классов.

И ещё один маленький пример, демонстрирующий изменение поведение объекта-представителя производного класса после того, как одна из функция базового класса становится виртуальной.

#include <iostream.h>
class A
{
public:
void funA () {xFun();};
/*virtual*/void xFun () {cout <<"this is void A::xFun();"<< endl;};
};
class B: public A
{
public:
void xFun () {cout <<"this is void B::xFun ();"<<endl;};
};
void main()
{
B objB;
objB.funA();
}

В начале спецификатор virtual а определении функции A::xFun() закомментирован. Процесс выполнения программы состоит в определении объекта-представителя objB производного класса B и вызова для этого объекта функции-члена funA(). Эта функция наследуется из базового класса, она одна и очевидно, что её идентификация не вызывает у транслятора никаких проблем. Эта функция принадлежит базовому классу, а это означает, что в момент её вызова, управление передаётся "на верхний уровень" объекта objB. На этом же уровне располагается одна из функций с именем xFun(), и именно этой функции передаётся управление в ходе выполнения выражения вызова в теле функции funA(). Мало того, из функции funA() просто невозможно вызвать другую одноименную функцию. В момент разбора структуры класса A транслятор вообще не имеет никакого представления о структуре класса B. Функция xFun() - член класса B оказывается недостижима из функции funA(). 

Но если раскомментировать спецификатор virtual в определении функции A::xFun(), между двумя одноименными функциями установится отношение замещения, а порождение объекта objB будет сопровождаться созданием таблицы виртуальных функций, в соответствии с которой будет вызываться замещающая функция член класса B. Теперь для вызова замещаемой функции необходимо использовать её квалифицированное имя:

void A::funA ()
{
xFun();
A::xFun();
}


 

Множественное наследование

В C++ производный класс может быть порождён из любого числа непосредственных базовых классов. Наличие у производного класса более чем одного непосредственного базового класса называется множественным наследием. Синтаксически множественное наследование отличается от единичного наследования списком баз, состоящим более чем из одного элемента.

class A { }; class B { }; class C : public A, public B { };

При создании объектов-представителей производного класса, порядок расположения непосредственных базовых классов в списке баз определяет очерёдность вызова конструкторов умолчания.

Этот порядок влияет и на очерёдность вызова деструкторов при уничтожении этих объектов. Но эти проблемы, также как и алгоритмы выделения памяти для базовых объектов, скорее всего, относятся к вопросам реализации. Вряд ли программист должен акцентировать на этом особое внимание.

Более существенным является ограничение, согласно которому одно и то же имя класса не может входить более одного раза в список баз при объявлении производного класса. Это означает, что в наборе непосредственных базовых классов, которые участвуют в формировании производного класса не должно встречаться повторяющихся элементов.

Вместе с тем, один и тот же класс может участвовать в формировании нескольких (а может быть и всех) непосредственных базовых классов данного производного класса. Так что для непрямых базовых классов, участвующих в формировании производного класса не существует никаких ограничений на количество вхождений в объявление производного класса:

class A { public: int x0, xA; }; class B : public A { public: int xB; }; class C : public A { public: int x0, xC; }; class D : public B, public C { public: int x0, xD; };

В этом примере класс A дважды используется при объявлении класса D в качестве непрямого базового класса.

Для наглядного представления структуры производного класса также используются направленные ациклические графы, схемы классов и объектов.

Как и раньше, самый нижний узел направленного ациклического графа, а также нижний уровень схем соответствует производному классу и фрагменту объекта, представляющего производный класс.

Такой фрагмент объекта мы будем называть производным фрагментом-представителем данного класса.

Верхние узлы графа и верхние уровни схем классов и объектов соответствуют базовым классам и фрагментам объектов, представляющих базовые и непосредственные базовые классы.

Эти фрагменты объекта мы будем называть базовыми и непосредственными базовыми фрагментами-представителями класса.

Вот как выглядит граф ранее приведённого в качестве примера производного класса D:

A A B C D

А вот как представляется структура производного класса в виде неполной схемы класса. Базовые классы располагаются на этой схеме в порядке, который соответствует списку базовых элементов в описании базы производного класса. Этот же порядок будет использован при изображении диаграмм объектов. И это несмотря на то обстоятельство, что порядок вызова конструкторов базовых классов определяется конкретной реализацией. За порядком вызова конструкторов базовых классов всегда можно наблюдать после определения их собственных версий.

A B A C D

А вот и схема объекта производного класса.

D MyD; MyD ::= A (int)x0;

(int)xA;

B (int)xB;

A (int)x0; (int)xA;

C (int)x0;

D (int)x0;

(int)xD;

Первое, что бросается в глаза - это множество одноимённых переменных, "разбросанных" по базовым фрагментам объекта. Да и самих базовых фрагментов здесь немало.

Очевидно, что образующие объект базовые фрагменты-представители одного базового класса, по своей структуре неразличимы между собой. Несмотря на свою идентичность, все они обладают индивидуальной характеристикой - положением относительно производного фрагмента объекта.

При множественном наследовании актуальной становится проблема неоднозначности, связанная с доступом к членам базовых классов. Доступ к члену базового класса является неоднозначным, если выражение доступа именует более одной функции, объекта (данные-члены класса также являются объектами), типа (об этом позже!) или перечислителя.

Например, неоднозначность содержится в следующем операторе:

MyD.xA = 100;

здесь предпринимается неудачная попытка изменения значения данного-члена базового фрагмента объекта MyD. Выражение доступа MyD.xA именует сразу две переменных xA. Разрешение неоднозначности сводится к построению такого выражения доступа, которое однозначно указывало бы функцию, объект, тип (об этом позже!) или перечислитель.

Наша очередная задача сводится к описанию однозначных способов доступа к данным-членам класса, расположенным в разных базовых фрагментах объекта. И здесь мы впервые сталкиваемся с ограниченными возможностями операции доступа.

MyD.B::x0 = 100;

Этот оператор обеспечивает изменение значения данного-члена базового фрагмента - представителя класса B. Здесь нет никаких проблем, поскольку непосредственный базовый класс B наследует данные-члены базового класса A. Поскольку в классе B отсутствуют данные-члены с именем x0, транслятор однозначно определяет принадлежность этого элемента. Итак, доступ к данному-члену базового класса A "со стороны" непосредственного базового класса B не представляет особых проблем.


MyD.C::x0 = 100;

А теперь изменяется значение данного-члена базового фрагмента - представителя класса С. И опять же транслятор однозначно определяет местоположение изменяемой переменной. Переменная x0 была объявлена в непосредственном базовом классе C. И операция доступа указывает на эту переменную. А вот попытка изменения значения переменной x0, расположенной базовом фрагменте-представителе класса A "со стороны" непосредственного базового класса C обречена. Так, оператор

MyD.A::x0 = 777;

некорректен по причине неоднозначности соотнесения класса и его члена, поскольку непонятно, о каком базовом фрагменте-представителе класса A идёт речь. Выражения доступа с составными квалифицированными именами, как например,

MyD.C::A::x0

в контексте нашей программы также некорректны: составное квалифицированное имя предполагает вложенное объявление класса. Это свойство операции доступа уже обсуждалось ранее, в разделах, непосредственно посвящённых операциям. Вложенные объявления будут рассмотрены ниже. 

Операция :: оставляет в "мёртвой зоне" целые фрагменты объектов. Однако возможность доступа к членам класса, которые оказались вне пределов досягаемости операции доступа всё же существует. Она обеспечивается указателями и операциями явного преобразования типа.

Идея состоит в том, чтобы, объявив указатель на объект-представитель базового класса, попытаться его настроить с помощью операций явного преобразования типа на соответствующий фрагмент объекта производного класса. В результате недосягаемые с помощью операции доступа фрагменты объекта превращаются в безымянные объекты простой конфигурации. Доступ к их членам в этом случае обеспечивается обычными операциями косвенного обращения. Рассмотрим несколько строк, которые демонстрируют такую технику работы с недосягаемыми фрагментами.

A* pObjA;
B* pObjB;
C* pObjC;
D* pObjD = &MyD;
// Мы начинаем с объявления соответствующих указателей.
pObjC = (C*)&MyD;
pObjA = (A*)pObjC;
// Произведена настройка указателей на требуемые фрагменты.
pObjA->x0 = 999;
// А это уже элементарно!

Очевидно, что можно обойтись без поэтапных преобразований и воспользоваться свойством коммутативности операции явного преобразования типа:

((A*)(C*)pObjD)->x0 = 5;
((A*)(B*)pObjD)->x0 = 55;
// Разным фрагментам - разные значения.

Аналогичным образом обстоят дела с функциями-членами базовых классов. Этот раздел мы завершаем небольшой программой, демонстрирующей методы доступа к членам базовых фрагментов объекта производного класса.

#include <iostream.h>
class A
{
public:
int x0;
int Fun1(int key);
};
int A::Fun1(int key)
{
cout << " Fun1( " << key << " ) from A " << endl;
cout << " x0 == " << x0 << "..." << endl;
return 0;
}
class B: public A
{
public:
int x0;
int Fun1(int key);
int Fun2(int key);
};
int B::Fun1(int key)
{
cout << " Fun1( " << key << " ) from B " << endl;
cout << " x0 == " << x0 << "..." << endl;
return 0;
}
int B::Fun2(int key)
{
Fun1(key * 5);
cout << " Fun2( " << key << " ) from B " << endl;
cout << " x0 == " << x0 << "..." << endl;
return 0;
}
class C: public A
{
public:
int x0;
int Fun2(int key);
};
int C::Fun2(int key)
{
A::x0 = 25;
Fun1(key * 5);
cout << " Fun2( " << key << " ) from C " << endl;
cout << " x0 == " << x0 << "..." << endl;
return 0;
}
class D: public B, public C
{
public:
int x0;
int Fun1(int key);
};
int D::Fun1(int key)
{
cout << " Fun1( " << key << " ) from D " << endl;
cout << " x0 == " << x0 << "..." << endl;
return 0;
}
void main ()
{
D MyD;
ObjD.x0 = 111;
A* pObjA;
B* pObjB;
C* pObjC;
D* pObjD = &MyD;
MyD.B::x0 = 100;
MyD.C::x0 = 333;
MyD.Fun1(1);
pObjD->B::Fun1(1);
pObjD->C::Fun2(1);
pObjA = (A*) (B*) pObjD;
((A*) ((C*) pObjD))->Fun1(111);
((A*) ((B*) pObjD))->Fun1(111);
pObjA->Fun1(111);
pObjC = (C*)&MyD;
pObjA = (A*)pObjC;
((A*)(B*)pObjD)->x0 = 1;
((A*)(B*)pObjD)->Fun1(777);
((A*)(C*)pObjD)->x0 = 2;
((A*)(C*)pObjD)->Fun1(999);
}


 

Виртуальные классы

Мы продолжаем модификацию последнего варианта нашей программы, добавляя к прототипу функции int A::Fun1(int); спецификатор virtual.

class A { public: int x0; virtual int Fun1(int key); };

Результат выполнения программы можно предугадать. Функция-член класса A становится виртуальной, а значит, замещается в соответствии с таблицей виртуальных функций в ходе сборки объекта-представителя производного класса D. В состав этого объекта включены два независимых друг от друга базовых фрагмента-представителя базового класса A. Количество таблиц виртуальных функций соответствует количеству базовых фрагментов, представителей базового класса, содержащего объявления виртуальных функций.

Как бы мы ни старались, вызвать функцию-член класса A из любого фрагмента объекта-представителя производного класса D невозможно. Сначала конструкторы строят объект, настраивают таблицы виртуальных функций, а потом уже мы сами начинаем его "перекраивать", создавая на основе базового фрагмента видимость самостоятельного объекта. Напрасно. Объект построен, таблицы виртуальных функций также настроены. До конца жизни объекта виртуальные функции остаются недоступны.

Следует обратить особое внимание на то обстоятельство, что независимо от места вызова виртуальной функции (а мы её вызываем непосредственно из базовых фрагментов объекта), замещающей функции в качестве параметра передаётся корректное значение this указателя.

Очевидно, что соответствующая корректировка значения этого указателя производится в процессе вызова замещающей функции. При этом возможны, по крайней мере, два различных подхода к реализации алгоритма корректировки. 

Соответствующее корректирующее значение может определяться в момент создания объекта конструкторами и храниться в виде константной величины вместе с таблицами виртуальных функций, либо this указатель может динамически настраиваться в момент вызова виртуальной функции благодаря специальному программному коду настройки этого указателя. Но это всё уже зависит от конкретной реализации языка.

В этом разделе нам осталось обсудить понятие виртуального базового класса. Согласно соответствующей БНФ, спецификатор virtual может быть включён в описатель базы:

ОписательБазы ::= ПолноеИмяКласса ::= [virtual] [СпецификаторДоступа] ПолноеИмяКласса ::= [СпецификаторДоступа] [virtual] ПолноеИмяКласса

Модифицируем нашу программу. Мы добавим в описатели баз производных классов B и C спецификатор virtual:

class A { public: int x0; int Fun1(int key); }; class B: virtual public A { public: int x0; int Fun1(int key); int Fun2(int key); }; class C: public virtual A { public: int x0; int Fun2(int key); }; class D: public B, public C { public: int x0; int Fun1(int key); };

Вот как выглядит после модификации граф производного класса D:

A B C D

А вот как меняется структура класса D, представляемая в виде неполной схемы. Спецификатор virtual способствует минимизации структуры производного класса. Виртуальные базовые классы не тиражируются.

A B C D

А вот и схема объекта-представителя класса D.

D MyD; MyD ::= A (int)x0; (int)xA; B (int)xB; C (int)x0; D (int)x0; (int)xD;

Спецификатор virtual в описании базы позволяет минимизировать структуру объекта. Различные варианты обращения к данным-членам базового фрагмента приводят к модификации одних и тех же переменных.

Базовый фрагмент объекта связан со своими производными фрагментами множеством путей, по которым с одинаковым успехом может быть обеспечен доступ к данным-членам базового фрагмента.

В C++ допускаются такие варианты объявления производных классов, при которых одни и те же классы одновременно выступают в роли виртуальных и невиртуальных базовых классов. Например, сам класс D может быть использован в качестве базового класса:

class F: public A { ::::: } class G: public A, public D { ::::: } ::::: G MyG;

Множество одноименных виртуальных и невиртуальных базовых фрагментов, данных-членов, простых и виртуальных функций. Можно до бесконечности рисовать направленные ациклические графы, диаграммы классов и объектов… Искусство объявления классов и навигации по фрагментам объектов совершенствуется в результате напряжённой длительной практики.

Введём новое понятие, связанное с доступом к данным и функциям-членам производных классов.

Имя x (имя переменной, класса, или функции), объявленное в классе X, обозначается как X::x. Имя B::f доминирует над именем A::f, если объявление класса B содержит в списке баз имя класса A.

На понятии доминирования имён основывается правило доминирования, определяющее корректность доступа к членам производного класса, объявленного на основе виртуальных базовых классов.

Это правило гласит, что возможность достижения по пути направленного ациклического графа нескольких одноименных функций либо данных-членов приводит к неоднозначности, за исключением случая, когда между именами существует отношение доминирования.


 

Указатели на компоненты класса. Доступ по указателю

Прежде всего, рассмотрим объявление класса XXX.

class XXX
{
public:
long x1;
int x2;
/*Данные-члены класса.*/
long getVal1() {return x1;}
long getVal2() {return x2*x1;}
/*Функции-члены класса без параметров.*/
int getVal3(int param) {return x2*param;}
char* getVal4(char *str) {return str;}
/*Функции-члены класса с параметрами.*/
static int f1() {return 100;}
static int f2() {return 10;}
static int f3(int param) {return param;}
/* Определение различных статических функций*/
XXX(long val1, int val2){x1 = val1; x2 = val2;}
/*Конструктор.*/
};

Поскольку нестатические функции-члены формально, а нестатические данные-члены фактически не существуют без объекта-представителя класса, определение указателя на компонент класса (член класса или функцию-член) отличается от определения указателя на объект или обычную функцию.

Для объявления указателя на нестатическую функцию используется специальная синтаксическая конструкция, состоящая из спецификатора объявления и заключённого в скобки квалифицированного имени указателя, состоящего из имени класса, операции доступа к члену класса ::, разделителя * , собственно имени указателя, закрывающей скобки и списка параметров:

int (XXX::*fp_3) (int);

Подобный указатель может быть проинициализирован инициализатором, состоящим из операции присвоения, операции взятия адреса и квалифицированного имени соответствующей функции-члена:

int (XXX::*fp_3) (int) = &XXX::getVal1;

Вот и нашлась достойная область применения квалифицированным именам.

Как известно, значение унарного выражения, состоящего из операции взятия и операнда, который является именем функции и первичного выражения, состоящего из имени функции эквивалентны. Это адрес данной функции. Поэтому поэтому в качестве инициализирующего выражения для указателя на функцию-член класса также может быть использовано первичное выражение, представляющее собой квалифицированное имя функции-члена:

Fp_3 = XXX::getVal2

Класс - это не объект! И не совсем понятно, какое значение имеет адрес нестатичесного члена класса. Значение проинициализированного указателя на нестатическую компоненту остаётся неопределённым.

Оно определяется лишь в результате выполнения операций обращения к членам класса .* и ->* .

При этом функция-член класса вызывается по указателю на компоненту относительно конкретного объекта или указателя на объект-представитель класса. Первым операндом операций обращения к членам класса является l-выражение, ссылающееся на объект (возможно, что имя объекта) или указатель на объект, вторым операндом является ссылка на указатель на компоненту класса:

int val = (q.*fp)(6);
char val = (pq->*fp4)("new string");

Аналогичным образом осуществляется объявление и инициализация указателей на данные-члены класса. При этом структура объявления указателя на член класса проще (нет спецификации возвращаемого значения, не нужно указывать список параметров). Это не функция, здесь дело обходится спецификацией объявления и квалифицированными именами указателей:

long (XXX::*px1) = &XXX::x1;
// Определение и инициализация указателя на член класса XXX типа long
q.*px11 = 10; // p - объект-представитель класса XXX.
pq->*px11 = 10;
// pq - указатель на объект-представитель класса XXX.

Основные приёмы работы с указателями на функции-члены демонстрируются на следующих примерах:

class XXX
{
public:
long x1;
int x2;
/*Данные-члены класса.*/
long getVal1() {return x1;}
long getVal2() {return x2*x1;}
/*Функции-члены класса без параметров.*/
int getVal3(int param) {return x2*param;}
char* getVal4(char *str) {return str;}
/*Функции-члены класса с параметрами.*/
static int f1() {return 100;}
static int f2() {return 10;}
static int f3(int param) {return param;}
/* Определение различных статических функций*/
XXX(long val1, int val2){x1 = val1; x2 = val2;}
/*Конструктор.*/
};
void main()
{
XXX q(1,2);/* Определение объекта.*/
XXX* pq = new (XXX);
pq->x1 = 100;
pq->x2 = 100;
/*Определение и инициализация объекта по указателю.*/
long (XXX::*fp_0) ();
/*Указатель на функцию-член класса.*/
long (XXX::*fp_1) () = &XXX::getVal1;
/*
Проинициализированный указатель на функцию-член класса. Его
значение является относительной величиной и равняется значению
смещения функции-члена относительно первого члена класса.
*/
fp_0 = XXX::getVal1;
/*
Инициализация первого указателя. Один и тот же указатель можно
настраивать на различные функции-члены класса. Главное, чтобы у
всех этих функций-членов совпадали списки параметров и возвращаемые
значения функций.
*/
long val_1 = (q.*fp1)();
/*Вызов функции-члена класса по указателю из объекта.*/

long val_2 = (pq->*fp0)();
/*
Вызов функции-члена класса по указателю с помощью указателя на объект.
*/
int (XXX::*fp_3) (int) = &XXX::getVal3;
/*
Проинициализированный указатель на функцию-член класса. С параметрами
типа int.
*/
int val_3 = (q.*fp_3)(6);
/*
Вызов функции-члена класса по указателю из объекта с передачей параметров.
*/
char* (XXX::*fp_4) (char) = &XXX::getVal3;
/*
Проинициализированный указатель на функцию-член класса с параметрами типа int.
*/
char val_4 = (pq->*fp4)("new string");
/*
Вызов функции-члена класса по указателю с помощью указателя на объект.
*/
int (*fp_5) () = &XXX::f1;
/*
Указатель на статическую функцию объявляется без спецификации класса.
Явная спецификация класса необходима лишь при инициализации указателя.
*/
int retval = (*fp_5)();
/*Вызов статической функции по указателю.*/
fp_5 = XXX::f2;
/*
Перенастройка статического указателя. Главное требование - совпадение
списков параметров и типа возвращаемого значения.
*/
int (*fp_6) (int) = &XXX::f3;
/*Указатель на статическую функцию с параметрами.*/
int retval = (*fp_6)(255);
/*Вызов статической функции с параметрами по указателю.*/
long (XXX::*px1) = &XXX::x1;
/*Определили и проинициализировали указатель на член класса long*/
q.*px11 = 10;
/*Используя указатель на компоненту класса, изменили значение переменной
x1 объекта q, представляющего класс XXX. */
pq->*px11 = 10;
/*Используя указатель на компоненту класса, изменили значение переменной
x1 объекта, представляющего класс XXX и расположенного по адресу pq. */
}

Вызов статических функций-членов класса не требует никаких объектов и указателей на объекты. От обычных функций их отличает лишь специфическая область видимости.


 

 

Объединения

Мы возвращаемся к объединениям. Наш уровень знаний делает знакомство с этой конструкцией приятным и лёгким.

Объединение можно представить в виде структуры, размер которой достаточен для сохранения не более одного данного-члена объединения. Это конструкция для экономии памяти. Здесь может быть ничего лишнего. Отсюда его основные свойства и особенности:

  • объединение может включать функции-члены, в том числе конструкторы и деструкторы. Они, безусловно, могут быть полезны для обслуживания единственного значения объекта-представителя объединения;
  • объединение не может иметь базовых классов и само также не может служить базовым классом. По этой причине в объединения не могут входить виртуальные функции (они бесполезны);
  • объединения также не могут включать статические данные-члены, объекты-представители некоторого класса со специально объявленными конструкторами, деструкторами, операторными функциями присваивания. Всё это служит помехой компактному сохранению значений.

Неименованное объединение определяет объект, а не объявляет тип. Имена членов безымянного объединения должны отличаться от других имён из области действия, где было объявлено это объединение. Безымянное объединение не содержит объявления функций-членов.

В своей области действия имена членов объединения используются непосредственно без обычных операций обращения. Глобальное безымянное объединение объявляется как статическое. Всякий раз это всего лишь универсальный многофункциональный "контейнер" для хранения значений различных типов в одной и той же области памяти.

Этот раздел мы завершим примером, который раскрывает неожиданные возможности использования объединения. Сочетание объединения с битовым полем позволит нам убедиться в корректности преобразований дробной части вещественного числа. Здесь самое время обратиться к соответствующему приложению.

#include <iostream.h>
union
{
float floatVal;
struct
{
int bit0 : 1;
int bit1 : 1;
int bit2 : 1;
int bit3 : 1;
int bit4 : 1;
int bit5 : 1;
int bit6 : 1;
int bit7 : 1;
int bit8 : 1;
int bit9 : 1;
int bit10 : 1;
int bit11 : 1;
int bit12 : 1;
int bit13 : 1;
int bit14 : 1;
int bit15 : 1;
int bit16 : 1;
int bit17 : 1;
int bit18 : 1;
int bit19 : 1;
int bit20 : 1;
int bit21 : 1;
int bit22 : 1;
int bit23 : 1;
int bit24 : 1;
int bit25 : 1;
int bit26 : 1;
int bit27 : 1;
int bit28 : 1;
int bit29 : 1;
int bit30 : 1;
int bit31 : 1;
} BitField;
} MyUnion;
void main ()
{
MyUnion.BitField.bit31 = 0;
MyUnion.BitField.bit30 = 1;
MyUnion.BitField.bit29 = 0;
MyUnion.BitField.bit28 = 0;
MyUnion.BitField.bit27 = 0;
MyUnion.BitField.bit26 = 0;
MyUnion.BitField.bit25 = 1;
MyUnion.BitField.bit24 = 1;
MyUnion.BitField.bit23 = 0;
MyUnion.BitField.bit22 = 0;
MyUnion.BitField.bit21 = 1;
MyUnion.BitField.bit20 = 1;
MyUnion.BitField.bit19 = 0;
MyUnion.BitField.bit18 = 0;
MyUnion.BitField.bit17 = 1;
MyUnion.BitField.bit16 = 0;
MyUnion.BitField.bit15 = 0;
MyUnion.BitField.bit14 = 0;
MyUnion.BitField.bit13 = 1;
MyUnion.BitField.bit12 = 0;
MyUnion.BitField.bit11 = 0;
MyUnion.BitField.bit10 = 0;
MyUnion.BitField.bit9 = 0;
MyUnion.BitField.bit8 = 0;
MyUnion.BitField.bit7 = 0;
MyUnion.BitField.bit6 = 0;
MyUnion.BitField.bit5 = 0;
MyUnion.BitField.bit4 = 0;
MyUnion.BitField.bit3 = 0;
MyUnion.BitField.bit2 = 0;
MyUnion.BitField.bit1 = 0;
MyUnion.BitField.bit0 = 0;
cout << MyUnion.floatVal << endl;
}


 

Обзор принципов объектно-ориентированного программирования

Мы уже многое повидали.

Основные типы с их свойствами и перечнем операций, леводопустимые выражения, операторы управления, массивы и указатели, указатели на функции и функции, использующие указатели на функции как параметры и возвращаемые значения, совместно используемые (перегруженные) функции и алгоритм сопоставления…

При объявлении классов мы знаем, в чём состоят различия между статическими и нестатическими членами, нам известно назначение деструкторов и особенности различных вариантов конструкторов.

Мы познакомились с принципом наследования - одним из трёх основных принципов объектно-ориентированного программирования. Он реализуется через механизмы наследования и виртуальных классов, которые позволяют строить новые производные классы на основе ранее объявленных базовых классов. Принцип наследования уподобил процесс программирования процессу сборки сложных устройств и механизмов из наборов стандартных узлов и деталей.

Принцип инкапсуляции - второй принцип объектно-ориентированного программирования, делает процесс программирования ещё более похожим на работу в сборочном цехе. Хорошо спроектированный класс имеет открытый интерфейс для взаимодействия с "внешним миром" и защищённую от случайного воздействия "внутреннюю" часть. Такой класс подобен автомобильному двигателю. В момент его установки в кузове или на раме при сборке автомобиля, он уже полностью собран. И не нужно сверлить в корпусе двигателя дополнительные отверстия для подсоединения трубопроводов системы охлаждения, подачи топлива и машинного масла. Разделение класса на скрытую внутреннюю часть и открытый интерфейс обеспечивается системой управления доступом к компонентам класса и дружественными функциями.

Принцип полиморфизма (полиморфизм означает буквально многообразие форм) - ещё один принцип объектно-ориентированного программирования. Он заключается в способности объекта во время выполнения программы динамически изменять свои свойства. Возможность настройки указателя на объект базового класса на объекты производных классов и механизм виртуальных функций лежат в основе этого принципа объектно-ориентированного программирования.

Многое нам ещё предстоит узнать, но то главное, что собственно и делает C++ объектно-ориентированным языком, уже известно.


 

Представление операций для классов. Операторные функции

Классы вводят в программу производные типы. Такие типы могут входить в списки параметров функций и определять тип возвращаемого значения. В вызовах функций при передаче параметров и возвращении значений данные производных типов используются в программе наравне с данными базовых типов. В условиях фактического равноправия производных и основных типов данных должна существовать возможность сохранения привычной структуры выражений при работе с данными производных типов.

Это означает, что выражение для вычисления суммы двух слагаемых уже известного нам типа ComplexType по своей структуре не должно отличаться от соответствующих выражений для слагаемых типа int или float. Но большинство операций языка C++ определены лишь для основных типов данных. Использование в качестве операндов операций выражений производных типов вызывает ошибки трансляции. Поэтому в классе ComplexType и были определены специальные функции-члены, реализующие арифметические операции над множеством комплексных чисел.

И всё же возможность сохранения привычной структуры выражений для производных типов в C++ существует.

Вернёмся к известному классу ComplexType. Мы определим два объекта класса ComplexType, после чего воспользуемся операцией присвоения.

ComplexType ctVal1(3.14, 0.712); ComplexType ctVal2, ctVal3; /* Комплексные числа со случайными значениями данных-членов.*/ ::::: ctVal2 = ctVal1; ctVal3 = ctVal2 = ctVal1; /* Операция присвоения коммутативна.*/

Если теперь вывести значения данных-членов объектов ctVal2 и ctVal3, то окажется, что они полностью совпадают со значениями данных-членов объекта ctVal1. Операция присваивания изначально определена для объектов класса ComplexType. Её можно рассматривать как предопределённую операцию, которая обеспечивает фактически побитовое копирование объекта, стоящего справа от символа = в объект, расположенный слева от этого знака.

Подобно любому другому выражению, выражение присваивания имеет собственное значение. Это значение равно выражению, стоящему справа от операции =. В ходе выполнения программы изменяется значение l-выражения в выражении присваивания, после чего значение этого выражения оказывается равным изменённому значению этого самого l-выражения.

Строго говоря, операцию присваивания для объектов производных типов нельзя называть операцией. Как и ранее рассмотренные нами "операции" приведения, она является операторной функцией. Это значит, что при её объявлении используется специальное имя, состоящее из ключевого слова operator с символом операции, а для её вызова можно использовать полную и сокращённую форму.

Мы приступаем к очередной модификации объявления класса ComplexType с целью переопределения новой операторной функции, реализующей то, что можно называть "операцией присваивания".

Работу по объявлению этой функции мы начнём с того, что попытаемся представить её общий вид:

  • функция объявляется как нестатический член класса и вызывается для объекта, которому надо присвоить соответствующее значение;
  • её имя состоит из ключевого слова operator, за которым, очевидно, следует символ = ;
  • основное назначение функции состоит в присваивании значения (множества значений) одного объекта другому. Следовательно, операторная функция operator=() должна иметь, по крайней мере, один параметр, который должен представлять присваиваемое значение;
  • и последнее, очень важное обстоятельство. Операторная функция operator=() должна возвращать новое значение объекта и по форме вызова должна создавать видимость коммутативности, поскольку этим свойством обладает операция присвоения.

class ComplexType { ::::: ComplexType operator = (const ComplexType &); /* Ссылка на константу при объявлении параметра не является обязательным условием для объявления операторной функции. Но это гарантия того, что присваиваемое значение не будет изменено в результате обращения к данным-членам. Операторная функция operator=() возвращает значение (именно значение!) объект класса ComplexType. Это не самый оптимальный способ обеспечения коммутативности операторной функции. Но при этом обеспечивается подобие операторной функции операции присваивания. */ ::::: } ::::: ComplexType ComplexType::operator = (const ComplexType& ctKey) { cout << "This is operator = (ComplexType& ctKey)..." << endl; /* Подтверждение о том, что выполняется именно эта функция. */ this->real = ctKey.real; this->imag = ctKey.imag; this->CTcharVal = ctKey.CTcharVal; this->x = ctKey.x; /* Теперь вся ответственность за корректность процесса копирования целиком и полностью возлагается на программиста. */ return *this; /* Мы возвращаем значение объекта, представленного this указателем. */ } ::::: /* Будем считать, что объекты ctVal1 и ctVal2 уже определены. Осталось рассмотреть варианты вызовов этой функции. */ ctVal2.operator = (ctVal1); /* Вариант полной формы вызова функции.*/ ctVal2 = ctVal1; /* Вариант сокращённой формы вызова функции. Операция обращения, ключевое слово operator в составном имени операторной функции и скобки, заключающие выражение, представляющее значение параметра опускаются. Создаётся иллюзия использования обычной операции присваивания. */ /* Демонстрация коммутативности операторной функции присваивания. */ ctVal3.operator = (ctVal2.operator = (ctVal1)); /* Операторная функция operator=() вызывается непосредственно из объекта ctVal3 со значением атрибута (ссылкой на объект), который сам в свою очередь является результатом применения операторной функции operator=() к объекту ctVal2 с параметром-ссылкой на объект ctVal1. Всё очень просто и красиво! */ ctVal3 = ctVal2 = ctVal1; /* Сокращённая форма коммутативного вызова операторной функции присваивания. */


 

Объявление и определение операторных функций

 

При объявлении и определении операторных функций (в том числе и operator=() ), используется синтаксическая конструкция, обозначаемая в терминах формальной грамматики нетерминальным символом ИмяФункцииОперации. Несколько форм Бэкуса-Наура позволяют однозначно определить это понятие:

Имя ::= ИмяФункцииОперации ::= ***** ИмяФункцииОперации ::= operator СимволОперации СимволОперации ::= +|-|*|?|%|^|&|~|!|,|=|<|>|<=|>=|++|--|<<|>>|==|!=|&&| |||+=|-=|*=|<<=|>>=|[]|()|->|->*|new|delete|

Как следует из приведённых БНФ, большинство символов операций языка C++ могут участвовать в создании так называемых имён функций операций или операторных функций. То есть на основе этих символов можно объявлять операторные функции, сокращённая форма вызова которых позволяет создавать видимость применения операций к объектам производных типов.

C++ не накладывает никаких ограничений на семантику этих самых операторных функций. Наша операторная функция operator=() могла бы вообще не заниматься присвоением значений данных-членов. Она могла бы не возвращать никаких значений. Само собой, что тогда выражение вызова этой функции не могло бы быть коммутативным. А единственный параметр можно было бы передавать по значению. Но тогда всякий раз при вызове функции неизбежно должен был бы вызываться конструктор копирования, который бы создавал в области активации функции копию объекта, которую впоследствии должен был бы разрушать деструктор.

Операторная функция operator=(), как и любая другая функция, может быть перегружена. Например, объявление параметра типа int, позволило бы присваивать комплексным числам целочисленные значения. Здесь нет пределов совершенствования. В принципе, механизм операторных функций регламентирует лишь внешний вид заголовка функции (его "операторное" имя, количество параметров, в ряде случаев - возвращаемое значение). Информация о заголовке принципиальна, поскольку от этого зависит форма сокращённого вызова операторной функции.

Ещё несколько замечаний по поводу спецификации возвращаемого значения операторной функции.

Операторная функция operator=() может вообще не возвращать никаких значений. Сокращённая форма вызова

ctVal2 = ctVal1;

с точки зрения транслятора абсолютно корректна и полностью соответствует следующим прототипам:

void ComplexType::operator = (const ComplexType& ctKey); void ComplexType::operator = (ComplexType& ctKey); void ComplexType::operator = (ComplexType ctKey);

Правда, в таком случае ни о какой коммутативности, "безопасности" и эффективности вновь определяемой операторной функции нет и быть не может.

С другой стороны, уже существующий вариант нашей операторной функции также может быть оптимизирован. Функция может возвращать не ОБЪЕКТ (ЗНАЧЕНИЕ), а ССЫЛКУ на объект.

В этом случае при возвращении значения не будет создано временного объекта. Также не будет вызываться деструктор для его уничтожения. Модификация операторной функции operator=() минимальна - всего лишь дополнительная ptrОперация & в спецификации возвращаемого значения (мы приводим здесь только прототип новой версии функции):

ComplexType& operator = (const ComplexType &);

Всё остальное транслятор исправит самостоятельно, так что никаких дополнительных модификаций в тексте программы производить не придётся. Эта функция будет эффективней, правда, семантика выражения её вызова будет отличаться от семантики соответствующего выражения присвоения с базовыми типами. В первом случае результатом выполнения выражения оказывается присваиваемое значение, во втором - ссылка на объект.

Следующий пример является подтверждением того факта, что при объявлении операторных функций полностью отсутствуют чёткие правила. Это подтверждает следующий пример, посвящённый объявлению и вызову различных вариантов операторных функций operator():

:::::
class ComplexType
{
:::::
};
:::::
class rrr // Объявляется новый класс.
{
public:
ComplexType* pComplexVal;
// Собственные версии конструкторов и деструкторов.
rrr ()
{
pComplexVal = new ComplexType;
// Порождение собственного экземпляра объекта ComplexType.
}
~rrr ()
{
if (pComplexVal) = delete pComplexVal;
}
// Наконец, встроенная операторная функция.
ComplexType* operator -> ()
{
cout << "This is operator -> ()..." << endl;
return pComplexVal;
}
};
:::::
// А это уже собственно фрагмент программы…
rrr rrrVal; // Определяем объект - представитель класса rrr.
cout << rrrVal ->real << " real." << endl;
:::::
Сокращённая форма вызова операторной функции operator->() имеет вид rrrVal->real и
интерпретируется транслятором как (rrrVal.operator->())->real, о чём и свидетельствует оператор, содержащий полную 
форму вызова этой операторной функции.
:::::
cout << (rrrVal.operator->())->imag << " imag." << endl;
:::::


 

Операторная функция operator() с несколькими параметрами

И это ещё не всё! Ещё не рассматривались варианты операторной функции operator() с несколькими параметрами. И здесь следует вспомнить о функциях с переменным количеством параметров. Это не единственный, но наиболее оптимальный подход к объявлению операторной функции operator() с несколькими параметрами. Здесь мы не будем вдаваться в детали алгоритма извлечения информации из списка параметров (мы их уже обсуждали раньше), а ограничимся лишь общей схемой объявления и вариантами выражения вызова. В нашей версии (всего лишь одной из возможных!), первым параметром функции всегда будет целое число:

ComplexType& operator () (int, ...);// Прототип. ::::: ComplexType& ComplexType::operator () (int iKey, ...) { cout << "This is operator ( " << iKey << ", ...)" << endl; return *this; } ::::: CDw2(50); CDw2(50, 100); CDw2(50, "Это тоже вызов операторной функции", 3.14, 0,123456789);

В C++ может быть объявлено более трёх десятков различных вариантов операторных функций. К этому выводу приводит анализ списка символов операций, которые потенциально могут входить в качестве элемента имени операции.

Здесь не имеет смысла описывать все возможные операторные функции по отдельности. В этом разделе мы рассмотрим ещё несколько интересных "нетипичных" случаев объявления, в следующих разделах будут описаны типичные общие схемы объявлений операторных функций.

Как известно, операция косвенного обращения -> является бинарной операцией. Её первым операндом является указатель на объект, вторым - имя члена класса.

Однако в C++ соответствующий операторный аналог представляется операторной функцией без параметров. Кроме того, для этой функции регламентируется тип возвращаемого значения. Она должна обязательно возвращать указатель либо ссылку на объект некоторого класса.

Рассмотрим различные варианты объявления, определения и вызова этой операторной функции.

Первый вариант тривиален:

::::: ComplexType* operator -> (); ::::: ComplexType* ComplexType::operator -> () { cout << "This is operator -> ()..." << endl; return this; } :::::

Таково, в общих чертах, объявление и определение функции. Функция без параметров.

::::: if (CDw2.operator->() == NULL) cout << "!!!" << endl; :::::

Это полная форма вызова в выражении равенства в составе условного оператора.

::::: CDw3->real = 125.07; (CDw3.operator->())->real = 125.07; :::::

Сокращённая и полная формы вызова операторной функции в составе оператора присвоения. Функция возвращает адрес, к которому применяется обычная двухместная операция косвенного обращения.

А вот более простого варианта сокращённой формы вызова функции operator->(), наподобие того, который ранее использовался в составе условного оператора, в C++ не существует. Правильно построенных выражений вида (xObject->) с единственным операндом, где -> является символом операции, в C++ нет, поскольку -> бинарная операция.

Из-за того, что не всегда удаётся различить по контексту выражение вызова функции и операцию косвенного обращения, сокращённый вызов операторной функции operator->() используется исключительно для имитации выражений с операцией косвенного обращения.

Операторная функция operator->() возвращает указатель на объект, и как любая нестатическая функция-член класса должна вызываться непосредственно "из объекта". Эта прописная истина не представляла бы никакого интереса, если бы в C++ существовали жёсткие ограничения на тип возвращаемого значения функции-члена класса. Но таких ограничений для операторных функций в C++ не существует, а потому возможны и такие экзотические варианты операторных функций:

::::: class ComplexType { ::::: }; ::::: class rrr // Объявляется новый класс. { public: ComplexType* pComplexVal; // Собственные версии конструкторов и деструкторов. rrr () { pComplexVal = new ComplexType; // Порождение собственного экземпляра объекта ComplexType. } ~rrr () { if (pComplexVal) = delete pComplexVal; } // Наконец, встроенная операторная функция. ComplexType* operator -> () { cout << "This is operator -> ()..." << endl; return pComplexVal; } }; ::::: // А это уже собственно фрагмент программы… rrr rrrVal; // Определяем объект - представитель класса rrr. cout << rrrVal ->real << " real." << endl; ::::: Сокращённая форма вызова операторной функции operator->() имеет вид rrrVal->real и интерпретируется транслятором как (rrrVal.operator->())->real, о чём и свидетельствует оператор, содержащий полную форму вызова этой операторной функции. ::::: cout << (rrrVal.operator->())->imag << " imag." << endl; :::::

В этом случае из объекта-представителя класса rrr вызывается операторная функция, в обязательном порядке возвращающая адрес объекта-представителя класса ComplexType, к которому сразу же (!) применяется операция косвенного обращения.

Здесь мы рассмотрели три операторные функции, сокращённая форма вызова которых имитировала операции присвоения, вызова и косвенного обращения. Эти операторные функции занимают особое место среди прочих операторных функций.

Во-первых, описанные в этом разделе способы объявления и определения этих функций не имеет альтернативы.

Во-вторых, на внешний вид объявления и формы вызова этих функций наложили свой отпечаток особенности синтаксиса и семантики соответствующих операций. Так, операция присвоения возвращает значение (или ссылку на значение), при этом, одновременно изменяя значение первого операнда (объекта, из которого осуществляется вызов операторной функции), бинарная операция косвенного обращения имитируется функцией без параметров, а операторная функция вызова может быть объявлена со списком параметров переменной длины.


 

Вызов операторной функции operator ~() против вызова деструктора

Мы готовы к анализу контекста явного вызова деструктора. Ранее, в разделе, посвящённом деструкторам, упоминалось, что явный вызов деструктора требует операций обращения. Рассмотрим следующий пример.

На основе символа операции '~' может быть определена операторная функция. Нам сейчас абсолютно безразлично её назначение и устройство. Мы определим её лишь в самых общих чертах.

ComplexType ComplexType::operator ~ ()
{
cout << "Это ComplexType ComplexType::operator ~ ()" << endl;
return ComplexType();
}

Кроме того, предположим существование ещё одной функции-члена класса ComplexType, в теле которой и расположим интересующие нас выражения и операторы.

void ComplexType::xFun()
{
:::::
ComplexType CTw = ComplexType();
/*
В результате выполнения выражения преобразования типа вызывается
конструктор умолчания, который создаёт временный объект, значение
которого копируется в переменную CTw.
*/
~CTw;
CTw.operator ~();
/*
Сокращённая и полная формы вызова операторной функции ComplexType
ComplexType::operator ~ ()
*/
~ComplexType();
/*
Создаётся временный безымянный объект, для которого вызывается
операторная функция ComplexType ComplexType::operator ~ ().
Используется сокращённая форма вызова.
*/
ComplexType().operator ~();
/*
Создаётся временный безымянный объект, для которого вызывается
операторная функция ComplexType ComplexType::operator ~ ().
Используется полная форма вызова.
*/
CTw.~ComplexType();
/*Наконец, явный вызов деструктора для объекта CTw */
this->~ComplexType();
/*
Явный вызов деструктора для объекта, расположенного по адресу *this
*/
:::::}

 

Функции operator new() и operator delete()

Время жизни объекта определяется областью действия его имени. В зависимости от расположения оператора определения объекта, он может располагаться в глобальном или локальном сегменте памяти. При определении глобального объекта соответствующие конструкторы объявляются со спецификатором public, поскольку они должны быть доступны фактически до начала выполнения программы. Глобальные объекты существуют в течение всего времени выполнения программы.

Локальные объекты создаются в соответствующих сегментах памяти в ходе выполнения операторов определения, после передачи управления в функцию или вложенный блок операторов. По возвращении из вложенного блока или функции, имя объекта оказывается вне области действия имени. Сам же объект уничтожается в момент освобождения соответствующего сегмента памяти. Важная роль при этом отводится деструкторам.

Можно избежать преждевременной гибели объекта, расположив его в динамической памяти. В этом случае память для объекта выделяется с помощью выражения размещения. Значением этого выражения является адрес области памяти, выделенной для размещения объекта в результате выполнения выражения. Очевидно, что это значение можно присвоить переменной типа указатель на объект данного класса.

Динамическая память не опустошается автоматически. "Гибель" локального указателя, настроенного на выделенную область динамической памяти означает всего лишь потерю доступа к этой области памяти. В этом случае уничтожается указатель, но освобождения памяти не происходит.

Для освобождения памяти используется операция (операторная функция) delete. Подобно операторной функции new, delete также является статическим членом класса.

В контексте выражений размещения и удаления могут быть использованы стандартные операции C++ new и delete, а может быть обеспечен вызов операторных функций operator new и operator delete.

Согласно грамматике C++, основным операндом для символа операции new в выражении размещения является заключённое в круглые скобки ИмяТипа, либо ИмяТипаNew (без скобок), которое разворачивается в конструкцию, содержащую информацию о размерах размещаемого массива (константные выражения в квадратных скобках):

ВыражениеРазмещения
::= [::] new [Размещение] ИмяТипаNew [ИнициализаторNew]
::= [::] new [Размещение] (ИмяТипа) [ИнициализаторNew]
ИмяТипаNew ::= СписокСпецификаторовТипа [ОписательNew]
ОписательNew ::= [СписокCVОписателей] [ОписательNew]
::= [ОписательNew] [Выражение]
::= *****

При этом можно определить несколько различных вариантов операторной функции operator new. Перегруженные операторные функции будут различаться списками параметров. В C++ предусмотрены специальные средства передачи значений параметров подобным перегруженным операторным функциям. С этой целью используется так называемое Размещение, которое является необязательным составным элементом выражения размещения. Заключённый в круглые скобки список выражений располагается в выражении размещения непосредственно перед именем операторной функции new.

Мы объявляем простой класс, содержащий определения операторных функций распределения динамической памяти. И размещаем это объявление в заголовочном файле с именем TypeX.h.

// TypeX.h
#ifndef TYPEX
#define TYPEX
/*
Инструкции препроцессора используются для предотвращения многократного
объявления класса в программном модуле.
Даже если в исходном файле появится несколько инструкций препроцессора,
обеспечивающих включение заголовочного файла TypeX.h, в исходном файле
окажется всего лишь одно объявление класса TypeX.
*/
// Объявление класса TypeX.
class TypeX
{
public:
/* Встроенный конструктор */
TypeX() { cout << "Это TypeX()" << endl; }
/* Встроенный конструктор с параметром */
TypeX(int x) { cout << "Это TypeX(" << x << ")" << endl; }
/* Встроенный деструктор */
~TypeX() { cout << "Это ~TypeX()" << endl; }
/* Встроенная операторная функция operator new() */
void *operator new(size_t size)
{
cout << "Это void *operator new(" << size << ")" << endl;
return new char(size);
}
/* Операторная функция operator new() с дополнительным параметром */
void *operator new(size_t size, int xPar)
{
cout << "void *operator new(" << size << "," << xPar << ")" << endl;
return new char(size);
}
/* Встроенная операторная функция operator delete() */
void operator delete(void *cPoint, size_t size)
{
cout << "Это void operator delete(" << size << ")" << endl;
if (cPoint) delete cPoint;
};
};
#endif


 

Сложная семантика выражений C++ проявляется на простых примерах. Небольшие программы позволят выявить принципиальные моменты алгоритмов трансляции, свойства операций динамического распределения памяти, особенности операторных функций operator new() и operator delete(). В программе следует обратить внимание на второе выражение размещения, которое позволяет активизировать конструктор с параметрами.

#include <iostream.h>
#include "TypeX.h"
void main()
{
TypeX *xPoint = NULL, *xPointP = NULL, *xxPointP = NULL;
xPoint = new TypeX;
xPointP = new TypeX(25);
// Выражение размещения может содержать параметры.
// Так осуществляется управление конструктором.
xxPointP = new (125+25) TypeX(50);
// Выражение размещения может включать размещение.
// Этот одноэлементный список выражений обеспечивает передачу
// значений параметров операторной функции operator new.
// Альтернативные формы вызова операторных функций:
// ИмяТипа в круглых скобках.
// xPoint = new (TypeX);
// xPointP = new (TypeX)(25);
// xxPointP = new (125+25) (TypeX)(50);
delete xPoint;
delete xPointP;
delete xxPointP;
cout << "OK" << endl;
}

В ходе трансляции распознаются выражения размещения и освобождения, и делается всё необходимое для своевременного вызова конструкторов и деструктора. Если к тому же, в объявлении класса обнаружены объявления соответствующих операторных функций, эти выражения преобразуются транслятором в вызовы операторных функций.

Так что транслируем, запускаем и наблюдаем результаты:

Это void *operator new(1)
Это TypeX()
Это void *operator new(1)
Это TypeX(25)
Это void *operator new(1, 150)
Это TypeX(50)
Это ~TypeX()
Это void operator delete(1)
Это ~TypeX()
Это void operator delete(1)
Это ~TypeX()
Это void operator delete(1)
OK

В ходе выполнения этой программы на дисплей выводится сообщение о работе операторной функции operator new(), которая вызывается в результате определения значения выражения размещения.

После этого, появляется сообщение о работе конструкторов, запуск которых обеспечивается транслятором в результате выполнения выражений размещения.

Затем, непосредственно перед выполнением выражения освобождения, выполняется деструктор, о запуске которого также заботится транслятор.

Наконец, управление передаётся операторной функции operator delete(). Жизненный цикл безымянных объектов, размещённых в динамической памяти в результате выполнения выражений размещения и адресуемых посредством указателей xPoint и xPointP, завершён.

Недоступный и скрытый от программиста механизм запуска конструктора, достаточно сложен. В этом можно убедиться, изменив операторную функцию operator new() в классе TypeX следующим образом:

/* Встроенная операторная функция operator new() */
void *operator new(size_t size)
{
cout << "Это void *operator new(" << size << ")" << endl;
return NULL;
}

Новая операторная функция даже не пытается использовать операцию выделения памяти. Она возвращает пустое значение указателя. При этом значением выражения размещения в операторе

xPoint = new TypeX;

оказывается нулевой адрес. И в результате запуск конструктора отменяется:

Это void *operator new(1)
OK

Аналогичным образом работает программный код, который обеспечивает вызов деструктора: непосредственно перед запуском деструктора производится проверка значения указателя.

Мы возвращаем операторную функцию к исходному состоянию, после чего подвергнем исходную программу небольшой модификации. Расположим непосредственно перед символами операций new и delete (символ операции не обязательно представляет операцию!) разделители :: (именно разделители, поскольку они служат для модификации операции, а не используются в сочетании с операндами).

#include <iostream.h>
#include "TypeX.h"
void main()
{
TypeX *xPoint = NULL;
xPoint = ::new TypeX;
::delete xPoint;
cout << "OK" << endl;
}

В результате выполнения новой версии нашей программы мы получаем следующий результат:

Это TypeX()
Это ~TypeX()
OK

Операторные функции не вызываются, однако память выделяется и производится запуск конструктора, а затем и деструктора.

Это означает, что помеченные разделителем :: выражения размещения и освобождения исправно работают, выделяя и освобождая необходимую память. Символы операций ::new и ::delete воспринимаются транслятором как символы собственных "глобальных" операций выделения и освобождения памяти языка C++.

К аналогичному результату мы приходим, исключив из объявления класса TypeX объявления операторных функций operator new() и operator delete(). В этом случае перед символами операций new и delete даже не требуется располагать разделители. В этом случае транслятор их однозначно воспринимает как символы операций.

Мы снова восстанавливаем файл с объявлением класса TypeX и очередной раз модифицируем нашу программу. На этот раз мы заменим выражения размещения и освобождения выражениями явного вызова операторных функций.

#include <iostream.h>
#include "TypeX.h"
void main()
{
TypeX *xPoint = NULL;
xPoint = (TypeX *) TypeX::operator new (sizeof(TypeX));
TypeX::operator delete(xPoint, sizeof(TypeX));
// delete xPoint;
cout << "OK" << endl;
}

В результате выполнения этой версии программы на дисплей будут выведены следующие сообщения:

Это void *operator new(1)
Это void operator delete(1)
OK

Операторные функции работают успешно, память выделяется и освобождается, однако управление конструктору и деструктору не передаётся. Выражение вызова операторных функций operator new() и operator delete() не обеспечивают вызова конструктора и деструктора. Мы уже знаем, что в C++, за исключением весьма странного выражения явного вызова, вызов конструктора и деструктора обеспечивается транслятором в контексте ограниченного множества выражений. Нет соответствующего выражения, - нет и вызова конструктора.


 

Шаблоны функций и шаблонные функции

Рассмотрим простую функцию, реализующую алгоритм сравнения двух величин:

int min (int iVal_1, int iVal_2)
{
return iVal_1 < iVal_2 ? iVal_1 : iVal_2;
/*
Возвращается значение iVal_1, если это значение меньше iVal_2.
В противном случае возвращается значение iVal_2.
*/
}

Для каждого типа сравниваемых величин должен быть определён собственный вариант функции min(). Вот как эта функция выглядит для float:

float min (float fVal_1, float fVal_2)
{
return fVal_1 < fVal_2 ? fVal_1 : fVal_2;
}

А для double… А для…

И так для всех используемых в программе типов сравниваемых величин. Мы можем бесконечно упражняться в создании совместно используемых функций, хотя можно воспользоваться средствами препроцессирования:

#define min(a,b)   ((a)<(b)?(a):(b))

Это определение правильно работает в простых случаях:

min(10, 20);
min(10.0, 20.0);

В более сложных случаях могут получаться неожиданные результаты, о которых уже когда-то давно мы говорили… Это происходит из-за того, что препроцессор действует независимо от компилятора, до компилятора и вообще производит лишь простую текстовую обработку исходного модуля.

C++ предоставляет ещё одно средство для решения этой задачи. При этом сохраняется присущая макроопределениям краткость и строгость контроля типов языка. Этим средством является шаблон функции.

Шаблон функции позволяет определять семейство функций. Это семейство характеризуется общим алгоритмом, который может применяться к данным различных типов. При этом задание конкретного типа данных для очередного варианта функции обеспечивается специальной синтаксической конструкцией, называемой списком параметров шаблона функции. Объявление функции, которому предшествует список параметров шаблона, называется шаблоном функции.

Синтаксис объявления шаблона определяется следующим множеством предложений Бэкуса-Наура:

Объявление ::= ОбъявлениеШаблона
ОбъявлениеШаблона ::= template <СписокПараметровШаблона> Объявление
СписокПараметровШаблона ::= ПараметрШаблона
::= СписокПараметровШаблона, ПараметрШаблона
ПараметрШаблона ::= ТиповыйПараметр
::= *****
ТиповыйПараметр ::= class Идентификатор

Итак, объявление и определение шаблона функции начинается ключевым словом template, за которым следует заключённый в угловые скобки и разделённый запятыми непустой список параметров шаблона. Эта часть объявления или определения обычно называется заголовком шаблона.

Каждый параметр шаблона состоит из служебного слова class, за которым следует идентификатор. В контексте объявления шаблона функции служебное слово class не несёт никакой особой смысловой нагрузки. Дело в том, что аналогичная конструкция используется также и для объявления шаблона класса, где, как скоро увидим, ключевое слово class играет свою особую роль. В заголовке шаблона имена параметров шаблона должны быть уникальны.

Следом за заголовком шаблона располагается прототип или определение функции - всё зависит от контекста программы. Как известно, у прототипа и определения функции также имеется собственный заголовок. Этот заголовок состоит из спецификатора возвращаемого значения (вполне возможно, что спецификатором возвращаемого значения может оказаться идентификатор из списка параметров шаблона), имя функции и список параметров. Все до одного идентификаторы из заголовка шаблона обязаны входить в список параметров функции. В этом списке они играют роль спецификаторов типа. Объявления параметров, у которых в качестве спецификатора типа используется идентификатор из списка параметров шаблона, называется шаблонным параметром. Наряду с шаблонными параметрами в список параметров функции могут также входить параметры основных и производных типов.

Шаблон функции служит инструкцией для транслятора. По этой инструкции транслятор может самостоятельно построить определение новой функции.

Параметры шаблона в шаблонных параметрах функции обозначают места будущей подстановки, которую осуществляет транслятор в процессе построения функции. Область действия параметров шаблона ограничивается шаблоном. Поэтому в различных шаблонах разрешено использование одних и тех же идентификаторов-имён параметров шаблона.

В качестве примера рассмотрим программу, в которой для определения минимального значения используется шаблон функции min().

template <class Type> Type min (Type a, Type b);
/*
Прототип шаблона функции.
Ключевое слово template обозначает начало списка параметров
шаблона. Этот список содержит единственный идентификатор Type.
Сама функция содержит два объявления шаблонных параметра,
специфицированных шаблоном параметра Type.
Спецификация возвращаемого значения также представлена шаблоном
параметра Type.
*/
int main (void)
{
min(10,20);// int min (int, int);
min(10.0,20.0);// float min (float, float);
/*
Вызовы шаблонной функции. Тип значений параметров определён.
На основе выражения вызова (транслятор должен распознать тип
параметров) и определения шаблона транслятор самостоятельно
строит различные определения шаблонных функций. И только после
этого обеспечивает передачу управления новорождённой шаблонной
функции.
*/
return 1;
}
template <class Type>
Type min (Type a, Type b)
{
return a < b ? a : b;
}
/*
По аналогии с определением функции, эту конструкцию будем называть
определением шаблона функции.
*/

Определение шаблона функции заставляет транслятор самостоятельно достраивать определения новых шаблонных функций, а точнее, создавать множество совместно используемых функций, у которых типы параметров и, возможно, тип возвращаемого значения зависит от типа параметров и типа возвращаемого значения в вызовах шаблонной функции. Этот процесс определения называют конкретизацией шаблона функции.


 

В результате конкретизации шаблона функции min() транслятор строится следующий вариант программы с двумя шаблонными функциями. По выражению вызова на основе шаблона строится шаблонная функция. Почувствуйте прелесть употребления однокоренных слов! Шаблон функции и шаблонная функция - два разных понятия.

int min (int a, int b);
float min (float a, float b);
int main (void)
{
min(10,20);
min(10.0,20.0);
return 1;
}
int min (int a, int b)
{
return a < b ? a : b;
}
float min (float a, float b)
{
return a < b ? a : b;
}

Построение шаблонной функции осуществляется на основе выражений вызова. При этом в качестве значений параметров в выражении вызова могут быть использованы значения любых типов, для которых определены используемые в теле функции операции. Так, для функции min() тип параметров зависит от области определения операции сравнения <. 

Типы формального параметра шаблона и значения параметра выражения вызова сопоставляются без учёта каких-либо модификаторов типа. Например, если параметр шаблона в определении функции объявлен как

template <class Type>
Type min (Type *a, Type *b)
{
return a < b ? a : b;
}
и при этом вызов функции имеет вид:
int a = 10, b = 20;
int *pa = &a, *pb = &b;
min(pa,pb);

то в процессе конкретизации идентификатор типа Type будет замещён именем производного типа int:

int min (int *a, int *b)
{
return a < b ? a : b;
}

В процессе конкретизации недопустимы расширения типов и другие преобразования типов параметров:

template <class Type>
Type min (Type a, Type b)
{
return a < b ? a : b;
}
unsigned int a = 10;
:::::
min(1024,a);
/*
Здесь транслятор сообщит об ошибке. В вызове функции тип второго
фактического параметра модифицирован по сравнению с типом первого
параметра - int и unsigned int. Это недопустимо. В процессе
построения новой функции транслятор не распознаёт модификации типов.
В вызове функции типы параметров должны совпадать. Исправить ошибку
можно с помощью явного приведения первого параметра.
*/
min((unsigned int)1024,a);
:::::

Имя параметра шаблона в определяемой функции используется в качестве имени типа. С его помощью специализируются формальные параметры, определяется тип возвращаемого значения, определяется тип объектов, локализованных в теле функции. Имя параметра шаблона скрывает объекты с аналогичным именем в глобальной по отношению к определению шаблонной функции области видимости. Если в теле шаблонной функции необходим доступ к внешним объектам с тем же именем, следует использовать операцию изменения области видимости.

И опять пример с излюбленным классом ComplexType. На множестве комплексных чисел определены лишь два отношения: равенства (предполагает одновременное равенство действительной и мнимой частей) и неравенства (предполагает все остальные случаи). В нашей новой программе мы объявим и определим шаблон функции neq(), которая будет проверять на неравенство значения различных типов.

Для того, чтобы построить шаблонную функцию neq() для комплексных чисел, нам придётся дополнительно определить операторную функцию-имитатор операции != для объектов-представителей множества комплексных чисел. Это важно, поскольку операция != явным образом задействована в шаблоне neq(). Транслятор не поймёт, как трактовать символ != , а, значит, и как строить шаблонную функцию neq(ComplexType, ComplexType), если эта операторная функция не будет определена для класса ComplexType.

#include <iostream.h>
template <class Type>
int neq (Type, Type); /*Прототип шаблона функции.*/
class ComplexType
{
public:
double real;
double imag;
// Конструктор умолчания.
ComplexType(double re = 0.0, double im = 0.0)
{real = re; imag = im;}
/*
Операторная функция != . Без неё невозможно построение шаблонной функции neq() для комплексных чисел.
*/
int operator != (ComplexType &KeyVal)
{
if (real == KeyVal.real && imag == KeyVal.imag) return 0;
else return 1;
}
};
void main ()
{
// Определены и проинициализированы переменные трёх типов.
int i = 1, j = 2;
float k = 1.0, l = 2.0;
ComplexType CTw1(1.0,1.0), CTw2(2.0,2.0);
//На основе выражений вызова транслятор строит три шаблонных функции.
cout << "neq() for int:" << neq(i,j) << endl;
cout << "neq() for float:" << neq(k,l) << endl;
cout << "neq() for ComplexType:" << neq(CTw2,CTw3) << endl;
}
/*Определение шаблона функции.*/
template <class Type>
int neq (Type a, Type b)
{
return a != b ? 1 : 0;
// return a != b; /* На самом деле, можно и так… */
}


 

И ещё один пример. Этот пример подтверждает обязательность включения всех параметров шаблона в список параметров шаблона определяемой функции. Независимо от того, какая роль предназначается шаблонному параметру (он вообще может не использоваться в шаблонной функции), его присутствие в списке параметров обязательно. В процессе построения шаблонной функции транслятор модифицирует весь шаблон полностью - его заголовок и его тело. Так что в теле шаблона можно объявлять переменные, специфицированные параметрами шаблона.

#include <iostream.h>
#include <typeinfo.h>
/*
В программе используется объект класса Type_info, позволяющий
получать информацию о типе. Здесь подключается заголовочный файл,
содержащий объявление этого класса
*/
template <class YYY, class ZZZ> YYY Tf (ZZZ, YYY, int);
/*
Шаблон прототипа функции. Функция Tf возвращает значение пока
ещё неопределённого типа, обозначенного параметром шаблона YYY.
Список её параметров представлен двумя (всеми!) параметрами шаблона
и одним параметром типа int.
*/
void main()
{
cout << Tf((int) 0, '1', 10) << endl;
/*
Собственно эти вызовы и управляют работой транслятора. Тип передаваемых
значений параметров предопределяет структуру шаблонной функции.
В первом случае шаблону параметра ZZZ присваивается значение "int",
шаблону параметра YYY присваивается значение "char", после чего прототип
шаблонной функции принимает вид
char Tf (int, char, int);
*/
cout << Tf((float) 0, "This is the string...", 10) << endl;
/*
Во втором случае шаблону параметра ZZZ присваивается значение
"float", шаблону параметра YYY присваивается значение "char *",
после чего прототип шаблонной функции принимает вид
char* Tf (float, char *, int);
В результате, используя один общий шаблон, мы получаем две совершенно
различных совместно используемых функции.
*/
}
/*
Шаблон функции. Первый параметр не используется, поэтому в списке
параметров он представлен спецификатором объявления. Второй шаблонный
параметр определён и также зависит от шаблона, третий параметр от
шаблона не зависит.
*/
template <class YYY, class ZZZ> YYY Tf (ZZZ, YYY yyyVal, int x)
{
ZZZ zzzVal;
int i;
for (i = 0; i < x; i++)
{
cout << "Tf() for " << typeid(zzzVal).name() << endl;
}
return yyyVal;
}



Ошибки и исключительные ситуации

Мы завершаем путь. Всё это время мы стремились не допускать ошибок в выражениях, операторах, объявлениях, определениях, макроопределениях, программах. Но до сих пор у нас нет чёткого представления о том, что такое ошибка.

В общем случае под ошибкой мы будем понимать несоответствие правилу, алгоритму. Это рабочее определение. Конечно, правила бывают нечёткими, алгоритмы - некорректными. Это неважно. В любом случае можно сказать, что "всё не так, как должно быть". И этого достаточно.

Разные ошибки проявляют себя по-разному и могут быть обнаружены в разное время, на разных стадиях жизненного цикла программы и при различных обстоятельствах.

Выявлением некорректных макроопределений, несуществующих заголовочных файлов и неверных условий компиляции занимается препроцессор. Ошибки препроцессора выявляются на ранних этапах трансляции. Сами по себе они не проявляются.

Транслятору C++ хорошо известен синтаксис языка. Поэтому нарушение правил порождения слов, выражений и предложений, в том числе и неуместное использование ключевых слов, достаточно легко обнаруживается на стадии трансляции.

Транслятор распознаёт константные выражения различной сложности. Он способен самостоятельно производить арифметические вычисления. Так что с вопросами определения статических массивов также не возникает никаких проблем.

На этапе трансляции распознаются леводопустимые выражения. Поэтому значительная часть ошибок, связанных с некорректным использованием операций присвоения также выявляется на стадии трансляции.

Многие ошибки несоответствия типов также могут быть выявлены на этапе трансляции, в ходе создания объектного кода. Здесь следует вспомнить об операции явного преобразования типа, которая отключает контроль транслятора за типами.

На этапе создания исполнительного модуля программа (или система) компоновки способна распознать объявленные и неопределённые переменные и функции, а также незавершённые объявления классов.

В ряде случаев на этапе создания объектного кода могут выявляться неопределённые и неиспользуемые переменные, и даже потенциально опасные фрагменты кода. Транслятор может предупредить об использовании в выражениях неинициализированных переменных.

Однако не все расхождения с правилами, идеальным схемами и алгоритмам могут быть обнаружены до того момента, пока программа находится в состоянии разработки.

Существует категория ошибок, которые не способны выявить даже самые изощрённые препроцессоры, трансляторы и программы сборки. К их числу относятся так называемые ошибки времени выполнения. Эти ошибки проявляются в ходе выполнения программы.

Мы не раз подчёркивали, что в C++ часто возникают ситуации, при которых ответственность за правильность выполнения операций, операторов и даже отдельных функций целиком возлагается на программиста. Арифметические вычисления (деление на нуль), преобразования типа, работа с индексами и адресами, корректная формулировка условий в операторах управления, работа с потоками ввода-вывода - это далеко не полный перечень неконтролируемых в C++ ситуаций.

Ошибки времени выполнения, возникающие непосредственно в ходе выполнения программы, в терминах объектно-ориентированного программирования называются исключительными ситуациями. Исключительные ситуации - это события, которые прерывают нормальный ход выполнения программы.


 

Синхронные и асинхронные исключительные ситуации

Различают синхронные и асинхронные исключительные ситуации.

Синхронная исключительная ситуация возникает непосредственно в ходе выполнения программы, причём её причина заключается непосредственно в действиях, выполняемых самой программой.

Асинхронные исключительные ситуации непосредственно не связаны с выполнением программы. Их причинами могут служить аппаратно возбуждаемые прерывания (например, сигналы от таймера), сообщения, поступающие от внешних устройств или даже от локальной сети.


 

Реакция на исключительную ситуацию 

Реакция на исключительную ситуацию называется исключением.

Заметим, что исключительная ситуация не всегда неожиданна. Очень часто при разработке алгоритма уже закладывается определённая реакция на вероятную ошибку.

Например, функция, размещающая целочисленные значения в массиве по определённому индексу, может самостоятельно следить за допустимыми значениями индекса. Она возвращает единицу в случае успешного размещения значения и нуль, если значение параметра, определяющего индекс, не позволяет этого сделать.

#define MAX 10 int PushIntArray(int* const, int, int); void main() { int intArray[MAX]; int IndexForArray, ValueForArray; ::::: for (;;) { ::::: // Значения IndexForArray и ValueForArray меняются в цикле. if (!PushIntArray(intArray, IndexForArray, ValueForArray)) { cout << "Некорректное значение индекса" << endl; IndexForArray = 0; } ::::: } ::::: } int PushIntArray(int* const keyArray, int index, int keyVal) { if (index >= 0 && index < MAX) { keyArray[index] = keyVal;// Спрятали значение и сообщили об успехе. return 1; } else return 0; // Сообщили о неудаче. }

Перед нами самый простой вариант исключения как формы противодействия синхронной исключительной ситуации. Из функции main вызывается функция, PushIntArray, которой в качестве параметров передаются адрес массива, значение индекса и значение, предназначенное для сохранения в массиве.

Функция PushIntArray проверяет значение индекса и возвращает соответствующее сообщение. Эта функция выявляет возможные ошибки и уведомляет о них вызывающую функцию. Подобное сообщение о неудаче можно рассматривать как прообраз генерации (или возбуждения) исключения.

Вызывающая функция может корректировать значение индекса: исправление выявленных ошибок (то есть реакция на исключение) - компетенция вызывающей функции.

Очевидно, что не всегда исключение может быть возбуждено по такой простой схеме. Например, функция, возвращающая результат деления двух действительных чисел, должна предусматривать вероятность возникновения ситуации, при которой делитель оказывается равным нулю. Ожидаемая исключительная ситуация также может сопровождаться определённой реакцией, нейтрализующей возможные последствия вероятной ошибки.

#include <iostream.h> #define EXDIVERROR 0.0 /* Здесь может быть определено любое значение. Это не меняет сути дела. Так кодируется значение, предупреждающее об ошибке. Не самая хорошая идея: некоторые корректные значения всегда будут восприниматься как уведомления об ошибке. */ float exDiv (float, float); void main() { float val1, val2; ::::: if (exDiv(val1, val2) == EXDIVERROR) { ::::: cout << "exDiv error…"; // Здесь можно попытаться исправить ситуацию. ::::: } } float exDiv (float keyVal1, float keyVal2) { if (val2) return keyVal1/keyVal2; return EXDIDERROR; }

Функция exDiv может быть модифицирована следующим образом: возвращаемое целочисленное значение сообщает о ходе вычисления, а непосредственно сам результат вычисления передаётся по ссылке.

Подобная схема противодействия исключительным ситуациям уже применялась при работе со стеком.

#include <iostream.h> int exDiv (float, float, float&); void main() { float val1, val2, resDiv; ::::: if (!exDiv(val1, val2, resDiv)) { ::::: cout << "exDiv error…"; ::::: } } int exDiv (float keyVal1, float keyVal2, float& keyRes) { if (val2) {keyRes = keyVal1/keyVal2; return 1;} return 0; }

Ещё один возможный вариант обратной связи между вызываемой и вызывающей функциями заключается в определении специального класса, объединяющего в одном объекте возвращаемое значение и служебную информацию о результате выполнения функции. В таком случае возвращаемое значение превращается в исключение лишь в случае возникновения исключительной ситуации.

#include <iostream.h> class DivAnsver { public: int res; float fValue; // Конструктор. DivAnsver(): res(1), fValue(0.0) {}; // ctorИнициализаторы в действии! }; DivAnsver exDiv (float, float); void main() { DivAnsver Ansver; Ansver = exDiv(0.025, 0.10); cout << Ansver.fValue << "..." << Ansver.res << endl; Ansver = exDiv(0.025, 0.0); cout << Ansver.fValue << "..." << Ansver.res << endl; } DivAnsver exDiv (float val1, float val2) { DivAnsver Ans; if (val2) Ans.fValue = val1/val2; else Ans.res = 0; return Ans; }

Функция exDiv возвращает значение объекта Ans (предопределённый конструктор копирования об этом позаботится). При этом, если деление возможно, значение данного-члена res оказывается равным единице, а fValue принимает значение частного от деления. В противном случае res устанавливается в нуль и объект Ans становится исключением.

Подобным изменениям можно подвергнуть объявление класса, реализующего стек: возвращаемое функцией pop() значение объекта-представителя шаблонного класса мог бы содержать результат выполнения функции и значение содержимого стека. 

И опять в этом случае применима традиционная схема: вызывающая функция обращается к вызываемой функции с предложением выполнить конкретное действие. Последняя принимает решение относительно возможности выполнения этого действия и если возможно, выполняет его. В любом случае вызывающая функция уведомляется о проделанной работе, получая либо результат выполнения, либо исключение.

На основе получаемой информации, вызывающая функция принимает соответствующее решение, которое может состоять в продолжении выполнения программы, попытке исправления ошибочной ситуации, либо аварийной остановке выполнения программы.

Казалось бы, всё хорошо и на этом можно было бы остановиться. Однако, нет пределов совершенству!

Существует целый ряд проблем, связанных с подобным способом организации программного кода. Рассмотрим некоторые из них.

Структура вызывающей функции определяется множеством значений, которые может возвратить вызываемая функция. Каждое возвращаемое значение, как правило, сопровождается определённой реакцией. Чем больше вариантов возвращаемых значений и исключений, тем менее наглядным, понятным и легкочитаемым оказывается программный код вызывающей функции.

С ростом числа вариантов возвращаемых значений становится всё более актуальной проблема разделения "положительных" и "отрицательных" ответов.

И вообще, если вызываемая функция возвращает несколько вариантов исключений, то программный код, необходимый для адекватной реакции на ошибки, может превысить объём кода, реализующего основную логику программы.

Наконец, конструкторы и деструкторы вообще не возвращают никаких значений. Поэтому они не способны сообщить о своих проблемах общепринятым способом. Для них приходится специально изобретать особые нестандартные средства взаимодействия.


 

 

Управление исключением - блоки try и catch, операция throw

Предлагаемое в C++ решение проблемы реакции на синхронные исключительные ситуации связано с использованием так называемых контролируемых блоков операторов.

Операторы, составляющие критические участки кода (например, вызов функций, в которых могут возникнуть исключительные ситуации) и операторы, определяющие перехват возможных исключений, размещаются в этих блоках отдельно от прочих "безопасных" операторов функции.

Синтаксис контролируемых блоков описывается следующим множеством формул Бэкуса-Наура:

Оператор ::= КонтролируемыйБлокОператоров КонтролируемыйБлокОператоров ::= try СоставнойОператор СписокРеакций СписокРеакций ::= Реакция [СписокРеакций] Реакция ::= catch (ОбъявлениеИсключения) СоставнойОператор ОбъявлениеИсключения ::= СписокСпецификаторовТипа Описатель ::= СписокСпецификаторовТипа АбстрактныйОписатель ::= СписокСпецификаторовТипа ::= ...

Это одно из последних множеств БНФ на нашем пути. Всё те же знакомые описатели, любимые абстрактные описатели, и даже хорошо известное многоточие.

На основе данных БНФ строим контролируемый блок операторов. Как всегда, пока важен лишь внешний вид оператора.

КонтролируемыйБлокОператоров try СоставнойОператор СписокРеакций try { Оператор Оператор Оператор } СписокРеакций try { int retVal; retVal = MyFun(255); cout << "retVal == " << retVal << "…" << endl } catch (ОбъявлениеИсключения) СоставнойОператор СписокРеакций try { int retVal; retVal = MyFun(255); cout << "retVal == " << retVal << "..." << endl } catch (char *) { x = x * 25; } catch (MyException MyProblem1) { cout << "Неполадки типа MyException: " << MyProblem1.text << endl; } catch (...) { cout << "Нераспознанные исключения..." << endl; }

Итак, контролируемый блок операторов. Прежде всего, это блок операторов, то есть, составной оператор. Его место - тело функции. Этот оператор может входить в любой другой блок операторов.

Он начинается с ключевого слова try (поэтому дальше мы его будем называть try-блоком), следом за которым располагается так называемый блок испытания. В блоке испытания обычно размещается критический код, выполнение которого может привести к возникновению ошибки времени выполнения.

За ним следует, по крайней мере, один элемент списка реакций со своим блоком перехвата. Каждый блок перехвата начинается с заголовка - ключевого слова catch, за которым в круглых скобках располагается объявление ситуации. Синтаксис объявления ситуации напоминает объявление параметра в прототипе функции. В этом объявлении не используется лишь инициализаторы.

catch-блок содержит код, предназначенный для перехвата исключений. Однако без генератора исключений он абсолютно бесполезен.

Возбуждение (или генерация) исключения обеспечивается операцией throw. Это весьма странная операция. Даже с точки зрения синтаксиса:

Выражение ::= ГенерацияИсключения ГенерацияИсключения ::= throw [Выражение]

Выражение, состоящее из одного символа операции (с пустым множеством операндов) уже является выражением. Выражением с неопределённым значением. Однако оператор на основе этого выражения построить можно!

throw;

Оператор возбуждения исключения является полноправным оператором и в принципе может располагаться в любом месте программы: в теле обычной функции, функции-члена, конструкторе или деструкторе. Он может использоваться как в сочетании с контролируемыми блоками операторов, так и в "автономном" режиме.

Его выполнение в автономном режиме или за пределами контролируемого блока приводит к завершению процесса выполнения программы. Точнее, сначала может быть вызвана функция unexpected, следом за которой по умолчанию запускается функция terminate. Она вызывает функцию abort для аварийного завершения работы программы.

Функции unexpected и terminate в ходе выполнения программы можно заменить какие-либо другими функциями, для чего следует воспользоваться функциями set_unexpected и set_terminate. Прототипы функций set_unexpected и set_terminate обычно располагаются в заголовочном файле <except.h>. В качестве параметров они получают указатели на функции и возвращают значения адресов замещённых функций. Так что по ходу дела всё можно будет с помощью этих же функций вернуть назад.

Пользовательские функции, замещающие функции unexpected и terminate, всё равно оказываются самыми последними функциями завершаемой программы. Именно поэтому они должны иметь тип возвращаемого значения void, а также не должны иметь параметров.

Наконец, последней функцией завершаемой программы всё равно оказывается функция abort. Всевозможные ухищрения лишь откладывают момент её неизбежного вызова. Позже мы рассмотрим пример, реализующий замещение этих функций.

При автономном использовании оператора возбуждения исключения, его перехват оказывается как бы и ни при чём. Однако это всего лишь частный случай, один из самых простых вариантов сценария, к которому может привести использование операции throw.

Операция throw может применяться в сочетании с операндом, каковым может оказаться выражение произвольного типа и значения.

Оператор, построенный на основе такого выражения, можно называть генератором исключения. А его место расположения обычно называют точкой генерации. Вот примеры разнообразных генераторов исключений:

throw 1; throw "Это сообщение об исключении…"; throw 2*2*fVal; throw (int)5.5; throw (ComplexType)125.96; /* Разумеется, если определён соответствующий конструктор преобразования или функция приведения. */

Для генератора важны как значение выражения-исключения, так и его тип. Иногда даже конкретное значение исключения не так важно, как его тип.

В качестве исключения может быть использовано значение указателя. Допускаются исключения и такого вида:

throw NULL; throw (void *) &iVal;

И, естественно, не существует быть генераторов исключений для выражений типа void. Пустой тип не имеет значений.

Обычно генератор исключения используется в сочетании с try-блоком. Их взаимодействие обеспечивается через стек вызова. Поэтому точка генерации исключения должна располагаться в теле функции, непосредственно или косвенно вызываемой из множества операторов данного try-блока. В крайнем случае, генератор исключения сам может быть одним из операторов этого try-блока. 

Рассмотрим небольшой пример, после которого опишем основные принципы взаимодействия генератора и блока перехвата исключений.

Но сначала вспомним пару форм Бэкуса-Наура, посвящённых объявлению и определению функций. Речь пойдёт о спецификации исключения. С первого взгляда всё это уже кажется простым и понятным:

ОбъявлениеФункции ::= [СписокСпецификаторовОбъявления] Описатель [СпецификацияИсключения]; ОпределениеФункции ::= [СписокСпецификаторовОбъявления] Описатель [ctorИнициализатор] [СпецификацияИсключения] ТелоФункции СпецификацияИсключения ::= throw ([СписокТипов]) СписокТипов ::= [СписокТипов ,] ИмяТипа

Из последнего уточнения структуры объявления и определения функции следует, что объявление и определение любой функции может быть дополнено спецификацией исключения. Эта спецификация является дополнительным элементом заголовка функции и состоит из ключевого слова throw и заключённого в круглые скобки списка типов. При этом пустой список типов эквивалентен полному отсутствию спецификации исключения.

Назначение спецификации исключения мы обсудим позже, а пока - демонстрация особенностей работы механизма управления исключениями.


 

 

Работа системы управления исключением

Генерацию и перехват исключений не рекомендуется использовать в целях, отличных от обработки ошибок. Считается, что это может уменьшить ясность программы.

Считается также, что механизмы обработки исключением изначально создавались для обработки сравнительно редко проявляющихся ошибок и использовались чаще всего для завершения работы программы. В силу этого нет (пока нет) никакой гарантии относительно оптимальности, эффективности и надёжности этого механизма в качестве средства для обычного программного управления.

Вместе с тем, далеко не каждая исключительная ситуация должна вести к завершению программы. Например, при вычислении частного от деления двух случайных чисел, система управления исключением в случае возможного деления на нуль оказывается одним из основных средств управления программой.

Примерно такая же ситуация складывается и в нашем примере. Мы специально моделируем исключительные ситуации для оценки возможностей применения механизма перехвата.

Мы не будем всякий раз прерывать ход выполнения программы из-за того, что возникла какая-то странная ситуация. Если мы в силах восстановить нормальный ход выполнения программы - мы должны сделать это.

И если исключительная ситуация возникает в цикле - пусть её перехватчик остановит цикл. А вопросы эффективности и корректной работы со стеком - это вопросы к транслятору.

#include <iostream.h>
#include <string.h>
/*
"Рабочее тело" одного из исключений. На его основе создаётся
объект исключения.
*/
class MyException
{
public:
int CopyKey;
char *ExcMessage;
// Конструктор умолчания.
MyException(): ExcMessage("Стандартное сообщение от MyException...")
{
CopyKey = 0;
}
// Конструктор копирования.
MyException(const MyException& MyExcKey)
{
cout << "Работает конструктор копии..." << endl;
ExcMessage = strdup(MyExcKey.ExcMessage);
CopyKey = 1; // Признак копии для деструктора.
}
// Деструктор освобождает динамическую память.
~MyException()
{
if (CopyKey && ExcMessage) delete(ExcMessage);
}
};
int MyFun() throw (int, char *);
int Fun2() throw (int);
void main() throw (MyException)
{
int RetMainVal;
for (RetMainVal = 0; RetMainVal >= 0; )
{
try
{
RetMainVal = MyFun();
cout << "RetMainVal == " << RetMainVal << endl;
if (RetMainVal == 9) throw MyException(); /* Вызов конструктора для создания безымянного объекта -
представителя класса 
MyException в точке возбуждения исключения (с использованием выражения явного преобразования типа). 
После этого код, 
расположенный ниже точки генерации исключения уже не выполняется. */
cout << "Последний RetMainVal не был равен 9!"
<< " Иначе были бы мы здесь..." << endl;
}
// Место расположения перехватчиков исключений.
catch (int ExcVal)
{
cout << "(int) ExcVal == " << ExcVal << endl;
}
catch (char *ExcMessage)
{
cout << "(char *) ExcMessage " << ExcMessage << endl;
}
catch (MyException ExcObj)
/*
Безымянный объект, созданный в точке возбуждения исключения,
инициализирует параметр обработчика исключения. С этой целью нами
был определен специальный конструктор копирования.
*/
{
cout << ExcObj.ExcMessage
<< "... Такое вот сообщение пришло" << endl;
/*
После завершения выполнения блока обработки исключения, параметр
обработчика уничтожается. Для этого мы определили собственную версию
деструктора.
*/
}
cout << "За пределами tryБлока: RetMainVal == "
<< RetMainVal << endl;
// cout << ExcMessage << "!!!" << endl;
// Обработчик исключений определяет собственную область действия.
// ExcMessage оказывается за пределами области действия имени.
}
cout << "Это конец работы программы."
<< " И чтобы больше никаких перехватов..." << endl;
}
int MyFun() throw (int, char *)
{
int Answer, RetMyFunVal;
cout << "MyFun > ";
cin >> Answer;
cout << Answer << endl;
switch (Answer)
{
case 1:
throw 1;
cout << "Когда рак на горе свистнет, тогда это сообщение появится.";
break;
case 2:
throw "XXX";
case 3:
RetMyFunVal = Fun2();
cout << "Вернулись из Fun2(). RetMyFunVal = "
<< RetMyFunVal << endl;
break;
}
cout << "Привет из MyFun..." << endl;
return Answer;
}
int Fun2() throw (int)
{
int Answer;
cout << "Fun2 > ";
cin >> Answer;
cout << Answer << endl;
switch (Answer)
{
case 0:
throw 1;
/*
После возбуждения исключения, процесс нормального выполнения
программы прерывается. Мы уже не попадаем в точку возврата функции.
Используя стек, минуем функцию MyFun и оказываемся непосредственно
в catch-блоке функции main, связанном с исключением типа int.
*/
default:
Answer *= 2;
}
cout << "Конец работы в Fun2." << endl;
return Answer;
}

Перед нами программа-полигон для демонстрации взаимодействия генераторов исключений и перехватчиков. Функция main содержит контролируемый блок операторов. Наряду с другими операторами, он составляет тело оператора цикла for.

Функция возвращает значение определённого типа. Тип возвращаемого значения является важной характеристикой функции. Спецификация возвращаемого значения явным образом указывается при объявлении и определении функции. В различных ситуациях та же функция может возбуждать исключения совершенно разных типов и классов. Средством контроля над типами возбуждаемых исключений как раз является спецификация исключений. Этот необязательный элемент в заголовке обеспечивает дополнительный контроль над функцией со стороны транслятора. Хотя функция и может без предварительной спецификации возбуждать любые исключения, им не следует пренебрегать.

Транслятор следит за тем, чтобы не нарушались области действия имён объектов. Областью действия переменной, объявленной непосредственно в try-блоке, является данный try-блок. Соответственно, областью действия переменной, объявленной в одном из catch-блоков, этот самый catch-блок.

try-блок содержит критический код, выполнение которого может привести к возникновению исключительных ситуаций. Возникновение исключительных ситуаций находится под контролем и сопровождается генерацией соответствующего исключения. Одна из точек генерации располагается непосредственно в try-блоке. В данном случае исключительная ситуация возникает, если вызванная перед этим функция в качестве возвращаемого значения возвращает девятку.

Прочие точки генерации исключений, представляющие реакцию на гипотетические исключительные ситуации, располагаются в теле функций, вызываемых из try-блока.

Возникающие в этих функциях исключительные ситуации (по нашему сценарию это реакция на конкретные значения, вводимые в интерактивном режиме) сопровождаются генерацией различных исключений.

В принципе, try-блок может и не содержать участков критического кода и на контролируемом им участке программного кода может и не возникать никаких исключительных ситуаций. В этом случае выполнение этого кода ничем не будет отличаться от выполнения обычного (будто бы бывают обычные блоки) блока операторов. Впрочем, это не наш случай.

И вот, наконец, свершилось! В ходе выполнения контролируемого кода, непосредственно в try-блоке или в теле одной из вызываемых из этого блока функций возникает ситуация, которая может быть квалифицирована как исключительная. Реакцией на неё является возбуждение с помощью throw-оператора соответствующего исключения. С этого момента весь ход выполнения программы меняется.

Немедленно прекращается выполнение любых операторов, располагаемых следом за точкой генерации исключения.

Если точка генерации исключения оказалась в последнем операторе вызываемой функции, то отменяются все мероприятия по предполагаемому возвращению из вызываемой функции.

Тем более отменяется выполнение каких-либо операторов вызова. Точка генерации исключения в определённом смысле оказывается действительно точкой. В этой самой точке принципиально меняется весь дальнейший ход выполнения программы. Сразу после возбуждения исключения начинается поиск соответствующего блока перехвата исключения.

При этом область поиска ограничивается теми блоками операторов (естественно, в том числе и функциями), информация о которых была зафиксирована в стеке на момент возбуждения исключения. Это и понятно, поскольку перехват исключения производится в соответствии с принципом, согласно которому за последствия исключительной ситуации отвечает вызывающая функция. В ходе этого поиска производится действие, подобное "разматыванию" стека. И лишь возможные различия в деталях этих процессов, которые могут зависеть от конкретной реализации, служат аргументом в пользу того, чтобы не делать механизм перехвата исключения заурядным средством управления процессом выполнения.

Существуют чёткие критерии соответствия блока перехвата и возбуждённого исключения. Перечислим их:

  • блок перехвата исключения соответствует возбуждённому исключению, если в их объявлении и генерации использован один и тот же тип;
  • если возбуждаемое исключение может быть преобразовано к типу исключения, объявленного в блоке перехвата путём неявного преобразования типа, исключение считается соответствующим данному блоку перехвата;
  • если возбуждаемое исключение преобразуется к типу исключения, объявленного в блоке перехвата путём явного преобразования типа, оно считается соответствующим данному блоку перехвата;
  • исключение, которое является объектом-представителем производного класса, соответствует блоку перехвата, в котором объявлено исключение-представитель базового класса. Таким образом, исключение производного класса может быть перехвачено в блоке перехвата, в котором объявлено исключение-представитель базового класса. Это обстоятельство следует учитывать при расположении в программе блоков, определяющих списки реакций. В списке реакций контролируемого блока операторов перехватчики исключений, порождённых базовыми классами, должны располагаться в списке исключений ниже перехватчиков исключений, представляющих производные классы;
  • блок перехвата, содержащий вместо объявления исключения многоточие catch (...) {/*...*/}, соответствует любому исключению. Это своего рода универсальный блок перехвата. Он должен завершать список перехватчиков, поскольку ни один блок перехвата после него не сможет быть выполнен для обработки данного исключения, поскольку все возможные исключения будут перехвачены этим блоком.

Как известно, конструкторы и деструкторы не возвращают значений. Но в них могут быть размещены операторы генерации исключений. Если теперь программный код, обеспечивающий вызов конструкторов или деструкторов разместить в try-операторе, то можно будет организовать перехват исключения от конструкторов и деструкторов. Возбуждение исключения в конструкторе должно сопровождаться, если это необходимо, автоматическим вызовом деструкторов для уничтожения образующих этот объект составных элементов (если таковые существуют). Если исключительная ситуация возникла в ходе создания массива объектов, вызываемый в результате генерации исключения деструктор уничтожит лишь созданные на момент возникновения исключительной ситуации объекты.

Если соответствующий блок перехвата был обнаружен и содержит именованный параметр, временный объект, созданный throw операцией, его инициализирует. Здесь всё происходит примерно также, как и при вызове функции. Для инициализации параметра исключения, являющегося представителем какого-либо класса, может потребоваться собственная версия конструктора копирования и деструктора. Проинициализированный именованный параметр получает доступ к информации, заложенной в исключение в момент его генерации. И здесь уместна аналогия с вызовом функции. Существует проинициализированный и поименованный параметр - будет и доступ к передаваемой информации. В ряде случаев, как и при вызове функции, без конкретного значения параметра можно и обойтись - лишь бы вовремя активизировался соответствующий обработчик и принял бы соответствующие меры по ликвидации последствий исключительной ситуации. А меры в этой связи могут быть приняты самые разнообразные. Здесь всё определяется конкретной задачей.

Стартовав из try-блока, в результате возникновения исключительной ситуации, при благоприятном стечении обстоятельств, мы оказались в одном из связанных с ним блоков перехвата исключения. По сигналу тревоги, благодаря системе программирования C++, в нужное время мы прибыли в нужное место. Теперь всё зависит от программиста. Наши действия в catch-блоке практически ничем не ограничены. Выведем ли мы предупредительное сообщение на экран, исправим ли значение индекса массива, запросим ли новое значение для делителя - это транслятор не волнует. Формально мы совершили действие, в результате которого исключительная ситуация перехвачена, а её причина, возможно, что и ликвидирована. Что бы мы ни сделали catch-блоке (в конце концов, исправляя ошибку, мы можем сделать новую ошибку), будет воспринято без возражений.

Находясь в catch-блоке, мы можем вообще отказаться от каких-либо неотложных мероприятий. С помощью оператора throw; можно повторно возбудить последнее исключение. Этот оператор обязательно должен быть расположен в catch-блоке. В результате повторно запускается всё тот же механизм поиска нового подходящего catch-блока. Стек при этом продолжает разматываться, и если при этом в ходе выполнения программы имела место ситуация "вложенных" контролируемых блоков (из try-блока одной функции прямо или косвенно была вызвана функция, содержащая собственный контролируемый блок), то повторно возбуждённое исключение может быть перехвачено уровнем ниже. Таким образом, можно поручить перехват исключения функции, которая была вызвана ранее и, возможно, не несёт ответственности за возникшую исключительную ситуацию. Если соответствующего перехватчика исключения не окажется, выполнение программы будет остановлено.

Побывав в одном из блоков перехвата, и, возможно, выполнив какие-либо корректные действия, мы можем возобновить выполнение программы, начиная с первого оператора за пределами данного контролируемого блока операторов.

Может так случиться, что исключение окажется неперехваченным. Не во всех же программах прописывается универсальный блок перехвата… Безуспешный просмотр всех записей стека в поисках соответствующего перехватчика является признаком неперехваченного исключения. Оно оказывается за пределами контролируемого блока операторов, таким же независимым и свободным, как исключение, возбуждённое в "автономном" режиме. И последней преградой на пути неперехваченного исключения встаёт функция unexpected.

Эту функцию невозможно переопределить, а из-за жёстких ограничений на её список параметров (он непременно должен быть пустым), нельзя определить соответствующие совместно используемые функции. Функция unexpected - "вещь в себе", заглушка. Известно лишь, что она вызывает функцию terminate, но может вызвать и ещё какую-либо другую функцию. Изменить ситуацию на этом "последнем рубеже" можно лишь одним единственным способом - определив собственную функцию, которая должна заместить функцию unexpected в результате выполнения уже известной функции set_unexpected. Здесь ещё существует возможность исправить положение. Дальше такой возможности уже не будет.


 

На очереди ещё одна простая программа. Это уже не полигон. Это интерактивный вычислитель частного от деления двух плавающих чисел. Эта программа в бесконечном цикле запрашивает значения делимого и делителя, а в случае возникновения исключительной ситуации возбуждает исключение. В момент возбуждения исключения, пользователю предоставляется возможность принятия решения относительно дальнейшего продолжения вычислений. 

Представляющий исключение класс MyDivideByZeroError обладает всем необходимым набором элементов для эффективного взаимодействия с системой управления исключениями. Он располагает конструктором умолчания для возбуждения исключения. Там же имеется конструктор копирования для инициализации соответствующего параметра в блоке перехвата исключения. Есть и деструктор, который обеспечивает освобождение динамической памяти.

#include <iostream.h>
#include <string.h>
#define YESMESS "Мы продолжаем."
#define NOMESS "Мы завершаем."
class MyDivideByZeroError
{
char *MyErrorMessage;
public:
char ContinueKey;
MyDivideByZeroError(): MyErrorMessage(NULL)
{
char YesKey;
cout << "Зафиксировано деление на нуль." << endl;
cout << "Принимать экстренные меры? (Y/N) >> ";
cin >> YesKey;
if ( YesKey == 'Y' || YesKey == 'y' )
{
ContinueKey = 1;
MyErrorMessage = strdup(YESMESS);
}
else
{
ContinueKey = 0;
MyErrorMessage = strdup(NOMESS);
}
}
MyDivideByZeroError(const MyDivideByZeroError& CopyVal)
{
ContinueKey = CopyVal.ContinueKey;
MyErrorMessage = strdup(CopyVal.MyErrorMessage);
}
~MyDivideByZeroError()
{
if (MyErrorMessage) delete(MyErrorMessage);
}
void PrintMessage()
{
cout << MyErrorMessage << endl;
}
};
float Dividor(float, float) throw(MyDivideByZeroError);
void main()
{
float MyVal1, MyVal2;
for (;;)
{
//__ Начало контролируемого блока __________________________________.
try
{
cout << "========================================" << endl;
cout << "MyVal1 >> ";
cin >> MyVal1;
cout << "MyVal2 >> ";
cin >> MyVal2;
cout << "Считаем... " << Dividor(MyVal1, MyVal2) << endl;
cout << "Получилось! ";
}
catch (MyDivideByZeroError MyExcept)
{
MyExcept.PrintMessage();
if (MyExcept.ContinueKey == 0)
{
cout << "Надоело воевать с ошибками! Уходим." << endl;
break;
}
}
//__ За пределами контролируемого блока ____________________________.
cout << "Уже за пределами блока. Мы продолжаем..." << endl;
}
}
float Dividor(float Val1, float Val2) throw(MyDivideByZeroError)
{
if (Val2 == 0.0) throw MyDivideByZeroError();
return Val1/Val2;
}

И, наконец, пример замещения функций unexpected и terminate. Последняя программа в этой книге.

#include <iostream.h>
#include <except.h>
#define MAXERR 5
class MaxError;
class MyError
{
public:
MyError()
{
CounterError++;
if (CounterError > MAXERR)
{
cout << " Здесь MyError()... throw MaxError()!" << endl;
throw MaxError();
}
else
{
cout << " Здесь MyError()... CounterError++!" << endl;
}
}
void ErrSay()
{
cout << " Здесь ErrSay(): " << CounterError << endl;
}
static int CounterError;
};
int MyError::CounterError = 0;
class MaxError
{
public:
MaxError()
{
if (CounterMaxError == 0)
{
/*
MaxError один раз может подправить значение счётчика MyError::CounterError.
*/
CounterMaxError++;
MyError::CounterError -= 2;
cout << "Здесь MaxError().. MyError::CounterError-= 2;" << endl;
}
else
{
cout << " Здесь MaxError()... ###" << endl;
}
}
static int CounterMaxError;
};
int MaxError::CounterMaxError = 0;
void RunnerProcessor();
void Run() throw(MyError);
void MyUnex();
void MyTerm();
void main()
{
unexpected_function OldUnex;
terminate_function OldTerm;
OldUnex = set_unexpected(MyUnex);
OldTerm = set_terminate(MyTerm);
/*
Мы замещаем функции unexpected() и terminate().
Адресные переменные нужны для того, чтобы запомнить адреса старых
функций. В случае необходимости, их можно восстановить:
set_unexpected(OldUnex);
set_terminate(OldTerm);
*/
RunnerProcessor();
}
void RunnerProcessor()
{
for (;;)
{
try
{
Run();
}
catch (MyError err)
{
err.ErrSay();
}
}
}
void Run() throw(MyError)
{
cout << "Работает Run()..." << endl;
throw MyError();
}
void MyUnex()
{
/* Мы всё ещё находимся в пределах try-блока. */
cout << "Это MyUnex()..." << endl;
throw MyError();
}
void MyTerm()
{
int MyTermKey = 0;
/*
Вышли из try-блока. Включилась система автоматического торможения.
*/
for ( ; MyTermKey < 5; )
{
cout << "Это MyTerm()........................" << MyTermKey << endl;
MyError::CounterError = 0;
MaxError::CounterMaxError = 0;
RunnerProcessor();
MyTermKey += 1; /* Цикл здесь уже не циклится! */
}
MaxError::CounterMaxError = 0;
throw MyError(); /* Исключения не работают! */
}

Всё. Приехали. Можно расслабиться. Можно постоять на берегу океана. Послушать шум ветра в соснах. Посмотреть на касаток в холодной прозрачной воде. Только недолго. Впереди ждут великие дела.

 

Список литературы

  1. М. Эллис, Б. Строуструп. Справочное руководство по языку C++ с комментариями: Пер. с англ. - Москва: Мир, 1992. 445с.
  2. Стенли Б. Липпман. C++ для начинающих: Пер. с англ. 2тт. - Москва: Унитех; Рязань: Гэлион, 1992, 304-345сс.
  3. Бруно Бабэ. Просто и ясно о Borland C++: Пер. с англ. - Москва: БИНОМ, 1994. 400с.
  4. В.В. Подбельский. Язык C++: Учебное пособие. - Москва: Финансы и статистика, 1995. 560с.
  5. Ирэ Пол. Объектно-ориентированное программирование с использованием C++: Пер. с англ. - Киев: НИИПФ ДиаСофт Лтд, 1995. 480с.
  6. Т. Фейсон. Объектно-ориентированное программирование на Borland C++ 4.5: Пер. с англ. - Киев: Диалектика, 1996. 544с.
  7. Т. Сван. Освоение Borland C++ 4.5: Пер. с англ. - Киев: Диалектика, 1996. 544с.
  8. Г. Шилдт. Самоучитель C++: Пер. с англ. - Санкт-Петербург: BHV-Санкт-Петербург, 1998. 620с.
  9. У. Сэвитч. C++ в примерах: Пер. с англ. - Москва: ЭКОМ, 1997. 736с.
  10. К. Джамса. Учимся программировать на языке C++: Пер. с англ. - Москва: Мир, 1997. 320с.
  11. В.А. Скляров. Язык C++ и объектно-ориентированное программирование: Справочное издание. - Минск: Вышэйшая школа, 1997. 480с.
  12. Х. Дейтел, П. Дейтел. Как программировать на C++: Пер. с англ. - Москва: ЗАО "Издательство БИНОМ", 1998. 1024с.