Книга: С++ — это просто. — 3-е изд.: Пер. с англ.
Назад: Const-указатели
Дальше: Для чего применять виртуальные функции?

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

Во-первых, они не случайно имеют в точности то же имя, что и класс, членами

которого они являются. На деле существует правило, по которому класс и его

конструктор обязаны иметь одинаковые имена. Благодаря этому компилятор

узнает, что функция-член является конструктором.

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

объекта и, следовательно, нет смысла возвращать значение.

В нашей программе инструкция:

Integer i1 ( 100 ), i2, i3 ;

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

переменных i.

Обратите внимание на то, что класс Integer имеет два конструктора с одинаковыми

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

конструкторов вызывается при его создании:

integer i2 ; // вызывает конструктор без аргументов

integer i1 ( 100 ) ; // вызывает конструктор с одним аргументом

Класс может содержать несколько перегруженных конструкторов в зависимости от

того, как мы хотим инициализировать его объекты.

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

94

Глава 4

лятор автоматически создает конструктор без аргументов. Конструктор без аргументов часто называют неявным конструктором (implicit constructor) или конструктором по умолчанию (default constructor).

На заметку: если мы задаем конструктор с одним аргументом, мы уже не можем

рассчитывать на то, что компилятор предоставит конструктор по умолчанию. При

необходимости мы должны определить его сами.

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

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

можем сделать это через вызов функции setData( ).

Деструкторы

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

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

же имя, что и у конструктора (и они оба совпадают с именем класса), но оно пред-варяется символом «тильда» (⁓). Следующий листинг демонстрирует работу деструктора:

#include <iostream>

using namespace std ;

class example

{

private :

int data ;

public :

example( ) // конструктор (то же имя, что у класса)

{

cout << endl << "Внутри конструктора" ;

}

~example( ) // деструктор (то же имя с тильдой)

{

cout << endl << "Внутри деструктора" << endl ;

}

} ;

int main( )

{

example e ;

return 0 ;

}

При создании объекта e вызывается конструктор. Когда управление выходит за

пределы функции main( ), объект e разрушается. Это вызывает функцию деструктора. Как и конструкторы, деструкторы не возвращают значения. Они также не

принимают аргументов (предполагается, что есть лишь один способ разрушить

объект).

Классы и объекты

95

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

объект конструктором. Мы подробнее рассмотрим этот вопрос дальше.

Класс Complex

Как известно, комплексное число состоит из вещественной части и мнимой части.

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

#include <iostream>

using namespace std ;

class Complex

{

private :

float real, imag ;

public :

Complex( )

{

}

Complex ( float r, float i )

{

real = r ;imag = i ;

}

void displayData( )

{

cout << "вещественная часть = " << real << " мнимая часть = " << imag << endl ;

}

void addComplex ( Complex x, Complex y )

{

real = x.real + y.real ;

imag = x.imag + y.imag ;

}

Complex addComplex ( Complex y )

{

Complex t ;

t.real = real + y.real ;t.imag = imag + y.imag ;

return ( t ) ;

}

Complex operator + ( Complex y )

{

Complex t ;

t.real = real + y.real ;t.imag = imag + y.imag ;

return ( t ) ;

}

} ;

96

Глава 4

int main( )

{

Complex c1 ( 1.1f, 2.2f ), c2 ( 3.5f, 4.4f ), c3, c4, c5 ;

c3.addComplex ( c1, c2 ) ;

c3.displayData( ) ;

c4 = c1.addComplex ( c2 ) ;

c4.displayData( ) ;

c5 = c1 + c2 ;

c5.displayData( ) ;

return 0 ;

}

В этой программе мы задействовали параметризованные конструкторы с двумя

аргументами для инициализации объектов c1 и c2. Далее мы применили три разных

способа сложения двух комплексных чисел. Они показаны ниже.

c3.addComplex ( c1, c2 ) ;

c4 = c1.addComplex ( c2 ) ;

c5 = c1 + c2 ;

Как видите, addComplex( ) — это перегруженная функция. В ее первой версии, где

складываются два комплексных числа, ей передаются c1 и c2. Они присваиваются x и y, корректно складываются и сохраняются в c3. Почему результат сложения

сохраняется в c3? Потому что мы вызвали компонентную функцию для c3.

Второй вызов функции addComplex( ) производится для c1. В этом вызове функции передается c2. Следовательно, при выполнении сложения real относится к real из c1, а y.real относится к real из c2. Результат сложения сохраняется в объекте t типа Complex. В конце сложения t возвращается и присваивается c4 в main( ).

Рассмотрим более подробно вызов перегруженного оператора +:

c5 = c1 + c2 ; // вызов

Компилятор преобразует эту команду в:

c5 = c1.operator + ( c2 ) ;

Таким образом, вызов функции operator +( ) производится для c1. В этом вызове

функции передается c2. Следовательно, при выполнении сложения real относится

к real из c1, а y.real относится к real из c2. Результат сложения сохраняется в объекте t типа Complex. В конце сложения t возвращается и присваивается c5 в теле

функции main( ).

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

вы получили корректный результат для операции:

c6 = c1 + c2 * c3 ;

Классы и объекты

97

Указатель this

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

class Sample

{

private :

int i ;

float j ;

public :

void setData ( int x, float y )

{

i = x ;

j = y ;

}

} ;

int main( )

{

Sample s1, s2 ;

s1.setData ( 10, 3.14f ) ;

s2.setData ( 20, 6.28f ) ;

}

Здесь два объекта s1 и s2 типа Sample имеют по своему набору элементов данных i и j.

Когда мы используем i и j в методе setData( ), какому объекту они принадлежат, s1

или s2? Для простоты понимания мы говорим, что когда мы производим вызов для

объекта s1, i и j являются членами данных объекта s1, а когда мы производим вызов для объекта s2, i и j являются членами данных объекта s2. Реальный же механизм, который здесь задействован, — это специальный указатель, именуемый this-указателем. Попытаемся понять, как работает this-указатель.

Когда мы совершаем вызов:

s1.setData ( 10, 3.14f ) ;

вместе с 10 и 3.14f в setData( ) также передается и адрес s1. Функция setData( ) хранит этот адрес в указателе с именем this. Тип указателя this — Sample *. Когда

мы обращаемся к i и j в функции setData( ), они задействуются в форме: this->i = x ;

this->j = y ;

98

Глава 4

Поскольку this содержит в этом вызове адрес объекта s1, i и j принадлежат объекту s1. Когда же мы совершаем вызов посредством объекта s2, this-указатель содержит адрес объекта s2, а i и j относятся к i и j объекта s2.

Легко убедиться в том, что this-указатель содержит адреса объектов s1 и s2 в первом и втором вызове соответственно, добавив следующую команду в setData( ): cout << "Текущий адрес указателя this = " << this << endl ; Заметьте, что тип this-указателя — Sample * const. По этой причине он не может

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

Указатель this работает для каждого вызова, сделанного с использованием объекта.

Перегрузка унарных операторов

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

Примерами унарных операторов являются операторы инкремента и декремента ++

и --, а также унарный минус, как в -45, например.

Давайте реализуем перегруженный унарный оператор «++» для класса с именем

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

его префиксную и постфиксную формы, приведенные ниже:

d = ++c ; // префиксная версия оператора «++»

f = e++ ; // постфиксная версия оператора «++»

В первом случае оператор «++» сначала инкрементирует объект c, а затем его значение присваивается объекту d. И напротив, во втором случае сначала значение

объекта e присваивается f, а уже затем происходит инкрементирование e. Чтобы

различать две версии перегруженной операторной функции ++, в постфиксной

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

Ниже приведена реализация класса Index, содержащего обе версии перегруженной

функции оператора «++»:

#include <iostream>

using namespace std ;

class Index

{

private :

int count ;

public :

Index( )

Классы и объекты

99

{

count = 0 ;

}

Index ( int i )

{

count = i ;

}

Index operator ++ ( )

{

++count ;return *this ;

}

Index operator ++ ( int )

{

Index temp ( count ) ; // вызывает конструктор count++ с одним аргументом ; return temp;

}

void showdata( )

{

cout << count << endl ;

}

} ;

int main( )

{

Index c, d, e, f ;

cout << "c = " ;

c.showdata( ) ;

d = ++c ;

cout << "c = " ;

c.showdata( ) ;

cout << "d = " ;

d.showdata( ) ;

cout << "e = " ;

e.showdata( ) ;

f = e++ ;

cout << "e = " ;

e.showdata( ) ;

cout << "f = " ;

f.showdata( ) ;

return 0 ;

}

А вот результат работы программы:

c = 0

c = 1

d = 1

e = 0

100

Глава 4

e = 1

f = 0

Обратите внимание на следующие моменты:

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

d = c.operator ++ ( ) ; // вызывает префиксную версию

f = e.operator ++ ( 0 ) ; // вызывает постфиксную версию

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

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

вызов.

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

присвоен объектам d или f.

При вызове префиксной формы инкремента this-указатель содержит адрес

объекта c. Мы инкрементируем счетчик (count) c и возвращаем его, используя

выражение *this. Мы могли бы сделать то же самое другим способом:

Index temp ;

++count ;

temp.count = count ;

return temp ;

Однако создание нового объекта temp потребляет лишние ресурсы. Так что

лучше им не пользоваться.

Объекты и память

Если объект подобен переменной, можем ли мы узнать количество байтов, зани-маемых им в памяти? Да, можем, при помощи оператора sizeof, который применяется для встроенных типов данных. Испытаем его на деле:

#include <iostream>

using namespace std ;

class Sample

{

private :

int i ;

float a ;

char ch ;

public :

Sample ( int j, float b, char dh )

{

i = j ;

a = b ;

ch = dh ;

}

} ;

Классы и объекты

101

int main( )

{

Sample s1 ( 10, 3.14f, 'A' ) ;

Sample s2 ( 20, 6.28f, 'B' ) ;

cout << sizeof ( s1 ) << endl ;

cout << sizeof ( s2 ) << endl ;

return 0 ;

}

Программа сообщает размер объектов s1 и s2, равный 12 байтам, что является сум-мой размеров i, a и ch. Это означает, что размер любого объекта представляет

собой сумму размеров его членов данных. Отсюда другой важный вопрос: влияют

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

класса используют одни и те же функции-члены.

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

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

объекта должен быть свой собственный набор членов данных.

В нашей программе два объекта типа Sample, поэтому в памяти хранится по два

экземпляра полей i, a и ch. Тем не менее имеется лишь одна копия конструктора и

функции display( ). Эти функции общие для всех объектов класса, что не приводит

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

лишь одна функция.

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

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

Еще раз о структурах и классах

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

class Sample

struct Sample

{

{

private :

private :

int data ;

int data ;

public :

public :

void fun( )

void fun( )

{

{

// код

// код

}

}

} ;

} ;

102

Глава 4

Единственная формальная разница между классом и структурой заключается в том, что члены класса по умолчанию закрыты (private), а в структуре они по умолчанию открыты (public). Таким образом, можно смело удалить ключевое слово

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

И хотя по умолчанию все поля структуры открыты, мы не можем отказаться от

ключевого слова public. При этом определение private распространится даже на

функцию fun( ). Если мы по-прежнему хотим избавиться от ключевого слова

public, нам придется определить функцию перед членами данных, как показано

ниже:

struct Sample

{

void fun( )

{

// код

}

private :

int data ;

} ;

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

Идеальная организация программы

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

организации программы по следующим причинам.

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

придется скопировать его из одного файла и вставить в файл новый программы.

Это трудоемкая задача, если речь идет о множестве классов.

Если сторонний программист захочет использовать наш класс, нам также придется

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

тайны.

Чтобы избежать подобных затруднений в будущем, нам следует разбить программу

на 3 файла:

файл .h, в котором мы просто объявляем класс;

файл .cpp, в котором мы определяем функции-члены класса;

файл .cpp, использующий первые два файла класса (его часто называют клиент-ским кодом).

Объявление класса в файле .h содержит поля данных и объявление прототипа

функций-членов. Основа класса, т. е. определение функций-членов, хранится в от-

Классы и объекты

103

дельном файле .cpp. Такой подход позволяет отделить интерфейс класса от его

реализации, что считается хорошей (и безопасной) практикой разработки программного обеспечения.

Более того, программистам, желающим использовать наш класс, мы можем предоставить только скомпилированный код реализации класса и файл .h.

Поскольку мы не предоставляем файл .cpp, секрет реализации остается у нас. Мы

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

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

кода, т. к. позволяет легко скопировать файлы классов в другой проект.

Ниже приведен листинг программы, код которой разбит на 3 файла в соответствии

с вышеозначенными принципами:

// Employee.h - Объявление класса

// Этот файл содержит объявление класса Employee, не раскрывая

// реализацию его функций-членов

#include <string>

using namespace std ;

class Employee

{

private :

string name ;

int age ;

public :

Employee( ) ;

Employee ( string n, int a ) ;

void getData( ) ;

void showData( ) ;

} ;

// Employee.cpp – Реализация класса

// Этот файл содержит реализацию функций-членов класса Employee

#include <iostream>

#include "Employee.h"

using namespace std ;

Employee::Employee( )

{

name = "" ;

104

Глава 4

age = 0 ;

}

Employee::Employee ( string n, int a )

{

name = n ;

age = a ;

}

void Employee::getData( )

{

cout << endl << "Введите имя и возраст" << endl ;cin >> name >> age ;

}

void Employee::showData( )

{

cout << "Имя = " << name << endl << "Возраст = " << age << endl ;

}

// Main.cpp – Клиентский код

// Этот файл содержит код для использования класса Employee,

// интерфейс которого отделен от его реализации

#include <iostream>

#include "Employee.h"

using namespace std ;

int main( )

{

Employee e1 ( "Sanjay", 34 ) ;

e1.showData( ) ;

Employee e2 ;

e2.getData( ) ;

e2.showData( ) ;

return 0 ;

}

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

Для хранения имени сотрудника вместо массива символов мы использовали

класс стандартной библиотеки string. Преимущество класса string по сравнению

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

Класс string определен в заголовочном файле ′string′. Для применения класса

нужно подключить этот файл.

В заголовочном файле ′Employee.h′ мы объявили using namespace std, поэтому

нам не требуется указывать std::string там, где должен использоваться класс

string.

Классы и объекты

105

Файл ′Employee.cpp′ содержит определения функций-членов класса Employee.

Чтобы сделать класс Employee доступным в этом файле, нам нужно подключить

′Employee.h′.

Мы подключили ′Employee.h′, применив кавычки вместо обычных угловых

скобок. Это связано с тем, что ′Employee.h′ будет находиться в папке нашего

проекта, а не в стандартной папке для подключаемых файлов.

Каждому определению функции-члена предшествует имя класса Employee и

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

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

В функции getData( ) мы получаем имя сотрудника при помощи cin >> name.

Этого достаточно, если имя состоит из одного слова. Если же оно состоит из нескольких слов, можно использовать инструкцию:

getLine ( cin, name ) ;

К Main.cpp необходимо подключить ′Employee.h′. Без этого мы не сможем использовать класс Employee в функции main( ).

И наконец, как нам организовать код в трех файлах? Если вы работаете в Visual Studio, выполните следующие шаги:

Создайте проект под названием ′Client′. Введите исходный код из листинга

′Client.cpp′.

Щелкните правой кнопкой мыши по проекту ′Client′ в «Обозревателе решений»

(Solution Explorer). Выберите в меню «Добавить | Класс...» (Add | Class...).

Выберите «Класс С++...» (С++ class...). Затем нажмите кнопку «Добавить»

(Add). В появившемся диалоговом окне введите имя класса ′Employee′, затем

нажмите на кнопку «Завершить» (Finish). Будут созданы два файла

′Employee.h′ и ′Employee.cpp′.

Рис. 4.2. Процесс компиляции и сборки

106

Глава 4

Введите код объявления класса в ′Employee.h′, а код реализации класса —

в ′Employee.cpp′.

Сформируйте и запустите программу, нажав Ctrl+F5.

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

На рис. 4.2 показано, как осуществляется компиляция и сборка.

1. Укажите, верны ли следующие утверждения:

По умолчанию члены структуры являются открытыми (public), а члены класса — закрытыми (private).

В классе члены данных всегда закрыты (private), тогда как функции-члены всегда открыты (public).

Конструктор класса всегда должен быть открытым (public).

Если класс содержит конструктор с 3 аргументами, то необходимо явно задать

конструктор без аргумента, конструктор с одним и двумя аргументами.

Функции-члены класса должны вызываться явно, тогда как конструктор вызывается автоматически.

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

Конструкторы могут быть перегруженными.

Конструктор не возвращает значение.

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

Если оператор ++ перегружен, то выражения j++ и ++j вызовут одну и ту же

перегруженную функцию.

Когда объект выходит из области видимости, автоматически вызывается его

деструктор.

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

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

Указатель this можно использовать даже за пределами класса.

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

их внутри класса.

2. Укажите ошибки, если таковые имеются, в следующих фрагментах программного кода:

(A)

#include <string>

using namespace std ;

Классы и объекты

107

class Address

{

private :

string name, city ;

public :

Address ( string p, string q )

{

name = p ;

city = q ;

}

}

int main( )

{

Address my ( "Мак", "Лондон" ) ;

return 0 ;

}

(B)

class Date

{

private :

int day, month, year ;

Date( )

{

day = 7 ;month = 9 ;year = 2019 ;

}

} ;

int main( )

{

Date today ;

return 0 ;

}

(C)

class Value

{

private :

int i ;

float f ;

public :

Value( )

{

i = 0 ;

f = 0.0 ;

return 1 ;

}

} ;

108

Глава 4

int main( )

{

Value v1 ;

return 0 ;

}

(D)

#include <iostream>

using namespace std ;

class Triplets

{

private :

int t1, t2, t3 ;

public :

Triplets ( int x, int y, int z )

{

t1 = x ;

t2 = y ;

t3 = z ;

}

void display( )

{

cout << endl << t1 << t2 << t3 ;

}

} ;

int main( )

{

Triplets r ( 2, 3, 4 ), s ;

r.display( ) ;

s.display( ) ;

return 0 ;

}

(E)

#include <iostream>

using namespace std ;

class Sample

{

private :

int data1 ;

float data2 ;

public :

void Sample( ) ;

void showData( ) ;

} ;

Классы и объекты

109

Sample::void Sample( )

{

data1 = 10 ;

data2 = 20 ;

}

Sample::void showData( )

{

cout << endl << data1 << data2 ;

}

int main( )

{

Sample s ;

s.showData( ) ;

return 0 ;

}

(F)

#include <iostream>

using namespace std ;

class List

{

private :

class Node

{

int data ;

Node *link ;

} *p ;

public :

void create( )

{

p = new Node ;

p.data = 10 ;

p.link = NULL ;

}

} ;

int main( )

{

List l1 ;

l1.create( ) ;

return 0 ;

}

3. Каков результат выполнения следующих программ?

(A)

#include <iostream>

using namespace std ;

110

Глава 4

class User

{

private :

int i ;

float f ;

char c ;

public :

void displayData( )

{

cout << i << '\n' << f << "\n" << c << endl ;

}

} ;

int main( )

{

cout << sizeof ( User ) << endl ;

User u1 ;

cout << sizeof ( u1 ) << endl ;

u1.displayData( ) ;

return 0 ;

}

(B)

#include <iostream>

using namespace std ;

class Date

{

private :

int dd, mm, yy ;

public :

Date( )

{

cout << "Сегодня у нас" << endl ;

}

} ;

int main( )

{

Date today ;

Date *p = &today ;

cout << p << endl ;

return 0 ;

}

(C)

#include <iostream>

using namespace std ;

Классы и объекты

111

class Student

{

private :

int m1, m2, m3 ;

float per ;

public :

Student( )

{

m1 = m2 = m3 = 0 ;

per = 0.0 ;

}

void calcPer ( int x, int y , int z )

{

m1 = x ;

m2 = y ;

m3 = z ;

per = ( m1 + m2 + m3 ) / 3.0 ;

displayPer( ) ;

}

void displayPer( )

{

cout << "Проценты = " << per << "%" << endl ;

}

} ;

int main( )

{

Student s1 ;

s1.displayPer( ) ;

s1.calcPer ( 35, 35, 35 ) ;

s1.displayPer( ) ;

return 0 ;

}

(D)

#include <iostream>

using namespace std ;

class Control

{

public :

Control( )

{

calculate( ) ;

cout << "Конструктор" << endl ;

}

112

Глава 4

void calculate( )

{

display( ) ;

cout << "Калькулятор" << endl ;

}

void display( )

{

cout << "отображен" << endl ;

}

} ;

int main( )

{

Control c1 ;

return 0 ;

}

4. Решите следующие задачи и ответьте на вопросы:

Измените класс Rectangle, обсуждаемый в этой главе, таким образом, чтобы

операция

Rectangle r1 = 3 ;

присваивала значение 3 переменным len и br.

Если в программе используются четыре объекта, то сколько указателей this будет существовать для этих объектов и почему?

Определите класс Matrix, содержащий матрицу 3 x 3. Определите в этом классе

перегруженные операторы для выполнения сложения, умножения и сравнения

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

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

