索引结构是否合法?

时间:2016-11-14 13:46:37

标签: c++ c struct

无论如何'糟糕'代码是,并假设对齐等在编译器/平台上不是问题,这是未定义或损坏的行为吗?

如果我有这样的结构: -

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

struct data thing;

合法abc作为(&thing.a)[0](&thing.a)[1]和{{1} }?

在每种情况下,在我尝试过的每个编译器和平台上,我尝试过的每个设置都可以使用#39;。我只是担心编译器可能没有意识到 b thing [1] 是相同的东西并存储到' b'可能会被放入寄存器中,而事物[1]从内存中读取错误的值(例如)。在每种情况下,我都尝试过它做了正确的事情。 (我当然意识到这并不能证明太多)

这不是我的代码;它必须与我合作的代码,我对这是糟糕代码还是破解代码感兴趣,因为不同影响我更改它的优先级很多:)

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

10 个答案:

答案 0 :(得分:69)

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

你是以阵列方式接纳成员,但这是C ++标准所说的(强调我的):

  

[dcl.array/1] ...数组类型的对象包含连续分配的非空N组   类型为T的子对象

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

  

[class.mem/17] ...;实施对齐要求可能导致两个相邻   成员不能在彼此之后立即分配 ......

虽然以上两个引号应足以暗示为什么索引到struct并不是C ++标准定义的行为,让我们选择一个示例:查看表达式(&thing.a)[2] - 关于下标运算符:

  

<强> [expr.post//expr.sub/1]   后缀表达式后跟方括号中的表达式是a   后缀表达。其中一个表达式应该是类型的glvalue   “T的数组”或“T指针”类型的prvalue和另一个   是无范围枚举或整数类型的prvalue。结果是   类型“T”。类型“T”应是完全定义的对象类型.66   表达式E1[E2]((E1)+(E2))

相同(根据定义)

深入研究上述引文的粗体文字:关于向指针类型添加整数类型(请注意此处的重点)..

  

[expr.add/4]当具有整数类型的表达式添加到或减去   指针,结果具有指针操作数的类型。 如果   表达式P指向 数组 对象x[i] 的元素x   使用n个元素,表达式P + JJ + PJ所在的位置)   值j)指向(可能是假设的)元素x[i + j]   如果0 ≤ i + j ≤ n; 否则,行为未定义。 ...

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

旁注:虽然我已经在各种编译器上广泛地试验了代码及其变体,但是它们没有在这里引入任何填充,(它工作);从维护的角度来看,代码非常脆弱。在执行此操作之前,您仍应断言实现已连续分配成员。并保持入境:-)。但它仍然是未定义的行为......

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

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

1 :请参阅@ Barry对此问题的回答,其中只有一个法律案例,您可以通过此parttern访问结构的thing.a成员。

答案 1 :(得分:49)

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

导致未定义行为的事情是越界访问 1 。当你有一个标量(结构中的成员a,b,c)并尝试将其用作数组 2 来访问下一个假设元素时,就会导致未定义的行为,即使恰好存在该地址的另一个相同类型的对象。

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

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)
出于这些运算符的目的,指向不是元素的对象的指针 数组的行为与指向长度为1的数组的第一个元素的指针相同 对象的类型作为其元素类型。

答案 2 :(得分:42)

在C ++中如果你真的需要它 - 创建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

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

注意:假设您已经拥有包含字段的结构,并且需要通过索引添加访问权限,则会给出此答案。如果速度是一个问题,你可以改变结构,这可能更有效:

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

此解决方案将改变结构的大小,因此您也可以使用方法:

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

答案 3 :(得分:14)

对于c ++:如果您需要在不知道其名称的情况下访问成员,则可以使用指向成员变量的指针。

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;

答案 4 :(得分:10)

在ISO C99 / C11中,基于联合的类型惩罚是合法的,因此您可以使用它而不是将指针索引到非数组(请参阅其他各种答案)。

ISO C ++不允许基于联合的类型惩罚。 GNU C++ does, as an extension,我认为其他一些不支持GNU扩展的编译器确实支持联合类型惩罚。但这并不能帮助您编写严格的可移植代码。

