Точный момент «возврата» в 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" (посмотреть вживую).

Предписывает ли стандарт, какое значение следует возвращать в приведенных выше примерах, или оно не указано?

Яркий пример того, почему побочные эффекты в деструкторах следует использовать очень осторожно, если они вообще используются.

Matthew Read 22.10.2018 19:24

Что касается того, когда происходит return, до C++ 14 (!) Формулировка для return не говорит о том, что локальные временные файлы существуют достаточно долго, чтобы их можно было использовать при построении возвращаемого значения.

Davis Herring 23.10.2018 06:54

@MatthewRead: Что вы на самом деле пытаетесь сказать, так это того, что нужно избегать всей концепции RAII?

Michał Łoś 23.10.2018 07:25

@ MichałŁoś нет. Концепция RAII фактически игнорирует побочные эффекты. Идеальный код RAII не имеет никакого кода в конструкторах, кроме инициализации. Побочный эффект - это то, что изменяется конструктором вне объекта. Но жизнь никогда не бывает идеальной

Swift - Friday Pie 23.10.2018 10:59

@ Swift-FridayPie Итак, как работает unique_ptr? Что он делает в деструкторе?

Michał Łoś 23.10.2018 11:10

@ MichałŁoś на самом деле, хороший пример, когда elision допускает что-то неожиданное. Вы не можете скопировать unique_ptr, но можете вернуть его именно из-за copy elision.

Swift - Friday Pie 23.10.2018 11:14

Он не был бы уничтожен, если бы не из-за копирования-исключения, а затем из-за семантики перемещения (новый перемещенный в объект будет принимать указатель, а деструктор старого объекта проигнорирует его нулевой указатель, но он будет вызван в конце концов). Я хочу сказать, что в обработчике RAII то, что делает деструктор, важнее того, что делает конструктор.

Michał Łoś 23.10.2018 11:18

@ MichałŁoś нет, не return std::move(pointer), но также и return pointer;, и он работает без активной семантики перемещения. Copy elision в некотором роде является оптимизацией семантики перемещения сама по себе, когда вам действительно не нужен временной объект. Его никогда не существовало, поэтому лишних ресурсов не было, поэтому разрушать нечего. Единственный случай, когда это вредно для RAII, - это когда «получение ресурса» трактуется как «владение интерфейсом», что некоторое время было предметом споров.

Swift - Friday Pie 23.10.2018 11:21

Если вы используете IDE, например Visual Studio, вы можете пошагово просмотреть код на C++ с открытым окном сборки и наоборот, чтобы убедиться в этом сами.

QuentinUK 23.10.2018 13:12

@MatthewRead "почему побочные эффекты в деструкторах нужно использовать очень осторожно" Так что же может делать дтор? Изменить *this? Конечно, все врачи - это побочные эффекты.

curiousguy 24.10.2018 01:02
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
69
11
4 278
3
Перейти к ответу Данный вопрос помечен как решенный

Ответы 3

Из-за Оптимизация возвращаемого значения (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 не применяется, и значение будет скопировано в возвращаемое значение точно после вызова return, а деструктор Writer будет запущен на res.first после того, как произойдет копирование.

6.6 Jump statements

On exit from a scope (however accomplished), destructors (12.4) are called for all constructed objects with automatic storage duration (3.7.2) (named objects or temporaries) that are declared in that scope, in the reverse order of their declaration. Transfer out of a loop, out of a block, or back past an initialized variable with automatic storage duration involves the destruction of variables with automatic storage duration that are in scope at the point transferred from...

...

6.6.3 The Return Statement

The copy-initialization of the returned entity is sequenced before the destruction of temporaries at the end of the full-expression established by the operand of the return statement, which, in turn, is sequenced before the destruction of local variables (6.6) of the block enclosing the return statement.

...

12.8 Copying and moving class objects

31 When certain criteria are met, an implementation is allowed to omit the copy/move construction of a class object, even if the copy/move constructor and/or destructor for the object have side effects. In such cases, the implementation treats the source and target of the omitted copy/move operation as simply two different ways of referring to the same object, and the destruction of that object occurs at the later of the times when the two objects would have been destroyed without the optimization.(123) This elision of copy/move operations, called copy elision, is permitted in the following circumstances (which may be combined to eliminate multiple copies):

— in a return statement in a function with a class return type, when the expression is the name of a non-volatile automatic object (other than a function or catch-clause parameter) with the same cvunqualified type as the function return type, the copy/move operation can be omitted by constructing the automatic object directly into the function’s return value

123) Because only one object is destroyed instead of two, and one copy/move constructor is not executed, there is still one object destroyed for each one constructed.

Я тоже думал об этом, но что меня удивляет - результаты разные, так что дело не только в вырезании копии. Хотел бы знать, что об этом сказано в стандарте.

ead 22.10.2018 16:04

добавлена ​​цитата стандарта

Shloim 22.10.2018 16:11

«Деструктор» в вашем первом предложении должен быть «конструктором копирования», не так ли? В противном случае я думаю, что ответ не имеет смысла.

Konrad Rudolph 22.10.2018 18:13

@KonradRudolph Ну, если бы был вызван конструктор копирования, то остался бы какой-то экземпляр, для которого в какой-то момент должен быть вызван деструктор. Если конструктор копирования будет вызываться нет, то деструктор также не может быть вызван. На самом деле, два оператора (один с «деструктором» и один с «конструктором копирования») эквивалентны (если я не ошибаюсь).

tomsmeding 22.10.2018 21:24

@tomsmeding Они не эквивалентны, потому что мы говорим о разных объектах: деструктор std::string не изменяет объект, деструктор Writer делает, и для этого важно, была ли строка, передаваемая конструктору Writer, скопировано или нет (но не было ли оно впоследствии уничтожено).

Konrad Rudolph 22.10.2018 22:30

@KonradRudolph - это как конструктор копирования из res в возвращаемое значение, так и деструктор для res. Оба не вызываются из-за RVO.

Shloim 23.10.2018 08:30

Здесь главное отметить, что Writer работает с res, и когда применяется RVO, res и возвращаемое значение являются одним и тем же объектом.

Shloim 23.10.2018 08:31

хотя этот ответ объясняет, что происходит (спасибо за это!), он не отвечает на вопрос (по крайней мере, явно), указан ли результат функции стандартом. На самом деле MSVC дает разные результаты в зависимости от уровня оптимизации.

ead 23.10.2018 10:37

Стандарт определяет порядок выполнения, и я использовал определение RVO, чтобы успешно предсказать результат. MSVC, вероятно, позволяет отключить RVO и, следовательно, изменить результат.

Shloim 23.10.2018 10:44

@ead В стандарте не нужно указывать ничего большего, чем он сам. В результате при возврате создается копия для возврата, если она не опущена, тогда применяются правила. Теперь в зависимости от того, происходит ли элизия у цели, для Writer d'tor живет в другом месте. D'tor для Writer всегда выполняется, или для std::string, локального для make_string_ok, вообще не имеет значения. Возможно, вы сможете лучше понять это с ответом ниже (отказ от ответственности: это мой).

luk32 23.10.2018 11:18

Но компилятор не обязан делать RVO, он может это делать, а может и не делать. Нет гарантии, что результат будет «A», но также нет гарантии, что результат будет «AB».

ead 23.10.2018 11:23

@Shloim. Я до сих пор не понимаю, почему ты говоришь о Деструктор res. В этом случае он не оказывает заметного влияния. Имеют значение конструктор копирования res и деструктор w.

Konrad Rudolph 23.10.2018 11:25

@ luk32 Я хочу сказать, что RVO не является обязательным для компилятора, и поэтому (насколько я понимаю) стандарт допускает оба результата, «A» и «AB», что означает, что поведение / результат функции не указывается.

ead 23.10.2018 11:27

@ead Да. Оптимизация не является обязательной и может изменить наблюдаемое поведение. Это исключение из общего правила «как если бы», которое гласит, что компилятору разрешено выполнять любое преобразование, не изменяющее наблюдаемого поведения. Речь идет не только о побочных эффектах исключенных объектов c'tors и d'tor, но и о других побочных эффектах, которые в вашем случае являются d'tor Writer, он работает на другом объекте, независимо от того, выполняется ли оптимизация. применяется. Применяется по усмотрению компилятора.

luk32 23.10.2018 12:28

@ead, я добавил отрывок из стандарта C++ по оптимизации RVO

Shloim 23.10.2018 12:39

В C++ есть концепция под названием elision.

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

До могло произойти исключение:

  1. Когда у вас есть переменная без параметров Foo f; в функции, которая вернула Foo, а оператором возврата был простой return f;.

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

В все (почти?) Случаи # 2 исключены новыми правилами prvalue; elision больше не происходит, потому что то, что использовалось для создания временного объекта, больше не выполняется. Вместо этого конструкция «временного» напрямую привязана к постоянному объекту размещения.

Теперь исключение не всегда возможно, учитывая ABI, в который компилируется компилятор. Два распространенных случая, когда это возможно, известны как оптимизация возвращаемого значения и оптимизация именованного возвращаемого значения.

RVO обстоит так:

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

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

До здесь должны существовать конструкторы копирования / перемещения, и исключение не является обязательным; в из-за новых правил prvalue конструктор копирования / перемещения не требуется, и для компилятора нет опции, здесь должно быть 1 значение.

Другой известный случай - оптимизация возвращаемого значения, NRVO. Это случай (1) исключения, описанный выше.

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

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

Даже , второе слияние (между возвращаемым значением func и Foo foo) не является необязательным (и технически prvalue, возвращаемое из 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 вместе незаконно, и их время жизни перекрывается.

Вы все еще можете сделать это как если бы, но только если вы можете изучить и понять все побочные эффекты деструкторов, конструкторов и .futz().

Правильно ли я понял: в моем случае это NRVO, поэтому C++ 17 не гарантирует исключение копии. Это означает, что возвращаемое значение фактически не указано, потому что компилятор может применять или не применять NRVO?

ead 22.10.2018 20:59

@ead Да, исключение не гарантируется. Компилятор не мог этого сделать; он бы не сделал этого только в вашем случае, если бы вы потребовали, чтобы этого не было (с флагом, переданным компилятору). Однако он хрупкий; добавьте еще одну ветвь с другим возвращенным именованным объектом, который перекрывается по времени жизни, и результат вашего кода изменится.

Yakk - Adam Nevraumont 22.10.2018 21:08

Я на мгновение запутался, когда вы сказали second merge. Возможно, вы захотите изменить порядок абзацев.

Passer By 23.10.2018 11:58
Ответ принят как подходящий

Это RVO (+ возврат копии как временной, которая затуманивает изображение), одна из оптимизаций, позволяющих изменять видимое поведение:

10.9.5 Копирование / перемещение элизии(акценты мои):

When certain criteria are met, an implementation is allowed to omit the copy/move construction of a class object, even if the constructor selected for the copy/move operation and/or the destructor for the object have side effects**. In such cases, the implementation treats the source and target of the omitted copy/move operation as simply two different ways of referring to the same object.

This elision of copy/move operations, called copy elision, is permitted in the following circumstances (which may be combined to eliminate multiple copies):

  • in a return statement in a function with a class return type, when the expression is the name of a non-volatile automatic object (other than a function parameter or a variable introduced by the exception-declaration of a handler) with the same type (ignoring cv-qualification) as the function return type, the copy/move operation can be omitted by constructing the automatic object directly into the function call's return object
  • [...]

В зависимости от того, применяется ли это, вся ваша посылка ошибается. На 1. вызывается c'tor для res, но объект может находиться внутри make_string_ok или снаружи.

Дело 1.

Пункты 2 и 3 могут и не произойти, но это побочный момент. Мишень получила побочные эффекты Writers, пораженных дтором, находилась за пределами make_string_ok. Это оказалось временным, созданным с помощью make_string_ok в контексте оценки operator<<(ostream, std::string). Компилятор создал временное значение, а затем выполнил функцию. Это важно, потому что временное существует вне его, поэтому цель для Writer не является локальной для make_string_ok, а для operator<<.

Случай 2.

Между тем, ваш второй пример не соответствует критерию (как и те, которые были опущены для краткости), потому что типы разные. Так умирает писатель. Он бы даже умер, если бы был частью pair. Итак, здесь копия res.first возвращается как временный объект, а затем dtor Writer воздействует на исходный res.first, который вот-вот умрет сам.

Кажется довольно очевидным, что копия создается до вызова деструкторов, потому что объект, возвращаемый копией, также уничтожается, поэтому вы не сможете его скопировать в противном случае.

В конце концов, это сводится к RVO, потому что объект Writer работает либо на внешнем объекте, либо на локальном, в зависимости от того, применяется оптимизация или нет.

Предписывает ли стандарт, какое значение следует возвращать в приведенных выше примерах, или оно не указано?

Нет, оптимизация не является обязательной, хотя она может изменить наблюдаемое поведение. Применять это или нет - на усмотрение компилятора. Это исключение из общего правила «как если бы», которое гласит, что компилятору разрешено выполнять любое преобразование, не меняющее наблюдаемого поведения.

Случай для этого стал обязательным в C++ 17, но не в вашем. Обязательный - это когда возвращаемое значение является безымянным временным.

Это немного отличается - это деструктор другой объект (Writer), который имеет побочные эффекты, которые могут повлиять на возвращаемое значение.

Toby Speight 22.10.2018 21:35

@TobySpeight Очко занято. Я немного расширил ответ. И "реализация рассматривает источник и цель пропущенной операции копирования / перемещения как просто два разных способа обращения к одному и тому же объекту" выделено жирным шрифтом. Writer работает точно так же, это просто другой целевой объект. Кроме того, порядок копирования для возврата и применения dtors для локальных значений при возврате кажется довольно очевидным ... вы не можете уничтожить объект, который будет скопирован для возврата.

luk32 23.10.2018 10:54

@TobySpeight Кроме того, я заметил, что может быть важно подчеркнуть, что это может быть дым и экраны. Важно то, где живет исключенная копия и что является целью для Writer.

luk32 23.10.2018 11:22

Другие вопросы по теме