C++. Intensywny kurs - Josh Lospinoso

Kup ebooka

174.00 zł
139.20 zł (107,88 zł najniższa cena z 30 dni)

-
Proszę czekać

Przedmowa

"C++ to skomplikowany język" - taką reputację po wielu latach istnienia na rynku zdobył język C++, jednak nie zawsze była ona uzasadniona. To stwierdzenie jest często wykorzystywane jako argument, by odradzić danej osobie naukę języka C++ lub zachęcić do zainteresowania się innym językiem programowania. Trudno uzasadnić podawanie takich argumentów, ponieważ błędna jest podstawowa zasada, na której się one opierają: C++ nie jest skomplikowanym językiem. Największym problemem języka C++ jest jego reputacja, a innym, równie poważnym, brak wysokiej jakości materiałów edukacyjnych pozwalających na jego naukę.

W ciągu ostatnich czterech dekad C++ przeszedł ewolucję od języka C. Na początku był boczną gałęzią języka C (z niewielkimi dodatkami) wykorzystującą prekompilator o nazwie Cfront, który zamieniał kod C++ na C. Uzyskany program był następnie przetwarzany w standardowym kompilatorze C. Stąd nazwa Cfront - "przed C". Po kilku latach rozwoju okazało się, że to rozwiązanie zbyt mocno ogranicza język, dlatego podjęto prace nad stworzeniem rzeczywistego kompilatora. Taki program został napisany przez Bjarne'a Stroustrupa (pierwotnego twórcę języka) i mógł samodzielnie kompilować programy C++. Inne firmy były również zainteresowane rozszerzeniem możliwości języka C i stworzyły własne kompilatory C++, w większości kompatybilne z programem Cfront lub nowszym rozwiązaniem.

Zakończyło się to niepowodzeniem, ponieważ w wyniku tego pojawiły się różne wersje języka, które były niekompatybilne ze sobą. Warto także wspomnieć o tym, że podejmowanie przez jedną osobę wszystkich decyzji i tworzenie planów rozwoju nie jest dobrym sposobem na ustanowienie międzynarodowego standardu obejmującego wiele firm. W tym celu utworzono standardowe procedury i organizacje, które nimi zarządzają. Język C++ został zatem sformalizowany i stał się normą ISO należącą do Międzynarodowej Organizacji Normalizacyjnej. Pierwszy oficjalny standard C++ pojawił się po wielu latach w 1998 roku, co zostało pozytywnie odebrane przez społeczność programistów.

Jednak radość nie trwała zbyt długo. Mimo że wersja C++98 była poprawnym opracowaniem, zawierała kilka nowych elementów rozwojowych, które nie były znane programistom, a także funkcje, które działały w dziwny sposób. Czasem rzeczywiście same funkcje były dobrze zdefiniowane, ale nie istniała współpraca między nimi. Przykładem takiego zachowania była możliwość zdefiniowania nazwy pliku z użyciem typu std::string. Pliku z tak utworzoną nazwą nie można było jednak otworzyć.

Kolejnym dodatkiem było umożliwienie korzystania z szablonów, które stały się głównym elementem standardowej biblioteki szablonów będącej obecnie jednym z najważniejszych składników języka C++. Dopiero po wydaniu tej biblioteki odkryto, że jest ona kompletna w sensie Turinga, dzięki czemu za jej pomocą można stworzyć wiele zaawansowanych konstrukcji językowych, wykonując obliczenia w czasie kompilacji. To znacznie zwiększyło możliwości twórców bibliotek związane z tworzeniem kodu generycznego, który był w stanie obsłużyć dowolnie złożone dedukcje, co w tamtych czasach było ewenementem w porównaniu z innymi językami programowania.

Pojawiło się jednak utrudnienie: mimo że język C++98 był niewątpliwie dobrym rozwiązaniem, wiele jego kompilatorów nie zawierało poprawnej implementacji szablonów. Dwa podstawowe kompilatory z tamtych czasów, czyli GNU GCC 2.7 i Microsoft Visual C++ 6.0, nie były w stanie wykonać dwustopniowego wyszukiwania nazwy wymaganego przez szablony. Jedynym sposobem na usunięcie tego problemu było napisanie nowego kompilatora...

W przypadku kompilatora GNU starano się najpierw zachować istniejący kod źródłowy, ale w końcu w chwili pojawienia się wersji 2.95 zdecydowano się na stworzenie produktu od nowa. Oznaczało to, że przez wiele lat nie ukazały się żadne nowe funkcje ani wersje, i dlatego wielu programistów było z tego niezadowolonych. Niektóre firmy przejęły pierwotny kod źródłowy i próbowały kontynuować jego rozwój, tworząc wersje 2.95.2, 2.95.3 i 2.96. Niestety, okazało się, że były one niestabilne. Wreszcie pojawiła się nowa, stworzona całkowicie od podstaw, wersja GCC 3.0. Początkowo nie była zbyt udana. Mimo że kompilowała szablony i kod C++ znacznie lepiej niż starsze wersje, nie potrafiła skompilować jądra Linuksa do wykonywalnego pliku binarnego. Społeczność linuksowa wyraźnie sprzeciwiła się modyfikacji kodów w celu dostosowania ich do nowego kompilatora i zaczęła twierdzić, że kompilator jest uszkodzony. Ostatecznie problem zakończył się w momencie pojawienia się wersji 3.2 i świat Linuksa zaczął wykorzystywać kompilator GCC 3.2 i jego nowsze wydania.

Firma Microsoft starała się bardzo długo unikać ponownego tworzenia kompilatora. Wymyślała niestandardowe funkcje oraz metody heurystyczne w celu ustalenia, czy wyszukiwanie nazwy szablonu powinno zostać rozwiązane w pierwszym lub drugim przejściu. Rozwiązanie działało w zasadzie poprawnie, jednak biblioteki zostały napisane na początku 2010 roku i okazało się, że niemożliwe stało się doprowadzenie do tego, by ze sobą współpracowały - nawet po modyfikacjach ich kodów źródłowych. Firma Microsoft w końcu zabrała się za tworzenie nowego parsera i wydała jego zaktualizowaną wersję w 2018 roku, mimo to wielu programistów nie wzięło go pod uwagę. Ostatecznie, w 2019 roku nowy parser zaczął być domyślnie dołączany do nowych projektów.

Jeszcze przed 2019 rokiem miało jednak miejsce ważne wydarzenie, a mianowicie w 2011 roku pojawiło się wydanie C++11. Po opublikowaniu wersji C++98 język zaczęto uzupełniać o nowe funkcje. Ze względu na to, że jedna z tych funkcji nie działała zgodnie z oczekiwaniami, inauguracja nowej wersji C++ opóźniła się o trzy lata aż do 2009 roku. W międzyczasie podejmowano próby, aby kompilator zadziałał z nową funkcją. W 2009 roku problem został ostatecznie usunięty, pozostała część funkcji została przystosowana do nowego wydania, a wersja C++ z 1998 roku została ostatecznie zaktualizowana. Pojawiło się więc mnóstwo nowych funkcji i ulepszeń biblioteki. Kompilatory znów nie mogły nadrobić zaległości i dlatego większość z nich potrafiła skompilować program w wersji C++11 dopiero pod koniec 2013 roku.

Komitet języka C++ wyciągnął wnioski ze swojej wcześniejszej porażki i postanowił dokładnie zaplanować każde nowe wydanie, które miało się pojawiać co trzy lata. W pierwszym roku planowano stworzyć i przetestować nowe funkcje, w drugim dobrze je zintegrować, a także uzyskać ich stabilne wersje, a wreszcie oficjalnie udostępnić w trzecim roku. Ten proces miał być powtarzany co trzy lata. Wersja C++11 była pierwszą instancją, a w 2014 roku powinna się pojawić druga. Okazało się, że komitet zrobił dokładnie tak jak obiecał, zdecydowanie zaktualizował wersję C++11 i umożliwił korzystanie z istniejących funkcji w znacznie większym stopniu, niż było to do tej pory możliwe. Pewne wcześniej istniejące ograniczenia zostały złagodzone i zaczęły obowiązywać jedynie w przypadkach, które uznano za dopuszczalne - w szczególności dotyczyły one specyfikatora constexpr.

Twórcy kompilatorów, którzy wciąż próbowali sprawić, by wszystkie funkcje C++11 działały poprawnie, zdali sobie sprawę, że muszą przyspieszyć swoje prace, ponieważ w przeciwnym razie znikną z rynku. Do 2015 roku wszystkie kompilatory obsługiwały prawie pełen standard C++14. Było to niezwykłe osiągnięcie, biorąc pod uwagę to, co wcześniej działo się z wersjami C++98 i C++11. Dzięki temu w pracach komitetu języka C++ zaczęli ponownie uczestniczyć wszyscy główni twórcy kompilatorów. Było to ważne, ponieważ wiedza o planowanych, ale jeszcze niewdrożonych funkcjach powodowała, że dana organizacja stawała się automatycznie najważniejszym twórcą kompilatorów. Jeśli nawet okazałoby się, że pewna funkcja nie pasuje do projektu kompilatora, zawsze można było wpłynąć na komitet języka C++ i dostosować ją w taki sposób, który znacznie ułatwiłby jej obsługę i umożliwił innym programistom wcześniejsze skorzystanie z niej.

Język C++ przeżywa obecnie odrodzenie. Okres ten rozpoczął się około 2011 roku, gdy wprowadzono standard C++11 i przyjęto styl programowania zwany "nowoczesnym językiem C++". Od tamtej pory był on udoskonalany i wszystkie funkcje wprowadzone w wersji C++11 zostały ulepszone w standardach C++14 i C++17. Są one w pełni obsługiwane przez najważniejsze kompilatory liczące się na rynku. Co więcej, wkrótce zostanie wydany nowy standard C++20, a wszystkie najnowsze wersje kompilatorów już wspierają jego podstawowe elementy.

Nauka nowoczesnego języka C++ jest uproszczona. Programiści nie muszą już poznawać języka C, następnie C++98 i C++11, a potem zapominać o tych składnikach C i C++98, które zostały zmienione i stały się przestarzałe. Większość poprzednich szkoleń rozpoczynała się od wprowadzenia na temat historii C++, ponieważ trzeba było zrozumieć, dlaczego niektóre rzeczy zostały zdefiniowane w dziwny sposób. W przypadku tej książki Josh słusznie pominął informacje o historii języka C++ i pojawiają się one tylko w przedmowie.

Nie musisz już znać tej całej historii, aby nauczyć się programować w języku C++. Nowoczesny styl C++ pozwala na jej całkowite pominięcie. Obecnie możesz tworzyć dobrze zaprojektowane programy, znając tylko podstawowe zasady języka C++. To najlepszy czas na jego naukę.

Wróćmy jednak do wcześniejszego zastrzeżenia, czyli braku materiałów edukacyjnych o wysokiej jakości, pozwalających na naukę języka C++. Wysoki poziom nauczania jest obecnie zapewniany w ramach samego komitetu C++. Istnieje grupa analityczna zajmująca się wyłącznie jakością przekazywania wiedzy o języku C++! Moim zdaniem to zastrzeżenie, o którym wspominaliśmy wcześniej, zostało całkowicie wyeliminowane przez obecnie czytaną książkę.

W przeciwieństwie do pozostałych pozycji poświęconych językowi C++, które do tej pory przeczytałem, ta książka uczy podstaw i zasad. Dzięki niej dowiesz się, w jaki sposób należy analizować zagadnienia. To spowoduje, że będziesz mógł samodzielnie przeanalizować funkcje oferowane przez standardową bibliotekę szablonów. Korzyści z nauki mogą się pojawić nieco później, ale będziesz bardzo zadowolony, gdy zobaczysz, że Twoje pierwsze programy w języku C++ poprawnie się kompilują, a Ty w pełni rozumiesz ich działanie. W tej książce zawarto nawet zagadnienia, które są nieobecne w większości innych książek o języku C++: chodzi o konfigurowanie środowiska i testowanie kodu przed uruchamianiem gotowego programu.

Życzę miłej lektury i przetestowania wszystkich ćwiczeń, które są zawarte w książce, a także powodzenia w podróży po świecie języka C++!

Peter Bindels

Główny inżynier oprogramowania firmy TomTom

Wstęp

Chwyć stary pędzel i zacznij malować razem z nami.

Bob Ross

Zapotrzebowanie na tworzenie oprogramowania systemowego jest ogromne. Dzięki wszechobecności przeglądarek internetowych, urządzeń mobilnych i internetowi rzeczy (IoT) być może nastały najlepsze czasy na to, by stać się programistą. Wydajny, łatwy w utrzymaniu i poprawny kod jest zawsze pożądany, a ja jestem osobiście głęboko przekonany, że język C++ jest właściwym językiem do wszystkich zastosowań.

Język C++, używany przez doświadczonego programistę, może wyprodukować mniejszy, wydajniejszy i bardziej czytelny kod niż jakikolwiek inny znany język programowania systemowego. Jest to język oparty na zasadzie darmowej abstrakcji1 (zero-overhead abstraction), dzięki czemu Twoje aplikacje mają wysoką wydajność i można je szybko zaprogramować. Oprócz tego jest on w prosty i bezpośredni sposób powiązany ze sprzętem, co sprawia, że możesz użyć niskopoziomowych funkcji, gdy ich będziesz potrzebować. Kiedy programujesz w C++, stajesz na barkach gigantów, którzy poświęcili dziesięciolecia na tworzenie tego niezwykle potężnego i uniwersalnego języka.

Ogromną zaletą języka C++ jest to, że dzięki niemu uzyskujesz zupełnie darmowy dostęp do standardowej biblioteki C++, czyli stdlib. Biblioteka stdlib składa się z trzech połączonych ze sobą składników: kontenerów, iteratorów i algorytmów. Jeśli kiedykolwiek stworzyłeś własny algorytm szybkiego sortowania lub zajmowałeś się programowaniem systemowym i napotkałeś problemy związane z przepełnieniem bufora, wskaźnikami zawieszonymi, użyciem pamięci dynamicznej po jej zwolnieniu lub jej wielokrotnym zwolnieniem, z przyjemnością zapoznasz się z biblioteką stdlib. Umożliwia ona uzyskanie wysokiego poziomu bezpieczeństwa typologicznego, poprawności kodu i jego dużej wydajności. Będziesz także zadowolony z niewielkiego rozmiaru kodu i jego czytelności.

Podstawą modelu programowania w języku C++ jest cykl życia obiektu, który gwarantuje, że zasoby używane przez program, takie jak pliki, pamięć i gniazda sieciowe, są poprawnie zwalniane, nawet w przypadku wystąpienia błędów. Wyjątki, używane rozsądnie, mogą zlikwidować w kodzie znaczne ilości bałaganu odpowiedzialnego za sprawdzanie warunków wystąpienia błędów. Ponadto semantyka przenoszenia i kopiowania zapewnia wysoki poziom bezpieczeństwa, wydajności i uniwersalności związany z uprawieniami własności zasobów, czego nie posiadają starsze języki programowania systemowego, takie jak C.

C++ jest żywym językiem. Komitet Międzynarodowej Organizacji Normalizacyjnej (ISO) odpowiedzialny za rozwój języka C++ od ponad 30 lat regularnie wprowadza w nim ulepszenia. W ostatniej dekadzie wydano kilka aktualizacji standardu C++11, C++14 i C++17, które pojawiły się odpowiednio w 2011, 2014 i 2017 roku. Nowej wersji C++20 można się spodziewać w 2020 roku.

Gdy używam terminu nowoczesny C++, mam na myśli najnowszą wersję języka C++, która obsługuje dodane do niego nowe funkcje i paradygmaty. Dokonano poważnych udoskonaleń języka, które poprawiają jego wyrazistość, wydajność, bezpieczeństwo i ogólną użyteczność. Język ten poniekąd przeżywa obecnie największą popularność i na pewno nie zamierza zniknąć z rynku. Jeśli zdecydujesz się zainwestować w naukę języka C++, przez wiele lat będziesz odnosił z tego korzyści.

O książce

Programiści, chcący nauczyć się języka C++, mają dostęp do wielu książek na bardzo wysokim poziomie merytorycznym, takich jak Skuteczny nowoczesny C++ Scotta Meyersa czy też Język C++ Bjarne'a Stroustrupa, ale są one na ogół skierowane do dość zaawansowanego odbiorcy. Istnieją również prostsze pozycje, jednak one często pomijają kluczowe szczegóły, ponieważ są przeznaczone dla zupełnie początkujących programistów. Doświadczony programista ma więc problem, od czego powinien zacząć naukę języka C++.

Skomplikowane zagadnienia wolę prezentować rozważnie, niespiesznie budując koncepcje z użyciem podstawowych elementów. Język C++ ma zniechęcającą reputację, ponieważ jego podstawowe składniki są tak ściśle ze sobą połączone, że utrudnia to ukazanie pełnego obrazu całości. Podczas jego nauki starałem się go zrozumieć, dlatego korzystałem z wielu książek, samouczków wideo i zniechęconych kolegów. Napisałem więc taką książkę, którą sam chciałbym mieć pięć lat temu.

Dla kogo jest przeznaczona ta książka?

Książka ta jest przeznaczona dla średnio zaawansowanych i zaawansowanych programistów, którzy znają już podstawowe koncepcje programowania, nawet jeśli nie mają żadnego doświadczenia w programowaniu systemowym. Do przestudiowania książki zapraszam także doświadczonych programistów aplikacji.

Uwaga Jeśli jesteś doświadczonym programistą języka C lub początkującym programistą systemowym i zastanawiasz się, czy warto zainwestować w naukę języka C++, koniecznie przeczytaj podrozdział "Wstęp dla programistów C", aby uzyskać szczegółowe informacje.

Co znajduje się w tej książce?

Książka została podzielona na dwie części. Część I zawiera informacje o samym języku C++. Zamiast zapoznawać się z tym językiem w sposób chronologiczny (zaczynając od starego C++98, a kończąc na współczesnym C++11/14/17), od razu poznasz idiomatyczny, nowoczesny C++. Część II wprowadzi Cię w świat biblioteki standardowej C++ (stdlib), dzięki czemu będziesz mógł poznać najważniejsze pojęcia, które są z nią związane.

