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

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

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

Функция-член класса с тем же именем, что и у класса, называется конструктором. Она используется для построения объектов этого класса. Конструктор не должен возвращать никакого значения, даже void. class Complex { private: double r, m; public: Complex(double r, double m) : r(r), m(m) {} ... };

Если класс имеет конструктор, все объекты этого класса будут проинициализированы. Если конструктору требуются параметры, они должны быть предоставлены. Complex c1(5, -2);

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

Запись : <имя члена класса>(<значение>) [, …] называется списком инициализации (членов класса). С его помощью выполняется именно инициализация членов класса. Это может быть принципиально, например, для константных членов класса. class X { private: const double x; public: X(double _x) { x = _x; } // Ошибка X(double _x) : x(_x) { } // Правильно ... };

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

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(2), затем копирует его в x Complex y = Complex(2, 0); // Создает Complex(2, 0), затем копирует его в у

От вызовов конструктора копирования легко избавиться. С тем же успехом можно записать следующее. Complex x(2); // Проинициализировать x значением 2 Complex y(2, 0); // Проинициализировать у значением (2, 0)

1.3. Конструктор перемещения

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

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

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

Как это следует из названия, конструктор перемещения используется для реализации семантики перемещения, т.е. для осуществления перемещения данных во время инициализации и конструирования новых объектов, что позволяет сократить издержки на копирование. Семантика перемещения даёт широкие возможности для оптимизации внутреннего кода вызовов функций. Эта оптимизация достигается отказом от копирования данных при создании временных объектов, у которых отсутствует необходимость сохранять свои внутренние ресурсы для дальнейшего использования. 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) { 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(); }

1.4. Делегирующий конструктор

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

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

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

Рассмотрим пример создания класса для динамической матрицы. Если память частично будет выделена, а потом произойдёт ошибка, то выделенная память освобождена не будет. 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() { if (!matrix) return; for (int i = 0; i < rows; i++) if (matrix[i]) delete[] matrix[i]; delete[] matrix; }

1.5. Инициализация членов класса

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

Действия выполняются именно в таком порядке, т.е. если использовать два способа, то следующий будет аннулировать результаты предыдущего. class X { private: int a = 1; int b = int(); int c = int(2); int d = {3}; int e {}; int f {4}; public: X() : a(3), b(2) { } // a = 3, b = 2 X(int x) : a(3), b(2) { b = x; } // a = 3, b инициализируется параметром конструктора };

Ещё один важный момент – члены класса инициализируются в том порядке, в котором они объявлены, не зависимо от порядка в списке инициализации. Поэтому лучше в списке инициализации указывать члены класса в таком же порядке, в каком они объявлены. И совсем не допустимо создавать какие-либо зависимости между инициализацией разных членов класса. class X { private: int a, b; public: X() : b(5), a(b) { } };

В приведённом выше примере сначала инициализируется член класса a. Инициализируется он значением члена класса b, который к этому моменту ещё не проинициализирован несмотря на то, что в списке инициализации член класса b указан ранее. Поэтому член класса a получает неопределённое значение.

Однако в теле конструктора присваивания выполняются в том порядке, в каком они записаны. class X { private: int a, b; public: X() { b = 5; a = b; } };

Нестатические константные члены класса могут быть проинициализированы непосредственно при объявлении или в списке инициализации. Присвоить им значение в конструкторе нельзя. Если не проинициализировать константный член класса при объявлении, то компилятор потребует определить конструктор, который будет инициализировать его в списке инициализации, иначе будет выдаваться сообщение об ошибке. class X { private: const int a = 10; }; class X { private: const int a; public : X() : a(10) { } };

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

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

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

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

Деструктор может также вызываться явным образом. class X { private: int n; public: X(); ~X(); }; X x; x.~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 + 1]; *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 + 1]; *str = 0; } Str(const char *p) { str = new char [strlen(p) + 1]; strcpy(str, p); } ~Str() { if (str) delete[] str; } }; Str s1 = 'a'; // Ошибка – нет неявного преобразования char в Str Str s2(10); // Правильно – создаётся строка из 10 символов

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

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

Для такой функции не могут быть заданы ни параметры, ни возвращаемый тип. Но, на самом деле, возвращаемое значение у неё есть, его тип совпадает с типом, к которому осуществляется преобразование. Соответственно, функция должна иметь оператор return. 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); int main() { f(1); // Неоднозначность – f(X(1)) или f(Y(1))? f(X(1)); // Правильно f(Y(1)); // Правильно g("Mask"); // Ошибка – требуется применение двух преобразований, определённых пользователем g(X("Mask")); // Правильно – g(Z(X("Mask"))) g(Z("Mask")); // Правильно – g(Z(X("Mask"))) } class T { T(int); }; void h(double); void h(T); int main() { h(1); } // h(double(1)) или h(T(1))? Вызов h(1) означает h(double(1)) // потому что в этом случае используются только стандартные преобразования

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

4. Особенности создания конструкторов

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

Этот набор является базовым, без этих функций невозможны создание объектов класса и работа с ними. class Pair { private: int x, y; }; int main() { Pair a; // Вызывается конструктор умолчания Pair b(a); // Вызывается конструктор копирования b = a; // Вызывается операция присваивание копированием b = Pair(); // Вызывается операция присваивание перемещением } // Деструктор вызывается неявно

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

Второе правило ослабляется для совместимости со старыми версиями – копирующие операции генерируются несмотря на явное объявление деструктора. class Pair { private: int x, y; public: Pair(int _x, int _y) : x(_x), y(_y) { } }; int main() { Pair a; // Ошибка – нет конструктора умолчания } class Pair { private: int x, y; public: Pair(const Pair& p) : x(p.x), y(p.y) { } }; int main() { Pair a; // Ошибка – нет конструктора умолчания }

Что делать, если нужны все функции? Можно явно написать конструктор умолчания или другую нужную функцию. А можно использовать ключевое слово default, которое указывает компилятору, что нужно создать стандартный вариант той или иной функции. class Pair { private: int x, y; public: Pair() = default; Pair(int _x, int _y) : x(_x), y(_y) { } Pair(const Pair& p) = default; }; int main() { Pair a; Pair b(a); Pair c(2, 5); c = a; c = Pair(3, 9); } class Pair { private: int x, y; public: Pair() = default; Pair(int _x, int _y) : x(_x), y(_y) { } Pair(const Pair& p) = default; Pair& operator= (const Pair& p) { x = p.x; y = p.y; } // Операция присваивания копированием }; int main() { Pair a; Pair b(a); Pair c(2, 5); c = a; c = Pair(3, 9); // Ошибка – нет операции присваивания перемещением } class Pair { private: int x, y; public: Pair() = default; Pair(int _x, int _y) : x(_x), y(_y) { } Pair(const Pair& p) = default; Pair& operator= (const Pair& p) { x = p.x; y = p.y; } Pair& operator= (Pair&& p) = default; // Операция присваивания перемещением }; int main() { Pair a; Pair b(a); Pair c(2, 5); c = a; c = Pair(3, 9); }

Отличие между стандартным конструктором умолчания и пустым конструктором умолчания, написанным явно, проявляется при инициализации членов класса. Стандартный конструктор умолчания не инициализирует члены класса базовых типов, и при создании переменной она будет считаться неинициализированной, что приведёт к ошибке компиляции. Пустой конструктор умолчания тоже не инициализирует поля базовых типов, но компилятор считает, что программист сделал, что хотел, и ошибок не выдаёт. class Pair { private: int x, y; public: Pair() = default; }; int main() { Pair a; // Ошибка } class Pair { private: int x, y; public: Pair() { }; }; int main() { Pair a; // Нет ошибки } class Pair { private: int x, y; public: Pair() = default; }; int main() { Pair a; // Нет ошибки }

При желании можно явно отказаться от использования в классе того или иного стандартного конструктора с помощью ключевого слова delete. class Pair { private: int x, y; public: Pair() = default; Pair(int _x, int _y) : x(_x), y(_y) { } Pair(const Pair& p) = delete; // Теперь объект класса нельзя скопировать или присвоить Pair& operator= (const Pair& p) = delete; };

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

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

4. Пример «Разработка стека»

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

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

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

Кроме этого, необходимо, естественно, определить функции, входящие в класс. Простейшие из них напишем в определении класса, чтобы они стали встраивымыми. После этого можно использовать разработанный класс. #include <stdio.h> class Stack { private: // Область действия константы, определённой с помощью define, - файл. // Чтобы локализовать область действия константы в классе, можно использовать перечислимый тип enum { SIZE = 100 }; enum { NO_ERROR, STACK_EMPTY, STACK_FULL }; int stack[SIZE]; // Массив для хранения элементов стека int *cur; // Указатель на текущий элемент стека int error; // Признак ошибки public: Stack() { cur = stack; error = NO_ERROR; } int Push(int n); int Pop(); int IsEmpty() const { return cur == stack; } // Константные функции-члены класса могут применяться int IsError() const { return error != NO_ERROR; } // как к константным, так и к неконстантным объектам const char* LastError() const; }; 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; } } 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(); Stack(const Stack& s) = delete; // Удаляем конструктор копирования Stack& operator= (const Stack& s) = delete; // Удаляем операцию присваивания int Push(int n); int Pop(); int IsEmpty() const { return cur == stack; } int IsError() const { return error != NO_ERROR; } 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() { 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; } } 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()); // Ошибка – попытка положить элемент в заполненный стек }

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

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