索引到一个结构是合法的吗?
不pipe代码是多么糟糕,并且假设alignment方式在编译器/平台上不是问题,这是不确定的还是破坏的行为?
如果我有这样的结构:
struct data { int a, b, c; }; struct data thing;
(&thing.a)[0]
, (&thing.a)[1]
和(&thing.a)[2]
是否合法访问a
, b
和c
?
在任何情况下,在每一个编译器和平台上,我都试过了,每一个设置我都试过了。 我只是担心编译器可能没有意识到b和thing [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 + J
和J + 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)而言,不是以这种方式获取地址的数组元素的对象被认为属于具有一个
T
types元素的数组。
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 + J
和J + P
(其中J
具有值j
)指向(可能是假设的)元素x[i + j]
如果0 <= i + j <= n
; 否则,行为是不确定的。
(&thing.a)[0]
是完美的格式,因为&thing.a
被认为是一个大小为1的数组,我们正在接受第一个索引。 这是一个允许的指标。
(&thing.a)[2]
违反了0 <= i + j <= n
的前提条件,因为我们有i == 0
, j == 2
, n == 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
结构还包括用于下标操作符的重载,访问其数组成员的索引元素,以及用于迭代的begin
和end
函数。 而且,所有这些都被重载非const和const版本,我觉得需要包括完整性。
当Data
的->
被用来按名称访问元素(如: my_data->b = 5;
)时,返回一个Proxy
对象。 然后,因为这个Proxy
右值不是一个指针,它自己的->
操作符是自动链调用的,它返回一个指向自身的指针。 这样, Proxy
对象就被实例化了,并在初始expression式的评估过程中保持有效。
Proxy
对象的构造根据在构造器中传递的指针来填充其3个引用成员a
, b
和c
,假定指向包含至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: