Tento příspěvek navazuje na diskuzi u předchozího článku. Ta se týkala o tom, zda má či nemá C++ destructive move, jaké výhody a problémy to přináší. Upozorňuji dopředu, že lze očekávat Rust vs C++ flamewar
Diskuzi pod původním článkem nehledejte, smazal jsem její větší část, protože mi přišla vyhrocená a kompletně mimo téma. Největším argumentem tam bylo, že prý vůbec nerozumím tomuto termínu a jsem zamknut ve své C++ bublině a nedokážu ohodnoti úžasné výkonové přínosy v tom jiném jazyce, kde se destructive move používá (tady je zřejmé, že jde Rust).
A tak jsem si to nastudoval pořádně, abych zjistil … že to fakt není velká věc… ale aspoň se v tomto článku podělím se svými zjištěními a s mým osobním názorem.
Nejlepší je asi ukázat na nějakém příkladě. A protože jde o nízkoúrovňové záležitosti, ukážeme si to v C.
Příklad 1. #include <string.h> struct Person { char *name; int byear; char gender; //'M', 'F', 'X', etc }; //NOTE: takes ownership! void add_to_table(struct Person *p); int main() { struct Person p; p.name=strdup("Franta Vonasek"); p.byear=1995; p.gender='M'; add_to_table(&p); }
V tomto příkladě definujeme osobu, která drží jméno v řetězci – a je to jediná proměnná, která obsahuje ukazatel, ostatní proměnné jsou nativní typy, které není třeba speciálně spravovat (ukážeme si, že to nemusí být pravda). Důležitý je zde komentář u funkce add_to_table
, který upozorňuje programátora, že funkce kompletně přebírá vlastnictví struktury a tedy volající by už neměl na strukturu sahat. Všimněte si také, že se předává ukazatel, a z popisu není zřejmé, jestli se předání vlastnictví týká celé struktury, nebo jen řetězce. Předpokládejme, že se jedná pouze o řetězec, protože jinak by se celá struktura musela alokovat na heapu.
Tohle je přesně to místo, kde se hovoří o destruktive move. Konkrétně v Rustu (představme si to momentálně bez ukazatele, vrátím se k tomu), do transakce vstupuje překladač, který zabrání programátorovi použití proměnné p po tom, co obsah proměnné převzala funkce add_to_table
. Ta destrukce – kterou tam nevidíte – se týká jen života (lifetime) proměnné, je to myšleno tak, že během přesunu (move) dojde k odstranění proměnné (destructive), tedy nehledejte tam destruktor.
Podmínkou k tomu aby to fungovalo je nějaký kontrakt, který přesně definuje úlohy obou stran, jedna strana se vzdává vlastnictví a druhá ho získává. Tento kontrakt je v Rustu vynucený (dokud nepoužije trait Copy), v ostatních jazycích pro změnu neexistuje. Samozřejmě, že můžeme takový kontrakt zavést například pomocí poznámky v komentáři. Je ovšem velice naivní si myslet, že kolegové programátoři tyto poznámky čtou a řídí se jimi. A není v moci nikoho jim zabránit to ignorovat a udělat chybu. Pojďme se tedy podívat na jiná řešení, kde můžeme umožnit move, aniž bychom věděli, jak neznámá volaná funkce s daty naloží
Příklad 2. char *alloc_string(const char *txt); void dealloc_string(char *); void add_to_table(struct Person *p); int main() { struct Person p; p.name=alloc_string("Franta Vonasek"); p.byear=1995; p.gender='M'; add_to_table(&p); if (p.name) {dealloc_string(p.name);p.name=NULL;} }
Malá úprava, místo free
použiju dealloc_string
, protože při použití free by nebylo co demonstrovat, free lze volat i na NULL. Aby to mělo nějaká pravidla, zavedl jsem funkci alloc_string
, která alokuje string pro dealloc_string
– ať už to znamená cokoliv
To co tady demonstruji není ani destructive ani move, je to něco jiného. Ale samotný move je možný. Přesun zde ale zajistí neznámá funkce add_to_table
, která provedený přesun indikuje nastavením řetězce na NULL. Volající tak musí po návratu zkontrolovat, zda došlo k přesunu nebo ne.
Ani zde není nic, co by vynutilo kontrakt. Deklarace funkce se od předchozího řešení nezměnila a neindikuje nic speciálního. S neexistencí kontraktů kontrolovaných překladačem musíme být opatrnější. Pokud bychom předpokládali, že volaná funkce provede move avšak nestalo by se, vytvoříme memory leak. Nárok se klade i na samotnou funkci, která musí provedený přesun indikovat nastavením NULL na pointeru (což je v zásadě základní povinnost při práci s pointerem v C, a pokud to někdo nedělá, pak zaslouží vyhazov na hodinu).
O tom, že by mohl být proveden move by mohla napovědět deklarace funkce. Od C99 můžeme používat klíčové slovo const. Pokud tedy v parametru chybí const, musíme být na pozoru, protože volaná funkce může strukturu měnit, nebo přesunout. Pokud tam const je, tak přesun není možné (pokud autor funkce není prase které se přetypováním const neodstraní)
Ukážeme si ještě jeden asi mnohem zásadnější příklad
Příklad 3. void add_to_table(struct Person p); int main() { struct Person p; p.name=alloc_string("Franta Vonasek"); p.byear=1995; p.gender='M'; add_to_table(p); //dealloc_string(p.name)??? }
V tomto příkladě předáváme Person hodnotou, nikoliv pointerem. Ten problém je, že vznikne kopie struktury v místě, kam minimálně v rámci C nemáme přístup. Pokud tedy nevíme nic o funkci add_to_table
, musíme předpokládat, že obsah nepřesouvá ale navíc, pokud by přesouvala, nemá nám to jak indikovat. Výše uvedený příklad tedy povede k memory leak
pokud funkce obsah nepřesune. Když ovšem pojistíme přes dealloc_string() – který bude nejspíš volat free() - může zase dojít k double free
chybě, pokud by náhodou funkce obsah destruovala, nebo use after free
, pokud by obsah přesunula a ten by žil někde jinde.
Máme například následující strukturu
struct OpenedFile { char *filename; int fd; };
Windows programátorovi musím vysvětlit, že fd
je file descriptor, který vyžaduje speciální zacházení podobně jako ve WinAPI se zachází s HANDLE (jenže ten je pointer).
Problém je, že fd
se vztahuje k existující aktivní resource, zde otevřenému souboru. Při nedestruktivním move jej tedy musíme spravovat stejně jako pointer. Stalo se zvykem, že neplatná hodnota pro fd
je –1. Pokud tedy používáme přesun pomocí příkladu 2. musíme po zavolání funkce, která má přesun zajistit, provést kontrolu nejen filename
ale i fd
struct OpenedFile f = ...; set_source_file(&f); if (f.filename) { free(f.filename); f.filename = NULL; } if (f.fd != -1) { close(f.fd); f.fd = -1; }
Rust tento problém vyřešil šalamounsky, jak už bylo řečeno, v Rustu se vždy přesouvá a tak odpadá kontrola hodnot po návratu z funkce, do které se přesunulo. Za tímto řešení stojí myšlenka, že většina „objektů“ nemá smysl kopírovat, a je to v asi pravda u objektů třeba typu OpenedFile
(lze ukázat, že to může mít smysl, ale jindy). Pokud chci, aby vznikala kopie, musím buď explicitně použít clone
, nebo objekt musí mít trait Copy
.
Má osobní poznámka k trait Copy: Nelíbí se mi, protože přímým pohledem do zdrojáku nepoznám, co se kopíruje a co se přesouvá. Lepší je, když každé volání, kde se kopíruje je nějak označeno. Třeba zakázat kolegům používat trait Copy u nových objektů a vynutit si použití explicitního clone()
Na začátek je třeba říct, že move semantics v C++ vznikly před rokem 2010 a byly schváleny s normou C++11. Oproti tomu Rust přichází s vynuceným move až v roce 2015. Přesto nebo možná proto, že část programátorů byla zklamána z vývojem kolem C++ a chtělo jít jinou cestou. Move semantics byly v C++ už v předběžné verzi označené C++0× – to vzniklo jako optimistická představa, že nová C++ norma bude schválena ještě před rokem 2010 – nestalo se, ukazuje, jakými porodními bolestmi procházelo schvalování nové normy.
Mnoho C++ programátorů dodnes nechápe, co je r-value reference, ten ošklivý symbol &&. Označení se vztahuje k postavení u „přiřazeni“. Rozlišujeme tedy l-value reference, stojí na levé straně od přiřazení a představuje zpravidla existující proměnnou, a r-value reference, která stojí na pravé straně od přiřazení a přestavuje hodnotu. Oproti předchozí C++ tak zavádí možnost vytvářet reference na přímo zadané hodnoty (to doposud šlo jen u const reference)
K jeho pochopení je třeba se na to podívat jak ze strany volajícího tak volaného
Ze strany volaného je r-value reference na stejné úrovni jako reference. Je úplně jedno jestli tam je && nebo &. Je to vždycky reference. Pokud proměnnou dále používáme, překladač ji bude brát jako obyčejnou referenci. Kdybych to měl srovnat s C, použití && nebo & je podobné jako použití ukazatele ať už z Příkladu 1 nebo 2.
Ze strany volajícího se to liší. Tady r-value reference říká, že parametr bude po použití zaručeně zničen. Tedy je bezpečné jej měnit. To je rozdíl oproti normální referenci, která nic neříká o ničení parametru. R-value reference tedy vznikne tam, kde jako parametr vstupuje přímá hodnota, nebo výsledek jiné funkce
foo(10,"ahoj", std::string("cau"), bar)
Výše uvedené parametry jsou r-value refernce až na bar, která je l-value reference (obyčejná reference). Pokud tedy budu mít funkci deklarovanou jako
void foo(int &&, const char * &&, std::string &&, Bar &&)
Pak si překladač bude stěžovat na to, že bar
není r-value reference, takže volaný nemá jistotu, že bude bar
po návratu zničen. Já ale mám možnost tohle rozhodnutí změnit a k tomu právě slouží funkce std::move. Tato funkce přetypuje parametr na r-value reference, čimž indikuji volanému – hele, tohle můžeš libovolně měnit, protože to stejně zničím – a volaný mi bude věřit, i když to nemusí být pravda.
Důležité je, že volaný může, ale nemusí proměnnou změnit. Stejně tak může, ale nemusí její obsah odnést
foo(10,"ahoj", std::string("cau"), std::move(bar));
Speciální postavení pak má move-konstruktor. To že jde o move je jen programátorská dohoda, protože tento konstruktor nemusí nic přesouvat
Foo::Foo(Foo &&other)
Konstruktor získává jistotu, že other bude zničen, takže jej může libovolně měnit. V daném kontextu však nemá smysl uvažovat jiné činnosti než obsah other
„vykrást“ a přivlastnit si jej – tedy move. Jediný co musí move-konstructor zajistit , tak zanechat other
ve stavu, kdy jej lze bezpečně zničit, ať už je to prázdný stav, nebo nějaký nevalidní „moved-from“ stav. Volbu a efektivní design tohoto stavu si volí programátor.
Pokud funkce přijímá parametry hodnotou, je třeba si představit, že vznikají nové instance téhož v zásobníku volajícího
void baz(Foo foo); //.... baz(std::move(foo));
Tímto zápisem nedochází k přesunu do funkce baz
, ale přesouvám foo
do skryté instance Foo
v prvním parametru funkce baz
. K přesunu dojde ještě před zavoláním funkce baz. Volající také musí tuto instanci na konec zlikvidovat (destruktor). Jiná situace je
void baz(Foo &&foo); //.... baz(std::move(foo));
V tomto případě dojde předání reference na foo s poznámku „foo bude zničeno, dělej si s tím co chceš“. Předpokládá se, že baz
bude zřejmě obsah foo
přesouvat. Ale přesunout ho nemusí. Po návratu z baz
tedy získáme buď prázdný objekt nebo objekt ve stavu moved-from
, ale stále s platným lifetime, nebo získáme původní objekt bez změny, například pokud se funkce rozhodne, že parametr nevyužije (například selhala nějaká validace)
To čemu C++ čelilo v té době je také kompatibilita ABI. Historicky existoval souboj mezi stdcall
a cdecl
, tak jak je to označeno ve WinAPI. Celé Windows jsou pak v zásadě postavené na stdcall
. Jak se to liší? Jde o povinnost úklidu argumentů. Ve stdcall
je ta povinnost na straně volaného, u cdecl
na straně volajícího.
A protože se bavíme o C++ jako něčeho, co je zpětně kompatibilní s C, muselo u cdecl
zůstat. A to zanáší několik problémů. Vrátíme se k příkladu 3, který C++ move semantic řeší, byť nejsme schopni zajistit destructive move
Příklad 4 #include <cstring> #include <cstdlib> #include <utility> void dealloc_string(char *str) noexcept; char *alloc_string(const char *) noexcept; class Person { public: char *name; int byear; char gender; Person(const char *name, int byear, char gender): name(alloc_string(name)), byear(byear), gender(gender) {} ~Person() {if (name) dealloc_string(name);} Person(Person &&other): name(other.name), byear(other.byear), gender(other.gender) { other.name = nullptr; } }; void add_to_table(Person p) noexcept; int main(int arg, char **c) { Person p("Franta Vonasek", 1995, 'M'); add_to_table(std::move(p)); }
Upravený příklad najdete na godboltu.
Jak jsem zmínil v předchozím odstavci u příkladu 3, pokud předáváme parametr hodnotou, vznikne „jeho kopie“ v prostoru, kam v C již nemáme přístup. To neplatí u C++, přímý přístup sice nezískáme, ale k obsahu se dostaneme přes destruktor. Díky volacím konvencím je totiž povinnost volajícího uklidit argumenty a bez znalosti volané funkce nemusíme vědět, jestli došlo k přesunu nebo ne. Z toho důvodu je třeba volat destruktor, který zkontroluje, v jakém stavu je objekt a případně jej zdestruuje pokud je to potřeba.
Výsledný kód vypadá takto (gcc-15 na x64)
.LC0: .string "Franta Vonasek" main: sub rsp, 24 mov edi, OFFSET FLAT:.LC0 call alloc_string(char const*) mov rdi, rsp mov BYTE PTR [rsp+12], 77 mov QWORD PTR [rsp], rax mov DWORD PTR [rsp+8], 1995 call add_to_table(Person) mov rdi, QWORD PTR [rsp] test rdi, rdi je .L2 call dealloc_string(char*) .L2: xor eax, eax add rsp, 24 ret
Za domácí úkol zkuste odpovědět na následující otázku. Zcela jistě tam vzniknou dvě instance Person, jedna v proměnné p a jedna jako argument funkce add_to_table
. Proč tam tedy vidím jen jeden destruktor? Těch důvodů může být víc
Nicméně důvod, proč nejde použít destructive move je ten, že není známo, zda funkce add_to_table
skutečně provedla přesun. Chybí k tomu potřebná metadata. V porovnání s Rustem, který je celý o metadatech a kde optimalizace právě stojí na metadatech o volaných funkcích.
Je otázkou, jak moc velký problém neexistence přímé podpory destructive move v C++ je. Nejsem ale přesvědčen, že by to byl tak velký problém který by odůvodňoval založení nového jazyka (je pravda, že jsem se setkal i s menšími důvody a dnešní svět máme díky tomu plný uhlíku, déček, a bůh ví čeho ještě)
Moderní překladače jsou při optimalizacích velice chytré a pokud mají dostatek informací o kódu, mohou přijít na to, že volání destruktoru po move je zbytečné, protože jsou schopni ověřit, že přesun proběhne za všech okolností a objekt tedy není třeba destruovat. Tímto způsobem může překladač vytvořit destructive move, aniž by se o to programátor musel starat. Jediný co programátor musí zajistit je, aby mu to fungovalo v runtime (ideálně v debug verzi). Překladače pak jsou schopny kód vyhodnotit ještě v compile time a eliminovat mrtvé větve kódu.
Toto se týká i LTO (link time optimalization) kde překladač má k dispozici celý výsledný kód a může provést eliminace mrtvých cest. Tím pádem může všechny nedestruktivní move převést na destruktivní na základě informací z celého kódu. Nejsem bohužel schopen tohle demonstrovat
A pokud se to nepodaří - většinou bude náš destruktor obsahovat test na to, zda objekt byl přesunut nebo ne. Pokud tedy překladač nedokáže bezpečně vyhodnotit, že vždy dojde k přesunu, bude kód delší o cca 3 instrukce, které dnes mají velice malé náklady v moderních CPU – ano trochu to může být problém u jednoduchých mikrokontrolérů, ale tam zase těch případů bude méně, často tyto projekty se vejdou do jednoho cpp souboru.
Jak už jsem ukazoval výše, je zde problém s tím, že úklid provádí volající kvůli existujícím ABI. Pokud tedy došlo k přesunu, musel by to volaný někde indikovat. To by paradoxně zvedlo režii na argumenty. Každý argument by totiž musel mít flag, který by indikoval, jestli volající převzal vlastnictví nebo ne. Argumenty by byly v zásobníku naskládány takto
<argument><flag><argument><flag>
Opravdu by to nešlo lépe? Tak si k tomu přidejte ještě výjimky. U současného řešení je to opět volající, který uklízí při výjimce, protože pokud výjimka vypadla z volaného, tak se předpokládá, že volaný kompletně selhal.
Rust výjimky nemá, a to je určitě také řešení.
Skutečné destructive move v C++ zavést nelze. Nikdy. Zapomeňte na nesčetné návrhy, které se neustále opakují už z počátku století. Ti lidé často netuší celé pozadí. I kdyby se na krásně zavedl jazykový konstruct, bude stejně překladač volit cestu takovou, která odpovídá požadavkům na ABI. Takže všechny jazykové konstrukty vyžadující destructive move buď nepůjdou přeložit, nebo je překladače tiše nahradí sekvencí move+destructor
Pomoci by se dalo optimalizaci. Jak jsem psal výše, vše je problém toho, že úklid parametrů provádí volající a je to z důvodů ABI. To je sice hezké, ale já bych třeba ve svém programu klidně přijal úpravu ABI tak, aby úklid prováděl volaný. Tedy návrat k stdcall. Pomohlo by to?
Podle mne ano, protože volaný má dostatek informací o tom, jestli k přesunu došlo nebo ne. V rámci optimalizace odstranění mrtvého kódu by pak mohl překladač odstranit volání destruktorů tam, kde došlo k přesunu a výsledkem by byl skutečný destruktive move tedy bez volání destruktoru. Bohužel by se muselo hodně věcí změnit od překladačů, linkerů ale třeba i debuggerů
Druhou možností by mohlo být použití c++ kontraktů. Tohle je relativně nová sekce přicházející v C++26. Možná by šlo nějakým způsobem přesvědčit překladač (a nechat zkontrolovat), že k přesunu dojde vždy, takže překladač pak může část s destruktorem vyřadit jako mrtvý kód. Na straně volaného by se pak kontrakt zkontroloval. Ale jak říkám, v tomto směru nemám dostatek informací.
Jedním z argumentů proti C++ je, že s každou operací kopie, move, destrukce se musí volat uživatelský kód a to ve všech úrovních pokud jde o nějakou objektovou hierarchii a to může stát obrovské množství výkonu, zatímco u destructive move jde o memcpy (což je IMHO relativně pomalá funkce – ale autor tím zřejmě myslel binární kopírování dat).
Není to pravda. Zvlášť pokud jde o šablony, ve kterých to má význam řešit. Výsledkem překladu není žádné volání, optimalizace umí – jak už jsem napsal – kód inlinovat, takže se z volání funkcí stává něco jako „recept“ na generování kódu. Zároveň při optimalizaci odstraňování mrtvého kódu dojde i odstranění různých podmínek a vyhození celých kusu kódu, který se dle statické analýzy neprovede. Co víc, pokud překladač zjistí, že move operace vždy nastavuje N pointerů na NULL a nikdy jinak, a destruktor testuje každý zvlášť, překladač zjistí, že tyto pointery jsou buď nastavené, nebo jsou všichni NULL a žádný jiný případ nenastane, tak celý komplikovaný test se smrskne na jeden test jedno z pointerů. Překladač si tohle může dovolit, programátor ne.
Obecně si překladač může dovolit různé prasečiny, které jsou programátoru zapovězeny.
Jen krátce – nemusí – a ani to nedoporučuji. Ve výše uvedených příkladech jsem to psal, abych mohl demonstrovat chování. Ale kompetentní programátor by místo char * použil std::string a tím by si ušetřil mnoho kódu. Obecně páni programátoři, nevymýšlejte kolo, použijte standardní šablony, kde už je vše naprogramováno a dokonce překladače umí ještě lépe optimalizovat kód STL, než vaši upachtěnou originální šablonu. Fakt si zbytečně vytváříte problémy a přidáváte práci.
Ano. C++ nemá destructive move. A nikdy mít nebude. Zároveň ale překladače v rámci optimalizaci umí destructive move simulovat. K lepší optimalizaci by možná stálo zvážit zavedení (volitelně) stdcall
ale je to hodně kontroverzní změna i tak. Nutnost kontrolovat zda došlo k přesunu u parametrů je bohužel dáno historickým rozhodnutím, ale naštestí to nepřináší velký dopad na výkon. Z tohoto důvodu se domnívám, že nejde o „big deal“.
Destruktory a move konstruktory se většinou nevolají ale inlinují (pokud to je dobře napsáno)
Programátor nemusí u každého objektu definovat všechny copy a move konstruktory a destruktory. V 99% případů to je zbytečné.
A poslední myšlenka na závěr. Končí doba, kdy programovací jazyk diktoval způsob a efektivitu překladu. Přijde doba, kdy programovací jazyk bude pouze vyjadřovacím nástrojem myšlenek a překladač se budou snažit generovat efektivní kód, který vede k činnosti popsané jazykem, ale zvolí úplně jinou cestu (efektivnější), než autor zamýšlel. Například už dnes překladače poznají, že váš program počítá neefektivně něco, na co existuje matematický vzoreček a do kódu prostě vloží ten vzorec.
Calling convention na design destructive move nikdy neměla vliv, ani C++ ABI. Pokud přidám do jazyka nějakou feature, tak si můžu dovolit upravit ABI pro potřebu té feature, pokud dřív neexistovala (přidání feature není ABI break). Navíc C++ standard nic jako calling convention nedefinuje, takže z pohledu C++ je platform ABI irelevantní.
Takže problém není úklid na úrovni ABI, problém je ten, že destructive move má corner cases, které nikdo nebyl schopný vyřešit. Takže jsme se dostali do stavu, že tomu říkáme sice move, ale move to vůbec nemusí být, a každý kdo chce implementovat move, musí implementovat taky stav, kdy něco bylo moved... většinou se ta instance prostě vyresetuje do "default constructed" stavu, ale někdy to nejde a to je pak peklo. Navíc je to zbytečný overhead, který umí kompiler odstranit jen když je move constructor a destructor inline (nebo LTO, atd...).
Ale nikdy bych neříkal nikdy. Destructive move je něco co lidi v C++ chcou, a nedají s tím pokoj, protože to je užitečná věc.
Primárně argumenty funkce jsou ve vlastnictví volajícího ne volaného. Tedy volaný je nemůže zničit. Takhle to tam je od začátku, už od počátku tisíciletí, ještě před vynálezem std::move
Fakt to nešlo předělat
Volaný je vůbec ničit ale nemusí. Destructive move znamená to, že ty data se proste opustí a ta instance zanikne, a calling convention tady nemá žádný vliv. Rust to vyřešil naprosto nejlíp, protože move znamená, že se udělá prostě memcpy (takže ty data musí být jak jinak než "movable").
Problém u C++ je, že celá semantika std::move() a &&ref nevynucuje, že k tomu "move" opravdu dojde, a to je vážný problém - compiler by musel dělat mnohem hlubší analýzu kódu než teď, a protože C++ nevyžaduje mít zdrojáky knihoven (stačí hlavičkové soubory), tak zkoumat jak je něco implementované v knihovnách prostě nejde (tady to má rust jednodušší, protože používá jen C ABI a vyžaduje static linking všech rustích závislostí, takže do těch funkcí "vidí").
Další věc je exception safety - co by se tak mělo stát, když chci udělat move argumentu, co předávám funkci, a ještě než dojde k tomu move, tak se výhodí exception? Instance by nezanikla v té funkci a byl by to memory leak? Tady by bylo potřeba mít nějaký flag, který by to hlídal (a myslím, že rust přesně toto dělá).
Ale jak píšu, nikdy bych neříkal nikdy, přecejenom je to C++ a tam je možné všechno :)
Napíšu článek kde ukazují že dokud je za úklid argumentů zodpovědný volající tak to jinak udělat nejde viz příklad 2 - je to marný, je to marný, víra nahrazuje argumenty
Prostě když obj foo přesunu do argumentů funkce bar a ta jej použije a už nepřesune, v jakém kontextu se zavolá destruktor? V kontextu funkce bar? Nebo v kontextu funkce volajíci? Zkuste si to zjistit.
Problém je, že volající neví co bar s foo udělala. Zatímco funkce bar to ví moc dobře
Funkce bar to taky nemusí vědět, když ty data předá dál.
Stdcall nebo Cdecl na to nemá absolutně žádný vliv (toto je v článku slepá ulička) - nastartuj compiler explorer a podívej se, jak se předávají argumenty co používají tyto 2 volací konvence, a zkus si předat třeba std::vector<char> - není v tom absolutně žádný rozdíl, protože stdcall se v tomto případě ani nepoužije. Stdcall je jen pro C ABI - C++ použije cdecl. A takto to je mimochodem i v některých případech v C - třeba printf() nikdy nebude stdcall.
Takže článek je v tomto smyslu zavádějící - mluvit o destructive move a zdůvodňovat to popisování cdecl a stdcall nedává smysl. Toto prostě není ten důvod.
Prosím už nepiš, jsou to hrozné ptákoviny co z tebe vždycky vyleze , už jen ta první věta je blbě. Ano protože to předá dál už to není její problém. Což je přesně důvod proč to nejde v c++ protože tam jsou předané argumenty pořád na starost volajícího
Ondřej Novák - takže pokaždé, když tě tady někdo upozorní na zjevnou chybu v článku budeš na toho člověka reagovat takto? To nebylo myšlené zle, na druhou stranu pokud píšu články o C++, tak na tu kritiku musím být připravený, protože tento jazyk nikdo nezná na 100%.
Takže ještě jednou - calling convention na destructive move nemá absolutně žádný vliv. To, že se tím článek zabývá je chyba a je to zavádějící, když důvody proč to v C++ není jsou úplně jiné.
Jak už tu jiní napsali, calling convention neřeší volání destruktoru, a pokud předám nějakou instanci by value, která má netriviální destruktor, tak se stejně předá jen pointer na ni (a toto je mimiochodem specifikované přimo v C++ standardu). No a to je důvod, proč si zkusit ten compiler explorer - pro líné je taky link:
https://godbolt.org/z/vT4jdTh35
Takže jak se předá std::vector by value? Referencí... O úklid se vždycky stará kdo? Caller...
Nehledě na to, že stdcall už dnes nikde nevyzkoušíš, tak maximálne ve windows, když voláš nějakou DLL funkci. Všechno je cdecl. Všude je cdecl. I když v GCC použiješ stdcall, stejně to bude cdecl. Všechny překladače to znají ale ignorují to.
A není to jen o předání argumentů. ale i o zodpovědnosti za jejich destrukci. K tomu si se ještě nevyjádřil. Tak znova, kde se zavolá destruktor, když foo přesunu do bar pomocí argumentu ve volání ? Až zodpovíš tenhle dotaz, budu reagovat
Za mě by klidně mohlo existovat pravidlo, že callee vždy zajistí destrukci všech "moved" (rvalue) argumentů. Problém jsou Céčkové variadické funkce, kde callee ani neví, co vlastně dostal jako argumenty. Kompilátor by sice mohl takovou situaci detekovat a označit za chybu, ale není to ideální. Možná proto se autoři C++ standardu rozhodli vydat jinou cestou, takže destrukci argumentů zajišťuje caller.
Každopádně souhlasím s názorem výše, že calling conventions nemají vliv na destructive move. Stack cleanup klidně může provádět caller a volání destruktoru callee. Navíc na moderních architekturách se argumenty často vejdou do registrů, takže explicitní stack cleanup ani není potřeba.
Jinak stdcall se stále používá, protože jinak by nešlo volat WinAPI, kde většina funkcí je stdcall. Pozor na to, že cdecl a stdcall jsou záležitost pouze 32-bit x86 (IA-32). Na dnes běžnějším 64-bit x86 (x86-64) a ostatních 64-bit architekturách se zpravidla používá jen jedna calling convention, kde stack cleanup provádí caller, protože jinak nelze implementovat Céčkové variadické funkce.
Microsoft má na 32-bit x86 také ještě thiscall pro member funkce (pokud nejsou variadické). Je to podobné jako jejich oblíbený stdcall, takže stack cleanup provádí callee, jen se navíc "this" pointer předává v ECX registru a ne na stacku s ostatními argumenty.
Všechno je to hezky popsané na Wikipedii: https://en.wikipedia.org/wiki/X86_calling_conventions
K tomu pár věcí, kde vidím problémy.
1) C++ před C++11 prováděl destrukci caller. Proto bylo move navrženo tak jak bylo, o tom je článek. Nešlo to změnit. A je obtížné to změnit i nyní.
-asi by šlo změnit ABI a označovat funkce nějakým flagem, aby se to při linkování nesmíchalo, ale kdyby to bylo jednoduchý, tak už to tam je.
2) Je jedno, jak se argumenty předávají, pointer můžu mít jak v zásobníku tak v registru. Záleží, v jakém kontextu se zavolá destruktor. Pokud v kontextu callee, pak by destructive move bylo standardní součástí C++. Protože to ale dělá caller, tak bez znalosti toho, co s obsahem udělala callee nemůže rozhodnout a musí si nějak poznačit, zda se obsah v callee přesunul/destruoval nebo ne. A než zavádět nějaký rt flagy, zůstalo to označení na programátorovi
Rust to dělá přesně tak, jak napsal Daniel přede mnou. Používá cdecl volací konvenci, kde je za stack cleanup zodpovědný caller a za volání destructoru callee. Takže ano, je možné mít desctructive move i s volací konvencí, kdy se o stack stará caller, protože volání destructoru není spojené s údržbou stacku.
Aby mě někdo nechytal za slovo: ano Rust nemá stabilní ABI, ale aktuálně to tak funguje, o stack se stará caller a o dealokaci callee.
Označil bych to spíš o hybrid.
cdecl znamená, že o úklid parametrů se stará volající a to nejen o vyčištění zásobníků, ale v C++ i o volání destruktorů.
Pokud Rust používá variantum, že volaný zavolá destruktory ale zásobník pak vyčistí volající, pak je to takový hybrid.
Ale v zásadě je to tak jak píšu, tohle jsou ty reálné důvody, proč bylo zvolené určité řešení.
cdecl jako takové řeší jen vyčištění zásobníku, nic neříká o volání destructorů. C++ se rozhodlo, že bude volat destructory těsně před vyčištěním zásobníku v caller contextu, ale nic nebrání tomu volat destructory v callee kontextu, jako to dělá Rust, a pořád to bude cdecl. Nicméně to je slovíčkaření.
C++ už v historii ABI breaky mělo, např. std::string kvůli zrušení copy-on-write a std::list kvůli size() v C++11. Vážně se o možnosti větších ABI breaků uvažovalo na konferenci v Praze a hlasovalo se o tom, ale ABI break boužel neprošel. Podle mého názoru se tím C++ odsoudilo k tomu, že bude v dlouhodobém měřítku stále méně relevantní (Google se už od C++ dost odklání). Zajímavé povídání je tom zde: The Day The Standard Library Died
Co tím chci říct, i C++ je schopné ABI break potenciálně udělat (třeba na druhý pokus...).
> Google se už od C++ dost odklání
Toho se nebojím
- velké firmy to dělají běžně. Microsoft má C#, Oracle ma Java, Google bude mít nějaký ten karbon, nebo co. Nebylo by to poprvé. Už jsem to zažil. V mé době byla Java ten ultra jazyk, co jednou nahradí C++
velké firmy to dělají běžně. Microsoft má C#, Oracle ma Java, Google bude mít nějaký ten karbon, nebo co
Ale kdepak, Carbon je jen experiment, který (zatím) nemá praktické využití. Google sází mimo jiné na Rust, v Androidu už se s C++ vůbec nepočítá.
This repository has the source code for Comprehensive Rust 🦀, a multi-day Rust course developed by the Android team. The course covers all aspects of Rust, from basic syntax to generics and error handling.
Course Format and Target Audience
The course is used internally at Google when teaching Rust to experienced software engineers. They typically have a background in C++ or Java.
Je zajímavý, jak jsem vždycky jinde. V roce 2007 jsem taky chtěl smrt STL. S příchodem C++11 a více si to už nemyslím, Jsou tam problémy, ale je to lepší, než to bylo. Už to není vztah "nahradit" ale spíš "rozšířit".
Teď je otázka, jestli je to tím, že já jsem byl napřed, nebo oni jsou pozadu :-D
Všechno je cdecl na 64-bit Windows, protože 64-bit Windows měl jen Win64 calling konvenci, až později přišel vectorcall. X86_64 Linux vždycky používal System V ABI, které definuje jen cdecl, která se ukázala asi jako nejlepší.
Stdcall vs cdecl je striktně diskuze o 32-bit x86, nikde jinde stdcall není.
Bez Copy traitu by to byl dost opruz. To by totiž najednou bylo potřeba psát .clone() i u věcí jako u64, a to asi opravdu nikdo nechce - nepřeložilo by se třeba ani tohle:
let a = 3;
let b = 4;
let c = if a > b { a } else { b };
println!("Max of {a} and {b} is {c}");
a musel bys psát
let a = 3;
let b = 4;
let c = if a > b { a.clone() } else { b.clone() };
println!("Max of {a} and {b} is {c}");
Rozumím. Je to asi největší confusion co mi Rust dělá, některé objekty se přesouvají některé se nepřesouvají. Primárně se to stále chová jako matematická rovnice, kde proměnné se umí kopírovat a kde x=y znamená, že x má stejnou hodnotu jako y. Ale běda, pokud y je move only object.
Pak z hlediska čitelnosti mi dává větší smysl
x=std::move(y)
ať už x a y je cokoliv.
Jen dodám, že si pamatuju před vynálezem std::move jsem používal operátor <<
x << y (přesun y do x) - x musela být reference
Místo std::vector se v jedné herní firmě používal AutoArray<T>, který měl definovaný traits pro T, a jednou z nich bylo - binary movable.
Stejně tak ve své knihovně jsem měl vlastní funkci moveObject(T &, void *ptr), který přesunul první argument na adresu ptr, první argument byl považován za zdestruovaný a celé se to řídilo Traits toho typu. Pokud nebyly definované, funkce udělala placement new(ptr) s copy constructorem na T a zavolala destruktor na původní proměnnou.
Osobně bych psát kód v C jako v článku nikomu nedoporučoval. Zadělává si tím na problém - za chvíli nebude vědět, kdo má co uvolňovat a komu co patří.
Článek jsem pouze prolétl, ale pár poznámek:
1. Současné ABI a standardy jako POSIX mají za sebou cca půl století, myslím si, že by stálo za to navrhnout nějakou verzi 2.0, kde by se zohlednily současné poznaky.
2. C++ předpokládá design tříd jako std::vector, potom move sémantika probíhá tak, že se vytvoří nový handle s bez obsahu, zkopíruje(swap) se hodnota adresy na obsah z původního handlu, původní handle se může jen tak uvolnit, protože nemá žádný obsah. Jedná se o sadu triviálních operací.
3. Jak bylo v článku zmíněno, move sémtantika prošla od své první standardizace vývojem a je možné, že se bude leštit nadále. Celkem zajímavý je v principu návrh Herba Suttera - in, out, inout, což by snížilo složitost kódu, ale jedná se o generickou záležitost, což se momentálně řeší tak, že se vygenerují všechny možnosti a překladač sám vybere tu nejvhodnější - move, copy, reference, const reference.
Najdi chybu:
if (!p.name) {dealloc_string(p.name);p.name=NULL;}
Najdi chybu:
if (!f.filename) {
free(f.filename);
f.filename = NULL;
}
Ja bych ti asi tak moc neresil, na objekt pouziji unique_ptr a ten objekt se mi proste presunuje, tam kam potrebuji. Po std::move se nemusim bat, ze bych to pouzil znova, protoze unique_ptr se vynuluje. Jasne, porad se zavola destruktor na unique_ptr, ale s tim asi dokazu zit (nezoptimaluzuje to v takovem pripade kompiler ?)
V C++ se musíš bát, že bys unique_ptr použil po move znovu, protože dereference nullptr je undefined behavior. Tohle v C++ nedopadne dobře:
auto a = std::make_unique<int>(0); auto b = std::move(a); std::cout << *a; // UNDEFINED BEHAVIOR
Destructive move řeší i tento případ, kdy se po move už objekt nedá znovu použít.
Tak s tim asi taky dokazu zit, porad je to totiz lepsi, nez puvodni pripad, kdy jedina informace o tom, ze funkce objekt sezere je to, ze je tam nad tim komentar.
> C++ nemá destructive move. A nikdy mít nebude.
Ehm. V C++ světě se tomu říká relokace. Třeba gamedev knihovny to rády využívají. A momentálně se pracuje na standardizaci.
Samozřejmě že oproti rustu je to silně omezené. Nejde třeba po předání proměnnou nějak "otrávit" aby už na ni nikdo náhodou nemohl hrabat.
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 53 737×
Přečteno 25 575×
Přečteno 23 821×
Přečteno 22 331×
Přečteno 22 270×