Elementarz III: funkcje

W poprzedniej części, kontynuowaliśmy tworzenie obrazków ze zbiorów punktów. Zobaczyliśmy, że jeśli wydzielimy jakiś zbiór punktów i nazwiemy go, możemy je logicznie uporządkować, ułatwiając zorientowanie się w tym co jest czym i ewentualną modyfikację. Ponadto możemy zdefiniować osobno powtarzający się punkt.

W tym odcinku

  1. Co dalej?
  2. To samo czy co innego?
  3. Znowu zapis
  4. Ćwiczenia
  5. Łączenie funkcji
  6. Deklarowanie funkcji
  7. Funkcje jako argumenty
  8. Podsumowanie

Co dalej?

Zadajmy sobie więc pytanie „co dalej?” (jak zwykle, zachęcam do dzielenia się, you're not as biased as I am). Pierwsze co mi się narzuca to brak możliwości skopiowania wydzielonego kształtu, bo wszystkie współrzędne są bezwzględne — skopiowanie ich spowoduje narysowanie znów tego samego, w tym samym miejscu. Bez sensu.

Ktoś z zacięciem matematyka może powiedzieć — nie chcę więcej rysować myszką — woli narysować wykres funkcji albo fraktal, bo piękno tkwi w regularności i to ona go interesuje.

Osobie, która zajmuje się grafiką lub malarstwem zapewne brakować będzie kolorów i dodanie ich wydawałoby się sprawą pierwszej potrzeby. Natomiast fotograf mógłby chcieć od razu zastosować jakiś ciekawy filtr.

Bardzo ważne jest to, żeby wyobrazić sobie możliwie wiele przypadków, bo dzięki temu możemy znaleźć lepsze, bardziej elastyczne rozwiązania. Mam nadzieję, że szybka analiza wyżej wymienionych pomysłów pokaże co mam na myśli. Dla każdego przypadku należy sobie zadać pytanie, jak to osiągnąć. Zachęcam, żeby spróbować sobie na nie odpowiedzieć, to sprawia, że bardziej angażujecie się w naukę i czytając o rozwiązaniu macie już jakiś punkt odniesienia. Ważna rzecz w czasie uczenia się.

Powielanie kształtów? Filtry?

Tak naprawdę niewiele nam brakuje, żeby móc przenieść kształt w inne miejsce. Wystarczyłoby podać wektor przesunięcia obrazu i dodać jego współrzędne do współrzędnych każdego punktu. Możecie łatwo zaobserwować na poniższym przykładzie.

Błąd: brak canvas!
OK

Jeśli pomyślimy o tym chwilę, jest to szczególny przypadek filtru, czyli przekształcenia, jakie nakładamy na cały obraz. Moglibyśmy równie dobrze obraz obrócić, wygiąć, itd. Za każdym razem, sprowadziłoby się to do zmiany współrzędnych punktów składowych.

Są jednak pewne filtry, które wymagałyby bardziej zasadniczej zmiany, na przykład nie możemy rozmazać obrazu poprzez zmianę czy dodanie nowych punktów. Póki co, nasz zapis nie daje nam możliwości zmiany kolorów, a tym zasadniczo jest rozmazanie — mieszaniem sąsiadujących kolorów.

Wykresy funkcji? Fraktale?

Narysowanie wykresu funkcji to sprawa dość prosta. Wybieramy sobie jakieś współrzędne od których zaczniemy i zakres wartości dla których będziemy znajdować wartość funkcji. Dla kolejnych wartości argumentu funkcji znajdujemy wartość funkcji. Dodając te wartości do współrzędnych punktu odniesienia otrzymujemy kolejne punkty wykresu funkcji. Poniższy przykład ilustruje takie działanie.

Błąd: brak canvas!
OK

Możemy zaobserwować, że lista punktów wygenerowanych przez funkcję pojawia się, dla porządku, w osobnej zmiennej wykres. Zmieniając wzór funkcji, punkty w tej zmiennej również ulegają zmianie. Jest zatem coś bardzo podobnego między tymi dwoma działaniami. Na podobnej zasadzie działałoby rysowanie fraktali, choć to bardziej złożony proces. Na tę chwilę istotne jest zaobserwowanie tego jak jedne dane, przekształcają się w regularny sposób na inne.

Kolory

Wreszcie ostatni pomysł, możliwość zmiany kolorów. Jest zasadniczo inny niż pozostałe, bo nie daje się go zrealizować poprzez proste zmiany wartości w opisie. Aby zrealizować ten pomysł musielibyśmy wprowadzić nowy element do zapisu. Jeśli kolory nie są kluczową funkcją, wolę zrobić coś łatwiejszego i bardziej funkcjonalnego.

Dodatkowo, kolory sprawią, że zapis będzie bardziej złożony. W różnym stopniu, zależnie od sposobu realizacji. Mógłbym na przykład zrobić tak, że pierwszy element listy reprezentującej linię to jej kolor. Inaczej, mógłbym dodać dwa dodatkowe elementy do współrzędnych punktu reprezentujące jego kolor. W pierwszym przypadku nie komplikujemy bardzo zapisu, ale w drugim mamy większą elastyczność.

Jeszcze inaczej, mógłbym zrobić tak, że linie mogą mieć dwa rodzaje elementów — dwuelementowe listy, współrzędne punktów i trójelementowe, zmiana kolorów. Dobry kompromis między elastycznością i oszczędnością miejsca (o ile chcemy zmieniać kolory tylko czasami).

Tak czy inaczej, zapis już jest nieczytelny, tak więc wolę najpierw możliwie uprościć zapis. Dzięki temu, późniejsze dodawanie nowych elementów w zapisie nie będzie tak uciążliwe.

To samo czy co innego?

Ważną rzeczą, którą warto zauważyć w powyższej analizie to to, że czasami różne czynności okazują się być przypadkami tego samego działania. Zarówno rysowanie wykresów jak i fraktali sprowadzałoby się do obliczania kolejnych punktów na podstawie wzoru. Zarówno przesuwanie jak i obracanie czy wyginanie obrazu sprowadziłoby się do zmiany współrzędnych istniejących punktów.

Rozpoznawanie tego typu zależności jest bardzo ważną umiejętnością, z resztą nie tylko dla programisty — w ogóle ułatwia rozwiązywanie jakichkolwiek problemów. Zauważając, że pozornie różne zjawiska są w gruncie rzeczy podobne pozwalają wnioskować przez analogię. W programowaniu zaś możemy wydzielić wspólną część rozwiązania i oszczędzić sobie pracy. Programowanie, z pewnością, jest dobrym ćwiczeniem tej umiejętności i bardzo sobie ją cenię.

Wracając do naszych pomysłów, możemy zauważyć, że je wszystkie można jeszcze uogólnić. Można je sprowadzić do funkcji, takich samych jak te, których uczy się na lekcjach matematyki. Krótko mówiąc, funkcja to przyporządkowanie danym wejściowym (argumentom) określone wyniki. Na przykład funkcja przyporządkowująca prędkość na podstawie przebytej drogi i czasu jaki minął — droga i czas to parametry (czyli symbole w miejsce których wstawiane będą argumenty), prędkość jest wynikiem. Jest to bardzo proste przyporządkowanie, bo wystarczy podzielić drogę przez czas i mamy wynik.

Natomiast funkcje w programowaniu różnią się od tych, które znamy ze szkoły, przede wszystkim tym, że w szkole działaliśmy, przeważnie, na liczbach. To dlatego, że matematyka kształtowała się w warunkach, w których człowiek sam wszystko liczył (ponad 2000 lat odkąd Grecy zaczęli się nią zajmować, w porównaniu do niespełna 100 lat jak robimy to przy pomocy komputerów).

Jako, że nie jesteśmy w stanie ogarnąć zbyt wielu rzeczy na raz (podobno 7±2) — same wzory były dostatecznie trudne. Pisząc programy, nie musimy się martwić o żmudne obliczenia, bo komputery się nie mylą, jeśli program jest dobrze napisany. Za to komputer nie zorientuje się w czasie pracy, że program jest błędny. Dlatego musimy z góry wiedzieć (przeważnie wiemy to dopiero po dziesiątkach prób i błędów), że dobrze opisaliśmy problem. Dlatego, w programowaniu, przeważnie pracujemy na złożonych strukturach możliwie odpowiadającym rzeczom i zjawiskom.

Dlatego nasze funkcje będą przyporządkowywać kształtom, kątom i wektorom inne kształty, ale idea jest taka sama — dane wejściowe, wzór, wynik. Fakt, że one i tak sprowadzają się do liczb i list w większości przypadków jest zaniedbywalny (bo gdy raz napiszemy funkcje działające na tak niskim poziomie abstrakcji, możemy ich używać w nieskończoność, lub też możemy użyć gotowego programu, który kupimy lub ktoś udostępnił do ogólnego użytku).

W ten sposób doszliśmy do pojęcia funkcji czystej (pure function). W programowaniu przyjęło się nazywać funkcją dowolny zbiór działań, który na podstawie argumentów zwraca jakąś wartość, jednak bez gwarancji, że te same dane zawsze dają ten sam wynik. Mogą również mieć skutki uboczne (np. zapis pliku na dysku, narysowanie czegoś na ekranie, czy ściągnięcie pliku z internetu). W tym kursie jednak będziemy się trzymać funkcji czystych, mają one wiele zalet — są łatwiejsze w utrzymywaniu, łatwiej je analizować i dzielić tak by niezależne części zadania mogły być wykonywane jednocześnie (na różnych rdzeniach tego samego procesora, czy nawet różnych komputerach). Takie programowanie (funkcyjne, w odróżnieniu od imperatywnego) to kwestia nawyku, lepiej od razu od niego zacząć. Znacznie łatwiej jest się przestawić z programowania funkcyjnego na imperatywne niż na odwrót.

Ponadto, użycie funkcji łatwo jest umieścić w naszym kodzie, podobnie jak dotąd używaliśmy zmiennych. Podobnie jak one, choć w znacznie większym stopniu pozwalają nam zastępować duże porcje mało zrozumiałego kodu, więcej mówiącymi nazwami (choć dawanie dobrych nazw też jest umiejętnością, którą się rozwija z czasem i procentuje nie tylko w programowaniu). Wywołanie funkcji będziemy zapisywać tak:

przesuń obraz (5, 5)

aby uzyskać zbiór punktów kształt przesunięty o wektor (5,5). Co jednak typowe dla programowania funkcyjnego, wartość w zmiennej kształt się nie zmieni.

Znowu zapis

Ze względu na nowe wymagania, zmieniamy trochę zapis. Zacznijmy od powyższego wywołania funkcji. Najpierw następuje nazwa, potem argumenty. Ze względu na to, że argument drugi — wektor przesunięcia — jest zapisany jako wyrażenie 5,5, musi być ujęty w nawias, bo skąd byłoby wiadomo, czy przecinek odnosi się do piątki czy całego wywołania funkcji? Często pewnie dałoby się dojść metodą prób i błędów, ale to dość uciążliwy i mało wydajny sposób. W ogóle, takie jest znaczenie nawiasu — powoduje, że objęte wyrażenia są rozpatrywane najpierw, a potem wstawione w to miejsce.

Nawiasy kwadratowe zastąpiliśmy nawiasami okrągłymi, bo już nie są potrzebne (wcześniej były wymogiem, żeby przeglądarka mogła je łatwo interpretować). Za to stały się potrzebne średniki. Oddzielają one wyrażenia. Wcześniej każde wyrażenie miało taką samą formę — nazwa, znak równości, wartość. Teraz jest większa dowolność. Nawet jeśli definiujemy zmienną, której przypisujemy opis kształtu, możemy dalej dopisać plus i kolejny kształt. Wówczas już nie tak łatwo dojść…

Ćwiczenia

Poniżej przykład . Zobacz jak zmiana współrzędnych w drugim argumencie funkcji przesuń przesuwa obrazek.

Błąd: brak canvas!
Javascript nie działa!

Na powyższym przykładzie spróbuj:

  1. Zamiast funkcji przesuń, wypróbuj funkcję obróć — drugi parametr to kąt wyrażony w stopniach.
  2. Narysuj coś przy pomocą myszki. Zobacz co się stanie. Jak sprawić, żeby dodany w ten sposób kształt również był przesunięty?
  3. Wykonaj przesunięcie i obrót kształtu jednocześnie.

Pamiętaj, że jeśli argument funkcji jest złożonym wyrażeniem (lub liczbą ujemną), należy ją umieścić go w nawiasie. Odpowiedzi w dalszej części.

Łączenie funkcji

Działanie funkcji można łączyć tak, że wynik jednej funkcji staje się argumentem dla innej. Na przykład, jeśli chcemy jednocześnie przemieścić i obrócić obraz:

przesuń (obróć obraz 90) (100,100)

lub

obróć (przesuń obraz (100,100)) 90

W tym przypadku nie ma znaczenia, którą funkcję wykonamy jako pierwszą. Jednak nie zawsze tak jest. Wystarczy, że wykonamy obrót względem danego punktu (u nas, jeśli punkt nie jest podany, przyjmuje się środek kształtu, a on się przesuwa razem z kształtem). Możemy to łatwo zaobserwować dodając do siebie wyniki tych funkcji:

obróć (przesuń obraz (100,100)) 90 (300,300)
    + przesuń (obróć obraz 90 (300,300)) (100,100)

Jeśli spróbujemy zrobić podobnie z poprzednimi dwiema funkcjami pokryją się one ze sobą.

W ten sposób, łącząc ze sobą podstawowe funkcje możemy tworzyć co raz bardziej złożone działania. Dlatego dobrze jest, pisząc funkcje (o tym za chwilę), dawać im możliwie uniwersalną formę (choć w praktyce często tego nie robimy ze względu na prostotę lub wydajność takich funkcji).

Jest jeszcze jeden zasadniczy sposób łączenia funkcji ze sobą, ale najpierw musimy nauczyć się deklarować własne funkcje.

Deklarowanie funkcji

Najogólniej rzecz biorąc, funkcja jest dowolnym sposobem na przyporządkowanie wyników dla różnych zestawów argumentów. Jednak w programowaniu, najczęściej, funkcję wyraża się poprzez wyrażenie, które pozwala obliczyć taką wartość wynikową. Innymi słowy jest tylko sposobem na zastąpienie zestawu działań nazwą funkcji. Na przykład:

turlaj = kształt droga -> przesuń 
                            (obróć kształt (droga * 10))
                            (droga*5, 0)

Kluczowy jest tu operator ->. Po jego lewej stronie znajdują się nazwy parametrów funkcji (tu kształt i droga, bo turlaj = ma wyższy priorytet i przypisuje tę funkcję do nazwy turlaj), a po prawej przekształcenie, które funkcja na nich wykonuje.

Zatem, funkcja najpierw obraca kształt o 10 stopni na każdy piksel drogi. Potem przesuwa ten kształt o określoną (droga) liczbę pikseli w prawo (lub w lewo, jeśli liczba jest ujemna). Spróbuj jak działa.

Funkcje jako argumenty

Drugim sposobem łączenia funkcji jest przekazywanie funkcji jako argument dla innej funkcji. To bardzo potężny sposób pozwalający uczynić nasze funkcje bardziej ogólnymi i wszechstronnymi.

Najbardziej podstawową funkcją, działającą w ten sposób, którą znajdziemy w chyba w każdym współczesnym języku programowania, jest przyporządkowanie (ang. map). Jest to działanie na liście, przy użyciu funkcji. Oblicza ona wynik danej funkcji dla każdego elementu listy i zwraca listę tych wyników, w takiej samej kolejności. Stąd też wymaganie, żeby funkcja przyjmowała jeden argument (choć w niektórych językach może być to rozszerzone).

Rozważmy taki kod:

lista
  (mapuj {x -> obróć (350,150) (x * 6) (350,250)}
         (odlicz 61))

Znaczenie nawiasu klamerkowego jest podobne do zwykłego nawiasu, tyle tylko, że ma najwyższy priorytet rozpatrywania. Myślę, że na razie nie jest potrzebne wchodzić głęboko w tę kwestię. To temat odrębny artykuł.

Warto zauważyć, że w tym wypadku funkcja obróć obraca tylko jeden punkt wokół punktu. Funkcja odlicz zwraca kolejne liczby naturalne od 1 do podanej danej w parametrze (w tym przypadku 61). Co zatem może oznaczać takie wyrażenie? 6x60=360 stopni, więc pierwszy i ostatni to ten sam punkt. 60 punktów każdy będący obrotem o kolejne 15 stopni wokół tego samego punktu. Sprawdź i czytaj dalej.

Technicznie rzecz biorąc, sześćdziesięciokąt foremny, praktycznie okrąg wychodzi. Skoro cały czas obracamy punkt wokoło tego samego punktu, funkcja mapuj daje nam kolejne punkty leżące na tym samym łuku. Wynik tego działania musi być opakowany w jeszcze jedną listę, ponieważ to jest opis jednej linii, a program wciąż oczekuje ode mnie listy takich opisów.

Czy jesteś w stanie przewidzieć co się stanie jeśli zmienimy kąt obrotu na x*130? Co się stanie jeśli będzie to x*120? Spróbuj. Jeśli dalej nie wiesz, próbuj zmieniać argument odlicz od jedynki wzwyż, wszystko stanie się jasne. Całkiem ciekawe jak zmiana nawet o jeden stopień powoduje dużą różnicę, polecam prześledzić kąty od 130-135. Co będzie jeśli uzależnić współrzędne środka obrotu od x? Mi się podoba na przykład:

lista
  (mapuj {x -> obróć (350,150) (x * 121) (200 + 3*x,250)}
         (odlicz 61))

Oczywiście można też przesuwać środek punktu obracanego. Co wtedy? A no tak:

lista
  (mapuj {x -> obróć (350,240-x/10) (x * 6) (350,250)}
         (odlicz 2500))

Podsumowanie

Zrobiliśmy olbrzymi krok. Jedna prosta idea, bardzo zasadniczo zmieniająca warunki pracy. Zaczęliśmy robić już coś co można nazwać programowaniem i dalej będziemy głównie eksplorować różne zastosowania i konsekwencje tych prostych pomysłów.

Zobaczyliśmy jak pozornie niezwiązane ze sobą działania sprowadzały się do przekształcania wartości za pomocą określonego wzoru. Zobaczyliśmy jak proste działania można łączyć ze sobą w bardziej złożone. Wreszcie zobaczyliśmy jak złożenie 4 prostych funkcji i działań matematycznych pozwala uzyskać bardzo wiele różnych, bardzo ciekawych efektów wizualnych.

W dalszej części zobaczymy jeszcze więcej ciekawych obrazków pokazujących jak odbicie działań matematycznych może tworzyć urzekające wzory (i nie tylko), stopniowo wprowadzając nowe zagadnienia techniczne.

W następnym odcinku, poświęcimy więcej czasu kompozycji, na bardziej złożonych przykładach.