Cyfroteka.pl

klikaj i czytaj online

Cyfro
Czytomierz
00109 005373 19042355 na godz. na dobę w sumie
Programowanie funkcyjne w języku C++. Tworzenie lepszych aplikacji - książka
Programowanie funkcyjne w języku C++. Tworzenie lepszych aplikacji - książka
Autor: Liczba stron: 320
Wydawca: Helion Język publikacji: polski
ISBN: 978-83-283-4703-8 Data wydania:
Lektor:
Kategoria: ebooki >> komputery i informatyka >> programowanie >> c++ - programowanie
Porównaj ceny (książka, ebook (-35%), audiobook).

Programowanie jest sztuką, dzięki której możesz stworzyć coś z niczego, przy czym tylko od Ciebie zależy, jak doskonałe będzie to dzieło. Dobrze napisany kod jest wydajny, łatwy w testowaniu, można go używać ponownie i wykazuje mniejszą podatność na błędy. Jednym słowem, taki kod powinien możliwie prosto wyrażać złożoną logikę programu, bezproblemowo obsługiwać błędy i przejrzyście implementować współbieżność. Te wymagania pozwoli Ci spełnić funkcyjny styl programowania. Język C++ umożliwia programowanie funkcyjne dzięki szablonom, wyrażeniom lambda i innym ważnym opcjom. Pomocne też będzie korzystanie z biblioteki STL.

Ta książka jest przeznaczona dla profesjonalnych programistów C++, którzy chcą opanować funkcyjny styl programowania i dzięki temu wykorzystać w nowy sposób potężne zalety tego języka. Po interesującym wprowadzeniu do tej metodologii w książce zamieszczono dziesiątki przykładów, schematów i ilustracji wyjaśniających koncepcje programowania funkcyjnego w C++. Pokazano, jak tworzyć bezpieczniejszy kod bez obniżania wydajności pracy programu, jak stosować obiekty funkcyjne i funkcje stosowane, algebraiczne typy danych oraz wiele innych. Nie zabrakło praktycznych przykładów kodu, który stanowi znakomite uzupełnienie prezentowanych treści.

W tej książce między innymi:

Programowanie funkcyjne w C++: twórz najlepsze rozwiązania!

Znajdź podobne książki Ostatnio czytane w tej kategorii

Darmowy fragment publikacji:

Tytuł oryginału: Functional Programming in C++: How to improve your C++ programs using functional techniques Tłumaczenie: Jacek Janusz ISBN: 978-83-283-4703-8 Original edition copyright © 2019 by Manning Publications Co. All rights reserved. Polish edition copyright © 2019 by HELION SA. All rights reserved. All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from the Publisher. Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości lub fragmentu niniejszej publikacji w jakiejkolwiek postaci jest zabronione. Wykonywanie kopii metodą kserograficzną, fotograficzną, a także kopiowanie książki na nośniku filmowym, magnetycznym lub innym powoduje naruszenie praw autorskich niniejszej publikacji. Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi bądź towarowymi ich właścicieli. Autor oraz Helion SA dołożyli wszelkich starań, by zawarte w tej książce informacje były kompletne i rzetelne. Nie biorą jednak żadnej odpowiedzialności ani za ich wykorzystanie, ani za związane z tym ewentualne naruszenie praw patentowych lub autorskich. Autor oraz Helion SA nie ponoszą również żadnej odpowiedzialności za ewentualne szkody wynikłe z wykorzystania informacji zawartych w książce. Helion SA ul. Kościuszki 1c, 44-100 Gliwice tel. 32 231 22 19, 32 230 98 63 e-mail: helion@helion.pl WWW: http://helion.pl (księgarnia internetowa, katalog książek) Pliki z przykładami omawianymi w książce można znaleźć pod adresem: ftp://ftp.helion.pl/przyklady/profun.zip Drogi Czytelniku! Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres http://helion.pl/user/opinie/profun Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję. Printed in Poland. • Kup książkę • Poleć książkę • Oceń książkę • Księgarnia internetowa • Lubię to! » Nasza społeczność Spis treści Przedmowa O książce O autorze Rozdział 1. Wprowadzenie do programowania funkcyjnego 1.1. Co to jest programowanie funkcyjne? 1.1.1. 1.1.2. Związek z programowaniem obiektowym Praktyczny przykład porównania programowania imperatywnego i deklaratywnego 1.2. Funkcje czyste 1.2.1. Unikanie stanu mutowalnego 1.3. Myślenie w sposób funkcjonalny 1.4. Korzyści wynikające z programowania funkcyjnego Zwięzłość i czytelność kodu 1.4.1. 1.4.2. Współbieżność i synchronizacja 1.4.3. Przekształcanie C++ w funkcyjny język programowania Ciągła optymalizacja 1.5. 1.6. Czego nauczysz się w trakcie czytania tej książki? Podsumowanie Rozdział 2. Pierwsze kroki z programowaniem funkcyjnym 2.1. Funkcje używające innych funkcji? 2.2. Obliczanie średnich Zwijanie Przycinanie łańcucha Partycjonowanie kolekcji na podstawie predykatu Filtrowanie i transformacja Przykłady z biblioteki STL 2.2.1. 2.2.2. 2.2.3. 2.2.4. 2.2.5. Problemy ze składaniem algorytmów STL Tworzenie własnych funkcji wyższego rzędu 2.4.1. 2.4.2. 2.4.3. 2.4.4. Otrzymywanie funkcji w postaci argumentów Implementacja z pętlami Rekurencja oraz optymalizacja przez rekurencję ogonową Implementacja przy użyciu zwijania 2.3. 2.4. Podsumowanie 9 11 15 17 18 19 20 24 27 29 31 32 33 33 34 36 36 39 40 42 42 45 48 50 52 53 55 55 56 57 61 62 Kup książkęPoleć książkę 4 Spis treści Rozdział 3. Obiekty funkcyjne 3.1. Funkcje i obiekty funkcyjne Automatyczna dedukcja typu zwracanego 3.1.1. 3.1.2. Wskaźniki do funkcji 3.1.3. 3.1.4. Przeciążanie operatora wywołania Tworzenie generycznych obiektów funkcyjnych 3.2. Wyrażenia lambda i domknięcia 3.2.1. 3.2.2. 3.2.3. 3.2.4. Składnia wyrażenia lambda Co się kryje wewnątrz wyrażenia lambda? Tworzenie dowolnych zmiennych składowych w wyrażeniach lambda Uogólnione wyrażenia lambda 3.3. Tworzenie obiektów funkcyjnych, które są jeszcze bardziej zwięzłe niż wyrażenia lambda 3.3.1. 3.3.2. Obiekty funkcyjne operatorów w bibliotece STL Obiekty funkcyjne operatorów w innych bibliotekach 3.4. Opakowywanie obiektów funkcyjnych przy użyciu std::function Podsumowanie Rozdział 4. Tworzenie nowych funkcji na podstawie istniejących 4.1. Częściowe stosowanie funkcji 4.1.1. 4.1.2. 4.1.3. 4.1.4. 4.1.5. Ogólny sposób zamiany funkcji dwuargumentowych na jednoargumentowe Użycie std::bind do wiązania wartości z określonymi argumentami funkcji Zamienianie ze sobą argumentów funkcji dwuargumentowej Użycie std::bind z funkcjami mającymi więcej argumentów Użycie wyrażeń lambda jako alternatywy dla std::bind 4.2. Rozwijanie: inny sposób podejścia do funkcji Rozwijanie funkcji w prostszy sposób Użycie rozwijania podczas dostępu do bazy danych Rozwijanie a częściowe stosowanie funkcji 4.2.1. 4.2.2. 4.2.3. Złożenie funkcji Podnoszenie funkcji — kolejne podejście 4.4.1. Odwracanie elementów par w kolekcji 4.3. 4.4. Podsumowanie Rozdział 5. Czystość: unikanie stanu mutowalnego Problemy ze stanem mutowalnym 5.1. 5.2. Funkcje czyste i przejrzystość referencyjna 5.3. 5.4. 5.5. Duże znaczenie kwalifikatora const Programowanie bez efektów ubocznych Stan mutowalny i niemutowalny w środowisku współbieżnym 5.5.1. 5.5.2. 5.5.3. Logiczna i wewnętrzna zgodność z deklaracją const Optymalizacja funkcji składowych dla zmiennych tymczasowych Pułapki związane z użyciem słowa kluczowego const Podsumowanie 63 64 64 67 68 70 73 74 75 77 79 80 83 84 86 88 89 90 92 95 97 98 101 103 104 106 109 110 113 116 117 119 120 122 125 129 132 134 136 138 140 Kup książkęPoleć książkę Spis treści 5 Rozdział 6. Wartościowanie leniwe 6.1. Wartościowanie leniwe w C++ 6.2. Wartościowanie leniwe jako technika optymalizacyjna Sortowanie kolekcji przy wykorzystaniu wartościowania leniwego 6.2.1. 6.2.2. Wyświetlanie elementów w interfejsach użytkownika 6.2.3. 6.2.4. Przycinanie drzew rekurencyjnych przez buforowanie wyników funkcji Programowanie dynamiczne jako forma wartościowania leniwego 6.3. Uogólnione zapamiętywanie 6.4. Szablony wyrażeń i leniwe łączenie łańcuchów 6.4.1. Czystość i szablony wyrażeń Podsumowanie Rozdział 7. Zakresy 7.1. Wprowadzenie do zakresów 7.2. Tworzenie widoków danych tylko do odczytu 7.2.1. 7.2.2. 7.2.3. Wartościowanie leniwe wartości zakresów Użycie funkcji filter z zakresami Użycie funkcji transform z zakresami 7.3. Mutowalność zmiennych w zakresach 7.4. Używanie zakresów ograniczonych i nieskończonych 7.4.1. 7.4.2. Użycie zakresów ograniczonych do optymalizacji obsługi zakresów wejściowych Tworzenie zakresów nieskończonych przy użyciu wartowników 7.5. Używanie zakresów do obliczania częstości pojawiania się słów Podsumowanie Rozdział 8. Funkcyjne struktury danych 8.1. Niemutowalne listy łączone 8.1.1. 8.1.2. 8.1.3. 8.1.4. Dodawanie i usuwanie elementów z początku listy Dodawanie i usuwanie elementów z końca listy Dodawanie i usuwanie elementów z wnętrza listy Zarządzanie pamięcią 8.2. Niemutowalne struktury danych podobne do wektorów 8.2.1. Wyszukiwanie elementu w drzewie trie Dołączanie elementów w drzewie trie 8.2.2. Aktualizacja elementów w drzewie trie 8.2.3. Usuwanie elementów z końca drzewa trie 8.2.4. 8.2.5. Inne operacje i całkowita wydajność drzewa trie Podsumowanie Rozdział 9. Algebraiczne typy danych i dopasowywanie do wzorców 9.1. Algebraiczne typy danych 9.1.1. 9.1.2. 9.1.3. 9.1.4. 9.1.5. Typy sumy uzyskiwane poprzez dziedziczenie Typy sumy uzyskiwane dzięki uniom i typowi std::variant Implementacja określonych stanów Szczególny typ sumy: wartości opcjonalne Użycie typów sumy w celu obsługi błędów 141 142 145 145 147 148 150 152 155 159 160 163 165 166 166 167 168 170 172 172 173 175 178 179 180 180 181 182 183 185 187 189 191 192 192 193 195 196 197 200 204 205 207 Kup książkęPoleć książkę 6 Spis treści 9.2. Modelowanie dziedziny za pomocą algebraicznych typów danych 9.2.1. 9.2.2. Rozwiązanie najprostsze, które czasami jest niewystarczające Rozwiązanie bardziej zaawansowane: podejście zstępujące 9.3. Lepsza obsługa algebraicznych typów danych za pomocą dopasowywania do wzorców Zaawansowane dopasowywanie do wzorców za pomocą biblioteki Mach7 9.4. Podsumowanie Rozdział 10. Monady 10.1. Funktory pochodzące od nie Twojego rodzica 10.1.1. Obsługa wartości opcjonalnych 10.2. Monady: więcej możliwości dla funktorów 10.3. Proste przykłady 10.4. Składane zakresy i monady 10.5. Obsługa błędów 10.5.1. Typ std::optional T jako monada 10.5.2. Typ expected T, E jako monada 10.5.3. Monada Try 10.6. Obsługa stanu przy użyciu monad 10.7. Monada współbieżnościowa i kontynuacyjna 10.7.1. Typ future jako monada 10.7.2. Implementacja wartości przyszłych 10.8. Składanie monad Podsumowanie Rozdział 11. Metaprogramowanie szablonów 11.1. Zarządzanie typami w czasie kompilacji 11.1.1. Debugowanie typów dedukowanych 11.1.2. Dopasowywanie do wzorców podczas kompilacji 11.1.3. Udostępnianie metainformacji o typach 11.2. Sprawdzanie właściwości typu w czasie kompilacji 11.3. Tworzenie funkcji rozwiniętych 11.3.1. Wywoływanie wszystkich obiektów wywoływalnych 11.4. Tworzenie języka dziedzinowego Podsumowanie Rozdział 12. Projektowanie funkcyjne systemów współbieżnych 12.1. Model aktora: myślenie komponentowe 12.2. Tworzenie prostego źródła wiadomości 12.3. Modelowanie strumieni reaktywnych w postaci monad 12.3.1. Tworzenie ujścia w celu odbierania wiadomości 12.3.2. Transformowanie strumieni reaktywnych 12.3.3. Tworzenie strumienia z określonymi wartościami 12.3.4. Łączenie strumienia strumieni 12.4. Filtrowanie strumieni reaktywnych 12.5. Obsługa błędów w strumieniach reaktywnych 212 213 214 215 218 219 221 222 223 226 229 231 234 234 236 237 238 240 242 244 245 247 249 250 252 254 257 258 261 263 265 271 273 274 278 281 283 286 288 289 290 291 Kup książkęPoleć książkę Spis treści 12.6. Odpowiadanie klientowi 12.7. Tworzenie aktorów ze stanem mutowalnym 12.8. Tworzenie systemów rozproszonych z użyciem aktorów Podsumowanie Rozdział 13. Testowanie i debugowanie 13.1. Czy program, który się kompiluje, jest poprawny? 13.2. Testy jednostkowe i funkcje czyste 13.3. Testy generowane automatycznie 13.3.1. Generowanie przypadków testowych 13.3.2. Testowanie oparte na właściwościach 13.3.3. Testy porównawcze 13.4. Testowanie systemów współbieżnych opartych na monadach Podsumowanie Skorowidz 7 293 297 298 299 301 302 304 305 306 307 309 311 314 315 Kup książkęPoleć książkę Kup książkęPoleć książkę Wprowadzenie do programowania funkcyjnego W niniejszym rozdziale omówiono następujące zagadnienia:  Zrozumienie programowania funkcyjnego.  Myślenie o celu zamiast o krokach algorytmu.  Zrozumienie funkcji czystych.  Korzyści z programowania funkcyjnego.  Przekształcanie C++ w funkcyjny język programowania. Będąc programistami, musimy w trakcie naszej kariery uczyć się wielu języków progra- mowania. Jednakże zwykle koncentrujemy się tylko na dwóch lub trzech spośród nich, które nam najbardziej odpowiadają. Często słyszy się stwierdzenie, że nauka nowego języka programowania jest łatwa — różnice między językami występują głównie w składni, a większość z nich udostępnia mniej więcej te same funkcje. Jeśli znamy język C++, nauczenie się języka Java lub C# powinno być łatwe — i na odwrót. To stwierdzenie ma pewne zalety. Jeśli jednak uczymy się nowego języka, zwykle próbujemy symulować styl programowania, którego używaliśmy w poprzednim. Gdy po raz pierwszy spotkałem się na mojej uczelni z funkcyjnym językiem programowania, zacząłem od nauczenia się, w jaki sposób można skorzystać z jego funkcji w celu Kup książkęPoleć książkę 18 ROZDZIAŁ 1. Wprowadzenie do programowania funkcyjnego zasymulowania pętli for i while oraz rozgałęzień typu if-then-else. Takie podejście przyjęła większość z nas, by zdać egzamin i nigdy już nie wracać do poznanej wcześniej wiedzy. Istnieje takie powiedzenie, że jeśli jedynym narzędziem, jakie masz, jest młotek, będziesz zachęcany, aby traktować każdy problem jak gwóźdź. Stosuje się je również w sytuacji odwrotnej: jeśli masz gwóźdź, będziesz chciał użyć dowolnego narzędzia w taki sposób, jakby było młotkiem. Wielu programistów, którzy chcą przetestować działanie funkcyjnego języka programowania, podejmuje decyzję, że nie warto się go uczyć, ponieważ nie widzą korzyści w jego zastosowaniu. Starają się oni używać nowego narzędzia w taki sam sposób, w jaki używali starego. Dzięki tej książce nie nauczysz się nowego języka programowania, lecz poznasz alternatywny sposób używania znanego Ci języka (C++). Jest to jednak sposób tak różniący się od tego, co do tej pory poznałeś, że często będziesz czuł się tak, jakbyś poznawał i wykorzystywał nowy język. Dzięki temu nowemu stylowi programowania można tworzyć bardziej zwięzłe programy oraz kod, który jest bezpieczniejszy, łatwiejszy do zrozumienia i analizowania, a także — ośmielę się napisać — piękniejszy niż kod napisany w zwykły sposób w języku C++. 1.1. Co to jest programowanie funkcyjne? Programowanie funkcyjne to dawny paradygmat programowania, który narodził się w środowisku akademickim w latach pięćdziesiątych XX wieku i przez długi czas pozo- stawał związany z tym środowiskiem. Chociaż zawsze był gorącym tematem dla badaczy naukowych, nigdy nie stał się popularny w „świecie realnym”. Zamiast niego wszędzie zaczęły panować języki imperatywne (najpierw proceduralne, później zorientowane obiektowo). Prognozuje się często, że pewnego dnia funkcyjne języki programowania będą rządzić światem, jednakże ten moment jeszcze nie nastąpił. Znane języki funkcyjne, takie jak Haskell i Lisp, nadal nie znajdują się na listach 10 najpopularniejszych języ- ków programowania. Listy te są zarezerwowane dla tradycyjnie imperatywnych języ- ków, takich jak C, Java i C++. Podobnie jak większość prognoz, również i ta musi zostać odpowiednio zinterpretowana, by mogła zostać uznana za spełnioną. Zamiast języków funkcyjnych, które stają się najbardziej popularne, dzieje się coś innego: najbardziej popularne języki programowania zaczynają wprowadzać funkcje inspiro- wane funkcjonalnymi językami programowania. Co to jest programowanie funkcyjne? Na to pytanie trudno odpowiedzieć, ponie- waż nie istnieje powszechnie przyjęta definicja. Jest takie powiedzenie, że jeśli zadasz powyższe pytanie dwóm programistom specjalizującym się w programowaniu funk- cyjnym, otrzymasz (co najmniej) trzy różne odpowiedzi. Istnieje tendencja do definio- wania programowania funkcyjnego poprzez związane z nim koncepcje, jak na przykład funkcje czyste, wartościowanie leniwe, dopasowywanie wzorców itp. Dana osoba zwykle wymienia cechy swojego ulubionego języka. Kup książkęPoleć książkę 1.1. Co to jest programowanie funkcyjne? 19 Aby nikogo nie zrazić, zaczniemy od przesadnie matematycznej definicji, pocho- dzącej z grupy dyskusyjnej Usenet, dotyczącej programowania funkcjonalnego: Programowanie funkcyjne to styl programowania, który kładzie nacisk na wy- znaczanie wyrażeń, a nie na wykonywanie poleceń. Wyrażenia w tych językach są tworzone za pomocą funkcji i służą do łączenia wartości podstawowych. Język funkcyjny to język, który wspiera programowanie w stylu funkcyjnym i zachęca do niego. — najczęściej zadawane pytania (FAQ), grupa dyskusyjna comp.lang.functional W tej książce omówimy różne koncepcje związane z programowaniem funkcyjnym. Od Ciebie będzie zależało, jakie elementy uznasz za decydujące o tym, by język mógł być nazwany funkcyjnym. Ogólnie mówiąc, programowanie funkcyjne to styl programowania, w którym główne elementy składowe programu są funkcjami, w przeciwieństwie do obiektów i proce- dur. Program napisany w stylu funkcyjnym nie zawiera poleceń, które należy wyko- nać, aby osiągnąć określony wynik, ale raczej definiuje, jaki powinien być ten wynik. Rozważmy prosty przykład: obliczanie sumy listy liczb. W świecie imperatywnym implementujesz algorytm poprzez przetwarzanie listy i dodawanie liczb do zmiennej akumulacyjnej. Wyjaśniasz krok po kroku proces, w jaki sposób należy sumować listę liczb. Z drugiej strony, w stylu funkcyjnym musisz zdefiniować tylko to, co jest sumą listy liczb. Komputer wie, co należy zrobić, by wyznaczyć sumę. Jednym ze sposobów, w jaki możesz zaprezentować tę definicję, jest stwierdzenie, że suma listy liczb jest równa pierwszemu elementowi listy dodanemu do sumy reszty listy i że suma wynosi zero, jeśli lista jest pusta. Określasz sumę bez wyjaśniania, jak należy ją obliczyć. Powyższa różnica w działaniu algorytmów jest źródłem powstania terminów pro- gramowanie imperatywne i deklaratywne. Programowanie imperatywne oznacza, że nakazujesz komputerowi zrobienie czegoś, wyraźnie określając każdy krok, który musi zostać zrealizowany w celu wyznaczenia wyniku. Programowanie deklaratywne ozna- cza, że podajesz, co należy zrobić, a zadaniem języka programowania jest dowiedzieć się, w jaki sposób można to zrealizować. Określasz, czym jest suma listy liczb, a język używa tej definicji do obliczenia sumy danej listy liczb. 1.1.1. Związek z programowaniem obiektowym Nie można powiedzieć, co jest lepsze: najpopularniejszy paradygmat imperatywny, czyli programowanie obiektowe (OOP), czy najczęściej używany paradygmat dekla- ratywny — programowanie funkcyjne. Oba podejścia mają swoje zalety i wady. Paradygmat obiektowy opiera się na tworzeniu abstrakcji dla danych. Pozwala pro- gramiście ukryć wewnętrzną reprezentację danych wewnątrz obiektu i umożliwić resz- cie świata dostęp do nich jedynie za pośrednictwem interfejsu API. Styl programowania funkcyjnego definiuje abstrakcje dotyczące funkcji. Pozwala to tworzyć struktury sterujące, które są bardziej złożone w porównaniu z językiem Kup książkęPoleć książkę 20 ROZDZIAŁ 1. Wprowadzenie do programowania funkcyjnego bazowym. Kiedy w języku C++ 11 wprowadzono pętlę for opartą na zakresach (nazwaną foreach), musiała ona zostać zaimplementowana w każdym kompilatorze C++ (a jest ich wiele). Korzystając z technik programowania funkcyjnego, można było to zrobić bez zmiany kompilatora. Wiele bibliotek zewnętrznych od dawna implementowało własne wersje pętli opartych na zakresach. Gdy używamy idiomów programowania funkcyj- nego, możemy tworzyć nowe konstrukcje językowe, takie jak pętle for oparte na zakre- sach i inne, bardziej zaawansowane. Będą one użyteczne nawet podczas pisania pro- gramów w stylu imperatywnym. W pewnych sytuacjach pierwszy paradygmat jest bardziej odpowiedni od drugie- go — i na odwrót. Często najlepszym rozwiązaniem jest połączenie obu stylów. Wynika to z faktu, że wiele starych i nowych języków programowania stało się wielowymia- rowymi zamiast bycia wiernymi swojemu podstawowemu paradygmatowi. 1.1.2. Praktyczny przykład porównania programowania imperatywnego i deklaratywnego Aby zademonstrować różnicę między tymi dwoma stylami programowania, zacznijmy od prostego programu zaimplementowanego w stylu imperatywnym, a następnie prze- konwertujmy go na funkcjonalny odpowiednik. Jednym ze sposobów pomiaru stopnia złożoności oprogramowania jest zliczanie wierszy kodu. Chociaż można debatować, czy jest to dobry wskaźnik, jest on doskonałym sposobem na wykazanie różnic między sty- lami imperatywnym i funkcyjnym. Wyobraź sobie, że chcesz napisać funkcję, która pobiera listę plików i zlicza liczbę wierszy w każdym z nich (patrz rysunek 1.1). Aby przykład był jak najprostszy, poli- czysz w pliku tylko liczbę znaków nowego wiersza. Załóżmy również, że ostatni wiersz w pliku również kończy się takim znakiem. Rysunek 1.1. Danymi wejściowymi programu jest lista plików. Program musi zwrócić liczbę znaków nowego wiersza dla każdego z plików Myśląc w sposób imperatywny, mógłbyś wziąć pod uwagę następujące działania w celu rozwiązania problemu: 1. Otwieraj po kolei każdy z plików. 2. Zdefiniuj licznik do przechowywania liczby wierszy. 3. Odczytuj plik po jednym znaku naraz i zwiększaj licznik za każdym razem, gdy pojawi się znak nowego wiersza (\n). 4. Po dotarciu do końca pliku zapisz liczbę obliczonych wierszy. Kup książkęPoleć książkę 1.1. Co to jest programowanie funkcyjne? 21 Poniższy listing 1.1 pozwala na czytanie z plików znak po znaku i zlicza liczbę znaków nowego wiersza. Listing 1.1. Zliczanie liczby wierszy w sposób imperatywny std::vector int count_lines_in_files(const std::vector std::string files) { std::vector int results; char c = 0; for (const auto file : files) { int line_count = 0; std::ifstream in(file); while (in.get(c)) { if (c == \n ) { line_count++; } } results.push_back(line_count); } return results; } Program wynikowy zawiera dwie zagnieżdżone pętle i kilka zmiennych służących do zachowania bieżącego stanu procesu. Chociaż przykład jest prosty, zawiera kilka miejsc, w których można popełnić błąd — niezainicjalizowaną (lub źle zainicjalizowaną) zmienną, nieprawidłowo zaktualizowany stan lub niewłaściwy warunek pętli. Kom- pilator zgłasza niektóre z tych błędów jako ostrzeżenia, lecz te, które zostaną przez niego pominięte, są zwykle trudne do wykrycia, ponieważ nasze mózgi działają w taki sposób, by je ignorować, podobnie jak w przypadku błędów w pisowni. Powinieneś spróbować napisać swój kod w sposób minimalizujący możliwość popełniania takich błędów. Czytelnicy, którzy są bardziej zaawansowani w programowaniu przy użyciu języka C++, być może zauważyli, że zamiast „ręcznie” obliczać liczbę nowych wierszy, można było użyć standardowego algorytmu std::count. Język C++ zapewnia wygodny dostęp do abstrakcji takich jak iteratory strumieniowe, które umożliwiają traktowanie stru- mieni wejścia i wyjścia w sposób podobny do zwykłych kolekcji, na przykład list i wek- torów. Możemy więc skorzystać z tych iteratorów w naszym algorytmie (listing 1.2). Listing 1.2. Użycie algorytmu std::count w celu zliczania znaków nowego wiersza int count_lines(const std::string filename) { std::ifstream in(filename); return std::count( std::istreambuf_iterator char (in), std::istreambuf_iterator char (), \n ); } std::vector int count_lines_in_files(const std::vector std::string files) Zlicza znaki nowego wiersza od bieżącej pozycji w strumieniu aż do końca pliku Kup książkęPoleć książkę 22 ROZDZIAŁ 1. Wprowadzenie do programowania funkcyjnego { std::vector int results; for (const auto file : files) { results.push_back(count_lines(file)); } return results; } Zapisz wynik Dzięki powyższemu rozwiązaniu nie interesujesz się tym, w jaki sposób powinien zostać zaimplementowany sposób liczenia. Po prostu stwierdzasz, że chcesz policzyć liczbę wierszy, które pojawiają się w danym strumieniu wejściowym. Taki sposób myślenia jest kluczowy podczas tworzenia programów w stylu funkcyjnym. Użyj abstrakcji, które pozwolą Ci zdefiniować cel, zamiast zastanawiać się, jak coś należy zrobić. Tak właśnie postępujemy w większości rozwiązań omawianych w tej książce. Dlatego też progra- mowanie funkcyjne jest spokrewnione z programowaniem generycznym (szczególnie w języku C++) — oba pozwalają myśleć na wyższym poziomie abstrakcji w porów- naniu z przyziemnym podejściem imperatywnego stylu programowania. Język zorientowany obiektowo? Uważam za zabawne stwierdzenie, które popiera większość programistów, że C++ jest językiem obiektowym. Jest ono nieprawdziwe, ponieważ prawie żaden element standar- dowej biblioteki języka programowania C++ (zwykle określanej jako standardowa biblio- teka szablonów lub STL) nie wykorzystuje polimorfizmu opartego na dziedziczeniu, który stanowi sedno paradygmatu programowania obiektowego. Biblioteka STL została stworzona przez Aleksandra Stiepanowa, zagorzałego krytyka pro- gramowania obiektowego. Chciał on stworzyć ogólną bibliotekę programistyczną i zre- alizował swój zamiar przy użyciu systemu szablonów języka C++ połączonego z kilkoma technikami programowania funkcyjnego. Jest to jeden z powodów, dla których w tej książce bardzo polegam na bibliotece STL. Nawet jeśli nie jest ona właściwą biblioteką do programowania funkcyjnego, modeluje wiele jego koncepcji, co czyni ją doskonałym punktem wyjścia umożliwiającym wkroczenie w świat programowania funkcjonalnego. Zaletą tego rozwiązania jest to, że używasz mniej zmiennych zarządzających stanem programu, o które trzeba się martwić. Możesz więc definiować wysokopoziomowe cele, zamiast określać dokładne kroki, które należy podjąć, aby uzyskać wynik. Już nie obchodzi Cię, w jaki sposób zostało zrealizowane liczenie. Jedynym zadaniem funkcji count_lines jest konwertowanie jej danych wejściowych (nazwy pliku) na typ, który może zostać zrozumiany przez algorytm std::count (parę iteratorów strumieniowych). Pójdźmy jeszcze dalej i zdefiniujmy cały algorytm w stylu funkcjonalnym (listing 1.3): określmy, co należy zrobić, zamiast tego, jak należy to zrobić. Pozostała jeszcze pętla for oparta na zakresie, która stosuje funkcję do wszystkich elementów w kolekcji i gromadzi wyniki. Jest to powszechnie stosowany wzorzec i należy się spodziewać, że język programowania obsługuje go w swojej standardowej bibliotece. W języku C++ jest nim algorytm std::transform (w innych językach jest on zwykle dostępny pod nazwą map lub fmap). Implementacja takiej samej logiki, jednak przy użyciu algorytmu std::transform, została przedstawiona w następnym listingu. Algorytm std::transform Kup książkęPoleć książkę 1.1. Co to jest programowanie funkcyjne? 23 przetwarza elementy kolekcji files jeden po drugim, przekształcając je za pomocą funkcji count_lines i przechowując wynikowe wartości w wektorze results. Listing 1.3. Odwzorowywanie plików na liczbę wierszy przy użyciu algorytmu std::transform std::vector int count_lines_in_files(const std::vector std::string files) { std::vector int results(files.size()); std::transform(files.cbegin(), files.cend(), results.begin(), count_lines); Funkcja transformacji Określa, jakie elementy należy przekształcić Gdzie przechowywać wyniki return results; } Ten kod nie określa już kroków algorytmu, które należy wykonać, ale raczej sposób transformacji danych wejściowych w celu uzyskania pożądanego wyniku. Można wnio- skować, że usunięcie zmiennych stanu i wykorzystanie implementacji algorytmu liczenia zdefiniowanego w bibliotece standardowej zamiast tworzenia własnego sprawia, iż kod jest mniej podatny na błędy. Problem polega na tym, że najnowszy listing zawiera zbyt dużo standardowego kodu i dlatego nie może być uważany za bardziej czytelny od przykładu pierwotnego. Główna funkcja wykorzystuje tylko trzy ważne słowa:  transform — to, co jest realizowane w kodzie,  files — dane wejściowe,  count_lines — funkcja transformacji. Reszta jest dodatkiem. Nasza funkcja byłaby znacznie czytelniejsza, gdybyś mógł zapisywać tylko ważne fragmenty kodu i pomijać pozostałe. W rozdziale 7. zobaczysz, że jest to możliwe dzięki bibliotece zakresów. W tym miejscu zaprezentuję tylko, w jaki sposób zmienia się funk- cja po zaimplementowaniu zakresów i ich transformacji. Zakresy używają operatora | (potok), oznaczającego przetwarzanie kolekcji przez transformację (listing 1.4). Listing 1.4. Transformacja przy użyciu zakresów std::vector int count_lines_in_files(const std::vector std::string files) { return files | transform(count_lines); } Powyższy kod wykonuje to samo co listing 1.3, lecz jest łatwiejszy do zrozumienia. Pobierasz listę wejściową, przekazujesz ją do transformacji i zwracasz wynik. Ta forma zapisu ulepsza także zarządzanie kodem. Być może zauważyłeś, że funkcja count_lines ma wadę konstrukcyjną. Jeśli spojrzysz tylko na jej nazwę i typ (count_ lines: std::string - int), zobaczysz, że funkcja przyjmuje łańcuch, ale nie jest Kup książkęPoleć książkę 24 ROZDZIAŁ 1. Wprowadzenie do programowania funkcyjnego Notacja w celu określenia typu funkcji Język C++ nie używa jednego typu w celu reprezentowania funkcji (w rozdziale 3. zapo- znasz się ze wszystkimi elementami, które C++ uważa za podobne do funkcji). Aby okre- ślić typy argumentów i typ zwracany przez funkcję bez dokładnego wskazania, jaki typ będzie ona miała w języku C++, musimy wprowadzić nową notację, niezależną od języka. Zapis f: (arg1_t, arg2_t, ..., argn_t) - wynik_t oznacza, że funkcja f przyjmuje n argu- mentów, gdzie arg1_t jest typem pierwszego argumentu, arg2_t jest typem drugiego itd. Funkcja f zwraca wartość typu wynik_t. Jeśli funkcja przyjmuje tylko jeden argument, pomijamy nawiasy wokół jego typu. W celu uproszczenia w tym zapisie unikamy również używania słów const. Na przykład jeśli stwierdzimy, że funkcja repeat ma typ (char, int) - std::string, oznacza to, że funkcja przyjmuje dwa argumenty — jeden znak i jedną liczbę całkowitą — a zwraca łańcuch. W języku C++ zostanie to zapisane w następujący sposób (druga wersja jest dostępna od wersji C++ 11): std::string repeat(char c, int count); auto repeat(char c, int count) - std::string; jasne, czy ten ciąg znaków reprezentuje nazwę pliku. Byłoby rzeczą normalną ocze- kiwać, że funkcja zlicza liczbę wierszy w podanym łańcuchu. Aby rozwiązać ten pro- blem, możesz podzielić funkcję na dwie: pierwszą open_file: std::string - std:: if-stream, która pobiera nazwę pliku i zwraca strumień pliku; oraz drugą count_li nes: std::ifstream - int, która zlicza liczbę wierszy w danym strumieniu. Dzięki tej zmianie jest oczywiste, czego dotyczą nazwy argumentów i użyte typy. Zmiana funkcji count_lines_in_files opartej na zakresach wymaga dodania tylko jednej dodat- kowej transformacji (listing 1.5). Listing 1.5. Zmodyfikowana transformacja wykorzystująca zakresy std::vector int count_lines_in_files(const std::vector std::string files) { return files | transform(open_file) | transform(count_lines); } To rozwiązanie jest dużo bardziej zwięzłe niż algorytm imperatywny z listingu 1.1 i znacznie prostsze do zrozumienia. Zaczynasz od zbioru nazw plików (nie ma zna- czenia, jakiego typu kolekcji używasz), a następnie wykonujesz dwie transformacje dla każdego elementu z tej kolekcji. Najpierw pobierasz nazwę pliku i tworzysz strumień na jej podstawie, a w dalszej kolejności przetwarzasz go, aby zliczyć znaki nowego wiersza. Dokładnie to reprezentuje powyższy kod — bez zbędnej składni i nadmia- rowej treści. 1.2. Funkcje czyste Jednym z najbardziej znaczących źródeł błędów w oprogramowaniu jest stan programu. Trudno jest śledzić wszystkie możliwe stany, w których program może się znajdować. Paradygmat programowania obiektowego daje możliwość umieszczania części stanów Kup książkęPoleć książkę 1.2. Funkcje czyste 25 w obiektach, co ułatwia zarządzanie. Nie zmniejsza to jednak znacząco liczby moż- liwych stanów. Załóżmy, że tworzysz edytor tekstu, a w zmiennej przechowujesz tekst napisany przez użytkownika. W pewnym momencie użytkownik klika przycisk Zapisz, a następnie kontynuuje swoją pracę. Program zapisuje tekst na nośniku, wysyłając na niego po jednym znaku naraz (jest to nieco uproszczone podejście, ale proszę o chwilę cierpli- wości). Co się stanie, gdy użytkownik zmieni część tekstu w czasie trwania zapisu na dysk? Czy program zapisze tekst taki, jaki był w momencie, gdy użytkownik kliknął przycisk Zapisz, czy też zapisze bieżącą wersję, a może zrobi coś innego? Problem polega na tym, że wszystkie trzy przypadki są możliwe. Odpowiedź na zadane pytanie będzie zależeć od postępu operacji zapisu i od tego, która część tek- stu uległa zmianie. W przypadku przedstawionym na rysunku 1.2 program zapisze tekst, który nigdy nie był w edytorze. Rysunek 1.2. Jeśli zezwolisz użytkownikowi na modyfikowanie tekstu podczas jego zapisywania, mogą zostać zapamiętane niepełne lub nieprawidłowe dane, tworząc uszkodzony plik Niektóre fragmenty zapisanego pliku będą pochodzić z tekstu przed zmianą, a inne będą zawierać tekst po modyfikacji. Dane z dwóch różnych stanów zostaną zapisane w tym samym czasie. Problem ten nie istniałby, gdyby funkcja zapisująca posiadała własną, niemutowalną kopię danych, które powinna zapisać (patrz rysunek 1.3). Największym problemem stanu mutowalnego jest to, że tworzy zależności między częściami programu, które nie muszą mieć ze sobą nic wspólnego. Powyższy przykład obejmuje dwie wyraźnie oddzielne czynności użytkownika: zapamiętywanie wprowadzanego tekstu i jego wpisywanie. Powinny być one możliwe do wykonania niezależnie od siebie. Istnienie wielu działań, które mogą być wykonywane w tym samym czasie i które współdzielą stan mutowalny, tworzy zależność między nimi i powoduje powstanie problemów podobnych do tych właśnie opisanych. Kup książkęPoleć książkę 26 ROZDZIAŁ 1. Wprowadzenie do programowania funkcyjnego Rysunek 1.3. Jeśli utworzysz pełną kopię lub użyjesz struktury, która może zapamiętać w tym samym czasie wiele wersji danych, będziesz mógł rozdzielić procesy zapisywania pliku i zmiany tekstu w edytorze tekstowym Michael Feathers, autor książki Praca z zastanym kodem. Najlepsze techniki (Helion, 2017), powiedział: „Programowanie obiektowe sprawia, że kod jest zrozumiały dzięki hermetyzacji zmieniających się elementów. Programowanie funkcyjne sprawia, że kod jest zrozumiały poprzez minimalizację zmieniających się elementów”. Wynika z tego, że nawet lokalne zmienne mutowalne mogą być uznane za nieodpowiednie. Tworzą one zależności między różnymi częściami funkcji, utrudniając jej podział na kilka mniejszych. Jedną z najpotężniejszych idei zawartych w programowaniu funkcyjnym są funkcje czyste (ang. pure functions): to funkcje, które wykorzystują (ale nie modyfikują) argu- menty przekazane im w celu obliczenia wyniku. Jeśli funkcja czysta jest wywoływana wiele razy z tymi samymi argumentami, musi zawsze zwracać ten sam wynik i nie pozo- stawiać śladu, że została kiedykolwiek wywołana (brak efektów ubocznych). To wszystko oznacza, że funkcje czyste nie są w stanie zmienić stanu programu. Jest to świetny pomysł, ponieważ nie musisz myśleć o stanie programu. Niestety, oznacza to również, że funkcje czyste nie mogą czytać ze standardowego wejścia, zapi- sywać do standardowego wyjścia, tworzyć ani usuwać plików, wstawiać wierszy do bazy danych itd. Gdybyśmy chcieli przesadnie egzekwować niemutowalność, musieli- byśmy nawet zabraniać funkcjom czystym zmieniania rejestrów procesora, komórek pamięci lub czegokolwiek innego znajdującego się na poziomie sprzętu. Sprawia to, że podana przed chwilą definicja funkcji czystych staje się bezużyteczna. Procesor wykonuje instrukcje jedną po drugiej i musi śledzić, która z ich powinna zostać wykonana w następnej kolejności. Nie można wykonać niczego w komputerze bez zmieniania przynajmniej wewnętrznego stanu procesora. Ponadto nie można pisać przydatnych programów, jeśli nie można się komunikować z użytkownikiem lub innym oprogramowaniem. Z tego powodu zmniejszymy wymagania i udoskonalimy naszą definicję: funkcją czystą jest każda funkcja, która nie wykazuje widocznych (na poziomie wyższym) efektów ubocznych. Kod wywołujący funkcję nie powinien zauważyć żadnego śladu, że funk- cja została wykonana, poza uzyskaniem wyniku wywołania. Nie będziemy ograniczać Kup książkęPoleć książkę 1.2. Funkcje czyste 27 się do używania i tworzenia wyłącznie funkcji czystych, ale spróbujemy ograniczyć liczbę nieczystych, z których korzystamy. 1.2.1. Unikanie stanu mutowalnego Zaczęliśmy mówić o stylu programowania funkcyjnego, rozważając imperatywną imple- mentację algorytmu, który zlicza znaki nowego wiersza w zbiorze plików. Funkcja, która zlicza znaki nowego wiersza, powinna zawsze zwracać tę samą tablicę liczb całkowitych, gdy zostanie wywołana z tą samą listą plików (pod warunkiem, że pliki nie zostały zmie- nione przez obiekt zewnętrzny). Oznacza to, że funkcja może być zaimplementowana jako funkcja czysta. Przyglądając się początkowej implementacji tej funkcji z listingu 1.1, możemy zauwa- żyć kilka instrukcji, które są nieczyste: for (const auto file: files) { int line_count = 0; std::ifstream in(file); while (in.get(c)) { if (c == \n ) { line_count++; } } results.push_back(line_count); } Wywołanie metody .get dla strumienia wejściowego powoduje jego zmianę, a także modyfikuje wartość zapisaną w zmiennej c. Kod zmienia tablicę results, dodając do niej nowe wartości, i modyfikuje wartość zmiennej line_count poprzez jej inkremen- tację (na rysunku 1.4 pokazano zmiany stanu podczas przetwarzania pojedynczego pliku). Ta funkcja zdecydowanie nie jest zaimplementowana w czysty sposób. Rysunek 1.4. Podczas wyznaczania liczby znaków nowego wiersza występujących w jednym pliku modyfikowanych jest kilka niezależnych zmiennych. Pewne zmiany są zależne od innych, a niektóre nie są zależne Nie jest to jednak jedyne pytanie, które musisz sobie zadać. Inną ważną kwestią jest to, czy zanieczyszczenia danej funkcji są obserwowalne z zewnątrz. Wszystkie zmienne mutowalne w tej funkcji są lokalne — nie są nawet współdzielone między możliwymi współbieżnymi wywołaniami funkcji — i nie są widoczne dla kodu wywołującego ani Kup książkęPoleć książkę 28 ROZDZIAŁ 1. Wprowadzenie do programowania funkcyjnego żadnego zewnętrznego obiektu. Użytkownicy tej funkcji mogą uważać ją za czystą, nawet jeśli jej implementacja taka nie jest. Przynosi to korzyści dla kodu wywołującego, ponieważ może on polegać na tym, że nie zmieniasz stanu funkcji, choć wciąż musisz zarządzać swoim własnym stanem. Czyniąc to, powinieneś upewnić się, że nie zmie- niasz niczego, co nie należy do Ciebie. Naturalnie byłoby lepiej, gdybyś ograniczył zmienne stanu i starał się, by implementacja funkcji była jak najbardziej czysta. Jeśli upewnisz się, że w swojej implementacji używasz tylko funkcji czystych, nie będziesz musiał się zastanawiać, czy nie masz do czynienia z żadnymi zmianami stanu, ponie- waż nic nie modyfikujesz. Drugie rozwiązanie (listing 1.2) umieszcza proces zliczania w funkcji o nazwie count_lines. Ta funkcja również wygląda z zewnątrz jak czysta, nawet jeśli wewnętrznie deklaruje strumień wejściowy i modyfikuje go. Niestety, ze względu na interfejs pro- gramowy klasy std::ifstream, jest to najlepsze rozwiązanie, jakie możesz uzyskać: int count_lines(const std::string filename) { std::ifstream in(filename); return std::count( std::istreambuf_iterator char (in), std::istreambuf_iterator char (), \n ); } Na tym etapie nie poprawiamy funkcji count_lines_in_files w jakikolwiek znaczący sposób. Przenosimy jedynie część zanieczyszczeń do innego miejsca i zachowujemy dwie zmienne mutowalne. W przeciwieństwie do count_lines, funkcja count_lines_in_files nie obsługuje operacji wejścia i wyjścia. Została ona również zaimplementowana tylko w powiązaniu z funkcją count_lines, którą Ty (tworząc kod wywołujący) możesz uznać za czystą. Nie ma powodu, by zawierała jakieś nieczyste fragmenty. Kolejna wersja kodu, która korzysta z notacji zakresów, implementuje funkcję count_lines_in_files bez wyko- rzystania żadnego lokalnego stanu (mutowalnego lub niemutowalnego). Cała funkcja została zdefiniowana z punktu widzenia wywołań innej funkcji dla określonych danych wejściowych: std::vector int count_lines_in_files(const std::vector std::string files) { return files | transform(count_lines); } Przedstawione rozwiązanie jest doskonałym przykładem stylu programowania funk- cyjnego. Jest ono krótkie i zwięzłe, a jego działanie jest oczywiste. Co więcej, sprawą oczywistą jest, że kod nie wykonuje żadnych innych czynności — nie ma widocznych efektów ubocznych. Po prostu zwraca pożądane dane wyjściowe dla określonych danych wejściowych. Kup książkęPoleć książkę 1.3. Myślenie w sposób funkcjonalny 29 1.3. Myślenie w sposób funkcjonalny Tworzenie kodu najpierw w stylu imperatywnym, a następnie stopniowe zmienianie go, aż stanie się funkcyjny, byłoby działaniem nieefektywnym i nieproduktywnym, dlatego też powinieneś w inny sposób zastanawiać się nad rozwiązaniem problemów. Zamiast myśleć o krokach algorytmu, powinieneś rozważyć, czym są dane wejściowe, jakie powinny być wyniki i jakie transformacje należy wykonać, aby odwzorować wej- ście na wyjście. Na rysunku 1.5 zaprezentowano listę nazw plików, a Twoim zadaniem jest obliczenie liczby wierszy znajdujących się w każdym z nich. Pierwszą rzeczą, którą powinno się wziąć pod uwagę, jest to, że można uprościć ten problem, przetwarzając w danym momencie tylko pojedynczy plik. Masz listę nazw plików, ale możesz przetwarzać każdy z nich niezależnie od pozostałych. Jeśli możesz znaleźć sposób rozwiązania problemu dla pojedynczego pliku, możesz także łatwo rozwiązać zadanie pierwotne (rysunek 1.6). Rysunek 1.5. Myśląc funkcjonalnie, rozważasz transformacje, które musisz zastosować dla danych wejściowych, aby uzyskać pożądany wynik w danych wyjściowych Rysunek 1.6. Tę samą transformację możesz wykonać dla każdego elementu z kolekcji. Pozwala to zająć się prostszym problemem transformacji pojedynczego elementu zamiast całej ich kolekcji Obecnie głównym problemem jest zdefiniowanie funkcji, która pobiera nazwę pliku i wyznacza liczbę wierszy w pliku reprezentowanym przez tę nazwę. Wynika z tego, że otrzymujesz pewną daną (nazwę pliku), lecz potrzebujesz czegoś innego (zawartości pliku, by można było policzyć liczbę znaków nowego wiersza). Dlatego potrzebna jest funkcja, która może zwrócić zawartość pliku, jeśli zostanie podana jego nazwa. Od Ciebie zależy, czy zawartość ma zostać zwrócona jako ciąg znaków, strumień plikowy, czy jesz- cze coś innego. Kod musi po prostu być w stanie udostępniać tylko jeden znak naraz, aby można było go przekazać do funkcji, która zlicza liczbę wierszy. Gdy masz już funkcję, która zwraca zawartość pliku (std::string → std::ifstream), możesz wywołać inną funkcję, która zlicza wiersze na podstawie otrzymanego wcze- śniej wyniku (std::if-stream → int). Złożenie tych dwóch funkcji poprzez przekazanie Kup książkęPoleć książkę 30 ROZDZIAŁ 1. Wprowadzenie do programowania funkcyjnego strumienia ifstream utworzonego przez pierwszą z nich jako danej wejściowej dla drugiej daje pożądaną funkcjonalność (patrz rysunek 1.7). Rysunek 1.7. Możesz podzielić większy problem, polegający na zliczaniu liczby wierszy w pliku o znanej nazwie, na dwa mniejsze problemy: otwieranie pliku, mając podaną jego nazwę, a następnie zliczanie w nim liczby wierszy Dzięki temu pomysłowi rozwiązałeś problem. Musisz podnieść dwie funkcje, aby móc obsługiwać nie tylko pojedynczą wartość, lecz także zbiór tych wartości. Jest to koncepcyjnie równoznaczne z tym, co realizuje algorytm std::transform (z bardziej skomplikowanym interfejsem API): przyjmuje funkcję, która może zostać użyta z poje- dynczą wartością, a następnie tworzy transformację, która może działać na całym zbio- rze wartości (patrz rysunek 1.8). Na razie traktuj podnoszenie (ang. lifting) jako ogólny sposób przekształcania funkcji, które wykorzystują prosty typ danych, do funkcji obsłu- gujących bardziej złożone struktury danych zawierające wartości tego typu. Podno- szenie zostanie dokładniej omówione w rozdziale 4. Rysunek 1.8. Za pomocą algorytmu transform można tworzyć funkcje, które potrafią przetwarzać kolekcje elementów pochodzących z funkcji mogących przetwarzać tylko jeden element naraz Na tym prostym przykładzie zaprezentowano funkcyjne podejście pozwalające na dzie- lenie większych problemów programistycznych na mniejsze, niezależne zadania, które łatwo można złożyć. Przydatną analogią związaną ze złożeniem i podniesieniem funkcji Kup książkęPoleć książkę 1.4. Korzyści wynikające z programowania funkcyjnego 31 jest ruchoma linia montażowa (rysunek 1.9). Na początku mamy surowiec, z którego zostanie wytworzony produkt końcowy. Ten materiał przechodzi przez maszyny, które go przekształcają, by wreszcie otrzymać produkt finalny. Dzięki linii montażowej myślisz o transformacjach, przez które przechodzi produkt, zamiast o działaniach, które musi wykonać maszyna. Rysunek 1.9. Składanie i podnoszenie funkcji można porównać do ruchomej linii montażowej. Różne transformacje działają z pojedynczymi elementami. Podnosząc te transformacje, by mogły przetwarzać kolekcje elementów, i składając je w taki sposób, aby wynik jednej transformacji został przekazany do następnej, otrzymasz linię montażową, która stosuje serię transformacji dla dowolnej liczby elementów W tym przypadku surowiec jest daną wejściową, którą otrzymujesz, a maszyny są funkcjami zastosowanymi dla tej danej. Każda funkcja jest wysoce wyspecjalizowana w wykonywaniu jednego prostego zadania i nie „interesuje” jej reszta linii montażowej. Wymaga ona tylko prawidłowych danych wejściowych; nie obchodzi jej jednak, skąd one pochodzą. Elementy wejściowe są umieszczane jeden po drugim na linii monta- żowej (możesz także mieć wiele linii montażowych, które umożliwiają równoległe prze- twarzanie większej liczby elementów). Każda dana jest transformowana i w rezultacie otrzymujesz kolekcję przekształconych elementów. 1.4. Korzyści wynikające z programowania funkcyjnego Różne aspekty programowania funkcyjnego zapewniają uzyskiwanie różnych korzyści. Omówimy je we właściwym czasie; zacznijmy jednak od przedstawienia kilku pod- stawowych korzyści, które chcielibyśmy uzyskać w większości przypadków. Najbardziej oczywistą sprawą, którą większość osób zauważa po rozpoczęciu wdra- żania programów w stylu funkcjonalnym, jest to, że kod staje się znacznie krótszy. Niektóre projekty nawet zawierają w kodzie oficjalne adnotacje, takie jak: „Mógłby to być jeden wiersz w języku Haskell”. Dzieje się tak, ponieważ narzędzia oferowane przez programowanie funkcyjne są proste, lecz jednocześnie bardzo ekspresyjne, a większość Kup książkęPoleć książkę 32 ROZDZIAŁ 1. Wprowadzenie do programowania funkcyjnego funkcjonalności można zaimplementować na wyższym poziomie bez przejmowania się kłopotliwymi szczegółami. Ta cecha w połączeniu z czystością sprawiła, że styl programowania funkcyjnego stał się w ostatnich latach przedmiotem zainteresowania. Czystość podnosi poziom poprawności kodu, a ekspresyjność pozwala tworzyć zwięzłe programy (w których możesz popełniać mniej błędów). 1.4.1. Zwięzłość i czytelność kodu Osoby, które programują w stylu funkcyjnym, twierdzą, że łatwiej jest zrozumieć two- rzone przez nie programy. Jest to ocena subiektywna, a programiści, którzy są przy- zwyczajeni do pisania i czytania kodu imperatywnego, mogą się z nią nie zgodzić. Obiektywnie można powiedzieć, że programy napisane w stylu funkcyjnym są krótsze i bardziej treściwe. Było to widoczne we wcześniejszym przykładzie: rozpoczynaliśmy od 20 wierszy kodu, a skończyliśmy na pojedynczym wierszu dla funkcji count_lines_ in_files i około 5 wierszach dla funkcji count_lines, która zawierała przede wszyst- kim treści narzucone przez C++ i STL. Osiągnięcie tego celu było możliwe dzięki wykorzystaniu abstrakcji wyższego poziomu dostarczonych przez funkcyjne składniki biblioteki STL. Jedną z przykrych prawd jest to, że wielu programistów języka C++ nie korzysta z abstrakcji wyższego poziomu, takich jak algorytmy biblioteki STL. Mają oni ku temu różne powody, poczynając od samodzielnego pisania bardziej wydajnego kodu, a kończąc na unikaniu tworzenia programu, którego nie będą mogli zrozumieć ich współpracownicy. Przyczyny te są czasem rzeczywiście ważne, ale nie jest tak w większości przypadków. Brak korzystania z bardziej zaawansowanych funkcji języka programowania, którego używasz, zmniejsza jego możliwości i ekspresyjność oraz sprawia, że kod staje bardziej złożony i trudniejszy w utrzymaniu. W 1987 roku Edsger Dijkstra opublikował artykuł zatytułowany „Instrukcja GOTO powinna być uważana za szkodliwą”. Opowiadał się za odrzuceniem instrukcji GOTO, która w tamtym okresie była nadużywana, na rzecz programowania strukturalnego i uży- wania konstrukcji wyższego poziomu, w tym procedur, pętli i rozgałęzień if-then-else: Nadmierne użycie instrukcji GOTO powoduje, że bardzo trudno znaleźć sensowny zestaw parametrów opisujących postęp procesu. Instrukcja GOTO w obecnym stanie jest po prostu zbyt prymitywna; staje się ona wielkim zaproszeniem do robienia bałaganu w programie.1 W wielu przypadkach również pętle i rozgałęzienia są zbyt prymitywne. Podobnie jak instrukcja GOTO, także pętle i rozgałęzienia mogą sprawić, że programy będą trud- niejsze do napisania i zrozumienia. Często można je zastąpić konstrukcjami wyższego poziomu należącymi do programowania funkcyjnego. Ten sam kod jest niejednokrot- nie używany w wielu miejscach, a programiści nawet tego nie zauważają, ponieważ 1 „Communications of the ACM”, 11, nr 3 (marzec 1968). Kup książkęPoleć książkę 1.4. Korzyści wynikające z programowania funkcyjnego 33 działa on z różnymi typami lub ma odmienne zachowanie, którego można po prostu nie brać pod uwagę. Korzystając z istniejących abstrakcji dostarczonych przez STL lub bibliotekę zewnętrzną, a także tworząc własne, możesz uczynić swój kod bezpieczniejszym i krót- szym. Ułatwisz także wyszukiwanie błędów w tych abstrakcjach, ponieważ ten sam kod zostanie wykorzystany w wielu miejscach. 1.4.2. Współbieżność i synchronizacja Głównym problemem podczas tworzenia systemów współbieżnych jest współdzielony stan zmienny. Szczególnej uwagi wymaga upewnienie się, że komponenty nie prze- szkadzają sobie nawzajem. Zrównoleglenie programów wykorzystujących funkcje czyste jest operacją banalną, ponieważ funkcje te niczego nie modyfikują. Nie ma potrzeby realizowania jawnej syn- chronizacji przy użyciu operacji niepodzielnych lub muteksów. Kod napisany dla sys- temu jednowątkowego można uruchamiać w wielu wątkach bez prawie żadnych zmian. Więcej na ten temat dowiesz się w rozdziale 12. Rozważ następujący fragment kodu, który w wektorze xs sumuje pierwiastki kwa- dratowe wartości: std::vector double xs = {1.0, 2.0, ...}; auto result = sum(xs | transform(sqrt)); Jeśli implementacja funkcji sqrt jest czysta (a nie ma żadnego powodu, by tak nie sądzić), algorytm sumowania może automatycznie podzielić dane wejściowe na porcje i obliczyć dla nich sumy częściowe w oddzielnych wątkach. Kiedy wszystkie wątki się zakończą, wystarczy zebrać wyniki i je podsumować. Niestety, język C++ nie zna (jeszcze) pojęcia funkcji czystej, więc współbieżność nie może być realizowana w sposób automatyczny. Zamiast tego musisz jawnie wywołać równoległą wersję algorytmu sum. Funkcja sum może nawet w trakcie działania być w stanie wykryć liczbę rdzeni procesora i wykorzystać tę informację przy podejmowaniu decyzji, na ile fragmentów należy podzielić wektor xs. Jeśli napisałeś powyższy kod przy użyciu pętli for, nie można go uwspółbieżnić w prosty sposób. Zamiast pozostawić podejmowanie decyzji bibliotece udostępniającej algorytm sumujący, musisz się zasta- nowić, czy zmienne nie zostały zmodyfikowane w tym samym czasie przez różne wątki, a następnie wygenerować optymalną liczbę wątków dla systemu, w którym uruchomisz program. UWAGA Po rozpoznaniu, że ciała pętli są czyste, kompilatory C++ mogą czasami wyko- nywać automatyczną wektoryzację lub inne optymalizacje. Te optymalizacje wpływają wów- czas również na kod, który wykorzystuje standardowe algorytmy, ponieważ są one zazwy- czaj wewnętrznie implementowane za pomocą pętli. 1.4.3. Ciągła optymalizacja Używanie abstrakcji programowania wyższego poziomu pochodzących z STL lub innych zaufanych bibliotek ma inną wielką zaletę: Twój program będzie się stawał coraz lepszy, nawet jeśli nie zmienisz w nim żadnej instrukcji. Każde ulepszenie języka Kup książkęPoleć książkę 34 ROZDZIAŁ 1. Wprowadzenie do programowania funkcyjnego programowania, implementacja kompilatora lub używanej biblioteki poprawi również sam program. Chociaż to stwierdzenie dotyczy zarówno funkcyjnych, jak i niefunkcyj- nych abstrakcji wyższego poziomu, użycie programowania funkcyjnego znacznie zwięk- sza ilość kodu, który może je wykorzystywać. Wydaje się to oczywiste, ale wielu programistów woli samodzielnie tworzyć nisko- poziomowy kod o wysokim poziomie wydajności, czasami nawet w asemblerze. Takie podejście może przynieść korzyści, ale w większości przypadków po prostu optymali- zuje kod dla konkretnej platformy docelowej i uniemożliwia kompilatorowi zoptyma- lizowanie go dla innej. Rozważmy funkcję sum. Możesz ją zoptymalizować pod kątem systemu, który wstęp- nie wczytuje instrukcje, i sprawić, że wewnętrzna pętla będzie używać w każdej iteracji dwóch elementów lub ich większej liczby zamiast sumować liczby po kolei. Zmniejszy to liczbę skoków w kodzie, więc procesor będzie częściej pobierał poprawne instrukcje. To oczywiście poprawiłoby wydajność platformy docelowej. Ale co się stanie, jeśli uruchomisz ten sam program na innej platformie? W przypadku niektórych platform optymalna będzie pętla oryginalna; dla innych lepszym rozwiązaniem będzie sumo- wanie większej liczby pozycji przy każdej iteracji pętli. Niektóre systemy mogą nawet udostępnić określoną instrukcję procesora, która wykona dokładnie to, czego potrze- buje funkcja. Samodzielnie optymalizując kod w powyższy sposób, nie zrealizujesz zamierzo- nego zadania dla wszystkich platform oprócz jednej. Jeśli używasz abstrakcji wyższe- go poziomu, polegasz na innych programistach, którzy tworzą kod zoptymalizowany. Większość implementacji STL zapewnia istnienie określonych optymalizacji dla plat- form i używanych na nich kompilatorów. 1.5. Przekształcanie C++ w funkcyjny język programowania C++ narodził się jako rozszerzenie języka programowania C w celu umożliwienia programistom tworzenia kodu zorientowanego obiektowo (początkowo nazywał się on „C z klasami”). Nawet w jego pierwszej standardowej wersji (C++ 98) trudno było go nazwać językiem zorientowanym obiektowo. Dzięki wprowadzeniu szablonów i utwo- rzeniu biblioteki STL, która rzadko korzysta z dziedziczenia i metod wirtualnych, język C++ stał się językiem multiparadygmatycznym. Biorąc pod uwagę projekt i implementację biblioteki STL, można nawet argumen- tować, że C++ nie jest przede wszystkim językiem obiektowym, lecz językiem pro- gramowania generycznego. Programowanie generyczne opiera się na założeniu, że można stworzyć kod, który wykorzystuje ogólne pojęcia, a następnie zastosować go do dowolnej struktury, która pasuje do tych pojęć. Biblioteka STL, przykładowo, udostęp- nia szablon vector, którego można używać z różnymi typami, takimi jak liczby całko- wite, łańcuchy oraz typy zdefiniowane przez użytkowników spełniające określone warunki wstępne. Kompilator generuje następnie zoptymalizowany kod dla każdego z określonych typów. Taką funkcjonalność nazywa się zwykle polimorfizmem sta- Kup książkęPoleć książkę 1.5. Przekształcanie C++ w funkcyjny język programowania 35 tycznym lub polimorfizmem czasu kompilacji, w przeciwieństwie do polimorfizmu dynamicznego lub polimorfizmu czasu wykonania dostępnego przez dziedziczenie i metody wirtualne. W przypadku programowania funkcyjnego w języku C++ znaczenie szablonów nie polega (głównie) na używaniu klas kontenerowych, takich jak wektory, lecz na tym, że możliwe stało się stworzenie algorytmów STL, czyli zestawu wspólnych wzorców algorytmicznych, takich jak sortowanie i zliczanie. Większość z tych algorytmów pozwala na przekazywanie im niestandardowych funkcji w celu dostosowywania działania bez wykorzystywania wskaźników do funkcji i konstrukcji void*. W ten sposób możesz na przykład zmienić kolejność sortowania czy też określić, które elementy powinny być uwzględniane przy liczeniu. Możliwość przekazywania funkcji jako argumentów do innej funkcji oraz posiada- nia funkcji, które zwracają nowe funkcje (lub dokładniej mówiąc, konstrukcje, które wyglądają jak funkcje, co omówimy w rozdziale 3.), spowodowała powstanie znormali- zowanej wersji C++ jako języka funkcyjnego. Wersje C++ 11, C++ 14 i C++ 17 wprowadziły sporo możliwości, które znacznie ułatwiają pisanie programów w stylu funkcyjnym. Dodatkowe opcje to głównie lukier składniowy — jest on jednak ważny i wyraża się w postaci słowa kluczowego auto oraz wyrażenia lambda (te konstrukcje omówimy w rozdziale 3.). Opcje te przyniosły również znaczne ulepszenia zestawu algorytmów standardowych. Następna wersja standardu jest planowana na 2020 rok i oczekuje się, że wprowadzi ona jeszcze więcej możliwości inspirowanych programo- waniem funkcyjnym, takich jak zakresy, koncepcje i współprogramy, które są obecnie zawarte w specyfikacji technicznej. Ewolucja standardu ISO C++ Język programowania C++ jest standardem ISO. Każda nowa wersja przed wydaniem jest poddawana rygorystycznemu procesowi. Język podstawowy i biblioteka standardowa są opracowywane przez komisję, więc każda nowa funkcja jest szczegółowo omawiana i zatwierdzana, zanim stanie się częścią ostatecznej propozycji dla nowej wersji standardu. Gdy wreszcie wszystkie zmiany zostaną dołączone do definicji standardu, musi on zostać poddany kolejnemu, ostatecznemu głosowaniu, które ma miejsce w przypadku każdego nowego standardu ISO. Od 2012 roku komitet dzieli swoje prace na podgrupy. Każda grupa zajmuje się określoną funkcją językową, która po uznaniu jej za gotową jest dostarczana jako specyfikacja techniczna. Specyfikacje techniczne nie są związane z głównym standardem i mogą później zostać do niego dołączone. Celem specyfikacji technicznej jest przetestowanie przez programistów nowych funkcji i wykrycie błędów, zanim te funkcje znajdą się w głównym standardzie. Dostawcy kompila- torów nie są zobowiązani do implementacji specyfikacji technicznej, ale zazwyczaj to robią. Więcej informacji na ten temat można znaleźć na stronie https://isocpp.org/std/status. Chociaż większość zagadnień, które omówimy w tej książce, może być wykorzystywana w starszych wersjach języka C++, skoncentrujemy się głównie na C++ 14 i C++ 17. Kup książkęPoleć książkę 36 ROZDZIAŁ 1. Wprowadzenie do programowania funkcyjnego 1.6. Czego nauczysz się w trakcie czytania tej książki? Ta książka jest skierowana przede wszystkim do doświadczonych programistów, któ- rzy codziennie używają języka C++ i chcą wykorzystywać w swojej pracy bardziej zaawansowane narzędzia. Aby uzyskać jak najwięcej korzyści z czytania tej książki, powinieneś się zapoznać z podstawowymi opcjami języka C++, takimi jak system typów C++, referencje, konstrukcje const, szablony, przeciążanie operatorów itd. Nie musisz być zaznajomiony z funkcjami wprowadzonymi w wersji C++ 14 oraz 17, które bardziej szczegółowo zostały omówione w tej książce. Te funkcje nie są jeszcze szeroko stosowane i prawdopodobnie wielu czytelników nie będzie z nimi zaznajo- mionych. Rozpoczniemy od podstawowych pojęć, takich jak funkcje wyższego rzędu, które pozwolą Ci zwiększyć ekspresyjność języka i sprawić, że Twoje programy będą krótsze. Pokażemy także, jak można zaprojektować oprogramowanie bez stanów mutowalnych, aby uniknąć problemów z jawną synchronizacją we współbieżnych systemach infor- matycznych. Następnie zmienimy bieg na wyższy i zajmiemy się bardziej zaawanso- wanymi tematami, takimi jak zakresy (prawdziwie modularna alternatywa dla algoryt- mów biblioteki standardowej) i algebraiczne typy danych (które można wykorzystać do zmniejszenia liczby stanów, w których program może się znajdować). Na koniec omówimy jeden z najczęściej wymienianych idiomów programowania funkcyjnego — mające złą sławę monady. Dowiemy się, w jakiś sposób można wykorzystać różne monady do wdrażania złożonych systemów, które można składać ze sobą. Po ukończeniu czytania tej książki będziesz w stanie zaprojektować i wdrożyć bez- pieczniejsze, współbieżne systemy, które można bez większego wysiłku skalować poziomo. Będziesz mógł także między innymi implementować program w sposób minimalizujący lub nawet uniemożliwiający użycie go w niepoprawnym stanie z powodu pojawienia się błędu, a także traktować oprogramowanie jako przepływ danych i używać najnow- szego wynalazku C++, czyli zakresów, aby zdefiniować ten przepływ. Dzięki tym umiejętnościom będziesz w stanie stworzyć terser czy też kod mniej podatny na błędy, nawet jeśli zajmujesz się systemami oprogramowania zorientowanymi obiektowo. A gdybyś chciał w pełni wykorzystać styl funkcyjny, pomoże Ci on w bardziej przej- rzysty i strukturalny sposób projektować systemy oprogramowania, co zobaczysz w rozdziale 13. podczas implementacji prostej usługi internetowej. Podsumowanie  Główną zasadą filozoficzną programowania funkcyjnego jest to, że nie powi- nieneś zajmować się sposobem, w jaki coś powinno działać, ale raczej tym, co powinno robić.  Oba style — programowanie funkcyjne i programowanie obiektowe — mają wiele do zaoferowania. Powinieneś wiedzieć, kiedy można używać tylko okre- ślonego z nich, a kiedy można je łączyć ze sobą. Kup książkęPoleć książkę Podsumowanie 37  C++ jest multiparadygmatycznym językiem programowania, którego można używać do tworzenia programów w różnych stylach — proceduralnym, obiekto- wym i funkcyjnym — a także do łączenia tych stylów z programowaniem gene- rycznym.  Programowanie funkcyjne współgra z programowaniem generycznym, szczegól- nie w przypadku języka C++. Oba style zachęcają programistów, by nie myśleli na poziomie sprzętu, lecz na wyższych poziomach abstrakcji.  Podnoszenie funkcji pozwala przekształcać funkcje działające na pojedynczych wartościach na takie, które operują na kolekcjach wartości. Dzięki złożeniu funk- cji daną wartość można przetworzyć w łańcuchu transformacji, w którym każde przekształcenie przekazuje wynik do następnego.  Unikanie stanu mutowalnego ulepsza jakość kodu i eliminuje potrzebę stoso- wania muteksów w kodzie wielowątkowym.  Podejście funkcyjne oznacza posługiwanie się danymi wejściowymi i transfor- macjami, które należy wykonać, aby uzyskać pożądany wynik. Kup książkęPoleć książkę 38 ROZDZIAŁ 1. Wprowadzenie do programowania funkcyjnego Kup książkęPoleć książkę Skorowidz C ciąg Fibonacciego, 148 ciągła optymalizacja, 33 częściowe stosowanie funkcji, 90, 92, 109 czytelność kodu, 32 D dane mutowalne, 131 niemutowalne, 131 tylko do odczytu, 166 debugowanie, 301 typów dedukowanych, 252 dedukcja argumentów, 285 typu zwracanego, 64 deklaracja const, 134 domknięcia, 73, 75 dopasowywanie do wzorców, 215, 218, 254 t
Pobierz darmowy fragment (pdf)

Gdzie kupić całą publikację:

Programowanie funkcyjne w języku C++. Tworzenie lepszych aplikacji
Autor:

Opinie na temat publikacji:


Inne popularne pozycje z tej kategorii:


Czytaj również:


Prowadzisz stronę lub blog? Wstaw link do fragmentu tej książki i współpracuj z Cyfroteką: