Что такое неопределенное поведение при этих преобразованиях указателей?

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


Я разрабатываю очень минимальный класс указателя смещения.

Его конечная цель — связанные структуры данных в общей памяти, которые должны справляться с различным смещением виртуального адреса в разных процессах. Идея вдохновлена ​​boost::interprocess::offset_ptr

Мой первый тест показал результат, зависящий от уровня оптимизации, что предполагает неопределенное поведение. Как это можно исправить? См. например

#include <cstddef>
#include <iostream>

template<typename T>
class NullableOffsetPtr
{
public:
    NullableOffsetPtr(T const* ptr) :
        offset_{
            ptr == nullptr ? std::ptrdiff_t{1} : reinterpret_cast<std::byte const*>(ptr) - reinterpret_cast<std::byte const*>(this)}
    {
    }

    T* get()
    {
        return offset_ == 1 ? nullptr : reinterpret_cast<T*>(reinterpret_cast<std::byte*>(this) + offset_);
    }

private:
    std::ptrdiff_t offset_; // Using units of bytes, as alignments of *this and *ptr may not match.
};

class Sample
{
};

int main(int argc, char* argv[])
{
    Sample sample;
    NullableOffsetPtr<Sample> ptr{&sample};

    std::cout << "(ptr.get() == &sample): " << (ptr.get() == &sample) << std::endl;
    std::cout << "ptr.get(): " << ptr.get() << std::endl;
    std::cout << "&sample  : " << &sample << std::endl;

    return 0;
}

При всех уровнях оптимизации -O1 и выше выходные данные исполняемых файлов, скомпилированных gcc, будут

(ptr.get() == &sample): 0
ptr.get(): 0x7ffe503cd42f
&sample  : 0x7ffe503cd42f

при сравнении указателей значение false, несмотря на то, что отдельные значения (меняющиеся при каждом запуске) одинаковы!

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

См. также пример божьего болта

Бонусный вопрос 1: я понимаю причину, по которой expr.add#4.2 ограничивает + и - указателями, нацеленными на элементы одного и того же массива, что было объяснено в нескольких комментариях. Однако то же самое можно сделать, если и указатель, и указатель плюс целевые объекты смещения находятся в одной и той же (вложенной) композиции, а не обязательно в массиве. В противном случае макрос offsetof будет бессмысленным. Пример:

#include <iostream>

struct Inner
{
    int i1;
    int i2;
};

struct Outer
{
    int i1;
    int i2;
    Inner inner;
};

int main(int argc, char* argv[])
{
    Outer outer;
    std::cout << "&outer.i2 - &outer.i1: " << &outer.i2 - &outer.i1 << std::endl;
    std::cout << "&outer.inner.i2 - &outer.i1: " << &outer.inner.i2 - &outer.i1 << std::endl;

    return 0;
}

возвращает

&outer.i2 - &outer.i1: 1
&outer.inner.i2 - &outer.i1: 3

Все целевые указатели invold находятся внутри объекта outer, но массив не задействован.

expr.add#4.2 говорит о «(возможно, гипотетическом) элементе массива». Будет ли это встречено в рамках упомянутого состава, или что еще значит «возможно-гипотетический»?

Бонусный вопрос 2: в какой степени boost::interprocess::offset_ptr может предотвратить UB? Нашёл термин «незаконно, но правильно» в контексте встраивания. Однако я не понимаю, как связана встраивание, и существует ли широко признанная незаконная корректность, которую можно было бы проверить. Какие-либо предложения?

переосмысление приведения из памяти к типу разрешено только в том случае, если этот тип тривиально конструируется и тривиально копируется (например, он должен быть типом POD). Таким образом, вы ограничиваете T, чтобы удовлетворить этим требованиям.

Pepijn Kramer 04.05.2024 08:24

@PepijnKramer У вас есть источник? Обратите внимание, что код OP не полагается на неявное начало жизни объекта.

HolyBlackCat 04.05.2024 08:25

Также этот код начнет работать, если вы поместите обе переменные как нестатические члены в один и тот же класс.

HolyBlackCat 04.05.2024 08:27

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

Pepijn Kramer 04.05.2024 08:32

@PepijnKramer Да, и его срок службы действительно начался, так что если это UB, то по какой-то другой причине.

HolyBlackCat 04.05.2024 08:40

Просто интересно, почему такое особое обращение с nullptr? Есть ли какое-то неясное правило, которое сделало бы этот UB? Кроме того, это заставило меня задуматься, не может ли смещение 1 возникнуть естественным путем без подачи nullptr.

Ulrich Eckhardt 04.05.2024 08:47

Сложение и вычитание двух несвязанных указателей — это UB, т.е. ptr-this

Alan Birtles 04.05.2024 08:50

Мне не очень нравятся эти дураки. Вы можете привести к uintptr_t, чтобы сделать расчет смещения четко определенным, но это не поможет сделать последующий *this ... + offset законным.

HolyBlackCat 04.05.2024 09:17

Пожалуйста, задавайте один и только вопрос за каждый вопрос; не задавайте «бонусные» вопросы; если у вас есть дополнительные вопросы, задайте новый вопрос.

Mark Rotteveel 04.05.2024 15:03
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
1
10
124
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

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

Вот соответствующая формулировка:

  • [expr.add]/4.2, в котором говорится, что + можно использовать только для перемещения указателя между элементами одного и того же массива. Аналогично, - может вычитать указатели только между элементами одного и того же массива.
  • [basic.types.general]/4, в котором говорится, что вы можете рассматривать объекты как массивы unsigned char.
  • [basic.lval]/11.3 благословляет char, unsigned char и std::byte, позволяя получить доступ к любому объекту через указатели/ссылки на них, игнорируя строгий псевдоним.

Также общеизвестно, что вы не можете выполнять арифметику с указателями в uintptr_t, чтобы обойти [expr.add]/4.2, но сейчас я не могу найти соответствующую формулировку. (Я имею в виду, что вы не можете таким образом переключаться между несвязанными объектами. Вы можете использовать его, чтобы - дал вам правильное смещение.)

Из Руководства GCC:

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

В формулировках о времени жизни и псевдонимах в целом беспорядок, и если копнуть слишком глубоко, многие вещи технически оказываются UB (или подразумеваются таковыми), хотя на практике они не соблюдаются. Например, существует концепция, согласно которой байт доступен через указатель. Он используется в описании std::launder и, насколько я могу судить, больше нигде (в стандарте нигде не сказано, что доступ к «недоступному» байту — это UB).

Я думаю, вы могли бы использовать std::intptr_t, чтобы обойти это, но при этом вы сразу же столкнетесь с определенными реализацией и, честно говоря, недостаточно специфицированными/недодокументированными эффектами.

Jan Schultke 04.05.2024 09:17

@JanSchultke Да, только что отредактировал это. Насколько я понимаю, приведение к [u]intptr_t позволяет безопасно вычислить смещение (по крайней мере, на практике), но этого недостаточно, чтобы благословить + на переход между несвязанными объектами.

HolyBlackCat 04.05.2024 09:18

IIUC, единственная допустимая операция над std::intptr_t — это приведение его к исходному типу указателя. Это не указано явно для std::intptr_t, но в семантике reinterpret_cast, которая определяет результаты преобразования указателя в целочисленный тип. (Это по памяти — я не проверял последнюю версию стандарта.)

James Kanze 04.05.2024 12:29

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

HolyBlackCat 04.05.2024 12:58

@HolyBlackCat std::intptr_t — это целочисленный тип, и вы можете делать с ним все, что захотите, и с любым целым числом. Однако после того, как вы изменили его значение, приведение его обратно к типу указателя является неопределенным поведением.

James Kanze 05.05.2024 13:23

HolyBlackCat, JanSchultke, @JamesKanze: Использование целочисленного типа действительно решает проблему UB! Код NullableOffsetPtr на самом деле не перемещается между объектами, а воспроизводит исходный указатель, использованный для построения, по возвращению get(). Требуется только, чтобы intptr_t(ptr) - intptr_t(this) + intptr_t(this) == intptr_t(ptr). Что гарантировано для целочисленной арифметики. Аргумент будет нарушен, как только мой класс попытается выполнить больше, например увеличить/уменьшить с помощью вычисления смещения, но это не предназначено. Большое спасибо за ваши подсказки и помощь!

Michael Steffens 07.05.2024 15:29

Поправка: reinterpret_cast<intptr_t>(ptr) - reinterpret_cast<intptr_t>(this) + reinterpret_cast<intptr_t>(this) == reinterpret_cast<intptr_t>(ptr)

Michael Steffens 07.05.2024 15:39
Ответ принят как подходящий

Sample — пустой класс, и поскольку это не имеет заметного эффекта в вашем коде, он может иметь общий адрес с NullableOffsetPtr. В результате &sample и ptr.get() могут дать одинаковый результат при печати.

Однако вычитание указателя в конструкторе NullableOffsetPtr является неопределённым поведением:

Когда вычитаются два выражения указателя P и Q, типом результата является определяемый реализацией целочисленный тип со знаком; этот тип должен быть тем же типом, который определен как std::ptrdiff_t в заголовке ([support.types.layout]).

  • Если P и Q оба оценивают значения нулевого указателя, результат равен 0.
  • В противном случае, если P и Q указывают соответственно на элементы массива i и j одного и того же объекта массива x, выражение P - Q имеет значение i-j.
  • В противном случае поведение не определено.

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