Инварианты времени жизни объекта и семантика перемещения

13

Когда я давно изучал C ++, мне было настоятельно подчеркнуто, что отчасти C ++ состоит в том, что, как и у циклов, есть «инварианты цикла», у классов также есть инварианты, связанные с временем жизни объекта - вещи, которые должны быть истинными. пока объект жив. Вещи, которые должны быть установлены конструкторами и сохранены методами. Инкапсуляция / контроль доступа помогут вам обеспечить соблюдение инвариантов. RAII - это то, что вы можете сделать с этой идеей.

Начиная с C ++ 11 у нас теперь есть семантика перемещения. Для класса, который поддерживает перемещение, перемещение от объекта формально не заканчивает его время жизни - движение должно оставить его в каком-то «допустимом» состоянии.

При разработке класса, является ли плохой практикой, если вы разрабатываете его так, что инварианты класса сохраняются только до точки, из которой он перемещается? Или это нормально, если это позволит вам сделать это быстрее.

Для конкретности предположим, что у меня есть не копируемый, но перемещаемый тип ресурса, например:

class opaque {
  opaque(const opaque &) = delete;

public:
  opaque(opaque &&);

  ...

  void mysterious();
  void mysterious(int);
  void mysterious(std::vector<std::string>);
};

И по какой-то причине мне нужно сделать копируемую обертку для этого объекта, чтобы ее можно было использовать, возможно, в какой-то существующей диспетчерской системе.

class copyable_opaque {
  std::shared_ptr<opaque> o_;

  copyable_opaque() = delete;
public:
  explicit copyable_opaque(opaque _o)
    : o_(std::make_shared<opaque>(std::move(_o)))
  {}

  void operator()() { o_->mysterious(); }
  void operator()(int i) { o_->mysterious(i); }
  void operator()(std::vector<std::string> v) { o_->mysterious(v); }
};

В этом copyable_opaqueобъекте инвариант класса, созданного при конструировании, состоит в том, что член o_всегда указывает на допустимый объект, поскольку нет ctor по умолчанию, и единственный ctor, который не является ctor-копией, гарантирует это. Все operator()методы предполагают, что этот инвариант выполняется, и сохраняют его впоследствии.

Тем не менее, если объект перемещен из, то o_будет указывать ни на что. И после этого, вызов любого из методов operator()вызовет сбой UB.

Если объект никогда не перемещается, то инвариант будет сохранен вплоть до вызова dtor.

Давайте предположим, что гипотетически я написал этот класс, и несколько месяцев спустя мой воображаемый коллега испытал UB, потому что в какой-то сложной функции, когда по каким-то причинам было перемешано множество этих объектов, он перешел из одной из этих вещей и позже назвал одну из его методы. В конце концов, это его вина, но этот класс "плохо спроектирован?"

Мысли:

  1. В C ++ обычно плохо создавать объекты-зомби, которые взрываются, если вы к ним прикасаетесь.
    Если вы не можете сконструировать какой-либо объект, не можете установить инварианты, тогда выведите исключение из ctor. Если вы не можете сохранить инварианты в каком-либо методе, то как-то сообщите об ошибке и выполните откат. Должно ли это быть другим для перемещенных объектов?

  2. Достаточно ли просто документировать «после того, как этот объект был перемещен, незаконно (UB) делать с ним что-либо кроме уничтожения» в заголовке?

  3. Лучше ли постоянно утверждать, что он действителен при каждом вызове метода?

Вот так:

class copyable_opaque {
  std::shared_ptr<opaque> o_;

  copyable_opaque() = delete;
public:
  explicit copyable_opaque(opaque _o)
    : o_(std::make_shared<opaque>(std::move(_o)))
  {}

  void operator()() { assert(o_); o_->mysterious(); }
  void operator()(int i) { assert(o_); o_->mysterious(i); }
  void operator()(std::vector<std::string> v) { assert(o_); o_->mysterious(v); }
};

Утверждения существенно не улучшают поведение и вызывают замедление. Если ваш проект использует схему «Release build / debug build», а не просто всегда работает с утверждениями, я думаю, это более привлекательно, поскольку вы не платите за проверки в сборке релиза. Если на самом деле у вас нет отладочных сборок, это кажется довольно непривлекательным.

  1. Лучше ли сделать класс копируемым, но не подвижным?
    Это также кажется плохим и приводит к снижению производительности, но решает проблему «инвариантов» простым способом.

Что бы вы посчитали уместными здесь «лучшими практиками»?

Крис Бек
источник

Ответы:

20

В C ++ обычно плохо создавать объекты-зомби, которые взрываются, если вы к ним прикасаетесь.

Но это не то, что вы делаете. Вы создаете «объект зомби», который взорвется, если вы дотронетесь до него неправильно . Что в конечном итоге ничем не отличается от любых других основанных на состоянии предварительных условий.

Рассмотрим следующую функцию:

void func(std::vector<int> &v)
{
  v[0] = 5;
}

Эта функция безопасна? Нет; Пользователь может передать пустое vector . Таким образом, у функции есть де-факто предусловие, в котором vесть хотя бы один элемент. Если это не так, то вы получаете UB, когда вы звоните func.

