Na granicy systemu

pawelwlodarski.blogspot.com 7 lat temu

Nie wiadomo czy to miejska legenda czy przypadek prawdziwy ale podobno w Indiach mają taką sztuczkę na łapanie małp : wsadzają banan w butelkę a butelkę zakopują. Teraz małpka wyciąga łapkę, łapie banan zaciskając paluszki i już łapka tam zostaje. Małpka banana nie puści i będzie tak czekała zaklinowana aż kłusownicy po nią przyjdą.

Przykład jest tak absurdalny, iż doczekał się znaczenia metaforycznego kiedy to ludzie chwytają tak swoje myśli, które to wiążą ich w mentalnych klatkach. I ludzie ci tak będą czekać w tej klatce a myśli nie puszczą. Jaki to ma związek z dzisiejszym artykułem. W zasadzie żaden ale to fajna anegdota a zawsze te pierwsze akapity są najtrudniejsze.

Granice Systemu

Dzisiejszy artykuł sponsorują dwie funkcje :

  • toHex : String => Hex
  • createUser : Json => User
Obie w założeniu działają na granicy systemu czyli zupełnie niekontrolowany 'input' zamieniają w typ domenowy. Coś jak na rysunku

Hex i User... z pozoru mają ze sobą kilka wspólnego - tak jak i obydwie funkcje toHex oraz createUser. Ale jest pewna wspólna rzecz, jedna niespodziewana i siejąca spustoszenie w systemach IT rzecz. choćby bez zaglądania do ich definicji można z dużym prawdopodobieństwem rzecz - obydwie te funkcje kłamią!

Program jako funkcja całkowita

