Elementarz V: Jak radzić sobie ze złożonością i chaosem?

Do tej pory, było bardzo pozytywnie. W ostatnim odcinku, zaczęła rosnąć nam złożoność rozwiązania. To już był poziom złożoności, przy którym nawet doświadczony programista strzeli kilka byków (ja w każdym razie zrobiłem), a co dopiero przy realnych programach, które mają wielokrotnie większą złożoność! Radzimy sobie z tym dzięki temu, że znaczną część nauki o programowaniu stanowią techniki trzymania złożoności pod kontrolą. Poza tym, mamy narzędzia, których umiemy używać, żeby odnajdywać błędy.

Żeby skutecznie programować, potrzebujemy obu! Nawet najlepsze narzędzia nie pomogą jeśli nie trzymamy skomplikowania kodu pod kontrolą, a trzymanie złożoności pod pełną kontrolą (za taką uważam formalny dowód poprawności kodu) zwykle jest zbyt kosztowne, żeby wchodziło to w grę.

Pierwszy krok dla trzymania złożoności pod kontrolą już zrobiliśmy. Stosujemy czyste funkcje (tj. ich wynik zawsze jest taki sam dla tych samych parametrów), a zmienne nie mogą zmieniać swoich wartości. Wywołanie funkcji jest tylko podstawieniem wartości obliczoną na podstawie wzoru. To znacznie ułatwia wnioskowanie o działaniu kodu. Teraz zobaczymy co robić, kiedy coś działa nie tak.

Nieudana spiralka

Powiedzmy, że chciałem narysować spiralkę. Zgodnie z dobrym zwyczajem, zaczynam od minimalnego kodu, a potem na bieżąco dopisuję kolejne aspekty. W obecnej sytuacji jestem w bardzo komfortowej sytuacji, bo mogę natychmiast zobaczyć efekt, więc mogę na żywo, metodą prób i błędów dochodzić do rozwiązania.

Pierwsza próba

Tak więc, zaczynam oczywiście od okręgu, który potem będę chciał zmodyfikować, żeby zamienić go w spiralę. Tak więc zaczynam:

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

Jak zrobić spiralę? Wystarczy, że będę przybliżał obracany punkt (100,100) do  środka (200,200). Czyli tak:

spirala = środek promień długość ->
    lista (
        mapuj {n -> {
                 x = środek 1 + promień - n/5;
                 obróć (x, środek 2)
                       (n * 7)
                       środek
              }}
              (odlicz długość));

spirala (340,220) 200 100

wypróbuj!

Sprawdzenie

I co? Wygląda obiecująco. Odliczymy (trzeci parametr funkcji spirala) do 500, 1000, jest dobrze. 1500? Ups… Co się stało? Trochę dziwna spirala. Można się domyśleć, ale żeby zasymulować prawdziwie trudną sytuację, powstrzymajmy się. Zrobimy najbardziej podstawową rzecz, jaką możemy zrobić. W wywołaniu funkcji obróć, zmień x na śledź x, a w linii statusu pojawią się wszystkie wartości tego wyrażenia. Co z nich wynika?

Myślę, że obserwując wartości, szybko zobaczymy, że w pewnym momencie wartość x spada poniżej 340. Dlatego spiralka najpierw, zgodnie z oczekiwaniami zawija się do środka, ale po przekroczeniu punktu krytycznego, zaczyna się znów rozwijać w drugą stronę.

Funkcja śledź nie jest do końca czysta, ponieważ generuje skutek uboczny, odkłada wartość wyrażenia na liście, która jest potem wyświetlana osobno. W tym przypadku nie ma to jednak znaczenia, ponieważ program nie ma dostępu do tej listy. Lista nie wpływa na działanie programu. Dlatego taki skutek uboczny nie utrudnia zrozumienia programu. Wręcz przeciwnie.

Bardziej zaawansowane śledzenie

