Лекция 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}; |
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 x(2);
Complex y(2, 0); |
Пример конструктора копирования см. в примере в конце лекции.
2. Деструкторы
Функция-член класса Х с именем ~Х называется деструктором. Она используется для разрушения значения класса Х непосредственно перед разрушением содержащего его объекта. Деструктор не имеет параметров и возвращаемого типа, нельзя задавать даже void.
Деструкторы автоматически вызываются, когда
- автоматический или временный объект уходит из области действия;
- завершается программа (для статически сконструированных объектов);
- используется операция delete для объектов размещенных операцией new.
Деструктор может также вызываться явным образом.
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; |
Однако неявное преобразование может быть нежелательно в некоторых случаях.
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'; |
Неявное преобразование можно подавить, объявив конструктор с модификатором 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); |
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"));
}
|
class XX { XX(int); };
void h(double);
void h(XX);
void main() |
{ h(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;
}; |
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; }
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;
}