Czy są jakieś wady przekazywania struktur przez wartość w C, zamiast przekazywania wskaźnika?

157

Czy są jakieś wady przekazywania struktur przez wartość w C, zamiast przekazywania wskaźnika?

Jeśli struktura jest duża, istnieje oczywiście aspekt wydajności kopiowania dużej ilości danych, ale w przypadku mniejszej struktury powinno to być to samo, co przekazywanie kilku wartości do funkcji.

Może być nawet bardziej interesujący, gdy jest używany jako wartości zwracane. C ma tylko pojedyncze wartości zwracane przez funkcje, ale często potrzebujesz kilku. Zatem prostym rozwiązaniem jest umieszczenie ich w strukturze i zwrócenie jej.

Czy są jakieś powody za lub przeciw?

Ponieważ może nie być oczywiste dla wszystkich, o czym mówię, podam prosty przykład.

Jeśli programujesz w C, prędzej czy później zaczniesz pisać funkcje, które wyglądają tak:

void examine_data(const char *ptr, size_t len)
{
    ...
}

char *p = ...;
size_t l = ...;
examine_data(p, l);

To nie jest problem. Jedynym problemem jest to, że musisz uzgodnić ze swoim współpracownikiem, w jakiej kolejności powinny być parametry, więc używasz tej samej konwencji we wszystkich funkcjach.

Ale co się dzieje, gdy chcesz zwrócić te same informacje? Zwykle dostajesz coś takiego:

char *get_data(size_t *len);
{
    ...
    *len = ...datalen...;
    return ...data...;
}
size_t len;
char *p = get_data(&len);

Działa to dobrze, ale jest znacznie bardziej problematyczne. Wartość zwracana jest wartością zwracaną, z wyjątkiem tego, że w tej implementacji tak nie jest. Z powyższego nie można stwierdzić, że funkcja get_data nie może sprawdzić, na co wskazuje len. I nie ma nic, co sprawia, że ​​kompilator sprawdza, czy wartość jest faktycznie zwracana przez ten wskaźnik. Więc w przyszłym miesiącu, gdy ktoś inny modyfikuje kod bez jego zrozumienia (ponieważ nie przeczytał dokumentacji?), Ulega on zepsuciu i nikt nie zauważa, lub zaczyna się losowo zawieszać.

Zatem rozwiązanie, które proponuję, to prosta struktura

struct blob { char *ptr; size_t len; }

Przykłady można przepisać w następujący sposób:

void examine_data(const struct blob data)
{
    ... use data.tr and data.len ...
}

struct blob = { .ptr = ..., .len = ... };
examine_data(blob);

struct blob get_data(void);
{
    ...
    return (struct blob){ .ptr = ...data..., .len = ...len... };
}
struct blob data = get_data();

Z jakiegoś powodu myślę, że większość ludzi instynktownie sprawiłaby, że exam_data wziąłby wskaźnik do struct blob, ale nie rozumiem dlaczego. Wciąż otrzymuje wskaźnik i liczbę całkowitą, jest po prostu znacznie wyraźniejsze, że idą razem. A w przypadku get_data nie można zepsuć w sposób, który opisałem wcześniej, ponieważ nie ma wartości wejściowej dla długości, a musi być zwrócona długość.

dkagedal
źródło
To, co jest warte, void examine data(const struct blob)jest błędne.
Chris Lutz,
Dzięki, zmieniłem to tak, aby zawierało nazwę zmiennej.
dkagedal
1
„Nie ma sposobu, aby stwierdzić z powyższego, że funkcja get_data nie może sprawdzić, na co wskazuje len. - to w ogóle nie ma dla mnie sensu (być może dlatego, że Twój przykład jest nieprawidłowym kodem, ponieważ ostatnie dwie linie pojawiają się poza funkcją); proszę, czy możesz to rozwinąć?
Adam Spiers
2
Dwie linie poniżej funkcji służą do zilustrowania sposobu wywoływania funkcji. Podpis funkcji nie daje żadnej wskazówki, że implementacja powinna pisać tylko do wskaźnika. Kompilator nie ma możliwości dowiedzenia się, że powinien sprawdzić, czy wartość jest zapisywana we wskaźniku, więc mechanizm wartości zwracanej można opisać tylko w dokumentacji.
dkagedal
1
Głównym powodem, dla którego ludzie nie robią tego częściej w C, jest historia. Przed C89 nie można było przekazywać ani zwracać struktur według wartości, więc wszystkie interfejsy systemowe, które są starsze niż C89 i logicznie powinny to robić (na przykład gettimeofday), zamiast tego używają wskaźników, a ludzie biorą to jako przykład.
zwolnił

Odpowiedzi:

202

W przypadku małych struktur (np. Point, rect) przekazywanie przez wartość jest całkowicie dopuszczalne. Ale oprócz szybkości jest jeszcze jeden powód, dla którego powinieneś uważać przy przekazywaniu / zwracaniu dużych struktur według wartości: przestrzeń stosu.

Wiele programów w C jest przeznaczonych dla systemów wbudowanych, w których pamięć jest na wagę złota, a rozmiary stosu mogą być mierzone w KB lub nawet w bajtach ... Jeśli przekazujesz lub zwracasz struktury według wartości, kopie tych struktur zostaną umieszczone na stos, potencjalnie powodując sytuację, w której nazwa tej witryny pochodzi od ...

Jeśli widzę aplikację, która wydaje się nadmiernie wykorzystywać stos, struktury przekazywane przez wartość są jedną z rzeczy, których szukam w pierwszej kolejności.

Roddy
źródło
2
„Jeśli przekazujesz lub zwracasz struktury według wartości, kopie tych struktur zostaną umieszczone na stosie”. Nazwałbym braindead każdym toolchainem, który to robi. Tak, to smutne, że tak wielu to zrobi, ale nie jest to coś, czego wymaga standard C. Rozsądny kompilator to wszystko zoptymalizuje.
Przywróć Monikę
1
@KubaOber Dlatego nie robi się tego często: stackoverflow.com/questions/552134/ ...
Roddy
1
Czy istnieje ostateczna linia oddzielająca małą strukturę od dużej struktury?
Josie Thompson
63

Jednym z powodów, aby tego nie robić, o którym nie wspomniano, jest to, że może to spowodować problem, w którym liczy się zgodność binarna.

W zależności od używanego kompilatora, struktury mogą być przesyłane przez stos lub rejestry w zależności od opcji / implementacji kompilatora

Zobacz: http://gcc.gnu.org/onlinedocs/gcc/Code-Gen-Options.html

-fpcc-struct-return

-freg-struct-return

Jeśli dwóch kompilatorów się nie zgadza, sprawy mogą wybuchnąć. Nie trzeba dodawać, że główne powody, aby tego nie robić, są zilustrowane, to zużycie stosu i przyczyny związane z wydajnością.

tonylo
źródło
4
To była odpowiedź, której szukałem.
dkagedal
2
To prawda, ale te opcje nie odnoszą się do wartości przekazywanej. odnoszą się do zwracania struktur, co jest zupełnie inną rzeczą. Zwracanie rzeczy przez odniesienie jest zwykle niezawodnym sposobem na strzelenie sobie w obie stopy. int &bar() { int f; int &j(f); return j;};
Roddy,
19

Aby naprawdę odpowiedzieć na to pytanie, trzeba zagłębić się w teren montażu:

(Poniższy przykład używa gcc na x86_64. Każdy może dodać inne architektury, takie jak MSVC, ARM itp.)

Weźmy przykładowy program:

// foo.c

typedef struct
{
    double x, y;
} point;

void give_two_doubles(double * x, double * y)
{
    *x = 1.0;
    *y = 2.0;
}

point give_point()
{
    point a = {1.0, 2.0};
    return a;
}

int main()
{
    return 0;
}

Skompiluj go z pełnymi optymalizacjami

gcc -Wall -O3 foo.c -o foo

Spójrz na montaż:

objdump -d foo | vim -

Oto, co otrzymujemy:

