Czy indeksowanie do struktury jest legalne?

104

Niezależnie od tego, jak „zły” jest kod i przy założeniu, że wyrównanie itp. Nie jest problemem na kompilatorze / platformie, czy jest to niezdefiniowane lub zepsute zachowanie?

Jeśli mam taką strukturę: -

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

struct data thing;

Czy jest legalny dostęp a, bi cjak (&thing.a)[0], (&thing.a)[1]i (&thing.a)[2]?

W każdym przypadku, na każdym kompilatorze i platformie, na którym go wypróbowałem, przy każdym ustawieniu, które wypróbowałem, „działało”. Martwię się tylko, że kompilator może nie zdawać sobie sprawy, że b i rzecz [1] to to samo i zapisy do 'b' mogą zostać umieszczone w rejestrze, a rzecz [1] odczyta niewłaściwą wartość z pamięci (na przykład). Jednak w każdym przypadku robiłem to dobrze. (Zdaję sobie sprawę, oczywiście, że to niewiele dowodzi)

To nie jest mój kod; to jest kod, z którym muszę pracować, interesuje mnie, czy jest to zły kod, czy uszkodzony kod, ponieważ różnica ma duży wpływ na moje priorytety zmiany go :)

Otagowano C i C ++. Najbardziej interesuje mnie C ++, ale także C, jeśli jest inne, tylko dla zainteresowania.

jcoder
źródło
51
Nie, to nie jest „legalne”. Jest to niezdefiniowane zachowanie.
Sam Varshavchik
10
Działa to w tym bardzo prostym przypadku, ponieważ kompilator nie dodaje żadnego wypełnienia między elementami. Spróbuj z konstrukcjami o różnych rozmiarach, a ulegną awarii.
Jakiś programista,
7
Kopanie przeszłości - UB był kiedyś nazywany demonami nosowymi .
Adrian Colomitchi
21
Świetnie, tutaj natknąłem się, ponieważ podążam za tagiem C, czytam pytanie, a następnie piszę odpowiedź, która dotyczy tylko C, ponieważ nie widziałem tagu C ++. C i C ++ bardzo się tutaj różnią! C pozwala na punning typu za pomocą unii, C ++ nie.
Lundin
7
Jeśli chcesz uzyskać dostęp do elementów jako tablicy, zdefiniuj je jako tablicę. Jeśli potrzebują różnych nazw, użyj imion. Próba wzięcia ciasta i zjedzenia go doprowadzi w końcu do niestrawności - prawdopodobnie w najbardziej niewygodnym momencie, jaki można sobie wyobrazić. (Myślę, że indeks 0 jest prawidłowy w C; indeks 1 lub 2 nie. Istnieją konteksty, w których pojedynczy element jest traktowany jako tablica o rozmiarze 1.)
Jonathan Leffler

Odpowiedzi:

73

To jest nielegalne 1 . To niezdefiniowane zachowanie w C ++.

Bierzesz członków w sposób tablicowy, ale oto, co mówi standard C ++ (moje podkreślenie):

[dcl.array / 1] : ... Obiekt typu tablicowego zawieraprzydzielony wsposób ciągły niepusty zbiór N podobiektów typu T ...

Jednak w przypadku członków nie ma takiego ciągłego wymogu:

[class.mem / 17] : ...; Wymagania dotyczące dostosowania implementacji mogą spowodować, że dwóch sąsiednich członków nie zostanie przydzielonych bezpośrednio po sobie ...

Chociaż powyższe dwa cudzysłowy powinny wystarczyć, aby wskazać, dlaczego indeksowanie do a structnie jest zachowaniem zdefiniowanym przez standard C ++, wybierzmy jeden przykład: spójrz na wyrażenie (&thing.a)[2]- dotyczące operatora indeksu dolnego:

[expr.post//expr.sub/1] : Wyrażenie przyrostkowe, po którym następuje wyrażenie w nawiasach kwadratowych, jest wyrażeniem przyrostkowym. Jedno z wyrażeń będzie wartością gl typu „tablica T” lub wartością pr typu „wskaźnik do T”, a drugie wartością prwartości wyliczenia bez zakresu lub typu całkowego. Wynik jest typu „T”. Typ „T” powinien być całkowicie zdefiniowanym typem obiektu.66 Wyrażenie E1[E2]jest identyczne (z definicji) z((E1)+(E2))

Zagłębiając się w pogrubiony tekst powyższego cytatu: dotyczący dodawania typu całkowitego do typu wskaźnikowego (zwróć uwagę na podkreślenie tutaj).

[expr.add / 4] : Gdy wyrażenie, które ma typ całkowity, jest dodawane lub odejmowane od wskaźnika, wynik ma typ operandu wskaźnika. Jeśli WyrażeniePpunkty dla elementux[i]z tablicą obiektux z n elementów, ekspresjiP + JiJ + P(gdzieJma wartośćj) do punktu (ewentualnie), hipotetycznego elementux[i + j] jeśli0 ≤ i + j ≤ n; w przeciwnym razie zachowanie jest niezdefiniowane. ...

Zwróć uwagę na wymaganie dotyczące tablicy dla klauzuli if ; w przeciwnym razie inaczej w powyższym cytacie. Wyrażenie (&thing.a)[2]oczywiście nie kwalifikuje się do klauzuli if ; Stąd niezdefiniowane zachowanie.


Na marginesie: chociaż intensywnie eksperymentowałem z kodem i jego odmianami na różnych kompilatorach i nie wprowadzają tutaj żadnego dopełnienia (to działa ); z punktu widzenia konserwacji kod jest wyjątkowo delikatny. przed wykonaniem tej czynności należy nadal zapewnić, że implementacja przydzieliła członków w sposób ciągły. I trzymaj się w granicach :-). Ale jego nadal niezdefiniowane zachowanie ...

Niektóre możliwe obejścia (ze zdefiniowanym zachowaniem) zostały podane w innych odpowiedziach.



Jak słusznie wskazałem w komentarzach, [basic.lval / 8] , który był w mojej poprzedniej edycji, nie ma zastosowania. Dzięki @ 2501 i @MM

1 : Zobacz odpowiedź @ Barry'ego na to pytanie dla jedynego przypadku prawnego, w którym możesz uzyskać dostęp do thing.aczłonka struktury za pośrednictwem tej strony.

WhiZTiM
źródło
1
@jcoder Jest zdefiniowany w class.mem . Aktualny tekst znajduje się w ostatnim akapicie.
NathanOliver
4
Ścisłe dopasowanie nie ma tutaj znaczenia. Typ int jest zawarty w typie zagregowanym, a ten typ może alias 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
@ Downvoters, chcesz skomentować? - i poprawić lub wskazać, gdzie ta odpowiedź jest błędna?
WhiZTiM
4
Ścisłe aliasing nie ma tu znaczenia. Wypełnienie nie jest częścią przechowywanej wartości obiektu. Również ta odpowiedź nie odnosi się do najczęstszego przypadku: co się dzieje, gdy nie ma wypełnienia. Poleciłbym faktycznie usunięcie tej odpowiedzi.
MM
1
Gotowe! Usunąłem akapit o ścisłym aliasowaniu.
WhiZTiM
48

Nie. W C jest to niezdefiniowane zachowanie, nawet jeśli nie ma dopełnienia.

To, co powoduje niezdefiniowane zachowanie, to dostęp poza granicami 1 . Kiedy masz skalar (składowe a, b, c w strukturze) i próbujesz użyć go jako tablicy 2, aby uzyskać dostęp do następnego hipotetycznego elementu, wywołujesz niezdefiniowane zachowanie, nawet jeśli zdarzy się, że w miejscu znajduje się inny obiekt tego samego typu ten adres.

Możesz jednak użyć adresu obiektu struct i obliczyć przesunięcie do określonego elementu członkowskiego:

struct data thing = { 0 };
char* p = ( char* )&thing + offsetof( thing , b );
int* b = ( int* )p;
*b = 123;
assert( thing.b == 123 );

Należy to zrobić dla każdego elementu osobno, ale można to umieścić w funkcji przypominającej dostęp do tablicy.


1 (Cytat z: ISO / IEC 9899: 201x 6.5.6 Operatory addytywne 8)
Jeżeli wynik jest wskazany o jeden za ostatnim elementem tablicy, nie powinien być używany jako operand jednoargumentowego operatora *, który jest oceniany.

2 (Cytat z: ISO / IEC 9899: 201x 6.5.6 Operatory addytywne 7)
Na potrzeby tych operatorów wskaźnik do obiektu, który nie jest elementem tablicy, zachowuje się tak samo, jak wskaźnik do pierwszego elementu tablica o długości jeden z typem obiektu jako typem elementu.

2501
źródło
3
Zwróć uwagę, że działa to tylko wtedy, gdy klasa jest standardowym typem układu. Jeśli nie, to nadal UB.
NathanOliver
@NathanOliver Powinienem wspomnieć, że moja odpowiedź dotyczy tylko C. Edited. Jest to jeden z problemów związanych z pytaniami o podwójny język.
2501
Dzięki i dlatego poprosiłem osobno o C ++ i C, ponieważ interesujące jest poznanie różnic
jcoder
@NathanOliver Adres pierwszego członka na pewno pokrywa się z adresem klasy C ++, jeśli jest to standardowy układ. Jednak nie gwarantuje to, że dostęp jest dobrze zdefiniowany, ani nie oznacza, że ​​takie dostępy do innych klas są niezdefiniowane.
Potatoswatter
czy powiedziałbyś, że char* p = ( char* )&thing.a + offsetof( thing , b );prowadzi to do nieokreślonego zachowania?
MM
43

W C ++, jeśli naprawdę tego potrzebujesz - utwórz 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

nie tylko gwarantuje, że będzie działać, ale jego użycie jest prostsze, nie musisz pisać nieczytelnego wyrażenia (&thing.a)[0]

Uwaga: ta odpowiedź jest udzielana przy założeniu, że masz już strukturę z polami i musisz dodać dostęp przez indeks. Jeśli szybkość jest problemem i możesz zmienić strukturę, może to być bardziej skuteczne:

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

To rozwiązanie zmieniłoby rozmiar struktury, więc możesz użyć również metod:

struct data 
{
     int array[3];
     int &a() { return array[0]; }
     int &b() { return array[1]; }
     int &c() { return array[2]; }
};
Slava
źródło
1
Chciałbym zobaczyć demontaż tego, w porównaniu z demontażem programu w C za pomocą punningu typu. Ale, ale ... C ++ jest tak szybkie jak C ... prawda? Dobrze?
Lundin
6
@Lundin jeśli zależy Ci na szybkości tej konstrukcji to dane powinny być zorganizowane przede wszystkim jako tablica, a nie jako osobne pola.
Slava
2
@Lundin w obu masz na myśli nieczytelne i niezdefiniowane zachowanie? Nie, dziękuję.
Slava
1
Przeciążanie operatora @Lundin jest funkcją składniową w czasie kompilacji, która nie powoduje żadnego narzutu w porównaniu do normalnych funkcji. Zajrzyj na godbolt.org/g/vqhREz, aby zobaczyć, co faktycznie robi kompilator, gdy kompiluje kod C ++ i C. To niesamowite, co robią i czego się od nich oczekuje. Osobiście wolę lepsze bezpieczeństwo typów i wyrazistość C ++ niż C milion razy. I działa cały czas bez polegania na założeniach dotyczących wypełnienia.
Jens
2
Te odniesienia co najmniej podwoją rozmiar przedmiotu. Po prostu zrób thing.a().
TC
14

W przypadku języka c ++: Jeśli chcesz uzyskać dostęp do elementu członkowskiego bez znajomości jego nazwy, możesz użyć wskaźnika do zmiennej składowej.

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;
StoryTeller - Unslander Monica
źródło
1
Jest to korzystanie z udogodnień językowych, w wyniku czego jest dobrze zdefiniowane i, jak zakładam, wydajne. Najlepsza odpowiedź.
Peter - Przywróć Monikę
2
Załóż, że jest skuteczny? Zakładam, że jest odwrotnie. Spójrz na wygenerowany kod.
JDługosz
1
@ JDługosz, masz rację. Rzucając okiem na wygenerowany zestaw, wygląda na to, że gcc 6.2 tworzy kod odpowiadający użyciu offsetoffw C.
StoryTeller - Unslander Monica
3
możesz także poprawić rzeczy, tworząc arr constexpr. Spowoduje to utworzenie pojedynczej stałej tabeli przeglądowej w sekcji danych zamiast tworzenia jej w locie.
Tim
10

W ISO C99 / C11, punktowanie typu oparte na związkach jest legalne, więc można go używać zamiast indeksowania wskaźników do innych niż tablice (zobacz różne inne odpowiedzi).

ISO C ++ nie zezwala na punktowanie typów oparte na unii. GNU C ++ robi to jako rozszerzenie i myślę, że niektóre inne kompilatory, które generalnie nie obsługują rozszerzeń GNU, obsługują punning typu union. Ale to nie pomaga w pisaniu czysto przenośnego kodu.

W obecnych wersjach gcc i clang, napisanie funkcji składowej C ++ przy użyciu a switch(idx)do wybrania elementu spowoduje optymalizację pod kątem stałych w czasie kompilacji, ale da straszny rozgałęziony asm dla indeksów czasu wykonywania. Nie ma w tym nic złego switch(); jest to po prostu błąd brakującej optymalizacji w obecnych kompilatorach. Mogliby efektywnie działać kompilator switch () Slavy.


Rozwiązaniem / obejściem tego problemu jest zrobienie tego w inny sposób: nadanie klasie / strukturze składowej tablicy i napisanie funkcji akcesorów, aby dołączyć nazwy do określonych elementów.

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

Możemy przyjrzeć się wynikom asm dla różnych przypadków użycia w eksploratorze kompilatora Godbolt . Są to kompletne funkcje Systemu V x86-64, z pominięciem końcowej instrukcji RET, aby lepiej pokazać, co otrzymasz, gdy są wbudowane. ARM / MIPS / cokolwiek byłoby podobne.

# 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]

Dla porównania, odpowiedź @ Slava za pomocą a switch()for C ++ sprawia, że ​​asm jest taki dla indeksu zmiennej czasu wykonywania. (Kod w poprzednim linku 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

Jest to oczywiście okropne w porównaniu do wersji punningowej opartej na związkach typu C (lub 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]
Peter Cordes
źródło
@MM: słuszna uwaga. To raczej odpowiedź na różne komentarze i alternatywa dla odpowiedzi Sławy. Przeformułowałem początkowy fragment, więc przynajmniej zaczyna się jako odpowiedź na pierwotne pytanie. Dzięki za zwrócenie uwagi.
Peter Cordes
Chociaż punning oparty na związkach wydaje się działać w gcc i clang podczas używania []operatora bezpośrednio na składniku związku, Standard definiuje array[index]jako równoważny *((array)+(index))i ani gcc, ani clang nie rozpoznają wiarygodnie, że dostęp do *((someUnion.array)+(index))jest dostępem do someUnion. Jedynym wyjaśnieniem, jakie widzę, jest to, że Standard someUnion.array[index]ani *((someUnion.array)+(index))nie jest zdefiniowany, ale są tylko popularnymi rozszerzeniami, a gcc / clang zdecydował się nie obsługiwać drugiego, ale wydaje się, że obsługuje pierwsze, przynajmniej na razie.
supercat
9

W C ++ jest to w większości niezdefiniowane zachowanie (zależy to od indeksu).

Z [expr.unary.op]:

Dla celów arytmetyki wskaźników (5.7) i porównania (5.9, 5.10), obiekt, który nie jest elementem tablicy, którego adres jest przyjmowany w ten sposób, jest traktowany jako należący do tablicy z jednym elementem typu T.

&thing.aDlatego uważa się, że wyrażenie odnosi się do tablicy o wartości jeden int.

Od [wyr.sub]:

Wyrażenie E1[E2]jest identyczne (z definicji) z*((E1)+(E2))

I z [expr.add]:

Gdy wyrażenie, które ma typ całkowity, jest dodawane lub odejmowane od wskaźnika, wynik ma typ operandu wskaźnika. Jeśli wyrażenie Pwskazuje na element x[i]tablicy xz nelementami, wyrażenia P + Ji J + P(gdzie Jma wartość j) wskazują na (prawdopodobnie hipotetyczny) element x[i + j]if 0 <= i + j <= n; w przeciwnym razie zachowanie jest niezdefiniowane.

(&thing.a)[0]jest doskonale sformułowana, ponieważ &thing.ajest traktowana jako tablica o rozmiarze 1 i bierzemy ten pierwszy indeks. To jest dozwolony indeks.

(&thing.a)[2]narusza warunek, że 0 <= i + j <= n, ponieważ mamy i == 0, j == 2, n == 1. Samo skonstruowanie wskaźnika &thing.a + 2jest niezdefiniowanym zachowaniem.

(&thing.a)[1]jest interesującym przypadkiem. W rzeczywistości nie narusza niczego w [expr.add]. Możemy wziąć wskaźnik o jeden za koniec tablicy - co by to było. Tutaj przechodzimy do notatki w [basic.compound]:

Wartość typu wskaźnika, który jest wskaźnikiem na koniec obiektu lub za nim, reprezentuje adres pierwszego bajtu w pamięci (1.7) zajmowanego przez obiekt53 lub pierwszego bajtu w pamięci po zakończeniu pamięci zajmowanej przez obiekt odpowiednio. [Uwaga: wskaźnik znajdujący się za końcem obiektu (5.7) nie jest uważany za wskazujący na niepowiązany obiekt typu obiektu, który może znajdować się pod tym adresem.

Dlatego branie wskaźnika &thing.a + 1jest zdefiniowanym zachowaniem, ale wyłuskiwanie go jest niezdefiniowane, ponieważ na nic nie wskazuje.

Barry
źródło
Obliczanie (& rzecz. A) + 1 jest prawie prawidłowe, ponieważ wskaźnik za końcem tablicy jest prawidłowy; odczyt lub zapis przechowywanych tam danych zachowuje się niezdefiniowane, w porównaniu z & thing.b z <,>, <=,> = jest niezdefiniowanym zachowaniem. (i rzecz. a) + 2 jest absolutnie nielegalne.
gnasher729
@ gnasher729 Tak, warto bardziej wyjaśnić odpowiedź.
Barry
To (&thing.a + 1)ciekawa sprawa, której nie opisałem. +1! ... Ciekawe, czy jesteś w komitecie ISO C ++?
WhiZTiM
Jest to również bardzo ważny przypadek, ponieważ w przeciwnym razie każda pętla używająca wskaźników jako półotwartego interwału byłaby UB.
Jens
Odnośnie ostatniego standardowego cytatu. C ++ musi być lepiej określone niż C tutaj.
2501
8

To jest niezdefiniowane zachowanie.

W C ++ istnieje wiele reguł, które próbują dać kompilatorowi nadzieję na zrozumienie tego, co robisz, aby mógł to uzasadnić i zoptymalizować.

Istnieją zasady dotyczące aliasingu (dostępu do danych za pomocą dwóch różnych typów wskaźników), granic tablicy itp.

Kiedy masz zmienną x, fakt, że nie jest ona członkiem tablicy, oznacza, że ​​kompilator może założyć, że żaden []dostęp do tablicy nie może jej zmodyfikować. Więc nie musi ciągle przeładowywać danych z pamięci za każdym razem, gdy go używasz; tylko gdyby ktoś mógł go zmodyfikować z nazwy .

W związku z tym (&thing.a)[1]można założyć, że kompilator nie odwołuje się do thing.b. Może wykorzystać ten fakt do zmiany kolejności odczytów i zapisów thing.b, unieważniając to, co chcesz, aby zrobić, bez unieważniania tego, co faktycznie nakazałeś.

Klasycznym tego przykładem jest odrzucenie 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';

tutaj zazwyczaj otrzymujesz kompilator mówiący 7, potem 2! = 7, a następnie dwa identyczne wskaźniki; pomimo faktu, że ptrwskazuje x. Kompilator przyjmuje fakt, że xjest to wartość stała, aby nie zawracać sobie głowy czytaniem, gdy pytasz o wartość x.

Ale kiedy bierzesz adres x, zmuszasz go do istnienia. Następnie odrzucasz const i modyfikujesz je. Tak więc rzeczywista lokalizacja w pamięci, w której xzostała zmieniona, kompilator może nie czytać jej podczas czytania x!

Kompilator może stać się na tyle sprytny, aby wymyślić, jak nawet uniknąć śledzenia, ptraby przeczytać *ptr, ale często tak nie jest. Nie ptr = ptr+argc-1krępuj się i użyj lub trochę zamieszania, jeśli optymalizator staje się mądrzejszy od ciebie.

Możesz zapewnić niestandardowy, operator[]który otrzyma właściwy przedmiot.

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

posiadanie obu jest przydatne.

Yakk - Adam Nevraumont
źródło
„fakt, że nie jest to element tablicy oznacza, że ​​kompilator może założyć, że żaden dostęp do tablicy oparty na [] nie może go zmodyfikować”. - nieprawda, np. (&thing.a)[0]może to zmienić
MM
Nie rozumiem, jak przykład const ma cokolwiek wspólnego z pytaniem. To kończy się niepowodzeniem tylko dlatego, że istnieje określona zasada, że ​​obiektu stałego nie można modyfikować, a nie z żadnego innego powodu.
MM
1
@MM, to nie jest przykład indeksowania do struktury, ale jest to bardzo dobra ilustracja tego, jak użycie niezdefiniowanego zachowania do odniesienia się do czegoś przez jego pozorną lokalizację w pamięci może skutkować innym wyjściem niż oczekiwano, ponieważ kompilator może zrobić coś innego z UB, niż chciałeś.
Wildcard
@MM Przepraszamy, nie ma innego dostępu do tablicy niż trywialny dostęp przez wskaźnik do samego obiektu. A druga to tylko przykład łatwo dostrzegalnych skutków ubocznych niezdefiniowanego zachowania; kompilator optymalizuje odczyty, xponieważ wie, że nie można tego zmienić w określony sposób. Podobna optymalizacja może wystąpić, gdy zmienisz bprzez, (&blah.a)[1]jeśli kompilator może udowodnić, że nie ma zdefiniowanego dostępu, bktóry mógłby to zmienić; taka zmiana może wystąpić z powodu pozornie nieszkodliwych zmian w kompilatorze, otaczającym go kodzie lub czymkolwiek. Więc nawet sprawdzenie, czy to działa, nie wystarczy.
Yakk - Adam Nevraumont
6

Oto sposób użycia klasy proxy w celu uzyskania dostępu do elementów w tablicy składowej według nazwy. Jest bardzo C ++ i nie ma żadnych korzyści w porównaniu z funkcjami dostępowymi zwracającymi ref, z wyjątkiem preferencji składniowych. To przeciąża ->operatora, aby uzyskać dostęp do elementów jako elementów członkowskich, więc aby być akceptowalnym, należy zarówno nie lubić składni metod dostępu ( d.a() = 5;), jak i tolerować używanie ->z obiektem niebędącym wskaźnikiem. Spodziewam się, że może to również zmylić czytelników niezaznajomionych z kodem, więc może to być bardziej zgrabna sztuczka niż coś, co chcesz wprowadzić do produkcji.

DataStruktura w tym kodzie również przeciążenia dla operatora indeks dolny, na dostęp do jego indeksowania elementów wewnątrz arelementu układu, jak również begini endfunkcji, dla iteracji. Ponadto wszystkie z nich są przeładowane wersjami innymi niż const i const, które uważałem za potrzebne, aby zapewnić kompletność.

Gdy Data„S ->służy do łączenia elementu o nazwie (tak: my_data->b = 5;), A Proxyobiekt jest zwracany. Następnie, ponieważ ta Proxywartość r nie jest wskaźnikiem, jej własny ->operator jest wywoływany automatycznie, co zwraca wskaźnik do siebie samego. W ten sposób Proxyobiekt jest tworzony i pozostaje ważny podczas oceny początkowego wyrażenia.

Wolnostojąca z Proxyobiektu wypełnia jego 3 elementy odniesienia a, bi cw zależności od wskaźnika przechodzi w konstruktora, który zakłada się na miejscu w buforze zawierającym co najmniej 3 wartości których typ jest dany jako parametr szablonu T. Więc zamiast używać nazwanych referencji, które są członkami Dataklasy, oszczędza to pamięć przez zapełnianie referencji w punkcie dostępu (ale niestety przy użyciu, ->a nie .operatora).

Aby sprawdzić, jak dobrze optymalizator kompilatora eliminuje wszystkie pośrednie zmiany wprowadzone przez użycie Proxy, poniższy kod zawiera 2 wersje main(). #if 1Wersja używa ->i []operatorów, a #if 0wersja wykonuje równoważne zestaw procedur, ale tylko przez bezpośredniego dostępu Data::ar.

Nci()Funkcji generuje wykonawcze wartość całkowitą dla inicjalizacji elementów macierzy, co zapobiega optymalizator z po podłączeniu wartości stałych bezpośrednio do każdego std::cout <<połączenia.

Dla gcc 6.2, używając -O3, obie wersje main()generują ten sam zestaw (przełączanie między #if 1i #if 0przed pierwszym main()do porównania): 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
Christopher Oicles
źródło
Ładne. Głos zabrali głównie dlatego, że udowodniłeś, że to optymalizuje. BTW, możesz to zrobić dużo łatwiej, pisząc bardzo prostą funkcję, a nie całość main()z funkcjami czasowymi! np. int getb(Data *d) { return (*d)->b; }kompiluje się do just mov eax, DWORD PTR [rdi+4]/ ret( godbolt.org/g/89d3Np ). (Tak, Data &duczyniłoby składnię łatwiejszą, ale użyłem wskaźnika zamiast ref, aby podkreślić dziwność przeciążania w ->ten sposób.)
Peter Cordes
W każdym razie to jest fajne. Inne pomysły, na przykład int tmp[] = { a, b, c}; return tmp[idx];nie optymalizują się, więc fajnie, że tak.
Peter Cordes
Jeszcze jeden powód, dla którego tęsknię operator.w C ++ 17.
Jens
2

Jeśli odczyt wartości jest wystarczający, a wydajność nie jest problemem, lub jeśli ufasz swojemu kompilatorowi, że dobrze optymalizuje rzeczy, lub jeśli struktura ma tylko 3 bajty, możesz bezpiecznie zrobić to:

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

W przypadku wersji tylko C ++ prawdopodobnie chciałbyś użyć static_assertdo sprawdzenia, czy struct datama standardowy układ, i być może zamiast tego zgłosić wyjątek do nieprawidłowego indeksu.

hyde
źródło
1

Jest to nielegalne, ale istnieje obejście:

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

Teraz możesz zindeksować v:

Sven Nilsson
źródło
6
Wiele projektów C ++ uważa, że ​​redukowanie wszędzie jest w porządku. Nadal nie powinniśmy głosić złych praktyk.
StoryTeller - Unslander Monica
2
Związek rozwiązuje problem ścisłego aliasingu w obu językach. Ale przebijanie za pomocą unii jest dobre tylko w C, a nie w C ++.
Lundin
1
nadal nie zdziwiłbym się, gdyby to zadziałało na 100% wszystkich kompilatorów c ++. zawsze.
Sven Nilsson
1
Możesz to wypróbować w gcc z włączonymi najbardziej agresywnymi ustawieniami optymalizatora.
Lundin
1
@Lundin: punning typu union jest legalny w GNU C ++, jako rozszerzenie w stosunku do ISO C ++. Wydaje się, że nie jest to jasno określone w instrukcji , ale jestem tego całkiem pewien. Jednak ta odpowiedź musi wyjaśniać, gdzie jest ważna, a gdzie nie.
Peter Cordes