JavaRush /Курсы /C++ SELF /Состояние «после move»: валидно, но ...

Состояние «после move»: валидно, но ...

C++ SELF
46 уровень , 2 лекция
Открыта

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 объекта

Чтобы ваши типы были удобны, стоит мысленно проверить такой минимальный набор:

  1. объект можно уничтожить в любом состоянии
  2. объект можно привести к пустому состоянию (явно или косвенно)
  3. объекту можно присвоить новое значение (копированием или перемещением)
  4. методы, которые не требуют данных (например 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++ комментарий — не компилятор, но баги читает именно будущий человек (и он будет недоволен).

1
Задача
C++ SELF, 46 уровень, 2 лекция
Недоступна
Записка в пути
Записка в пути
1
Задача
C++ SELF, 46 уровень, 2 лекция
Недоступна
Переезд инвентаря
Переезд инвентаря
1
Задача
C++ SELF, 46 уровень, 2 лекция
Недоступна
Буфер посылки
Буфер посылки
1
Задача
C++ SELF, 46 уровень, 2 лекция
Недоступна
Текст без сюрпризов
Текст без сюрпризов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