0000000000400480 <give_two_doubles>:
    400480: 48 ba 00 00 00 00 00    mov    $0x3ff0000000000000,%rdx
    400487: 00 f0 3f 
    40048a: 48 b8 00 00 00 00 00    mov    $0x4000000000000000,%rax
    400491: 00 00 40 
    400494: 48 89 17                mov    %rdx,(%rdi)
    400497: 48 89 06                mov    %rax,(%rsi)
    40049a: c3                      retq   
    40049b: 0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)

00000000004004a0 <give_point>:
    4004a0: 66 0f 28 05 28 01 00    movapd 0x128(%rip),%xmm0
    4004a7: 00 
    4004a8: 66 0f 29 44 24 e8       movapd %xmm0,-0x18(%rsp)
    4004ae: f2 0f 10 05 12 01 00    movsd  0x112(%rip),%xmm0
    4004b5: 00 
    4004b6: f2 0f 10 4c 24 f0       movsd  -0x10(%rsp),%xmm1
    4004bc: c3                      retq   
    4004bd: 0f 1f 00                nopl   (%rax)

Z wyłączeniem noplpadów give_two_doubles()ma 27 bajtów, a give_point()ma 29 bajtów. Z drugiej strony give_point()daje o jedną instrukcję mniej niżgive_two_doubles()

Co ciekawe, zauważyliśmy, że kompilator był w stanie zoptymalizować movpod kątem szybszych wariantów SSE2 movapdi movsd. Co więcej, give_two_doubles()faktycznie przenosi dane do i z pamięci, co spowalnia.

Najwyraźniej wiele z tego może nie mieć zastosowania w środowiskach osadzonych (w których obecnie większość czasu dla języka C jest). Nie jestem mistrzem montażu, więc wszelkie komentarze będą mile widziane!

kizzx2
źródło
6
Liczenie instrukcji nie jest wcale takie interesujące, chyba że możesz pokazać ogromną różnicę lub policzyć bardziej interesujące aspekty, takie jak liczba trudnych do przewidzenia skoków itp. Rzeczywiste właściwości wydajności są znacznie bardziej subtelne niż liczba instrukcji .
dkagedal
6
@dkagedal: True. Z perspektywy czasu myślę, że moja własna odpowiedź została napisana bardzo słabo. Chociaż nie skupiałem się zbytnio na liczbie instrukcji (nie wiem, co dało ci takie wrażenie: P), faktyczna uwaga była taka, że ​​przekazywanie struktury przez wartość jest lepsze niż przekazywanie przez referencję w przypadku małych typów. W każdym razie, przekazywanie wartości jest preferowane, ponieważ jest prostsze (bez żonglowania przez całe życie, nie trzeba się martwić, że ktoś zmieni twoje dane lub constcały czas) i stwierdziłem, że nie ma dużego spadku wydajności (jeśli nie zysku) w kopiowaniu typu pass-by-value wbrew temu, w co wielu mogłoby wierzyć.
kizzx2
15

Proste rozwiązanie zwróci kod błędu jako wartość zwracaną, a wszystko inne jako parametr w funkcji.
Ten parametr może oczywiście być strukturą, ale nie widzę żadnej szczególnej korzyści z przekazywania tego przez wartość, po prostu wysłał wskaźnik.
Przekazywanie struktury według wartości jest niebezpieczne, musisz być bardzo ostrożny, co przekazujesz, pamiętaj, że w C nie ma konstruktora kopiującego, jeśli jeden z parametrów struktury jest wskaźnikiem, wartość wskaźnika zostanie skopiowana, może to być bardzo zagmatwane i trudne utrzymać.