в теле функции main( ) и вызовите операторную функцию ++, чтобы увеличить

время на 1 минуту.

Процедурное программирование строится на взаимодействии функций.

Классы — это данные пользовательского типа.

Классы описывают, как будут выглядеть объекты, созданные на их основе.

Объекты содержат определенный набор данных. Каждый объект является от-дельным экземпляром своего класса.

Значения данных в объектах часто называют данными экземпляра или состояни-ем объекта.

Классы и объекты

113

Открытые члены класса доступны за пределами класса.

Закрытые члены класса НЕ доступны за пределами класса.

Внутри класса любой его член может получить доступ к любому другому члену

этого класса.

По умолчанию все члены класса являются закрытыми, а все члены структуры

открытыми.

Как правило, данные класса являются закрытыми, а доступ и управление ими

осуществляются через открытые функции-члены этого класса.

Существуют два способа инициализации объекта:

• Способ 1: при помощи обычных функций-членов, таких как getData( ) и

setData( ).

Преимущество 1 — данные защищены от преобразований.

Преимущество 2 — улучшенная валидация (проверка данных), т. к. она

производится в одной части кода.

Преимущество 3 — валидация осуществляется разработчиком класса.

• Способ 2: при помощи специальной функции-члена — конструктора.

Преимущество 1 — улучшенная организация программного кода.

Преимущество 2 — гарантированная инициализация при помощи конст-

руктора.

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

Имя конструктора должно совпадать с именем класса.

Конструктор — это функция.

Конструктор не возвращает значения.

Конструктор вызывается автоматически при создании объекта.

Конструктор вызывается лишь один раз за все время существования объекта.

Конструктор может быть перегруженным.

Если не задать определение конструктора, компилятор вставит непараметризированный конструктор (без аргументов).

У класса может быть и конструктор, и функция setData( ).

Конструктор инициализирует объект.

Функция setData( ) изменяет объект.

Метод деструктора вызывается при разрушении объекта.

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

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

114

Глава 4

При возвращении управления из функции указатель this разрушается.

Формально у каждого объекта имеются данные экземпляра и функции-члены.

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

функции-члены используются совместно всеми объектами класса.

Совместное использование функций оправдано, т. к. функции-члены одинаковы

для любого объекта класса.

Хорошей практикой разработки программного обеспечения является размещение объявления класса в заголовочном файле .h, а его реализации — в файле

.cpp.

Премудрости классов

Недостаточно знать, что такое классы и как создавать

из них объекты. Не освоив всех тонкостей, вы не сможете

в полной мере воспользоваться их преимуществами

116

Глава 5

Статическое и динамическое выделение памяти

• Выделение памяти для массивов и структур

• Выделение памяти для объектов

Статические члены класса

Универсальный спецификатор const

Перегруженный оператор присваивания

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

Преобразование данных:

• Преобразование между встроенными типами

• Преобразование между встроенными

и пользовательскими типами

• Преобразование между различными

пользовательскими типами

• Процедура преобразования в исходном объекте

• Процедура преобразования в целевом объекте

Упражнения

Важное

Премудрости классов

117

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

известно, что класс — это пользовательский тип данных, а объект — его

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

классы и объекты. И начнем мы с распределения памяти.

Статическое и динамическое

выделение памяти

Как известно, при создании переменных/объектов под них выделяется память. C++

позволяет осуществить это двумя способами:

Статическое выделение памяти

Динамическое выделение памяти

В первом случае пространство выделяется для переменных/объектов в области

памяти, известной как стек. Поскольку решение о том, сколько потребуется места

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

Во втором случае решение об объеме выделяемого пространства и само фактиче-ское выделение осуществляются на этапе выполнения. Распределение происходит

в области памяти, называемой динамической памятью (heap). Программисты C++

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

(free store). Объекты, создаваемые в динамической памяти, безымянны, и потому

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

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

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

delete.

Ниже приведен ряд примеров статического и динамического размещения встроенных типов данных:

// статическое выделение памяти

int i ;

float a ;

char ch ;

118

Глава 5

// динамическое выделение памяти

int *ptrInt = new int ;

float *ptrFloat = new float ;

char *ptrChar = new char ;

// применение выделенных объектов

*ptrInt = 35 ;

*ptrFloat = 3.14 ;

*ptrChar = ‘A’ ;

// удаление выделенных объектов

delete ptrInt ;

delete ptrFloat ;

delete ptrChar ;

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

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

Оператор delete производит действие, обратное оператору new. Он возвращает

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

Динамическое распределение памяти обеспечивает повышенную гибкость — мы

можем очищать динамически созданные объекты, когда захотим, используя оператор delete. Однако и тут есть свои нюансы. Для каждого объекта, созданного в динамической памяти, должен быть создан связанный с ним указатель в стеке. Так, для одного целого числа (int) нам потребуется 4 байта в динамической памяти и

4-байтовый указатель в стеке. В то же время статически созданное целое число занимает лишь 4 байта в стеке.

Выделение памяти для массивов и структур

Из-за упомянутых выше недостатков этого метода динамическое выделение памяти

не следует применять для создания небольших объектов. Оно уместно при работе

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

для массивов и структур:

// объявление структуры

struct Employee

{

string name ;

int age ;

float salary ;

} ;

// статическое выделение памяти

int num[ 25 ] ;

struct Employee e ;

Премудрости классов

119

// динамическое выделение памяти

int *ptrInt = new int[ 25 ] ;

struct Employee *ptr = new struct Employee ;

// применение выделенных объектов

ptrInt [ 0 ] = 35 ;

ptrInt[ 20 ] = 40 ;

ptr->name = "Sanjay" ;

ptr->age = 23 ;

ptr->salary = 4500.00f ;

// удаление выделенных объектов

delete [ ] ptrInt ;

delete ptr ;

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

адрес массива, который сразу присваивается ptrInt. Также заметьте, как динамически выделяемый массив удаляется из памяти при помощи оператора delete. Как

пример

delete [ ] ptrInt ;

уточняет, что мы удаляем не один элемент thing, а массив элементов thing (в данном случае под thing подразумевается целое число), на который ссылается указатель ptrInt. Подойдет ли в таком случае простая форма записи?

delete ptrInt ;

Нет. Компилятор может и пропустить эту ошибку, однако корректность работы

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

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

delete [ ], когда мы выделяем память, используя ключевое слово new [ ].

В статически выделенных массивах мы должны определиться с размером массива

на момент написания программы. Это значит, что мы не можем задать массив

таким образом:

int n ;

cin >> n ;

int arr[ n ] ;

Связано это с тем, что компилятор уже во время компиляции должен знать, сколько

байтов необходимо выделить для этого массива. Во время компиляции n не имеет

никакого значения; возможность указать значение для n появляется только во время выполнения программы.

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

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

120

Глава 5

int n ;

cin >> n ;

int *p = new int[ n ] ;

Этот массив инициализируется на основе значения n, указанного пользователем во

время выполнения программы.

Выделение памяти для объектов

При динамическом размещении объектов возникает один дополнительный аспект

по сравнению с динамическим размещением встроенных типов и массивов. При

создании объектов в динамической памяти при помощи оператора new им выполняется три действия:

Выделяет для объекта память требуемого размера.

Запускает функцию-конструктор объекта.

Возвращает указатель соответствующего типа.

Выполнение конструктора гарантирует корректную инициализацию объекта перед

его использованием. Подтвердим это на примере программы:

#include <iostream>

using namespace std ;

class Sample

{

private :

int i ;

float a ;

public :

Sample( )

{

i = 10 ;

a = 3.14f ;

}

void showData( )

{

cout << i << endl << a << endl ;

}

~Sample( )

{

cout << "Достигнут деструктор" << endl ;

}

} ;

int main( )

{

Sample *ptr ;

ptr = new Sample( ) ;

ptr->showData( ) ;

delete ptr ;

}

Премудрости классов

121

В результате программы получим:

10

3.14

Достигнут деструктор

Как явствует из результата, конструктор вызывается, когда мы создаем объект динамически, используя оператор new, а деструктор вызывается, когда мы удаляем

этот объект, используя delete.

Прежде чем перейти к рассмотрению нового материала, рассмотрим несколько моментов, связанных с применением операторов new и delete, которые позволяют

лучше понять их работу:

Объект, созданный при помощи new, существует до тех пор, пока не будет явно

разрушен при помощи delete.

delete может применяться только к указателю, возвращаемому оператором new, или к нулю. Применение delete к нулю не совершает ровно никакого действия.

Иными словами, передача delete нулевого указателя (NULL pointer) безопасна и

гарантированно не имеет никакого эффекта. Это упрощает код, используемый

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

Команда delete p не удаляет указатель. Вместо этого она удаляет объект, на который указывает p. Так что буквально имя ключевого слова должно было бы

звучать 'deletethethingpointedtoby' («удалить то, на что здесь указано»), а не

просто delete. Есть среди вас сторонники такого оператора?

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

как область, на которую указывает p, возвращается в свободное хранилище во

второй раз. Это может нарушить работу динамической памяти.

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

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

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

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

Это ошибка, при которой память останется выделенной, но доступа к ней не

будет.

122

Глава 5

Статические члены класса

Мы знаем, что каждый новый объект класса (их ещё называют инстантами, от

англ. instance) имеет собственный набор членов данных, тогда как функции-члены

являются общими для всех них. Однако, если мы хотим, чтобы какой-либо член

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

Нам всего лишь необходимо объявить такой член данных статическим (static).

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

Предположим, у нас есть класс Circle, и мы хотим отслеживать, сколько инстантов

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

переменную-счетчик (count). При создании каждого нового объекта можно инкре-ментировать значение счетчика count в конструкторе. Таким образом, его послед-нее значение всегда сообщит нам, сколько объектов класса Circle было создано на

настоящий момент.

Рассмотрим на примере:

#include <iostream>

using namespace std ;

class Circle

{

private :

char color ;

float radius ;

static int count ;

public:

Circle ( char c, float r )

{

color = c ;

radius = r ;

count++ ;

}

static void showCount( )

{

cout << "счетчик = " << count << endl ;

}

} ;

int Circle::count = 0 ; // определение счетчика

int main( )

{

Circle c1 ( 'R', 1.2f ) ;

Circle::showCount( ) ;

Circle c2 ( 'G', 2.2f ) ;

Премудрости классов

123

Circle::showCount( ) ;

Circle c3 ( 'B', 3.2f ) ;

Circle::showCount( ) ;

return 0 ;

}

Класс Circle содержит три члена данных — color (цвет), radius (радиус) и count (счетчик). Из них первые два являются членами данных конкретного инстанта, тогда как третий является статическим (static) членом данных, общим для всех объектов класса. В теле основной функции main( ) мы определили три объекта класса

