Обоснование ограничительных правил для извлечения и повторной вставки с картой - PullRequest
0 голосов
/ 08 ноября 2018

Начиная с C ++ 17, ассоциативные контейнеры поддерживают извлечение узла и его повторную вставку (возможно, в другой контейнер того же типа). Объект, возвращаемый extract(key), представляет собой node_handle , который предназначен только для перемещения и для контейнеров карты имеет функции-члены

key_type &key() const;
mapped_type &mapped() const;

, которые позволяют изменять не только отображаемый тип, но и ключ. Это можно использовать для изменения ключа без перераспределения (пример взят из документации для map::extract()):

std::map<int, std::string> map{{1,"mango"}, {2,"papaya"}, {3,"guava"}};
auto handle = map.extract(2);
handle.key() = 4;
map.insert(move(handle));

Насколько я понимаю, map реализован в виде дерева двоичного поиска, в то время как map::extract() отсоединяет узел от дерева и возвращает указатель на него через дескриптор узла, который переходит во владение. После map::insert() узел повторно связывается с деревом, и владение снова переходит в карту.

Таким образом, узел (и сохраненные key и mapped_type) не перераспределяются, перемещаются или копируются в процессе. Стандарт гласит: (мой свет):

Члены извлечения делают недействительными только итераторы для удаленного элемента; указатели и ссылки на удаленный элемент остаются действительными. Тем не мение, доступ к элементу с помощью таких указателей и ссылок, в то время как элемент принадлежит node_type: неопределенное поведение . Ссылки и указатели на элемент, полученный в то время как он принадлежит типу узла недействительно , если элемент успешно вставлен.

Мой вопрос: в чем причина (1) превращения UB в доступ к извлеченному элементу по его адресу и (2) аннулирования после вставки адреса, полученного в извлеченном состоянии?

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

#include <map>
#include <string>
#include <iostream>

struct immovable_string : std::string
{
    immovable_string(const char*s) : std::string(s) {}
    immovable_string() = default;
    immovable_string(immovable_string const&) = delete;
    immovable_string(immovable_string &&) = delete;
    immovable_string&operator=(immovable_string const&) = delete;
    immovable_string&operator=(immovable_string &&) = delete;
};

int main()
{
    std::map<int,immovable_string> map;

    map.emplace(1,"mango");
    map.emplace(2,"papaya");
    map.emplace(3,"guava");

    std::cout << "initially:     "
              << " address=" << std::addressof(map[2])
              << " value=" << map[2] <<'\n';

    auto handle = map.extract(2);

    std::cout << "after extract: "
              << " address=" << std::addressof(handle.mapped())
              << " value=" << handle.mapped() <<'\n';

    handle.key() = 4;
    map.insert(move(handle));

    std::cout << "after insert:  "
              << " address=" << std::addressof(map[4])
              << " value=" << map[4] <<'\n';
}

компилируется (с gcc 8.2.0, используя -std=c++17) и выдает вывод

initially:      address=0x7f9e06c02738 value=papaya
after extract:  address=0x7f9e06c02738 value=papaya
after insert:   address=0x7f9e06c02738 value=papaya

как и ожидалось (те же результаты получены для std::string вместо immovable_string и / или unordered_map вместо map).


Редактировать

Обратите внимание, что я не спрашиваю о проблемах, связанных с изменением key (map магазинов pair<const Key,T>).

Мой вопрос касается исключительно ограничений доступа к отображаемому элементу с помощью указателей или ссылок. Вся идея идиомы извлечения и вставки имеет смысл только , если элемент не перемещается / не копируется, т.е. если его адрес остается действительным всегда (что фактически определяется стандартом). Предоставление доступа к элементу, пока в извлеченном состоянии UB кажется странным, а делает механизм извлечения и вставки менее полезным : подумайте о многопоточном коде, когда один поток обращается к элементу, а другой извлекает и повторно вставляет Это. Это может быть реализовано без каких-либо проблем, но может вызвать UB - ПОЧЕМУ?

Вот сценарий UB (который ИМХО прекрасно подходит, UB не требуется):

void somefunc(object*ptr) { ptr->do_something(); }
void re_key(map<int,object> &M, int oldKey, int newKey)
{
    if(M.find(0)!=M.end() && M.find(newKey)==M.end()) {
        auto handle = M.extract(0);
        handle.key() = newKey;
        M.insert(std::move(handle));
    }
}

map<int,object> M = fillMap();
auto ptr = addressof(M[0]);     // takes initial address
thread t1(somefunc,ptr);        // uses said address to access object
thread t2(re_key,M,7);          // extracts and inserts an object 

Конечно, если insert() завершится неудачно, handle будет уничтожен, а адрес признан недействительным. Это очевидно, но пользователь может кое-что об этом.

Ответы [ 2 ]

0 голосов
/ 08 ноября 2018

Я просто продолжаю ответ Kerrek SB в надежде объяснить проблему более подробно (и, следовательно, более убедительно). В принятой редакции 3 рассматриваемого документа (P0083R3) упоминается головоломка std::pair<const Key, Mapped> против std::pair<Key, Mapped>, и что «Преобразование между ними может быть безопасно осуществлено с использованием метода, аналогичного используемому * 1005». * при извлечении и повторной вставке. "

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

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

Рассмотрим, например, тривиально эту функцию:

int f(std::pair<const int, int> &a, const std::pair<int, int> &b)
{
    a.second = 5;
    return b.second;
}

