Moje odkazy
Obsah článku:
vydáno: 29. 10. 2024 22:20
Programovací jazyky C++ i Java implementují výjimky (exceptions) pro ošetřování
chyb resp. řízení toku programu při chybových stavech. V bloku try
provádíme
operace, které mohou selhat a vyhodit výjimku. V bloku catch
tuto případnou
výjimku zachytíme a nějak na ni reagujeme. V blogu finally
pak máme kód, který
se má provést vždy, bez ohledu na to, zda výjimka vyletěla nebo ne – typicky jde
o kód, který zavírá zdroje, uvolňuje paměť nebo dělá jiný úklid.
Na rozdíl od Javy jazyk C++ blok finally
nemá. Není to zvláštní, když v C++ je
jinak prakticky vše, co lze vymyslet? V dnešním článku si ukážeme, jak v C++
vytvořit obdobu bloku finally
a následně i lepší řešení, která jsou pro C++
přirozenější.
Problém si nejprve popíšeme na modelovém příkladu v pseudokódu (což by mohla být třeba Java 6). Chybná varianta:
Zdroj z = vytvořZdroj();
metodaKteráMůžeVyhoditVýjimku(z);
zavři(z);
return 0;
Při vyhození výjimky se zdroje nezavřou, dojde např. k úniku paměti nebo jinému
nežádoucímu stavu. Funkční verze bez finally
:
Zdroj z = vytvořZdroj();
try {
metodaKteráMůžeVyhoditVýjimku(z);
zavři(z);
return 0;
} catch (Exception e) {
zavři(z);
return 1;
}
Na tom je nešikovné, že úklid máme rozkopírovaný na dvou místech. Proto se hodí
blok finally
:
Zdroj z = vytvořZdroj();
try {
metodaKteráMůžeVyhoditVýjimku(z);
return 0;
} catch (Exception e) {
return 1;
} finally {
zavři(z);
}
Ten se vykoná před návratem z metody v obou větvích.
Nyní již k C++. Chybný kód může vypadat např. takto:
#include <iostream>
#include <exception>
void fxThrowing(bool fail, void* data) {
std::cout << " doing something with data " << data << std::endl;
if (fail) throw std::logic_error("error from fxThrowing()");
}
void fxAllocating(bool fail) {
void* data = malloc(486);
std::cout << " allocated memory at: " << data << std::endl;
fxThrowing(fail, data);
free(data);
std::cout << " freed memory at: " << data << std::endl;
}
int main(int argc, char** argv) {
bool fail = argc == 2 && std::string("fail") == argv[1];
const char* name = "bad";
std::cout << name << " (fail=" << fail << ")\n";
try {
fxAllocating(fail);
} catch (const std::exception& e) {
std::cout << " caught exception: " << e.what() << std::endl;
}
}
Alokujeme paměť pomocí malloc()
a pokud jde vše, jak má, zase ji uvolníme. Když
ale metoda fxThrowing()
vyhodí výjimku (když programu předáme parametr fail
), dojde k jejímu odchycení až v main()
a alokovaná paměť se neuvolní.
C++ sice nemá klíčové slovo finally
, ale protože je to jazyk neomezených
možností, můžeme si v něm ekvivalent bloku finally
implementovat sami a to díky
funkcím ze standardní knihovny: current_exception()
a rethrow_exception()
.
Pomocí té první získáme v bloku catch
ukazatel na aktuální výjimku, za blokem
catch
máme náš „blok finally
“ a nakonec odchycenou výjimku vyhodíme pomocí druhé
funkce.
#include <iostream>
#include <exception>
void fxThrowing(bool fail, void* data) {
std::cout << " doing something with data " << data << std::endl;
if (fail) throw std::logic_error("error from fxThrowing()");
}
void fxAllocating(bool fail) {
void* data = malloc(486);
std::cout << " allocated memory at: " << data << std::endl;
std::exception_ptr e;
try {
fxThrowing(fail, data);
} catch (...) {
e = std::current_exception();
}
// finally:
free(data);
std::cout << " freed memory at: " << data << std::endl;
if (e) std::rethrow_exception(e);
}
int main(int argc, char** argv) {
bool fail = argc == 2 && std::string("fail") == argv[1];
const char* name = "good-finally";
std::cout << name << " (fail=" << fail << ")\n";
try {
fxAllocating(fail);
} catch (const std::exception& e) {
std::cout << " caught exception: " << e.what() << std::endl;
}
}
Na rozdíl od Javy může být v C++ výjimkou cokoli, třeba i číslo, ne jen potomek
třídy Exception
/Throwable
resp. std::exception
. Výše uvedený kód pak bude
fungovat, i když vyhodíme výjimku třeba jako: throw 666;
Závisí na implementaci, zda dostaneme původní výjimku nebo její kopii. Pokud by
náhodou kopírovací konstruktor výjimky vyhodil další výjimku, vrátí se ta. Pokud
by i její kopírování selhalo, můžeme dostat std::bad_exception
.
I když je emulace javovského finally
možná, pro C++ je přirozenější použít RAII
(Resource Acquisition Is Initialization) neboli SBRM (Scope-Based Resource
Management) a zdroj zabalit do objektu, který ve svém destruktoru zajistí uvolnění
zdroje. Ukážeme si několik možností, jak to realizovat.
Životní cyklus zdrojů nebo obecně objektů je spjatý s rozsahem platnosti dané
proměnné. Ten končí v případě lokální proměnné typicky u uzavírající složené
závorky a u instančních proměnných při konci dané instance. V tu chvíli se
zavolá destruktor a v něm se uklidí otevřené zdroje. Pokud je proměnná typu
ukazatel (číslo odkazující na adresu v paměti), končí pouze ukazatel, nikoli
objekt, na který ukazuje. Na rozdíl od jazyků s GC (garbage collector) je tento
systém deterministický – úklid proběhne vždy na stejném místě a ve stejném
pořadí, ne jako např. v Javě, kde se finalize()
zavolá, až na ni v GC přijde
řada a nebo taky nikdy… Na druhou stranu, GC je pro programátora pohodlnější při
udržování větších objektových grafů a při práci se sdílenými objekty s delším
životním cyklem – není potřeba se (tolik) zabývat vlastnictvím objektů ani jejich
„půjčováním“ a syntaxe je jednoduchá. Část úklidu (která nemusí být provedena
deterministicky) také může probíhat asynchronně, v jiném vlákně a nebrzdit běh
aplikace. Jak už to bývá, každý přístup má svoje výhody a nevýhody.
Ve stylu SBRM se v C++ řeší např. i zamykání (std::mutex
). Přestože bychom mohli volat metody lock()
a unlock()
, používá se spíše std::lock_guard<std::mutex> stráž(zámek);
. Tím zamkneme zámek a vytvoříme objekt – k odemčení dojde při konci platnosti tohoto objektu, tzn. nehrozí, že bychom zámek zapomněli odemknout. Platnost je obvykle vyhovující, ale můžeme ji zkrátit pomocí složených závorek (cyklus, větvení nebo prostě jen závorky).
Vytvoříme si třídu, kde v konstruktoru uděláme malloc()
a v destruktoru zavoláme
free()
. Instance tohoto objektu alokujeme na zásobníku a přistupujeme k nim přes lokální proměnné (případně je můžeme alokovat na haldě a zabalit do chytrého ukazatele std::shared_ptr
). Tím je zajištěno, že se paměť uvolní vždy – i při vyhozené výjimce:
#include <iostream>
#include <exception>
class Buffer {
private:
Buffer(const Buffer&) = delete;
Buffer& operator=(const Buffer&) = delete;
public:
size_t size;
void* data;
Buffer(size_t size) : size(size), data(malloc(size)) {
std::cout << " allocated memory at: " << data << std::endl;
}
virtual ~Buffer() {
free(data);
std::cout << " freed memory at: " << data << std::endl;
}
};
void fxThrowing(bool fail, void* data) {
std::cout << " doing something with data " << data << std::endl;
if (fail) throw std::logic_error("error from fxThrowing()");
}
void fxAllocating(bool fail) {
Buffer buf(486);
fxThrowing(fail, buf.data);
}
int main(int argc, char** argv) {
bool fail = argc == 2 && std::string("fail") == argv[1];
const char* name = "good-class";
std::cout << name << " (fail=" << fail << ")\n";
try {
fxAllocating(fail);
} catch (const std::exception& e) {
std::cout << " caught exception: " << e.what() << std::endl;
}
}
Tato třída se zaměřuje jen na tento konkrétní problém a nijak neřeší zapouzdření. Ukazatel na alokovaná data je viditelný zvenku.
Resource
Tuto třídu můžeme zobecnit pomocí generického programování (v C++ pomocí šablon) a vytvořit třídu, které v konstruktoru předáme ukazatel na daný zdroj a funkci pro jeho uvolnění.
#include <iostream>
#include <exception>
template<typename T>
class Resource {
private:
Resource(const Resource&) = delete;
Resource& operator=(const Resource&) = delete;
public:
T* data;
void(*dtor)(T*);
Resource(T* data, void(*dtor)(T*)) : data(data), dtor(dtor) {
std::cout << " created resource for data at: " << data << std::endl;
}
virtual ~Resource() {
dtor(data);
std::cout << " called destructor on: " << data << std::endl;
}
};
void fxThrowing(bool fail, void* data) {
std::cout << " doing something with data " << data << std::endl;
if (fail) throw std::logic_error("error from fxThrowing()");
}
void fxAllocating(bool fail) {
Resource buf(malloc(486), free);
fxThrowing(fail, buf.data);
}
int main(int argc, char** argv) {
bool fail = argc == 2 && std::string("fail") == argv[1];
const char* name = "good-class-generic";
std::cout << name << " (fail=" << fail << ")\n";
try {
fxAllocating(fail);
} catch (const std::exception& e) {
std::cout << " caught exception: " << e.what() << std::endl;
}
}
Ukazatel získáme pomocí malloc()
a pro uvolnění použijeme existující funkci
free()
, nemusíme psát vlastní.
Stejně jako v předchozí variantě ale nesmíme zapomenout zakázat kopírování. Jinak by bylo možné objekt předat hodnotou do jiné funkce nebo jako návratovou hodnotu, čímž by došlo k jeho zkopírování – ukazatel by však ukazoval na původní zdroj. To přináší hned několik problémů naráz. Jednak konec platnosti původního objektu způsobí uvolnění zdroje, takže ukazatel v kopii objektu teď ukazuje do prázdna (tzv. dangling pointer), nebude fungovat a jeho použití může způsobit poškození dat nebo pád programu. A jednak konec platnosti kopie vyvolá pokus o uvolnění zdroje, který se na dané adrese v paměti už nenachází (tzv. double free) a místo něj tam může být něco jiného, co teď poškodíme nebo zase způsobíme pád programu.
Psát si vlastní třídy se zakázaným kopírováním, které řeší uvolnění zdrojů, je celkem užitečné a nepříliš pracné. Psát si vlastní třídy, které lze kopírovat a zároveň správně řeší uvolnění zdrojů (typicky přes počítání referencí) sice lze, ale je to složitější a hlavně už tyto třídy ve standardní knihovně existují.
Typickým příkladem jsou tzv. chytré ukazatele, konkrétně sdílený ukazatel
std::shared_ptr
, což je generická třída, která dělá to, co náš Resource
z předchozího příkladu, ale kromě toho podporuje kopírování a zdroj uvolní až ve
chvíli, kdy zanikne poslední kopie std::shared_ptr
.
#include <iostream>
#include <exception>
#include <memory>
/** not needed, just for logging */
void* myMalloc(size_t size) {
void* ptr = ::malloc(size);
std::cout << " allocated memory at: " << ptr << std::endl;
return ptr;
}
/** not needed, just for logging */
void myFree(void* ptr) {
::free(ptr);
std::cout << " freed memory at: " << ptr << std::endl;
}
void fxThrowing(bool fail, void* data) {
std::cout << " doing something with data " << data << std::endl;
if (fail) throw std::logic_error("error from fxThrowing()");
}
void fxAllocating(bool fail) {
// std::shared_ptr<void> buf(malloc(486), free); // standard functions
std::shared_ptr<void> buf(myMalloc(486), myFree); // our ones with logging
fxThrowing(fail, buf.get());
}
int main(int argc, char** argv) {
bool fail = argc == 2 && std::string("fail") == argv[1];
const char* name = "good-smart-pointer";
std::cout << name << " (fail=" << fail << ")\n";
try {
fxAllocating(fail);
} catch (const std::exception& e) {
std::cout << " caught exception: " << e.what() << std::endl;
}
}
Chytrému ukazateli typicky předáme surový ukazatel na instanci třídy vytvořenou
přes new
nebo chytrý ukazatel vytvoříme generickou funkcí std::make_shared()
,
kterou použijeme místo konstruktoru naší třídy (díky šablonám má funkce stejné parametry jako daný konstruktor).
Chytrý ukazatel pak zdroj uvolní
pomocí delete
. Také ale můžeme chytrému ukazateli předat surový ukazatel na
libovolná data + vlastní funkci na jejich uvolnění. Toho právě využíváme v našem
příkladu.
Tím se vyhneme implementaci vlastních tříd. Je to poměrně užitečné i při práci s knihovnami jazyka C, kde se předávají surové ukazatele, na automatickou správu paměti nehraje a vše je potřeba řešit ručně – tedy v C – v C++ za nás tuto práci udělá chytrý ukazatel. Je to takový mezistupeň mezi surovým céčkovým stylem a plnohodnotným objektovým obalem nad céčkovou knihovnou – zavírání zdrojů za nás řeší C++ a volání metod resp. funkcí píšeme klasicky céčkově.
V příkladu jsem zvolil malloc()
a free()
, protože mi pro ilustraci problémů
s únikem paměti přijdou nejvhodnější. Nicméně v moderním C++ není moc důvod tyto
nízkoúrovňové konstrukce používat přímo. Máme tu řadu hotových tříd a další si
můžeme napsat. Z těch hotových bych jmenoval např. std::string
, což je (na
rozdíl od java.lang.String
) řetězec bajtů nikoli znaků, nebo generický kontejner
std::vector
.
#include <iostream>
#include <exception>
#include <vector>
void fxThrowing(bool fail, void* data) {
std::cout << " doing something with data " << data << std::endl;
if (fail) throw std::logic_error("error from fxThrowing()");
}
void fxAllocating(bool fail) {
std::vector<char> buf(486);
fxThrowing(fail, buf.data());
}
int main(int argc, char** argv) {
bool fail = argc == 2 && std::string("fail") == argv[1];
const char* name = "good-vector";
std::cout << name << " (fail=" << fail << ")\n";
try {
fxAllocating(fail);
} catch (const std::exception& e) {
std::cout << " caught exception: " << e.what() << std::endl;
}
}
Případně tu máme i generické pole std::array
, které má ale neměnnou velikost.
Musíme ale brát v úvahu, že instance těchto tříd lze kopírovat, což vede buď na
duplikaci paměti (v každé části programu bychom zapisovali někam jinam a četli
něco jiného) nebo v případě, že do vektoru či pole uložíme ukazatele, nedojde
k automatickému uvolnění odkazovaných zdrojů. To by šlo řešit tak, že bychom do
pole vkládali chytré ukazatele… Což ale vede na dost ošklivé a dlouhé generické
signatury typů. Tento problém lze někde řešit pomocí klíčového slova auto
(obdoba var
v Javě – typ
není uveden v kódu a odvozuje se automaticky při kompilaci) nebo si můžeme
vytvořit alias:
using Objekty = std::vector<std::shared_ptr<Objekt>>;
A dát složitějšímu typu nějaký vhodnější název.
Kompletní zdrojové kódy příkladů jsou ke stažení v mém Mercurialu:
hg clone https://hg.frantovo.cz/blog/cpp-finally
Pro kompilaci a spuštění příkladů slouží následující Makefile:
CXX ?= g++
CXXFLAGS ?= -O2 -g3 -ggdb -Wall -Wno-sign-compare
CXXFLAGS += --std=c++20
CXXFLAGS += -fsanitize=undefined -fsanitize=address
LDFLAGS ?=
SRC = $(shell find -maxdepth 1 -name '*.cpp')
BIN = $(shell find -maxdepth 1 -name '*.cpp' | xargs basename -s .cpp)
all: $(BIN)
.PHONY: all run clean
clean:
$(RM) $(BIN)
run: $(BIN)
@echo "\e[1;32mHappy path without exceptions:\e[0m"
@for bin in $(BIN); do ./$$bin ; done
@echo; echo
@echo "\e[1;32mInterrupted by exceptions:\e[0m"
@for bin in $(BIN); do ./$$bin fail; done
$(BIN): $(SRC)
$(CXX) $(CXXFLAGS) -o $(@) $(@).cpp $(LDFLAGS)
Kód se kompiluje se zapnutým ASanem (address sanitizer), který nás upozorní na neuvolněnou paměť a další chyby. Poměrně nepostradatelný nástroj… A díky němu na první pohled vidíme, že bad varianta funguje dobře v optimistickém scénáři, ale selhává při vyhození výjimky – neuvolní alokovanou paměť.
Nástroj nám řekne, že uniklo 486 bajtů, což je přesně tolik, kolik jsme
alokovali přes malloc()
a kolik nebylo uvolněno přes free()
kvůli výjimce.
Možná vás zarazila zmínka výše o Javě 6, což je už poněkud prastará verze, která
se v provozu už moc nevidí (oproti tomu Javu 8 lze potkat pořád poměrně často).
Ten modelový příklad by totiž v Javě 7 a novějších nedával takový smysl, protože
v těchto verzích i v Javě máme RAII resp. SBRM. Ta syntaxe sice není tak
elegantní jako v C++ a přidává nám jednu úroveň odsazení kódu, ale problém řeší
a eliminuje potřebu bloku finally
i pro Javu.
Viz článek Java a princip RAII (SBRM, CADRe) známý z C++
Funkční ekvivalent bloku finally
v C++ realizovat lze, ale není to preferovaná
varianta. Blok finally
dává větší smysl v jazycích s GC resp. bez RAII/SBRM.
Přesto si myslím, že je dobré o funkcích std::current_exception()
a std::rethrow_exception()
vědět a že by se mohly někdy hodit.
Tento článek zatím nikdo nekomentoval