Для продолжения наших рассуждений нам понадобится реализовать оператор operator+
для класса myvectror
(мы этого еще не сделали). Написать его не сложно:
/* myvector.h */
template<typename T>
class myvectror
{
…
public:
friend
myvector<T> operator+(const myvectror<T>& v1, const myvectror<T>& v2)
{
myvector<T> v(v1.sz);
for(int i = 0; i < v1.sz; i++)
v[i] = v1[i] + v2[i]
return v;
}
}
Теперь предположим, что в процессе создания программы нам потребовалось написать следующий код:
/* main.cpp */
myvector<int> v1(3), v2(3);
myvector<int> vv(v1 + v2);
В строке myvector<int> vv(v1 + v2);
сначала создается временный объект при вычислении значения v1 + v2
, а за тем вызывается конструктор копии myvector(myvector const & other)
в который в качестве значения передается, созданный ранее, временный объект. В результате дважды происходит копирование одного и того же объекта.
Нам уже известно, что на деле этого, скорее всего, не произойдет, благодаря, встроенной в большинство современных компиляторов, Return Value Optimization. Однако автоматическая оптимизация не всегда бывает эффективной, поэтому в стандарте C++11 Бьярн Страуструп предложил вынести RVO на уровень языка. Для этого были введены move-конструктор и move-operator=.
Выражение v1 + v2
из нашего примера называется rvalue.
Мы не можем записать v1 + v2 = v
, поэтому для того, чтобы как-то обращаться к rvalue используется ссылка на на него и обозначается с помощью двойного амперсанда &&, например T && t
Идея move-конструктора состоит в том, чтобы не удалять временный объект, а сделать в создаваемом объекте ссылки на поля временного объекта. Деструкторы для временных переменных вызываются в тот момент, когда эти переменные уже не используются для вычислений, поэтому необходимо так же позаботится о том, чтобы деструктор временного объекта вызывался при выходе из блока.
Реализация move-конструктора будет выглядеть следующим образом:
class myvector
{
…
public:
myvector(myvector<T>&& v)
{
sz = v.sz;
data = v.data;
// Не позволит сразу удалить временный объект
v.data = nullptr;
}
// Так же переписываем деструктор
~myvector()
{
if(data != nullptr)
delete[] data;
}
}
Теперь в строке myvector<int> vv(v1 + v2);
компилятор выберет move-конструктор вместо конструктора копии.
Аналогичная ситуация возникает при попытке выполнить такой код:
/* main.cpp */
myvector<int> v1(3), v2(3);
myvector<int> vv;
vv = v1 + v2;
Здесь v1 + v2
– это rvalue, а vv
– lvalue.
В этих случаях используется move-operator=, который реализуется следующим образом:
class myvector
{
…
public:
myvector<T>& operator=(myvector<T>&& v)
{
if(data != nullptr)
delete[] data;
sz = v.sz;
data = v.data;
v.data = nullptr;
return *this;
}
}
Таким образом move-конструктор и move-operator= решают вопрос о том, как уменьшить накладные расходы на копирование переменных.
Ввиду наличия большого количества стандартных классов использовать move-конструкторы приходится редко, однако знание такого механизма необходимо.
Как говорилось ранее существуют функции-члены, которые генерируются "молча", без явного описания. На практике, иногда, такие функции могут создать нежелательную функциональность, от которой нужно избавиться.
Для этих целей в C++ предусмотрен механизм запрета генерации стандартных конструкторов и функций. Рассмотрим его на примере класса A
:
class A
{
public:
A(int i) {…}
// Данная запись указывает на необходимость сгенерировать
// конструктор по умолчанию
A() = default;
// Запретить генерацию конструктора по умолчанию
A(const A&) = delete;
// А так можно запретить генерацию operator=
A& operator=(const A&) = delete;
}
Создадим новый класс frac
, который реализует работу с рациональными дробями.
Экземпляр класса frac
хранит число f в виде отношения m/n, где m и n целые числа типа int
.
В данном классе необходимо реализовать преобразование объекта frac
к типу double
. То есть frac f(1, 3);
будет эквивалентно double d = 1/3.0;
К примеру, если мы захотим создать функцию Gauss
, которая решала бы уравнение вида Ax = b методом Гаусса, нам необходимо, чтобы эта функция могла работать с классом frac
// T может быть равен double
// T может быть равен frac
template <typename T>
Gauss(const matrix<T> &A, const myvector<T> & b)
{
…
}
Конструктор класса принимает в качестве аргументов значения чисел m и n, при этом логично хранить дроби в несократимом виде, поэтому разделим поля m
и n
на их наибольший общий делитель.
Реализация функции operator+()
сводится к сложению двух отношений m1/n1 + m2/n2, при этом недопустимо прямое деление полей m
и n
, поэтому запишем формулу сложения дробей только с помощью операций сложения, умножения и целочисленного деления.
Важно помнить, что порядок операций для нас важен – результатом деления всегда должно быть целое число.
В итоге получим следующий код:
class frac
{
// n - натуральное
// m - целое
int m, n;
public:
frac(int mm = 0, int nn = 1): m(mm), n(nn)
{
int nd = nod(m, n);
m /= nd; n /= nd;
}
friend frac operator+(const frac f1, const frac f2)
{
int nd = nod(f1.n, f2.n);
return frac(f2.n/nd*f1.m + f1.n/nd*f2.m, f1.n/nd*f2.n)
}
// реализовать operator*
}
Посмотрим на те возможности которые можно реализовать с помощью frac
В реальной программе, работая с классом frac
, мы хотим писать так: frac f = 1;
.
То есть запись вида f = 2;
должна быть эквивалентна f = frac(2);
Для решения этой задачи существует специальный конструктор, который называется конструктором преобразования.
Любой конструктор, который может быть вызван с одним параметром является конструктором преобразования и служит для неявного преобразования параметра к типу объекта данного класса. Это значит, что в нашем случае этот конструктор уже реализован. То есть запись f = f1 + 3;
эквивалентна f = f1 + frac(3);
, а f = 2 * f2;
преобразуется к f = frac(2) * f2;
~ f = frac(2,1) * f2;
Однако обратим внимание на запись frac(2) * f2
, здесь умножить целое число на дробь эффективнее, чем делать преобразование frac(2,1)
и умножать дробь на дробь.
Поэтому, для ускорения работы перегрузим operator*
:
…
friend
frac operator*(int n, const frac &f)
{
return frac(n * f.m, f.n);
}
…
Рассмотрим такой код:
myvector<int> v(10), v1(10); // 10 нулей
v1 = v + 1;
Понятно, что делая запись v1 = v + 1;
мы подразумеваем увеличение размера вектора на единицу и как следствие получение в v1
нового вектора из 11 нулей.
На деле v1 = v + 10;
эквивалентно v1 = v + myvector<int>(10);
. То есть мы получим еще один вектор из 10 нулей.
Для того чтобы избежать данной ошибки, необходимо запретить преобразовывать 10 к myvector<int>(10)
. Для этого существуют явные конструкторы преобразования.
Явный конструктор преобразования задается с помощью ключевого слова explicit, поэтому его еще называют explicit-конструктор.
class myvector
{
…
public:
explicit myvector(int n) {}
}
Теперь компилятор запретит выражения типа v1 = v + 1;
. Если же необходимо сложить два вектора, тогда надо явно указывать выполняемую операцию как v1 = v + myvector<int>(10);
.
Часто при разработке новых классов появляется желание приводить уже существующие типы к новому, и наоборот. Попробуем написать следующий код:
frac f = frac(2, 3);
double d = f;
В данном случае операция double d = f
не сработает. Необходимо определить оператор приведения типа operator double()
, для того, чтобы f
была эквивалентна double(f)
class frac
{
int m, n;
public:
operator double()
{
return m/(double)n;
}
};
Теперь, в силу того, что operator double()
является inline, запись double d = f;
будет заменяться на double d = f.m/(double)f.n;