Część I: Język C++

Rozdział 1. Gotowi do pracy. W tym wprowadzającym rozdziale dowiesz się, jak należy skonfigurować środowisko programistyczne C++. Skompilujesz i uruchomisz swój pierwszy program, a także nauczysz się go debugować.

Rozdział 2. Typy. Zapoznasz się z systemem typów C++. Przyjrzysz się typom podstawowym będących fundamentem, na którym zostały zbudowane wszystkie inne typy. Następnie poznasz tradycyjne struktury danych oraz klasy w pełni funkcjonalne. Dowiesz się, jaka jest rola konstruktorów, inicjalizacji i destruktorów.

Rozdział 3. Typy referencyjne. W tym rozdziale zapoznasz się z obiektami przechowującymi adresy pamięci innych obiektów. Te typy są podstawą wielu ważnych wzorców programistycznych i umożliwiają tworzenie uniwersalnego, wydajnego kodu.

Rozdział 4. Cykl życia obiektu. Kontynuujemy w tym rozdziale analizę niezmienników klas i konstruktorów w kontekście okresu przechowywania. Wraz z wprowadzeniem paradygmatu inicjalizacji w przypadku pozyskiwania zasobu (RAII) zostaną omówione destruktory. Poznasz wyjątki i dowiesz się, jak wymuszają one niezmienniki klas i uzupełniają wzorzec RAII. Po omówieniu semantyki przenoszenia i kopiowania nauczysz się, jak można ją zaimplementować dzięki użyciu konstruktorów i operatorów przypisania.

Rozdział 5. Polimorfizm czasu wykonania. W tym rozdziale zapoznasz się z interfejsami, czyli koncepcją programowania, która umożliwia tworzenie kodu polimorficznego w czasie wykonania. Poznasz podstawy dziedziczenia i kompozycji obiektów, co pozwoli na wykorzystanie interfejsów w języku C++.

Rozdział 6. Polimorfizm czasu kompilacji. Ten rozdział omawia szablony, czyli funkcję języka, która umożliwia tworzenie kodu polimorficznego. Zapoznasz się również z konceptami, które pojawią się w następnej wersji języka C++, a także jawnymi konwersjami umożliwiającymi zmianę typów obiektów.

Rozdział 7. Wyrażenia. Dzięki temu rozdziałowi zgłębisz działanie operandów i operatorów. Jeżeli dokładnie zrozumiesz typy, cykle życia obiektu i szablonów, będziesz gotowy zająć się podstawowymi komponentami języka C++, wśród których najważniejszą rolę spełniają wyrażenia.

Rozdział 8. Instrukcje. W tym rozdziale poznasz elementy będące składowymi funkcji. Dowiesz się o instrukcjach wyrażeniowych, instrukcjach złożonych, deklaracjach, instrukcjach iteracyjnych i instrukcjach skoku.

Rozdział 9. Funkcje. Ostatni rozdział części I stanowi rozszerzenie analizy, w jaki sposób należy umieszczać instrukcje w jednostce pracy. Poznasz szczegóły definicji funkcji, typy zwracane, rozwiązywanie przeciążenia, funkcje wariadyczne, szablony wariadyczne i wskaźniki do funkcji. Dowiesz się również, jak za pomocą operatora wywołania funkcji i wyrażeń lambda tworzyć typy wywoływalne zdefiniowane przez użytkownika. Poznasz klasę std::function, która udostępnia jednolity kontener służący do przechowywania obiektów wywoływalnych.

Część II: Biblioteki i platformy

Rozdział 10. Testowanie. W tym rozdziale omawiamy wspaniały świat testów jednostkowych i platform imitacyjnych. W trakcie tworzenia oprogramowania dla systemu autonomicznej jazdy zapoznasz się projektowaniem opartym na testach. Przy okazji poznasz platformy, takie jak Boost Test, Google Test, Google Mock i inne.

Rozdział 11. Wskaźniki inteligentne. Prezentujemy specjalne klasy narzędziowe, które są dostępne w bibliotece stdlib w celu obsługi praw własności obiektów dynamicznych.

Rozdział 12. Narzędzia. W tym rozdziale zapoznasz się z typami, klasami i funkcjami dostępnymi w bibliotekach stdlib i Boost, służącymi do rozwiązywania typowych problemów programistycznych. Dowiesz się o strukturach danych, funkcjach numerycznych i generatorach liczb losowych.

Rozdział 13. Kontenery. Analizujemy wiele specjalnych struktur danych dostępnych w bibliotekach Boost i stdlib, które pomagają uporządkować dane. Dowiesz się o kontenerach sekwencyjnych, kontenerach asocjacyjnych i nieuporządkowanych kontenerach asocjacyjnych.

Rozdział 14. Iteratory. Poznasz połączenie między kontenerami, o których dowiedziałeś się w poprzednim rozdziale, a łańcuchami, które pojawią się w następnym. Dowiesz się o różnych rodzajach iteratorów, a także o tym, dlaczego ich konstrukcja zapewnia niesamowitą elastyczność.

Rozdział 15. Łańcuchy. W tym rozdziale nauczysz się, jak z użyciem określonej rodziny kontenerów można przetwarzać teksty występujące w języku naturalnym. Dowiesz się także o specjalnych funkcjach wbudowanych w łańcuchy, które pozwalają na wykonywanie typowych zadań.

Rozdział 16. Strumienie. Zapoznasz się z podstawową koncepcją leżącą u podstaw operacji wejścia i wyjścia. Dowiesz się, jak obsługiwać strumienie wejściowe i wyjściowe za pomocą funkcji formatujących i nieformatujących, a także w jaki sposób należy używać manipulatorów. Nauczysz się również zapisywania danych oraz odczytywania ich z plików.

Rozdział 17. Systemy plików. W tym miejscu poznasz narzędzia biblioteki stdlib służące do zarządzania systemami plików. Dowiesz się, jak konstruować i modyfikować ścieżki, badać pliki i katalogi oraz wyświetlać struktury katalogów.

Rozdział 18. Algorytmy. W tym rozdziale pokażemy, w jaki sposób można łatwo rozwiązywać problemy za pomocą biblioteki stdlib. Poznasz imponującą liczbę algorytmów o wysokiej jakości.

Rozdział 19. Współbieżność i równoległość. Przedstawiamy tu kilka prostych metod pozwalających na programowanie wielowątkowe, które są częścią biblioteki stdlib. Dowiesz się o typach future, muteksach, a także o zmiennych warunkowych i operacjach atomowych.

Rozdział 20. Programowanie sieciowe z użyciem biblioteki Boost Asio. W tym rozdziale dowiesz się, jak tworzyć programy o wysokiej wydajności, które potrafią komunikować się ze sobą za pośrednictwem sieci. Zobaczysz, jak należy używać biblioteki Boost Asio z blokującymi i nieblokującymi operacjami wejścia i wyjścia.

Rozdział 21. Tworzenie aplikacji. W ostatnim rozdziale tej książki przeanalizujemy kilka ważnych zagadnień. Dowiesz się o funkcjach obsługi technicznej, które pozwalają zarządzać cyklem życia aplikacji. Poznasz również bibliotekę Boost ProgramOptions, która ułatwia tworzenie aplikacji konsolowych odczytujących dane wejściowe wprowadzane przez użytkownika.

Uwaga Odwiedź stronę https://ccc.codes/, aby uzyskać dostęp do kodów źródłowych wykorzystanych w tej książce.

Wstęp dla programistów C

Arthur Dent: Co się z nim dzieje?

Hig Hurtenflurst: Jego stopy nie pasują do jego butów.

Douglas Adams, Autostopem przez Galaktykę

Ten wstęp jest przeznaczony dla doświadczonych programistów języka C, którzy zastanawiają się nad przeczytaniem tej książki. Programiści innych języków mogą go pominąć.

Język C++ wywodzi się z języka C i został zaprojektowany przez Bjarne'a Stroustrupa. Chociaż C++ nie jest w pełni kompatybilny z C, dobrze napisane programy w języku C są często również poprawnymi programami C++. Na przykład każdy fragment kodu w książce Język C autorstwa Briana Kernighana i Dennisa Ritchiego jest poprawnym programem C++.

Jednym z głównych powodów popularności języka C wśród programistów systemowych jest to, że pozwala tworzyć oprogramowanie na wyższym poziomie abstrakcji niż asembler. Dzięki temu kod staje się bardziej przejrzysty, mniej podatny na błędy i łatwiejszy w utrzymaniu.

Ogólnie rzecz biorąc, programiści systemowi nie są skłonni poświęcać czegoś w zamian za wygodę programowania, więc język C przestrzega zasady braku narzutu (zero-overhead principle): nie płacisz za to, czego nie używasz. System o silniej kontroli typów jest doskonałym przykładem darmowej abstrakcji. Jest używany tylko w czasie kompilacji, aby sprawdzić poprawność programu. Po czasie kompilacji typy znikną, a wygenerowany kod asemblera nie będzie zawierać żadnego śladu systemu typów.

Język C++, będąc potomkiem C, bardzo poważnie podchodzi do zasady darmowej abstrakcji i bezpośredniego odwzorowania na sprzęt. To zobowiązanie wykracza poza funkcje języka C obsługiwane przez C++. Wszystkie elementy, które zostały dodane do języka C++ i które nie istnieją w C, w tym nowe funkcje językowe, działają zgodnie z powyżej przytoczonymi zasadami, a wyjątkowe odstępstwa są dokonywane bardzo celowo. W rzeczywistości niektóre funkcje C++ powodują powstanie jeszcze mniejszego obciążenia niż odpowiedni kod języka C. Jednym z takich przykładów jest słowo kluczowe constexpr. Nakazuje ono kompilatorowi wyznaczyć wyrażenie w czasie kompilacji (jeśli to możliwe), jak pokazano w programie na listingu 1.

#include <cstdio>

constexpr int isqrt(int n) {

  int i=1;

  while (i*i<n) ++i;

  return i-(i*i!=n);

}

int main() {

  constexpr int x = isqrt(1764);

  printf("%d", x);

}

Listing 1. Program ilustrujący użycie słowa kluczowego constexpr

Funkcja isqrt oblicza pierwiastek kwadratowy argumentu n. Począwszy od wartości 1, funkcja zwiększa zmienną lokalną i do momentu, aż wynik wyrażenia i*i będzie większy lub równy n. Jeśli i*i == n, zwraca i; w przeciwnym razie zwraca i-1. Zauważ, że wywołanie isqrt zawiera literał, więc kompilator może teoretycznie obliczyć wynik za Ciebie. Będzie miał on tylko jedną wartość .

Skompilowanie programu z listingu 1 za pomocą kompilatora GCC 8.3, w którym jako platformę docelową wybrano x86-64 z opcją -O2, daje w wyniku kod asemblera zaprezentowany na listingu 2.

.LC0:

    .string "%d"

main:

    sub rsp, 8

    mov esi, 42

    mov edi, OFFSET FLAT:.LC0

    xor eax, eax

    call printf

    xor eax, eax

    add rsp, 8

ret

Listing 2. Kod asemblera uzyskany po skompilowaniu programu z listingu 1

Istotne znaczenie ma tutaj druga instrukcja w funkcji main . Kompilator nie wyznacza pierwiastka kwadratowego z 1764 w czasie wykonania, lecz wylicza go wcześniej i używa odpowiedniej instrukcji, by przypisać do zmiennej x docelową wartość 42. Oczywiście można obliczyć pierwiastek kwadratowy za pomocą kalkulatora, a następnie wstawić wynik, jednak użycie constexpr zapewnia wiele korzyści. Dzięki temu można uniknąć wielu błędów związanych z ręcznym kopiowaniem i wklejaniem oraz sprawić, by kod stał się bardziej ekspresywny.

Uwaga Jeśli nie znasz asemblera x86, zapoznaj się z książką Asembler. Sztuka programowania, wydanie II, autorstwa Randalla Hyde'a, a także z Professional Assembly Language autorstwa Richarda Bluma.

Uaktualnienie języka C do Super C

Nowoczesne kompilatory C++ zaspokoją większość Twoich nawyków związanych z programowaniem w C. Dzięki temu prościej będzie zastosować kilka taktycznych subtelności, które zapewnia C++, z jednoczesnym celowym unikaniem większego zagłębiania się w ten język. Ten styl C++ - nazwijmy go Super C - należy przeanalizować z kilku powodów. Po pierwsze, doświadczeni programiści C mogą od razu skorzystać z zastosowania w swoich programach prostych koncepcji języka C++. Po drugie, Super C nie jest idiomatycznym językiem C++. Zwykłe użycie referencji i słów kluczowych auto w programie C może od razu sprawić, że kod stanie się bardziej niezawodny i czytelny. Musisz jednak poznać także inne koncepcje, aby w pełni skorzystać z nowego języka. Po trzecie, w przypadku niektórych prostych środowisk (chodzi przykładowo o oprogramowanie wbudowane, niektóre jądra systemu operacyjnego i przetwarzanie heterogeniczne) dostępne narzędzia nie obsługują w pełni języka C++. W takich sytuacjach można skorzystać z co najmniej niektórych idiomów C++, a Super C będzie prawdopodobnie obsługiwany. W tym podrozdziale omówiono niektóre koncepcje języka Super C, które możesz od razu zastosować w swoim kodzie.

Uwaga Niektóre konstrukcje obsługiwane przez język C nie działają w C++. Aby dowiedzieć się więcej, zapoznaj się z odnośnikami znajdującymi się na stronie internetowej poświęconej tej książce (https://ccc.codes).

Przeciążanie funkcji

Weź pod uwagę następujące funkcje konwertujące istniejące w standardowej bibliotece C:

char* itoa(int value, char* str, int base);

char* ltoa(long value, char* buffer, int base);

char* ultoa(unsigned long value, char* buffer, int base);

Cel tych funkcji jest taki sam: przekształcają one typ całkowy na łańcuch języka C. W języku C każda funkcja musi mieć unikatową nazwę. Inaczej jest jednak w C++: funkcje mogą mieć takie same nazwy, dopóki ich argumenty się różnią - nazywa się to przeciążaniem funkcji. Jak pokazano na listingu 3, możesz użyć przeciążenia funkcji, aby stworzyć własne funkcje konwertujące.

char* toa(int value, char* buffer, int base) {

  --cięcie--

}

char* toa(long value, char* buffer, int base)

  --cięcie--

}

char* toa(unsigned long value, char* buffer, int base) {

  --cięcie--

}

int main() {

  char buff[10];

  int a = 1;

  long b = 2;

  unsigned long c = 3;

  toa(a, buff, 10);

  toa(b, buff, 10);

  toa(c, buff, 10);

}

Listing 3. Wywoływanie funkcji przeciążonych

W przypadku każdej z funkcji typ danych pierwszego argumentu jest odmienny. Kompilator C++ ma więc wystarczającą ilość informacji, aby wybrać właściwą funkcję. Każde wywołanie toa używa unikatowej funkcji. Na początku tworzysz zmienne a , b i c , które są różnymi typami obiektów int odpowiadających jednej z trzech funkcji toa. Jest to wygodniejsze rozwiązanie niż definiowanie funkcji o wielu różnych nazwach, ponieważ wystarczy wymyślić jedną, a kompilator sam zdecyduje, którą funkcję należy wywołać.

Referencje

Wskaźniki są kluczową cechą języka C (a przez to większości oprogramowania systemowego). Umożliwiają one wydajne przetwarzanie dużych ilości informacji poprzez przekazywanie wyłącznie adresów zamiast rzeczywistych danych. Wskaźniki są równie ważne w języku C++, ale w jego przypadku istnieją dodatkowe opcje zabezpieczające, które chronią przed wyłuskaniem wskaźników o wartości NULL i niezamierzonymi zmianami wartości wskaźników.

Obsługę wskaźników znacznie poprawiają referencje. Są one podobne do wskaźników, ale istnieją między nimi również pewne, kluczowe różnice. Na poziomie składniowym referencje różnią się od wskaźników w dwóch obszarach. Po pierwsze, referencje deklarujesz za pomocą znaku &, zamiast *, jak pokazano na listingu 4.

struct HolmesIV {

  bool is_sentient;

  int sense_of_humor_rating;

};

void make_sentient(HolmesIV*); // Używa wskaźnika do struktury HolmesIV

void make_sentient(HolmesIV&); // Używa referencji do struktury HolmesIV

Listing 4. Kod ilustrujący sposób deklarowania funkcji przyjmujących wskaźniki i referencje

Po drugie, dostęp do elementów struktury uzyskuje się za pomocą operatora kropki (.), zamiast operatora strzałki (->), jak pokazano na listingu 5.

void make_sentient(HolmesIV* mike) {

  mike->is_sentient = true;

}

void make_sentient(HolmesIV& mike) {

  mike.is_sentient = true;

}

Listing 5. Program ilustrujący użycie operatorów kropek i strzałek

Na najniższym poziomie referencje są równoważne wskaźnikom, ponieważ tak jak one są darmową abstrakcją. Kompilator tworzy więc podobny kod. Aby to potwierdzić, weź pod uwagę wyniki kompilacji funkcji make_sentient z użyciem kompilatora GCC 8.3 dla platformy x86-64 z opcją -O2. Na listingu 6 przedstawiono kod asemblera wygenerowany na podstawie kodu z listingu 5.

make_sentient(HolmesIV*):

    mov BYTE PTR [rdi], 1

    ret

make_sentient(HolmesIV&):

    mov BYTE PTR [rdi], 1

ret

Listing 6. Kod asemblera wygenerowany na podstawie listingu 5

Referencje zapewniają jednak wyższe bezpieczeństwo w czasie kompilacji w porównaniu ze zwykłymi wskaźnikami, ponieważ, ogólnie rzecz biorąc, nie mogą być równe NULL.

W przypadku wskaźników mógłbyś użyć literału wskaźnikowego nullptr, aby przeprowadzić odpowiedni test bezpieczeństwa. Możesz więc odpowiednio zmodyfikować funkcję make_sentient, jak pokazano na listingu 7.

void make_sentient(HolmesIV* mike) {

  if(mike == nullptr) return;

  mike->is_sentient = true;

}

Listing 7. Modyfikacja kodu z listingu 5 w celu zaimplementowania weryfikacji z użyciem literału nullptr

Powyżej zaprezentowana weryfikacja jest niepotrzebna w przypadku użycia referencji. Nie oznacza to jednak, że referencje mają zawsze poprawną wartość. Rozważ następującą funkcję:

HolmesIV& not_dinkum() {

  HolmesIV mike;

  return mike;

}

Funkcja not_dinkum zwraca referencję, która z pewnością ma wartość inną niż NULL. Jednak ta referencja wskazuje jakieś miejsce w odzyskanym obszarze pamięci (prawdopodobnie obszar ramki stosu funkcji not_dinkum). Taki sposób zwracania referencji jest zabroniony. Rezultaty działania kodu będą naprawdę mizerne. Są one znane pod nazwą niezdefiniowanego zachowania w środowisku wykonawczym: program może ulec awarii, spowodować powstanie jakiegoś błędu lub zrobić coś zupełnie nieoczekiwanego.

Inna cecha związana z większym bezpieczeństwem użycia zmiennych referencyjnych polega na tym, że nie można ich ponownie przypisywać. Innymi słowy, po zainicjowaniu referencji nie można jej już zmienić, aby wskazywała inny adres pamięci, co pokazano na listingu 8.

int main() {

  int a = 42;

  int& a_ref = a;

  int b = 100;

  a_ref = b;

}

Listing 8. Program potwierdzający, że referencje nie mogą być ponownie przypisywane

Zmienna a_ref została zadeklarowana jako referencja do int a . Nie można jednak w żaden sposób sprawić, by zmienna ta mogła się później odwoływać do innej wartości typu int. Możesz co prawda spróbować ponownie ustawić zmienną a za pomocą operatora = , ale w rzeczywistości nastąpi wówczas przypisanie wartości b do wartości a, zamiast ustawienia a_ref w celu uzyskania referencji do b. Po uruchomieniu fragmentu kodu obie zmienne a i b będą równe 100, a a_ref nadal będzie wskazywać zmienną a. Listing 9 zawiera równoważny kod, w którym użyto wskaźników.

int main() {

  int a = 42;

  int* a_ptr = &a;

  int b = 100;

  *a_ptr = b;

}

Listing 9. Program równoważny listingowi 8, ale wykorzystujący wskaźniki

Na listingu 9 wskaźnik zostaje zadeklarowany za pomocą operatora *, zamiast & . Wartość zmiennej b zostaje przypisana do pamięci wskazywanej przez a_ptr . W przypadku referencji nie są wymagane żadne dodatkowe znaki po lewej stronie operatora przypisania. Jeśli jednak pominiesz znak * w wyrażeniu *a_ptr, kompilator stwierdzi, że próbujesz przypisać int do typu wskaźnikowego i wyświetli odpowiedni błąd.

Referencje są jedynie wskaźnikami zawierającymi dodatkowe środki ostrożności i odrobinę "lukru" syntaktycznego. Umieszczając referencję po lewej stronie znaku przypisania, ustawiasz wskazywaną zmienną na wartość jego prawej strony.

Inicjalizacja z użyciem słowa kluczowego auto

Język C często wymaga wielokrotnego powtarzania informacji o typie. W C++ możesz tylko raz przekazać informację o typie zmiennej, używając w tym celu słowa kluczowego auto. Kompilator rozpozna typ zmiennej, ponieważ będzie znał typ wartości użytej do jej zainicjalizowania. Rozważ następujące inicjalizacje zmiennych C++:

int x = 42;

auto y = 42;

Zmienne x i y są typu int. Możesz być zaskoczony tym, że kompilator potrafi wydedukować typ y, ale weź pod uwagę, że 42 jest literałem liczby całkowitej. W przypadku użycia słowa auto kompilator dedukuje typ znajdujący się po prawej stronie znaku przypisania, a następnie ustawia taki sam typ zmiennej. Ponieważ literał liczby całkowitej jest typu int, kompilator wywnioskuje, że typ y musi być również int. W tak prostym przykładzie jak powyższy nie wydaje się to być znaczącą korzyścią, ale trzeba wziąć pod uwagę zainicjalizowanie zmiennej wartością zwracaną przez funkcję, jak pokazano na listingu 10.

#include <cstdlib>

struct HolmesIV {

  --cięcie--

};

HolmesIV* make_mike(int sense_of_humor) {

  --cięcie--

}

int main() {

  auto mike = make_mike(1000);

  free(mike);

}

Listing 10. Program testowy inicjalizujący zmienną za pomocą zwracanej wartości funkcji

Program wykorzystujący słowo kluczowe auto jest łatwiejszy do zrozumienia i bardziej podatny na refaktoryzację niż w przypadku jawnego deklarowania typu zmiennej. Użycie słowa auto podczas deklarowania zmiennej uprości wprowadzanie modyfikacji w programie, jeśli będziesz chciał zmienić typ zwracany przez funkcję make_mike. Zalety użycia słowa kluczowego auto są jeszcze większe w przypadku bardziej złożonych typów, na przykład dotyczących szablonów biblioteki stdlib. W takich sytuacjach kompilator wykonuje za ciebie wszystkie czynności związane z dedukcją typów.

Uwaga Słowo auto możesz także uzupełniać o kwalifikatory const, volatile, & oraz *.

Przestrzenie nazw i niejawne użycie słowa typedef z typami struct, union i enum

C++ niejawnie uzupełnia oznaczenia typów o słowo typedef. Jeśli w języku C chcesz użyć struktury, unii lub typu wyliczeniowego, musisz przypisać nazwę do utworzonego typu za pomocą słowa kluczowego typedef. Na przykład:

typedef struct Jabberwocks {

  void* tulgey_wood;

  int is_galumphing;

} Jabberwock;

Pisząc programy w języku C++, wyśmiewasz się z takiego kodu. Ponieważ słowo kluczowe typedef może zostać użyte niejawnie, C++ pozwala zadeklarować typ Jabberwock w następujący sposób:

struct Jabberwock {

  void* tulgey_wood;

  int is_galumphing;

};

Taki zapis jest wygodniejszy i pozwala zaoszczędzić trochę miejsca. Co się jednak stanie, jeśli będziesz chciał zdefiniować również funkcję o nazwie Jabberwock? Cóż, nie powinieneś tego robić, ponieważ ponowne użycie tej samej nazwy dla typu danych i funkcji może spowodować pewne zamieszanie. Jeśli naprawdę jesteś zdeterminowany, wiedz o tym, że język C++ pozwala zadeklarować przestrzeń nazw (namespace), aby tworzyć różne zakresy dla identyfikatorów. Dzięki temu można w przypadku typów i funkcji zdefiniowanych przez użytkowników zadbać o porządek, jak pokazano na listingu 11.

#include <cstdio>

namespace Creature {

  struct Jabberwock {

   void* tulgey_wood;

   int is_galumphing;

  };

}

namespace Func {

  void Jabberwock() {

   printf("Burble!");

  }

}

Listing 11. Użycie przestrzeni nazw w celu ujednoznacznienia funkcji i typów o identycznych nazwach

W powyższym przykładzie struktura i funkcja Jabberwock żyją wspólnie w "bardosnej"2 harmonii. Umieszczając każdy element w jego własnej przestrzeni nazw - strukturę w przestrzeni nazw Creature , a funkcję w przestrzeni nazw Func - możesz jednoznacznie określić, którą nazwę Jabberwock masz na myśli. Można dokonać takiego ujednoznacznienia na kilka sposobów. Najprostszym z nich jest zakwalifikowanie nazwy za pomocą podanej przestrzeni nazw, na przykład:

Creature::Jabberwock x;

Func::Jabberwock();

Możesz także zastosować dyrektywę using, aby zaimportować wszystkie nazwy występujące w danej przestrzeni nazw. W takim przypadku nie będziesz już musiał używać w pełni kwalifikowanej nazwy elementu. Na listingu 12 użyto przestrzeni nazw Creature.

#include <cstdio>

namespace Creature {

  struct Jabberwock {

   void* tulgey_wood;

   int is_galumphing;

  };

}

namespace Func {

  void Jabberwock() {

   printf("Burble!");

  }

}

using namespace Creature;

int main() {

  Jabberwock x;

  Func::Jabberwock();

}

Listing 12. Wykorzystanie zapisu using namespace w celu odwołania się do typu zawartego w przestrzeni nazw Creature

Skorzystanie z zapisu using namespace pozwala pominąć kwalifikację z użyciem przestrzeni nazw . Wciąż jednak potrzebujesz kwalifikatora w nazwie Func::Jabberwock, ponieważ nie jest ona częścią przestrzeni nazw Creature.

Używanie przestrzeni nazw jest wykorzystaniem idiomatycznych cech języka C++ i realizowaniem zasady darmowej abstrakcji. Podobnie jak inne identyfikatory tego typu, również słowo namespace jest usuwane przez kompilator podczas generowania wynikowego kodu asemblera. Przydaje się ono zwłaszcza w dużych projektach, ponieważ pozwala umieszczać kod w różnych bibliotekach.

Łączenie plików obiektowych języków C i C++

Z zachowaniem pewnej ostrożności kody języków C i C++ mogą pokojowo współistnieć. Czasami konieczne jest, aby kompilator C połączył pliki obiektowe wygenerowane przez kompilator C++ (i odwrotnie). Taka operacja jest możliwa, ale wymaga trochę wysiłku.

Z łączeniem plików są związane dwa problemy. Po pierwsze, konwencje wywoływania w kodach języków C i C++ mogą być potencjalnie niezgodne ze sobą. Na przykład mogłyby istnieć odmienne protokoły ustawiania stosu i rejestrów podczas wywoływania funkcji. Te różnice w konwencjach wywoływania występują na poziomie języka i nie są ogólnie związane z tym, w jaki sposób tworzysz swoje funkcje. Po drugie, kompilatory C++ generują inne symbole niż kompilatory C. Czasami konsolidator musi zidentyfikować obiekt po nazwie. Kompilatory C++ umożliwiają dekorowanie obiektu, dołączając do niego łańcuch zwany nazwą dekorowaną. Kompilator, wykorzystując dekorację, zapamiętuje oprócz samej nazwy funkcji również inne związane z nią informacje. Mogą one dotyczyć przeciążania funkcji, konwencji wywoływania czy też użycia określonej przestrzeni nazw. Dekorowanie ma na celu zapewnienie, że konsolidator będzie mógł jednoznacznie zidentyfikować daną funkcję. Niestety, nie został stworzony żaden standard definiujący sposób występowania dekoracji w języku C++ (dlatego podczas łączenia jednostek kompilacji powinieneś używać tych samych narzędzi i ustawień). Konsolidatory języka C nie mają pojęcia o sposobie dekorowania nazw C++, co może spowodować problemy, jeśli dekoracja nie zostanie odpowiednio zmieniona za każdym razem, gdy będziesz dołączać kod C do kodu języka C++ (i odwrotnie).

Rozwiązanie problemu jest proste. Jeśli będziesz chciał dołączyć kod języka C, powinieneś zawrzeć go w instrukcji extern "C", jak pokazano na listingu 13.

// header.h

#ifdef __cplusplus

extern "C" {

#endif

void extract_arkenstone();

struct MistyMountains {

  int goblin_count;

};

#ifdef __cplusplus

}

#endif

Listing 13. Wykorzystanie plików obiektowych wygenerowanych w stylu języka C

Ten nagłówek może być współdzielony przez kody C i C++. Działa on poprawnie, ponieważ __cplusplus jest specjalnym identyfikatorem zdefiniowanym w kompilatorze C++ (ale nie w kompilatorze C). Po zakończeniu działania preprocesora kompilator C widzi kod taki, jak na listingu 14.

void extract_arkenstone();

struct MistyMountains {

  int goblin_count;

};

Listing 14. Kod otrzymany po przetworzeniu przez preprocesor listingu 13 w środowisku języka C

Powstaje zwykły plik nagłówkowy języka C. Kod pomiędzy instrukcjami #ifdef __cplusplus zostaje usunięty podczas przetwarzania wstępnego, więc zewnętrzny kod opakowujący extern "C" nie jest widoczny. W przypadku C++ identyfikator __cplusplus jest zdefiniowany w pliku nagłówkowym header.h, więc kompilator widzi zawartość listingu 15.

extern "C" {

  void extract_arkenstone();

  struct MistyMountains {

   int goblin_count;

  };

}

Listing 15. Kod otrzymany po przetworzeniu przez preprocesor listingu 13 w środowisku języka C++

Zarówno nazwa extract_arkenstone, jak i MistyMountains są teraz zawarte w bloku extern "C", więc kompilator wie, że powinien połączyć pliki obiektowe języka C. Obecnie Twoje pliki źródłowe języka C mogą wywoływać skompilowany kod C++, a kody źródłowe języka C++ wywoływać skompilowany kod C.

Główne cechy języka C++

W tym podrozdziale zwięźle zaprezentujemy najważniejsze cechy, dzięki którym C++ jest najważniejszym językiem programowania systemowego. Nie przejmuj się zbytnio szczegółami. Podstawowym celem tego podrozdziału jest zachęcenie Cię do dalszej nauki.

Zwięzłe wyrażanie idei i ponowne wykorzystanie kodu

Dobrze napisany kod w języku C++ jest przejrzysty i zwięzły. Spójrz, jak wygląda ewolucja z języka ANSI-C do nowoczesnego C++ w przypadku następującej prostej operacji: przetwarzanie w pętli tablicy v posiadającej n elementów. Odpowiedni kod pokazano na listingu 16.

#include <cstddef>

int main() {

  const size_t n{ 100 };

  int v[n];

  // ANSI-C

  size_t i;

  for (i=0; i<n; i++) v[i] = 0;

  // C99

  for (size_t i=0; i<n; i++) v[i] = 0;

  // C++17

  for (auto& x : v) x = 0;

}

Listing 16. Program ilustrujący kilka sposobów iterowania po tablicy

Na powyższym fragmencie kodu widzimy różne sposoby deklarowania pętli w językach ANSI-C, C99 i C++. Zmienna indeksująca i w wersjach ANSI-C i C99 została podporządkowana temu, co próbujesz osiągnąć, a mianowicie uzyskiwaniu dostępu do każdego elementu tablicy v. Wersja C++ wykorzystuje pętlę opartą na zakresie, która po prostu przetwarza wszystkie wartości tablicy v, jednocześnie ukrywając szczegóły dotyczące metody iteracji. Podobnie jak wiele innych darmowych abstrakcji w języku C++, również ta konstrukcja pozwala skoncentrować się na znaczeniu kodu, a nie na jego składni. Pętle for oparte na zakresie działają z wieloma typami. Możesz nawet sprawić, aby działały z typami zdefiniowanymi przez użytkownika.

Jeśli już wspominamy o typach zdefiniowanych przez użytkownika, powinniśmy zauważyć, że pozwalają one wyrażać idee bezpośrednio w kodzie. Załóżmy, że chcesz zaprojektować funkcję navigate_to, która nakazuje hipotetycznemu robotowi, aby przemieścił się do jakiejś pozycji o współrzędnych x i y. Weź pod uwagę następujący prototyp funkcji:

void navigate_to(double x, double y);

Czym są zmienne x i y? Jakich jednostek miar używają? Aby się dowiedzieć, należy się zapoznać z dokumentacją (lub ewentualnie kodami źródłowymi). A oto następujący ulepszony prototyp:

struct Position{

--cięcie--

};

void navigate_to(const Position& p);

Ta funkcja jest znacznie bardziej przejrzysta. Nie istnieje już żadna dwuznaczność związana z używanymi przez nią parametrami. Dopóki masz poprawnie zdefiniowaną strukturę Position, wiesz dokładnie, w jaki sposób możesz wywołać funkcję navigate_to. Martwienie się o jednostki, konwersje itd. jest teraz obowiązkiem programisty, który stworzy klasę Position.

Podobną przejrzystość możesz osiągnąć w wersjach C99 i C++11 za pomocą wskaźnika const, ale to C++ sprawia, że typy zwracane są zwarte i ekspresywne. Załóżmy, że dla robota chcesz napisać następną funkcję o nazwie get_position, która - dobrze zgadłeś! - odczytuje jego pozycję. W języku C masz dwie możliwości, jak pokazano na listingu 17.

Position* get_position();

void get_position(Position* p);

Listing 17. Interfejs API w stylu C służący do zwracania typu zdefiniowanego przez użytkownika

W przypadku pierwszej opcji funkcja wywołująca jest odpowiedzialna za usunięcie zwracanej wartości , która prawdopodobnie została przydzielona dynamicznie (chociaż na podstawie kodu nie możemy tego stwierdzić z pewnością). W drugiej opcji funkcja wywołująca musi przydzielić pamięć dla struktury Position, którą powinna przekazać do get_position . To podejście jest bardziej zbliżone do typowego stylu C, ale na przeszkodzie staje sam język: chcesz tylko odczytać obiekt Position, ale dodatkowo musisz zadbać o to, by funkcja wywołująca lub wywoływana była odpowiedzialna za przydzielanie i zwalnianie pamięci. Język C++ pozwala w zwięzły sposób zwracać bezpośrednio z funkcji typy zdefiniowane przez użytkownika, jak pokazano na listingu 18.

Position get_position() {

  --cięcie--

}

void navigate() {

  auto p = get_position();

  // zmienną p można od razu używać

  --cięcie--

}

Listing 18. Zwracanie przez wartość typu zdefiniowanego przez użytkownika w języku C++

Ponieważ get_position zwraca wartość , kompilator może pominąć tworzenie kopii, więc program działa tak, jakbyś bezpośrednio stworzył automatyczną zmienną Position . Nie pojawia się żaden narzut w czasie wykonania. Funkcjonalnie odpowiada to blisko stylowi języka C, w którym następuje przekazywanie przez referencję, co pokazano na listingu 17.

Biblioteka standardowa C++

Biblioteka standardowa C++ (stdlib) sprawia, że dla niej samej warto pozostawić język C i zacząć używać C++. Zawiera ona wysokiej jakości kod generyczny, który jest zgodny ze standardami i od razu gotowy do wykorzystania. Trzema znaczącymi komponentami stdlib są kontenery, iteratory i algorytmy.

Kontenery są strukturami danych, które przechowują serie obiektów. Działanie kontenerów zostało dobrze przetestowane, są one bezpieczne w użytkowaniu i (zazwyczaj) co najmniej tak wydajne jak kod, który musiałbyś samodzielnie napisać. Oznacza to, że stworzenie własnej wersji tych kontenerów wymagałoby dużego wysiłku, a uzyskany wynik nie byłby lepszy od tego, co już mamy dostępne w bibliotece stdlib. Kontenery zostały podzielone na dwie kategorie: kontenery sekwencyjnekontenery asocjacyjne. Kontenery sekwencyjne są koncepcyjnie podobne do tablic - zapewniają dostęp do ciągu elementów. Kontenery asocjacyjne zawierają pary klucz/wartość, więc elementy mogą być wyszukiwane według klucza.

Algorytmy biblioteki stdlib są funkcjami ogólnego przeznaczenia i służą do wykonywania typowych zadań programistycznych, takich jak zliczanie, wyszukiwanie, sortowanie i przekształcanie. Podobnie jak kontenery, są wyjątkowo wysokiej jakości i mają szerokie zastosowanie. Użytkownicy bardzo rzadko powinni wdrażać własną wersję danego algorytmu, ponieważ użycie biblioteki stdlib znacznie zwiększa wydajność programisty, bezpieczeństwo kodu i jego czytelność.

Iteratory są elementem łączącym kontenery z algorytmami. Jeśli aplikacja wykorzystuje algorytmy biblioteki stdlib, często zdarza się, że dane, które chcesz przetwarzać, znajdują się w kontenerze. Kontenery udostępniają iteratorom jednolity interfejs, a algorytmy wykorzystują iteratory, dzięki czemu programiści (włącznie z tymi, którzy implementują bibliotekę stdlib) nie muszą tworzyć niestandardowego algorytmu dla każdego typu kontenera.

Na listingu 19 pokazano, w jaki sposób można posortować kontener z wartościami z użyciem kilku wierszy kodu.

#include <vector>

#include <algorithm>

#include <iostream>

int main() {

  std::vector<int> x{ 0, 1, 8, 13, 5, 2, 3 };

  x[0] = 21;

  x.push_back(1);

  std::sort(x.begin(), x.end());

  std::cout << "Printing " << x.size() << " Fibonacci numbers.\n";

  for (auto number : x) {

   std::cout << number << std::endl;

  }

}

Listing 19. Sortowanie kontenera z wartościami za pomocą biblioteki stdlib

W tle wykonywanych jest sporo obliczeń, ale sam kod jest zwarty i ekspresywny. Najpierw inicjalizujesz kontener std::vector . Wektory to tablice o dynamicznych rozmiarach, zdefiniowane w bibliotece stdlib. Nawiasy inicjalizujące ({0, 1, ...}) umożliwiają zdefiniowanie wartości elementów w wektorze x. Używając nawiasów kwadratowych ([]) i wartości indeksu, możesz, podobnie jak w przypadku tablicy, uzyskać dostęp do elementów wektora. Tę technikę stosuje się, aby przypisać pierwszemu elementowi wartość 21 . Ponieważ wektory mają dynamiczne rozmiary, można do nich dodawać elementy z zastosowaniem metody push_back . Pozornie magiczne wywołanie std::sort prezentuje potęgę algorytmów dostępnych w bibliotece stdlib . Metody x.begin() i x.end() zwracają iteratory, które są następnie używane przez algorytm std::sort do przeprowadzenia natychmiastowego sortowania. Użycie iteratorów separuje algorytm sort od wektora.

Dzięki iteratorom możesz także używać innych kontenerów z biblioteki stdlib. Na przykład zamiast wektora, mógłbyś wykorzystać listę (mówiąc dokładniej, listę dwukierunkową). Ponieważ lista również udostępnia iteratory za pomocą metod .begin() i .end(), możesz wywołać algorytm sort w taki sam sposób, jak w przypadku wektora.

Poza tym na listingu 19 została użyta biblioteka strumieni iostreams. Jest to mechanizm udostępniony przez bibliotekę stdlib, który służy do wykonywania buforowanych operacji wejścia i wyjścia. Aby wysłać do strumienia std::cout (który hermetyzuje standardowe wyjście stdout) wartość x.size() (liczbę elementów zawartych w wektorze x), niektóre literały łańcuchowe oraz liczbę elementów ciągu Fibonacciego, używasz operatora wstawiania (<<) . Obiekt std::endl jest manipulatorem wejścia i wyjścia, który zapisuje znak \n i opróżnia bufor, zapewniając, że przed wykonaniem następnej instrukcji cały strumień zostanie wysłany na standardowe wyjście stdout.

A teraz wyobraź sobie, ilu wyzwaniom musiałbyś stawić czoła, aby stworzyć równoważny program w języku C. Teraz już wiesz, dlaczego biblioteka stdlib jest tak cennym narzędziem.

Wyrażenia lambda

Wyrażenia lambda, zwane również w niektórych kręgach funkcjami nienazwanymi lub anonimowymi, to kolejna, potężna właściwość języka, która poprawia poziom uporządkowania kodu. Czasem innej funkcji należy przekazać wskaźnik do funkcji lub wykonać transformację każdego elementu serii. W takich przypadkach dość niewygodne byłoby zdefiniowanie funkcji o jednorazowym zastosowaniu. Tu właśnie pojawiają się wyrażenia lambda. Wyrażenie lambda to unikatowa funkcja użytkownika, zdefiniowana wewnątrz instrukcji i zawierająca parametry wywołania. Rozważ następujący wiersz. Funkcja ta oblicza, ile liczb parzystych znajduje się w kontenerze x:

auto n_evens = std::count_if(x.begin(), x.end(),

[] (auto number) { return number % 2 == 0; });

Ten fragment kodu używa algorytmu count_if z biblioteki stdlib do zliczania liczb parzystych w kontenerze x. Pierwsze dwa argumenty funkcji std::count_if są takie same, jak w przypadku std::sort. Są to iteratory, które określają zakres działania algorytmu. Trzecim argumentem jest wyrażenie lambda. Użyta notacja wygląda prawdopodobnie nieco dziwnie, ale zasady definiowania wyrażenia lambda są proste:

[obszar przechwytywania] (argumenty) { treść }

Obszar przechwytywania (capture) zawiera wszelkie niezbędne obiekty z zakresu, dla którego zdefiniowano wyrażenie lambda, aby wykonać obliczenia zawarte w jego treści. Argumenty określają nazwy i typy parametrów, z którymi wyrażenie lambda powinno zostać wywołane. Treść (body) zawiera wszelkie obliczenia, które należy wykonać po wywołaniu wyrażenia lambda. Może ono (ale nie musi) zwrócić jakąś wartość. Kompilator wydedukuje prototyp funkcji na podstawie domniemanych typów.

W powyższym wywołaniu algorytmu std::count_if wyrażenie lambda nie musiało przechwytywać żadnych zmiennych. Wszystkie niezbędne dane są zawarte w pojedynczym argumencie number. Ponieważ kompilator zna typ elementów zawartych w kontenerze x, typ parametru number jest określany słowem kluczowym auto, dzięki czemu można go poprawnie wydedukować. Wyrażenie lambda jest wywoływane dla każdego elementu kontenera x, który jest przekazywany jako parametr number. Treść wyrażenia lambda zwraca wartość true tylko wtedy, gdy liczba jest podzielna przez 2, więc zliczane są jedynie liczby parzyste.

Wyrażenia lambda nie istnieją w języku C i nie można ich w nim emulować. Za każdym razem, gdy będziesz potrzebować obiektu funkcyjnego, musisz zadeklarować oddzielną funkcję. Poza tym nie jest możliwe przechwytywanie obiektów w sposób, jaki jest dostępny w wyrażeniach lambda.

Programowanie generyczne z użyciem szablonów

Programowanie generyczne polega na tworzeniu kodu działającego z różnymi typami, zamiast na konieczności wielokrotnego powtarzania tego samego kodu przez kopiowanie i wklejanie każdego typu, który chcesz obsługiwać. W języku C++ używasz szablonów w celu utworzenia kodu generycznego. Szablony są specjalnym rodzajem parametrów, które informują kompilator, by uwzględniał szeroki zakres możliwych typów.

Być może nie zdajesz sobie z tego sprawy, ale już używałeś szablonów. Wszystkie kontenery biblioteki stdlib wykorzystują szablony. Typ obiektów, przechowywanych w większości kontenerów, nie ma znaczenia. Przykładowo od tego typu nie zależy działanie logiki określającej liczbę elementów w kontenerze lub zwracającej jego pierwszy element.

Załóżmy, że chcesz napisać funkcję, która doda trzy liczby tego samego typu. Chciałbyś używać dowolnego typu danych, które można dodawać do siebie. W przypadku języka C++ jest to prosty, ogólny problem programistyczny, który można rozwiązać bezpośrednio za pomocą szablonów, jak pokazano na listingu 20.

template <typename T>

T add(T x, T y, T z) {

  return x + y + z;

}

int main() {

  auto a = add(1, 2, 3); // a jest typu int

  auto b = add(1L, 2L, 3L); // b jest typu long

  auto c = add(1.F, 2.F, 3.F); // c jest typu float

}

Listing 20. Użycie szablonów w celu utworzenia ogólnej funkcji dodawania

Podczas deklarowania funkcji add nie musisz znać typu T. Powinieneś tylko wiedzieć, że wszystkie argumenty i wartość zwracana są typu T, a obiekty tego typu można dodawać. Gdy kompilator napotka wywołanie add, wydedukuje typ T i wygeneruje funkcję zgodnie z Twoim życzeniem. To znaczący przypadek ponownego użycia kodu!

Niezmienniki klas i zarządzanie zasobami

Być może największą indywidualną innowacją, jaką język C++ wprowadził do programowania systemowego, był cykl życia obiektu. Ta koncepcja ma swoje korzenie jeszcze w języku C, w którym obiekty mają różne okresy przechowywania w zależności od tego, jak zostaną zadeklarowane w kodzie.

Język C++ wykorzystuje ten model zarządzania pamięcią w przypadku konstruktorów i destruktorów. Te specjalne funkcje są metodami należącymi do typów zdefiniowanych przez użytkownika. Typy zdefiniowane przez użytkownika są podstawowymi elementami składowymi aplikacji C++. Możesz je traktować jako struktury, które mogą również zawierać funkcje.

Konstruktor obiektu jest wywoływany zaraz po rozpoczęciu jego okresu przechowywania, a destruktor jest wywoływany tuż przed jego zakończeniem. Zarówno konstruktor, jak i destruktor są funkcjami bez typu zwracanego i mają taką samą nazwę jak klasa, do której należą. Aby zadeklarować destruktor, należy przed nazwą klasy dodać znak tyldy (~), jak pokazano na listingu 21.

#include <cstdio>

struct Hal {

  Hal() : version{ 9000 } { // Konstruktor

   printf("I'm completely operational.\n");

  }

  ~Hal() { // Destruktor

   printf("Stop, Dave.\n");

  }

  const int version;

};

Listing 21. Klasa Hal zawierająca konstruktor i destruktor

Pierwszą metodą w klasie Hal jest konstruktor . Konfiguruje on obiekt Hal i ustanawia niezmienniki klasy (class invariants). Niezmienniki to cechy klasy, które nie zmieniają się po ich utworzeniu. Z pewną pomocą kompilatora i środowiska wykonawczego programista decyduje, jakie mają być niezmienniki klasy, i sprawia, że jego kod je egzekwuje. W tym przypadku konstruktor przypisuje wartość 9000 polu version, które jest niezmiennikiem. Destruktor jest drugą metodą . Ilekroć pamięć obiektu Hal ma zostać zwolniona, destruktor wyświetla w konsoli napis "Stop, Dave." (sprawienie, by Hal zaśpiewał "Daisy Bell", pozostawiam czytelnikowi jako ćwiczenie).

Kompilator zapewnia, że metody konstruktor i destruktor są wywoływane automatycznie dla obiektów ze statycznym, z lokalnym okresem przechowywania, a także z lokalnym czasem przechowywaniu wątku. W przypadku obiektów z dynamicznym okresem przechowywania używasz słów kluczowych new i delete, które zastępują starsze konstrukcje malloc i free, jak pokazano na listingu 22.

#include <cstdio>

struct Hal {

--cięcie--

};

int main() {

  auto hal = new Hal{}; // Pamięć została przydzielona, a następnie został wywołany konstruktor

  delete hal; // Destruktor został wywołany, a następnie została zwolniona pamięć

}

I'm completely operational.

Stop, Dave.

Listing 22. Program tworzący i usuwający obiekt klasy Hal

Jeśli (z jakiegokolwiek powodu) konstruktor nie jest w stanie ukończyć działania, zazwyczaj zgłasza wyjątek. Jako programista C mogłeś już mieć do czynienia z wyjątkami podczas programowania z użyciem niektórych interfejsów API systemu operacyjnego (na przykład Windows Structured Exception Handling). Po zgłoszeniu wyjątku stos jest rozwijany, dopóki nie zostanie znaleziony moduł obsługi wyjątków. W tym momencie program odzyskuje kontrolę nad swoim działaniem. Rozsądne użycie wyjątków może uprościć kod, ponieważ sprawdzanie warunków błędów można wykonywać jedynie wówczas, gdy ma to sens. C++ obsługuje wyjątki na poziomie językowym, co ilustruje listing 23.

#include <exception>

try {

  // Pewien kod, który mógłby zgłosić wyjątek std::exception

} catch (const std::exception &e) {

  // W tym miejscu program zaczyna kontynuować swoje działanie

}

Listing 23. Blok try-catch

Kod, który może zgłosić wyjątek, powinno się umieścić w bloku znajdującym się zaraz po instrukcji try . Jeśli w którymkolwiek momencie zostanie zgłoszony wyjątek, stos się rozwinie (łaskawie niszcząc wszelkie obiekty, które znajdą się poza zakresem) i uruchomi dowolny kod, który umieściłeś po wyrażeniu catch . Jeżeli żaden wyjątek nie zostanie zgłoszony, kod wewnątrz instrukcji catch nigdy nie zostanie wykonany.

Konstruktory, destruktory i wyjątki są ściśle związane z inną podstawową cechą C++. Cecha ta wiąże cykl życia obiektu z zasobami, jakie on posiada. Jest to koncepcja zwana inicjalizowaniem w chwili pozyskania zasobu (paradygmat RAII), czasami także nazywana opisowo "konstruktor przydziela, destruktor zwalnia". Przyjrzyj się klasie C++ zaprezentowanej na listingu 24.

#include <system_error>

#include <cstdio>

struct File {

  File(const char* path, bool write) {

   auto file_mode = write ? "w" : "r";

   file_pointer = fopen(path, file_mode);

   if (!file_pointer) throw std::system_error(errno, std::system_category());

  }

  ~File() {

   fclose(file_pointer);

  }

  FILE* file_pointer;

};

Listing 24. Klasa File

Konstruktor klasy File przyjmuje dwa argumenty . Pierwszy argument odpowiada ścieżce pliku, a drugi jest wartością typu bool odpowiadającą temu, czy plik powinien zostać otwarty do zapisu (true), czy do odczytu (false). Wartość tego argumentu jest przypisywana do pola file_mode za pomocą operatora trójargumentowego ?:. Operator trójargumentowy ocenia wyrażenie logiczne i zwraca jedną z dwóch wartości w zależności od wyniku. Na przykład:

x ? val_if_true : val_if_false

Jeśli wyrażenie logiczne x jest równe true, wartość wyrażenia będzie wynosić val_if_true. Jeśli x jest równe false, wartość wyrażenia będzie równa val_if_false.

We fragmencie kodu z listingu 24 konstruktor obiektu File próbuje otworzyć plik o ścieżce path w trybie do odczytu lub do zapisu . Jeśli coś nie zadziała, wywołanie funkcji fopen przypisze polu file_pointer wartość nullptr (jest to specjalna wartość zdefiniowana w języku C++ podobna do 0). Gdy tak się stanie, zostanie zgłoszony wyjątek system_error . Ten wyjątek to po prostu obiekt, który zawiera szczegółowe informacje o błędzie systemowym. Jeśli pole file_pointer nie będzie równe nullptr, będzie je można użyć. Jest to niezmiennik tej klasy.

Rozważ teraz program z listingu 25 wykorzystujący klasę File.

#include <cstdio>

#include <system_error>

#include <cstring>

struct File {

--cięcie--

};

int main() {

  {

   File file("last_message.txt", true);

   const auto message = "We apologize for the inconvenience.";

fwrite(message, strlen(message), 1, file.file_pointer);

  }

  // W tym miejscu plik last_message.txt jest już zamknięty!

  {

   File file("last_message.txt", false);

   char read_message[37]{};

   fread(read_message, sizeof(read_message), 1, file.file_pointer);

   printf("Read last message: %s\n", read_message);

  }

}

We apologize for the inconvenience.

Listing 25. Program wykorzystujący klasę File

Nawiasy klamrowe definiują zakres. Ponieważ wewnątrz tego zakresu znajduje się pierwsza zmienna file, określa on czas jej życia. Gdy konstruktor zakończy swoje działanie , dzięki niezmiennikowi klasy będziesz już wiedział, że wartość file.file_pointer jest poprawna. Znając działanie konstruktora klasy File, wiesz, że wartość file.file_pointer musi być poprawna przez cały czas istnienia obiektu File. Następnie zapisujesz wiadomość za pomocą funkcji fwrite. Nie ma potrzeby jawnego wywoływania funkcji fclose, ponieważ zmienna file wykracza poza zakres, a destruktor automatycznie resetuje pole file.file_pointer za Ciebie . Ponownie tworzysz obiekt klasy File, ale tym razem w trybie do odczytu . Jeśli konstruktor wykona to prawidłowo, będziesz wiedział, że plik last_message .txt został pomyślnie otwarty, dlatego wczytasz jego zawartość do zmiennej read_message. Po wyświetleniu wiadomości wywoływany jest destruktor obiektu file, a wskaźnik file.file_pointer jest ponownie resetowany.

Czasami potrzebowałbyś jeszcze większej uniwersalności związanej z dynamicznym przydzielaniem pamięci, jednak nadal chciałbyś polegać na cyklu życia obiektu w C++, aby mieć pewność, że nie pojawią się wycieki pamięci lub przypadkowo nie skorzystasz ze wskaźnika po jego zwolnieniu. Jest to rola wskaźników inteligentnych, które zarządzają cyklem życia obiektów dynamicznych za pomocą modelu własności. Gdy żaden wskaźnik inteligentny nie będzie właścicielem obiektu dynamicznego, obiekt ten ulegnie zniszczeniu.

Jednym z takich wskaźników inteligentnych jest unique_ptr, który modeluje własność wyłączną. Na listingu 26 przedstawiono podstawowy sposób jego użycia.

#include <memory>

struct Foundation{

  const char* founder;

};

int main() {

  std::unique_ptr<Foundation> second_foundation{ new Foundation{} };

  // Sposób dostępu do pola founder taki sam, jak w przypadku wskaźników

  second_foundation->founder = "Wanda";

}

Listing 26. Program wykorzystujący wskaźnik inteligentny unique_ptr

Obiekt Foundation zostaje dynamicznie zaalokowany, a uzyskany wskaźnik Foundation* jest przekazywany do konstruktora second_foundation z zastosowaniem odpowiedniej składni . Zmienna second_foundation jest typu unique_ptr. Typ ten jest po prostu obiektem RAII opakowującym dynamiczną zmienną Foundation. Gdy zmienna second_foundation zostanie zniszczona , usuwana jest również dynamiczna zmienna Foundation.

Wskaźniki inteligentne różnią się od zwykłych, standardowych wskaźników, ponieważ te drugie są po prostu adresami pamięci. Powoduje to, że musisz samodzielnie zarządzać pamięcią związaną z takimi adresami. Z drugiej strony wskaźniki inteligentne obsługują wszystkie nieprzyjemne szczegóły. Po opakowaniu dynamicznego obiektu wskaźnikiem inteligentnym masz pewność, że pamięć zostanie odpowiednio zwolniona, gdy tylko obiekt przestanie być już potrzebny. Kompilator będzie o tym wiedział, ponieważ destruktor wskaźnika inteligentnego zostanie wywołany, gdy obiekt znajdzie się poza zakresem.

Semantyka przenoszenia

Czasami chciałbyś zmodyfikować prawo własności obiektu. Taka sytuacja pojawia się często na przykład w przypadku używania wskaźników unique_ptr. Nie można skopiować obiektu o typie unique_ptr, ponieważ po zniszczeniu jednej z jego kopii inny element typu unique_ptr zawierałby odwołanie do usuniętego obiektu. Aby przenieść własność z jednego unikatowego wskaźnika na drugi, używasz więc semantyki przenoszenia C++, zamiast wykonywać kopiowanie obiektu. Tę operację zaprezentowano na listingu 27.

#include <memory>

struct Foundation{

  const char* founder;

};

struct Mutant {

  // W konstruktorze jest odpowiednio konfigurowany obiekt foundation:

  Mutant(std::unique_ptr<Foundation> foundation)

   : foundation(std::move(foundation)) {}

  std::unique_ptr<Foundation> foundation;

};

int main() {

  std::unique_ptr<Foundation> second_foundation{ new Foundation{} };

  // ... użycie zmiennej second_foundation

  Mutant the_mule{ std::move(second_foundation) };

  // Zmienna second_foundation jest w stanie "po-przeniesieniu"

  // Obiekt the_mule staje się właścicielem obiektu typu Foundation

}

Listing 27. Program przenoszący obiekt typu unique_ptr

Tak jak poprzednio, tworzysz unikatowy obiekt unique_ptr<Foundation> . Używasz go przez jakiś czas, a następnie decydujesz się przenieść prawa własności na obiekt Mutant. Funkcja move informuje kompilator, że chcesz wykonać transfer. Po utworzeniu obiektu the_mule czas życia obiektu Foundation zostaje z nim związany poprzez zmienną składową.

Zrelaksuj się i ciesz się swoimi nowymi butami

C++ jest faktycznie najważniejszym językiem programowania systemowego. Duża część Twojej wiedzy o języku C zostanie bezpośrednio wykorzystana w C++, jednakże nauczysz się także wielu nowych pojęć. Wykorzystując konstrukcje Super C, możesz zacząć stopniowo włączać język C++ do swoich programów napisanych w C. Gdy już zdobędziesz umiejętności związane z bardziej zaawansowanymi zagadnieniami języka C++, przekonasz się, że tworzenie kodu w nowoczesnym C++ ma wiele istotnych zalet w stosunku do C. Będziesz mógł zwięźle wyrażać idee w kodzie, wykorzystywać imponującą bibliotekę stdlib, by tworzyć na wyższym poziomie abstrakcji, używać szablonów w celu poprawy wydajności środowiska uruchomieniowego i ponownego wykorzystania kodu oraz opierać się na cyklu życia obiektu C++ w celu zarządzania zasobami.

Uważam, że inwestycja w naukę C++ przyniesie ogromne korzyści. Myślę, że po przeczytaniu tej książki zgodzisz się ze mną.

Część I. Język C++

Najpierw się czołgamy. Później pełzamy po stłuczonym szkle.

Scott Meyers, Effective STL

W części I książki przeanalizujemy najważniejsze pojęcia występujące w języku C++. W rozdziale 1 skonfigurujesz swoje środowisko pracy i poznasz niektóre konstrukcje językowe, w tym podstawowe zasady programowania obiektowego będącego główną abstrakcją używaną w języku C++.

W kolejnych pięciu rozdziałach zostaną dokładnie zaprezentowane obiekty i typy, czyli serce i dusza języka C++. W przeciwieństwie do innych książek o programowaniu nie będziesz musiał budować serwerów internetowych ani uruchamiać statków kosmicznych. Wszystkie programy zawarte w części I będą po prostu używać zwykłej konsoli. Zamiast gwarantować natychmiastową satysfakcję, postaramy się przede wszystkim, abyś mógł sobie stworzyć mentalny model języka.

Rozdział 2 zawiera obszerną analizę typów, które są konstrukcją językową definiującą Twoje obiekty.

Rozdział 3 uwzględnia zagadnienia omówione w rozdziale 2 i prezentuje typy referencyjne, które opisują obiekty odnoszące się do innych obiektów.

Rozdział 4 opisuje cykl życia obiektu, czyli jeden z najpotężniejszych aspektów języka C++.

W rozdziałach 5 i 6 zostaje przeanalizowany polimorfizm czasu kompilacji wykorzystujący szablony oraz polimorfizm czasu wykonania związany z interfejsami. Te dwie cechy pozwalają na tworzenie kodu o luźnych powiązaniach, który to kod nadaje się do wielokrotnego użytku.

Znając podstawy działania modelu obiektowego C++, będziesz gotowy do przestudiowania rozdziałów od 7 do 9. Dowiesz się w nich o wyrażeniach, instrukcjach i funkcjach, których będziesz używać do tworzenia kodu. Może się to wydawać trochę dziwne, że omówienie tych konstrukcji językowych pojawia się dopiero pod koniec części I, ale bez dobrej znajomości obiektów i ich cykli życia niemożliwe stałoby się zrozumienie niczego poza najbardziej podstawowymi cechami języka.

Język C++ jest wszechstronny, ambitny i potężny. Może to zniechęcić osoby, które dopiero zaczynają się go uczyć. Aby tego uniknąć, część I została napisana w taki sposób, który sprawia, że jest sekwencyjna i spójna, a dzięki temu może być czytana jak opowieść.

Część I to niejako opłata za wstęp. Ciężką pracę, którą wykonasz, ucząc się pojęć języka C++, będziesz mógł potraktować jako zezwolenie na skorzystanie ze "szwedzkiego bufetu" bibliotek i platform omawianych w części II.

Rozdział 1. Gotowi do pracy

[...] tak siarczyście uderzyłem w ziemię, że aż zakręciło mi się w głowie i wydrążyłem dziurę głęboką na dziewięć sążni. [...] Spojrzawszy w dół, zauważyłem, że mam na sobie buty z wyjątkowo wytrzymałymi paskami. Chwyciłem je mocno i zacząłem (wciąż na nowo) ciągnąć z całych sił.

Rudolf Raspe, Przygody barona Münchhausena

W tym rozdziale najpierw skonfigurujesz swoje środowisko programistyczne C++, które jest zbiorem narzędzi umożliwiających projektowanie oprogramowania C++. Za pomocą środowiska programistycznego skompilujesz pierwszą aplikację konsolową C++, którą będziesz mógł uruchomić z wiersza poleceń.

Następnie poznasz główne komponenty środowiska programistycznego oraz spełniane przez nich role podczas generowania aplikacji, którą napiszesz. W kolejnych rozdziałach omówimy na tyle dużo elementów języka C++, że pozwoli to na stworzenie użytecznych programów przykładowych.

C++ ma reputację języka, który jest trudny, aby się go nauczyć. To prawda, że C++ jest językiem rozbudowanym, złożonym i ambitnym, a nawet doświadczeni programiści wciąż uczą się nowych wzorców, funkcji i sposobów użycia.

Głównym źródłem istnienia tych wątpliwości jest to, że cechy języka C++ są ze sobą ściśle związane. Niestety, często powoduje to niepokój wśród osób dopiero rozpoczynających swoją przygodę z tym językiem. Ponieważ pojęcia C++ są tak ściśle ze sobą powiązane, po prostu nie jest oczywiste, od czego należy zacząć. W części I książki zaproponowano co prawda metodyczny sposób zapanowania nad tym całym zgiełkiem, ale nasza opowieść musi się od czegoś zacząć. W tym rozdziale zostanie więc przedstawione tylko tyle informacji, abyś mógł rozpocząć swoją naukę. Nie przejmuj się zbytnio szczegółami!

Struktura prostego programu C++

W tym podrozdziale napiszesz prosty program w języku C++, a następnie go skompilujesz i uruchomisz. Kod źródłowy C++ jest umieszczany w plikach tekstowych mających postać czytelną dla człowieka i zwanych plikami źródłowymi. Następnie używasz kompilatora do konwersji kodu źródłowego C++ na wykonywalny kod maszynowy. Jest on programem, który może już zostać uruchomiony w komputerze.

Weźmy się więc do pracy i utwórzmy pierwszy plik źródłowy C++.

Stworzenie pierwszego pliku źródłowego w języku C++

Otwórz swój ulubiony edytor tekstowy. Jeśli nie masz jeszcze takiego, wypróbuj programy Vim, Emacs lub gedit w systemie Linux, TextEdit w systemie Mac lub Notatnik w systemie Windows3. Wprowadź kod z listingu 1.1, a następnie na pulpicie zapisz wynikowy plik pod nazwą main.cpp.

#include <cstdio>

int main (){

  printf("Hello, world!");

  return 0;

}

Hello, world!

Listing 1.1. Twój pierwszy program w języku C++ wyświetla zdanie Hello, world! na ekranie

Plik źródłowy z kodem z listingu 1.1 zostaje skompilowany do programu, który wyświetla znaki Hello, world! na ekranie. Zgodnie z konwencją pliki źródłowe języka C++ mają rozszerzenie .cpp.

Uwaga Listingi w tej książce będą zawierać również dane wyjściowe programu. Dane te zostaną umieszczone zaraz pod kodem źródłowym. W odróżnieniu od niego wyniki zostaną wydrukowane w szarym odcieniu. Ewentualne adnotacje liczbowe będą odpowiadać wierszowi, który utworzył dany wynik. Na przykład instrukcja printf z listingu 1.1 jest odpowiedzialna za wynik Hello, world!, więc oba wiersze zawierają tę samą adnotację .

Funkcja main: punkt startowy programu

Jak widać na listingu 1.1, programy C++ mają pojedynczy punkt startowy zwany funkcją main . Punkt startowy to funkcja, która zostaje wykonana, gdy użytkownik uruchomi program. Funkcje to bloki kodu, które mogą przyjmować dane wejściowe, wykonywać instrukcje i zwracać wyniki.

W funkcji main wywołujesz funkcję printf, która wyświetla w konsoli znaki Hello, world! . Następnie program kończy pracę, zwracając kod wyjściowy 0 do systemu operacyjnego . Kody wyjściowe to wartości całkowite, które są używane przez system operacyjny do ustalenia, czy program zadziałał poprawnie. Ogólnie rzecz biorąc, kod wyjściowy o wartości zero (0) oznacza, że program uruchomił się pomyślnie. Inne kody wyjściowe mogą wskazywać na jakieś problemy. Użycie instrukcji return w funkcji main jest opcjonalne - domyślnym kodem wyjściowym jest wartość 0.

Funkcja printf nie została zdefiniowana w programie, lecz znajduje się w bibliotece cstdio .

Biblioteki: wykorzystywanie kodu zewnętrznego

Biblioteki są przydatnymi kolekcjami kodów, które można zaimportować do programów, aby uniknąć konieczności ponownego wymyślania koła. Praktycznie każdy język programowania ma jakiś sposób na dołączanie funkcji bibliotecznych do programu:

- Python, Go i Java używają instrukcji import; - Rust, PHP i C# używają use lub using; - JavaScript, Lua, R i Perl używają require lub requires; - C i C++ wykorzystują instrukcję #include.

Na listingu 1.1 wykorzystano bibliotekę cstdio , która wykonuje operacje wejścia i wyjścia służące na przykład do wyświetlania tekstu w konsoli.

Zestaw narzędzi kompilatora

Po stworzeniu kodu źródłowego C++ następnym krokiem jest jego przekształcenie w program wykonywalny. Zestaw narzędzi kompilatora (lub po prostu zestaw narzędzi) to zbiór trzech elementów, które uruchamiają się jeden po drugim w celu wykonania konwersji kodu źródłowego do programu:

- Preprocesor wykonuje podstawowe operacje na kodzie źródłowym. Na przykład #include <cstdio> jest dyrektywą, która instruuje preprocesor, aby zawarł informacje o bibliotece cstdio bezpośrednio w kodzie źródłowym programu. Po zakończeniu przetwarzania pliku źródłowego preprocesor tworzy pojedynczą jednostkę kompilacji. Każda jednostka kompilacji jest następnie przekazywana do kompilatora w celu dalszego przetwarzania; - Kompilator analizuje jednostkę kompilacji i generuje plik obiektowy. Pliki obiektowe zawierają format pośredni zwany kodem obiektowym. Pliki te zawierają dane i instrukcje w takim formacie, którego większość osób nie mogłaby zrozumieć. Kompilatory przetwarzają w danym momencie jedną jednostkę kompilacji, więc odpowiada ona jednemu plikowi obiektowemu; - Konsolidator (inaczej zwany linkerem) tworzy programy na podstawie plików obiektowych. Konsolidatory są również odpowiedzialne za wyszukanie bibliotek, które zawarłeś w kodzie źródłowym. Na przykład gdy kompilujesz program z listingu 1.1, konsolidator musi wyszukać bibliotekę cstdio i dołączyć z niej wszystko, czego Twój program potrzebuje, by skorzystać z funkcji printf. Zauważ, że nagłówek cstdio nie jest tym samym, co biblioteka cstdio. Nagłówek zawiera informacje o tym, jak korzystać z biblioteki. Więcej o bibliotekach i organizacji kodu źródłowego dowiesz się w rozdziale 21.

Konfigurowanie środowiska programistycznego

Każde środowisko programistyczne C++ umożliwia edycję kodu źródłowego i zawiera zestaw narzędzi kompilatora, aby przekształcić ten kod źródłowy w program. Środowisko programistyczne jest także często wyposażone w debugger - bezcenny program, który pozwala uruchamiać program wiersz po wierszu w celu znalezienia błędów.

Wszystkie te elementy - edytor tekstu, zestaw narzędzi kompilatora i debugger - mogą zostać umieszczone w jednym programie, który jest wówczas zwany zintegrowanym środowiskiem programistycznym (integrated development environment, w skrócie IDE). Takie środowiska mogą znacząco zwiększać wydajność pracy zarówno początkującym, jak i zaawansowanym programistom.

Uwaga Niestety język C++ nie ma interpretera, za pomocą którego można interaktywnie uruchamiać fragmenty kodu. Różni się tym od innych języków, takich jak Python, Ruby i JavaScript. Istnieją jednak pewne aplikacje internetowe, które pozwalają testować i udostępniać małe fragmenty kodu C++. Zapoznaj się z produktami Wandbox (https://wandbox.org/), który pozwala na kompilowanie i uruchamianie kodu, oraz Compiler Explorer stworzony przez Matta Godbolta (https://www.godbolt.org/), który pozwala na wyświetlenie kodu asemblera generowanego przez Twój program. Oba narzędzia wykorzystują różne kompilatory i systemy.

Każdy system operacyjny ma własne edytory kodu źródłowego i zestawy narzędzi kompilatora. Podrozdział ten został więc podzielony na punkty, z których każdy dotyczy innego systemu operacyjnego. Wybierz ten, który jest odpowiedni dla Ciebie.

System Windows 10 i nowsze wersje: Visual Studio

W chwili oddawania oryginalnego wydania tej książki do druku najpopularniejszym kompilatorem C++ dla systemu Microsoft Windows był Microsoft Visual C++ Compiler (w skrócie MSVC). Najłatwiejszym sposobem uzyskania programu MSVC jest zainstalowanie zintegrowanego środowiska Visual Studio 20174:

1. Pobierz wersję Visual Studio Community. Odpowiednie łącze jest dostępne na stronie https://ccc.codes/.

2. Uruchom instalatora, a następnie zaktualizuj go w razie potrzeby.

3. Upewnij się, że w zakładce Workloads (Zadania) została wybrana opcja Desktop development with C++ (Tworzenie programów C++ dla desktopów).

4. Kliknij przycisk Install (Zainstaluj), aby zainstalować zintegrowane środowisko Visual Studio.

5. Kliknij Launch (Uruchom), aby uruchomić program Visual Studio. Cały proces może potrwać kilka godzin w zależności od prędkości komputera i wybranych opcji. Typowe instalacje wymagają od 20 GB do 50 GB miejsca na twardym dysku.

Tworzenie nowego projektu:

1. Wybierz opcję File/New/Project (Plik/Nowy/Projekt).

2. W grupie Installed (Zainstalowane) kliknij opcję Visual C++, a następnie wybierz General (Ogólny). W środkowym panelu wybierz opcję Empty Project (Pusty projekt).

3. Wprowadź hello jako nazwę swojego projektu. Twoje okno powinno wyglądać jak na rysunku 1.1, ale lokalizacja projektu będzie się różnić w zależności od nazwy użytkownika. Kliknij OK.

Rysunek 1.1. Kreator nowego projektu w środowisku Visual Studio

4. W panelu Solution Explorer (Eksplorator solucji) znajdującym się po lewej stronie obszaru roboczego kliknij prawym przyciskiem myszy folder Source Files (Pliki źródłowe), a następnie wybierz opcję Add/Existing Item (Dodaj/Istniejący element). Szczegóły są widoczne na rysunku 1.2.

Rysunek 1.2. Dodawanie istniejącego pliku źródłowego do projektu Visual Studio

5. Wybierz utworzony wcześniej plik main.cpp z listingu 1.1. Jeśli jeszcze nie utworzyłeś tego pliku, możesz alternatywnie wybrać opcję New Item (Nowy element), zamiast Existing Item. Nazwij plik jako main.cpp i w oknie edytora wprowadź zawartość listingu 1.1.

6. Wybierz opcję Build/Build Solution (Zbuduj/Zbuduj solucję). Jeśli w oknie wyjściowym pojawią się jakieś komunikaty o błędach, upewnij się, że poprawnie wprowadziłeś zawartość listingu 1.1. Jeżeli błędy będą się nadal powtarzać, przeczytaj dokładnie ich opis, aby dowiedzieć się więcej o problemie.

7. Aby uruchomić program, wybierz opcję Debug/Start Without Debugging (Debugowanie/Uruchom bez debugowania) lub naciśnij klawisze Ctrl+F5. W konsoli powinien się pojawić tekst Hello, world! (a pod nim napis Press Any Key to Continue).

System macOS: Xcode

Jeśli używasz systemu macOS, powinieneś zainstalować środowisko programistyczne Xcode.

1. Otwórz App Store.

2. Wyszukaj i zainstaluj środowisko programistyczne Xcode. Instalacja może potrwać ponad godzinę, w zależności od prędkości urządzenia i połączenia internetowego. Po zakończeniu instalacji otwórz Terminal i przejdź do katalogu, w którym zapisałeś plik main.cpp.

3. W oknie terminalu wprowadź instrukcję clang++ main.cpp -o hello, aby skompilować program. Opcja -o informuje zestaw narzędzi, pod jaką nazwą powinien zostać zapisany plik wynikowy (jeśli pojawią się jakiekolwiek błędy kompilatora, sprawdź, czy poprawnie wprowadziłeś instrukcję).

4. W oknie terminalu wprowadź polecenie ./hello, aby uruchomić program. Na ekranie powinien się pojawić napis Hello, world!.

Aby skompilować i uruchomić program, otwórz środowisko Xcode i wykonaj następujące działania:

1. Wybierz opcję File/New/Project (Plik/Nowy/Projekt).

2. Wybierz opcję macOS/Command Line Tool (macOS/Narzędzia wiersza poleceń), a następnie kliknij Next (Następny). W kolejnym oknie dialogowym możesz zmodyfikować miejsce utworzenia katalogu plików projektu. Na razie zaakceptuj wartości domyślne i kliknij Create (Utwórz).

3. Wprowadź hello jako nazwę projektu. W polu Language (Język) wybierz opcję C++ (zob. rysunek 1.3).

4. Teraz musisz do swojego projektu zaimportować kod z listingu 1.1. Najprościej będzie to można zrobić przez skopiowanie i wklejenie zawartości pliku main.cpp do okna main.cpp projektu. Innym sposobem jest użycie programu Finder do zamiany Twojego pliku main.cpp na plik main.cpp projektu (podczas tworzenia nowych projektów nie będziesz tego musiał zazwyczaj wykonywać. Tę uwagę zamieszczono tutaj jedynie ze względu na to, że wskazówki dotyczą wielu systemów operacyjnych).

5. Kliknij przycisk Run (Uruchom).

Rysunek 1.3. Okno dialogowe New Project w środowisku Xcode

System Linux: Xcode

W momencie oddawania oryginalnej wersji tej książki do druku mogłeś mieć bezpośredni dostęp do kompilatorów GCC 8.1 i Clang 6.0.05 z użyciem narzędzia Advanced Package Tool (APT), które zarządza pakietami systemu Debian. W tym punkcie wyjaśniono, w jaki sposób można zainstalować narzędzia GCC i Clang w środowisku Ubuntu 18.04, będącym w tym czasie najnowszą wersją kanału obsługi długoterminowej (LTS).

1. Otwórz okno terminala.

2. Zaktualizuj i podnieś wersje obecnie zainstalowanych pakietów:

$ sudo apt update && sudo apt upgrade

3. Zainstaluj GCC 8 i Clang 6.0:

$ sudo apt install g++-8 clang-6.0

4. Sprawdź wersje GCC i Clang:

$ g++-8 -version

g++-8 (Ubuntu 8-20180414-1ubuntu2) 8.0.1 20180414 (experimental) [trunk

revision 259383]

Copyright (C) 2018 Free Software Foundation, Inc.

This is free software; see the source for copying conditions. There is NO

warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR

PURPOSE.

$ clang++-6.0 --version

clang version 6.0.0-1ubuntu2 (tags/RELEASE_600/final)

Target: x86_64-pc-linux-gnu

Thread model: posix

InstalledDir: /usr/bin

Jeśli pojawi się błąd informujący, że nie znaleziono polecenia, będzie to oznaczać, że pojawił się problem podczas instalacji odpowiedniego kompilatora. Spróbuj wówczas wyszukać bardziej szczegółowe informacje o otrzymanym błędzie, szczególnie w dokumentacji i na forach związanych z danym menedżerem pakietów.

Instalowanie kompilatora GCC na podstawie źródeł

Jeśli w menedżerze pakietów nie możesz znaleźć najnowszych wersji GCC lub Clang (lub wariant Twojego systemu Unix ich nie zawiera), zawsze możesz spróbować zainstalować kompilator GCC na podstawie jego źródeł. Pamiętaj, że zajmuje to dużo czasu (nawet kilka godzin), a dodatkowo może być niezbędne wykonanie pewnych manualnych działań: instalacja często kończy się błędami, które należy rozwiązać, aby móc kontynuować jej działanie. Aby zainstalować GCC, postępuj zgodnie z instrukcjami opublikowanymi na stronie https://gcc.gnu.org/. W tym podpunkcie zawarto jedynie skrót znacznie obszerniejszej dokumentacji dostępnej na wspomnianej stronie.

Uwaga Dla uproszczenia w niniejszej instrukcji nie przedstawiono szczegółów instalacji narzędzia Clang. Więcej informacji na jego temat uzyskasz na stronie https://clang.llvm.org/.

Aby zainstalować kompilator GCC 8.1 na podstawie jego źródeł, wykonaj następujące czynności:

1. Otwórz okno terminala.

2. Zaktualizuj i podnieś wersje obecnie zainstalowanych pakietów. Za pomocą przykładowego narzędzia APT możesz wykonać następujące polecenie:

$ sudo apt update && sudo apt upgrade

3. Z jednego z dostępnych serwerów lustrzanych opublikowanych na stronie https://gcc.gnu.org/mirrors.html pobierz pliki gcc-8.1.0.tar.gzgcc-8.1.0.tar.gz.sig. Są one dostępne w katalogu releases/gcc-8.1.0.

4. Sprawdź integralność pakietu (opcjonalnie). Najpierw zaimportuj odpowiednie klucze GnuPG. Można je znaleźć na stronie serwerów lustrzanych. Na przykład:

$ gpg --keyserver keyserver.ubuntu.com --recv C3C45C06

gpg: requesting key C3C45C06 from hkp server keyserver.ubuntu.com

gpg: key C3C45C06: public key "Jakub Jelinek <jakub@redhat.com>" imported

gpg: key C3C45C06: public key "Jakub Jelinek <jakub@redhat.com>" imported

gpg: no ultimately trusted keys found

gpg: Total number processed: 2

gpg: imported: 2 (RSA: 1)

Sprawdź, co zostało pobrane:

$ gpg --verify gcc-8.1.0.tar.gz.sig gcc-8.1.0.tar.gz

gpg: Signature made Wed 02 May 2018 06:41:51 AM DST using DSA key ID

C3C45C06

gpg: Good signature from "Jakub Jelinek <jakub@redhat.com>"

gpg: WARNING: This key is not certified with a trusted signature!

gpg: There is no indication that the signature belongs to the

owner.

Primary key fingerprint: 33C2 35A3 4C46 AA3F FB29 3709 A328 C3A2 C3C4

5C06

Ostrzeżenia, które się wyświetliły, oznaczają, że w moim komputerze nie oznaczyłem certyfikatu osoby podpisującej jako zaufanego. Aby sprawdzić, czy podpis należy do właściciela, musisz inną metodą zweryfikować klucz podpisu (na przykład osobiście spotykając się z właścicielem lub niezależnie weryfikując odcisk cyfrowy klucza podstawowego). Jeżeli chcesz uzyskać więcej informacji o oprogramowaniu kryptograficznym GNU Privacy Guard (GPG), zapoznaj się z książką PGP & GPG: Email for the Practical Paranoid autorstwa Michaela W. Lucasa lub otwórz stronę https://gnupg.org/download/integrity_check.html w celu uzyskania szczegółowych informacji na temat opcji związanych ze sprawdzaniem integralności GPG.

5. Rozpakuj pakiet (wykonanie tego polecenia może potrwać kilka minut):

$ tar xzf gcc-8.1.0.tar.gz

6. Przejdź do nowo utworzonego katalogu gcc-8.1.0:

$ cd gcc-8.1.0

7. Pobierz pliki wymagane do zainstalowania GCC:

$ ./contrib/download_prerequisites

--cięcie--

gmp-6.1.0.tar.bz2: OK

mpfr-3.1.4.tar.bz2: OK

mpc-1.0.3.tar.gz: OK

isl-0.18.tar.bz2: OK

All prerequisites downloaded successfully.

8. Skonfiguruj GCC z uwzględnieniem następujących poleceń:

$ mkdir objdir

$ cd objdir

$ ../configure --disable-multilib

checking build system type... x86_64-pc-linux-gnu

checking host system type... x86_64-pc-linux-gnu

--cięcie--

configure: creating ./config.status

config.status: creating Makefile

Odpowiednie instrukcje są dostępne pod adresem https://gcc.gnu.org/install/configure.html.

9. Zbuduj pliki binarne kompilatora GCC (być może zrób to w nocy, ponieważ ta czynność może potrwać nawet kilka godzin).

$ make

Dokładne instrukcje są dostępne pod adresem https://gcc.gnu.org/install/build.html.

10. Sprawdź, czy pliki binarne kompilatora GCC zostały prawidłowo utworzone:

$ make -k check

Dokładne instrukcje są dostępne pod adresem https://gcc.gnu.org/install/.

11. Zainstaluj kompilator GCC:

$ make install

To polecenie umieszcza pliki binarne w domyślnym katalogu programów wykonywalnych systemu operacyjnego, którym zwykle jest /usr/local/bin. Pełne instrukcje są dostępne na stronie https://gcc.gnu.org/install/.

12. Wykonując poniższe polecenie, sprawdź, czy kompilator GCC został poprawnie zainstalowany:

$ x86_64-pc-linux-gnu-gcc-8.1.0 -version

Jeśli pojawi się błąd informujący, że polecenie nie zostało znalezione, oznacza to, że instalacja się nie powiodła. Zapoznaj się z listą mailingową gcc-help dostępną na stronie https://gcc.gnu.org/ml/gcc-help/.

Uwaga Być może będziesz chciał zdefiniować alias skomplikowanej nazwy x86_64-pc-linux-gnu-gcc-8.1.0 i skrócić ją do czegoś takiego jak g++8. W tym celu możesz użyć następującej instrukcji:

$ sudo ln -s /usr/local/bin/x86_64-pc-linux-gnu-gcc-8.1.0 /usr/local/bin/g++8

13. Przejdź do katalogu, w którym zapisałeś plik main.cpp, a następnie skompiluj program z użyciem kompilatora GCC:

$ x86_64-pc-linux-gnu-gcc-8.1.0 main.cpp -o hello

14. Użycie flagi -o jest opcjonalne; informuje ona kompilator, jak powinien się nazywać program wynikowy. Ponieważ podałeś opcję hello, powinieneś móc uruchomić swój program, wpisując w oknie terminala ciąg znaków ./hello. Jeśli pojawią się błędy kompilatora, upewnij się, że poprawnie wprowadziłeś tekst programu (informacja o błędzie kompilatora powinna Ci pomóc w ustaleniu przyczyny wystąpienia problemu).

Edytory tekstu

Jeśli wolałbyś nie używać żadnego z wyżej wymienionych środowisk, możesz wprowadzać kod C++ za pomocą prostego edytora tekstu, takiego jak Notatnik (Windows), TextEdit (Mac) lub Vim (Linux). Istnieje jednak wiele doskonałych edytorów, które zostały zaprojektowane specjalnie do programowania w języku C++. Wybierz ten z nich, który zapewni Ci najwyższą wydajność.

Jeżeli korzystasz z systemu Windows lub macOS, masz już do dyspozycji wysokiej jakości i w pełni funkcjonalne środowisko programistyczne, a mianowicie Visual Studio lub Xcode. W systemie Linux dostępne są programy, takie jak Qt Creator (https://www.qt.io/ide/), Eclipse CDT (https://eclipse.org/cdt/) oraz CLion JetBrains (https://www.jetbrains.com/clion/). Jeśli jesteś użytkownikiem edytorów Vim lub Emacs, znajdziesz dla nich wiele wtyczek związanych z C++.

Uwaga Jeżeli wieloplatformowość podczas programowania w języku C++ jest dla Ciebie ważna, polecam dokładnie przyjrzeć się narzędziu CLion Jetbrains. Mimo że w przeciwieństwie do wielu jego konkurentów jest to produkt płatny, w momencie oddawania oryginalnej wersji tej książki do druku pojawiły się obniżone ceny i bezpłatne licencje dla studentów oraz opiekunów projektów o otwartym oprogramowaniu.

Rozpoczęcie samodzielnej pracy z językiem C++

W tym podrozdziale zdobędziesz wystarczającą ilość wiedzy, aby zrozumieć przykłady kodu z kolejnych rozdziałów. Oczywiście pojawią się pytania dotyczące szczegółów, jednak wkrótce uzyskasz na nie odpowiedzi. Przez ten czas postaraj się nie panikować!

System typów języka C++

C++ jest językiem obiektowym. Obiekty są abstrakcjami posiadającymi stan i określony sposób działania. Weź pod uwagę obiekt pochodzący z prawdziwego świata, taki jak włącznik światła. Możesz opisać jego stan jako sytuację, w jakiej znajduje się przełącznik. Czy jest włączony, czy wyłączony? Jakie maksymalne napięcie może obsłużyć? W jakim pomieszczeniu został zainstalowany? Możesz także opisać sposób działania przełącznika. Czy przełącza się z jednego stanu (włączony) do innego (wyłączony)? A może jest to ściemniacz, który może zawierać wiele pośrednich stanów między włączeniem a wyłączeniem?

Połączenie sposobu działania ze stanami opisującymi obiekt jest nazywane jego typem. C++ jest językiem o silnej kontroli typów, co oznacza, że każdy obiekt ma predefiniowany typ danych.

C++ ma wbudowany typ liczb całkowitych zwany int. Obiekt int może przechowywać liczby całkowite (jego stan) i obsługuje wiele operacji matematycznych (jego sposób działania).

Aby wykonywać sensowne działania z użyciem typu int, tworzysz obiekty int i nadajesz im nazwy. Nazwane obiekty to zmienne.

Deklarowanie zmiennych

Deklarujesz zmienne, podając ich typ, następnie nazwę, a na koniec znak średnika. Na poniższym przykładzie zadeklarowano zmienną o nazwie the_answer i typie int:

int the_answer ;

Po typie int pojawia się nazwa zmiennej, czyli the_answer .

Inicjalizowanie stanu zmiennej

Gdy deklarujesz zmienne, inicjalizujesz je. Inicjalizacja obiektu ustala jego stan początkowy, na przykład poprzez określenie jego wartości. W szczegóły dotyczące inicjalizacji zagłębimy się w rozdziale 2. Aby ustawić wartość początkową zmiennej, możesz na razie używać znaku równości (=) umieszczonego po jej deklaracji. Na przykład w jednym wierszu mógłbyś zadeklarować zmienną the_answer i przypisać jej jakąś wartość:

int the_answer = 42;

Po uruchomieniu powyższego wiersza kodu będziesz miał zmienną o nazwie the_answer, typie int i wartości 42. Do zmiennych możesz przypisywać wyniki wyrażeń matematycznych, na przykład:

int lucky_number = the_answer / 6;

W tym wierszu następuje wyznaczenie wyrażenia the_answer / 6 i przypisanie wyniku do zmiennej lucky_number. Typ int obsługuje wiele innych operacji, takich jak dodawanie +, odejmowanie -, mnożenie * i modulo %.

Uwaga Jeśli nie wiesz, co to jest operacja modulo, lub zastanawiasz się, co się stanie, gdy po podzieleniu przez siebie dwóch liczb całkowitych pozostanie reszta, zadajesz bardzo dobre pytania. Odpowiedzi na nie zostaną szczegółowo przedstawione w rozdziale 7.

Instrukcje warunkowe

Instrukcje warunkowe umożliwiają podejmowanie decyzji w programach. Decyzje te opierają się na wyrażeniach logicznych, które zwracają wartość prawdy (true) lub fałszu (false). Na przykład w celu utworzenia wyrażeń logicznych można użyć operatorów porównania, takich jak "większy niż" lub "różny od".

Niektóre podstawowe operatory porównania, które działają z typami int, zostały zaprezentowane w programie na listingu 1.2.

int main() {

  int x = 0;

  42 == x; // Równość

  42 != x; // Nierówność

  100 > x; // Większy niż

  123 >= x; // Większy lub równy

  -10 < x; // Mniejszy niż

  -99 <= x; // Mniejszy lub równy

}

Listing 1.2. Program wykorzystujący operatory porównania

Ten program nie generuje danych wyjściowych (aby to sprawdzić, skompiluj treść listingu 1.2, a następnie uruchom program). W takim przypadku jego kompilacja pozwala sprawdzić, czy został napisany prawidłowy program w języku C++. Aby wygenerować ciekawsze programy, powinieneś używać instrukcji warunkowej, na przykład if.

Instrukcja if zawiera wyrażenie logiczne i jedną lub więcej zagnieżdżonych instrukcji. W zależności od tego, czy wyrażenie logiczne zwraca prawdę lub fałsz, program decyduje, która instrukcja zagnieżdżona powinna zostać wykonana. Istnieje kilka form instrukcji if, ale podstawowe jej użycie jest następujące:

if ( wyrażenie-logiczne) instrukcja

Jeśli wyrażenie-logiczne jest prawdziwe, wykonywana jest instrukcja zagnieżdżona ; w przeciwnym razie nic się nie dzieje.

Czasami chciałbyś, aby zamiast pojedynczej instrukcji została wykonana cała ich grupa. Taka grupa nazywana jest instrukcją złożoną (lub blokową). Aby zadeklarować instrukcję złożoną, po prostu umieść grupę instrukcji w nawiasach klamrowych { }. W instrukcji if możesz w następujący sposób użyć instrukcji złożonej:

if ( wyrażenie-logiczne) {

  instrukcja1;

instrukcja2;

--cięcie--

}

Jeśli wyrażenie-logiczne będzie prawdziwe, zostaną wykonane wszystkie poszczególne instrukcje zawarte w instrukcji złożonej ; w przeciwnym razie nie nie zostanie wykonana żadna z nich.

Możesz rozbudować instrukcję if, używając składników else if i else. Te opcjonalne dodatki pozwalają opisywać bardziej skomplikowane wersje rozgałęziania, jak pokazano na listingu 1.3.

if (wyrażenie-logiczne-1) instrukcja-1

else if (wyrażenie-logiczne-2) instrukcja-2

else instrukcja-3

Listing 1.3. Instrukcja if z gałęziami else ifelse

Najpierw obliczane jest wyrażenie-logiczne-1 . Jeśli zwróci ono wartość prawdy, wykonana zostanie instrukcja-1, a instrukcja if zakończy działanie. Jeśli wyrażenie-logiczne-1 zwróci fałsz, obliczone zostanie wyrażenie-logiczne-2 . Jeżeli zwróci ono prawdę, zostanie wykonana instrukcja-2. W przeciwnym razie zostanie uruchomiona instrukcja-3 . Zauważ, że instrukcje instrukcja-1, instrukcja-2 i instrukcja-3 wzajemnie się wykluczają i wspólnie zapewniają obsługę wszystkich możliwych wyników instrukcji if. Wykonana zatem zostanie tylko jedna z nich.

Możesz dołączyć dowolną liczbę klauzul else if lub całkowicie je pominąć. Podobnie jak w przypadku początkowej instrukcji if, wyrażenia logiczne dla każdej klauzuli else if są obliczane po kolei. Gdy jedno z tych wyrażeń logicznych zwróci wartość true, proces wyznaczania wartości zostanie zatrzymany i wykonana zostanie odpowiednia instrukcja. Jeśli żadna z instrukcji else if nie zwróci wartości true, zawsze zostanie wykonana instrukcja-3 (podobnie jak w przypadku klauzul else if, również użycie else jest opcjonalne).

Przeanalizuj listing 1.4, w którym wykorzystano instrukcję if, do określenia, jaka instrukcja print powinna zostać wykonana.

#include <cstdio>

int main() {

  int x = 0;

  if (x > 0) printf("Positive.");

  else if (x < 0) printf("Negative.");

  else printf("Zero.");

}

Zero.

Listing 1.4. Program zawierający instrukcję warunkową

Skompiluj program i uruchom go. W wyniku powinieneś otrzymać słowo Zero w konsoli. A teraz zmień wartość zmiennej x . Co zostało wyświetlone?

Uwaga Zauważ, że na listingu 1.4 w funkcji main pominięto instrukcję return. Ponieważ main jest funkcją specjalną, użycie instrukcji return jest w niej opcjonalne.

Funkcje

Funkcje są blokami kodu, które akceptują dowolną liczbę obiektów wejściowych zwanych parametrami lub argumentami i mogą zwracać obiekty wyjściowe do obiektów wywołujących.

Funkcje możesz deklarować zgodnie z ogólną składnią przedstawioną na listingu 1.5.

typ-zwracany nazwa-funkcji (typ-par1 par_name1 , typ-par2 par_name2 ) {

--cięcie--

  return wartość-zwracana;

}

Listing 1.5. Ogólna składnia funkcji C++

Pierwszym elementem deklaracji funkcji jest typ zmiennej zwracanej , na przykład int. Gdy funkcja zwraca wartość , typ wartości wartość-zwracana musi być zgodny z typem typ-zwracany.

Po podaniu typu zwracanego deklarowana jest nazwa funkcji . Wewnątrz nawiasów okrągłych znajdujących się po nazwie funkcji można zawrzeć dowolną liczbę wymaganych parametrów wejściowych oddzielonych przecinkami. Każdy parametr ma swój typ i nazwę.

Na listingu 1.5 zaprezentowano funkcję z dwoma parametrami. Pierwszy parametr ma typ typ-par1 i nazwę par_name1, a drugi parametr ma typ typ-par2 i nazwę par_name2. Parametry reprezentują obiekty przekazane do funkcji.

Nawiasy klamrowe następujących po liście parametrów zawierają treść funkcji. Jest to instrukcja złożona, która realizuje logikę funkcji. W ramach tej logiki funkcja może zdecydować o zwróceniu wartości do obiektu, który ją wywołuje. Funkcje zwracające wartości będą miały jedną lub więcej instrukcji return. Gdy funkcja zwróci wartość, przestanie działać, a wykonywanie programu wróci do miejsca, z którego została wywołana. Przyjrzyjmy się przykładowi.

Przykład: funkcja schodkowa

W tym podpunkcie zostało zaprezentowane dla celów demonstracyjnych, w jaki sposób można stworzyć funkcję matematyczną o nazwie step_function, która zwraca -1 dla wszystkich argumentów ujemnych, 0 dla argumentów o wartości zerowej i 1 dla wszystkich argumentów dodatnich. Treść takiej funkcji pokazano na listingu 1.6.

int step_function(int x) {

  int result = 0;

  if (x < 0) {

   result = -1;

  } else if (x > 0) {

   result = 1;

  }

  return result;

}

Listing 1.6. Funkcja schodkowa, która zwraca -1 dla wartości ujemnych, 0 dla zera i 1 dla wartości dodatnich

Funkcja step_function przyjmuje pojedynczy argument x . Zmienna result jest deklarowana i inicjalizowana wartością 0 . Następnie instrukcja if przypisuje zmiennej result wartość -1 , gdy argument x jest mniejszy niż 0. Jeśli x jest większy niż 0, instrukcja if ustawia zmienną result na wartość 1 . Ostatecznie zmienna result jest zwracana do obiektu wywołującego .

Wywoływanie funkcji

Aby wywołać (lub uruchomić) funkcję, należy użyć jej nazwy, nawiasów i listy wymaganych parametrów oddzielonych przecinkami. Kompilator czyta pliki od góry do dołu, dlatego deklaracja funkcji musi się pojawić przed jej pierwszym użyciem.

Przeanalizuj program z listingu 1.7, w którym wykorzystano funkcję step_function.

Na listingu 1.7 trzykrotnie wywołano funkcję step_function z różnymi argumentami i przypisano otrzymane wyniki do zmiennych value1, value2 i value3.

Czy nie byłoby miło, gdybyś mógł wyświetlić te wartości? Na szczęście możesz w prosty sposób użyć funkcji printf, aby wygenerować dane wyjściowe na podstawie różnych zmiennych. Sztuką jest odpowiednie użycie jej specyfikatorów formatowania.

int step_function(int x) {

  --cięcie--

}

int main() {

  int value1 = step_function(100); // value1 wynosi 1

  int value2 = step_function(0); // value2 wynosi 0

  int value3 = step_function(-10); // value3 wynosi -1

}

Listing 1.7. Program korzystający z funkcji step_function (ten program nie generuje żadnych danych wyjściowych)

Specyfikatory formatowania w funkcji printf

Oprócz wyświetlania stałych łańcuchów (takich jak Hello, world! z listingu 1.1) funkcja printf może połączyć wiele wartości ze sobą w celu uzyskania estetycznie sformatowanego ciągu znaków. Należy ona do specjalnego rodzaju funkcji, które mogą przyjmować jeden lub więcej argumentów.

Pierwszym argumentem funkcji printf jest zawsze łańcuch formatujący. Udostępnia on szablon dla treści, która powinna zostać wyświetlona, i zawiera dowolną liczbę specjalnych specyfikatorów formatu. Określają one, w jaki sposób należy interpretować i formatować argumenty występujące po łańcuchu formatującym. Wszystkie specyfikatory formatu zaczynają się od znaku %.

Przykładowo specyfikatorem formatu dla typu int jest %d. Ilekroć funkcja printf napotka ciąg %d w łańcuchu formatującym, będzie wiedzieć, że po specyfikacji formatu należy oczekiwać argumentu typu int. Następnie funkcja zastąpi specyfikator formatu rzeczywistą wartością argumentu.

Uwaga Funkcja printf jest pochodną funkcji writef zawartej w BCPL, nieistniejącym już języku programowania zaprojektowanym przez Martina Richardsa w 1967 roku. Użycie specyfikatorów %H, %I%O w funkcji writef pozwoliło na uzyskiwanie wyników w postaci szesnastkowej, dziesiętnej i ósemkowej dzięki funkcjom WRITEHEX, WRITEDWRITEOCT. Nie jest jednak jasne, skąd pochodzi specyfikator %d (być może z litery D użytej w funkcji WRITED?), ale nic na to nie można poradzić.

Przyjrzyj się następującemu wywołaniu funkcji printf, które wyświetla łańcuch Ten 10, Twenty 20, Thirty 30:

printf("Ten %d , Twenty %d , Thirty %d ", 10 , 20 , 30 );

Pierwszy argument "Ten %d, Twenty %d, Thirty %d" jest łańcuchem formatującym. Zauważ, że istnieją trzy specyfikatory formatu %d . Po łańcuchu formatującym istnieją również trzy inne argumenty . Gdy funkcja printf generuje dane wyjściowe, zastępuje argument argumentem , argument argumentem , a argument argumentem .

BIBLIOTEKA IOSTREAM, FUNKCJA PRINTF, A SPOSÓB NAUCZANIA O OPERACJACH WYJŚCIA I WYJŚCIA

Wiele osób ma już wyrobione zdanie o tym, jaką standardową metodę generowania wyników wyjściowych należy prezentować początkującym programistom C++. Pierwszą z opcji jest funkcja printf, której rodowód sięga języka C. Drugą możliwością jest funkcja cout, która jest częścią biblioteki iostream należącej do biblioteki standardowej C++. W tej książce prezentujemy obie opcje: funkcję printf w części I i cout w części II. Poniżej wyjaśniamy, dlaczego wybrana została właśnie taka metoda nauczania.

Ta książka udostępnia krok po kroku wiedzę o języku C++. Każdy rozdział zawiera uporządkowane informacje, więc pozostaje Ci jedynie wiara w to, że kod działa prawidłowo. Mniej więcej wiesz, co jest wykonywane w każdym wierszu programu. Ponieważ funkcja printf jest dość prosta, poznasz ją dość dokładnie, zanim jeszcze przeczytasz rozdział 3.

W przeciwieństwie do niej zrozumienie działania strumienia wyjściowego cout wymaga wcześniejszego posiadania wiedzy o wielu koncepcjach związanych z językiem C++. Niestety, aż do końca części I nie będziesz mógł dobrze pojąć, jak on działa. Oto typowe zagadnienia do wyjaśnienia: co to jest bufor strumienia? Co to jest operator <<? Co to jest metoda? Jak działa flush()? Czy cout automatycznie zapisuje dane w destruktorze? Co to jest destruktor? Co to jest setf? Co to jest znacznik formatowania? A BitmaskType? A manipulator? Itd., itp.

Oczywiście funkcja printf ma pewne wady, więc gdy już poznasz strumień cout, powinieneś go zacząć używać zamiast niej. W funkcja printf mogą łatwo powstawać rozbieżności między specyfikatorami formatu a argumentami, co może powodować dziwne zachowanie, awarie programów, a nawet luki w zabezpieczeniach. Użycie strumienia cout oznacza, że nie potrzebujesz łańcuchów formatujących, a więc nie musisz pamiętać o specyfikatorach formatu. Nigdy nie pojawi się niezgodność między łańcuchami formatującymi a argumentami. Biblioteka iostream jest również rozszerzalna, co oznacza, że możesz z jej pomocą obsługiwać operacje wejścia i wyjścia dla własnych typów.

Co prawda ta książka w sposób bezpośredni prezentuje nowoczesny C++, jednak w przypadku powyższego zagadnienia rozmyślnie wprowadzono wyjątek, dzięki czemu wiedza jest przekazywana w sposób liniowy. Dodatkową korzyścią z takiego rozwiązania jest wcześniejsza możliwość zapoznania się ze specyfikatorami printf, które mogą się przydać w Twojej karierze programistycznej. Większość języków, na przykład C, Python, Java i Ruby, posiada obsługę specyfikatorów printf, a oprócz tego istnieją analogiczne rozwiązania w językach C#, JavaScript i innych.

Kolejne podejście do funkcji step_function

Spójrzmy na inny przykład wykorzystujący funkcję step_function. Listing 1.8 zawiera deklaracje zmiennych, wywołania funkcji i specyfikatory formatu printf.

#include <cstdio>

int step_function(int x) {

--cięcie--

}

int main() {

   int num1 = 42;

   int result1 = step_function(num1);

   int num2 = 0;

   int result2 = step_function(num2);

   int num3 = -32767;

   int result3 = step_function(num3);

   

   printf("Num1: %d, Step: %d\n", num1, result1);

   printf("Num2: %d, Step: %d\n", num2, result2);

   printf("Num3: %d, Step: %d\n", num3, result3);

   

   return 0;

}

Num1: 42, Step: 1

Num2: 0, Step: 0

Num3: -32767, Step: -1

Listing 1.8. Program, który wyświetla wyniki użycia funkcji step_function z kilkoma liczbami całkowitymi

Ponieważ program używa funkcji printf, dołączono nagłówek cstdio . Funkcja step_function została zdefiniowana , dzięki czemu później można jej użyć w programie. Funkcja main jest w nim zdefiniowanym punktem startowym.

Uwaga Niektóre listingi w tej książce będą wykorzystywać poprzednie kody. Aby uratować trochę drzew, używam notacji --cięcie--, która oznacza brak zmian w używanych fragmentach wcześniejszych kodów.

Wewnątrz funkcji main inicjalizujesz kilka zmiennych typu int, na przykład num1 . Następnie przekazujesz te zmienne do funkcji step_function i inicjalizujesz zmienne wynikowe, aby zapisać zwrócone wartości, na przykład result1 .

Wreszcie z użyciem funkcji printf wyświetlasz zwrócone wartości. Każde wywołanie zaczyna się od łańcucha formatującego, na przykład "Num1: %d, Step: %d\n" . W każdym łańcuchu formatującym zostały umieszczone dwa specyfikatory formatu %d. Zgodnie z wymaganiami funkcji printf po łańcuchu formatującym pojawiają się dwa parametry: num1 i result1, które odpowiadają specyfikatorom formatu.

Komentarze

Komentarze to adnotacje występujące w postaci czytelnej dla człowieka. Można umieścić je w kodzie źródłowym. Możesz dodawać komentarze z użyciem znaków // lub /**/. Znaki // informują kompilator, aby zignorował wszystko, co będzie się znajdować na prawo od nich, aż do znaku nowego wiersza, co oznacza, że możesz umieszczać komentarze za kodem, a także w przeznaczonych dla nich wierszach:

// Ten komentarz występuje w przeznaczonym dla tego celu wierszu

int the_answer = 42; // Ten komentarz występuje przy kodzie

Możesz też użyć notacji /**/, aby w kodzie umieścić bardziej rozbudowane komentarze zajmujące wiele wierszy:

/*

* To jest komentarz,

* który się zawiera w wielu wierszach.

* Nie zapomnij o znaku zamykającym.

*/

Komentarz zaczyna się od znaków /* i kończy znakami */ (znaki gwiazdek występujące w wierszach między początkowym i końcowym ukośnikiem są opcjonalne, ale często używane).

Na temat tego, kiedy należy używać komentarzy, toczy się odwieczna debata. Niektórzy znani programiści sugerują, że kod powinien być tak ekspresywny i zrozumiały, aby komentarze były w dużej mierze niepotrzebne. Twierdzą oni, że opisowe nazwy zmiennych, krótkie funkcje i dobre testy to zwykle cała potrzebna dokumentacja. Z drugiej strony inni programiści lubią umieszczać komentarze w wielu miejscach programu.

Możesz przyjąć całkowicie własne zasady komentowania. Kompilator i tak całkowicie zignoruje wszystko, co zrobisz, ponieważ nigdy nie interpretuje komentarzy.

Debugowanie

Jedną z najważniejszych umiejętności inżyniera oprogramowania jest wydajne i skuteczne debugowanie. Większość środowisk programistycznych ma własne narzędzia do debugowania. W systemach Windows, macOS i Linux narzędzia takie są rzeczywiście wysokiej jakości. Poznanie sposobu ich obsługi to inwestycja, która bardzo szybko się zwróci. W tym podrozdziale zostanie zaprezentowane użycie debuggera w celu wykonania programu z listingu 1.8. Możesz wybrać takie środowisko, które będzie dla Ciebie najbardziej odpowiednie.

Visual Studio

Visual Studio ma doskonały wbudowany debugger. Sugeruję, aby debugować programy w konfiguracji Debug (program gotowy do debugowania). Dzięki temu używany przez Ciebie zestaw narzędzi ulepszy jakość procesu debugowania. Jedynym powodem do wybrania debugowania w trybie Release (program gotowy do wydania) jest diagnozowanie niektórych rzadko pojawiających się problemów, które w nim występują, ale nie istnieją w trybie Debug.

1. Otwórz plik main.cpp i znajdź pierwszy wiersz funkcji main.

2. Kliknij margines po lewej stronie numeru wiersza odpowiadający pierwszemu wierszowi funkcji main, aby wstawić punkt przerwania. W miejscu kliknięcia pojawi się czerwone kółko, jak pokazano na rysunku 1.4.

Rysunek 1.4. Wstawianie punktu przerwania

3. Wybierz opcję Debug/Start Debugging (Debugowanie/Rozpoczęcie debugowania). Program zatrzyma się w wierszu, w którym wstawiłeś punkt przerwania. Debugger wstrzyma wykonywanie programu, a następnie pojawi się żółta strzałka wskazująca następną instrukcję do uruchomienia, jak pokazano na rysunku 1.5.

Rysunek 1.5. Debugger zatrzymuje wykonywanie w punkcie przerwania

4. Wybierz opcję Debug/Step Over (Debugowanie/Krok po kroku). Operacja ta wykonuje całą instrukcję bez "wchodzenia" do wnętrza funkcji. Jej domyślnym skrótem klawiaturowym jest F10.

5. Ponieważ w następnym wierszu znajduje się funkcja step_function, wybierz opcję Debug/Step Into (Debugowanie/Wejście do wnętrza funkcji), aby wywołać funkcję step_function i zatrzymać program w jej pierwszym wierszu. Następnie możesz kontynuować debugowanie tej funkcji, korzystając z opcji Step Over lub Step Into. Domyślnym skrótem klawiaturowym dla opcji Step Into jest F11.

6. Aby wrócić do funkcji main, wybierz opcję Debug/Step Out (Debugowanie/Wyjście z wnętrza funkcji). Jej domyślnym skrót klawiaturowym są klawisze Shift+F11.

7. Przejdź do okna Autos (Zmienne wyświetlane automatycznie), wybierając opcję Debug/Windows/Auto (Debugowanie/Okna/Zmienne wyświetlane automatycznie). Możesz w nim zobaczyć bieżącą wartość niektórych ważnych zmiennych, jak pokazano na rysunku 1.6.

Rysunek 1.6. W oknie Autos są wyświetlane wartości zmiennych dla bieżącego punktu przerwania

Możesz zauważyć, że zmienna num1 została ustawiona na wartość 42, a result1 na wartość 1. Ale dlaczego zmienna num2 ma taką dziwaczną wartość? Ponieważ jej zainicjalizowanie wartością 0 jeszcze się nie wydarzyło - nastąpi to w kolejnej instrukcji.

Uwaga Dzięki debuggerowi poznałeś właśnie bardzo ważny, niskopoziomowy szczegół programowania w C++: przydzielenie pamięci dla obiektu i zainicjalizowanie obiektu wartością to dwa odrębne zagadnienia. Więcej informacji o alokacji pamięci i inicjalizacji obiektów możesz znaleźć w rozdziale 4.

Debugger programu Visual Studio ma jeszcze wiele innych funkcjonalności. Aby uzyskać więcej informacji na ten temat, użyj łącza prowadzącego do dokumentacji programu Visual Studio dostępnego pod adresem https://ccc.codes/.

Xcode

Środowisko Xcode ma również doskonały wbudowany debugger, który jest całkowicie z nim zintegrowany.

1. Otwórz plik main.cpp i przejdź do pierwszego wiersza funkcji main.

2. Kliknij pierwszy wiersz funkcji main, a następnie wybierz opcję Debug/Breakpoints/Add Breakpoint at Current Line (Debugowanie/Punkty przerwania/Dodaj punkt przerwania w bieżącym wierszu). Pojawi się punkt przerwania, jak pokazano na rysunku 1.7.

Rysunek 1.7. Wstawianie punktu przerwania

3. Wybierz opcję Run (Uruchom). Program uruchomi się do wiersza z wstawionym punktem przerwania. Debugger zatrzyma wykonywanie programu i pojawi się zielona strzałka wskazująca następną instrukcję do uruchomienia, jak pokazano na rysunku 1.8.

Rysunek 1.8. Debugger zatrzymuje wykonywanie programu w punkcie przerwania

4. Wybierz opcję Debug/Step Over (Debuguj/Krok po kroku), aby wykonać instrukcję bez "wchodzenia" w jakiekolwiek wywołanie funkcji. Domyślnym skrótem klawiaturowym dla tej opcji jest F6.

5. Ponieważ w następnym wierszu znajduje się funkcja step_function, wybierz opcję Debug/Step Into (Debugowanie/Wejście do wnętrza funkcji), aby wywołać funkcję step_function i zatrzymać program w jej pierwszym wierszu. Następnie możesz kontynuować debugowanie tej funkcji, korzystając z opcji Step Into lub Step Over. Domyślnym skrótem klawiaturowym dla opcji Step Into jest F7.

6. Aby wrócić do funkcji main, wybierz opcję Debug/Step Out (Debugowanie/Wyjście z wnętrza funkcji). Jej domyślnym skrót klawiaturowym jest klawisz F8.

7. Przejdź do okna Autos (Zmienne wyświetlane automatycznie) znajdującego się w dolnej części edytora zawierającego kod. Możesz w nim zobaczyć bieżącą wartość niektórych ważnych zmiennych, jak pokazano na rysunku 1.9.

Rysunek 1.9. W oknie Autos są wyświetlane wartości zmiennych dla bieżącego punktu przerwania

Możesz zauważyć, że zmienna num1 została ustawiona na wartość 42, a result1 na wartość 1. Ale dlaczego zmienna num2 ma taką dziwaczną wartość? Ponieważ jej zainicjalizowanie wartością 0 jeszcze się nie wydarzyło - nastąpi to w kolejnej instrukcji.

Debugger środowiska Xcode ma jeszcze wiele innych funkcjonalności. Aby uzyskać więcej informacji, użyj łącza prowadzącego do dokumentacji programu Xcode dostępnego pod adresem https://ccc.codes/.

Debugowanie w kompilatorach GCC i Clang za pomocą debuggerów GDB i LLDB

Program GNU Project Debugger (GDB) to potężny debugger (https://www.gnu.org/software/gdb/). Możesz go używać za pomocą wiersza poleceń. Aby włączyć obsługę debugowania podczas kompilacji z użyciem kompilatorów g++ lub clang++, musisz dodać opcję -g.

Twój menedżer pakietów najprawdopodobniej będzie zawierał program GDB. Aby zainstalować GDB za pomocą przykładowego narzędzia Advanced Package Tool (APT), wprowadź następujące polecenie:

$ sudo apt install gdb

Kompilator Clang ma również doskonały debugger o nazwie Low Level Debugger (LLDB), który można pobrać ze strony https://lldb.llvm.org/. Ponieważ obsługuje on polecenia GDB zaprezentowane w tym punkcie, ze względu na zwięzłość nie będę go dokładniej analizować. Za pomocą LLDB można debugować programy skompilowane z obsługą debugowania GCC, a także odwrotnie - za pomocą GDB można debugować programy skompilowane z obsługą debugowania Clang.

Uwaga Środowisko Xcode używa w tle debuggera LLDB.

Aby uruchomić program z listingu 1.8 w trybie debugowania, wykonaj następujące działania:

1. Za pomocą wiersza poleceń przejdź do folderu, w którym zapisałeś pliki nagłówkowe i źródłowe.

2. Skompiluj swój program z obsługą debugowania:

$ g++-8 main.cpp -o stepfun -g

3. Za pomocą aplikacji gdb rozpocznij debugowanie swojego programu. W konsoli powinna się uruchomić następująca interaktywna sesja:

$ gdb stepfun

GNU gdb (Ubuntu 7.7.1-0ubuntu5~14.04.2) 7.7.1

Copyright (C) 2014 Free Software Foundation, Inc.

License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.

html>

This is free software: you are free to change and redistribute it.

There is NO WARRANTY, to the extent permitted by law. Type "show copying"

and "show warranty" for details.

This GDB was configured as "x86_64-linux-gnu".

Type "show configuration" for configuration details.

For bug reporting instructions, please see:

<http://www.gnu.org/software/gdb/bugs/>.

Find the GDB manual and other documentation resources online at:

<http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".

Type "apropos word" to search for commands related to "word"...

Reading symbols from stepfun...done.

(gdb)

4. Aby wstawić punkt przerwania, użyj polecenia break, które wykorzystuje argument odpowiadający nazwie pliku źródłowego. Jest on oddzielony dwukropkiem (:) od numeru wiersza, w którym chcesz przerwać działanie programu. Załóżmy przykładowo, że chcesz wstawić punkt przerwania w pierwszym wierszu pliku main.cpp. Na listingu 1.8 jest to wiersz 5 (w zależności od tego, jak wprowadziłeś kod źródłowy, może być konieczne zmodyfikowanie tej wartości). Za pomocą następującej instrukcji wiersza poleceń (sesja gdb) możesz utworzyć punkt przerwania:

(gdb) break main.cpp:5

5. Możesz także poinformować gdb, aby zatrzymał wykonywanie programu w określonej funkcji:

(gdb) break main

6. Bez względu na to, co wybierzesz, możesz teraz uruchomić swój program:

(gdb) run

Starting program: /home/josh/stepfun

Breakpoint 1, main () at main.cpp:5

5 int num1 = 42;

(gdb)

7. Użyj polecenia step, aby wykonywać krok po kroku każdy wiersz programu, z możliwością wejścia do wnętrza funkcji:

(gdb) step

6 int result1 = step_function(num1);

8. Aby kontynuować wykonywanie krok po kroku, naciśnij klawisz Enter w celu powtórzenia ostatniego polecenia:

(gdb)

step_function (x=42) at step_function.cpp:4

9. Aby wyjść z wnętrza funkcji, użyj polecenia finish:

(gdb) finish

Run till exit from #0 step_function (x=42) at step_function.cpp:7

0x0000000000400546 in main () at main.cpp:6

6 int result1 = step_function(num1);

Value returned is $1 = 1

10. Aby wykonać instrukcję bez wchodzenia do wnętrza funkcji, użyj polecenia next:

(gdb) next

8 int num2 = 0;

11. Aby sprawdzić bieżącą wartość zmiennych, użyj polecenia info locals:

(gdb) info locals

num2 = -648029488

result2 = 32767

num1 = 42

result1 = 1

num3 = 0

result3 = 0

Zwróć uwagę na to, że wszelkie zmienne, które nie zostały jeszcze zainicjalizowane, nie będą zawierały sensownych wartości.

12. Aby kontynuować wykonywanie programu do następnego punktu przerwania (lub do jego zakończenia), użyj polecenia continue:

(gdb) continue

Continuing.

Num1: 42, Step: 1

Num2: 0, Step: 0

Num3: -32768, Step: -1

[Inferior 1 (process 1322) exited normally]

13. Użyj polecenia quit, aby w dowolnym momencie wyjść z sesji gdb.

Debugger GDB ma jeszcze wiele innych funkcjonalności. Aby uzyskać więcej informacji, zapoznaj się z dokumentacją dostępną pod adresem https://sourceware.org/gdb/current/onlinedocs/gdb/.

Podsumowanie

W tym rozdziale zapoznałeś się z działającym środowiskiem programistycznym C++ i skompilowałeś swój pierwszy program w tym języku. Dowiedziałeś się o poszczególnych składnikach zestawu narzędzi kompilatora i rolach, jakie odgrywają w procesie kompilacji. Następnie zapoznałeś się z kilkoma podstawowymi zagadnieniami C++, takimi jak typy, deklarowanie zmiennych, instrukcje, warunki, funkcje, w tym funkcja printf. Rozdział ten zawiera również samouczek dotyczący konfigurowania debuggera i wykonywania procesu debugowania.

Uwaga Jeśli masz problemy z konfiguracją środowiska, przeszukaj internet w celu uzyskania dokładniejszych informacji o komunikatach błędów. Jeśli to się nie uda, opublikuj swoje pytanie na stronie Stack Overflow (https://stackoverflow.com/), subreddicie C++ (https://www.reddit.com/r/cpp_questions/) lub na kanale C++ strony Slack (https://cpplang.now.sh/).

Ćwiczenia

Za pomocą poniższych ćwiczeń będziesz mógł sprawdzić wiedzę zdobytą w tym rozdziale (kody programów z książki są dostępne na stronie https://ccc.codes).

1.1. Utwórz funkcję o nazwie absolute_value, która zwraca wartość bezwzględną pojedynczego argumentu. Definicja wartości bezwzględnej liczby całkowitej x jest następująca: jest to wartość x, jeśli liczba x jest większa lub równa 0; w przeciwnym razie jest to wartość x pomnożona przez -1. Jako szablonu możesz użyć programu z listingu 1.9:

#include <cstdio>

int absolute_value(int x) {

  // Tu umieścisz swój kod

}

int main() {

  int my_num = -10;

  printf("The absolute value of %d is %d.\n", my_num,

    absolute_value(my_num));

}

Listing 1.9. Szablon dla programu wykorzystującego funkcję absolute_value

1.2. Spróbuj uruchomić program z różnymi wartościami. Czy pojawiają się wyniki, jakich się spodziewałeś?

1.3. Uruchom program z użyciem debugera, a następnie wykonuj instrukcje krok po kroku.

1.4. Napisz kolejną funkcję o nazwie sum, która używa dwóch argumentów int i zwraca ich sumę. Jak mógłbyś zmodyfikować szablon z listingu 1.9, aby przetestować nową funkcję?

1.5. Język C++ ma aktywną społeczność internetową. W sieci możesz znaleźć doskonałe materiały związane z tym językiem. Przejrzyj podcast CppCast znajdujący się na stronie http://cppcast.com/. Znajdź kanały CppCon i C++Now dostępne na stronie YouTube. Dodaj adresy https://cppreference.com/http://www.cplusplus.com/ do swoich zakładek w przeglądarce.

1.6. Pobierz kopię standardu C++17 Międzynarodowej Organizacji Normalizacyjnej (ISO) ze strony https://isocpp.org/std/the-standard/. Niestety, oficjalny standard ISO jest chroniony prawem autorskim i musi zostać zakupiony. Na szczęście możesz bezpłatnie pobrać "wersję roboczą", która tylko kosmetycznie różni się od wersji oficjalnej.

Uwaga Ponieważ numery stron standardu ISO różnią się w zależności od wersji, w niniejszej książce będziemy się odnosić do określonych podrozdziałów, wykorzystując ten sam schemat nazewnictwa, który został wprowadzony w samym standardzie. W tym schemacie jest cytowany podrozdział zawierający jego nazwę w nawiasach kwadratowych. Za pomocą znaku kropki dołączane są punkty. Pryzkładowo aby zacytować punkt "Model obiektowy C++", który zawiera się w podrozdziale "Introduction", napisałbyś [intro.object].

Polecana lektura

- Andrew Hunt, David Thomas, Pragmatyczny programista. Od czeladnika do mistrza, Helion, 2011; - Norman Matloff, Peter Jay Salzman, The Art of Debugging with GDB, DDD, and Eclipse, No Starch Press, 2008; - Michael W. Lucas, PGP & GPG: Email for the Practical Paranoid, No Starch Press, 2006; - John Graham-Cumming, The GNU Make Book, No Starch Press, 2015.

Przypisy

1 Więcej na temat zasady darmowej abstrakcji można się dowiedzieć w artykule dostępnym pod adresem https://cpp-polska.pl/post/czy-c-jest-wolniejszy-od-cij-kilka-slow-o-zero-cost-abstraction [od tłumacza].

2 Nie ma w języku angielskim słowa "frabjous". Jest to przekręcenie (świadome) słowa "fabulous" - fantastyczny, radosny itp. Autor użył takiego zmodyfikowanego słowa, wzorując się zapewne na wierszu Jabberwocky Lewisa Carrolla, uważanym za jeden z najznakomitszych przykładów angielskiego wiersza absurdalnego, pochodzącym z jego książki Po drugiej stronie lustra i zawierającym mnóstwo neologizmów. Można by użyć w tym przypadku "normalnego" słowa "fantastycznej" czy też "radosnej". Sformułowanie "bardosny" zostało użyte w przekładzie wiersza przez Juliusza W. Gomulickiego: https://home.agh.edu.pl/~szymon/jabberwocky.shtml) [od tłumacza].

3 Bardzo dobry do tych celów będzie również Notepad++. Można go znaleźć pod adresem: https://notepad-plus-plus.org/ [od tłumacza].

4 Obecnie jest już dostępna wersja Visual Studio 2019 pod adresem https://visualstudio.microsoft.com/pl/vs/ [od tłumacza].

5 Wersje te uległy zmianie i w trakcie tłumaczenia książki były następujące: GCC 9.2 i Clang 9.0.0 [od tłumacza].