W językach kompilowanych mechanizmy wejścia-wyjścia opiera się niemal zawsze o koncepcję strumienia (stream), czyli abstrakcyjnego obiektu z którego możemy czytać dane i/lub je do niego zapisywać. Ponieważ strumień jest opakowaniem na jakiś zewnętrzny zasób – plik, połączenie sieciowe, itp. – należy generalnie dbać o jego poprawne i szybkie zamknięcie, gdy nie jest już potrzebny. Dotyczy to także zwłaszcza języków z zarządzaną pamięcią, gdzie osierocony obiekt strumienia może nie być posprzątany przez bardzo długi czas, zajmując zewnętrzne, niezarządzane zasoby systemowe.
Poprawny sposób postępowania z obiektem strumienia, który chciałem dzisiaj omówić, dotyczy konkretnie języka Java, gdyż tam cała sprawa jest co najmniej nietrywialna. Dzieje się tak z trzech powodów:
catch
), albo zadeklarowany jako opuszczający funkcję (throws
). Jest to sprawdzane podczas kompilacji.close
, zamykająca strumień, deklaruje potencjalne wyrzucanie wyjątku IOException
.with
(obecnej np. w C# i Pythonie), która automatycznie posprzątałaby po obiekcie strumienia w momencie opuszczenia jej zasięgu.Brak instrukcji with
sprawia, iż do dyspozycji pozostaje nam wyłącznie try-catch
lub try-finally
. Naiwne zastosowanie któregoś z nich nie daje jednak pożądanych efektów:
Takim efektem byłby na przykład fakt kompilowania się kodu :) W tej wersji jest to jednak niemożliwe (o ile funkcja nie deklaruje wyrzucania IOException
), bowiem wyjątek ten może zostać rzucony przez metodę close
… A przynajmniej taka jest teoria, którą kompilator niestety pedantycznie sprawdza.
W rzeczywistości ten kod ma przynajmniej jeszcze jeden błąd, którego nie wyeliminuje otoczenie wywołania close
odpowiednim blokiem try-catch
. Jego znalezienie pozostawiam aczkolwiek jako – ahem – ćwiczenie dla czytelnika ;) W zamian pokażę dla odmiany nieco lepszy sposób na obejście zaprezentowanych problemów.
Polega on na zastosowaniu dwóch zagnieżdżonych bloków try
: jednego z catch
do złapania IOException
i drugiego z finally
do zamknięcia strumienia. W całości prezentuje się to następująco:
Przy zastosowaniu takiej konstrukcji wszystkie miejsca, w których wyjątek I/O może wystąpić, są otoczone blokiem try-catch
, więc kompilator nie będzie miał powodów do narzekań. Nadal też gwarantujemy, że strumień zostanie zawsze zamknięty, co z kolei zapewnia blok try-finally
.
A że wygląda to wszystko cokolwiek nieestetycznie? Cóż… Java :)
Wydaje mi się, że przynajmniej pod kilkoma względami programowanie przypomina prowadzenie samochodu. Obu tych umiejętności względnie łatwo się nauczyć i niemal niemożliwe jest zapomnieć. Po nabyciu pewnej wprawy mamy też wystarczającą biegłość, by nie musieć koncentrować całej uwagi na którejś z tych czynności. Wyjątkiem są jedynie te miejsca, w których zalecane jest zachowanie szczególnej ostrożności.
Dla mnie (i pewnie nie tylko dla mnie) takimi miejscami w kodzie są styki programu z otoczeniem, przez które musi przebiegać jakaś komunikacja polegająca na wymianie danych i/lub informacji o zdarzeniach. Fragmenty te są ważne i nierzadko problematyczne, gdyż często pociągają za sobą konieczność dopasowania sposobu wykonywania programu do zewnętrznych wymagań. Jak na skrzyżowaniu, trzeba czasem chwilę poczekać i przynajmniej parę razy obejrzeć się dookoła.
W tym kontekście używa się słowa ‘synchronizacja’, jednak kojarzy mi się ono nieodparcie z programowaniem współbieżnym. To trochę złe skojarzenie, bowiem nie trzeba wcale tworzyć kilku wątków czy procesów, by kwestia zaczynała mieć znaczenie. Wystarczą operacje wejścia-wyjścia (zwłaszcza względem mediów wolniejszych niż lokalny system plików) lub obsługa jakiegoś rodzaju zdarzeń zewnętrznych, albo chociażby RPC (Remote Procedure Call – zdalne wywoływanie procedur) – ale oczywiście ta lista nie wyczerpuje wszystkich możliwości. Ogólnie chodzi o wszelkie odstępstwa od możliwości wykonywania programu krok po kroku – a właściwie kroczek za kroczkiem, gdzie każda sekwencja stałej liczby instrukcji zajmuje umowną, niezauważalną chwilę.
Jeśli by się nad tym zastanowić przez moment, to takich sytuacji jest sporo. Ba, właściwie to wspomniane scenariusze sekwencyjne są raczej wyjątkiem, a nie regułą. Dzisiejsze aplikacje działają w wielozadaniowych środowiskach, na ograniczonych pulach zasobów, wymieniają dane przez różne (niekoniecznie szybkie) kanały informacji, i jeszcze dodatkowo są pod ciągłą presją wymagań co do cechy określanej angielskim słówkiem responsiveness – czyli “komunikatywności z użytkownikiem”. Nic dziwnego, że wspomniane przeze mnie wcześniej ‘punkty szczególnej ostrożności’ stają się na tyle istotne, że zazwyczaj to wokół nich buduje się całą architekturę programu.
W jaki sposób są one realizowane? Zależy to od wielu czynników, w tym od rodzaju aplikacji oraz możliwości i struktury systemowego API, które ona wykorzystuje. Tym niemniej można wyróżnić kilka schematów, aplikowalnych w wielu sytuacjach – chociaż nie zawsze z równie dobrym skutkiem. Są nimi:
i jest przestępstwem ściganym z urzędu w każdym rozsądnym zespole projektowym :) Istnieją aczkolwiek okoliczności łagodzące, zezwalające na jego popełnienie pod ściśle określonymi warunkami. Musimy jedynie być pewni, że zużywanie do 100% czasu procesora przez nasz program jest akceptowalne i że między kolejnymi zapytaniami możemy też zrobić coś produktywnego. Tak się składa, że istnieje typ aplikacji, w którym oba te warunki mogą być spełnione: gry. Ich pętla główna to nic innego jak aktywne czekanie na informacje o zdarzeniach z renderowaniem kolejnych klatek w tak zwanym międzyczasie.
Takie krótkie drzemki zdecydowanie zmniejszają zużycie procesora przez aplikację, ale nie dają jej dużego pola manewru. Ta metoda jest więc stosowalna główne dla usług działających w tle. A raczej byłaby, gdyby nie istniały znacznie lepsze :)
BackgroundWorker
z Windows Forms jest tak prosty w użyciu). Gorzej jest wtedy, gdy na potrzeby asynchronicznego callbacku musimy rozbić na kilka części (i stanów) program, który bez tego działałby niemal sekwencyjnie.Między powyższymi sposobami możliwe są “konwersje”, oczywiście do pewnego stopnia. Wymagać to może uruchomienia dodatkowego wątku, w którym wykonujemy polling operację asynchroniczną lub wręcz blokującą, i którego stan możemy odpytywać lub otrzymać jako sygnał na obiekcie synchronizacyjnym po wejściu w stan przerywalnego czekania.
Nieczęsto jednak taka zabawa ma uzasadnienie. W najlepszym razie otrzymamy rozwiązanie równoważne, a najgorszym stracimy na wydajności operacji dostosowanej pod konkretny typ powiadamiania. Lepiej jest jednak trzymać się tego, co dana platforma i API nam proponuje.
W każdym języku programowania potrzebny jest system wejścia/wyjścia. To zresztą bardzo często wykorzystywana jego część, więc powinna charakteryzować się wysoką jakością. Chcielibyśmy mianowicie nie tylko tego, aby I/O było szybkie. Powinno być też elastyczne i proste w obsłudze. Czasami udaje się te wymagania pogodzić w całkiem zadowalający rezultat, a czasem nie.
Weźmy na przykład Javę. Posiada ona bardzo rozwinięty system wejścia/wyjścia, umożliwiający odczyt i zapis z wielu różnych źródeł: ekranu, plików, gniazdek sieciowych, potoków łączacych wątki, itp. Ponadto komunikacja może odbywać się na wiele sposobów: mamy na przykład dość “surowe” strumienie, nieco bardziej użyteczne czytacze (readers) i zapisywacze (writers), a także kanały (channel) i bufory (buffers).
Cały system wydaje się zatem bardzo zaawansowany. Niestety, w praktyce jest on zdecydowanie przerośnięty, a poza tym charakteryzuje się pewną ‘ciekawą’ cechą – nazwijmy to – projektową. Osobiście uważam, że twórcy Javy w tym momencie przedobrzyli i chcąc zastosować bardzo elastyczny w założeniu wzorzec Dekorator, stworzyli interfejsowy koszmarek. Wspomniany wzorzec polega na kolejnym “opakowywaniu” obiektów tak, aby rozszerzać ich możliwości; obiekt ‘zewnętrzny’ nie musi przy tym wiedzieć dokładnie, czym jest obiekt ‘wewnętrzny’. I tutaj rzeczywiście tak jest, lecz na nieszczęscie sami musimy zawinąć obiekt w te wszystkie warstwy.
Przykład? System.in
, czyli strumień standardowego wejścia, w swej pierwotnej postaci jest niemal zupełnie bezużyteczny. Żeby zrobić z nim cokolwiek sensownego (np. odczytać linię tekstu), musimy najpierw opakować go do postaci odpowiedniego czytacza:
Podobnie jest chociażby z plikami. Za każdym razem musimy ubrać nasz obiekt na cebulkę, aby był on przydatny, przechodząc przy okazji przez cały arsenał bardzo różnych klas, od których jest wręcz gęsto w JDK.
Trzeba aczkolwiek przyznać uczciwie, że System.IO
z .NET też zawiera całe mnóstwo różnych klas. Tam konieczność podobnego opakowywania zachodzi jednak o wiele rzadziej, gdyż interfejsy tych klas są trochę inteligentniejsze.
A co ze “staroświeckimi” językami, jak C++ czy Delphi? No cóż, w nich operuje się głównie pojęciem uniwersalnych strumieni i w zasadzie niczego więcej. Nie trzeba ich jednak niczym otaczać, bo fabrycznie potrafią już chociażby operować na podstawowych typach danych, a nie tylko ciągach bitów. Niby to mniej elastyczne i nie tak “obiektowo czyste”, ale o ile przyjemniejsze w użyciu.