Jeśli chcemy śledzić wartości złożone, powinny być one objęte nawiasem klamrowym. Wynika to z natury działania nawiasu okrągłego — oblicza wartość i wstawia w danym miejscu. Dlatego, jeśli napiszemy śledź (n+1), (n+1) zostanie wykonane przed przesłaniem do funkcji śledź i funkcja nie będzie mogła przyporządkować wyniku do bardziej ogólnego wzoru.

Nawias klamrowy tylko grupuje działania, ale odwleka ich wykonanie jak tylko się da. Dlatego, funkcja śledź dostanie parametr w formie wyrażenia, którego będzie mogła użyć do pogrupowania wyników.

Czasami też może się zdarzyć, że wyniki są bardzo trudne do ogarnięcia, wtedy może być przydatne wykonanie jakiegoś przekształcenia na każdej wartości. Choćby gdybyśmy chcieli śledzić współrzędne kolejnych punktów w okręgu:

okrąg = środek promień->
    lista (
        mapuj {n -> (śledź {obróć (środek 1 - promień, środek 2)
                                 (n * 7)
                                 środek})}
            (odlicz 53));

okrąg (340,220) 200

wypróbuj!

Zarejestrowane wartości są trochę trudne do czytania:

[140,220], [141.4907696717356,195.6261313189705], [145.9408547448007,171.615
62088006644], [153.28391470055965,148.32641009093993], [163.4104814282146,12
6.10568744282183], [176.16959114220165,105.28471272979078], [191.37103490452
114,86.17387872822835], [208.78819420189853,69.0580839554456], [228.16141930
585064,54.19248548899165], [249.20190005209065,41.79869516232645], [271.5959
7133486625,32.06147584281834], [295.009789131227,25.125987042952943], [319.0
943073464693,21.095620926345333], [343.4904812874567,20.03046096872174], [36
7.83462019201306,21.946386251685936], [391.76380902050414,26.81483474218635]
, [414.92131868318245,34.56322908664251], [436.9619240492674,45.076058572120
84], [457.5570504584946,58.19660112501052], [476.39967201249965,73.729259676
1659], [493.2088886237956,91.44247806269212], [507.7341135890848,111.0721929
969946], [519.7588092598335,132.32577064218452], [529.1037151198634,154.8863
691085686], [535.6295201467611,178.41766183644813], [539.2389396183491,202.5
688514504683], [539.8781654038191,226.97989934050017], [537.5376681190276,25
1.28689300804615], [532.2523391876638,275.1274711633998], [524.1009706904881
,298.1462256978547], [513.2050807568877,320], [499.7271020094586,340.3630046
304096], [483.8679600677302,358.93167409179944], [465.86407820996743,375.429
1922913942], [445.983852846641,389.6096192312852], [424.52365234814,401.2615
5740733], [401.8033988749895,410.21130325903073], [378.1617990753091,416.325
4366895328], [353.9512947488251,419.51281005196483], [329.5328087514112,419.
7259069509148], [305.270364466614,416.96155060244166], [281.52565905545265,4
11.2609511926071], [258.6526713848399,402.70909152852016], [236.992385017989
16,391.4334601404224], [216.8677049348683,377.60215072134434], [198.57864376
269052,361.4213562373095], [182.39784927865568,343.13229506513176], [168.566
53985957757,323.00761498201086], [157.2909084714798,301.34732861516], [148.7
3904880739286,278.47434094454724], [143.03844939755842,254.72963553338627], 
[140.27409304908522,230.46719124858888], [140.48718994803514,206.04870525117
497]

Dzieje się tak dlatego, że do wykonania obrotu używamy funkcji trygonometrycznych, które zwykle dają takie wyniki. Natomiast, funkcja obróć ich nie zaokrągla (choćby ze względu na dokładność ewentualnych, dalszych przekształceń).

Wystarczy dodać dodatkowy parametr:

okrąg = środek promień->
    lista (
        mapuj {n -> (śledź {obróć (środek 1 - promień, środek 2)
                                 (n * 7)
                                 środek}
                           {x -> mapuj {n -> jedności n}
                                       x})}
            (odlicz 53));

okrąg (340,220) 200

wypróbuj!

I mamy ładne wyniki, zaokrąglone do jedności:

[140,220], [141,195], [145,171], [153,148], [163,126],
[176,105], [191,86], [208,69], [228,54], [249,41], [271,32],
[295,25], [319,21], [343,20], [367,21], [391,26], [414,34],
[436,45], [457,58], [476,73], [493,91], [507,111], [519,132],
[529,154], [535,178], [539,202], [539,226], [537,251], [532,275],
[524,298], [513,320], [499,340], [483,358], [465,375], [445,389],
[424,401], [401,410], [378,416], [353,419], [329,419], [305,416],
[281,411], [258,402], [236,391], [216,377], [198,361], [182,343],
[168,323], [157,301], [148,278], [143,254], [140,230], [140,206]

Bez porównania, prawda?

Naiwne rozwiązanie

Wracając do głównego wątku, dowiedzieliśmy się, że błędem było założenie, że wystarczy przesuwać punkt obracany w kierunku środka obrotu, bo jeśli będziemy to robić odpowiednio długo, przekroczymy ten środek i wtedy zacznie nam powstawać druga spirala nakładająca się na pierwszą. Co z tym zrobić?

Oczywiście, najprostszym sposobem będzie odwrócić kierunek. Zacząć od punktu środkowego i oddalać go od punktu początkowego. Wówczas nigdy nie będzie tak, że spirala zacznie się rozwijać w drugą stronę.

spirala = środek długość ->
    lista (
        mapuj {n -> {
                 x = środek 1 - n/5;
                 obróć (x, środek 2)
                       (n * 7)
                       środek
              }}
              (odlicz długość));

spirala (340,220) 1500

wypróbuj!

Tyle tylko, że to jest zasadniczo inna funkcja, bo nie pozwala wprost określić maksymalnego promienia spirali, co może być przydatne. Dodatkowo, powyżej pewnej długości (ok. 2200) już zasadniczo nie ma innego kształtu, ale program wykonuje się dłużej.

Prawdziwa przyczyna problemu

Co zatem powinniśmy zrobić? Prawdopodobnie zastanowić jak przebudować oryginalną funkcję, żeby problem nie mógł wystąpić, innymi słowy ułożyć ją tak, żeby wartość x nigdy nie przekroczyła granicy.

Co możemy zrobić, żeby rozwiązać ten problem? Choćby uzależnić zmianę x od ilości kroków i ostatecznej długości. Tak, żeby zawsze był osiągnięty środek.

spirala = środek promień długość -> {
    krokx = promień/długość;

    lista (
        mapuj {n -> {
                 x = środek 1 + n * krokx;
                 obróć (x, środek 2)
                       (n * 7)
                       środek
              }}
              (odlicz długość))
};

spirala (340,220) 200 200

wypróbuj!

To już wygląda znacznie lepiej. Jak widzimy, gęstość spirali się dobrze dostosowuje do zadanej długości. Czy to już na pewno wszystko? A co jeśli dam długość 5? Czy powinniśmy akceptować taki wynik?

Na tą chwilę, rozsądek pewnie podpowiada, że czemu nie. Doświadczenie programisty mówi co innego, ponieważ komponując funkcje, raczej nie myślimy o sytuacjach wyjątkowych, dlatego należy pisać je tak, żebyśmy nie musieli o nich myśleć.

Póki co, ewentualny błąd nie będzie kosztowny, ale wraz ze złożonością programu, nietypowe zachowania kolejnych funkcji będzie prowadzić do co raz mniej oczywistych sytuacji. Po przekroczeniu pewnego progu, program może zachowywać się, w niektórych przypadkach, właściwie chaotycznie. Dlatego zachęcam do rozpoczęcia od dobrych nawyków.

Możliwie dobry parametr

Zauważyliście, że, właściwie, parametr długość trochę zatracił swoje znaczenie, bo nie określa wprost długości spirali? Nawet jeśli damy długość 1, spirala nie będzie mieć mniejszej długości niż promień.