Aby uzupełnić odpowiedź (pełne uznanie dla Roddy'ego ), użycie stosu jest kolejnym powodem, dla którego nie można przekazywać struktury po wartości, wierzcie mi, że debugowanie przepełnienia stosu to prawdziwe PITA.

Odtwórz ponownie, aby skomentować:

Przekazywanie struktury przez wskaźnik oznacza, że ​​jakaś jednostka ma prawo własności do tego obiektu i ma pełną wiedzę o tym, co i kiedy powinno zostać zwolnione. Przekazywanie struktury przez wartość tworzy ukryte odniesienia do wewnętrznych danych struktury (wskaźniki do innych struktur itp.), Co jest trudne do utrzymania (możliwe, ale dlaczego?).

Ilya
źródło
6
Ale przekazywanie wskaźnika nie jest bardziej „niebezpieczne” tylko dlatego, że umieszczasz go w strukturze, więc nie kupuję tego.
dkagedal
Świetny punkt dotyczący kopiowania struktury zawierającej wskaźnik. Ten punkt może nie być oczywisty. Dla tych, którzy nie wiedzą, do czego się odnosi, poszukaj głębokiej kopii lub płytkiej kopii.
zooropa
1
Jedną z konwencji funkcji C jest to, że parametry wyjściowe są wymienione jako pierwsze przed parametrami wejściowymi, np. Int func (char * out, char * in);
zooropa
Masz na myśli, jak na przykład getaddrinfo () umieszcza parametr wyjściowy na końcu? :-) Istnieje tysiąc zestawów konwencji i możesz wybrać dowolną z nich.
dkagedal
10

Jedną rzeczą, o której ludzie tutaj zapomnieli do tej pory wspomnieć (lub ja to przeoczyłem), jest to, że struktury zwykle mają wypełnienie!

struct {
  short a;
  char b;
  short c;
  char d;
}

Każdy znak to 1 bajt, każdy krótki to 2 bajty. Jak duża jest struktura? Nie, to nie jest 6 bajtów. Przynajmniej nie w bardziej powszechnie używanych systemach. W większości systemów będzie to 8. Problem polega na tym, że wyrównanie nie jest stałe, jest zależne od systemu, więc ta sama struktura będzie miała różne wyrównanie i różne rozmiary w różnych systemach.

Nie tylko to wypełnienie jeszcze bardziej pochłonie Twój stos, ale także dodaje niepewności związanej z brakiem możliwości przewidzenia wypełnienia z wyprzedzeniem, chyba że wiesz, jak działa system, a następnie spojrzysz na każdą strukturę, którą masz w aplikacji i obliczysz rozmiar dla tego. Przekazanie wskaźnika zajmuje przewidywalną ilość miejsca - nie ma żadnej niepewności. Rozmiar wskaźnika jest znany systemowi, jest zawsze równy, niezależnie od tego, jak wygląda struktura, a rozmiary wskaźnika są zawsze dobierane w taki sposób, że są wyrównane i nie wymagają dopełnienia.

Mecki
źródło
2
Tak, ale wypełnienie istnieje bez zależności od przekazywania struktury przez wartość lub przez odwołanie.
Ilya
2
@dkagedal: Której części z „różnych rozmiarów w różnych systemach” nie rozumiesz? Tylko dlatego, że tak jest w twoim systemie, zakładasz, że musi być taki sam dla każdego innego - właśnie dlatego nie powinieneś przekazywać wartości. Zmieniono próbkę, więc nie działa również w twoim systemie.
Mecki
2
Myślę, że komentarze Mecki dotyczące wypełniania struktur są istotne zwłaszcza dla systemów wbudowanych, w których rozmiar stosu może być problemem.
zooropa
1
Wydaje mi się, że drugą stroną argumentu jest to, że jeśli twoja struktura jest prostą strukturą (zawierającą kilka typów pierwotnych), przekazywanie przez wartość umożliwi kompilatorowi żonglowanie nią za pomocą rejestrów - podczas gdy jeśli używasz wskaźników, wszystko kończy się w pamięć, która jest wolniejsza. Jest to dość niskie i zależy w dużej mierze od architektury docelowej, jeśli którekolwiek z tych ciekawostek mają znaczenie.
kizzx2
1
O ile twoja struktura nie jest malutka lub twój procesor nie ma wielu rejestrów (a procesory Intela nie mają), dane kończą się na stosie, a to także jest pamięć i tak szybko / wolno jak każda inna pamięć. Z drugiej strony wskaźnik jest zawsze mały i jest tylko wskaźnikiem, a sam wskaźnik zwykle zawsze kończy się w rejestrze, gdy jest częściej używany.
Mecki,
9

Myślę, że twoje pytanie całkiem dobrze podsumowało sprawę.

Inną zaletą przekazywania struktur według wartości jest to, że własność pamięci jest jawna. Nie ma się co zastanawiać, czy struktura pochodzi ze sterty i kto jest odpowiedzialny za jej uwolnienie.

Darron
źródło
9

Powiedziałbym, że przekazywanie (niezbyt dużych) struktur przez wartość, zarówno jako parametry, jak i jako wartości zwracane, jest całkowicie uzasadnioną techniką. Trzeba oczywiście uważać, aby struktura była albo typu POD, albo żeby semantyka kopiowania była dobrze określona.

Aktualizacja: Przepraszam, miałem swój limit myślenia w C ++. Pamiętam czas, kiedy w C nie było legalne zwracanie struktury z funkcji, ale prawdopodobnie od tego czasu się to zmieniło. Nadal powiedziałbym, że jest to ważne, o ile wszystkie kompilatory, których oczekujesz, obsługują tę praktykę.

Greg Hewgill
źródło
Zauważ, że moje pytanie dotyczyło C, a nie C ++.
dkagedal
Zwrócenie struktury z funkcji jest prawidłowe :)
Ilya
1
Podoba mi się sugestia Llyi, aby użyć zwrotu jako kodu błędu i parametrów do zwracania danych z funkcji.
zooropa
8

Oto coś, o czym nikt nie wspomniał:

void examine_data(const char *c, size_t l)
{
    c[0] = 'l'; // compiler error
}

void examine_data(const struct blob blob)
{
    blob.ptr[0] = 'l'; // perfectly legal, quite likely to blow up at runtime
}

Składowe a const structconst, ale jeśli ten element jest wskaźnikiem (jak char *), staje się char *constraczej tym, const char *czego naprawdę chcemy. Oczywiście moglibyśmy założyć, że constjest to dokumentacja intencji i że każdy, kto ją narusza, pisze zły kod (którym są), ale to nie wystarczy dla niektórych (zwłaszcza tych, którzy spędzili cztery godziny na tropieniu przyczyny wypadek).

Alternatywą może być zrobienie a struct const_blob { const char *c; size_t l }i użycie tego, ale to raczej bałagan - pojawia się ten sam problem ze schematem nazewnictwa, który mam ze typedefwskaźnikami. Dlatego większość ludzi trzyma się tylko dwóch parametrów (lub, co bardziej prawdopodobne w tym przypadku, używa biblioteki ciągów).

Chris Lutz
źródło
Tak, jest to całkowicie legalne, a także coś, co czasami chcesz zrobić. Ale zgadzam się, że ograniczeniem rozwiązania strukturalnego jest to, że nie można wskazać wskaźników, które wskazują na const.
dkagedal,
Paskudny struct const_blobproblem z rozwiązaniem polega na tym, że nawet jeśli const_blobmają elementy różniące się od blobtylko „pośrednią-stałą”, typy struct blob*do a struct const_blob*będą traktowane jako odrębne na potrzeby ścisłej reguły aliasingu. W związku z tym, jeśli kod rzuca a blob*na a const_blob*, każdy kolejny zapis do struktury bazowej przy użyciu jednego typu po cichu unieważni wszelkie istniejące wskaźniki innego typu, tak że każde użycie wywoła niezdefiniowane zachowanie (które zwykle może być nieszkodliwe, ale może być śmiertelne) .
supercat
5

Strona 150 Samouczka składania PC na http://www.drpaulcarter.com/pcasm/ zawiera jasne wyjaśnienie, w jaki sposób C pozwala funkcji na zwrócenie struktury:

C pozwala również na użycie typu struktury jako wartości zwracanej funkcji. Oczywiście struktura nie może zostać zwrócona w rejestrze EAX. Różne kompilatory różnie radzą sobie z tą sytuacją. Typowym rozwiązaniem używanym przez kompilatory jest wewnętrzne przepisanie funkcji na taką, która przyjmuje wskaźnik struktury jako parametr. Wskaźnik służy do umieszczenia zwracanej wartości w strukturze zdefiniowanej poza wywołaną procedurą.

Używam następującego kodu C, aby zweryfikować powyższe stwierdzenie:

struct person {
    int no;
    int age;
};

struct person create() {
    struct person jingguo = { .no = 1, .age = 2};
    return jingguo;
}

int main(int argc, const char *argv[]) {
    struct person result;
    result = create();
    return 0;
}

Użyj „gcc -S”, aby wygenerować zestaw dla tego fragmentu kodu C:

    .file   "foo.c"
    .text
.globl create
    .type   create, @function
create:
    pushl   %ebp
    movl    %esp, %ebp
    subl    $16, %esp
    movl    8(%ebp), %ecx
    movl    $1, -8(%ebp)
    movl    $2, -4(%ebp)
    movl    -8(%ebp), %eax
    movl    -4(%ebp), %edx
    movl    %eax, (%ecx)
    movl    %edx, 4(%ecx)
    movl    %ecx, %eax
    leave
    ret $4
    .size   create, .-create
.globl main
    .type   main, @function
main:
    pushl   %ebp
    movl    %esp, %ebp
    subl    $20, %esp
    leal    -8(%ebp), %eax
    movl    %eax, (%esp)
    call    create
    subl    $4, %esp
    movl    $0, %eax
    leave
    ret
    .size   main, .-main
    .ident  "GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3"
    .section    .note.GNU-stack,"",@progbits

Stos przed wywołaniem create:

        +---------------------------+
ebp     | saved ebp                 |
        +---------------------------+
ebp-4   | age part of struct person | 
        +---------------------------+
ebp-8   | no part of struct person  |
        +---------------------------+        
ebp-12  |                           |
        +---------------------------+
ebp-16  |                           |
        +---------------------------+
ebp-20  | ebp-8 (address)           |
        +---------------------------+

Stos zaraz po wywołaniu create:

        +---------------------------+
        | ebp-8 (address)           |
        +---------------------------+
        | return address            |
        +---------------------------+
ebp,esp | saved ebp                 |
        +---------------------------+
Jingguo Yao
źródło
2
Są tu dwa problemy. Najbardziej oczywistym z nich jest to, że wcale nie opisuje „w jaki sposób C pozwala funkcji na zwrócenie struktury”. To tylko opisuje, jak można to zrobić na 32-bitowym sprzęcie x86, który jest jedną z najbardziej ograniczonych architektur, jeśli spojrzeć na liczbę rejestrów itp. Drugi problem polega na tym, że kompilatory C generują kod zwracający wartości jest podyktowane przez ABI (z wyjątkiem funkcji nieeksportowanych lub wbudowanych). Nawiasem mówiąc, funkcje wbudowane są prawdopodobnie jednym z miejsc, w których zwracanie struktur jest najbardziej przydatne.
dkagedal
Dzięki za poprawki. Aby uzyskać szczegółowe informacje na temat konwencji wywoływania, dobrym źródłem informacji jest en.wikipedia.org/wiki/Calling_convention .
Jingguo Yao
@dkagedal: Istotne jest nie tylko to, że x86 robi rzeczy w ten sposób, ale raczej to, że istnieje podejście „uniwersalne” (tj. to), które umożliwi kompilatorom dla każdej platformy obsługę zwrotów dowolnego typu struktury, która nie jest ' tak ogromna, że ​​rozwaliła stos. Podczas gdy kompilatory dla wielu platform będą używać innych, bardziej wydajnych środków do obsługi niektórych wartości zwracanych typu struktury, nie ma potrzeby, aby język ograniczał typy zwracane struktury do tych, które platforma może obsłużyć optymalnie.
supercat
0

Chcę tylko wskazać jedną z zalet przekazywania struktur według wartości jest to, że optymalizujący kompilator może lepiej zoptymalizować kod.

Vad
źródło