Лекция 11. Специальные функции-члены класса

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

1. Конструкторы

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

class Complex { private: double r, m; public: Complex(double r, double m) : r(r), m(m) {} ... };

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

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

Complex c1(5, -2); Complex c2 = {5, -2}; // Правильно // Ошибка – класс Complex имеет конструктор

1.1. Конструктор умолчания

Конструктор умолчания класса Х – это конструктор класса Х, вызываемый без параметров. Конструктор умолчания обычно имеет вид Х::Х(), однако и конструктор, который может вызываться без параметров, потому что имеет параметры с умолчанием, например, Х::Х(int = 0), также считается конструктором умолчания. При отсутствии других объявленных конструкторов, конструктор умолчания генерируется компилятором.

class Complex { private: double r, m; public: Complex() : r(0), m(0) {} ... };
Complex x; // Вызов конструктора умолчания
class Complex { private: double r, m; public: Complex(double nr = 0, double nm = 0) : r(nr), m(nm) {} ... };
Complex y1(-6, 3); // Вызов конструктора с параметрами
Complex y2; // Конструктор вызывается как конструктор умолчания

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

Конструктор копирования для класса Х – это конструктор, который может быть вызван для копирования объекта класса Х, т.е. такой конструктор, который может быть вызван с одним параметром – ссылкой на объект класса Х. Например, X::X(const X&) и X::X(X&, int = 0) являются конструкторами копирования.

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

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

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

Семантика этих операций по определению совпадает с семантикой инициализации.

Complex x = 2; Complex y = Complex(2, 0); // Создает Complex(2), затем копирует его в x // Создает Complex(2, 0), затем копирует его в у

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

Complex x(2); Complex y(2, 0); // Проинициализировать x значением 2 // Проинициализировать у значением (2, 0)

Пример конструктора копирования см. в примере в конце лекции.

2. Деструкторы

Функция-член класса Х с именем ~Х называется деструктором. Она используется для разрушения значения класса Х непосредственно перед разрушением содержащего его объекта. Деструктор не имеет параметров и возвращаемого типа, нельзя задавать даже void.

Деструкторы автоматически вызываются, когда

Деструктор может также вызываться явным образом.

class X { private: int n; public: X(); ~X(); };
X xx;
xx.~X(); // Явный вызов деструктора

3. Преобразования

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

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

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

3.1. Преобразование посредством конструктора

Конструктор с одним параметром задаёт преобразование типа своего параметра к типу своего класса.

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

class X { private: int x; public: X(int n); ... }; X::X(int n) { x = n; }
X a = 1; // Эквивалентно X a = X(1)

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

class Str { private: char *str; public: Str(int n) { str = new char [n]; *str = 0; } Str(const char *p) { str = new char [strlen(p) + 1]; strcpy(str, p); } ~Str() { if (str) delete [] str; } };
Str s = 'a'; // Создание строки из Хint('a') элементов

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

class Str { private: char *str; public: explicit Str(int n) { str = new char [n]; *str = 0; } Str(const char *p) { str = new char [strlen(p) + 1]; strcpy(str, p); } ~Str() { if (str) delete [] str; } };
Str s1 = 'a'; Str s2(10); // Ошибка – нет неявного преобразования char в Str // Правильно – создаётся строка из 10 символов

3.2. Преобразующие функции

Функция-член класса Х, имя которой имеет вид operator <имя типа>, определяет преобразование из Х в тип, заданный именем типа. Такие функции называются преобразующими функциями или функциями приведения.

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

class X { private: int x; public: X(int n); operator int(); ... }; X::X(int n) { x = n; } X::operator int() { return x; } int a; X b(0);
a = (int)b; // Явный вызов преобразующей функции
a = b; // Неявный вызов преобразующей функции

3.3. Разрешение неоднозначности

Присваивание значения типа V объекту класса X допустимо в том случае, если имеется оператор присваивания X::operator= (Z) такой, что V является Z или существует единственное преобразование V в Z. Инициализация рассматривается аналогично.

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

class X { ... X(int); X(char*); ... }; class Y { ... Y(int); ... }; class Z { ... Z(X); ... };
X f(X); Y f(Y); Z g(Z);
void main() { f(1); f(X(1)); f(Y(1)); g("Mask"); g(X("Mask")); g(Z("Mask")); } // Неоднозначность - f(X(1)) или f(Y(1))? // Правильно // Правильно // Ошибка – требуется применение двух преобразований, определённых пользователем // Правильно – g(Z(X("Mask"))) // Правильно – g(Z(X("Mask")))
class XX { XX(int); }; void h(double); void h(XX); void main()
{ h(1); } // h(double(1)) или h(XX(1))? Вызов h(1) означает h(double(1)), // потому что в этом случае используются только стандартные преобразования.

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

4. Примеры

4.1. Разработка класса для работы со стеком

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

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

class Stack { public: Stack(); ~Stack(); int Push(int n); int Pop(); int IsEmpty() const; int IsError() const; const char* LastError() const; }; // Конструктор // Деструктор // Добавление элемента в стек // Выбор элемента из стека // Проверка, пуст ли стек // Проверка, была ли ошибка // Функция, возвращающая строку описания ошибки

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

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

#include <stdio.h> class Stack { private: enum { SIZE = 100 }; enum { NO_ERROR, STACK_EMPTY, STACK_FULL }; int stack[SIZE]; int *cur; int error; public: Stack(); ~Stack(); int Push(int n); int Pop(); int IsEmpty() const; int IsError() const; const char* LastError() const; }; // Область действия константы, определённой с помощью define, - файл. // Чтобы локализовать область действия константы в классе, используют перечислимый тип // Массив для хранения элементов стека // Указатель на текущий элемент стека // Признак ошибки // Константные функции-члены класса могут применяться // как к константным, так и к неконстантным объектам
Stack::Stack() { cur = stack; error = NO_ERROR; } Stack::~Stack() { } int Stack::Push(int n) { if (cur - stack < SIZE) { *cur++ = n; error = NO_ERROR; return 1; } else { error = STACK_FULL; return 0; } } int Stack::Pop() { if (cur != stack) { error = NO_ERROR; return *--cur; } else { error = STACK_EMPTY; return 0; } } inline int Stack::IsEmpty() const { return cur == stack; } inline int Stack::IsError() const { return error != NO_ERROR; } const char* Stack::LastError() const { if (error == NO_ERROR) return "There is no error"; else if (error == STACK_EMPTY) return "Stack is empty"; else return "Stack is full"; }
int main() { Stack s; s.Push(1); s.Push(2); s.Push(3); while (!s.IsEmpty()) printf("%d\n", s.Pop()); printf("%d\n", s.Pop()); printf("%s\n", s.LastError()); for (int i = 0; i < 110; i++) s.Push(i); if (s.IsError()) printf("%s\n", s.LastError()); } // Ошибка – попытка взять элемента из пустого стека // Ошибка – попытка положить элемент в заполненный стек

Второй вариант

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

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

#include <stdio.h> #include <malloc.h> class Stack { private: enum { SIZE = 100 }; enum { NO_ERROR, STACK_EMPTY, NOT_ENOUGH_MEMORY }; int size; int *stack; int *cur; int error; public: Stack(); Stack(const Stack& s); // Конструктор копирования ~Stack(); int Push(int n); int Pop(); int IsEmpty() const; int IsError() const; const char* LastError() const; }; Stack::Stack() { size = SIZE; stack = NULL; if (stack = (int *)malloc(size * sizeof(int))) { cur = stack; error = NO_ERROR; } else { error = NOT_ENOUGH_MEMORY; size = 0; } } Stack::Stack(const Stack& s) { size = s.size; stack = NULL; error = NO_ERROR; if (size) if ((stack = (int *)malloc(size * sizeof(int))) == NULL) { error = NOT_ENOUGH_MEMORY; size = 0; } else for (int i = 0; i < size; i++) *(stack + i) = *(s.stack + i); cur = s.cur; } Stack::~Stack() { if (stack) free(stack); } int Stack::Push(int n) { if (!stack) return 0; if (cur - stack < size) { *cur++ = n; error = NO_ERROR; return 1; } else if (stack = (int *)realloc(stack, (size + SIZE) * sizeof(int))) { cur = stack + size; size += SIZE; *cur++ = n; error = NO_ERROR; return 1; } else { error = NOT_ENOUGH_MEMORY; size = 0; return 0; } } int Stack::Pop() { if (cur != stack) { error = NO_ERROR; return *--cur; } else { error = STACK_EMPTY; return 0; } } inline int Stack::IsEmpty() const { return cur == stack; } inline int Stack::IsError() const { return error != NO_ERROR; } const char* Stack::LastError() const { if (error == NO_ERROR) return "There is no error"; else if (error == STACK_EMPTY) return "Stack is empty"; else return "There is not enough memory"; }
int main() { Stack s; s.Push(1); s.Push(2); s.Push(3); while (!s.IsEmpty()) printf("%d\n", s.Pop()); printf("%d\n", s.Pop()); printf("%s\n", s.LastError()); for (int i = 0; i < 110; i++) s.Push(i); if (s.IsError()) printf("%s\n", s.LastError()); } // Ошибка – попытка взять элемента из пустого стека // Ошибка – попытка положить элемент в заполненный стек

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

4.2. Использование конструктора перемещения

class Vector { private: int size; int *vector; public: Vector(int s); Vector(const Vector &v); // Конструктор копирования Vector(Vector &&v) noexcept; // Конструктор перемещения ~Vector(); }; Vector::Vector(int s) { size = s; vector = new int [size]; } Vector::Vector(const Vector& v) : Vector() { size = v.size; vector = new int [size]; for (int i = 0; i < size; i++) vector[i] = v.vector[i]; } Vector::Vector(Vector&& v) noexcept { size = v.size; vector = v.vector; v.size = 0; v.vector = nullptr; } Vector::~Vector() { if (vector) delete[] vector; } // Функция f возвращает локальную переменную Vector f() { Vector v = Vector(7); return v; } // Используется конструктор перемещения, т.к. после выполнения функции локальные переменные удаляются int main() { Vector v = f(); }

4.3. Использование делегирующего конструктора

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

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

Рассмотрим пример создания класса для динамической матрицы. Если память частично будет выделена, а потом произойдёт ошибка, то выделенная память освобождена не будет. class Matrix { private: int rows, cols; int **matrix; public: Matrix(int r, int c); ~Matrix(); }; Matrix::Matrix(int r, int c) { rows = r; cols = c; matrix = new int* [rows]; for (int i = 0; i < rows; i++) matrix[i] = new int[cols]; } Matrix::~Matrix() { for (int i = 0; i < rows; i++) delete[] matrix[i]; delete[] matrix; }

Если же мы добавим конструктор умолчания и вызовем его из конструктора, создающего матрицу, то матрица будет считаться созданной уже после вызова конструктора умолчания, и деструктор будет вызван даже при преждевременном завершении конструктора (например, при возникновении ошибок при выделении памяти). class Matrix { private: int rows, cols; int **matrix; public: Matrix(); Matrix(int r, int c); ~Matrix(); }; Matrix::Matrix() { rows = cols = 0; matrix = nullptr; } Matrix::Matrix(int r, int c) : Matrix() { rows = r; cols = c; matrix = new int* [rows]; for (int i = 0; i < rows; i++) matrix[i] = nullptr; for (int i = 0; i < rows; i++) matrix[i] = new int[cols]; } Matrix::~Matrix() { for (int i = 0; i < rows; i++) delete[] matrix[i]; delete[] matrix; }