Проблема копирования

До C++11 возврат объекта из функции означал его копирование:

1std::vector<float> getReadings() {
2    std::vector<float> v(1000);
3    fill(v);
4    return v;  // копирование — 1000 float выделяются и memcpy-копируются
5}
6
7auto readings = getReadings();  // ещё одна копия

Компиляторы применяли оптимизацию возвращаемого значения (RVO) в простых случаях, но сложный код, контейнеры контейнеров или нетривиальные пути возврата — всё это откатывалось к копированию. Большие структуры данных передавались по указателю или ссылке во избежание затрат.

Семантика перемещения даёт C++ механизм системы типов для передачи владения ресурсами без их копирования.


Что такое перемещение

Перемещение — дешёвая передача владения: источник отдаёт свой внутренний буфер адресату, затем устанавливает себя в допустимое, но пустое состояние.

1std::vector<float> a(1000);   // выделяет 1000 float
2std::vector<float> b = std::move(a);
3// b теперь владеет буфером — нет выделения, нет копирования
4// a допустим, но пуст: a.size() == 0

Под капотом конструктор перемещения std::vector:

  1. Копирует внутренний указатель, размер и ёмкость из a в b
  2. Устанавливает указатель a в nullptr, размер и ёмкость в 0

Три копии размером с указатель. Никакого malloc, никакого memcpy.


Rvalue-ссылки

Семантика перемещения обеспечивается rvalue-ссылками (T&&). Rvalue — значение без постоянного имени — временный объект, возвращаемое значение функции или явно приведённое с помощью std::move.

1void sink(std::vector<float>&& v);  // принимает rvalue-ссылку — может похитить v
2
3std::vector<float> data(100);
4
5sink(std::move(data));  // явное перемещение — data теперь пуст
6sink(getReadings());    // неявное перемещение — временный, std::move не нужен

std::move — просто приведение типа, оно ничего не перемещает. Приводит data к rvalue-ссылке, что позволяет разрешению перегрузки выбрать конструктор или оператор присваивания перемещения.


Написание конструкторов перемещения

Для класса, владеющего ресурсом, реализуйте конструктор перемещения:

 1class Buffer {
 2public:
 3    explicit Buffer(size_t n)
 4        : data_(new float[n]), size_(n) {}
 5
 6    // Конструктор копирования — глубокая копия
 7    Buffer(const Buffer& other)
 8        : data_(new float[other.size_]), size_(other.size_) {
 9        std::copy(other.data_, other.data_ + size_, data_);
10    }
11
12    // Конструктор перемещения — похищаем указатель
13    Buffer(Buffer&& other) noexcept
14        : data_(other.data_), size_(other.size_) {
15        other.data_ = nullptr;
16        other.size_ = 0;
17    }
18
19    // Присваивание перемещением
20    Buffer& operator=(Buffer&& other) noexcept {
21        if (this != &other) {
22            delete[] data_;
23            data_ = other.data_;
24            size_ = other.size_;
25            other.data_ = nullptr;
26            other.size_ = 0;
27        }
28        return *this;
29    }
30
31    ~Buffer() { delete[] data_; }
32
33private:
34    float*  data_;
35    size_t  size_;
36};

noexcept на конструкторах перемещения критически важен. Стандартные контейнеры (например, std::vector при перевыделении) используют конструктор перемещения, только если он помечен noexcept — иначе откатываются к копированию. Всегда помечайте перемещения noexcept, если они не могут бросать.


Правило пяти (и нуля)

Если вы определяете любое из: деструктор, конструктор копирования, присваивание копированием, конструктор перемещения, присваивание перемещением — следует определить все пять (или явно использовать = default / = delete).

1class Buffer {
2public:
3    Buffer(const Buffer&)            = default;  // или реализовать
4    Buffer(Buffer&&)                 = default;
5    Buffer& operator=(const Buffer&) = default;
6    Buffer& operator=(Buffer&&)      = default;
7    ~Buffer()                        = default;
8};

Для классов, хранящих только перемещаемые типы (нет сырых указателей, нет дескрипторов), сгенерированные компилятором значения по умолчанию корректны — это правило нуля: не определяйте ни одного из пяти, и компилятор генерирует корректные версии из членов.

1class SensorData {
2    std::vector<float> samples_;  // перемещаемый
3    std::string        name_;     // перемещаемый
4    // Правило нуля — компилятор генерирует корректное копирование и перемещение
5};

Возврат объектов — NRVO и перемещение

Современные компиляторы применяют Оптимизацию именованного возвращаемого значения (NRVO): когда функция возвращает именованную локальную переменную, компилятор конструирует её непосредственно в хранилище вызывающего — нет ни копирования, ни перемещения.

1std::vector<float> getReadings() {
2    std::vector<float> v(1000);  // конструируется напрямую в месте вызова
3    fill(v);
4    return v;  // NRVO — нет копирования или перемещения при применении оптимизации
5}

Если NRVO не применимо (несколько путей возврата, возвращающих разные объекты), компилятор откатывается к неявному перемещению из выражения return.

Не применяйте std::move к возвращаемому значению — это подавляет NRVO:

1// НЕПРАВИЛЬНО — std::move предотвращает NRVO
2std::vector<float> getReadings() {
3    std::vector<float> v(1000);
4    return std::move(v);  // принуждает к перемещению; NRVO было бы лучше
5}

Возвращайте по значению, позвольте компилятору оптимизировать.


Паттерны без копирования

Перемещение в контейнер

1std::vector<Buffer> buffers;
2Buffer b(1024);
3buffers.push_back(std::move(b));  // перемещает b — нет копирования 1024 float
4// b теперь пуст — не используйте его после перемещения

Перенаправление в фабрике

1template <typename T, typename... Args>
2std::unique_ptr<T> make(Args&&... args) {
3    return std::make_unique<T>(std::forward<Args>(args)...);
4}

std::forwardидеальная переадресация — сохраняет категорию lvalue/rvalue аргументов. При вызове с rvalue переадресует rvalue; с lvalue — lvalue. Это стандартный паттерн для универсальных фабричных функций и обёрток.

Параметры-поглотители

Когда функция сохраняет аргумент, принимайте по значению и перемещайте:

 1class Logger {
 2    std::string prefix_;
 3public:
 4    // По значению — вызывающий может передать rvalue (будет перемещён) или lvalue (скопируется один раз)
 5    explicit Logger(std::string prefix)
 6        : prefix_(std::move(prefix)) {}
 7};
 8
 9Logger l1("SENSOR");                  // одна конструкция, одно перемещение — оптимально
10Logger l2(std::string("DISPLAY"));   // одна конструкция, одно перемещение
11std::string s = "APP";
12Logger l3(s);                         // одна копия (s — lvalue)
13Logger l4(std::move(s));              // одно перемещение — s теперь пуст

Это эффективнее, чем принимать const std::string& (что требовало бы явной копии внутри конструктора) когда вызывающий имеет временный объект для передачи.


Перемещение и встраиваемая прошивка

На встраиваемых платформах std::vector и динамическое выделение памяти часто запрещены. Семантика перемещения по-прежнему важна для:

Перемещения unique_ptr: передача владения выделенным в куче драйвером или ресурсом без копирования управляемого объекта.

1auto sensor = std::make_unique<Bmp280>(hi2c1);
2pipeline.setSensor(std::move(sensor));  // нет копирования объекта драйвера

Перемещения буферов фиксированного размера:

1struct Frame {
2    std::array<uint8_t, 64> data;
3    size_t len;
4};
5
6// Перемещение Frame — компилятор генерирует перемещение, копирующее 64 байта
7// То же, что и копирование для тривиально копируемых типов, но намерение явное

Для тривиально копируемых типов (std::array<uint8_t, N>, простые структуры с POD-членами), перемещение идентично копированию — компилятор в любом случае копирует байты. Семантика перемещения важна для типов с нетривиальными ресурсами (куча, файловые дескрипторы, дескрипторы HAL с логикой очистки).


Итоги

  • Семантика перемещения передаёт владение ресурсами без копирования — O(1) vs O(n)
  • std::move — приведение типа, а не операция — позволяет выбор перегрузки перемещения
  • Помечайте конструкторы перемещения noexcept — требуется для использования внутри std::vector при resize
  • Правило нуля: позвольте компилятору генерировать копирование/перемещение, если класс содержит только перемещаемые члены
  • NRVO устраняет копии при возврате — не применяйте std::move к return-выражению
  • Параметры-поглотители: принимайте по значению, std::move в хранилище — избегает лишней копии для rvalue-вызывающих

Что дальше