使用当前版本的gcc和clang,使用switch(idx)编写C ++成员函数来选择成员将优化编译时常量索引,但会为运行时索引生成可怕的分支asm。 switch()对此没有任何内在错误;这只是当前编译器中的遗漏优化错误。他们可以编译Slava&#39; switch()函数有效。

解决方法/解决方法是以另一种方式执行:为类/结构提供一个数组成员,并编写访问器函数以将名称附加到特定元素。

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 compiler explorer上查看不同用例的asm输出。这些是完整的x86-64 System V功能,省略了尾部RET指令,以更好地显示内联时获得的内容。 ARM / MIPS /无论什么都是类似的。

# asm from g++6.2 -O3
int getb(array_data &d) { return d.b(); }
    mov     eax, DWORD PTR [rdi+4]

void setc(array_data &d, int val) { d.c() = 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使用switch()对C ++的回答使得asm像运行变量索引一样。 (以前的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 ++)基于联合的类型双关语版本相比,这显然很糟糕:

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]

答案 5 :(得分:8)

这是未定义的行为。

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

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

当你有一个变量x时,它不是一个数组成员的事实意味着编译器可以假设没有基于[]的数组访问可以修改它。因此,每次使用它时,都不必经常从内存中重新加载数据;只有当某人可以从名称​​修改

因此编译器可以假设(&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,然后是两个相同的指针;尽管ptr指向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;

两者都有用。

答案 6 :(得分:8)

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

来自[expr.unary.op]:

  

出于指针的目的   arithmetic(5.7)和comparison(5.9,5.10),一个不是数据元素的对象,其地址被接受   这种方式被认为属于具有T类型的一个元素的数组。

因此,表达式&thing.a被认为是指一个int的数组。

来自[expr.sub]:

  

表达式E1[E2]*((E1)+(E2))

相同(根据定义)

来自[expr.add]:

  

当向指针添加或从指针中减去具有整数类型的表达式时,结果具有指针操作数的类型。如果表达式P指向具有x[i]元素的数组对象x的元素n,则表达式P + JJ + P(其中{{ 1}}具有值J)指向(可能是假设的)元素j if x[i + j];否则,行为未定义。

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

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

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

  

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

因此,取指针(&thing.a)[1]是定义的行为,但解除引用它是未定义的,因为它没有指向任何东西。

答案 7 :(得分:6)

这是一种使用代理类按名称访问成员数组中元素的方法。这是非常C ++,除了语法偏好之外,与ref-returns访问器函数没有任何好处。这会使d.a() = 5;运算符重载以作为成员访问元素,因此为了被接受,人们需要既不喜欢访问器的语法(->),又要容忍Data使用非-pointer对象。我希望这也可能会使不熟悉代码的读者感到困惑,所以这可能比你想要投入生产的东西更加巧妙。

此代码中的ar结构还包括下标运算符的重载,访问其begin数组成员内的索引元素,以及endData函数,用于迭代。此外,所有这些都超载了非const和const版本,我认为需要将其包含在内以便完整。

当使用->&#39; s my_data->b = 5;按名称访问元素时(如下所示:Proxy),将返回Proxy个对象。然后,因为此-> rvalue不是指针,所以它自己的Proxy运算符被自动链调用,它返回一个指向自身的指针。这样,Proxy对象被实例化,并在评估初始表达式时保持有效。

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

为了测试编译器的优化器如何消除使用main()引入的所有间接,下面的代码包括#if 1的2个版本。 ->版本使用[]#if 0运算符,Data::ar版本执行等效的过程集,但只能直接访问Nci()

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

对于gcc 6.2,使用-O3,两个版本的#if 1生成相同的程序集(在第一个#if 0之前在main()#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 之间切换:{ {3}}

{{1}}

答案 8 :(得分:2)

如果读取值足够,效率不是问题,或者您相信编译器能够很好地优化,或者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来验证struct data是否具有标准布局,并且可能会在无效索引上抛出异常。

答案 9 :(得分:1)

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

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

现在你可以索引v: