Лекция 5:

к оглавлению
оглавление

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

Встраиваемые функции

Итак, давайте вспомним наш пример.

struct _3d
{
 double x, y, z;
 double mod () {return sqrt (x*x + y*y +z*z);}
 double projection (_3d r) {return (x*r.x + y*r.y + z*r.z) / mod();}
 _3d operator + (_3d b);
};

_3d _3d::operator + (_3d b)
{
 _3d c;
 c.x = x + b.x;
 c.y = y + b.y;
 c.z = z + b.z;
 return c;
}

Обратите внимание на то, где мы описываем тело того или иного метода. Методы mod() и protection() описаны вместе со своими телами непосредственно внутри структуры. Но можно поступить иначе: поместить прототипы метода внутрь структуры, а определение тела функции - вне структуры, как мы поступили с оператором "+".

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

Кроме представленного выше способа создания встраиваемых функций (записать тело метода непосредственно в структуре), есть еще один способ – вставить спецификатор inline перед определением метода:

inline _3d _3d::operator + (_3d b)
{
 _3d c;
 c.x = x + b.x;
 c.y = y + b.y;
 c.z = z + b.z;
 return c;
}

Теперь оператор "+" станет встраиваемой функцией.

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

Важно понимать, что inlineне является командой для компилятора, это скорее просьба сделать метод встраиваемым. Если по каким-то причинам (например, при наличии в теле функции операторов цикла, switch или goto) компилятор не выполнит запрос, то функция будет откомпилирована как невстраиваемая.

[к началу]

Конструкторы и деструкторы

Рассмотрим наш пример с трехмерным вектором. При объявлении класса _3d мы использовали struct{}, а, следовательно, данные и методы были по умолчанию общедоступными. Это, в частности, означает, что следующий пример, вообще говоря, вполне правилен:

_3d vectorA;
double m;
vectorA.x = 17.56;
vectorA.y = 35.12;
vectorA.z = 1.0;
m = vectorA.mod();

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

class _3d
{
 double x, y, z;
public:
 double mod () {return sqrt (x*x + y*y +z*z);}
 double projection (_3d r) {return (x*r.x + y*r.y + z*r.z) / mod();}
 _3d operator + (_3d b);
};

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

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

class _3d
{

 double x, y, z;
public:
 double mod () {return sqrt (x*x + y*y +z*z);}
 double projection (_3d r) {return (x*r.x + y*r.y + z*r.z) / mod();}
 void set (double newX, double newY, double newZ)
 {
  x = newX; y = newY; z = newZ;
 }
 _3d operator + (_3d b);
};

Метод set() позволяет присвоить некоторые начальные значения координатам вектора (и только этот метод!).

Еще одно замечание: хотя x, y и z теперь относятся к защищенным членам класса, явное обращение этим элементам объекта, переданного в качестве параметра (см. метод projection(...) и оператор "+"), по-прежнему допускается.

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

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

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

В языке С++ деструкторы имеют имена: "~имя_класса". Как и конструктор, деструктор не возвращает никакое значение, но в отличие от конструктора не может быть вызван явно. Конструктор и деструктор не могут быть описаны в закрытой части класса.

class _3d
{
 double x, y, z;
public:
 _3d();
 ~_3d()
 {
  cout << 'Работа деструктора _3d \n';
 }
 double mod () {return sqrt (x*x + y*y +z*z);}
 double projection (_3d r) {return (x*r.x + y*r.y + z*r.z) / mod();}
 void set (double newX, double newY, double newZ)
 {
  x = newX; y = newY; z = newZ;
 }
 };

_3d::_3d() // конструктор класса _3d
{

 x=y=z=0;
 cout << 'Работа конструктора _3d \n';
}

main()
{
 _3d A; // создается объект A и происходит инициализация его элементов
  // A.x = A.y = A.z = 0;
 A.set (3,4,0); // Теперь A.x = 3.0, A.y = 4.0, A.z = 0.0
cout << A.mod()<<'\n';
}

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

Работа конструктора _3d
5.0
Работа деструктора _3d

[к началу]

Конструкторы с параметрами и перегрузка конструкторов

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

class _3d
{
 double x, y, z;
public:
 _3d ();
 _3d (double initX, double initY, double initZ);
 ...
};

_3d::_3d(double initX, double initY, double initZ)
//конструктор класса _3d с параметрами
{
 x = initX;
 y = initY;
 z = initZ;
 cout << 'Работа конструктора _3d \n';
}

main()
{
 _3d A; //создается объект A и происходит инициализация его элементов
 // A.x = A.y = A.z = 0;
 A.set (3,4,0); //Теперь A.x = 3.0, A.y = 4.0, A.z = 0.0
 _3d B (3,4,0); //создается объект B и происходит инициализация его элементов
 // B.x = 3.0, B.y = 4.0, B.z = 0.0
}

Такой способ вызова конструктора является сокращенной формой записи выражения

_3d B = _3d (3,4,0);

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

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

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

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

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

[к началу]

Присваивание объектов

Один объект можно присвоить другому, если оба объекта имеют одинаковый тип. По умолчанию, когда объект A присваивается объекту B, то осуществляется побитовое копирование всех элементов–данных A в соответствующие элементы–данные B. Если объекты имеют разные типов разные, то компилятор выдаст сообщение об ошибке. Важно понимать, одинаковыми должны быть имена типов, а не их физическое содержание. Например, следующие два типа несовместимы.

