Destructive Move v C++?

6. 7. 2025 11:13 (aktualizováno) Ondřej Novák

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

Základy k termínu destructive move

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.

Co je (má být) destructive move

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.

Nemusí jít jen o pointer

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

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

Moderní C++ a jeho (ne)destructive move

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.

C++ move semantic a r-value reference

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)

Problém jsou volající konvence a ABI

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.

Překladače jsou inteligentní

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.

Co kdyby C++ umělo destructive move jako programovací nástroj?

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

Jakým způsobem zavést v C++ destructive move

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

Poznámka před závěrem: volání a nevolání

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.

Argument: Progamátor C++ musí psát move constructory a destructory

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.

Závěr

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.

Sdílet