Upozorňuji dopředu, že nehodlám zde probírat to, co všichni programátoři v C++ určitě znají a co si mohou přečíst na milionech stránkách, které nabídne google. Myslím tím zejména ony problémy s diamantovým děděním a jak správně pracovat s virtuální dědičností. Při práci s generikou, kdy se hojně používají „prázdné třídy“ časem narazíme na další komplikaci, které i zkušeného programátora překvapí a možná mu trošku zkomplikují život.
V generickém programování (to jsou takové ty programy, ve kterých se hojně vyskytuje klíčové slovo template) postupem času asi každý programátor nalezne zalíbení ve vytváření prázdných tříd. To jsou třídy, které obsahují deklarace, nebo definice metod, avšak neobsahují žádné proměnné, ani virtuální metody. Objekt takové třídy by ve skutečnosti neobsadil žádnou paměť, protože jeho instance vlastně nic neobsahuje. Velmi často používáme prázdné třídy k vytváření alokátorů nebo porovnávačů
class CmpItem {
public:
bool operator()(const Item &a, const Item &b) const;
};
typedef std::set<Item, CmpItem> MujSet;
Kdo se s tím ještě nesetkal, toho překvapí, že dotaz na sizeof(CmpItem) vrátí hodnotu 1, ačkoliv třída je prázdná a neobsahuje jedinou proměnnou. Je to přitom záměr. Představte si, že by taková třída propadla zkrz parametr šablony do nějakého pole. Pokud by velikost třídy byla nastavena na nulu, všechny hodnoty v poli by měly stejnou adresu a pole by také mělo velikost 0 bajtů. Převod ukazatele na index pak nutně vede na dělení nulou. Tyto důvody a dalších N podobných komplikací (například, že dvě různé instance stejné třídy nesmějí sdílet stejnou adresu v paměti) vedly autory norem k pravidlu, že instance každé třídy musí alokovat vždy minimálně 1 bajt paměti. Stejně se například chová malloc(0) … alokuje 1 bajt a vrátí jeho adresu.
Pokud použijeme prázdnou třídu jako členskou proměnnou, zabere 1 bajt v rozvržení instance třídy. Pokud je taková proměnná umístěna mezi vícebajtové proměnné, které je třeba zarovnat na dělitelnou adresu, pak překladač za takovou proměnnou umístí povinný padding. Na 32bitové platformě to znamená, nikoliv 1 bajt, ale 4 bajty! Dost vyplýtvaného místa na prázdnou třídu nemyslíte?
Tomuto plýtvání lze zabránit tak, že namísto proměnné budeme prázdnou třídu dědit.
class A {};
class B {
A a;
int x;
};
class C: public A {
int x;
};
GCC vám řekne toto:
sizeof(A): 1 sizeof(B): 8 sizeof(C): 4
Račte si to vyzkoušet: (codepad). Příklad krásně demonstruje to co jsem napsal. Proměnná a má skutečně velikost 1 a protože x musí být zarovnáno, je za proměnnou a doplněn padding. Instance třídy pak zabere 8 bajtů. Pokud však třídu A podědíme, zmenšíme velikost instance na poloviční velikost. Za třídu A si lze dosadit třídu dodanou šablonou, například výše uvedený příklad CmpItem, který může (ale nemusí) být deklarován prázdný (například při řazení indexů v databázi bude obsahovat odkaz na kontejner dat, jelikož index obsahuje pouze indexy … což jsou vlastně jen čísla, která bez odkazu na data nelze řadit).
Pokud máme možnost do šablony dodat jen jednu třídu, která může být (běžně) prázdná (například CmpItem), lze to obejít děděním. Co když těch tříd máme víc? Můžeme použít vícenásobné dědění? Zdálo by se logické, že to bude fungovat obdobně.
Ve skutečnosti to je problém, protože to nezafunguje tak, jak bychom si představovali. Navíc se implementace liší podle platformy a překladače. K demonstraci jsem si připravil následující příklad:
#include <iostream>
class A1 {};
class A: public A1 {};
class B1 {};
class B: public A1, public B1 {};
class C1 {};
class C: public A1, public B1, public C1 {};
class D1 {};
class D: public A1, public B1, public C1, public D1 {};
class E1: public A1, public B1 {};
class E: public E1 {};
class F1: public A1, public B1 {};
class F: public E1, public F1 {};
class G1: public A {};
class G: public E1, public G1 {};
class H: public C, public D {};
class I: public H, public G {};
int main(int argc, char* argv[])
{
std::cout << "sizeof(A): " << sizeof(A) << std::endl;
std::cout << "sizeof(B): " << sizeof(B) << std::endl;
std::cout << "sizeof(C): " << sizeof(C) << std::endl;
std::cout << "sizeof(D): " << sizeof(D) << std::endl;
std::cout << "sizeof(E): " << sizeof(E) << std::endl;
std::cout << "sizeof(F): " << sizeof(F) << std::endl;
std::cout << "sizeof(G): " << sizeof(G) << std::endl;
std::cout << "sizeof(H): " << sizeof(H) << std::endl;
std::cout << "sizeof(I): " << sizeof(I) << std::endl;
}
Výsledky, které vrací překladač od Microsoftu, Visual C++ 2008:
sizeof(A): 1 sizeof(B): 1 sizeof(C): 2 sizeof(D): 3 sizeof(E): 1 sizeof(F): 3 sizeof(G): 2 sizeof(H): 6 sizeof(I): 9
Jiná čísla vrací GCC
sizeof(A): 1 sizeof(B): 1 sizeof(C): 1 sizeof(D): 1 sizeof(E): 1 sizeof(F): 2 sizeof(G): 2 sizeof(H): 2 sizeof(I): 4
Rozebereme si nejprve výsledek od Microsoftu. Vzhledem k tomu, že jsem nikde nenašel oficiální popis, budu vycházet z mého pozorování. Podle všeho Visual C++ započítává každou další třídu, ze které se dědí, jako členskou proměnnou o jednom bajtě. To je krásně vidět na výsledcích pro třídy A – D. Zatímco třída A má velikost 1 „z povinnosti“, velikost třídy B je opravdu 1 bajt, protože pro dědění z B1 je tento bajt alokován. Přitom jde evidentně o zbytečnou operaci, protože obě podtřídy budou mít ukazatel stejné hodnoty. V případě třídy C je nutné započítat už dva bajty za dvě třídy navíc. Podobně je na tom třída D. Z další analýzy se zdá, že každá třída navíc navyšuje počet alokovaných adres pro prázdnou třídu. Aby jsme si udělali přehled, je nutné provést tranzitivní uzávěr celé hierarchie a označit si třídy, které jsou ve vícenásobné dědičnosti navíc. Rozepíšeme si třídu I:
I,H,G,C,D,E1,G1,A1,B1,C1,A1,B1,C1,D1,A1,B1,A,A1
Tučně jsem označil třídy, které se nachází na druhém a dalším místě v deklaraci vícenásobné dědičnosti. Vidíte, že jich je devět, a z toho vyplývá devět alokovaných bajtů pro celou třídu I
Proč tohle Visual C++ dělá opravdu netuším. Ale podle mne se to možná vyjasní, pokud se podíváme na výsledek GCC. Podle mého názoru se Visual C++ snaží zabránit situaci, kdy některé třídy, které se v rozložené hierarchii opakují, by mohly sdílet stejnou adresu, přestože jde o různé instance… a jak víme, různé instance nesmějí mít stejnou adresu, viz první kapitola. Nicméně způsob, jakým se to řeší, není úplně nejšťastnější.
To GCC není vícenásobnou dědičností nijak rozhozeno a i pro třídu D vrací velikost 1. Teprve třída F, ve které se dvakrát vyskytují třídy A1 a B1 alokuje dvě adresy. Opět se podíváme na třídu I:
I,H,G,C,D,E1,G1,A1,B1,C1,A1,B1,C1,D1,A1,B1,A,A1
Tady jsem podtrhl třídu A1, tučně označil třídu B1 a kurzívou třídu C1. Vidíte, že po rozbalení třídy I zde máme 4× třídu A1. Protože se nejedná o virtuální dědění, žádný diamant se zde nekoná, a třída A1 je zde zastoupena čtyřmi instancemi, kde každá instance by měla ležet na jiné adrese. Zajímavé je, že GCCčku není proti srsti, že stejnou adresu jako A1 bude pravděpodobně sdílet i B1. Jde vlastně o objekt A1,B1,C1, který dohromady může alokovat pouze jednu adresu. Jiný objekt A1, B1 okupuje druhou adresu, a tak dále. Rozložení podtříd do adres můžeme zobrazit třeba takto.
+0 I,G,G1,A,A1 +1 E1,A1,B1 +2 H,D,A1,B1,C1,D1 +3 C,A1,B1,C1
Neověřoval jsem to přesně, ale cílem je, aby se žádná třída neopakovala na jednom řádku. Samozřejmě, že celá problematika bude složitější v tom, že nestačí jen dodržet pravidlo nesdílení adresy pro stejné podtřídy, ale zároveň každá potřída musí fungovat samostatně, takže počet alokovaných adres může být i vyšší. Například, třída G alokuje 2 adresy, tedy musí alokovat offset 0 a offset 1. Třída H také alokuje 2 adresy, zbývají pro ně offset 2 a offset 3. Ostatní třídy alokují jednu adresu a tak se musí naskládat ke svým nadtřídám na každém řádku. Celé to pak tvoří třídu I, která začíná na offsetu 0 a zabírá 4 bajty.
Linuxovou verzi si můžete opět vyzkoušet na codepadu
No na závěr bych napsal toto: Nepřehánět to s vícenásobnou dědičnosti. Pokud jsem si doposud šablony skládat přes vícenásobnou dědičnost do jedné souhrnné šablony, tak jsem si neuvědomoval tento problém a žil jsem v domění, že to překladač nějak dobře zoptimalizuje. Nedělo se tak. A tak se jednoho krásného dne stalo, že jsem objevil podivný padding u třídy ConstStrA, která by se dala zkráceně zapsat takto:
class ConstStrA {
public:
const char *str;
unsigned int length;
};
A ačkoliv z tohoto pohledu má mít třída pouhých 8 bajtů, ve skutečnosti má bajtů 16, protože není takto jednoduše deklarovaná, ale jedná se o kombinaci několik šablon (například lze změnit char za jiný typ, přes další šablonu se tam importuje rozhrani pro práci s řetězci). Výsledná třída má před první proměnnou 4 bajtový padding, a stejný padding se nachází na konci. To vše vytvořilo vícenásobné dědění prázdných tříd různě po cestě navěšených (a aby to nebylo všechno, při předávání instance třídy se všechny bajty, včetně nepoužívaného paddingu, poctivě kopírují na zásobník, Visual C++)
Je hlavně podivné, jak vícenásobnou dědičnost řeší Microsoft Visual C++. Předpokládám, že v normě C++ není jasně definován layout tříd, které používají vícenásobnou dědičnost v souvislosti s prázdnými třídami. I z toho důvodu musí člověk být opatrný, pakliže chce prázdné třídy používat jako nástroj generického programování. Čas od času se mu může důsledek použitého návrhu šablony zásadně promítnout do výsledného kódu.
Jinak řečeno, vše je implementation defined. Za pár let může být ve zcela jinak.
Skutečný důvod, proč Microsoft dělá řadu věcí jinak je ten, že kromě normy C++ (která z hlediska obsahu článku nedefinuje vůbec nic). Za několik let může být článek pouhou historickou ukázkou.
Pokud by jednoduchá třída vracela sizeof nulu, je to plně v souladu s normou. Pokud je kompilátor schopen se vypořádat ve všech situacích, klidně to může udělat.
Jak gcc, tak Microsoft dělají práci navíc. Požadují nějaké další vlastnosti. Microsoft požaduje a preferuje dokonalou runtime kontrolu chyb. Bezchybnost programu je pro Microsoft vyšší prioritou než cokoli jiného.
Oba kompilátory plýtvají místem, jen podle jiného algororitmu. Oba se snaží doplnil dodatečné funkčnosti.
Rozdílnou filozofii lze vidět na implementaci STL. Gcc prostě má co požaduje norma a tečka. Microsoft má STL proloženou hromadou assertů a kontrol. Jednou, když jsem nemohl přijít několik dní na chybu v STL části, jsem jí prostě z gcc přeložil MS. Program jsem spustil a po vteřině mi chybu i se jménem zdrojáku a číslem řádku a detailním popisem vyhodil v message boxu.
Prioritou MS je i v délkách vysoká možnost detekce chyb.
Na Vašem místě bych ale zkontroloval jaké zarovnání u MS používáte.
Jinak by se slušelo také říci, že sizeof(x) neznamená, že proměnná zabere v paměti x bajtů. Oba kompilátory to naznačily už tím, že i nulovou délku vyhodily jako 1 bajt přes sizeof.
Nic nebrání kompilátoru, aby optimalizoval a pole 1000 objektů prázdné třídy uložil jako nula bajtů třeba na zásobník. A samozřejmě vrátil 1000 × sizeof(jeden objekt).
Na sizeof prostě kálí pes. Je to hodnota, která je větší nebo rovná tomu co objekt skutečně zabere.
Dělat diagnostiku přes sizeof a dělat z toho rozsáhlé závěry je pofiderní, samotný operátor sizeof lže.
A samozřejmě templaty něco stojí. Jako vše high-level.
Pane Ponkrác, všechno to jsou Vaše doměnky, které stojí na hliněných nohou. Sizeof opravdu vrací velikost, kterou třída zabere v paměti. Tedy bez paddingu, který tam může překladač dodat v případě, že potřebuje udělat zarovnání pro další membery. Dokonce i samotná velikost může být vrácena s respektem na zarovnání (pokud třída obsahuje member, který se zarovnává na adresu dělitelnou 4, bude velikost celého objektu také dělitelná 4, protože podle tohoto čísla se například adresují prvky v poli).
Že prázdná třída skutečně má velikost 1 (abych byl přesný, prázdná třída má velikost sizeof(char) ), vám potvrdí sám Stroustrup, viz: <a>http://www.research.att.com/~bs/bs_faq2.html#sizeof-empty
Ostatní poznámky spíš ukazují vaší neznalost celé problematiky (otázka, k čemu je tedy sizeof, když podle vás nevrací správné údaje? Jaké by měl mít využití? Jak budete bez sizeof implementovat takový std::vector, jak budete počítat potřebnou velikost paměti?)
Pokud by nefungoval sizeof(), tak by nikdy nemohlo fungovat tohle:
https://github.com/peterix/dfhack
... a ono to funguje :P
Komu se Visual Studio 2008 zdá archaické, tak naměřené hodnoty potvrzuji s Visual C++ 11 Beta (Debug/Release/32bit/64bit) a GCC 4.7 (MinGW, bez optimalizací).
Reakce na [1]: Microsoftí STL (vyvíjejí Dinkumware a Stephan T. Lavavej) obsahuje hromadu assertů a kontrol samozřejmě pouze v Debug režimu a dá se vypnout, v Release jsou všechny kontroly defaultně vypnuté (nebylo tomu vždy, tuším že od verze která přišla s VisualStudio2010).
Přijde mi, že se tady míchají jablka s hruškami. Je velký sémantický rozdíl mezi tím, když třídu podědím, nebo když ji mám jako členskou proměnnou. Dědění je dost silná vazba a programátor by s ním měl být velice opatrný. Bohužel C++ nemá způsob, jak se mu vyhnout (např. nemá interface). Takže typický programátor v C++ chápe dědičnost jako technický prostředek k dosažení svých cílů, ale nevidí v něm ten význam, který dědičnost má.
Tomáši, myslím si, že o tom to není. C++ samozřejmě interface má, jenže to má v obecnější formě. Zatímco interface se třeba v javě chápe jako další jazykový nástroj, v C++ je interface jen o tom, že programátor dodrží nějaké směrnice ..."štábní kulturu" ... (například všechny metody jsou abstraktní). Vícenásobná dědičnost je pak obecnou verzí implements ... jaký je rozdíl mezi extends a implements? Kromě toho, že jde o technickou záležitost v jazyce Java, tak vlastně žádný. Ano, jistě, pro sebe si můžeme definovat nějakou interpretaci použití nástrojů, že jako extends dědí, zatímco implements pouze implementuje rozhraní, ale to samé si lze udělat v C++. Já si například všechny interfacy označuju písmenem I na začátku. Pokud tedy někam napíšu "class A: public IFoo", pak ekvivalentní zápis v Javě je "class A implements Foo". Význam technických prostředků si samozřejmě vždycky musí udělat programátor. Klidně mohu dědění chápat jako spojování struktur, jazyk to umožňuje, ale kolegové mi nejspíš nebudou rozumět ... mimochodem, krásným příkladem je objektové prostředí v neobjektovém jazyce javascript, i tam je to spíš o těch směrnicích a štábní kultuře. Sám jazyk tomu moc nepomáhá.
V článku jsem se snažil poukázat na to, že tahle vícenásobná dědičnost může přinést nějaké skryté úskalí, ale není to evidentně chyba jazyka jako takového, ale spíš implementace (v tom má pan Ponkrác pravdu). Microsoftí implementace je z tohoto pohledu určitě horší, protože vícenásobná dědičnost nafukuje objekty. GCC to dělá správně, tak to má být. Stejně jako v Javě, ani zde vícenásobnou dědičnost nijak "nepocítíme", dokud tedy nezačneme dědit jednu třídu (implementovat jedno rozhraní) vícekrát. To je ale už jiná kapitola, kterou všichni "hledači diamantů" znají.
S tím tak úplně nemůžu souhlasit. Mám matný dojem, že jsem to tady již někdy zmiňoval, ale C++ opravdu nepodporuje interfacy dobře. Respektive nepodporuje rozumně dědičnost interfaců:
class IA { virtual void f()=0; }
class IB : extends IA {}
class A : public IA { void f() {}; }
class B : public A, public IB {}
tak kompilátor zařve, že v B schází IA::f()
Samozřejmě, lze to ošetřit virtuálním děděním interfaců, jenže:
1) při větší hierarchii interfaců je to dost velká výkonnostní a paměťová ztráta
2) musím to řešit nikoli tam, kde problém vzniká (u objektu B), ale už u interfaců, což je v přímém rozporu s principy OOP a navíc někdy i špatně řešitelné (interfacy v cizí nemodifikovatelné knihovně apod.).
To nic nemění na tom, že C++ je krásnej jazyk :-)
PS: A nevím, kdes přišel na tom, že javascript je neobjektovej jazyk. Javascript je plně objektový jazyk, akorát beztřídní (to, že je objekt navíc funkcí? a co?).
PPS: Taky jsem nepochopil vícenásobnou dědičnost, Javu a diamanty, protože v Javě vícenásobná dědičnost není a mimo některé speciální konstrukty (např. konstanty) se tam s diamantem člověk snad co vím potkat nemůže. Nebo se pletu?
Máte pravdu, že vícenásobná dědičnost diamantového typu je pro C++ problém. Ale je to dáno tím, jak je vlastně dědičnost implementována. Neexistuje ideální řešení, buď je to pomalé, velké, nebo rychlé, ale s určitýma omezeními. Nehledě na to, že interfacy vznikly právě proto, že v těch jazycích vícenásobná dědičnost není možná. To co píšete o interfacech v C++, tak to není problém toho, že by C++ neumělo interfacy, ono neumí jen diamantové dědění a to obecně, ne jen u interfaců. U interfaců speciálně je ale situace mnohem jednodušší. Vícenásobné dědění sice vede, že příslušný interface je ve třídě víckrát, ale na použití takových objektů to nemá až takový vliv (až snad s omezením, že výjimky nelze odchytávat na duplicitní interface, a při downcastu musím překladači říct, který z vícenásobných interfaců vlastně chci použít).
Pokud jde o výše uvedený příklad, tak ten se právě dá řešit dvěma způsoby. První je použitím virtuálního dědění, což je ale pro interfacy kanón na vrabce, protože zavádí další (skrytou) dereferenci, což se projeví na velikosti objektu (přibude minimálně jeden pointer pro každou virtuálně poděděnou třídu). Druhým řešením je řešit to až v místě implementace. Ve vašem příkladu by to znamenalo, že by třída B redefinovala metodu f() a pouze by ji přesměrovala na A:f(). Nevýhodou je, že je to víc psaní, není to tak pohodlné, ale je to řešení. Na výkonu se to neprojeví, překladač tam buď přímo narve instrukci jmp na implementaci metody v předkovi, nebo ji tam rovnou inlinuje (instrukce jmp je relativně levná), nebo dokonce do tabulky virtuální adres vepíše přímo adresu na předka (nemám ověřeno). Z hlediska velikost objektu to sice znamená, že objekt bude mít dva ukazatele na dvě tabulky virtuálních adres, které budou obsahovat společnou část pro IA, ale to je rozhodně méně, než kdyby ty tabulky tam byly tři (pro IA, IB+B, A+B) a k tomu dva ukazatele na IA a při volání metody z IA by se prováděla ještě jedna dereference. Dalším problémem tohoto řešení je onen zmiňovaný problematický downcast.
Takže není to o interfacech, ty v C++ trpí právě implementací diamantové dědičnosti. Nicméně, pokud tam diamant nemám... což je drtivá většina případů... pak interfacy mohu normálně používat stejně jako v jiném jazyce. Takže nesouhlasím s tím, že C++ nemá interfacy. C++ má jen problém s diamantovým děděním.
PS: Javascript není klasickým objektovým jazykem. Javascript balancuje někde mezi. Je to o hranici, kdy řeknu, co je a co není objekt. Ono lze totiž objektově programovat i v klasickém neobjektovém C... a nikdo neřekne, že C je objektový. Ale s knihovní podporou tam mohu honit objekty se všemy vlastnostmi, které objekty mají, až se ze mně bude kouřit. A nějak podobně to cítím v Javascriptu. Například mám problém používat objekty v čistém JS (nedělám v tom denně). Ale s knihovnou JAK (od Seznamu), je to už hračka. Jo, jsou tam objekty, klasické, dědičnost, prototyp, ale je k tomu potřeba speciální knihovna, která to dělá. Pak váhám, zda mám říkat, že je objektové JS, nebo zda objekty přináší až JAK. Pozn: píšu subjektivní názor.
K tomu příkladu:
Upravil jsem Váš příklad tak aby šel přeložit. Diamantové dědění rozhranní opravdu nelze: http://codepad.org/JJ4hv8M4
Virtuální dědění: http://codepad.org/mAxqB7kB
Nevirtuální dědění: http://codepad.org/5Lo7MsZ0
Zajímavé je, že mi vyšla stejná velikost objektu. GCCčko rozhodně optimalizuje dobře. Stále tam je ale nevýhoda u nevirtuálního dědění s downcastem
Řešit to lze takto:
U výjimek ale takový downcast není možné nařídit, takže výjimku B nelze odchytnout na IA a propadně ven jako neodchycená
u virtuálního dědění to jde:
Programování je holt boj :-)
C++ rozhraní nepodporuje, ale podporuje tzv. abstraktní třídy. Ty se řídí dědičností stejně jako neabstraktní. Dokonce lze bez problémů vytvořit abstraktní třídu jako potomka neabstraktní.
class A {
};
class IA : public A {
public:
virtual void f() = 0;
};
class B : public IA {
public:
virtual void f() {}
};
To že se nahrazuje rozhraní pomocí abstraktních tříd je samozřejmě pravda, ale z toho samozřejmě vyplývají vlastnosti a omezení stejně jako pro neabstraktní třídy. Tedy pro diamant je nutné vždy použít virtuální dědičnost. Osobně jsem se tím setkal snad jen u C++ streamů. C++ nelze považovat za plně objektový jazyk. Není to jeho chyba, ale vlastnost.
[12] No pokud bych měl takto lpět na termínech, tak plně objektový jazyk je snad jedině smalltalk. Já se tady nechci pouštět do nekonečných debat o správném pojmenování termínů a rozjíždět na to flamewar. V příspěvcích [8] a [9] jsem to popsal, to se neměnní. Pokud bych teď použil mezi evangeliky oblíbený ducktyping , tak C++ sice nemá "ty interfacy", ale má něco, co se deklaruje jako interface, chová se jako interface a používá se jako interface,...
Howg.
[13] C++ nemá nic co by se deklarovalo jako rozhraní, ale má abstraktní třídy, které se chovají částečně jako rozhraní, a používají se v C++ místo rozhraní.
[14] Ano špatně jsem to napsal. Reagoval jsem na zde rozebíraný problém diamantu při použit "rozhraní" v C++. Obecně v C++ platí. Pokud je nutné vytvářet dědičnost ve tvaru diamantu, tak pomocí virtuální dědičnosti. A nezáleží jaký typ třídy to je.
Jenom bych ještě chtěl dodat, že mým cílem nebylo slovíčkařit o rozhraní a abstraktní třídu. Měl jsem ale pocit, že se zde chce po abstraktní třídě, která se v C++ používá místo rozhraní, a já i normálně toto označení používám, jiná forma dědičností než mají neabstraktní třídy. Jenže v C++ jsou prostě jen třídy.
[15] je hezký komentář, plně v tomhle Linuse chápu. Článek i komentáře jsem si přečetl, některým částem jsem nerozuměl ani na 2. pokus. Napsal jsem šachový algoritmus, PKCS#11 knihovnu pro čipové karty v C++, úspěšně portoval SIP klienta na Android z části v C++, kdysi jsem i na tom Seznamu v C++ dělal, ale C++ furt pořádně nerozumím. Když mám po někom luštit "správný" C++ kód (pořádně prošablonovaný, každý druhý operátor přetížený, vícenásobná dědičnost, všechno vrhá výjimky, instance, implementace, interface...), tak jsem z toho vždy nešťastný. Ondrův článek i příspěvky pěkně ukazují, že C++ pořádně nerozumí ani ti, co si myslí, že mu rozumí. Nemůžu si pomoci, podle mě to není dobrý jazyk. Vyměnil bych ho za nějaké to C s jednoduchými třídami.
Honzo to by mě zajímalo, čemu jste nerozuměl a podle čeho jste poznal, že tomu nerozumím já. Jen mě překvapilo to, jak zejména microsoftí překladač nedobře optimalizuje a že s určitými implementačními efekty je třeba počítat. Tyhle nuance najdete i ve spouště jiných jazyků. Já se akorát zabývám jen jedním.
[20] Nerozuměl jsem už tomu prvnímu příkladu na sizeof, konkrétně proč sizeof(C): 4, mimo jiné i pro to, že nevím, co se děje, když potomek předefinuje membera předka, zde x. (Přijde mi to jako čuňárna, kterou jsem nikdy neměl potřebu použít, ani by mě to nenapadlo, tak mě norma C++ pro tento případ nikdy moc nezajímala.) To, že C++ nerozumí i zkušení, je napsáno už v úvodním textu "...které i zkušeného programátora překvapí...", neměl jsem na mysli nějaký poklesek autora. Nuance jsou ve všech jazycích, jen mi přijde, že v C++ je zvykem ty nuance dost používat. Prostě kód "právného" C++ programátora (často i jinak inteligentního člověka a schopného programátora) nepůjde 5 let starým překladačem ani přeložit. Naopak aspoň trochu normální céčkař většinou nemá potřebu využívat třeba pokročilých vlastností preprocesoru nebo např. definovat pole pointerů na funkci vracející pointer pole funkcí nebo dělat bláznivé kousky s pointerovou aritmetikou. Vím, že tím hodně lidí i urazím, ale C++ mi přijde jako takové Céčko, které se postupně dobastlovalo, aby z toho bylo něco na způsob Javy nebo C#, což se nakonec částečně podařilo, ale za cenu velkých komplikací a ztráty čitelnosti a jednoduchosti.
Ne opravdu, zrovna ten příklad není žádná nuance ale definice. Je je potřeba v tom C++ vidět tenké rozhraní mezi vyšším programovacím jazykem a strojovým kódem. Zatímco interpretované a bajtcodové jazyky mají mezi vlastní myšlenkou programátora a cílovým kódem procesoru "tuny balastu", které zajišťuje absolutní oddělenost implementačních detailů od vlastního programování, C++ od začátku jde tou cestou, kdy si na nic nehraje, a umožňuje programátorovi brát na vědomí i technické detaily. Což činí jazyk velice mocným, ale pro někoho zřejmě nepřehledným.
K tomu prvnímu příkladu: http://codepad.org/mUADjrZ1
Jde o to, že prázdná třída zabírá v paměti 1 bajt, pokud je použita jako proměnná... to stanoví norma. Na strojích se zarovnáním na 4 adresy pak zabírá 1 bajt + 3 nevyužité adresy pro zarovnání. Zarovnává se kvůli další proměnné, která má 4 bajty. Dohromady má třída B celých 8 bajtů. Pokud ale prázdnou třídu pouze podědíme, norma stanoví, že prázdná třída může zabírat 0 bajtů (říká se tomu "Empty base-class optimization") Potom už není proč zarovnávat a v třídě zůstává jen 4 bajtová proměnná, proto C zabírá jen 4 bajty.
Celý vtip je v tom, že mnoho programátorů tohle řešit nebude. Máme stroje o gigabajtech, programy jsou plné nevyužitého prostoru, neoptimalizujeme, data ukládáme bez nějakých kompresí. Nějaký 4 nebo 8 bajtů nás nemusí rozhodit. Taky ten příklad s těmi hromadami dědění je extrémní, měl jen demonstrovat a dokazovat mé zjištění. Já jsem totiž náročný a nespokojím se s tím, že něco nějak funguje a je mi jedno jak. Programuju jak pro X GB stroje, tak pro atmely, kde je k dispozici 1KB paměti a kde člověk počítá každý bajt (přesto se tam programuje v C++). A o svá zjištění jsem se chtěl podělit.
Neřešte to, nenechte se odradit. Většinou jsou to prkotiny.
[18] Jenže v C++ se nijakým způsobem nerozlišuje toto rozhraní a abstraktní třída. Všechny pravidla pro abstraktní třídy platí i pro rozhraní. Toto rozlišení je samozřejmě na místě z hlediska použití programátorem, ale z hlediska C++ neexistuje.
Ještě se zítra pro jistotu podívám do draftu ke standardu C++.
[22] Dik za vysvětlení, už tomu příkladu rozumím. Ale furt mi přijde to C++ divné. Když počítám každý byte, tak nebudu mít pole (ani std::vector) nějakých tříd, ale céčkové pole primitivních typů, kde si velikost spočítám i bez nějakého velkého intelektuálního úsilí. A když každý byte nepočítám, tak můžu mít klidně i třeba java.util.Vector vyplněný java.lang.Objecty :-) A v zásadě takhle je to skoro se vším. Když chci psát efektivně, tak mi stačí C nebo na větší věci a projekty s přirozeným výskytem objektů hodně lehké C++. Když chci psát rychle ne nemusím řešit každý byte, tak mi vyhovuje Java. V obou případech mi přijde výsledek hezčí a čitelnější než v hardcore C++.
[24] Já? Vy se tu oháníte rozhraním. Pouze jsem uvedl, že nemůžete od toho, co se v C++ používá jako rozhraní, chtít jiné chování od tříd, včetně dědění do diamantu, protože je to prostě jen další třída. Proto také [18] je z hlediska C++ nesmysl. To ovšem neznamená, že mám něco proti označení rozhraní.
[26] Tak jestli chcete být hyperkorektní, tak C++ nemá třídy, ale jen takové rozšířené struktury. A taky nemá enumy, jen pojmenované inty. Přejete si takovou úroveň diskuze?
[25] a co když chci řešit každý byte a přitom mít k dispozici objektový systém, který za mě řeší spoustu věcí, který bych musel jinak řešit složitě ručně (virtuální metody, automatické volání destruktorů, chytré ukazatele, výjimky, generika?)
[27] virtuální metody (tam kde je nejaka prirozena hierarchie) a automatické volání destruktorů uznavam a radim do toho hodne lehkeho C++. Chytrych ukazatelu se tochu bojim, trochu mi prijde, ze uz pak clovek neresi kazdy byte, trochu mi prijde, ze staci citelne alokovani a dealokovani. Vyjimky mi vadi, spravne chytat vyjimky mi subjektivne prijde o dost tezsi nez napr. hlidat navratove hodnoty. Generika (to jsou ty vektory, mapy atd?) se mi samozrejme casto hodi, ale taky se mi moc v C++ nelibi, pak to vede k tomu ze nekdo zapomene predefinovat operator = a dejou se divne veci. (Resil jsem aspon 3x, vzdycky nejaka krasna padacka.) A taky mi prijde, ze pak uz clovek neresi kazdy byte. A taky s tim jsou problemy na obskurnich platformach, donedavna i Android NDK.
Je to i dost subjektivni, kazdemu sedne neco jineho. Nejhorsi je, kdyz se programator jako ja setka s programatorem jako Ondra, to je pak utrpeni pro oba. To si pak jeden o druhem mysli, ze je uplny blb, ze nechyta vyjimky a druhem o prvnim taky, ze je uplny blb, ze vrha vyjimky a nenapise to rude palcovym pismem v dokumentaci.
[28] Není třeba psát nic do dokumentace :-) Stačí jen dodržet určitá pravidla, která mnozí z nás odmítají dodržovat. Třeba to, že v C++ může vyletět vyjimka odkudkoliv, kromě od funkcí, které mají vyjimky vypnuté / throw() /. Ano, je to změna stylu programování oproti klasickému C, ale opravdu doporučuju si na to zvyknout. Právě chtrých ukazatelů bych se nebál, protože se neliší od běžných ukazatelů, akorát za člověka řeší právě ty situace, které normálně řeší odchytáváním kdejaké vyjimky ... často úplně zbytečně ... jen proto, aby funkce po sobě uklidila ... přestože tohle je věc, která patří do destruktorů... a věřte mi, překladač ten úklid pomocí destruktorů zvládne mnohem lépe, než vy ručně. Díky tomu se nemusím soustředit na chyby ani na úklid a udělám víc práce. Porovnejte si tyto dva kódy
bool foo() {
char *str = allocString("hello world");
if (str == 0) return false;
if (!printString(str)) {
deallocString(str);
return false;
}
//... dalsi cast kodu
deallocString(str);
return true;
}
void foo() {
String str("hello world"); //vyhodi vyjimku kdyz se to nepodari
printString(str); //vyhodi vyjimku kdyz se to nepodari a automaticky uklidi
} //automaticky uklidi str.
A to kdy se řeší a neřeší každý bajt. Já řeším každý bajt v C++ :-) Dá se to, ale na některá úskalí s tím spojené budu upozorňovat na rootu.
[29] To deallocString bych volal jen na jednom miste. Ve zvlast zamotanych pripadech i za cenu goto, ale tady to jde snadno i bez toho, tak bych tam goto samozrejme necpal. A Ondra neuvedl chytani te vyjimky. Bude tam pak mit bud nejaky switch nebo alespon volani virtualni metody te vyjimky ve stylu vyjimka->osetri() v miste, kde uz ctenar kodu nebude vedet, o co jde... Takze uz to s tou citelnosti neni tak jednoznacne. I v uplne cistem C se da napsat spousta veci celkem kulturne.
[30] A nač řešit odchycení výjimky. Soustředíme se na algoritmus. Pokud vylítne výjimka uvnitř nějakého databázového výpočtu, tak máme možnost catch (...) pro uvedení databáze do konzistetního stavu a throw; pro poslání výjimky vejš. To je tak všechno, co nás ve funkci zájímá. Co si s výjimkou udělá volající je jeho věc. Klidně to může propadnout až na základní úroveň, kde se uživateli zobrazí okénko s popisem chyby, která nastala... to je další výhoda výjimek, totiž pokud má každá výjimka popis ... (a třeba v mé knihovně je to povinnost)... pak se snadno identifikuje problém.
Naopak je sice hezké, že vidím, že ve funkci jsem na pěti místech program ukončil (i za cenu použití goto!) pro chybu, kterou reportuju výše v návratové hodnotě, ale jakou mám jistotu, že si toho volající všimnul?
Další nevyhodou je, že každý programátor má vlastní systém chyb. Někdo vrací nulu, druhý jedničku, jeden vrací kód chyby, druhý používá errno, někdo na to má globální stavovou proměnnou (užijte si v MT prostředí!). Chybové kódy nejsou unikátní, mezi knihovnami se musí překládat, přičemž se často některé informace ztratí... napríklad selže zápis na disk, protože na sektoru 5128 zařízení /dev/sdb1 je chyba. No a tahle chyba propadne k uživateli jako obecná chyba -1 při pokusu uložit dokument. Na to UIčko zareaguje hláškou "není místo na disku" nebo "disk je chráněn proti zápisu", případně "nepovedlo se zapsat na disk". Diagnostika problému je v háji.... o zavádějících hláškách nemluvně.
> jakou mám jistotu, že si toho volající všimnul
No prece uplne stejnou, jako ze spravnym zpusobem zpracuje vyjimku. Clovek, ktery v C ignoruje navratove hodnoty bude v C++ chytat a ignorovat vyjimky a nebo je nebude chytat vubec a necha to sletet. Takze az dojde misto na disku, program C lajdaka mozna jen neulozi soubor a mozna spadne, program C++ lajdaka taky mozna neulozi soubor a mozna spadne. (A preci jen neotestovat navratovou hodnotu napr. fopen v miste volani je jeste vetsi lajdactvi nez neodchytit vyjimku metody saveDocument(), ale to mozna prijde jen mne, ceckari.)
> Další nevyhodou je, že každý programátor má vlastní systém chyb
Ja myslim, ze kazdy C++ programator ma zase vlastni system vyjimek, ne? (V Jave - pokud nemusim resit "kazdy byte" jsou aspon ty standardni vyjimky pro deleni nulou, IndexOutOfBoundException, NullPointerException, ale v C++ ani nic takoveno neni, ne? Nehlede na to ze vyjimky mohou byt i vyssiho, logickeho charakteru, tam uz se nedaji napasovat do nejakych skatulek vubec.)
[33] Zdá se mi, že o výjimkách by to chtělo napsat nový článek. Takže telegraficky:
a) člověk, který ignoruje výjimku znamená, že mu jeho program bude nejspíš padat a generovat chybové hlášení
b) člověk, který ignoruje chybový návratový kod si koleduje o spoustu skrytých problémů, jako zacyklení, memory leak, resource leak, porušení dat, neočekávaný pád v jiném místě programu
Každý programátor může mít vlastní systém vyjímek, ale pokud je programátor myslící člověk, tak každá jeho výjimka buď přímo nebo nepřímo dědí std::exception. Tato výjimka má funkci what() a ta by měla vracet něco smysluplného. To je úplný základ. Pak ať si tam ten programátor postaví třeba celý strom výjimek. Ten, kdo nezná výjimkový systém toho programátora, má pořád šanci se minimálně z výjimky dozvědět, co se stalo a pomocí typeid se dozvědět i název třídy výjimky. V mém systému výjimek se navíc z what() člověk dozví i číslo řádku a jméno souboru, kde k výjimce došlo a soupis tzv "reasons", tj připojené další výjimky, jenž způsobily tuto výjimku. A to všechno prosím bez nutnosti znát můj výjimkový systém, bez potřeby handlovat nějaké IDčka chybových zpráv. No už je to moc dlouhý, takže začnu chystat podklady k blogpostu.
[28] Ahoj, Honzo :-)
Chybějící operátor = je samozřejmě problém, ale to je zpíše pozůstatek přinesený z C a jeho struktur. Nový standard C++11 už umožňuje operator = zrušit (a u staršího to trikem s private jde taky), ale bohužel kvůli zpětné kompatibilitě už nikdy nebude nutné jej u ne-POD objektů explicitně deklarovat, aby existoval.
[33] C++ má i standardní výjimky (std::out_of_range, std::invalid_argument ap.), které vyhrazují třeba STL kontejnery, ale pro své třídy je nikdo nenutí programátora používat. Nicméně to kromě dědění z java.lang.Throwable nevynucuje ani Java (a nakonec mě ani nenapadá, jak to vynucovat).
Neodchycení výjimky znamená pád programu (std::terminate). Nezpracování chybového kódu znamená nedefinovaný stav a celkem libovolné chování závislé na tom, co, kde a za jakých okolností se nepodařilo. V tomhle se zrovna C++ chová stejně jako Java.
Intenzivně se zabývám programováním zejména v jazyce C++. Vyvíjím vlastní knihovny, vzory, techniky, používám šablony, to vše proto, aby se mi usnadnil život při návrhu aplikací. Pracoval jsem jako programátor ve společnosti Seznam.cz. Nyní jsem se usadil v jednom startupu, kde vyvíjím serverové komponenty a informační systémy v C++
Přečteno 57 600×
Přečteno 27 720×
Přečteno 26 403×
Přečteno 24 367×
Přečteno 22 864×