Когда я давно изучал 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, потому что в какой-то сложной функции, когда по каким-то причинам было перемешано множество этих объектов, он перешел из одной из этих вещей и позже назвал одну из его методы. В конце концов, это его вина, но этот класс "плохо спроектирован?"
Мысли:
В C ++ обычно плохо создавать объекты-зомби, которые взрываются, если вы к ним прикасаетесь.
Если вы не можете сконструировать какой-либо объект, не можете установить инварианты, тогда выведите исключение из ctor. Если вы не можете сохранить инварианты в каком-либо методе, то как-то сообщите об ошибке и выполните откат. Должно ли это быть другим для перемещенных объектов?Достаточно ли просто документировать «после того, как этот объект был перемещен, незаконно (UB) делать с ним что-либо кроме уничтожения» в заголовке?
Лучше ли постоянно утверждать, что он действителен при каждом вызове метода?
Вот так:
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», а не просто всегда работает с утверждениями, я думаю, это более привлекательно, поскольку вы не платите за проверки в сборке релиза. Если на самом деле у вас нет отладочных сборок, это кажется довольно непривлекательным.
- Лучше ли сделать класс копируемым, но не подвижным?
Это также кажется плохим и приводит к снижению производительности, но решает проблему «инвариантов» простым способом.
Что бы вы посчитали уместными здесь «лучшими практиками»?
источник
Ответы:
Но это не то, что вы делаете. Вы создаете «объект зомби», который взорвется, если вы дотронетесь до него неправильно . Что в конечном итоге ничем не отличается от любых других основанных на состоянии предварительных условий.
Рассмотрим следующую функцию:
Эта функция безопасна? Нет; Пользователь может передать пустое
vector
. Таким образом, у функции есть де-факто предусловие, в которомv
есть хотя бы один элемент. Если это не так, то вы получаете UB, когда вы звонитеfunc
.Так что эта функция не является «безопасной». Но это не значит, что он сломан. Он нарушается, только если код, использующий его, нарушает предварительное условие. Maybe
func
- это статическая функция, используемая в качестве помощника при реализации других функций. Локализованный таким образом, никто не назвал бы это способом, который нарушает его предварительные условия.Многие функции, будь то область имен или члены класса, будут ожидать состояния значения, с которым они работают. Если эти предварительные условия не выполняются, функции не будут работать, как правило, с UB.
Стандартная библиотека C ++ определяет правило «допустимо, но не указано». Это говорит о том, что, если в стандарте не указано иное, каждый перемещаемый объект будет действительным (это допустимый объект этого типа), но конкретное состояние этого объекта не указано. Сколько элементов имеет перемещенный элемент
vector
? Это не говорит.Это означает, что вы не можете вызвать любую функцию, которая имеет какое-либо предварительное условие.
vector::operator[]
имеет предварительное условие, чтоvector
имеет хотя бы один элемент. Так как вы не знаете состояниеvector
, вы не можете это назвать. Это было бы не лучше, чем вызовfunc
без предварительной проверки того, что объектvector
не пустой.Но это также означает, что функции, которые не имеют предварительных условий, в порядке. Это совершенно законный код C ++ 11:
vector::assign
не имеет никаких предпосылок. Он будет работать с любым действительнымvector
объектом, даже с тем, который был перемещен.Таким образом, вы не создаете объект, который сломан. Вы создаете объект, состояние которого неизвестно.
Бросать исключения из конструктора перемещения обычно считается ... грубым. Если вы перемещаете объект, которому принадлежит память, вы передаете право собственности на эту память. И это обычно не включает в себя все, что может бросить.
К сожалению, мы не можем обеспечить это по разным причинам . Мы должны признать, что метание-ход является возможным.
Следует также отметить, что вам не нужно следовать «действительному, но еще не указанному» языку. Это просто то, как стандартная библиотека C ++ говорит, что перемещение для стандартных типов работает по умолчанию . Определенные стандартные типы библиотек имеют более строгие гарантии. Например,
unique_ptr
очень ясно о состоянии удаленногоunique_ptr
экземпляра: он равенnullptr
.Таким образом, вы можете предоставить более надежную гарантию, если хотите.
Только помните: движение является оптимизация производительности , один , который, как правило , делается на объектах, которые о должны быть уничтожены. Рассмотрим этот код:
Это переместится
v
в возвращаемое значение (при условии, что компилятор не исключает его). И нет никакого способа сделать ссылкуv
после завершения перемещения. Поэтому любая работа, которую вы сделали, чтобы привестиv
в полезное состояние, не имеет смысла.В большинстве кодов вероятность использования экземпляра объекта move-from низкая.
Весь смысл наличия предварительных условий состоит в том, чтобы не проверять такие вещи.
operator[]
имеет предварительное условие, чтобыvector
иметь элемент с данным индексом. Вы получите UB, если попытаетесь получить доступ за пределами размераvector
.vector::at
не имеет такой предпосылки; он явно генерирует исключение, если уvector
него нет такого значения.Предпосылки существуют по соображениям производительности. Они таковы, что вам не нужно проверять вещи, которые вызывающий абонент мог проверить для себя. Каждый вызов
v[0]
не должен проверять,v
является ли он пустым; только первый делает.Нет. Фактически, класс никогда не должен быть «копируемым, но не подвижным». Если его можно скопировать, то его можно переместить, вызвав конструктор копирования. Это стандартное поведение C ++ 11, если вы объявляете пользовательский конструктор копирования, но не объявляете конструктор перемещения. И это поведение, которое вы должны принять, если вы не хотите реализовывать специальную семантику перемещения.
Семантика перемещения существует для решения очень специфической проблемы: работа с объектами, имеющими большие ресурсы, где копирование будет непомерно дорогим или бессмысленным (например, файловые дескрипторы). Если ваш объект не подходит, то копирование и перемещение для вас одинаковы.
источник