Другое

std::span с константными кортежами: Объяснение строгого псевдонимирования

Узнайте, почему std::span<std::tuple<const int, int>> не может безопасно ссылаться на std::array<std::tuple<int, int>> из-за правил строгого псевдонимирования. Узнайте о безопасных альтернативах и лучших практиках для корректного использования const в C++.

Может ли std::span кортежей с константными членами ссылаться на массив кортежей без константных членов?

Я работаю с C++17 и пытаюсь понять, безопасно ли использовать std::span<std::tuple<const int, int>> для ссылки на std::array<std::tuple<int, int>, 1>.

Контекст

  • Я беспокоюсь о правилах строгого псевдонимизации
  • Макет двух типов кортежей должен быть одинаковым, но я не уверен, как влияет квалификатор const
  • Я могу заставить это скомпилироваться (например, с reinterpret_cast), но хочу знать, безопасно ли это

Сценарий использования

Я реализую структуру, похожую на std::map, хранящуюся во внутреннем std::array. Я хочу:

  • Чтобы первый элемент (ключ) был константным, чтобы предотвратить изменение, которое может нарушить упорядочивание
  • Чтобы второй элемент (значение) был изменяемым для модификации на месте

Дополнительный вопрос

Учитывая, что std::pair задает порядок членов, не было бы использование std::pair вместо std::tuple более безопасным подходом?

Нет, использовать std::span<std::tuple<const int, int>> для ссылки на std::array<std::tuple<int, int>, 1> небезопасно из-за строгих правил алиасирования в C++. Несмотря на то, что компоновка памяти кортежей может быть идентичной, разные типы (const int против int) нарушают ограничения строгого алиасирования, что приводит к неопределенному поведению при разыменовании через span.

Содержание

Проблема строгого алиасирования

Строгие правила алиасирования в C++ предписывают, что объект одного типа не может быть доступен через lvalue другого типа, с некоторыми исключениями. Стандарт C++ гласит, что при попытке прочитать или изменить сохраненное значение объекта через glvalue другого типа поведение не определено, если не выполнены определенные условия.

В вашем случае std::tuple<const int, int> и std::tuple<int, int> являются различными типами, даже если их первый член отличается только квалификатором const. При создании std::span первого типа и попытке сослаться на массив второго типа вы нарушаете эти фундаментальные правила безопасности типов.

Ключевой момент: Квалификатор const влияет на систему типов, а не только на представление в памяти. С точки зрения компилятора, это совершенно разные типы.

Почему важны требования к типам std::span

std::span - это невладеющее представление (view) над непрерывной последовательностью объектов. Как объясняется в cppreference, параметр шаблона определяет тип элементов, которые будет содержать span. Система типов span обеспечивает соответствие базовых данных объявленному типу элемента.

Когда вы объявляете std::span<std::tuple<const int, int>>, вы сообщаете компилятору, что ожидаете просматривать массив, где каждый элемент имеет тип std::tuple<const int, int>. Если затем вы направляете этот span на массив std::tuple<int, int>, вы создаете несоответствие типов, с которым компилятор не может безопасно оптимизировать.

Обсуждение на Stack Overflow подчеркивает именно эту проблему: “Ничто не заставляет компоновку std::tuple<const int, int> как-либо связана с std::tuple<int, int>. Не то чтобы какой-либо разумный стандартный библиотекарь делал это, но порядок членов может быть изменен или может быть введено выравнивание.”

Распространенное заблуждение о reinterpret_cast

Хотя вы можете скомпилировать код с использованием reinterpret_cast для объединения этих типов, этот подход нарушает правила строгого алиасирования и приводит к неопределенному поведению. Согласно документации reinterpret_cast, “reinterpret_cast не создает объекты. Разыменование указателя на несуществующий объект - это Неопределенное Поведение.”

Gist на GitHub о строгом алиасировании содержит четкий пример того, как это может пойти не так:

cpp
int a = 1;
short j;
float f = 1.f;
printf("%i\n", j = *(reinterpret_cast<short*>(&a)));  // Нарушает строгое алиасирование
printf("%i\n", j = *(reinterpret_cast<int*>(&f)));    // Нарушает строгое алиасирование

Хотя эти преобразования могут “работать” на практике, они полагаются на детали реализации и могут сломаться при оптимизации компилятором.

Компоновка памяти против системы типов

Важно различать компоновку памяти и систему типов:

  • Компоновка памяти: Квалификатор const не влияет на то, как значения хранятся в памяти. И const int, и int используют одинаковое количество пространства и имеют одинаковое двоичное представление.
  • Система типов: Квалификатор const создает отдельный тип, который компилятор должен уважать для безопасности типов и целей оптимизации.

