索引到一个结构是合法的吗?

不pipe代码是多么糟糕,并且假设alignment方式在编译器/平台上不是问题,这是不确定的还是破坏的行为?

如果我有这样的结构:

struct data { int a, b, c; }; struct data thing; 

(&thing.a)[0](&thing.a)[1](&thing.a)[2]是否合法访问abc

在任何情况下,在每一个编译器和平台上,我都试过了,每一个设置我都试过了。 我只是担心编译器可能没有意识到bthing [1]是一样的东西,而存储到'b'的存储可能被放在一个寄存器中,而东西[1]从内存中读取错误的值(例如)。 在任何情况下,我都试过它做了正确的事情。 (我当然知道,这并不多)

这不是我的代码; 它是我必须使用的代码,我感兴趣的是这是不好的代码还是破坏的代码,因为不同的改变会影响到我的优先级。

标记为C和C ++。 我最感兴趣的是C ++,但是如果它不同,只是为了兴趣。

这是违法的1 。 这是C ++中未定义的行为。

你正在以数组的方式获取成员,但这里是C ++标准所说的(重点是我的):

[dcl.array / 1] : …一个数组types的对象包含一个连续分配的非空子集的N个子typesT …

但是,对于会员来说,没有这种连续的要求:

[class.mem / 17] : …;实现alignment要求可能会导致两个相邻的成员不能立即被分配

虽然上面的两个引用应该足以暗示为什么索引到一个struct ,而不是C ++标准定义的行为,我们来看一个例子:看expression式(&thing.a)[2] – 关于下标运营商:

[expr.post//expr.sub/1] :一个后缀expression式,后面跟着一个方括号中的expression式,是一个后缀expression式。 其中一个expression式应该是一个“T数组”的types的glvalue或者types“指向T的指针”的prvalue,另一个应该是一个非范型枚举或整型的前值。 结果是“T”型。 types“T”应该是一个完全定义的对象types.61 expression式E1[E2]((E1)+(E2))是相同的(根据定义((E1)+(E2))

挖掘到上面的引用的粗体文本:关于添加一个整型的指针types(注意这里的重点)..

[expr.add / 4] :当一个具有整型的expression式被添加到指针或从指针中减去时,结果就是指针操作数的types。 如果expression式P指向具有n个元素的数组对象x元素x[i] ,则expression式P + JJ + P (其中J具有值j )指向(可能是假设的)元素x[i + j]如果0 ≤ i + j ≤ n ; 否则 ,行为是不确定的。 …

请注意if子句的数组要求; 否则在上面的报价。 expression式(&thing.a)[2]显然不符合if子句; 因此,未定义的行为。


注意:尽pipe我已经在各种编译器上广泛地尝试了代码及其变体,并且在这里没有介绍任何填充,(它起作用 ); 从维护的angular度来看,代码是非常脆弱的。 你仍然应该断言在执行之前执行分配成员连续。 并保持界限:-)。 但其尚未定义的行为….

其他答案提供了一些可行的解决方法(具有定义的行为)。



正如在评论中正确指出的那样,我以前的编辑中的[basic.lval / 8]并不适用。 谢谢@ 2501和@MM

1 :参见@ Barry对这个问题的答案,只有一个法律案例,你可以通过这个parttern访问thing.a成员。

在C中,即使没有填充,这也是未定义的行为。

导致未定义行为的事情是越界访问1 。 当你有一个标量(结构体中的成员a,b,c)并尝试使用它作为数组2来访问下一个假设的元素时,即使碰巧有另一个相同types的对象,也会导致未定义的行为那个地址。

但是,您可以使用结构对象的地址并计算特定成员的偏移量:

 struct data thing = { 0 }; char* p = ( char* )&thing + offsetof( thing , b ); int* b = ( int* )p; *b = 123; assert( thing.b == 123 ); 

这必须为每个成员单独完成,但可以放入类似于数组访问的函数中。


1 (引自:ISO / IEC 9899:201x 6.5.6附加操作符8)
如果结果指向一个超过数组对象的最后一个元素,则不应将其用作所评估的一元运算符的操作数。

2 (引自:ISO / IEC 9899:201x 6.5.6附加操作符7)
对于这些运算符而言,指向不是数组元素的对象的指针的行为与以对象types作为其元素types的长度为1的数组的第一个元素的指针相同。

在C ++中,如果你确实需要它 – create operator []:

 struct data { int a, b, c; int &operator[]( size_t idx ) { switch( idx ) { case 0 : return a; case 1 : return b; case 2 : return c; default: throw std::runtime_error( "bad index" ); } } }; data d; d[0] = 123; // assign 123 to data.a 

它不仅保证工作,而且使用更简单,你不需要编写不可读的expression式(&thing.a)[0]

注意:这个答案是假设你已经有一个结构的字段,你需要通过索引添加访问。 如果速度是一个问题,你可以改变结构,这可能会更有效:

 struct data { int array[3]; int &a = array[0]; int &b = array[1]; int &c = array[2]; }; 

这个解决scheme会改变结构的大小,所以你也可以使用方法:

 struct data { int array[3]; int &a() { return array[0]; } int &b() { return array[1]; } int &c() { return array[2]; } }; 

对于c ++:如果你需要访问一个成员而不知道它的名字,你可以使用一个指向成员variables的指针。

 struct data { int a, b, c; }; typedef int data::* data_int_ptr; data_int_ptr arr[] = {&data::a, &data::b, &data::c}; data thing; thing.*arr[0] = 123; 

在ISO C99 / C11中,基于联合的types双击是合法的,所以你可以使用它来代替索引指向非数组的指针(参见各种其他答案)。

ISO C ++不允许基于联合的types双击。 GNU C ++作为一个扩展 ,我认为一些不支持GNU扩展的其他编译器通常支持联合types双击。 但是这并不能帮助你编写严格的可移植代码。

使用当前版本的gcc和clang,使用switch(idx)编写C ++成员函数来select成员将优化编译时常量索引,但是会为运行时索引产生可怕的分支asm。 switch()没有什么本质的错误, 这只是当前编译器中的错误优化错误。 他们可以有效地编译Slava的switch()函数。


对此的解决scheme/解决方法是以另一种方式做到这一点:给你的类/结构数组成员,并写访问函数将名称附加到特定的元素。

 struct array_data { int arr[3]; int &operator[]( unsigned idx ) { // assert(idx <= 2); //idx = (idx > 2) ? 2 : idx; return arr[idx]; } int &a(){ return arr[0]; } // TODO: const versions int &b(){ return arr[1]; } int &c(){ return arr[2]; } }; 

我们可以在Godbolt编译器资源pipe理器中查看不同用例的asm输出。 这些是完整的x86-64系统V函数,省略了尾随的RET指令,以便更好地显示内联时的内容。 ARM / MIPS /无论是相似的。

 # asm from g++6.2 -O3 int getb(array_data &d) { return db(); } mov eax, DWORD PTR [rdi+4] void setc(array_data &d, int val) { dc() = val; } mov DWORD PTR [rdi+8], esi int getidx(array_data &d, int idx) { return d[idx]; } mov esi, esi # zero-extend to 64-bit mov eax, DWORD PTR [rdi+rsi*4] 

相比之下,@ Slava对C ++使用switch()的答案使得像这样的asm成为一个运行时variables索引。 (代码在以前的Godbolt链接)。

 int cpp(data *d, int idx) { return (*d)[idx]; } # gcc6.2 -O3, using `default: __builtin_unreachable()` to promise the compiler that idx=0..2, # avoiding an extra cmov for idx=min(idx,2), or an extra branch to a throw, or whatever cmp esi, 1 je .L6 cmp esi, 2 je .L7 mov eax, DWORD PTR [rdi] ret .L6: mov eax, DWORD PTR [rdi+4] ret .L7: mov eax, DWORD PTR [rdi+8] ret 

与C(或GNU C ++)基于联合的types双关版本相比,这显然很糟糕:

 c(type_t*, int): movsx rsi, esi # sign-extend this time, since I didn't change idx to unsigned here mov eax, DWORD PTR [rdi+rsi*4] 

这是未定义的行为。

C ++中有很多规则试图给编译器一些理解你在做什么的希望,所以它可以推理并优化它。

有关于别名的规则(通过两种不同的指针types访问数据),数组边界等。

当你有一个variablesx ,事实上它不是一个数组的成员意味着编译器可以假定没有[]基于数组的访问可以修改它。 因此,每次使用时都不需要不断地从内存中重新加载数据。 只有当某人能够从名字上修改它时。

因此(&thing.a)[1]可以被编译器假定为不引用thing.b 。 它可以使用这个事实来重新排列对thing.b ,使你想要做的事情失效,而不会使你实际告诉它做的事情失效。

一个典型的例子是抛弃const。

 const int x = 7; std::cout << x << '\n'; auto ptr = (int*)&x; *ptr = 2; std::cout << *ptr << "!=" << x << '\n'; std::cout << ptr << "==" << &x << '\n'; 

在这里你通常会得到一个编译器,说7然后2!= 7,然后是两个相同的指针; 尽pipeptr指向x 。 编译器认为x是一个常数值,当你询问x的值时,不用去读它。

但是,当你把x的地址,你强迫它的存在。 然后你抛出const,并修改它。 所以x在内存中的实际位置已被修改,编译器可以自由地在读取x时不实际读取它!

编译器可能会弄明白如何甚至避免以下ptr阅读*ptr ,但他们通常不是。 如果优化器比你更聪明,随意去使用ptr = ptr+argc-1或者是一些混乱。

您可以提供一个自定义的operator[]来获取正确的项目。

 int& operator[](std::size_t); int const& operator[](std::size_t) const; 

两者都有用。

在C ++中,这大多是未定义的行为(取决于哪个索引)。

来自[expr.unary.op]:

对于指针运算(5.7)和比较(5.9,5.10)而言,不是以这种方式获取地址的数组元素的对象被认为属于具有一个Ttypes元素的数组。

expression式&thing.a因此被认为是指一个int数组。

来自[expr.sub]:

expression式E1[E2]*((E1)+(E2))相同(根据定义*((E1)+(E2))

并从[expr.add]:

当具有整数types的expression式被添加到指针或从指针中减去时,结果具有指针操作数的types。 如果expression式P指向具有n元素的数组对象x元素x[i] ,则expression式P + JJ + P (其中J具有值j )指向(可能是假设的)元素x[i + j]如果0 <= i + j <= n ; 否则,行为是不确定的。

(&thing.a)[0]是完美的格式,因为&thing.a被认为是一个大小为1的数组,我们正在接受第一个索引。 这是一个允许的指标。

(&thing.a)[2]违反了0 <= i + j <= n的前提条件,因为我们有i == 0j == 2n == 1 。 简单地构造指针&thing.a + 2是未定义的行为。

(&thing.a)[1]是一个有趣的例子。 它实际上并没有违反[expr.add]中的任何内容。 我们被允许采取一个指针超过数组的末尾 – 这将是。 在这里,我们转到[basic.compound]中的一个注释:

指向或超过对象末尾的指针types的值表示对象占用的内存(1.7)中的第一个字节的地址或对象占用的存储结束后的第一个字节, 分别。 [注意:超过对象(5.7)末尾的指针不被视为指向可能位于该地址的对象types的不相关对象。

因此,采取指针&thing.a + 1是定义行为,但取消引用它是未定义的,因为它不指向任何东西。

下面是使用代理类按名称访问成员数组中元素的一种方法。 这是非常C ++,除了语法偏好,除了ref-returning访问函数,没有任何好处。 这会重载->操作符来访问元素作为成员,所以为了可以接受,需要既不喜欢访问者的语法( da() = 5; ),也可以容忍使用非指针对象。 我认为这也可能会让不熟悉代码的读者感到困惑,所以这可能比您想投入生产的东西更为巧妙。

这个代码中的Data结构还包括用于下标操作符的重载,访问其数组成员的索引元素,以及用于迭代的beginend函数。 而且,所有这些都被重载非const和const版本,我觉得需要包括完整性。

Data->被用来按名称访问元素(如: my_data->b = 5; )时,返回一个Proxy对象。 然后,因为这个Proxy右值不是一个指针,它自己的->操作符是自动链调用的,它返回一个指向自身的指针。 这样, Proxy对象就被实例化了,并在初始expression式的评估过程中保持有效。

Proxy对象的构造根据在构造器中传递的指针来填充其3个引用成员abc ,假定指向包含至less3个其types被赋予为模板参数T值的缓冲区。 因此,不是使用Data类成员的命名引用,而是通过在访问点填充引用来节省内存(但不幸的是,使用->而不是.运算符)。

为了testing编译器的优化器如何消除使用Proxy引入的所有间接性,下面的代码包含2个版本的main()#if 1版本使用->[]运算符, #if 0版本执行相同的程序集,但只能直接访问Data::ar

Nci()函数生成用于初始化数组元素的运行时整数值,这样可以防止优化器将常量值直接插入到每个std::cout << call中。

对于gcc 6.2,使用-O3, main()两个版本都会生成相同的程序集(在比较第一个main()之前的#if 1#if 0之间切换): https : //godbolt.org/g/QqRWZb

 #include <iostream> #include <ctime> template <typename T> class Proxy { public: T &a, &b, &c; Proxy(T* par) : a(par[0]), b(par[1]), c(par[2]) {} Proxy* operator -> () { return this; } }; struct Data { int ar[3]; template <typename I> int& operator [] (I idx) { return ar[idx]; } template <typename I> const int& operator [] (I idx) const { return ar[idx]; } Proxy<int> operator -> () { return Proxy<int>(ar); } Proxy<const int> operator -> () const { return Proxy<const int>(ar); } int* begin() { return ar; } const int* begin() const { return ar; } int* end() { return ar + sizeof(ar)/sizeof(int); } const int* end() const { return ar + sizeof(ar)/sizeof(int); } }; // Nci returns an unpredictible int inline int Nci() { static auto t = std::time(nullptr) / 100 * 100; return static_cast<int>(t++ % 1000); } #if 1 int main() { Data d = {Nci(), Nci(), Nci()}; for(auto v : d) { std::cout << v << ' '; } std::cout << "\n"; std::cout << d->b << "\n"; d->b = -5; std::cout << d[1] << "\n"; std::cout << "\n"; const Data cd = {Nci(), Nci(), Nci()}; for(auto v : cd) { std::cout << v << ' '; } std::cout << "\n"; std::cout << cd->c << "\n"; //cd->c = -5; // error: assignment of read-only location std::cout << cd[2] << "\n"; } #else int main() { Data d = {Nci(), Nci(), Nci()}; for(auto v : d.ar) { std::cout << v << ' '; } std::cout << "\n"; std::cout << d.ar[1] << "\n"; d->b = -5; std::cout << d.ar[1] << "\n"; std::cout << "\n"; const Data cd = {Nci(), Nci(), Nci()}; for(auto v : cd.ar) { std::cout << v << ' '; } std::cout << "\n"; std::cout << cd.ar[2] << "\n"; //cd.ar[2] = -5; std::cout << cd.ar[2] << "\n"; } #endif 

如果读取值足够了,效率不是问题,或者如果你相信你的编译器能够很好地优化它,或者struct只有3个字节,你可以安全的做到这一点:

 char index_data(const struct data *d, size_t index) { assert(sizeof(*d) == offsetoff(*d, c)+1); assert(index < sizeof(*d)); char buf[sizeof(*d)]; memcpy(buf, d, sizeof(*d)); return buf[index]; } 

对于仅C ++版本,您可能希望使用static_assert来validationstruct data是否具有标准布局,而不是在无效索引上抛出exception。

这是非法的,但有一个解决方法:

 struct data { union { struct { int a; int b; int c; }; int v[3]; }; }; 

现在你可以索引v: