wtorek, 10 stycznia 2012

O czystości języków programowania

Dzisiaj przybliżę nieco pojęcie czystości w odniesieniu do języków programowania, a później do samego Haskella. Określenia "czysty" używa się do dwóch typów języków: funkcyjnych oraz obiektowych. Zacznę może od tych drugich, gdyż jest to łatwiej wytłumaczyć.

Otóż czysty język obiektowy to taki, w którym każdy element języka jest albo obiektem, klasą albo metodą (czyli funkcją w klasie) albo atrybutem klasy (czyli zmienną w klasie). Co to oznacza w praktyce? Oznacza to tyle, że nawet instrukcja warunka if jest metodą w jakiejś klasie. Nie będę się nad tym dużo rozpisywał, podam jedynie, że przykładem takiego języka jest SmallTalk.

W językach funkcyjnych natomiast sprawa rozbija się o funkcje. Określenie danego języka językiem czysto funkcyjnym sugeruje, że każdy element języka jest funkcją. Co to oznacza? Oznacza to tyle, że funkcja jako argument przyjmuje funkcje a to sprawia, że nie możemy ich traktować jak zmienne (wyrażenie a :: Int nie jest zmienną tylko funkcją, która nie ma żadnych argumentów a zwraca typ Int). Nie możemy również tworzyć pętli, z uwagi na to, że w świecie funkcyjnym nie mamy kolejności wykonywania obliczeń. W związku z tym nie możemy obliczyć jednej rzeczy, a później przeskoczyć do linijki wyżej (funkcje potrzebne do obliczenia linijki wyżej mogły nie zostać jeszcze obliczone).

Jest jeszcze jeden aspekt najczęściej źle rozumiany. Funkcja nie może mieć efektów ubocznych. Co to są efekty uboczne? Ano są to niespodziewane rezultaty wykonania funkcji. Kluczowe jest słowo "niespodziewane". Na przykład weźmy sobie taką funkcję: toInt :: String -> Int. Zastanówmy się czego możemy się spodziewać po tej funkcji a czego nie. Patrząc po nazwie i jej typie spodziewamy się, że funkcja sparsuje sobie łańcuch znaków i zwróci liczbę jemu odpowiadającą. Nie spodziewamy się natomiast, że funkcja wykona zapytanie w bazie danych i zwróci wynik tego zapytania (np. "SELECT '123'::integer"). Niespodziewane jest również wypisanie czegoś na ekranie bądź narysowanie okienka czy cokolwiek innego. Zatem język czysty funkcyjnie nie powinien na to zezwalać.

Powszechne jest twierdzenie, że problem efektów ubocznych rozwiązano w Haskellu monadami. To nie do końca jest prawda. Za pomocą monad rozwiązano problem z IO, a nie same efekty uboczne jako takie. Przypuśćmy kontynuując przykład przytoczony powyżej, że chcielibyśmy napisać funkcję toIntDirty, która dokonywała by konwersji stringa na liczbę stosując zapytanie do bazy danych pokazane wyżej. Musielibyśmy więc sprawić aby patrząc nad typ tej funkcji spodziewalibyśmy się tej sytuacji. Trzeba więc to jakoś zaznaczyć. W Haskellu stosuje się w tym celu opakowanie danego typu w inny. Robi się to np. w taki sposób:

newtype Dirty a = Dirty a

Zatem nasza funkcja miała by typ toIntDirty :: String -> Dirty Int. Ktoś może zapytać co stoi na przeszkodzie aby stosować tą samą technikę w językach imperatywnych? Nic. Problem jest w tym, że kompilator tego na nas nie wymusi (bo język tego nie wymusza). Zauważmy, że funkcja, która wywołuje naszą funkcję toIntDirty musi również zaznaczyć, że podczas jej wykonania mogą się dziać "brudne" rzeczy. Haskell dlatego właśnie jest czystym językiem, gdyż można to wymusić. Dla jaśniejszej sytuacji weźmy taki kod w C:

typedef struct { int result; } DirtyInt;

DirtyInt toIntDirty (const char* text);

int toInt (const char* text)
{
    return toIntDirty(text).result;
} 

Ten kod jest w pełni legalny w C i nie ma możliwości aby wymusić na programiście, że funkcja toInt powinna zwracać DirtyInt, ponieważ w środku wykonuje "nieczystą" funkcję. W Haskellu natomiast, tak jak już to wspominałem, można wymusić aby programista nie mógł się "pozbyć" typu Dirty jak już go użyje. Robi się to tak:

module Type.Dirty where 
    (
      Dirty
    )

newtype Dirty a = Dirty a

