Poczytaj mi Clojure, cz. 13

Stan, tożsamość i zmiana

Grafika

Clojure jest językiem o solidnych fundamentach teoretycznych. W tej części zajmiemy się podstawami koncepcyjnymi tego dialektu Lispu, 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, które trwają w czasie. 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 nasi znajomi inaczej wyglądają i zachowują się. Mimo to potrafimy wskazać, co się zmieniło bądź kto się zmienił, ponieważ są takie części obrazu świata, które pozostają dla nas niezmienne i jednostajne. Pozwalają nam one identyfikować elementy płynnej rzeczywistości, a także nadawać im nazwy. Te konstrukty to stałe tożsamości, które utrzymywane są w naszych osobistych systemach interpretacyjnych, czyli umysłach.

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 charakteryzowało będzie odmienne podejście do kwestii stanu, tożsamości i zmiany. Ów sposób patrzenia na te fundamentalne sprawy nazywamy paradygmatem (ang. paradigm), który dla programisty ma istotne konsekwencje techniczne.

Tożsamość

Kiedy coś, czemu nadaliśmy tożsamość (ang. identity), ulega zmianie, wciąż możemy to rozpoznawać, ponieważ w przestrzeniach naszej pamięci dokonujemy aktualizacji abstrakcyjnej koncepcji z powiązanym z nią obiektem o zmiennych cechach. Poza sposobami utrzymywania tożsamości ciekawą kwestią są również jej składowe, tzn. czynniki, które mają wpływ na rozpoznanie konkretnego przedmiotu jako przynależnego do niej. Dotyczy to również filarów programowania.

W świecie ludzkiej percepcji tożsamość jest cechą subiektywną – umysłowym konstruktem, który pozwala wyodrębniać pewne fragmenty świata, mimo że ich właściwości się zmieniają. Nie potrzebujemy odnajdywać wielu charakterystycznych cech danej tożsamości, ponieważ nasz umysł będzie ze wszystkich sił próbował podłączyć do aktualnego doświadczenia bazę wspomnień związanych z konkretną osobą czy przedmiotem, aby zachować w miarę trwały układ odniesienia. Pozwala to na korzystanie z wcześniejszych doświadczeń w dalszych interakcjach z wyróżnionym elementem otoczenia, zamiast od nowa wykształcać z nim relację. W ten sposób redukujemy napięcie, wiedząc czego można się spodziewać, a tym samym wybieramy mniej energochłonną i bardziej komfortową opcję.

Słowo „tożsamość” pochodzi z łacińskiego określenia idem, które oznacza „tak samo” (rdzeniem jest tu łacińskie id oznaczające „to”, „ta” lub „ten”, a uźródłowione w praindoeuropejskim i). Mamy do czynienia z pojęciem, które wyraża podobieństwo obiektów, a na poziomie procesu zdolność rozróżniania obiektów o wspólnych właściwościach (zarówno ich grupowania, jak i wyodrębniania).

To, jakie konkretnie składniki będą określały tożsamość, zależy od przyjętych przez nas założeń. Możemy polegać na atrybutach związanych na przykład z kształtem, kolorem, wagą bądź inną cechą, ale też z rolą bądź przeznaczeniem postrzeganego elementu w obrębie danego systemu interpretacyjnego. Tożsamość może, ale nie musi, zależeć od jakościowych bądź ilościowych właściwości przedmiotu, do którego się odnosi; może też, ale nie musi, wskazywać wielu elementów (w procesie ich utożsamiania).

W przypadku języków programowania tożsamość będzie implementowana z użyciem konkretnych mechanizmów, które zastosujemy do wytworzenia abstrakcyjnego obiektu, aby następnie móc kojarzyć go z danymi o wspólnych cechach bądź genezie. Wyższy poziom abstrakcji jest konieczny, abyśmy mogli podążać za tym samym zbiorem agregatów pomimo zmieniających się danych. Na przykład tożsamość o nazwie Suma może odnosić się do liczby całkowitej 123, aby za chwilę oznaczać 456, a jeszcze później ∞.

Powyżej możemy zauważyć, że implementując tożsamość w językach programowania, będziemy starali się uniezależniać ją od kolejnych wartości, a nawet rodzajów danych, do których się odnosi, aby była jak najbardziej „pojemna” i uniwersalna (mogła określać dane o różnej charakterystyce).

Podsumowując: tożsamością w programowaniu nazwiemy abstrakcyjny, ustalony byt służący do agregowania zmieniających się wartości, które mają wspólne znaczenie w kontekście przyjętej logiki programu.

Aspekty tożsamości

Rozpatrując tożsamość w kontekście programowania komputerów, możemy wyróżnić jej kilka istotnych aspektów, których poprawna implementacja jest kluczowa:

Aspekt identyfikacyjny

Identyfikacyjny aspekt tożsamości

Aspekt identyfikacyjny tożsamości pozwala używać jej do wskazywania danych z użyciem czytelnego identyfikatora i odwoływać się do wyrażanych na przestrzeni czasu wartości o wspólnych cechach w kontekście przyjętej w programie logiki. Dzięki niemu możemy rozpoznawać tożsamości w kodzie źródłowym i korzystać z nich w celu dostępu do wartości bieżących.

Aspekt abstrakcyjny

Abstrakcyjny aspekt tożsamości

Aspekt abstrakcyjny pozwala korzystać z tożsamości w odniesieniu do danych o różnej charakterystyce (niezależnie od ich budowy czy wyrażanej wartości). Dzięki niemu możemy też traktować tożsamości jako samodzielne byty, na których da się wykonywać pewne operacje. Ta właściwość oznacza, że tożsamość ma wyższy poziom abstrakcji, niż wskazywane nią wartości.

Aspekt wyróżniający

Wyróżniający aspekt tożsamości

Aspekt wyróżniający pozwala odróżniać wytworzone tożsamości i porównywać je ze sobą. Dzięki temu aspektowi możemy badać, czy mamy do czynienia z tą samą tożsamością, nawet gdyby została powiązana z wieloma nazwami. Warto zaznaczyć, że nie chodzi tu o porównywanie wartości bieżących, ale właśnie samych tożsamościowych konstruktów.

Aspekt kontrolny

Kontrolny aspekt tożsamości

Aspekt kontrolny (hermetyzujący) wprowadza do tożsamości mechanizmy kontroli dostępu (odczytywania i ustawiania) wartości bieżących, zapobiegając bezpośrednim zmianom wskazywanych struktur danych. Dodatkowo zapewnia sterowanie zasięgiem identyfikatorów i widocznością powiązanych z nimi wartości. Dzięki zarządzaniu dostępem możemy mieć pewność, że nie dojdzie do zmian danych inaczej, niż przez odwołanie się do tożsamości.

Imperator z amnezją

W imperatywnym paradygmacie programowania (i podejściach pochodnych) świat programu komputerowego składa się z cyfrowych bytów przypisanych do pamięciowych lokalizacji. Oznacza to, że podstawowym sposobem na to, aby identyfikować serię zmieniających się stanów (np. liczbę środków na rachunku bankowym) jest poleganie na stałym umiejscowieniu ich w pamięci operacyjnej. Wydaje się to intuicyjne i łatwe do zrealizowania, ponieważ komputer wyposażony jest w RAM, a programy mogą tam przechowywać dane.

W kodzie źródłowym powyższy model będzie wyrażany z użyciem konstrukcji reprezentujących struktury, których zawartość możemy zmieniać. Gdybyśmy chcieli odzwierciedlić na przykład zbiór liści na drzewie, utworzymy strukturę Drzewo zawierającą struktury typu Liść. Oczywiście na poziomie implementacji będą to dane odpowiednich typów: albo wbudowanych, albo stworzonych przez programistę na bazie obiektowego systemu typów – zależnie od języka programowania.

Kiedy w uniwersum naszego programu nastanie abstrakcyjna jesień, zmienimy stan obiektu Drzewo, usuwając liście bądź modyfikując ich kolor. Istotne jest jednak, że dzięki nazwom i ustalonym lokalizacjom, możemy odwoływać się do pamięciowych struktur identyfikowanych etykietami DrzewoLiść.

W opisanym 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: tożsamości, wspomnianego stanu i czasu.

Tożsamość imperatywna

W paradygmacie imperatywnym istotnym składnikiem tożsamości jest umiejscowienie identyfikowanego obiektu w konkretnym obszarze pamięci. Wyrazem tego jest konstrukt zwany zmienną (ang. variable), czyli wskazanie lokalizacji struktury powiązane z nadaną przez programistę nazwą. Jej zawartość ulega zmianom, lecz dzięki temu, że istnieje stała lokalizacja, możemy traktować pamięciowy obiekt jako byt o ustalonej tożsamości.

Składniki tożsamości imperatywnej

Składniki imperatywnej tożsamości: etykieta i obszar pamięciowy o zmiennej zawartości

Odnosząc się do przykładu z obiektem Drzewo: może ono mieć zmienną liczbę liści, zmienną wysokość i zmienny kształt, ale wciąż będzie można je 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 pomaga stwarzać tożsamość obiektu.

Hermetyzujący aspekt tożsamości jest w tym podejściu ograniczony niemal do zera. Możliwość zapisu i odczytu zmiennych, do których dostęp uzyskano nie jest ograniczona.

Tożsamość referencyjna

Ciekawym zjawiskiem jest tożsamość w językach zorientowanych obiektowo i nowoczesnych językach wieloparadygmatowych. Możemy ją nazwać tożsamością referencyjną, ponieważ bazuje na dodatkowych konstruktach stanowiących odniesienia do pamięciowych lokalizacji przechowujących obiekty.

Źródłem iluzji niezależności tożsamości referencyjnej są wskazywane obszary pamięci, co sprawia, że mamy do czynienia z pewnego rodzaju imitacją wartości. Powstaje wtedy swoista dwuskładnikowa tożsamość: jedna wytworzona referencyjnie, a druga związana z pamięciowym adresem.

Zależnie od języka programowania mogą powstać tu dwa problemy. Pierwszym będzie bezpośrednia osiągalność zmiennych struktur wskazywanych referencją (gdy język nie hermetyzuje ich z użyciem odpowiednich metod dostępowych przypisanych do obiektu), a drugim mutowalność samej referencji, którą można kojarzyć z etykietą w taki sam sposób, w jaki ustawiamy zawartości zmiennych. W efekcie mogą pojawić się komplikacje związane z jednoczesnym dostępem, o których wspomnimy omawiając różnice między tożsamością imperatywną a funkcyjną.

Tożsamość obiektowa

W językach zorientowanych obiektowo istnieje też określona paradygmatem koncepcja tożsamości, którą definiujemy jako jedną z właściwości każdego obiektu, pozwalającą odróżnić go od innych niezależnie od tego, z jakich składa się danych. Dzięki niej programista jest w stanie sprawdzić, czy różnie nazwane referencje nie odnoszą się do tego samego obiektu. Najczęściej tożsamość ta będzie polegała na unikatowym identyfikatorze obiektu.

Możemy zauważyć, że taka tożsamość dobrze implementuje aspekt identyfikacyjny i rozróżniający, jednak całkowicie brakuje jej aspektu hermetyzującego. Tu z pomocą przychodzą obiektowe mechanizmy, w których każdy obiekt może izolować własne zmienne składowe i dawać do nich dostęp tylko z użyciem odpowiednich metod (tzw. akcesorów). Problemem pozostaje jednak jakość owej hermetyzacji, szczególnie w kontekście przetwarzania współbieżnego, ponieważ poszczególne pola struktury wchodzące w skład obiektu również mogą być obiektami, które ulegają zmianom, a nad którymi obiekt bieżący nie ma już takiej kontroli. Brak kontroli uwidoczni się również na poziomie referencji wskazującej obiekt, która – jak wspomnieliśmy wcześniej – jest efektywnie zmienną.

Wartości a zmienne

Wartości z natury są niezmienne. Parafrazując Richa Hickey’a, twórcę języka Clojure: liczba 1 będzie zawsze liczbą 1, a wtorek nie stanie się środą. Istnieją jednak dane takich typów, które mimo, że znaczeniowo pełnią rolę niezmiennych wartości, są w praktyce mutowalnymi obiektami. Na przykład wyrażony w programie komputerowym kolor żółty (reprezentowany pamięciowym obiektem Żółty) może zacząć tak naprawdę odzwierciedlać kolor zielony, gdy dokonamy zmiany w jego wewnętrznej strukturze i przedefiniujemy numerycznie wyrażoną barwę. Cóż nam po tym, że będzie on identyfikowany stałą nazwą, skoro nie będziemy mogli w stu procentach polegać na umieszczonej pod tą nazwą wartości? Przypominać to będzie tożsamość osoby z osobowością wieloraką.

Język programowania zakorzeniony imperatywnie jest w stanie ukrywać fakt, że obiekty mogą ulegać mutacjom, tworząc stosowne interfejsy, które będą wprowadzały kontrolę zmian. Jest to popularną praktyką szczególnie w językach zorientowanych obiektowo. Możemy jednak zauważyć tu pewną niekonsekwencję na poziomie samej reguły, ponieważ fundamentalnie i tak większość danych będzie przechowywana w zmiennych strukturach, do których można uzyskać dostęp z pominięciem obiektów referencyjnych bądź opakowujących. Wystarczy jeden taki obiekt, jedno niezabezpieczone przed bezpośrednim dostępem pole, aby nie można było polegać na działaniu całego programu. W efekcie na programistę przeniesiony zostaje obowiązek odróżniania struktur o zmiennej strukturze od tych, które są typowymi (niezmiennymi) wartościami. W stosunku do tych pierwszych wymienionych będzie musiał korzystać z odpowiednich akcesorów w dostępie do danych, aby nie naruszyć referencyjnego powiązania, które zapewnia, że mamy do czynienia ze stałą tożsamością.

Zauważmy, że w wielu popularnych językach programowania sposoby obsługi zarówno zmiennych struktur (np. obiektu Drzewo), jak i zasadniczo niezmiennych wartości (np. liczby 1), są takie same. Zarówno Drzewo jak i wartość numeryczna 1 będą związane z jakąś nazwą zmiennej (bądź nazwą obiektu referencyjnego). Brak jest jednoznacznie wyrażonej i konsekwentnie stosowanej zasady rządzącej ważnymi cechami wszystkich obsługiwanych danych.

Dane mutowalne są nadużywane, choć z powodzeniem wiele z nich można by traktować właśnie jak wartości, których nie trzeba nigdy modyfikować. W takich językach programowania przezorniej jest oczywiście zakładać, że każdy obiekt może ulegać zmianom. 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 miejsca. Gdy tożsamość zależy od lokalizacji obiektu w pamięci, zmiana stanu będzie oznaczała jego mutację. Gdy uda się to zrealizować, należy dodatkowo zadbać o to, aby nie można było jej w prosty sposób obejść, pozwalając na dostęp do wartości bieżących wyłącznie z jej pośrednictwem.

Stan

Doprecyzujmy naszą wiedzę dotyczącą stanu (ang. state), uwzględniając wcześniej podane informacje. Nazwiemy nim kondycję pewnej tożsamości w danym punkcie czasu, na którą składają się cechy wyrażone jej wartością bieżącą. Ta ostatnia może być pojedyncza lub składać się z innych wartości.

Warto zaznaczyć, że przez zmianę stanu rozumiemy pojawienie się innego stanu zamiast bieżącego, a nie jakąś jego transformację. Z definicji stan uznajemy za element atomowy, wiążący konkretne wartości z konkretnym czasem w ramach pewnej tożsamości.

Zauważmy, że tożsamość może być niezależna od stanu i dlatego z jej pomocą da się identyfikować zmieniające się stany, nawet gdy będą one różniły się nie tylko wartościami, ale nawet rodzajami danych. W jednej chwili Drzewo może być obiektem typu Brzoza, a w innym typu Topola.

W modelu imperatywnym stanem danej tożsamości będzie zawartość zmiennej reprezentującej ją w danym momencie.

Powiemy więc, że stan zmienił się (w inny), gdy dojdzie do zmiany danych rezydujących w pewnych obszarach pamięci, z których korzysta program; na przykład, gdy na modelowanym drzewie ubędzie liści bądź zyskają one inny kolor. Dojdzie wtedy do przekształcenia pamięciowych obiektów w miejscu (ang. in-place) ich rezydowania. Przyjęcie nowego stanu jest w tym modelu związane z „zapomnieniem” stanu poprzedniego.

Źródłem zmian stanów będą nie tylko rezultaty obliczeń umieszczane w zmiennych strukturach, ale też m.in. operacje wejścia/wyjścia bądź rezultaty zdarzeń mających źródło w podprogramach obsługi interfejsu użytkownika.

Globalny stan

Spotkamy się czasami z określeniem „globalny stan” (ang. global state). Oznacza on taki stan, który wyrażany jest konstruktami, do których mamy dostęp z dowolnej części programu. Przykładem będzie tu zmienna globalna (ang. global variable) o zasięgu nieograniczonym, która jest współdzielona między wszystkimi wątkami i dostępna z każdego obszaru kodu źródłowego.

Zestaw wszystkich pamięciowych obiektów wspomnianego rodzaju nazwiemy globalnym stanem programu komputerowego, ponieważ ich zawartości bezpośrednio wpływają na sposób jego działania, a podczas ich użytkowania nie obserwujemy konieczności wywoływania jakiejś funkcji w celu dostępu do zawartości.

Rich Hickey porównuje czasem stan do atramentu wpuszczanego do wody. Jeżeli części programu polegają na obiektach reprezentujących globalny stan, szybko dochodzi do uzależnienia rezultatów działania całej aplikacji od tych niezależnych ognisk zmian. Gdy programu nie wyposażono w precyzyjne sposoby sterowania globalnym stanem, lecz jest on po prostu wyrażany zmiennymi strukturami przypisanymi do stałych miejsc, jego działanie może być nieprzewidywalne, a programista będzie miał utrudnione zadanie podczas lokalizowania przyczyn i obszarów występowania usterek. Te same wyrażenia kodu źródłowego będą w różnych momentach przeliczane do różnych wartości (czasem powodując też różne efekty uboczne). W językach zakorzenionych imperatywnie próbuje się przed tym zabezpieczać odpowiednio kontrolując widoczność, lecz mechanizmy te są łatwe do przypadkowego lub celowego obejścia; na przykład możemy zapamiętać referencję do zmiennej lub wskaźnik do jej pamięciowego adresu i nadal modyfikować zawartość, mimo że identyfikator został przesłonięty bądź oznaczony jako niewidoczny.

Stan a tożsamość

Niezmienna tożsamość obiektów jest sposobem na to, aby móc nawiązywać z nimi relacje i posługiwać się nimi. Wiemy wtedy gdzie szukać danych, które mają znaczenie w kontekście przyjętej logiki aplikacji, a dokładniej jakie operacje możemy wykonywać i jakich rodzajów cech oczekiwać. Drzewo ma liście, rachunek bankowy ma środki, człowiek ma wiek, itd.

W modelu imperatywnym w przypadku zmiany stanu zmiennego obiektu dochodzi do jego mutacji, ponieważ modyfikacja wartości musi zachodzić w jego strukturze, w jego „tkance”. To istotna różnica między tym, jak funkcjonuje komputer, a system interpretacyjny człowieka, która sprawia, że programowanie jest czasami mniej intuicyjne, niż chcielibyśmy.

Na poziomie najmniejszych części imperatywnego modelu nie ma innej możliwości zmiany stanu, ponieważ miejsce nie może się zmienić, aby tożsamość nie została utracona. Możemy temu zaradzić, wprowadzając mechanizmy referencyjne, jednak będą one wyjątkami od reguły i pewnego rodzaju obejściem problemu, nawet jeżeli programista nie będzie musiał ich implementować, lecz będą wbudowane w język.

Czas

Przyjrzyjmy się przez chwilę koncepcji czasu (ang. time). 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 ostatnio uświadomione dzięki zewnętrznemu i wewnętrznemu doświadczeniu. Przypomina to sekwencję obrazów ułożonych od najstarszego do najnowszego, które możemy porównywać i na tej podstawie uzyskiwać dodatkową wiedzę o kształcie lokalnego świata w różnych momentach. Stosowanie porównań między kolejnymi obrazami (i ich fragmentami) jest możliwe dzięki wrażeniu kontynuacji generowanemu przy udziale tożsamości rejestrowanych elementów rzeczywistości.

W przypadku imperatywnego podejścia do programowania, w którym reprezentujemy rzeczywistość z użyciem zmiennych, informacja o czasie zostaje efektywnie zagubiona. Na poziomie wątku zadania imperatywny język programowania cierpi na swoistą amnezję, ponieważ dokonuje nadpisywania struktur pamięciowych, aby wyrażać zmieniające się stany. 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 struktura jest abstrakcyjną szufladką, w której różne wątki programu mogą na przestrzeni czasu umieszczać dane. Faktycznie podlega więc ona upływowi czasu, chociaż właściwość ta nie jest obsługiwana. A gdy dojdzie do tego, że jakiś wątek nie zakończy użytkowania zawartości struktury, a inny już ją zmieni? Pojawią się kłopoty.

Zauważmy, że mówiąc „na przestrzeni czasu” analizujemy proces z punktu widzenia człowieka, który obserwuje uruchomiony program. Ten ostatni w tym modelu podstawowo nie jest „świadomy” czasu. Wczytywane instrukcje pobierają dane z pamięci, a po przeliczeniu umieszczają w niej rezultaty, nadpisując zastaną zawartość. Nie istnieje pamięć zmian, więc nie można szczerze powiedzieć, że mamy do czynienia z obsługą czasu, mimo iż program wykonuje się w (postrzeganym przez nas) czasie.

Nic nie przeszkadza programiście wprowadzać w konkretnych sytuacjach konstrukty wyrażające czas, aby chronić pamięciowe obiekty przed zapomnieniem. Na poziomie implementacji możemy zastosować mechanizmy, które zabezpieczą pewne obszary pamięci przed jednoczesnym dostępem bądź stworzą strukturę, w której uwzględniamy historię zmian. Od lat w językach bazujących na paradygmacie imperatywnym stosuje się przecież odpowiednie dzienniki, blokady zapisu, a także zamrażanie i klonowanie obiektów. Możemy użyć takich sposobów, uwzględniając czas i zmienność pamięciowych struktur, lecz nie jest to inherentną zasadą imperatywnej rzeczywistości. Zasadniczo nie ma pod tym względem rygoru ze strony paradygmatu.

Kiedy wątek wykonywania się programu zostaje wybudzony kolejnym cyklem procesora, „spodziewa się”, że zastanie cały wirtualny świat dokładnie takim, jakim go pozostawił. Stąd tak klinicznie brzmiący tytuł sekcji: mamy niemałe kłopoty, jeżeli okaże się, że w praktyce modelowana rzeczywistość jest na tyle zmienna i wielowątkowa, że dużą część programu stanowią procedury obsługi chroniące go przed więcej niż jednym architektem (rdzeniem procesora realizującym równoległą czynność na tych samych danych).

Światy równoległe

Zastanówmy się nad hipotetyczną sytuacją, w której rzeczywistość imperatywna jest stwarzana i kontrolowana przez więcej niż jednego demiurga. Mamy tu na myśli programy wielowątkowe (ang. multithreaded) i współbieżne (ang. concurrent). Współdzielona jest w nich dostępna aplikacji pamięć, a jeden uruchomiony wątek może „popsuć” zapamiętaną rzeczywistość innemu. Jeżeli program nie będzie pisany z uwzględnieniem tego zagrożenia, jego działanie może być 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ę na wyłączność wprowadzania zmian w pamiętniku, 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ą – mamy wtedy impas. Wszystko to spowodowane zostało pierwotnie tym, że każdy z wątków miał absolutną i bezpośrednią władzę nad mutowalnymi obiektami znajdującymi się w pamięci. Sam więc, powodowany intencją programisty, musiał ją ograniczać, aby nie tworzyć sytuacji konfliktowych.

Innym sposobem na przedstawienie omawianego tu problemu może być użycie terminologii bazodanowej. Powiemy wtedy, że w modelu imperatywnym występuje zespolenie dwóch ważnych funkcji związanych z przetwarzaniem zbioru danych: postrzeżeniowej i operacyjnej. Gdy w systemie przetwarzania te dwie role nie są odpowiednio odseparowane (na poziomie mechanizmów rządzących dostępem do struktur danych), uzyskanie spójności w obrębie logicznie powiązanych wartości staje się utrudnione. Dzieje się tak dlatego, że występuje konieczność gwarantowania, iż odpowiedzialny za wprowadzanie zmian operator nie będzie aktywny w momentach odczytu danych przez postrzegającego. Praktycznym przykładem tego zjawiska jest konieczność zakładania blokad na tabele baz relacyjnych na czas wprowadzania w nich zmian, a także tworzenie kopii biznesowych baz, aby gwarantować dostępność podczas trwającego dłużej przeliczania większości danych (np. w procesach zamknięcia dnia).

Konsekwentny operator

Clojure proponuje bardziej funkcyjne podejście do kwestii tożsamości i zmieniających się w czasie stanów. Domyślnie mamy do czynienia z niezmiennymi wartościami, które reprezentowane są danymi niemutowalnymi (ang. immutable). Pamięciowe struktury używane do ich wyrażania nie ulegają modyfikacjom, a więc wartość raz wpisana do konkretnego obszaru pamięci pozostaje w nim do momentu usunięcia przez Garbage Collectora lub zakończenia pracy programu.

Niektóre wbudowane, złożone struktury danych mogą okazjonalnie zawierać zmienne fragmenty, aby po wykonaniu na nich pewnych operacji (np. dodania jednego elementu do wektora zawierającego ich milion) współdzielić niezmienione części z nową instancją. Motywacją jest tu oszczędność pamięci i czasu potrzebnego na wykonanie pełnej kopii. Fakt ten jest jednak skutecznie ukrywany i izolowany, a nowa struktura będzie z punktu widzenia działającego programu widziana jako niezależna od bazowej. W praktyce więc tego typu obiekty również są niezmienne.

W programowaniu zorientowanym funkcyjnie 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 mniej inwazyjny. Nie modyfikuje pamięciowych przestrzeni bezpośrednio, używając ich jak buforów akumulujących zmienne dane, lecz wytwarza kolejne „włókna” rezultatów. Taki sposób działania wynika z konstrukcji całego modelu rzeczywistości.

Podobnie jak w podejściu imperatywnym wartości będą zlokalizowane w jakichś pamięciowych przestrzeniach, ponieważ mamy do czynienia z pewną z góry określoną architekturą sprzętową. Różnica polega jednak na tym, że nie jest istotne w jakich, ponieważ nie ma do nich bezpośredniego dostępu – tożsamości w Clojure są wyabstrahowane i fundamentalnie niezależne od umiejscowienia. Operacja zmiany wartości w efekcie wykonania obliczeń nie nadpisuje zawartości przestrzeni pamięciowej, lecz stwarza nową wartość, tzn. na podstawie operatora i podanych operandów uzyskujemy wynik. Konsekwencją tego jest zupełnie inny mechanizm utrzymywania stałych tożsamości – nie można już z dobrodziejstwem sprzętowego inwentarza wiązać ich z umiejscowieniem.

Dane niemutowalne

Jeżeli, podążając za wcześniejszym przykładem, postanowimy w Clojure przyjrzeć się strukturze elementu Drzewo, to w funkcyjnym świecie będzie ona wyrażana niezmienną wartością, a nie mutowalną zawartością (zmienną). Wartość będzie przypominała obraz utrwalony na fotograficznej błonie, który reprezentuje Drzewo w pewnym momencie istnienia, wyrażając jego aktualny stan.

Pamięć w tym modelu przypomina pamięć człowieka: gdy obserwujemy zmianę właściwości jakiegoś elementu rzeczywistości, nie sprawia ona, że nasze wspomnienia związane z tą utrwaloną tożsamością są natychmiastowo zastępowane, lecz powstają nowe, bardziej aktualne, odnoszące się do strumienia zdarzeń o wspólnym rodowodzie.

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

Migawki stanów

Świat rzeczywistości funkcyjnej przypomina migawki następujących po sobie stanów, gdzie jeden jest logiczną kontynuacją drugiego, zaś powodami wykształcania nowych wartości są operacje przeprowadzane na wartościach istniejących. Wynika to ze sposobu przechowywania rezultatów obliczeń: zamiast nadpisywać określoną strukturę w pamięci tworzone są serie rezultatów, którym w zależności od potrzeb możemy nadawać stałe tożsamości, aby śledzić zachodzące zmiany; możemy również polegać tylko na ostatecznym wyniku wszystkich zagnieżdżonych operacji – będziemy wtedy mieli do czynienia z wyrafinowanym kalkulatorem.

W przedstawionej wcześniej analogii z roztargnionym malarzem będzie to zmiana sposobu pracy z zamalowywania poprzednich obrazów na tworzenie coraz to nowych i ulepszonych szkiców na osobnych płótnach. Malarz jest w tym przykładzie funkcją, obraz ustaloną tożsamością, zaś kolejne pomalowane płótna wartościami, które sprawiają, że mamy do czynienia z różnymi stanami.

Stanem nazwiemy więc tu wartość bieżącą (w danym punkcie czasu) konkretnej, ustalonej tożsamości.

Funkcyjna tożsamość

Wiemy, że tożsamość pozwala identyfikować takie elementy modelu świata, które mogą przyjmować zmienne stany. W programach imperatywnych używamy w tym celu zmiennych, które przypisane są do konkretnych, pamięciowych lokalizacji. W przypadku danych niemutowalnych zastosowanie tego schematu byłoby problematyczne, ponieważ zakładając stałą lokalizację i umieszczoną w niej stałą zawartość tracimy zdolność wyrażania zmian. Będziemy więc mieli do czynienia z nieco innym podejściem.

Zauważmy, że to, co naprawdę łączy jedną wartość z drugą (powstałą na bazie tej pierwszej), to operacja, zwykle wyrażana funkcją. Tożsamość w funkcyjnym świecie nie będzie więc bazowała na umiejscowieniu danych w pamięci, ale na konstrukcji zdolnej do śledzenia łańcucha dowolnych wartości następujących po sobie w czasie i rezydujących w różnych miejscach. Nie możemy już polegać na tożsamości będącej pochodną architektury sprzętowej komputera, musimy więc samodzielnie stwarzać i utrzymywać ów konstrukt.

W funkcyjnym paradygmacie programowania tożsamość to po prostu tworzony od podstaw stały abstrakt – cyfrowy byt, który w kolejnych punktach czasu może identyfikować różne wartości. Poza tym będzie on w kodzie programu rozpoznawany z użyciem jakiejś czytelnej etykiety, aby można było wygodnie się nim posługiwać.

W Clojure istnieją dwa mechanizmy służące do budowania tożsamości. Pierwszego użyjemy do identyfikowania wartości, których nie musimy współdzielić z innymi wątkami wykonywania się programu, a drugiego do śledzenia zmieniających się w czasie, współdzielonych stanów.

Powiązania nazw

Pierwszym sposobem identyfikacji danych będą powiązania (ang. bindings) nazw z wartościami, czyli konstrukcje, które pozwalają na nadawanie tym ostatnim czytelnych identyfikatorów. Jeżeli wykonujemy jakąś operację i chcemy w pewnym obszarze programu zapamiętać jej rezultat, możemy skorzystać z odpowiedniej formy, która powiąże wynik z symboliczną nazwą. W obrębie bieżącego wątku wykonywania jesteśmy również w stanie dokonywać zmiany istniejącego powiązania przez skojarzenie już używanego identyfikatora z inną wartością.

Tego rodzaju tożsamości umożliwiają wyrażanie zmiennych stanów, lecz w zakresie ograniczonym zarówno leksykalnie (powiązanie istnieje w obrębie wyrażeń wchodzących w skład ciała odpowiedniej konstrukcji, np. let), jak i współbieżnie (powiązanie dostępne jest tylko w obrębie lokalnego wątku wykonywania).

Możemy zauważyć, że tego typu tożsamościowy konstrukt eliminuje problemy z równoczesnym modyfikowaniem tych samych obszarów pamięci, lecz jest to osiągane przez pozbawienie go możliwości operowania na stanach, do których dostęp ma wiele wątków. Powiązań nazw z wartościami użyjemy więc raczej do zapamiętywania rezultatów obliczeń w obrębie wybranych fragmentów programu.

Składniki tożsamości funkcyjnej (powiązania)

Składniki funkcyjnej tożsamości na bazie powiązania: etykieta i odwołanie do wartości

Gdyby do powiązań zastosować wcześniejszą analogię z pamiętnikiem, mielibyśmy do czynienia z czynnością uzupełniania go o nowe wpisy, jednak tylko przez wyznaczoną w danej chwili osobę i tylko przez dodawanie tekstu w jeszcze niezapisanych miejscach. Powiązaniem byłaby rola pisarza, którą mogłaby piastować w danym czasie jedna z osób znajdujących się przy stoliku.

Referencje

W celu identyfikowania zmiennych stanów, które są współdzielone między wątkami, skorzystamy z tożsamości o nieco bardziej finezyjnej konstrukcji. Przypomnijmy: potrzebujemy mechanizmu, który umożliwiałby identyfikację zmieniających się w czasie, logicznie powiązanych wartości, przy założeniu, że w tym samym momencie kilka wątków programu może mieć dostęp do wartości bieżącej i próbować ją odczytywać bądź modyfikować.

Współdzielonych stanów nie da się zupełnie uniknąć, szczególnie gdy aplikacja ma pracować z danymi, które pochodzą z zewnątrz. Strumienie tekstowe i multimedialne, działania użytkownika, komunikacja sieciowa, a nawet różniące się zawartości plików – są to źródła danych, których przetwarzanie o wiele przejrzyściej i łatwiej zaimplementować, gdy dopuścimy pamięciowe konstrukty reprezentujące zmienne stany.

Gdyby powyższe założenie próbować wprowadzić w życie z wykorzystaniem konwencjonalnych, imperatywnych zmiennych, implementacja byłaby mało efektywna, ponieważ pojawiłyby się wspomniane wcześniej problemy z nadpisywaniem danych przez inne wątki, koniecznością stosowania blokad implikujących podatność na zakleszczenia itd. Nie możemy również zastosować omówionych wcześniej powiązań, gdyż nie są one widoczne we wszystkich wątkach wykonywania się programu. Nawet gdybyśmy w jakiś sposób zmusili je do tego, ich aktualizacje nie byłyby bezpieczne w kontekście przetwarzania współbieżnego i moglibyśmy na nich polegać wyłącznie przy wielowątkowym identyfikowaniu wartości, które nie ulegają zmianom.

Kwestia podążania za łańcuchami zmian tworzącymi chwilowe wartości nie jest nowa, a rozwiązać ją można przez zaaplikowanie dwóch procesów: śledzenia (ang. tracking) wartości bieżącej i identyfikowania (ang. identifying) konstruktu śledzącego.

Aby sprostać omawianemu zadaniu Clojure wprowadza tzw. typy referencyjne, czyli takie rodzaje danych, które służą do wytwarzania specyficznych powiązań obiektów referencyjnych z wartościami przy odpowiednim sterowaniu dostępem. Tak wytworzona tożsamość zawiera pojedynczy, mutowalny element reprezentujący zmienny stan, przy czym nie służy on do przechowywania wartości, lecz odniesienia do już istniejącej. Odniesienie to, jak łatwo się domyślić, można zmieniać.

Żeby zaktualizować stan obiektu referencyjnego i powiązać go z nową wartością, należy posłużyć się odpowiednią funkcją. Jej zadaniem będzie obsługa procesu wprowadzania zmiany w taki sposób, aby był on współbieżnie bezpieczny. Oznacza to na przykład, że gdy w jakimś wątku następuje właśnie odczyt wartości, funkcja poczeka, aż zakończy się ta operacja i dopiero wtedy ustawi odwołanie do nowej wartości dla wszystkich wątków.

Z użyciem referencyjnego konstruktu śledzona będzie wartość bieżąca wyrażająca aktualny stan tożsamości. Sam obiekt referencyjny będzie można identyfikować w programie z użyciem nadanej mu z użyciem standardowego powiązania symbolicznej nazwy.

Składniki tożsamości funkcyjnej (referencje)

Składniki funkcyjnej tożsamości na bazie referencji: etykieta i odwołanie do referencji

W analogii do sytuacji z pamiętnikiem obiektem referencyjnym będzie osoba wyznaczona do koordynowania procesu umieszczania nowych wpisów. Każdy z autorów otrzyma od niej kopię wszystkich kartek, do których będzie mógł dopisywać własne historie. Warunkiem wprowadzenia zmian będzie jednak przekazanie brudnopisu do redaktora, aby dołączył nowe teksty do oficjalnego wydania. Pamiętnik jest tu tożsamością, ale sposobem dostępu do jego aktualnej wersji (bieżącego stanu) jest skorzystanie z pośrednictwa osoby redagującej. Odpowiada ona za łączenie otrzymanych w tym samym czasie aktualizacji w spójną całość, a także za uaktualnianie brudnopisów tym, którzy chcą jeszcze coś dodać.

W Clojure zależnie od zapotrzebowania użyjemy jednego z kilku obecnych typów referencyjnych:

  • Var – gdy zechcemy globalnie nazwać jakąś wartość, funkcję bądź inny obiekt referencyjny;

  • Atom – kiedy potrzebujemy konstruować liczniki, w których zmian może dokonywać tylko jeden wątek w danym kwancie czasu, a gdy to się dzieje, pozostałe, chcące również wprowadzać zmiany, muszą oczekiwać;

  • Agent – w przypadkach dużej liczby aktualizacji bez międzywątkowego blokowania zapisu, lecz z oczekiwaniem na odzwierciedlenie zbuforowanych operacji, które może nastąpić po jakimś czasie;

  • Ref– jeżeli zależy nam na koordynowanych transakcjach, w których wiele zależnych od siebie wartości bieżących wybranych tożsamości musi zostać uaktualnionych jednocześnie.

Poza tym istnieją jeszcze typy Future, Promise, DelayVolatile, o których można przeczytać w rozdziale poświęconym współbieżności.

Łatwo zauważyć, że opisywana tu funkcyjna tożsamość bazująca na referencjach do złudzenia przypomina tożsamość referencyjną z języków zorientowanych obiektowo. To dobra obserwacja, szczególnie w użytym określeniu poziomu podobieństwa (sic!). Zasada pozostaje ta sama: mamy specjalny obiekt, który odnosi się do innego i w ten sposób może dokonywać śledzenia zmiennych stanów bez względu na umiejscowienie czy charakter danych. W przypadku obiektowo zorientowanych języków zakorzenionych imperatywnie mamy jednak do czynienia ze zmiennymi strukturami, które podatne są na zaburzenia zawartości przez równolegle wykonywane operacje. Winną takiego stanu rzeczy nie jest oczywiście referencyjnie skonstruowana tożsamość, lecz to, że w praktyce mamy do czynienia z kolejną, ukrytą tożsamością, jednak bazującą na umiejscowieniu struktury, do której referencyjna tożsamość się odnosi. Przyczyną tego problemu są dane mutowalne używane zamiast wartości jako elementy wyrażające zmieniające się stany. Narzędzie jest więc dobre, lecz używane w odniesieniu do złych materiałów.

Przykład z życia

Aby zobrazować abstrakcyjny byt tożsamościowy, który może identyfikować zmieniające się stany, możemy posłużyć się przykładem. 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 liczbie, pochodzeniu każdej kwoty i numerze konta, możemy naszym prywatnym zasobom finansowym nadać nazwę, na przykład kasa. Będzie ona identyfikowała konstrukt myślowy, który jest pewnym znaczeniem rezydującym w naszej 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ę lub będziemy mieli większy wydatek – wtedy, gdy 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. 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 mamy 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ą wielkoś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 ją wyróżnić, czyni ją istotną, chociaż nieprzynależną do świata zmysłów. Najważniejsze jednak jest to, że można jej używać, czyli przeprowadzać na niej operacje. 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.

Zaletą istnienia stałych, wyodrębnionych tożsamości, 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. 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. Możemy więc zaryzykować stwierdzenie, że funkcyjna tożsamość jest bliższa modelowi świata, którym sami się posługujemy.

Przykład użycia

Spróbujmy bardziej konkretnych przykładów tworzenia tożsamości, czyli podwińmy rękawy i posłużmy się typami referencyjnymi języka Clojure.

Poniższa forma najpierw wytwarza obiekt referencyjny (w tym przypadku typu Var), a następnie przypisuje ten obiekt do symbolicznej nazwy kasa. W ten sposób powstaje zmienna globalna:

1
(def kasa 5)
(def kasa 5)

Stworzyliśmy tożsamość o nazwa kasa z użyciem zmiennej globalnej, której obiekt referencyjny wskazuje na wartość 5. W istocie mamy tu do czynienia z dwoma powiązaniami:

  • symbolu kasa z obiektem referencyjnym (w przestrzeni nazw),
  • obiektu referencyjnego z wartością 5 (w jego strukturze).

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))
;; łatwo (def kasa 100000) ;; bezpiecznie (alter-var-root #'kasa (constantly 100000))

Pierwsze operacja przypisze nową wartość, lecz nie będzie to czynność synchronizowana między wątkami. Jeżeli przeprowadzilibyśmy ją wielokrotnie i współbieżnie, niektóre wątki korzystałyby z różnych wartości – szczególnie wtedy, gdy polegalibyśmy na wartości poprzedniej, aby wyliczyć nową.

Operacja druga uwzględnia przetwarzanie współbieżne i dokonuje zmiany wewnętrznego powiązania obiektu referencyjnego w taki sposób, że w trakcie uaktualniania wartości pozostałe wątki, w których pojawiłaby się również operacja aktualizacji tego stanu, będą oczekiwały, aż czynność prowadzona w bieżącym wątku się zakończy.

Zmienne globalne są specyficznymi tożsamościami, ponieważ zaprojektowano je z myślą o aspekcie identyfikującym. Użyjemy ich na przykład do nazywania funkcji, ustawień konfiguracyjnych czy globalnie dostępnych tożsamości referencyjnych, które powinny być dostępne w całym programie.

Spójrzmy na inny przykład, korzystający z najbardziej zaawansowanego typu referencyjnego, czyli Ref. W jego przypadku wykorzystywana jest programowa pamięć transakcyjna, dzięki której zmiany w wielu zależnych od siebie wartościach bieżących wybranych, współdzielonych tożsamości są wprowadzane w sposób koordynowany:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
;; Tożsamości referencyjne.
(def gotówka   (ref 0))
(def rachunek  (ref 0))
(def skarbonka (ref 0))

;; Tożsamość główna identyfikująca funkcję
;; zwracającą stan kasy w postaci mapy.
(defn kasa []
  (dosync
    {:gotówka   @gotówka
     :rachunek  @rachunek
     :skarbonka @skarbonka
     :suma      (+ @gotówka @rachunek @skarbonka)}))

;; Otrzymywanie gotówki.
(dosync (alter gotówka + 100))

;; Otrzymywanie przelewu.
(dosync (alter rachunek + 10000))

;; Sprawdzanie stanu kasy.
(kasa)
; => {:gotówka 100, :rachunek 10000, :skarbonka 0, :suma 10100}

;; Wypłata połowy środków z rachunku w gotówce
;; i włożenie 100 zł do skarbonki.
(dosync
  (alter gotówka   + (alter rachunek / 2))
  (alter gotówka   - 100)
  (alter skarbonka + 100))

;; Sprawdzanie stanu kasy.
(kasa)
; => {:gotówka 5000, :rachunek 5000, :skarbonka 100, :suma 10100}
;; Tożsamości referencyjne. (def gotówka (ref 0)) (def rachunek (ref 0)) (def skarbonka (ref 0)) ;; Tożsamość główna identyfikująca funkcję ;; zwracającą stan kasy w postaci mapy. (defn kasa [] (dosync {:gotówka @gotówka :rachunek @rachunek :skarbonka @skarbonka :suma (+ @gotówka @rachunek @skarbonka)})) ;; Otrzymywanie gotówki. (dosync (alter gotówka + 100)) ;; Otrzymywanie przelewu. (dosync (alter rachunek + 10000)) ;; Sprawdzanie stanu kasy. (kasa) ; => {:gotówka 100, :rachunek 10000, :skarbonka 0, :suma 10100} ;; Wypłata połowy środków z rachunku w gotówce ;; i włożenie 100 zł do skarbonki. (dosync (alter gotówka + (alter rachunek / 2)) (alter gotówka - 100) (alter skarbonka + 100)) ;; Sprawdzanie stanu kasy. (kasa) ; => {:gotówka 5000, :rachunek 5000, :skarbonka 100, :suma 10100}

W powyższym przykładzie korzystamy z makra dosync, które realizuje obliczenia wewnątrz transakcji. Każda tożsamość, która reprezentowana jest obiektem typu Ref, będzie aktualizowana z użyciem odpowiednich mechanizmów międzywątkowej koordynacji. Użyta w kodzie funkcja alter służy do zmiany stanu wybranej tożsamości. Przyjmuje ona jej symboliczną nazwę, operator używany do dokonania obliczeń i argumenty, które będą przekazane do operatora wraz z wartością bieżącą. Funkcje realizujące operacje (tu +, /-) będą wywoływane na migawce wartości bieżących wszystkich aktualizowanych tożsamości. Migawka ta zawiera stany tych tożsamości z momentu rozpoczęcia transakcji. Dopiero, gdy wszystkie wartości zostaną przeliczone i przygotowane do aktualizacji, transakcja będzie zatwierdzona, a gotówka, skarbonkarachunek zyskają nowe, współdzielone wartości bieżące.

Więcej o wykorzystaniu typu Ref i programowej pamięci transakcyjnej można przeczytać w odcinku 18. podręcznika.

Tożsamość imperatywna a funkcyjna

W paradygmacie imperatywnym mamy tożsamość bazującą na umiejscowieniu (konkretnych struktur danych), a w funkcyjnym bazującą na śledzeniu stanów (następujących po sobie wartości). Ta pierwsza jest bardziej konkretna i prostsza w konstrukcji, chociaż wskazuje na zmienne dane, z kolei druga wymaga skomplikowanych sposobów obsługi i świadomego uaktualniania powiązań z wartościami, lecz możemy jej użyć do śledzenia serii niemutowalnych danych (wartości) bez względu na ich pamięciowe umiejscowienie.

Funkcyjna tożsamość jest pierwotnie pusta, a więc samoistna – nie musi identyfikować żadnego obiektu, ani nie zależy od charakterystyki danych, do których się odnosi. 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ść jest powiązana z pamięciową lokalizacją, a więc nie jest w pełni samoistna. Wyjątkiem będzie imperatywna tożsamość referencyjna, w której usunięto bezpośrednie powiązanie etykiety z miejscem, lecz wciąż nie ma tam wbudowanej kontroli nad procesem wprowadzania zmian w docelowym obiekcie. Ten hermetyzujący aspekt tożsamości jest zminimalizowany: tożsamościowy konstrukt nadaje się do identyfikowania danych, jednak nie pośredniczy w aktualizacjach, pozostawiając umiejętnościom programisty implementację w tym zakresie.

Powyższe świadczy o tym, że w paradygmacie imperatywnym tożsamość jest naturalnym efektem sposobu, w jaki funkcjonuje komputer. Jest obligatoryjna i zawsze obecna, co implikowane jest faktem, że zmienne struktury danych zajmują stałe miejsca. Programy, które chcą zabezpieczać się przed nadpisywaniem ważnych danych czy negatywnym wpływem globalnych stanów, muszą wprowadzać dodatkowe mechanizmy zarządzania dostępem do zmiennych i ukrywać możliwość bezpośredniego dostępu do nich przed programistą w mniej lub bardziej udany sposób. Na barki twórcy oprogramowania spada też obowiązek zadbania o to, aby właściwie aktualizować zmienne struktury w przypadkach globalnych stanów i wielu aktywnych wątków. Oznacza to, że twórca oprogramowania musi w jakiś sposób implementować tożsamościowe konstrukcje na własną rękę – oczywiście, jeżeli zależy mu na pełnej hermetyzacji i prawdziwym abstrahowaniu szeregów wartości.

W paradygmacie funkcyjnym tożsamość jest opcjonalna – wartości przyjmowane i zwracane przez funkcje doskonale radzą sobie bez niej. W tym modelu tożsamość możemy wytwarzać, gdy zachodzi potrzeba identyfikowania zmiennych stanów wyrażanych na przestrzeni czasu seriami powiązanych logicznie wartości. W Clojure również mamy do czynienia ze współdzielonymi stanami, nawet globalnymi, jednak już na poziomie mechanizmów języka są one odpowiednio izolowane i wyposażane w mechanizmy kontroli – właśnie dzięki przemyślanej implementacji tożsamości.

RAM

Można słusznie zauważyć, że programy korzystające z funkcyjnej tożsamości będą potrzebowały większej ilości pamięci, ponieważ każde powiązanie i każdy obiekt referencyjny są dodatkowymi strukturami danych, które trzeba utrzymywać. Jest to cena jaką płacimy za modelowanie bytu wyższego rzędu: musi on być niezależny od pozostałych obiektów oraz ich właściwości, żeby móc je wskazywać i identyfikować.

Znane z modelu imperatywnego poleganie na pamięciowej lokalizacji do określania tożsamości ma kilka wad, ale jedną istotną zaletę: oszczędność RAM-u. Również same mutacje struktur mogą być postrzegane jako zaleta, ponieważ nadpisywanie danych jest oszczędnością pamięci dostępnej programowi. Nie potrzebujemy też wtedy często interweniującego Garbage Collectora usuwającego zapamiętane rezultaty, które nie będą już programowi potrzebne.

Zysk pamięciowy to tylko jeden z czynników, który należy brać pod uwagę. Jeżeli aplikacja zamiast 1 megabajta danych będzie potrzebowała 2 czy nawet 10 megabajtów, a koszt 1000 megabajtów to minuta pracy firmy korzystającej z oprogramowania, wybór podyktowany wyłącznie oszczędnością RAM-u będzie zwyczajnie nieracjonalny; szczególnie, gdy weźmiemy pod uwagę zysk polegający na zwiększeniu produktywności związanej z usuwaniem usterek i testowaniem programu podczas jego rozwijania.

CPU

Dobra obsługa współbieżności, która cechuje Clojure z racji minimalnej liczby zmiennych obiektów pamięciowych, jest właściwością przybierającą 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 zagęszczać elementów półprzewodnikowych. Ograniczeniem jest tu po prostu budowa materii – miniaturyzacja kończy się na pojedynczym atomie.

Obejściem powyższego problemu jest budowanie układów, które nie są skalowane przez zwiększenie liczby elementów na pewnym obszarze, lecz przez złożenie kilku podobnych komponentów, które potrafią równolegle przetwarzać dane. W kontekście programowania mowa tu oczywiście o procesorach.

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żeli 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 m.in. wymienione już blokady, semafory czy tzw. model aktora, jednak będą to jedynie obejścia problemu, który leży u podstaw imperatywnego modelu.

W paradygmacie funkcyjnym i języku Clojure miejsca, w których może dochodzić do konfliktów podczas dostępu do współdzielonych danych, są wprost określane przez programistę w drodze wyjątku, z użyciem odpowiednich typów referencyjnych. Musi on więc wybrać, jakiego rodzaju mechanizmu użyje, zależnie od wymagań tworzonego oprogramowania.

Zobacz także:

Jesteś w sekcji

comments powered by Disqus