Обоснование того, что невиртуальный производный класс не является взаимопреобразуемым указателем с его первой базой

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

Однако меня интересует не тот факт, что это неопределенное поведение для reinterpret_cast (или static_cast через void *) между структурой нестандартного макета и ее общедоступной базой, а скорее объяснение того, почему стандарт C++ в настоящее время запрещает такие приведения . Существующие вопросы и ответы не охватывают этот аспект.

В частности, рассмотрим следующий пример (Godbolt):

#include <type_traits>

struct Base {
  int m_base_var = 1;
};

struct Derived: public Base {
  int m_derived_var = 2;
};

Derived g_derived;

constexpr Derived *g_pDerived = &g_derived;
constexpr Base *g_pBase = &g_derived;
constexpr void *g_pvDerived = &g_derived;

//These assertions all hold
static_assert(!std::is_pointer_interconvertible_base_of_v<Base, Derived>);
static_assert((void *)g_pDerived == (void *)g_pBase);
static_assert((void *)g_pDerived == g_pvDerived);
static_assert((void *)g_pBase == g_pvDerived);

//This is well-defined and returns &g_derived
Derived * getDerived() {
  return static_cast<Derived *>(g_pvDerived);
}

//This is also well-defined; outer static_cast added to illustrate the sequence of conversions
Base * getBase() {
  return static_cast<Base *>(static_cast<Derived *>(g_pvDerived));
}

//This is UB due to the first static_assert!
Base * getBaseUB() {
  return static_cast<Base *>(g_pvDerived);
}

Как видно из ссылки Godbolt, все три функции компилируются в одну и ту же сборку на x86-64 GCC. Однако стандарт запрещает третий вариант, поскольку Base не является взаимопреобразуемой базой Derived.

У меня вопрос: есть ли очевидная причина, по которой стандарт запрещает такое приведение? В частности, на всех известных мне реализациях значение указателя на подобъект Base такое же, как и у указателя на весь Derived, и я не вижу особой причины, по которой Derived не следует считать стандартным- макет больше. (Другими словами, Base живет по нулевому смещению внутри Derived.) Будет ли законно для реализации C++ размещать Base по ненулевому смещению внутри Derived? (Может быть, уже есть реализация, которая делает это?)

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

Классы с нестандартной компоновкой ну не имеют стандартной компоновки. Линия должна быть проведена где-то.

aschepler 05.02.2023 15:35

Вот в чем дело: линия была нарисована в точке, где Base и Derived имеют нестатические элементы данных. В этот момент Derived больше не является стандартным макетом, и такой вид приведения/алиасинга запрещен. Мне это кажется довольно произвольным, поэтому я задаюсь вопросом, есть ли для этого техническая причина или комитет просто решил где-то провести черту, и она там оказалась.

Jonathan S. 05.02.2023 16:02

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

Eljay 05.02.2023 16:24

@Eljay: Это не имеет смысла. Если бы что-то было virtual, шрифт не был бы стандартным макетом, так как это уже явно запрещено. Вам не нужно дважды запрещать это.

Nicol Bolas 05.02.2023 16:32
Стоит ли изучать 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 называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
5
4
95
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

Это действительно два вопроса:

  1. Что такое стандартная компоновка, как на самом деле?

  2. Почему взаимозаменяемость указателя связана со стандартным макетом?

Стандартная компоновка была построена из половины концепции типов «обычных старых данных» до C++11. Другая половина — тривиальная копируемость (т. е. запоминание экземпляра объекта так же хорошо, как и конструктор копирования). Эти две половины на самом деле не взаимодействовали, но для POD требовались обе.

С чисто стандартной точки зрения C++ стандартная компоновка является требованием для возможности создания типа, компоновка которого соответствует существующему типу, так что если вы поместите оба этих типа в union, вы сможете получить доступ к подобъектам неактивных членов. Это основная функциональность, которую стандартная компоновка включила в язык с тех пор, как она была изобретена в C++11.