Согласно ограничениям на псевдонимы типов, реализация может предполагать, что a и b не могут находиться в одной и той же ячейке памяти. Поэтому в реализации также допускается, что a.second и b.second не находятся в одной и той же ячейке памяти, , даже если они имеют один и тот же тип . Следовательно, реализация имеет право на некоторые базовые свободы генерации кода, такие как выполнение загрузки b.second перед сохранением до a.second без необходимости фактического сравнения адресов a и b вначале.

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

int g()
{
    std::map<int, int> m{{1, 1}};
    auto &r = m[1];
    auto node = m.extract(1);
    return f(r, node.value());
}

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

  1. node_type для std::map не имеет value() метода.
  2. Даже если это так, node_type должно std::launder (или что-то примерно эквивалентное) значению.

Тем не менее, эти пункты не обеспечивают фактического средства правовой защиты. Что касается первого пункта, рассмотрим этот незначительный вариант:

int f(std::pair<const int, int> &a, const int &b)
{
    a.second = 5;
    return b;
}

int g()
{
    std::map<int, int> m{{1, 1}};
    auto &r = m[1];
    auto node = m.extract(1);
    return f(r, node.mapped());
}

Теперь оптимизация глазка в f не дает компилятору достаточной информации, чтобы исключить алиасы. Однако, давайте предположим, что компилятор может встраивать как node.mapped() (и, следовательно, компилятор может установить, что он возвращает ссылку на second из std::pair<int, int>), так и f. Внезапно компилятор может снова почувствовать себя вправе опасной оптимизации.

А как же отмывание? Прежде всего, это здесь не применимо, потому что информация, касающаяся extract и отмывания, сделанного в нем, может вообще отличаться от единицы перевода node_type::mapped(). Это необходимо подчеркнуть: с жесткими ограничениями, которые были стандартизированы, отмывание может быть сделано во время извлечения, это не должно быть сделано при каждом вызове value(), что также ясно является намерением, выраженным в Цитата я предоставил в начале. Основная проблема, однако, заключается в том, что отмывание не может предотвратить UB здесь, даже если это было сделано внутри node_type::mapped(). Фактически, следующий код имеет неопределенное поведение (пример предполагает, что sizeof(int) <= sizeof(float)):

float g()
{
    float value = 0.0f; // deliberate separate initialization, see below
    value = 3.14f;
    int *intp = std::launder(reinterpret_cast<int *>(&value));
    *intp = 1;
    return value + *intp;
}

Это потому, что с использованием std::launder вообще не дает пользователю права печатать punning . Вместо этого std::launder позволяет повторно использовать память value только путем установления зависимости продолжительности жизни между float, который изначально находится в &value, и int, который живет там после std::launder. На самом деле, что касается стандарта, value и *intp не могут быть живы одновременно точно, потому что они имеют несовместимые с указателем типы и одну и ту же ячейку памяти.

(Здесь std::launder добивается, например, предотвращения переупорядочения от value = 3.14f; до *intp = 1. Проще говоря, компилятору не разрешается переупорядочивать записи после std::launder и читать из отмытый указатель до std::launder, если только он не может доказать, что области памяти на самом деле не перекрываются, и это действительно так, даже если речь идет о несовместимых с указателем типах. Я использовал отдельное назначение, чтобы я мог прояснить этот момент .)

В конечном итоге все сводится к тому, что для безопасной поддержки предполагаемого использования реализациям придется добавить дополнительную магию поверх того, что упомянуто в статье (и последнее в значительной степени уже реализовано). потому что это по крайней мере очень похоже на эффекты std::launder). Это не только вызвало бы дополнительные усилия, но также могло бы иметь побочный эффект, заключающийся в предотвращении определенных оптимизаций в тех случаях, когда пользователь добровольно соблюдает установленные ограничения. Подобные суждения принимаются постоянно при стандартизации C ++ или почти во всем, когда эксперты пытаются сопоставить прогнозируемые затраты с определенными или, по крайней мере, вероятными выгодами. Если вам все еще нужно знать больше, вам, вероятно, придется обратиться к некоторым членам CWG напрямую, потому что именно здесь был сделан запрос на применение этих ограничений, как указано в документе, связанном выше.

Надеюсь, это поможет немного прояснить ситуацию, даже если вы все еще не согласны с принятым решением.

В качестве последнего замечания я настоятельно рекомендую вам посмотреть некоторые из замечательных выступлений на C ++ UB, если вы еще этого не сделали, например, Неопределенное поведение - это удивительно от Петра Падлевского или Garbage В, Мусор Out ... Чендлер Кэррут.

0 голосов
/ 08 ноября 2018

Я думаю, что преобладающая тонкость в "экстракционной" системе состоит в том, что value_type для map<K, T> равно pair<const K, T> & mdash; обратите внимание на const!

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

Это, в основном, требует от вас серьезного рассмотрения проблемы и убеждения себя в том, что pair<const K, T> иногда можно интерпретировать как pair<K, T> (и имейте в виду, что pair - это шаблон, на который пользователям разрешено специализироваться!) ). Поэтому, чтобы избежать какого-либо потенциала для изменения константных объектов, должна быть четкая последовательность вставленных и извлеченных состояний для любого узла.

Существует стандартная формулировка, чтобы помочь с проблемой специализации в [container.node.overview] p4:

Если пользовательская специализация pair существует для pair<const Key, T> или pair<Key, T>, где Key - это key_type и T контейнера - это mapped_type контейнера, поведение операций с дескрипторами узла не определено.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...