Dlaczego wiele funkcji zwracających struktury w C faktycznie zwraca wskaźniki do struktur?

49

Jaka jest zaleta zwracania wskaźnika do struktury w porównaniu do zwracania całej struktury w returninstrukcji funkcji?

Mówię o funkcjach takich jak fopeni innych funkcjach niskiego poziomu, ale prawdopodobnie istnieją funkcje wyższego poziomu, które zwracają również wskaźniki do struktur.

Uważam, że jest to raczej wybór projektowy niż tylko kwestia programowania i jestem ciekawy, aby dowiedzieć się więcej o zaletach i wadach tych dwóch metod.

Jednym z powodów, dla których pomyślałem, że byłoby korzystne, aby zwrócić wskaźnik do struktury, jest łatwiejsze określenie, czy funkcja zakończyła się niepowodzeniem przez zwrócenie NULLwskaźnika.

Zwrócenie pełnej struktury, która NULLbyłaby trudniejsza, mniej wydajna. Czy to ważny powód?

yoyo_fun
źródło
9
@ JohnR.Strohm Próbowałem i faktycznie działa. Funkcja może zwrócić strukturę ... Więc dlaczego nie został zrobiony?
yoyo_fun,
27
Wstępna standaryzacja C nie pozwalała na kopiowanie struktur ani przekazywanie ich przez wartość. Biblioteka standardowa C ma wiele zapisów z tamtej epoki, które nie byłyby dzisiaj tak pisane, np. Zajęło C11 usunięcie całkowicie źle zaprojektowanej gets()funkcji. Niektórzy programiści nadal mają awersję do kopiowania struktur, stare nawyki bardzo umierają.
amon
26
FILE*jest skutecznie nieprzezroczystym uchwytem. Kod użytkownika nie powinien dbać o jego wewnętrzną strukturę.
CodesInChaos
3
Zwrot przez referencję jest rozsądnym domyślnym ustawieniem tylko wtedy, gdy masz śmieci.
Idan Arye,
6
@ JohnR.Strohm „Bardzo starszy” profil wydaje się cofać przed 1989 rokiem ;-) - kiedy ANSI C zezwoliło na to, czego nie zrobił K&R C: Kopiowanie struktur w przypisaniach, przekazywanie parametrów i zwracanie wartości. Oryginalna książka K&R rzeczywiście wyraźnie powiedziała (parafrazuję): „możesz zrobić dokładnie dwie rzeczy ze strukturą, wziąć adres & i uzyskać dostęp do członka .”.
Peter - Przywróć Monikę

Odpowiedzi:

61

Istnieje kilka praktycznych powodów, dla których funkcje takie jak fopenwskaźniki powrotu zamiast instancji structtypów:

  1. Chcesz ukryć reprezentację structtypu przed użytkownikiem;
  2. Przydzielasz obiekt dynamicznie;
  3. Odwołujesz się do pojedynczego wystąpienia obiektu za pomocą wielu odniesień;

W przypadku typów takich FILE *jest to spowodowane tym, że nie chcesz ujawniać użytkownikowi szczegółów reprezentacji typu - FILE *obiekt służy jako nieprzezroczysty uchwyt, a Ty po prostu przekazujesz ten uchwyt do różnych procedur we / wy (i chociaż FILEjest to często zaimplementowany jako structtyp, nie musi tak być).

Możesz więc ujawnić gdzieś niekompletny struct typ w nagłówku:

typedef struct __some_internal_stream_implementation FILE;

Chociaż nie możesz zadeklarować wystąpienia niekompletnego typu, możesz zadeklarować do niego wskaźnik. Więc mogę utworzyć FILE *i przypisać do niego poprzez fopen, freopenitp, ale nie mogą bezpośrednio manipulować obiekt to wskazuje.

Jest również prawdopodobne, że fopenfunkcja przydziela FILEobiekt dynamicznie, używając malloclub podobnie. W takim przypadku warto zwrócić wskaźnik.

Wreszcie możliwe jest, że przechowujesz jakiś stan w structobiekcie i musisz udostępnić ten stan w kilku różnych miejscach. Jeśli zwrócisz instancje tego structtypu, będą one oddzielnymi obiektami w pamięci i ostatecznie zsynchronizują się. Zwracając wskaźnik do jednego obiektu, wszyscy odnoszą się do tego samego obiektu.

John Bode
źródło
31
Szczególną zaletą używania wskaźnika jako typu nieprzezroczystego jest to, że sama struktura może zmieniać się między wersjami bibliotek i nie trzeba ponownie kompilować wywołujących.
Barmar
6
@Barmar: Rzeczywiście, ABI Stabilność jest ogromny punkt sprzedaży C, a to nie będzie tak stabilny bez mętny wskaźnik.
Matthieu M.,
37

