Читать книгу Programowanie funkcyjne w języku C#. Jak pisać lepszy kod - Enrico Buonanno - Страница 16
1.1.3 Pisanie programów o silnych gwarancjach
ОглавлениеZ dwóch omówionych właśnie pojęć, funkcje jako elementy pierwszoklasowe wydają się z początku bardziej ekscytujące i na nich skoncentrujemy się w dalszej części rozdziału. Ale zanim przejdziemy dalej, chciałbym w skrócie pokazać, dlaczego unikanie zmiany stanu daje ogromnie korzyści, eliminując wiele złożoności wprowadzanych przez mutowanie stanu.
Popatrzmy na przykład. (Wrócimy do tych tematów bardziej szczegółowo, nie należy się zatem martwić, jeśli nie wszystko będzie tu jasne). Wpiszmy do REPL podany niżej kod.
using static System.Linq.Enumerable;
To pozwala na wywołanie Range oraz WriteLine bez pełnej przestrzeni nazw
using static System.Console;
var nums = Range(-10000, 20001).Reverse().ToList();
// => [10000, 9999, ... , -9999, -10000]
Action task1 = () => WriteLine(nums.Sum());
Action task2 = () => { nums.Sort(); WriteLine(nums.Sum()); };
Parallel.Invoke(task1, task2);
Wykonuje oba zadania równolegle
// wyświetla: 92332970
// 0
Listing 1.3 Zmiana stanu wynikająca z procesów współbieżnych daje nieprzewidywalne wyniki
Definiujemy tu nums jako listę liczb całkowitych z zakresu między 10000 a –10000. Ich suma powinna oczywiście wynosić 0. Następnie tworzymy dwa zadania: task1 wylicza i wyświetla sumę, a task2 najpierw sortuje listę, a potem wylicza i wyświetla sumę. Każde z tych zadań uruchomione niezależnie poprawnie wyznaczy sumę. Jeśli jednak uruchomimy je równolegle, task1 da nam niepoprawny i nieprzewidywalny wynik.
Łatwo zobaczyć, dlaczego tak jest: podczas gdy task1 czyta liczby z listy, aby wyznaczyć sumę, task2 zmienia kolejność tej samej listy. To tak, jakby ktoś próbował czytać książkę, podczas gdy ktoś inny przewracałby jej strony: czytalibyśmy jakieś pomieszane zdania! Graficznie można to pokazać jak na rysunku 1.1.
Co będzie, jeśli użyjemy metody OrderBy z LINQ, zamiast sortowania listy w miejscu?
Action task3 = () => WriteLine(nums.OrderBy(x => x).Sum());
Parallel.Invoke(task1, task3);
// wyświetla: 0
// 0
Jak widać, użycie funkcyjnej implementacji dostępnej w LINQ daje nam przewidywalny wynik, nawet jeśli wykonujemy zadania równolegle. Jest tak, gdyż zadanie task3 nie modyfikuje oryginalnej listy, ale tworzy nowy „widok” danych, które są uporządkowane – task1 i task3 jednocześnie odczytują oryginalną listę, ale odczyty te nie powodują niespójności, co widać na rysunku 1.2.
Rysunek 1.1 Modyfikacja danych w miejscu może spowodować niepoprawny widok danych w przypadku wątków współbieżnych
Ten prosty przykład ilustruje szerszą prawdę: gdy deweloperzy piszą aplikację w stylu imperatywnym (jawnie zmieniając stan programu), a potem wprowadzają współbieżność (w związku z nowymi wymaganiami lub potrzebą poprawy wydajności), nieuchronnie stają w obliczu żmudnej pracy i potencjalnie trudnych do usunięcia błędów. Gdy program od początku jest pisany w stylu funkcyjnym, współbieżność można często dodać „za darmo” lub przy znacząco mniejszym wysiłku. Szerzej omawiamy zmianę stanu i współbieżność w rozdziałach 2 i 9. Na razie wracamy do opisu FP.
Wprawdzie większość osób jest zgodnych, że traktowanie funkcji jako pierwszorzędnego elementu i unikanie zmiany stanu to fundamentalne elementy FP, jednak ich zastosowanie często pociąga za sobą wiele praktyk i technik, zatem jest dyskusyjne, które z nich należy traktować jako niezbędne i włączyć do książki takiej jak ta.
Zachęcam do pragmatycznego podejścia do tematu i próby zrozumienia FP jako zbioru narzędzi, których możemy użyć, aby wykonać nasze zadania programistyczne. W miarę poznawania tych technik zaczniemy patrzeć na problemy z innej perspektywy: zaczniemy myśleć funkcyjnie.
Gdy już mamy konkretną definicję FP, spójrzmy na sam język C# i jak obsługuje on techniki FP.
Rysunek 1.2 Podejście funkcyjne: tworzenie nowej, zmodyfikowanej wersji oryginalnej struktury
Funkcyjne czy obiektowe?Często jestem proszony o porównanie FP i programowania obiektowego (OOP). Nie jest to łatwe, gdyż istnieje wiele błędnych założeń dotyczących tego, jak powinno wyglądać OOP.Teoretycznie podstawowe zasady OOP (enkapsulacja, abstrakcja danych itd.) są ortogonalne z zasadami FP, więc nie ma powodu, aby nie połączyć ze sobą tych dwóch paradygmatów.Jednak w praktyce większość deweloperów obiektowych (OO) mocno opiera się w swojej implementacji metod na stylu imperatywnym, aktualizując stan w miejscu i stosując jawne sterowanie przepływem: używają modelu OO w ujęciu ogólnym, a programowania imperatywnego na niskim poziomie. Właściwe pytanie zatem brzmi, czy stosować programowanie imperatywne czy funkcyjne. Na końcu tego rozdziału podsumowuję zalety FP.Inne pytanie, które często się pojawia, dotyczy tego, w jakim stopniu FP różni się od OOP w sensie tworzenia struktury dużych, złożonych aplikacji. Trudna sztuka tworzenia struktury złożonych aplikacji opiera się na kilku zasadach:■ Modularność (podział oprogramowania na wielokrotnie używane komponenty).■ Separacja odpowiedzialności (każdy komponent powinien wykonywać tylko jedno zadanie).■ Podział na warstwy (komponenty wysokiego poziomu mogą być zależne od komponentów niskiego poziomu, ale nie odwrotnie).■ Luźne powiązania (zmiany w komponencie nie powinny wpływać na komponenty od niego zależne).Te zasady są ogólnie słuszne niezależnie od tego, czy komponentem jest tu funkcja, klasa czy aplikacja.Nie są one jednak w żadnym stopniu prawdziwe tylko w odniesieniu do OOP, zatem te same zasady można wykorzystać do strukturyzacji aplikacji napisanej w stylu funkcyjnym – różnica polega na tym, czym są komponenty i które API uwypuklają.W praktyce funkcyjny nacisk na czyste funkcje (które są omawiane w rozdziale 2) i zdolność do łączenia się (rozdział 5) znacznie ułatwiają osiągnięcie niektórych z tych celów projektowych2. |