在查看Dear Imgui的代码时,我发现了以下代码(为了相关性而编辑):
struct ImVec2
{
float x, y;
float& operator[] (size_t idx) { return (&x)[idx]; }
};
很明显这种方法在实践中是有效的,但从C++标准的角度来看,这段代码是否合法?如果不合法,那么主要的编译器(G++、MSVC、Clang)是否提供任何显式或隐式的保证,以确保该代码可以按照预期工作?
在查看Dear Imgui的代码时,我发现了以下代码(为了相关性而编辑):
struct ImVec2
{
float x, y;
float& operator[] (size_t idx) { return (&x)[idx]; }
};
很明显这种方法在实践中是有效的,但从C++标准的角度来看,这段代码是否合法?如果不合法,那么主要的编译器(G++、MSVC、Clang)是否提供任何显式或隐式的保证,以确保该代码可以按照预期工作?
这段代码合法吗?
不合法,存在未定义行为。表达式&x
是指向float
对象而不是float
数组的float*
指针。因此,如果idx
是1
或2
或其他值,则表达式(&x)[idx]
分别是(&x)[1]
或(&x)[2]
,这意味着您正在尝试访问不应由您访问的内存。
任何主要的编译器(G++、MSVC、Clang)是否提供明确或隐含的保证,使此代码按照预期工作?
未定义行为意味着可以发生1任何事情,包括但不限于程序产生您期望的输出。但永远不要(或者基于)依赖具有未定义行为的程序的输出来做出结论。程序可能会崩溃。
因此,您可能看到的输出结果是未定义行为的结果。并且如我所说,不要依赖具有UB的程序的输出。程序可能会崩溃。
所以,使程序正确的第一步是消除未定义行为。只有这样,您才能开始推理程序的输出。
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 Cordeschar*
(或类似的指针),它就没有被定义。标准希望能够保证,如果您有一个指向单个变量 x
(或在 x
中)的指针,则无论如何进行指针算术运算,该指针都不会指向不是 x
的东西(除非您将其转换为 char*
)。这旨在简化编译器从变量值中推导出值时的工作。 char*
指针算术是一个故意留下的漏洞;因此,如果代码没有使用它,编译器可以做出某些优化的假设而无需证明它们。 - Yakk - Adam Nevraumontfloat*
也定义了行为。为了完全安全,您应该在进行float*
转换之前将指针数学强制转换为char*
;ISO标准仅允许对数组指针进行指针数学运算,但您应该能够将任何对象视为char
或std::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
不是0
或1
,则行为是未定义的,因为您没有进行边界检查。(使用idx & 1
将花费一个AND指令,但会给您带来无符号模2。但越界索引很可能是一个错误,因此在这种情况下默默地工作并不好。如果您想要任何边界检查,可能需要一个在非错误情况下永远不会被采取的分支,例如assert、抛出异常或返回NaN。)
在 C 语言中,指向结构体的指针是否等同于指向其第一个成员的指针? - 是的,反过来也是,在 N1570 6.7.2.1p15 中进行了引用。
在 C++ 中,同样的保证仅适用于 "标准布局" 类型,这排除了存在虚函数表的情况。在第一个成员之前不允许填充,允许在第一个成员和整个结构体之间进行指针转换。请参阅当前草案中的11.4.1 Class members - General:
- 如果具有非静态数据成员的标准布局类对象,则如果该成员不是位字段,则其地址与其第一个非静态数据成员的地址相同。它的地址也与其每个基类子对象的地址相同。
[注 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*
}
};
this
开头的版本,GCC仅在编译时常量索引大于等于2时才发出警告。这表明它知道可能存在问题,但认为访问仍在成员所在的整个结构体范围内时不会出现问题,这是个好兆头。__builtin_assume(idx==0)
尽管没有警告。虽然它在idx=2
时发出警告,但在idx=1
时没有警告并不能确定它是安全的。但我们有其他支持证据,例如这样的代码存在于现实世界的源代码中,并且明显是工作的。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
/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
派生出指向 y
的 float*
。创建任何对象的一过性指针,包括标量 float
,都是合法的,但通常不合法对其进行解引用。我们必须寻找其他规则来证明这一点。如果存在问题,则 gcc -fno-strict-aliasing
也不会使其合法。
#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
}
x
和y
指向对象内部,导致程序无法优化。 - Ben Voigtreinterpret_cast<const char*>(this) + 4*idx
获取成员指针,然后将其转换为float*
并解引用是合法的。一个int
没有身份,它不是从任何地方“派生”出来的,因此您不需要制作offsetof
值数组,只需使用static_assert(offsetof(V2,y)==4)
检查从结构体开始的偏移量是否正确的为0
和4
。这就是我之前所说的“以ISO C和C++允许的方式编写相同逻辑”的方法,可以编译成相同的汇编代码。 - Peter Cordes这段代码使用C风格内存访问——没有进行边界检查。它接受任何size_t
作为输入。这是一个等待发生缓冲区溢出漏洞的错误。它被包装在类操作符中,不明显地暴露了它是不安全的。
在C和C ++中,始终、始终、始终检查数组访问的边界。
不,它不能保证可移植性而正常工作。其他人引用了标准。可能会破坏它的一些因素是:在聚合类型成员之间插入填充、以与预期不同的顺序放置成员(特别是在复杂的class
中),或违反优化器关于指针是否允许别名的假设。
尽管如此,一些编译器确实指定了他们结构的精确布局(例如IBM的z/OS编译器指定默认情况下成员是自然对齐的),或提供类似#pragma pack
的指令,允许程序员指定struct
每个成员的确切偏移量。
然而,任何真实世界的编译器都很不可能破坏这样的代码——特别是如果平台有标准ABI,其他布局会破坏它。你根本没有类型拼接,只是通过float*
访问float
。通常,在C中将地址加上0或1是合法的,因为&x
可以被视为单例数组的指针,(&x)+1
作为该数组的结束指针,但是解除引用(&x)+1
可能会破坏。一些实现可能以你意想不到的方式表示此指针(例如,作为fat指针),或者优化器可能假定指针永远不会被解除引用,并生成在它被解除引用时会出错的代码。
认真考虑用一个数组来替换你命名的各个数据成员,特别是当你需要使用x
、y
、z
和w
时。
如果你不能改变单例变量的表示,但需要符合语言标准的代码,这是可能的。
default:
块抛出异常的 switch
块,或者紧跟嵌套三元表达式的边界检查,在某些奇怪的实现中仍然有效,这些实现在 x
和 y
之间插入填充以某种原因,并且还会拒绝任何无效的溢出。现代编译器应该能够将其转换为有效的代码。例如,带有任何优化标志的 Clang 15.0.0 可以对此类代码进行良好的处理:
return (idx == 0) ? x :
(idx == 1) ? y :
z;
只有几个选项,它就可以生成有条件的移动甚至使用简单的指针算术来计算地址,类似于数组索引。如果选项更广泛,它会生成一个查找表。
这更冗长,可能过于复杂,而且随着您添加的成员越来越多,代码也会变得越来越复杂,但编译后的代码并不糟糕,并且没有未定义或未指定的行为。
operator[]
明确规定不进行边界检查,因此我认为不安全的边界检查是现状。(此外,xyzw 是以图形为重点的向量的前四个元素传统命名方式)。 - user20126994&x
指向一个单一的float
,因此idx
的唯一合法值是0
。在代码中每次出现v[i]
,其中v
是ImVec2
,都有一个隐含的__builtin_assume(i==0)
。即使clang现在没有使用它,以后的版本也可能会使用它。 - benrgoperator[]
没有边界检查,标准库提供了 at
来进行检查。 - Carsten S<
,>
,<=
等关系运算符)是允许的,但是算术运算不被允许。我自己也曾经多次忘记了关系比较和算术要求并不相同。 - Ben Voigt