Гайд Move семантика, l-value и r-value ссылки

Начинающий
Статус
Оффлайн
Регистрация
17 Май 2023
Сообщения
11
Реакции[?]
10
Поинты[?]
10K
Привет!

На форуме уже был гайд по умным указателям, которые тесно связаны с тем, о чём пойдет речь в этой теме. Однако тут мы зайдём чуть дальше и рассмотрим такие вещи, как:

- l-value и r-value ссылки; их разница
- что такое семантика перемещения (move semantics)
- что такое семантика копирования (copy semantics)


В C++ каждое выражение принадлежит к определенной категории значений.

В стандарте C++03 и раньше их было всего два — l-value и r-value. В последствии их стало больше (glvalue, prvalue, xvalue и т. д.), однако я считаю, что это уже за рамками этой темы и мало кому пригодится. (кому интересно
Пожалуйста, авторизуйтесь для просмотра ссылки.
) я реально должен писать "noad” после каждой ссылки? я похож на того, кто будет рекламировать ёбаный справочник?

l-value (left-value)

По сути это просто любое значение, которое имеет свой собственный определенный адрес в памяти. То есть любая переменная — это l-value.

r-value (right-value)

r-value же это всё, что не является l-value!

- Анонимные объекты типа SomeClass(1, 10)
- x + 3
- 5

Всё это - r-value, так как им нельзя что-либо присвоить и в конце выражения где они находятся происходит их уничтожение.
C++:
int x{ 2 };

// x - это l-value, а литерал 3 - r-value
x = 3;

// низя, так как 3 - это r-value
3 = 58;

Раньше в плюсах существовал лишь один тип ссылок, однако в 2011 году с новым стандартом появился ещё один и теперь существует разделение — ссылки на l-value и ссылки на r-value. Первый тип, как все знают, создается с использованием одного амперсанда, а второй — с двумя.
C++:
size_t w{ 10 };

// инициализация ссылки на l-value переменной w
size_t& l_ref_w{ w };

// инициализация ссылки на r-value литералом 3
size_t&& r_ref{ 3 };

На самом деле, у r-value ссылок есть два полезных свойства:

- увеличивают продолжительность жизни объекта, которым они инициализируются до времени жизни самой ссылки
- дают возможность изменять r-value значения, на которые указывают (если это конечно не const ссылки)


Теперь рассмотрим интересный пример:
C++:
#include <iostream>

class CorpseClass
{
public:
    CorpseClass(int a, int b) : x(a), y(b) { };

    template<class T>
    auto quux(T& lref) -> void
    {
        std::cout << "l-value reference" << '\n';
    }

    template<class T>
    auto quux(T&& rref) -> void
    {
        std::cout << "r-value reference" << '\n';
    }

private:
    int x;
    int y;
};



int main(int argc, char* argv[])
{
    CorpseClass cc(1, 2);
    int a{ 8 };

    cc.quux(a);
    cc.quux(7);
    cc.quux(CorpseClass(7, 8));

    return 0;
}
Результат выполнения:
Код:
l-value reference
r-value reference
r-value reference
В данном случае поведение метода quux() зависит от того, какую ссылку мы ему передали - r-value или l-value. В первом и втором случае всё понятно — переменная a является l-value аргументом, литерал 7 же соответственно r-value аргументом. Вызывая же quux() в третий раз, мы передаём ему анонимный объект класса CorpseClass, который тоже является r-value. Я выделил методы в целый класс как раз для того, чтобы продемонстрировать все три примера.

Думаю мало для кого секрет, но стоит отметить, что и l-value ссылки и r-value ссылки почти никогда не стоит возвращать, так как есть опасность возвращения пустой ссылки (ссылка, которая указывает на удаленную память).
Давайте немного посоздаём велосипеды и напишем класс corpse_ptr, который будет копировать поведение одного из умных указателей auto_ptr (который вроде как вообще хотели удалить из языка)
C++:
#include <iostream>

template<class T>
class corpse_ptr
{
public:
    corpse_ptr(T* ptr = nullptr) : m_ptr(ptr) {}
    ~corpse_ptr() { delete m_ptr; }

    T& operator * () const { return *m_ptr; }
    T* operator -> () const { return m_ptr; }
private:
    T* m_ptr;
};


class CoolObject
{
public:
    CoolObject() { std::cout << "object created" << '\n'; }
    ~CoolObject() { std::cout << "object obliterated" << '\n'; }
};


int main(int argc, char* argv[])
{       
    // динамическое выделение памяти
    corpse_ptr<CoolObject> obj{ new CoolObject() };

    // ого! не нужно писать delete

    return 0;
}  // автоматическое уничтожение нашего объекта
Результат выполнения:
Код:
object created
object obliterated
У данной реализации есть некоторые проблемы:
C++:
auto pass_by_value(corpse_ptr<CoolObject> cobj) -> void {};

int main(int argc, char* argv[])
{
    corpse_ptr<CoolObject> cobj1{ new CoolObject };
    pass_by_value(cobj1);
    
    return 0;
}

Используя функцию pass_by_value с аргументом cobj1 наша программа выдаст исключение, так как при передаче аргумента произойдет поверхностное копирование, продублируется указатель и после выполнения pass_by_value и вызова деструктора у одного, другой будет ссылаться на удаленный ресурс в памяти.

