FK~

Moje odkazy

Ostatní odkazy

Close Windows
Nenajdete mě na Facebooku ani Twitteru
Rozpad EU
Jsem členem FSF
There Is No Cloud …just other people's computers.
Sane software manifesto / Manifest příčetného softwaru

Blok finally při odchytávání výjimek: C++ vs. Java

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.

C++ a Java: blok finally (ilustrace)

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.

Chybná verze v C++

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í.

Obdoba bloku finally v C++

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()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.

Řešení založená na SBRM / RAII v C++

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()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).

Třída Buffer

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.

Generická třída 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í.

Chytrý ukazatel

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ě.

Třída Finally volající funkci?

Předchozí tři varianty obalují ukazatel na zdroj třídou a spojují ho v jednom objektu s funkcí pro uvolnění zdroje. Ve výsledku máme tolik objektů, kolik máme zdrojů. Což je úplně v pořádku. Nicméně pokud bychom přeci jen chtěli něco, co se víc podobá bloku finally z Javy, můžeme vytvořit jeden společný objekt, který bude řešit jen uvolňování zdrojů – surové ukazatele na zdroje necháme zvlášť v nezávislých lokálních proměnných:

#include <iostream>
#include <exception>
#include <functional>

class Finally {
private:
	Finally(const Finally&) = delete;
	Finally& operator=(const Finally&) = delete;
public:
	std::function<void(void) > fx;

	Finally(std::function<void(void) > fx) : fx(fx) {
	}

	virtual ~Finally() {
		fx();
	}
};

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* buf = malloc(486);
	void* tmp = malloc(123);
	std::cout << "  allocated memory at: " << buf << std::endl;
	std::cout << "  allocated memory at: " << tmp << std::endl;

	Finally finally([&]() {
		free(buf);
		free(tmp);
		std::cout << "  freed memory at: " << buf << std::endl;
		std::cout << "  freed memory at: " << tmp << std::endl;
	});

	fxThrowing(fail, buf);
	fxThrowing(fail, tmp);
}

int main(int argc, char** argv) {
	bool fail = argc == 2 && std::string("fail") == argv[1];
	const char* name = "good-lambda";
	std::cout << name << " (fail=" << fail << ")\n";
	try {
		fxAllocating(fail);
	} catch (const std::exception& e) {
		std::cout << "  caught exception: " << e.what() << std::endl;
	}
}

Někomu může být tato syntaxe bližší (je hodně podobná klasickému bloku finally jen s tím rozdílem, že tento blok musíme umístit před kód, který pracuje se zdroji a který může vyhodit výjimku). Problém je ale v tom, že uvolňování všech zdrojů je pohromadě a provádí se sekvenčně bez ohledu na to, které zdroje se povedlo alokovat. Pokud např. alokace některých zdrojů selže nebo je alokujeme v cyklu či podmínce, společná instance Finally se pokusí uvolnit i zdroje, které nebyly alokovány. Což někdy nemusí vadit a jindy může mít katastrofální následky. Tohle řešení vypadá na první pohled elegantně, ale ve výsledku je dost ošemetné, protože se snadno střelíme do nohy. Nakonec bychom asi stejně skončili s více instancemi třídy Finally, což je oproti předchozím variantám jen horší. Ve specifických případech tento přístup může dávat smysl, ale obecně ho doporučit nelze.

Užitečné to může být pro odložené spuštění kódu, který se má vykonat při opuštění metody bez ohledu na to, kterou větví k němu dochází (returnthrow tam můžeme mít víckrát). Napadá mne využití pro logování nebo měření času stráveného v metodě. Nicméně i k tomu bych si asi napsal specifickou třídu, která se v konstruktoru parametrizuje např. textovým řetězcem (zpráva k zalogování) než obecnou funkcí.

Vektor

V příkladu jsem zvolil malloc()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.

Makefile a ASan

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ěť.

address sanitizer: chyba generovaná při úniku paměti

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.

SBRM / RAII v Javě

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++

Závěr

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()std::rethrow_exception() vědět a že by se mohly někdy hodit.

Odkazy a zdroje:

Témata: [Java] [C++]

Komentáře čtenářů

Tento článek zatím nikdo nekomentoval

Přidat komentář

reagujete na jiný komentář (zrušit)
jméno nebo přezdívka
název příspěvku
webová stránka, blog
e-mailová adresa
nápověda: možnosti formátování
ochrana proti spamu a špatným trollům

Náhled komentáře