Scala, statycznie typowany, funkcyjny język programowania, jest na rynku już 20 lat (świętujemy urodziny Scali, sprawdź #scalaversary). W tym czasie wykształciło się kilka głównych podejść do pisania kodu w Scali. Obejmują one używanie systemów efektów, technik wykonania kodu asynchronicznego oraz korzystanie z różnych bibliotek i frameworków.
Pisanie aplikacji w Scali to nie tylko wybór technologii, ale również przestrzeganie uniwersalnych zasad, pomimo tego, iż w samym języku mamy do czynienia z różnorodnymi podejściami. Niezależnie od używanego stosu technologicznego, pewne praktyki są zalecane, a innych należy unikać.
Te uniwersalne zasady wynikają z trzech filarów: samego języka, biblioteki standardowej oraz społeczności skupionej wokół Scali. Przyjrzymy się im bliżej, zwracając uwagę na różnice między Scalą a Javą. Takie podejście pozwoli Ci zrozumieć, co sprawia, iż Scala jest wyjątkowa i jak można tworzyć lepsze aplikacje, korzystając z jej możliwości.
Niemutowalne struktury danych
Jedną z najbardziej fundamentalnych i istotnych cech kodu napisanego w Scali jest niemutowalność struktur danych. Istnieje wiele korzyści z takiego podejścia (oraz pewne wady):
- dane nie mogą być modyfikowane „gdzieś indziej” przez „kogoś innego”
- stan zewnętrzny względem funkcji nie może się zmienić, kod jest łatwiejszy do zrozumienia; nazywa się to także lokalnym wnioskowaniem
- odporność na zniekształcenie danych, ponieważ nie jest możliwa równoległa modyfikacja danych
- większe bezpieczeństwo wątków i ogólnie prostsze programowanie współbieżne
Inne platformy programistyczne również preferują niemutowalne struktury danych, jeżeli chodzi o programowanie współbieżne. Scala robi to jednak standardowo, co może być powodem, dla którego jest tak dobrze przystosowana do programowania współbieżnego, które jest jedną ze scalowych nisz.
Scala wspiera niemutowalne struktury danych na wielu poziomach. Po pierwsze, mamy konstrukcje bezpośrednio w języku, takie jak case classes, pola klas, które domyślnie są stałymi (a nie zmiennymi), enums (w Scala 3) i ADTs (algebraiczne struktury danych). Innym ważnym aspektem jest łatwość tworzenia zmodyfikowanych struktur danych dzięki automatycznie generowanych metod kopiujących.
Ale co równie ważne, biblioteka standardowa jest zbudowana wokół niemutowalnych struktur danych — jeżeli potrzebujesz użyć Set, List, lub Map, domyślnie otrzymasz wersję niemutowalną.
Wreszcie, większość bibliotek Scali w swoim publicznym API konsumuje i produkuje niemutowalne struktury danych. To po prostu sposób na obsługę danych w Scali. Oczywiście, przez cały czas możesz używać mutowalnych struktur danych, jeżeli jest to potrzebne. Ale pamiętaj, iż w świecie Scali jest to bardziej wyjątek od zasady, niż standardowa praktyka.
Nic nie stoi na przeszkodzie, by używać niemutowalnych struktur danych na innych platformach, w tym node.js, Java czy .NET. Jednak wsparcie językowe w tych przypadkach jest ograniczone. Co więcej, standardowe biblioteki i te często wykorzystywane przez społeczności programistów zwykle opierają się na danych, które można zmieniać. Choć niemutowalność jest technicznie możliwa, w praktyce może okazać się dość niepraktyczna.
Na przykład, prawie wszystkie biblioteki Javy używają mutowalnych interfejsów kolekcji z biblioteki standardowej. jeżeli używasz biblioteki Javy, prawie na pewno będzie ona korzystać ze standardowych, mutowalnych kolekcji. To coś, co będzie prawie niemożliwe do zmiany: Java pozostanie językiem preferującym mutowalność.
Wyrażenia i wartości
Cechą Scali, która może nie wyróżnia się na pierwszy rzut oka, ale gwałtownie staje się niezbędna, jest to, iż wszystko w tym języku jest wyrażeniem. Gdy już się do tego przyzwyczaisz, pisanie w języku, w którym instrukcje i wyrażenia są oddzielone, będzie się wydawać uciążliwe i niepotrzebnie ograniczające.
Jest to dowód elastyczności Scali i wszechstronnej składni tego języka. W kontekście trzech filarów, które zdefiniowaliśmy na początku, orientacja na wyrażenia jest cechą czysto językową.
Pokrewną praktyką w Scali jest reprezentowanie różnych koncepcji jako wartości. Zaczynając od funkcji: składnia do definiowania wartości-funkcji jest bardzo lekka. W Scali po prostu czuje się to naturalnie. I jest to szeroko wykorzystywane zarówno przez bibliotekę standardową, która używa funkcji wyższego rzędu (czyli takich, które przyjmują inne funkcje jako parametry lub zwracają funkcje), jak również przez cały ekosystem, który korzysta z tego podejścia.
Ale nie kończy się to na funkcjach. Jedną z głównych zalet Scali jest jej elastyczność w definiowaniu abstrakcji. Często przekłada się to na możliwość reprezentowania różnych koncepcji jako wartości. Może to być nieco zaskakujące, ale dodanie takiego poziomu abstrakcji otwiera interesujące nowe możliwości.
Jako przykład weźmy funkcyjne systemy efektów, jak ZIO czy cats-effect. W nich całe obliczenie jest reprezentowane jako wartość. Dzięki temu otrzymujemy leniwe i kontrolowane wykonywanie kodu z efektami ubocznymi. To z kolei, w połączeniu z niestandardowym środowiskiem uruchomieniowym, umożliwia deklaratywną współbieżność, z wykorzystaniem lekkich wątków, solidnie zaprojektowanymi przerwaniami i łatwością refaktorowania.
Inny przykład z naszego własnego podwórka, to tapir i klient sttp. Oba mają do czynienia z domeną HTTP; w obu najpierw tworzysz wartość reprezentującą punkt końcowy lub żądanie HTTP. Ta wartość jest oczywiście niemutowalna, co pozwala na stopniowe udoskonalanie, jak również na oddzielenie warstw: sieciowej od domenowej.
Makra, a nie refleksja
Trzecia zasada wynika bezpośrednio ze skupienia Scali na poprawności kodu poprzez stosowanie statycznego typowania. Ponownie, będziemy głównie kontrastować „podejście Scali” z tym, co oferuje Java.
Scala jest bardzo elastyczna w definiowaniu abstrakcji, ale tylko do pewnego etapu. W pewnym momencie staniesz przed koniecznością napisania dużych fragmentów powtarzalnego kodu. Przykłady obejmują generowanie enkoderów/dekoderów JSON, tworzenie grafu obiektów dzięki wstrzykiwania zależności lub mapowanie klas na tabele bazy danych.
Pytanie brzmi: jak definiujemy ten proces generowania kodu? Jakiego języka używamy i jak użytkownicy mogą zdefiniować, co powinno być wygenerowane?
W Javie takie zadania zwykle wykonuje się dzięki adnotacji, które następnie są odczytywane przy użyciu refleksji w czasie wykonywania, dzięki frameworka takiego jak Spring. Framework ten działa wtedy jako interpreter, generując bytecode lub instancjonując predefiniowane klasy. Moim zdaniem adnotacje są nadużywane w Javie i w rzeczywistości tworzą równoległy, ograniczony i niebezpieczny język programowania, z niedookreślonymi, ad hoc interpreterami.
Jaka jest więc alternatywa? W Scali odpowiedzią jest generowanie kodu w czasie kompilacji. W najbardziej powszechnej formie derywacja sterowana typami jest znana jako implicits (w Scali 2) lub givens (w Scali 3). Podobnie jak IDE często może wygenerować typ dla fragmentu kodu, kompilator Scali może wygenerować kod na podstawie typu. Doświadczenie programisty wokół tej funkcji zostało ulepszone w Scali 3; dla lepszego obrazu sytuacji, zobacz tę prezentację Magdy Stożek.
Ale Scala nie kończy na tym — bezpośrednim zamiennikiem dla generowania kodu opartego na adnotacjach są inlines i makra. Pozwalają one na generowanie kodu Scali w czasie kompilacji, zgodnie z type-safety, podczas korzystania z pełnego języka Scali do definiowania procesu i bezproblemowej integracji z innymi funkcjami Scali.
Adnotacje mogą przez cały czas być używane do kierowania makrem i dostarczania dodatkowych metadanych — ale to tylko jedna z opcji; badanie typów i kodu przekazanych do makra to kolejna. Istnieje wiele przykładów użycia makr w Scali, na przykład biblioteka jsoniter-scala lub macwire do wstrzykiwania zależności w czasie kompilacji.
Makra często są uważane za trudne do zrozumienia. I choć pisanie makra może być rzeczywiście dalekie od trywialnego, to samo dzieje się — być może choćby w większym stopniu — gdy mowa o pisaniu procesorów adnotacji czy generatorów kodu bajtowego.
Używanie makr to inna historia; zwykle nie różni się to od wywołania metody. Fakt, iż komunikaty o błędach nie zawsze są doskonałe, jest problemem, ale to kwestia dojrzewania ekosystemu, a nie fundamentalna wada całego podejścia.
Chociaż generowanie kodu oparte na refleksji, kierowane adnotacjami, przez cały czas dominuje w ekosystemie Javy, są sygnały, iż przesuwa się to w kierunku generacji w czasie kompilacji. Metamodel Hibernate’a to jeden z przykładów (używa procesorów adnotacji). Jednak niska ekspresyjność adnotacji jako podjęzyka pozostaje problemem. Innym przykładem jest wtyczka do serializacji w Kotlinie.
Jasno, gdy jest to konieczne; niejawnie, gdy nie
Ostatnia zasada nie wynika bezpośrednio z projektu Scali czy jej biblioteki standardowej, a raczej z „kultury” programowania funkcyjnego ogólnie i społeczności Scali w szczególności.
Często widzimy pokusę wprowadzania różnych form „magii” niemal na każdej platformie. Intencje są dobre: chcemy automatyzacji i rzeczy dziejących się automatycznie. Jednak problemy zaczynają się, gdy jako użytkownicy tej „magii” nie możemy dokładnie określić i zrozumieć, co się stanie, kiedy i dlaczego. Jest to w sprzeczności z przewidywalnością i możliwością eksploracji naszego kodu, co jest istotnym czynnikiem w dostarczaniu utrzymywalnego oprogramowania.
Doskonałym przykładem takiej „magii” jest skanowanie classpath i „auto-discovery”, które warunkowo włącza pewne komponenty, w zależności od ich obecności na classpath. Jest to rozwiązanie prawie całkowicie nieużywane w aplikacjach pisanych dzięki Scali.
Chcesz, aby zachowanie Twojego systemu operacyjnego było modyfikowane tylko na podstawie faktu, iż coś pobrałeś z Internetu? Jednak to właśnie robi „magia” skanowania classpath i auto-instrumentacji. W Scali wolimy jawnie włączać funkcje, choćby jeżeli to oznacza trzy dodatkowe linie kodu.
Innym przykładem jest to, iż aplikacje Scali zawsze jawnie definiują metody „main”. Z jakiegoś powodu, kod Java często nie ma tak jasno zdefiniowanego punktu wejścia, zamiast tego polega na naszych starych dobrych znajomych: adnotacjach i refleksji. Może to Java jest tutaj unikatowa, albo to Scala przywraca pewien porządek do sytuacji – nie jestem pewien. Ale na pewno nie ma powodu, by obawiać się pisania metody “main”.
Skąd to się bierze? Pisząc kod w Scali, czy ogólnie używając stylu „funkcyjnego”, pracujemy z funkcjami, które mogą przyjmować inne funkcje jako parametry i zwracać wartości, które następnie przekazujemy dalej do innych funkcji. W końcu o to chodzi w programowaniu funkcyjnym: traktowanie funkcji jako pełnoprawnych wartości.
W konsekwencji możemy bezpośrednio śledzić ścieżki kodu – używając prostej, ale bardzo skutecznej metody „przejdź do definicji” w naszych IDE.
Podsumowanie
Żadna z opisanych powyżej cech nie jest unikalna dla Scali. To ich kombinacja zapewnia tak dobry efekt. To nie tylko kwestia projektu języka; domyślne ustawienia mają znaczenie, jak pokazują niemutowalne struktury danych i niemutowalne kolekcje w bibliotece standardowej.
Posiadanie regularnej, zaskakująco małej gramatyki w języku zorientowanym na wyrażenia otwiera nowe możliwości – na przykład, do zwięzłego reprezentowania koncepcji jako wartości.
Rozszerzanie procesu kompilacji o generowanie kodu, z bezpieczeństwem zapewnianym przez system typów, jest bardziej efektywne, bezpieczne i przewidywalne w porównaniu z przetwarzaniem adnotacji w czasie wykonania.
Wreszcie, bycie jasnym co do tego, co się dzieje, kiedy i dlaczego w Twoim kodzie, zwiększa eksplorowalność i zrozumiałość naszego kodu, choćby gdy jest to konieczne do nawigacji po kodzie infrastruktury dostarczanej przez bibliotekę.
Poznaj na żywo scalową społeczność
Już 21 i 22 marca masz okazję poznać światową społeczność Scali, dołączając do konferencji Scalar w Warszawie. Scalar to największe wydarzenie skupiające praktyków programowania funkcyjnego w Europie. Sprawdź agendę oraz dzień warsztatowy! Odkryj, co może cię zainspirować i czego możesz się nauczyć.
Na hasło: JustGeekIT udostępniliśmy 10 biletów z 10% zniżką. Kto pierwszy ten lepszy, kod należy podać w momencie rejestracji.
Zdjęcie główne pochodzi z Unsplash.com.