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 :)
Wyjątki są sposobem na zasygnalizowanie nietypowych i niespodziewanych błędów, które poważnie zaburzają działanie programu. I właśnie to, że potencjalnie wyjątek może wystąpić w bardzo wielu miejscach w kodzie, rodzi pewne kłopoty. Problemami są chociażby zasoby: coś, co się pozyskuje, wykorzystuje, a następnie zwalnia, gdyż w przeciwnym razie doszłoby do wycieku. Typowym zasobem jest chociażby dynamicznie alokowana pamięć – jeżeli jej nie zwolnimy, nastąpi klasyczny wyciek, jako że C++ nie posiada garbage collectora, który mógłby się tym zająć za nas.
W C++ zalecanym rozwiązaniem tego problemu jest technika znana jako RAII (Resource Acquision Is Initialization – pozyskanie zasobu jest inicjalizacją). Korzysta ona z faktu, że w naszym ulubionym języku programowania możemy tworzyć obiekty lokalne z konstruktorami i destruktorami. Te drugie wywołają się zawsze przy opuszczaniu danego bloku kodu – niezależnie od tego, czy stało się z powodu wyjątku czy tez normalnego przebiegu programu. Pomysł polega więc na tym, by tworzyć obiekt specjalnie przygotowanej klasy w momencie pozyskania zasobu, zaś destruktor tego obiektu zajmie się już jego zwolnieniem, niezależnie od powodu.
Dopóki korzystamy z pamięci albo z plików, wszystko jest w porządku; odpowiednie klasy (jak auto_ptr
) posiada bowiem Biblioteka Standardowa. Gorzej jeśli chcemy skorzystać z innego rodzaju zasobów. Jeśli odpowiednia klasa realizująca technikę RAII nie istnieje, nie pozostaje nam nic innego, jak samemu ją sobie zapewnić (czytaj: napisać). I tak dla każdego rodzaju niestandardowych zasobów, które używamy. Po niedługim czasie można by z tych klas ułożyć własną “bibliotekę standardową” ;)
Alternatywą dla RAII jest dodanie trzeciego bloku (po try
i catch
) do konstrukcji łapiącej wyjątki. Jest on zwykle nazywany finally
. Instrukcje zawarte w tym bloku są wykonywane zawsze po tych z bloku try
– niezależnie od tego czy wyjątek wystąpił czy nie. Jest to więc bardzo dobre miejsce na wszelki kod zwalniający pozyskane wcześniej zasoby, np.:
Co ciekawe, posiadają go języki, które jeden z najważniejszych zasobów – pamięć – mają zarządzaną przez odśmiecacz, który praktycznie wyklucza możliwość powstania wycieków. Rzecz jednak w tym, że niektóre zasoby, jak chociażby otwarte pliku, nie mogą sobie czekać na to, aż odśmiecacz przypomni sobie o nich, gdyż wtedy byłyby blokowane stanowczo zbyt długo.
Czy C++ też potrzebuje instrukcji finally
? Na pewno nie jest to bardzo paląca potrzeba, jako że technika RAII zapewnia komplet potrzebnej tutaj funkcjonalności. To drugie, alternatywne rozwiązanie ma jednak szereg zalet:
finally
można też pożytecznie wykorzystać nawet wówczas, gdy w grę nie wchodzi możliwość pojawienia się wyjątku. Jeżeli na przykład w jakiejś skomplikowanej funkcji mamy wiele miejsc, w których może nastąpić jej zakończenie, a przy każdej okazji może być potrzeba wykonania jeszcze jakichś czynności końcowych. Wtedy moglibyśmy zamknąć całą treść funkcji w try
, a owe czynności umieścić w sekcji finally
. Trzeba by się było jednak liczyć z tym, że blok try
nie jest darmowy i jego użyciu nakłada pewien narzut.I przede wszystkim: RAII i finally
nie wykluczają się nawzajem. Dlatego obecność tego drugiego mechanizmu w C++ na pewno by nam nie zaszkodziła :)