Когда reinterpret_cast нарушает закон?

Я написал код, который накапливает ненужное пространство памяти и возвращает его при необходимости, организуя в виде стека (требования клиентов странные).

void* stack = nullptr;

template <typename P>
void push(P* p) {
  // reinterpret memory space of *p as a storage for the address of the top element of the stack;
  *reinterpret_cast<void**>(p) = stack;
  // now *p is the top element, so update stack to point to *p;
  stack = p;
}

template <typename P>
P* pop() {
  if (stack == nullptr) throw;

  // get the address of the top element
  P* p = static_cast<P*>(stack);
  // now stack should point to the next element after *p, which address is stored in *p's memory space
  stack = *reinterpret_cast<void**>(stack);
  return p;
}

stack — указатель на верхний элемент. Когда создается новое пространство памяти pushed, его первые 8 байтов используются для хранения адреса предыдущего элемента, stack обновляется. Простая цепочка стека.

Проблема

Пока я пытался добиться такого поведения, компилятор часто говорил следующее (я знаю, что некоторые манипуляции с памятью незаконны):

присвоение приведения является незаконным, приведение lvalue не поддерживается

Я хочу знать, законна ли моя реализация сейчас или нет?

Пример

#include <iostream>
#include <bitset>

constexpr size_t bytes_8  = 8 * 8;
constexpr size_t bytes_20 = 8 * 20;
constexpr size_t bytes_50 = 8 * 50;

// we assume that std::bitset holds its data contigiously in the beginning

struct A {
  std::bitset<bytes_8> bits;
};

struct B {
  std::bitset<bytes_20> bits;
};

struct C {
  std::bitset<bytes_50> bits;
};
int main() {
  // Imagine they were allocated on heap:
  A a;  B b;  C c;

  std::cout << "Addresses" << '\n'
            << "a: " << &a << "\t"
            << "b: " << &b << "\t"
            << "c: " << &c
            << std::endl << std::endl << std::endl;

  push<A>(&a);
  std::cout << "---------- push<A>(&a) ----------\n"
            << "'a' is the top element (and the only one), so 'stack' points to it,\n"
            << "meanwhile 'a' stores the address of the previous element (none)\n\n"
            << "stack: " << stack << "\ta.bits: " << std::hex << a.bits.to_ullong()
            << std::endl << std::endl;

  push<B>(&b);
  std::cout << "---------- push<B>(&b) ----------\n"
            << "now 'b' is the top element, so 'stack' updates to point to it,\n"
            << "'b' stores the address of the previous element (a)\n\n"
            << "stack: " << stack << "\tb.bits: " << std::hex << b.bits.to_ullong()
            << std::endl << std::endl;

  push<C>(&c);
  std::cout << "---------- push<C>(&c) ----------\n"
            << "finally 'c' is the top element and 'stack' points to it,\n"
            << "'c' stores the address of the previous element (b)\n\n"
            << "stack: " << stack << "\tc.bits: " << std::hex << c.bits.to_ullong()
            << std::endl << std::endl << std::endl;


  auto p1 = pop<C>();
  std::cout << "---------- pop<C>() ----------\n"
            << "'b' is the top element and the next one after it is 'a'\n\n"
            << "ret: " << p1 << "\nstack: " << stack << "\tb.bits: " << std::hex << b.bits.to_ullong()
            << std::endl << std::endl;

  auto p2 = pop<B>();
  std::cout << "---------- pop<B>() ----------\n"
            << "'a' is the top element and the last one in the stack\n\n"
            << "ret: " << p2 << "\nstack: " << stack << "\ta.bits: " << std::hex << a.bits.to_ullong()
            << std::endl << std::endl;

  auto p3 = pop<A>();
  std::cout << "---------- pop<A>() ----------\n"
            << "the stack is empty\n\n"
            << "ret: " << p3 << "\nstack: " << "0x" << stack
            << std::endl << std::endl;

  return 0;
}
Выход:
Addresses
a: 0x7fffffffdc20       b: 0x7fffffffdc40       c: 0x7fffffffdc60


---------- push<A>(&a) ----------
'a' is the top element (and the only one), so 'stack' points to it,
meanwhile 'a' stores the address of the previous element (none)

stack: 0x7fffffffdc20   a.bits: 0

---------- push<B>(&b) ----------
now 'b' is the top element, so 'stack' updates to point to it,
'b' stores the address of the previous element (a)

stack: 0x7fffffffdc40   b.bits: 7fffffffdc20

---------- push<C>(&c) ----------
finally 'c' is the top element and 'stack' points to it,
'c' stores the address of the previous element (b)

stack: 0x7fffffffdc60   c.bits: 7fffffffdc40


---------- pop<C>() ----------
'b' is the top element and the next one after it is 'a'

ret: 0x7fffffffdc60
stack: 0x7fffffffdc40   b.bits: 7fffffffdc20

---------- pop<B>() ----------
'a' is the top element and the last one in the stack

ret: 0x7fffffffdc40
stack: 0x7fffffffdc20   a.bits: 0

---------- pop<A>() ----------
the stack is empty

ret: 0x7fffffffdc20
stack: 0x0

Я знаю, что это УБ

Alexander S 22.04.2024 13:26

Вы читали это?

frippe 22.04.2024 13:30

Я не уверен, что вы подразумеваете под «законным». За это тебя не посадят в тюрьму. В C++ есть спецификация, которая говорит вам, как интерпретировать вашу программу как C++ (и в вашем случае вывод таков: «это недопустимый C++, и из спецификации не могут быть выведены никакие дальнейшие последствия», т. е. вы сами по себе). Но ты, кажется, уже это знаешь. Возможно, вы спрашиваете, как можно написать переносимый код с четко определенным поведением?

Kerrek SB 22.04.2024 13:31

Странно, что хоть здесь и не происходит ничего трагического: я просто использую память так, как хочу, но это не валидный C++... Почему?

Alexander S 22.04.2024 13:35

@frippe в основном да

Alexander S 22.04.2024 13:35

Я хочу использовать этот метод для распределителя свободных списков, и будет жаль, если это что-то сломается (непереносимо и т. д.).

Alexander S 22.04.2024 13:38

Возможно, вы захотите «просто использовать память так, как я хочу», но если стандарт C++ говорит, что в данном случае это UB (и я признаю, что не читал всех подробностей в вашем вопросе, но вы упомянули, что знаете, это UB), тогда не стоит. Это так просто. Если вы это сделаете, нет никакой гарантии относительно результата вашей программы.

wohlstad 22.04.2024 13:53

Вы просто временно уничтожаете базовую память своих объектов, записывая туда указатели. Почему? Недостаточно памяти? Используйте boost::inrusive::slist (или прочитайте его код для вдохновения), чтобы получить контейнер, который не владеет своими элементами.

Öö Tiib 22.04.2024 14:03

У этого кода нет причин использовать void* (не говоря уже о void**) и бороться/мешать/подрывать систему типов, чтобы накопить ненужное пространство памяти и вернуть его при необходимости. Переосмыслите дизайн.

Eljay 22.04.2024 14:37

@Eljay Добавляет ли эта система типов какие-либо проблемы с производительностью, если она так сильно запутана void*?

Alexander S 22.04.2024 15:33

Нет, система типов часто устраняет проблемы с производительностью. qsort из стандартной библиотеки C часто работает медленнее, чем std::sort, именно потому, что C++ сохраняет информацию о типе, что позволяет компилятору выполнять более агрессивную оптимизацию.

Caleth 22.04.2024 15:53

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

Marek R 22.04.2024 16:18

@ÖöTiib: Я почти уверен, что OP кэширует память, чтобы обойти new и delete

Mooing Duck 22.04.2024 18:34

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

Eljay 22.04.2024 19:25
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
2
14
146
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

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

Помещение элемента в стек перезаписывает его часть. Это операция с потерями. Байты sizeof void* в начале представления объекта *p исчезли и никогда не будут восстановлены. Даже если вы больше не используете этот объект, его деструктор все равно должен запуститься и, как правило, ему нужны эти данные.

Я согласен, но push следует применять только к тем частям памяти, которые не содержат никакой информации (мусор)

Alexander S 22.04.2024 14:55

@AlexanderS • показанная память не мусор, она используется. До тех пор, пока объект не будет уничтожен, после чего он станет доступен для повторного использования... но вы повторно используете его, когда он все еще является живым объектом. (И ваш пример кода использует живые объекты автоматического хранения (стека), но мы игнорируем это из-за комментария, чтобы игнорировать это и притворяться, что они находятся в динамическом свободном хранилище (куче).)

Eljay 22.04.2024 19:29
Ответ принят как подходящий

Вы можете повторно использовать хранилище объектов после окончания их жизни, но вам действительно следует отслеживать размеры.

struct frame {
    frame * ptr;
    std::size_t size;
};

frame* stack = nullptr;

template <typename P>
requires sizeof(P) >= sizeof frame
void push(P* obj) {
    p->~P();
    stack = new(p) frame{ stack, sizeof(P) };
}

template <typename P, typename... Args>
P* pop(Args&&... args) {
    if (stack == nullptr || stack->size != sizeof(P)) throw std::bad_alloc{};
    frame * top = stack;
    stack = stack->ptr;
    top->~frame();
    return new(top) P(std::forward<Args>(args)...);
}

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

Насколько я понимаю, весь этот странный код у меня был вызван потому, что я совсем об этом забыл new :)

Alexander S 22.04.2024 16:02

Основное отличие состоит в том, что новое размещение в этом ответе просто использует объект *p для своего хранения, тогда как ваша переинтерпретация делает вид, что объект типа P является объектом типа void*, то есть UB.

Kerrek SB 22.04.2024 17:21

Другие вопросы по теме