Обновлено: Ух ты, это много информации по делу, отвечает на вопрос «почему» и решило мою насущную проблему. Однако все еще борюсь с частью «как». Ваши ответы вызывают дополнительные объяснения и бонусные вопросы внизу.
Я разрабатываю очень минимальный класс указателя смещения.
Его конечная цель — связанные структуры данных в общей памяти, которые должны справляться с различным смещением виртуального адреса в разных процессах. Идея вдохновлена 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? Нашёл термин «незаконно, но правильно» в контексте встраивания. Однако я не понимаю, как связана встраивание, и существует ли широко признанная незаконная корректность, которую можно было бы проверить. Какие-либо предложения?
@PepijnKramer У вас есть источник? Обратите внимание, что код OP не полагается на неявное начало жизни объекта.
Также этот код начнет работать, если вы поместите обе переменные как нестатические члены в один и тот же класс.
@HolyBlackCat Я согласен, что это замечание могло быть лучше (я плохо) ... но тем не менее этот код предполагает, что время жизни T началось.
@PepijnKramer Да, и его срок службы действительно начался, так что если это UB, то по какой-то другой причине.
Просто интересно, почему такое особое обращение с nullptr
? Есть ли какое-то неясное правило, которое сделало бы этот UB? Кроме того, это заставило меня задуматься, не может ли смещение 1 возникнуть естественным путем без подачи nullptr
.
Сложение и вычитание двух несвязанных указателей — это UB, т.е. ptr-this
Мне не очень нравятся эти дураки. Вы можете привести к uintptr_t
, чтобы сделать расчет смещения четко определенным, но это не поможет сделать последующий *this ... + offset
законным.
Пожалуйста, задавайте один и только вопрос за каждый вопрос; не задавайте «бонусные» вопросы; если у вас есть дополнительные вопросы, задайте новый вопрос.
Вы можете использовать арифметику указателей для доступа к одному объекту из другого только в том случае, если оба объекта являются подобъектами (возможно, косвенно) одного и того же объекта верхнего уровня. (Например, GCC начинает работать, если вы сделаете оба объекта нестатическими членами данных одного и того же экземпляра класса.)
Вот соответствующая формулировка:
+
можно использовать только для перемещения указателя между элементами одного и того же массива. Аналогично, -
может вычитать указатели только между элементами одного и того же массива.unsigned char
.char
, unsigned char
и std::byte
, позволяя получить доступ к любому объекту через указатели/ссылки на них, игнорируя строгий псевдоним.Также общеизвестно, что вы не можете выполнять арифметику с указателями в uintptr_t
, чтобы обойти [expr.add]/4.2
, но сейчас я не могу найти соответствующую формулировку. (Я имею в виду, что вы не можете таким образом переключаться между несвязанными объектами. Вы можете использовать его, чтобы -
дал вам правильное смещение.)
Из Руководства GCC:
При приведении указателя к целому числу и обратно результирующий указатель должен ссылаться на тот же объект, что и исходный указатель, в противном случае поведение не определено. То есть нельзя использовать целочисленную арифметику, чтобы избежать неопределенного поведения арифметики указателей...
В формулировках о времени жизни и псевдонимах в целом беспорядок, и если копнуть слишком глубоко, многие вещи технически оказываются UB (или подразумеваются таковыми), хотя на практике они не соблюдаются. Например, существует концепция, согласно которой байт доступен через указатель. Он используется в описании std::launder
и, насколько я могу судить, больше нигде (в стандарте нигде не сказано, что доступ к «недоступному» байту — это UB).
Я думаю, вы могли бы использовать std::intptr_t
, чтобы обойти это, но при этом вы сразу же столкнетесь с определенными реализацией и, честно говоря, недостаточно специфицированными/недодокументированными эффектами.
@JanSchultke Да, только что отредактировал это. Насколько я понимаю, приведение к [u]intptr_t
позволяет безопасно вычислить смещение (по крайней мере, на практике), но этого недостаточно, чтобы благословить +
на переход между несвязанными объектами.
IIUC, единственная допустимая операция над std::intptr_t
— это приведение его к исходному типу указателя. Это не указано явно для std::intptr_t
, но в семантике reinterpret_cast
, которая определяет результаты преобразования указателя в целочисленный тип. (Это по памяти — я не проверял последнюю версию стандарта.)
@JamesKanze Я полагаю, что выполнение арифметических операций зависит от реализации? Я верю, что реализация вернет правильное целое число из -
, но не верю, что она вернет правильный указатель из +
(я также добавил по этому поводу раздел из руководства GCC).
@HolyBlackCat std::intptr_t
— это целочисленный тип, и вы можете делать с ним все, что захотите, и с любым целым числом. Однако после того, как вы изменили его значение, приведение его обратно к типу указателя является неопределенным поведением.
HolyBlackCat, JanSchultke, @JamesKanze: Использование целочисленного типа действительно решает проблему UB! Код NullableOffsetPtr на самом деле не перемещается между объектами, а воспроизводит исходный указатель, использованный для построения, по возвращению get(). Требуется только, чтобы intptr_t(ptr) - intptr_t(this) + intptr_t(this) == intptr_t(ptr). Что гарантировано для целочисленной арифметики. Аргумент будет нарушен, как только мой класс попытается выполнить больше, например увеличить/уменьшить с помощью вычисления смещения, но это не предназначено. Большое спасибо за ваши подсказки и помощь!
Поправка: reinterpret_cast<intptr_t>(ptr) - reinterpret_cast<intptr_t>(this) + reinterpret_cast<intptr_t>(this) == reinterpret_cast<intptr_t>(ptr)
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.
- В противном случае поведение не определено.
переосмысление приведения из памяти к типу разрешено только в том случае, если этот тип тривиально конструируется и тривиально копируется (например, он должен быть типом POD). Таким образом, вы ограничиваете T, чтобы удовлетворить этим требованиям.