Является ли конструктор копирования C++ по умолчанию небезопасным? Являются ли итераторы принципиально небезопасными?
Раньше я считал, что объектная модель C++ очень устойчива, когда следуют передовым методам.
Однако несколько минут назад у меня было понимание, которого у меня не было раньше.
Рассмотрим этот код:
class Foo
{
std::set<size_t> set;
std::vector<std::set<size_t>::iterator> vector;
// ...
// (assume every method ensures p always points to a valid element of s)
};
Я написал такой код. И до сегодняшнего дня я не видел проблемы с этим.
Но, подумав об этом больше, я понял, что этот класс очень сломлен:
Его конструктор копирования и назначение копирования копируют итераторы внутри vector
Это означает, что они по-прежнему будут указывать на старый set
! Новый не является точной копией в конце концов!
Другими словами, я должен вручную реализовать конструктор копирования, даже если этот класс не управляет никакими ресурсами (без RAII)!
Это удивляет меня. Я никогда не сталкивался с этой проблемой раньше, и я не знаю ни одного элегантного способа ее решения. Думая об этом немного больше, мне кажется, что конструкция копирования по умолчанию небезопасна - на самом деле, мне кажется, что классы не должны быть копируемыми по умолчанию, потому что любой вид связи между их переменными экземпляра рискует сделать копию по умолчанию Неверный конструктор.
Действительно ли итераторы небезопасны для хранения? Или классы действительно не должны быть копируемыми по умолчанию?
Решения, о которых я могу думать ниже, все нежелательны, так как они не позволяют мне воспользоваться автоматически сгенерированным конструктором копирования:
- Вручную реализовать конструктор копирования для каждого нетривиального класса, который я пишу. Это не только подвержено ошибкам, но и болезненно писать для сложного класса.
- Никогда не храните итераторы как переменные-члены. Это кажется серьезным ограничением.
- Отключите копирование по умолчанию для всех классов, которые я пишу, если я не могу явно доказать, что они верны. Похоже, это полностью противоречит дизайну C++, который для большинства типов имеет семантику значений и, следовательно, может быть скопирован.
Это хорошо известная проблема, и если да, то есть ли у нее элегантное / идиоматическое решение?
7 ответов
Это известная проблема?
Ну, это известно, но я бы не сказал, известный. Указатели братьев и сестер встречаются не часто, и большинство реализаций, которые я видел в дикой природе, были сломаны точно так же, как у вас.
Я считаю, что проблема достаточно редкая, чтобы избежать внимания большинства людей; Интересно, что сейчас, когда я следую больше за Rust, чем за C++, он довольно часто появляется из-за строгости системы типов (т. е. компилятор отказывает этим программам, вызывая вопросы).
у него есть элегантное / идиоматическое решение?
Существует много типов ситуаций, связанных с указателями одного уровня, поэтому это действительно зависит, однако я знаю два общих решения:
- ключи
- общие элементы
Давайте рассмотрим их по порядку.
Указывая на члена класса или указывая на индексируемый контейнер, можно использовать смещение или ключ, а не итератор. Это немного менее эффективно (и может потребоваться поиск), однако это довольно простая стратегия. Я видел, что он имел большой эффект в ситуации с совместно используемой памятью (где использование указателей - нет-нет, поскольку область совместно используемой памяти может отображаться по разным адресам).
Другое решение используется Boost.MultiIndex и состоит в альтернативной структуре памяти. Это вытекает из принципа навязчивого контейнера: вместо того, чтобы помещать элемент в контейнер (перемещая его в памяти), интрузивный контейнер использует крючки, уже находящиеся внутри элемента, чтобы соединить его в нужном месте. Исходя из этого, достаточно легко использовать разные крючки для соединения отдельных элементов в несколько контейнеров, верно?
Ну, Boost.MultiIndex пинает его на два шага дальше:
- Он использует традиционный интерфейс контейнера (т. Е. Перемещает объект), но узел, в который перемещается объект, является элементом с несколькими хуками.
- Он использует различные крючки / контейнеры в одной сущности
Вы можете проверить различные примеры и, в частности, Пример 5: Последовательные индексы очень похожи на ваш собственный код.
C++ копирование / перемещение ctor/assign безопасны для обычных типов значений. Типы регулярных значений ведут себя как целые числа или другие "обычные" значения.
Они также безопасны для семантических типов указателей, если операция не изменяет то, на что указывает указатель. Указание на что-то "внутри себя" или на другого члена является примером того, где оно терпит неудачу.
Они в некоторой степени безопасны для ссылочных семантических типов, но смешивание семантики указатель / ссылка / значение в одном классе имеет тенденцию быть небезопасным / ошибочным / опасным на практике.
Нулевое правило состоит в том, что вы создаете классы, которые ведут себя как обычные типы значений или как семантические типы указателей, которые не нужно переустанавливать при копировании / перемещении. Тогда вам не нужно писать копии / перемещать ctors.
Итераторы следуют семантике указателей.
Идиоматический / элегантный способ заключается в том, чтобы тесно связать контейнер итератора с указанным контейнером, а также заблокировать или записать копию ctor там. Они на самом деле не отдельные вещи, если один содержит указатели на другой.
Да, это хорошо известная "проблема" - всякий раз, когда вы храните указатели в объекте, вам, вероятно, понадобится какой-то специальный конструктор копирования и оператор присваивания, чтобы гарантировать, что все указатели действительны и указывают на ожидаемые объекты.,
Поскольку итераторы - это просто абстракция указателей на элементы коллекции, они имеют ту же проблему.
Это известная проблема?
Да. Каждый раз, когда у вас есть класс, содержащий указатели или подобные указателю данные, такие как итератор, вы должны реализовать свой собственный конструктор копирования и оператор присваивания, чтобы гарантировать, что новый объект имеет действительные указатели / итераторы.
и если да, то есть ли у него элегантное / идиоматическое решение?
Может быть, не так элегантно, как хотелось бы, и, возможно, не является лучшим по производительности (но иногда копии не таковы, поэтому в C++11 добавлена семантика перемещения), но, возможно, что-то подобное будет работать для вас (если предположить, что std::vector
содержит итераторы в std::set
того же родительского объекта):
class Foo
{
private:
std::set<size_t> s;
std::vector<std::set<size_t>::iterator> v;
struct findAndPushIterator
{
Foo &foo;
findAndPushIterator(Foo &f) : foo(f) {}
void operator()(const std::set<size_t>::iterator &iter)
{
std::set<size_t>::iterator found = foo.s.find(*iter);
if (found != foo.s.end())
foo.v.push_back(found);
}
};
public:
Foo() {}
Foo(const Foo &src)
{
*this = src;
}
Foo& operator=(const Foo &rhs)
{
v.clear();
s = rhs.s;
v.reserve(rhs.v.size());
std::for_each(rhs.v.begin(), rhs.v.end(), findAndPushIterator(*this));
return *this;
}
//...
};
Или, если используется C++11:
class Foo
{
private:
std::set<size_t> s;
std::vector<std::set<size_t>::iterator> v;
public:
Foo() {}
Foo(const Foo &src)
{
*this = src;
}
Foo& operator=(const Foo &rhs)
{
v.clear();
s = rhs.s;
v.reserve(rhs.v.size());
std::for_each(rhs.v.begin(), rhs.v.end(),
[this](const std::set<size_t>::iterator &iter)
{
std::set<size_t>::iterator found = s.find(*iter);
if (found != s.end())
v.push_back(found);
}
);
return *this;
}
//...
};
Да, конечно, это известная проблема.
Если в вашем классе хранятся указатели, как опытный разработчик, вы бы интуитивно знали, что поведения копирования по умолчанию может быть недостаточно для этого класса.
Ваш класс хранит итераторы и, поскольку они также являются "дескрипторами" для данных, хранящихся в другом месте, применяется та же логика.
Это вряд ли "удивительно".
Утверждение, что Foo
не управляет какими-либо ресурсами является ложным.
Копировать конструктор в сторону, если элемент set
удален, должен быть код в Foo
что управляет vector
так что соответствующий итератор удален.
Я думаю, что идиоматическое решение состоит в том, чтобы просто использовать один контейнер, vector<size_t>
и проверьте, что количество элементов равно нулю перед вставкой. Тогда копирование и перемещение по умолчанию в порядке.
"По своей сути небезопасно"
Нет, упомянутые вами функции не являются небезопасными; тот факт, что вы подумали о трех возможных безопасных решениях проблемы, свидетельствует о том, что здесь нет "неотъемлемого" недостатка безопасности, даже если вы считаете, что решения нежелательны.
И да, здесь есть RAII: контейнеры (set
а также vector
) управляют ресурсами. Я думаю, что вы имеете в виду, что RAII "уже позаботился" о std
контейнеры. Но тогда вам нужно рассматривать сами экземпляры контейнера как "ресурсы", и фактически ваш класс управляет ими. Вы правы в том, что не управляете непосредственно кучей памяти, потому что стандартная библиотека заботится об этом аспекте проблемы управления. Но есть еще одна проблема управления, о которой я расскажу чуть ниже.
"Волшебное" поведение по умолчанию
Проблема в том, что вы, очевидно, надеетесь, что вы можете доверять конструктору копирования по умолчанию, который "делает правильные вещи" в нетривиальном случае, таком как этот. Я не уверен, почему вы ожидали правильного поведения - возможно, вы надеетесь, что запоминание эмпирических правил, таких как "правило 3", будет надежным способом убедиться, что вы не стреляете себе в ногу? Конечно, это было бы неплохо (и, как указывалось в другом ответе, Rust идет намного дальше, чем другие языки низкого уровня, к тому, чтобы сделать ходьбу пешком намного сложнее), но C++ просто не предназначен для "бездумного" дизайна классов такого рода. и не должно быть.
Концептуализация поведения конструктора
Я не буду пытаться ответить на вопрос, является ли это "общеизвестной проблемой", потому что я не знаю, насколько хорошо охарактеризована проблема "сестринских" данных и хранения итераторов. Но я надеюсь, что смогу убедить вас, что если вы потратите время на то, чтобы подумать о поведении копирующего конструктора для каждого класса, который вы можете скопировать, это не должно вызывать удивления.
В частности, когда вы решите использовать конструктор копирования по умолчанию, вы должны подумать о том, что на самом деле будет делать конструктор копирования по умолчанию: а именно, он будет вызывать конструктор копирования каждого не примитивного члена, не являющегося объединением (т.е. членов, которые есть конструкторы копирования) и побитовое копирование остальных.
При копировании вашего vector
итераторов, что делает std::vector
копи-конструктор делать? Он выполняет "глубокое копирование", то есть данные внутри вектора копируются. Теперь, если вектор содержит итераторы, как это повлияет на ситуацию? Ну, все просто: итераторы - это данные, хранящиеся в векторе, поэтому сами итераторы будут скопированы. Что делает конструктор копирования итератора? Я не собираюсь на самом деле искать это, потому что мне не нужно знать специфику: мне просто нужно знать, что итераторы подобны указателям в этом (и других отношениях), а копирование указателя просто копирует сам указатель, не указанные данные. То есть итераторы и указатели не имеют глубокого копирования по умолчанию.
Обратите внимание, что это не удивительно: конечно, итераторы не выполняют глубокое копирование по умолчанию. Если они это сделают, вы получите новый набор для каждого копируемого итератора. И это имеет даже меньше смысла, чем кажется на первый взгляд: например, что бы это на самом деле означало, если бы однонаправленные итераторы делали глубокие копии своих данных? Предположительно, вы получите частичную копию, т. Е. Все оставшиеся данные, которые все еще находятся "перед" текущей позицией итератора, плюс новый итератор, указывающий на "фронт" новой структуры данных.
Теперь учтите, что у конструктора копирования нет возможности узнать контекст, в котором он вызывается. Например, рассмотрим следующий код:
using iter = std::set<size_t>::iterator; // use typedef pre-C++11
std::vector<iter> foo = getIters(); // get a vector of iterators
useIters(foo); // pass vector by value
когда getIters
вызывается, возвращаемое значение может быть перемещено, но оно также может быть сконструировано для копирования. Назначение foo
также вызывает конструктор копирования, хотя это также может быть исключено. И если useIters
принимает его аргумент по ссылке, тогда у вас также есть вызов конструктора копирования.
В любом из этих случаев вы ожидаете, что конструктор копирования изменит std::set
указывает итераторы, содержащиеся в std::vector<iter>
? Конечно, нет! Так естественно std::vector
Конструктор копирования не может быть предназначен для модификации итераторов таким конкретным способом, и на самом деле std::vector
Конструктор копирования - это именно то, что вам нужно в большинстве случаев, когда он будет фактически использоваться.
Однако предположим, std::vector
может работать так: предположим, что у него есть специальная перегрузка для "вектора-итераторов", которая может переместить итераторы, и что компилятору можно как-то "сказать" только для вызова этого специального конструктора, когда итераторы действительно должны быть вновь сесть. (Обратите внимание, что решение "вызывать специальную перегрузку только при генерации конструктора по умолчанию для содержащего класса, который также содержит экземпляр базового типа данных итераторов" не будет работать; что, если std::vector
итераторы в вашем случае указывали на другой стандартный набор и рассматривались просто как ссылка на данные, управляемые каким-то другим классом? Черт, как компилятор должен знать, все ли итераторы указывают на одно и то же std::set
?) Игнорируя эту проблему: как компилятор узнает, когда вызывать этот специальный конструктор, как будет выглядеть код конструктора? Давайте попробуем, используя _Ctnr<T>::iterator
как наш тип итератора (я буду использовать C++11/14ism и буду немного неаккуратным, но общая точка зрения должна быть ясной):
template <typename T, typename _Ctnr>
std::vector< _Ctnr<T>::iterator> (const std::vector< _Ctnr<T>::iterator>& rhs)
: _data{ /* ... */ } // initialize underlying data...
{
for (auto i& : rhs)
{
_data.emplace_back( /* ... */ ); // What do we put here?
}
}
Итак, мы хотим, чтобы каждый новый, скопированный итератор был повторно размещен для ссылки на другой экземпляр _Ctnr<T>
, Но откуда эта информация? Обратите внимание, что конструктор копирования не может принять новый _Ctnr<T>
в качестве аргумента: тогда он больше не будет конструктором копирования. И в любом случае, как бы компилятор узнал, какой _Ctnr<T>
предоставлять? (Обратите внимание, что для многих контейнеров поиск "соответствующего итератора" для нового контейнера может быть нетривиальным.)
Управление ресурсами с std::
контейнеры
Это не просто проблема того, что компилятор не настолько "умен", как мог бы или должен быть. Это тот случай, когда вы, программист, имеете в виду конкретный дизайн, который требует конкретного решения. В частности, как уже упоминалось выше, у вас есть два ресурса, оба std::
контейнеры. И у вас есть отношения между ними. Здесь мы подходим к чему-то, о чем говорилось в большинстве других ответов, и что к этому моменту должно быть очень и очень ясно: связанные члены класса требуют особой осторожности, поскольку C++ не управляет этой связью по умолчанию. Но то, что я надеюсь, также ясно с этой точки зрения, что вы не должны думать о проблеме как о возникающей именно из-за связи между данными; проблема заключается просто в том, что конструкция по умолчанию не волшебна, и программист должен знать требования для правильного копирования класса, прежде чем разрешить неявно сгенерированному конструктору обрабатывать копирование.
Элегантное решение
... А теперь мы переходим к эстетике и мнениям. Вы, кажется, считаете неуместным быть вынужденным написать конструктор копирования, когда у вас нет необработанных указателей или массивов в вашем классе, которыми нужно управлять вручную.
Но определяемые пользователем конструкторы копирования элегантны; позволить вам написать их - элегантное решение C++ проблемы написания правильных нетривиальных классов.
Следует признать, что это похоже на случай, когда "правило 3" не совсем применимо, поскольку существует явная необходимость =delete
копируй конструктор или пиши его сам, но пока нет явной необходимости в определяемом пользователем деструкторе. Но опять же, вы не можете просто программировать на основе эмпирических правил и ожидать, что все будет работать правильно, особенно на языке низкого уровня, таком как C++; Вы должны знать детали (1) того, что вы на самом деле хотите, и (2) как этого можно достичь.
Итак, учитывая, что связь между вашим std::set
и ваш std::vector
фактически создает нетривиальную проблему, решая проблему путем объединения их в класс, который правильно реализует (или просто удаляет) конструктор копирования, на самом деле является очень элегантным (и идиоматическим) решением.
Явное определение против удаления
Вы упоминаете потенциальное новое "практическое правило", которому следует придерживаться в своей практике кодирования: "Отключите копирование по умолчанию для всех классов, которые я пишу, если только я не могу явно доказать, что они верны". Хотя это может быть более безопасным эмпирическим правилом (по крайней мере, в этом случае), чем "правилом 3" (особенно, когда ваш критерий "нужно ли мне реализовать 3" заключается в проверке необходимости удаления), мое выше Осторожно, не полагаясь на эмпирические правила.
Но я думаю, что решение здесь на самом деле проще, чем предлагаемое практическое правило. Вам не нужно формально доказывать правильность метода по умолчанию; вам просто нужно иметь базовое представление о том, что он будет делать и что вам нужно делать.
Выше, в своем анализе вашего конкретного случая, я углубился во многие детали - например, я поднял вопрос о возможности "глубокого копирования итераторов". Вам не нужно вдаваться в подробности, чтобы определить, будет ли конструктор копирования по умолчанию работать правильно. Вместо этого просто представьте, как будет выглядеть ваш созданный вручную конструктор копирования; вы должны довольно быстро сказать, насколько похож ваш воображаемый явно заданный конструктор на тот, который сгенерирует компилятор.
Например, класс Foo
содержащий один вектор data
будет иметь конструктор копирования, который выглядит следующим образом:
Foo::Foo(const Foo& rhs)
: data{rhs.data}
{}
Даже не записывая это, вы знаете, что можете положиться на неявно сгенерированный, потому что он точно такой же, как вы написали выше.
Теперь рассмотрим конструктор для вашего класса Foo
:
Foo::Foo(const Foo& rhs)
: set{rhs.set}
, vector{ /* somehow use both rhs.set AND rhs.vector */ } // ...????
{}
Сразу же, учитывая, что просто копирование vector
члены не будут работать, вы можете сказать, что конструктор по умолчанию не будет работать. Итак, теперь вам нужно решить, должен ли ваш класс быть копируемым или нет.