Přátelské nedorozumění nad kávou

Nemohu se ubránit pocitu, že se Java stala něčím jako náboženstvím. Jestliže se o ní nevyjádříte dostatečně uctivě, vrhnou se na vás její příznivci jako na kacíře. Není to nic nového, podobný osud potkal před časem Pascal, C++, Forth a jiné jazyky. Jedním z prvních z nich byl nepochybně Fortran: Kdo by se nepamatoval na článek o skutečných programátorech, kteří používají právě tento jazyk, a pojídačích koláčů, kteří používají Pascal.

Problém je, že řada argumentů v podobných diskusích vychází z neporozumění nebo z neznalosti čehokoli jiného kromě zbožňovaného jazyka. Proto jsem velice rád, že se argumentace ve prospěch Javy ujal pan Čada, který velmi dobře zná nejen Javu a C++, o které nám zde půjde, ale i řadu dalších souvislostí, a to jak po teoretické, tak i po praktické stránce.

O co vlastně jde

Články „Jak jsem potkal Javu“ a „Co má Java proti C++“ vznikly na základě poznámek, které jsem si dělal, když jsem se s tímto jazykem seznamoval. Nekladl jsem si jiný cíl než poukázat na místa, která mohou být pro céčkaře z nějakých důvodů zajímavá nebo nebezpečná. Nic víc a nic méně.

Článek pana Čady se vyjadřuje k řadě problémů; já se zde pokusím na některé z nich odpovědět.

O přetěžování operátorů

Tvrzení, že přetěžování operátorů má smysl jen pro matice a podobné matematické struktury a pro aritmetické operátory, vypadá na pohled přesvědčivě, skutečnost je ale složitější. Prohlédneme-li si pozorně standardní knihovnu jazyka C++, zjistíme že téměř každou z tříd v ní provází skupina přetížených operátorů. Některé z nich jsou metody, jiné obyčejné funkce. Uveďme si alespoň několik příkladů:

n     Přetížený operátor indexování slouží k přístupu k prvkům dvoustranné fronty (deque) a asociativního pole (map).

n     Přetížený přiřazovací operátor umožňuje přenos všech prvků z jednoho kontejneru do jiného.

n     Přetížené operátory == a != slouží pro porovnávání obsahu kontejnerů. (Např. dva seznamy jsou si rovny, jestliže obsahují stejné prvky ve stejném pořadí.)

n     Díky tomu, že můžeme přetěžovat operátory ==, !=, ++, -- a * (dereferencování), lze v C++ snadno definovat iterátory, tedy pomocné datové struktury, které umožňují zacházet jednotným způsobem s kontejnery obsahujícími „posloupnosti“, tedy s poli, seznamy, frontami ap. Pak můžeme snadno vytvořit např. šablonu funkce, která bude umět setřídit jak „obyčejné“ céčkovské pole tak seznam nebo dvoustrannou frontu.

Za vyloženou lahůdku považuji použití přetížených operátorů pro vstupní a výstupní operace. Kdykoli mám v Javě napsat něco jako

System.out.println(a);  
System.out.println(b);

vzpomenu si na eleganci zápisu

cout << a << endl << b << endl;

a tiše (nebo i nahlas) zanadávám.

Hlavní síla operátorů pro vstupy a výstupy v C++ ale spočívá v tom, že si je mohu rozšířit – přetížit – i na své vlastní datové typy a zacházet s nimi zcela stejně jako s typy vestavěnými.

Samozřejmě, operátory lze přetěžovat i nesmyslným způsobem. Lze definovat operátor *, který bude sčítat, lze definovat operátor =, který bude přenášet data zleva doprava atd.

Ovšem skutečnost, že něco lze zneužít nebo použít nesprávným způsobem, ještě přece není důvod k zákazu. Logika „lze to zneužít, tedy se to zneužívá, a proto to zakážeme“, mi připomíná logiku zákona, který zakazoval vypalovat soukromě alkohol a za provinilého považoval každého, kdo měl destilační přístroj nebo jeho „podstatnou část“. To bychom totiž museli zakázat i metody: Kdo nám zabrání definovat metodu Plus(), která bude dělit? Dovedeme-li tuto logiku do absurdity, měli bychom zakázat programování jako celek, neboť jen tak lze zabránit psaní počítačových virů, trojských koňů a jiných lahůdek.

Virtuální a nevirtuální metody

Dovolím si oponovat názoru, že nevirtuální metody jsou něco příšerného až zrůdného. Je to jen další vyjadřovací prostředek, který C++ programátorovi nabízí, a lze jej velice elegantně využít. Než se ale pustíme do úvah na toto téma, řekněme si několik slov o polymorfismu jako takovém.

Za tímto označením se skrývá jedno z pravidel objektového programování, které říká, že na místě, kde očekáváme instanci předka (bázové třídy), lze použít instanci potomka (odvozené třídy), neboť odvozená třída by měla představovat zvláštní případ (specializaci) bázové třídy. To znamená, že při použití objektu ve skutečnosti nemusíme znát jeho třídu; prostě mu pošleme zprávu (zavoláme jeho metodu) a on zareaguje správným způsobem. Představme si např., že pracujeme s grafickými objekty, které jsou instancemi tříd odvozených od společného předka – třídy GrafickyObjekt. Máme jich plný zásobník a chceme je všechny nakreslit. Pošleme jim tedy všem po řadě zprávu „nakresli se“, tj. zavoláme odpovídající metodu, a očekáváme, že objekt, který představuje čtverec, se nakreslí jako čtverec, zatímco objekt, který představuje kruh, se opravdu nakreslí jako kruh, aniž se o to musíme zvlášť starat.

Syntakticky bude odpovídající příkaz v Javě i v C++ pokaždé stejný, bude to volání metody se stejným identifikátorem (např. Nakresli()) a se stejnými parametry. Je věcí programu, aby se postaral o volání správných metod odpovídajících skutečnému typu instance. Technický prostředek, kterým se toho dosahuje, se označuje jako „pozdní vazba“.

Tolik na úvod, ze kterého by mělo být jasné, že polymorfismus má smysl pouze v případě, že je ve hře dědičnost. V Javě tvoří všechny třídy jedinou dědickou hierarchii, proto je tak trochu logické, že všechny metody využívají pozdní vazbu.

Na druhé straně v C++ mohou existovat zcela samostatné třídy, tedy třídy, které neleží v žádné dědické hierarchii. Typickým příkladem může být knihovní třída complex<T> reprezentující komplexní čísla vytvořená z dvojice čísel typu T. Proč by taková třída měla obsahovat nějaké společné vlastnosti všech objektů?

Protože tato třída není potomkem žádné třídy a protože nepředpokládáme, že by se mohla stát předkem nějaké třídy, není nejmenší důvod používat pro její metody pozdní vazbu – tedy deklarovat je jako virtuální.

Nicméně i v polymorfních třídách mohou mít nevirtuální metody své opodstatnění. Ostatně neuškodí, podíváme-li se na možnosti, které nám v souvislosti s virtuálními a nevirtuálními metodami C++ nabízí a jaký mají význam.

Definujeme-li v předkovi:

n     čistě virtuální metodu bez implementace (v Javě a občas i v C++ se nazývá „abstraktní“), definujeme tím její rozhraní a přikazujeme potomkům, že musí definovat její implementaci;

n     čistě virtuální metodu s implementací (to je konstrukce, která v Javě nemá analogii), definujeme tím její rozhraní a nabízíme potomkům implicitní implementaci, kterou ale nelze volat pomocí pozdní vazby – potomek ji musí zavolat s plnou kvalifikací;

n     virtuální metodu („obyčejnou“), definujeme tím její rozhraní a implementaci, kterou mohou potomci změnit, je-li to potřebné;

n     nevirtuální metodu, definujeme tím její rozhraní a implementaci, která je pro potomky závazná.

K poslednímu bodu je třeba poznamenat, že nejde o závaznost syntaktickou (jako je klíčové slovo final v Javě), neboť překladač se nevzbouří, jestliže nevirtuální metodu v potomkovi předefinujeme. Existují i situace, kdy to má smysl, většinou tím ale způsobíme chybu – program se bude chovat jinak, než jsme si přáli.

Vícenásobná dědičnost a rozhraní

Vícenásobná dědičnost se v C++ opravdu používá především k implementaci rozhraní. Lze pro ni nalézt i jiná použití, neboť představuje zajímavou alternativu ke skládání objektů – například standardní vstupně-výstupní proud iostream je společným potomkem tříd istream a ostream, které představují samostatný vstupní, resp. samostatný výstupní proud. To ale není příliš podstatné; podívejme se na příklad, na kterém pan Čada předvádí, k jakým problémům může vícenásobná dědičnost vést. Pro pohodlí čtenáře – ale i své – ho zde zopakuji. Nejprve definujeme abstraktní třídu Interface, která nahrazuje javské rozhraní:

class Interface {public:  
   virtual void metoda()=0;  
};

Pak definujeme třídu Object, která by měla sloužit jako společný předek všech tříd v našem programu:

class Object { public:  
  virtual void xxx() {  
     printf("Metoda vsech objektu...\n");  
  }
};

Nakonec definujeme třídu Xxx, která implementuje rozhraní Interface a která je potomkem třídy Object:

class Xxx:  
public Object, public Interface {  
 public:
  virtual void metoda() {  
    printf("metoda tridy Xxx\n");  
  };
};

Nakonec se podíváme na funkci use(), která  pracuje s rozhraním Interface a která působí problémy:

void use(Interface* o)  
{ // Původní implementace p. Čady  
 ((Object*)o)->xxx(); // !  
 o->metoda();
}

Předáme-li této funkci jako parametr ukazatel na instanci třídy Xxx, zavolá se v příkazu, označeném vykřičníkem, metoda metoda(), nikoli metoda xxx(). To je velice nepříjemná chyba, jejíž hledání v programu může trvat velmi dlouho.

Co se vlastně stalo?

Přetypování (Object*)o překladači vlastně říká: „Zde máš ukazatel o na třídu Interface; buď tak laskav a zacházej s ním jako s ukazatelem na třídu Object.“ Tyto dvě třídy spolu nijak nesouvisí a v době překladu nelze obecně zjistit, zda o náhodou nebude za běhu obsahovat ukazatel na instanci odvozené třídy, který je zároveň potomkem třídy Object.  Uvedený postup tedy představuje jedinou alespoň trochu rozumnou interpretaci požadovaného přetypování.

Výsledkem je, že volání metody xxx()překladač přeloží jako volání první virtuální metody třídy Object, tj. metody metoda().

My jsme si ale přáli něco jiného: Chtěli jsme zacházet s ukazatelem na rozhraní jako s ukazatelem na instanci nějaké třídy, která je potomkem třídy Object, a zavolat její zděděnou metodu xxx(). To samozřejmě jde, musíme ale zapomenout na analogii s Javou a říci si o to tak, jak se sluší a patří v C++.

Především bychom měli se měli rozloučit s přetypovacím operátorem (typ), převzatým z jazyka C. Jeho úkoly – a něco navíc – si mezi sebe rozdělily operátory dynamic_cast, static_cast, const_cast a reinterpret_cast. Protože nám jde o přetypování mezi ukazateli na polymorfní třídy v rámci jedné dědické hierarchie, použijeme operátor dynamic_cast:

Object *uo = dynamic_cast<Object*>(o);  
if(uo) uo ->xxx();            // OK

Nyní již program bude fungovat bez problémů.

Poznamenejme, že zatímco operátor (typ) se v C++ vyhodnocuje už v době překladu, operátor dynamic_cast se volá za běhu programu. Tento operátor nejprve zjistí, zda má požadované přetypování smysl, a pokud ano, provede jej. Pokud ne, vrátí 0. Proto jsme vrácenou hodnotu uložili do pomocné proměnné a před použitím testovali její hodnotu.

Použitím operátoru dynamic_cast lze vyřešit i další problémy v příkladech, které pan Čada uvádí ve svém, článku a které obsahují přetypování ukazatelů na objekt.

Existují v C++ třídy?

To je otázka, na kterou není vůbec snadné odpovědět – záleží na tom, co si pod tím představujeme. Položme si několik otázek, které by nám mohly pomoci najít odpověď.

n     Můžeme za běhu programu poslat třídě zprávu? – Můžeme; v C++ existují metody tříd, nazývané podobně jako v Javě „statické“.

n     Má třída svou datovou reprezentaci v programu? – Může ji mít. Skládá z datových složek třídy („statických“ dat) a popřípadě z tabulky virtuálních metod a informací nezbytných pro dynamické určování typu za běhu programu (ovšem pokud má naše třída alespoň jednu virtuální metodu).

n     Chová se instance objektového typu v C++ jako „černá skříňka“, která přijímá zprávy a reaguje na ně? – Odpověď zní ano, pokud samozřejmě s instancí zacházíme korektně. Například nekorektní přetypování může způsobit problémy, ale o tom hovořím v oddílu „Vícenásobná dědičnost a rozhraní“.

n     Může program vytvořit instanci třídy, která nebyla v době psaní programu známa, kterou naprogramoval někdo jiný, kterou a kterou umístil např. do dynamické knihovny? – Může, ale není to zdaleka tak jednoduché jako v Javě nebo v jiných čistě objektových jazycích. Musí být splněny jisté dodatečné a částečně omezující předpoklady, např. že půjde o třídu odvozenou od pevně daného předka, že odpovídající dynamická knihovna bude obsahovat určité pomocné funkce atd.

n     Stačí tři obvykle uváděné principy objektového programování – zapouzdření, dědičnost, polymlorfizmus – k tomu, abychom uznali, že jazyk podporuje objektové programování? – Na tuto otázku nechť si odpoví čtenář sám.

V zájmu objektivity je ale nutno dodat, že nepolymorfní třídy v C++, tedy třídy, které neobsahují žádné virtuální metody, se opravdu podobají spíše strukturám s metodami než objektům – nefunguje pro ně mj. dynamická identifikace typů (RTTI). Lze si ovšem jen těžko představit, že se v jakékoli rozumné dědické hierarchii vyskytují nepolymorfní třídy.

Super

Hlavní účel klíčového slova super opravdu není potlačení pozdní vazby, jak snad z mé poznámky v článku „Jak jsem potkal Javu“ mohlo vyplývat. Jak známo, toto super zpřístupňuje složky předka, nadtřídy.

Mimochodem, příklad, na kterém pan Čada použití super ukazuje, není právě nejšťastnější. I když technicky lze třídu komplexních čísel zavést jako potomka třídy reálných čísel, logicky to není nejlepší, neboť v matematice je jejich vztah přesně opačný – reálná čísla jsou zvláštním případem komplexních čísel (s nulovou imaginární částí). Kdybychom opravdu někde použili komplexní čísla odvozená od reálných čísel, znamenalo by to, že

n     v místech, kde se očekává pouze reálné číslo, nám program dovolí použít i jakékoli komplexní číslo,

n     v místech, kde se očekává komplexní číslo, nám program nedovolí použít číslo reálné.

Obojí je nesmyslné, ovšem hlavního tématu diskuze, významu klíčového slova super, se to v podstatě netýká.

Vraťme se tedy ke klíčovému slovu super a k polymorfismu. Java obecně neumožňuje potlačit pozdní vazbu, tj. předepsat, že chceme volat zděděnou metodu, nikoli metodu implementovanou v potomkovi. Jedinou situaci, kterou takto lze označit, ukazuje následující příklad:

class Predek {  
  void f(){/* ... */}  
}

class Potomek extends Predek {  
  void f(){/* ... */}  
  void g(){super.f();}
}

Kdybychom v metodě g() potomka nepoužili klíčové slovo super, volala by se metoda f() třídy Potomek. Takto zavoláme metodu předka, tedy vyřadíme (alespoň částečně) pozdní vazbu. Tento trik ovšem umožňuje pouze využít metodu předka k implementaci metody potomka, neumožňuje programově zavolat metodu předka pro instanci potomka. Neumožňuje také volat metodu ze vzdálenějšího předka.

Reference a garbage collector

Automatická správa paměti, garbage collector, je velice pohodlné zařízení. Když si na něj zvyknete, nebude se vám chtít vracet se k systémům, které ho neobsahují. Nicméně v C++ jej lze poměrně snadno nahradit.

V běžných případech stačí zapouzdřit ukazatel na dynamicky alokovaný objekt do automatického ukazatele, instance pomocného objektového typu, jehož destruktor se postará o uvolnění paměti objektu. Takový automatický ukazatel si můžeme snadno naprogramovat sami; standardní knihovna jazyka C++ ale obsahuje šablonu třídy auto_ptr<T>, která implementuje ukazatel na typ T. Tento automatický ukazatel obsahuje i příznak vlastnictví a o uvolnění paměti se pokusí pouze ukazatel, který objekt „vlastní“.

Ve složitějších případech se pro dynamicky alokované objekty používá tzv. počítání referencí. Objekt se v tom případě stará o svém uvolnění z paměti sám – obsahuje celočíselnou proměnnou, ve které si počítá, kolik ukazatelů na něj existuje, a v případě, že tento počet klesne na nulu, spáchá sebevraždu např. příkazem

delete this;

Je jasné, že jde o způsob poměrně nepohodlný a náchylný k chybám, na druhé straně ale o způsob velice efektivní.

Funkční parametry

V článku „Jak jsem potkal Javu“ jsem jako způsob předávání funkčního parametru předvedl automaticky generovaný kód z JBuilderu, ve kterém se předávaná metoda s daným prototypem „zabalí“ do anonymní třídy. To je zcela standardní javská konstrukce, nejde o žádné rozšíření.

Souhlasím s tím, že způsob, který pan Čada ukazuje, je na první pohled elegantnější. Nicméně způsob, který se používá v JBuilderu, umožňuje, aby handler, podprogram, který se stará o odezvu na nějakou událost, byl metodou okna nebo obecně objektu, který představuje součást grafického rozhraní, ve kterém je komponenta umístěna. To umožňuje mj. sdílení handlerů – několik komponent může při různých událostech volat týž handler. (Můžeme např. požadovat, aby se při dvojkliku myší na vstupní řádku stalo totéž jako při stisknutí tlačítka vedle této řádky.) Nepsat stejný kód vícekrát, to je přece stará programátorská zásada.

Překladač a programátor

Jedním ze základních rysů jazyka C++ je, že jeho překladač pokládá programátora za myslícího, samostatného a svéprávného člověka, který ví, co chce. Proto se mu snaží vyhovět, nehledá důvod, proč jeho program označit za chybný, a dovoluje mu řadu „potenciálně nebezpečných“ operací, jako je přímé zacházení s ukazateli, přetypování ukazatele na jednu třídu na ukazatel na jinou třídu, předefinování nevirtuálních metod v odvozené třídě apod. V tomto přístupu k programátorovi se C++ výrazně odlišuje nejen od Javy, ale i od Pascalu, Ady a řady jiných programovacích jazyků. Proto také v případě mnoha potenciálně nebezpečných konstrukcí překladač hlásí pouze varování nebo ani to ne – spoléhá na kázeň a znalosti programátora.

Java zdaleka tak liberální není, i když ji v žádném případě nelze označit za vyloženě restriktivní. To není odsudek, to je prosté konstatování skutečnosti.

Podle mých zkušeností jsou mezi programátory dvě výrazné skupiny. Jedna z nich dává přednost jazykům, které poskytují volnost výrazových prostředků a možnost zvolit si vlastní osobitý styl, a to i za cenu, že mohou udělat chybu. K tomu jsou ochotni dodržovat jistou vlastní disciplínu, která je při práci s takovým nástrojem nezbytná.

Druhá skupina preferuje spíše jazyky, které programátora vedou, nutí ho pracovat určitým předem daným stylem, ale na druhé straně umožňují snáze navrhovat strukturu programu a zamezují některým druhům chyb.

Obojí má své výhody a nevýhody a příslušnost ke kterékoli z těchto skupin podle mých zkušeností naprosto nevypovídá o kvalitě programátora. Možná, že souvisí s jeho postojem ke světu, ale obávám se, že to je spíše filozofický problém.

Myslet ve svém jazyku

K tomu, abychom mohli úspěšně programovat v nějakém jazyku – a nemusí to být jen C++ nebo Java – nestačí znát klíčová slova a základní knihovní funkce nebo třídy. Každý jazyk má svou vnitřní logiku, a tu je při návrhu programu třeba brát v úvahu. Jinak se dostaneme do situace, budeme dělat z Javy C++, z C++ Pascal a podobně – a budeme náramně překvapeni, že se v našem programu objevují „záhadné“ a „nesmyslné“ chyby.

Miroslav Virius (virius@km1.fjfi.cvut.cz)