Обсуждение на Reddit подчеркивает это различие: “Всякий раз, когда предпринимается попытка прочитать или изменить сохраненное значение объекта типа DynamicType через glvalue типа AliasedType, поведение не определено, если не выполнено одно из следующих условий.”

std::pair против std::tuple: что безопаснее?

Хотя std::pair явно указывает порядок своих членов больше, чем std::tuple, это не решает проблему строгого алиасирования. Основная проблема остается в том, что std::pair<const int, int> и std::pair<int, int> - это разные типы.

Однако std::pair предлагает некоторые преимущества в этом контексте:

  1. Явный порядок членов: Первый член всегда является первым элементом в памяти
  2. Стандартизированная компоновка: В отличие от кортежей, пары имеют более предсказуемые компоновки между реализациями
  3. Более ясный намерение: Использование pair делает ваше намерение иметь структуру (ключ, значение) более очевидным

Структурированные привязки в C++17 могут сделать работу с парами более удобной:

cpp
std::pair<const int, int> element;
auto& [key, value] = element;

Безопасные альтернативные решения

Вот несколько безопасных подходов для достижения вашей цели:

1. Используйте корректные по const типы span

cpp
std::array<std::tuple<int, int>, 1> data;
std::span<std::tuple<int, int>> mutable_span(data);
std::span<const std::tuple<int, int>> const_span(data);

2. Реализуйте корректную по const обертку

cpp
template<typename T>
struct const_key_wrapper {
    T value;
    // Методы доступа с корректным использованием const
    const auto& getKey() const { return std::get<0>(value); }
    auto& getValue() { return std::get<1>(value); }
    const auto& getValue() const { return std::get<1>(value); }
};

3. Используйте std::array из std::pair с правильной квалификацией const

cpp
std::array<std::pair<const int, int>, 1> data;
std::span<const std::pair<const int, int>> key_value_span(data);

4. Реализуйте собственный тип представления

cpp
template<typename Key, typename Value>
class key_value_span {
    std::span<std::pair<Key, Value>> data;
public:
    // Предоставьте методы доступа с корректным использованием const
    const Key& getKey(size_t index) const { return data[index].first; }
    Value& getValue(size_t index) { return data[index].second; }
};

Практические рекомендации

  1. Избегайте типопсевдонимов (type punning): Никогда не используйте reinterpret_cast для обхода безопасности типов для данных, которые будут доступны через оба типа.

  2. Используйте std::pair для пар “ключ-значение”: Для вашей структуры, похожей на карту, std::pair<const Key, Value> более подходит, чем std::tuple.

  3. Рассмотрите расширения компилятора: Если вам абсолютно необходим типопсевдоним, рассмотрите использование расширений компилятора, таких как __builtin_types_compatible_p или std::launder (C++17), с крайней осторожностью.

  4. Документируйте ваш дизайн: Четко документируйте, почему вам нужен доступ с корректным использованием const, и как вы поддерживаете инварианты.

  5. Тестируйте с оптимизациями: Всегда тестируйте ваш код с максимальными уровнями оптимизации (-O3, -Ofast), чтобы выявлять нарушения строгого алиасирования.

Блог Qt о типопсевдонимах дает хороший совет: “С помощью тщательного кодирования можно использовать типопсевдонимы и не нарушать строгое алиасирование. Однако это очень сложно сделать, и также сложно не свести компилятор с ума. Поэтому правило таково: если вы используете типопсевдонимы (то есть, если вы приводите к другому типу указателя через C-приведение или reinterpret_cast), вы, вероятно, делаете что-то не так.”

Заключение

  • Строгие правила алиасирования запрещают использование std::span<std::tuple<const int, int>> для ссылки на std::array<std::tuple<int, int>>, даже если компоновки памяти могут быть идентичными
  • reinterpret_cast не решает проблемы - он лишь скрывает неопределенное поведение
  • std::pair предпочтительнее std::tuple для структур “ключ-значение” из-за его явного упорядочивания членов и более ясного намерения
  • Безопасные альтернативы включают использование корректных по const типов span, реализацию классов-оберток или проектирование собственных типов представлений
  • Всегда отдавайте приоритет безопасности типов вместо удобства при работе со структурами данных с корректным использованием const

Самый безопасный подход - переработать вашу структуру данных для поддержания безопасности типов при достижении требований к доступу с корректным использованием const. Рассмотрите использование std::array<std::pair<const Key, Value>> с соответствующими типами span вместо попыток обойти ограничения системы типов.

Источники

  1. Stack Overflow - Span referencing different tuple collection
  2. cppreference - std::span
  3. cppreference - reinterpret_cast
  4. GitHub - What is Strict Aliasing and Why do we Care?
  5. Reddit - Strict aliasing, placement new, and reinterpret_cast
  6. Qt Blog - Type-punning and strict-aliasing
  7. CodingGame - Structured Bindings in C++17
Авторы
Проверено модерацией
Модерация