Конструкторы / FAQ C++

Добавлено 4 октября 2020 в 10:39

Что там с конструкторами?

Конструкторы строят объекты из пыли.

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

Обычное сокращение для конструктора – «ctor».


Есть ли разница между List x; и List x();?

Большая разница!

Предположим, что List – это имя некоего класса. Затем функция f() объявляет локальный объект List с именем x:

void f()
{
  List x;     // локальный объект с именем x (класса List)
  // ...
}

А функция g() объявляет функцию с именем x(), которая возвращает List:

void g()
{
  List x();   // функция с именем x (которая возвращает List)
  // ...
}

Может ли один конструктор класса вызвать другой конструктор того же класса для инициализации объекта this?

Ответ ниже относится к классическому (до 11) C++. Этот вопрос касается особенности конструкторов C++11, которые вызывают конструкторы того же типа. (ссылка скоро появится)

Нет.

Приведем пример. Предположим, вы хотите, чтобы ваш конструктор Foo::Foo(char) вызывал другой конструктор того же класса, скажем, Foo::Foo(char,int), чтобы Foo::Foo(char,int) помог инициализировать объект this. К сожалению, в классическом C++ это невозможно.

Некоторые люди всё равно это делают. К сожалению, это не делает то, что им нужно. Например, строка Foo(x, 0); не вызывает Foo::Foo(char,int) для объекта this. Вместо этого вызывается Foo::Foo(char,int) для инициализации временного локального объекта (не this), а затем немедленно уничтожает этот временный объект, когда управление передается через ;.

class Foo {
public:
  Foo(char x);
  Foo(char x, int y);
  // ...
};

Foo::Foo(char x)
{
  // ...
  Foo(x, 0);  // НЕ помогает инициализировать объект this!!
  // ...
}

Иногда вы можете объединить два конструктора с помощью параметра по умолчанию:

class Foo {
public:
  Foo(char x, int y = 0);  // Дает эффект комбинирования двух конструкторов
  // ...
};

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

class Foo {
public:
  Foo(char x);
  Foo(char x, int y);
  // ...
private:
  void init(char x, int y);
};

Foo::Foo(char x)
{
  init(x, int(x) + 7);
  // ...
}

Foo::Foo(char x, int y)
{
  init(x, y);
  // ...
}

void Foo::init(char x, int y)
{
  // ...
}

Кстати, НЕ пытайтесь достичь этого с помощью размещения new. Некоторые люди думают, что могут сказать new(this) Foo(x, int(x)+7) в теле Foo::Foo(char). Однако это плохо, плохо, плохо. Пожалуйста, не пишите и не говорите, что это работает с вашей конкретной версией вашего конкретного компилятора; это плохо. Конструкторы делают за кулисами кучу маленьких волшебных вещей, но эта плохая техника наступает на частично собранные части. Просто нет.


Всегда ли для класса Fred конструктор по умолчанию будет Fred::Fred()?

Нет.

«Конструктор по умолчанию» – это конструктор, который можно вызывать без аргументов. Одним из примеров этого является конструктор, который не принимает параметров:

class Fred {
public:
  Fred();   // Конструктор по умолчанию: может быть вызван без аргументов
  // ...
};

Другой пример «конструктора по умолчанию» – тот, который может принимать аргументы, если им заданы значения по умолчанию:

class Fred {
public:
  Fred(int i=3, int j=5);   // Конструктор по умолчанию: может быть вызван без аргументов
  // ...
};

Какой конструктор вызывается, когда я создаю массив объектов Fred?

Конструктор по умолчанию Fred'a (кроме случаев, описанных ниже).

class Fred {
public:
  Fred();
  // ...
};

int main()
{
  Fred a[10];              // Вызывает конструктор по умолчанию 10 раз
  Fred* p = new Fred[10];  // Вызывает конструктор по умолчанию 10 раз
  // ...
}

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

class Fred {
public:
  Fred(int i, int j);      // Предположим, что конструктора по умолчанию нет
  // ...
};

int main()
{
  Fred a[10];              // ОШИБКА: у Fred нет конструктора по умолчанию
  Fred* p = new Fred[10];  // ОШИБКА: у Fred нет конструктора по умолчанию
  // ...
}

