我有一个自定义的小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正确对齐?
3条答案
按热度按时间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 *
方面,它不会为您带来任何好处。,这在你(应该)关心的所有情况下都是完全好的。
9gm1akwq2#
未定义行为:C中的内存使用,当它 * 是 * 或 * 不是 * 未定义行为时
1、一些背景研究:让我们了解在C中管理内存时什么是未定义行为,什么不是未定义行为
正如编程中经常出现的情况一样,有很多细微差别需要讨论。所以,让我尝试解决你的问题。
我的问题有点不清楚和误导。我认为不是转换本身是UB,而是在从
pInstance
转换后取消引用pChild
。在C语言中,由于各种原因,强制转换是未定义的行为,但不是在你的问题中所做的强制转换中。请参阅此答案下面的评论以获得更多见解。
1.你正在解引用超出你的程序/对象所拥有的内存,或者
1.你正在阅读 * 未初始化的 * 内存/值(即使你的程序 * 确实 * 拥有该内存)
请考虑以下示例:
1.示例1:指向程序不拥有的内存是未定义的行为
1.未定义的行为:在任何机器上
请注意,执行上述操作的正确方法如下(示例文件:“/Arduino 1.8.13/hardware/tools/avr/avr/include/avr/iom328pb.h”):
1.例2:使用我们不拥有的内存和/或未初始化的内存是未定义的行为
1.未定义的行为:在任何机器上
1.例3:使用我们的程序 does own的内存池是 not undefined行为
现在,有了以上所学知识,我们再来看看您的问题:
我的问题有点不清楚和误导。我认为不是转换本身是UB,而是在从
pInstance
转换后取消引用pChild
。这个问题的答案是:“这取决于你是否解引用 valid(拥有的,如果阅读它,已经初始化)与 invalid(没有拥有的,* 或 * 未初始化)内存。
请考虑以下几点:
让我们更深入地探索第一种情况,其中存在 * 可能 * 未定义的行为。
那么,*
(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 任何虚拟表多态函数):
对于上面最后一个(危险的)强制转换,C允许你有一个动态强制转换,当且仅当你用C dynamic_cast语法调用它时,它会在运行时失败,* 和 * 检查错误,像这样:
要点:
一旦你第一次通过把父对象放在子对象的开始处来获得对齐,基本上就把每个对象看作是一个内存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++代码:
输出:
同样,你的C代码也有同样的行为。
这样做是有效的:
但是这样做是不对的:
因此,对于您的特定功能:
这很好:
但这不是**!:
我对C中强制执行OoP(面向对象编程)和继承的看法
只是一个警告:传递指针和存储指向vtables和函数的指针以及C结构中的东西将使跟踪代码并试图理解它变得非常困难!我不知道有索引器(包括Eclipse,Eclipse有我见过的最好的索引器),可以追溯到代码中哪个函数或类型被分配给了指针。或者用C从头开始引导自己的 C++ (同样,为了学习),我建议不要使用这些模式。
如果你想要“面向对象”的C语言,并具有继承和所有功能,不要这样做。如果你想要“基于对象”的C语言,通过不透明的指针/结构进行基本的私有成员封装和数据隐藏,那就很好了!下面是我更喜欢的方法:Option 1.5 ("Object-based" C Architecture) .
最后一点:你可能比我更了解虚表(vtables)。说到底,这是你的代码,所以你想做什么架构就做什么架构,但我不想在那个代码库中工作:)。
参见
1.[我的回答] When should static_cast, dynamic_cast, const_cast, and reinterpret_cast be used?
f87krz0w3#
C标准将对许多“继承式”习惯用法的支持视为实现质量问题。仅用于不涉及此类继承的任务的实现不需要支持它,但所有或几乎所有实现都可以配置为支持此类构造。在clang和gcc中,可以通过使用
-fno-strict-aliasing
编译选项来支持它们。请注意,在C89中,允许结构互换使用的惯用方法是让它们以共同的初始序列开始。虽然有些人可能会认为C99旨在使用这种惯用法来破坏代码,但这意味着C99的编写严重违反了委员会的章程。如果C99的作者打算维护他们的章程,他们的意图是,将受益于CIS保证的程序将以支持它的方式进行处理,而不支持它的实现将仅用于不会受益于它的任务。
使用公共初始序列方法,派生结构将以与其父结构相同的成员开始。如果一个结构类型和从它派生的所有结构都以具有相同名称和不同类型的成员开始,则期望指向与结构类型兼容的类型的指针的函数可以用一致的语法(例如,
&foo->header
)来传递它。使用宏可能会很有用,它可以在语法上接受指向任何遵循模式的结构的指针,并将其 Package 以调用实际的函数,例如。以这种方式使用宏有点难看,但它允许代码在
ptr
是指向任何从woozle
派生并遵循模式的对象的指针时说use_woozle(ptr, x, y);
,同时拒绝传递其他东西的尝试。相比之下,使用void*
参数或将参数转换为struct woozle
将绕过类型检查,否则将有效地捕获许多错误。例如传递具有错误间接级别的指针。