RVO и NRVO в C++17 +17


Предположим, что в программе на 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().

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




К сожалению, не доступен сервер mySQL