Circle. Каждый раз при создании объекта мы устанавливаем цвет (color) и радиус

(radius) объекта Circle и приращиваем значение счетчика count.

Как следствие, count будет инкрементирован трижды. Статическая функция-член

showData( ) отображает текущее значение count. Вот результат работы программы: count = 1

count = 2

count = 3

Как мы и ожидали, значение count увеличивается всякий раз, когда создается новый объект Circle.

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

является лишь чертежом и не резервирует никакой памяти.

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

Также обратите внимание на синтаксис вызова статической функции-члена: showCount( ):

Circle::showCount( ) ;

Как видите, она вызывается без ссылки на объект. И это естественно, поскольку

счетчик (count), отображаемый функцией showCount( ), не относится к какому-либо конкретному объекту.

Вот еще ряд полезных сведений о статических членах класса:

Функция-член класса (такая как конструктор, например) имеет доступ как к статическим членам данных, так и к членам данных конкретного объекта.

Статическая функция-член может обращаться только к статическим членам

данных.

Для статической функции не существует указателя this.

Статический член данных не имеет никакого отношения к статическому классу

памяти.

124

Глава 5

Идею совместного использования объектами одной переменной можно применить

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

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

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

Универсальный спецификатор const

В главе 2 мы рассмотрели применение ключевого слова const для решения двух

задач:

Предотвращение модификации значений переменных.

Запрет функции не модифицировать фактические аргументы при помощи формальных аргументов.

Но на этом область применения const не ограничивается, его можно применять еще

в трех случаях:

С объектами.

С функциями-членами классов.

С аргументами функций-членов.

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

#include <iostream>

using namespace std ;

class Sample

{

private :

int data ;

public :

Sample( )

{

data = 0 ;

}

void changeData( ) const

{

data = 10 ;

}

void showData( )

{

cout << endl << "данные = " << data << endl ;

}

void add ( Sample const &s, Sample const &t )

{

data = s.data + t.data ;

Премудрости классов

125

s.data = 45 ; // ошибка

t.data = 50 ; // ошибка

}

void getData( )

{

cin >> data ;

}

} ;

int main( )

{

const Sample s1 ;

s1.getData( ) ; // ошибка

Sample s2 ;

s2.changeData( ) ;

Sample s3 ;

s3.changeData( ) ;

Sample s4 ;

s4.add ( s2, s3 ) ;

s4.showData( ) ;

return 0 ;

}

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

И хотя мы не пометили объект s2 как const, мы не можем изменить и его, вызвав

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

Из чего следует, что с const-объектами можно использовать только константные

функции-члены, поскольку лишь они гарантированно не допускают модификацию

объекта.

Обратите внимание: чтобы объявить функцию changeData( ) константной, ключевое слово const помещается после декларатора, но перед телом функции. Если

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

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

Наконец, в add( ) мы обозначили s и t как ссылки на const-объекты Sample. В результате, когда мы пытаемся изменить объекты s2 и s3 через ссылки s и t, компилятор выдает ошибку. Что само по себе и неплохо, ибо при выполнении операции

сложения нет причин, по которым s2 и s3 должны измениться.

126

Глава 5

Перегруженный оператор присваивания

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

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

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

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

Circle c1, c2 ;

c1 = c2 ; // вызывает функцию перегруженного оператора присваивания

Circle c3 = c2 ; // вызывает конструктор копирования

Здесь c1 и c2 — объекты определенного ранее класса Circle.

Инструкция c1 = c2 дает команду компилятору поэлементно копировать данные из

c2, член за членом, в c1. Это то, что перегруженный оператор присваивания делает

по умолчанию.

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

объект c3, и данные из c2 поэлементно копируются в c3. Это то, что по умолчанию

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

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

при этом вызываются разные функции. Почему команда c1 = c2 вызывает конструктор копирования? Все просто: для c1 непараметризированный конструктор уже

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

лишь один раз.

В то же время почему функция перегруженного оператора присваивания не вызывается в Circle c3 = c2? Это связано с тем, что c3 еще не создан, а функция оператора присваивания может быть вызвана только для существующего объекта. Более

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

при этом должен быть инициализирован значениями из c2. Вот почему вызывается

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

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

#include <iostream>

using namespace std ;

class Circle

{

private :

int radius ; float x, y ;

Премудрости классов

127

public :

Circle( )

{

}

Circle ( int rr, float xx, float yy )

{

radius = rr ;

x = xx ;

y = yy ;

}

Circle& operator = ( Circle& c )

{

cout << " Вызван оператор присваивания" << endl ; radius = c.radius ;

x = c.x ;

y = c.y ;

return *this ;

}

Circle ( Circle& c )

{

cout << "Вызван конструктор копирования" << endl ; radius = c.radius ;

x = c.x ;

y = c.y ;

}

void showData( )

{

cout << "Радиус = " << radius << endl ; cout << "Координата X = " << x << endl ; cout << "Координата Y = " << y << endl << endl ;

}

} ;

int main( )

{

Circle c1 ( 10, 2.5f, 2.5f ) ;

Circle c2, c3 ;

c3 = c2 = c1 ;

Circle c4 = c1 ;

c1.showData( ) ;

c2.showData( ) ;

c3.showData( ) ;

c4.showData( ) ;

return 0 ;

}

Результат работы программы:

Вызван оператор присваивания

Вызван оператор присваивания

Вызван конструктор копирования

128

Глава 5

Радиус = 10

Координата X = 2.5

Координата Y = 2.5

Радиус = 10

Координата X = 2.5

Координата Y = 2.5

Радиус = 10

Координата X = 2.5

Координата Y = 2.5

Радиус = 10

Координата X = 2.5

Координата Y = 2.5

Большая часть программы достаточно прозрачна. Здесь важно обратить внимание

на функцию operator = ( ), которая перегружает оператор присваивания, и на конструктор копирования.

При выполнении команды c3 = c2 = c1 вызывается функция перегрузки оператора.

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

данных из одного объекта в другой.

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

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

Рассмотрим команду:

c3 = c2 = c1 ;

Как известно, сначала выполняется команда c2 = c1. Внутри она преобразуется в: c2.operator = ( c1 ) ;

Аргумент c1 передается в функцию оператора присваивания по ссылке. Поэтому

ссылка c в этой функции относится к c1. Теперь данные c1 копируются в поля данных c2 посредством команд:

radius = c.radius ;

x = c.x ;

y = c.y ;

На данном этапе this-указатель содержит адрес c2. Таким образом, возвращая

*this, мы просто возвращаем объект c2. Более того, он возвращается по ссылке. Поэтому его копия не создается в функции main( ). Следующим шагом присваивания

становится c3 = c2. Здесь все происходит так же, как описано выше.

Премудрости классов

129

Теперь давайте внимательно рассмотрим конструктор копирования. При выполнении инструкции Circle c4 = c1 вызывается перегруженный конструктор копирования. Конструктор копирования принимает один аргумент — объект класса Circle, передаваемый по ссылке. Вот его прототип:

Circle ( Circle & ) ;

Обязательно ли использовать ссылку в аргументе на конструктор копирования?

Нельзя ли вместо нее передать значение?

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

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

И процесс этот будет продолжаться до тех пор, пока у компилятора не закончится

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

должен передаваться по ссылке.

Кроме того, конструктор копирования вызывается, когда объекты передаются по

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

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

копии объектов создаваться не должны.

Когда же объект возвращается из функции, вызывается конструктор копирования

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

Преобразование данных

Существует три возможных вида преобразования данных:

Преобразование между встроенными типами данных.

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

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

Ниже мы рассмотрим все эти виды преобразований.

Преобразование между встроенными типами

Рассмотрим фрагмент кода, содержащий операции со встроенными типами: float a = 30 ; // int в double

double b = 1.55f ; // float в double

int a = 3.14 ; // double в int

float b = 6.28 ; // double во float

При компиляции первые два присваивания легко проходят валидацию, т. к. это

расширяющие преобразования. В то же время при компиляции следующих двух

операций выводятся предупреждения, т. к. это сужающие преобразования — такие,

130

Глава 5

при которых может произойти потеря точности вычислений. Чтобы избежать пре-дупреждений, можно использовать приведение типов, как показано ниже: int a = int ( 3.14 ) ; // double в int

float b = float ( 6.28 ) ; // double во float

Расширяющие преобразования осуществляются имплицитно, тогда как для

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

Преобразование

между встроенными и пользовательскими типами

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

процедуры преобразования.

Следующая программа демонстрирует, как преобразовать базовый тип в пользовательский и наоборот. В этой программе пользовательский тип — это класс String, а встроенный — int. Программа иллюстрирует преобразование String в int и int в String. Вот ее исходный код:

// преобразования: String в int, int в String

#include <iostream>

#include <cstdlib>

using namespace std ;

class String

{

private :

char str[ 20 ] ;

public :

String( )

{

str[ 0 ] = '\0' ;

}

String ( char *s )

{

strcpy ( str, s ) ;

}

String ( int a )

{

itoa ( a, str, 10 );

}

operator int( )

{

int i = 0, l, num = 0, k = 1 ;

l = strlen ( str ) - 1 ;

Премудрости классов

131

while ( l >= 0 )

{

num = num + ( str[ l ] - 48 ) * k ;

l-- ;

k *= 10 ;

}

return ( num ) ;

}

void displayData( )

{

cout << str << endl ;

}

} ;

int main( )

{

String s1 = 123 ;

cout << "s1 = " ;

s1.displayData( ) ;

s1 = 150 ;

cout << "s1 = " ;

s1.displayData( ) ;

String s2 ( "123" ) ;

int i = int ( s2 ) ;

cout << "i = " << i << endl ;

String s3 ( "456" ) ;

i = s3 ;

cout << "i = " << i << endl ;

return 0 ;

}

Для преобразования int в пользовательский тип String мы использовали конструктор с одним аргументом. Он вызывается, когда объект типа String создается

с одним аргументом, как в

String s1 = 123 ; // то же, что String s1 ( 123 ) ;

Конструктор преобразует int в строку и присваивает ее переменной str, используя

функцию itoa( ).

В строке

s1 = 150 ;

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

132

Глава 5

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

безымянный временный объект, где str содержит значение, соответствующее

целому числу 150, а затем присваивая этот объект s1. Таким образом, если компилятор не находит перегруженный оператор присваивания ′=′, он ищет конструктор

для выполнения той же задачи.

Для преобразования String в int используется перегруженный оператор приведения

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

к типу int, а затем возвращает это целочисленное значение. Вызывается этот оператор в двух случаях:

i = int ( s2 ) ;

и

i = s3 ;

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

преобразования.

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

из-за возможности смешивания типов данных.

Преобразование

между различными пользовательскими типами данных

