Проблема копирования
До 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:
- Копирует внутренний указатель, размер и ёмкость из
aвb - Устанавливает указатель
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-вызывающих
Что дальше
- Умные указатели и владение —
unique_ptr— основное применение семантики перемещения - Lock-free очереди — перемещение данных между потоками без выделения памяти
- std::span и string_view — избежание копий вообще без владения данными