Ten akapit będzie streszczeniem innego artykułu : Program jako funkcja całkowita i generalnie chodzi o to, iż bug w systemie jest wtedy jak myślimy iż nasz program-funkcja jest zdefiniowany dla konkretnych danych wyjściowych a nie jest :( . Na przykład nasz program to aplikacja webowa przetwarzająca request w response czyli Request=>Response no i dla requestu z sqlinjection zachowanie poprawne nie było zdefiniowane - czyli na przykład wyświetlenie błędu - i całość zakończyła się katastrofą.

No i teraz jak mamy "miniprogram" Json => User to o ile dla wszystkich błędnego jsona nie zwracamy użytkownika Roman to to nie może działać!!!

Null object (czy coś takiego)

Kiedyś pamiętam był na tapecie taki koncept by nie zwracać z funkcji nuli tylko na przykład puste listy ,puste tablice, puste mapy, puste... puste k**wa cokolwiek. Potem pamiętam serię artykułów, iż ludzie trochę zaczęli przekombinowywać i zwracać "puste" obiekty nie tylko gdy brakowało danych w bazie czy coś w ten deseń ale gdy np. wystąpił błąd. Jest to zamiatanie problemu pod dywan. Bardzo złe. Bardzo niedobre. Co możemy zwrócić jak jest zły JSON? No jeżeli to nie jest na chama robiony CRUD to latający obiekt z "wrong variance" czy jak to się mówi po angielsku - spowoduje tylko kłopoty

Podobnie dla HEX -> "FFAA00" to dobry hex , "DUPA" to zły hex (acz trywialna implementacja String.getBytes obydwa zamieni w poprawny typ z poprawnymi pojedynczymi wartościami ale złym znaczeniem!!!) . I teraz weźmy dwie szkoły. Po pierwsze możemy rozszerzyć rezultat funkcji String => Option[Hex] czy JSON => Option[User]

Druga szkoła to dobrze znane nam wyjątki. Tutaj sztuczka polega na tym, iż blok try-catch niejako ogranicza dziedzine funkcji ale w trochę nieintuicyjny sposób bo albo działamy na poprawnym zbiorze wartości i dostaniemy poprawny rezultat albo działamy na niepoprawnym zbiorze i ... zakrzywiamy czasoprzestrzeń lądując "kiedyś" i "gdzieś".

Problem z null

Na nulla możemy także spojrzeć z perspektywy funkcji częściowej i całkowitej. Otóż jeżeli np. mam funkcje String => Option[Hex] i jest ona całkowita ze względu na każdy element zbioru String to można gwałtownie zamienić ją na częściową wprowadzając null gdyż wtedy domena zamienia się na "każdy element zbioru string i do tego null" czyli aby znowu uzyskać funkcję całkowita potrzebujemy dodać dedykowaną obsługę nulla.

Czas ma znaczenie

Znak zapytania na ostatnim diagramie to bardzo często jakiś tam ErrorHandlingController. To jedno ale druga sprawa to, iż nie za bardzo mamy czas pomyśleć bo to dzieje się teraz!! . Skok w try następuje od razu. W przypadku szkoły pierwszej wspomnianej wcześniej mamy pewien typ. Ten typ mamy tu i teraz i możemy się chwile zastanowić - on nie ucieknie. Innymi słowami try-catch to wykonanie instrukcji a Option[User] czy Option[Hex] to obliczenia w toku. Co więcej obliczenia, które choćby nie muszą być jeszcze wykonane jeżeli mamy leniwy język!

No i mając ten Option mogę stworzyć jego dalsze przetwarzania przy pomocy dostępnych kompozycji jak option.orElse(otherOption) . Przy skokach w try oczywiście też mogę dać obsługę wyjątków ale zwykle nie wynika taka operacja z typów co dla RuntimeException zwiększa ryzyko, iż o tym zapomnimy faktycznie o obsłudze. No i oczywiście sam skok to nie jest jakaś zwracana wartość tylko no... "skok" także nie można tego za bardzo komponować co może zakończyć się tuzinem zagnieżdżonych klamerek przy bardziej wyszukanej obsłudze.

Szkoła dwa_i_pół

W GO popularne jest inne podejście. Często funkcja zwraca potencjalny rezultat i potencjalny błąd przez co jest całkowita.

func aleBezpieczneDzielenie(a, b float64) (float64, error) {
 if b ==
0.0 {
  return
0.0,errors.New("oj neidobrze")
 } else {
  return a/b , nil
 }
} 
Problem z tym przykładem jest taki, iż pomimo, iż potencjalne - to jednocześnie wartość i błąd musza być konkretne stad mimo wszystko zwracane jest 0.0 co jest dziwne - no i w sumie to ode mnie zależy czy błąd sprawdzę czy nie także przez cały czas może się popsuć. Oczywiście nie znam tak dobrze jeszcze tego języka i być może jakaś opcja jeszcze gdzieś tam inna istnieje by to inaczej obsłużyć.

Szkoła trzecia - siła typów

Zaczęliśmy od A=>B by dojść do teoretycznie bezpieczniejszego A=>Option[B] czyli bezpiecznie przyjąć dane z zewnątrz systemu

  • intoSystem: String => Option[Hex]
  • intosystem: JSON => Option[User]

Ale co gdy mimo wszystko chcemy zachować funkcję String => Hex ? Cóż wtedy, wtedy cóż? Być może to jakaś funkcja biblioteczna a może po prostu nam pasuje tak jak jest? Zawsze można to zrobić to podejściem ze szkoły pierwszej czyli coś w stylu lift : (A=>B) => (A=>Option[B]) ale można też rozwiazac problem od drugiej strony ograniczając dziedzinę do tylko prawilnych User-JSONów lub Hex-Stringów!

Lub na jeszcze innym rysunku zobaczymy porównanie rozwiązania błędnego z funkcją częściową, funkcję całkowitą poprzez rozbudowanie domeny wyniku, oraz funkcję całkowitą poprzez ograniczenie domeny wejścia do bezpiecznego typu

W kodzie by to wyglądało mniej więcej tak:

val validate:
String
=>
Option[SafeHexString]
=
...
val toHex :
SafeHexString
=>
Hex

validate(input).map(toHex)

Podsumowanie

Artykuł zaczęliśmy od śmiesznej anegdoty o małpce złapanej w butelkę (po która przyjdą kłusownicy - czyli może nie tak śmiesznej). Miało to nie być związane z samym artykułem ale jednak będzie. Metafora banana i trzymania się kurczowo nawyków. Gdy Optional pojawił się w Javie wielu programistów, z którymi miałem styczność traktował go jako takiego "wrapper na nulla". "Wrapper na nulla" należy do domeny Javy, być może dobrze czasem wznieść się na bardziej abstrakcyjny poziom by zobaczyć inną istotę używanych konstrukcji. jeżeli komuś nie pasuje metafora z małpką która będzie zjedzona w Indiach to inna opowieść to "Brzydkie Kaczątko" ale trzeba by ją trochę nagiąć by kaczątko stało się monadą niewykorzystanego potencjału.

Idź do oryginalnego materiału