Как и в случае преобразования между пользовательскими и встроенными типами

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

классов используются те же два метода — конструктор с одним аргументом или

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

Процедура преобразования в исходном объекте

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

используются два класса: Date и dmy. Оба класса созданы для обработки дат, с той

лишь разницей, что класс Date обрабатывает их как строку (dt), тогда как класс

dmy обрабатывает их как три целых числа, представляющих день (day), месяц

(mth) и год (yr). Ниже приведен листинг программы:

#include <iostream>

#include <cstdlib>

using namespace std ;

Премудрости классов

133

class Date

{

private :

char dt[ 9 ] ;

public :

Date( )

{

dt[ 0 ] = '\0' ;

}

Date ( char *s )

{

strcpy ( dt, s ) ;

}

void displayData( )

{

cout << dt << endl ;

}

} ;

class dmy

{

private :

int day, mth, yr ;

public :

dmy( )

{

day = mth = yr = 0 ;

}

dmy ( int d, int m, int y )

{

day = d ;

mth = m ;

yr = y ;

}

operator Date( )

{

char temp[ 3 ], str[ 9 ] ;

itoa ( day, str, 10 ) ;

strcat ( str, "/" ) ;

itoa ( mth, temp, 10 ) ;

strcat ( str, temp ) ;

strcat ( str, "/" ) ;

itoa ( yr, temp, 10 ) ;

strcat ( str, temp ) ;

return ( Date ( str ) ) ;

}

134

Глава 5

void displayData( )

{

cout << day << "/" << mth << "/" << yr << endl ;

}

} ;

int main( )

{

Date d1 ;

dmy d2 ( 17, 11, 94 ) ;

d1 = d2 ;

cout << "d1 = " ;

d1.displayData( ) ;

cout << "d2 = " ;

d2.displayData( ) ;

return 0 ;

}

В теле функции main( ) мы определили объект d1 типа Date, который не инициализирован. Мы также определили объект d2 типа dmy, который был инициализирован. Далее выполняется присваивание посредством команды d1 = d2.

Поскольку d1 и d2 являются объектами разных классов, присваивание включает

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

в основную функцию main( ), где он присваивается d1.

Процедура преобразования в целевом объекте

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

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

должен иметь доступ к данным в исходном классе. Иными словами, поскольку

элементы данных day, mth и yr в классе dmy являются закрытыми, мы должны

предоставить функции getDay( ), getMth( ) и getYr( ) для их возврата. Что и реализовано в следующей программе:

#include <iostream>

#include <cstdlib>

using namespace std ;

class dmy

{

private :

int day, mth, yr ;

Премудрости классов

135

public :

dmy( )

{

day = mth = yr = 0 ;

}

dmy ( int d, int m, int y )

{

day = d ;

mth = m ;

yr = y ;

}

int getDay( )

{

return ( day ) ;

}

int getMth( )

{

return ( mth ) ;

}

int getYr( )

{

return ( yr ) ;

}

void displayData( )

{

cout << day << "/" << mth << "/" << yr << endl ;

}

} ;

class Date

{

private :

char dt[ 9 ] ;

public :

Date( )

{

dt[ 0 ] = '\0' ;

}

Date ( char *s )

{

strcpy ( dt, s ) ;

}

void displayData( )

{

cout << dt << endl ;

}

136

Глава 5

Date ( dmy t )

{

int d = t.getDay( ) ;

int m = t.getMth( ) ;

int y = t.getYr( ) ;

char temp[ 3 ] ;

itoa ( d, dt, 10 ) ;

strcat ( dt, "/" ) ;

itoa ( m, temp, 10 ) ;

strcat ( dt, temp ) ;

strcat ( dt, "/" ) ;

itoa ( y, temp, 10 ) ;

strcat ( dt, temp ) ;

}

} ;

int main( )

{

Date d1 ;

dmy d2 ( 17, 11, 19 ) ;

d1 = d2 ;

cout << "d1 = " ;

d1.displayData( ) ;

cout << "d2 = " ;

d2.displayData( ) ;

return 0 ;

}

При выполнении команды d1 = d2 вызывается конструктор с одним аргументом

в классе Date (аргументом которого является объект dmy). Эта функция-конструктор получает доступ к данным d2, вызывая функции getDay( ), getMth( ) и getYr( ).

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

в исходном объекте.

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

с одним аргументом в целевом классе, а когда — функцию преобразования в исходном классе?

Часто этот выбор очевиден:

Если у вас есть библиотека классов, у вас может не быть доступа к ее исходному

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

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

Премудрости классов

137

используем и функцию преобразования, и конструктор с одним аргументом?

Компилятор сообщит об ошибке, поскольку в таком случае возникает логи-ческое противоречие.

1. Укажите, верны ли следующие утверждения:

Статическое выделение памяти происходит во время компиляции программы, тогда как динамическое выделение памяти происходит во время ее выполнения.

Если память выделяется при помощи оператора new [ ], она должна освобож-даться посредством оператора delete [ ].

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

объекта.

Динамическая память и хранилище свободной памяти — это две разные вещи.

Применение delete к нулевому (NULL) указателю безопасно и гарантированно

не производит никаких действий.

В C++ для перераспределения памяти следует использовать оператор renew.

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

Как и другие операторы, оператор new также может быть перегружен.

Ни в коем случае нельзя применять оператор delete на указатель дважды.

Статический член данных полезен, когда всем объектам одного класса нужно

иметь общий информационный элемент.

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

Класс может иметь как статические члены данных, так и статические функции-члены.

Определение статического члена данных включается в объявление класса, но

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

Если display( ) является статической функцией-членом класса с именем Sample, то ее можно вызвать следующим образом:

Sample s1 ;

s1.display( ) ;

Если display( ) является статической функцией-членом класса с именем Sample, то ее можно вызвать следующим образом:

Sample::display( ) ;

Ключевое слово const можно использовать как с функциями-членами класса, так

и с аргументами функций-членов.

138

Глава 5

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

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

С const-объектами можно использовать лишь константные функции-члены.

Если не включить оператор присваивания в объявление класса, то компилятор

автоматически добавит его в этот класс.

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

Следующие два набора команд тождественны:

Sample s1 ;

s1 = s2 ;

и

Sample s1 = s2 ;

Невозможно вернуть локальный объект по ссылке.

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

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

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

Для выполнения преобразования из объекта одного пользовательского типа

в другой необходимо предусмотреть функции преобразования.

2. Решите следующие задачи и ответьте на вопросы:

Что удаляет команда delete p — указатель или объект, на который указывает p?

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

значений, а затем выведите их на экран. Мы должны иметь возможность обращаться к элементам этих массивов, используя формы записи a[ i ], b[ i ][ j ] и

c[ i ][ j ][ k ].

Сколько байтов будет выделено следующим программным кодом?

int main( )

{

const int MAXROW = 3 ;

const int MAXCOL = 4 ;

int ( *p )[ MAXCOL ] ;

p = new int[ MAXROW ][ MAXCOL ] ;

return 0 ;

}

Каков будет результат работы следующей программы?

#include <iostream>

using namespace std ;

Премудрости классов

139

int main( )

{

const int MAXROW = 3 ;

const int MAXCOL = 4 ;

int ( *p )[ MAXCOL ] ;

p = new int[ MAXROW ][ MAXCOL ] ;

cout << endl << sizeof ( p ) << endl << sizeof ( *p ) ; return 0 ;

}

В каких случаях применим спецификатор const?

• С обычными переменными.

• С аргументами глобальных функций.

• С функциями-членами класса.

• С аргументами функций-членов.

• С объектами.

Если нам необходимо реализовать оператор присваивания и конструктор копирования в классе Rectangle, каковы будут их прототипы?

В чем различие между этими двумя командами?

delete a ;

delete [ ] a ;

Что выполняет оператор delete, помимо освобождения памяти, используемой

объектом?

Разработайте программу, реализующую два класса, time12 и time24. Первый

поддерживает 12-часовой формат времени, а второй — 24-часовой. Обеспечьте

функции преобразования для выполнения преобразования из объекта одного

класса в объект другого класса.

Разработайте программу, реализующую класс date ("Дата"), который содержит

поля данных day ("День"), month ("Месяц") и year ("Год"). Напишите функцию

перегрузки оператора присваивания и конструктор копирования для этого класса.

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

и сколько байтов выделить, принимаются на этапе компиляции.

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

и сколько байтов выделить, принимаются на этапе выполнения программы.

Стек и динамическая память — это области памяти.

Статическое распределение памяти осуществляется в стеке.

Динамическое распределение памяти осуществляется в динамической памяти.

140

Глава 5

Динамическая память также называется свободной памятью или свободным

хранилищем.

Обычные переменные рекомендуется создавать в стеке.

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

Если в конструкторе используется оператор new, в деструкторе необходимо использовать оператор delete.

delete ptr удаляет объект, на который указывает ptr.

Память, выделенная при помощи new [ ], должна быть очищена при помощи

delete [].

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

Статические члены данных совместно используются множеством экземпляров

класса.

Статические функции-члены могут обращаться только к статическим членам

данных.

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

его пределами.

Доступ к статической функции-члену можно получить, используя форму записи

имя_класса::имя_функции( ).

Переменные, объекты, аргументы функции, объявленные с ключевым словом

const, не подлежат модификации.

Функция, объявленная с ключевым словом const, не может модифицировать

объект, для которого она вызывается.

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

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

приведения типов.

Преобразование встроенного типа данных в пользовательский можно выполнить

при помощи конструктора.

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

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

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

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

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

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

Технологии процветают скорее благодаря многократному

использованию, чем изобретению нового. Повторное

использование экономит время, силы и средства. То же самое

и с программированием. В C++ заложен ряд средств

многократного использования. И самым новаторским среди них

является, пожалуй, наследование. В этой главе мы раскроем

применение этого средства на конкретных примерах

142

Глава 6

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

Еще один пример наследования

Варианты применения наследования

Наследование и конструкторы

Виды наследования

• Одиночное наследование

• Многоуровневое наследование

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

Предупреждение

Поэтапная разработка

Упражнения

Важное

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

143

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

структурными элементами объектно-ориентированного программирова-

Тния — давайте рассмотрим еще одно ключевое понятие C++, известное как

средства многократного (повторного) использования. Язык C++ содержит три таких средства:

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

Отношения включения.

Шаблоны.

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

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

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

недоступен. В этой главе мы подробно рассмотрим механизм наследования.

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

Наследование — это процесс создания новых классов, называемых производными

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

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

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

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

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

Возможность многократного использования кода способствует распространению

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

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

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

результатами, за исключением одного момента. Класс Index может лишь инкре-ментировать счетчик, но не умеет декрементировать его.

Исправить это мы можем, внедрив функцию декремента непосредственно в исходный код класса Index.

144

Глава 6

Однако есть ряд причин воздержаться от подобного решения. Прежде всего, класс

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

нужно будет проводить снова. К тому же всегда существует вероятность того, что

в конечном итоге исходный класс будет работать неудовлетворительно.

Иногда может быть и другая причина не изменять класс Index — у нас может просто не быть доступа к его исходному коду, особенно если он распространялся как

часть библиотеки классов.

Во избежание этих проблем можно пойти совершенно другим путем и применить

наследование для создания нового класса на основе класса Index, не изменяя его.

Вот как этого можно достичь:

#include <iostream>

using namespace std ;

class Index // базовый класс

{

protected :

int count ;

public :

Index( )

{

count = 0 ;

}

void display( )

{

cout << "счетчик = " << count << endl ;

}

void operator ++ ( )

{

count++ ;

}

} ;

class NewIndex : public Index // производный класс

{

public :

void operator -- ( )

{

count-- ;

}

} ;

{

NewIndex i ;

++i ;

++i ;

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

145

display( ) ;

--i ;

i.display( ) ;

return 0 ;

}

Здесь мы сначала объявили базовый класс с именем Index, а затем создали на его

основе производный класс с именем NewIndex. NewIndex наследует все особенности базового класса Index. Ему не требуются функции operator ++( ) и display( ), поскольку они уже представлены в базовом классе.

В первой строке класса NewIndex

class NewIndex : public Index

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

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

Рис. 6.1. Наследование

Важно понимать, что стрелка на рисунке означает «выведен из». Направление

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

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

функциям производного класса.

Поскольку мы не объявили никакого конструктора в классе NewIndex, компилятор

добавит в него непараметризированный конструктор. При создании объекта i класса NewIndex сначала будет вызван конструктор в базовом классе, а затем конструктор в производном классе.

При вызове функций operator ++( ) или display( ) для объекта i их поиск сначала

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

оттуда же.

146

Глава 6

При вызове функции operator --( ) для объекта i ее поиск сначала производится

в классе NewIndex. Она обнаруживается в классе NewIndex и оттуда же выполняется, обеспечивая декрементирование счетчика count.

Члены данных в классах, созданных нами до сих пор, использовали спецификатор

доступа private. Однако в этой программе мы пометили count как protected («защищенный») член данных. Будь он помечен как private, он был бы недоступен

функции operator --( ), т. к. она находится за пределами класса Index. Мы также не

хотели придавать счетчику count статус public, т. к. это позволило бы получить

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

лишились всех преимуществ скрытия данных.

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

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

main( ). И это то, что нам нужно. Поэтому мы задействуем спецификатор доступа

protected.

На рис. 6.2 наглядно продемонстрировано, что и к чему имеет доступ в отношениях

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

Таким образом, благодаря производному классу NewIndex (и в целом благодаря

наследованию) мы расширили функционал класса Index, не модифицируя его.

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

базового класса. Если бы в нашей программе мы сгенерировали объект j класса

Index, то функция operator --( ) осталась бы недоступной для этого объекта.

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

Рис. 6.2. Доступ при наследовании

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

147

Еще один пример наследования

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

она работает. Далее мы подробно рассмотрим еще несколько особенностей наследования.

#include <iostream>

using namespace std ;

const int MAX = 10 ;

class Stack // базовый класс

{

protected :

int arr[ MAX ] ;int top ;

public :

Stack( )

{

top = -1 ;

}

void push ( int num )

{

top++ ;

arr[ top ] = num ;

}

int pop( )

{

int num ;

num = arr[ top ] ;

top-- ;

return ( num ) ;

}

} ;

class NewStack : public Stack // производный класс

{

public :

bool isFull( )

{

if ( top == MAX - 1 ) return true ;

else

return false ;

}

bool isEmpty( )

{

if ( top == -1 ) return true ;

else

return false ;

}

} ;

148

Глава 6

int main( )

{

NewStack stk ;

if ( !stk.isFull( ) ) stk.push ( 10 ) ;

else

cout << "Стек заполнен" << endl ;

if ( !stk.isFull( ) )

stk.push ( 20 ) ;

else

cout << "Стек заполнен" << endl ;

if ( !stk.isFull( ) )

stk.push ( 30 ) ;

else

cout << "Стек заполнен" << endl ;

int n ;

if ( !stk.isEmpty( ) )

{

n = stk.pop( ) ;

cout << "Текущий элемент = " << n << endl ;

}

else

cout << "Стек пуст" << endl ;

if ( !stk.isEmpty( ) )

{

n = stk.pop( ) ;

cout << "Текущий элемент = " << n << endl ;

}

else

cout << "Стек пуст" << endl ;

if ( !stk.isEmpty( ) )

{

n = stk.pop( ) ;

cout << "Текущий элемент = " << n << endl ;

}

else

cout << "Стек пуст" << endl ;

if ( !stk.isEmpty( ) )

{

n = stk.pop( ) ;

cout << "Текущий элемент = " << n << endl ;

}

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

149

else

cout << "Стек пуст" << endl ;

return 0 ;

}

Как вы уже, возможно, знаете, структура данных типа стек (stack) является списком, организованным по принципу LIFO («последним пришел, первым вышел»).

Такая структура реализована в базовом классе stack. Она позволяет помещать элементы в стек и извлекать их из него. Однако функция Stack::push( ) не учитывает

возможность полного заполнения стека. Так же функция Stack::pop( ) не преду-сматривает ситуации, когда в какой-то момент стек окажется пустым, и она не

сможет удалить элемент из пустого стека.

Чтобы справиться с этими задачами, мы создали класс NewStack, наследующий

класс Stack. Мы включили в него две функции — isFull( ) и isEmpty( ). Эти функции сообщают о том, полон стек или нет и пуст он или нет.

Заметьте, что в функции main( ) мы создали объект производного класса. Через не-го доступны не только функции isFull( ) и isEmpty( ), но также функции push( ) и pop( ) класса Stack.

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

Варианты применения наследования

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

Применение существующего функционала.

Переопределение существующего функционала.

Создание нового функционала.

Сочетание нового и существующего функционалов.

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

программа:

// Программа, демонстрирующая различные варианты применения наследования

#include <iostream>

using namespace std ;

class Ex

{

public :

void fun( )

{

cout << "Внутри Ex - fun( )" << endl ;

}

150

Глава 6

void save( )

{

cout << " Внутри Ex - save( )" << endl ;

}

void enc( )

{

cout << " Внутри Ex - enc( )" << endl ;

}

void open( )

{

cout << " Внутри Ex - open( )" << endl ;

}

} ;

class NewEx : public Ex

{

public :

void save( )

{

cout << " Внутри NewEx - save( )" << endl ;

}

void enc( )

{

cout << " Внутри NewEx - enc( )" << endl ;

}

void autoUpdate( )

{

cout << " Внутри NewEx - autoUpdate( )" << endl ;

}

void open( )

{

cout << " Внутри NewEx - open( )" << endl ;

Ex::open( ) ;

}

} ;

int main( )

{

NewEx e ;

e.fun( ) ;

e.save( ) ;

e.enc( ) ;

e.autoUpdate( ) ;

e.open( ) ;

}

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

Внутри Ex - fun( )

Внутри NewEx - save( )

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

151

Внутри NewEx - enc( )

Внутри NewEx - autoUpdate( )

Внутри NewEx - open( )

Внутри Ex - open( )

В ней мы определили два класса — Ex в качестве базового и NewEx в качестве

производного. Класс Ex содержит функции-члены fun( ), save( ), enc( ) и open( ), тогда как класс NewEx содержит функции-члены save( ), enc( ), open( ) и

autoUpdate( ). В основной функции main( ) мы создали объект класса NewEx, а

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

При вызове функции fun( ) ее поиск сначала производится в классе NewEx. Поскольку в NewEx ее нет, поиск продолжается в базовом классе, где она и обнаруживается. В результате вызывается функция базового класса и выводится сообщение «Внутри Ex — Fun( )». Тем самым мы продемонстрировали вызов одной из

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

При вызове функции save( ) ее поиск снова осуществляется в производном классе

NewEx. Поскольку поиск в NewEx дает положительный результат, вызывается

именно эта версия функции save( ) (а не та, что находится в классе Ex). Это пример

того, как с помощью наследования мы можем переопределить существующий

функционал. То же самое относится к функции enc( ).

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

наследованию создается новый функционал.

Наконец, при вызове функции open( ) она обнаруживается в классе NewEx. Вызывается данная версия open( ), которая, в свою очередь, вызывает версию функции

open( ) из базового класса посредством инструкции Ex::open( ).

В итоге на экран выводится:

Внутри NewEx - open( )

Внутри Ex - open( )

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

Наследование и конструкторы

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

форму записи:

имя_базового_класса::имя_функции( ) ;

При отсутствии явного указания функция базового класса не вызывается из тела

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

152

Глава 6

ются по-другому. В отношении конструкторов в цепочке наследования следует

уяснить два момента:

При создании объекта производного класса сначала вызывается конструктор

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

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

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

Проиллюстрируем эти факты на примере следующей небольшой программы:

// Код, демонстрирующий вызовы конструкторов в цепочке наследования

#include <iostream>

using namespace std ;

class Base

{

public :

Base( )

{

cout << "Непараметризированный конструктор базового класса" << endl ;

}

Base ( int xx )

{

cout << "Конструктор базового класса с одним аргументом" << endl ;

}

} ;

class Der : public Base

{

public :

Der( )

{

cout << "Непараметризированный конструктор производного класса" << endl ;

}

Der ( int x ) : Base ( x )

{

cout << "Конструктор производного класса с одним аргументом" << endl ;

}

} ;

int main( )

{

Der y ;

Der z ( 10 ) ;

}

При выполнении программы на экран выводится:

Непараметризированный конструктор базового класса

Непараметризированный конструктор производного класса

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

153

Конструктор базового класса с одним аргументом

Конструктор производного класса с одним аргументом

Из результата работы программы следует, что, когда мы создаем объекты y и z, сначала вызывается конструктор базового класса, а затем конструктор производного класса. При создании объекта y автоматически вызывается непараметризированный конструктор базового класса. Однако при создании z конструктор с одним

аргументом должен был вызываться явно при помощи следующей формы записи: Der ( int x ) : Base ( x )

Если бы мы не использовали Base (x), тогда был бы вызван непараметризированный конструктор базового класса.

Это означает, что список параметров функции-конструктора производного класса

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

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

для базового класса.

Должно быть, вы задаетесь вопросом, почему необходимо, чтобы построение шло

в порядке происхождения классов. Лучше всего это можно прояснить при помощи

