IT story

구조체로 인덱싱하는 것이 합법적입니까?

hot-time 2020. 8. 14. 18:46
반응형

구조체로 인덱싱하는 것이 합법적입니까?


코드가 얼마나 '나쁜'것과 상관없이, 정렬 등이 컴파일러 / 플랫폼에서 문제가되지 않는다고 가정하면이 동작이 정의되지 않았거나 깨졌습니까?

다음과 같은 구조체가 있으면 :-

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 ++이 다르면 C에도 관심이 있습니다.


불법입니다 1 . 이것은 C ++에서 정의되지 않은 동작입니다.

멤버를 배열 방식으로 취하고 있지만 다음은 C ++ 표준이 말하는 것입니다 (강조 표시).

[dcl.array / 1] : ... 배열 유형의 객체에는T 유형의 N 개의 하위 객체가 연속적으로 할당 된 비어 있지 않은 집합이 포함됩니다.

그러나 회원에게는 다음과 같은 연속적인 요구 사항 이 없습니다 .

[class.mem / 17] : ...; 구현 정렬 요구 사항으로 인해 인접한 두 멤버가 서로 바로 할당되지 않을 수 있습니다 ...

위의 두 따옴표는 왜 a 로의 인덱싱 struct이 C ++ 표준에 의해 정의 된 동작이 아닌지 힌트하기에 충분해야하지만, 한 가지 예를 선택해 보겠습니다. 표현식을보세요 (&thing.a)[2]-아래 첨자 연산자 관련 :

[expr.post//expr.sub/1] : 접미사 식 뒤에 대괄호로 묶인식이 접미사 식입니다. 식 중 하나는 "T의 배열"유형의 glvalue이거나 "T에 대한 포인터"유형의 prvalue이고 다른 하나는 범위가 지정되지 않은 열거 또는 정수 유형의 prvalue입니다. 결과는 "T"유형입니다. "T"유형은 완전히 정의 된 객체 유형이어야합니다 .66 표현식 E1[E2]은 정의에 따라((E1)+(E2))

포인터 유형에 정수 유형을 추가하는 것과 관련하여 위 인용문의 굵은 텍스트를 파헤칩니다 (여기에서 강조 표시) ..

[expr.add / 4] : 정수형을 가진 표현식을 포인터에 더하거나 빼면 결과는 포인터 피연산자의 유형을 갖습니다. 표현식이 n 개의 요소 있는 배열 객체의 요소가리키는 경우 , 표현식(값이있는)은 (가설적인) 요소를 가리 킵니다.if; 그렇지 않으면 동작이 정의되지 않습니다. ...Px[i]xP + JJ + PJjx[i + j]0 ≤ i + j ≤ n

if 절의 배열 요구 사항에 유의하십시오 . 그렇지 않으면 그렇지 않으면 위의 인용이다. 표현식은 분명히 if 절에 적합하지 않습니다 . 따라서 정의되지 않은 동작입니다.(&thing.a)[2]


보조 노트에 : 비록 나는 광범위하게 코드와 다양한 컴파일러에 미치는 변화를 실험하고 그들은 (이 여기에 패딩을 소개하지 않는 작동합니다 ) 유지 관리 관점에서 볼 때 코드는 매우 취약합니다. 이 작업을 수행하기 전에 구현이 멤버를 연속적으로 할당했다고 주장해야합니다. 그리고 인바운드 유지 :-). 그러나 여전히 정의되지 않은 동작 ....

다른 답변에서 일부 실행 가능한 해결 방법 (정의 된 동작 포함)이 제공되었습니다.



주석에서 올바르게 지적했듯이 , 이전 편집에 있던 [basic.lval / 8] 은 적용되지 않습니다. @ 2501 및 @MM 감사합니다.

1 : thing.a이 parttern을 통해 구조체 멤버에 액세스 할 수있는 유일한 법적 사례에 대해서는이 질문에 대한 @Barry의 답변을 참조하십시오 .


아니요. C에서는 패딩이 없어도 정의되지 않은 동작입니다.

정의되지 않은 동작을 유발하는 것은 범위를 벗어난 액세스 1 입니다. 스칼라 (구조체의 멤버 a, b, c)가 있고이를 배열 2 로 사용하여 다음 가상 요소에 액세스 하려고 하면 같은 유형의 다른 객체가있는 경우에도 정의되지 않은 동작이 발생합니다. 그 주소.

그러나 구조체 객체의 주소를 사용하여 오프셋을 특정 멤버로 계산할 수 있습니다.

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 ++에서 정말로 필요한 경우-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에서 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 ++) 공용체 기반 유형 punning 버전에 비해 분명히 끔찍합니다.

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))

그리고 [expr.add]에서 :

정수 유형이있는 표현식을 포인터에 더하거나 빼면 결과는 포인터 피연산자의 유형을 갖습니다. 발현 경우 P소자에 점 x[i]배열 객체 xn요소, 표현 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]의 메모를 살펴 보겠습니다.

객체의 끝을 가리키는 포인터이거나 객체의 끝을 지나는 포인터 유형의 값은 객체가 차지하는 메모리 (1.7)의 첫 번째 바이트 주소 (1.7) 또는 객체가 차지하는 스토리지의 끝 이후 메모리의 첫 번째 바이트를 나타냅니다. , 각각. [참고 : 개체의 끝 (5.7)을 지나는 포인터는 해당 주소에있을 수있는 개체 유형의 관련없는 개체를 가리키는 것으로 간주되지 않습니다.

따라서 포인터를 사용하는 &thing.a + 1것은 정의 된 동작이지만 참조를 해제하는 것은 아무 것도 가리 키지 않기 때문에 정의되지 않습니다.


이것은 정의되지 않은 동작입니다.

C ++에는 여러분이하고있는 일을 이해하고 최적화 할 수 있도록 컴파일러에게 희망을 주려는 많은 규칙이 있습니다.

앨리어싱 (두 가지 다른 포인터 유형을 통해 데이터에 액세스), 배열 경계 등에 대한 규칙이 있습니다.

변수가있는 x경우 배열의 구성원이 아니라는 사실은 컴파일러가 []기반 배열 액세스가이를 수정할 수 없다고 가정 할 수 있음을 의미 합니다. 따라서 사용할 때마다 메모리에서 데이터를 지속적으로 다시로드 할 필요가 없습니다. 누군가가 그것의 이름에서 그것을 수정할 수있는 경우에만 .

따라서 (&thing.a)[1]컴파일러는를 참조하지 않는다고 가정 할 수 있습니다 thing.b. 이 사실을 사용하여에 대한 읽기 및 쓰기 순서를 다시 thing.b지정하여 실제로 수행하도록 지시 한 내용을 무효화하지 않고 원하는 작업을 무효화 할 수 있습니다.

A classic example of this is casting away 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';

here you typically get a compiler saying 7 then 2 != 7, and then two identical pointers; despite the fact that ptr is pointing at x. The compiler takes the fact that x is a constant value to not bother reading it when you ask for the value of x.

But when you take the address of x, you force it to exist. You then cast away const, and modify it. So the actual location in memory where x is has been modified, the compiler is free to not actually read it when reading x!

The compiler may get smart enough to figure out how to even avoid following ptr to read *ptr, but often they are not. Feel free to go and use ptr = ptr+argc-1 or somesuch confusion if the optimizer is getting smarter than you.

You can provide a custom operator[] that gets the right item.

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

having both is useful.


Heres a way to use a proxy class to access elements in a member array by name. It is very C++, and has no benefit vs. ref-returning accessor functions, except for syntactic preference. This overloads the -> operator to access elements as members, so to be acceptable, one needs to both dislike the syntax of accessors (d.a() = 5;), as well as tolerate using -> with a non-pointer object. I expect this might also confuse readers not familiar with the code, so this might be more of a neat trick than something you want to put into production.

The Data struct in this code also includes overloads for the subscript operator, to access indexed elements inside its ar array member, as well as begin and end functions, for iteration. Also, all of these are overloaded with non-const and const versions, which I felt needed to be included for completeness.

When Data's -> is used to access an element by name (like this: my_data->b = 5;), a Proxy object is returned. Then, because this Proxy rvalue is not a pointer, its own -> operator is auto-chain-called, which returns a pointer to itself. This way, the Proxy object is instantiated and remains valid during evaluation of the initial expression.

Contruction of a Proxy object populates its 3 reference members a, b and c according to a pointer passed in the constructor, which is assumed to point to a buffer containing at least 3 values whose type is given as the template parameter T. So instead of using named references which are members of the Data class, this saves memory by populating the references at the point of access (but unfortunately, using -> and not the . operator).

In order to test how well the compiler's optimizer eliminates all of the indirection introduced by the use of Proxy, the code below includes 2 versions of main(). The #if 1 version uses the -> and [] operators, and the #if 0 version performs the equivalent set of procedures, but only by directly accessing Data::ar.

The Nci() function generates runtime integer values for initializing array elements, which prevents the optimizer from just plugging constant values directly into each std::cout << call.

For gcc 6.2, using -O3, both versions of main() generate the same assembly (toggle between #if 1 and #if 0 before the first main() to compare): 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

If reading values is enough, and efficiency is not a concern, or if you trust your compiler to optimize things well, or if struct is just that 3 bytes, you can safely do this:

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

For C++ only version, you would probably want to use static_assert to verify that struct data has standard layout, and perhaps throw exception on invalid index instead.


It is illegal, but there is a workaround:

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

Now you can index v:

참고URL : https://stackoverflow.com/questions/40590216/is-it-legal-to-index-into-a-struct

반응형