Является ли конструктор копирования 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)!

Это удивляет меня. Я никогда не сталкивался с этой проблемой раньше, и я не знаю ни одного элегантного способа ее решения. Думая об этом немного больше, мне кажется, что конструкция копирования по умолчанию небезопасна — на самом деле, мне кажется, что классы должны не быть копируемым по умолчанию, потому что любая связь между их переменными экземпляра может привести к недействительности конструктора копирования по умолчанию.

Действительно ли итераторы небезопасны для хранения? Или классы действительно не должны быть копируемыми по умолчанию?

Решения, о которых я могу подумать ниже, все нежелательны, так как они не позволяют мне воспользоваться автоматически сгенерированным конструктором копирования:

  1. Вручную реализовать конструктор копирования для каждого нетривиального класса, который я пишу. Это не только подвержено ошибкам, но и болезненно писать для сложного класса.
  2. Никогда не храните итераторы как переменные-члены. Это кажется серьезным ограничением.
  3. Отключите копирование по умолчанию для всех классов, которые я пишу, если я не могу явно доказать, что они верны. Похоже, это полностью противоречит дизайну C ++, который для большинства типов имеет семантику значений и, следовательно, может быть скопирован.

Является ли это общеизвестной проблемой, и если да, то есть ли у нее элегантное / идиоматическое решение?

36

Решение

Это известная проблема?

Ну, это известно, но я бы не сказал, известный. Указатели братьев и сестер не часто встречаются, и большинство реализаций, которые я видел в дикой природе, были сломаны точно так же, как у вас.

Я полагаю, что проблема является достаточно редкой, чтобы избежать внимания большинства людей; Интересно, что сейчас, когда я следую больше за Rust, чем за C ++, он возникает довольно часто из-за строгости системы типов (т. е. компилятор отказывает этим программам, вызывая вопросы).

у него есть элегантное / идиоматическое решение?

Есть много типов указатели родного брата ситуации, так что это действительно зависит, однако я знаю два общих решения:

  • ключи
  • общие элементы

Давайте рассмотрим их по порядку.

Указывая на члена класса или указывая на индексируемый контейнер, можно использовать смещение или же ключ а не итератор. Это немного менее эффективно (и может потребоваться поиск), однако это довольно простая стратегия. Я видел, что он имел большой эффект в ситуации с совместно используемой памятью (где использование указателей — нет-нет, поскольку область совместно используемой памяти может отображаться по разным адресам).

Другое решение используется Boost.MultiIndex и состоит в альтернативной структуре памяти. Это вытекает из принципа навязчивого контейнера: вместо того, чтобы помещать элемент в контейнер (перемещая его в памяти), интрузивный контейнер использует крючки, уже находящиеся внутри элемента, чтобы соединить его в нужном месте. Исходя из этого, он достаточно прост в использовании разные крючки для соединения отдельных элементов в несколько контейнеров, верно?

Ну, Boost.MultiIndex пинает его на два шага дальше:

  1. Он использует традиционный интерфейс контейнера (т. Е. Переместить ваш объект в), но узел в который перемещается объект — это элемент с несколькими хуками
  2. Оно использует различный крючки / контейнеры в одном объекте

Ты можешь проверить различные примеры и особенно Пример 5: секвенированные индексы выглядит очень похоже на ваш собственный код.

14

Другие решения

C ++ копирование / перемещение ctor / assign безопасны для обычных типов значений. Типы регулярных значений ведут себя как целые числа или другие «обычные» значения.

Они также безопасны для семантических типов указателей, если операция не изменяет то, на что указывает указатель. Указание на что-то «внутри себя» или на другого участника является примером того, где оно терпит неудачу.

Они в некоторой степени безопасны для ссылочных семантических типов, но смешивание семантики указатель / ссылка / значение в одном классе имеет тенденцию быть небезопасным / ошибочным / опасным на практике.

Нулевое правило заключается в том, что вы создаете классы, которые ведут себя как обычные типы значений или как семантические типы указателей, которые не нужно переустанавливать при копировании / перемещении. Тогда вам не нужно писать копии / перемещать ctors.

Итераторы следуют семантике указателей.

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

21

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

Поскольку итераторы — это просто абстракция указателей на элементы коллекции, они имеют ту же проблему.

18

Это известная проблема?

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

и если да, то есть ли у него элегантное / идиоматическое решение?

Может быть, не так элегантно, как хотелось бы, и, возможно, не является лучшим по производительности (но иногда копии не таковы, поэтому в 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;
}

//...
};
9

Да, конечно, это известная проблема.

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

Ваш класс хранит итераторы и, поскольку они также являются «дескрипторами» для данных, хранящихся в другом месте, применяется та же логика.

Это вряд ли «удивительно».

7

Утверждение, что Foo не управляет какими-либо ресурсами является ложным.

Копировать конструктор в сторону, если элемент set удаляется, там должен быть код в Foo что управляет vector так что соответствующий итератор удален.

Я думаю, что идиоматическое решение состоит в том, чтобы просто использовать один контейнер, vector<size_t>и убедитесь, что количество элементов равно нулю перед вставкой. Тогда копирование и перемещение по умолчанию в порядке.

5

Нет, упомянутые вами функции не являются небезопасными; тот факт, что вы подумали о трех возможных безопасных решениях проблемы, свидетельствует о том, что здесь нет «присущего» отсутствия безопасности, даже если вы считаете, что решения нежелательны.

И да, там является 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 мог работать так: предположим, что у него есть специальная перегрузка для «вектора-итераторов», которая может переместить итераторы, и что компилятору можно как-то «сказать» только для вызова этого специального конструктора, когда итераторы действительно должны быть перезагружены -seated. (Обратите внимание, что решение «вызывает специальную перегрузку только при генерации конструктора по умолчанию для содержащего класса, который также содержит экземпляр базового типа данных итераторов «не будет работать; что если 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:: контейнеры. И у вас есть отношения между ними. Здесь мы подходим к чему-то, о чем говорилось в большинстве других ответов, и что к этому моменту должно быть очень, очень ясно: связанные с Члены класса требуют особого внимания, так как 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члены не будут работать, вы можете сказать, что конструктор по умолчанию не будет работать. Итак, теперь вам нужно решить, должен ли ваш класс быть копируемым или нет.

3
По вопросам рекламы [email protected]