следующей небольшой программы:

// Порядок создания объектов в цепочке наследования

class Base

{

protected : int i ;

public :

Base( )

{

i = 4 ;

}

} ;

class Der : public Base

{

private :

int j ;

public :

Der( )

{

j = i * 4 ;

}

} ;

int main( )

{

Der d ;

}

Здесь i помечен как защищенный член данных, поэтому он доступен функциям

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

154

Глава 6

не от базового класса к производному. В этом случае сначала будет вызван конструктор Der. И поскольку i доступен в конструкторе, а конструктор базового класса

еще не вызван, ему не было присвоено значение. В результате переменная j не

будет установлена корректно. Значит, и значение в объекте d не будет присвоено

корректно. И напротив, при соблюдении порядка происхождения классов объект d будет инициализирован надлежащим образом.

В отношениях наследования наследуются не все функции. В частности, конструктор, деструктор, функция перегруженного оператора присваивания и конструктор

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

аспектами объекта только на своем конкретном уровне в иерархии классов. Следовательно, они не наследуются.

Виды наследования

Язык C++ допускает три различных вида наследования:

Одиночное наследование.

Многоуровневое наследование.

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

Рассмотрим каждый из них в отдельности.

Одиночное наследование

Все программы в этой главе относились одиночному наследованию, когда один

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

Открытое (public) наследование.

Защищенное (protected) наследование.

Закрытое (private) наследование.

Во всех наших программах открытое наследование реализовывалось посредством

таких объявлений, как

class Der : public Base

class NewStack : public Stack

Мы можем выбрать защищенное или закрытое наследование, указав:

class Der : protected Base

class NewStack : private Stack

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

class Der : Base

подразумевает, что мы выбираем закрытое (private) наследование.

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

155

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

при защищенном (protected) наследовании public-члены и protected-члены базового класса становятся защищенными в производном классе. При закрытом наследовании public-члены и protected-члены базового класса становятся закрытыми

(private) в производном классе. Это отражено на рис. 6.3.

Рис. 6.3. Подвиды одиночного наследования

Могут ли два класса наследовать базовый класс? Безусловно могут. К примеру, у нас может быть базовый класс Shape, который наследуется классами Circle и

Rectangle путем одиночного наследования.

Могут возникать ситуации, когда необходимо скрыть часть функциональных

средств базового класса. К примеру, мы хотим, чтобы функция display( ) класса

Base была доступна за пределами класса, при этом остальные функции оставались

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

base :: display ;

К защищенному наследованию прибегают не так часто; в языке С++ оно скорее

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

Многоуровневое наследование

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

класса. Таким образом, может существовать несколько уровней наследования.

К примеру, у нас может быть базовый класс LinkedList, который наследуется классом LinkedList1. А из класса LinkedList1 мы можем вывести класс LinkedList2.

При многоуровневом наследовании число уровней не ограничено.

156

Глава 6

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

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

Представим, что некая компания продает аппаратное и программное обеспечение.

Для моделирования системы продаж этой компании мы можем создать четыре

класса — Item (Товар), Sales (Продажи), HardwareItem (Оборудование) и SoftwareItem (ПО). На рис. 6.4 отображено содержимое этих классов, а также взаимо-связь между ними.

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

Объявления этих классов приведены ниже:

class Item

{

private :

string title ;

float price ;

public :

void getData( ) ;

void displayData( ) ;

} ;

class Sales

{

private :

float salesFigure[ 3 ] ;

public :

void getData( ) ;

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

157

void displayData( ) ;

} ;

class HardwareItem : private Item, private Sales

{

private :

string category ;

string oem ;

public :

void getData( ) ;

void displayData( ) ;

} ;

class SoftwareItem : private Item, private Sales

{

private :

string category ;

string os ;

public :

void getData( ) ;

void displayData( ) ;

} ;

Хотя большинство из этих объявлений самоочевидны, стоит отметить два момента: Объявление

class HardwareItem : public Item, public Sales

указывает на то, что класс HardwareItem происходит от двух классов: Item и

Sales. Таким образом, он наследует свойства обоих этих классов.

В этом объявлении наследования важен порядок перечисления классов. Если мы

создадим объект HardwareItem, то конструкторы будут вызываться в последовательности Item( ), Sales( ). После этого будет выполнен код, помещенный

в теле конструктора HardwareItem( ). То же самое относится к конструкторам

с несколькими аргументами.

Если мы вызовем функцию displayData( ) из функции displayData( ) объекта

HardwareItem (производный класс), компилятор не будет знать, функцию

displayData( ) какого базового класса (Sales или Item) следует вызвать. Это ло-гическое противоречие легко устранить, указав перед функцией displayData( ) имя класса и оператор разрешения контекста, как показано ниже:

Item :: displayData( ) ;

Sales :: displayData( ) ;

Предупреждение

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

fun( ), а производный класс не имеет функции с таким именем. Что произойдет,

158

Глава 6

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

fun( )? Произойдет ошибка, поскольку компилятор не сможет определить функцию

fun( ) базового класса, которую вы хотите вызвать. Проблема эта решается при помощи оператора разрешения контекста, как показано ниже:

// Base1 и Base2 — базовые классы

// Оба класса содержат функцию fun( )

// Класс Der наследует от Base1 и Base2

Der d ;

d.fun( ) ; // Ошибка

d.Base1::fun( ) ;

d.Base2::fun( ) ;

Поэтапная разработка

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

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

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

Не перестает восхищать та четкость, с которой разделены классы. Вам даже не

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

Достаточно иметь заголовочный файл с описанием класса, и объектный файл или

библиотечный.

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

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

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

1. Укажите, верны ли следующие утверждения:

Можно создать производный класс из базового класса, даже если исходный код

базового класса недоступен.

Множественное наследование отличается от многоуровневого наследования.

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

159

Точно так же как функция-член производного класса может получить доступ

к защищенным (protected) и открытым (public) членам базового класса, функции-члены базового класса могут получить доступ к защищенным (protected) и

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

Производный класс можно создать при помощи открытого (public), закрытого

(private) или защищенного (protected) наследования.

Функция-член производного класса имеет доступ к protected-членам и public-членам базового класса независимо от того, с каким спецификатором доступа

был создан производный класс, public или private.

Если производный класс создан со спецификатором доступа public, то объект

производного класса может получить доступ к public-членам базового класса.

Объект производного класса (любого происхождения) не может получить доступ к private-членам или protected-членам своего базового класса.

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

При открытом (public) наследовании защищенные (protected) члены базового

класса получают public-статус для функций за пределами производного класса.

Закрытое (private) и защищенное (protected) наследование ничем не отличаются.

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

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

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

Если базовый класс содержит функцию-член func( ), а в производном классе нет

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

доступ к func( ).

Если для производного класса не указаны конструкторы, объекты производного

класса будут задействовать конструкторы базового класса.

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

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

Класс D может происходить от класса C, являющегося производным от класса B, наследующего класс A.

Запрещается делать объекты одного класса членами другого класса.

2. Решите следующие задачи и ответьте на вопросы:

Создайте класс String, содержащий следующие функции:

• Функцию перегруженного оператора сложения для выполнения строковой

конкатенации.

• Функцию перегруженного оператора присваивания для копирования строк.

160

Глава 6

• Функцию перегруженного сокращенного оператора присваивания +=.

• Функцию для отображения длины строки.

• Функцию для отображения размера строки.

• Функцию toLower( ) для преобразования прописных букв в строчные.

• Функцию toUpper( ) для преобразования строчных букв в прописные.

Предположим, что есть базовый класс B и класс D, производный от B. Класс B

имеет две открытые функции-члены b1( ) и b2( ), тогда как в классе D есть две

компонентные функции d1( ) и d2( ). Создайте эти классы для следующих условий:

• Функция b1( ) должна быть доступна из main( ), а функция b2( ) — нет.

• Функции b1( ) и b2( ) не должны быть доступны из main( ).

• Функции b1( ) и b2( ) должны быть доступны из main( ).

Класс D наследует два базовых класса B1 и B2. Создайте эти классы так, чтобы

в каждом из них присутствовал непараметризированный конструктор. Удостоверьтесь в том, что при построении объекта класса D сначала вызывается конструктор B2, а затем конструктор B1. Также реализуйте деструктор для каждого

класса. В каком порядке будут вызываться эти деструкторы?

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

• К public-членам класса D.

• К protected-членам класса D.

• К private-членам класса D.

• К public-членам класса B.

• К protected-членам класса B.

• К private-членам класса B.

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

• на уровне исходного кода;

• на уровне объектного кода.

Многократное использование на уровне исходного кода осуществляется при помощи шаблонных функций (Template functions) и шаблонных (параметризированных) классов (Template classes).

Шаблоны позволяют нам создавать обобщенные функции/классы, а компилятор

генерирует из них специализированные функции/классы.

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

161

Для создания специализированных функций/классов должен быть доступен исходный код.

Многократное использование на уровне объектного кода осуществляется благодаря отношениям включения и наследованию.

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

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

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

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

В цепочке наследования доступны защищенные (protected) члены.

Объект производного класса содержит все данные базового класса.

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

класса.

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

Конструктор базового класса можно вызвать, используя форму записи:

имя_базового_класса( )

Наследование обеспечивает:

• Применение существующего функционала, для чего достаточно установить

отношения наследования.

• Скрытие существующего функционала путем определения той же функции

в производном классе.

• Расширение существующего функционала посредством вызова функции базового класса из производного класса, используя форму записи:

имя_базового_класса::функция_базового класса( )

Существуют три вида наследования:

• одиночное;

• многоуровневое;

• множественное.

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

При множественном наследовании порядок классов в объявлении производного

класса задает порядок, в котором вызываются конструкторы базовых классов.

Полиморфизм

После классов/объектов и наследования полиморфизм

является третьей ключевой особенностью C++.

Ограничившись наследованием, можно упустить из виду

наиважнейший раздел языка, а именно полиморфизм.

В этой главе мы подробно разберем концепцию полиморфизма

164

Глава 7

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

Чистая виртуальная функция

Абстрактный класс

Связывание функций

Анатомия виртуальных функций

Для чего применять виртуальные функции?

Срезание (нарезка) объектов

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

• Вызов виртуальных функций

из конструкторов/деструкторов

Виртуальные базовые классы

Упражнения

Важное

Полиморфизм

165

охоже, что на практике программисты переходят с C на C++ в три этапа. На

первом этапе они начинают использовать C++ просто как «улучшенный

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

программированию.

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

Многие и правда считают, что они овладели объектно-ориентированным программированием с тех самых пор, как освоили классы, объекты и наследование. Какими

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

В C++ полиморфизм представлен в двух основных формах:

Одно явление существует в нескольких различных формах.