Może lepiej byłoby określić „gęstość” spirali, a ściślej ujmując, ilość punktów, które przetnie odcinek od środka do pierwszego punktu spirali. Czy wiesz jak to zrobić? Podpowiem, że kluczowy jest tu element, na który dotąd nie zwróciliśmy większej uwagi — kąt o który obracamy punkt z każdym kolejnym punktem.

Jeśli pomnożymy ten kąt przez ilość punktów w spirali (dotychczasową „długość”), to uzyskamy pełen kąt zatoczony przez spiralę. Gdy podzielimy tę wartość przez 360, uzyskamy ilość okrążeń, jakie zatoczy spirala.

Innymi słowy, wystarczy pomnożyć gęstość przez ilość punktów potrzebnych by osiągnąć pełen obrót i przyjąć tę wartość za długość całej spirali. Żeby ułatwić obliczenia, zmienię kąt zmieniany w każdym kroku na 6, które dzieli 360 na 60.

spirala = środek promień gęstość -> {
    d = 60 * gęstość;
    krokx = promień/d;

    lista (
        mapuj {n -> {
                 x = środek 1 + n * krokx;
                 obróć (x, środek 2)
                       (n * 6)
                       środek
              }}
              (odlicz d))
};

spirala (340,220) 200 10

wypróbuj!

Teraz parametr gęstość trochę lepiej odpowiada rzeczywistości. Też, wydaje mi się, że bardziej odpowiada temu, co nas interesuje gdy chcemy narysować spiralę.

Pytanie tylko, czy ta funkcja nie będzie mieć nieprzewidywanych działań? Niestety, nadal będzie mieć. Wystarczy, że podamy gęstość mniejszą niż 1/60, wówczas d będzie mniejsze od 1, co spowoduje, że funkcja odlicz zwróci pustą listę, co poskutkuje tym, że nie pojawi się żaden kształt. Rozwiązanie tego problemu za chwilę.

Wnioski

Okazuje się, że już nawet poprawne napisanie stosunkowo prostej funkcji, rysującej spiralę, pozostawia sporo miejsca na błędy wynikające z uproszczonego myślenia (za pierwszym razem sam go zrobiłem, pomimo 15 lat zajmowania się programowaniem). Poznaliśmy też sposób na sprawdzenie pośrednich wyników, w celu lepszego zrozumienia, co właściwie zaprogramowaliśmy.

Gdy zrozumieliśmy lepiej naturę problemu, mogliśmy zapisać funkcję tak, żeby pozostawiała mniej miejsca na błąd, jednak nie udało nam się jeszcze wyeliminować zachowań nietypowych, które mogą się kumulować, ostatecznie powodując zachowanie chaotyczne w pewnych przypadkach.

Myślę, że też warto zwrócić uwagę, że taką samą funkcję można zapisać na wiele sposobów. Nie tylko możemy inaczej zapisać wyrażenie, ale też możemy użyć innych parametrów. Niektóre zestawy parametrów mogą być przyjaźniejsze programiście niż inne. Warto o tym pamiętać, projektując funkcje.

Typowanie

Doświadczenie z powyższej próby prowadzi nas do jednego z ważniejszych mechanizmów, których używamy, żeby zapewnić poprawność naszych programów. Mianowicie, chcemy wyeliminować możliwość podania pewnych wartości parametrów, innymi słowy ograniczyć zakres możliwych wartości danego parametru. Taki zakres wartości, w języku programowania, nazywamy typem.

Typowanie, w takiej czy innej formie, jest nieuniknione w programowaniu, mogliśmy się z nim spotkać dotychczas, wpisując wyrażenie: obróć 7 30. Skutkuje to komunikatem: Argument nie jest listą: 7. Mądre używanie typów pozwala zredukować ryzyko nieoczekiwanych zachowań.

W przypadku naszego ostatniego problemu, możemy określić typ w taki sposób:

spirala = środek (promień > 0) (gęstość > 1/60) -> {
    d = 60 * gęstość;
    krokx = promień/d;

    lista (
        mapuj {n -> {
                 x = środek 1 + n * krokx;
                 obróć (x, środek 2)
                       (n * 6)
                       środek
              }}
              (odlicz d))
};

