在C++中,将结构体的标量成员视为数组处理是否有效?

42

在查看Dear Imgui的代码时,我发现了以下代码(为了相关性而编辑):

struct ImVec2
{
    float x, y;
    float& operator[] (size_t idx) { return (&x)[idx]; }
};

很明显这种方法在实践中是有效的,但从C++标准的角度来看,这段代码是否合法?如果不合法,那么主要的编译器(G++、MSVC、Clang)是否提供任何显式或隐式的保证,以确保该代码可以按照预期工作?


评论不适合进行长时间的讨论;此对话已被移至聊天室 - Samuel Liew
4个回答

39

这段代码合法吗?

不合法,存在未定义行为。表达式&x是指向float对象而不是float数组的float*指针。因此,如果idx12或其他值,则表达式(&x)[idx]分别是(&x)[1](&x)[2],这意味着您正在尝试访问不应由您访问的内存。

任何主要的编译器(G++、MSVC、Clang)是否提供明确或隐含的保证,使此代码按照预期工作?

未定义行为意味着可以发生1任何事情,包括但不限于程序产生您期望的输出。但永远不要(或者基于)依赖具有未定义行为的程序的输出来做出结论。程序可能会崩溃。

因此,您可能看到的输出结果是未定义行为的结果。并且如我所说,不要依赖具有UB的程序的输出。程序可能会崩溃。

所以,使程序正确的第一步是消除未定义行为。只有这样,您才能开始推理程序的输出。


1为了更准确地定义未定义行为,请参阅此处,其中提到:程序的行为没有限制。


2
评论不适合进行长时间的讨论;此对话已被移至聊天室 - Machavity
3
您的评论不正确;OP的代码确实存在未定义行为。在您的回答中有类似但不完全相同的代码,它不是未定义行为。(它们非常相似,编译后得到相同的汇编代码) - Yakk - Adam Nevraumont
@Yakk-AdamNevraumont:替换过时的注释:这个答案是正确的,但它只是刚好符合未定义行为。标准的意图似乎是如果你使用char*进行指针运算,那么它就是明确定义的。标准要求即使使用float*运算,这也适用于std::complex,因此实现通常会在一般情况下定义它或使用特殊的东西来定义std::complex。https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/p1912r1.pdf(libstdc++有两个单独的`_M_real`和`_M_imag`成员,而不是一个带有数组的联合体。) - Peter Cordes
3
标准非常清楚,如果您不使用 char*(或类似的指针),它就没有被定义。标准希望能够保证,如果您有一个指向单个变量 x(或在 x 中)的指针,则无论如何进行指针算术运算,该指针都不会指向不是 x 的东西(除非您将其转换为 char*)。这旨在简化编译器从变量值中推导出值时的工作。 char* 指针算术是一个故意留下的漏洞;因此,如果代码没有使用它,编译器可以做出某些优化的假设而无需证明它们。 - Yakk - Adam Nevraumont
2
现在,这可能不会最终变得实用,也许标准是错误的,但它绝对是有意为之的。 - Yakk - Adam Nevraumont

30
这在ISO C++和ISO C中几乎是安全的,并且编译器似乎即使使用float*也定义了行为。为了完全安全,您应该在进行float*转换之前将指针数学强制转换为char*;ISO标准仅允许对数组指针进行指针数学运算,但您应该能够将任何对象视为charstd::byte数组,这就是使用offsetof可用于创建可以解引用的指针的原因。但是实际上,在像GCC这样的真正实现中,即使只使用float*,它似乎也已经明确定义了。
假设没有填充: 您可以static_assert,即offsetof(ImVec2, y) == sizeof(float)
  • 对于标准布局类型,指向第一个成员的指针可转换为/自整个结构/类对象的指针。

  • 对于标准布局类型,使用offsetof(T,y)作为偏移量来索引是明确定义的。参见Using offsetof to access struct member(C语言,但我认为在C++中意图是让offsetof以相同方式可用)。尽管ISO标准的措辞是否真正支持此操作存在争议,但这是其意图,并且编译器开发人员同意它应该是明确定义的。

  • 与指针不同,在使用sizeof(float)*idx运行时变量时,size_t值是可替换的。因为从offsetof得到的4可以工作,所以从idx*sizeof(float)得到的运行时变量4也可以工作。

  • 使用offsetof进行char*数学运算可能可以安全地使用float*代替实际上将其转换为char*然后再转换回来。但这并没有得到标准的支持,并且依赖于某些假设,认为它们是等效的。因此,为了最大的安全性,请使用char*,这样您只依赖于与使用offsetof访问成员相同的行为,而该标准我认为意图是明确定义的。

    请参见Is adding to a "char *" pointer UB, when it doesn't actually point to a char array?,zwol的答案指出了使访问结构体成员数组之外的位置未定义的目标冲突,但也允许通过从offsetof的偏移量进行成员访问。

当然,如果idx不是01,则行为是未定义的,因为您没有进行边界检查。(使用idx & 1将花费一个AND指令,但会给您带来无符号模2。但越界索引很可能是一个错误,因此在这种情况下默默地工作并不好。如果您想要任何边界检查,可能需要一个在非错误情况下永远不会被采取的分支,例如assert、抛出异常或返回NaN。)

即使该指针是结构体数组中的一部分,通过这个指针访问超出结构体结尾的位置可能是合法的。我们必须将其作为转换为数组成员来证明,然后访问到类似于 `offsetof` 的另一个数组成员。(相对于另一个数组成员访问一个数组成员是有保障的)

第一个成员和整个结构体之间的可互换性

在 C 语言中,指向结构体的指针是否等同于指向其第一个成员的指针? - 是的,反过来也是,在 N1570 6.7.2.1p15 中进行了引用。

在 C++ 中,同样的保证仅适用于 "标准布局" 类型,这排除了存在虚函数表的情况。在第一个成员之前不允许填充,允许在第一个成员和整个结构体之间进行指针转换。请参阅当前草案中的11.4.1 Class members - General:

  1. 如果具有非静态数据成员的标准布局类对象,则如果该成员不是位字段,则其地址与其第一个非静态数据成员的地址相同。它的地址也与其每个基类子对象的地址相同。

[注 11:因此,由实现插入到标准布局结构体对象中的未命名填充可能存在,但不能在其开始处存在,以达到适当的对齐。-结束说明]

[注 12:对象及其第一个子对象可以进行指针互换([basic.compound], [expr.static.cast])。-结束说明]


最大安全性方法

另一种编写此代码的方法是自己从结构体对象开始,而不是依赖于 `&x` 隐式地作为 `this`。并使用 `char*` 进行数学运算。

您可以使用 reinterpret_cast<const char*>(this) + 4*idx 来获取成员的指针,然后将其转换为 `float*` 并进行解引用。(或者实际上是 `sizeof(float)`,并且假设 `offsetof(ImVec2, y) == sizeof(float)`。) 由于您有一个具有 2 个成员的结构体,使用 `char*` 数学运算的 `idx * offsetof(ImVec2,y)` 也可以工作,并且希望编译器仍然能够生成类似于 `lea rax, [rdi + rsi*4]` 的 x86 汇编代码来返回指针,即 C++ 引用。

这相当于将this强制转换为float*,但实际的指针运算发生在一个char*上,这在任何对象中都是允许的。

#include <cstdlib>
#include <cstddef>
#include <type_traits>

struct ImVec2
{
    float x, y;
    float& operator[] (size_t idx) {
        static_assert(std::is_standard_layout<ImVec2>::value, "can't index in a struct that isn't standard layout");
        // offset(x) == 0 is guaranteed by ISO C++ for standard-layout types
        static_assert(offsetof(ImVec2, x) == 0,             "struct of float x,y isn't 2 contiguous members");
        // A hypothetical compiler could put padding before y
        static_assert(offsetof(ImVec2, y) == sizeof(float), "struct of float x,y isn't 2 contiguous members");

        // assert(idx <= sizeof(*this) / sizeof(x) && "out of bounds access to xy vector");
        char *obj = reinterpret_cast<char*>(this);
        obj += sizeof(float) * idx;      // or idx * offsetof(T,y) for a 2-member struct
        return *reinterpret_cast<float*>(obj);
       // memcpy into  float tmp  could avoid ever dereferencing a float* if you only want to return by value
       // It's safe to derive a pointer to a member from a pointer to the whole object
    }

    float & index_from_member (size_t idx){
        return (&x)[idx];    // Less safe; (ImVec2*)(&x) is allowed, but the pointer math is on float* not char*
    }
};

当结构布局正常并且问题中的简单版本起作用时,这当然会编译为同一组汇编代码。

真正的编译器会在idx>=2时发出警告,但对于0或1则不会

对于问题中的版本或以this开头的版本,GCC仅在编译时常量索引大于等于2时才发出警告。这表明它知道可能存在问题,但认为访问仍在成员所在的整个结构体范围内时不会出现问题,这是个好兆头。
编译器没有警告或者UBSAN没有运行时检测并不能证明在ISO C++或C中是安全的,甚至不能完全证明它在那个编译器上是安全的。
但是,一个情况下有警告而另一个情况下没有警告确实确认了编译器关心差异,并且阈值在哪里。除非还有其他未定义行为它没有发出警告。或者始终存在一种可能性,即警告和GCC内部的某些其他部分不同步,并且GCC的某些值范围验证部分可能会推断__builtin_assume(idx==0)尽管没有警告。虽然它在idx=2时发出警告,但在idx=1时没有警告并不能确定它是安全的。但我们有其他支持证据,例如这样的代码存在于现实世界的源代码中,并且明显是工作的。
因此,GCC似乎确实定义了行为。对于return iv.index_from_member(1),即使我们访问了x的范围之外,也不会发出警告。

## GCC12.2 -O3 -Wall
<source>: In function 'float test_orig()':
<source>:38:32: warning: array subscript 2 is outside array bounds of 'ImVec2 [1]' [-Warray-bounds]
   38 |     return iv.index_from_member(2);
      |            ~~~~~~~~~~~~~~~~~~~~^~~
<source>:37:12: note: at offset 8 into object 'iv' of size 8
   37 |     ImVec2 iv = {2.0, 2.0};
      |            ^~
ASM generation compiler returned: 0

请注意,GCC的警告将其描述为大小为8的对象。
即使使用“-O3 -Wall -Wextra”,Clang也不会发出警告,但使用“-fsanitize = undefined”会生成汇编代码,该代码将无条件调用值为2的编译时常量idx的__ubsan_handle_type_mismatch_v1函数(在内联之后)。 (首先检查堆栈指针的指针溢出,即函数进入时RSP是否为0。)
/app/example.cpp:22:16: runtime error: reference binding to address 0x7ffce4f8eba0 with insufficient space for an object of type 'float'
0x7ffce4f8eba0: note: pointer points here
 00 00 00 40  b0 b5 34 d6 08 56 00 00  83 e0 48 0e 86 7f 00 00  00 00 00 00 00 00 00 00  98 ec f8 e4
              ^ 
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /app/example.cpp:22:16 in 
/app/example.cpp:38:15: runtime error: load of address 0x7ffce4f8eba0 with insufficient space for an object of type 'float'
0x7ffce4f8eba0: note: pointer points here
 00 00 00 40  b0 b5 34 d6 08 56 00 00  83 e0 48 0e 86 7f 00 00  00 00 00 00 00 00 00 00  98 ec f8 e4

如果不安全可能会发生的故障模式

在另一种情况下,标准没有定义行为(例如在非标准布局类中,或者如果 x 前面有其他成员),它容易出现问题的方式是,在内联之后,编译器会得出唯一可能的 idx 值是 0,并且实际上根本不执行运行时变量索引。然后优化早期和晚期计算导致或使用 idx 的计算。(但是,如果编译器没有定义行为,则 UB 可能会导致任意破坏,至少对于当前编译器版本而言。)

这不是严格别名 UB。您没有通过 float* 访问 int 对象或类似的东西。两个对象都是 float,唯一的潜在问题是从仅恰好位于其旁边的另一个对象 x 派生出指向 yfloat*。创建任何对象的一过性指针,包括标量 float,都是合法的,但通常不合法对其进行解引用。我们必须寻找其他规则来证明这一点。如果存在问题,则 gcc -fno-strict-aliasing 也不会使其合法。


1
@PasserBy 嗯,是的,但是OP的原始代码也是这样... - Neil
1
幸运的是,在这种情况下,它只需要为第一个成员工作! - Neil
3
这个链接这个链接讨论了指向char的指针加法操作是否会导致未定义行为。如果你去查找,会发现对于第二个问题有大量争论。 - Passer By
6
这是一个很棒的回答,但我不完全同意其中的一点:“在一个情况下存在警告而在另一个情况下不存在确实确认了编译器关心差异的程度,这就是阈值所在的地方。”GCC拥有数十年来的数百名贡献者,我认为很可能会出现一个人编写检测并警告一个情况但未对另一个情况进行警告的逻辑,而另一个人编写的逻辑将两种情况都视为UB并将它们暴露给可能没有警告就能破坏一切的优化。 - ruakh
3
我认为这个链接与本文有关:https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/p1912r1.pdf - Carsten S
显示剩余12条评论

22
现实情况是,类型转换解决方案在C语言中已经成功应用了很长时间。问题在于它很脆弱,并且C++不同于C——可能会出现您没有考虑到的额外问题。
为了找到一个您可能会接受的解决方案,我建议使用引用访问器:
#include <iostream>
#include <stdexcept>

struct point
{
  double xy[2];

  double & x() { return xy[0]; }
  double & y() { return xy[1]; }

  double const & x() const { return xy[0]; }
  double const & y() const { return xy[1]; }

  double       & operator [] ( std::size_t n )       { return xy[n]; }
  double const & operator [] ( std::size_t n ) const { return xy[n]; }
};

int main()
{
  point p{ 2, 3 };
  
  std::cout << p[0] << ", " << p.x() << "\n";
  std::cout << p[1] << ", " << p.y() << "\n";
  
  p[0]  = 5;
  p.y() = 7;
  std::cout << p[0] << ", " << p.x() << "\n";
  std::cout << p[1] << ", " << p.y() << "\n";
  
  auto f = []( const point & p )
  {
#if 0
    p[0]  = 11;  // won't compile
    p.y() = 13;  // won't compile
#endif
    std::cout << p[0] << ", " << p.x() << "\n";
    std::cout << p[1] << ", " << p.y() << "\n";
  };
  f( p );
}

它编译得非常干净。


您可能会尝试直接使用引用:

struct point
{
  double xy[2];
  double & x;  // DON’T DO THIS
  double & y;  // DON’T DO THIS

  point() : x{xy[0]}, y{xy[0]} { }
  point( double x, double y ) : x{xy[0]=x}, y{xy[1]=y} { }
};

这种方法的问题在于它会破坏 const 的保证。也就是说,即使你在某个地方有一个 const point,你仍然可以通过引用来修改它。
void f( const point & p )
{
  p[0] = 97;          // compiler complains properly
  p.y  = 3.14159265;  // compiler blithely accepts this
}

除此之外,它还会破坏许多其他东西。请参见下面的Ben Voight的评论。
因此,请不要这样做。像我上面建议的那样使用引用访问方法

7
后一种方法的更大问题在于它改变了结构体的大小。 - Sopel
4
非静态数据成员如果是引用类型(公平地说,您确实建议不要这样做),更严重的问题是它们会像指针一样占用存储空间,改变结构体的大小和对齐方式。此外,您的赋值运算符、移动赋值运算符、拷贝构造函数和移动构造函数都将默认禁用。这就意味着没有标准布局、没有平凡或POD类型。而且编译器很可能无法证明在所有情况下xy指向对象内部,导致程序无法优化。 - Ben Voigt
7
如果你使用memcpy来复制它,就会使其非平凡可复制;复制品中的引用将指向原始结构体的成员变量。 - Peter Cordes
2
@user20126994:我认为使用reinterpret_cast<const char*>(this) + 4*idx获取成员指针,然后将其转换为float*并解引用是合法的。一个int没有身份,它不是从任何地方“派生”出来的,因此您不需要制作offsetof值数组,只需使用static_assert(offsetof(V2,y)==4)检查从结构体开始的偏移量是否正确的为04。这就是我之前所说的“以ISO C和C++允许的方式编写相同逻辑”的方法,可以编译成相同的汇编代码。 - Peter Cordes
4
我考虑了很久是否提及引用成员版本,因为这实在是个非常糟糕的想法......但最终还是认为值得一提,因为它太容易被想起了。看到所有关于它有多糟糕的评论,我感到有点高兴,哈哈。我会更新答案并参考你的评论。:O) - Dúthomhas
显示剩余7条评论

9

最重要的事情

这段代码使用C风格内存访问——没有进行边界检查。它接受任何size_t作为输入。这是一个等待发生缓冲区溢出漏洞的错误。它被包装在类操作符中,不明显地暴露了它是不安全的。

在C和C ++中,始终、始终、始终检查数组访问的边界。

现在,回到你的问题

不,它不能保证可移植性而正常工作。其他人引用了标准。可能会破坏它的一些因素是:在聚合类型成员之间插入填充、以与预期不同的顺序放置成员(特别是在复杂的class中),或违反优化器关于指针是否允许别名的假设。

尽管如此,一些编译器确实指定了他们结构的精确布局(例如IBM的z/OS编译器指定默认情况下成员是自然对齐的),或提供类似#pragma pack的指令,允许程序员指定struct每个成员的确切偏移量。

然而,任何真实世界的编译器都很不可能破坏这样的代码——特别是如果平台有标准ABI,其他布局会破坏它。你根本没有类型拼接,只是通过float*访问float。通常,在C中将地址加上0或1是合法的,因为&x可以被视为单例数组的指针,(&x)+1作为该数组的结束指针,但是解除引用(&x)+1可能会破坏。一些实现可能以你意想不到的方式表示此指针(例如,作为fat指针),或者优化器可能假定指针永远不会被解除引用,并生成在它被解除引用时会出错的代码。

你可以考虑采取的替代方法

认真考虑用一个数组来替换你命名的各个数据成员,特别是当你需要使用xyzw时。

如果你不能改变单例变量的表示,但需要符合语言标准的代码,这是可能的。

default: 块抛出异常的 switch 块,或者紧跟嵌套三元表达式的边界检查,在某些奇怪的实现中仍然有效,这些实现在 xy 之间插入填充以某种原因,并且还会拒绝任何无效的溢出。现代编译器应该能够将其转换为有效的代码。例如,带有任何优化标志的 Clang 15.0.0 可以对此类代码进行良好的处理:

return (idx == 0) ? x :
       (idx == 1) ? y :
                    z; 

只有几个选项,它就可以生成有条件的移动甚至使用简单的指针算术来计算地址,类似于数组索引。如果选项更广泛,它会生成一个查找表。

这更冗长,可能过于复杂,而且随着您添加的成员越来越多,代码也会变得越来越复杂,但编译后的代码并不糟糕,并且没有未定义或未指定的行为。


5
我认为这个答案并不完全正确。Clang 15.0.0 似乎不能对此进行优化:https://godbolt.org/z/KnaEYoM7E。我同意边界检查是一个好主意(特别是在调试模式下),但我不同意“它被包装在一个类操作符中,不明显地暴露了其不安全性”的说法。C 数组的索引是不安全的,而 std::array 的 operator[] 明确规定进行边界检查,因此我认为不安全的边界检查是现状。(此外,xyzw 是以图形为重点的向量的前四个元素传统命名方式)。 - user20126994
1
它似乎仍然无法工作:https://godbolt.org/z/Esz66eKbY - user20126994
2
“重新排列聚合体的成员” - 标准不允许这样做。“任何真实世界的编译器都很难破坏那样的代码” - 如果你指的是OP的代码,我认为即使没有填充,它也可能出现严重故障。编译器可以看到&x指向一个单一的float,因此idx的唯一合法值是0。在代码中每次出现v[i],其中vImVec2,都有一个隐含的__builtin_assume(i==0)。即使clang现在没有使用它,以后的版本也可能会使用它。 - benrg
2
我不同意第一段。事实上,大多数情况下,我希望 operator[] 没有边界检查,标准库提供了 at 来进行检查。 - Carsten S
2
@Davislor:“在同一结构体内使用指针算术偏移是合法的。”实际上并不是。但我希望它是合法的。在单个完整对象内进行指针比较(如<><=等关系运算符)是允许的,但是算术运算不被允许。我自己也曾经多次忘记了关系比较和算术要求并不相同。 - Ben Voigt
显示剩余18条评论

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