C++

R-value посилання та move-семантика

L-values vs r-values, rvalue references (&&), конструктор переміщення, оператор присвоювання переміщенням, std::move(), Rule of Five. Ефективне управління ресурсами через семантику переміщення.

R-value посилання та move-семантика

Мотивація: неефективність копіювання

У попередніх статтях ми навчилися створювати класи з глибоким копіюванням (deep copy) — безпечною, але затратною операцією. Розглянемо типовий сценарій:

ExpensiveCopy.cpp
#include <iostream>
#include <cstring>

class String
{
private:
    char* m_data;
    size_t m_length;

public:
    String(const char* str = "")
    {
        m_length = std::strlen(str);
        m_data = new char[m_length + 1];
        std::strcpy(m_data, str);
        std::cout << "Constructed: " << m_data << "\n";
    }

    ~String()
    {
        delete[] m_data;
        std::cout << "Destroyed\n";
    }

    // Конструктор копіювання — виконує ГЛИБОКЕ копіювання
    String(const String& other)
    {
        m_length = other.m_length;
        m_data = new char[m_length + 1];
        std::strcpy(m_data, other.m_data);
        std::cout << "Copied: " << m_data << "\n";
    }

    // Оператор присвоювання копіюванням
    String& operator=(const String& other)
    {
        if (this == &other)
            return *this;

        delete[] m_data;

        m_length = other.m_length;
        m_data = new char[m_length + 1];
        std::strcpy(m_data, other.m_data);
        std::cout << "Assigned: " << m_data << "\n";

        return *this;
    }

    const char* getData() const { return m_data; }
};

String createTemporary()
{
    String temp("Temporary object");
    return temp;  // Повертається по значенню
}

int main()
{
    String str;
    str = createTemporary();  // Неефективно: створюється + копіюється + знищується
    
    std::cout << "Final: " << str.getData() << "\n";
    return 0;
}
./ExpensiveCopy
Constructed:
Constructed: Temporary object
Assigned: Temporary object
Destroyed
Final: Temporary object
Destroyed

Проблема: Функція createTemporary() створює тимчасовий об'єкт, який одразу копіюється в str і потім знищується. Ми виконуємо дорогу операцію виділення пам'яті (new) та копіювання даних для об'єкта, який одразу перестане існувати.

Питання: чи не можемо ми просто «вкрасти» ресурси тимчасового об'єкта замість їх копіювання? Саме для цього існує семантика переміщення (move semantics).

L-values та r-values: точне визначення

До C++11 розмежування l-values та r-values було інтуїтивним. Тепер воно стає фундаментальним для розуміння семантики переміщення.

Що таке l-value?

L-value (locator value) — вираз, який вказує на конкретну область пам'яті, яку можна ідентифікувати за адресою. Про l-value найпростіше думати як про іменовані змінні, об'єкти або функції.

LValues.cpp
int x = 10;             // x — це l-value (має адресу)
int* ptr = &x;          // можна взяти адресу x

std::string name = "C++";  // name — це l-value
std::string& ref = name;   // посилання на l-value

int arr[5];             // arr — це l-value
int* p = arr;           // можна взяти адресу масиву

Ключова властивість l-value: можна взяти адресу через &.

Що таке r-value?

R-value — вираз, який не має стабільної адреси в пам'яті. Це:

  • Літерали (42, 3.14, "hello")
  • Тимчасові об'єкти (String("temp"))
  • Результати обчислень (x + y, func())
RValues.cpp
int y = 42;             // 42 — це r-value (літерал)
// int* bad = &42;      // ❌ ПОМИЛКА: не можна взяти адресу літералу

std::string fullName = std::string("John") + " Doe";  
// std::string("John") + " Doe" — це r-value (тимчасовий результат)

int calculate() { return 5 + 10; }
int z = calculate();    // calculate() повертає r-value

🔹 L-value

  • Має стабільну адресу в пам'яті
  • Можна використовувати зліва та справа від =
  • Приклади: змінні, елементи масиву, посилання

🔸 R-value

  • Немає постійної адреси
  • Можна використовувати тільки справа від =
  • Тимчасові, знищуються після виразу

Категорії значень у C++11

Стандарт C++11 розширив систему категорій значень:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor transparent
skinparam componentStyle rectangle

package "Expression Value Categories" {
    [expression] as expr
    [glvalue] as gl
    [rvalue] as rv
    [lvalue] as lv
    [xvalue] as xv
    [prvalue] as prv
}

expr --> gl
expr --> rv
gl --> lv
gl --> xv
rv --> xv
rv --> prv

note right of lv
  Іменовані змінні,
  об'єкти з адресою
end note

note right of prv
  Літерали, тимчасові
  об'єкти
end note

note right of xv
  "eXpiring values" —
  об'єкти, готові до
  переміщення
end note

@enduml
Для практичного використання достатньо запам'ятати:
  • l-value — змінні та іменовані об'єкти
  • r-value — тимчасові значення та результати виразів

Посилання l-value (традиційні посилання)

До C++11 існував лише один тип посилань — посилання l-value (&):

LValueReferences.cpp
int x = 10;
int& lref = x;          // ✅ OK: посилання на l-value

// int& bad = 42;       // ❌ ПОМИЛКА: не можна прив'язати до r-value

Константні посилання l-value

Константне посилання l-value (const &) — особливий випадок: може бути ініціалізоване як l-value, так і r-value:

ConstLValueRef.cpp
int x = 10;
const int& cref1 = x;       // ✅ OK: посилання на l-value
const int& cref2 = 42;      // ✅ OK: посилання на r-value (створюється тимчасовий об'єкт)
const int& cref3 = x + 5;   // ✅ OK: посилання на результат виразу
Важливо: константне посилання продовжує час життя тимчасового об'єкта до кінця області видимості самого посилання:
const std::string& ref = std::string("Hello");  // тимчасовий об'єкт живе, поки існує ref
Посилання l-valueМожуть бути ініціалізовані l-valueМожуть бути ініціалізовані r-valueМожна змінювати
int&✅❌✅
const int&✅✅❌

Посилання r-value: нова можливість C++11

Посилання r-value — це новий тип посилань, що був доданий у C++11. Вони створюються через подвійний амперсанд (&&) і можуть бути ініціалізовані лише r-values:

RValueReferences.cpp
int x = 10;

// int&& bad = x;           // ❌ ПОМИЛКА: не можна прив'язати r-value посилання до l-value
int&& rref1 = 42;           // ✅ OK: посилання на літерал (r-value)
int&& rref2 = x + 5;        // ✅ OK: посилання на результат виразу (r-value)
int&& rref3 = 10 * 2;       // ✅ OK: посилання на результат обчислення

std::cout << rref1 << "\n"; // 42
rref1 = 100;                 // ✅ Можна змінювати через неконстантне r-value посилання!
std::cout << rref1 << "\n"; // 100
Посилання r-valueМожуть бути ініціалізовані l-valueМожуть бути ініціалізовані r-valueМожна змінювати
int&&❌✅✅
const int&&❌✅❌

Дві ключові властивості r-value посилань

  1. Продовження часу життя об'єкта:
    Як і константні l-value посилання, r-value посилання продовжують час життя тимчасового об'єкта до кінця області видимості самого посилання.
  2. Можливість модифікації:
    Неконстантні r-value посилання дозволяють змінювати тимчасові об'єкти, на які вони вказують.
RValueLifetime.cpp
#include <iostream>

class Demo
{
public:
    Demo() { std::cout << "Constructed\n"; }
    ~Demo() { std::cout << "Destroyed\n"; }
    
    void show() { std::cout << "Still alive!\n"; }
};

int main()
{
    {
        Demo&& rref = Demo();  // Тимчасовий об'єкт прив'язується до r-value посилання
        rref.show();           // Можемо викликати методи
    }  // rref виходить з області видимості → об'єкт знищується тут
    
    std::cout << "End of main\n";
    return 0;
}
./RValueLifetime
Constructed
Still alive!
Destroyed
End of main
Навіщо r-value посилання? Вони дозволяють компілятору розрізняти виклики функцій з тимчасовими об'єктами та з постійними змінними — основа семантики переміщення.

Перевантаження функцій за типом посилання

Завдяки r-value посиланням ми можемо створювати різні версії функцій для l-values та r-values:

OverloadByReference.cpp
#include <iostream>

void process(const int& lref)  // Версія для l-values
{
    std::cout << "L-value reference: " << lref << "\n";
}

void process(int&& rref)       // Версія для r-values
{
    std::cout << "R-value reference: " << rref << "\n";
}

int main()
{
    int x = 10;
    
    process(x);          // Викликається версія з l-value reference
    process(42);         // Викликається версія з r-value reference
    process(x + 5);      // Викликається версія з r-value reference
    
    return 0;
}
./OverloadByReference
L-value reference: 10
R-value reference: 42
R-value reference: 15
Процес підбору перевантаження
Filter
NameTypeValue
ВикликТип аргументуОбрана функція
process(x)int (l-value)process(const int&)
process(42)int (r-value, літерал)process(int&&)
process(x + 5)int (r-value, вираз)process(int&&)
Running
Process: 12842
Пастка: Якщо версія з r-value посиланням відсутня, компілятор викличе версію з const int& (константне l-value посилання може прийняти r-value). Якщо ж відсутня версія з const int&, то виклик з l-value не скомпілюється.

Повернення r-value посилань із функцій

Майже ніколи не слід повертати r-value посилання з функції з тієї ж причини, з якої не варто повертати l-value посилання — ви отримаєте висяче посилання (dangling reference):

DanglingRValue.cpp
#include <iostream>

int&& createDangling()
{
    int temp = 42;
    return std::move(temp);  // ❌ НЕБЕЗПЕЧНО: temp знищується після return
}

int main()
{
    int&& ref = createDangling();  
    std::cout << ref << "\n";      // ⚠️ Невизначена поведінка: доступ до видаленої пам'яті
    
    return 0;
}
Правило безпеки: Повертайте r-value посилання лише якщо об'єкт, на який ви посилаєтесь, має час життя, що перевищує час життя функції. На практиці це означає: повертайте об'єкти по значенню, а не через r-value посилання.

Конструктор переміщення та оператор присвоювання переміщенням

Тепер, коли ми розуміємо r-value посилання, можемо реалізувати семантику переміщення у власних класах. Для цього потрібні дві нові спеціальні функції:

  • Конструктор переміщення (move constructor)
  • Оператор присвоювання переміщенням (move assignment operator)

Сигнатури

ClassName(ClassName&& other) noexcept;              // Конструктор переміщення
ClassName& operator=(ClassName&& other) noexcept;   // Оператор присвоювання переміщенням
Важливо: Обидві функції приймають неконстантне r-value посилання (&&), оскільки їм потрібно модифікувати вихідний об'єкт (забрати у нього ресурси).

Приклад: клас із семантикою переміщення

Повернемося до нашого класу String і додамо конструктор переміщення та оператор присвоювання переміщенням:

StringWithMove.cpp
#include <iostream>
#include <cstring>

class String
{
private:
    char* m_data;
    size_t m_length;

public:
    // Конструктор
    String(const char* str = "")
    {
        m_length = std::strlen(str);
        m_data = new char[m_length + 1];
        std::strcpy(m_data, str);
        std::cout << "Constructed: " << m_data << "\n";
    }

    // Деструктор
    ~String()
    {
        delete[] m_data;
        std::cout << "Destroyed\n";
    }

    // Конструктор копіювання (глибоке копіювання)
    String(const String& other)
    {
        m_length = other.m_length;
        m_data = new char[m_length + 1];
        std::strcpy(m_data, other.m_data);
        std::cout << "Copied: " << m_data << "\n";
    }

    // Оператор присвоювання копіюванням
    String& operator=(const String& other)
    {
        if (this == &other)
            return *this;

        delete[] m_data;

        m_length = other.m_length;
        m_data = new char[m_length + 1];
        std::strcpy(m_data, other.m_data);
        std::cout << "Copy assigned: " << m_data << "\n";

        return *this;
    }

    // ========== MOVE SEMANTICS ==========

    // Конструктор переміщення
    String(String&& other) noexcept
        : m_data(other.m_data), m_length(other.m_length)
    {
        // Забираємо ресурси у вихідного об'єкта
        other.m_data = nullptr;
        other.m_length = 0;
        std::cout << "Moved (constructor)\n";
    }

    // Оператор присвоювання переміщенням
    String& operator=(String&& other) noexcept
    {
        if (this == &other)
            return *this;

        // Звільняємо власні ресурси
        delete[] m_data;

        // Забираємо ресурси у вихідного об'єкта
        m_data = other.m_data;
        m_length = other.m_length;

        // Залишаємо вихідний об'єкт у "нульовому" стані
        other.m_data = nullptr;
        other.m_length = 0;

        std::cout << "Move assigned\n";
        return *this;
    }

    const char* getData() const { return m_data ? m_data : ""; }
};

String createTemporary()
{
    String temp("Temporary object");
    return temp;  // Використовується конструктор переміщення
}

int main()
{
    String str;
    str = createTemporary();  // Використовується оператор присвоювання переміщенням
    
    std::cout << "Final: " << str.getData() << "\n";
    return 0;
}
./StringWithMove
Constructed:
Constructed: Temporary object
Move assigned
Destroyed
Final: Temporary object
Destroyed

📝 Копіювання (Copy)

  • Виділяється нова пам'ять
  • Дані копіюються з джерела
  • Джерело залишається незмінним
  • Використовується для l-values

🚀 Переміщення (Move)

  • Пам'ять не виділяється
  • Вказівники переприсвоюються
  • Джерело стає порожнім (nullptr)
  • Використовується для r-values

Анатомія конструктора переміщення

Розберемо конструктор переміщення детально:

String(String&& other) noexcept
    : m_data(other.m_data), m_length(other.m_length)  // 1️⃣ Поверхневе копіювання вказівників
{
    other.m_data = nullptr;    // 2️⃣ Обнуляємо вказівник джерела
    other.m_length = 0;        // 3️⃣ Обнуляємо довжину джерела
}

Крок 1: Копіюємо вказівник m_data — це швидка операція (копіювання адреси, а не даних).
Крок 2-3: Обнуляємо вихідний об'єкт — критично! Інакше при його знищенні буде видалена пам'ять, якою тепер володіє новий об'єкт.

Переміщення ресурсів
Hex Dump / ASCII
Empty memory block
Offset: 0 bytes
Big Endian

До переміщення:

other.m_data → [0x00401000] → "Temporary object"
this.m_data  → nullptr

Після переміщення:

other.m_data → nullptr
this.m_data  → [0x00401000] → "Temporary object"

Анатомія оператора присвоювання переміщенням

String& operator=(String&& other) noexcept
{
    if (this == &other)        // 1️⃣ Перевірка самоприсвоювання
        return *this;

    delete[] m_data;           // 2️⃣ Звільняємо власні ресурси

    m_data = other.m_data;     // 3️⃣ Забираємо вказівник
    m_length = other.m_length;

    other.m_data = nullptr;    // 4️⃣ Обнуляємо джерело
    other.m_length = 0;

    return *this;
}

Крок 1: Захист від x = std::move(x) — хоча це рідкісний випадок.
Крок 2: Звільняємо старі дані (якщо об'єкт вже щось містив).
Крок 3: Переміщуємо вказівник з other у this.
Крок 4: Обнуляємо other, щоб його деструктор не видалив дані.

Критична помилка: якщо забути обнулити other.m_data, обидва об'єкти вказуватимуть на одну пам'ять → подвійне видалення (double delete) → crash.

Коли викликаються конструктор переміщення та оператор присвоювання переміщенням?

Компілятор автоматично обирає версію з переміщенням, коли аргументом є r-value:

WhenMoveIsCalled.cpp
#include <iostream>
#include <utility>  // для std::move

class Demo
{
public:
    Demo() { std::cout << "Default ctor\n"; }
    Demo(const Demo&) { std::cout << "Copy ctor\n"; }
    Demo(Demo&&) noexcept { std::cout << "Move ctor\n"; }
    Demo& operator=(const Demo&) { std::cout << "Copy assignment\n"; return *this; }
    Demo& operator=(Demo&&) noexcept { std::cout << "Move assignment\n"; return *this; }
};

Demo createObject()
{
    return Demo();  // Повертається тимчасовий об'єкт (r-value)
}

int main()
{
    Demo obj1;
    Demo obj2 = obj1;          // Копіювання (l-value)
    Demo obj3 = createObject(); // Переміщення (r-value від функції)
    
    Demo obj4;
    obj4 = obj1;               // Копіююче присвоювання (l-value)
    obj4 = createObject();     // Переміщуюче присвоювання (r-value від функції)
    
    return 0;
}
./WhenMoveIsCalled
Default ctor
Copy ctor
Default ctor
Move ctor
Default ctor
Copy assignment
Default ctor
Move assignment
Правило автоматичного вибору:
Якщо аргумент — r-value (тимчасовий об'єкт, літерал, результат виразу), компілятор викличе версію з &&.
Якщо аргумент — l-value (іменована змінна), компілятор викличе версію з const &.

Функція std::move(): явне перетворення l-value в r-value

Проблема: що робити, якщо ми хочемо перемістити іменовану змінну (l-value)?

Наприклад:

String source("Data");
String destination = source;  // Копіювання — source є l-value

Тут викликається конструктор копіювання, хоча ми могли б хотіти перемістити дані з source, якщо він нам більше не потрібен.

Рішення: std::move()

Функція std::move() (з заголовка <utility>) — це перетворення типу (cast), яке конвертує l-value у r-value:

StdMove.cpp
#include <iostream>
#include <utility>  // для std::move

class String
{
    // ... (попередня реалізація) ...
};

int main()
{
    String source("Original data");
    
    // Копіювання (source — l-value)
    String destination1 = source;
    std::cout << "Source after copy: " << source.getData() << "\n";
    
    // Переміщення (std::move(source) перетворює source у r-value)
    String destination2 = std::move(source);
    std::cout << "Source after move: " << source.getData() << "\n";  // Порожній рядок!
    
    return 0;
}
./StdMove
Constructed: Original data
Copied: Original data
Source after copy: Original data
Moved (constructor)
Source after move:
Destroyed
Destroyed
Destroyed
Важливе застереження про std::move():
Після виклику std::move(x) змінна x залишається у валідному, але невизначеному стані. Єдине, що гарантовано безпечно робити з x після std::move():
  • Присвоїти йому нове значення: x = newValue;
  • Знищити його (викликати деструктор)
НЕ використовуйте x після std::move() для читання або інших операцій, якщо не присвоїли йому нове значення!

Семантична нотація: std::move() — це не переміщення

Назва std::move() — це семантична підказка. Насправді функція:

  • Не переміщує дані
  • Не виконує жодних операцій з пам'яттю
  • Лише приводить тип до r-value посилання

Фактичне переміщення відбувається у конструкторі переміщення або операторі присвоювання переміщенням:

String destination = std::move(source);
//                   └────┬────┘
//            перетворює source у r-value
//                        ↓
//           викликається String(String&& other)
//                        ↓
//             ТУТ відбувається переміщення
Внутрішня реалізація std::move() (спрощено):
template<typename T>
typename std::remove_reference<T>::type&& move(T&& arg) noexcept
{
    return static_cast<typename std::remove_reference<T>::type&&>(arg);
}
Це просто static_cast до r-value посилання.

Практичний приклад: оптимізація swap()

Раніше (стаття 66) ми писали функцію swap() через копіювання. Тепер можемо оптимізувати її через переміщення:

SwapOptimization.cpp
#include <iostream>
#include <utility>

template<typename T>
void swapCopy(T& a, T& b)
{
    T temp = a;      // Копіювання
    a = b;           // Копіювання
    b = temp;        // Копіювання
}

template<typename T>
void swapMove(T& a, T& b)
{
    T temp = std::move(a);   // Переміщення
    a = std::move(b);        // Переміщення
    b = std::move(temp);     // Переміщення
}

class HeavyObject
{
private:
    int* m_data;
    size_t m_size;

public:
    HeavyObject(size_t size) : m_size(size)
    {
        m_data = new int[m_size];
        std::cout << "Allocated " << m_size << " ints\n";
    }

    ~HeavyObject()
    {
        delete[] m_data;
    }

    // Конструктор копіювання
    HeavyObject(const HeavyObject& other) : m_size(other.m_size)
    {
        m_data = new int[m_size];
        std::copy(other.m_data, other.m_data + m_size, m_data);
        std::cout << "Copied " << m_size << " ints\n";
    }

    // Оператор присвоювання копіюванням
    HeavyObject& operator=(const HeavyObject& other)
    {
        if (this == &other) return *this;
        delete[] m_data;
        m_size = other.m_size;
        m_data = new int[m_size];
        std::copy(other.m_data, other.m_data + m_size, m_data);
        std::cout << "Copy assigned " << m_size << " ints\n";
        return *this;
    }

    // Конструктор переміщення
    HeavyObject(HeavyObject&& other) noexcept
        : m_data(other.m_data), m_size(other.m_size)
    {
        other.m_data = nullptr;
        other.m_size = 0;
        std::cout << "Moved\n";
    }

    // Оператор присвоювання переміщенням
    HeavyObject& operator=(HeavyObject&& other) noexcept
    {
        if (this == &other) return *this;
        delete[] m_data;
        m_data = other.m_data;
        m_size = other.m_size;
        other.m_data = nullptr;
        other.m_size = 0;
        std::cout << "Move assigned\n";
        return *this;
    }
};

int main()
{
    std::cout << "=== Swap with copying ===\n";
    HeavyObject obj1(1000);
    HeavyObject obj2(2000);
    swapCopy(obj1, obj2);

    std::cout << "\n=== Swap with moving ===\n";
    HeavyObject obj3(1000);
    HeavyObject obj4(2000);
    swapMove(obj3, obj4);

    return 0;
}
./SwapOptimization
=== Swap with copying ===
Allocated 1000 ints
Allocated 2000 ints
Copied 1000 ints
Copy assigned 2000 ints
Copy assigned 1000 ints
=== Swap with moving ===
Allocated 1000 ints
Allocated 2000 ints
Moved
Move assigned
Move assigned

Висновок: версія з std::move() уникає виділення та копіювання 3000 + 2000 + 1000 = 6000 цілих чисел. Замість цього лише переприсвоюються вказівники — операція O(1) замість O(n).


Вимоги до стану об'єкта після переміщення

Після переміщення вихідний об'єкт має залишатися у валідному стані, який дозволяє:

  1. Безпечно викликати деструктор
  2. Присвоїти новий стан

Рекомендований «нульовий стан»

Найкращий підхід — залишати об'єкт у нульовому стані:

String(String&& other) noexcept
    : m_data(other.m_data), m_length(other.m_length)
{
    other.m_data = nullptr;    // Нульовий стан
    other.m_length = 0;
}

Такий об'єкт:

  • Безпечний для деструктора (delete nullptr — валідна операція)
  • Можна перевірити: if (str.getData() == nullptr)
  • Можна повторно використати: str = "New value";
Стандартна бібліотека C++ гарантує:
Після std::move() об'єкти STL (наприклад, std::string, std::vector) залишаються у валідному, але невизначеному стані. Єдине гарантоване — їх безпечно знищувати та присвоювати їм нові значення.

Приклад безпечного повторного використання

ReuseAfterMove.cpp
#include <iostream>
#include <utility>

class String
{
    // ... (попередня реалізація) ...
};

int main()
{
    String original("Hello");
    
    String moved = std::move(original);
    std::cout << "After move, original: \"" << original.getData() << "\"\n";
    
    // ✅ Безпечно: присвоюємо нове значення
    original = String("Reinitialized");
    std::cout << "After reassignment: \"" << original.getData() << "\"\n";
    
    return 0;
}
./ReuseAfterMove
Constructed: Hello
Moved (constructor)
After move, original: ""
Constructed: Reinitialized
Move assigned
Destroyed
After reassignment: "Reinitialized"
Destroyed
Destroyed

Правило п'яти (Rule of Five)

У C++11 класичне Правило трьох (Rule of Three) розширюється до Правила п'яти:

📝 Правило трьох (C++98)

Якщо клас потребує будь-якої з трьох функцій, він потребує всіх трьох:

  1. Деструктор
  2. Конструктор копіювання
  3. Оператор присвоювання копіюванням

🚀 Правило п'яти (C++11)

Якщо клас керує ресурсами, він повинен мати всі п'ять функцій:

  1. Деструктор
  2. Конструктор копіювання
  3. Оператор присвоювання копіюванням
  4. Конструктор переміщення
  5. Оператор присвоювання переміщенням

Повна реалізація класу з Rule of Five

RuleOfFive.cpp
#include <iostream>
#include <cstring>

class ManagedResource
{
private:
    int* m_data;
    size_t m_size;

public:
    // 1️⃣ Конструктор за замовчуванням
    ManagedResource(size_t size = 0)
        : m_size(size), m_data(size ? new int[size] : nullptr)
    {
        std::cout << "Constructor: allocated " << m_size << " ints\n";
    }

    // 2️⃣ Деструктор
    ~ManagedResource()
    {
        delete[] m_data;
        std::cout << "Destructor: freed " << m_size << " ints\n";
    }

    // 3️⃣ Конструктор копіювання
    ManagedResource(const ManagedResource& other)
        : m_size(other.m_size), m_data(other.m_size ? new int[other.m_size] : nullptr)
    {
        if (m_data)
            std::copy(other.m_data, other.m_data + m_size, m_data);
        std::cout << "Copy constructor: deep copy of " << m_size << " ints\n";
    }

    // 4️⃣ Оператор присвоювання копіюванням
    ManagedResource& operator=(const ManagedResource& other)
    {
        std::cout << "Copy assignment\n";
        
        if (this == &other)
            return *this;

        delete[] m_data;

        m_size = other.m_size;
        m_data = other.m_size ? new int[other.m_size] : nullptr;
        
        if (m_data)
            std::copy(other.m_data, other.m_data + m_size, m_data);

        return *this;
    }

    // 5️⃣ Конструктор переміщення
    ManagedResource(ManagedResource&& other) noexcept
        : m_data(other.m_data), m_size(other.m_size)
    {
        other.m_data = nullptr;
        other.m_size = 0;
        std::cout << "Move constructor\n";
    }

    // 6️⃣ Оператор присвоювання переміщенням
    ManagedResource& operator=(ManagedResource&& other) noexcept
    {
        std::cout << "Move assignment\n";
        
        if (this == &other)
            return *this;

        delete[] m_data;

        m_data = other.m_data;
        m_size = other.m_size;

        other.m_data = nullptr;
        other.m_size = 0;

        return *this;
    }
};

ManagedResource createResource()
{
    return ManagedResource(100);
}

int main()
{
    ManagedResource res1(50);
    ManagedResource res2 = res1;            // Copy constructor
    
    ManagedResource res3 = createResource(); // Move constructor
    
    res1 = res2;                             // Copy assignment
    res1 = std::move(res3);                  // Move assignment
    
    return 0;
}
./RuleOfFive
Constructor: allocated 50 ints
Copy constructor: deep copy of 50 ints
Constructor: allocated 100 ints
Move constructor
Destructor: freed 100 ints
Copy assignment
Move assignment
Destructor: freed 0 ints
Destructor: freed 100 ints
Destructor: freed 50 ints
Специфікатор noexcept:
Move-конструктор та move-оператор присвоювання завжди слід позначати noexcept. Це дозволяє STL-контейнерам використовувати переміщення замість копіювання під час реалокації.

Правило нуля (Rule of Zero)

Якщо можливо, уникайте написання спеціальних функцій взагалі — використовуйте RAII-обгортки:

RuleOfZero.cpp
#include <iostream>
#include <memory>
#include <vector>

class GoodClass
{
private:
    std::unique_ptr<int[]> m_data;  // Автоматичне керування пам'яттю
    std::vector<std::string> m_names; // std::vector має власну семантику переміщення

public:
    GoodClass(size_t size)
        : m_data(std::make_unique<int[]>(size))
    {
        std::cout << "GoodClass constructed\n";
    }

    // ❌ НЕ потрібно писати:
    // - Деструктор (std::unique_ptr сам видалить пам'ять)
    // - Конструктор копіювання (заборонено через unique_ptr)
    // - Оператор копіювання
    // - Конструктор переміщення (генерується автоматично)
    // - Оператор переміщення (генерується автоматично)
};

int main()
{
    GoodClass obj1(100);
    GoodClass obj2 = std::move(obj1);  // Автоматичне переміщення
    
    return 0;
}
./RuleOfZero
GoodClass constructed
Правило нуля: найкращий код — це код, який не потрібно писати. Використовуйте готові RAII-обгортки (std::unique_ptr, std::shared_ptr, std::vector, std::string) замість сирих вказівників.

Заборона копіювання: = delete

Якщо семантика копіювання не має сенсу для класу, заборонте копіювання явно:

DeleteCopy.cpp
#include <iostream>

class UniqueResource
{
private:
    int* m_handle;

public:
    UniqueResource(int value) : m_handle(new int(value))
    {
        std::cout << "Resource acquired: " << *m_handle << "\n";
    }

    ~UniqueResource()
    {
        delete m_handle;
        std::cout << "Resource released\n";
    }

    // ❌ Забороняємо копіювання
    UniqueResource(const UniqueResource&) = delete;
    UniqueResource& operator=(const UniqueResource&) = delete;

    // ✅ Дозволяємо переміщення
    UniqueResource(UniqueResource&& other) noexcept
        : m_handle(other.m_handle)
    {
        other.m_handle = nullptr;
        std::cout << "Resource moved\n";
    }

    UniqueResource& operator=(UniqueResource&& other) noexcept
    {
        if (this == &other)
            return *this;

        delete m_handle;
        m_handle = other.m_handle;
        other.m_handle = nullptr;

        std::cout << "Resource move-assigned\n";
        return *this;
    }

    int getValue() const { return m_handle ? *m_handle : 0; }
};

int main()
{
    UniqueResource res1(42);
    
    // UniqueResource res2 = res1;  // ❌ Помилка компіляції: копіювання заборонене
    
    UniqueResource res3 = std::move(res1);  // ✅ OK: переміщення дозволене
    std::cout << "res3 value: " << res3.getValue() << "\n";
    
    return 0;
}
./DeleteCopy
Resource acquired: 42
Resource moved
res3 value: 42
Resource released
Resource released

Цей патерн використовується у std::unique_ptr, std::thread, std::fstream та інших класах зі семантикою унікального володіння.


Переміщення у контейнерах: практичний приклад

Розглянемо, як семантика переміщення покращує продуктивність при роботі з контейнерами:

VectorMoveOptimization.cpp
#include <iostream>
#include <vector>
#include <chrono>

class HeavyObject
{
private:
    int* m_data;
    size_t m_size;

public:
    HeavyObject(size_t size = 1000) : m_size(size)
    {
        m_data = new int[m_size];
    }

    ~HeavyObject()
    {
        delete[] m_data;
    }

    // Конструктор копіювання
    HeavyObject(const HeavyObject& other) : m_size(other.m_size)
    {
        m_data = new int[m_size];
        std::copy(other.m_data, other.m_data + m_size, m_data);
    }

    // Оператор присвоювання копіюванням
    HeavyObject& operator=(const HeavyObject& other)
    {
        if (this == &other) return *this;
        delete[] m_data;
        m_size = other.m_size;
        m_data = new int[m_size];
        std::copy(other.m_data, other.m_data + m_size, m_data);
        return *this;
    }

    // Конструктор переміщення
    HeavyObject(HeavyObject&& other) noexcept
        : m_data(other.m_data), m_size(other.m_size)
    {
        other.m_data = nullptr;
        other.m_size = 0;
    }

    // Оператор присвоювання переміщенням
    HeavyObject& operator=(HeavyObject&& other) noexcept
    {
        if (this == &other) return *this;
        delete[] m_data;
        m_data = other.m_data;
        m_size = other.m_size;
        other.m_data = nullptr;
        other.m_size = 0;
        return *this;
    }
};

int main()
{
    using namespace std::chrono;
    
    std::vector<HeavyObject> vec;
    vec.reserve(10000);  // Резервуємо місце, щоб уникнути реалокацій
    
    // Вимірюємо час додавання через копіювання
    auto start1 = high_resolution_clock::now();
    for (int i = 0; i < 10000; ++i)
    {
        HeavyObject obj;
        vec.push_back(obj);  // Копіювання (l-value)
    }
    auto end1 = high_resolution_clock::now();
    auto duration1 = duration_cast<milliseconds>(end1 - start1).count();
    
    vec.clear();
    
    // Вимірюємо час додавання через переміщення
    auto start2 = high_resolution_clock::now();
    for (int i = 0; i < 10000; ++i)
    {
        HeavyObject obj;
        vec.push_back(std::move(obj));  // Переміщення (r-value)
    }
    auto end2 = high_resolution_clock::now();
    auto duration2 = duration_cast<milliseconds>(end2 - start2).count();
    
    std::cout << "Copy: " << duration1 << " ms\n";
    std::cout << "Move: " << duration2 << " ms\n";
    std::cout << "Speedup: " << (double)duration1 / duration2 << "x\n";
    
    return 0;
}
./VectorMoveOptimization
Copy: 1847 ms
Move: 126 ms
Speedup: 14.66x
STL та push_back():
std::vector::push_back() має дві перевантажені версії:
  • void push_back(const T& value) — для l-values (копіює)
  • void push_back(T&& value) — для r-values (переміщує)
Використовуйте std::move() або emplace_back() для ефективного додавання об'єктів.

Повернення об'єктів із функцій: NRVO та переміщення

Return Value Optimization (RVO)

Сучасні компілятори застосовують RVO (Return Value Optimization) — об'єкт створюється безпосередньо у місці виклику, без копіювання:

RVO.cpp
#include <iostream>

class Demo
{
public:
    Demo() { std::cout << "Constructed\n"; }
    Demo(const Demo&) { std::cout << "Copied\n"; }
    Demo(Demo&&) noexcept { std::cout << "Moved\n"; }
    ~Demo() { std::cout << "Destroyed\n"; }
};

Demo createDemo()
{
    return Demo();  // RVO: об'єкт створюється безпосередньо у місці виклику
}

int main()
{
    Demo obj = createDemo();
    return 0;
}
./RVO (з оптимізацією)
Constructed
Destroyed

Без RVO ми б побачили:

Constructed        (у createDemo)
Moved              (повернення з функції)
Destroyed          (тимчасовий у createDemo)
Destroyed          (obj у main)

Повернення локальних змінних

Для іменованих локальних змінних компілятор може застосувати NRVO (Named Return Value Optimization). Якщо NRVO неможливе, автоматично використовується переміщення:

NRVO.cpp
#include <iostream>
#include <string>

std::string createString(bool condition)
{
    std::string result;
    
    if (condition)
        result = "True branch";
    else
        result = "False branch";
    
    return result;  // NRVO або переміщення (НЕ копіювання)
}

int main()
{
    std::string str = createString(true);
    std::cout << str << "\n";
    return 0;
}
Не використовуйте std::move() при поверненні локальних змінних!
// ❌ ПОГАНО: блокує RVO
return std::move(localVariable);

// ✅ ДОБРЕ: дозволяє RVO, або автоматичне переміщення
return localVariable;
Компілятор автоматично переміщує локальні змінні при поверненні, якщо RVO неможливе.

Переміщення та алгоритми STL

Багато алгоритмів STL можуть використовувати семантику переміщення:

STLAlgorithms.cpp
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>

int main()
{
    std::vector<std::string> source = {"apple", "banana", "cherry", "date"};
    std::vector<std::string> destination;

    std::cout << "Before move:\n";
    std::cout << "Source: ";
    for (const auto& s : source)
        std::cout << s << " ";
    std::cout << "\n";

    // std::move_iterator перетворює копіювання у переміщення
    destination.assign(
        std::make_move_iterator(source.begin()),
        std::make_move_iterator(source.end())
    );

    std::cout << "\nAfter move:\n";
    std::cout << "Source: ";
    for (const auto& s : source)
        std::cout << "\"" << s << "\" ";  // Порожні рядки
    std::cout << "\nDestination: ";
    for (const auto& s : destination)
        std::cout << s << " ";
    std::cout << "\n";

    return 0;
}
./STLAlgorithms
Before move:
Source: apple banana cherry date
After move:
Source: "" "" "" ""
Destination: apple banana cherry date
Корисні функції для переміщення в STL:
  • std::move(begin, end, dest) — алгоритм переміщення діапазону
  • std::make_move_iterator() — адаптер ітератора для переміщення
  • std::move_if_noexcept() — переміщує, якщо move-конструктор noexcept, інакше копіює

Універсальні посилання та досконале пересилання

Універсальні посилання (universal references / forwarding references) — це шаблонні параметри виду T&&, які можуть прийняти як l-value, так і r-value:

UniversalReferences.cpp
#include <iostream>
#include <utility>

void process(int& lref)
{
    std::cout << "L-value processed\n";
}

void process(int&& rref)
{
    std::cout << "R-value processed\n";
}

template<typename T>
void forward(T&& arg)  // Універсальне посилання
{
    process(std::forward<T>(arg));  // Досконале пересилання
}

int main()
{
    int x = 10;
    
    forward(x);       // T виводиться як int& → передається як l-value
    forward(42);      // T виводиться як int → передається як r-value
    
    return 0;
}
./UniversalReferences
L-value processed
R-value processed

std::forward<T>(): досконале пересилання

std::forward<T>() зберігає категорію значення аргументу при передачі:

template<typename T>
void wrapper(T&& arg)
{
    realFunction(std::forward<T>(arg));
    // Якщо arg був r-value, передається як r-value
    // Якщо arg був l-value, передається як l-value
}

🔄 std::move()

Безумовне перетворення у r-value:

std::move(x)  // завжди r-value

⚡ std::forward<T>()

Умовне пересилання категорії значення:

std::forward<T>(arg)
// r-value → r-value
// l-value → l-value
Коли використовувати що:
  • std::move() — коли ви точно знаєте, що хочете перемістити об'єкт
  • std::forward<T>() — у шаблонних функціях, які пересилають аргументи далі

Практичні рекомендації

✅ Що робити

  1. Позначайте move-функції noexcept:
    ClassName(ClassName&&) noexcept;
    ClassName& operator=(ClassName&&) noexcept;
    
  2. Залишайте переміщені об'єкти у нульовому стані:
    other.m_ptr = nullptr;
    other.m_size = 0;
    
  3. Використовуйте std::move() для явного переміщення:
    vec.push_back(std::move(heavyObject));
    
  4. Застосовуйте Rule of Zero, коли можливо:
    class Good {
        std::unique_ptr<int[]> m_data;  // Автоматичне керування
    };
    

❌ Чого уникати

  1. НЕ використовуйте переміщений об'єкт (крім присвоювання):
    auto moved = std::move(original);
    // ❌ std::cout << original.getData();  // Невизначена поведінка
    
  2. НЕ забувайте обнуляти вказівники після переміщення:
    // ❌ НЕБЕЗПЕЧНО:
    m_ptr = other.m_ptr;  // забули: other.m_ptr = nullptr;
    
  3. НЕ використовуйте std::move() при поверненні локальних:
    // ❌ return std::move(local);  // Блокує RVO
    // ✅ return local;              // Дозволяє RVO
    
  4. НЕ пишіть move-функції без noexcept:
    // ❌ ClassName(ClassName&&);             // STL не використає
    // ✅ ClassName(ClassName&&) noexcept;    // STL оптимізує
    

Практичне завдання


Резюме

У цій статті ми детально розглянули семантику переміщення (move semantics) — одне з найважливіших нововведень C++11:

🎯 L-values та R-values

  • L-value — іменовані змінні з адресою в пам'яті
  • R-value — тимчасові значення без стабільної адреси
  • Посилання r-value (&&) — прив'язуються до r-values

🚀 Move-семантика

  • Конструктор переміщення: забирає ресурси замість копіювання
  • Оператор присвоювання переміщенням: переприсвоює вказівники
  • Після переміщення об'єкт має бути у нульовому стані

⚡ std::move()

  • Перетворює l-value у r-value
  • Не виконує переміщення, лише змінює тип
  • Використовується для явного переміщення іменованих змінних

📐 Rule of Five

Клас з керуванням ресурсами потребує:

  1. Деструктор
  2. Copy constructor
  3. Copy assignment
  4. Move constructor
  5. Move assignment

Ключові висновки

  1. Переміщення ефективніше копіювання — особливо для об'єктів із динамічною пам'яттю або важкими ресурсами.
  2. Компілятор автоматично обирає версію з переміщенням для r-values (тимчасових об'єктів).
  3. std::move() для l-values — використовуйте, коли впевнені, що об'єкт більше не потрібен.
  4. Завжди позначайте move-функції noexcept — це дозволяє STL використовувати переміщення при реалокації.
  5. Rule of Zero — найкращий код той, що не потрібно писати. Використовуйте готові RAII-обгортки.
Наступні кроки: У наступних статтях ми розглянемо розумні вказівники (std::unique_ptr, std::shared_ptr, std::weak_ptr), які активно використовують семантику переміщення для безпечного автоматичного керування пам'яттю.

Додаткові матеріали

📚 Стандартна бібліотека

  • std::move() — явне перетворення у r-value
  • std::forward<T>() — досконале пересилання
  • std::move_if_noexcept() — безпечне переміщення
  • std::make_move_iterator() — ітератор для переміщення

🔗 Пов'язані теми

  • Стаття 59: Конструктор копіювання
  • Стаття 66: Глибоке копіювання та оператор присвоювання
  • Наступна стаття: Розумні вказівники std::unique_ptr
Copyright © 2026