索引到结构是否合法?


104

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

如果我有这样的结构:-

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

struct data thing;

它是合法的访问abc作为(&thing.a)[0](&thing.a)[1](&thing.a)[2]

在每种情况下,我都会在每个编译器和平台上对其进行尝试,并在每个设置上对其进行“尝试”。我只是担心编译器可能不会意识到bthing [1]是同一件事,并且存储到“ b”的内容可能会放在寄存器中,而thing [1]从内存中读取错误的值(例如)。在每种情况下,我都尝试过它做对了。(我当然知道并不能证明太多)

这不是我的代码;这是我必须使用的代码,我对这是不好的代码还是坏的代码很感兴趣,因为不同之处会影响我对其进行大量更改的优先级:)

标记为C和C ++。我主要是对C ++感兴趣,但对C也很感兴趣,只是出于兴趣。


51
不,这不是“合法的”。这是未定义的行为。
Sam Varshavchik '16

10
在这种非常简单的情况下,它可以为您工作,因为编译器不会在成员之间添加任何填充。尝试使用大小不同的类型的结构,它将崩溃。
一些程序员伙计们,2016年

7
挖掘过去的UB曾经被昵称为nasal守护程序
Adrian Colomitchi '16

21
很好,在这里我偶然发现是因为我遵循C标记,阅读了问题,然后写了一个仅适用于C的答案,因为我没有看到C ++标记。C和C ++在这里有很大的不同!C允许使用联合类型修剪,而C ++不允许。
隆丁

7
如果需要以数组形式访问元素,请将它们定义为数组。如果需要使用不同的名称,请使用这些名称。尝试吃蛋糕并最终会导致消化不良-可能是在最不方便想象的时间。(我认为索引0在C中是合法的;索引1或2不是合法的。在某些情况下,单个元素被视为大小为1的数组。)
Jonathan Leffler

Answers:


73

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

您正在以数组方式获取成员,但这是C ++标准所说的(强调我的意思):

[dcl.array / 1] ...一个数组类型的对象包含一个连续分配的非空的N个子对象集,它们的类型为T。

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

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

尽管以上两个引号足以表明为什么按struct原样索引到a 并不是C ++标准定义的行为,但让我们举一个例子:看一下表达式(&thing.a)[2]-关于下标运算符:

[expr.post//expr.sub/1] 后缀表达式后跟方括号中的表达式是后缀表达式。表达式之一应为类型“ T的数组”的glvalue或类型为“指向T的指针”的prvalue,另一个表达式应为无作用域枚举或整数类型的prvalue。结果为“ T”类型。类型“ T”应为完全定义的对象类型。66 表达式E1[E2](根据定义)与((E1)+(E2))

深入研究以上引用的粗体文本:关于将整数类型添加到指针类型(请注意此处的重点)。

[expr.add / 4]将具有整数类型的表达式添加到指针或从指针中减去时,结果将具有指针操作数的类型。如果表达式P指向元件x[i]阵列对象x 与n个元素,表述P + JJ + P(其中,J具有值j)点到(可能-假设的)元件x[i + j] ,如果0 ≤ i + j ≤ n; 否则,行为是不确定的。...

注意if子句的数组要求;否则以上引用中的其他内容。该表达式显然不适合if子句;因此,未定义的行为。(&thing.a)[2]


附带说明一下:尽管我已经在各种编译器上对代码及其变体进行了广泛的实验,但它们在此处未引入任何填充((有效));从维护的角度来看,该代码非常脆弱。您仍应断言在执行此操作之前,实现已连续分配了成员。并保持边界:-)。但是它仍然是未定义的行为。

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



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

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


1
@jcoder在class.mem中定义。有关实际文本,请参见最后一段。
NathanOliver

4
严格分音与此处无关。int类型包含在聚合类型中,并且此类型可以别名int。- an aggregate or union type that includes one of the aforementioned types among its elements or non-static data members (including, recursively, an element or non-static data member of a subaggregate or contained union),
2501年

1
@下降投票者,想发表评论吗?-并改进或指出此答案在哪里错误?
WhiZTiM

4
严格的混叠与此无关。填充不是对象存储值的一部分。同样,该答案也无法解决最常见的情况:没有填充时会发生什么。建议实际删除此答案。
MM

1
做完了!我删除了有关严格混叠的段落。
WhiZTiM '16

48

否。在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的数组,对象的类型为其元素类型。


3
请注意,仅当类为标准布局类型时,此方法才有效。如果不是,那还是UB。
NathanOliver

@NathanOliver我应该提到我的答案仅适用于C。这是这种双重标签语言问题的问题之一。
2501年

谢谢,这就是为什么我分别询问C ++和C的原因,因为知道这些差异很有趣
jcoder

@NathanOliver如果标准布局,则保证第一个成员的地址与C ++类的地址一致。但是,既不能保证访问定义明确,也不能保证对其他类的访问未定义。
Potatoswatter

您会说char* p = ( char* )&thing.a + offsetof( thing , b );导致不确定的行为吗?
MM

43

在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]; }
};

1
我很乐意看到它的反汇编,而不是使用punning类型的C程序的反汇编。但是,但是... C ++和C一样快...对吗?对?
伦丁

6
@Lundin如果您关心这种构造的速度,那么数据首先应该组织成数组,而不是单独的字段。
斯拉瓦

2
@Lundin都表示不可读和未定义行为?不用了,谢谢。
斯拉瓦

1
@Lundin运算符重载是一种编译时语法功能,与正常函数相比不会产生任何开销。请查看godbolt.org/g/vqhREz,以查看编译器在编译C ++和C代码时实际执行的操作。他们的工作和人们期望他们做的事真是令人惊讶。我个人更喜欢C ++超过一百万次的类型安全性和可表达性。而且它一直在工作,而无需依赖于关于填充的假设。
詹斯

2
这些引用将使事物的大小至少增加一倍。做吧thing.a()
TC

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;

1
这是使用语言工具的结果,因此定义得很明确,而且我认为是有效的。最佳答案。
彼得-恢复莫妮卡

2
假设有效?我想相反。查看生成的代码。
JDługosz

1
@JDługosz,您说得对。服用偷看在生成的汇编,似乎GCC 6.2创建代码等同于使用offsetoff在C.
Unslander莫妮卡-说书

3
您还可以通过使arr constexpr来改善性能。这将在数​​据部分中创建一个固定的查找表,而不是动态创建它。
蒂姆(Tim)

10

在ISO C99 / C11中,基于联合的类型绑定是合法的,因此您可以使用它代替索引非数组的指针(请参阅其他答案)。

ISO C ++不允许基于联合的类型处理。 作为扩展GNU C ++确实可以,并且我认为一般不支持GNU扩展的其他一些编译器也支持联合类型对齐。但这并不能帮助您编写严格的可移植代码。

在当前版本的gcc和clang中,使用a 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使用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]

@MM:好点。它更像是对各种评论的答案,也是Slava答案的替代方法。我改写了开头的单词,因此至少起初是对原始问题的回答。感谢您指出了这一点。
彼得·科德斯

虽然基于工会的类型修剪在gcc和clang中似乎可以同时[]在工会成员上直接使用运算符,但Standard定义array[index]为等价的*((array)+(index)),并且gcc和clang都无法可靠地识别出对*((someUnion.array)+(index))的访问someUnion。我能看到的唯一解释是,该标准someUnion.array[index]*((someUnion.array)+(index))未对其进行定义,而仅仅是一个流行的扩展,并且gcc / clang选择了不支持第二个,而似乎至少现在不支持第一个。
超级猫

9

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

来自[expr.unary.op]:

出于指针算术(5.7)和比较(5.9,5.10)的目的,不是以这种方式获取地址的数组元素的对象被视为属于一个类型为type的数组T

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

来自[expr.sub]:

该表达式E1[E2](根据定义)与*((E1)+(E2))

从[expr.add]:

当将具有整数类型的表达式添加到指针或从指针中减去时,结果将具有指针操作数的类型。如果表达式P指向具有元素x[i]的数组对象xn元素,则表达式if P + JJ + P(其中J的值是j)指向x[i + j]if (可能是假设的)元素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]中的注释:

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

因此,采用指针&thing.a + 1是已定义的行为,但取消引用它是未定义的,因为它没有指向任何内容。


评估(&thing.a)+ 1 几乎是合法的,因为超出数组末尾的指针是合法的;读取或写入存储在其中的数据是未定义的行为,与&thing.b进行比较,其中<,>,<=,> =是未定义的行为。(&thing.a)+ 2是绝对非法的。
gnasher729 '16

@ gnasher729是的,值得进一步澄清答案。
巴里

(&thing.a + 1)是我未能涵盖的有趣案例。+1!...很好奇,您是ISO C ++委员会的成员吗?
WhiZTiM '16

这也是非常重要的情况,因为否则,每个使用指针作为半开间隔的循环都将是UB。
詹斯

关于最后的标准引用。这里必须比C更好地指定C ++。
2501年

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指向xx当您要求的值时,编译器会认为这是一个常量值,因此不必理会它x

但是,当您使用的地址时x,会强制其存在。然后,您丢弃const,并对其进行修改。因此,内存中的实际位置x已被修改,编译器可以自由地在读取时不实际读取它x

编译器可能足够聪明,可以弄清楚如何甚至避免遵循ptr阅读规则*ptr,但往往不是。ptr = ptr+argc-1如果优化器比您更聪明,请随时使用或使用此类混淆。

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

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

两者都有用。


“它不是数组的成员这一事实意味着编译器可以假定没有任何基于[]的数组访问可以修改它。” -不正确,例如(&thing.a)[0]可以对其进行修改
MM

我看不到const示例与问题有什么关系。失败仅是因为存在一个特定的规则,即不得修改const对象,而不是任何其他原因。
MM

1
@MM,它不是索引到一个结构的例子,但它是一个非常如何利用不确定的行为来参考的东西由它的良好例证明显在内存中的位置,可能会导致不同的输出超过预期,因为编译器可以做别的事情与UB比您想要的要多。
通配符

@MM对不起,除了通过对象本身的指针进行的琐碎访问外,没有其他数组访问。第二个只是一个例子,它很容易看到未定义行为的副作用;编译器会优化读取结果,x因为它知道您无法以定义的方式进行更改。如果编译器可以证明没有定义的访问权限会更改它,则当您b通过更改时,可能会发生类似的优化。这种变化可能是由于编译器,周围代码或其他任何东西的明显无害更改而发生的。因此,即使对其进行测试也是不够的。(&blah.a)[1]b
Yakk-Adam Nevraumont

6

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

Data此代码中的结构还包括下标运算符的重载,以访问其ar数组成员内的索引元素以及beginend函数以进行迭代。而且,所有这些都非标准和const版本都被重载,为了完整性,我认为必须将其包含在内。

当使用Datas ->通过名称访问元素时(例如:)my_data->b = 5;,将Proxy返回一个对象。然后,由于此Proxy右值不是指针,因此将->自动调用其自身的运算符,该运算符将返回指向自身的指针。这样,Proxy对象被实例化并在初始表达式的求值期间保持有效。

一个的敷设渠道Proxy对象填充其3个基准构件abc根据在构造传递一个指针,其被假定为指向包含至少3个值,其类型被给出为模板参数的缓冲器T。因此,与其使用作为Data类成员的命名引用,->还不如通过在访问点填充引用来节省内存(但不幸的是,使用而不是.运算符)。

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

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

对于gcc 6.2,使用-O3,这两个版本的都会main()生成相同的程序集(在第一个进行比较之前#if 1#if 0之后进行切换main()):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

好漂亮 主要是因为您证明了这一点可以优化。顺便说一句,您可以通过编写一个非常简单的函数(而不是main()带有计时函数的整个函数)来轻松完成此操作!例如,int getb(Data *d) { return (*d)->b; }编译只mov eax, DWORD PTR [rdi+4]/ retgodbolt.org/g/89d3Np)。(是的,Data &d这样做会使语法更容易,但是我使用了指针而不是ref来强调->这种方式的重载的怪异性。)
Peter Cordes

无论如何,这很酷。诸如此类的其他想法int tmp[] = { a, b, c}; return tmp[idx];并没有得到优化,因此这一点很巧妙。
彼得·科德斯

operator.在C ++ 17中错过的另一个原因。
詹斯

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具有标准布局,并可能在无效索引上引发异常。


1

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

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

现在您可以索引v:


6
许多c ++项目认为到处都是垂头丧气就好了。我们仍然不应该宣扬不良做法。
StoryTeller-Unslander Monica's

2
联合解决了两种语言中严格的别名问题。但是,通过联合类型修剪只适用于C语言,不适用于C ++。
伦丁

1
仍然,如果这对所有c ++编译器的100%都有效,我不会感到惊讶。曾经。
Sven Nilsson

1
您可以在启用了最积极的优化程序设置的gcc中尝试使用。
伦丁

1
@Lundin:联合类型修剪在GNU C ++中是合法的,是对ISO C ++的扩展。手册中似乎没有很清楚地说明这一点,但是我对此很确定。不过,此答案需要说明在哪里有效,在哪里无效。
彼得·科德斯
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.