Так что эта функция не является «безопасной». Но это не значит, что он сломан. Он нарушается, только если код, использующий его, нарушает предварительное условие. Maybe func- это статическая функция, используемая в качестве помощника при реализации других функций. Локализованный таким образом, никто не назвал бы это способом, который нарушает его предварительные условия.

Многие функции, будь то область имен или члены класса, будут ожидать состояния значения, с которым они работают. Если эти предварительные условия не выполняются, функции не будут работать, как правило, с UB.

Стандартная библиотека C ++ определяет правило «допустимо, но не указано». Это говорит о том, что, если в стандарте не указано иное, каждый перемещаемый объект будет действительным (это допустимый объект этого типа), но конкретное состояние этого объекта не указано. Сколько элементов имеет перемещенный элемент vector? Это не говорит.

Это означает, что вы не можете вызвать любую функцию, которая имеет какое-либо предварительное условие. vector::operator[]имеет предварительное условие, что vectorимеет хотя бы один элемент. Так как вы не знаете состояние vector, вы не можете это назвать. Это было бы не лучше, чем вызов funcбез предварительной проверки того, что объект vectorне пустой.

Но это также означает, что функции, которые не имеют предварительных условий, в порядке. Это совершенно законный код C ++ 11:

vector<int> v1 = {1, 2, 3, 4, 5};
vector<int> v2{std::move(v1)};
v1.assign({6, 7, 8, 9, 10});

vector::assignне имеет никаких предпосылок. Он будет работать с любым действительным vectorобъектом, даже с тем, который был перемещен.

Таким образом, вы не создаете объект, который сломан. Вы создаете объект, состояние которого неизвестно.

Если вы не можете сконструировать какой-либо объект, не можете установить инварианты, тогда выведите исключение из ctor. Если вы не можете сохранить инварианты в каком-либо методе, то как-то сообщите об ошибке и выполните откат. Должно ли это быть другим для перемещенных объектов?

Бросать исключения из конструктора перемещения обычно считается ... грубым. Если вы перемещаете объект, которому принадлежит память, вы передаете право собственности на эту память. И это обычно не включает в себя все, что может бросить.

К сожалению, мы не можем обеспечить это по разным причинам . Мы должны признать, что метание-ход является возможным.

Следует также отметить, что вам не нужно следовать «действительному, но еще не указанному» языку. Это просто то, как стандартная библиотека C ++ говорит, что перемещение для стандартных типов работает по умолчанию . Определенные стандартные типы библиотек имеют более строгие гарантии. Например, unique_ptrочень ясно о состоянии удаленного unique_ptrэкземпляра: он равен nullptr.

Таким образом, вы можете предоставить более надежную гарантию, если хотите.

Только помните: движение является оптимизация производительности , один , который, как правило , делается на объектах, которые о должны быть уничтожены. Рассмотрим этот код:

vector<int> func()
{
  vector<int> v;
  //fill up `v`.
  return v;
}

Это переместится vв возвращаемое значение (при условии, что компилятор не исключает его). И нет никакого способа сделать ссылку vпосле завершения перемещения. Поэтому любая работа, которую вы сделали, чтобы привести vв полезное состояние, не имеет смысла.

В большинстве кодов вероятность использования экземпляра объекта move-from низкая.

Достаточно ли просто документировать «после того, как этот объект был перемещен, незаконно (UB) делать с ним что-либо кроме уничтожения» в заголовке?

Лучше ли постоянно утверждать, что он действителен при каждом вызове метода?

Весь смысл наличия предварительных условий состоит в том, чтобы не проверять такие вещи. operator[]имеет предварительное условие, чтобы vectorиметь элемент с данным индексом. Вы получите UB, если попытаетесь получить доступ за пределами размера vector. vector::at не имеет такой предпосылки; он явно генерирует исключение, если у vectorнего нет такого значения.

Предпосылки существуют по соображениям производительности. Они таковы, что вам не нужно проверять вещи, которые вызывающий абонент мог проверить для себя. Каждый вызов v[0]не должен проверять, vявляется ли он пустым; только первый делает.

Лучше ли сделать класс копируемым, но не подвижным?

Нет. Фактически, класс никогда не должен быть «копируемым, но не подвижным». Если его можно скопировать, то его можно переместить, вызвав конструктор копирования. Это стандартное поведение C ++ 11, если вы объявляете пользовательский конструктор копирования, но не объявляете конструктор перемещения. И это поведение, которое вы должны принять, если вы не хотите реализовывать специальную семантику перемещения.

Семантика перемещения существует для решения очень специфической проблемы: работа с объектами, имеющими большие ресурсы, где копирование будет непомерно дорогим или бессмысленным (например, файловые дескрипторы). Если ваш объект не подходит, то копирование и перемещение для вас одинаковы.

Николь Болас
источник
5
Ницца. +1. Я хотел бы отметить, что: «Весь смысл наличия предварительных условий состоит в том, чтобы не проверять такие вещи». - Я не думаю, что это верно для утверждений. Утверждения являются ИМХО хорошим и надежным инструментом для проверки предварительных условий (по крайней мере, в большинстве случаев).
Мартин Ба,
3
Путаницу копирования / перемещения можно прояснить, осознав, что ctor перемещения может оставить исходный объект в любом состоянии, в том числе идентичном новому объекту, что означает, что возможные результаты являются расширенным набором результатов копирования ctor.
MSalters