Funkcje wyższego rzędu Haskell
Wstęp Funkcje w haskellu mogą przyjmować i zwracać inne funkcje jako parametr. Jeżeli funkcja jednocześnie przyjmuje za parametr funkcję i przy tym wynikiem jej działania również jest funkcja, to nazywamy ją funkcją wyższego rzędu. Jest to bardzo ważny komponent języka haskell, gdyż za jego pomocą można zaimplementować mechanizm, który będzie odpowiednikiem działania „pętli” nie występujących naturalnie w tymże języku. Używając funkcji wyższego rzędu możemy zmieniać stan obiektu podczas ich działania, więc jest to naprawdę potężne narzędzie.
Funkcje „Curried” Oficjalnie wszystkie funkcje w haskell’u przyjmują jeden parametr. Jak więc jest możliwe, że używamy funkcji(np. Max), które przyjmują więcej, niż jeden parametr? Jest to rozwiązane jednym prostym trickiem, mianowicie: wszystkie funkcje, które przyjmują kilka parametrów są funkcjami „curried”. Co to tak naprawdę znaczy? Najlepiej będzie zobrazować to przykładem.
Curried – przykład 1 Weźmy dobrze znaną nam funkcję Max(). Wygląda na to, że funkcja przyjmuje dwa parametry typu int i zwraca ten, który ma większą wartość. Nic bardziej mylnego. Porównując liczby 4 i 5, najpierw zostanie utworzona funkcja, która przyjmuje za parametr wartość 4 i to samo zwraca, a następnie zostanie wywołana ta sama funkcja z parametrem 5 zwracając rezultat. Dlatego też wywołanie max 4 5, oraz (max 4) 5 zwróci dokładnie ten sam wynik.
Curried a parametry Wstawianie spacji między dwa parametry jest prostym sposobem na łączenie wywołań funkcji. Spacja jest traktowana jako operator i ma najwyższy priorytet. Sprawdźmy jaki typ ma funkcja max. Co może być zapisane również jako: Powinniśmy to czytać w następujący sposób. Funkcja max przyjmuje parametr a I zwraca (->) funkcję, która przyjmuję za parametr a i zwraca a. Dlatego typ zwracany i parametry funkcji są oddzielane od siebie strzałkami.
Korzyści z curried Jakie mamy korzyści z istnienia tych funkcji? Najprościej: jeżeli wywołamy funkcję ze zbyt małą ilością parametrów, otrzymamy częściowo zaaplikowaną funkcję, czyli taką, która przyjmuje tyle parametrów, ile jej przekazaliśmy. Jest to łatwy sposób do tworzenia funkcji w locie, które możemy potem przekazać do innych funkcji, albo napełnić je danymi.
Korzyści z curried Spójrzmy na ten przykład: Co się tak naprawdę stanie, kiedy wywołamy multThree 3 5 9 albo ((multThree 3) 5) 9? Pierwsza liczba zostanie przekazana do funkcji multThree, ponieważ jest odseparowana spacją. To stworzy kolejną funkcję, która przyjmie 5 i zwróci funkcję, która przyjmie ostatni parametr i zwróci iloczyn wszystkich parametrów, czyli 135. Pamiętajmy, że ten typ funkcji może być zapisany również jako: To, co jest przed strzałką(->) jest parametrem, który funkcja przyjmuje wraz z jej wartością zwracaną. Więc nasza funkcja przyjmuje parametr a i zwraca funkcję typu (Num a) => a -> a. Podobnie, ta funkcja przyjmuje parametr a i zwraca funkcję typu (Num a) => a-> a, która finalnie zwróci nasze a.
Korzyści z curried Wywołując funkcje z za dużą ilością parametrów tworzymy nową funkcję w locie. Co jeżeli chcielibyśmy stworzyć funkcję, która przyjmuje liczbą za parametr i porównuje ją do 100? Możemy zrobić coś takiego: Jeżeli wywołamy ją z parametrem 99, zwróci GT. Zauważmy, że x jest z prawej strony przy obydwu porównaniach. Pomyślmy zatem, co zwraca compare 100. Wyrażenie to zwraca funkcję, która przyjmuje liczbę i porównuje ją do 100, nie ma w tym nic zaskakującego. Możemy więc zapisać naszą funkcję jako:
Korzyści z curried Deklaracja typu zostaje taka sama, ponieważ compare 100 zwraca funkcję. Compare ma typ i wywołanie jej z parametrem 100 zwróci Dodatkowe ograniczenie klasy znalazło się tutaj, ponieważ 100 jest obiektem klasy Num.
Funkcje infix Funkcje infiksowe również mogą być częściowo aplikowane używając sekcji. Sekcje funkcji infiksowej łatwo otoczyć używając nawiasów i dostarczyć jej tylko jeden parametr. To stworzy nam nową funkcję, która przyjmuje jeden parametr i zaaplikuje do wybranego miejsca. Wywołując divideByTen 200 możemy powiedzieć, że dokonujemy operacji 200 / 10, tak jak robimy (/10) 200. Funkcja która będzie sprawdzała czy przekazany znak jest wielką literą, mogłaby wyglądać tak:
Curried ciąg dalszy Co się stanie, jeżeli spróbujemy użyć funkcji multThree 3 4 w GHCI, pomijając słowo kluczowe let? GHCI mówi nam, że wyrażanie wyprodukowało funkcję type a -> a, ale nie wie, jak wydrukować to na ekranie. Funkcja nie jest instancją klasy Show, więc nie możemy uzyskać jej „stringowej” reprezentacji. Kiedy wpisujemy 1 + 1 prompt najpierw oblicza wynik działania, a następnie wywołuje funkcję show na tym wyniku, która zwraca jego tekstową reprezentację. W tym przypadku tekstowa reprezentacja to „2”, która zostanie pokazana na ekranie.
Wyższe rzędy w sortowaniu Funkcje mogą przyjmować funkcje za parametry, tak samo jak je zwracać. Żeby to zilustrować stworzymy funkcję, która przyjmuje funkcję i wykonuje ją dwa razy. Zwróćmy uwagę na typ deklaracji. Wcześniej nie potrzebowaliśmy nawiasów ponieważ -> jest domyślnie prawostronnym operatorem. Tutaj są one obowiązkowe. Wskazują, że pierwszy parametr jest funkcją, która coś przyjmuje i zwraca to samo(ten sam typ). Funkcją może być zarówno Int -> Int, jak i String -> String, ale drugi parametr musi być tego samego typu.
Funkcja applyTwice Ciało funkcji jest proste. Używamy parametru f jako funkcji, wstrzykując do niej parametr x poprzez użycie spacji i aplikując rezultat z powrotem do funkcji f. Jeżeli nasza funkcja wymaga od nas przekazania funkcji, która przyjmuje tylko jeden parametr, możemy po prostu częściowo zastosować tę funkcję, do punktu w którym przyjmuje tylko jeden parametr i wtedy ją przekazać.
Funkcja zipWith Teraz użyjemy programowania wyższego rzędu do zaimplementowania funkcji, znajdującej się w standardowej bibliotece. Mowa o funkcji zipWith, która pobiera funkcje i dwie listy jako parametry a następnie łączy je wstrzykując funkcję (pomiędzy). Spójrzmy na typ deklaracji, pierwszy parametr jest funkcją, która przyjmuje dwa obiekty i produkuje trzeci. Nie muszą być tego samego typu, ale mogą. Trzeci i czwarty parametr to listy. Wynik również jest listą. Pierwsza będzie lista parametrów typu a, ponieważ przyjeliśmy a jako pierwszy argument. Druga będzie typu b, ponieważ jest to drugi argument. Listą wynikową będzie natomiast c. Deklaracja typu mówi, ze funkcja akceptuje a->b->c jako parametr, więc akceptuje również a -> a-> a.
Funkcja zipWith - zastosowanie Jak widać pojedyncza funkcja wyższego rzędu jest bardzo wszechstronna. Programowanie imperatywne zazwyczaj używa tego typu funkcji jako pętli for, czy while. Ustawia coś jako zmienną, sprawdza jej stan aby osiągnąć pewne zachowanie i wtedy owija to wokół jakiegoś interfejsu, jak funkcja. Programowanie funkcyjne używa funkcji wyższego rzędu, aby odciąć się od wzorców takich jak sprawdzanie dwóch list w parach i robienie z nich czegoś, albo tworzenie zestawu rozwiązań, z którego odrzucimy te, których nie potrzebujemy.
Funkcja flip Zaimplementujmy teraz inną funkcję, która również jest w zestawie standardowych bibliotek. Chodzi o funkcję flip. Funkcja ta przyjmuje funkcję i zwraca funkcję – zgodnie z oryginałem. Następnie zamienia dwa pierwsze argumenty. Możemy to zaimplemen czytając deklarację typu możemy powiedzieć, że funkcja przyjmuje a i b i zwraca funkcję, która przyjmuje b i a. Ponieważ funkcje domyślnie są „curried”, druga para nawiasów jest niepotrzebna, ze względu na to, iż
Funkcja flip Jest równoznaczne z Co z kolei jest równoznaczne z Piszemy, ze g x y = f y x. Jeżeli jest to prawda, wtedy f y x = g x y – pamiętając o tym możemy zdefiniować tę funkcję w znacznie prostszy sposób.
flip – druga definicja Tutaj mamy korzyść ze względu na to, że ta funkcja to funkcja „curried”. Kiedy wywołujemy flip’ bez parametrów y i x, otrzymamy funkcję f, która wywołuje te dwa parametry w odwrotnej kolejności. Mimo, ze funkcje odwracające są zazwyczaj przekazywane do innych funkcji, możemy mieć korzyści z tworzenia funkcji wyższego rzędu, „myśląc do przodu” i pisząc co może być ich wynikiem, jeżeli zostaną wywołane przy pełnych parametrach.
Mapy i filtry Mapy pobierają funkcje i listy, a następnie aplikują te funkcje do każdego elementu w liście, tworząc nową listę. Zobaczmy, jakiego typu jest sygnatura i jak to zdefiniować. Typ sygnatury mówi, że pobierana jest funkcja, która przyjmuje parametr a i zwraca b, oraz listy stworzone z parametrów a i b. Interesujące jest to, że poprzez rzut okiem na sygnaturę funkcji, czasami można stwierdzić, co ona robi. Map jest jedną z funkcji wyższego rzędu, które można użyć na milion różnych sposobów. Na następnym slajdzie jest na to przykład.
Funkcja map Prawdopodobnie zauważyliście, że każdy z tych przypadków został osiągnięty za pomocą zrozumienia działania listy. Jest tym samym, co Tak, czy inaczej używanie map jest dużo bardziej czytelne w przypadku, kiedy aplikuje jakąś funkcję do elementów listy.
filter Filter jest funkcją, która przyjmuje predykat (predykat to funkcja, która zwraca prawdę lub fałsz) i listę, a następnie zwraca listę elementów, która spełniają warunki predykatu. Typ i sygnatura mogłyby wyglądać tak: prosta sprawa, jeżeli p x zwróci prawdę, to element zostanie włączony do nowej listy, jeżeli nie to zostanie na swoim miejscu.
Filter – inne przykłady Wszystkie z powyższych przykładów również mogą być osiągnięte za pomocą list i predykatów. Nie ma żadnej szczególnej zasady, kiedy powinniśmy używać danego sposobu. Sami powinniśmy zdecydować, który przykład będzie bardziej czytelny. Filter jest ekwiwalentny do użycia kilku predykatów w liście lub połączenia predykatów z logicznymi funkcjami &&.
Powrót do QuickSort’a Pamiętacie naszą funkcję quicksort z poprzedniego rozdziału? Użyliśmy list do filtrowania elementów, które są mniejsze bądź równe i większe od pivota. Możemy osiągnąć tę samą funkcjonalność w bardziej czytelny sposób: Mapowanie i filtrowanie jest chlebem powszednim każdego programisty. Przypomnijmy sobie, jak rozwiązaliśmy problem znajdywania prawidłowych trójkątów z pewnym obwodem.
Problem z trójkątami Programując imperatywnie moglibyśmy rozwiązać ten problem używając trzech pętli, a następnie sprawdzanie, czy dane rozwiązanie jest akceptowalne. W tym przypadku powinniśmy to wydrukować na ekran. W programowaniu funkcyjnym ten wzorzec jest osiągnięty poprzez mapy i filtry. Tworzymy funkcję, która przyjmuje wartość i zwraca jakiś wynik. Mapuje tę funkcję przez listę wartości, a następnie filtruje listę wynikową mapowania do momentu znalezienia interesującego nas wyniku. Dzięki leniwości haskell’a, jeżeli mapujemy coś przez listę kilka razy i filtrujemy kilka razy – przejdziemy przez listę tylko raz.
Największa podzielna liczba przez 3829 Znajdźmy największą liczbę, która będzie mniejsza, niż 100 000 i jednocześnie podzielna przez 3829. Aby to zrobić po prostu przefiltrujemy zestaw możliwości, w których wiemy, że rozwiązanie nie jest prawidłowe. Najpierw tworzymy listę wszystkich numerów mniejszych od 100 000 (malejąco). Następnie filtrujemy ją naszym predykatem, pierwsza liczba na liście będzie największą liczbą podzielną przez 3892.
Suma liczb mniejszych od 10 000 Spróbujmy znaleźć sumę wszystkich nieparzystych kwadratów liczb, które są mniejsze od 10 000. Zacznijmy od funkcji takeWhile. Przyjmuje ona predykat i listę a, następnie przechodzi od początku listy i zwraca elementy, dopóki predykat zwraca prawdę. Po znalezieniu elementu, który nie spełni warunków predykatu – funkcja się zatrzymuje. Jeżeli chcielibyśmy złapać pierwsze słowo ze stringa „elephants know how to party”, możemy zrobić coś takiego: takeWhile (/=‘ ‘) – zwróci słowo elephants.
Suma liczb mniejszych od 10 000 Wróćmy do naszej funkcji zwracającej nieparzyste kwadraty liczb mniejszych od 10 000. Zaczniemy od zmapowania funkcji ^2 do nieskończonej listy. Następnie przefiltrujmy ją tak, aby otrzymać liczby nieparzyste. Następnie weźmiemy elementy mniejsze od 10 000. Na samym końcu musimy znaleźć sumę tych elementów. Nie musimy definiować funkcji do tych operacji, możemy je zrobić w jednym wierszu: Możemy też zapisać to inaczej:
Inne zastosowanie Do rozwiązania następnego problemu użyjemy sekwencji Collatz. Weźmiemy liczbę naturalna, jeżeli jest parzysta, to dzielimy ją przez 2. Jeżeli nie jest parzysta, to dzielimy ją przez 3 i dodajemy 1. Bierzemy wynik i robimy to samo. W ten sposób otrzymamy ciąg liczb. Możemy zobaczyć, że w każdym przypadku w końcu otrzymamy liczbę 1, zaczynając od liczby 13, otrzymamy taki wynik: 13, 40, 20, 10, 5, 16, 8, 4, 2, 1. 13*3 + 1 jest równe 40, 40 podzielone przez 2 jest równe 20 itd. Możemy zauważyć, że łańcuch ma 10 znaków.
Inne zastosowanie Teraz, kiedy wiem jak to wygląda. Zaczynając od liczb między 1 a 100, jak dużo będzie liczb, dla których łańcuch będzie dłuższy, niż 15 elementów? Po pierwsze napiszmy funkcję, która wyprodukuje łańcuchy: Ponieważ każdy łańcuch kończy się na liczbie 1, będzie to nasz warunek końcowy. Jest to ładny standard funkcji rekurencyjnej.
Inne zastosowanie Następnie zaimplementujmy funkcję, która da nam rozwiązanie naszego zadania. Mapujemy funkcję łańcucha od [1…100] aby otrzymać listę łańcuchów, które będą reprezentowane jako listy. Następnie filtrujemy je przez predykat, który sprawdzi, która lista ma długość większą od 15. Po przefiltrowaniu będziemy wiedzieli, ile jest takich list.
Inne zastosowanie Używając map, możemy stworzyć coś takiego map (*) [0…], nie ma lepszego przykładu, aby pokazać, że funkcje są realnymi wartościami, które możemy przekazać do innych funkcji, czy wstawić do listy. Na razie zmapowaliśmy funkcję, która przyjmuje jeden parametr nad listą (map (*2) [0..]), aby otrzymać liste typu (Num a) => [a]. Możemy również bez problemu zrobić coś takiego map (*) [0..]. To co się tutaj dzieje, to aplikowanie number z listy do funkcji *, która ma typ (Num a) => a -> a -> a. Aplikowanie tylko jednego parametru do funkcji, która przyjmuje dwa parametry, zwraca funkcję, która przyjmuje jeden parametr. Jeżeli zmapujemy * przez listę [0..], otrzymamy listę funkcji, które przyjmują tylko jeden parametr, więc (Num a) => [a -> a]. Map (*) [0..] wyprodukuje listę, którą można stworzyć również w ten sposób: [(*0), (*1), (*2), (*3), (*4), (*5)..
Ostatni przykład Pobierając element o indeksie 4 z listy, którą zwróciła nasza funkcja, możemy zauważyć, że jest on analogiczny do (*4). Następnie aplikujemy 5 do tej funkcji, co jest tożsame z napisaniem (* 4) 5, albo po prostu 4 * 5.
Wyrażenia lambda Wyrażenia lambda są funkcjami anonimowymi, która są używane kiedy potrzebuje wykorzystać daną funkcję tylko raz. Zazwyczaj tworzymy funkcję lambda z zamysłem przekazania jej do funkcji wyższego rzędu. Aby stworzyć funkcje lambda piszemy znak „\”, następnie podajemy parametry separując je spacjami. Potem używamy znaku „->” i definiujemy ciało funkcji. Zazwyczaj otaczamy je nawiasami, ponieważ w innym przypadku rozszerzą się na wszystkie operacje w prawo.
Wyrażenia lambda Jeżeli cofniemy się do poprzednich slajdów, to możemy zauważyć, że użyliśmy klauzuli where w naszej funkcji numLongChains – w celu stworzenia funkcji isLong, która następnie została przekazana do filtra. Zamiast tego możemy użyć wyrażenia lambda. Lambdy to wyrażenia, dlatego możemy je po prostu wrzucić tak, jak na powyższym przykładzie. Wyrażenie (\xs -> length xs > 15) zwraca funkcję, która powie nam, które listy mają długość większą, niż 15.
Wyrażenia lambda Ludzie którzy nie są biegli w używaniu funkcji „curried” i aplikowania parametrów często używają funkcji lambda, nawet jeżeli nie jest to konieczne. Weźmy za przykład wyrażenie map (+3) [1,6,3,2] i map (\x -> x + 3) [1,6,3,2], które są tożsame. (+3) i (\x -> x + 3) są funkcjami, które pobierają liczbę i dodają do niej wartość 3. Używanie lambdy w tym przypadku nie jest najlepszym pomysłem, jeżeli zależy nam na czytelności naszego kodu.
Lambda a parametry Tak jak normalne funkcje, lambdy mogą przyjmować dowolną liczbę parametrów. Tak samo jak w normalnych funkcjach, możemy stosować „pattern matching”. Jedyna różnica jest taka, że nie możemy zdefiniować kilku wzorców dla jednego parametru, tak jak w normalnych funkcjach poprzez [] i (x:xs). Jeżeli wzorca nie uda się dopasować w wyrażeniu lambda, wyskoczy na runtime error – bądźmy ostrożni w stosowaniu lamb do wyszukiwania wzorca.
Wyrażenia lambda Lambdy są zazwyczaj otaczane przez nawiasy. Chyba, że mamy na myśli rozszerzenie ich w prawą stronę. Poniżej znajduje się ciekawy przykład Jeżeli zdefiniujemy funkcję w ten sposób, to oczywiste staje się, dlaczego deklaracja typu jest tym, czym jest. Są tutaj trzy „->” w obydwu typach deklaracji i równaniach. Oczywiście pierwszy sposób zapisu jest zdecydowanie bardziej czytelny, drugi jest sztuczką służącą do zilustrowania działania funkcji „curried”.
Wyrażenia lambda Czasami jednak używanie notacji z poprzedniego slajdu ma sens. Na przykład funkcja flip zdefiniowana w ten sposób jest bardziej czytelna. Nawet jeżeli jest to tożsame z pisaniem flip’ f x y = f y x, robimy to oczywiście po to, aby użyć do produkcji nowej funkcji (w większości przypadków). Najbardziej pospolitym sposobem użycia flip jest wywołanie go z parametrem funkcyjnym i przekazanie rezultatu do mapy albo filtra. Używanie lambdy w tym przypadku, kiedy chcemy podkreślić, że nasza funkcja służy tylko do zaaplikowania i przekazania do innej funkcji jako parametr.
Folds Kiedy zajmowaliśmy się funkcjami rekurencyjnymi mogliśmy zauważyć, że wiele z nich operowało na listach. Korzystalismy wtedy ze schematu dzielenia listy na „head” i „tail”. Jest to bardzo powszchny wzorzec dlatego powstało kilka funkcji majacych go upraszczać. Na początek przeanalizujmy funkcje foldl ( left fold). \acc x -> acc + x jest funkcją binarną, 0 jest wartością startową i xs jest lista która ma zostac złożona acc = 0, x = 3 -> 0+3 = 3 acc = 3, x = 5 -> 3+5 = 8 acc = 8, x = 2 -> 8+2 = 10 acc = 10, x = 1 -> 10+1 = 11
Folds Funkcję lambda (\ acc x -> acc + x) możemy zapisać krócej jako (+). Generalnie jeżeli mamy funkcję typu foo a = bar b a możemy ją zapisać krócej jako foo = bar b.
Folds Przypomnijmy sobie funkcję elem Zaimplementujemy ją używając tym razem foldl. 3 [1,2,3] => acc=false, x =1, y=3 acc=false, x =2, y=3 acc=false, x =3, y=3 , acc=true
Folds Foldr ( right fold) działa w podobny sposób jak foldl z ta róznicą że acumulator foldr porusza sie od prawej strony dlatego też rózni sie jego funkcja binarna, w foldl pierwszym parametrem był akumulator a drugim aktualna wartość ( \acc x -> ... ) w foldr jest odwrotnie ( \x acc -> ... ). Zaimplementujemy funkcje map używając foldr: 3 [1,2,3] => acc=[], x=3 acc=[6], x=2 acc=[5,6], x=1 acc=[4,5,6]
Folds Możemy zaimplemetować map z wykorzystaniem foldl ale używanie „ ++ ” jest bardziej kosztowne od „ : ” dlatego w tym wypadku lepiej skorzystać z foldr. Funkcje foldl1 i foldr1 działają identycznie jak foldl i foldr z tą różnicą że nie trzeba im podawać wartości startowej, funkcje same domyslnie wybiorą pierwszy lub ostatni element listy. Wiedząc to możemy na przykład skrócić funkcje sum:
Folds Funkcje folds są bardzo potężnym narzędziem w haskelu i można z ich wykorzystaniem zaimplementować wiele standardowych funkcji:
Folds Funkcje scanl i scanr sa jak foldl i foldr ale pokazuja wszystkie wartości jakie przyjmował akumulator pod czas działania funkcji. scanl (+) 0 [3,5,2,1] -> acc=0, x=3 acc=3, x=5 acc=8, x=2 acc=10, x=1 acc=11 Analogicznie istnieją również funkcje scanl1 i scanr1.
Funkcje $ Czym jest symbol $, do czego go wykorzystujemy, jest to aplikacja funkcji która w przeciwieństwie do użycia spacji ma najniższy priorytet. Symbolu $ możemy głównie używać do ograniczenia ilości nawiasów w delaracji wyrażenia . Powyższe wyrażenie możemy przy użyciu $ zapisać w takiej postaci: Po wykonaniu symbolu $ wyrażenie po jego prawej stronie przekazywane jest jako parametr do funkcji po lewej stronie.