구조체로 인덱싱하는 것이 합법적입니까? 정의되지 않았거나 깨졌습니까? 다음과 같은

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

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

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 지정하여 실제로 수행하도록 지시 한 내용을 무효화하지 않고 원하는 작업을 무효화 할 수 있습니다.

이것의 고전적인 예는 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 then 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;

둘 다 있으면 유용합니다.