索引结构是否合法?

Is it legal to index into a struct?

本文关键字:是否 结构 索引      更新时间:2023-10-16

不管代码有多"糟糕",假设对齐等在编译器/平台上不是问题,这是未定义的或破碎的行为吗?

如果我有一个这样的结构体:-

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

是否合法访问a, bc作为(&thing.a)[0], (&thing.a)[1](&thing.a)[2] ?

在每种情况下,在我尝试的每种编译器和平台上,在我尝试的每种设置下,它都是"工作的"。我只是担心编译器可能没有意识到bthing[1]是同一件事,存储到'b'可能被放在寄存器中,thing[1]从内存中读取错误的值(例如)。在我尝试的每一个案例中,它都做了正确的事情。(我当然知道这并不能证明什么)

这不是我的代码;这是我必须使用的代码,我感兴趣的是这是坏的代码还是破碎的代码,因为不同的影响我的优先级,以改变它很多:)

标记C和c++。我最感兴趣的是c++,但如果C是不同的,我也会感兴趣。

<>共舞,1是非法的吃饭。这是c++中的未定义行为。

你以数组的方式获取成员,但c++标准是这样说的(强调我的):

(dcl

。数组/1]:…数组类型的对象包含连续分配的非空集N

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

[类。实现对齐要求可能会导致两个相邻的

虽然上面的两个引号应该足以暗示为什么索引struct不是c++标准所定义的行为,但让我们举一个例子:看看表达式(&thing.a)[2] -关于下标操作符:

[expr.post//expr.sub/1]: 后缀表达式后跟方括号中的表达式就是A后缀表达式。其中一个表达式必须是类型为"T的数组"或"指向T的指针"类型的右值,另一个应是非作用域枚举或整型的右值。结果是T型的。类型"T"应为完全定义的对象类型表达式E1[E2](根据定义)与((E1)+(E2))

相同。

深入研究上面引用的粗体文本:关于向指针类型添加整型(注意这里的重点)…

[expr。add/4]:当对一个整型表达式进行加或减时指针,则结果具有指针操作数的类型。如果表达式P指向数组对象x 中的元素x[i]对于n个元素,表达式P + JJ + P(其中J有值j)指向(可能是假设的)元素x[i + j]if 0 ≤ i + j ≤ n;,否则,行为未定义. ...

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

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

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



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

1:查看@Barry对这个问题的回答,您可以通过该模式访问结构体的thing.a成员。

No。在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的数组的第一个元素的指针相同对象的类型作为其元素类型。

在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

它不仅保证工作,而且使用更简单,您不需要编写不可读的表达式(&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]; }
};

对于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;

在ISO C99/C11中,基于联合的类型双关语是合法的,因此您可以使用它来代替索引指向非数组的指针(参见各种其他答案)。

ISO c++不允许基于联合的类型双关语。GNU c++作为一个扩展是这样做的,而且我认为其他一些不支持GNU扩展的编译器通常也支持联合类型双关。但这并不能帮助你写出严格可移植的代码。

在当前版本的gcc和clang中,使用switch(idx)来选择成员的c++成员函数会优化编译时常量索引,但会为运行时索引产生可怕的分支asm。switch()本身没有什么问题;这只是当前编译器中遗漏的优化错误。他们可以有效地编译Slava的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编译器资源管理器上查看不同用例的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在c++中使用switch()的答案使得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]

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

从[expr.unary.op]:

用于指针算术(5.7)和比较(5.9,5.10),不是数组元素的对象,其地址被输入这种方式被认为属于一个元素类型为T的数组。

因此,表达式&thing.a被认为是指向一个int的数组。从[expr.sub]:

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

And from [expr.add]:

当对指针进行整型表达式的加减运算时,其结果具有指针操作数的类型。如果表达式P指向包含n元素的数组对象x的元素x[i],则表达式P + JJ + P(其中J的值为j)如果0 <= i + j <= n则指向(可能是假设的)元素x[i + j];否则,行为是未定义的。

(&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]中的一个注释:

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

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

这是未定义的行为。

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;

这是一个使用代理类按名称访问成员数组中的元素的方法。它非常像c++,除了语法偏好之外,与返回返回的访问器函数相比没有任何好处。这将重载->操作符以将元素作为成员访问,因此要接受,需要既不喜欢访问器的语法(d.a() = 5;),又允许将->与非指针对象一起使用。我想这可能会让不熟悉代码的读者感到困惑,所以这可能更像是一个巧妙的技巧,而不是您想要投入生产的东西。

这段代码中的Data结构体还包括下标操作符的重载,用于访问ar数组成员中的索引元素,以及beginend函数,用于迭代。此外,所有这些都重载了非const和const版本,我认为有必要将其包括在内,以确保完整性。

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

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

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

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

对于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来验证struct data是否具有标准布局,并且可能会对无效索引抛出异常。

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

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

现在你可以索引v: