Poczytaj mi Clojure – cz. 2: Stan, tożsamość i zmiana

Clojure jest językiem o solidnych podstawach teoretycznych. W tej części zajmiemy się metafizyką tego dialektu Lispa, a dokładniej definicjami stanu, tożsamości, powiązania i wartości. Pozwoli nam to oswoić się z paradygmatem funkcyjnym i zrozumieć dlaczego niektóre oczywiste czynności wymagają przeprowadzania operacji niestosowanych w innych językach programowania.

Stan, tożsamość i zmiana

Postrzegany przez nas świat jest pełen zmiennych stanów. Możemy zauważyć, jak mijają pory roku, jak z dnia na dzień zmienia się liczba środków na naszych rachunkach bankowych i jak z biegiem lat różnią się nasi znajomi czy przyjaciele. Mimo to potrafimy wskazać co się zmieniło lub kto się zmienił – są takie części obrazu świata, które pozostają dla nas niezmienne i pozwalają nam identyfikować zmieniające się elementy rzeczywistości. Te konstrukty to stałe tożsamości.

Gdy ktoś znajomy zaczyna ubierać się czy zachowywać w nieco inny sposób, to nadal go rozpoznajemy, dokonując aktualizacji zestawu cech powiązanych z jego tożsamością. Kwestią istotną jest jednak to w oparciu o jakie atrybuty konstruujemy tożsamość konkretnej osoby czy przedmiotu. Ta sama kwestia dotyka języków programowania.

Programy komputerowe modelują rzeczywistość i operując na przyjętych modelach, pomagają nam rozwiązywać rzeczywiste problemy. Te odzwierciedlenia mogą być jednak wyrażane na różne sposoby, które charakteryzuje odmienne podejście do kwestii stanu, tożsamości i zmiany. To ogólne podejście nazywamy paradygmatem (ang. paradigm), który dla programisty ma istotne konsekwencje techniczne.

Na przykład w językach funkcyjnych (opartych o funkcyjny paradygmat programowania) nie istnieją zmienne (ang. variables) w konwencjonalnym rozumieniu, a zamiast tego mówi się o tzw. powiązaniach (ang. bindings), czyli sposobach kojarzenia symbolicznych etykiet ze stałymi wartościami, które są rezultatami obliczeń lub podanymi wprost wartościami stałymi.

Z kolei imperatywny paradygmat programowania polega na tworzeniu programów złożonych z instrukcji, które zmieniają stany umieszczonych w pamięci obiektów. Dokonują tego przez modyfikowanie ich zawartości. Jest to odzwierciedleniem sposobu działania komputera – procesor wczytuje rozkazy i wykonuje je, odwołując się do komórek pamięci operacyjnej.

Pochodnymi paradygmatami, które bazują na modelu imperatywnym są programowanie proceduralneprogramowanie strukturalne. Imperatywne podejście możemy też zaobserwować w wielu składnikach obiektowego paradygmatu programowania.

Autystyczny imperator

W podejściu imperatywnym i pochodnych świat programu komputerowego składa się bytów, które mają cechy wyrażone pamięciowymi obiektami o ustalonych lokalizacjach. Wydaje się to analogiczne do tego, co możemy zaobserwować w codzienności: człowiek ma wzrost i wiek, samochód ma koła, drzewo ma liście, liście mają kolor itd.

W kodzie źródłowym model ten wyrażony będzie np. z użyciem zmiennych struktur. Gdy odzwierciedlamy w programie komputerowym zbiór liści na drzewie, to tworzymy np. strukturę Drzewo zawierającą obiekty Liść. Kiedy zbliża się jesień, zmieniamy stan obiektu Drzewo, np. usuwając liście czy modyfikując ich kolor. Przez zmianę stanu rozumiemy pojawienie się nowego stanu w miejsce bieżącego, a nie jego modyfikację. Stany z definicji uznajemy zawsze za stałe, chociaż mogące reprezentować zmienne warunki.

W powyższym procesie na pierwszy rzut oka nie ma nic niespotykanego i wydaje się on całkiem naturalny, dopóki nie staniemy się dociekliwi i nie zaczniemy badać kilku istotnych kwestii: czasu, tożsamościstanu.

Czas

Zacznijmy od czasu. Jako ludzie możemy go nazwać, ponieważ mamy pamięć i zauważamy zmieniające się stany lokalnego świata, bazując na różnicach tego, co odzwierciedlone w pamięci z tym, co uświadomione dzięki zmysłowemu i/lub myślowemu doświadczeniu (w praktyce również umieszczone w pamięci, ale krótkotrwałej).

W przypadku imperatywnego podejścia do programowania, które wiąże się z użyciem zorientowanego imperatywnie języka, jedynym czasem jest tak naprawdę moment bieżący, chociaż w istocie nawet i tak nie można go nazwać, bo aby to zrobić, należałoby go wyróżnić w relacji do innych momentów (przeszłych i przyszłych). Dlaczego tak jest?

Na poziomie wątku zadania imperatywny język programowania cierpi na amnezję, ponieważ dokonuje nadpisywania struktur pamięciowych, aby wyrażać zmiany. Przypomina to trochę pracę roztargnionego artysty malarza, który poprawia tworzony obraz, zmywając pewne części i przykrywając je znów farbą, aby za chwilę zapomnieć, co wcześniej widniało na płótnie.

Zmienna, nawet jeżeli sama nie przechowuje wartości, lecz odnosi się do niej, jest abstrakcyjną szufladką, w której różne wątki programu mogą na przestrzeni czasu umieszczać różne dane. Co jeśli stanie się to w momencie, gdy jakaś część programu nie skończyła pracy z poprzednią zawartością, a zastąpiła ją już nowa?

Nic nie przeszkadza programiście wprowadzać w konkretnych sytuacjach pewne byty zależne od czasu lub chronić obiekty przed zmianami. Blokady zapisu, tzw. zamrażanie obiektów czy stwarzanie ich duplikatów – w wielu nowoczesnych językach możemy to zrobić, lecz nie jest to inherentną zasadą imperatywnej rzeczywistości. Zasadniczo nie ma pod tym względem rygoru ze strony paradygmatu.

Ponieważ czas (jeśli można tak go nazwać w tym modelu) jest funkcją intencji, więc płynie, gdy wprowadzane są zmiany, a zatrzymuje się, gdy ich nie ma. Kiedy wątek wykonywania się programu zostaje wybudzony kolejnym cyklem procesora, “spodziewa się”, że zastanie świat dokładnie takim, jakim go pozostawił. Stąd tak klinicznie brzmiący tytuł sekcji: nie tylko mamy do czynienia z zamknięciem w skończonej rzeczywistości, ale też niemałe kłopoty, jeśli okaże się, że w praktyce jest ona zmienna, bo działa w niej więcej niż jeden architekt.

Stan i zawartość

Stanem hipotetycznego obiektu nazwiemy jego aktualną kondycję, na którą składają się cechy wyrażone przede wszystkim wartością bieżącą. W modelu imperatywnym będzie to zawartość określonego miejsca w pamięci.

Mówimy, że stan zmienił się w inny, gdy dojdzie do zmiany danych rezydujących w danej komórce pamięci lub identyfikowanej w ustalony sposób, na przykład, gdy na modelowanym drzewie ubędzie liści czy zyskają nowy kolor. Dojdzie wtedy do przekształcenia obiektu w miejscu (ang. in-place) jego rezydowania. Przyjęcie nowego stanu jest więc tu związane ze zmianą wartości w konkretnym obszarze pamięci.

Nawet, jeżeli dany język posługuje się przezroczystymi referencjami w celu obsługi zmiennych, to w efekcie będziemy mieli do czynienia z niekoordynowaną i nieatomową operacją zmiany stanu, w której dochodzi do modyfikacji struktury pamięciowej (poprzednia zawartość jest “zapominana”).

Tożsamość

W postrzeganiu i modelowaniu rzeczywistości bardzo istotnym komponentem jest tożsamość (ang. identity).

W świecie ludzkiej percepcji tożsamość jest cechą subiektywną – umysłowym konstruktem, który pozwala odróżniać fragmenty świata, mimo że ich stany się zmieniają. Jeżeli na przykład spotykamy znajomego z dzieciństwa, który zachowaniem i wyglądem różni się od tego, jak go pamiętamy, to nadal pozostaje on kimś znanym, pod warunkiem, że utożsamimy go z cechami, które pozostały stałe, np. imieniem, pseudonimem, specyficznymi nawykami, przynależnością do określonych grup społecznych itp.

Nie potrzebujemy odnajdywać wielu charakterystycznych właściwości, bo nasz umysł będzie ze wszystkich sił próbował podłączyć do aktualnego doświadczenia bazę wspomnień związanych z daną osobą, aby zachować jej tożsamość. Pozwoli to na korzystanie z wcześniejszych doświadczeń w dalszych interakcjach z danym człowiekiem, zamiast od nowa go poznawać. W ten sposób redukujemy napięcie, wiedząc czego można się spodziewać, i wybieramy mniej energochłonną i bardziej komfortową opcję.

Dzięki takiemu generalizowaniu możemy skuteczniej funkcjonować w codzienności, ponieważ jesteśmy w stanie podejmować decyzje w oparciu o ustalone czynniki, zamiast za każdym razem brać pod uwagę setki cech przedmiotów i osób, z którymi wchodzimy w kontakt.

W przypadku imperatywnego paradygmatu tożsamość bazuje na umiejscowieniu obiektu w pamięci lub na umiejscowieniu odniesienia do takiego obiektu. Jest wskazaniem lokalizacji pewnej struktury, dodatkowo powiązanym z nadaną przez programistę nazwą. Zawartość ulega zmianom, lecz dzięki temu, że istnieje stała lokalizacja, możemy traktować obiekt jako byt o ustalonej tożsamości.

Ilustracja składników tożsamości w modelu imperatywnym

Odnosząc się do przykładu z obiektem Drzewo: może on mieć zmienną liczbę liści, zmienną wysokość i zmienny kształt, ale wciąż będzie można go zidentyfikować, ponieważ rezyduje w stałym obszarze lub odniesienie do niego znajduje się w stałym miejscu. Etykieta Drzewo jest nazwą tego obszaru i wraz z nim stwarza tożsamość obiektu.

Stan a tożsamość

Niezmienna tożsamość obiektów jest sposobem na to, aby móc nawiązywać z nimi relacje i sprawnie się nimi posługiwać. Wiemy wtedy czego się spodziewać, tzn. jakie operacje możemy wykonywać i jakich typów cech oczekiwać.

W przypadku zmiany stanu obiektu dochodzi do jego mutacji, ponieważ modyfikacja wartości musi zachodzić w jego strukturze, w jego “tkance”. Na poziomie samej konstrukcji świata imperatywnego nie ma innej możliwości, ponieważ miejsce nie może się zmienić, aby tożsamość nie została utracona. Da się dokonać zmiany pamięciowej lokalizacji, jednak wtedy w gestii programisty będzie leżało wyśledzenie wszelkich odwołań do obiektu i ich aktualizacja.

Wartości

Wartości z natury są niezmienne. Liczba 1 będzie zawsze liczbą 1, a wtorek nie stanie się środą. Istnieją jednak dane takich typów, które pomimo tego, że znaczeniowo pełnią rolę niezmieniających się wartości, w istocie są mutowalnymi obiektami. Na przykład wyrażony w programie komputerowym kolor żółty (reprezentowany obiektem Żółty) może zacząć tak naprawdę odzwierciedlać kolor zielony, gdy dokonamy zmiany w jego wewnętrznej strukturze i przedefiniujemy numerycznie wyrażoną barwę.

Programista jest w stanie ukrywać fakt, że obiekty mogą ulegać mutacjom, hermetyzując je i tworząc stosowne interfejsy, które nie będą wyposażone w operacje wprowadzania zmian. Możemy jednak zauważyć tu pewną niekonsekwencję na poziomie samej reguły. Nie da się od razu odróżnić, które obiekty mogą się zmieniać, a które są typowymi wartościami.

Niejasność wynika z tego, że sposoby obsługi zarówno zmiennych struktur (np. obiektu Drzewo), jak i zasadniczo niezmiennych wartości (liczba 1, napis “Drzewo”), są takie same. Zarówno Drzewo jak i wartość numeryczna 1 będą związane z jakąś nazwą zmiennej. Brak jest jednoznacznie wyrażonej i konsekwentnie stosowanej zasady rządzącej ważnymi cechami wszystkich pamięciowych obiektów.

W efekcie bardzo często w programach imperatywnych nadużywane są dane mutowalne, choć z powodzeniem można by traktować je właśnie jak wartości, których nie można modyfikować. Po prostu bezpieczniej i wygodniej jest zakładać, że każdy obiekt może ulec zmianie. Podejście przeciwne, polegające na wprowadzeniu niemutowalnych wartości, byłoby możliwe, jednak wcześniej należałoby w jakiś sposób uniezależnić tożsamość od umiejscowienia. Gdy tożsamość zależy od lokalizacji obiektu w pamięci, zmiana stanu będzie oznaczała jego mutację.

Wspólny świat

Zastanówmy się nad hipotetyczną sytuacją, w której rzeczywistość imperatywna stwarzana i kontrolowana jest przez więcej niż jednego demiurga. Mamy tu na myśli programy wielowątkowe (ang. multithreaded) i wykonywane współbieżnie (ang. concurrent). Współdzielona jest w nich dostępna aplikacji pamięć, a jeden uruchomiony wątek może “popsuć” rzeczywistość. Jeżeli program nie będzie pisany z uwzględnieniem tego zagrożenia, jego działanie stanie się wadliwe.

Powyżej wspomniany problem łatwo zobrazować sytuacją z pamiętnikiem prowadzonym jednocześnie przez kilka osób. Jedna z nich chce go czytać, druga zamierza dopisać jakąś historię, a trzecia pragnie dodać ilustrację na jednej ze stron. Mamy trzy wątki wykonywania i jedną przestrzeń z danymi. Aby nie pojawiały się konflikty, można na przykład umówić się, że osoba, która operuje na pewnym fragmencie pamiętnika, otrzyma egzemplarz na wyłączność. W tym czasie pozostałe muszą zaczekać. Ten sposób nazywa się blokowaniem (ang. locking) i jest często wykorzystywany podczas dostępu do zasobów w imperatywnych programach wielowątkowych. Problemem w nim bywa kiepska wydajność (polegająca na przykład na konieczności cyklicznego sprawdzania, czy blokada została już zdjęta) i sytuacje tzw. zakleszczeń (ang. deadlocks).

W analogii z pamiętnikiem zakleszczenie mogłoby polegać na tym, że pierwsza osoba zakłada blokadę, druga czeka na nią, trzecia czeka na drugą, a pierwsza nie zauważa, że ma wyłączność i również zaczyna oczekiwać, ale na trzecią – impas. Wszystko spowodowane pierwotnie tym, że każdy z wątków ma absolutną i bezpośrednią władzę nad obiektami znajdującymi się w pamięci. Sam więc, powodowany intencją programisty, musi ją ograniczać, aby nie tworzyć sytuacji konfliktowych.

Konsekwentny operator

Programowanie w paradygmacie funkcyjnym różni się od imperatywnego przede wszystkim tym, co w modelu świata uważamy za istotne. W tym ostatnim, omawianym wcześniej, pracujemy z zawartością obiektów znajdujących się w pamięci – bezpośrednio lub z użyciem odpowiednich interfejsów ukrywających wewnętrzne struktury. W świecie funkcyjnym natomiast skupiamy się bardziej na zarządzaniu procesami (operacjami).

Zamiast imperatora realizującego kolejne rozkazy, mamy do czynienia z operatorem lokalnej rzeczywistości. On również ma na nią wpływ i może dokonywać zmian, ale czyni to w sposób znacznie mniej inwazyjny, z poszanowaniem czasu. Nie modyfikuje danych bezpośrednio, lecz wytwarza kolejne “włókna” rezultatów. Taki sposób działania wynika z konstrukcji całego modelu rzeczywistości.

Operowanie w funkcyjnym świecie bazuje najczęściej na filtrowaniu tych jego fragmentów, które potrzebne są do uzyskania wyników i generowaniu nowych wartości stałych, będących rezultatami tego procesu.

Podobnie jak w podejściu imperatywnym wartości będą zlokalizowane w jakichś pamięciowych przestrzeniach. Różnica polega jednak na tym, że nie jest tak istotne w jakich. Operacja zmiany wartości nie mutuje przestrzeni pamięciowej w destrukcyjny sposób, lecz stwarza nową wartość (również niezmienną), tzn. na podstawie operatora i podanych operandów uzyskujemy wynik. Konsekwencją tego jest zupełnie inny mechanizm utrzymywania tożsamości obiektów – nie można już wiązać ich z umiejscowieniem.

Zmiany stanów

Świat rzeczywistości funkcyjnej przypomina migawki następujących po sobie stanów, gdzie jeden jest kontynuacją drugiego, zaś powodem jest fakt przeprowadzenia jakiejś operacji. Architekt aplikacji przeznacza najwięcej czasu na projektowanie kaskad zależnych od siebie i często zagnieżdżonych funkcji, które wyrażają zmiany.

Wątek programu może mieć dostęp do wszystkich stanów obiektu w czasie, które reprezentowane są powstającymi w wyniku operacji wartościami. Czasem nawet, w przypadku tzw. operacji zwłocznych, nie będą to typowe wartości (zlokalizowane w pewnych miejscach), ale elementy, które potencjalnie mogą dopiero powstać. Spośród szeregu zmieniających się stanów możemy wybierać wartości momentalne, niezbędne do dalszej realizacji celów programu. W analogii z roztargnionym malarzem będzie to zmiana sposobu pracy z zamalowywania poprzednich fragmentów obrazu na tworzenie coraz to nowych i ulepszonych szkiców na osobnych płótnach. Jeżeli któryś szkic spodoba się zamawiającemu obraz, będzie miał czas i sposobność go pooglądać, nie zakłócając twórczej pracy.

Stanem nazwiemy więc bieżącą wartość, którą dana tożsamość przyjmuje w danym czasie.

Niemutowalne wartości

Jeśli za przykład weźmiemy strukturę elementu Drzewo, to w funkcyjnym świecie będzie ona również niezmienną wartością, a nie mutowalną zawartością, do której odnosi się jakaś zmienna.

Wartość będzie przypominała obraz utrwalony na fotograficznej błonie, który oddaje Drzewo w jakimś istotnym momencie istnienia, wyrażając pewien stan. Możemy więc konsekwentnie traktować pamięciowe obiekty jak stałe wartości, bez obaw o nadpisanie ważnych danych.

Pamięć w tym modelu przypomina pamięć ludzką: gdy obserwujemy zmianę, to nie sprawia ona, że nasze wspomnienia są zastępowane, lecz powstają nowe, bardziej aktualne.

Programistów przyzwyczajonych do zmiennych zastanowi jednak, w jaki sposób można opracowywać dane, jeżeli nie ma możliwości bezpośredniego nadpisywania pamięciowych struktur. Czy tylko przez tworzenie kaskad zawartych w sobie wywołań funkcji, albo marnotrawienie miejsca na coraz to nowe rezultaty? Żeby to wyjaśnić musimy bliżej przyjrzeć się jak w rzeczywistości funkcyjnej wygląda proces zmiany.

Zmiana

Zdarzenie, w którym do hipotetycznej kolekcji reprezentującej drzewo dodawany jest element (np. Liść), będzie w istocie wywołaniem pewnej funkcji, która zwróci nową wartość (zmodyfikowane Drzewo). Funkcja przyjmie argument wyrażający jego aktualną strukturę i zwróci jej nową emanację (np. z dodanym liściem). Będzie ona związana z poprzednią przyczyną i skutkiem, a nie miejscem. Łatwo też przewidzieć, że oryginalny obiekt, na którym operowaliśmy, pozostanie w pamięci. Nawet zastosowana do jego stworzenia funkcja sama też jest specyficzną wartością, na której można dokonywać operacji (tzw. typ funkcyjny), ale o tym później.

Z niezmiennymi wartościami jest pewien problem. Są rezultatami operacji, które możemy przechwycić i ewentualnie wprowadzić jako argumenty do kolejnych funkcji, ale nie możemy wpływać na ich stan, bo same służą do jego wyrażania.

Możliwość odzwierciedlania zmieniających się stanów bywa bardzo przydatna w modelowaniu problemów i bytów ogólnych, z którymi mamy na co dzień do czynienia. Chodzi o zdolność do tworzenia tzw. ”grubych” obiektów, czyli struktur, które byłyby przydatnymi generalizacjami, np. drzewo, rachunek bankowy, stan gry czy aktualnie odtwarzany klip wideo. Cechą wymienionych konstruktów jest właśnie ich czasowa zmienność.

Jeśli przyjrzymy się problemom, które jako ludzie rozwiązujemy każdego dnia, to zauważymy, że rzadko skupiamy się na atomowych i niezmiennych wartościach, czyli nieprzetworzonych informacjach ze zmysłów odzwierciedlających detale otoczenia. Po pierwsze: ich liczba jest przytłaczająca, aby wszystkie na bieżąco uwzględniać. Po drugie: nie mamy wystarczająco pojemnych buforów pamięci krótkotrwałej, żeby na takim poziomie szczegółowości rozpatrywać układy złożone z wielu elementów. Przypominałoby to budowanie domu z ziaren piasku i cementowego pyłu, korzystając ze szpatułek i szkła powiększającego. Idąc dalej można nawet powiedzieć, że informacje zmysłowe również nie są same w sobie stałowartościowe, ponieważ materia też się zmienia. Na pewnym poziomie fizyczna rzeczywistość (a przynajmniej jej percepcyjna reprezentacja) jest w jakimś stopniu abstrakcyjna.

Zastanówmy się więc, czy w świecie programowania funkcyjnego – na przykładzie konkretnej implementacji, jaką jest Clojure – istnieje jakiś sposób na opcjonalne tworzenie zmiennych bytów identyfikowanych stałymi nazwami przy zachowaniu reguły, która mówi o powszechnie niezmiennych wartościach.

Funkcyjna tożsamość

Wiemy, że w codziennym życiu i w paradygmacie programowania imperatywnego tożsamość pomaga identyfikować obiekty, których wartości mogą się zmieniać. Gdyby dało się podobny mechanizm odnaleźć również w domenie funkcyjnej, byłoby to ogromnym ułatwieniem.

Potrzebujemy stwarzać zmienne bytystałych tożsamościach (aby rozróżniać je od innych i odwoływać się do nich), ale nie możemy korzystać w tym celu z ich wartości czy z umiejscowienia tych wartości. Takie założenie wydaje się trudne do zrealizowania, ale tylko wtedy, gdy zakładamy, że natura tych bytów byłaby tożsama z naturą wartości. Problem da się rozwiązać, wprowadzając wyższy poziom abstrakcji – mechanizm, który pozwalałby używać wartości, jednak sam by na nich nie bazował.

Zauważmy, że to, co naprawdę łączy jedną wartość z drugą (powstałą na bazie tej pierwszej), to operacja. Tożsamość w funkcyjnym świecie nie będzie więc bazowała na umiejscowieniu jednej, konkretnej wartości w pamięci, która zostanie zmodyfikowana, ale na łańcuchu wielu wartości następujących po sobie w czasie.

Ponieważ pojedynczy zmienny byt musi być w różnych punktach czasu wyrażany różnymi wartościami, powinien więc być abstraktem. Tak naprawdę stała w nim od początku będzie tylko nazwa – nie będzie podstawowo zależny od żadnych konkretnych danych.

Przykład z życia

Aby zobrazować abstrakcyjny byt o zmiennych stanach i stałej tożsamości, możemy posłużyć się analogią. Wyobraźmy sobie, że mamy do wydania określoną sumę pieniędzy w postaci środków na rachunku bankowym. Żeby nie przemęczać umysłu każdorazowym myśleniem o ich dokładnej ilości, pochodzeniu każdego grosza i numerze konta, możemy zasobom finansowym nadać nazwę, na przykład kasa. Będzie ona identyfikowała konstrukt myślowy, który jest pewnym znaczeniem rezydującym w pamięci.

Korzystając z informacji pochodzących z otoczenia, będziemy mogli wspomnianą przestrzeń pamięciową aktualizować. Dokonamy tego w momentach, w których zajdzie taka potrzeba, czyli na przykład, gdy dostaniemy wypłatę albo będziemy mieli większy wydatek i postanowimy sprawdzić stan konta. Z pojęcia kasa skorzystamy też wybierając się na zakupy.

Wyobraźmy sobie więc, że udajemy się do sklepu spożywczego, ale sprzedawca nie przyjmuje płatności kartą. Musimy wobec tego odwiedzić bankomat. Podejmując decyzję o wypłacie gotówki również pomyślimy o kasie, ponieważ pod tym terminem kryje się zestaw skojarzeń związanych z naszymi pieniędzmi. Istotnym elementem procesu zakupu towaru w sklepie będzie też skorzystanie z naszej kasy.

Zauważmy, że kasa najpierw znajdowała się w systemie komputerowym banku, potem w bankomacie, następnie w portfelu, aż w końcu mogliśmy jej użyć, żeby dokonać zakupu. Czy środki na rachunku można nazwać kasą? Tak. Czy jest nią także gotówka w dłoni? Owszem!

Kasa jest nazwą abstraktu o ustalonej tożsamości. Na przestrzeni czasu może być on kojarzony z różnymi wartościami, które same w sobie są stałe i mogą być nawet związane z konkretnymi miejscami. Leżący na stole banknot o nominale 100 PLN nie stanie się dwustuzłotowym, a liczba wyrażająca stan środków w bazie danych banku nie zmieni numerycznego znaczenia.

Gdy ktoś zapyta “ile masz kasy?”, będziemy mogli odpowiedzieć ostatnią (aktualną) wartością. Skojarzenie właśnie z nią zostanie podmienione na inne w momencie, gdy dokonana będzie kolejna operacja zmieniająca stan kasy (czyli aktualizująca skojarzenie z konkretną wartością), na przykład przez spoglądnięcie na rachunek ze sklepu, a wcześniej na wydruk z bankomatu.

Czy fakt, że kasa jest abstraktem czyni ją mniej namacalną? W pewnym sensie tak. Nie można bezpośrednio pokazać kasy, można jedynie pokazać jej aktualny stan, reprezentowany bieżącą wartością (np. liczbą banknotów o danych nominałach czy liczbową reprezentacją środków na rachunku).

Czy będąc abstraktem, kasa nie istnieje naprawdę? Nie istnieje materialnie, ale sam fakt, że możemy wyróżnić jej tożsamość, czyni ją istotną i znaczącą, chociaż nieprzynależną do świata zmysłów. Najważniejsze jednak jest to, że można jej używać, czyli przeprowadzać na niej operacje. Tak naprawdę będą to działania na niezmiennych wartościach, polegające na ich wczytywaniu i stwarzaniu nowych. Sklepikarz nie odetnie połowy banknotu, tylko wyda nam resztę, której wartość zsumujemy z wartością posiadanej kasy i zmienimy jej bieżący stan na inny (wynikający ze skojarzenia z nową wartością).

Zaletą istnienia stałych tożsamości określających abstrakcyjne byty, które mogą przyjmować różne stany, jest brak potrzeby rezygnowania ze stałych wartości w celu przeprowadzania operacji. Na co dzień bardzo często posługujemy się wartościami ustalonymi, których znaczenia się nie zmieniają – np. używamy ich we wzajemnej komunikacji do wyrażania wielkości, które mają uniwersalne znaczenie. Korzystamy też z abstraktów, ale nie przyszłoby nam nigdy do głowy, aby na stałe wiązać je z konkretnymi wartościami (przestałyby być wtedy abstraktami). Gdy dostajemy wypłatę, nie zapominamy o tym, że wcześniej zarabialiśmy mniej; gdy w sklepie otrzymujemy paragon, nie szukamy poprzedniego rachunku z tego samego miejsca, aby go zastąpić; a kiedy składamy zeznanie podatkowe, nie palimy wysłanych rok wcześniej kopii dokumentów, bo straciły na aktualności. Większość procesów zarządzania informacją bazuje na aktualizacji (podmianie) wartości bieżącej, a nie na modyfikacji poprzednich danych.

Stwarzanie tożsamości o zmiennym stanie

Skoro stałe tożsamości o zmiennych stanach są użyteczne, to w jaki sposób możemy z nich korzystać, gdy jesteśmy programistami? Na przykład w taki:

  1. Stworzyć abstrakcyjny byt o stałej tożsamości.
  2. Nazwać go.
  3. Przypisać mu wartość początkową.
  4. Wykonywać na nim operacje.

Przykładowy, zmienny byt kasa nigdy nie zaistnieje jako typowa wartość w pamięci, chociaż będzie mógł mieć różne wartości. Będzie utożsamiany z pewną nazwą wskazującą na specjalną strukturę, która została powiązana z wartością początkową (np. stanem konta). Jeśli zechcemy dokonać zmiany stanu, odwołując się do tożsamości określanej etykietą kasa, to będziemy musieli wykonać dwie operacje:

  1. Wyliczyć nową wartość na bazie poprzedniej (zmiana).
  2. Zaktualizować obiekt kasa, aby tożsamość była powiązana z nowo uzyskaną wartością.

W ten sposób mimo niemutowalnych wartości jesteśmy w stanie utrzymywać stałą tożsamość przy zmieniających się stanach. Oczywiście pod warunkiem, że będziemy wykonywali operacje zawsze na tych obiektach (czy raczej za ich pośrednictwem), a nie bezpośrednio na identyfikowanych przez nie wartościach, które nie mogą się zmieniać.

Obiekty referencyjne

Problem podążania za łańcuchami zmian bez wytwarzania trwałych powiązań z wartościami chwilowymi nie jest nowy, a rozwiązać go można właśnie przez zaaplikowanie dwóch procesów: śledzenia (ang. tracking) i identyfikowania (ang. identifying).

Śledzona będzie wartość bieżąca wyrażająca stan tożsamości zmiennego bytu, poczynając od wartości pierwotnej, która może być nawet pusta (wartość nil). Do tego celu przyda się odpowiedni obiekt referencyjny, który będzie służył do zapamiętywania kolejno wyliczanych wartości. Obiekt ten będzie można też identyfikować z użyciem wskazującej na jego strukturę nazwy.

Mechanizmy języka zadbają o to, aby wyliczenie nowej wartości zawsze wiązało się z uaktualnieniem referencji.

Reprezentacja tożsamości

Uważny czytelnik zaobserwuje, że istnieje punkt, w którym jednak dokonywana jest mutacja wartości, czyli dochodzi do ingerencji w zawartość pamięciowego obiektu. Jest to moment aktualizacji odniesienia do wartości bieżącej w obiekcie referencyjnym utrzymującym tożsamość.

Na przykład, gdy ilość środków powiązanych z obiektem kasa ma się zwiększyć, wywoływana jest odpowiednia funkcja. Jej argumentami są: obiekt referencyjny i wartość o jaką należy zwiększyć środki. W rezultacie wykonane będzie sumowanie bieżącej wartości wskazywanej przez referencję i wartości podanej w argumencie. Powstanie nowa wartość, a funkcja powiąże ją z obiektem kasa, aktualizując referencję. Dojdzie do zmiany stanu.

W praktyce zmiana powiązania będzie oznaczała mutację pamięciowej struktury obiektu referencyjnego – dokładnie tak, jak w modelu imperatywnym. Jest to realizowane w ten sposób, ponieważ sam obiekt referencyjny także musi być w jakiś stały sposób identyfikowany, a jego wartość musi się zmieniać. Jego tożsamość będzie więc zależała od miejsca. To odzwierciedlanie abstraktów w pamięciowej i zależnej od lokalizacji “tkance” struktur śledzących przypomina sposób, w jaki sami, jako istoty ludzkie, korzystamy z własnych konstrukcji myślowych. Z jednej strony są one abstrakcyjne i nienamacalne, ale żebyśmy byli w stanie z nich korzystać w naszych mózgach musi dojść do uformowania odpowiednich ścieżek, których istotnym komponentem są materialne cząsteczki związków chemicznych.

Ilustracja składników tożsamości zmiennego abstraktu w Clojure

W Clojure do utrzymywania tożsamości zmieniających się stanów służą specjalnie stworzone w tym celu mechanizmy, na które z reguły składają się dwa wspomniane już wcześniej procesy:

  • referencja – powiązanie mutowalnego obiektu referencyjnego z niemutowalną wartością lub innym obiektem w celu utrzymywania aktualnego stanu tożsamości,

  • identyfikacja – powiązanie symbolicznej nazwy z obiektem referencyjnym w celu nazwania tożsamości.

Operacje na typach referencyjnych w Clojure są bezpieczne ze względu na przetwarzanie współbieżne, ponieważ ich obsługa wymaga korzystania z odpowiednich funkcji, które potrafią zadbać o to, aby obliczenie wartości pochodnej i aktualizacja odniesienia do niej były atomowe, jeśli chodzi o czas.

Przykład użycia

Poniższa formuła najpierw tworzy obiekt referencyjny (w tym przypadku tzw. zmienną globalną), a następnie przypisuje odwołanie do tego obiektu symbolowi kasa:

1
(def kasa 5)

W ten sposób powstaje tożsamość. Dokonywaną operację nazwiemy powiązaniem zmiennej globalnej kasa z wartością 5, chociaż tak naprawdę mamy tu dwa powiązania:

  • symbolu kasa z obiektem referencyjnym (w tzw. przestrzeni nazw),

  • obiektu referencyjnego z niemutowalną wartością 5 (w zajmowanym przez niego miejscu w pamięci).

A tak może wyglądać przyjęcie nowego stanu przez tożsamość określaną symbolem kasa:

1
2
3
4
5
;; łatwo
(def kasa 100000)

;; bezpiecznie
(alter-var-root #'kasa (constantly 100000))

Tożsamości o stałych stanach

Poza tożsamościami, których stany mogą się zmieniać, mamy również do czynienia z takimi, gdzie nie zachodzi konieczność śledzenia wartości, ponieważ są one niezmienne. Taka stałowartościowa tożsamość będzie pełniła funkcję bardziej identyfikacyjną niż referencyjną w stosunku do pamięciowego obiektu.

Przykładem realizacji stałej tożsamości o stałym stanie są powiązania symboli z różnymi strukturami, np. z argumentami funkcji i makr (pozwalają na odwołania do nich w ciałach) czy tzw. zmiennymi leksykalnymi (pozwalają odwoływać się do ich wartości).

Działa to tak samo jak w modelu imperatywnym – identyfikowane są konkretne obiekty pamięciowe, z tą jednak różnicą, że te ostatnie są wartościami, których nie można zmieniać (za wyjątkiem lokalnych obiektów Var, które są specjalnymi konstrukcjami imitującymi zmienne znane z języków imperatywnych i będą omówione w innych rozdziałach).

Symbole nie są obiektami referencyjnymi i nie zawierają w swych strukturach miejsca, które mogłoby wskazywać na inne obiekty. Są po prostu używane przez język jako etykiety do nazywania innych struktur.

Pragmatyka

Wielowątkowość

Jak wyglądałby funkcyjny sposób dostępu do współdzielonego pamiętnika z wcześniejszego przykładu? Po pierwsze każdy ze współwłaścicieli miałby własną kserokopię, pochodzącą z momentu uzyskania dostępu. Jeśli wprowadziłby zmiany, to utworzyłby lokalną pochodną, której jeszcze nie widzą inni. Blokowanie nie byłoby potrzebne, ponieważ kolejne przejawienia się pamiętnika byłyby niemutowalnymi wartościami, umieszczonymi w prywatnych obszarach.

W pewnym momencie stałoby się jednak konieczne zsynchronizowanie zmian z pierwotną kopią, do której odnoszą się wszyscy. W tym celu autor zmian oddawałby pamiętnik hipotetycznemu bibliotekarzowi, którego rolą byłoby wprowadzenie zmian w oryginale, tzn. w pierwotnym pamiętniku.

W przypadku języka Clojure pamiętnikiem mogłaby być jakaś kolekcja wskazywana zmienną globalną, biblioteką programowa pamięć transakcyjna (ang. Software Transactional Memory, skr. STM), a bibliotekarzem obiekt agenta, służący do kontrolowania współdzielonego dostępu do zmieniającego się stanu w asynchroniczny sposób. Modyfikacja wspólnego obszaru pamięci w takim układzie jest przeprowadzana świadomie i w drodze wyjątku, gdy jest to naprawdę niezbędne – wynika to ze specyfiki języka i rygoru dotyczącego obsługi danych mutowalnych.

RAM

Można słusznie zauważyć, że programy funkcyjne będą zajmowały więcej pamięci, ponieważ modyfikowanie dużych struktur danych oznaczało będzie ich ciągłe kopiowanie. Jest to prawdą tylko po części. Korzystanie z list pozwala interpreterowi wewnętrznie optymalizować realnie zajmowaną pamięć, tzn. kopiować tylko te elementy, które naprawdę się zmieniły, odpowiednio manewrując elementami listy. Dla programisty struktury danych będą zdawały się namnażać po każdej operacji, ale “pod maską” interpretera czy kompilatora będziemy mieć do czynienia z daleko posuniętą optymalizacją.

Poza tym wiele operacji może bazować na wykonywaniu zwłocznym i na leniwym wartościowaniu (ang. lazy evaluation) – mamy wtedy do czynienia z wirtualnymi strukturami danych, których kolejne elementy pojawiają się na mocy obliczeń w chwili, gdy są potrzebne, a nie rezydują w pamięci.

CPU

Dobra obsługa współbieżności, która cechuje języki funkcyjne z racji minimalnej liczby zmiennych obiektów pamięciowych, jest cechą, która przybiera na znaczeniu. Dzieje się tak ze względu na fizyczne granice skalowalności sprzętu elektronicznego, a konkretnie z powodu nieuchronnie nadchodzącego momentu, w którym nie będzie już można bardziej zagęszczać elementów półprzewodnikowych. Ograniczeniem jest tu po prostu budowa materii – miniaturyzacja kończy się na pojedynczym atomie.

Obejściem powyższego kłopotu jest budowanie układów, które nie są skalowane przez zagęszczenie elementów, lecz przez złożenie kilku podobnych komponentów, które potrafią równolegle wykonywać operacje. W kontekście programowania mowa tu oczywiście o procesorach, czyli centralnych jednostkach obliczeniowych (ang. Central Processing Units, skr. CPU).

Architektura wielordzeniowa (ang. multi-core) radzi sobie z problemem skali przez wprowadzanie wielu fizycznych układów obliczeniowych. Jednak same rdzenie CPU nie zdadzą się na wiele, jeśli nie zostaną odpowiednio obsłużone. Jednym ze sposobów jest zrównoleglanie procesów systemu operacyjnego, ale bardziej istotnym będzie jednoczesne wykonywanie kilku procesów lub wątków danego programu. W przypadku procesów do czynienia mamy z osobnymi przestrzeniami danych, więc konieczna jest gruntowna przebudowa aplikacji i wprowadzenie komunikacji międzyprocesowej. Z kolei przy korzystaniu z wątków (ang. threads) dane są współdzielone, ale pojawiają się problemy związane z wykonywaniem współbieżnym. Te ostatnie można rozwiązać, stosując np. blokady, semafory czy tzw. model aktora, jednak będą to jedynie obejścia problemu, który leży u podstaw imperatywnego podejścia.

W przypadku modelu funkcyjnego i języka Clojure miejsca, w których może dochodzić do konfliktów podczas dostępu są wprost określane przez programistę w drodze wyjątku. Musi on więc wybrać, jakiego rodzaju mechanizmu użyje. Do dyspozycji ma kilka typów referencyjnych, które różnią się podejściem do współbieżności oraz zasięgiem: zmienne globalne (typ Var), a także typy Atom, AgentRef.

Tożsamość imperatywna a funkcyjna

W świecie imperatywnym mamy tożsamość bazującą na umiejscowieniu (konkretnych struktur danych), a w funkcyjnym tożsamość bazującą na śledzeniu stanów (następujących po sobie wartości). Ta pierwsza tożsamość jest trwała, chociaż wskazuje na zmienne dane, z kolei druga jest dynamiczna (aktualizuje się po każdej operacji zmieniającej stan) i wskazuje na niemutowalne wartości.

Funkcyjna tożsamość jest też pierwotnie pusta, a więc samodzielna (nie musi identyfikować żadnego obiektu) i można ją nazwać abstrakcyjnym bytem wyższego rzędu, ponieważ wyraża relacje między innymi bytami (niezmiennymi wartościami znajdującymi się w pamięci). W modelu imperatywnym tożsamość zawsze jest powiązana z pamięciową lokalizacją, a więc nie jest samodzielna.

Powyższe świadczy o tym, że w paradygmacie imperatywnym tożsamość jest efektem sposobu, w jaki działa komputer. Jest obligatoryjna i zawsze obecna, co implikowane jest faktem, że zmienne struktury danych zajmują stałe miejsca w pamięci. W paradygmacie funkcyjnym tożsamość jest opcjonalna – wartości przyjmowane i zwracane przez operacje (funkcje) doskonale radzą sobie bez niej. W tym modelu tożsamość świadomie się stwarza, gdy zachodzi potrzeba identyfikowania sekwencji powiązanych operacjami wartości wyrażających jakiś zmienny byt.

Konwencje nazewnicze

W dalszych częściach podręcznika programowania w języku Clojure będę starał się trzymać następujących konwencji nazewniczych, będących konsekwencją przedstawionych rozważań:

  • Wartością (ang. value) określę niemutowalny obiekt umieszczony w pamięci.

  • Powiązaniem (ang. binding) nazwę obiekt identyfikacyjny skojarzony z jakąś wartością.

    W zależności od kontekstu będę tym terminem nazywał albo przyporządkowanie symbolu do obiektu referencyjnego lub wartości (nazywanie, identyfikacja), albo użycie obiektu referencyjnego w celu odwoływania się do innego obiektu pamięciowego (śledzenie, odniesienie).

    Może zdarzyć się również tak, że powiązaniem określę oba te procesy jednocześnie, np. przyporządkowanie symbolicznej nazwy do wartości, które odbywa się za pośrednictwem obiektu referencyjnego.

  • Tożsamością (ang. identity) nazwę byt (np. obiekt biznesowy czy obliczeniowy), który pozwala identyfikować serię stanów następujących w czasie przez powiązanie ostatniej istotnej wartości z obiektem identyfikującym (np. obiektem referencyjnym, który został dodatkowo symbolicznie nazwany).

  • Stanem (ang. state) będę nazywał aktualną wartość tożsamości, która może zmieniać się w czasie przez powiązanie tożsamości z inną wartością.

Zobacz także:

Ten materiał jest częścią większej całości, którą można zobaczyć odwiedzając poniższy odnośnik:

Komentarze