Наследование и переопределение методов в C – как сделать определенное поведение
У меня есть своя небольшая функция наследования в стиле ООП, примерно такая:
// base class
struct BaseTag;
typedef struct {
int (*DoAwesomeStuff)(struct BaseTag* pInstance);
} S_BaseVtable;
typedef struct BaseTag{
S_BaseVtable* pVtable;
int AwesomeValue;
} S_Base;
// child class
struct ChildTag;
typedef struct {
S_BaseVtable Base;
void (*SomeOtherStuff)(struct ChildTag* pInstance);
} S_ChildVTable;
typedef struct ChildTag {
S_Base BaseClass;
int EvenAwesomerValue;
} S_Child;
Теперь предположим, что у меня есть конструктор дочернего класса, в котором виртуальная таблица базового класса переопределяется дочерней виртуальной таблицей:
void Child_ctor(S_Child* pInstance) {
Base_ctor((S_Base*) pInstance);
pInstance.BaseClass.pVtable = (S_BaseVtable*) &MyChildVTable;
}
Также в этой дочерней виртуальной таблице я хочу переопределитьDoAwesomeStuff()
метод из базового класса с помощью такого метода:
int Child_DoAwesomeStuff(struct BaseTag* pInstance) {
S_Child* pChild = (S_Child*) pInstance; // undefined behaviour
return pChild->EvenAwesomerValue;
}
Я иногда видел этот шаблон в вариациях, но вижу в нем некоторые проблемы. Мои главные вопросы:
- Как я могу получить доступ к
S_ChildVtable
из дочернего экземпляра, который скрыт заS_BaseVtable
указатель? - Как мне правильно разыграть
pInstance
аргументChild_DoAwesomeStuff()
к типу?
Насколько я понимаю стандарт C, приведение изS_Child*
кS_Base*
(и соответствующие типы виртуальных таблиц) можно использовать в качестве первого членаS_Child
являетсяS_Base
пример. Но наоборот, это неопределенное поведение.
Было бы что-то вродеS_Child* pChild = (S_Child*)((char*) pInstance)
быть законным и определенным?
Редактировать
Мой вопрос был немного неясным и вводящим в заблуждение. Я думаю, что это не сам приведение, а разыменование pChild после того, как он был приведен из pInstance.
Я снова просмотрел стандарт C11, чтобы найти какую-нибудь ссылку, но мне это уже не так ясно.
6.3.2.3/7:
Указатель на тип объекта может быть преобразован в указатель на другой тип объекта. Если результирующий указатель неправильно выровнен (68) для ссылочного типа, поведение не определено. В противном случае при повторном преобразовании результат будет равен исходному указателю.
Итак, я думаю, мой вопрос на самом деле таков: какая механика должна быть установлена, чтобы обеспечить правильное выравнивание S_Base и S_Child?
3 ответа
Неопределенное поведение: использование памяти в C, когда это неопределенное поведение или нет.
Во-первых, небольшое предварительное исследование: давайте поймем, что такое неопределенное поведение, а что нет при управлении памятью в C.
Как это часто бывает в программировании, здесь необходимо обсудить множество нюансов . Итак, позвольте мне попытаться внести изменения в ваш вопрос.
Мой вопрос был немного неясным и вводящим в заблуждение. Я считаю, что UB — это не сам приведение, а разыменование после его приведения из .
В C приведение типов является неопределенным поведением по ряду причин, но не в приведениях, которые вы выполняете в своем вопросе. Для получения дополнительной информации смотрите комментарии под этим ответом.
Разыменование также является неопределенным поведением по нескольким причинам, включая эти две основные, о которых я расскажу, которые могут быть наиболее актуальными для вашего вопроса:
- вы разыменовываете внешнюю память, которая не принадлежит вашей программе/объекту, или
- вы читаете неинициализированную память/значения (даже если ваша программа правильно владеет этой памятью)
Рассмотрим следующие примеры:
Пример 1: указание на память, которой не владеет наша программа , является неопределенным поведением.
Неопределенное поведение: на любой машине
// arbitrarily point to some address in memory, and assume it's an 8-bit // unsigned integer uint8_t * p = (uint8_t*)0x1234; // undefined behavior if this address is // outside all memory addresses // currently owned by your program // now dereference this pointer and assign a value to this integer *p = 1; // undefined behavior (whether reading OR writing here) because // you are accessing memory that your program does not own nor // control!
НЕ неопределенное поведение: на 8-битном микроконтроллере ATmega328 (например, Arduino Uno)
uint8_t * p = (uint8_t*)0x23; // not undefined behavior, because this // address belongs to a well-defined // hardware register used by this mcu // now dereference this pointer and assign a value to this integer *p = 1; // NOT undefined behavior because the ATmega328 datasheet // (https://ww1.microchip.com/downloads/aemDocuments/documents/MCU08/ProductDocuments/DataSheets/40001906C.pdf) // indicates on p445 that address 0x23 is the PINB hardware // register, which allows you to read from or toggle IO pins. // Writing a 1 here actually toggles the output of GPIO pin B0.
Обратите внимание, что правильный способ сделать вышеперечисленное заключается в следующем (пример файла: «/Arduino 1.8.13/hardware/tools/avr/avr/include/avr/ iom328pb.h »):
#define PINB (*(volatile uint8_t *)(0x23)) #define PINB7 7 #define PINB6 6 #define PINB5 5 #define PINB4 4 #define PINB3 3 #define PINB2 2 #define PINB1 1 #define PINB0 0 PINB = 1 << PINB0;
Пример 2: использование памяти, которой мы не владеем и/или которая не инициализирована, является неопределенным поведением.
- Неопределенное поведение: на любой машине
uint32_t * pu32 = (uint32_t*)0x1234; // ok uint32_t u1; u1 = *pu32; // Undefined behavior! Reading memory our program doesn't // own *pu32 = 0; // Undefined behavior! Writing to memory our program doesn't // own pu32 = &u1; // ok: pointing our pointer to valid memory our program owns uint32_t u2; u2 = u1; // Undefined behavior! Reading an undefined value from u1. *pu32 = u1; // Undefined behavior! Reading an undefined value from u1. u1 = *pu32; // Undefined behavior! Our program DOES own this memory // that pu32 points to now, but the value stored there is // undefined/uninitialized.
- НЕ неопределенное поведение: на любой машине
uint32_t * pu32; uint32_t u1; pu32 = &u1; // ok: our ptr now points to valid memory *pu32 = 7; // set u1 to 7 u1 = 8; // set u1 to 8 uint32_t u2 = u1; // set u2 to 8 uint32_t u3 = *pu32; // set u3 to 8 (since pu32 points to u1)
- Неопределенное поведение: на любой машине
Пример 3: использование пула памяти, которым владеет наша программа, не является неопределенным поведением.
- НЕ неопределенное поведение: на любой машине
uint8_t memory_pool_of_bytes[4]; // ok // ok: pointing our uint32_t* pointer to use this memory pool of bytes uint32_t * pu32 = (uint32_t *)memory_pool_of_bytes; *pu32 = 1000000; // ok; our program owns this memory!
- НЕ неопределенное поведение: на любой машине
Теперь, получив изложенные выше знания, давайте вернемся к вашему вопросу:
Мой вопрос был немного неясным и вводящим в заблуждение. Я считаю, что UB — это не сам приведение, а разыменование после его приведения из .
Ответ на этот вопрос: «Это зависит от того, разыменовываете ли вы действительную (принадлежащую и уже инициализированную при чтении) память или недействительную (не принадлежащую или не инициализированную) память.
Учтите следующее:
// create a base
S_Base base;
Child_DoAwesomeStuff(&base); // Undefined behavior inside this func??? Maybe!
// vs:
// create a child
S_Child child;
Child_DoAwesomeStuff((S_Base*)&child); // Undefined behavior inside this func???
// No! This is fine.
Давайте углубимся и рассмотрим первый случай, когда возможно неопределенное поведение.
S_Base base; // ok: statically allocate a chunk of memory large
// enough to hold an `S_Base` type.
S_Base* pBase = &base; // ok: create a pointer to point to our memory above.
S_Child* pChild = (S_Child*)pBase; // **technically** ok, but a very bad idea
// because it **could lead to** undefined
// behavior later! `pChild` does NOT point
// to a "valid complete object of the target
// type".
pChild->BaseClass.AwesomeValue = 7; // fine, because this is owned memory!
pChild->EvenAwesomerValue; // UNDEFINED BEHAVIOR! This is NOT owned memory! We
// just read outside the memory we statically
// allocated in the first line above!
Итак, является ли
(S_Child*)pBase;
вызвать неопределенное поведение? Нет! Но это опасно ! Имеет доступ к собственной памяти внутриpChild
неопределенное поведение? Нет! Мы владеем этим. Наша программа это выделила. Но есть ли доступ к памяти за пределами того, чем владеет наша программа (например:pChild->EvenAwesomerValue
) неопределенное поведение? Да! Мы не владеем этой памятью. Это похоже на многие неопределенные случаи, которые я рассмотрел выше.
В C++ описанное выше опасное поведение решено за счет преобразования, позволяющего привести родительский тип к дочернему типу. Затем во время выполнения он будет динамически проверять, . Если он обнаруживает, что это не так , он устанавливает результирующий указатель на, чтобы уведомить вас об этом. В C вам нужно просто отслеживать эти вещи вручную.
«Какая механика должна быть установлена, чтобы гарантировать, что (родительский) и правильно выровнены?»
Это просто: просто поместите структуру в самое начало структуры, и они автоматически выровняются. Теперь указатель на ваш объект указывает на тот же адрес, что и указатель на объект внутри него, поскольку дочерний элемент содержит базовый объект.
Они автоматически выравниваются, если вы не используете какие-либо ключевые слова выравнивания или заполнения или расширения компилятора для изменения ситуации. Заполнение автоматически добавляется компилятором после членов структуры, но никогда перед первым членом. Подробнее об этом читайте здесь: .
Простой пример (без каких -либо функций полиморфизма виртуальных таблиц):
typedef struct parent_s
{
int i;
float f;
} parent_t;
typedef struct child_s
{
parent_t parent; // parent (base) member MUST be 1st within the child
// to be properly aligned with the start of the child!
int i;
float f;
} child_t;
child_t child;
parent_t parent;
parent_t* p_parent = &child; // ok; p_parent IS a "valid complete object of the
// target [parent] type", since the child's
// allocated memory blob does indeed encompass the
// parent's
child_t* p_child = &child; // ok; p_child is a "valid complete object of
// the target [child] type"
child_t* p_child = (child_t*)&parent; // DANGEROUS! Technically this cast is
// *not* undefined behavior *yet*, but it
// could lead to it if you try to access
// child members outside the memory blob
// created for the parent.
//
// p_child is NOT a "valid complete object
// of the target [child] type".
Что касается последнего (опасного) приведенного выше приведения, C++ позволит вам иметь динамическое приведение, которое завершится сбоем во время выполнения, если и только если вы вызовете его с синтаксисом динамического_cast C++ и проверите наличие ошибок, например:
child_t* p_child = dynamic_cast<child_t*>(&parent);
if (p_child == nullptr)
{
printf("Error: dynamic cast failed. p_child is NOT a \"valid complete "
"object of the target [child_t] type.\"");
// do error handling here
}
Ключевой вывод:
Как только вы впервые получите выравнивание, поместив родительский элемент в самом начале внутри дочернего, по сути, просто думайте о каждом объекте как о капле памяти или пуле памяти. Если пул памяти, который у вас есть (на который вы указываете), больше ожидаемого размера, основанного на типе указателя, указывающего на него, все в порядке! Ваша программа владеет этой памятью. Но если пул памяти, который у вас есть (на который вы указываете), меньше ожидаемого размера, основанного на типе указателя, указывающего на него, с вами не все в порядке! Доступ к памяти за пределами выделенного объекта памяти является неопределенным поведением .
В случае ООП и отношений родитель/потомок дочерний объект всегда должен быть больше родительского объекта, поскольку внутри него содержится родительский объект. Таким образом, приведение дочернего типа к родительскому типу — это нормально, поскольку дочерний тип больше, чем родительский тип, и дочерний тип сначала удерживает родительский тип в своей памяти, но приведение родительского типа к дочернему типу недопустимо, если память BLOB-объект, на который указывают, изначально был создан как дочерний элемент этого дочернего типа.
Теперь давайте посмотрим на это на C++ и сравним с вашим примером на C.
Наследование и приведение родительского <--> дочернего типа в C++ и C
ПокаpInstance
указатель, передаваемыйChild_DoAwesomeStuff()
фактически изначально был создан как объект, а затем приводил указатель обратно кS_Child
указатель (S_Child*
) не является неопределенным поведением. Это будет неопределенное поведение, если вы попытаетесь привести указатель к объекту, который изначально был создан какstruct BaseTag
(он жеS_Base
) введите дочерний тип указателя.
Именно так работает и C++:dynamic_cast<>()
(о чем я упоминаю ).
Пример кода C++ из в разделе «dynamic_cast» приведен ниже.
Обратите внимание, что в приведенном ниже коде C++ оба и являются указателями на базовый тип (Base *
), тем не менее, на самом деле создается как (дочерний) тип черезnew Derived
, тогда как на самом деле он построен какBase
(базовый или родительский) введите черезnew Base
.
Поэтому кастинг
pba
to совершенно допустимо, так как это действительно тот тип, но приведение
pbb
кDerived*
недействителен , поскольку на самом деле это не тот тип. C++dynamic_cast<Derived*>(pbb)
вызов перехватывает это неопределенное поведение во время выполнения, обнаруживая, что возвращаемый тип не является полностью сформированным.Derived
тип и возвращаетnullptr
, что равно0
, и вы получите отпечаток с надписьюNull pointer on second type-cast.
Вот этот код C++:
// dynamic_cast
#include <iostream>
#include <exception>
using namespace std;
class Base { virtual void dummy() {} };
class Derived: public Base { int a; };
int main () {
try {
Base * pba = new Derived;
Base * pbb = new Base;
Derived * pd;
pd = dynamic_cast<Derived*>(pba);
if (pd==0) cout << "Null pointer on first type-cast.\n";
pd = dynamic_cast<Derived*>(pbb);
if (pd==0) cout << "Null pointer on second type-cast.\n";
} catch (exception& e) {cout << "Exception: " << e.what();}
return 0;
}
Выход:
Null pointer on second type-cast.
Аналогично, ваш код C имеет такое же поведение.
Это действительно:
// create a child
S_Child child;
// treat it like a base (ok since `S_Base` is at the beginning of it--since the
// child contains a base object)
S_Base* pBase = (S_Base*)&child;
// Now obtain the child back from the base pointer
S_Child* pChild = (S_Child*)pBase; // ok, since pBase really points to a
// child object
Но делать это неправильно :
// create a base
S_Base base;
// Get a pointer to it
S_Base* pBase = &base;
// Now try to magically obtain a child from a base object
S_Child* pChild = (S_Child*)pBase; // NOT ok! **May lead to** undefined behavior
// when dereferencing, since pBase really
// points to a base object!
Итак, для вашей конкретной функции:
// Note: I replaced `struct BaseTag*` with `S_Base*` for readability
int Child_DoAwesomeStuff(S_Base* pInstance) {
S_Child* pChild = (S_Child*) pInstance;
return pChild->EvenAwesomerValue;
}
Это отлично:
// create a child
S_Child child;
Child_DoAwesomeStuff((S_Base*)&child); // ok
Но это не так !:
// create a base
S_Base base;
Child_DoAwesomeStuff(&base); // NOT ok! **May lead to** undefined behavior
// inside this func!
Мои мысли о применении ООП (объектно-ориентированного программирования) и наследовании в C
Просто предупреждение: передача указателей и сохранение указателей на виртуальные таблицы, функции и другие вещи внутри структур C сделают отслеживание вашего кода и попытки понять его очень трудными! Ни один известный мне индексатор (включая Eclipse, а у Eclipse лучший индексатор, который я когда-либо видел) не может проследить, какая функция или тип были назначены указателю в вашем коде. Если вы не делаете это просто для обучения или для создания собственного языка C++ с нуля на C (опять же, для обучения), я не рекомендую использовать эти шаблоны.
Если вам нужен «объектно-ориентированный » C с наследованием и всем остальным, не делайте этого. Если вам нужен «объектно-ориентированный » C с помощью непрозрачных указателей/структур для базовой инкапсуляции частных членов и сокрытия данных, это прекрасно! Вот как я предпочитаю это делать: Вариант 1.5 («Объектно-ориентированная» архитектура C) .
Последнее замечание: вы, вероятно, знаете больше о виртуальных таблицах (vtables), чем я. В конце концов, это ваш код, так что делайте любую архитектуру, которую хотите, но я не хочу работать с этой кодовой базой :).
Смотрите также
- является ли полученный объект «действительным полным объектом целевого типа»https://cplusplus.com/doc/tutorial/typecasting/https://cplusplus.com/doc/tutorial/typecasting/ — отличная статья о приведении типов! См., в частности, раздел «dynamic_cast» и фрагмент кода в нем.
- Заполнение и упаковка структурыНаполнение и упаковка структуры
- [мой ответ] в своем ответе здесьКогда следует использовать static_cast, Dynamic_cast, const_cast и reinterpret_cast?
- https://en.wikipedia.org/wiki/Undefined_behavior
Итак, я думаю, мой вопрос на самом деле таков: какая механика должна быть установлена, чтобы обеспечить правильное выравнивание S_Base и S_Child?
TL;DR: не требуется никаких специальных механизмов для преобразования между указателями на те типы, которые действительны в вашей структуре наследования.
Выравнивание описано в C17 6.2.8 «Выравнивание объектов» и затронуто во многих других местах спецификации.
Хотя спецификация языка явно не отвечает на этот вопрос, мы можем заметить, что требования к выравниванию структурного типа должны быть, по крайней мере, такими же строгими, как и требования к его наиболее строго выровненному члену, иначе реализация не сможет гарантировать, что все члены всех экземпляров будет правильно выровнено. Поскольку у вас есть член type , первый не может иметь более слабые требования к выравниванию, чем второй, поэтому преобразование допустимого типа в тип никогда не может противоречить неправильному выравниванию.
Можно иметь более строгие требования к выравниванию, чем
Указатель на объект структуры, преобразованный соответствующим образом, указывает на его начальный член (или, если этот член является битовым полем, то на единицу, в которой он находится), и наоборот.
(С17 6.7.2.1/15)
Конечно, это применимо в обоих направлениях, поэтому это обеспечивает дополнительную (даже лучшую) поддержку
Практически то же самое применимо и к вашим виртуальным таблицам, поскольку вы структурируете их аналогично структурам элементов данных.
Было бы что-то вроде
быть законным и определенным?
В ролях
S_Child *pChild = (S_Child *) pInstance;
, что прекрасно во всех случаях, которые вас (должны) волновать.
Стандарт C рассматривает поддержку многих идиом «стиля наследования» как проблему качества реализации. Реализации, предназначенные исключительно для задач, не требующих такого наследования, не обязательно должны его поддерживать, но все или почти все реализации могут быть настроены для поддержки таких конструкций. В clang и gcc их можно поддерживать с помощью
Обратите внимание, что в C89 идиоматический способ взаимозаменяемого использования структур заключался в том, чтобы они начинались с общей начальной последовательности. Хотя некоторые люди могут утверждать, что C99 был предназначен для взлома кода с использованием этой идиомы, это означало бы, что C99 был написан с грубым нарушением устава Комитета. Если бы авторы C99 намеревались соблюдать свою хартию, они бы предполагали, что программы, на которые будут распространяться гарантии CIS, обрабатывались способом, который ее поддерживает, а реализации, которые ее не поддерживают, использовались только для задач, которые ее поддерживают. не выиграет от этого.
При использовании подхода общей начальной последовательности производные структуры начинаются с тех же элементов, что и их родительские структуры. Если тип структуры и все структуры, производные от него, начинаются с члена, имеющего то же имя и отличительный тип, то функции, которые ожидают указатель на тип, совместимый с типом структуры, могут передать его с согласованным синтаксисом, например
struct woozle { struct woozleHeader *woozle_hdr; int x, y; };
struct derived_woozle { struct woozleHeader *woozle_hdr; int x, y; double z; };
int do_use_woozle(struct woozleheader **p, int x, int y);
#define use_woozle(it, x, y) do_woozle(&(it)->woozle_hdr, (x), (y))
Использование макросов таким способом немного некрасиво, но оно позволит коду сказать: