Подтвердить что ты не робот

Почему исключаемые из NRVO параметры по стоимости?

Представьте себе:

S f(S a) {
  return a;
}

Почему ему не разрешено псевдоним a и слот возвращаемого значения?

S s = f(t);
S s = t; // can't generally transform it to this :(

Спецификация не позволяет это преобразование, если конструктор копирования S имеет побочные эффекты. Вместо этого для этого требуется не менее двух копий (один от t до a и один от a до возвращаемого значения, а другой от возвращаемого значения до S, и только последний может быть удален. Обратите внимание, что я написал = t выше для представления факта копии t в f a, единственной копии, которая все еще была бы обязательной при наличии побочных эффектов конструктора move/copy).

Почему это?

4b9b3361

Ответ 1

Вот почему копирование elision не имеет смысла для параметров. Это действительно о реализации концепции на уровне компилятора.

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

Все, что внутренняя функция должна выполнять, чтобы исключить копию, построит вывод в том месте, которое предоставляется вызывающим. Если функция может это сделать, вы получите копию. Если функция не может, то она будет использовать одну или несколько временных переменных для хранения промежуточных результатов, а затем скопировать/переместить их в место, предоставляемое вызывающим. Он по-прежнему построен на месте, но построение вывода происходит через копию.

Таким образом, мир вне определенной функции не должен знать или заботиться о том, выполняет ли функция эликсирование. В частности, вызывающая функция не должна знать, как реализована функция. Это не делает ничего другого; это сама функция, которая решает, возможно ли исключение.

Хранение значений параметров также предоставляется вызывающим абонентом. Когда вы вызываете f(t), вызывающая программа создает копию t и передает ее на f. Аналогично, если S неявно конструируется из int, то f(5) построит S из 5 и передаст его в f.

Все это делается вызывающим. Вызывающий не знает и не заботится о том, чтобы он был переменным или временным; он просто дал место памяти стека (или регистров или что-то еще).

Теперь помните: copy elision работает, потому что вызываемая функция строит переменную непосредственно в выходное местоположение. Поэтому, если вы пытаетесь исключить возврат из параметра значения, то хранилище для параметра значения также должно быть самим хранилищем вывода. Но помните: это вызывающий объект, который обеспечивает это хранилище как для параметра, так и для вывода. И, следовательно, чтобы исключить выходную копию, вызывающий должен построить параметр непосредственно в выходной файл.

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

Поэтому комитет С++ не потрудился разрешить эту возможность.

Ответ 2

Обоснование, как я понимаю, для этого ограничения состоит в том, что вызывающее соглашение могло (и во многих случаях) требовать, чтобы аргумент функции и возвращаемого объекта находился в разных местах (памяти или регистров). Рассмотрим следующий измененный пример:

X foo();
X bar( X a ) 
{ 
   return a;
}
int main() {
   X x = bar( foo() );
}

Теоретически весь набор копий будет возвращать в foo ($tmp1), аргументе a of bar, возвращать оператор bar ($tmp2) и x в main. Компиляторы могут вывести два из четырех объектов, создав $tmp1 в местоположении a и $tmp2 в месте x. Когда компилятор обрабатывает main, он может заметить, что возвращаемое значение foo является аргументом bar и может сделать их совпадающими, и в этот момент он не может знать (без инкрустации), что аргумент и возврат bar являются одним и тем же объектом, и он должен соответствовать вызывающему соглашению, поэтому он помещает $tmp1 в положение аргумента bar.

В то же время он знает, что цель $tmp2 заключается только в создании x, поэтому он может размещать оба на одном и том же адресе. Внутри bar не так много можно сделать: аргумент a расположен вместо первого аргумента в соответствии с вызывающим соглашением, а $tmp2 должен быть расположен в соответствии с вызывающим соглашением (в в общем случае в другом месте, подумайте, что пример может быть расширен до bar, который принимает больше аргументов, только один из которых используется как оператор return.

Теперь, если компилятор выполняет inlining, он может обнаружить, что дополнительная копия, которая потребуется, если функция не была встроена, действительно не нужна, и у нее будет шанс ее ускорить. Если бы стандарт позволял исключать эту конкретную копию, то тот же код имел бы различное поведение в зависимости от того, включена ли эта функция или нет.

Ответ 3

От t до a нецелесообразно копировать копию. Параметр объявляется изменчивым, поэтому копирование выполняется, поскольку ожидается, что он будет изменен в функции.

От a до возвращаемого значения я не вижу причин для копирования. Возможно, это какой-то надзор? Параметр по величине чувствует себя как локальные люди внутри тела функции... я не вижу здесь никакой разницы.

Ответ 4

Дэвид Родригес - dribeas отвечает на мой вопрос "Как разрешить построение эллипса для классов С++" дал мне следующую идею. Хитрость заключается в том, чтобы использовать лямбда для задержки оценки внутри тела функции:

#include <iostream>

struct S
{
  S() {}
  S(const S&) { std::cout << "Copy" << std::endl; }
  S(S&&) { std::cout << "Move" << std::endl; }
};

S f1(S a) {
  return a;
}

S f2(const S& a) {
  return a;
}

#define DELAY(x) [&]{ return x; }

template <class F>
S f3(const F& a) {
  return a();
}

int main()
{
  S t;
  std::cout << "Without delay:" << std::endl;
  S s1 = f1(t);
  std::cout << "With delay:" << std::endl;
  S s2 = f3(DELAY(t));
  std::cout << "Without delay pass by ref:" << std::endl;
  S s3 = f2(t);
  std::cout << "Without delay pass by ref (temporary) (should have 0 copies, will get 1):" << std::endl;
  S s4 = f2(S());
  std::cout << "With delay (temporary) (no copies, best):" << std::endl;
  S s5 = f3(DELAY(S()));
}

Это выводится на ideone GCC 4.5.1:

Без задержки:
Копирование
Копирование
С задержкой:
Копировать

Теперь это хорошо, но можно предположить, что версия DELAY аналогична передаче по ссылке const, как показано ниже:

Без задержки перейдите по ссылке:
Копировать

Но если мы передаем временную ссылку const, мы все равно получим копию:

Без задержки пропустите ref (временно) (должно быть 0 копий, получите 1):
Копировать

Если версия с задержкой возвращает копию:

С задержкой (временная) (без копий, лучше всего):

Как вы можете видеть, это исключает все копии во временном случае.

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

Ответ 5

Я чувствую, потому что альтернатива всегда доступна для оптимизации:

S& f(S& a) { return a; }  // pass & return by reference
^^^  ^^^

Если f() закодирован, как указано в вашем примере, то вполне нормально предположить, что копия предназначена или ожидаются побочные эффекты; в противном случае почему бы не выбрать проход/возврат по ссылке?

Предположим, что если NRVO применяется (по вашему усмотрению), то нет разницы между S f(S) и S& f(S&)!

NRVO пинает в таких ситуациях, как operator +() (пример), потому что нет достойной альтернативы.

Один поддерживающий аспект, все ниже функции имеют разные типы поведения для копирования:

S& f(S& a) { return a; }  // 0 copy
S f(S& a) { return a; } // 1 copy
S f(S a) { A a1; return (...)? a : a1; }  // 2 copies

В третьем фрагменте, если (...) известен во время компиляции false, тогда компилятор генерирует только 1 копию.
Это означает, что компилятор целенаправленно не выполняет оптимизацию, когда доступна тривиальная альтернатива.

Ответ 6

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

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