Od teraz aby użyć typu Dirty musimy zaimportować moduł Type.Dirty. Zauważmy, że przy where nie napisałem Dirty(..) lecz Dirty. Zapis z kropkami powoduje wyeksportowanie wszystkich konstruktorów danego typu. Gdybym tak zrobił to legalny byłby zapis:

module Test where

import Type.Dirty

cleanDirty :: Dirty a -> a
cleanDirty (Dirty a) = a

a tego nie chcemy. W momencie jednak gdy nie ma tych kropek to powyższa funkcja się nie skompiluje. Tworzy to jednak kolejne problemy. Otóż pozostawiając to w ten sposób nie będziemy mogli wywołać żadnej "czystej" (mam tu na myśli bez typu Dirty) funkcji. Dla przykładu weźmy wspomnianą wcześniej funkcję toIntDirty, która zwraca typ Dirty Int. Chcielibyśmy teraz przemnożyć tego Inta przez, dajmy na to, liczbę 2. Nie da się. Nie zadziała np. coś takiego:

multiply :: Dirty Int -> Dirty Int
multiply a = a*2

ponieważ funkcja (*) nie działa na typie Dirty Int. Nie zadziała również to:

multiply2 :: Dirty Int -> Int
multiply2 (Dirty a) = a*2

z powodu wspomnianego wyżej (nie wyeksportowany konstruktor). Aby rozwiązać ten problem z pomocą przychodzą nam właśnie monady. Umożliwiają one dwie rzeczy. Po pierwsze pozwalają zachować kolejność obliczeń, a po drugie umożliwiają nam wywoływanie "czystych" funkcji na opakowanych typach. Monady reprezentowane są przez klasę Monad, której instancję sobie napiszemy do naszego typu Dirty:

module Type.Dirty where 
    (
      Dirty
    )

newtype Dirty a = Dirty a

instance Monad (Dirty a) where
    return a = Dirty a
    (Dirty a) >>= f = f a

Klasa Monad zawiera w sumie cztery funkcje ale do utworzenia instancji wystarczy tylko zdefiniować return oraz >>=. Funkcja return opakowuje zwykły typ w Dirty, np. return (10::Int) w naszym przypadku spowoduje utworzenie typu Dirty Int. Ciekawsza jest funkcja >>=. Ma ona typ Monad m => m a -> (a -> m b) -> m b co oznacza, że odpakowuje ona na chwilę z typu Dirty i przekazuje odpakowany typ (np. gdy mamy typ Dirty Int to odpakowany typ to Int) do funkcji f, a wynik tej funkcji musi być typu Dirty (ale nie koniecznie tego samego). W ten sposób możemy teraz napisać zdefiniować nasze mnożenie:

module Test where 

import Type.Dirty

toIntAndMultiplyDirty :: String -> Dirty Int
toIntAndMultiplyDirty text = toIntDirty text >>= return . (* 2)

W toIntAndMultiplyDirty tworzymy z tekstu liczbę i mnożymy ją przez 2 stosując w tym celu "czystą" funkcję (*). No dobrze, a w jaki sposób monady pozwalają osiągnąć zachowanie kolejności obliczeń? Bardzo prosto. Zauważmy, że w ciągu wywołań funkcji (>>=) każda funkcja po prawej stronie wymaga jako argumentu wyniku funkcji stojącej po lewej. Zatem zawsze zostanie wymuszone liczenie wartości wyrażenia zgodnie z kolejnością od lewej do prawej.

Wracając do IO, właśnie kolejność obliczeń była kluczowa. Wyobraźmy sobie, że chcemy wypisać dwie linijki jedna pod drugą. Gdyby typ IO nie był monadą wymuszającą kolejność wykonywania akcji to nie byłoby jasne i zgodne z intuicją, który napis wyświetli się jako pierwszy. Często natrafiałem na tezę, że funkcja nie jest czysta, ponieważ "kontaktuje się" ze światem zewnętrznym (np. wypisuje coś na ekranie). Otóż funkcja nie byłaby czysta, gdyby efekty jej wykonania były niespodziewane. Jeżeli jakaś funkcja zwraca typ IO to spodziewamy się, że kontaktuje się ona ze światem zewnętrznym, dokładnie tak samo spodziewamy się, że jak jakaś funkcja zwraca typ String to wykonuje ona operacje na łańcuchach znaków.

2 komentarze:

  1. Całkowita racja jeśli mówimy o programowaniu i czystości kodu jak i całej składni. Przyjmijmy, że firmy jak https://craftware.pl zajmujące się profesjonalnym pisaniem oprogramowania robiłyby to w bardzo brzydki sposób pozostawiając kod dość mocno nie przejrzysty. Gdyby doszło do poprawek to wtedy bardzo ciężko byłoby odnaleźć co gdzie jest.

    OdpowiedzUsuń