Подтвердить что ты не робот

Является ли С++ default copy-constructor неотъемлемо небезопасным? Являются ли итераторы принципиально небезопасными?

Раньше я думал, что объектная модель С++ очень надежна, когда соблюдаются лучшие практики.
Всего несколько минут назад, однако, я понял, чего раньше не было.

Рассмотрим этот код:

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)
};

Я написал такой код. И до сегодняшнего дня я не видел проблемы с этим.

Но, подумав об этом, я понял, что этот класс очень сломан:
Его copy-constructor и copy-присваивание копируют итераторы внутри vector, что подразумевает, что они все равно будут указывать на old set! Новый - это не настоящая копия!

Другими словами, я должен вручную реализовать конструктор-копир, хотя этот класс не управляет никакими ресурсами (без RAII)!

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

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

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

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

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

4b9b3361

Ответ 1

Является ли это общеизвестной проблемой?

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

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

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

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

  • Клавиши
  • общие элементы

Посмотрите их в порядке.

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

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

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

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

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

Ответ 2

С++ copy/move ctor/assign безопасны для обычных типов значений. Регулярные типы значений ведут себя как целые числа или другие "регулярные" значения.

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

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

Правило 0 - это то, что вы делаете классы, которые ведут себя как обычные типы значений, или семантические типы указателя, которые не нужно перепроверять при копировании/перемещении. Тогда вам не нужно писать copy/move ctors.

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

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

Ответ 3

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

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

Ответ 4

Является ли это известной проблемой

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

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

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

    //...
};

Или, если используется С++ 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;
    }

    //...
};

Ответ 5

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

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

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

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

Ответ 6

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

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

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

Ответ 7

"Небезопасно"

Нет, функции, которые вы упоминаете, по своей сути небезопасны; тот факт, что вы подумали о трех возможных безопасных решениях проблемы, свидетельствует о том, что здесь нет "неотъемлемой" нехватки безопасности, хотя вы считаете, что решения нежелательны.

И да, здесь есть RAII: контейнеры (set и vector) управляют ресурсами. Я считаю, что ваш вопрос в том, что RAII "уже позаботился" с помощью контейнеров std. Но вам нужно подумать, что сами экземпляры контейнеров являются "ресурсами", и на самом деле ваш класс управляет ими. Вы правы, что напрямую не управляете памятью кучи, потому что этот аспект проблемы управления заботится о вас стандартной библиотекой. Но есть еще проблема управления, о которой я расскажу чуть ниже.

Поведение "магия" по умолчанию

Проблема в том, что вы, по-видимому, надеетесь, что вы можете доверять конструктору копии по умолчанию, чтобы "делать правильную вещь" в нетривиальном случае, таком как это. Я не уверен, почему вы ожидали правильного поведения - возможно, вы надеетесь, что запоминание правил, таких как "правило 3", будет надежным способом обеспечить, чтобы вы не стреляли в ногу? Конечно, это было бы неплохо (и, как указано в другом ответе, Rust намного больше, чем другие языки низкого уровня, для того, чтобы сделать съемку намного сложнее), но С++ просто не предназначен для "бездумного" класса такого дизайна, и не должно быть.

Концептуальное поведение конструктора

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

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

При копировании ваших итераторов vector, что делает std::vector copy-constructor? Он выполняет "глубокую копию", т.е. Данные внутри вектора копируются. Теперь, если вектор содержит итераторы, как это влияет на ситуацию? Ну, это просто: итераторы - это данные, хранящиеся в векторе, поэтому сами итераторы будут скопированы. Что делает экземпляр-конструктор итератора? Я не собираюсь на самом деле смотреть на это, потому что мне не нужно знать специфику: мне просто нужно знать, что итераторы похожи на указатели в этом (и в другом отношении), и копирование указателя просто копирует сам указатель, а не данные, на которые указывает. I.e., итераторы и указатели по умолчанию не имеют глубокого копирования.

Обратите внимание, что это неудивительно: конечно, итераторы по умолчанию не выполняют глубокое копирование. Если бы они это сделали, у вас появился бы другой новый набор для каждого копируемого итератора. И это делает еще меньше смысла, чем изначально: например, что бы это значило, если однонаправленные итераторы сделали глубокие копии своих данных? Предположительно, вы получите частичную копию, т.е. Все остальные данные, которые все еще находятся "перед" текущей позицией итератора, плюс новый итератор, указывающий на "фронт" новой структуры данных.

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

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 copy-constructor не может быть предназначен для модификации итераторов определенным образом, и фактически std::vector copy-constructor - именно то, что вам нужно в большинстве случаев, где оно действительно будет использоваться.

Однако предположим, что std::vector может работать следующим образом: предположим, что у него была специальная перегрузка для "вектора-итераторов", которая могла бы снова заместить итераторы, и что компилятор мог как-то "сказать" только для вызова этого специальный конструктор, когда итераторы действительно должны быть переустановлены. (Обратите внимание, что решение "только вызывать специальную перегрузку при генерации конструктора по умолчанию для содержащего класса, который также содержит экземпляр типа данных, лежащих в основе итераторов", не сработает, а что, если итераторы std::vector в вашем случае были указывая на другой стандартный набор и рассматривались просто как ссылка на данные, управляемые каким-то другим классом? Черт, как компилятор должен знать, все ли итераторы указывают на то же самое? std::set?) Игнорирование этой проблемы компилятор будет знать, когда нужно вызвать этот специальный конструктор, как выглядит код конструктора? Попробуйте это, используя _Ctnr<T>::iterator как наш тип итератора (я буду использовать С++ 11/14isms и немного неаккуратно, но общая точка должна быть ясной):

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

Элегантное решение

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

Но пользовательские конструкторы копирования элегантны; позволяя вам написать их, является элегантным решением С++ для написания правильных нетривиальных классов.

По общему признанию, это похоже на случай, когда "правило 3" не совсем применимо, поскольку существует явная потребность либо в =delete конструкторе копирования, либо записать его самостоятельно, но нет явной потребности (пока) для определяемого пользователем деструктора. Но опять же, вы не можете просто программировать на основе эмпирических правил и ожидать, что все будет работать правильно, особенно на низкоуровневом языке, таком как С++; вы должны знать детали (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 не будет работать, вы можете сказать, что конструктор по умолчанию не будет работать. Итак, теперь вам нужно решить, должен ли ваш класс копироваться или нет.