Czy w C ++ płacę za to, czego nie jem?

170

Rozważmy następujące przykłady Hello World w C i C ++:

main.c

#include <stdio.h>

int main()
{
    printf("Hello world\n");
    return 0;
}

main.cpp

#include <iostream>

int main()
{
    std::cout<<"Hello world"<<std::endl;
    return 0;
}

Kiedy kompiluję je w godbolt do assemblacji, rozmiar kodu C wynosi tylko 9 linii ( gcc -O3):

.LC0:
        .string "Hello world"
main:
        sub     rsp, 8
        mov     edi, OFFSET FLAT:.LC0
        call    puts
        xor     eax, eax
        add     rsp, 8
        ret

Ale rozmiar kodu C ++ to 22 linie ( g++ -O3):

.LC0:
        .string "Hello world"
main:
        sub     rsp, 8
        mov     edx, 11
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&)
        xor     eax, eax
        add     rsp, 8
        ret
_GLOBAL__sub_I_main:
        sub     rsp, 8
        mov     edi, OFFSET FLAT:_ZStL8__ioinit
        call    std::ios_base::Init::Init() [complete object constructor]
        mov     edx, OFFSET FLAT:__dso_handle
        mov     esi, OFFSET FLAT:_ZStL8__ioinit
        mov     edi, OFFSET FLAT:_ZNSt8ios_base4InitD1Ev
        add     rsp, 8
        jmp     __cxa_atexit

... który jest znacznie większy.

Wiadomo, że w C ++ płacisz za to, co jesz. Więc w takim razie za co płacę?

Saher
źródło
3
Komentarze nie służą do rozszerzonej dyskusji; ta rozmowa została przeniesiona do czatu .
Samuel Liew
26
Nigdy nie słyszałem terminu eatzwiązanego z C ++. Myślę, że masz na myśli: „Płacisz tylko za to, czego używasz ”?
Giacomo Alzetta
7
@GiacomoAlzetta, ... to kolokwializm, wykorzystujący koncepcję bufetu all-you-can-eat. Używanie bardziej precyzyjnego terminu jest z pewnością lepsze w przypadku globalnej publiczności, ale jako native American English, tytuł ma dla mnie sens.
Charles Duffy,
5
@ trolley813 Wycieki pamięci nie mają nic wspólnego z ofertą i pytaniem OP. Punkt „Płacisz tylko za to, z czego korzystasz” / „Nie płacisz za to, czego nie używasz” polega na stwierdzeniu, że jeśli nie używasz określonej funkcji / abstrakcji, żaden wynik nie zostanie osiągnięty. Wycieki pamięci w ogóle nie mają z tym nic wspólnego, a to tylko pokazuje, że termin eatjest bardziej niejednoznaczny i należy go unikać.
Giacomo Alzetta,

Odpowiedzi:

60

To, za co płacisz, to wywołanie ciężkiej biblioteki (nie tak ciężkiej jak drukowanie na konsoli). Inicjalizujesz ostreamobiekt. Jest trochę ukrytej pamięci. Wtedy wzywasz, std::endlco nie jest synonimem \n. PlikiostreamBiblioteka pomaga dostosowanie wielu ustawień i wprowadzenie obciążenia procesora zamiast programatora. Za to płacisz.

Przejrzyjmy kod:

.LC0:
        .string "Hello world"
main:

Inicjalizacja obiektu ostream + cout

    sub     rsp, 8
    mov     edx, 11
    mov     esi, OFFSET FLAT:.LC0
    mov     edi, OFFSET FLAT:_ZSt4cout
    call    std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)

Dzwonię coutponownie, aby wydrukować nową linię i opróżnić

    mov     edi, OFFSET FLAT:_ZSt4cout
    call    std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&)
    xor     eax, eax
    add     rsp, 8
    ret

Inicjalizacja pamięci statycznej:

_GLOBAL__sub_I_main:
        sub     rsp, 8
        mov     edi, OFFSET FLAT:_ZStL8__ioinit
        call    std::ios_base::Init::Init() [complete object constructor]
        mov     edx, OFFSET FLAT:__dso_handle
        mov     esi, OFFSET FLAT:_ZStL8__ioinit
        mov     edi, OFFSET FLAT:_ZNSt8ios_base4InitD1Ev
        add     rsp, 8
        jmp     __cxa_atexit

Niezbędne jest również rozróżnienie między językiem a biblioteką.

A tak przy okazji, to tylko część historii. Nie wiesz, co jest napisane w wywołanych funkcjach.

Wysypka
źródło
5
Jako dodatkowa uwaga, dokładne testowanie pokaże, że dołączenie programu w C ++ do "ios_base :: sync_with_stdio (false);" i „cin.tie (NULL);” sprawi, że cout będzie szybszy niż printf (Printf ma narzut ciągu formatującego). Pierwsza eliminuje obciążenie związane z upewnianiem się, że cout; printf; coutzapisuje w kolejności (ponieważ mają własne bufory). Drugi spowoduje desynchronizację couti cin, w pierwszej kolejności , cout; cinpotencjalnie poprosi użytkownika o informacje. Płukanie zmusi go do synchronizacji tylko wtedy, gdy jest to naprawdę potrzebne.
Nicholas Pipitone
Cześć Nicholas, bardzo dziękuję za dodanie tych przydatnych uwag.
Arash
„konieczne jest rozróżnienie między językiem a biblioteką”: Cóż, tak, ale standardowa biblioteka dostarczana z językiem jest jedyną dostępną wszędzie, więc jest używana wszędzie (i tak, standardowa biblioteka C jest częścią specyfikacji C ++, więc może być używany w razie potrzeby). Jeśli chodzi o „Nie wiesz, co jest napisane w funkcjach, które wywołujesz”: możesz łączyć statycznie, jeśli naprawdę chcesz wiedzieć, i rzeczywiście kod wywołujący, który badasz, jest prawdopodobnie nieistotny.
Peter - Przywróć Monikę
211

Więc w takim razie za co płacę?

std::cout jest potężniejszy i bardziej skomplikowany niż printf . Obsługuje takie elementy, jak ustawienia regionalne, stanowe flagi formatowania i inne.

Jeśli ich nie potrzebujesz, użyj std::printflub std::puts- są dostępne w <cstdio>.


Wiadomo, że w C ++ płacisz za to, co jesz.

Chcę również wyjaśnić, że C ++ ! = Biblioteka standardowa C ++. Biblioteka standardowa ma być uniwersalna i „wystarczająco szybka”, ale często będzie wolniejsza niż wyspecjalizowana implementacja tego, czego potrzebujesz.

Z drugiej strony język C ++ stara się umożliwić pisanie kodu bez ponoszenia niepotrzebnych dodatkowych ukrytych kosztów (np. Opt-in virtual, brak czyszczenia pamięci).

Vittorio Romeo
źródło
4
+1 za powiedzenie, że Biblioteka standardowa ma być uniwersalna i „wystarczająco szybka”, ale często będzie wolniejsza niż wyspecjalizowana implementacja tego, czego potrzebujesz. Wydaje się, że wielu beztrosko korzysta z komponentów STL, nie biorąc pod uwagę wpływu na wydajność w porównaniu z własnymi.
Craig Estey
7
@Craig OTOH wiele części standardowej biblioteki jest zwykle szybszych i bardziej poprawnych niż te, które można by stworzyć.
Peter - Przywróć Monikę
2
@ PeterA.Schneider OTOH, kiedy wersja STL jest 20x-30x wolniejsza, toczenie własnej jest dobrą rzeczą. Zobacz moją odpowiedź tutaj: codereview.stackexchange.com/questions/191747/… W tym miejscu inni zasugerowali również [przynajmniej częściowo] zrobienie własnego.
Craig Estey
1
@CraigEstey Wektor jest (poza początkową alokacją dynamiczną, która może być znacząca, w zależności od tego, ile pracy zostanie ostatecznie wykonana z daną instancją) nie mniej wydajna niż tablica C; jest zaprojektowany , aby nie być. Należy uważać, aby nie kopiować go, początkowo rezerwować wystarczającą ilość miejsca itp., Ale wszystko to należy również zrobić z tablicą i mniej bezpiecznie. W odniesieniu do twojego połączonego przykładu: Tak, wektor wektorów będzie (o ile nie został zoptymalizowany), będzie obciążony dodatkowym kierunkiem w porównaniu z tablicą 2D, ale zakładam, że wydajność 20x nie jest zakorzeniona w algorytmie.
Peter - Przywróć Monikę
174

Nie porównujesz C i C ++. Porównujesz printfi std::cout, które są zdolne do różnych rzeczy (ustawienia regionalne, formatowanie stanowe itp.).

Spróbuj użyć następującego kodu do porównania. Godbolt generuje ten sam zestaw dla obu plików (testowany z gcc 8.2, -O3).

main.c:

#include <stdio.h>

int main()
{
    int arr[6] = {1, 2, 3, 4, 5, 6};
    for (int i = 0; i < 6; ++i)
    {
        printf("%d\n", arr[i]);
    }
    return 0;
}

main.cpp:

#include <array>
#include <cstdio>

int main()
{
    std::array<int, 6> arr {1, 2, 3, 4, 5, 6};
    for (auto x : arr)
    {
        std::printf("%d\n", x);
    }
}
pschill
źródło
Pozdrawiam za pokazanie równoważnego kodu i wyjaśnienie przyczyny.
HackSlash
134

Twoje aukcje rzeczywiście porównują jabłka i pomarańcze, ale nie z powodu podanego w większości innych odpowiedzi.

Sprawdźmy, co właściwie robi Twój kod:

DO:

  • wydrukuj pojedynczy ciąg, "Hello world\n"

C ++:

  • strumieniować ciąg "Hello world"dostd::cout
  • strumieniować std::endlmanipulator dostd::cout

Wygląda na to, że twój kod C ++ wykonuje dwa razy więcej pracy. Dla uczciwego porównania powinniśmy połączyć to:

#include <iostream>

int main()
{
    std::cout<<"Hello world\n";
    return 0;
}

… I nagle twój kod asemblera mainwygląda bardzo podobnie do C:

main:
        sub     rsp, 8
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
        xor     eax, eax
        add     rsp, 8
        ret

W rzeczywistości możemy porównać kod C i C ++ wiersz po wierszu i jest bardzo niewiele różnic :

sub     rsp, 8                      sub     rsp, 8
mov     edi, OFFSET FLAT:.LC0   |   mov     esi, OFFSET FLAT:.LC0
                                >   mov     edi, OFFSET FLAT:_ZSt4cout
call    puts                    |   call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
xor     eax, eax                    xor     eax, eax
add     rsp, 8                      add     rsp, 8
ret                                 ret

Jedyną prawdziwą różnicą jest to, że w C ++ wywołujemy operator <<z dwoma argumentami ( std::couti łańcuchem). Moglibyśmy usunąć nawet tę niewielką różnicę, używając bliższego C ekwiwalent:, fprintfktóry również ma pierwszy argument określający strumień.

To pozostawia kod asemblera _GLOBAL__sub_I_main, który jest generowany dla C ++, ale nie dla C. To jest jedyny prawdziwy narzut, który jest widoczny w tym zestawieniu (jest więcej, niewidoczny narzut dla obu języków, oczywiście). Ten kod wykonuje jednorazową konfigurację niektórych funkcji biblioteki standardowej C ++ na początku programu C ++.

Ale, jak wyjaśniono w innych odpowiedziach, istotna różnica między tymi dwoma programami nie zostanie znaleziona w wyniku montażu mainfunkcji, ponieważ całe ciężkie podnoszenie odbywa się za kulisami.

Konrad Rudolph
źródło
21
Nawiasem mówiąc, należy również skonfigurować środowisko wykonawcze C , a dzieje się to w wywołanej funkcji, _startale jej kod jest częścią biblioteki wykonawczej C. W każdym razie dzieje się tak zarówno dla C, jak i C ++.
Konrad Rudolph
2
@Deduplicator: Właściwie domyślnie biblioteka iostream nie wykonuje żadnego buforowania, std::couta zamiast tego przekazuje I / O do implementacji stdio (która używa własnych mechanizmów buforowania). W szczególności, po podłączeniu (co jest znane) do interaktywnego terminala, domyślnie nigdy nie zobaczysz w pełni buforowanych danych wyjściowych podczas pisania do std::cout. Musisz jawnie wyłączyć synchronizację ze stdio, jeśli chcesz, aby biblioteka iostream używała własnych mechanizmów buforowania std::cout.
6
@KonradRudolph: Właściwie printfnie trzeba tu przepłukiwać strumieni. W rzeczywistości, w typowym przypadku użycia (wyjście przekierowane do pliku), zwykle zauważysz, że printfinstrukcja nie jest opróżniana. Tylko wtedy, gdy wyjście jest buforowane liniowo lub niebuforowane, printfwyzwala flush.
2
@PeterCordes: Racja, nie możesz blokować za pomocą niewypełnionych buforów wyjściowych, ale możesz napotkać zdziwienie, gdy program zaakceptował twoje dane wejściowe i maszerował dalej bez wyświetlania oczekiwanego wyniku. Wiem o tym, ponieważ miałem okazję debugować komunikat „Pomoc, mój program zawiesił się podczas wprowadzania danych, ale nie mogę zrozumieć, dlaczego!” przez kilka dni inny programista sprawiał, że pasowało.
2
@PeterCordes: Argumentem, który przedstawiam, jest „napisz, co masz na myśli” - znaki nowej linii są odpowiednie, gdy masz na myśli, że dane wyjściowe mają być w końcu dostępne, a endl jest odpowiednie, gdy masz na myśli natychmiastowe udostępnienie wyników.
53

Wiadomo, że w C ++ płacisz za to, co jesz. Więc w takim razie za co płacę?

To proste. Płacisz za std::cout. „Płacisz tylko za to, co jesz” nie oznacza „zawsze dostajesz najlepsze ceny”. Jasne, printfjest tańsze. Można argumentować, że std::coutjest bezpieczniejszy i bardziej wszechstronny, stąd jego większy koszt jest uzasadniony (kosztuje więcej, ale zapewnia większą wartość), ale to mija się z celem. Nie używasz printf, używasz std::cout, więc płacisz za używanie std::cout. Nie płacisz za używanie printf.

Dobrym przykładem są funkcje wirtualne. Funkcje wirtualne mają pewne wymagania związane z kosztami czasu działania i przestrzenią - ale tylko wtedy, gdy faktycznie ich używasz. Jeśli nie korzystasz z funkcji wirtualnych, nic nie płacisz.

Kilka uwag

  1. Nawet jeśli kod C ++ wymaga większej liczby instrukcji asemblacyjnych, nadal jest to garść instrukcji, a wszelkie narzuty wydajnościowe są prawdopodobnie ograniczone przez rzeczywiste operacje we / wy.

  2. Właściwie czasami jest to nawet lepsze niż „w C ++ płacisz za to, co jesz”. Na przykład kompilator może wywnioskować, że wywołanie funkcji wirtualnej nie jest potrzebne w pewnych okolicznościach i przekształcić to w wywołanie niewirtualne. Oznacza to, że możesz bezpłatnie korzystać z funkcji wirtualnych . Czy to nie wspaniałe?

el.pescado
źródło
6
Funkcje wirtualne nie są dostępne za darmo. Nadal musisz ponieść koszty pierwszego ich napisania, a następnie debugowania transformacji twojego kodu przez kompilator, gdy nie pasuje on do twojego pomysłu na to, co miał zrobić.
alephzero
2
@alephzero Nie jestem pewien, czy porównanie kosztów rozwoju z kosztami wydajności jest szczególnie istotne.
Taka świetna okazja na zmarnowaną kalamburkę ... Mogłeś użyć słowa „kalorie” zamiast „cena”. Na tej podstawie można powiedzieć, że C ++ jest grubszy niż C. Lub przynajmniej ... konkretny kod, o którym mowa (jestem uprzedzony do C ++ na korzyść C, więc nie mogę uczciwie wyjść poza). Niestety. @Bilkokuya Może nie mieć znaczenia we wszystkich przypadkach, ale z pewnością jest to coś, czego nie należy lekceważyć. Zatem jest to ogólnie istotne.
Pryftan
46

"Lista asemblera dla printf" NIE jest przeznaczona dla printf, ale dla puts (rodzaj optymalizacji kompilatora?); printf jest o wiele bardziej skomplikowany niż puts ... nie zapomnij!

Álvaro Gustavo López
źródło
13
Jak dotąd jest to najlepsza odpowiedź, ponieważ wszyscy inni są zawieszeni na czerwonym tropie o std::coutwnętrznościach, których nie widać na liście montażowej.
Konrad Rudolph
12
Listing zestawu jest dla wywołania puts , które wygląda identycznie jak wywołanie, printfjeśli przekażesz tylko jeden ciąg formatu i zero dodatkowych argumentów. (poza tym, że będzie również, xor %eax,%eaxponieważ przekazujemy zero argumentów FP w rejestrach do funkcji wariadycznej). Żadne z nich nie jest implementacją, po prostu przekazuje wskaźnik do ciągu znaków do funkcji bibliotecznej. Ale tak, optymalizacja printfpod putskątem jest czymś, co gcc robi dla formatów, które mają tylko "%s"lub gdy nie ma konwersji, a ciąg kończy się znakiem nowej linii.
Peter Cordes
45

Widzę tutaj kilka ważnych odpowiedzi, ale zamierzam zagłębić się w szczegóły.

Przejdź do poniższego podsumowania, aby znaleźć odpowiedź na swoje główne pytanie, jeśli nie chcesz przeglądać całej ściany tekstu.


Abstrakcja

Więc w takim razie za co płacę?

Płacisz za abstrakcję . Możliwość pisania prostszego i bardziej przyjaznego dla człowieka kodu ma swoją cenę. W C ++, który jest językiem zorientowanym obiektowo, prawie wszystko jest obiektem. Kiedy używasz dowolnego przedmiotu, pod maską zawsze będą się dziać trzy główne rzeczy:

  1. Tworzenie obiektów, w zasadzie alokacja pamięci dla samego obiektu i jego danych.
  2. Inicjalizacja obiektu (zwykle jakąś init()metodą). Zwykle alokacja pamięci odbywa się pod maską, jako pierwsza rzecz na tym etapie.
  3. Zniszczenie obiektu (nie zawsze).

Nie widzisz tego w kodzie, ale za każdym razem, gdy używasz obiektu, wszystkie trzy powyższe rzeczy muszą jakoś się wydarzyć. Gdybyś zrobił wszystko ręcznie, kod byłby oczywiście znacznie dłuższy.

Teraz abstrakcję można wydajnie tworzyć bez dodawania narzutu: wbudowywanie metod i inne techniki mogą być używane zarówno przez kompilatory, jak i programistów, aby usunąć narzuty abstrakcji, ale to nie jest Twój przypadek.

Co tak naprawdę dzieje się w C ++?

Oto on, w podziale:

  1. std::ios_baseKlasa jest inicjowany, który jest klasą bazową dla Everything I / O pokrewnych.
  2. std::coutObiekt jest inicjowany.
  3. Twój ciąg jest ładowany i przekazywany do std::__ostream_insert, co (jak już zorientowałeś się po nazwie) jest metodą std::cout(w zasadzie <<operator), która dodaje ciąg do strumienia.
  4. cout::endljest również przekazywany do std::__ostream_insert.
  5. __std_dso_handlejest przekazywana do __cxa_atexit, która jest funkcją globalną odpowiedzialną za „czyszczenie” przed wyjściem z programu. __std_dso_handlesama jest wywoływana przez tę funkcję do zwalniania i niszczenia pozostałych obiektów globalnych.

Więc używając C == nie płacisz za nic?

W kodzie C dzieje się bardzo niewiele kroków:

  1. Ciąg jest załadowany i przekazywane putsza pośrednictwem edirejestru.
  2. puts zostanie wezwany.

Nie ma żadnych obiektów, więc nie ma potrzeby inicjowania / niszczenia czegokolwiek.

To jednak nie znaczy, że nie jesteś „płacić” za nic w C . Wciąż płacisz za abstrakcję, a także za inicjalizację standardowej biblioteki C i dynamiczne rozwiązywanie printffunkcji (a właściwieputs , która jest optymalizowana przez kompilator, ponieważ nie potrzebujesz żadnego ciągu formatu) nadal odbywa się pod maską.

Gdybyś napisał ten program w czystym asemblerze, wyglądałby mniej więcej tak:

jmp start

msg db "Hello world\n"

start:
    mov rdi, 1
    mov rsi, offset msg
    mov rdx, 11
    mov rax, 1          ; write
    syscall
    xor rdi, rdi
    mov rax, 60         ; exit
    syscall

Co w zasadzie powoduje tylko wywołanie wywołania write systemowego, po którym następuje wywołanie exitsystemowe. Teraz to byłoby absolutne minimum, aby osiągnąć to samo.


Podsumowując

C jest o wiele bardziej surowy i robi tylko niezbędne minimum, pozostawiając pełną kontrolę użytkownikowi, który jest w stanie w pełni zoptymalizować i dostosować praktycznie wszystko, czego chce. Mówisz procesorowi, aby załadował łańcuch do rejestru, a następnie wywołujesz funkcję biblioteki, aby użyć tego ciągu. Z drugiej strony C ++ jest o wiele bardziej złożony i abstrakcyjny . Ma to ogromną zaletę przy pisaniu skomplikowanego kodu i pozwala na łatwiejszy do napisania i bardziej przyjazny dla człowieka kod, ale oczywiście wiąże się to z kosztami. Zawsze będzie wada wydajności w C ++ w porównaniu z C w takich przypadkach, ponieważ C ++ oferuje więcej niż to, co jest potrzebne do wykonania takich podstawowych zadań, a zatem zwiększa narzut .

Odpowiadając na twoje główne pytanie :

Czy płacę za to, czego nie jem?

W tym konkretnym przypadku tak . Nie wykorzystujesz niczego, co C ++ ma do zaoferowania więcej niż C, ale to tylko dlatego, że w tym prostym fragmencie kodu nie ma nic, w czym C ++ mógłby ci pomóc: jest tak prosty, że w ogóle nie potrzebujesz C ++.


Aha, i jeszcze jedna rzecz!

Zalety C ++ mogą nie wydawać się oczywiste na pierwszy rzut oka, ponieważ napisałeś bardzo prosty i mały program, ale spójrz na nieco bardziej złożony przykład i zobacz różnicę (oba programy robią dokładnie to samo):

C :

#include <stdio.h>
#include <stdlib.h>

int cmp(const void *a, const void *b) {
    return *(int*)a - *(int*)b;
}

int main(void) {
    int i, n, *arr;

    printf("How many integers do you want to input? ");
    scanf("%d", &n);

    arr = malloc(sizeof(int) * n);

    for (i = 0; i < n; i++) {
        printf("Index %d: ", i);
        scanf("%d", &arr[i]);
    }

    qsort(arr, n, sizeof(int), cmp)

    puts("Here are your numbers, ordered:");

    for (i = 0; i < n; i++)
        printf("%d\n", arr[i]);

    free(arr);

    return 0;
}

C ++ :

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

int main(void) {
    int n;

    cout << "How many integers do you want to input? ";
    cin >> n;

    vector<int> vec(n);

    for (int i = 0; i < vec.size(); i++) {
        cout << "Index " << i << ": ";
        cin >> vec[i];
    }

    sort(vec.begin(), vec.end());

    cout << "Here are your numbers:" << endl;

    for (int item : vec)
        cout << item << endl;

    return 0;
}

Mam nadzieję, że wyraźnie widzisz, co mam na myśli. Zwróć także uwagę, jak w C musisz zarządzać pamięcią na niższym poziomie, używając malloci freejak musisz być bardziej ostrożny przy indeksowaniu i rozmiarach oraz jak musisz być bardzo dokładny podczas przyjmowania danych wejściowych i drukowania.

Marco Bonelli
źródło
27

Na początek jest kilka nieporozumień. Po pierwsze, program C ++ nie daje 22 instrukcji, to raczej 22 000 z nich (wyciągnąłem tę liczbę z kapelusza, ale jest mniej więcej w polu gry). Również kod C nie daje 9 instrukcji. To tylko te, które widzisz.

To, co robi kod C, to po zrobieniu wielu rzeczy, których nie widzisz, wywołuje funkcję z CRT (która jest zwykle, ale niekoniecznie obecna jako biblioteka współdzielona), a następnie nie sprawdza zwracanej wartości ani uchwytu błędy i wyskakuje. W zależności od ustawień kompilatora i optymalizacji, tak naprawdę nawet nie wywołuje, printfale putslub coś jeszcze bardziej prymitywnego.
Mógłbyś napisać mniej więcej ten sam program (z wyjątkiem niektórych niewidocznych funkcji init) również w C ++, gdybyś wywołał tę samą funkcję w ten sam sposób. Lub, jeśli chcesz być bardzo poprawny, ta sama funkcja z prefiksem std::.

Odpowiedni kod C ++ w rzeczywistości nie jest tym samym. Podczas gdy całość <iostream>jest dobrze znana z tego, że jest grubą, brzydką świnią, która powoduje ogromne obciążenie dla małych programów (w „prawdziwym” programie tak naprawdę nie zauważasz tak dużo), nieco sprawiedliwszą interpretacją jest to, że robi okropnie wiele rzeczy, których nie widzisz i które po prostu działają . W tym między innymi magiczne formatowanie prawie wszystkich przypadkowych rzeczy, w tym różne formaty liczb, ustawienia regionalne i tak dalej, a także buforowanie i właściwa obsługa błędów. Obsługa błędów? No tak, zgadnij co, wypisanie łańcucha może się nie powieść, aw przeciwieństwie do programu w C, program w C ++ nie zignorowałby tego po cichu. Biorąc pod uwagę costd::ostreamrobi to pod maską i nikt o tym nie wie, w rzeczywistości jest dość lekki. Nie tak, że go używam, ponieważ z pasją nienawidzę składni strumienia. Ale nadal jest całkiem niesamowite, jeśli weźmiesz pod uwagę, co robi.

Ale oczywiście C ++ ogólnie nie jest tak wydajny, jak może być. Nie może być tak wydajne, ponieważ nie jest tym samym i nie robi tego samego. Jeśli nic więcej, C ++ generuje wyjątki (i kod do generowania, obsługi lub niepowodzenia) i daje pewne gwarancje, których C nie daje. Więc oczywiście program w C ++ musi być trochę większy. Jednak w szerszej perspektywie nie ma to żadnego znaczenia. Wręcz przeciwnie, w przypadku prawdziwych programów nierzadko stwierdziłem, że C ++ działa lepiej, ponieważ z tego czy innego powodu wydaje się, że nadaje się do bardziej korzystnych optymalizacji. Nie pytaj mnie w szczególności dlaczego, nie wiedziałbym.

Jeśli zamiast odpalić i zapomnieć o nadziei na najlepsze, chcesz napisać kod C, który jest poprawny (tj. Faktycznie sprawdzasz błędy, a program zachowuje się poprawnie w obecności błędów), różnica jest marginalna, jeśli istnieje.

Damon
źródło
16
Bardzo dobra odpowiedź, poza tym, że stwierdzenie: „Ale oczywiście, C ++ ogólnie nie jest tak wydajne, jak może być” jest po prostu błędne. C ++ może być tak samo wydajny jak C, a kod wystarczająco wysokiego poziomu może być bardziej wydajny niż równoważny kod C. Tak, C ++ ma pewne narzuty ze względu na konieczność obsługi wyjątków, ale w nowoczesnych kompilatorach narzut ten jest pomijalny w porównaniu do zysków wydajnościowych wynikających z lepszych, bezpłatnych abstrakcji.
Konrad Rudolph
Jeśli dobrze zrozumiałem, czy też std::coutrzucają wyjątki?
Saher
6
@Saher: Tak, nie, może. std::coutjest a std::basic_ostreami którą można zgłosić, i może ponownie zgłosić inaczej występujące wyjątki, jeśli jest do tego skonfigurowana , lub może połknąć wyjątki. Rzecz w tym, że coś może zawieść, a C ++, podobnie jak standardowa biblioteka C ++, jest (w większości) zbudowana, więc awarie nie pozostają niezauważone. To jest irytacja i błogosławieństwo (ale bardziej błogosławieństwo niż irytacja). C z drugiej strony pokazuje tylko środkowy palec. Nie sprawdzasz kodu zwrotnego, nigdy nie wiesz, co się stało.
Damon
1
@KonradRudolph: To prawda, właśnie to starałem się wskazać, pisząc: „Nierzadko stwierdziłem, że C ++ działa lepiej, ponieważ z tego czy innego powodu wydaje się, że nadaje się do bardziej korzystnych optymalizacji. Nie pytaj mnie w szczególności dlaczego” . Nie jest od razu oczywiste, dlaczego, ale nierzadko po prostu optymalizuje się lepiej. Dla byle jakiego powodu. Można by pomyśleć, że dla optymalizatora to wszystko to samo, ale tak nie jest.
Damon
22

Płacisz za błąd. W latach 80., gdy kompilatory nie były wystarczająco dobre, aby sprawdzić ciągi formatujące, przeciążanie operatorów było postrzegane jako dobry sposób na wymuszenie pewnego pozoru bezpieczeństwa typów podczas operacji io. Jednak każda z funkcji banera jest od samego początku źle zaimplementowana lub koncepcyjnie bankrutująca:

<iomanip>

Najbardziej odrażającą częścią interfejsu API strumienia io w C ++ jest istnienie tej biblioteki nagłówka formatowania. Oprócz tego, że jest stanowy, brzydki i podatny na błędy, łączy formatowanie ze strumieniem.

Załóżmy, że chcesz wydrukować wiersz zawierający 8-cyfrowy znak szesnastkowy wypełniony zerem bez znaku int, po którym następuje spacja, a następnie znak double z 3 miejscami po przecinku. Z<cstdio> można odczytać zwięzły ciąg formatu. Za pomocą <ostream>musisz zapisać stary stan, ustawić wyrównanie w prawo, ustawić znak wypełnienia, ustawić szerokość wypełnienia, ustawić podstawę na wartość szesnastkową, wypisać liczbę całkowitą, przywrócić zapisany stan (w przeciwnym razie formatowanie liczb całkowitych zanieczyści formatowanie zmiennoprzecinkowe), wypisz spację , ustaw notację na ustaloną, ustaw precyzję, wypisz podwójną i nową linię, a następnie przywróć stare formatowanie.

// <cstdio>
std::printf( "%08x %.3lf\n", ival, fval );

// <ostream> & <iomanip>
std::ios old_fmt {nullptr};
old_fmt.copyfmt (std::cout);
std::cout << std::right << std::setfill('0') << std::setw(8) << std::hex << ival;
std::cout.copyfmt (old_fmt);
std::cout << " " << std::fixed << std::setprecision(3) << fval << "\n";
std::cout.copyfmt (old_fmt);

Przeciążanie operatorów

<iostream> jest dzieckiem plakatu pokazującym, jak nie używać przeciążenia operatorów:

std::cout << 2 << 3 && 0 << 5;

Występ

std::cout jest kilka razy wolniejszy printf() . Szalejące featuritis i wirtualna wysyłka mają swoje żniwo.

Bezpieczeństwo wątku

Obie <cstdio>i <iostream>są bezpieczne wątkowo, ponieważ każde wywołanie funkcji jest niepodzielne. Ale printf()za jedno połączenie można zrobić dużo więcej. Jeśli uruchomisz następujący program z <cstdio>opcją, zobaczysz tylko wiersz f. Jeśli używasz <iostream>na maszynie wielordzeniowej, prawdopodobnie zobaczysz coś innego.

// g++ -Wall -Wextra -Wpedantic -pthread -std=c++17 cout.test.cpp

#define USE_STREAM 1
#define REPS 50
#define THREADS 10

#include <thread>
#include <vector>

#if USE_STREAM
    #include <iostream>
#else
    #include <cstdio>
#endif

void task()
{
    for ( int i = 0; i < REPS; ++i )
#if USE_STREAM
        std::cout << std::hex << 15 << std::dec;
#else
        std::printf ( "%x", 15);
#endif

}

int main()
{
    auto threads = std::vector<std::thread> {};
    for ( int i = 0; i < THREADS; ++i )
        threads.emplace_back(task);

    for ( auto & t : threads )
        t.join();

#if USE_STREAM
        std::cout << "\n<iostream>\n";
#else
        std::printf ( "\n<cstdio>\n" );
#endif
}

Odpowiedź na ten przykład jest taka, że ​​większość ludzi ćwiczy dyscyplinę, aby i tak nigdy nie pisać do jednego deskryptora pliku z wielu wątków. Cóż, w takim przypadku, trzeba stwierdzić, że <iostream>będzie pomocny chwycić blokadę na każdy <<i każdy >>. Podczas gdy w <cstdio>, nie będziesz blokować tak często, a nawet masz możliwość nie blokowania.

<iostream> zużywa więcej zamków, aby uzyskać mniej spójny wynik.

KevinZ
źródło
2
Większość implementacji printf ma niezwykle przydatną funkcję do lokalizacji: numerowane parametry. Jeśli potrzebujesz wygenerować dane wyjściowe w dwóch różnych językach (np. Angielskim i francuskim), a kolejność słów jest inna, możesz użyć tego samego printf z innym ciągiem formatującym i wydrukować parametry w innej kolejności.
gnasher729
2
To stanowe formatowanie strumieni musiało dać tak wiele trudnych do znalezienia błędów, że nie wiem, co powiedzieć. Świetna odpowiedź. Głosowałbym za więcej niż raz, gdybym mógł.
mathreadler
6
std::coutJest kilka razy wolniejszy printf()” - to twierdzenie powtarza się w całej sieci, ale nie było to prawdą od wieków. Nowoczesne implementacje IOstream działają na równi z printf. Ten ostatni wykonuje również wewnętrznie wirtualną wysyłkę, aby poradzić sobie ze buforowanymi strumieniami i zlokalizowanymi IO (wykonanymi przez system operacyjny, ale mimo to wykonanymi).
Konrad Rudolph
3
@KevinZ I to świetnie, ale jest to test porównawczy jednego, konkretnego wywołania, które pokazuje konkretne mocne strony fmt (wiele różnych formatów w jednym ciągu). W bardziej typowym użyciu różnica między printfi coutkurczy się. Nawiasem mówiąc, na tej samej stronie jest mnóstwo takich testów.
Konrad Rudolph
3
@KonradRudolph To też nie jest prawda. Mikroenchmarki często nie przewidują kosztu wzdęcia i pośrednictwa, ponieważ nie wyczerpują pewnych ograniczonych zasobów (takich jak rejestry, icache, pamięć, predyktory rozgałęzień) tam, gdzie będzie to prawdziwy program. Kiedy mówisz o „bardziej typowym zastosowaniu”, w zasadzie oznacza to, że gdzie indziej masz znacznie więcej wzdęć, co jest w porządku, ale nie na temat. Moim zdaniem, jeśli nie masz wymagań wydajnościowych, nie musisz programować w C ++.
KevinZ
18

Oprócz tego, co powiedziały wszystkie inne odpowiedzi,
jest też fakt, że std::endlto nie to samo co '\n'.

Jest to niestety powszechne nieporozumienie. std::endlnie oznacza „nowej linii”,
oznacza „wydrukuj nową linię, a następnie opróżnij strumień ”. Spłukiwanie nie jest tanie!

Całkowicie ignorując przez chwilę różnice między printfi std::cout, aby funkcjonalnie odpowiadać przykładowi w C, przykład w C ++ powinien wyglądać następująco:

#include <iostream>

int main()
{
    std::cout << "Hello world\n";
    return 0;
}

A oto przykład tego, jak powinny wyglądać twoje przykłady, jeśli uwzględnisz spłukiwanie.

do

#include <stdio.h>

int main()
{
    printf("Hello world\n");
    fflush(stdout);
    return 0;
}

C ++

#include <iostream>

int main()
{
    std::cout << "Hello world\n";
    std::cout << std::flush;
    return 0;
}

Porównując kod, zawsze powinieneś uważać, aby porównać podobne do podobieństwa i zrozumieć konsekwencje tego, co robi twój kod. Czasami nawet najprostsze przykłady są bardziej skomplikowane, niż niektórym się wydaje.

Pharap
źródło
W rzeczywistości używanie std::endl jest funkcjonalnym odpowiednikiem pisania nowej linii do buforowanego liniowo strumienia standardowego. stdoutw szczególności musi być buforowany przez linię lub niebuforowany po podłączeniu do urządzenia interaktywnego. Wydaje mi się, że Linux nalega na opcję buforowania liniowego.
W rzeczywistości biblioteka iostream nie ma trybu buforowanego wierszami ... sposobem na osiągnięcie efektu buforowania linii jest właśnie użycie std::endldo wypisywania nowych linii.
@Hurkyl nalegać? Więc jaki jest pożytek setvbuf(3)? A może chcesz powiedzieć, że domyślnym jest buforowanie linii? FYI: Zwykle wszystkie pliki są buforowane blokowo. Jeśli strumień odwołuje się do terminala (jak zwykle robi to stdout), jest buforowany liniowo. Standardowy strumień błędów stderr jest zawsze domyślnie niebuforowany.
Pryftan,
Nie printfspłukuje automatycznie po napotkaniu postaci nowej linii?
bool3max,
1
@ bool3max To tylko powiedziałoby mi, co robi moje środowisko, może być inaczej w innych środowiskach. Nawet jeśli zachowuje się tak samo we wszystkich najpopularniejszych implementacjach, nie oznacza to, że gdzieś istnieje skrajny przypadek. Dlatego tak ważny jest standard - norma dyktuje, czy coś musi być takie samo dla wszystkich implementacji, czy też może się różnić między implementacjami.
Pharap
16

Chociaż istniejące odpowiedzi techniczne są poprawne, myślę, że ostatecznie pytanie wynika z tego błędnego przekonania:

Wiadomo, że w C ++ płacisz za to, co jesz.

To tylko marketingowa rozmowa ze społecznością C ++. (Szczerze mówiąc, w każdej społeczności językowej toczą się rozmowy marketingowe). Nie oznacza to niczego konkretnego, na czym można by poważnie polegać.

„Płacisz za to, czego używasz” ma oznaczać, że funkcja C ++ ma narzut tylko wtedy, gdy używasz tej funkcji. Ale definicja „funkcji” nie jest nieskończenie szczegółowa. Często zdarza się, że aktywujesz funkcje, które mają wiele aspektów i nawet jeśli potrzebujesz tylko części tych aspektów, często implementacja nie jest praktyczna lub możliwa do częściowego wprowadzenia funkcji.

Ogólnie rzecz biorąc, wiele języków (choć prawdopodobnie nie wszystkie) stara się być wydajnymi, z różnym skutkiem. C ++ jest gdzieś na skali, ale w jego projekcie nie ma nic specjalnego ani magicznego, co pozwoliłoby mu odnieść sukces w tym celu.

