Ograniczenia i zalety Haskella. Devdebata
Jakbyś porównał testowanie do expressing programmer intent via types? — W Haskellu używamy obu podejść jednocześnie, ale staramy się eliminować jak największą ilość potencjalnych problemów, zwłaszcza niezwiązanych z logiką biznesową, już na etapie projektowania typów. Dzięki temu w testach można skupić się na esencji — odpowiedział Adam Szlachta, Software Engineer w Restaumatic, jeden z uczestników devdebaty o Haskellu, którą przeprowadziliśmy.
Zobaczcie, o co zapytaliśmy zaproszonych seniorów. Odpowiedzi udzielili:
Piotr Moczurad. Senior Python Developer w ucreate. Zapalony programista funkcyjny, zdradzający Haskella z Pythonem. Zawodowo pracuje w firmie ucreate, w której pomaga rozwijać się startupom. Wcześniej był przez parę lat członkiem zespołu tworzącego wizualno-tekstowy język Luna. W wolnym czasie robi doktorat na AGH, jeździ na rowerze, gra na rozmaitych instrumentach i wbrew przeciwnościom próbuje pisać w Clojure.
Mikołaj Konarski. Konsultant w Well-Typed LLP, słynnej i leciwej firmie, która zaprogramuje wszystko, byle w Haskellu. Obronił doktorat z semantyki i kompilacji funkcyjnych języków programowania. Długie lata uczył studentów programowania funkcyjnego, nim to jeszcze był modne. W wolnych chwilach koduje grę ASCII roguelike Allure of the Stars.
Kamil Figiela. Współzałożyciel typed.space, inicjatywy popularyzującej Haskella wśród programistów oraz w zastosowaniach produkcyjnych, oraz doktorant Akademii Górniczo-Hutniczej w Krakowie. Programuje od dziecka, a od kilku lat zawodowo pracuje głównie w Haskellu. Z zamiłowania tancerz i DJ.
Adam Szlachta. Software Engineer w Restaumatic. Wielbiciel programowania funkcyjnego, programujący zawodowo w Haskellu i PureScripcie. Współzałożyciel organizacji typed.space popularyzującej Haskella w branży IT. Autor warsztatów i prelekcji na temat programowania funkcyjnego, a także prowadzący zajęcia z programowania funkcyjnego w Haskellu na Akademii Górniczo-Hutniczej w Krakowie. Prywatnie m.in. pasjonat syntezy modularnej.
Spis treści
Co najbardziej podoba się Tobie w Haskellu?
Odpowiada Piotr Moczurad, Senior Python Developer w ucreate:
Elegancki model „wszystko jest funkcją” i stosunkowo mało wyjątków od tej reguły. Dzięki temu składnia Haskella jest bardzo prosta i zachęca do tworzenia bardziej skomplikowanych bytów przez kompozycję mniejszych części.
Odpowiada Mikołaj Konarski, Haskell Consultant w Well-Typed LLP:
Że piszę lub modyfikuję program, kompilator marudzi, marudzi, w końcu jest zadowolony, uruchamiam i *działa*. To jest częste zaskoczenie nowych użytkowników Haskella i nigdy się to nie nudzi.
Odpowiada Kamil Figiela, współzałożyciel typed.space:
Programowanie w Haskellu można porównać do układania klocków Lego. Z prostych elementów komponujemy większe, a kompilator przez cały czas weryfikuje czy łączone elementy pasują do siebie. Jeśli nie pasują, to nie pozwoli ich połączyć, co więcej podpowie jakiego typu się spodziewał. Oczywiście nadal możemy stworzyć cykl z nieparzystej liczby kół zębatych i nasz program nie zadziała poprawnie, ale i tak unikamy mnóstwa frustrujących pomyłek. Silny system typów daje nie tylko lepsze gwarancje co do poprawności kodu, ale również zmienia sposób myślenia i wymusza bardziej przemyślaną architekturę.
Odpowiada Adam Szlachta. Software Engineer w Restaumatic:
Wymuszenie myślenia nastawionego na kompozycję elementów w przeciwieństwie do myślenia opartego na krokach jakie trzeba wykonać w programie. Haskell ułatwia to myślenie rozbudowanym systemem typów, w tym elementami takimi jak funkcje, algebraiczne typy danych i klasy typów. Programy pisane w Haskellu cechuje matematyczne piękno, prostota i niezawodność.
Z drugiej strony, co najbardziej wkurza Cię w Haskellu? Jakie są jego ograniczenia?
Odpowiada Piotr Moczurad, Senior Python Developer w ucreate:
Leniwa ewaluacja, która jest źródłem wielu problemów przy pisaniu i benchmarkowaniu programów. Można by zaryzykować, że jest to błędna decyzja podjęta przy projektowaniu języka, z której teraz wszystkim głupio się wycofać. Dość powiedzieć, że większość optymalizacji programów w Haskellu pod kątem wydajności (w moim przypadku) sprowadza się do szukania „gdzie jeszcze zapomniałem wymusić ewaluację”. Oczywiście nie oznacza to, że sama w sobie leniwa ewaluacja to zły pomysł, ale zamiast podejścia haskellowego (czyli możliwości chwilowego „wyłączenia” leniwości) proponowałbym podejście scalowe (możliwość chwilowego „włączenia” leniwości dla zmiennej). Więcej o tym temacie na świetnym blogu.
Odpowiada Mikołaj Konarski, Haskell Consultant w Well-Typed LLP:
Z powierzchownych rzeczy, wkurza mnie powszechna kultura upiększania kodu, np. formatowanie takie, żeby rozmaite rzeczy w kolejnych liniach zaczynały się od tej samej kolumny. To tak jak spędzanie godzin przed lustrem. Z poważnych rzeczy, wkurza mnie, gdy w najbardziej zagnieżdżonej pętli programu nie rozumiem dlaczego kompilator zrobił lub nie zrobił INLINE (np. funkcji wyższego rzędu). Czasami kosztuje to sporo wydajności i muszę nurkować do generowanego kodu pośredniego, żeby sprawdzić co się dzieje. Ogólniej mówiąc, ograniczeniem Haskella jest jego runtime, z garbage collectorem, ze sposobem kompilacji closures itp., które utrudniają robienie aplikacji real time i czasem wymagają nieoczywistej interwencji.
Odpowiada Kamil Figiela, współzałożyciel typed.space:
Są pewne braki w narzędziach deweloperskich, brakuje na przykład dobrego narzędzia do zarządzania importami modułów. Z miesiąca na miesiąc sytuacja się poprawia, ale pozostało sporo do zrobienia. Bolączką większych projektów może być stosunkowo długi czas kompilacji, a także zużycie pamięci przez kompilator – to jest cena, którą płacimy za możliwości i gwarancje jakie daje nam rozbudowany system typów. Haskella nie użyjemy tam, gdzie nie użylibyśmy innych języków z garbage collectingiem, czyli np. w systemach czasu rzeczywistego. Problematyczna jest nadal cross-kompilacja czy wsparcie platformy ARM, chociaż w tych tematach dużo zostało zrobione w ostatnim czasie.
Odpowiada Adam Szlachta. Software Engineer w Restaumatic:
Na co dzień najbardziej irytujące jest wciąż niewystarczająco wygodne wsparcie dla IDE, zwłaszcza w zakresie automatycznego wstawiania importów czy podpowiadania identyfikatorów pasujących do kontekstu. Ponadto pewien bagaż historyczny skutkujący mnogością rozszerzeń i stylów pisania, co zapewne przyczynia się do mniejszej adopcji w biznesie niż bym sobie tego życzył.
Jakbyś porównał wysiłek programowania do wysiłku debuggowania aplikacji napisanej w Haskellu?
Odpowiada Piotr Moczurad, Senior Python Developer w ucreate:
Chyba głównym „selling pointem” Haskella jest właśnie ten aspekt: wkładamy sporo wysiłku a priori w dobre zamodelowanie problemu przy pomocy typów, a następnie zbieramy tego owoce w postaci „type-driven development” (jeśli wiem, jaki typ ma dana funkcja, to zapewne ma niewiele możliwych, jeśli nie jedną, sensowną implementację) i bezproblemowego debugowania i refaktorowania. Jednak, jak to zwykle bywa, jest pewien haczyk: część problemów wymaga użycia wydajnych bibliotek w C (++) lub, do pewnego stopnia, działania bezpośrednio na pamięci: wtedy i w Haskellu można zobaczyć segmentation fault i szukać długo jego przyczyny.
Odpowiada Mikołaj Konarski, Haskell Consultant w Well-Typed LLP:
Gdy piszę program, mam tysiąc możliwych metafor (typów danych, czyli modeli matematycznych), które mogę wybrać, żeby ująć problem i trudność polega na tym, żeby wybrać model adekwatny, ale taki, który później będzie jak najbardziej bił mnie po łapach, jeśli zrobię coś głupiego. To jest ból zbyt obfitego wyboru. Natomiast debuggowanie jest już potem bardzo mechaniczne i polega na eliminowaniu kolejnych podejrzanych. Jeśli typy danych były wybrane sensownie, podejrzanych jest bardzo niewielu i zasięg każdego jest ograniczony do niewielkiej części kodu (kompilator tego pilnuje). Tutaj Sherlock Holmes jest na etapie programowania, nie debuggowania.
Odpowiada Kamil Figiela, współzałożyciel typed.space:
Dobre zamodelowanie i zakodowanie aplikacji wymaga pewnego wysiłku, ale debugowanie to czysta przyjemność. Wiele typowych dla innych środowisk błędów (chociażby rozmaite warianty „null pointer exception”) zostanie po prostu wyłapane przez kompilator. Programowanie w Haskellu promuje używanie czystych funkcji, czyli takich, których wynik jest determinowany przez argumenty, a sama funkcja nie powoduje żadnych efektów ubocznych (np. IO). Przewidywalność i powtarzalność zachowania powoduje, że łatwo wyizolować zdefektowane elementy. Najtrudniejsze do zdiagnozowania mogą być błędy na styku Haskella z bibliotekami napisanymi w innych językach – na styku technologii o gwarancje systemu typów czy thread-safety musimy zadbać sami.
Odpowiada Adam Szlachta. Software Engineer w Restaumatic:
Chyba zacząć należy od tego, że w Haskellu klasyczne debugowanie zdarza się rzadziej niż w innych językach, bo znaczna część potencjalnych błędów jest już wyłapywana na etapie kompilacji. Programowanie w Haskellu czasem przypomina narzucanie na siebie mnóstwa ograniczeń, a potem tworzenie implementacji, która się poprawnie skompiluje. Błędy, które prześlizgną się do tego etapu też jednak są dużo łatwiejsze do wyśledzenia, gdyż odpowiedzialne komponenty można łatwo wyizolować dzięki np. metodom zarządzania efektami. Możemy np. określić na poziomie typów, że dana funkcja ma prawo czytać z bazy, ale już nie ma prawa do niej zapisywać i dzięki temu eliminacja podejrzanych w dużej mierze opiera się na czytaniu sygnatur typów. Podsumowując: wysiłek programowania jest większy, a debugowania mniejszy niż w przypadku innych języków.
Jak oceniasz bezpieczeństwo refaktorowania oraz innych głębokich modyfikacji kodu?
Odpowiada Piotr Moczurad, Senior Python Developer w ucreate:
Kompilator na pewno bardzo pomaga przy refaktorowaniu: pisząc w dynamicznie typowanych językach unit testy można odnieść wrażenie, że wykonuje się pracę, która powinna dziać się automatycznie: „czy ta funkcja na pewno zawsze zwróci liczbę?” W tym sensie refaktory w Haskellu są praktycznie za darmo: (1) zmieniam coś w kodzie, (2) kompiluję, (3) poprawiam błędy, wracam do punktu (2), aż wszystko działa. Choć, jeśli już mowa o refaktorowaniu, warto posłuchać, co mówi Rich Hickey o typie „Maybe”: https://youtu.be/YR5WdGrpoug — być może dałoby się zrobić to jeszcze lepiej.
Odpowiada Mikołaj Konarski, Haskell Consultant w Well-Typed LLP:
Oczywiście bez testów ani rusz, ale z reguły znacznie więcej czasu spędzam poprawiając błędy, które wykrył kompilator, niż te nieliczne, które przetrwały do testów. Dzięki ścisłemu, statycznemu typowaniu Haskella i dzięki dyscyplinie definiowania własnych typów, które wyrażają założone własności programu, doświadczam, że po każdej dużej zmianie kodu, jego jakość się poprawia, bo coraz lepiej rozumiemy modele matematyczne, które są pod spodem. Nie jest łatwo w Haskellu zaciągać technological debt, bo kompilator staje okoniem. Trzeba by pisać program systematycznie od początku w złym stylu, takim, w którym jak najmniej obiecujemy i jak najmniej się określamy, żeby potem zmiany były fast, furious and dangerous.
Odpowiada Kamil Figiela, współzałożyciel typed.space:
W żadnym innym komercyjnie wykorzystywanym języku refaktorowanie nie jest równie bezpieczne. Poza wsparciem ze strony systemu typów warto podkreślić zalety testowania w oparciu o własności (property-based testing).
Odpowiada Adam Szlachta. Software Engineer w Restaumatic:
Refaktorowanie jest niezwykle bezpieczne i trzeba się trochę postarać, żeby coś zepsuć. Głębokie modyfikacje też są prostsze niż w innych technologiach, bo większość problemów jest zgłaszana na etapie kompilacji, co ułatwia skupienie umysł na logice biznesowej i nie zaprzątanie sobie głowy elementami przypadkowymi, w które obfituje kod w innych, słabiej typowanych językach (np. co zwróci porównanie wartości różnych typów albo czy gdzieś nie trzeba obsłużyć wartości null).
Jakbyś porównał testowanie do expressing programmer intent via types?
Odpowiada Piotr Moczurad, Senior Python Developer w ucreate:
„Expressing programmer intent via types” przynosi parę korzyści:
- mówi kompilatorowi, czego może się spodziewać pod danym kodzie — dzięki temu automatycznie możemy sprawdzić, czy to, co napisaliśmy, zgadza się z tym, na co się umówiliśmy z kompilatorem,
- mówi użytkownikom, czego mogą się spodziewać po kodzie — to chyba zaniedbywany aspekt, ale niezwykle ważny. Sygnatura funkcji może powiedzieć więcej, niż dwie strony dokumentacji. Z dobrze otypowanym kodem szybciej się pracuje,
- określa wiele „matematycznych” właściwości kodu. W skrajnych przypadkach będziemy mogli próbować dowodzić poprawności programów (a jeśli gra jest o dużą stawkę, to być może warto to zrobić).
W żaden sposób nie wyklucza się to z testami („jeśli się kompiluje, to działa” jest wielkim kłamstwem :)) i każdy dobry codebase Haskellowy będzie takowe posiadał. Będą jednak zapewne nieco bardziej wysokopoziomowe, niż w językach pokroju Pythona.
Odpowiada Mikołaj Konarski, Haskell Consultant w Well-Typed LLP:
W sumie odpowiedziałem powyżej. Te dwa narzędzia się uzupełniają, ale testy, szczególnie pseudo-losowe, np. używając inteligentnego generatora testów QuickCheck, częściej potrafią wykryć problemy nieoczekiwane. Typy danych, którymi wyrażamy nasze intencje, z reguły wykrywają wykroczenia, o których dobrze wiemy, że będzie nas do końca życia programu kusiło, żeby je popełniać. Te drugie błędy są częstsze, te pierwsze złośliwsze. Co ciekawe, w Haskellu jest część wspólna testowania oraz formalnego wyrażania własności programu: te same formuły logiczne potrafią być przez QuickCheck użyte do generowania testów, a przez Liquid Haskell do wzbogacania typów funkcji, których użycie jest potem weryfikowane pluginami do kompilatora.
Odpowiada Kamil Figiela, współzałożyciel typed.space:
Oba podejścia uzupełniają się i należy ich obu używać z rozsądkiem. Typy niosą informacje nie tylko dla kompilatora, ale również pełnią rolę dokumentacyjną. Dzięki gwarancjom dawanym przez system typów można pisać mniej kodu oraz mniej testów i skupić się na istocie rozwiązywanego problemu. Przykładem może być dość często stosowany w praktyce typ „niepustej listy”. Implementując funkcję, której logika zakłada istnienie co najmniej jednego elementu możemy taką gwarancję zapewnić na poziomie typów. Zwykła lista nie daje takiej gwarancji, a my musielibyśmy obsłużyć takie dane wejściowe i zaimplementować do tego testy. Z drugiej strony nie wszystkie własności warto kodować w systemie typów – od pewnego momentu dodatkową złożoność kodu nie ma uzasadnienia w korzyściach. Testy z pewnością przydadzą się do weryfikacji wysokopoziomowej logiki.
Odpowiada Adam Szlachta. Software Engineer w Restaumatic:
W Haskellu używamy obu podejść jednocześnie, ale staramy się eliminować jak największą ilość potencjalnych problemów, zwłaszcza niezwiązanych z logiką biznesową, już na etapie projektowania typów. Dzięki temu w testach można skupić się na esencji. Warto też zwrócić uwagę, że biblioteka QuickCheck, posiadająca warianty w każdym liczącym się języku programowania, powstała właśnie w Haskellu, a dzięki niej rozpropagowana została idea testów badających właściwości lub niezmienniki i generacji przypadków testowych automatycznie.
Dlaczego warto uczyć się Haskella, a nie Lisp?
Odpowiada Piotr Moczurad, Senior Python Developer w ucreate:
Dlaczego nie i tego, i tego? Pisanie w Lispie (albo modniej: Clojure) to świetne ćwiczenie dla umysłu i frajda sama w sobie. Zapewne gdybym miał wybrać jeden język, w którym będę pisał do końca życia, nie byłby to Lisp: gąszcz nawiasów i bardzo luźne typowanie nie pomaga w pisaniu niezawodnych programów. Z drugiej strony, przed dopisaniem każdej linijki Clojure zastanawiam się trzy razy, co być może prowadzi do nieco bardziej przemyślanego i zwięzłego kodu.
Odpowiada Mikołaj Konarski, Haskell Consultant w Well-Typed LLP:
Lisp jest czcigodnym dziadkiem języków funkcyjnych, a jego możliwości samo-modyfikacji kodu są niedoścignione (może za wyjątkiem assemblera). Jednak, z moich skromnych doświadczeń z Lispem, używanym jako język skryptowy edytora tekstów Emacs, wnoszę, że bardzo brakowałoby mi statycznego typowania oraz rygorystycznego wydzielenia, przy pomocy typów, tych małych fragmentów programu, które mają do czynienia z side-effects (gdzie nie wszystkie struktury danych są immutable). Mówiąc otwarcie, robię strasznie wiele błędów w emacs-lispie i kiedy one ujawniają się, nie wiem, co się dzieje. Z tego względu, do zastosowań, gdzie samo-modyfikacja nie jest konieczna, polecam raczej Haskella.
Odpowiada Kamil Figiela, współzałożyciel typed.space:
Prawdę mówiąc to nigdy nie kodowałem w Lispie, więc trudno mi się do niego bezpośrednio odnieść, mam jednak nieco doświadczenia z Elixirem. Poza funkcyjnym paradygmatem języki te łączy obecność makr i dynamiczne typowanie. To podejście w początkowej fazie projektu wydaje się bardzo wygodne i pozwala na bardzo szybką implementację aplikacji. W perspektywie dłuższego rozwoju przy każdej zmianie ryzykujemy wprowadzenie post-factum oczywistych, ale jednak niezauważonych i trudnych do zdiagnozowania bugów. Z mojego doświadczenia wiele z nich nie mogłoby wydarzyć się w Haskellu, gdzie mamy do czynienia ze statycznym typowaniem.
Odpowiada Adam Szlachta. Software Engineer w Restaumatic:
Z całą pewnością warto uczyć się Lispa, choćby dla rozszerzenia horyzontów myślowych — był on w końcu pierwszym językiem funkcyjnym i jest istotnie różny od Haskella. Konceptualnie szczególnie ciekawa jest idea S-wyrażeń i zapisu kodu jako struktury danych oraz system makr. Niemniej jednak w zastosowaniach praktycznych zdecydowanie stawiam na Haskella i ekspresywny system typów z powodów, które przedstawiłem we wcześniejszych odpowiedziach.
Czego Twoim zdaniem brakuje Haskellowi, by stać się językiem mainstreamowym?
Odpowiada Piotr Moczurad, Senior Python Developer w ucreate:
Standaryzacji i dobrej dokumentacji. Argument za standaryzacją to choćby współistnienie kilku pakietów do obsługi wyjątków. Warto wspomnieć o gąszczu pakietów na Hackage, które po bliższym przyjrzeniu się okazują się być zarzuconymi po miesiącu zabawkowymi projektami. Poniekąd łączy się to z dokumentacją: gdyby było jakieś „ciało standaryzujące”, które nie pozwoli wrzucić na Hackage pakietu bez dokumentacji lub niekompletnego, sytuacja wyglądałaby o wiele lepiej.
Swoją drogą, być może Haskellowi potrzeba jakiegoś koronnego use-case’u: Scala ma Akkę, Ruby ma Railsy, Python ma Numpy/Pandas/etc. Być może jeden świetny i przyjazny użytkownikom framework załatwiłby sprawę.
Odpowiada Mikołaj Konarski, Haskell Consultant w Well-Typed LLP:
Nauczania matematyki, a nie rachunków, w szkołach podstawowych i średnich.
Odpowiada Kamil Figiela, współzałożyciel typed.space:
Brakuje wielkiego biznesu lub frameworku, który ustandaryzowałby pewne rzeczy i w konsekwencji spopularyzowałby Haskella. Dla każdego zagadnienia (np. obsługa błędów, zarządzanie efektami, czy framework webowy) w repozytorium Hackage mamy conajmniej kilka konkurujących bibliotek z których należy „coś” wybrać. To trudne zwłaszcza dla osób stawiających pierwsze kroki w ekosystemie. W przypadku innych języków nie ma takich dylematów — Ruby ma Railsy, Scala ma Play, a Elixir ma Phoenixa.
Odpowiada Adam Szlachta. Software Engineer w Restaumatic:
Zdecydowanie brak standaryzacji w zakresie najlepszych praktyk, zwłaszcza w zakresie organizacji programu, a w szczególności zarządzania efektami. Istnieje kilka alternatywnych podejść: np. mtl, free(r) monads, ReaderT pattern i final tagless. Podobnie z bibliotekami. Biblioteką standardową, dostępu do baz relacyjnych albo web frameworków — jest wiele, ale nie ma żadnej, za którą stałaby duża organizacja, wspierała jej rozwój, promowała ją i dostarczała odpowiednich materiałów i wsparcia. Haskell ma wysoki próg wejścia dla nowych programistów i brak standardów jeszcze pogarsza sytuację. Co jakiś czas pojawiają się jednak próby zmiany tego stanu rzeczy, na pewno warta uwagi jest biblioteka RIO.
Zdjęcie główne artykułu pochodzi z unsplash.com.