Pro mě rust moc složitej, hodně věcí se píše jinak a zejména hodně věci se píše stejně a dělá něco jiného.
Zatím zůstanu u C++. aktuálně začínám 23.
Souhlas, že C++ je ještě složitější, ale to proto, že má dlouhou historii. Niméně nováček se nemusí učit Céčko, aby začal C++. A už vůbec nedoporučuju učit se C++ chronologicky. Třeba s příchodem C++20 se velká část konceptů platných s C++11 stala obsolete. Tady jednoznačně vidím problém ve výuce. Je těžké učit něco, co se mění.
IIUC když omylem udělám move, znamená to dvě věci:
1. Na původním místě to již nemohu použít. To v zásadě nevadí, resp. pokud to vadí, kompilátor se ozve a odmítne to zkompilovat. (Myslím, že lint/linter se používá spíše pro program, který primárně hledá chyby/podivnosti, které kompilátorem projdou.)
2. Potenciálně dojde ke mělkému zkopírování na jiné místo v paměti. Většina struktur asi bude celkem malá (případné obří pole nebude nejspíš přímo součástí struktury, stejně typicky jeho velikost nevíme předem), takže dopad na výkon nebude příšerný. Takže hlavní bude si to ohlídat ve specifickém případě, kdy i mělká kopie bude drahá.
Tak operace move IIUC bez optimalizací znamená, že dojde ke kopii, a data na původním místě se již nemají používat. S optimalizacemi to může znamenat, že data zůstanou na původním místě, ale ne vždy je to korektní – pokud se na původním může paměť uvolnit příliš brzy (nebo nemůže, ale kompilátor si to nezvládne odvodit), je potřeba kopírovat.
Tady se imo rozchází terminologie.
Rust rozlišuje "Copy" (aplikovatelné pro typy u kterých se dá bezpečně udělat bitová kopie jako třeba primitivní typy) a "Clone".
Z pohledu Rustu C++ by default neprovádí Copy, ale Clone. Což je věc která se musí v Rustu dělat explicitně.
Jediná výjimka z "move everything" jsou právě proměnné implementující Copy, kde je možné poslat funkci kopii bez nějakého většího výkonnostního dopadu.
Jedna z výhod je, že Rust defaultuje k výkonnější variantě. Clone musí být explicitní a je víc vidět kde se duplikují data.
1. 11. 2023, 12:20 editováno autorem komentáře
Zrovna std::move() je v C++ docela bastl. Ono to udělá move jen v případě, že má objekt move construktor, není konstantní a nemá nějaké konstantní membery (respektive všechno v hierarchii dědičnosti musí být nekonstantní a mít move constructory). Jinak to tiše fallbackne na copy. Takže když vidím v C++ zdrojáku std::move(), nevím vlastně nic. Může to udělat move i copy. To má hodně daleko k dobrému řešení, je na tom hodně vidět, že move sémantika byla dolepená až v C++11.
Dokážu si představit rozumný scéář pro přesun konstantního objektu :
Dostanu nějaký objekt. U sebe ve funkci ho nechci měnit, jen číst. Takže dává smysl ho mít jako const, abych si ho náhodou neupravil.
A pak ho chci vrátit nebo poslat někam dál a už s ním nic dalšího nedělat. Tam zase dává smysl ho movnout.
A pak je tu problém, jestli nějaký typ dostane defaultní move, nebo jen copy. Když mám nějakého nepřesunutelného membera, tak se zbytek taky kopíruje, nebo se kopíruje jen to, co nejde přesunout? Přiznám se, že z hlavy nevím a smysl dávají obě možnosti. Takže je to další pravidlo k naučení.
1. 11. 2023, 13:29 editováno autorem komentáře
> U sebe ve funkci ho nechci měnit, jen číst. Takže dává smysl ho mít jako const, abych si ho náhodou neupravil.
Nikdo ti nebrání si to aliasovat na const ref a s tím pracovat, ale často se to nevidí (s výjimkou nějakých lambd)
Pokud ti vadí defaultní chování některých memberů, nadefinuj si u objektu vlastní move constructor a tam si zadefinuj způsob, jak se má objekt přesouvat a jak má naložit s konstantnímy membery. Nemusí pak kopírovat celý objekt, ale jen ty konstantní membery.
U některých objektů nelze kopírovat ani přesouvat, například std::mutex. Tam pak je nutné oba konstruktory napsat vlastní... nebo objekt vytvářet dynamicky a použít unique_ptr nebo shared_ptr.
Jasně že to umím obejít. A není to žádný zásadní problém. Ale jde mi o to, že čistý návrh by dával smysl trochu jinak.
Tím, že si vytvořím alias si nezavřu přístup k původnímu nekonstantnímu objektu. A stále na něj omylem můžu nějak hrábnout. Leda že bych udělal alias se stejným jménem v nějakém vnořeném bloku.
Problém s move je, že se používá hlavně k relokaci tj move+destroy. Relokace const objektu (ne const reference) je naprosto rozumná operace. Move, kdy mi vykuchané zbytky zůstávají pro nějaké pozdější použití, je vlastně dost obskurní věc, bez které bych se dokázal i obejít.
A spousta věcí je ještě lépe relokovatelná než jen movnutelná, třeba ten unique_ptr.
1. 11. 2023, 14:19 editováno autorem komentáře
No mně by se taky líbilo, kdybych nikdy nedělal chyby, ale žijeme v reálném světě. Programátor, který si myslí, že se celkem běžně nemýlí je nebezpečný sobě i okolí.
A že je move v současné podobě dost omezené si uvědomuje i dost lidí z c++ komise. Proto se snaží standardizovat tu relokaci, protože takový unique_ptr nebo vector relokujete pomocí jednoduchého memcpy, ale standardní move je nepříjemně složitější kvůli oddělené destrukci.
Taky moc nevím, jak udělat move v C++ lépe, protože je potřeba zachovat zpětnou kompatibilitu, takže move sémantika v C++ je takový kočkopes.
Rust tohle historické dědictví nemá a řeší to velmi elegantně. Move je default, pokud nezavolám explicitně clone(), provede se move a ne copy. Move je destructible, po přesunutí objektu původní objekt zaniká a nejde použít (hlídá to borrow checker). Move je možné udělat na všech objektech jako memcpy, v Rustu neexistuje objekt, který by move nepodporoval nebo se musel dělat nějak složitěji než pomocí memcpy. Takhle definovaný move hodně zjednodušuje práci překladači i vývojáři.
> Zeptám se tedy, kde ten objekt žije? Zřejmě žije asi na heapu. To ne vždycky chci.
V Rustu move nesouvisí se stackem nebo heapem. Je úplně jedno, jestli je objekt na stacku nebo heapu, protože ve chvíli, kdy můžu udělat move jako memcpy, můžu udělat move i mezi stackem a heapem a opačně. Samozřejmě do toho pak vstupuje lifetime objektů na stacku, ale to už je jiný koncept, to hlídá borrow checker.
V Rustu můžeš objekt vytvořit kde chceš, na stacku nebo heapu a libovolně dělat move mezi stackem a heapem. Myslím, že nejlépe to osvětlí příklad:
struct A {
name: String,
}
fn main() {
// Instance A na heapu
let a_heap = Box::new(A{name: String::from("John")});
// Move A instance z heapu na stack
let a_stack = *a_heap;
// Vypise John
println!("{}", a_stack.name);
// Toto se nezkompiluje, protoze probehl destructive move:
// error[E0382]: borrow of moved value: `*a_heap`
println!("{}", a_heap.name);
}
jen reaguju čistě na vás, jako že nejsem znalec Rustu
šlo mi o to, jestli Rusr při přesunu objektu pri volání funkce sám se rozhodne, kam objekt přesune jestli na stack nebo do heapu, nebo to má v rukou programátor? dokážu si představit funkci, která dostane parametr a s ním spustí vlákno kam přesune ten parametr, tedy opravdu se musí přesouvat, protože původní lokace objektu se stane neplatnou
pak to funguje jako v c++ a žádný benefit tam nevidím.
Benefity move sémantiky v Rustu oproti C++ jsou docela zásadní:
Move je default. Když chci udělat kopii, musím to explicitně napsat.
Nemusím psát move konstruktor, všechny objekty mají move implicitně (kromě primitivních typů jako integery, tam move nedává smysl).
Move je destructible, původní objekt po move přestává existovat a hlídá to borrow checker. Nejde udělat chybu přístupu k objektu po move, jako v C++.
Move je interně implementován jako memcpy, žádné volání kódu objektu, tudíž vyšší výkon.
Neexistují obezličky jako std::move u kterých nikdo pořádně neví, jestli to move opravdu udělá, nebo ne. V Rustu je move možný vždy. Jasná, jednoduchá a výkonná implementace, ne jako ten přílepek v C++.
Memcpy v Rustu je vždy OK. Rust nic relokovat nemusí, protože v Rustu nejde v objektu udělat vnitřní reference sám na sebe. Tím pádem ukazují všechny pointery ven z objektu a memcpy se používá na move všech objektů. Vývojář nemusí nic řešit, nemusí psát žádné move konstruktory, překladač má jednoduchou práci a nic rychlejšího než memcpy na move neexistuje.
Vnitřní objekt ve smysu, že referencuje něco ze svého potomka nebo jiný sub-objekt v rámci objektu? Tohle jsem nedělal a nemám to v plánu, považuji to za špatný design. Ale chápu, že se to někomu takový nápad třeba může líbit.
Pokud máš potřebu dělat takové věci, není Rust jazyk pro tebe a nevyužiješ jeho výhody. Je škoda, že se na to nedokážeš podívat s nadhledem a apriori odmítáš všechno, co je jinak než v C++. Zrovna C++ je spíš příklad toho, jak by se to nemělo dělat, spousta špatného designu hlavně z historických důvodů, protože museli zachovat zpětnou kompatibilitu. Move sémantika je toho dobrým příkladem, v Rustu je vyřešená o světelné roky lépe, než v C++.
Ty designy jsou překonané, hype OOP už naštěstí skončilo a dneska už se necpe všude. Rust na to jde jinak, nemá klasické OOP.
Starého psa novým kouskům nenaučíš, někdo prostě pokrok odmítá a zuby nehty se drž horších překonaných řešení. Bohužel to vede k horší kvalitě kódu s nižší bezpečností a výkonem.
5. 11. 2023, 00:11 editováno autorem komentáře
Nějaká motivace a existující implementace rozebírají v https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/p1144r7.html
Ještě ten rozdíl move vs relokace komplikuje věci jako předávání unique_ptr registrem, ale do tohohle se ten paper nepouštěl. Tohle je separátní problém.
na to zda se udela opravdu move je nejlepsi jednoducha pomocna tabulka,
ktera rika co vyjde kdyz je parametr nejaky typ a co do nej leze.
a z tabulky je videt, ze se move udela jen v poslednim pripade kdy funkce bere && a leze do ni taky prava reference.
T& & ---> T&
T& && ---> T&
T&& & ---> T&
T&& && ---> T&&
found this :-)
//Hello. I want my own local copy of your Widget that I will manipulate,
//but I don't want my changes to affect the one you have. I may or may not
//hold onto it for later, but that's none of your business.
void foo(Widget w);
//Hello. I want to take your Widget and play with it. It may be in a
//different state than when you gave it to me, but it'll still be yours
//when I'm finished. Trust me!
void foo(Widget& w);
//Hello. Can I see that Widget of yours? I don't want to mess with it;
//I just want to check something out on it. Read that one value from it,
//or observe what state it's in. I won't touch it and I won't keep it.
void foo(const Widget& w);
//Hello. Ooh, I like that Widget you have. You're not going to use it
//anymore, are you? Please just give it to me. Thank you! It's my
//responsibility now, so don't worry about it anymore, m'kay?
void foo(Widget&& w);
Bacha, "void foo(Widget&& w);" ten widget movnout může a nemusí. Může zůstat nezměněný, nebo v nejakém neznámém ale validním stavu.
Překladač musí předpokládat, že ten objekt můžeš dál používat, stačí ho vyresetovat.
A žádné zodpovědnosti to programátora taky nezbavuje. Nemusím ho řešit jen pokud bych ho nemusel řešit ani kdybych žádné foo nevolal. Pokud ten widget potřeboval nějaký manuální úklid, tak ho musím uklidit tak jak tak. Protože foo ten move může řešit třeba tak, že mi do toho widgetu nasype vnitřnosti nějakého jiného starého widgetu.
A "template<typename T> void foo( T && t )" je překvapivě úplně jiná potvora. To může být kterákoliv verze, kromě té první.
A teď mi řekněte, jak tohle učit, aby človek nemusel vytírat zbytky vybuchlých hlav :D
Pokud je Widget normální typ, tak rozhodně ne. Pokud nemáte foo přetížené i pro normální reference, tak do toho lvalue ani nenacpete. Nezkompiluje se to.
Ta tabulka se používá jen u šablon, kde to && pak není rvalue reference ale říká se jí třeba "forwarding reference". Bez nějakého šablonového parametru nemáte odkud vzít druhou referenci ke spojení.
"template<typename T> void foo( T && t )"
tam není žádná věda, je třeba vidět, co je v T. Pokud je tam Obj &, pak funkce foo(Obj &) a pokud tam je Obj, pak foo(Obj &&).
Pokud volám tuto funkci a parametrem je proměnná, překladač za T dosadí referenci nebo const referenci. Pokud funkci volám s hodnotou, která vzniká jako dočasná instance, pak se za T dosadí přímo typ toho objektu, nebo může jít o návratovou hodnotu jiné funkce, i tam figuruje dočasná instance.
Ona rvalue reference se opravdu špatně chápe, ale přitom je to easy. Uvnitř funkce je to pořád reference a ta komunikace je pouze vně ve vztahu s volajícím. Rvalue reference pro volajícího znamená, že nesmí do parametru uvést lvalue referenci, tedy použít proměnnou jako parametr. Pokud tohle chce obejít, pak musí použít std::move(). a tím dává najevo, že proměnná ho od toho okamžiku nezajímá (jestli se přesune nebo ne je pak jiná věc, ale mě jako volajícího už nezajímá obsah a pokud se nepřesune, destruktor to vyčistí)
Pokud funkce nemá ani lvalue referenci, ani rvalue referenci, pak normálně konstruuju parametr konstruktorem. Ten mohu konstruovat jako kopii (copy constructor), nebo přesunem (move constructor) a to tak, že použiju std::move. Pokud objekt má move constructor, překladač ho upřednostní a tím je opět simulován přesun. Pokud ho nemá, použije se copy constructor. Pokud nemá ani copy constructor, dojde k chybě při překladu
Alex :
Ne, ta tabulka neříká, jestli se udělá move. Ta tabulka říká, jestli to co na první pohled vypadá jako rvalue reference je opravdu ona, nebo něco jiného. Je to jen jeden malý dílek z mozaiky.
A že je někde rvalue reference opravdu neznamená, že se ten move udělá. A jak psal ON, že nikde v dohledu nejsou žádné ampersandy neznamená, že se nemovuje. Ani std::move nemusí být někde v dohledu, stačí return nebo "foo( bar() )"