1. Введение
Когда вы впервые слышите фразу «объект после перемещения находится в валидном, но не специфицированном состоянии», хочется уточнить: «Это как “живой, но без паспорта”?» В целом — да. Стандартная библиотека формулирует это именно как отдельный контракт: moved-from объекты должны оставаться валидными, но их конкретное содержимое не обещается. Даже в черновиках стандарта это вынесено в отдельную идею (lib.types.movedfrom) и подчёркивается, что это общий принцип для moved-from состояния.
Давайте разберём фразу на две части.
«Валидно» означает: объект всё ещё является корректным объектом своего типа. Его деструктор можно вызвать безопасно. Его можно снова присвоить/переинициализировать, и он обязан нормально «вернуться к жизни».
«Не специфицировано» означает: нельзя строить логику программы на том, что именно осталось внутри. Там может быть «пусто», может быть «почти как было», может быть «что-то промежуточное» — и это не баг, а разрешённая свобода реализации.
Очень важный практический вывод: после move объект нельзя использовать как источник данных, но можно использовать как объект, который можно разрушить, очистить, присвоить заново и т.д.
2. Moved-from на примере std::string
Если вы учились на примерах, где std::string после std::move становится пустой строкой, это нормально: многие реализации так и делают, потому что это удобно. Проблема только в одном: это не обещание, это «популярная привычка реализации».
Посмотрим на пример, который компилируется всегда, но вывод может отличаться:
#include <iostream>
#include <string>
#include <utility>
int main() {
std::string a = "hello";
std::string b = std::move(a);
std::cout << "b = " << b << '\n'; // b = hello
std::cout << "a.size() = " << a.size() << '\n'; // может быть 0, а может и не 0
}
И вот здесь начинается взрослая жизнь.
a после перемещения:
- не обязан быть пустым;
- не обязан иметь size() == 0;
- не обязан печататься как пустая строка (хотя часто так и будет).
Но обязан оставаться таким объектом, которому можно, например, присвоить новое значение:
#include <iostream>
#include <string>
#include <utility>
int main() {
std::string a = "hello";
std::string b = std::move(a);
a = "reused"; // это обязано быть корректно
std::cout << "a = " << a << '\n'; // a = reused
}
Это и есть «валидность»: объект можно вернуть в нормальное рабочее состояние обычными операциями своего типа.
3. Как задать moved-from состояние в своём типе
Если std::string может позволить себе «не специфицировать» содержимое, то ваш пользовательский тип, особенно учебный, может быть намного дружелюбнее. И это хорошая инженерная привычка: переводить moved-from объект в понятное пустое состояние и поддерживать его инварианты.
Сейчас мы продолжим нашу учебную линию с ручным ресурсом (массив char), но будем думать не только про move, а про то, что после move наш объект должен оставаться пригодным к жизни.
Инварианты пустого состояния
Хорошая практика: выбрать простые инварианты, которые легко проверять глазами.
Например, для текстового буфера:
- если data_ == nullptr, то size_ == 0;
- если size_ == 0, то data_ либо nullptr, либо указывает на буфер с '\0' (но лучше выбрать один вариант и держаться его).
Мы выберем самый простой учебный вариант: пустой объект = data_ == nullptr и size_ == 0.
Это удобно ещё и тем, что delete[] nullptr безопасен: значит деструктор будет простым.
Мини-класс Text: move делает источник пустым
Скелет (без сложных оптимизаций, зато предсказуемо):
#include <cstddef>
#include <utility>
struct Text {
std::size_t size_{0};
char* data_{nullptr};
Text() = default;
explicit Text(std::size_t n) : size_(n), data_(new char[n + 1]{}) {
data_[n] = '\0';
}
~Text() { delete[] data_; }
Text(Text&& other) noexcept : size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr;
}
Text& operator=(Text&& other) noexcept {
if (this == &other) return *this;
delete[] data_;
size_ = other.size_;
data_ = other.data_;
other.size_ = 0;
other.data_ = nullptr;
return *this;
}
};
Обратите внимание: move-операции не «оставляют что-то там как получится». Мы явно приводим источник к пустому состоянию. Это делает moved-from состояние не просто валидным, а ещё и понятным.
4. Что можно делать с объектом после move
Когда вам говорят «валидно, но не специфицировано», мозг обычно спрашивает: «Это значит “можно всё, но на свой страх и риск” или “нельзя ничего”?» На практике это третье: можно делать то, что не требует смысла старых данных.
Сведём в таблицу. (Это не магия компилятора, а дисциплина программиста.)
| Действие с moved-from объектом | Обычно можно? | Почему |
|---|---|---|
| Разрушить (выйти из scope) | Да | Деструктор обязан быть безопасен |
| Присвоить новое значение | Да | Это часть «валидности» |
| Снова переместить из него | Да | Перемещение из пустого обычно нормально (получится пусто) |
| Вызвать clear() / привести к пустому состоянию | Да | Это не требует старых данных |
| Читать содержимое и рассчитывать на смысл | Нет | Содержимое не специфицировано |
| Делать if (x == "старое") (если бы был ==) | Нет | Сравнение со «старым» бессмысленно |
| Вызывать методы, требующие “не пусто” (например, front()) | Опасно | Если объект пуст, предусловия не выполняются |
Здесь ключевая мысль такая: после move вы можете обращаться с объектом как с “контейнером без гарантированного содержимого”.
5. Инварианты и «полупустые» объекты
Очень частая ошибка новичка: сделать move так, что объект-источник «почти пустой», но не совсем. Это хуже, чем просто «не специфицировано», потому что тогда вы сами себе строите ловушку.
Например, вот так делать плохо:
Buffer(Buffer&& other) noexcept : size_(other.size_), data_(other.data_) {
other.data_ = nullptr;
// other.size_ забыли обнулить
}
Почему это плохо? Потому что other окажется в состоянии:
- data_ == nullptr
- size_ > 0
И теперь любой код, который верит size_, может попытаться что-то сделать и упасть. Это уже не «не специфицировано», это просто неаккуратно спроектировано.
Давайте добавим в наш Text маленькие методы, которые завязаны на инварианты, и тем самым сделаем пустое состояние «официальным»:
#include <cstddef>
struct Text {
std::size_t size_{0};
char* data_{nullptr};
bool empty() const {
return size_ == 0; // по нашему контракту это согласовано с data_ == nullptr
}
void clear() {
delete[] data_;
data_ = nullptr;
size_ = 0;
}
};
Тут важно, что empty() смотрит на size_, а clear() приводит оба поля в согласованное состояние. Это прямой шаг к тому, чтобы moved-from объект был не только валиден, но и «нормально пуст».
6. Как не использовать объект после move в приложении
Представим (в духе наших предыдущих учебных примеров), что у нас есть консольное приложение «заметочник», где заметка — это заголовок и текст. Мы пока не строим сложную ООП-архитектуру, просто показываем, что пользовательский тип может переезжать в контейнер, а источник потом можно переиспользовать.
Сделаем минимальную модель:
#include <utility>
struct Note {
Text title;
Text body;
};
int main() {
Note n;
n.title = Text{5}; // условно "память под 5 символов"
n.body = Text{20};
Note saved = std::move(n); // n теперь moved-from
// n.body.size_ читать как "там 20" уже нельзя
n.title = Text{3}; // но можно "переинициализировать" и снова использовать
}
Здесь важен не функционал заметок, а привычка:
- как только вы написали saved = std::move(n), n больше не источник данных;
- но n остаётся объектом, которым можно пользоваться после переинициализации/присваивания.
Чтобы закрепить это ощущение, полезно держать в голове такую «диаграмму жизненного цикла»:
stateDiagram-v2
state "Normal" as N
state "MovedFrom" as M
[*] --> N: создан и заполнен
N --> M: std move x
M --> N: присваивание или clear и заполнение
N --> [*]: деструктор
M --> [*]: деструктор
Точка в том, что moved-from — это не «сломанный» и не «полумёртвый». Это просто состояние, где смысл старых данных не гарантирован.
7. Зачем стандарту “valid but unspecified” и как с этим жить
Почему стандарт выбирает “valid but unspecified”
Эта часть звучит философски, но на практике очень полезна. Если бы стандарт требовал, чтобы moved-from объект был, например, обязательно пустым, то реализациям пришлось бы иногда делать лишнюю работу, чтобы привести его к конкретному виду.
А формулировка «валиден, но не специфицирован» даёт реализации право:
- сделать перемещение максимально дешёвым;
- оставлять внутренние структуры в любом корректном состоянии;
- не поддерживать лишние гарантии там, где они мешают.
В документах комитета это даже подчёркивается как общий принцип: moved-from состояние описывается как «valid but unspecified» и выносится в отдельную норму для библиотеки.
Для вас (как для автора пользовательского типа) это означает: вы тоже можете так сделать, но в учебных и прикладных типах часто выгоднее выбрать более дружелюбный контракт — «после move объект пуст».
Заметьте тонкость: «пуст» — это уже ваша спецификация. Вы имеете право сделать moved-from состояние более определённым, чем требует минимум. Главное — не обещать того, что вы не соблюдаете.
Минимальный набор операций для moved-from объекта
Чтобы ваши типы были удобны, стоит мысленно проверить такой минимальный набор:
- объект можно уничтожить в любом состоянии
- объект можно привести к пустому состоянию (явно или косвенно)
- объекту можно присвоить новое значение (копированием или перемещением)
- методы, которые не требуют данных (например empty()), работают всегда
Сейчас прозвучит скучно, но это как ремень безопасности: вы редко благодарите его вслух, но он делает жизнь длиннее.
Мы уже реализовали clear() и empty() для Text. Давайте добавим «безопасный доступ» к данным: если объект пуст, вернём "" (пустую C-строку). Это не идеальный дизайн для всех случаев, но для учебного типа — очень наглядно:
struct Text {
std::size_t size_{0};
char* data_{nullptr};
const char* c_str() const {
return data_ ? data_ : "";
}
};
Теперь даже moved-from Text можно печатать (пусть и без гарантий смысла — но хотя бы без падений).
8. Типичные ошибки при работе с moved-from состоянием
Ошибка №1: использовать объект после std::move, как будто ничего не произошло.
Самый распространённый сценарий выглядит так: вы переместили x в y, а потом читаете x и логически полагаетесь на его «старые» данные. Код может «работать» на вашей машине, а потом внезапно менять поведение на другой библиотеке или после обновления компилятора, потому что содержимое moved-from не обязано совпадать с ожиданиями.
Ошибка №2: проектировать moved-from состояние противоречивым.
Если у вашего типа есть связанные поля (size и data), и после move вы обнулили только одно из них, вы получаете объект, который формально «жив», но любая функция, верящая инвариантам, может сломаться. Правильный move почти всегда заканчивается приведением источника к согласованному «пустому» состоянию.
Ошибка №3: писать методы, которые не выдерживают пустое состояние, и не объявлять предусловия.
Допустим, у типа есть метод front() или at(0). Если после move объект может стать пустым, эти методы должны либо корректно это обрабатывать, либо иметь чёткое предусловие «объект не пуст». Новичковая версия — «оно как-нибудь само», а “как-нибудь само” в C++ обычно означает “в 3 часа ночи”.
Ошибка №4: полагаться на вывод/логирование moved-from объекта как на диагностику корректности.
Иногда хочется сделать std::cout << x и проверить, что после move x стал пустым — и считать это доказательством корректности. Это плохой тест: вы проверяете частный случай конкретной реализации, а не контракт. Гораздо лучше проверять инварианты: «не падает», «можно присвоить заново», «можно разрушить», empty() согласован.
Ошибка №5: обещать больше, чем реально гарантируете.
Можно сделать moved-from состояние «пустым» и документировать это как часть вашего контракта — это отлично. Но нельзя писать в комментарии «после move данные сохраняются» и потом «забирать внутренности». В C++ комментарий — не компилятор, но баги читает именно будущий человек (и он будет недоволен).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