Лекция 14

move-конструкторы и move-operator= (C++11)

Для продолжения наших рассуждений нам понадобится реализовать оператор 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, а vvlvalue.

В этих случаях используется 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, который реализует работу с рациональными дробями.

Экземпляр класса 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;

  1. На главную