wypróbuj!

Można również określić typ dla środka:

punkt = x -> równe (długość x) 2
             & x 1 : liczba
             & x 2 : liczba;

spirala = (środek : punkt) (promień > 0) (gęstość > 1/60) -> {
    d = 60 * gęstość;
    krokx = promień/d;

    lista (
        mapuj {n -> {
                 x = środek 1 + n * krokx;
                 obróć (x, środek 2)
                       (n * 6)
                       środek
              }}
              (odlicz d))
};

wypróbuj!

To jest najprostszy model typowania, w którym mamy kilka podstawowych typów danych, tu lista i liczba, i kolejne typy tworzymy poprzez tworzenie warunków, które określają czy wartość mieści się w typie czy nie.

Warto w tym miejscu zauważyć, że operator : oznacza wykonanie funkcji podanej po prawej stronie z parametrem podanym wcześniej. Innymi słowy, d:odlicz jest równoznaczne z odlicz d.

Stworzyłem taką konstrukcja z dwóch względów. Po pierwsze, dlatego, że w liście parametrów, przyjąłem, że pierwszy symbol w nawiasie jest nazwą, z tego względu takie odwrócenie składni było potrzebne, żebym mógł wyrazić typ poprzez wywołanie funkcji sprawdzającej. Poza tym, myślę, że znacznie bardziej naturalne jest myślenie, że środek jest punktem aniżeli punktem jest środek.

Innym przypadkiem kiedy takie odwrócenie składni jest bardzo wygodne to kiedy chcemy stworzyć instrukcję warunkową:

lista
  (mapuj { n -> {
             dx = n : {(x % 2 > 0) -> {50}
                        x          -> {-50} };
             obróć (200 + dx,220)
                   (n * 6)
                   (350,220) }}
         (odlicz 61))

wypróbuj!

Widzicie? Funkcję rysującą okrąg zmieniłem tak, że obracany punkt przesuwam, na przemian o 50 lub -50 pikseli (operator % oblicza resztę z dzielenia). Jak widzicie, nic nie stoi na przeszkodzie, żeby podać kilka definicji tej samej funkcji, w zależności od parametru. Prawda, że umieszczenie sprawdzanej wartości po takiej funkcji byłoby bardzo niewygodne? Tak niewygodne, że takiej konstrukcji nie przewidziałem (zwraca błąd).

To jest bardzo prymitywny model typowania, choć w prawie żadnym języku programowania nie można wyrazić takich typów jak w a (b > a) -> b - a. Oczywiście, zamiast tego systemy typów w popularnych językach dają inne ciekawe możliwości, jednak zostaną poruszone w swoim czasie.

Podsumowując

W tej części zobaczyliśmy dwa sposoby radzenia sobie z chaosem i złożonością. Póki pracujemy nad małym kodem, wystarczą nam proste metody. Po pierwsze, zawsze możemy podejrzeć wartości pośrednie, które pojawiają się w danym miejscu w programie. Obserwując wartości jakie się pojawiają w kolejnych miejscach i porównując ze spodziewanymi, możemy wywnioskować w którym miejscu jest błąd i na czym polega.

Jest to bardzo prosty sposób, jednak jak możemy się szybko przekonać, bywa bardzo toporny, gdy nagle jesteśmy zasypani wartościami z setek wywołań, można się łatwo pogubić. Dlatego warto konstruować kod tak, żeby zredukować przestrzeń na błędy. Najbardziej podstawowym narzędziem, którego możemy użyć w tym celu jest typowanie, które pozwala nam, w momencie pisania funkcji, upewnić się że będzie ona wywołana tylko na wartościach, które mają sens.

W ten sposób możemy się zabezpieczyć przed kumulującymi się nieoczekiwanymi zdarzeniami, które prowadzą do zachowań trudnych do przewidzenia (a zatem też trudnych do wyjaśnienia, gdy już się zdarzą). Te umiejętności będą nam potrzebne by stopniowo zwiększać poziom złożoności i móc ostatecznie rozwiązywać prawdziwe problemy.