Вот почему стандартный макет позволяет только одному члену в иерархии классов иметь нестатические элементы данных. Если только один класс имеет NSDM, то не возникает вопросов об упорядочении между NSDM разных базовых классов и так далее. И возможность знать априори, каков этот порядок, жизненно важна для того, чтобы знать, что два типа совпадают.

Это также полезно для общения на разных языках.

Однако, как только стандартная компоновка была определена, она начала использоваться для вещей, которые были... менее явно частью ее области.

Например, стандартный макет стал определяющим фактором того, является ли offsetof допустимым поведением. Это связано с тем, что offsetof изначально был основан на типе POD, поэтому, когда часть макета была выделена, offsetof был обновлен, чтобы использовать его. Однако это было неоптимально, поскольку единственная вещь, основанная на макете, которая могла бы сломаться offsetof, — это виртуальные базовые классы (смещение членов виртуального базового класса зависит от наиболее производного класса, который зависит от типа объекта во время выполнения) . Теперь новое ограничение по-прежнему было лучше, чем POD, но его можно было бы расширить, включив в него больше вещей. Но это означало бы придумать новое определение.

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

Расширение правил, как вы хотите, требует создания нового определения для «первого подобъекта».

Можно ли преобразовать указатель базового класса в конкретный производный класс? Ну, это зависит от того, от каких других классов наследуется производный класс. Можно ли преобразовать первый указатель NSDM в класс, членом которого он является? Это зависит от того, какие другие классы участвуют в диаграмме наследования.

Эти зависимости уже существуют, но все они основаны на определенном, ранее существовавшем правиле. То, что вы хотите, требует создания нового правила, которое является более сложным. Ему придется скопировать 90% существующих правил стандартного макета (запрет virtual, публичные/частные члены и т. д.), а затем добавить свои собственные правила. Первый NSDM является взаимопреобразующим указателем, если только какие-либо базовые классы не пусты. Любой конкретный базовый класс является взаимопреобразующим указателем только до тех пор, пока все предыдущие базовые классы в порядке объявления не содержат NSDM.

Гораздо проще просто использовать стандартные правила компоновки и сказать, что «тип стандартной компоновки является взаимопреобразуемым указателем с его первым NSDM и всеми его базовыми классами».

Наличие дополнительного правила также накладывает некоторую нагрузку на спецификацию. Это частичная избыточность, которая порождает ошибки. Например, C++23 находится на пути к расширению стандартных типов компоновки, удаляя запрет на смешивание членов public и private, заставляя компоновку упорядочиваться строго по объявлению. Если бы у указателя-взаимозаменяемости были свои правила, можно было бы обновить стандартный макет, но не указатель-взаимозаменяемость.

И C, и C++ были определены по традиции до того, как были написаны стандарты. Когда писались первые стандарты, было важнее не требовать, чтобы любые реализации нарушали существующие для них программы, чем запрещать реализациям, обрабатывающим различные программы, изменяться таким образом, чтобы нарушать их.

Хотя для объекта производного класса было бы типично хранить все родительские данные в последовательности последовательных байтов, за которыми следует оставшаяся часть данных для объекта, иногда для реализации может быть полезно отклоняться от этого. Если в базовом классе было поле char, в производном классе было поле int, а в подклассе было еще пять полей char, реализация, которая размещает одно из полей подпроизводного класса между полем базового класса и первым производным классом. может быть в состоянии хранить данные более компактно, чем тот, который помещает все из каждого слоя в одну последовательность последовательных байтов.

Обратите внимание, что стандарт C++ прямо отказывается от юрисдикции в отношении того, какие программы C++ следует рассматривать как «соответствующие». Таким образом, не было очевидной необходимости гарантировать, что он не классифицирует как UB любые программы, которые большинство реализаций должны обрабатывать с пользой. Если бы авторы предвидели, как компиляторы будут относиться к решениям Комитета об отказе от юрисдикции над различными конструкциями как к приглашению к их бессмысленной обработке, они, вероятно, определили бы поведение в гораздо большем количестве краеугольных случаев, чем на самом деле.

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