class ClassName1
{
 int a, b;
public:
 void set (int ia, int ib) {a=ia; b=ib;}
};

class ClassName2
{
 int a, b;
public:
 void set (int ia, int ib) {a=ia; b=ib;}
};

Так что попытка выполнить

ClassName1 c1;
ClassName2 c2;
c2 = c1;

окажется неудачной.

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

class Pair
{
 int a, *b;
public:
 void set (int ia, int ib) {a=ia; *b=ib;}
 int getb (){return *b;}
 int geta (){return a;}
};

main()
{
 Pair c1,c2;
 c1.set(10,11);
 c2 = c1;
 c1.set(100,111);
 cout << 'с2.b = '<< c2.getb();
}

В результате работы программы получим "c2.b = 111", а не 11, как ожидалось.

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

class Pair
{
 int a, *b;
public:
 Pair operator = (Pair p)
 {
  a = p.a;
  *b = *(p.b);
  return *this;
 }
 ...
};

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

class _3d
{
 double x, y, z;
public:
 _3d ();
 _3d (double initX, double initY, double initZ);
 double mod () {return sqrt (x*x + y*y +z*z);}
 double projection (_3d r) {return (x*r.x + y*r.y + z*r.z) / mod();}
 _3d operator + (_3d b);
 _3d operator = (_3d b);
};

_3d _3d::operator = (_3d b)
{
x = b.x;
y = b.y;
z = b.z;
return *this;
}

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

В качестве аргументов функций-операторов выступают операнды, а возвращаемое значение - результат применения оператора. В частности для оператора "=" это необходимо, чтобы обеспечить возможность последовательного присваивания (a=b=c). Бинарные операторы имеют один аргумент - второй передается через указатель this. Унарные, соответственно, один - this.

[к началу]

Передача в функции и возвращение объекта

Объекты можно передавать в функции в качестве аргументов точно так же, как передаются данные других типов. Всем этим мы уже пользовались, когда реализовывали методы класса_3d, в котором в качестве параметров методов projection(...), operator +(...) и operator = (...) передавали объект типа _3d.

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

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

class ClassName
{
public:
 ClassName ()
 {
  cout << 'Работа конструктора \n';
 }
 ~ClassName ()
 {
  cout << 'Работа деструктора \n';
 }
};

void f (ClassName o)
{
 cout << 'Работа функции f \n';
}

main()
{
 ClassName c1;
 f (c1);
}

Эта программа выполнит следующее

Работа конструктора
Работа функции f
Работа деструктора
Работа деструктора

Конструктор вызывается только один раз. Это происходит при создании с1. Однако деструктор срабатывает дважды: один раз для копии o, второй раз для самого объекта c1. Тот факт, что деструктор вызывается дважды, может стать потенциальным источником проблем, например, для объектов, деструктор которых высвобождает динамически выделенную область памяти.

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

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

class ClassName {
public:
 ClassName ()
 {
  cout << 'Работа конструктора \n';
 }
 ~ClassName ()
 {
  cout << 'Работа деструктора \n';
 }
};

ClassName f()
{
 ClassName obj;
 cout << 'Работа функции f \n';
 return obj;
}

main()
{
 ClassName c1;
 c1 = f();
}

Эта программа выполнит следующее

Работа конструктора
Работа конструктора
Работа функции f
Работа деструктора
Работа деструктора
Работа деструктора

Конструктор вызывается два раза: для с1 и obj. Однако деструкторов здесь три. Как же так? Понятно, что один деструктор разрушает с1, еще один - obj. "Лишний" вызов деструктора (второй по счету) вызывается для так называемого временного объекта, который является копией возвращаемого объекта. Формируется эта копия, когда функция возвращает объект. После того, как функция возвратила свое значение, выполняется деструктор временного объекта. Понятно, что если деструктор, например, высвобождает динамически выделенную память, то разрушение временного объекта приведет к разрушению возвращаемого объекта.

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

Любой конструктор копирования имеет следующую форму:

имя_класса (const имя_класса & obj)
{
 ... // тело конструктора
}

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

class ClassName
{
public:
 ClassName ()
 {
  cout << 'Работа конструктора \n';
 }
 ClassName (const ClassName& obj)
 {
  cout << 'Работа конструктора копирования\n';
 }
 ~ClassName ()
 {
  cout << 'Работа деструктора \n';
 }
};

main()
{
 ClassName c1; // вызов конструктора
 ClassName c2 = c1; // вызов конструктора копирования
}

Замечание: конструктор копирования не влияет на операцию присваивания.

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

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

[к началу]

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

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

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

Для получения адреса объекта, перед ним необходим оператор &.

main()
{
 _3d A (2,3,4);
 _3d *pA;
 pA = &A;
 double dM = pA->mod();
}

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

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

void ToZero (_3d *vec)

{
 vec->set (0,0,0); // используется " -> "
}

main()
{
 _3d A (2,3,4);
 ToZero (&A);
}

В С++ можно сделать то же самое, не используя указатели, с помощью параметра–ссылки.

void ToZero (_3d &vec)
{
 vec.set (0,0,0); // используется " . "
}

main()
{
 _3d A (2,3,4);
 ToZero (A);
}

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

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

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

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

_3d& _3d::operator = (_3d& b)

{
 x = b.x;
 y = b.y;
 z = b.z;
 return *this;
}

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

[к началу]
назад КОНЕЦ ПЯТОЙ СЕРИИ вперед