Однако даже если в вашем классе уже есть конструктор по умолчанию, вы должны попытаться использовать std::vector<Fred>, а не массив (массивы – зло). std::vector позволяет вам решить использовать любой конструктор, а не только конструктор по умолчанию:

#include <vector>

int main()
{
  std::vector<Fred> a(10, Fred(5,7));  // 10 объектов Fred в std::vector будут инициализированы с Fred(5,7)
  // ...
}

Несмотря на то, что вам следует использовать std::vector, а не массив, бывают случаи, когда массив может быть правильным решением, и для этого вам может потребоваться синтаксис «явной инициализации массивов»:

class Fred {
public:
  Fred(int i, int j);      // Предположим, что конструктора по умолчанию нет
  // ...
};

int main()
{
  Fred a[10] = {
    Fred(5,7), Fred(5,7), Fred(5,7), Fred(5,7), Fred(5,7),  // 10 объектов Fred
    Fred(5,7), Fred(5,7), Fred(5,7), Fred(5,7), Fred(5,7)   // инициализированы с помощью Fred(5,7)
  };
  // ...
}

Конечно, вам не нужно вводить Fred(5,7) для каждой записи – вы можете вводить любые числа, которые хотите, даже параметры или другие переменные.

Наконец, вы можете использовать размещение new для ручной инициализации элементов массива. Предупреждение: это некрасиво: необработанный массив не может быть типа Fred, поэтому вам понадобится куча преобразований указателей, чтобы выполнять такие вещи, как операции вычисления индекса массива. Предупреждение: это зависит от компилятора и оборудования: вам нужно убедиться, что хранилище выровнено с выравниванием, которое, по крайней мере, столь же строго, требуется для объектов класса Fred. Предупреждение: сделать это безопасным для исключений утомительно: вам нужно вручную разрушить элементы, в том числе в случае, когда исключение генерируется на середине цикла, вызывающего конструкторы. Но если вы всё равно действительно хотите так сделать, прочитайте о размещении new. (Кстати, размещение new – это магия, которая используется внутри std::vector. Сложность получения всего правильно – это еще одна причина использовать std::vector.)

Кстати, я когда-нибудь упоминал, что массивы – это зло? Или я упоминал, что вам следует использовать std::vector, если нет веской причины использовать массив?


Мои конструкторы должны использовать «списки инициализации» или «присвоение»?

Списки инициализации. На самом деле конструкторы, как правило, должны инициализировать все объекты-члены в списке инициализации. Одно исключение обсуждается ниже.

Просмотрите обсуждение инициализации нестатических членов данных в C++11

// Стиль стандартной инициализации нестатических членов данных в C++
struct Point {
      int X = 0; // Посмотрите на это!!!
      int Y = 0; // 
};

Рассмотрим следующий конструктор, который инициализирует объект-член x_ с помощью списка инициализации: Fred::Fred() : x_(что-то) {}. Наиболее частым преимуществом этого является повышение производительности. Например, если выражение «что-то» имеет тот же тип, что и переменная-член x_, результат выражения «что-то» создается непосредственно внутри x_ – компилятор не создает отдельную копию объекта. Даже если типы не совпадают, компилятор обычно лучше справляется со списками инициализации, чем с присваиваниями.

Другой (неэффективный) способ создания конструкторов – это присваивание, например: Fred::Fred () {x_ = что-то; }. В этом случае выражение «что-то» вызывает создание отдельного временного объекта, и этот временный объект передается в оператор присваивания объекта x_. Затем этот временный объект уничтожается в точке ;. Это неэффективно.

Если этого было недостаточно, есть еще один источник неэффективности при использовании присваивания в конструкторе: объект-член будет полностью построен его конструктором по умолчанию, и это может, например, выделить некоторый объем памяти по умолчанию или открыть какой-то файл по умолчанию. Вся эта работа может быть напрасной, если выражение «что-то» и/или оператор присваивания заставят объект закрыть этот файл и/или освободить эту память (например, если конструктор по умолчанию выделил не достаточно большой пул памяти, или если он открыл неправильный файл).

Вывод: при прочих равных, ваш код будет работать быстрее, если вы будете использовать списки инициализации, а не присваивание.

