C结构体继承指针对齐问题

48

背景

我创建了一个基本的链表数据结构,主要是为了学习目的。这个链表的一个目标是它可以处理不同的数据结构。因此,我尝试使用结构体组合来模拟C语言中的“继承”。以下是构成我的链表基础的结构体。

typedef struct Link {
    struct Link* next;
    struct Link* prev;
} Link;

typedef Link List;

在我的实现中,我选择使用一个哨兵节点作为列表的头部和尾部(这就是 Link == List 的原因)。

为了使列表实际处理数据,一个结构体只需要将 Link 结构体作为第一个成员即可:

typedef struct {
    Link link;
    float data;
} Node;

于是链接列表看起来像这样。

         ┌───┬───┬───┐     ┌───┬───┐     ┌───┬───┬───┐     
... <--->│ PND │<--->│ PN │<--->│ PND │<---> ... 
         └───┴───┴───┘     └───┴───┘     └───┴───┴───┘
         End Node          myList        First Node

List myList;
Node node1 = {{}, 1.024};
....
Node nodeN = {{}, 3.14};

list_init(&myList) // myList.next = &myList; myList.prev = &myList;
list_append(&myList, &node1);
....
list_append(&myList, &nodeN);

问题

为了遍历这个列表,一个 Node 指针最初指向 第一个节点。然后沿着列表遍历,直到它再次指向哨兵节点时停止。

void traverse()
{
    Node* ptr;
    for(ptr = myList.next; ptr != &myList; ptr = ptr->link.next)
    {
        printf("%f ", ptr->data);
    }
}

我的问题与这行代码ptr != &myList有关。这行代码是否存在指针对齐问题?

for循环正确地产生了警告:(warning: assignment from incompatible pointer typewarning: comparison of distinct pointer types lacks a cast),可以按照警告信息所示进行强制类型转换为Node*,从而消除警告。然而,这是一个愚蠢的行为吗?当指针指向&myList时,我永远不会访问ptr->data,因为循环一旦满足ptr == &myList就会终止。

TLDR

在C语言中的结构体中,如果Derived是以Base作为第一个成员,则Base*可以指向Derived。如果没有访问到Derived特定的成员,那么Derived*可以指向Base吗?

编辑:用等价的内联代码替换了相关函数调用。


29
与问题本身无关,我只是想说这个演示非常棒。 - WhozCraig
1
我不认为你会有指针对齐问题,但我想问一下:为什么不将List作为Node的别名,并忽略Listdata成员?或者直接将其定义为Node(而不是List myList;,而是Node myList;)这将是一个更干净的方法来避免指针转换的问题。我同意其他评论中所说的:清晰地陈述了问题。 (+1) - lurker
我在我的问题中有inline list_first()和link_next()函数。 - thndrwrks
实际上不清楚 ptr != &myList 的目的是什么;它在标准C中是不允许的,gcc则会发出警告。例如,这是否意味着 (List *)ptr != &myListptr != (Node *)&myList?我建议第一个是可以的,但第二个理论上可能会违反对齐约束,就像 ptr = ptr->link.next (应该是 ptr = (Node *) ptr->link.next) 一样。 - davmac
假装一个 Base* 指针是一个 Derived* 指针,反之亦然,这不违反严格别名规则吗? - Cyan
显示剩余5条评论
3个回答

22
赞扬你的演讲。
我认为你的实现应该可以正常工作,因为C保证结构体的地址是其初始成员的地址。抛开C对结构体成员对齐的声明,这个保证意味着,只要你的实现总是将Link作为第一个成员,就不应该导致对齐问题。
来自here:C99 §6.7.2.1:
13 在结构体对象内,非位域成员和位域所在的单元按照它们声明的顺序递增地具有地址。指向结构体对象的指针,在适当转换后,指向其初始成员(如果该成员是位域,则指向其中所在的单元),反之亦然。结构体对象中可能存在未命名的填充,但在其开头不会有填充。
这应该是你想说关于Base *Derived *的事情,尽管纯C中没有这样的东西。它们只是具有相同内存布局的结构体。
然而,我认为这样实现有点脆弱,因为节点和链接直接相互依赖。如果更改节点的结构,则代码将变得无效。目前,我看不出除了能够重用链接编写新类型的新节点之外,还有什么额外的struct Link

实际上,在我看到您的帖子时,立即想到了一种链表实现方法,其工作方式非常类似于您打算使用列表的方式:kernel list

它使用相同的列表元素(list_head):

struct list_head {
    struct list_head *next, *prev;
};

它包含这个函数宏:

#define list_for_each_entry(pos, head, member)                          \
      for (pos = list_first_entry(head, typeof(*pos), member);        \
           &pos->member != (head);                                    \
           pos = list_next_entry(pos, member))

如果您查看宏的实现方式,您会发现它提供了对列表条目的迭代,而不需要知道包含列表的条目的布局。假设我正确解释了您的意图,我认为这就是您想要的方式。

1
您IP地址为143.198.54.68,由于运营成本限制,当前对于免费用户的使用频率限制为每个IP每72小时10次对话,如需解除限制,请点击左下角设置图标按钮(手机用户先点击左上角菜单按钮)。 - davmac
你也这么认为,真是太有趣了。我考虑过很长时间是否要在这个问题上加上评论。然而对于OP的问题,我认为指向初始成员的指针和指向结构体本身的指针应该是相同的,这才是真正重要的。 - midor
实际上是的。但 OP 的问题中存在头/尾问题。因为这个初始的Link对象不是Node的一部分(与列表的其余部分不同),所以在ptr = ptr->link.next中的指针转换未定义(6.7.2.1p13无法应用)。实际上,我怀疑这将永远不会引起问题。 - davmac

4
在C结构体中,如果Base是Derived的第一个成员,则Base*可以指向Derived。如果没有访问Derived特定成员,那么Derived*可以指向Base吗?如果你的意思是“Derived*可以指向任意的Base(包括不是Derived对象的第一个成员的Base)”,那么从技术上讲,不能。我对缺陷报告#74的理解是对齐要求可能不同。
如果一个结构体有一个类型为t的字段,那么该字段的对齐要求是否可以不同于不是结构体成员的相同类型对象的对齐要求? 如果问题a的答案是“是”,则在适用的情况下,应假定剩余的问题已针对结构内和结构外的对象进行了提问。
答:子句6.1.2.5说,“...指向限定或未限定版本的兼容类型的指针应具有相同的表示和对齐要求。” 子句6.5.2.1说,“结构体或联合对象的每个非位域成员都以适合其类型的实现定义方式对齐。” 稍后,“因此,在结构体对象内可能存在未命名的填充,...如有必要,以实现适当的对齐。” a)实现可以说明满足子句6.1.2.5的概括要求。这些要求可以使用子句6.5.2.1中可用的实现定义行为进一步加强。是的,对齐要求可能不同。

2
虽然这对于最符合标准的代码可能是正确的,但我非常怀疑在现实中存在着一个实际且半受欢迎的架构,其ABI确实如此。 - Dolda2000
我认为如果缺陷报告#74适用于引用的特定问题,则是错误的。请记住,C99 §6.7.2.1还包括短语“反之亦然”,我不知道如何通过“如果没有访问派生特定成员”来限定第二个问题是否会改变这一点。 - Greg A. Woods
@GregA.Woods 访问“派生类特定成员”需要存在一个派生对象;问题是询问“Derived”是否可以“指向Base”(不存在Derived对象)。我相信这个限定词是为了明确指出没有通过Derived*进行访问,并且问题纯粹是关于这样的指针是否存在,而不是它是否可以被解引用。§6.7.2.1没有说明从“Derived”到“Base*”的转换是否总是“适当的转换”,例如是否必然满足§6.3.2.3对齐要求。 - davmac
首先,这不是问题所问的。其次,我们必须假设,鉴于问题,一个Derived对象被暗示存在,因为指向它的指针具有非NULL值。无论访问是否发生在问题的范围内都没有关系。此外,任何值得一提的现代优化器都可以推断出将指针强制转换为指向Base对象,然后使用它来访问Base对象明确意味着正在访问Derived对象的第一个成员,因为唯一的指针操作是将其强制转换。 - Greg A. Woods
正如Derek Jones在他的评论《新C标准》中所说:“防止实现在结构类型的开头插入填充的唯一原因是现有的做法(以及将结构对象的地址视为该结构的第一个成员的地址的现有代码)。”对齐必须相同是由其他句子确定的。如果指针在转换后也没有被解引用,那么显然在转换之前和之后它的对齐方式都是无关紧要的。 - Greg A. Woods
显示剩余4条评论

1

我在ouah的回答中已经提到了这一点,但我会单独作为一个答案来呈现。

按照C标准的说法,你问题中的代码确实可能存在对齐问题。然而,我不认为有任何一种架构会表现出这样的对齐模式。至少我知道的,包括一些不同的桌面和嵌入式架构的经验,都没有这种情况。我也无法想象有哪种架构会从这样的对齐属性中获得任何好处。

值得注意的是,这种模式在实践中确实很常见;这有点像你总是可以将int放入指针中,虽然它并不被任何跨平台标准保证,但几乎没有平台不符合这一点,而且人们一直在这样做。


我也无法想象有哪种架构可以从这些对齐属性中获益。考虑到字大小的增加有时需要相应的对齐。在32位x86上,SSE2扩展需要8字节对齐以有效地处理64位值;但指针仅需要4字节对齐。因此,目前可能存在具有不同对齐要求的非平凡结构。(另一方面,当然,x86不会在指针加载时强制执行对齐,因此OP的代码可能仍然正常运行)。 - davmac
虽然从更一般的意义上来说这是正确的,但我不认为它适用于OP的情况,因为他只通过Node指针访问Link成员,而不是反过来。当通过Link指针访问Node成员时可能会出现您提到的那种对齐问题,但OP明确避免了这种情况。 - Dolda2000
我说的是对齐需求,而不是别名限制。无论是否有访问权限,Node可能具有比List更严格的对齐要求,这会使得ptr = ptr->link.next成为_非严格符合_。正如您所说,然而,在大多数实际架构上,这不太可能引起问题(就像我上面所提到的:x86不强制执行指针加载的对齐)。 - davmac
@davmac:这正是我所指的。Node可能比List有更严格的对齐要求,但反过来就不行了。由于没有按照Node对齐方式访问已对齐为List的数据结构,因此这不应该成为问题。即使通过Link指针解引用ptr->link.next,它仍将根据Node对齐方式进行分配。 - Dolda2000
由于没有按照List对齐的数据结构被视为Node进行访问是不正确的;请检查问题描述 - 在我的实现中,我选择使用一个作为列表头尾的哨兵节点;这个哨兵节点被定义为一个List而不是一个Node(List myList;),然后在traverse方法的for循环中可能通过ptr = ptr->link.next进行寻址。 - davmac
(Eck。它不作为节点访问,这是正确的。但是Node*被强制指向一个List(而不是Node)对象,因此对齐方式就会发挥作用,因为如果List对象没有适合Node的对齐方式,则指针转换是未定义的。) - davmac

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接