Theodoros Chatzigiannakis
źródło
1
Są tylko dwie rzeczy, o których mogę pomyśleć, gdzie płacisz za coś, czego nie używasz: wyjątki i RTTI. I nie sądzę, że to rozmowa marketingowa; C ++ to w zasadzie bardziej zaawansowane C, które oznacza również „nie płacić za to, czego używasz”.
Rakete1111
2
@ Rakete1111 Od dawna wiadomo, że jeśli wyjątki nie są zgłaszane, nie kosztują. Jeśli twój program konsekwentnie rzuca, powinien zostać przeprojektowany. Jeśli warunek niepowodzenia jest poza Twoją kontrolą, przed wywołaniem metody, która opiera się na tym, że warunek jest fałszywy, sprawdź stan za pomocą zwracanej wartości bool.
schulmaster
1
@schulmaster: Wyjątki mogą narzucać ograniczenia projektowe, gdy kod napisany w C ++ musi współdziałać z kodem napisanym w innych językach, ponieważ nielokalne przekazywanie kontroli może działać płynnie między modułami tylko wtedy, gdy moduły wiedzą, jak ze sobą koordynować.
supercat
1
(choć prawdopodobnie nie wszystkie) języki starają się być wydajne . Zdecydowanie nie wszystkie: ezoteryczne języki programowania starają się być nowatorskie / interesujące, nieefektywne. esolangs.org . Niektóre z nich, takie jak BrainFuck, są znane z niesprawności. Lub na przykład Szekspirowski język programowania, minimalny rozmiar 227 bajtów (codegolf), aby wypisać wszystkie liczby całkowite . Spośród języków przeznaczonych do użytku produkcyjnego większość z nich dąży do wydajności, ale niektóre (jak bash) dążą głównie do wygody i są znane jako powolne.
Peter Cordes,
2
Cóż, to marketing, ale prawie całkowicie prawda. Możesz trzymać się <cstdio>i nie uwzględniać <iostream>, tak jak w przypadku kompilacji -fno-exceptions -fno-rtti -fno-unwind-tables -fno-asynchronous-unwind-tables.
KevinZ
11

Funkcje wejścia / wyjścia w C ++ są elegancko napisane i zaprojektowane tak, aby były łatwe w użyciu. Pod wieloma względami są wizytówką funkcji zorientowanych obiektowo w C ++.

Ale rzeczywiście w zamian tracisz trochę wydajności, ale jest to nieistotne w porównaniu z czasem potrzebnym systemowi operacyjnemu na obsługę funkcji na niższym poziomie.

Zawsze możesz wrócić do funkcji w stylu C, ponieważ są one częścią standardu C ++, lub być może całkowicie zrezygnować z przenośności i użyć bezpośrednich wywołań do systemu operacyjnego.

Batszeba
źródło
23
„Funkcje wejścia / wyjścia w C ++ to ohydne potwory, które usiłują ukryć swoją Cthuliańską naturę za cienką warstwą użyteczności. Pod wieloma względami są przykładem tego, jak nie projektować nowoczesnego kodu w C ++”. Prawdopodobnie byłoby dokładniejsze.
user673679
3
@ user673679: To prawda. Wielkim problemem związanym ze strumieniami we / wy C ++ jest to, co jest poniżej: dzieje się naprawdę dużo złożoności i każdy, kto kiedykolwiek miał z nimi do czynienia (mówię w std::basic_*streamdół), zna nadchodzące eadache. Miały być szeroko rozpowszechnione i rozszerzane poprzez dziedziczenie; ale ostatecznie nikt tego nie zrobił, ze względu na ich złożoność (o iostreamach pisze się dosłownie), do tego stopnia, że ​​powstały nowe biblioteki (np. boost, ICU itp.). Wątpię, czy kiedykolwiek przestaniemy płacić za ten błąd.
edmz
1

Jak widzieliśmy w innych odpowiedziach, płacisz, gdy łączysz się w ogólnych bibliotekach i wywołujesz złożone konstruktory. Nie ma tu żadnego szczególnego pytania, a raczej zarzut. Wskażę kilka aspektów z prawdziwego świata:

  1. Barne miał podstawową zasadę projektowania, aby nigdy nie pozwolić, aby wydajność była powodem pozostania w C, a nie w C ++. To powiedziawszy, trzeba być ostrożnym, aby uzyskać te wydajności, a czasami zdarzają się wydajności, które zawsze działały, ale nie były „technicznie” zgodne ze specyfikacją C. Na przykład układ pól bitowych nie został tak naprawdę określony.

  2. Spróbuj przejrzeć ostream. O mój Boże, to nadęty! Nie zdziwiłbym się, gdybym znalazł tam symulator lotu. Nawet printf () z biblioteki stdlib zwykle działa około 50 KB. To nie są leniwi programiści: połowa rozmiaru printf była spowodowana argumentami o pośredniej precyzji, których większość ludzi nigdy nie używa. Prawie każda naprawdę ograniczona biblioteka procesora tworzy własny kod wyjściowy zamiast printf.

  3. Zwiększenie rozmiaru zwykle zapewnia bardziej ograniczone i elastyczne doświadczenie. Analogicznie, automat sprzedający za kilka monet sprzedaje filiżankę substancji przypominającej kawę, a cała transakcja trwa niecałą minutę. Wpadnięcie do dobrej restauracji wymaga nakrycia stołu, siedzenia, zamawiania, czekania, zdobycia dobrej filiżanki, otrzymania rachunku, zapłacenia za wybór form, dodania napiwku i życzenia miłego dnia w drodze. To inne doświadczenie i wygodniejsze, jeśli wpadasz z przyjaciółmi na złożony posiłek.

  4. Ludzie wciąż piszą ANSI C, choć rzadko K&R C. Z mojego doświadczenia wynika, że ​​zawsze kompilujemy go za pomocą kompilatora C ++, używając kilku poprawek konfiguracyjnych, aby ograniczyć to, co jest wciągana. Istnieją dobre argumenty za innymi językami: Go usuwa polimorficzny narzut i szalony preprocesor ; pojawiło się kilka dobrych argumentów za inteligentniejszym pakowaniem w terenie i układem pamięci. IMHO Myślę, że każdy projekt języka powinien zaczynać się od listy celów, podobnie jak Zen w Pythonie .

To była fajna dyskusja. Pytasz, dlaczego nie możesz mieć magicznie małych, prostych, eleganckich, kompletnych i elastycznych bibliotek?

Nie ma odpowiedzi. Nie będzie odpowiedzi. To jest odpowiedź.

Charles Merriam
źródło