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

Автор: manager Понедельник, Март 24th, 2008 Нет комментариев

Рубрика: C++. Бархатный путь

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

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

Мы можем модифицировать известный нам класс комплексных чисел, определив новую функцию-член 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
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
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 указателя для работы с классом, содержащим её объявление в качестве дружественной функции. Дружба — это всего лишь дополнение принципа инкапсуляции и ничего более.
Дружественные отношения не наследуются. Дружественные функции не имеют доступа к членам производного класса, чьи базовые классы содержали объявления этих функций. Дети не отвечают за отношения своих родителей.
Шаблоны функций и шаблонные функции

Рассмотрим простую функцию, реализующую алгоритм сравнения двух величин:
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 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
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
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
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
template
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
int neq (Type a, Type b)
{
return a != b ? 1 : 0;
// return a != b; /* На самом деле, можно и так┘ */
}

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

#include
#include
/*
В программе используется объект класса Type_info, позволяющий
получать информацию о типе. Здесь подключается заголовочный файл,
содержащий объявление этого класса
*/
template 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 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;
}

Оставить комментарий

Чтобы оставлять комментарии Вы должны быть авторизованы.

Похожие посты