Istnieją dwa sposoby „zwrócenia struktury”. Możesz zwrócić kopię danych lub referencję (wskaźnik) do niej. Generalnie preferowane jest zwrócenie (i ogólnie przekazanie) wskaźnika z kilku powodów.

Po pierwsze, kopiowanie struktury zajmuje dużo więcej czasu procesora niż kopiowanie wskaźnika. Jeśli jest to czynność często wykonywana przez kod, może powodować zauważalną różnicę wydajności.

Po drugie, bez względu na to, ile razy kopiujesz wskaźnik, nadal wskazuje on na tę samą strukturę w pamięci. Wszystkie modyfikacje zostaną odzwierciedlone w tej samej strukturze. Ale jeśli skopiujesz samą strukturę, a następnie dokonasz modyfikacji, zmiana pojawi się tylko na tej kopii . Kod zawierający inną kopię nie zobaczy zmiany. Czasami, bardzo rzadko, tego właśnie chcesz, ale przez większość czasu tak nie jest i może powodować błędy, jeśli pomylisz się.

Mason Wheeler
źródło
54
Wada zwracania przez wskaźnik: teraz musisz śledzić własność tego obiektu i ewentualnie go uwolnić. Ponadto pośrednie używanie wskaźnika może być bardziej kosztowne niż szybka kopia. Jest tu wiele zmiennych, więc używanie wskaźników nie jest ogólnie lepsze.
amon
17
Ponadto wskaźniki w dzisiejszych czasach to 64 bity na większości platform komputerowych i serwerowych. W mojej karierze widziałem więcej niż kilka struktur, które zmieściłyby się w 64 bitach. Nie zawsze można więc powiedzieć, że kopiowanie wskaźnika kosztuje mniej niż kopiowanie struktury.
Solomon Slow
37
To w większości dobra odpowiedź, ale czasami nie zgadzam się z tą częścią , bardzo rzadko, właśnie tego chcesz, ale przez większość czasu nie jest - wręcz przeciwnie. Zwrócenie wskaźnika pozwala na kilka rodzajów niepożądanych efektów ubocznych i kilka nieprzyjemnych sposobów na błędne posiadanie wskaźnika. W przypadkach, gdy czas procesora nie jest tak ważny, wolę wariant kopiowania, jeśli jest to opcja, jest znacznie mniej podatny na błędy.
Doc Brown,
6
Należy zauważyć, że tak naprawdę dotyczy to tylko zewnętrznych interfejsów API. W przypadku funkcji wewnętrznych każdy nawet marginalnie kompetentny kompilator ostatnich dziesięcioleci przepisze funkcję, która zwraca dużą strukturę, aby przyjąć wskaźnik jako dodatkowy argument i skonstruować tam bezpośrednio obiekt. Argumenty „niezmienne” i „zmienne” były wysuwane dość często, ale myślę, że wszyscy możemy się zgodzić, że twierdzenie, że niezmienne struktury danych prawie nigdy nie są tym, czego chcesz, nie jest prawdą.
Voo,
6
Można również wspomnieć o ścianach przeciwpożarowych kompilacji jako o profesjonalnym wskaźniku. W dużych programach z szeroko udostępnianymi nagłówkami niekompletne typy z funkcjami zapobiegają konieczności ponownej kompilacji za każdym razem, gdy zmienia się szczegół implementacji. Lepsze zachowanie kompilacji jest faktycznie efektem ubocznym enkapsulacji, który osiąga się, gdy interfejs i implementacja są rozdzielone. Zwracanie (i przekazywanie, przypisywanie) według wartości wymaga informacji o implementacji.
Peter - Przywróć Monikę
12

Oprócz innych odpowiedzi czasem warto zwrócić niewielką struct wartość. Na przykład można zwrócić parę jednych danych i związany z nimi kod błędu (lub sukcesu).

Na przykład fopenzwraca tylko jedno dane (otwarte FILE*), aw przypadku błędu podaje kod błędu przez errnozmienną pseudo-globalną. Ale być może lepiej byłoby zwrócić jeden structz dwóch elementów: FILE*uchwyt i kod błędu (który zostałby ustawiony, jeśli uchwyt pliku jest NULL). Z przyczyn historycznych tak nie jest (a błędy zgłaszane są przez errnoglobalny, który dziś jest makrem).

Zauważ, że język Go ma niezłą notację zwracającą dwie (lub kilka) wartości.

Zauważ też, że w Linux / x86-64 ABI i konwencje wywoływania (patrz strona x86-psABI ) określają, że jeden structz dwóch elementów skalarnych (np. Wskaźnik i liczba całkowita lub dwa wskaźniki lub dwie liczby całkowite) jest zwracany przez dwa rejestry (a to jest bardzo wydajne i nie przechodzi przez pamięć).

Tak więc w nowym kodzie C zwracanie małego C structmoże być bardziej czytelne, przyjazne dla wątków i bardziej wydajne.

Basile Starynkevitch
źródło
Faktycznie małe struktury są pakowane w rdx:rax. Więc struct foo { int a,b; };jest zwracany zapakowany rax(np. Z shift / lub) i musi być rozpakowany za pomocą shift / mov. Oto przykład na Godbolt . Ale x86 może używać niskich 32 bitów 64-bitowego rejestru do operacji 32-bitowych bez dbania o wysokie bity, więc zawsze jest to złe, ale zdecydowanie gorsze niż używanie 2 rejestrów przez większość czasu dla struktur 2-członowych.
Peter Cordes,
Powiązane: bugs.llvm.org/show_bug.cgi?id=34840 std::optional<int> zwraca wartość logiczną w górnej połowie rax, więc potrzebujesz 64-bitowej stałej maski do jej przetestowania test. Lub możesz użyć bt. Ale to jest do bani dla dzwoniącego i odbierającego w porównaniu do używania dl, które kompilatory powinny zrobić dla „prywatnych” funkcji. Związane również: libstdc ++ 's std::optional<T>nie jest trywialnie-copyable nawet gdy T jest więc zawsze zwraca poprzez ukryty wskaźnik: stackoverflow.com/questions/46544019/... . (libc ++ można w prosty sposób kopiować)
Peter Cordes,
@PeterCordes: twoje powiązane sprawy to C ++, a nie C
Basile Starynkevitch
Ups, racja. Cóż to samo miałoby zastosowanie dokładnie do struct { int a; _Bool b; };w C, jeśli rozmówca chciał przetestować logiczną, ponieważ trywialnie-copyable elemencie C ++ używać tego samego ABI jako C
Peter Cordes
1
Klasyczny przykładdiv_t div()
chux - Przywróć Monikę
6

Jesteś na dobrej drodze

Oba wymienione przez Ciebie powody są ważne:

Jednym z powodów, dla których pomyślałem, że byłoby korzystne, aby zwrócić wskaźnik do struktury, jest łatwiejsze określenie, czy funkcja zakończyła się niepowodzeniem przez zwrócenie wskaźnika NULL.

Zwracanie PEŁNEJ struktury, która ma wartość NULL, byłoby trudniejsze lub mniej wydajne. Czy to ważny powód?

Jeśli masz teksturę (na przykład) gdzieś w pamięci i chcesz odwoływać się do tej tekstury w kilku miejscach w programie; nie byłoby rozsądnie tworzyć kopii za każdym razem, gdy chciałbyś się do niej odwoływać. Zamiast tego, jeśli po prostu przekażesz wskaźnik, aby odnieść się do tekstury, twój program będzie działał znacznie szybciej.

Największym powodem jest jednak dynamiczna alokacja pamięci. Często podczas kompilacji programu nie masz pewności, ile dokładnie pamięci potrzebujesz na określone struktury danych. Gdy tak się stanie, ilość pamięci, którą należy użyć, zostanie określona w czasie wykonywania. Możesz zażądać pamięci za pomocą „malloc”, a następnie zwolnić ją, gdy skończysz używać „free”.

Dobrym przykładem tego jest czytanie z pliku określonego przez użytkownika. W takim przypadku nie masz pojęcia, jak duży może być plik podczas kompilacji programu. Możesz tylko dowiedzieć się, ile pamięci potrzebujesz, gdy program faktycznie działa.

Zarówno malloc, jak i bezpłatne wskaźniki powrotu do lokalizacji w pamięci. Funkcje korzystające z dynamicznego przydziału pamięci zwracają wskaźniki do miejsca, w którym utworzyły swoje struktury w pamięci.

Ponadto w komentarzach widzę pytanie, czy możesz zwrócić strukturę z funkcji. Rzeczywiście możesz to zrobić. Następujące powinny działać:

struct s1 {
   int integer;
};

struct s1 f(struct s1 input){
   struct s1 returnValue = xinput
   return returnValue;
}

int main(void){
   struct s1 a = { 42 };
   struct s1 b= f(a);

   return 0;
}
Ryan
źródło
Jak można nie wiedzieć, ile pamięci będzie potrzebować określona zmienna, jeśli masz już zdefiniowany typ struktury?
yoyo_fun,
9
@JenniferAnderson C ma pojęcie niekompletnych typów: nazwa typu może być zadeklarowana, ale jeszcze nie zdefiniowana, więc jej rozmiar jest niedostępny. Nie mogę zadeklarować zmiennych tego typu, ale mogę zadeklarować wskaźniki do tego typu, np struct incomplete* foo(void). W ten sposób mogę zadeklarować funkcje w nagłówku, ale tylko zdefiniować struktury w pliku C, co pozwala na enkapsulację.
amon
@amon Więc w ten sposób deklarowanie nagłówków funkcji (prototypów / podpisów) przed zadeklarowaniem ich działania odbywa się w C? I to samo można zrobić ze strukturami i związkami w C
yoyo_fun,
@JenniferAnderson deklarujesz prototypy funkcji (funkcje bez treści) w plikach nagłówka, a następnie możesz wywoływać te funkcje w innym kodzie, nie znając treści funkcji, ponieważ kompilator musi tylko wiedzieć, jak ustawić argumenty i jak zaakceptować zwracana wartość. Zanim połączysz program, musisz znać definicję funkcji (tj. Z ciałem), ale musisz ją przetworzyć tylko raz. Jeśli używasz nieprostego typu, musi on również znać jego strukturę, ale wskaźniki często mają ten sam rozmiar i nie ma znaczenia dla użycia prototypu.
simpleuser,
6

Coś w rodzaju FILE*kodu nie jest tak naprawdę wskaźnikiem do struktury, jeśli chodzi o kod klienta, ale jest raczej formą nieprzezroczystego identyfikatora powiązanego z jakimś innym bytem, ​​takim jak plik. Kiedy program wywołuje fopen, na ogół nie obchodzi go żadna zawartość zwracanej struktury - wszystko, na czym mu zależy, to to, że inne funkcje freadzrobią wszystko, co trzeba z tym zrobić.

Jeśli standardowa biblioteka przechowuje FILE*informacje o np. Bieżącej pozycji odczytu w tym pliku, wywołanie do freadmusiałoby być w stanie zaktualizować te informacje. Mając freadotrzymywać wskaźnik do FILEmarek takie proste. Gdyby freadzamiast tego otrzymał FILE, nie miałby możliwości zaktualizowania FILEobiektu przechowywanego przez dzwoniącego.

supercat
źródło
3

Ukrywanie informacji

Jaka jest zaleta zwracania wskaźnika do struktury w porównaniu do zwracania całej struktury w instrukcji return funkcji?

Najczęstszym z nich jest ukrywanie informacji . C nie ma, powiedzmy, możliwości tworzenia pól structprywatnych, nie mówiąc już o zapewnieniu metod dostępu do nich.

Jeśli więc chcesz silnie uniemożliwić programistom wyświetlanie i manipulowanie zawartością pointee, na przykład FILE, jedynym sposobem jest zapobieganie narażeniu ich na definicję poprzez traktowanie wskaźnika jako nieprzezroczystego, którego rozmiar pointee i definicje są nieznane światu zewnętrznemu. Definicja FILEbędzie wtedy widoczna tylko dla tych, którzy wykonują operacje wymagające jej definicji, na przykład fopen, podczas gdy tylko deklaracja struktury będzie widoczna dla nagłówka publicznego.

Kompatybilność binarna

Ukrywanie definicji struktury może również pomóc w zapewnieniu oddechu w celu zachowania zgodności binarnej w interfejsach API dylib. Pozwala to implementatorom bibliotek zmieniać pola w nieprzezroczystej strukturze bez naruszania binarnej kompatybilności z tymi, którzy korzystają z biblioteki, ponieważ charakter ich kodu musi tylko wiedzieć, co mogą zrobić ze strukturą, a nie jej wielkości lub pól to ma.

Jako przykład mogę dziś uruchomić niektóre starożytne programy zbudowane w czasach Windows 95 (nie zawsze idealnie, ale zaskakująco wiele nadal działa). Istnieje prawdopodobieństwo, że część kodu tych starożytnych plików binarnych używała nieprzezroczystych wskaźników do struktur, których rozmiar i zawartość zmieniły się od czasów Windows 95. Jednak programy nadal działają w nowych wersjach systemu Windows, ponieważ nie były narażone na zawartość tych struktur. Podczas pracy nad biblioteką, w której ważna jest zgodność binarna, to, na co klient nie jest narażony, zwykle może się zmieniać bez naruszania zgodności wstecznej.

Wydajność

Zwrócenie pełnej struktury, która ma wartość NULL, byłoby trudniejsze lub mniej wydajne. Czy to ważny powód?

Zazwyczaj jest mniej wydajny, zakładając, że ten typ może praktycznie pasować i być alokowany na stosie, chyba że za sceną jest używany znacznie mniej uogólniony alokator pamięci malloc, niż , jak już przydzielona pamięć puli alokatora o stałej wielkości zamiast zmiennej. W tym przypadku jest to kompromis w zakresie bezpieczeństwa, który najprawdopodobniej pozwala twórcom bibliotek zachować niezmienniki (gwarancje koncepcyjne) FILE.

Nie jest to tak ważny powód, przynajmniej z punktu widzenia wydajności, aby fopenzwrócić wskaźnik, ponieważ jedynym powodem, dla którego zwraca NULLto niepowodzenie otwarcia pliku. Byłoby to optymalizowanie wyjątkowego scenariusza w zamian za spowolnienie wszystkich ścieżek wykonywania typowych przypadków. W niektórych przypadkach może istnieć uzasadniony powód produktywności, aby uprościć projekty, aby zwracały wskaźniki i pozwalały NULLna zwrot w określonych warunkach.

W przypadku operacji na plikach narzut jest stosunkowo dość trywialny w porównaniu z samymi operacjami na plikach, a instrukcji i fclosetak nie można uniknąć. Więc to nie tak, że możemy zaoszczędzić klientowi kłopotów z uwolnieniem (zamknięciem) zasobu poprzez ujawnienie definicji FILEi zwrócenie jej wartości fopenlub oczekiwanie znacznego wzrostu wydajności, biorąc pod uwagę względny koszt samych operacji na plikach, aby uniknąć przydziału sterty .

Hotspoty i poprawki

W innych przypadkach jednak sprofilowałem wiele marnotrawczego kodu C w starszych bazach kodów z punktami dostępowymi malloci niepotrzebnymi obowiązkowymi brakami pamięci podręcznej w wyniku zbyt częstego używania tej praktyki z nieprzejrzystymi wskaźnikami i niepotrzebnego przydzielania zbyt wielu rzeczy na stosie, czasem w duże pętle.

Alternatywną praktyką, której używam, jest ujawnianie definicji struktur, nawet jeśli klient nie ma zamiaru ich modyfikować, używając standardu konwencji nazewnictwa, aby poinformować, że nikt inny nie powinien dotykać pól:

struct Foo
{
   /* priv_* indicates that you shouldn't tamper with these fields! */
   int priv_internal_field;
   int priv_other_one;
};

struct Foo foo_create(void);
void foo_destroy(struct Foo* foo);
void foo_something(struct Foo* foo);

Jeśli w przyszłości pojawią się problemy z kompatybilnością binarną, uważam, że wystarczające jest rezerwowanie dodatkowej przestrzeni na przyszłe cele, na przykład:

struct Foo
{
   /* priv_* indicates that you shouldn't tamper with these fields! */
   int priv_internal_field;
   int priv_other_one;

   /* reserved for possible future uses (emergency backup plan).
     currently just set to null. */
   void* priv_reserved;
};

Ta zarezerwowana przestrzeń jest trochę marnotrawstwem, ale może uratować życie, jeśli w przyszłości okaże się, że musimy dodać więcej danych Foobez niszczenia plików binarnych, które korzystają z naszej biblioteki.

Moim zdaniem ukrywanie informacji i zgodność binarna to zazwyczaj jedyny słuszny powód, aby zezwolić na alokację sterty struktur oprócz struktur o zmiennej długości (które zawsze tego wymagałyby, lub przynajmniej byłyby trochę niewygodne w użyciu, gdyby klient musiał przydzielić pamięć na stosie w sposób VLA do alokacji VLS). Nawet duże struktury są często tańsze w zwracaniu według wartości, jeśli oznacza to, że oprogramowanie działa znacznie bardziej z gorącą pamięcią na stosie. I nawet jeśli nie byłyby tańsze, by zwracać wartość po stworzeniu, można po prostu to zrobić:

int foo_create(struct Foo* foo);
...
/* In the client code: */
struct Foo foo;
if (foo_create(&foo))
{
    foo_something(&foo);
    foo_destroy(&foo);
}

... aby zainicjować Fooze stosu bez możliwości zbędnej kopii. Lub klient ma nawet swobodę alokacji Foona stercie, jeśli z jakiegoś powodu tego chce.


źródło