Одно действие ведет к совершению различных операций.

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

Во второй форме под действием понимается вызов одного метода, приводящий

к совершению различных операций, т. е. к вызову различных функций в иерархии

классов. Эта форма полиморфизма реализуется при помощи виртуальных функций.

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

Допустим, у нас есть два класса Circle («Круг») и Rectangle («Прямоугольник»), оба производные от класса Shape («Фигура»). Каждый класс содержит функцию

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

#include <iostream>

using namespace std ;

class Shape

{

public :

virtual void draw( )

{

cout << "В классе Shape" << endl ;

}

} ;

166

Глава 7

class Circle : public Shape

{

public :

void draw( )

{

cout << "В классе Circle" << endl ;

}

} ;

class Rectangle : public Shape

{

public :

void draw( )

{

cout << "В классе Rectangle" << endl ;

}

} ;

int main( )

{

Circle c1, c2, c3 ;

Rectangle r1, r2, r3 ;

int i ;

Shape *p[ ] = { &c1, &c2, &r1, &r2, &r3, &c3 } ; for ( i = 0 ;i <= 5 ;i++ )

p[ i ]->draw( ) ;

return 0 ;

}

Вот результат работы программы:

В классе Circle

В классе Circle

В классе Rectangle

В классе Rectangle

В классе Rectangle

В классе Circle

Обратите внимание на тот факт, что в нашей небольшой программе класс Shape, а

также производные от него классы Circle и Rectangle содержат метод с именем

draw( ). Вместо отрисовки фигуры эти функции просто отображают сообщение.

В основной функции main( ) мы создали массив указателей Shape и занесли в него

адреса объектов Circle и Rectangle. И хотя наблюдается несоответствие типов слева (Shape *) и справа (Circle * / Rectangle *), оно допускается, поскольку Shape является базовым классом, а Circle и Rectangle — его наследниками. Когда мы сохраняем адрес объекта производного класса в указателе на объект базового класса, он называется указателем с приведением вверх (upcasting). Так, p[ ] представляет

собой массив таких указателей.

Полиморфизм

167

В момент, когда нужно отобразить рисунок, мы можем просто запустить цикл: for ( i = 0 ;i <= 5 ;i++ )

p[ i ]->draw( ) ;

Когда p[i] адресует объект Circle, вызывается метод Circle::draw( ). Аналогичным

образом, когда он адресует объект Rectangle, вызывается метод Rectangle::draw( ).

Таким образом, одно и то же действие приводит к совершению разных операций.

Это и есть полиморфизм в действии.

Для того чтобы данный полиморфный подход работал, необходимо соблюсти ряд

условий:

Классы Circle и Rectangle должны наследовать один базовый класс, Shape.

Классы Shape, Circle и Rectangle должны содержать функцию с именем draw( ).

Функция draw( ) родительского класса Shape должна быть объявлена виртуальной (virtual).

Вызов метода draw( ) должен осуществляться посредством указателя.

Рассмотрим два возможных варианта.

Если функция draw( ) используется без ключевого слова virtual, инструкция

p[i ]->draw( ) вызовет метод Shape::draw( ).

Если p[ ] адресует объекты класса Shape, инструкция p[ i ]->draw( ) вызовет

метод Shape::draw( ).

Чистая виртуальная функция

Можно добавить еще одно усовершенствование виртуальной функции, объявлен-ной в классе Shape последней программы. Раз уж функция draw( ) в базовом классе

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

функции, а также добавить запись =0 в ее объявление, как показано ниже: class base

{

public :

virtual void draw( ) = 0 ;

} ;

Функция draw( ) известна как чистая виртуальная функция. Знак «=» не имеет

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

Если можно удалить тело виртуальной функции, то можно ли удалить всю функцию целиком? Нет, это не сработает. Без метода draw( ) в базовом классе инструкции типа

p[i]->draw( ) ;

станут некорректными.

168

Глава 7

Абстрактный класс

Выражение «нарисовать фигуру» не имеет особого смысла, т. к. в нем не уточняет-ся, какую конкретно фигуру нужно нарисовать. Поэтому мы должны каким-то образом предотвратить попытку создания объекта класса Shape. Если класс Shape не

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

о вызове для него метода draw( ). Об этом позаботится чистая виртуальная функция.

Если класс содержит чистую виртуальную функцию, то мы не можем создать из

него объект. Такой класс называется абстрактным классом.

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

должны переопределить ее во всех производных классах, из которых планируете

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

абстрактным.

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

Связывание функций

Термин «связывание» применим к отношению между вызовом функции и фактическим кодом, выполняемым в результате такого вызова. В целом связывание решает, какую функцию вызывать. Если решение это принимается во время компиляции, оно называется статическим или ранним связыванием. Если же оно принимается

позднее, т. е. в ходе выполнения программы, то это называется динамическим или

поздним связыванием.

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

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

указателя на объект, членом которого она является.

Как будет связываться перегруженная глобальная функция abs( )? В данном случае

компилятор может решить, какую версию abs( ) следует вызывать, основываясь на

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

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

этот класс.

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

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

Circle и Rectangle. В каждом из них есть функция draw( ), при этом версия базового класса определена как виртуальная. Проанализируйте следующие вызовы: Shape s ;

Circle c ;

Rectangle r ;

Полиморфизм

169

s.draw( ) ; // вызывает Shape::draw( )

c.draw( ) ; // вызывает Circle::draw( )

r.draw( ) ; // вызывает Rectangle::draw( )

Все вызовы связываются статически, поскольку они производятся для заданного

объекта. Поэтому здесь не возникает неоднозначности, какую функцию объекта

следует вызывать.

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

Shape s ;

Circle c ;

Rectangle r ;

fun ( &s ) ;

fun ( &c ) ;

fun ( &r ) ;

// место для кода

// определение функции

void fun ( Shape *p )

{

p->draw( ) ; // позднее связывание

}

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

к fun( ) указатель p содержит адрес объекта Shape, следовательно, p->draw( ) вызывает Shape::draw( ). Во втором обращении p адресует объект Circle, поэтому

p->draw( ) вызывает Circle::draw( ). И наконец, в третьем обращении p адресует

объект Rectangle, поэтому p->draw( ) вызывает Rectangle::draw( ).

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

Итак, для динамического связывания необходимы следующие условия:

Должно быть задействовано наследование.

Имя функции по единому прототипу должно присутствовать как в базовом, так

и в производном классе.

Функция базового класса должна быть определена как виртуальная.

Вызов функции должен осуществляться через указатель на базовый класс (в том

числе с приведением вверх).

170

Глава 7

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

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

и займемся в следующем разделе.

Анатомия виртуальных функций

Недостаточно научиться применять виртуальные функции — необходимо также

знать, как их обрабатывает компилятор.

С первой частью мы уже знакомы, однако прежде чем мы перейдем ко второй, давайте рассмотрим небольшую программу:

#include <iostream>

using namespace std ;

class Sample

{

private :

int i ;

public :

virtual void display( )

{

cout << endl << "В классе Sample";

}

} ;

class Trial

{

public :

virtual void display( )

{

cout << endl << " В классе Trial" ;

}

} ;

int main( )

{

Sample s ;

Trial t ;

cout << sizeof ( s ) << endl << sizeof ( t ) ;

return 0 ;

}

Программа выводит на экран:

8

4

Полиморфизм

171

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

указывается, что размер объекта — 8 байт. Размер объекта t без целого числа указан как 4 байта. Попробуем разобраться, почему так происходит.

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

функций. Он называется указателем на таблицу виртуальных методов (VPTR) или

на координирующую таблицу (VTABLE).

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

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

этого конкретного класса. Если вы не переопределяете функцию, которая была

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

базового класса в VTABLE производного класса.

Таким образом, указатель на таблицу виртуальных методов (VPTR) используется

для каждого объекта, тогда как сама таблица (VTABLE) — для каждого класса.

Кроме того, VPTR объекта всегда указывает на VTABLE того класса, из которого

сгенерирован объект. VPTR настраивается в конструкторе.

При вызове виртуальной функции через указатель базового класса компилятор

автоматически внедряет код для получения VPTR и поиска адреса функции

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

связывание.

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

происходит нечто неожиданное.

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

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

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

По этому адресу извлекается VPTR-указатель объекта производного класса, посредством которого осуществляется доступ к таблице VTABLE производного

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

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

автоматически, поэтому вам не нужно об этом беспокоиться.

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

На рис. 7.1 представлена иерархия классов, которую предлагается в ней реализовать.

172

Глава 7

Рис. 7.1. Иерархия классов

Ниже приведена программа, реализующая данную иерархию классов:

#include <iostream>

using namespace std ;

class Vehicle

{

public :

virtual void speed( )

{

cout << endl << "В теле speed класса Vehicle" ;

}

virtual void maintenance( )

{

cout << endl << "В теле maintenance класса Vehicle" << endl ;

}

void value( )

{

cout << endl << "В теле value класса Vehicle" ;

}

} ;

class FourWheeler : public Vehicle

{

public :

void speed( )

{

cout << endl << "В теле speed класса FourWheeler" ;

}

void maintenance( )

{

cout << endl << "В теле maintenance класса FourWheeler" ;

}

} ;

Полиморфизм

173

class TwoWheeler : public Vehicle

{

public :

void speed( )

{

cout << endl << "В теле speed класса TwoWheeler" ;

}

void maintenance( )

{

cout << endl << "В теле maintenance класса TwoWheeler" ;

}

void value( )

{

cout << endl << "В теле value класса TwoWheeler" ;

}

} ;

class AirBorne : public Vehicle

{

public :

void speed( )

{

cout << endl << "В теле speed класса AirBorne" ;

}

} ;

int main( )

{

Vehicle *ptr1 ;

Vehicle v ;

ptr1 = &v ;

ptr1 -> speed( ) ;

ptr1 -> maintenance( ) ;

ptr1 -> value( ) ;

Vehicle *ptr2, *ptr3, *ptr4 ;

FourWheeler maruti ;

TwoWheeler bajaj ;

AirBorne jumbo ;

ptr2 = &maruti ;ptr3 = &bajaj ;

ptr4 = &jumbo ;

ptr2 -> speed( ) ;

ptr2 -> maintenance( ) ;

174

Глава 7

ptr3 -> speed( ) ;

ptr3 -> maintenance( ) ;

ptr4 -> speed( ) ;

ptr4 -> maintenance( ) ;

ptr2 -> value( ) ;

ptr3 -> value( ) ;

Vehicle w ;

w.speed( ) ;

FourWheeler f ;

f.speed( ) ;

AirBorne a ;

a.maintenance( ) ;

return 0 ;

Назад: Const-указатели
Дальше: Для чего применять виртуальные функции?