Решить эту проблему можно, если мы переопределим конструктор копирования и оператор присваивания в corpse_ptr. По умолчанию они используют семантику копирования (копируют указатель), однако нам нужно переопределить их поведение таким образом, чтобы они использовали семантику перемещения. Это значит, что класс вместо копирования себя будет передавать владение объектом напрямую.

Обновим corpse_ptr так, чтобы он использовал семантику перемещения:
C++:
#include <iostream>

template<class T>
class corpse_ptr
{
public:
    corpse_ptr(T* ptr = nullptr) : m_ptr(ptr) {}
    ~corpse_ptr() { delete m_ptr; }

    // конструктор копирования, в котором используется семантика перемещения
    corpse_ptr(corpse_ptr& a)
    {
        m_ptr = a.m_ptr;
        a.m_ptr = nullptr;
    }

    // оператор присваивания, в котором используется семантика перемещения
    corpse_ptr& operator = (corpse_ptr& a)
    {
        if (&a == this) return *this;

        delete m_ptr;
        m_ptr = a.m_ptr;
        a.m_ptr = nullptr;
        return *this;
    }

    T& operator * () const { return *m_ptr; }
    T* operator -> () const { return m_ptr; }
    auto is_null() const -> bool { return m_ptr == nullptr; }
private:
    T* m_ptr;
};


class CoolObject
{
public:
    CoolObject() { std::cout << "object created" << '\n'; }
    ~CoolObject() { std::cout << "object obliterated" << '\n'; }
};


auto pass_by_value(corpse_ptr<CoolObject> cobj) -> void {};


int main(int argc, char* argv[])
{
    corpse_ptr<CoolObject> cobj1{ new CoolObject };
    pass_by_value(cobj1);

    return 0;
}
Результат выполнения:
C++:
object created
copy
object obliterated
В данном случае в момент вызова pass_by_value() вызывается наш переопределенный конструктор копирования и права на использование ресурса, которым владел cobj1, переходят к cobj, который находится внутри функции. После завершения выполнения pass_by_value() cobj уничтожается. Однако cobj1 после этого не остается с висячим указателем, так как в момент выполнения конструктора копирования мы его затерли. Таким образом, исключения не возникает.

Рассмотрим еще один пример, который уже демонстрирует работу переопределенного оператора присваивания:
C++:
template<class T>
auto swap(T& a, T& b) -> void
{
    T temp = a;

    a = b;
    b = temp;
}

int main(int argc, char* argv[])
{       
    corpse_ptr<CoolObject> cool_obj1(new CoolObject);
    corpse_ptr<CoolObject> cool_obj2;

    std::cout << "cool_obj1 is " << (cool_obj1.is_null() ? "null\n" : "not null\n");
    std::cout << "cool_obj2 is " << (cool_obj2.is_null() ? "null\n" : "not null\n");

    swap(cool_obj1, cool_obj2);

    std::cout << "swap\n";

    std::cout << "cool_obj1 is " << (cool_obj1.is_null() ? "null\n" : "not null\n");
    std::cout << "cool_obj2 is " << (cool_obj2.is_null() ? "null\n" : "not null\n");

    return 0;
}
Результат выполнения:
Код:
object created
cool_obj1 is not null
cool_obj2 is null
swap
cool_obj1 is null
cool_obj2 is not null
object obliterated

Как видим, наш велосипед работает на ура, никаких исключений не возникает и указатели плавно перетекают из одного объекта к другому. Возможно, для нашего случае это довольно неплохое решение. Однако, иногда приходится работать с более тяжелыми объектами и для таких случаев уже более 12 лет назад было придумано более оптимизированное решение из стандартной библиотеки языка:
C++:
#include <iostream>
#include <vector>
#include <utility>

template<class T>
auto swap(T& a, T& b) -> void
{
    T temp = std::move(a);

    a = std::move(b);
    b = std::move(temp);
}

int main(int argc, char* argv[])
{       
    std::vector<int> arr_a(1'000'000, 0);
    std::vector<int> arr_b(1'000'000, 1);

    swap(arr_a, arr_b);

    return 0;
}

Используя std::move (который находится в заголовке utility) мы можем избежать 3 млн копирований (!), которые бы произошли, если бы использовали старый вариант swap.

По сути, std::move просто преобразует l-value значение в r-value.

Однако этот код можно сделать ещё лучше, используя std::swap (который тоже находится в заголовке utility):
C++:
#include <iostream>
#include <vector>
#include <utility>

int main(int argc, char* argv[])
{       
    std::vector<int> arr_a(1'000'000, 0);
    std::vector<int> arr_b(1'000'000, 1);

    std::swap(arr_a, arr_b);

    return 0;
}
Пожалуй, это самый лучший способ поменять местами два таких тяжеловесных объекта.

Подводя итоги, можно сказать, что copy semantics — это традиционный способ передачи объектов в плюсах, а использование move semantics позволяет плавно перемещать ресурсы принадлежащие объекту-источнику в объект-получатель без операций копирования.
Честно говоря, не понимаю почему некоторые люди в интернете считают эту тему чем-то супер сложным.

В любом случае надеюсь, что моё второе сообщение на этом форуме кому-то поможет.
 
Сверху Снизу