Co je to sémantika přesunů?

Moje první odpověď byla extrémně zjednodušeným úvodem do sémantiky přesunů a mnoho detailů bylo záměrně vynecháno, aby to bylo jednoduché.Nicméně sémantika přesunů toho obsahuje mnohem víc a já jsem si řekl, že je čas na druhou odpověď, která by zaplnila mezery.První odpověď je již poměrně stará a nepřišlo mi správné ji jednoduše nahradit úplně jiným textem. Myslím, že jako první úvod poslouží stále dobře. Pokud však chcete proniknout hlouběji, čtěte dál 🙂

Stephan T. Lavavej si udělal čas a poskytl cenné připomínky. Moc ti děkuji, Stephane!“

Úvod

Sémantika pohybu umožňuje objektu za určitých podmínek převzít vlastnictví vnějších zdrojů nějakého jiného objektu. To je důležité dvěma způsoby:

  1. Proměnit drahé kopie v levné přesuny. Příklad najdete v mé první odpovědi. Všimněte si, že pokud objekt nespravuje alespoň jeden externí prostředek (buď přímo, nebo nepřímo prostřednictvím svých členských objektů), nebude sémantika přesunu nabízet žádné výhody oproti sémantice kopírování. V takovém případě znamená kopírování objektu a přesouvání objektu úplně totéž:

    class cannot_benefit_from_move_semantics{ int a; // moving an int means copying an int float b; // moving a float means copying a float double c; // moving a double means copying a double char d; // moving a char array means copying a char array // ...};
  2. Zavedení bezpečných typů „pouze pro přesun“; to znamená typů, pro které kopírování nemá smysl, ale přesun ano. Příkladem jsou zámky, rukojeti souborů a inteligentní ukazatele s jedinečnou sémantikou vlastnictví. Poznámka: Tato odpověď pojednává o std::auto_ptr, zastaralé šabloně standardní knihovny C++98, která byla nahrazena std::unique_ptr v C++11. Středně pokročilí programátoři v C++ pravděpodobně alespoň trochu znají std::auto_ptr a vzhledem k „sémantice přesunu“, kterou zobrazuje, se zdá být dobrým výchozím bodem pro diskusi o sémantice přesunu v C++11. YMMV.

Co je to přesun?

Standardní knihovna C++98 nabízí inteligentní ukazatel s jedinečnou sémantikou vlastnictví nazvaný std::auto_ptr<T>. Pokud neznáte auto_ptr, jeho účelem je zaručit, že dynamicky alokovaný objekt bude vždy uvolněn, a to i v případě výjimek:

{ std::auto_ptr<Shape> a(new Triangle); // ... // arbitrary code, could throw exceptions // ...} // <--- when a goes out of scope, the triangle is deleted automatically

Nezvyklé na auto_ptr je jeho „kopírovací“ chování:

auto_ptr<Shape> a(new Triangle); +---------------+ | triangle data | +---------------+ ^ | | | +-----|---+ | +-|-+ |a | p | | | | | +---+ | +---------+auto_ptr<Shape> b(a); +---------------+ | triangle data | +---------------+ ^ | +----------------------+ | +---------+ +-----|---+ | +---+ | | +-|-+ |a | p | | | b | p | | | | | +---+ | | +---+ | +---------+ +---------+

Všimněte si, že inicializace b pomocí a nekopíruje trojúhelník, ale místo toho přenáší vlastnictví trojúhelníku z a na b. Říkáme také „a se přesouvá do b“ nebo „trojúhelník se přesouvá z a do b„. To může znít matoucí, protože samotný trojúhelník zůstává vždy na stejném místě v paměti.

Přesunout objekt znamená přenést vlastnictví nějakého prostředku, který spravuje, na jiný objekt.

Konstruktor kopírování auto_ptr pravděpodobně vypadá nějak takto (poněkud zjednodušeně):

auto_ptr(auto_ptr& source) // note the missing const{ p = source.p; source.p = 0; // now the source no longer owns the object}

Nebezpečné a neškodné přesuny

Nebezpečné na auto_ptr je to, že to, co syntakticky vypadá jako kopírování, je ve skutečnosti přesun. Pokus o volání členské funkce na přesunutém auto_ptr vyvolá nedefinované chování, takže musíte být velmi opatrní a nepoužívat auto_ptr poté, co byl přesunut:

auto_ptr<Shape> a(new Triangle); // create triangleauto_ptr<Shape> b(a); // move a into bdouble area = a->area(); // undefined behavior

Ale auto_ptr není vždy nebezpečný. Tovární funkce jsou naprosto vhodným případem použití auto_ptr:

auto_ptr<Shape> make_triangle(){ return auto_ptr<Shape>(new Triangle);}auto_ptr<Shape> c(make_triangle()); // move temporary into cdouble area = make_triangle()->area(); // perfectly safe

Všimněte si, jak oba příklady dodržují stejný syntaktický vzor:

auto_ptr<Shape> variable(expression);double area = expression->area();

A přesto jeden z nich vyvolává nedefinované chování, zatímco druhý ne. Jaký je tedy rozdíl mezi výrazy a a make_triangle()? Nejsou oba stejného typu? Vskutku jsou, ale mají různé kategorie hodnot.

Kategorie hodnot

Je zřejmé, že mezi výrazem a, který označuje proměnnou auto_ptr, a výrazem make_triangle(), který označuje volání funkce, jež vrací auto_ptr podle hodnoty, a vytváří tak při každém svém volání nový dočasný objekt auto_ptr, musí být nějaký hluboký rozdíl. a je příkladem l-hodnoty, zatímco make_triangle() je příkladem r-hodnoty.

Přechod od l-hodnot, jako je a, je nebezpečný, protože bychom se později mohli pokusit zavolat členskou funkci prostřednictvím a, což by vyvolalo nedefinované chování. Naproti tomu přesun z rvalues, jako je make_triangle(), je naprosto bezpečný, protože poté, co kopírovací konstruktor vykoná svou práci, nemůžeme dočasnou hodnotu znovu použít. Neexistuje žádný výraz, který by zmíněný temporary označoval; pokud prostě znovu napíšeme make_triangle(), dostaneme jiný temporary. Ve skutečnosti je přesunutý dočasný výraz již na dalším řádku pryč:

auto_ptr<Shape> c(make_triangle()); ^ the moved-from temporary dies right here

Všimněte si, že písmena l a r mají historický původ v levé a pravé straně přiřazení. To už v C++ neplatí, protože existují lhodnoty, které se na levé straně přiřazení objevit nemohou (například pole nebo uživatelsky definované typy bez přiřazovacího operátoru), a existují rhodnoty, které mohou (všechny rhodnoty typů tříd s přiřazovacím operátorem).

Rhodnota typu třídy je výraz, jehož vyhodnocení vytvoří dočasný objekt. Za normálních okolností žádný jiný výraz uvnitř stejného oboru neoznačuje stejný dočasný objekt.

Rvalue reference

Teď už chápeme, že přechod z lvalues je potenciálně nebezpečný, ale přechod z rvalues je neškodný. Kdyby měl jazyk C++ podporu pro rozlišení argumentů lvalue od argumentů rvalue, mohli bychom buď zcela zakázat přesun z lvalue, nebo alespoň explicitně stanovit přesun z lvalue na místě volání, takže bychom se již nepřesouvali omylem.

Odpovědí jazyka C++11 na tento problém jsou reference rvalue. Reference na rhodnotu je nový druh reference, která se váže pouze na rhodnoty, a její syntaxe je X&&. Stará dobrá reference X& je nyní známá jako reference na lvalue. (Všimněte si, že X&& není reference na referenci; nic takového v C++ neexistuje)

Přidáme-li k tomu ještě const, máme už čtyři různé druhy referencí. Na jaké druhy výrazů typu X se mohou vázat?“

 lvalue const lvalue rvalue const rvalue--------------------------------------------------------- X& yesconst X& yes yes yes yesX&& yesconst X&& yes yes

V praxi můžete na const X&& zapomenout. Omezení na čtení z rhodnot není příliš užitečné.

Reference na rhodnoty X&& je nový druh reference, který se váže pouze na rhodnoty.

Implicitní konverze

Reference na rhodnoty prošly několika verzemi. Od verze 2.1 se reference na rhodnotu X&& váže také na všechny kategorie hodnot jiného typu Y, pokud existuje implicitní konverze z Y na X. V takovém případě se vytvoří dočasná hodnota typu X a odkaz na rhodnotu se váže na tuto dočasnou hodnotu:

void some_function(std::string&& r);some_function("hello world");

V uvedeném příkladu je "hello world" lhodnota typu const char. Protože existuje implicitní konverze z const char přes const char* na std::string, vytvoří se temporary typu std::string a na tento temporary se naváže r. Toto je jeden z případů, kdy je rozdíl mezi rhodnotami (výrazy) a dočasníky (objekty) poněkud nejasný.

Konstruktory move

Užitečným příkladem funkce s parametrem X&& je konstruktor move X::X(X&& source). Jeho účelem je přenést vlastnictví spravovaného prostředku ze zdroje do aktuálního objektu.

V jazyce C++11 byla funkce std::auto_ptr<T> nahrazena funkcí std::unique_ptr<T>, která využívá referencí rvalue. Vypracuji a prodiskutuji zjednodušenou verzi unique_ptr. Nejprve zapouzdříme surový ukazatel a přetížíme operátory -> a *, takže se naše třída bude cítit jako ukazatel:

template<typename T>class unique_ptr{ T* ptr;public: T* operator->() const { return ptr; } T& operator*() const { return *ptr; }

Konstruktor přebírá vlastnictví objektu a destruktor jej maže:

 explicit unique_ptr(T* p = nullptr) { ptr = p; } ~unique_ptr() { delete ptr; }

Teď přijde ta zajímavá část, konstruktor move:

 unique_ptr(unique_ptr&& source) // note the rvalue reference { ptr = source.ptr; source.ptr = nullptr; }

Tento move konstruktor dělá přesně to, co dělal kopírovací konstruktor auto_ptr, ale lze mu dodat pouze rhodnoty:

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a); // errorunique_ptr<Shape> c(make_triangle()); // okay

Druhý řádek se nezkompiluje, protože a je lhodnota, ale parametr unique_ptr&& source lze vázat pouze na rhodnoty. To je přesně to, co jsme chtěli; nebezpečné tahy by nikdy neměly být implicitní. Třetí řádek se zkompiluje v pořádku, protože make_triangle() je rhodnota. Konstruktor přesunu přenese vlastnictví z dočasného na c. Opět je to přesně to, co jsme chtěli.

Konstruktor move přenese vlastnictví spravovaného prostředku do aktuálního objektu.

Operátory přiřazení move

Poslední chybějící částí je operátor přiřazení move. Jeho úkolem je uvolnit starý prostředek a získat nový prostředek ze svého argumentu:

 unique_ptr& operator=(unique_ptr&& source) // note the rvalue reference { if (this != &source) // beware of self-assignment { delete ptr; // release the old resource ptr = source.ptr; // acquire the new resource source.ptr = nullptr; } return *this; }};

Všimněte si, že tato implementace operátoru přiřazení move duplikuje logiku destruktoru i konstruktoru move. Znáte idiom kopírování a výměny? Lze jej aplikovat i na sémantiku přesunu jako idiom move-and-swap:

 unique_ptr& operator=(unique_ptr source) // note the missing reference { std::swap(ptr, source.ptr); return *this; }};

Když je nyní source proměnná typu unique_ptr, bude inicializována konstruktorem move; to znamená, že argument bude přesunut do parametru. Argument musí být stále rhodnota, protože samotný konstruktor move má referenční parametr rhodnota. Jakmile tok řízení dosáhne uzavírací závorky operator=, source se dostane mimo obor, čímž se starý prostředek automaticky uvolní.

Operátor přiřazení move převede vlastnictví spravovaného prostředku na aktuální objekt, čímž se starý prostředek uvolní. Idiom move-and-swap zjednodušuje implementaci.

Přesun z lvalues

Někdy chceme přesunout z lvalues. To znamená, že někdy chceme, aby překladač zacházel s lhodnotou, jako by to byla rhodnota, aby mohl zavolat konstruktor move, i když by to mohlo být potenciálně nebezpečné. pro tento účel nabízí C++11 šablonu funkce standardní knihovny s názvem std::move uvnitř hlavičky <utility>. tento název je trochu nešťastný, protože std::move prostě obsazuje lhodnotu na rhodnotu; sama o sobě nic nepřesouvá. Pouze umožňuje přesun. Možná se měl jmenovat std::cast_to_rvalue nebo std::enable_move, ale už jsme se zasekli u tohoto jména.

Tady je návod, jak explicitně přesunout z l-hodnoty:

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a); // still an errorunique_ptr<Shape> c(std::move(a)); // okay

Všimněte si, že po třetím řádku už a nevlastní trojúhelník. To nevadí, protože explicitním zápisem std::move(a) jsme dali najevo své záměry: „Milý konstruktoru, dělej si s a, co chceš, abys inicializoval c; a mě už nezajímá. Klidně si s a dělej, co chceš.“

std::move(some_lvalue) obsazuje l-hodnotu na r-hodnotu, čímž umožňuje následný přesun.

Xvalues

Všimněte si, že i když je std::move(a) r-hodnota, její vyhodnocení nevytváří dočasný objekt. Tato hádanka donutila komisi zavést třetí kategorii hodnot. Něco, co může být svázáno s odkazem na rhodnotu, i když to není rhodnota v tradičním smyslu, se nazývá xhodnota (eXpirující hodnota). Tradiční rhodnoty byly přejmenovány na prhodnoty (Pure rvalues).

Jak prhodnoty, tak xhodnoty jsou rhodnoty. Xvalues i lvalues jsou glvalues (Zobecněné lvalues). Vztahy lze snáze pochopit pomocí diagramu:

 expressions / \ / \ / \ glvalues rvalues / \ / \ / \ / \ / \ / \lvalues xvalues prvalues

Všimněte si, že pouze xvalues jsou skutečně nové; zbytek je způsoben pouze přejmenováním a seskupením.

C++98 rvalues jsou v C++11 známy jako prvalues. Mentálně nahraďte všechny výskyty slova „rvalue“ v předchozích odstavcích slovem „prvalue“.

Přesun z funkcí

Dosud jsme viděli přesun do lokálních proměnných a do parametrů funkcí. Přesun je však možný i opačným směrem. Pokud se funkce vrací hodnotou, nějaký objekt v místě volání (pravděpodobně lokální proměnná nebo dočasná, ale může to být jakýkoli objekt) se inicializuje výrazem za příkazem return jako argumentem konstruktoru přesunu:

unique_ptr<Shape> make_triangle(){ return unique_ptr<Shape>(new Triangle);} \-----------------------------/ | | temporary is moved into c | vunique_ptr<Shape> c(make_triangle());

Možná překvapivě lze z funkce implicitně přesouvat i automatické objekty (lokální proměnné, které nejsou deklarovány jako static):

unique_ptr<Shape> make_square(){ unique_ptr<Shape> result(new Square); return result; // note the missing std::move}

Jak to, že konstruktor move přijímá jako argument lvalue result? Obor result zanedlouho skončí a při odvíjení zásobníku bude zničen. Nikdo by si potom nemohl stěžovat, že se result nějak změnil; když se tok řízení vrátí zpět k volajícímu, result už neexistuje! Z tohoto důvodu má C++11 speciální pravidlo, které umožňuje vracet automatické objekty z funkcí, aniž by bylo nutné psát std::move. Ve skutečnosti byste nikdy neměli používat std::move pro přesun automatických objektů z funkcí, protože to brání „optimalizaci pojmenované návratové hodnoty“ (NRVO).

Nikdy nepoužívejte std::move pro přesun automatických objektů z funkcí.

Všimněte si, že v obou továrních funkcích je návratovým typem hodnota, nikoli odkaz na rvalue. Reference na rvalue jsou stále reference a jako vždy byste nikdy neměli vracet referenci na automatický objekt; volající by skončil s visící referencí, pokud byste podvedli překladač, aby váš kód přijal, například takto:

unique_ptr<Shape>&& flawed_attempt() // DO NOT DO THIS!{ unique_ptr<Shape> very_bad_idea(new Square); return std::move(very_bad_idea); // WRONG!}

Nikdy nevracejte automatické objekty pomocí reference na rvalue. Přesun se provádí výhradně pomocí konstruktoru move, ne pomocí std::move, a ne pouhou vazbou rvalue na referenci rvalue.

Přesun do členů

Dříve nebo později napíšete kód jako tento:

class Foo{ unique_ptr<Shape> member;public: Foo(unique_ptr<Shape>&& parameter) : member(parameter) // error {}};

Zásadně si překladač bude stěžovat, že parameter je lvalue. Když se podíváte na jeho typ, uvidíte referenci na rhodnotu, ale reference na rhodnotu jednoduše znamená „reference, která je vázána na rhodnotu“; neznamená to, že reference sama je rhodnota! Ve skutečnosti je parameter jen obyčejná proměnná se jménem. Uvnitř těla konstruktoru můžete parameter používat libovolně často a vždy označuje stejný objekt. Implicitní přesun z ní by byl nebezpečný, proto to jazyk zakazuje.

Pojmenovaná reference na rvalue je lvalue, stejně jako jakákoli jiná proměnná.

Řešením je ručně povolit přesun:

class Foo{ unique_ptr<Shape> member;public: Foo(unique_ptr<Shape>&& parameter) : member(std::move(parameter)) // note the std::move {}};

Můžete namítnout, že parameter se po inicializaci member už nepoužívá. Proč neexistuje speciální pravidlo pro tiché vložení std::move stejně jako u návratových hodnot? Pravděpodobně proto, že by to příliš zatěžovalo implementátory překladače. Co kdyby se například tělo konstruktoru nacházelo v jiné překladové jednotce? Naproti tomu u pravidla pro návratové hodnoty stačí zkontrolovat tabulky symbolů a zjistit, zda identifikátor za klíčovým slovem return označuje automatický objekt, nebo ne.

Můžete také předat parameter podle hodnoty. Zdá se, že pro typy určené pouze k přesunu, jako je unique_ptr, zatím neexistuje žádný zavedený idiom. Osobně dávám předávání hodnotou přednost, protože to způsobuje menší nepořádek v rozhraní.

Speciální členské funkce

C++98 implicitně deklaruje tři speciální členské funkce na vyžádání, tj. když jsou někde potřeba: konstruktor kopie, operátor přiřazení kopie a destruktor.

X::X(const X&); // copy constructorX& X::operator=(const X&); // copy assignment operatorX::~X(); // destructor

Reference na hodnotu prošly několika verzemi. Od verze 3.0 deklaruje C++11 dvě další speciální členské funkce na vyžádání: konstruktor move a operátor přiřazení move. Všimněte si, že ani VC10, ani VC11 zatím neodpovídají verzi 3.0, takže si je budete muset implementovat sami.

X::X(X&&); // move constructorX& X::operator=(X&&); // move assignment operator

Tyto dvě nové speciální členské funkce se deklarují implicitně pouze v případě, že žádná ze speciálních členských funkcí není deklarována ručně. Také pokud deklarujete vlastní konstruktor move nebo operátor přiřazení move, nebudou konstruktor copy ani operátor přiřazení copy deklarovány implicitně.

Co tato pravidla znamenají v praxi?

Píšete-li třídu bez neřízených zdrojů, nemusíte žádnou z pěti speciálních členských funkcí deklarovat sami a získáte správnou sémantiku kopírování a sémantiku move zdarma. V opačném případě budete muset speciální členské funkce implementovat sami. Pokud vaše třída sémantiku přesunu nevyužívá, není samozřejmě nutné speciální členské funkce implementovat.

Všimněte si, že přiřazovací operátor kopírování a přiřazovací operátor přesunu lze sloučit do jednoho jednotného přiřazovacího operátoru, který přebírá svůj argument hodnotou:

X& X::operator=(X source) // unified assignment operator{ swap(source); // see my first answer for an explanation return *this;}

Tímto způsobem klesne počet speciálních členských funkcí, které je třeba implementovat, z pěti na čtyři. Existuje zde kompromis mezi bezpečností výjimek a efektivitou, ale na tuto problematiku nejsem odborník.

Předávání referencí (dříve známé jako univerzální reference)

Přemýšlejte o následující šabloně funkce:

template<typename T>void foo(T&&);

Mohli byste očekávat, že T&& se bude vázat pouze na rhodnoty, protože na první pohled vypadá jako reference na rhodnotu. Jak se však ukazuje, T&& se váže také na l-hodnoty:

foo(make_triangle()); // T is unique_ptr<Shape>, T&& is unique_ptr<Shape>&&unique_ptr<Shape> a(new Triangle);foo(a); // T is unique_ptr<Shape>&, T&& is unique_ptr<Shape>&

Pokud je argumentem r-hodnota typu X, T se odvodí jako X, tudíž T&& znamená X&&. To by každý očekával, ale pokud je argumentem lhodnota typu X, díky speciálnímu pravidlu se T odvodí jako X&, tudíž T&& by znamenalo něco jako X& &&. Protože však C++ stále ještě nemá pojem reference na reference, typ X& && se srazí na X&. Na první pohled to může znít zmateně a zbytečně, ale sbalování referencí je nezbytné pro dokonalé předávání (které zde nebudeme rozebírat).

T&& není reference na rhodnotu, ale reference na předávání. Váže se také na l-hodnoty, v takovém případě jsou T a T&& obě reference na l-hodnoty.

Pokud chcete omezit šablonu funkce na rhodnoty, můžete zkombinovat SFINAE s typovými vlastnostmi:

#include <type_traits>template<typename T>typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::typefoo(T&&);

Implementace move

Teď, když rozumíte sbalování referencí, zde je uvedeno, jak je implementována std::move:

template<typename T>typename std::remove_reference<T>::type&&move(T&& t){ return static_cast<typename std::remove_reference<T>::type&&>(t);}

Jak vidíte, move přijímá jakýkoli druh parametru díky předávání reference T&& a vrací referenci rvalue. Volání metafunkce std::remove_reference<T>::type je nutné, protože jinak by pro lhodnoty typu X byl návratový typ X& &&, který by se zhroutil na X&. Protože t je vždy l-hodnota (nezapomeňte, že pojmenovaný odkaz na r-hodnotu je l-hodnota), ale my chceme t svázat s odkazem na r-hodnotu, musíme t explicitně převést na správný návratový typ. volání funkce, která vrací odkaz na r-hodnotu, je samo o sobě x-hodnota. Teď už víte, odkud se xhodnoty berou 😉

Napsat komentář

Vaše e-mailová adresa nebude zveřejněna.