Точен момент на връщане в C++-функция

Изглежда като глупав въпрос, но недвусмислено ли е дефиниран точният момент, в който return xxx; се "изпълнява" във функция?

Моля, вижте следния пример, за да видите какво имам предвид (тук на живо):

#include <iostream>
#include <string>
#include <utility>

//changes the value of the underlying buffer
//when destructed
class Writer{
public:
    std::string &s;
    Writer(std::string &s_):s(s_){}
    ~Writer(){
        s+="B";
    }
};

std::string make_string_ok(){
    std::string res("A");
    Writer w(res);
    return res;
}


int main() {
    std::cout<<make_string_ok()<<std::endl;
} 

Това, което наивно очаквам да се случи, докато make_string_ok се нарича:

  1. Извиква се конструктор за res (стойността на res е "A")
  2. Извиква се конструктор за w
  3. return res се изпълнява. Текущата стойност на res трябва да бъде върната (чрез копиране на текущата стойност на res), т.е. "A".
  4. Извиква се деструктор за w, стойността на res става "AB".
  5. Извиква се деструктор за res.

Така че бих очаквал "A"като резултат, но ще получа "AB" отпечатано на конзолата.

От друга страна, за малко по-различна версия на make_string:

std::string make_string_fail(){
    std::pair<std::string, int> res{"A",0};
    Writer w(res.first);
    return res.first;
}

резултатът е според очакванията - "A" (вижте на живо).

Стандартът предписва ли коя стойност трябва да се върне в примерите по-горе или не е посочен?


person ead    schedule 22.10.2018    source източник
comment
comment
Отличен пример защо страничните ефекти в деструкторите трябва да се използват много внимателно, ако изобщо трябва да се използват.   -  person Matthew Read    schedule 22.10.2018
comment
По темата кога се случва return, до C++14( !) формулировката за return не казва, че локалните временни елементи са продължили достатъчно дълго, за да бъдат използвани при конструирането на върнатата стойност.   -  person Davis Herring    schedule 23.10.2018
comment
@MatthewRead: Това, което всъщност се опитваш да кажеш е, че цялата концепция за RAII трябва да се избягва?   -  person Michał Łoś    schedule 23.10.2018
comment
@MichałŁoś не. Концепцията RAII всъщност игнорира страничните ефекти. Перфектният RAII код няма никакъв код в конструкторите, освен инициализация. Страничен ефект е нещо, което се променя от конструктора извън на обекта. Но животът никога не е идеален   -  person Swift - Friday Pie    schedule 23.10.2018
comment
@Swift-FridayPie Как работи unique_ptr? Какво прави в деструктора?   -  person Michał Łoś    schedule 23.10.2018
comment
@MichałŁoś всъщност, добър пример, когато elision позволява нещо неочаквано. Не можете да копирате unique_ptr, но можете да го върнете, точно заради копирането.   -  person Swift - Friday Pie    schedule 23.10.2018
comment
Той няма да бъде унищожен, ако не поради елизия на копиране, а след това поради семантика на преместване (новият преместен към обект ще вземе указател, а деструкторът на стария обект ще игнорира неговия нулев указател, но в крайна сметка ще бъде извикан). Това, което искам да кажа е, че в RAII-манипулатора какво прави деструкторът е по-важно от това какво прави конструкторът.   -  person Michał Łoś    schedule 23.10.2018
comment
@MichałŁoś не, не return std::move(pointer), но също return pointer; и работи без активна семантика на преместване, Copy elision е по някакъв начин оптимизация на семантиката на преместване сама по себе си, когато наистина не се нуждаете от темпорален обект. Той никога не е съществувал, така че не е имало допълнително придобиване на ресурси, така че няма какво да се унищожи. Единственият случай, когато вреди на RAII, е когато придобиването на ресурс се третира като собственост на интерфейса, което беше спорна точка за известно време.   -  person Swift - Friday Pie    schedule 23.10.2018
comment
Ако използвате IDE като Visual Studio, можете да преминавате през кода в C++ с отворен прозорец на Асемблиране и обратно, за да се убедите сами.   -  person QuentinUK    schedule 23.10.2018
comment
@MatthewRead защо страничните ефекти в деструкторите трябва да се използват много внимателно И така, какво може да направи dtor? Промяна на *this? Разбира се, dtors са свързани със страничните ефекти.   -  person curiousguy    schedule 24.10.2018


Отговори (3)


Това е RVO (+ връщане на копие като временно, което замъглява картината), една от оптимизациите, които могат да променят видимото поведение:

10.9.5 Копиране/преместване на elision (акцентите са мои):

Когато са изпълнени определени критерии, имплементацията може да пропусне конструкцията за копиране/преместване на обект от клас, дори ако конструкторът, избран за операцията за копиране/преместване, и/или деструкторът за обекта имат страна ефекти**. В такива случаи изпълнението третира източника и целта на пропуснатата операция за копиране/преместване просто като два различни начина за препращане към един и същи обект.

Това премахване на операции за копиране/преместване, наречено избягване на копиране, е разрешено при следните обстоятелства (които могат да се комбинират, за да се елиминират множество копия):

  • в оператор за връщане във функция с тип връщане на клас, когато изразът е името на енергонезависим автоматичен обект (различен от параметър на функция или променлива, въведена от декларацията за изключение на манипулатор) със същия тип (игнорирайки cv-квалификацията) като типа на връщане на функцията, операцията копиране/преместване може да бъде пропусната чрез конструиране на автоматичния обект директно в обекта на връщане на извикването на функцията
  • [...]

В зависимост от това дали е приложено, цялата ви предпоставка става грешна. При 1. се извиква c'tor за res, но обектът може да живее вътре в make_string_ok или извън него.

Случай 1.

Куршуми 2. и 3. може изобщо да не се случат, но това е страничен момент. Целта получи странични ефекти от Writers засегнат dtor, беше извън make_string_ok. Това се оказа временно, създадено чрез използване на make_string_ok в контекста на оценка operator<<(ostream, std::string). Компилаторът създаде временна стойност и след това изпълни функцията. Това е важно, защото временният живее извън него, така че целта за Writer не е локална за make_string_ok, а за operator<<.

Случай 2.

Междувременно вашият втори пример не отговаря на критерия (нито пропуснатите за краткост), тъй като типовете са различни. Така че писателят умира. Дори щеше да умре, ако беше част от pair. Така че тук копие на res.first се връща като временен обект и след това dtor на Writer засяга оригиналния res.first, който е на път да умре.

Изглежда доста очевидно, че копието се прави преди извикване на деструктори, тъй като обектът, върнат от копието, също се унищожава, така че не бихте могли да го копирате по друг начин.

В края на краищата всичко се свежда до RVO, защото d'tor на Writer работи или върху външния обект, или върху локалния, според това дали оптимизацията е приложена или не.

Стандартът предписва ли коя стойност трябва да се върне в примерите по-горе или не е посочен?

Не, оптимизацията не е задължителна, въпреки че може да промени наблюдаваното поведение. По преценка на компилатора е дали да го приложи или не. Това е изключение от общото правило, според което компилаторът има право да прави всяка трансформация, която не променя наблюдаваното поведение.

Казус за него стана задължителен в c++17, но не и вашият. Задължителният е, когато върнатата стойност е ненаименована временна.

person luk32    schedule 22.10.2018
comment
Това е малко по-различно - това е деструкторът на различен обект (Writer), който има странични ефекти, които вероятно засягат върнатата стойност. - person Toby Speight; 22.10.2018
comment
@TobySpeight Взета точка. Разширих малко отговора. И удебелено реализацията третира източника и целта на пропуснатата операция за копиране/преместване просто като два различни начина за препращане към един и същ обект Writer d'tor работи точно по същия начин, просто е различен целеви обект. Освен това редът на копиране за връщане и прилагане на dtors за локални стойности при връщане изглежда доста очевиден... не можете да унищожите обект, който предстои да бъде копиран за връщане. - person luk32; 23.10.2018
comment
@TobySpeight Освен това забелязах, че може да е важно да подчертая, че d'tor elision може да е дим и екрани. Важното е къде живее скритото копие и какво е целта за Writer. - person luk32; 23.10.2018

Поради оптимизиране на връщаната стойност (RVO), може да не се извика деструктор за std::string res в make_string_ok . Обектът string може да бъде конструиран от страната на повикващия и функцията може само да инициализира стойността.

Кодът ще бъде еквивалентен на:

void make_string_ok(std::string& res){
    Writer w(res);
}

int main() {
    std::string res("A");
    make_string_ok(res);
}

Ето защо върнатата стойност трябва да бъде "AB".

Във втория пример RVO не се прилага и стойността ще бъде копирана към върнатата стойност точно при извикването за връщане, а деструкторът на Writer ще се изпълнява на res.first след извършване на копието.

6.6 Инструкции за прескачане

При излизане от обхват (колкото и да е изпълнен), деструкторите (12.4) се извикват за всички конструирани обекти с продължителност на автоматично съхранение (3.7.2) (наименувани обекти или временни елементи), които са декларирани в този обхват, в обратния ред на тяхната декларация. Прехвърлянето извън цикъл, извън блок или обратно след инициализирана променлива с продължителност на автоматично съхранение включва унищожаването на променливи с продължителност на автоматично съхранение, които са в обхват в точката, прехвърлена от...

...

6.6.3 Декларация за връщане

Инициализирането на копието на върнатия обект се подрежда преди унищожаването на временните елементи в края на пълния израз, установен от операнда на израза за връщане, което от своя страна се подрежда преди унищожаването на локалните променливи (6.6) на блок, обхващащ оператора за връщане.

...

12.8 Копиране и преместване на класови обекти

31 Когато са изпълнени определени критерии, имплементацията може да пропусне конструкцията за копиране/преместване на обект от клас, дори ако конструкторът за копиране/преместване и/или деструкторът за обекта имат странични ефекти. В такива случаи изпълнението третира източника и целта на пропуснатата операция за копиране/преместване просто като два различни начина за препращане към един и същ обект и унищожаването на този обект се случва в по-късния момент, когато двата обекта биха били унищожени без оптимизация. (123) Това премахване на операции за копиране/преместване, наречено премахване на копиране, е разрешено при следните обстоятелства (които могат да бъдат комбинирани, за да се елиминират множество копия):

— в оператор за връщане във функция с тип връщане на клас, когато изразът е името на енергонезависим автоматичен обект (различен от функция или параметър на catch-клауза) със същия cvunqualified тип като типа връщане на функцията, операцията копиране/преместване може да бъде пропусната чрез конструиране на автоматичния обект директно в върнатата стойност на функцията

123) Тъй като само един обект е унищожен вместо два и един конструктор за копиране/преместване не е изпълнен, все още има един унищожен обект за всеки конструиран.

person Shloim    schedule 22.10.2018
comment
И аз си помислих за това, но това, което ме изненадва - резултатите са различни, така че не става въпрос само за изрязване на копие. Бих искал да знам какво казва стандартът за това. - person ead; 22.10.2018
comment
добавен цитат на стандарта - person Shloim; 22.10.2018
comment
„деструктор“ в първото ви изречение трябва да бъде „конструктор за копиране“, нали? Иначе смятам, че отговорът няма смисъл. - person Konrad Rudolph; 22.10.2018
comment
@KonradRudolph Е, ако конструкторът за копиране бъде извикан, тогава ще остане някакъв екземпляр, на който в даден момент трябва да бъде извикан деструктор. Ако конструкторът за копиране не бъде извикан, тогава не може да бъде извикан и деструктор. Така че наистина двата оператора (един с деструктор и един с конструктор за копиране) са еквивалентни (ако не греша). - person tomsmeding; 22.10.2018
comment
@tomsmeding Те не са еквивалентни, защото говорим за различни обекти: деструкторът на std::string не модифицира обекта, деструкторът на Writer го прави и за това е от значение дали низът — предаван на конструктора Writer — е бил копиран или не (но не и дали впоследствие е унищожен). - person Konrad Rudolph; 22.10.2018
comment
@KonradRudolph е едновременно конструктор за копиране от res в върнатата стойност И деструктор за res. И двете не се викат поради RVO. - person Shloim; 23.10.2018
comment
Основното нещо, което трябва да се отбележи тук е, че Writer работи с res и когато се прилага RVO res и върнатата стойност са един и същ обект. - person Shloim; 23.10.2018
comment
докато този отговор обяснява какво се случва (благодаря за това!), той не отговаря на въпроса (поне изрично), дали резултатът от функцията е посочен от стандарта. В интерес на истината MSVC дава различни резултати в зависимост от нивото на оптимизация. - person ead; 23.10.2018
comment
Стандартът определя реда на изпълнение и аз използвах дефиницията на RVO, за да предскажа успешно резултата. MSVC вероятно ви позволява да деактивирате RVO и следователно да промените резултата. - person Shloim; 23.10.2018
comment
@ead Стандартът не трябва да указва нищо повече от него. Резултатът е, че при връщане се прави копие за връщане, освен ако не е премахнато, тогава се прилагат d'tors. Сега въз основа на това дали елизията се случва в целта за Writer d'tor живее на различно място. D'tor за Writer винаги се изпълнява d'tor за std::string локално към make_string_ok няма никакво значение. Може би можете да го разберете по-ясно с отговора по-долу (отказ от отговорност: той е мой). - person luk32; 23.10.2018
comment
Но компилаторът не е длъжен да прави RVO, може или не може да го направи. Няма гаранция, че резултатът е А, но също така няма гаранция, че резултатът е АВ. - person ead; 23.10.2018
comment
@Shloim Все още не разбирам защо тогава говориш за res деструктора. В този случай няма забележим ефект. Нещата, които имат значение, са конструкторът за копиране на res и деструкторът на w. - person Konrad Rudolph; 23.10.2018
comment
@luk32 Искам да кажа, че RVO не е задължително за компилатор и по този начин (доколкото разбирам) стандартът позволява и двата резултата, A и AB, което означава, че поведението/резултатът на функцията не е посочено. - person ead; 23.10.2018
comment
@ead Да. Оптимизацията не е задължителна и може да промени наблюдаваното поведение. Това е изключение от общото правило като че ли, което казва, че компилаторът има право да прави всяка трансформация, която не променя наблюдаваното поведение. Въпросните не са само страничните ефекти на елиминираните обекти c'tors и d'tor, целта за други странични ефекти също се променя, което във вашия случай е d'tor на Writer работи върху различен обект, независимо дали оптимизацията приложено е. Прилага се по преценка на компилаторите. - person luk32; 23.10.2018
comment
@ead, добавих пасажа от стандарта C++ относно RVO оптимизациите - person Shloim; 23.10.2018

В C++ има концепция, наречена elision.

Elision взема два привидно различни обекта и обединява тяхната идентичност и живот.

Преди c++17 може да възникне елизия:

  1. Когато имате непараметрична променлива Foo f; във функция, която е върнала Foo и изразът за връщане е просто return f;.

  2. Когато имате анонимен обект, който се използва за конструиране на почти всеки друг обект.

В c++17 всички (почти?) случаи на #2 се елиминират от новите правила за prvalue; elision вече не се появява, защото това, което се използва за създаване на временен обект, вече не го прави. Вместо това изграждането на "временния" е пряко обвързано с местоположението на постоянния обект.

Сега elision не винаги е възможно предвид ABI, към който компилира компилаторът. Два често срещани случая, в които е възможно, са известни като оптимизация на възвръщаемата стойност и оптимизация на наименувана възвръщаема стойност.

RVO е случаят по следния начин:

Foo func() {
  return Foo(7);
}
Foo foo = func();

където имаме върната стойност Foo(7), която се премахва във върнатата стойност, която след това се премахва във външната променлива foo. Това, което изглежда като 3 обекта (върната стойност на foo(), стойността на реда return и Foo foo), всъщност е 1 по време на изпълнение.

Преди c++17 конструкторите за копиране/преместване трябва да съществуват тук, а elision не е задължително; в c++17 поради новите правила за prvalue не е необходим конструктор за копиране/преместване и няма опция за компилатора, тук трябва да има 1 стойност.

Другият известен случай се нарича оптимизиране на възвръщаемата стойност, NRVO. Това е (1) случай на елизия по-горе.

Foo func() {
  Foo local;
  return local;
}
Foo foo = func();

отново, elision може да обедини живота и идентичността на Foo local, върнатата стойност от func и Foo foo извън func.

Дори c++17, второто сливане (между върнатата стойност на func и Foo foo) не е задължително (и технически prvavalue, върнато от func, никога не е обект, а просто израз, който след това е обвързан да конструира Foo foo), но първото остава незадължително и изисква съществуването на конструктор за преместване или копиране.

Елисията е правило, което може да възникне дори ако елиминирането на тези копия, разрушения и конструкции би имало забележими странични ефекти; това не е оптимизация "като че ли". Вместо това, това е фина промяна от това, което един наивен човек може да си помисли, че кодът на C++ означава. Наричането му „оптимизация“ е повече от малко погрешно.

Фактът, че не е задължителен и че фините неща могат да го счупят, е проблем с него.

Foo func(bool b) {
  Foo long_lived;
  long_lived.futz();
  if (b)
  {
    Foo short_lived;
    return short_lived;
  }
  return long_lived;
}

в горния случай, въпреки че е законно компилаторът да избягва както Foo long_lived, така и Foo short_lived, проблемите с изпълнението го правят по същество невъзможно, тъй като и двата обекта не могат да имат живота си, обединен с върнатата стойност на func; премахването на short_lived и long_lived заедно не е законно и животът им се припокрива.

Все още можете да го направите под as-if, но само ако можете да разгледате и разберете всички странични ефекти на деструкторите, конструкторите и .futz().

person Yakk - Adam Nevraumont    schedule 22.10.2018
comment
Правилно ли го разбрах: В моя случай това е NRVO, следователно c++17 не гарантира премахването на копието. Това означава, че върнатата стойност всъщност е неуточнена, тъй като компилаторът е свободен да прилага или да не прилага NRVO? - person ead; 22.10.2018
comment
@ead Да, elision не е гарантирано. Компилаторът не може да го направи; няма да го направи само във вашия случай, ако поискате да не се прави (с флаг, предаден на компилатора). Той обаче е крехък; добавете друг клон с върнат друг наименуван обект, който се припокрива в живота, и резултатът от вашия код ще се промени. - person Yakk - Adam Nevraumont; 22.10.2018
comment
Обърках се за момент, когато казахте second merge. Може да помислите за пренареждане на параграфите. - person Passer By; 23.10.2018