Примечание. Разницы в производительности нет, если тип x_ является встроенным/внутренним типом, например int или char* или float. Но даже в этих случаях я лично, ради последовательности действий, предпочитаю устанавливать эти элементы данных в списке инициализации, а не через присваивание. Еще один аргумент симметрии в пользу использования списков инициализации даже для встроенных/внутренних типов: нестатическим константным и нестатическим ссылочным членам данных нельзя присвоить значение в конструкторе, поэтому для симметрии имеет смысл инициализировать всё в списке инициализации.

Теперь об исключениях. У каждого правила есть исключения (хммм; есть ли исключения из правила «у каждого правила есть исключения»? напоминает мне теорему Гёделя о неполноте), и у правила «используй списки инициализации» есть несколько исключений. Суть в том, чтобы руководствоваться здравым смыслом: если использовать их не дешевле, лучше, быстрее и т.д., то ни в коем случае не используйте их. Это может произойти, если в вашем классе есть два конструктора, которым необходимо инициализировать члены данных объекта this в разном порядке. Или это может произойти, когда два члена данных ссылаются на себя. Или когда члену данных нужна ссылка на объект this, и вы хотите избежать предупреждения компилятора об использовании ключевого слова this до скобки {, которая начинает тело конструктора (когда ваш конкретный компилятор выдает это конкретное предупреждение). Или когда вам нужно выполнить тест if… throw для переменной (параметра, глобальной переменной и т.д.) перед использованием этой переменной для инициализации одного из ваших членов this. Этот список не является исчерпывающим; пожалуйста, не пишите с просьбой добавить еще одно «Или когда…». Дело просто в следующем: используйте здравый смысл.


Как следует упорядочить инициализаторы в списке инициализации конструктора?

Непосредственные базовые классы (слева направо), затем объекты-члены (сверху вниз).

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

#include <iostream>

class Y {
public:
  Y();
  void f();
};

Y::Y()      { std::cout << "Initializing Y\n"; }
void Y::f() { std::cout << "Using Y\n"; }

class X {
public:
  X(Y& y);
};

X::X(Y& y) { y.f(); }

class Z {
public:
  Z();
protected:
  X x_;
  Y y_;
};

Z::Z() throw()
  : y_()
  , x_(y_)
    ↑↑   // ПЛОХО: x_ должен быть перечислен до y_
{ }

int main()
{
  Z z;
  return 0;
}

Результат этой программы будет следующий.

Using Y
Initializing Y

Обратите внимание, что y_ используется (Y::f()) до ее инициализации (Y::Y()). Если бы программист прочитал и соблюдал рекомендации в этом FAQ, ошибка была бы более очевидной: список инициализации Z::Z() читался бы как x_(y_), y_(), визуально указывая, что y_ использовался еще до инициализации.

Для таких случаев не все компиляторы выдают диагностические сообщения. Но вы были предупреждены.


Этично ли инициализировать один объект-член с использованием другого объекта-члена в выражении инициализатора?

Да, но будьте осторожны и делайте это только тогда, когда это приносит пользу.

В списке инициализации конструктора проще и безопаснее всего избегать использования одного объекта-члена из объекта this в выражении инициализации последующего инициализатора для объекта this. Эта рекомендация предотвращает незаметные ошибки зависимости от порядка, если кто-то реорганизует компоновку объектов-членов в классе.

Из-за этого правила следующий конструктор использует s.len_ + 1u, а не len_ + 1u, хотя в остальном они эквивалентны. Префикс s. позволяет избежать ненужной зависимости от порядка, которую можно избежать.

#include <memory>

class MyString {
public:
  MyString();
 ~MyString();
  MyString(const MyString& s);              // конструктор копирования
  MyString& operator= (const MyString& s);  // присваивание
  // ...
protected:
  unsigned len_;
  char*    data_;
};

MyString::MyString()
  : len_(0u)
  , data_(new char[1])
{
  data_[0] = '\0';
}

MyString::~MyString()
{ delete[] data_; }

MyString::MyString(const MyString& s)
  : len_ (s.len_)
  , data_(new char[s.len_ + 1u])       // <-- Не {new char[len_+1]}
{                  ↑↑↑↑↑↑   // не len_
  memcpy(data_, s.data_, len_ + 1u);
}  
                      ↑↑↑↑   // нет проблем с использованием len_ в {теле} конструктора
int main()
{
  MyString a;      // конструктор по умолчанию; MyString ("") нулевой длины
  MyString b = a;  // конструктор копирования
  return 0;
}

Излишняя зависимость от порядка компоновки len_ и data_ могла бы возникнуть, если бы при инициализации конструктора data_ использовалось len_ + 1u вместо s.len_ + 1u. Однако использование len_ в теле конструктора ({...}) допустимо. Никакой зависимости от порядка не вводится, так как весь список инициализации гарантированно завершится до того, как тело конструктора начнет выполняться.


Что делать, если один объект-член должен быть инициализирован с помощью другого объекта-члена?

Добавьте комментарии к объявлению связанных членов данных с помощью // ЗАВИСИМОСТЬ ОТ ПОРЯДКА.

Если конструктор инициализирует объект-член объекта this, используя другой объект-член объекта this, изменение порядка членов данных класса может нарушить работу конструктора. Это важное ограничение в поддержке должно быть задокументировано в теле класса.

Например, в конструкторе ниже инициализатор для data_ использует len_, чтобы избежать избыточного вызова std::strlen(s), что вводит зависимость от порядка в теле класса.

#include <memory>

class MyString {
public:
  MyString(const char* s);                // продвигает const char*
  MyString(const MyString& s);            // конструктор копирования
  MyString& operator= (const MyString&);  // присваивание
 ~MyString();
  // ...
protected:
  unsigned len_;   // ЗАВИСИМОСТЬ ОТ ПОРЯДКА
  char*    data_;  // ЗАВИСИМОСТЬ ОТ ПОРЯДКА
};

MyString::MyString(const char* s)
  : len_ (std::strlen(s))
  , data_(new char[len_ + 1u])
{
  std::memcpy(data_, s, len_ + 1u);
}

MyString::~MyString()
{
  delete[] data_;
}

int main()
{
  MyString s = "xyzzy";
  return 0;
}

Обратите внимание, что комментарий // ЗАВИСИМОСТЬ ОТ ПОРЯДКА указан у задействованных членов данных в теле класса, а не в списке инициализации конструктора, в котором фактически была создана зависимость от порядка. Это потому, что порядок объектов-членов в теле класса имеет решающее значение; порядок инициализаторов в списке инициализации конструктора не имеет значения.


Следует ли использовать указатель this в конструкторе?

Некоторые считают, что вам не следует использовать указатель this в конструкторе, потому что объект еще не полностью сформирован. Однако вы можете использовать this в конструкторе (в {теле} и даже в списке инициализации), если будете осторожны.

Вот то, что всегда работает: {тело} конструктора (или функции, вызываемой из конструктора) может надежно обращаться к членам данных, объявленным в базовом классе, и/или членам данных, объявленным в собственном классе конструктора. Это связано с тем, что все эти члены данных гарантированно будут полностью созданы к моменту начала выполнения {тела} конструктора.

Вот кое-что, что никогда не работает: {тело} конструктора (или функции, вызываемой из конструктора) не может перейти к производному классу, вызывая виртуальную функцию-член, которая переопределяется в производном классе. Если ваша цель состояла в том, чтобы перейти к переопределенной функции в производном классе, вы не получите того, что хотите. Обратите внимание, что вы не получите переопределение в производном классе независимо от того, как вы вызываете виртуальную функцию-член: явно используя указатель this (например, this->method()), неявно используя указатель this (например, method()), или даже вызывая какую-либо другую функцию, которая вызывает виртуальную функцию-член вашего объекта this. Суть в следующем: даже если вызывающий объект создает объект производного класса, во время конструктора базового класса ваш объект еще не принадлежит к этому производному классу. Вы предупреждены.

Вот кое-что, что иногда работает: если вы передаете какой-либо из членов данных в объекте this инициализатору другого члена данных, вы должны убедиться, что этот член данных уже был инициализирован. Хорошая новость заключается в том, что вы можете определить, был ли инициализирован (или нет) член данных, с помощью некоторых простых языковых правил, которые не зависят от конкретного компилятора, который вы используете. Плохая новость заключается в том, что вы должны знать эти языковые правила (например, сначала инициализируются подобъекты базового класса (посмотрите порядок, если у вас есть множественное и/или виртуальное наследование!), затем члены данных, определенные в классе, инициализируются в порядке, в котором они появляются в объявлении класса). Если вы не знаете этих правил, не передавайте ни один член данных из объекта this (независимо от того, используете ли вы это ключевое слово явно или нет) в инициализатор любого другого члена данных! И если вы знаете правила, будьте осторожны.


Что такое «идиома именованного конструктора»?

Это метод, который обеспечивает для пользователей вашего класса более интуитивно понятные и/или более безопасные операции создания.

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

С помощью идиомы именованных конструкторов вы объявляете все конструкторы класса в разделах private или protected и предоставляете общедоступные статические (public static) методы, возвращающие объект. Эти статические методы представляют собой так называемые «именованные конструкторы». Как правило, для каждого способа создания объекта существует один такой статический метод.

Например, предположим, что мы создаем класс Point, который представляет положение на плоскости X-Y. Оказывается, есть два распространенных способа указать координаты в двумерном пространстве: прямоугольные координаты (X + Y), полярные координаты (радиус + угол). (Не беспокойтесь, если вы не можете их вспомнить; дело не в деталях систем координат; дело в том, что существует несколько способов создания объекта Point.) К сожалению, параметры для этих двух систем координат одинаковы: два значения типа float. Это создало бы ошибку двусмысленности в перегруженных конструкторах:

class Point {
public:
  Point(float x, float y);     // Прямоугольные координаты
  Point(float r, float a);     // Полярные координаты (радиус и угол)
  // ОШИБКА: перегрузка двусмысленна: Point::Point(float,float)
};

int main()
{
  Point p = Point(5.7, 1.2);   // Двусмысленно: какая система координат?
  // ...
}

Один из способов решить эту двусмысленность – использовать идиому именованного конструктора:

#include <cmath>               // Чтобы получить std::sin() и std::cos()

class Point {
public:
  static Point rectangular(float x, float y);      // Прямоугольные координаты
  static Point polar(float radius, float angle);   // Полярные координаты
  // Эти статические методы - это так называемые "именованные конструкторы"
  // ...
private:
  Point(float x, float y);     // Прямоугольные координаты
  float x_, y_;
};

inline Point::Point(float x, float y)
  : x_(x), y_(y) { }

inline Point Point::rectangular(float x, float y)
{ return Point(x, y); }

inline Point Point::polar(float radius, float angle)
{ return Point(radius*std::cos(angle), radius*std::sin(angle)); }

Теперь у пользователей Point есть четкий и недвусмысленный синтаксис для создания точек Point в любой системе координат:

int main()
{
  Point p1 = Point::rectangular(5.7, 1.2);   // очевидно, что прямоугольная система координат
  Point p2 = Point::polar(5.7, 1.2);         // очевидно, что полярная система координат
  // ...
}

Если вы ожидаете, что Point будет иметь производные классы, убедитесь, что ваши конструкторы находятся в разделе protected.

Идиому именованного конструктора также можно использовать, чтобы убедиться, что ваши объекты всегда создаются с помощью new.

Обратите внимание, что идиома именованного конструктора, по крайней мере, как реализовано выше, работает так же быстро, как и прямой вызов конструктора – современные компиляторы не будут делать никаких дополнительных копий вашего объекта.


Означает ли возврат по значению дополнительные копии и дополнительные накладные расходы?

Не обязательно.

Все(?) компиляторы коммерческого уровня оптимизируют лишнюю копию, по крайней мере, в случаях, как показано в предыдущем ответе данного FAQ.

Чтобы пример оставался чистым, давайте сократим всё до самого необходимого. Предположим, что функция caller() вызывает rbv() («rbv» означает «return by value», «возврат по значению»), которая возвращает объект Foo по значению:

class Foo { /*...*/ };

Foo rbv();

void caller()
{
  Foo x = rbv();  // Значение, возвращенное rbv(), передается в x
  // ...
}

Теперь вопрос: сколько будет объектов Foo? Будет ли rbv() создавать временный объект Foo, который копируется в x? Сколько будет временных объектов? Другими словами, обязательно ли ухудшает производительность возврат по значению?

Суть этого вопроса заключается в том, что ответ – нет, компиляторы C++ коммерческого уровня реализуют возврат по значению таким образом, который позволяет им устранить накладные расходы, по крайней мере, в простых случаях, подобных тем, которые показаны в предыдущем вопросе. В частности, все(?) компиляторы C++ коммерческого уровня оптимизируют следующий случай:

Foo rbv()
{
  // ...
  return Foo(42, 73);  // Предполагается, что у Foo есть конструктор Foo::Foo(int a, int b)
}

Конечно, компилятору разрешается создать временный локальный объект Foo, затем вызвать конструктор копирования, чтобы скопировать этот временный объект в переменную x внутри caller(), а затем уничтожить временный объект. Но все(?) компиляторы C++ коммерческого уровня этого делать не будут: оператор return сам создаст x. Не копию x, не указатель на x, не ссылку на x, а сам x.

Вы можете остановиться на этом, если не хотите по-настоящему вникать в предыдущий абзац, но если вы хотите знать «секретный ингридиент» (чтобы вы могли, например, надежно предсказать, когда компилятор сможет или не сможет предоставить вам эту оптимизацию), ключ заключается в том, чтобы знать, что компиляторы обычно реализуют возврат по значению, используя передачу по указателю. Когда caller() вызывает rbv(), компилятор тайно передает указатель туда, где предполагается, что rbv() создаст «возвращаемый» объект. Это может выглядеть примерно так (показано как void*, а не как Foo*, поскольку объект Foo еще не создан):

// Псевдокод
void rbv(void* put_result_here)  // Исходный код C++: Foo rbv()
{
  // ...код, который инициализирует (не присваивает) переменную, на которую указывает put_result_here
}

// Псевдокод
void caller()
{
  // Исходный код C++: Foo x = rbv()
  struct Foo x;  // Примечание: x не инициализируется до вызова rbv()
  rbv(&x);       // Примечание: rbv() инициализирует локальную переменную, определенную в caller()
  // ...
}

Итак, первым ингредиентом секретного соуса является то, что компилятор (обычно) преобразует «возврат по значению» в «передачу по указателю». Это означает, что компиляторы коммерческого уровня не утруждают себя созданием временного объекта: они напрямую создают возвращаемый объект в месте, на которое указывает put_result_here.

Второй ингредиент секретного соуса заключается в том, что компиляторы обычно реализуют конструкторы с использованием аналогичной техники. Это зависит от компилятора и в некоторой степени идеализировано (я намеренно игнорирую обработку new и перегрузку), но компиляторы обычно реализуют Foo::Foo(int a, int b), используя что-то вроде этого:

// Псевдокод
void Foo_ctor(Foo* this, int a, int b)  // Исходный код C++: Foo::Foo(int a, int b)
{
  // ...
}

Подводя итог, компилятор может реализовать оператор return в rbv(), просто передав put_result_here в качестве указателя this конструктора:

// Псевдокод
void rbv(void* put_result_here)  // Исходный код C++: Foo rbv()
{
  // ...
  Foo_ctor((Foo*)put_result_here, 42, 73);  // Исходный код C++: return Foo(42,73);
  return;
}

Таким образом, caller() передает &x в rbv(), а rbv(), в свою очередь, передает &x конструктору (как указатель this). Это означает, что конструктор напрямую создает x.

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

Заключительная мысль: это обсуждение ограничивалось тем, будут ли какие-либо создаваться дополнительные копии возвращаемого объекта в вызове с возвратом по значению. Не путайте это с другими вещами, которые могут произойти в caller(). Например, если вы изменили caller() с Foo x = rbv(); на Foo x; х = rbv(); (обратите внимание на ; после объявления), компилятор должен использовать оператор присваивания Foo, и если компилятор не может доказать, что конструктор Foo по умолчанию, за которым следует оператор присваивания, точно такой же, как его конструктор копирования, то язык C++ потребует от компилятора поместить возвращенный объект в безымянный временный объект в caller(), использовать оператор присваивания, чтобы скопировать временный объект в x, а затем уничтожить этот временный объект. Оптимизация возврата по значению по-прежнему играет свою роль, поскольку будет создаваться только один временный объект, но путем изменения Foo x = rbv(); на Foo x; x = rbv();, вы помешали компилятору избежать и этого последнего временного объекта.


А как насчет возврата локальной переменной по значению? Существует ли локальный объект как отдельный объект или его нужно оптимизировать?

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

Вот некоторые(!) компиляторы, которые полностью оптимизируют локальную переменную:

  • GNU C++ (g++) по крайней мере с версии 3.3.3
  • (необходимо добавить другие; требуется дополнительная информация)

Вот некоторые(!) компиляторы, которые не оптимизируют локальную переменную:

  • Microsoft Visual C ++. NET 2003
  • (необходимо добавить другие; требуется дополнительная информация)

Вот пример, показывающий, что мы имеем в виду в ответе на этот вопрос:

class Foo {
public:
  Foo(int a, int b);
  void some_method();
  // ...
};

void do_something_with(Foo& z);

Foo rbv()
{
  Foo y = Foo(42, 73);
  y.some_method();
  do_something_with(y);
  return y;
}

void caller()
{
  Foo x = rbv();
  // ...
}

В этом ответе рассматривается следующий вопрос: сколько объектов Foo на самом деле создается в системе времени выполнения? Концептуально может быть до трех различных объектов: временный, созданный Foo(42, 73), переменная y (в rbv()) и переменная x (в caller()). Однако, как мы видели ранее, большинство компиляторов объединяют Foo(42, 73) и переменную y в один и тот же объект, уменьшая общее количество объектов с 3 до 2. Но этот ответ продвигает данную тему еще на один шаг: будет ли y (в rbv()) виден как отдельный объект времени выполнения из x (в caller())?

Некоторые компиляторы, включая, помимо прочего, перечисленные выше, полностью оптимизируют локальную переменную y. В этих компиляторах в приведенном выше коде используется только один объект Foo: переменная x в caller() – это тот же объект, что и переменная y в rbv().

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

Поэтому вместо создания y как локального объекта эти компиляторы просто создают *put_result_here, и каждый раз, когда они видят переменную y, используемую в исходном коде, они заменяют ее на *put_result_here. Затем строка return y; становится просто return;, поскольку возвращенный объект уже был создан в месте, указанном вызывающей стороной.

Вот получившийся (псевдо) код:

// Псевдокод
void rbv(void* put_result_here)             // Исходный код C++: Foo rbv()
{
  Foo_ctor((Foo*)put_result_here, 42, 73);  // Исходный код C++: Foo y = Foo(42,73);
  Foo_some_method(*(Foo*)put_result_here);  // Исходный код C++: y.some_method();
  do_something_with((Foo*)put_result_here); // Исходный код C++: do_something_with(y);
  return;                                   // Исходный код C++: return y;
}

void caller()
{
  struct Foo x;                             // Примечание: x здесь не инициализируется!
  rbv(&x);                                  // Исходный код C++: Foo x = rbv();
  // ...
}

Предостережение: эта оптимизация может применяться только тогда, когда все операторы return в функции возвращают одну и ту же локальную переменную. Если один оператор return в rbv() вернул локальную переменную y, а другой вернул что-то еще, например глобальную или временную переменную, компилятор не сможет присвоить локальной переменной псевдоним места назначения вызывающей стороны, x. Проверка того, что все операторы return функции возвращают одну и ту же локальную переменную, требует дополнительной работы со стороны авторов компилятора, что обычно является причиной того, что некоторые компиляторы не могут реализовать оптимизацию возврата локальной переменной по значению.

Заключительная мысль: это обсуждение ограничивалось тем, будут ли какие-либо создаваться дополнительные копии возвращаемого объекта в вызове с возвратом по значению. Не путайте это с другими вещами, которые могут произойти в caller(). Например, если вы изменили caller() с Foo x = rbv(); на Foo x; х = rbv(); (обратите внимание на ; после объявления), компилятор должен использовать оператор присваивания Foo, и если компилятор не может доказать, что конструктор Foo по умолчанию, за которым следует оператор присваивания, точно такой же, как его конструктор копирования, то язык C++ потребует от компилятора поместить возвращенный объект в безымянный временный объект в caller(), использовать оператор присваивания, чтобы скопировать временный объект в x, а затем уничтожить этот временный объект. Оптимизация возврата по значению по-прежнему играет свою роль, поскольку будет только один временный объект, но путем изменения Foo x =

Теги

C++ / CppFAQВысокоуровневые языки программированияИдиома именованного конструктора / Named Constructor IdiomКонструктор / Constructor / ctor (программирование)Объектно-ориентированное программирование (ООП)ПрограммированиеЯзыки программирования