C中的继承和方法重写-如何使其定义行为

nle07wnf  于 2023-04-05  发布在  其他
关注(0)|答案(3)|浏览(88)

我有一个自定义的小OOP风格的继承功能,类似于这样:

// 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;

现在假设我有一个Child类构造函数,其中Base类vtable被子vtable覆盖:

void Child_ctor(S_Child* pInstance) {
    Base_ctor((S_Base*) pInstance);
    pInstance.BaseClass.pVtable = (S_BaseVtable*) &MyChildVTable;
}

同样在这个子vtable中,我想用这样的方法覆盖基类的DoAwesomeStuff()方法:

int Child_DoAwesomeStuff(struct BaseTag* pInstance) {
    S_Child* pChild = (S_Child*) pInstance; // undefined behaviour
    return pChild->EvenAwesomerValue;
}

我偶尔在变体中看到过这种模式,但我发现它存在一些问题。

  • 如何从隐藏在S_BaseVtable指针后面的子示例访问S_ChildVtable
  • 如何将Child_DoAwesomeStuff()pInstance参数正确转换为S_Child*类型?

就我对C标准的理解而言,从S_Child*转换为S_Base*(以及相应的vtable类型)是可以的,因为S_Child的第一个成员是S_Base示例。但反之亦然,这是未定义的行为。
S_Child* pChild = (S_Child*)((char*) pInstance)这样的东西是法律的的吗?

编辑

我的问题有点不清楚和误导。我认为是UB的不是强制转换本身,而是在从pInstance强制转换后取消引用pChild。
我又浏览了一遍C11标准,想找到一些参考资料,但现在我已经不太清楚了。
6.3.2.3/7:
指向一个对象类型的指针可以转换为指向另一个对象类型的指针。如果结果指针没有正确对齐(68)引用的类型,则行为未定义。否则,当再次转换回来时,结果将与原始指针进行比较。
所以我想我的问题是-需要什么机制来确保S_Base和S_Child正确对齐?

xienkqul

xienkqul1#

所以我想我的问题是-需要什么机制来确保S_Base和S_Child正确对齐?

**TL;DR:**不需要特殊的机制来覆盖指向在继承框架中有效的那些类型的指针之间的转换。

对齐在C17 6.2.8“对象的对齐”中有描述,并且在规范的许多其他地方都有涉及。
虽然语言规范没有明确地说明这个问题,但我们可以观察到,结构类型的对齐要求必须至少与其最严格对齐的成员一样严格,否则实现无法确保所有示例的所有成员都正确对齐。前者不能比后者具有更弱的对齐要求,因此有效的S_Child *到类型S_Base *的转换永远不会与不正确的对齐相冲突。
S_Child可能比S_Base有更严格的对齐要求,但这不是您在实践中需要担心的问题。将S_Base *转换为在继承系统中语义有效的类型S_Child *的唯一情况是原始S_Base *指向S_Child的第一个成员。在这种情况下,你可以相信
一个指向结构对象的指针,经过适当的转换,指向它的初始成员(或者如果该成员是位字段,则指向它所在的单元),反之亦然。
(C17 6.7.2.1/15)
当然,这适用于两个方向,因此它为S_Child *S_Base *的情况提供了额外的(甚至更好的)支持。
几乎同样的事情也适用于您的vtable,因为您正在以类似于数据成员结构的方式构建它们。
S_Child* pChild = (S_Child*)((char*) pInstance)这样的东西是法律的的吗?
如果pInstance是指向任何对象类型的有效指针,则转换为char *是有效的,但在将结果转换为类型S_Child *方面,它不会为您带来任何好处。

S_Child *pChild = (S_Child *) pInstance;

,这在你(应该)关心的所有情况下都是完全好的。

9gm1akwq

9gm1akwq2#

未定义行为:C中的内存使用,当它 * 是 * 或 * 不是 * 未定义行为时

1、一些背景研究:让我们了解在C中管理内存时什么是未定义行为,什么不是未定义行为

正如编程中经常出现的情况一样,有很多细微差别需要讨论。所以,让我尝试解决你的问题。
我的问题有点不清楚和误导。我认为不是转换本身是UB,而是在从pInstance转换后取消引用pChild
在C语言中,由于各种原因,强制转换是未定义的行为,但不是在你的问题中所做的强制转换中。请参阅此答案下面的评论以获得更多见解。

  • 解引用 * 是未定义的行为,也有几个原因,包括我将讨论的这两个主要原因,这可能与您的问题最相关:

1.你正在解引用超出你的程序/对象所拥有的内存,或者
1.你正在阅读 * 未初始化的 * 内存/值(即使你的程序 * 确实 * 拥有该内存)
请考虑以下示例:
1.示例1:指向程序不拥有的内存是未定义的行为
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!
  1. NOT未定义的行为:ATmega 328 8位微控制器(例如: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;

1.例2:使用我们不拥有的内存和/或未初始化的内存是未定义的行为
1.未定义的行为:在任何机器上

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.
  1. NOT未定义的行为:在任何机器上
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)

1.例3:使用我们的程序 does own的内存池是 not undefined行为

  1. NOT未定义的行为:在任何机器上
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,而是在从pInstance转换后取消引用pChild
这个问题的答案是:“这取决于你是否解引用 valid(拥有的,如果阅读它,已经初始化)与 invalid(没有拥有的,* 或 * 未初始化)内存。
请考虑以下几点:

// 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++通过dynamic_cast<>()转换解决了上面的 * 危险 * 行为,该转换允许将父类型转换为子类型。然后,它将在运行时 * 动态 * 检查结果对象"is a valid complete object of the target type"。如果发现它不是 *,则将结果指针设置为nullptr以通知您。在C中,你必须自己手动追踪这些东西。

“为了确保S_Base(父节点)和S_Child正确对齐,需要采取哪些措施?”

这个很简单只需将S_Base结构体放在S_Child结构体的 * 最开始 * 处,它们就会自动对齐。现在,指向S_Child对象的指针与指向S_Base对象的指针指向 * 完全相同的地址 *,因为子对象 * 包含 * 基对象。
它们会自动对齐,只要你不使用任何对齐或填充关键字或编译器扩展来改变它们。填充会根据需要由编译器自动添加到结构体成员之后,而不是第一个成员之前。更多内容请看这里:Structure padding and packing .
简单的例子(使用 out 任何虚拟表多态函数):

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允许你有一个动态强制转换,当且仅当你用C dynamic_cast语法调用它时,它会在运行时失败,* 和 * 检查错误,像这样:

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或内存池。如果(所指向的)是 * 大于 * 基于指向它的指针类型的预期大小,那就没问题!你的程序拥有那个内存。但是如果你所拥有的内存池(正指向的)* 小于 * 基于指向它的指针类型的预期大小,你就不好了!访问你所分配的内存blob之外的内存是 * 未定义的行为 *。

在OOP和父/子关系的情况下,子对象必须始终大于父对象,因为 * 它包含 * 一个父对象。因此,将子对象转换为父类型是可以的,因为子类型大于父类型,并且子类型在其内存中 * 首先 * 保存父类型,但是将父类型转换为子类型是不好的,除非所指向的内存blob最初被创建为该子类型的子类型。
现在,让我们在C++中看看这一点,并与您的C示例进行比较。

<-->C++和C中的继承和父子类型转换

只要传递给Child_DoAwesomeStuff()pInstance指针最初实际上被构造为S_Child对象,然后将指针转换回S_Child指针(S_Child*)是 * 不是 * 未定义的行为。只有当你试图将一个指针强制转换到一个最初被构造为struct BaseTag的对象时,它才是未定义的行为。(又名S_Base)类型转换为子指针类型。
这也是C的工作方式,使用dynamic_cast<>()(我提到了in my answer here)。
下面是来自https://cplusplus.com/doc/tutorial/typecasting/的“dynamic_cast”部分的示例C
代码。
在下面的C代码中,请注意pba * 和 * pbb都是指向基类型的指针(Base *),然而,pba实际上是通过new Derived * 构造 * 为Derived(子)类型,而pbb实际上是通过new Base * 构造 * 为Base(基或父)类型。
因此,将 * pba * 转换为Derived*是完全有效的,因为它确实是该类型,但将 * 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中强制执行OoP(面向对象编程)和继承的看法

只是一个警告:传递指针和存储指向vtables和函数的指针以及C结构中的东西将使跟踪代码并试图理解它变得非常困难!我不知道有索引器(包括Eclipse,Eclipse有我见过的最好的索引器),可以追溯到代码中哪个函数或类型被分配给了指针。或者用C从头开始引导自己的 C++ (同样,为了学习),我建议不要使用这些模式。
如果你想要“面向对象”的C语言,并具有继承和所有功能,不要这样做。如果你想要“基于对象”的C语言,通过不透明的指针/结构进行基本的私有成员封装和数据隐藏,那就很好了!下面是我更喜欢的方法:Option 1.5 ("Object-based" C Architecture) .
最后一点:你可能比我更了解虚表(vtables)。说到底,这是你的代码,所以你想做什么架构就做什么架构,但我不想在那个代码库中工作:)。

参见

  1. https://cplusplus.com/doc/tutorial/typecasting/-关于类型转换的优秀文章!请特别参阅“dynamic_cast”部分,以及其中的代码片段。
  2. Structure padding and packing
    1.[我的回答] When should static_cast, dynamic_cast, const_cast, and reinterpret_cast be used?
  3. https://en.wikipedia.org/wiki/Undefined_behavior
f87krz0w

f87krz0w3#

C标准将对许多“继承式”习惯用法的支持视为实现质量问题。仅用于不涉及此类继承的任务的实现不需要支持它,但所有或几乎所有实现都可以配置为支持此类构造。在clang和gcc中,可以通过使用-fno-strict-aliasing编译选项来支持它们。
请注意,在C89中,允许结构互换使用的惯用方法是让它们以共同的初始序列开始。虽然有些人可能会认为C99旨在使用这种惯用法来破坏代码,但这意味着C99的编写严重违反了委员会的章程。如果C99的作者打算维护他们的章程,他们的意图是,将受益于CIS保证的程序将以支持它的方式进行处理,而不支持它的实现将仅用于不会受益于它的任务。
使用公共初始序列方法,派生结构将以与其父结构相同的成员开始。如果一个结构类型和从它派生的所有结构都以具有相同名称和不同类型的成员开始,则期望指向与结构类型兼容的类型的指针的函数可以用一致的语法(例如,&foo->header)来传递它。使用宏可能会很有用,它可以在语法上接受指向任何遵循模式的结构的指针,并将其 Package 以调用实际的函数,例如。

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))

以这种方式使用宏有点难看,但它允许代码在ptr是指向任何从woozle派生并遵循模式的对象的指针时说use_woozle(ptr, x, y);,同时拒绝传递其他东西的尝试。相比之下,使用void*参数或将参数转换为struct woozle将绕过类型检查,否则将有效地捕获许多错误。例如传递具有错误间接级别的指针。

相关问题