Предположим, что в программе на C++ вы возвращаете из функции локальную переменную. Что происходит при вызове оператора return: копирование, перемещение или ни то, ни другое? От этого зависит длительность вызова функции и эффективность наших программ. Я постарался разобраться с этим вопросом и дам рекомендации по написанию функций так, чтобы повысить шансы на применение этой оптимизации компиляторами. Ну, а сокращения в названии статьи — это Return Value Optimization (RVO) и Named Return Value Optimization (NRVO).

Определение NRVO и RVO

Давайте договоримся о терминах. Предположим, мы написали функцию:

C f() {
  C local_variable;
  // Действия с local_variable
  return local_variable;
}

где C — некий пользовательский класс. Что произойдет при её вызове?

C result = f();

Кажется, должна выполниться такая последовательность действий:

  • создание local_variable при помощи вызова конструктора по умолчанию класса C;

  • вызов конструктора копии класса C,  чтобы копировать local_variable в result;

  • вызов деструктора для local_variable.

Действительно, это так и произойдёт, если не будет использована Named Return Value Optimization (NRVO). А если будет, то вместо создания local_variable компилятор сразу создаст result конструктором по умолчанию в точке вызова функции f(). А функция f() будет выполнять действия сразу с переменной result. То есть в этом случае не будет вызван ни конструктор копии, чтобы скопировать local_variable в result, ни деструктор local_variable. Можно представить это так:

  • компилятор создаёт конструктором по умолчанию до вызова функции f() переменную result ;

  • затем неявно передаёт в функцию f() указатель на result ;

  • в рамках функции f() не создаёт local_variable, а вместо этого работает с указателем на result;

  • в return ничего не копируется, поскольку данные уже там.

Что же касается Return Value Optimization, то это такая же оптимизация, как NRVO, но для случаев, когда экземпляр возвращаемого класса создаётся прямо в операторе return:

C f() { return C(); }

В таких ситуациях компилятору легче выполнить оптимизацию, чем в случае с NRVO.

Обычно компилятор, даже со всеми включёнными оптимизациями, не обязан применять RVO/NRVO, а лишь имеет на это право. Поговорим о том, что мешает компилятору применять эти оптимизации, а что помогает, и о том, как повышать шансы на их применение.

Чтобы не загромождать статью, я разберу только два случая, упомянутых выше:

  1. Локальная переменная возвращается из функции (NRVO).

  2. Объект, созданный в точке вызова return, возвращается из функции (RVO).

Отмечу также, что стандарт и некоторые другие источники предпочитают вместо RVO/NRVO употреблять более общий термин copy elision (пропуск копии). Пару слов о нём скажу в конце статьи.

Случаи, когда компилятор обязан применить RVO

В C++17 есть два случая, когда компилятор обязан применить RVO.

1. Функция возвращает prvalue.

Во-первых, RVO будет применяться, когда возвращается prvalue того же типа, что описан в сигнатуре функции, или когда возвращается тип, из которого может быть сконструирован (явно или не явно) тот тип, который задан в сигнатуре. При этом игнорируются квалификаторы const и volatile. А операторов return может быть несколько, но все должны возвращать prvalue. Например, в этом случае будет всегда применено RVO:

C f() { return C(); }
C result = f();

Также всегда будет применено RVO в таком случае:

C f() {
  int n = 1;
  return n;
}

при условии, что у класса C есть конструктор от int, который не объявлен как explicit. Именно RVO, поскольку экземпляр класс C будет сконструирован неявно из n в операторе return.

До 17-го стандарта это было рекомендацией для компилятора, а в C++17 стало обязательной «оптимизацией». Я взял термин в кавычки, поскольку с точки зрения C++17 это уже не оптимизация, а обязательная часть работы компилятора. Более того, согласно новому стандарту, чтобы код выше скомпилировался классу C не требуются конструктор копии и перемещающий конструктор, поскольку эти конструкторы гарантированно не будут использованы при вызове f(). То есть в примере result будет создан сразу в точке вызова функции f().

Конструктор копии и перемещающий конструктор могут отсутствовать только в том случае, если компилятор обязан применить RVO. Если компилятор не обязан применять RVO/NRVO, а лишь имеет на это право, то перемещающий конструктор или конструктор копии будет нужен для того, чтобы сработать, если оптимизация не получится.

2. Constant expression и constant initialization.

Также компилятор обязан применить RVO в функциях времени компиляции (constexpr) и при инициализации глобальных, статических и thread-local переменных (constant initialization). Интересно, что в этих же случаях применение NRVO гарантированно не случится. Рассмотрим на примерах:

struct S {
  constexpr S() {}
};

constexpr S rvo() { return S(); }
constexpr S nrvo() {
  S s;
  return s;
}

S global_rvo = rvo();
S global_nrvo = nrvo();

Здесь при инициализации global_rvo гарантированно применится RVO. Строчка S global_rvo = rvo(); скомпилируется, даже если у структуры S не будет конструкторов копии или перемещения. А вот для инициализации global_nrvo необходимо, чтобы у структуры S были конструкторы копии или перемещения, поскольку один из них должен быть вызван в обязательном порядке. Ведь, как отмечено выше, в случае constant initialization NRVO применять нельзя.

Теперь поговорим про случаи, когда компилятор сам решает, применять ли ему RVO/NRVO.

Необходимые условия для применения RVO/NRVO

Необходимые условия для применения этой оптимизации:

  • Тип объекта, возвращаемого из функции согласно сигнатуре, должен совпадать с типом локального объекта или конструироваться неявно из типа локального объекта. Допустимо отличаться на константность.

  • Возвращаться должен именно локальный объект, а не ссылка на него или какая-то его часть.

  • В случае NRVO возвращаемый объект не должен быть volatile.

Поясню на простом примере:

struct N {
  N() {}
  N(int) {}
};

N k(int i) {
  int n = 1;
  return n;
}

Здесь NRVO может быть применено, поскольку N конструируется из p.

Случаи, когда компилятору сложно применить RVO/NRVO

Предположим, выполнены все необходимые условия. Тем не менее, обычно оптимизация не применяется в следующих случаях:

  • Есть несколько путей выхода из функции, которые возвращают разные локальные объекты.

  • Возвращаемый локальный объект ссылается на встроенный asm-блок.

Не стоит писать return std::move(local_value)

Рассмотрим пример из начала статьи:

C f() {
  C local_variable;
  // Действия с local_variable
  return local_variable;
}

В нём оптимизация NRVO, скорее всего, сработает. Допустим, мы уверены, что у класса C есть перемещающий конструктор. Тогда может возникнуть соблазн написать что-то вроде:

C f() {
  C local_variable;
  // Действия с local_variable
  return std::move(local_variable); // Так писать не стоит
}

Так делать ни в коем случае нельзя, иначе компилятор не сможет применить NRVO. В таком случае в операторе return будет вызван перемещающий конструктор класса C. На его вызов требуются ресурсы. А если перемещающего конструктора нет, то будет вызван конструктор копирования. Поясню подробнее.

В случае, если необходимые условия для применения NRVO выполнены (пример без std::move()), но оптимизация не применена по каким-то причинам, то возвращаемый объект обязан быть рассмотрен как rvalue. То есть возвращаемый объект будет рассмотрен так, как если бы к нему было применено std::move(). Выражаясь иначе, если NRVO не применено, то при наличии у возвращаемого объекта перемещающего конструктора будет вызван он. А если перемещающий конструктор отсутствует, то будет вызван конструктор копирования.  И для этого не нужно писать std::move(local_variable).

Эти рассуждения приводят нас к тому, что применение std::move() к возвращаемому локальному объекту не приносит никакой пользы. Более того, это вредит: std::move() меняет тип возвращаемого объекта. По сути, функция возвращает rvalue-ссылку на тип объекта, которые ей передаётся в качестве аргумента. То есть в нашем случае это будет rvalue-ссылка на локальный объект типа C. А, как отмечено выше, это препятствует применению NRVO, поскольку тип возвращаемого объекта будет rvalue-ссылка на тип C, в то время как f() возвращает просто тип C.

То есть при возвращении из функции локального объекта в операторе return не стоит писать std::move(). Однако нередко бывает оправдано применение std::move() к возвращаемому из функции параметру, если он передан по rvalue-ссылке. Но это тема для отдельного обсуждения.

Как включить и выключить RVO/NRVO-оптимизацию в компиляторах

Оптимизация RVO/NRVO по умолчанию включена. Отключить её можно при помощи флага компиляции -fno-elide-constructors. Важно отметить, что флаг отключает именно оптимизацию, то есть в случаях, когда стандарт гарантирует отсутствие копирования возвращаемого значения это копирование не происходит даже с этим флагом.

Проверка, сработает ли RVO/NRVO в конкретном случае

Иногда хочется узнать, сработает ли RVO/NRVO в конкретном случае. Сделать это очень просто: достаточно вставить в возвращаемый класс записи в лог в некоторые специальные методы, а именно в конструкторы, в перемещающий конструктор, в конструктор копии и деструктор. Вот пример класса, который можно вернуть из функции и по логам понять, сработает ли RVO/NRVO:

class NRVOCheck {
public:
  NRVOCheck() { std::cout << "constructor. address: " << this << '\n'; }
  NRVOCheck(NRVOCheck const&) { std::cout << "copy constructor\n"; }
  NRVOCheck(NRVOCheck&&) { std::cout << "move constructor\n"; }
  ~NRVOCheck() { std::cout << "destructor. address: " << this << '\n'; }
};

Другие случаи отмены копирования (copy elision)

В C++ копирование иногда отменяется и в других случаях. Например, в этом коде вызывается только конструктор по умолчанию для инициализации C:

C f() {
    return C();
}
C x = C(C(f()));

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

И еще одно важное замечание. В C++ есть правило "as-if": компилятору разрешено не генерировать код для операций, если их отсутствие не изменит поведение программы, наблюдаемое со стороны. Например, конструктор копии, который компилятор генерирует автоматически, может быть не вызван, если это не изменит поведение программы. Но если мы вместо автоматически сгенерированного конструктора копии напишем свой конструктор копии, в котором выведем что-то в лог, то внешне наблюдаемое поведение изменится, потому что при его вызове что-то будет выведено в лог. И после этого он станет вызываться.

Так вот, в статье были описаны только случаи, когда компилятор обязан применить RVO или может применить RVO/NRVO несмотря на изменение внешне наблюдаемого поведения. То есть, допустим, мы в специальных методах (конструкторе по умолчанию, конструкторе копии, перемещающем конструкторе и т.д.) выводим информацию в std::cout, меняя внешне наблюдаемое поведение. Эти специальные методы компилятор всё равно сможет удалить при применении RVO/NRVO.

Вызов кода конструктора копии и перемещающего конструктора

Всё вышесказанное имеет очевидное следствие, на которое я хотел бы обратить внимание. В ряде случаев компилятор сам решает, применять ли RVO/NRVO. Если применяет, то конструктор копии или перемещения не будет вызван, а если не применяет — то будет. Это зависит от компилятора и платформы. Поэтому не стоит полагаться на вызов этого кода и размещать в нём что-то важное кроме копирования или перемещения объекта.

Выводы

RVO/NRVO не новые оптимизации. Компилятор имел право применять их ещё в стандарте C++98. По прошествии времени стандарт стал строже регламентировать применение этих оптимизаций. Однако по-прежнему остаётся довольно большая серая зона, в которой компилятор решает, применять ли RVO/NRVO. И если нет возможности гарантировать применение RVO, то стоит хотя бы постараться повысить шансы на её применение.

Рекомендации

Целесообразно прежде всего рассмотреть возможность вернуть prvalue, то есть создать экземпляр класса прямо в операторе return. Это будет гарантировать отсутствие копирований и перемещений, а также не потребует от создаваемого объекта конструкторов копии и перемещения. Даже если операторов return, в которых создаются объекты, будет несколько.

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

Также в случаях, когда мы рассчитываем на применения RVO/NRVO, важно следить за тем, чтобы тип создаваемого в операторе return объекта или тип локального объекта, возвращаемого из функции, точно совпадал с типом, прописанным в сигнатуре функции. Ну, или, как минимум, чтобы из типа, возвращаемого из функции, мог быть сконструирован тип, прописанный в сигнатуре функции.

Важно также следить за тем, чтобы в операторе return, возвращающем локальный объект, не стояло std::move().

Статьи и лекции

Комментарии (22)


  1. jimmy_b
    27.05.2022 14:55
    +3

    Было бы интересно услышать комментарии по поводу распространенного сочетания:

    C f() {
      if(/*condition*/)
        return C();
    
      C local_variable;
      // Действия с local_variable
      return local_variable;  
    }
    


    1. BykoIanko Автор
      27.05.2022 15:07
      +1

      Как мне кажется тут как раз серая зона. RVO/NRVO может сработать, а может и нет. Зависит от компилятора, платформы и даже еще от одного факта. От того, всегда или нет выполнено условие /*condition*/. Если выполнено всегда, компилятор может понять это, и оптимизировать код, оставив одну ветку if().

      Думаю, что если мы перепишем пример так:

      C f() {
        C local_variable;
        if(/*condition*/)
          return local_variable;
      
        // Действия с local_variable
        return local_variable;  
      }

      шансов на NRVO прибавится.


      1. jimmy_b
        27.05.2022 15:32

        да, интересен случай, когда condition заранее неизвестен.

        кажется, что RVO для компилятора понятнее и удобнее, чем NRVO.
        поэтому обычно считается, что после проверки if(/*condition*/), быстрый возврат return {}; — это хорошо.

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

        вроде бы, он хорош со всех сторон. но получается, что шансы на получения оптимизации для нас уменьшаются?


        1. BykoIanko Автор
          27.05.2022 15:52

          кажется, что RVO для компилятора понятнее и удобнее, чем NRVO.

          Похоже что так. RVO от части перекочевало из оптимизации в правила обязательной отмены копирования. Похоже, тенденция от стандарта к стандарту в том, чтоб уменьшать серую зону, в которой компилятор сам решает применять ли RVO/NRVO или нет.

          поэтому обычно считается, что после проверки if(/condition/), быстрый возврат return {}; — это хорошо.

          Кроме того, отмечу, что при таком подходе (return early pattern), код становится менее громоздким и часто лучше читается, на мой взгляд.

          вроде бы, он хорош со всех сторон. но получается, что шансы на получения оптимизации для нас уменьшаются?

          Кажется, что да. Я проверил на clang (MacOS X). На нашем втором примере (где в двух местах return local_variable; ) NRVO был применен. В нашем первом примере, если вызвать вот так:

          NRVOCheck f(int k) {
            if (k == 3)
              return NRVOCheck();
            NRVOCheck local_variable;
            // Действия с local_variable
            return local_variable;
          }
          f(2);

          NRVO не сработал.


          1. me21
            28.05.2022 09:17

            А бывает такое, чтобы в первом return был применён RVO, а во втором - NRVO? Или оптимизация одна на всю функцию, либо так либо так?


            1. BykoIanko Автор
              28.05.2022 11:12

              Думаю, в одной функции может быть, чтоб иногда было RVO, иногда NRVO, а иногда было вызвано копирование. Т.е. если говорить языком стандарта, то и RVO, и NRVO это частные случаи отмены копирования (copy elision). Крайняя точка принятия решения о том, случится ли отмена копирования - это точка вызова ф-ции в Run Time. Технически даже можно представить, что в ряде ситуаций, компилятор может оставить место под результат работы функции на стеке и начать ее исполнять. И уже позже, создать в оставленном месте результат работы функции. А если так не будет получатся, то по возможности выполнить перемещение в оставленное место. А если и это не возможно, то копирование.


  1. Mingun
    27.05.2022 16:45
    +4

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


    Ведь уже появился синтаксис атрибутов, что мешает наконец завести атрибуты для включения гарантий нужного поведения?


    1. BykoIanko Автор
      27.05.2022 18:06
      +1

      Ведь уже появился синтаксис атрибутов, что мешает наконец завести атрибуты для включения гарантий нужного поведения?

      Тут традиционный компромис:

      Гарантированное поведение на одной чаше весов.

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

      С++ решает обычно решает такие штуки в строну эффективности. С другой стороны, синтаксис языка и так сложен. Кажется, что нежелание добавлять еще опций по указанию таких штук (например, через атрибуты), тоже можно понять. Думаете пользовался бы популярностью атрибут "Не применять RVO/NRVO"?

      Ну и в дополнение, можно вспомнить о том, как принимаются решения о добавлении новых особенностей в C++ :)

      Как следствие получаем C++, как весьма сложную конструкцию, с очень высоким порогом входа. На мой взгляд, сейчас язык непомерно сложен, но стал по современнее последние годы.


      1. KanuTaH
        27.05.2022 18:27
        +1

        Думаете пользовался бы популярностью атрибут "Не применять RVO/NRVO"?

        Такой - точно нет, с другой стороны, возможно, некоторой популярностью бы пользовался атрибут "выдавать ошибку, если в данном месте не удается применить copy elision". С третьей стороны, это всего лишь оптимизация, и тогда таких атрибутов надо наделать вообще для любых оптимизаций, потому что чем они хуже? Мы утонем в атрибутах с таким подходом.


        1. BykoIanko Автор
          28.05.2022 06:15

          некоторой популярностью бы пользовался атрибут "выдавать ошибку, если в данном месте не удается применить copy elision".

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

          Мы утонем в атрибутах с таким подходом.

          Да. Есть такое дело :)


      1. Mingun
        27.05.2022 19:21

        Думаете пользовался бы популярностью атрибут "Не применять RVO/NRVO"?

        Наоборот, было бы полезно иметь "вот здесь должен быть rvo", "вот здесь только хвостовая рекурсия", если разработчик переписывает код и это уже не получается гарантировать, то бить по рукам, пусть думает дальше.


        Вижу мне уже вроде намекают, что такие атрибуты есть. Не знаю, я вообще на C++-сник уже давно.


        1. BykoIanko Автор
          28.05.2022 06:37

          Наоборот, было бы полезно иметь "вот здесь должен быть rvo", "вот здесь только хвостовая рекурсия", если разработчик переписывает код и это уже не получается гарантировать, то бить по рукам, пусть думает дальше.

          Да, это отличный подход! Много таких штук в C++, которые не понятно сработают или нет.

          Вижу мне уже вроде намекают, что такие атрибуты есть. Не знаю, я вообще на C++-сник уже давно.

          Если не сложно, уточните п-та, о чем речь. Мне про них не известно, а если есть я бы хотел знать. Если информация не очень точная, можно и в личку. Спасибо!


          1. Mingun
            28.05.2022 12:28

            Это я про ответ tbl ниже


          1. tbl
            28.05.2022 14:22

            Про хвостовую рекурсию в clang уже musttail вроде как есть.


    1. tbl
      27.05.2022 18:07
      +1

      [[fail_if_not(rvo, nrvo)]]


      1. BykoIanko Автор
        28.05.2022 06:45
        +1

        Что-то я такого атрибута ни где найти не могу. Это пример, как могло бы выглядеть? Вообщем ссылочку пришлите п-та, если знаете про такой стандартный или не стандартный атрибут.


        1. tbl
          28.05.2022 14:11

          Это ирония по поводу того, что разработчики C++ предлагают или ищут новые атрибуты на каждый новый чих стандарта. Например, недавно всплывало при обсуждении лайфтаймов и временных объектов, чтобы починить поломанные "by design" range-based for loop в C++23: [[short_and_concise_lifetime_annotation]] и [[probably_broken_if_this_is_ignored]]


  1. Videoman
    28.05.2022 17:01
    +1

    А если будет, то вместо создания local_variable компилятор сразу создаст result конструктором по умолчанию в точке вызова функции f(). А функция f() будет выполнять действия сразу с переменной result.
    Попытка максимально упростить описание может привести к неправильному понимаю того, что на самом деле происходит. На практике компилятор просто выделяет память под объект, никакого конструктора по умолчанию не вызывается (его вообще может не быть), а объект создается уже поверх выделенной памяти внутри функции, в которую передается указатель на выделенную память, тем конструктором, который используется в RVO.
    Вот пример:
    #include <iostream>
    
    class A
    {
    public:
    
        A() noexcept  { std::cout << "A()"; }
        A(const A& that) = delete;
        A(A&& that) noexcept = delete;
        A(int x, int y) noexcept : x(x), y(y) { std::cout << "A(int, int)"; };
    
        A& operator=(const A& that) = delete;
        A& operator=(A&& that) noexcept = delete;
    
        int x;
        int y;
    };
    
    A func(int x, int y) {
        return A(x, y);
    }
    
    int main()
    {
        A a = func(5, 5);
    
        return a.x + a.y;
    }
    


    1. BykoIanko Автор
      28.05.2022 17:36

      Спасибо за уточнение.


  1. vanxant
    29.05.2022 12:30

    Мне кажется, для начала было бы неплохо копнуть чуть-чуть глубже. А именно, рассмотреть вопрос, а как физически из функций возвращаются объекты, которые не влезают в машинное слово (регистр процессора). Т.е. результат int f() возвращается в регистре-аккумуляторе, но как быть с std::string f()? Строка может быть любой длины и потому размещается в куче; но кто тогда должен отвечать за освобождение этой памяти, когда результат больше не требуется? Сама функция f очевидно этим заниматься не может, значит, это должна делать вызывающая функция. Но тогда и выделять память должен тот, кто её освобождает (у них с f, в принципе, аллокаторы могут и не совпадать).

    И тут мы приходим к тому, что объявление C f() для крупных типов есть всего лишь синтаксический сахар для void f(C * result) . Это именно указатель на память, не ссылка на объект; обязанность вызвать at-конструктор в этой памяти лежит на функции f. Но если знать про наличие этого указателя, вся "оптимизация" rvo/nrvo сводится к тому, чтобы компилятор не тупанул и не завёл ещё одну переменную для временного хранения результата.

    Кстати, в языке Си вариант C f() разрешён синтаксисом и в С++, собственно, появился оттудова. Но в Си подобный неявный сахар считается табу, атата и харам, причём настолько, что такой синтаксис ставит многих знакомых мне сишников в ступор. Хочешь вернуть структуру — передавай указатель. Точка.


    1. tbl
      29.05.2022 20:47

      Нет, это не сахар, это именно работа над объектами (известной на момент компиляции длины) на стеке, а не в куче. И std::string - это объект известной длины, содержащий в себе длину строки и указатель на область памяти в куче, содержащей само содержимое строки. *RVO для std::string - это ожидание, что сохрантсяя оригинальная строка в куче, а не занятие байтоперекладыванием. Т.е. при *RVO компилятор выделяет место на стеке именно в том месте, где будет указатель вершины стека при возврате из функции. А если это невозможно, то используются move-конструкторы (поэтому отсутствие запрета существования move-конструктора нужно в этих случаях для *RVO)


      1. vanxant
        29.05.2022 22:59

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