Wstęp
Nowoczesne funkcjonalności dodawane do Standardu C++ począwszy od wersji C++11 z 2011 roku są naprawdę niezwykłe i przynoszą znaczne zmiany. Rozważ następujące dodatki wprowadzone do 2017 roku, które mogą przysporzyć finansowym programistom C++ natychmiastowe korzyści, a w większości przypadków są bardzo łatwe do wcielenia do kodu:
semantyka przenoszenia, umożliwiająca przenoszenie posiadania obiektu bez narzutu na wydajność kopiowania (C++11),
wskaźniki inteligentne, które radykalnie zmniejszają problemy związane ze zwykłymi wskaźnikami (C++11 i C++14),
standardowe algorytmy równoległe, które wymagają jedynie dodatkowego parametru, a mogą przyspieszyć kod o rzędy wielkości (C++17),
generowanie liczb losowych o typowych rozkładach (C++11),
współbieżność oparta na zadaniach, która pozwala wykonywać zadania równolegle, a wymaga po prostu zastąpienia paru wierszy kodu (C++11).
Zaktualizowane wersje standardu C++ są wydawane co trzy lata od wersji C++11, co doprowadziło do kolejnego standardu C++20 i najnowszego C++23. W szczególności teraz dostępne są również te nowsze funkcjonalności:
nowa klasa daty, której stosowanie jest istotne przy transakcjach o stałym dochodzie (C++20),
koncepty, dzięki którym programy generyczne oparte na szablonach są łatwiejsze do debugowania i bardziej wyraziste (C++20),
adaptery zakresów, umożliwiające kompozycję algorytmów w nowoczesnej formie funkcyjnej, przy jednoczesnej eliminacji "pośrednika", czyli nieefektywnego kopiowania obiektów (C++20 i C++23),
moduły - żegnamy #include i witamy bezpieczniejszy kod (C++20).
W niniejszej książce uwzględniono wszystkie wymienione tu nowoczesne funkcjonalności, a także opisano popularne biblioteki open source języka C++, takie jak biblioteka Eigen (do algebry liniowej) oraz biblioteki Boost, z naciskiem na ich zastosowania finansowe.
Jednak w programach informatyki i inżynierii finansowej (przynajmniej w Stanach Zjednoczonych), jak również na popularnych internetowych kursach kwalifikacyjnych z finansów ilościowych, edukacja na temat języka C++ wydaje się w dużej mierze żyć przeszłością. Wiele szkoleń nadal wydaje się opierać na standardzie 2003, czyli C++03. Skarżyło się na to wielu studentów biorących udział w programie dla studentów na dorocznej konferencji C++ CppCon (https://oreil.ly/JDAFa), a także paru studentów studiów magisterskich z finansów obliczeniowych na Uniwersytecie Waszyngtońskim prowadzonych przeze mnie do 2022 roku, którzy wcześniej ukończyli studia licencjackie z C++. Niektórzy studenci mówili, że profesorowie odmawiali zaktualizowania sylabusów, a inni mówili, że profesorowie chcieli uczyć nowoczesnego C++, ale nie otrzymali pozwolenia z wydziału.
Dlatego starałem się napisać książkę z myślą o tej publiczności: osobach, które mają pewne wstępne doświadczenie z C++, ale chcą dowiedzieć się więcej o nowoczesnych metodach związanych z implementacją modeli finansowych. Książka nadaje się również dla osób z doświadczeniem w innych językach programowania, o ile będą korzystać również z podręcznika dla początkujących, takiego jak C++ Primer, wydanie 5 (https://oreil.ly/z12jO), którego autorami są Stanley B. Lippman i in. (Addison-Wesley, 2012), lub Teach Yourself C++ in One Hour a Day, wydanie 9 (https://oreil.ly/5siYU), którego autorem jest Siddhartha Rao (Sams, 2022). Zdecydowanie polecam książkę The C++ Standard Library: A Tutorial and Reference, wydanie 2 (https://oreil.ly/51GHg), której autorem jest Nicolai M. Josuttis (Addison-Wesley, 2012), jako vademecum dla początkujących i zaawansowanych Czytelników. Można zauważyć, że podane tu łącza prowadzą do elektronicznych wydań dostępnych na platformie O'Reilly Learning (https://learning.oreilly.com/home/). Inne źródła cytowane w niniejszej książce również będą podawane z łączami do tej platformy, o ile są tam dostępne.
Kolejnym doskonałym źródłem jest seria prezentacji Back to Basics z dorocznej konferencji CppCon na temat języka C++, dostępna bezpłatnie na kanale YouTube (https://www.youtube.com/user/CppCon) tej konferencji. Jeśli chcesz dowiedzieć się więcej na konkretny temat, istnieje szansa, że dostępna jest odpowiednia prezentacja Back to Basics.
W niniejszej książce założono znajomość poniższych tematów. Warto, aby osoby, dla których język C++ jest nowością lub które używały go dość dawno, dokładnie zapoznały się z poniższymi tematami lub trzymały pod ręką odpowiednie źródła, takie jak wymienione powyżej:
podstawowa obsługa wyjątków,
static_cast<.>,
podstawowe projektowanie klas w ogóle - a w szczególności funkcje składowe i dane składowe o dostępie public, protected oraz private,
statyczne dane składowe i funkcje składowe,
cout i dane wyjściowe konsoli,
pliki nagłówkowe i implementacyjne,
różnica między #include<blah> a #include "blah.h",
standardowy kontener vector,
debugowanie kodu w zintegrowanym środowisku programistycznym (IDE),
kompilowanie i linkowanie programu wykonywalnego.
Ponadto, ponieważ modelowanie finansowe obejmuje matematykę, w pewnych rozdziałach założono również podstawową znajomość następujących tematów:
teoria prawdopodobieństwa i statystyka,
podstawowe metody numeryczne do znajdowania pierwiastków,
algebra liniowa i rozkłady macierzy,
wycena opcji metodą Blacka-Scholesa i związany z nią proces stochastyczny ruchu Browna.
Poruszanie się po niniejszej książce
Niniejsza książka jest ułożona w następujący sposób:
Rozdział 1, "Przegląd C++", zawiera przegląd języka C++, który zaczyna się od krótkiej historii C++ w finansach, a także opis drogi do nowoczesnego C++, która zaczęła się od wersji C++11. Obejmuje również rozdzielenie rdzenia języka i biblioteki standardowej C++, a następnie kluczowe nowoczesne aktualizacje języka, które będą używane w pozostałej części książki, takie jak automatyczna dedukcja typu, zakresowe pętle for, wyliczenia z zasięgiem i wyrażenia lambda. Obejmuje również podstawowe funkcjonalności matematyczne w rdzeniu języka i bibliotece standardowej. Kończy się dyskusją na temat stylu i standardów kodowania, w tym stosowanych w niniejszej książce.
Rozdział 2, "Klasy definiowane przez użytkownika przy użyciu nowoczesnych funkcjonalności C++", i rozdział 3, "Dziedziczenie, polimorfizm i wskaźniki inteligentne", są skupione na zorientowanych obiektowo funkcjonalnościach C++ oraz nowoczesnych dodatkach, które zmieniły (i pomogły poprawić) metody już powszechnie stosowane w C++ do implementacji kompozycji i dziedziczenia. W rozdziale 2 zajmujemy się głównie kompozycją, szczególnie przechowywaniem prywatnej danej składowej, która sama jest obiektem, a także temu, jak semantyka przenoszenia wprowadzona w C++11 może zwiększyć wydajność dzięki unikaniu kopiowania obiektu przy konstruowaniu obiektu otaczającego. Opisano tu również nowy, wprowadzony w C++20 operator trójstronny (nazywany także operatorem statku kosmicznego), który znacznie upraszcza implementację operatorów równości i nierówności. Rozdział 3 zaczyna się od nowoczesnych funkcjonalności, które lepiej służą dziedziczeniu i dynamicznemu polimorfizmowi. Następnie wracamy do kompozycji, gdzie podrzędny obiekt składowy jest teraz zasobem wskaźnika, a także temu, jak wskaźniki inteligentne pomagają eliminować problemy związane ze zwykłymi wskaźnikami. Kompozycja i dziedziczenie są następnie wiązane ze sobą w nowoczesnym kontekście, gdzie zasób jest wskaźnikiem inteligentnym do obiektu polimorficznego. Finansowe przykłady w obu rozdziałach są związane z wyceną opcji.
Rozdział 4, "Biblioteka STL Część I: kontenery i iteratory", oraz rozdział 5, "Biblioteka STL Część II: algorytmy i zakresy", obejmują to, co tradycyjnie było nazywane biblioteką Standard Template Library (STL). Jest to zbiór standardowych kontenerów - w tym kontenera vector i innych - oraz iteratorów, które pozwalają na przechodzenie przez te kontenery, a także algorytmy, które wykonują operacje na elementach kontenera. Kontenery i iteratory STL zostały opisane w rozdziale 4, a algorytmy w rozdziale 5. Rozdział 5 zawiera także rozważania na temat rozszerzeń algorytmów w wersjach C++20 i C++23 w bardziej wyrazistej postaci zakresowej, a także dalszych związków zakresów z widokami zakresów i adapterami zakresów. Zobaczymy, że mogą być one używane w kontekście programowania funkcyjnego do komponowania sekwencji algorytmów, tak aby możliwe było uniknięcie narzutu na wydajność związanego z kopiowaniem danych i generowaniem tymczasowych obiektów kontenerów.
Rozdział 6, "Generowanie liczb losowych i współbieżność", rozpoczyna się od omówienia generowania liczb losowych o znanych rozkładach prawdopodobieństwa, kolejnej nowej funkcjonalności biblioteki standardowej wydanej w wersji C++11. Szczególnie zmienne losowe o rozkładzie standardowym normalnym są kluczowe dla modeli finansowych czasu ciągłego, wartości losowe o rozkładzie Poissona są często używane w modelowaniu danych tyknięć (najmniejszych możliwych zmian) do testowania strategii handlowych na żywo. Zaprezentowano dwa przykłady. Pierwszym jest algorytm shuffle połączony z silnikiem losowym C++11 w celu generowania scenariuszy Monte Carlo linii kapitałowych, aby mierzyć ryzyko skojarzone z nieprzewidywalnością maksymalnego obsunięcia kapitału w strategii handlowej. Drugim jest model Monte Carlo do obliczania wartości europejskich opcji barierowych, gdzie zmienne losowe o standardowym rozkładzie normalnym są używane w dyskretyzowanym procesie ruchu Browna. W dalszej części rozdziału przenosimy skupienie na współbieżność i równoległość, najpierw z przykładami równoległych algorytmów STL wprowadzonych w C++17. Przeważnie są to przeciążenia istniejących algorytmów, ale z dodatkowym parametrem zasad wykonania, co oznacza, że znaczny wzrost wydajności można osiągnąć przez proste dołączenie dodatkowego argumentu. Na koniec rozdziału wracamy do przykładu wyceny opcji metodą Monte Carlo, ale przy użyciu funkcji async(.) i klasy future z biblioteki standardowej, które pomagają w generacji poszczególnych scenariuszy Monte Carlo jako zadań równoległych, co ponownie skutkuje znacznymi wzrostami szybkości o rząd wielkości przy dużej liczbie scenariuszy i kroków czasowych.
Rozdział 7, "Daty i papiery wartościowe o stałym dochodzie", wprowadza nową funkcjonalność daty w wersji C++20 w bibliotece standardowej, a także sposób, w jaki funkcje daty i walidacje zwykle wymagane do transakcji handlowych o stałym dochodzie mogą być wcielone do kodu. Dyskusja następnie przechodzi do opakowania tej funkcjonalności w zdefiniowanej przez użytkownika klasie daty, która może być używana do projektowania klasy krzywej rentowności i klasy obligacji, a także tego, jak możemy używać tego wszystkiego razem, aby wycenić obligację kuponową. Obejmuje to potencjalne żmudne, ale ważne szczegóły, które są niezbędne w transakcjach handlowych o stałym dochodzie, ale często pomijane w podręcznikach i sylabusach dotyczących tego tematu.
Rozdział 8, "Algebra liniowa", jest poświęcony algebrze liniowej w C++. Powszechną techniką stosowaną w programowaniu bibliotek algebry liniowej C++ jest programowanie szablonów wyrażeń, więc podano drobne wprowadzenie do tego tematu przed przejściem do zewnętrznej biblioteki open source C++ do algebry liniowej o nazwie Eigen. Będzie to obejmować aplikacje do śledzenia funduszy (regresja), szacowania wartości opcji koszykowych (rozkład Choleskiego) oraz dynamiki krzywej rentowności (rozkład głównych składowych). Rozdział kończy się spojrzeniem na możliwości algebry liniowej w bibliotece standardowej C++, szczególnie referencyjnej tablicy wielowymiarowej mdspan z C++23, a także standardowemu interfejsowi opartemu na BLAS (Basic Linear Algebra Subprograms) przewidzianemu na C++26.
Rozdział 9, "Biblioteki Boost", obejmuje niektóre z poddanych wzajemnej ocenie bibliotek open source Boost. Na przestrzeni lat spora część zawartości bibliotek Boost trafiła do Standardu C++ (w podobnej, ale niekoniecznie identycznej postaci), podczas gdy inne biblioteki Boost pozostają niezależne od Standardu, ale mogą być stosowane do problemów pojawiających się w finansach. W szczególności zbiór rozkładów statystycznych z biblioteki Boost Math Toolkit zapewnia funkcję gęstości prawdopodobieństwa (PDF), dystrybuantę (CDF) i funkcję percentyli dla każdego z 34 "podręcznikowych" rozkładów. Biblioteka Boost MultiArray zapewnia generyczną tablicę wielowymiarową, która może być adaptowana do dwumianowych (i trójmianowych) modeli kratowych do wyceny opcji, a biblioteka Boost Accumulators zawiera wydajnie przyrostowe obliczenia statystyk opisowych na dynamicznych zbiorach danych, które są dobrze dopasowane do śledzenia metryk wydajności w backtestach transakcji handlowych (testach na danych historycznych). Poszczególne biblioteki są opisane w tym rozdziale.
Rozdział 10, "Moduły i koncepty", obejmuje dwie nowsze funkcjonalności zawarte w C++20: moduły i koncepty. Moduły niestety są wprowadzane powoli, a ich implementacje w niektórych kompilatorach dopiero niedawno stały się bliskie ukończenia. Z drugiej strony przynoszą bardziej nowoczesny styl projektowania i implementowania oprogramowania w C++, z czystszym i bezpieczniejszym kodem, zwiększoną wydajnością, a w niektórych przypadkach skróconymi czasami kompilacji. Ponadto moim zdaniem, gdy moduły staną się bardziej popularne, będą mogły zapewnić znaczne korzyści osobom uczącym się C++, ponieważ pozwolą szybciej zacząć im działać. Dla odmiany koncepty zostały szybko przyjęte przez programistów i były długo oczekiwanym ulepszeniem. Koncepty dają programistom więcej kontroli nad programowaniem przy użyciu szablonów, szczególnie przez wskazywanie błędów czasów kompilacji, które w poprzednich latach mogły być schowane wśród lawiny tajemniczych błędów. Prowadzą także do czytelniejszego kodu w tym sensie, że pomagają, aby był lepiej samodokumentujący. Koncepty mogą być definiowane przez użytkownika, a mocny zbiór predefiniowanych konceptów opartych na cechach typów (type traits) z wersji C++11 jest dostarczany w bibliotece standardowej od wersji C++20.
Na końcu książki znajduje się pięć dodatków dla Czytelników zainteresowanych zagłębieniem się w bardziej zaawansowane i historyczne tematy.
Konwencje użyte w niniejszej książce
W niniejszej książce zastosowano następujące konwencje typograficzne:
Kursywa
Wskazuje nowe terminy, adresy URL, adresy e-mail, nazwy plików i rozszerzenia plików.
Stała szerokość
Stosowana do listingów programów, a także w akapitach, aby odwoływać się do elementów programu, takich jak nazwy zmiennych lub funkcji, bazy danych, typy danych, zmienne środowiskowe, instrukcje i słowa kluczowe.
Stała szerokość z kursywą
Oznacza tekst, który powinien zostać zastąpiony wartościami podanymi przez użytkownika lub wartościami określonymi przez kontekst.
Ten element oznacza ogólną uwagę.
Ten element wskazuje ostrzeżenie lub przestrogę.
Używanie przykładów kodu
Jak widać, w całej książce zamieszczonych jest wiele przykładów kodu. Są one dostępne do pobrania z serwisu GitHub (https://github.com/QuantDevHacks/LearningModCppFinance), dzięki czemu można je skompilować i uruchomić lokalnie. Kodu tego można swobodnie używać zgodnie z rozsądnymi warunkami licencji publicznej Mozilla w wersji 2.0. Informacje uzupełniające, które towarzyszą repozytorium, mogą być również publikowane od czasu do czasu.
Trzeba pamiętać, że te przykłady kodu mają na celu wprowadzenie do nowoczesnych funkcjonalności C++ i bibliotek open source, a także pokazać, jak można z nich korzystać. Nie są one po prostu częścią książki z przepisami ani kodu gotowego do produkcji. Priorytetem jest pomoc w nauce, zatem ten materiał można kreatywnie wykorzystać we własnych aplikacjach.
Zawsze istnieje ryzyko, zwłaszcza przy początkowej wersji oprogramowania, że zawiera ono błędy lub niespójności, za co z góry przepraszamy. W razie napotkania problemu prosimy o zgłoszenie go na towarzyszącej stronie GitHub.
W przypadku pytań technicznych lub problemów z wykorzystaniem przykładów kodu, prosimy o przesłanie wiadomości e-mail na adres support@oreilly.com.
Niniejsza książka ma służyć pomocą w wykonywaniu pracy. Ogólnie rzecz biorąc, jeśli przykładowy kod jest oferowany z książką, można go używać we własnych programach i dokumentacji. Nie trzeba się z nami kontaktować w celu uzyskania pozwolenia, o ile nie reprodukuje się znacznej części kodu. Na przykład pisanie programu z zastosowaniem kilku fragmentów kodu z niniejszej książki nie wymaga zgody. Sprzedaż lub dystrybucja przykładów z książek O'Reilly wymaga zgody. Udzielenie odpowiedzi na pytanie przez cytowanie niniejszej książki i cytowanie przykładowego kodu nie wymaga zgody. Wcielenie znaczącej ilości kodu przykładowego z niniejszej książki do dokumentacji własnego produktu wymaga zgody.
Doceniamy, ale na ogół nie wymagamy, atrybucji. Atrybucja zazwyczaj obejmuje tytuł, autora, wydawcę i ISBN (oryginalnego wydania angielskiego). Na przykład: "Learning Modern C++ for Finance, Daniel Hanson (O'Reilly). Copyright 2025 Daniel Hanson, 978-1-098-10080-3".
W razie uznania, że korzystanie z przykładów kodu wykracza poza dozwolony użytek lub zgodę podaną powyżej, prosimy o kontakt z nami: permissions@oreilly.com.
Jak się z nami skontaktować
Komentarze i pytania dotyczące niniejszej książki prosimy kierować do wydawcy:
O'Reilly Media, Inc.
1005 Gravenstein Highway North
Sebastopol, CA 95472
800-889-8969 (Stany Zjednoczone lub Kanada)
707-827-7019 (numer międzynarodowy lub lokalny)
707-829-0104 (faks) support@oreilly.com
https://oreilly.com/about/contact.html
Mamy stronę internetową dla niniejszej książki, na której zamieszczamy erratę1, przykłady i wszelkie dodatkowe informacje. Dostęp do tej strony można uzyskać pod adresem https://oreil.ly/learningModCPlusPlus_finance.
Aby uzyskać informacje na temat naszych książek i szkoleń, odwiedź https://oreilly.com.
Znajdź nas na LinkedIn: https://linkedin.com/company/oreilly-media. Oglądaj nas na YouTube: https://youtube.com/oreillymedia.
Podziękowania
Chciałbym serdecznie podziękować wielu osobom za pomoc w ukończeniu niniejszej książki.
Patrice Roy był jednym z oryginalnych recenzentów technicznych, ale zgodził się dodatkowo cierpliwie odpowiadać na wiele moich pytań dotyczących głębszych szczegółów technicznych i spraw dotyczących propozycji, które zostały przedłożone Komitetowi ISO C++. Przyczynił się również do konkretnych materiałów do rozdziałów 4, 5 i 6. Ken Adams dostarczył znaczną pomoc w dostarczaniu zarówno przykładów C++, jak i modelowania finansowego, które ostatecznie stały się materiałem do niniejszej książki, a także jest współautorem rozdziału 7, obejmującego daty, obliczenia dat i aplikacje do transakcji handlowych o stałym dochodzie.
Recenzenci techniczni Kurt Guntheroth i Vittorio Romeo również dostarczyli użytecznych i szczerych opinii. Ta książka jest znacznie lepszym produktem dzięki ich wcześniejszemu wkładowi. Mój redaktor Jeff Bleiel pomógł mi przejść przez wcześniejsze potknięcia i zachęcił mnie do kontynuowania i skupienia się na produkcie końcowym, a także dostarczył bezcennych zaleceń, które sprawiają, że książka jest bardziej spójna i czytelna. Dziękuję także redaktorowi zamawiającemu, czyli Amandzie Quinn, redaktorowi produkcyjnemu, czyli Ashley Stussy, i adiustatorowi, czyli Sharon Wilkey, w wydawnictwie O'Reilly.
Dwaj byli studenci - których teraz uważam za moich przyjaciół - Steven Zhang i Chris Alpert uprzejmie przeanalizowali późniejszą wersję roboczą książki i przykładowy kod z perspektywy docelowej grupy odbiorców. Uchwycili problemy i błędy, które wymagały uwagi, a także zasugerowali różne sformułowania w miejscach, gdzie należało bardziej wyjaśnić treść.
Rob Verran przedstawił kilka bardzo przyjemnych rzeczywistych przykładów stosowania wskaźników współdzielonych w systemach transakcji handlowych w rozdziale 3. Mark Hoemmen i Andrew Lumsdaine dodatkowo kilkukrotnie przejrzeli rozdział 8, a Mark był również na tyle uprzejmy, aby odpowiedzieć na kilka rund pytań związanych z nowymi funkcjonalnościami algebry liniowej, które weszły do C++23 (mdspan) oraz C++26 (interfejs BLAS). John Spraker dokonał również przeglądu niektórych szczegółów matematycznych w tym rozdziale i uchwycił kilka punktów, które wymagały korekty i wyjaśnienia. Thaddeus Tarpey podobnie przejrzał niektóre treści matematyczne związane omówieniem rozkładów statystycznych Boost w rozdziale 9. Ruibo Zhang ostateczne sprawdził kwestie matematyczne w rozdziałach 8 i 9, a także z zainteresowaniem zaadaptował części książki do implementacji w C++ procesów stochastycznych używanych w modelowaniu nowotworów, czyli obszarze jego badań naukowych.
Jeszcze dwie osoby, które nie są bezpośrednio związane z pisaniem niniejszej książki, zasługują na szczególne podziękowania i wspomnienie. Naprawdę dziękuję mojej żonie, Yoshie, za jej cierpliwość i zachętę, a także za utrzymanie mnie odpowiednio nawodnionego i nakarmionego podczas tej drogi przez mękę. Na wielkie podziękowania zasługuje również R. Douglas Martin, profesor emeritus wydziałów Matematyki Stosowanej i Statystyki Uniwersytetu Waszyngtońskiego. Bez jego mentoringu w ciągu ostatnich dwóch dekad nie miałbym możliwości i doświadczenia, które doprowadziły mnie do napisania tej książki.
Wszystkim tutaj wspomnianym nie mogę w pełni wyrazić, jak bardzo doceniam Waszą pomoc i wsparcie. Szczerze dziękuję i jestem głęboko wdzięczny.
Rozdział 1
Przegląd C++
W tym rozdziale przedstawimy przegląd C++, zaczynając od skróconej historii i wczesnej popularności w dziedzinie finansów ilościowych. Następnie przejdziemy do początków ery nowoczesnej, wyznaczonej przez większe ulepszenia wprowadzone w 2011 roku, czyli w wersji C++11. Ostatnia wersja przed C++11 została wydana w roku 2003 (C++03) i była aktualizacją służącą głównie usunięciu usterek, wprowadzoną po pierwszej wersji C++ zgodnej z ISO (International Organization of Standardization (https://www.iso.org/home.html)), którą wydano w 1998 roku (C++98). Modernizacja rozpoczęta w wersji C++11 jest kontynuowana i obejmuje dalsze innowacje dodawane do rdzenia języka i towarzyszącej mu biblioteki standardowej co trzy lata, aż do obecnej wersji C++23.
Zapoznamy się również z nowymi przydatnymi funkcjonalnościami języka, które będą używane w kolejnych rozdziałach, a także z wygodnymi aktualizacjami matematycznymi w bibliotece standardowej. Na koniec przyjrzymy się powszechnym konwencjom nazywania klas, funkcji i zmiennych ze zwróceniem uwagi na te, które będą używane w tej książce.
C++ i finanse ilościowe
Język C++ rozpoczął swoją szybką karierę w sektorze finansowym około połowy lat 90. XX w. Wielu z nas działających w branży w tym czasie wychowało się na Fortranie, który służył do pisania procedur numerycznych i aplikacji naukowych. Chociaż Fortran i jego biblioteki pomocnicze (BLAS, LAPACK, IMSL) były dobrze rozwinięte pod względem wsparcia matematyki i algebry liniowej, językowi brakowało wsparcia programowania obiektowego, którego uwzględnienie w C++ zostało w tym czasie uznane za dużą zaletę przez programistów finansowych. Oczywiście C++ wspiera nie tylko programowanie obiektowe, co opisano w dalszej części rozdziału.
Mówiąc w skrócie, modelowanie finansowe w naturalny sposób składa się z elementów, które mają ze sobą interakcje. Na przykład wycena nawet prostego kontraktu instrumentów pochodnych opartego na kursach walutowych i stopach procentowych wymagałaby zazwyczaj następujących elementów:
krzywe dochodowości poszczególnych walut,
strumień stawek rynkowych notowań kursów walutowych na żywo,
krzywe lub powierzchnie zmienności do wyceny opcji i obliczania miar ryzyka,
zbiór metod wyceny instrumentów pochodnych, takich jak wzory jawne i przybliżenia numeryczne.
Każdy z tych elementów może być reprezentowany przez obiekt, a C++ zapewnia środki do tworzenia tych obiektów i zarządzania relacjami między nimi.
Banki i inne instytucje finansowe również potrzebowały sposobu generowania raportów na temat ryzyka zarówno w skali regionalnej, jak i globalnej. Było to poważne wyzwanie dla firm prowadzących operacje giełdowe w głównych centrach finansowych na całym świecie, takich jak Nowy Jork, Londyn i Tokio. Na początku każdego dnia obrotowego wymagane było raportowanie ryzyka do siedziby firmy, powiedzmy w Nowym Jorku, które uwzględniało portfele utrzymywane zarówno lokalnie, jak i globalnie. Mogło to być obliczeniowo intensywnym zadaniem, ale wydajność C++ pozwalała na jego realizację, co było jeszcze jednym znaczącym czynnikiem wpływającym na wczesne przyjęcie tego języka przez branżę finansową.
Na przełomie XX i XXI wieku nowsze języki obiektowe, takie jak Java i C#, sprawiły, że proces tworzenia oprogramowania stał się nieco łatwiejszy i szybszy. W tym samym czasie potaniały bardziej wydajne procesory. Jednak te same cechy tych języków, które umożliwiały szybsze wdrażanie (takie jak wbudowane mechanizmy zarządzania pamięcią, sprzątanie śmieci i kompilacja pośrednia), mogły również negatywnie wpływać na wydajność w czasie wykonania. Podejmowane decyzje menedżerskie dotyczące przyjęcia języka często sprowadzały się do wyboru między szybkim programowaniem a wydajnością czasu wykonania. Jednak, nawet jeśli wybrano jeden z tych alternatywnych języków, obliczeniowo intensywne modele wyceny i obliczenia ryzyka były - i nadal są - często delegowane do istniejących bibliotek C++ i wywoływane przez interfejs. Należy także zauważyć, że C++ oferuje pewne optymalizacje czasu kompilacji, które nie są dostępne w tych innych językach programowania.
C++11: narodziny nowoczesnej epoki
W 2011 roku Komitet ISO C++ (https://isocpp.org) (zwykle nazywany właśnie Komitetem) wydał istotną poprawkę dotyczącą potrzebnej od dawna modernizacji. W szczególności wersja C++11 dostarczyła następujących oczekiwanych abstrakcji, które bezpośrednio przydają się programistom ilościowym:
generowanie liczb losowych na podstawie różnych rozkładów prawdopodobieństwa;
wyrażenia lambda hermetyzujące funkcje matematyczne, które mogą być również przekazywane jako argumenty do innych funkcji;
podstawowa współbieżność oparta na zadaniach, która pozwala wykonywać obliczenia równolegle bez potrzeby ręcznego zarządzania wątkami;
inteligentne wskaźniki, które mogą pomóc w zapobieganiu związanym z pamięcią awariom programów i niezdefiniowanemu działaniu.
Te i inne tematy są omawiane w kolejnych rozdziałach.
W tym rozdziale i w całej książce czasami pojawia się termin niezdefiniowane działanie. Termin ten występuje również w całym standardzie C++ i oznacza działanie, które nie ma żadnych gwarancji. W potocznym języku oznacza to, że kod "nie gra zgodnie z zasadami", a program jest uszkodzony. Zatem, jeśli masz kod, który zagraża spowodowaniem niezdefiniowanego działania, prosta rada brzmi: "nie rób tego".
Od czasu C++11 nowe wersje z coraz bardziej nowoczesnymi funkcjonalnościami odpowiadającymi wymaganiom branżom finansowej i nauki o danych są wydawane z trzyletnią kadencją, przy czym najnowsza wersja to C++23. Niniejsza książka obejmuje przede wszystkim programowanie w C++20, ale omówimy również kilka nowych elementów w C++23, a także kilka proponowanych nadchodzących atrakcji w C++26, które powinny zainteresować programistów finansowych.
Firmy zajmujące się handlem na własny rachunek (proprietary trading) oraz handlem wysokiej częstotliwości (HFT, high-frequency trading) znalazły się w czołówce przyjmowania standardów C++11 i późniejszych, ponieważ szybkość reagowania na sygnały z rynku i ksiąg handlowych w strategiach statystycznych może oznaczać głęboką różnicę zysków i strat. Nowoczesny C++ jest również bardzo pożądany do implementowania intensywnych obliczeniowo modeli wyceny instrumentów pochodnych wykorzystywanych przez traderów i menedżerów ryzyka w bankach inwestycyjnych i funduszach hedgingowych.
Szerszą historię i ewolucję C++, aż do nowoczesności, można poznać z polecanej książki C++ Today: The Beast is Back (https://oreil.ly/ZHjnQ), której autorami są Jon Kalb i Gašper Ažman (O'Reilly, 2015). Należy także zauważyć, że publikacja wytycznych ISO C++ Core Guidelines (https://oreil.ly/Kc69F) i standardów ISO C++ Coding Standards (https://oreil.ly/P6CK0) oraz poświęcona im większa uwaga sprawiają, że programowanie w C++ może być teraz bardziej niezawodne i wydajne niż we wcześniejszych latach. Szczególnie odwołania do Core Guidelines są dość częste w niniejszej książce.
Biblioteki matematyczne open source
W ciągu ostatniej dekady powstało wiele upragnionych solidnych bibliotek matematycznych open source programowanych w standardzie C++, które w związku z tym nie wymagają dawnej, czasochłonnej gimnastyki potrzebnej do używania interfejsu C. Najważniejsze z nich to biblioteki Boost, biblioteki algebry liniowej Eigen i Armadillo oraz biblioteki uczenia maszynowego, takie jak TensorFlow i PyTorch. W dalszej treści książki omówimy kolejno biblioteki Boost i Eigen.
Kilka mitów na temat C++
Oto niektóre z bardziej niesławnych fałszywych przekonań, które zostały utrwalone na temat C++:
Znajomość języka C jest niezbędna do nauki języka C++.
Chociaż standard C++ zachowuje większość języka C, zupełnie możliwe jest nauczenie się C++ bez znajomości C, co będzie można zobaczyć w dalszej treści. W istocie zbyt uparte trzymanie się stylu C może utrudniać przyswajanie potężnych abstrakcji i potencjalnych korzyści języka C +.
Język C++ jest zbyt trudny.
OK, tak, jest to częściowo prawda, ponieważ C++ jest bez wątpienia bogatym językiem, który zapewnia mnóstwo przysłowiowej liny, na której można się powiesić, i rzeczywiście może czasami być źródłem poważnej frustracji. Jednak przy wykorzystaniu nowoczesnych cech języka zupełnie możliwe jest szybsze osiągnięcie produktywności w programowaniu finansów ilościowych w C++, niż gdybyśmy polegali na starszych metodach C++03.
Wycieki pamięci są zawsze problemem w C++.
Dzięki inteligentnym wskaźnikom dostępnym od C++11 - jednej z bardziej znanych nowoczesnych funkcjonalności - większość kodu nie powinna wymagać zajmowania się surowymi wskaźnikami ani alokacją pamięci. Wraz z innymi funkcjonalnościami biblioteki standardowej, takimi jak standardowe algorytmy (omówione w rozdziale 5), nie powinno to stanowić już problemu w większości implementacji modelowania finansowego.
Kod kompilowany kontra interpretowany
C++ jest językiem kompilowanym: polecenia wpisane do pliku przez nas, zwykłych śmiertelników, są najpierw tłumaczone na zbiór instrukcji binarnych, czyli kod maszynowy, który jest zrozumiały dla procesora komputerowego. Inaczej jest w przypadku interpretowanych języków, takich jak Python, R i MATLAB, gdzie każdy wiersz kodu musi być przetwarzany ponownie za każdym razem, gdy jest wykonywany, co spowalnia czas wykonywania większych aplikacji, zwłaszcza tych, które w dużym stopniu polegają na instrukcjach iteracyjnych (pętlach).
W żadnym razie nie oznacza to niestosowności tych języków. Ich siła objawia się przez popularność w zastosowaniach do szybkich implementacji i wizualizacji modeli powstających w dziedzinach ilościowych, takich jak finanse, nauka o danych i nauki przyrodnicze, gdzie ich funkcje matematyczne i statystyczne są często dostępne jako już skompilowane w C, C++ lub Fortranie. Programiści finansowi mogą być świadomi modeli, które wymagają dni działania w języku interpretowanym, ale po ponownej implementacji w C++ jest to tylko kwestia minut lub sekund.
Wydajne podejście polega na użyciu interpretowanych języków matematycznych uzupełnionych językiem C++. Obliczeniowo intensywny kod modeli może być napisany w bibliotece C++, a następnie na przykład wywoływany interaktywnie lub z poziomu aplikacji w Pythonie lub R. C++ skutecznie dba o skomplikowane obliczenia, a ich wyniki mogą być stosowane wewnątrz potężnych narzędzi do tworzenia wykresów i innych wizualizacji w Pythonie i R, które nie są dostępne w bibliotece standardowej C++.
Kolejną zaletą organizowania modeli finansowych w formie biblioteki C++ jest to, że kod jest zapisywany raz i utrzymywany w pojedynczym repozytorium. W ten sposób wspólny zestaw kodu może być wdrożony w wielu działach i oddziałach, nawet zagranicznych, a następnie wywoływany przez interfejsy z poziomu aplikacji napisanych w różnych językach frontendowych. Pomaga to zapewnić spójne wyniki numeryczne w całej organizacji, co może być szczególną zaletą przy konieczności zachowania zgodności z przepisami.
Popularne pakiety open source integrujące kod C++ są dostępne zarówno dla R, jak i Pythona - odpowiednio są to Rcpp (https://oreil.ly/8QpEJ) i pybind11 (https://oreil.ly/9saMd). MATLAB również oferuje opcje dla interfejsów C++, chociaż te dodatkowe funkcjonalności mogą wymagać nietrywialnych opłat licencyjnych.
Komponenty C++
Standardowe wersje C++ na wysokim poziomie składają się z dwóch komponentów: funkcjonalności języka i biblioteki standardowej C++. Razem standardowy język C++ i biblioteka standardowa są powszechnie określane jako Standard.
Funkcjonalności języka C++
Funkcjonalności języka C++ przeważnie pokrywają się z następującymi podstawowymi operatorami i konstrukcjami, które można znaleźć w innych językach programowania:
podstawowe typy numeryczne całkowitoliczbowe i zmiennopozycyjne;
rozgałęzienia warunkowe: instrukcje if/else i instrukcje switch/case;
konstrukcje iteracyjne: pętle for i pętle while;
standardowe operatory matematyczne i logiczne dla typów numerycznych: dodawanie, odejmowanie, mnożenie, dzielenie, modulo i nierówności.
Funkcjonalności języka C++ realizują co najmniej cztery główne paradygmaty programowania: programowanie proceduralne, programowanie obiektowe, programowanie generyczne i programowanie funkcyjne. Krótka lista konkretnych powiązanych funkcjonalności (w odpowiedniej kolejności) jest następująca:
funkcje swobodne, zwane również funkcjami nieskładowymi (klasy),
klasy i dziedziczenie,
szablony,
wyrażenia lambda (od C++11).
Wszystkie są używane w całej książce, począwszy od tego rozdziału.
Wreszcie język C++ zapewnia mnóstwo podstawowych typów numerycznych i logicznych. Przede wszystkim będziemy używać następujących:
double (podwójna precyzja) do wartości zmiennopozycyjnych,
int do liczb całkowitych dodatnich i ujemnych,
unsigned int (lub po prostu unsigned) do liczb nieujemnych,
bool do reprezentacji wartości logicznej (true lub false).
Zakresy każdego z typów numerycznych mogą się różnić w zależności od platformy, ale w przypadku nowoczesnych kompilatorów typy wymienione tutaj są w większości wystarczające do nowoczesnych zastosowań finansowych. Obszerny przewodnik, który zawiera te zakresy i szczegóły, możemy znaleźć w witrynie cppreference.com (https://oreil.ly/q0DdU). Witryna ta jest niezbędnym zasobem dla każdego programisty C++.
Biblioteka standardowa C++
Biblioteka oprogramowania jest zasadniczo zbiorem funkcji i klas, które są wywoływane przez aplikację lub system. Programowanie bibliotek - zarówno open source, jak i komercyjnych - obecnie zdominowało nowoczesne programowanie C++ w porównaniu do samodzielnych aplikacji, które były bardziej popularne w poprzednich dekadach. W kolejnych rozdziałach opisano niektóre popularne opcje open source, które są przydatne do pracy obliczeniowej. Najważniejszą biblioteką C++ jest jednak biblioteka standardowa, która jest dostarczana z większością nowoczesnych kompilatorów.
Biblioteka standardowa C++ "umożliwia programistom korzystanie z ogólnych komponentów i wyższego poziomu abstrakcji bez utraty przenośności, zamiast konieczności opracowywania całego kodu od zera"2. Począwszy od C++23, bardzo przydatne funkcjonalności biblioteki do modelowania finansowego to m.in.:
klasy kontenerów jednowymiarowych, w szczególności klasa vector o dynamicznie zmienianym rozmiarze;
obszerny zbiór standardowych algorytmów, które operują na tych kontenerach, takich jak sortowanie, wyszukiwanie i wydajne stosowanie funkcji do zakresu elementów w kontenerze;
standardowe funkcje matematyczne na liczbach rzeczywistych, takie jak pierwiastek kwadratowy, funkcja wykładnicza i funkcje trygonometryczne;
liczby zespolone i powiązane operacje arytmetyczne;
generowanie liczb losowych na podstawie zbioru standardowych rozkładów prawdopodobieństwa;
współbieżność oparta na zadaniach, umożliwiająca dostarczanie wartości zwracanych z funkcji uruchamianych równolegle;
inteligentne wskaźniki, które pozwalają unikać zagrożeń związanych z alokacją pamięci i zarządzaniem nią;
klasa string do przechowywania danych znakowych i zarządzania nimi.
Użycie komponentów biblioteki standardowej wymaga od programisty jawnego zaimportowania ich do kodu, ponieważ znajdują się one w oddzielnej bibliotece, a nie w rdzeniu języka. Pomysł jest podobny do importowania tablicy NumPy do programu w Pythonie lub wczytywania zewnętrznego pakietu funkcji do skryptu R. W języku C++ jest to proces dwuetapowy, zaczynający się od dołączenia plików nagłówkowych (za pomocą dyrektywy preprocesora #include) zawierających funkcje i klasy biblioteki standardowej, których chcemy użyć, a następnie określenie zasięgu tych funkcji przy użyciu nazwy przestrzeni nazw biblioteki standardowej, std, często wymawianej jako "stood" przez programistów C++.
Jako pierwszy szybki przykład utworzymy vector wartości int oraz obiekt string i podamy je na wyjście konsoli z prostego programu wykonywalnego wewnątrz funkcji main() :
#include <vector> // klasa vector
#include <string> // klasa string
#include <iostream> // cout
int main()
{
std::vector<int> x{1, 2, 3};
std::string s{"This is a vector:"};
std::cout << s << x[0] << ", " << x[1] << ", " << x[2] << "\n";
}
Zauważ, że klasy biblioteki standardowej vector i string oraz wyjście konsoli cout są w zasięgu przestrzeni nazw std biblioteki standardowej. Jeśli chcesz zaoszczędzić sobie pisania std::, możesz użyć instrukcji using, najlepiej w zasięgach poszczególnych funkcji, chociaż umieszczenie ich na górze pliku może być akceptowalne w pewnych ograniczonych sytuacjach, takich jak pisanie małych zbiorów funkcji testowych:
#include <vector> // klasa vector
#include <string> // klasa string
#include <iostream> // cout
int main()
{
using std::vector, std::string, std::cout;
vector<int> v{1, 2, 3};
string s{"This is a vector:"};
cout << s << v[0] << ", " << v[1] << ", " << v[2] << "\n";
}
Następujący wynik nie jest zaskakujący:
This is a vector: 1, 2, 3
Instrukcja using jest wymagana tylko raz, z trzema komponentami biblioteki standardowej następującymi w tym samym wierszu. Jest to nowsza funkcjonalność wprowadzona od C++17. W przykładach w niniejszej książce często zakłada się, że instrukcje using zostały zastosowane do powszechnie używanych klas i funkcji biblioteki standardowej, takich jak vector, string i cout. Należy również zauważyć, że zwykle nie używamy cout w kodzie produkcyjnym. Będziemy używać tego jako symbolu zastępczego w miejscach, gdzie bardziej prawdopodobne byłoby przekazywanie wyniku do interfejsu GUI lub interfejsu bazy danych lub innej sekcji kodu.
Importowanie przestrzeni nazw std do globalnej przestrzeni nazw w następujący sposób
using namespace std;
jest czasami używane jako zastępstwo dla osobnych instrukcji using lub w celu jawnego określenia zasięgu w przestrzeni nazw std. Jednak nie jest to uważane za dobrą praktykę, ponieważ może powodować kolizje nazw w czasie kompilacji. Więcej szczegółów można znaleźć w pozycji ISO C++ Coding Standards FAQ, "Should I use using namespace std in my code?" (https://oreil.ly/lPnJw).
Ujednolicone inicjowanie (zwane także inicjowaniem klamrowym) stosowane w niektórych z poprzednich przykładów również zostało wprowadzone w C++11:
vector<int> v{1, 2, 3};
string s{"This is a vector:"};
Jest to przydatna funkcjonalność, która jest ogólnie omawiana w podrozdziale "Ujednolicone inicjowanie" na stronie 12. Dla większej wygody zobaczysz później, że parametr szablonu może zostać opuszczony w niektórych przypadkach, przy użyciu innej nowszej funkcjonalności o nazwie dedukcja argumentu szablonu klasy, czyli CTAD (class template argument deduction).
Na koniec wstępnego omówienia biblioteki standardowej warto zwrócić uwagę na jeszcze jeden typ std::size_t (typ rozmiaru). Nie jest on częścią rdzenia języka, ale jest zawarty w bibliotece standardowej i "zdefiniowany jako liczba całkowita bez znaku z wystarczającą ilością bajtów, aby reprezentować rozmiar dowolnego typu"3. Nie jest to tak bardzo odrębny typ, ponieważ jest aliasem jednego z istniejących typów liczb całkowitych bez znaku, więc może być to unsigned int na niektórych platformach i unsigned long na innych. Zgodnie z jego definicją zawsze będzie on co najmniej tak duży jak zwracany typ rozmiaru obiektu vector.
Typ rozmiaru vector jest również typem liczby całkowitej bez znaku zależnym od platformy, który w niektórych przypadkach może być taki sam jak size_t, ale niekoniecznie. Jednak, ponieważ size_t będzie co najmniej tak duży, wartość zwracana z funkcji składowej size() jest często po prostu niejawnie konwertowana:
#include <vector>
#include <cstdlib> // std::size_t
...
std::vector<int> v{1, 2, 3};
std::size_t v_size = v.size();
Jak wkrótce jednak zobaczymy, dzięki nowoczesnym funkcjonalnościom C++, takim jak auto i zakresowe pętle for, ten problem ten stał się raczej artefaktem.
Niektóre nowe funkcjonalności językowe od C++11
Ten podrozdział obejmuje kilka przydatnych funkcjonalności i składni wprowadzonych od C++11 (włącznie). Chociaż od pojawienia się C++11 minęło ponad 10 lat, wersja ta niestety nadal nie jest uwzględniana na wielu uniwersytetach i kierunkach studiów dotyczących finansów ilościowych, nie mówiąc już o późniejszych wydaniach, takich jak C++17.
Słowo kluczowe auto
W C++11 wprowadzono słowo kluczowe auto, które pozwala automatycznie dedukować typ zmiennej lub obiektu. Oto proste przykłady:
auto k = 1; // int
auto x = 419.53; // double
W tym przypadku typ zmiennej k jest dedukowany jako int, a zmiennej x jako double.
Programiści mają różne opinie na temat stosowania auto, ale wielu nadal woli jawnie określać podstawowe typy, takie jak int i double, aby uniknąć niejednoznaczności. Taki styl jest stosowany w niniejszej książce.
Słowo kluczowe auto staje się bardziej przydatne, gdy typ zwracany wynika naturalnie z kontekstu. Jak zobaczymy w rozdziale 3, unikatowy lub współdzielony wskaźnik można utworzyć za pomocą funkcji make_unique<T>(.) lub make_shared<T>(.) z biblioteki standardowej:
auto call_payoff = std::make_unique<CallPayoff>(75.0);
auto mkt_data = std::make_shared<LiveMktData>("CattleFutures");
W pierwszym przypadku make_unique<CallPayoff> dość oczywiste jest, że tworzony jest unikatowy wskaźnik do obiektu CallPayoff z ceną wykonania (strike) równą 75. W drugim make_shared<LiveMktData> informuje, że tworzy wspólny wskaźnik do obiektu LiveMktData z podanymi cenami futures bydła. Są one wystarczająco wyraziste i o wiele łatwiejsze w utrzymaniu niż to, co byłoby wymagane w przeciwnym razie:
std::unique_ptr<CallPayoff> call_payoff = std::make_unique<CallPayoff>(75.0);
std::shared_ptr<LiveMktData> mkt_data =
std::make_shared<LiveMktData>("CattleFutures");
Jest to również przydatne, jeśli typ zwracany jest długim i zagnieżdżonym typem szablonu klasy, na przykład pochodzącym z funkcji w następujący sposób:
std::map<std::string, std::complex<double>> map_of_complex_numbers(...)
{ // ...
std::map<std::string, std::complex<double>> map_key_string_val_complex;
// ...
return map_key_string_val_complex;
}
Wtedy zamiast sposobu sprzed C++11
std::map<std::string, std::complex<double>> cauchys_revenge =
map_of_complex_numbers(...);
możemy jasno i wyraźnie wywołać funkcję i zdefiniować wynik przy użyciu auto:
auto cauchys_revenge = map_of_complex_numbers(...);
Zakresowe pętle for
Przed C++11 iteracja przez vector wymagałaby użycia indeksu jako licznika, aż do liczby jego elementów:
vector<double> v{1.0, 2.0, 3.0, 4.0, 5.0};
for(std::size_t i = 0;
i < v.size();
++i)
{ // Zrób coś z v[i]
}
// Na przykład:
for (std::size_t i = 0;
i < v.size();
++i)
{
cout << v[i] << " ";
}
Alternatywnie można również użyć iteratorowej pętli for, która była również dostępna przed C++11:
for (auto iter = v.begin();
iter != v.end();
++iter)
{
cout << *iter << " ";
}
W niektórych przypadkach ta forma może być wygodniejsza, jak widać w późniejszych przykładach. Na marginesie tutaj należy zauważyć, że słowo kluczowe auto oznacza, że możemy zaoszczędzić sobie wcześniejszej konieczności jawnego określenia typu iteratora, tak jak na przykład tutaj:
for (std::vector<double>::iterator iter = v.begin();
iter != v.end();
++iter)
{ // ...
}
Zakresowe pętle for, wprowadzone w C++11, mogą uczynić ten kod bardziej eleganckim i funkcjonalnym. Zamiast jawnie używać indeksu vector, zakresowa pętla for po prostu instruuje: "dla każdego elementu x w v, zrób coś z nim", podobnie jak w przypadku Pythona lub R:
for (double x : v)
{
cout << x << " ";
}
Zakresowe pętle for mogą być również wykorzystywane do stosowania operacji do elementów vector. Na przykład możemy obliczyć sumę elementów wektora v:
double sum = 0.0;
for (double x : v)
{
sum += x;
}
To wszystko. Brak zmartwień o pomyłki z indeksowaniem, a kod bardziej oczywiście wyraża to, co robi. W C++ Core Guidelines faktycznie nakazano, aby preferować używanie zakresowych pętli for (https://oreil.ly/zvKke) z obiektami vector, jak również z innymi standardowymi kontenerami, które omówiono w rozdziale 4.
Operator referencji & może być również używany w zakresowych pętlach for, podobnie do funkcji:
for (double& x : v)
{
x *= x;
}
// vector v to teraz 1, 4, 9, 16, 25
vector<string> sorry_dave{"Open", "the", "pod", "bay", "doors", "HAL"};
for (const string& s : sorry_dave)
{
cout << s << " ";
}
Dane wyjściowe są następujące:
Open the pod bay doors HAL
Słowo kluczowe using
Innym sposobem na złagodzenie trudu związanego z enigmatycznymi typami szablonowymi przed C++11 było użycie typedef. Wracając do poprzedniego przykładu kontenera map liczb zespolonych, mogliśmy zdefiniować alias complex_map:
typedef std::map<std::string, std::complex<double>> complex_map;
Począwszy od C++11, instrukcja using została rozszerzona, aby wykonać to samo zadanie, ale z bardziej naturalną składnią:
using complex_map = std::map<std::string, std::complex<double>>;
W Core Guidelines zalecano preferowanie using nad typedef, przede wszystkim ze względu na lepszą czytelność: "Przy using nową nazwą jest ta pierwsza, a nie osadzona gdzieś w deklaracji"4. Występują również kwestie dotyczące respektowania zaawansowanych metod szablonów, ale wykracza to poza zakres niniejszej książki.
Ujednolicone inicjowanie
W C++11 wprowadzono ujednolicone inicjowanie, zwane także inicjowaniem klamrowym. Istnieje wiele przypadków użycia, począwszy od prostego przypadku inicjowania zmiennej numerycznej:
int i{100};
Nie jest to zbyt interesujące, ponieważ po prostu zastępuje zapis int i = 100;. Co jednak, gdybyśmy mieli następujący zapis?
double x = 92.09;
int k = x; // Kompiluje się, być może z ostrzeżeniem
To się skompiluje, choć możliwe (ale bez gwarancji) z ostrzeżeniem, że w efekcie część dziesiętna x zostanie obcięta, pozostawiając zmienną k zawierającą tylko samo 92. Przy ujednoliconym inicjowaniu kompilator poinformuje o błędzie konwersji zawężającej i zatrzyma kompilację, zapobiegając tym samym nieoczekiwanym rezultatom w czasie wykonania. To jest dobra rzecz, ponieważ lepiej wychwycić błędy w czasie kompilacji niż w czasie wykonania:
int n{x}; // BŁĄD kompilatora: konwersja zawężająca
Alternatywną równoważną formą ujednoliconego inicjowania jest umieszczenie znaku równości między nazwą zmiennej a lewym nawiasem klamrowym:
int i_alt = {100};
vector<int> v = {1, 2, 3};
Szczególnym stylem używania inicjowania z nawiasami klamrowymi przyjętym w niniejszej książce jest jednak inicjowanie klamrowe bez znaku równości.
Ujednolicone inicjowanie wektora
W przypadku wektora ujednolicone inicjowanie wymaga wyjątku od reguły. Analizując samą poprzednią dyskusję można by oczekiwać, że poniższy kod spowoduje utworzenie wektora u składającego się z dwóch liczb całkowitych:
vector<int> u{2};
Jednak, jak pokazano przed bieżącą dyskusją, to faktycznie zainicjuje wektor z jednym elementem int o wartości 2. Aby utworzyć wektor v dwóch elementów, nadal trzeba użyć starej postaci nawiasowej, aby wskazać, że 2 jest argumentem konstruktora, a nie wartością danych:
vector<int> v(2);
Na początku może się to wydawać dziwne, ale rozdział 4 i dodatek D zawierają szczegółowe uzasadnienie, które za tym stoi.
Formatowanie danych wyjściowych
Nową, wartą wspomnienia funkcjonalnością w bibliotece standardowej od C++20 jest nowa funkcja std::format(.), która może formatować dane znakowe i numeryczne na format tekstowy i zwracać je jako obiekt string.
Załóżmy na przykład, że mamy dwie zmienne u i v, którym przypisano pewne wartości:
double u = 1.5;
double v = 4.2;
Jeśli chcemy podać te wartości z etykietami nazw zmiennych w danych wyjściowych, możemy napisać coś takiego:
cout << "u = " << u << ", v = " << v << "\n";
Dane wyjściowe na ekranie będą wtedy następujące:
u = 1.5, v = 4.2
Jednak łączenie szewronów razem może stać się męczące. Zamiast tego możemy teraz używać format(.) w następujący sposób:
#include <format>
...
string output = std::format("\nu = {0}, v = {1}\n", u, v);
cout << output;
Oznacza to umieszczenie wartości utrzymywanej przez u na pierwszej pozycji (o indeksie zerowym) po "u = ", a następnie wartości v na następnej pozycji. Wynik jest wtedy podobny do poprzedniego:
u = 1.5, v = 4.2
Osoby znające C# mogą zauważyć, że stosujemy to w formie podobnej do Console.WriteLine(.).
Zamiast tego, ponieważ funkcja format(.) zwraca typ string, można ją po prostu umieścić wewnątrz instrukcji cout:
using std::format;
cout << format("nu = {0}, v = {1}\n", u, v);
Otrzymujemy w ten sposób:
u = 1.5, v = 4.2
Jednak przy kolejności od lewej do prawej, wartości indeksu mogą zostać opuszczone, a wyniki wyjściowe będą takie same, jak pokazano w poprzednim przykładzie:
cout << format("u = {}, v = {}\n", u, v);
W niektórych przypadkach jednak potrzebne są wartości indeksu:
#include <cmath> // Bardziej szczegółowy opis w dalszej treści rozdziału
// ...
cout << format("u = {0}, v = {1}, sin({0}) + {1} = {2}\n",
u, v, std::sin(u) + v);
Kolejność jest następnie zachowywana w danych wyjściowych:
u = 1.5, v = 4.2, sin(1.5) + 4.2 = 5.197495
Jeszcze raz powtarzam, że wyjście konsolowe w kodzie produkcyjnym stosowane jest rzadko, jeśli w ogóle, ale może być przydatne jako narzędzie podczas samej nauki języka C++. Jako takie, jest używane w całej książce do demonstrowania wyników wyjściowych.
Idąc dalej, często zakładamy, że instrukcja using istnieje, więc możemy opuścić zasięg std:: przy stosowaniu format(.) w przykładach kodu, podobnie jak w przypadku obiektów vector, string i cout, co wspomniano wcześniej.
Dedukcja argumentu szablonu klasy
W C++17 wprowadzono dedukcję argumentu szablonu klasy (CTAD). Podobnie jak auto, służy ona do dedukcji typu parametru szablonu w oparciu o zainicjowane dane. Zatem w miejscu wcześniejszego przykładu
std::vector<int> v_01{1, 2, 3};
możemy pominąć parametr szablonu int, aby uzyskać ten sam wynik:
std::vector v_02{1, 2, 3};
Przykłady te są znowu trywialne, z zastosowaniem tylko zakodowanych wartości, ale CTAD w bardziej realistycznych sytuacjach może rozjaśnić notację i sprawić, że kod będzie bardziej czytelny. Takie przykłady zobaczysz w dalszej części książki, szczególnie w rozdziale 8 podczas pracy z widokiem tablicy wielowymiarowej std::mdspan, co jest istotną nową funkcjonalnością w C++23.
Stałe wyliczone i wyliczenia z zasięgiem
Przed C++11 stałe wyliczone, częściej nazywane wyliczeniami (enum), były świetnym sposobem, aby rozjaśnić zwykłym śmiertelnikom obszerne kody całkowitoliczbowe, dzięki reprezentowaniu ich jako nazwanych stałych. Stosowanie wyliczeń może również zwiększyć wydajność przetwarzania komputerowego liczb całkowitych, zamiast przekazywania mniej poręcznych obiektów std::string, które zajmują więcej pamięci. I wreszcie pozwala uniknąć błędów spowodowanych przez literówki w cytowanych znakach i zabłąkanych ciągach.
Standard C++11 poprawił się w tym zakresie dzięki wyliczeniom z zasięgiem (przy użyciu enum class). Usuwają one niejasności, które mogą wystąpić przy nakładających się wartościach całkowitoliczbowych podczas używania zwykłych stałych wyliczonych, przy zachowaniu ich zalet.
W dalszej części przedstawiono motywację do preferowania bardziej nowoczesnych wyliczeń z zasięgiem niż wyliczeń opartych na liczbach całkowitych.
Stałe wyliczeniowe
Na początek możemy utworzyć wyliczenie o nazwie OptionType, które będzie reprezentować typ transakcji opcji dozwolonych w prostym systemie handlowym, takich jak opcje europejskie, amerykańskie, bermudzkie i azjatyckie. Deklarujemy nazwę typu wyliczeniowego enum jako OptionType. Następnie wewnątrz nawiasów klamrowych definiujemy określone wartości stałe rozdzielone przecinkami. Domyślnie każda stała ma przypisaną kolejną wartość całkowitoliczbową. Wartości te rozpoczynają się od zera i są zwiększane o jeden (indeksowanie od zera w C++). Za zamykającym nawiasem klamrowym musi być wstawiony średnik. W kodzie, aby to zilustrować, możemy zapisać to następująco:
enum OptionType
{
European, // domyślna wartość całkowitoliczbowa = 0
American, // = European + 1 = 1, itd...
Bermudan, // = 2
Asian // = 3
};
Aby zweryfikować każdą odpowiednią wartość całkowitoliczbową, wyświetlamy dane wyjściowe na ekranie:
cout << " European = " << European << "\n";
cout << " American = " << American << "\n";
cout << " Bermudan = " << Bermudan << "\n";
cout << " Asian = " << Asian << "\n";
cout << " American + Asian = " << American + Asian << "\n";
Jako sprawdzenie danych wyjściowych otrzymujemy to:
European = 0
American = 1
Bermudan = 2
Asian = 3
American + Asian = 4
Potencjalne konflikty wyliczeń
Jak wspomniano na początku, dla każdego typu enum, domyślne przypisania liczb całkowitych zaczynają się od zera, a następnie są zwiększane o jedną dla każdej wartości. Dlatego możliwe jest, że dwie stałe wyliczone z dwóch różnych typów byłyby równe numerycznie. Na przykład załóżmy, że zdefiniujemy dwa typy enum o nazwach Football i Baseball reprezentujące pozycje obrony w każdym sporcie. Domyślnie pozycje baseballowe zaczynają się od 0 dla miotacza (pitcher) i są zwiększane o jeden przy kolejnych pozycjach listy. To samo dotyczy pozycji zawodników futbolu amerykańskiego, które zaczynają się od defensive tackle. Stałe całkowitoliczbowe są podane w komentarzach.
enum Baseball
{
Pitcher, // 0
Catcher, // 1
First_Baseman, // 2
Second_Baseman, // 3
Third_Baseman, // 4
Shortstop, // 5
Left_Field, // 6
Center_Field, // 7
Right_Field // 8
};
enum Football
{
Defensive_Tackle, // 0
Edge_Rusher, // 1
Defensive_End, // 2
Linebacker, // 3
Cornerback, // 4
Strong_Safety, // 5
Free_Safety // 6
};
Następnie możemy porównać Defensive_End i First_Baseman:
if (Defensive_End == First_Baseman)
{
cout << "Defensive_End == First_Baseman\n";
}
else
{
cout << "Defensive_End != First_Baseman\n";
}
Nasz wynik byłby bezsensowny:
Defensive_End == First_Baseman
Dzieje się tak dlatego, że obie pozycje są mapowane do wartości całkowitoliczbowej 2. Szybką poprawką, która była często stosowana przed C++11, byłoby ponowne indeksowanie każdego zbioru wyliczeń:
enum Baseball
{
Pitcher = 100,
Catcher, // 101
First_Baseman, // 102
...
};
enum Football
{
Defensive_Tackle = 200,
Edge_Rusher, // 201
Defensive_End, // 202
...
};
Teraz, jeśli porównamy Defensive_End i First_Baseman, nie będą one już równe, ponieważ 202 ? 102. Mimo to duże bazy kodu mogą zawierać setki definicji wyliczeń, więc nie byłoby wykluczone ich nakładanie się na siebie i powodowanie błędów. Klasy wyliczeń wprowadzone w C++11 eliminują to ryzyko.
Wyliczenia z zasięgiem dzięki klasom wyliczeń
Przy stosowaniu nowoczesnego C++ bardziej solidne podejście eliminuje ręczne manipulowanie wartościami całkowitoliczbowymi wyliczeń. Pozostałe korzyści z wyliczeń nadal pozostają, takie jak unikanie enigmatycznych surowych kodów numerycznych i polegania na obiektach string, ale konflikty numeryczne, takie jak te pokazane w poprzednich przykładach, są unikane przez użycie wyliczenia z zasięgiem realizowanego przez klasę wyliczenia (enum class), czyli nową funkcjonalność języka w C++11. Na przykład możemy zdefiniować kategorie obligacji i kontraktów futures, jak pokazano tutaj:
enum class Bond_Type
{
Government,
Corporate,
Municipal,
Convertible
};
enum class Futures_Contract
{
Gold,
Silver,
Oil,
Natural_Gas,
Wheat,
Corn
};
Próba porównania wyliczeń z dwóch klas wyliczeń, takich jak obligacja Corporate i pozycja futures Natural_Gas, spowoduje teraz błąd kompilatora. Na przykład poniższy kod nawet nie da się skompilować:
if(Bond_Type::Corporate == Futures_Contract::Silver)
{
// ...
}
Działa to na naszą korzyść, ponieważ ponownie lepiej jest wychwycić błąd w czasie kompilacji, niż polować na niego w czasie wykonania. Obecnie według Core Guidelines powinniśmy preferować użycie klas wyliczeń zamiast wyliczonych stałych, aby "minimalizować niespodzianki"5.
W razie potrzeby możliwe jest rzutowanie wyliczeń z zasięgiem do wartości indeksu całkowitoliczbowego. Na przykład zarówno Bond::Corporate, jak i FuturesContract::Silver jest drugą składową w swojej klasie wyliczenia, więc domyślnie każda z nich może być przypisana do wartości 1 (indeksowanej od zera), nawet jeśli nie są porównywalne:
cout << format("Corporate Bond index: {}\n",
static_cast<int>(Bond_Type::Corporate));
cout << format("Natural Gas Futures index: {}\n",
static_cast<int>(Futures_Contract::Natural_Gas));
Wyniki są wyświetlane tutaj:
1
3
W razie potrzeby możliwe jest również wyraźne przypisanie szczególnych wartości indeksu w enum class. Na przykład:
// Jawne przypisywanie każdej wartości indeksu:
enum class Futures_Contract
{
Gold = 100,
Silver = 102,
Oil = 104,
Natural_Gas = 106,
Wheat = 108,
Corn = 110
};
Te liczby całkowite mogą być następnie odzyskane przez rzutowanie na typy int:
// silver_int = 102
auto silver_int = static_cast<int>(Futures_Contract::Silver);
// natural_gas_int = 106
auto natural_gas_int = static_cast<int>(Futures_Contract::Natural_Gas);
Ale ponownie, nie ma ryzyka równoważności z wyliczeniem z innej klasy enum class, ponieważ nie pozwoli na to kompilator. W niektórych przypadkach jednak posiadanie reprezentacji numerycznych może być wygodne, co pokazano w późniejszych rozdziałach.
Na koniec wyliczenia w ogólności, a w szczególności klasy wyliczeń są naturalnymi uzupełnieniami instrukcji switch/case. Poniższy kod przedstawia przykład z wyliczenia z zasięgiem:
void switch_statement_scoped_enum(Bond_Type bnd)
{
switch (bnd)
{
case Bond_Type::Government:
std::cout << "Government Bond..." << "\n";
// Rób coś...
break;
case Bond_Type::Corporate:
cout << "Corporate Bond..." << "\n";
// Rób coś...
break;
case Bond_Type::Municipal:
cout << "Municipal Bond..." << "\n";
// Rób coś...
break;
case Bond_Type::Convertible:
cout << "Convertible Bond..." << "\n";
// Rób coś...
break;
default:
cout << "Unknown Bond..." << "\n";
// Sprawdź typ obligacji...
break;
}
}
Wyrażenia lambda
Wyrażenie lambda jest często określane jako anonimowy obiekt funkcyjny, termin rzekomo ukuty z jego oryginalnej propozycji projektowej (https://oreil.ly/Sxm3m) w 2006 roku. Nazywane potocznie funkcją lambda lub zwyczajnie lambda, może w locie definiować funkcję (lub precyzyjniej funktor, co będzie omówione w następnym rozdziale) w ciele innej funkcji. Dodatkowo lambda może być przekazywana jako argument do innej funkcji. Ta ostatnia właściwość sprawia, że wyrażenia lambda mają tak potężne zastosowania wewnątrz standardowych algorytmów, co zostanie omówione w rozdziale 5.
Wyrażenia lambda są mile widzianym dodatkiem do nowoczesnego arsenału C++. Wprowadzone po raz pierwszy w C++11 miały różne ulepszenia wprowadzane w każdej kolejnej wersji, w tym najnowszym Standardzie C++20. Pomocne podsumowanie ewolucji tych ulepszeń można znaleźć we wpisie na blogu "The Evolution of Lambdas in C++14, C++17, C++20" (https://oreil.ly/OdvWi), którego autorem jest Jonathan Boccara.
Na początek, wyrażenie lambda, które wyświetla stare dobre "Hello World!", może zostać napisane w następujący sposób wewnątrz funkcji domykającej:
void hello_world()
{
auto f = []
{
std::cout << "Hello World!" << "\n";
};
f();
}
Lambda może również przyjmować argumenty funkcji za pomocą opcjonalnych nawiasów okrągłych, podobnie jak zwykła funkcja C++:
auto g = [](double x, double y)
{
return x + y;
};
double z = g(9.2, 2.6); // z = 11.8
W tym miejscu zwróćmy uwagę na trzy punkty.
Po pierwsze domyślnie typ zwracany przez wyrażenie lambda jest dedukowany według tych samych zasad, co te używane w przypadku zmiennych auto. To znaczy, że typ zwracany jest typem wyrażenia użytego do instrukcji return. Jednak opcjonalnie możemy jawnie wskazać zwracany typ w następujący sposób:
auto g = [](double x, double y) -> double
{
return x + y;
};
Gdy typ zwracany jest oczywisty, typ jawny jest zazwyczaj opuszczany.
Po drugie należy umieścić średnik na końcu bloku lambda. Przy umieszczeniu w pojedynczym wierszu, jak w pierwszym przykładzie, lambda jest dość oczywista, ponieważ wygląda jak każda inna jednowierszowa instrukcja C++. Jednak średnik można łatwo przeoczyć, jeśli implementacja lambda obejmuje kilka wierszy, a w takim przypadku kod się nie skompiluje.
Po trzecie w przypadku braku argumentów funkcji, takich jak w pierwszym przykładzie, nawiasy okrągłe są zazwyczaj opcjonalne (z wyjątkiem, technicznie rzecz biorąc, szczególnych przypadków, którymi nie musimy się zajmować w tej książce), ale nawiasy kwadratowe są obowiązkowe w celu zdefiniowania wyrażenia lambda. Nawiasy kwadratowe w poprzednich przykładach są puste, ale ogólnie zapewniają przechwytywanie wyrażenia lambda.
Przechwytywanie wyrażenia lambda robi to, co mówi: przechwytuje (niestatyczne) zmienne zewnętrzne, w tym obiekty, pozwalając na ich użycie wewnątrz ciała lambda. Dane przechwytywane mogą być przyjmowane przez wartość lub przez referencję inną niż const, przy czym ta ostatnia opcja potencjalnie prowadzi do modyfikacji.
Załóżmy na przykład, że mamy vector liczb rzeczywistych, o nazwie u. Chcemy dodać stałą wartość przesunięcia do każdego elementu, a następnie pomnożyć każdą przesuniętą wartość przez skalar alpha. Możemy napisać wyrażenie lambda, które przechwytuje u przez referencję, shift przez wartość, a następnie przyjmuje alpha jako argument. Jeśli zostanie to napisane w ten sposób, wektor u można zmodyfikować wewnątrz wyrażenia lambda:
vector u{1.0, 1.5, 2.0, 2.5, 3.0, 3.5};
double shift = 0.25;
auto shift_scalar_mult = [&u, shift](double alpha)
{
for (double& x : u)
{
x = alpha * (x + shift);
}
};
shift_scalar_mult(-1.0);
W tym przykładzie wektor u jest modyfikowany do nowego stanu, który jest również odzwierciedlany zewnętrznie po wyjściu z wyrażenia lambda, ponieważ jest przechwytywany przez referencję. Po uruchomieniu tego kodu wektor u będzie zawierać wartości -1,25, -1,75, -2,25, -2,75, -3,25 i -3,75.
W ogólności przechwytywanie może zawierać dowolną liczbę zmiennych, ale trzeba mieć pewność, że nie wyznaczamy pojedynczej zmiennej zarówno przez wartość, jak i referencję; na przykład umieszczenie
[u, &u, shift]
w poprzednim przechwyceniu spowodowałby błąd kompilatora.
Symbole wieloznaczne w przechwytywaniu wyrażeń lambda
Użycie symbolu wieloznacznego [=] lub [&] jako przechwytywania lambda pozwala na przechwycenie dowolnych poprzedzających zewnętrznych zmiennych lub obiektów przez wartość lub referencję, odpowiednio. Jednak programiści C++ debatują, czy jest to dobra praktyka. Prawdopodobnie najlepszym sposobem podsumowania tego istotnego punktu widzenia jest parafraza z Effective Modern C++:6
Używanie [&] może spowodować wiszące referencje i niezdefiniowane działanie.
Używanie [=] może powodować problemy z powodu nieoczekiwanego kopiowania wskaźników, w tym inteligentnych wskaźników.
Unikanie tych domyślnych ustawień może zwiększyć prawdopodobieństwo wychwycenia problemów w czasie kompilacji, a nie w czasie wykonania.
W niniejszej książce te napomnienia są przestrzegane.