Vracíme z funkce objekty
Podívejme se blížeji na to, jakým způsobem je implementováno vracení výsledku z volání funkcí. Zboříme při tom všelijaké mýty
Programátoři začátečníci o takových věcech vůbec nepřemýšlí. Naučí se, že každá funkce (členska i nečlenská) může vracet nějaký výsledek. Tímto výsledkem může být hodnota libovolného typu nebo objekt.
Programátoři pokročilejší už jsou trochu více informováni a často vědí, že vracet objekt může mít velký vliv na celkovou rychlost aplikace. A protože neví více, snaží se tomuto vyhnout. Programování tak podle toho vypadá tak jak vypadá. Jen připomínám, že valná část programátorů patří do této skupiny
Zkušení programátoři samozřejmě vědí, jak je předávání výsledku implementováno a dokáží toho náležitě využít. Těmto programátorům doporučuji následující článek jen opakování.
Ještě existuje jedna skupina a to jsou "Javisté". Znalci jazyka Java vědí, že v Javě se nepracuje přímo s objekty, ale s jejich referencí, takže vracet objekt vlastně znamená vracet jeho referenci. Reference je přitom jednoduchá hodnota (ukazatel), a vracení jednoduché hodnoty zpravidla nemá dopad na výkon. Proto tito programátoři zásadně vrací ukazatele v domění, že dosáhnou nejlepšího výkonu své aplikace. Ne vždy však mají pravdu.
Vracíme jednoduchou hodnotu
Pojďme se podívat jak to celé funguje. Nejprve si ukažme návrat jednoduché hodnoty.
int foo(int a, int b)
{
return a+b;
}
Většina C++ překladačů (no spíš prakticky všechny) provádí návrat takové hodnoty přes registr (nebo registry). Je to velice rychlé a nepřínáší to žádné komplikace.
Na procesorech x86 je návratová hodnota vracena v AL, AX nebo v EAX. 64-bitová je vracena v EDX:EAX (na 32-bitovém stroji). Nedochází zde k žádnému kopírování dat v paměti, a překladač může velmi dobře optimalizovat kód, protože nemusí výsledky odnikud vyzvedávat atd...
Vracíme malý objekt
class MalyObjekt
{
short a;
short b;
public:
// ....
};
MalyObjekt foo(int a, int b)
{
MalyObjekt x(a,b);
return x;
}
Malým objektem se zde rozumí objekt, který se svou velikostí vejde do registru. Překladač pak generuje kód, kterým objekt přenese celý v registru a tím si ušetří spoustu času a práce.
Má to však spoustu podmínek, například tu, že kopírovací konstruktor a operátor přiřazení je plně v režii překladače (tedy že nedefinujeme vlastní)
Vracíme objekt z template nebo inline
Než se vrhneme na pravidla vracení objektů, je třeba podotknout, že překladač se jinak chová tam, kde voláme funkci o které ví vše než tam kde voláme funkci, ze které zná jen její prototyp. Typickým příkladem první situace jsou template funkce (jenž si překladač vyrábí přímo na míru danému volání), nebo inline funkce (kde je možnost místo volání provést vložení kódu přímo na místo volání).
V tomto případě se opět můžeme obdržet velmi efektivní kód, který nic nekopíruje, nic neukládá a nevyzvedává, výsledné objekty je schopen ihned použít.
Pravidla vracení objektu
V okamžiku, kdy zavoláme funkci o které známe jen jeji prototyp, musí překladač generovat kód podle předem dohodnutého protokolu. Neví totiž nic o volané funkci. Nezapomeňme, že program se skládá s kompilačních jednotek, které se překládají samostatně a výsledky z těchto překladů jsou linkovány linkerem. Ten už nemůže do těchto vztahů nijak zasáhnout (Posledním hitem Microsoftích překladačů jsou globální optimalizace, kdy i linker může výsledný kód dále optimalizovat, zatím bych na to ale nespoléhal).
Při vysvětlování pravidel vracení objektů je třeba problém řešit na dvou místech.
- Na straně volajícího
- Na straně volaného.
Funkci nadeklarujeme například takto:
Objekt foo(int a, int b);
A voláme ji třeba takto
Objekt x;
x=foo(a,b);
Funkce samotná je implementována takto:
Objekt foo(int a, int b)
{
Objekt x;
//...
return x;
}
Protože se objekt nevejde do registru, musí překladač interně na obou stranách provést malou úpravu prototypu funkce.
void foo(int a, int b, Objekt *result);
Ano v tom je celý fígl, při volání funkce se přidává ještě jeden parametr, a to je ukazatel, který obsahuje adresu, kam se má uložit výsledek. Cílem volané funkce je tedy naplnit toto paměťové místo instancí výsledku.
(Dodatek, objekt result v tuto chvíli ještě není inicializován, je třeba jej zkonstruovat).
Komůrka
Jistě leckoho teď napadlo, proč tedy vracet, když mohu předat ukazatel a tak se vyhnout nějakým pravidlům. Přece překladač to dělá stejně.
Ano to je jistě pravda. Ale vracet objekt je často transparentnější a čitelnější a taktéž nemusí nutně přinášet výkonové dopady. Nehlědě na to, že mnohdy může být i rychlejší.
Překladač zde za nás dělá spoustu práce, která by nám zabrala čas. Překladač totiž nejen mění prototyp funkce, ale také musí někde vyhradit místo (komůrku) pro výsledek. To většinou dělá na zásobníku, i když né vždy je to potřeba. Právě rozhodnutí o tom, kdy to má smysl a kdy ne, je na překladači. Z našeho pohledu je tohle vše transparentní. I přesto, že o vytvoření komůrky vždy rozhoduje překladač, můžeme její existenci docela pekně využít.
V komůrce totiž vzniká další objekt, jehož život začíná uvnitř volané funkce a končí (často) středníkem na volající straně. Přitom alokace objektu je velice rychlá a proto tvrdím, že předávat přímo objekt je někdy rychlejší, než předávat pouze ukazatel na objekt alokovaný v heapu (alokace v heapu je pomalejší).
Kopírovací operátor a konstruktor
Zopakujme si kopírování objektů.
- kopírovací konstruktor, jehož cílem je vytvoři přesnou kopii objektu
- operátor přiřazení, který nejen kopíruje objekt ale původní objekt musí zničit.
Pokud voláme funkci takto:
Objekt x;
x=foo(a,b);
vzniká při volání funkce foo komůrka pro výsledek na zásobníků. Po návratu komůrka obsahuje výsledný objekt. Protože jsme řekli, že život tohoto objektu končí středníkem, musí se výsledek překopírovat do x. K tomu se využije operátor přiřazení.
Kopírovací konstruktor využijeme při návratu z funkce u příkazu return.
Objekt foo(int a, int b)
{
Objekt x;
//...
return x;
}
Jeho cílem je prostě zkonstruovat objekt v komůrce.
Jak se vyhnout kopírování?
Možná si někdo říká, že je to děsivé. Dvoje kopírování. Z funkce do komůrky, z komůrky do proměnné. Otázka zní, lze to nějak redukovat?
A odpověď zní: ANO!
Podívejme se na možnosti komůrky. Psal jsem, že její platnost končí středníkem. Dalo by se nějak tuto platnost prodloužit?
V normě C++ se píše, že v případě že deklarujeme objekt a zároveň mu přiřazujeme hodnotu, převede se tato operace na volání konstruktoru. Takže
Objekt x=Objekt(...);
se přetransformuje na
Objekt x(...);
Co se stane, když napíšeme.
Objekt x=foo(a,b);
Eliminujeme komůrku!
Jak je to možné? Překladač v tomto bodě ví, že prostor který rezervoval pro x je zatím nevyužit, objekt není inicializován.
To je právě důvod vzniku komůrky při použití ve výrazu (který není inicializací objektu), kdy překladač nemá po ruce žádné místo, kam by se mohl výsledek uložit. Přitom nemůže použít objekt uveden na levé straně rovnítka, protože ten je již inicializovaný. Musel by jej zničit, což ale taky není dobré, protože objekt zároveň může přímo nebo nepřímo figurovat v parametru, nebo jej funkce může potřebovat.
V tomto případě ale překladačí přímo podstrčíme volný prostor s tím, že objekt má vzniknout právě tam. A překladač z prostoru udělá komůrku. Životnost takového objektu však nekončí středníkem, ale je prodloužena až na konec scope.
Všimněme si, že jsme také eliminovali konstruktor.
Objekt x; //konstrukce x
x=foo(a,b); //destrukce x a kopírování
//
Objekt x=foo(a,b); //žádný konstruktor ani kopírování!
Podařílo se nám eliminovat jedno kopírování, ale bylo by možné se zbavit kopírovacího konstruktoru (kopírování výsledku do komůrky)? I zde to lze.
Překladač se řídí výrazem uvedeným za příkazem return. Zde můžeme uvést proměnnou, výraz vedoucí na vznik objektu a nebo... konstruktor.
A právě v případě, že uvedeme konstruktor objektu, který vracíme, přímo předepisujeme překladači, jakým konstruktorem má komůrku inicializovat. Ve všech ostatních případech překladač použije kopírovací konstruktor.
Objekt foo(int a, int b)
{
return Objekt(a,b); //inicializace komůrky konstruktorem
}
Když se nyní vrátíme na volající stranu...
Objekt x=foo(a,b); //1 konstruktor na straně volaného
Už je doufam všem jasné, proč jsem trval na tom, že vracení objektů je někdy rychlejší, než řešit tento problém nějak oklikou. Pokud zapojíme obě optimalizace, docílíme toho, že vracení objektu bude mít stejnou režiii, jako konstrukce objektu na strane volajícího. Režie návratu objektu se minimalizuje na pouhé předání ukazatele.
Optimalizace na straně překladače
Následující část se týká už jen některých překladačů (například VS2005 už toto také prý podporuje). Jedná se o optimalizace kódu na straně překladače s využitím výše uvedených pravidel.
Pokud totiž napíšeme
Objekt x;
x=foo(a,b);
pak hloupý překladač skutečně generuje konstruktor a kopírovací konstruktor. Chytrý překladač pozná, že se s objektem mezi vznikem a přiřazením nic neděje a přepíše tento kód na
Objekt x=foo(a,b);
Optimalizace lze dělat i na straně volané funkce. Pokud napíšeme:
Objekt foo(int a, int b)
{
Objekt x;
//...
return x;
}
překladač nám umožní, aby objekt x byl vytvořen přímo v komůrce. Při běhu funkce můžeme na objekt volat metody, které se provádí přímo nad komůrkou a nikoliv "někde vedle" a následně výsledek kopírovat
Závěr
Doufám, že jsem čtenáře přesvědčil, že vracet objekt má svůj význam, a že nemusí mít vždy výkonnostní dopady. Mezi další výhody patří naprostá modularita tohoto systému. Optimalizaci totiž provádíme na té straně, na které ji potřebujeme. Například když volající strana ví, že nemůže optimalizovat a musí využít operátoru přiřazení. Může se rozhodnout libovolně, aniž by toto bylo nutné ošetřovat na volané straně. Stejná situace nastane i na volané straně, která nemusí řešit, kam má výsledek uložit.
Jakýkoliv jiný systém nám může svazovat ruce a nutit psát neefektivní kód. Budeme-li vracet ukazatel, je nutné řešit vlastnictví ukazatele, čili kdo je zodpovědný za destrukci objektu. Nemůžeme objekt použít uprostřed výrazu, protože se objekt implicitně nezničí. Budeme-li naopak do funkce předávat referenci na objekt, do kterého se má uložit výsledek, je volání takovéhle funkce docela těžkopádné, musíme zakládat proměnnou a nemůžeme začlenit funkci do složitějšího výrazu.
Nebojte se vracet objekty.