通过将对象指针转换为`char *`,然后执行`*(member_type*)(pointer + offset)`来访问成员是否是UB?

Hol*_*Cat 9 c++ language-lawyer

下面是一个例子:

#include <cstddef>
#include <iostream>

struct A
{
    char padding[7];
    int x;
};
constexpr int offset = offsetof(A, x);

int main()
{
    A a;
    a.x = 42;
    char *ptr = (char *)&a;
    std::cout << *(int *)(ptr + offset) << '\n'; // Well-defined or not?
}
Run Code Online (Sandbox Code Playgroud)

我一直认为它是明确定义的(否则有什么意义offsetof),但不确定。

最近有人告诉我它实际上是UB,所以我想一劳永逸地弄清楚。

上面的例子是否会导致UB?如果将类修改为非标准布局,是否会影响结果?

如果是 UB,是否有任何解决方法(例如申请std::launder)?


整个主题似乎都没有实际意义且没有具体说明。

以下是我能找到的一些信息:

Lan*_*yer 7

这里我将参考C++20(草案)措辞,因为C++17和C++20之间修复了一个相关的编辑问题,并且可以参考HTML版本的C++20中的具体句子草案,但与 C++17 相比没有什么新内容。

\n\n

首先,指针值的定义[basic.compound]/3

\n\n
\n

指针类型的每个值都是以下之一:
\n \xe2\x80\x94指向对象或函数的指针(该指针被称为指向对象或函数),或
\n \xe2\x80\x94 a超过对象末尾的指针
([expr.add]),或\n \xe2\x80\x94该类型的空指针值
,或\n \xe2\x80\x94无效的指针值

\n
\n\n

现在,让我们看看表达式中发生了什么(char *)&a

\n\n

让我不证明这a是一个表示类型 的对象的左值A,我会说 \xc2\xabthe object a\xc2\xbb 来引用这个对象。

\n\n

子表达式的含义&a包含在[expr.unary.op]/(3.2)中:

\n\n
\n

如果操作数是 类型的左值T,则结果表达式是 \xe2\x80\x9c 类型的纯右值,指向T\xe2\x80\x9d 的指针,其结果是指向指定对象的指针

\n
\n\n

因此,&a是类型的纯右值A*,其值为 \xc2\xab指向(对象)a\xc2\xbb 的指针。

\n\n

现在,强制转换(char *)&a相当于reinterpret_cast<char*>(&a),其定义为static_cast<char*>(static_cast<void*>(&a))( [expr.reinterpret.cast]/7 )。

\n\n

转换为void*不会更改指针值 ( [conv.ptr]/2 ):

\n\n
\n

指向cv T \xe2\x80 \x9d 的类型为 \xe2\x80\x9c 的纯右值(其中T是对象类型)可以转换为类型为 \xe2\x80\x9c 的指向cv void \xe2\x80\x9d 的指针的纯右值。此转换不会改变指针值 ([basic.compound])。

\n
\n\n

即它仍然是指向(对象)a\xc2\xbb 的\xc2\xab 指针。

\n\n

[expr.static.cast]/13覆盖外部static_cast<char*>(...)

\n\n
\n

指向cv1 void \xe2\x80\x9d 的 \xe2\x80\x9c 指针类型的纯右值可以转换为指向cv2 T \xe2\x80\x9d的 \xe2\x80\x9c 指针类型的纯右值,其中T是对象类型,cv2是与 cv1 相同的 cv 限定,或比cv1更大的 cv 限定。\n 如果原始指针值表示内存中字节的地址 A,并且 A 不满足 的对齐要求T,则结果指针值未指定。\ n 否则,如果原始指针值指向对象a,并且存在可与a指针相互转换的类型的对象b (忽略 cv 限定) ,则结果是指向b的指针。\n 否则,指针转换后值不变。T

\n
\n\n

不存在可char与对象a( [basic.compound]/4 ) 进行指针互换的类型的对象:

\n\n
\n

两个对象ab指针可相互转换的,如果:
\n \xe2\x80\x94 它们是同一个对象,或者
\n \xe2\x80\x94 一个是联合对象,另一个是以下对象的非静态数据成员该对象 ([class.union]) 或
\n \xe2\x80\x94 一个是标准布局类对象,另一个是该对象的第一个非静态数据成员,或者,如果该对象没有非静态数据成员, -静态数据成员,该对象的任何基类子对象([class.mem]),或
\n \xe2\x80\x94 存在一个对象c,使得ac是指针可相互转换的,并且cb是指针-可相互转换。

\n
\n\n

这意味着static_cast<char*>(...)不会更改指针值,并且与其操作数中的值相同,即:\xc2\xab指向 a\xc2\xbb 的指针。

\n\n

因此,(char *)&a是类型的纯右值char*,其值为 \xc2\xab指向 a\xc2\xbb 的指针。该值存储到char* ptr变量中。然后,当您尝试使用这样的值(即 )进行指针算术时ptr + offset,您将进入[expr.add]/6

\n\n
\n

对于加法或减法,如果表达式P或 的Q类型为 \xe2\x80\x9c 指向cv T \xe2\x80\x9d 的指针,其中T和 数组元素类型不相似,则行为未定义。

\n
\n\n

出于指针算术的目的,该对象a被视为数组的元素A[1][basic.compound]/3),因此数组元素类型为A,指针表达式的类型P为 \xc2\xabpointer to char\xc2\ xbbcharA不是相似的类型(请参阅[conv.qual]/2),因此行为未定义。

\n

  • @HolyBlackCat _`memcpy` 和 `memmove` 很神奇,不能用标准 C++ 实现?_ 是的。 (3认同)

M.M*_*M.M 5

这个问题和另一个关于 的问题launder,在我看来都可以归结为对 C++17 [expr.static.cast]/13 最后一句的解释,其中涵盖了发生的情况static_cast<T *>指向不相关的指针的操作数时发生的情况正确对齐的类型:

\n\n
\n

指向cv1 void \xe2\x80 \x9d 的 \xe2\x80\x9c 指针类型的纯右值可以转换为指向cv2的 \xe2\x80\x9c 指针类型的纯右值 T \xe2\x80\x9d的 \xe2\x80\x9c 类型的指针的纯右值,

\n\n

[...]

\n\n

否则,指针值不会因转换而改变。

\n
\n\n

一些海报似乎认为这意味着强制转换的结果不能指向类型的对象T,因此reinterpret_cast指针或引用只能用于指针可相互转换的类型。

\n\n

但我不认为这是合理的,并且(这是一个反证法证)该立场还意味着:

\n\n
    \n
  • CWG1314 的决议被推翻。
  • \n
  • 检查标准布局对象的任何字节是不可能的(因为转换为unsigned char *或任何字符类型都不能用于访问该字节)。
  • \n
  • 严格的别名规则是多余的,因为实际实现此类别名的唯一方法是使用此类强制转换。
  • \n
  • 没有规范性文本来证明注释“[注意:将 \xe2\x80\x9cpointer 类型的纯右值转换为 T1 \xe2\x80\x9d 到类型 \xe2\x80\x9cpointer 到 T2 \xe2\x80\x9d (其中 T1 和 T2 是对象类型,并且 T2 的对齐要求并不比 T1 更严格)并返回其原始类型产生原始指针值。 \xe2\x80\x94end note ]"
  • \n
  • offsetof没有用(因此 C++17 对它的更改是多余的)
  • \n
\n\n

对我来说,这句话的意思是强制转换的结果指向内存中与操作数相同的字节,这似乎是更明智的解释。(与指向某个其他字节相反,这可能发生在本句未涵盖的某些指针转换中)。说“值不变”并不意味着“类型不变”,例如,我们将从 到 的转换描述intlong保留值。

\n\n
\n\n

另外,我想这可能对某些人来说是有争议的,但我认为如果指针的值是对象的地址,那么指针就指向该对象,除非标准明确排除这种情况,这是公理。

\n\n

这与[basic.compound]/3的文本一致,它说的是相反的,即如果一个指针指向一个对象,那么它的值就是该对象的地址。

\n\n

似乎没有任何其他显式语句定义指针何时可以或不能指向对象,但 basic.compound/3 表示所有指针必须是四种情况之一(指向对象、指向对象)超过结尾,空,无效)。

\n\n

排除的情况示例包括:

\n\n
    \n
  • 的用例std::launder专门解决了存在此类语言排除使用未经清洗的指针的情况。
  • \n
  • 尾指针不指向对象。(基本.化合物/3)
  • \n
\n