Конструкторы / 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), переменная yrbv()) и переменная xcaller()). Однако, как мы видели ранее, большинство компиляторов объединяют Foo(42, 73) и переменную y в один и тот же объект, уменьшая общее количество объектов с 3 до 2. Но этот ответ продвигает данную тему еще на один шаг: будет ли yrbv()) виден как отдельный объект времени выполнения из xcaller())?

Некоторые компиляторы, включая, помимо прочего, перечисленные выше, полностью оптимизируют локальную переменную 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 = rbv(); на Foo x; x = rbv();, вы помешали компилятору избежать и этого последнего временного объекта.


Почему я не могу инициализировать статический член данных в списке инициализации конструктора?

Потому что вы должны явно определить статические члены данных вашего класса.

Fred.h:

class Fred {
public:
  Fred();
  // ...
private:
  int i_;
  static int j_;
};

Fred.cpp (или Fred.C, или что-то еще):

Fred::Fred()
  : i_(10)  // Хорошо: вы можете (и должны) инициализировать члены данных таким способом
  , j_(42)  // Ошибка: вы не можете так инициализировать статические члены данных
{
  // ...
}

// Вы должны определять статические члены данных этим способом:
int Fred::j_ = 42;

Примечание: в некоторых случаях определение Fred::j_ может не содержать части = инициализатор. Подробности смотрите здесь и здесь.


Почему классы со статическими членами данных получают ошибки линкера?

Потому что статические члены данных должны быть явно определены точно в одном блоке компиляции. Если вы сделали не так, то вы, вероятно, получите ошибку компоновщика «undefined external». Например:

// Fred.h

class Fred {
public:
  // ...
private:
  static int j_;   // объявляет статический член данных Fred::j_
  // ...
};

Линкер будет кричать вам («Fred::j_ не определен»), если вы не определите (а не просто объявите) Fred::j_ в (точно) одном из ваших исходных файлов:

// Fred.cpp

#include "Fred.h"

int Fred::j_ = некое_выражение_оцениваемое_как_int;
// В качестве альтернативы, если вы хотите использовать неявное значение 0 для статических int:
// int Fred::j_;

Обычное место для определения статических членов данных класса Fred – это файл Fred.cpp (или Fred.C, или любое другое расширение исходного файла, которое вы используете).

Примечание: в некоторых случаях вы можете добавить = инициализатор; к объявлению статических объявлений в области класса, однако, если вы когда-либо будете использовать этот член данных, вам всё равно нужно будет явно определить его ровно в одном блоке компиляции. В этом случае вы не включаете в определение = инициализатор. Этой теме посвящен отдельный ответ FAQ.


Могу я добавить = инициализатор; к объявлению члена данных static const в области класса?

Да, но с некоторыми важными оговорками.

Перед тем как прочесть предостережения, вот простой допустимый пример:

// Fred.h

class Fred {
public:
  static const int maximum = 42;
  // ...
};

И, как и другие статические члены данных, он должен быть определен ровно в одной единице компиляции, но на этот раз без части = инициализатор:

// Fred.cpp

#include "Fred.h"

const int Fred::maximum;

// ...

Предостережения заключаются в том, что вы можете делать это только с целочисленными или перечисляемыми типами, и что выражение инициализатора должно быть выражением, которое может быть вычислено во время компиляции: оно должно содержать только другие константы, возможно, в сочетании со встроенными операторами. Например, 3*4 – это константное выражение времени компиляции, как и a*b, если a и b – константы времени компиляции. После объявления, приведенного выше, Fred::maximum также является константой времени компиляции: его можно использовать в других константных выражениях времени компиляции.

Если вы когда-нибудь возьмете адрес Fred::maximum, чтобы, например, передать его по ссылке или явно укажете &Fred::maximum, компилятор позаботится о том, чтобы у него был уникальный адрес. В противном случае Fred::maximum даже не займет место в области статических данных вашего процесса.


Что такое «фиаско (проблема) с порядком инициализации static» / «проблема порядка статической инициализации»?

Незаметный способ вывести вашу программу из строя.

Проблема порядка инициализации static – очень трудноуловимый и часто неправильно понимаемый аспект C++. К сожалению, его очень сложно обнаружить – ошибки часто возникают до запуска main().

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

Это оно. Это так просто.

Сложность в том, что у вас есть 50%-50% шанс испортить программу. Если блок компиляции для x.cpp инициализируется первым, всё в порядке. Но если сначала инициализируется модуль компиляции для y.cpp, то инициализация y будет выполняться до инициализации x, вам не повезло. Например, конструктор y может вызвать метод объекта x, но объект x еще не создан.

Чтобы узнать, как решить эту проблему, смотрите следующий раздел часто задаваемых вопросов.

Примечание. Проблема порядка инициализации static также в некоторых случаях может относиться к встроенным/внутренним типам.


Как мне предотвратить «проблему порядка инициализации static»?

Чтобы предотвратить проблему порядка инициализации static, используйте идиому «создание при первом использовании» (construct on first use), описанную ниже.

Основная идея идиомы «создания при первом использовании» – заключить статический объект внутрь функции. Например, предположим, что у вас есть два класса: Fred и Barney. Есть объект x класса Fred в области видимости пространства имен или в глобальной области видимости и объект y класса Barney в области видимости пространства имен или в глобальной области видимости. Конструктор Barney вызывает метод goBowling() объекта x. Файл x.cpp определяет объект x:

// файл x.cpp
#include "Fred.h"
Fred x;

Файл y.cpp определяет объект y:

// файл y.cpp
#include "Barney.h"
Barney y;

Для полноты картины конструктор Barney может выглядеть примерно так:

// файл Barney.cpp
#include "Barney.h"

Barney::Barney()
{
  // ...
  x.goBowling();
  // ...
}

У вас произойдет катастрофа инициализации static, если y будет создан до x. Как написано выше, эта катастрофа будет происходить примерно в 50% случаев, поскольку два объекта объявлены в разных исходных файлах, и эти исходные файлы не дают никаких подсказок компилятору или компоновщику относительно порядка статической инициализации.

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

// файл x.cpp

#include "Fred.h"

Fred& x()
{
  static Fred* ans = new Fred();
  return *ans;
}

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

// файл Barney.cpp

#include "Barney.h"

Barney::Barney()
{
  // ...
  x().goBowling();
  // ...
}

Это называется «Construct On First Use Idiom» (идиома «создания при первом использовании»), потому что она делает именно это: объект Fred (в области видимости пространства имен или в глобальной области видимости) создается при первом использовании.

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

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


Почему идиома «создания при первом использовании» не использует статический объект вместо статического указателя?

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

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

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

// файл x.cpp

#include "Fred.h"

Fred& x()
{
  static Fred ans;  // было static Fred* ans = new Fred();
  return ans;       // было return *ans;
}

Однако с этим изменением есть (или, скорее, может быть) довольно незаметная проблема. Чтобы понять эту потенциальную проблему, давайте, в первую очередь, вспомним, зачем мы всё это делаем: нам нужно на 100% убедиться, что наш статический объект (a) создается до его первого использования и (b) не разрушается до последнего использования. Очевидно, было бы катастрофой, если бы какой-либо статический объект использовался либо до создания, либо после разрушения. Суть здесь в том, что вам нужно беспокоиться о двух ситуациях (статическая инициализация и статическая деинициализация), а не только об одной.

Изменив объявление со static Fred* ans = new Fred(); на static Fred ans;, мы по-прежнему правильно обрабатываем ситуацию инициализации, но больше не обрабатываем ситуацию деинициализации. Например, если есть 3 статических объекта, скажем a, b и c, которые используют ans во время своих деструкторов, единственный способ избежать катастрофы статической деинициализации – это уничтожить ans после всех этих трех объектов.

Суть проста: если есть какие-либо другие статические объекты, деструкторы которых могут использовать ans после уничтожения ans, бах, вы мертвы. Если конструкторыa, b и c используют ans, обычно всё в порядке, так как во время статической деинициализации система выполнения будет уничтожать ans после того, как будет разрушен последний из этих трех объектов. Однако, если a и/или b и/или c не могут использовать ans в своих конструкторах, и/или если какой-либо код где-либо получает адрес ans и передает его какому-либо другому статическому объекту, всё кардинально меняется, и вы должны быть очень, очень осторожны.

Существует третий подход, который обрабатывает как статическую инициализацию, так и статическую деинициализацию, но он имеет другие нетривиальные затраты.


Какой метод гарантирует статическую инициализацию и статическую деинициализацию?

Краткий ответ: используйте «идиому изящного счетчика» («Nifty Counter Idiom») (но убедитесь, что вы понимаете сложность компромиссов!).

Мотивы:

  • Идиома «создания при первом использовании» («construct on first use») использует указатель и намеренно допускает для объекта «утечку» памяти. Часто это безобидно, поскольку операционная система обычно очищает память процесса, когда тот завершается. Однако если у объекта есть нетривиальный деструктор с важными побочными эффектами, такими как запись в файл или какое-либо другое энергонезависимое действие, вам нужно большее.
  • Здесь появляется вторая версия идиомы «создания при первом использовании» («construct on first use»): она не допускает утечку объекта, но не контролирует порядок статической деинициализации, поэтому (очень!) небезопасно использовать этот объект во время статической деинициализации, то есть из деструктора другого статически объявленного объекта.
  • Если вам необходимо управлять порядком статической инициализации и статической деинициализации, то есть если вы хотите получить доступ к статически выделенному объекту как из конструкторов, так и из деструкторов других статических объектов, продолжайте чтение.
  • В противном случае бегите.

TODO: описать решение.

TODO: описать компромиссы.


Как мне предотвратить «проблему порядка статической инициализации» для моих статических членов данных?

Используйте идиому «создания членов при первом использовании» (Construct Members On First Use Idiom), которая в основном совпадает с обычной идиомой «создания при первом использовании» или, возможно, одним из ее вариантов, но использует статическую функцию-член вместо глобальной функции или функции пространства имен.

Предположим, у вас есть класс X со статическим объектом Fred:

// файл X.h

class X {
public:
  // ...

private:
  static Fred x_;
};

Естественно, этот статический член инициализируется отдельно:

// файл X.cpp

#include "X.h"

Fred X::x_;

Естественно, объект Fred также будет использоваться в одном или нескольких методах X:

void X::someMethod()
{
  x_.goBowling();
}

Но теперь «сценарий катастрофы» заключается в том, что кто-то где-то каким-то образом вызывает этот метод до того, как будет создан объект Fred. Например, если кто-то другой создает статический объект X и вызывает его метод someMethod() во время статической инициализации, то вы зависите от компилятора, будет ли он создавать X::x_ до или после вызова someMethod(). (Обратите внимание, что комитет ANSI/ISO C++ работает над этой проблемой, но пока еще нет компиляторов, которые обрабатывают эти изменения.)

В любом случае всегда переносимо и безопасно изменить статический член данных X::x_ на статическую функцию-член:

// файл X.h

class X {
public:
  // ...

private:
  static Fred& x();
};

Естественно, этот статический член инициализируется отдельно:

// файл X.cpp

#include "X.h"

Fred& X::x()
{
  static Fred* ans = new Fred();
  return *ans;
}

Затем вы просто меняете любое использование x_ на x():

void X::someMethod()
{
  x().goBowling();
}

Если вы очень заботитесь о производительности и беспокоитесь о накладных расходах, связанных с вызовом дополнительной функции при каждом вызове X::someMethod(), вы можете вместо этого использовать static Fred&. Как вы помните, статическая локальная переменная инициализируются только один раз (первый раз выполняется управление их объявлением), поэтому X::x() вызывается только один раз: при первом вызове X::someMethod():

void X::someMethod()
{
  static Fred& x = X::x();
  x.goBowling();
}

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


Нужно ли мне беспокоиться о «проблеме порядка статической инициализации» для переменных встроенных/внутренних типов?

Да.

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

#include <iostream>

int f();  // предварительное объявление
int g();  // предварительное объявление

int x = f();
int y = g();

int f()
{
  std::cout << "using 'y' (which is " << y << ")\n";
  return 3*y + 7;
}

int g()
{
  std::cout << "initializing 'y'\n";
  return 5;
}

Вывод этой маленькой программы покажет, что она использует y до ее инициализации. Решением, как и раньше, является идиома «создания при первом использовании»:

#include <iostream>

int f();  // предварительное объявление
int g();  // предварительное объявление

int& x()
{
  static int ans = f();
  return ans;
}

int& y()
{
  static int ans = g();
  return ans;
}

int f()
{
  std::cout << "using 'y' (which is " << y() << ")\n";
  return 3*y() + 7;
}

int g()
{
  std::cout << "initializing 'y'\n";
  return 5;
}

Конечно, вы можете упростить это решение, переместив код инициализации для x и y в соответствующие функции:

#include <iostream>

int& y();  // предварительное объявление

int& x()
{
  static int ans;

  static bool firstTime = true;
  if (firstTime) {
    firstTime = false;
    std::cout << "using 'y' (which is " << y() << ")\n";
    ans = 3*y() + 7;
  }

  return ans;
}

int& y()
{
  static int ans;

  static bool firstTime = true;
  if (firstTime) {
    firstTime = false;
    std::cout << "initializing 'y'\n";
    ans = 5;
  }

  return ans;
}

И, если избавиться от операторов печати, вы можете еще больше упростить код:

int& y();  // предварительное объявление

int& x()
{
  static int ans = 3*y() + 7;
  return ans;
}

int& y()
{
  static int ans = 5;
  return ans;
}

Более того, поскольку y инициализируется с использованием константного выражения, ей больше не нужна функция-оболочка – она снова может быть простой переменной.


Как я могу обработать сбой в конструкторе?

Выбросить исключение. Подробности смотрите здесь (ссылка скоро появится).


Что такое «идиома именованных параметров»?

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

Основная проблема, решаемая идиомой именованных параметров, заключается в том, что C++ поддерживает только позиционные параметры. Например, вызывающему функцию не разрешается говорить: «Вот значение формального параметра xyz, а это значение – значение формального параметра pqr». Всё, что вы можете сделать в C++ (а также в C и Java), – это сказать: «Вот первый параметр, вот второй параметр и т.д.». Альтернатива, называемая именованными параметрами и реализованная на языке Ada, особенно полезна, если функция принимает большое количество параметров, по большей части допускаемых по умолчанию.

За прошедшие годы люди придумали множество обходных путей для отсутствия именованных параметров в C и C++. Один из них заключается в том, чтобы скрыть значения параметра в строковом параметре и затем проанализировать эту строку во время выполнения. Это то, что делается, например, во втором параметре fopen(). Другой обходной путь – объединить все логические параметры в битовой карте, а затем вызывающий объект выполняет последовательность логических операций с этой картой и набором сдвинутых побитно констант, чтобы создать реальный параметр. Это то, что сделано, например, во втором параметре open(). Эти подходы работают, но приведенный ниже метод делает код вызывающего, более очевидным, более простым для написания и чтения и, как правило, более элегантным.

Идея, называемая идиомой именованных параметров (Named Parameter Idiom), состоит в том, чтобы изменить параметры функции на методы созданного класса, где все эти методы возвращают *this по ссылке. Затем вы просто переименовываете основную функцию в метод без параметров для этого класса.

Чтобы облегчить понимание предыдущего абзаца, мы рассмотрим пример.

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

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

File f = OpenFile("foo.txt");

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

File f = OpenFile("foo.txt")
           .readonly()
           .createIfNotExist()
           .appendWhenWriting()
           .blockSize(1024)
           .unbuffered()
           .exclusiveAccess();

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

Итак, вот как это реализовать: сначала мы создаем класс (OpenFile), в котором все значения параметров хранятся как закрытые члены данных. Обязательные параметры (в данном случае единственным обязательным параметром является имя файла) реализованы как обычный позиционный параметр в конструкторе OpenFile, но этот конструктор на самом деле не открывает файл. Затем все необязательные параметры (только чтение, запись и чтение и т.д.) становятся методами. Эти методы (например, readonly(), blockSize(unsigned) и т.д.) возвращают ссылку на свой объект this, поэтому вызовы методов могут быть объединены в цепочку.

class File;

class OpenFile {
public:
  OpenFile(const std::string& filename);
    // устанавливает значения по умолчанию для каждого члена данных
  OpenFile& readonly();  // изменяет readonly_ на true
  OpenFile& readwrite(); // изменяет readonly_ на false
  OpenFile& createIfNotExist();
  OpenFile& blockSize(unsigned nbytes);
  // ...
private:
  friend class File;
  std::string filename_;
  bool readonly_;          // по умолчанию false [для примера]
  bool createIfNotExist_;  // по умолчанию false [для примера]
  // ...
  unsigned blockSize_;     // по умолчанию 4096 [для примера]
  // ...
};

inline OpenFile::OpenFile(const std::string& filename)
  : filename_         (filename)
  , readonly_         (false)
  , createIfNotExist_ (false)
  , blockSize_        (4096u)
{ }

inline OpenFile& OpenFile::readonly()
{ readonly_ = true; return *this; }

inline OpenFile& OpenFile::readwrite()
{ readonly_ = false; return *this; }

inline OpenFile& OpenFile::createIfNotExist()
{ createIfNotExist_ = true; return *this; }

inline OpenFile& OpenFile::blockSize(unsigned nbytes)
{ blockSize_ = nbytes; return *this; }

Остается только сделать так, чтобы конструктор класса File принимал объект OpenFile:

class File {
public:
  File(const OpenFile& params);
  // ...
};

Этот конструктор получает реальные параметры от объекта OpenFile, а затем открывает файл на самом деле:

File::File(const OpenFile& params)
{
  // ...
}

Обратите внимание, что OpenFile объявляет File своим другом, поэтому OpenFile не нуждается в куче (в противном случае бесполезных) методов get-методов в public:.

Поскольку каждая функция-член в цепочке возвращает ссылку, копирования объектов не происходит, и объединение в цепочку очень эффективно. Более того, если различные функции-члены являются встроенными, сгенерированный код объекта, вероятно, будет соответствовать коду в стиле C, который устанавливает различные члены структуры. Конечно, если функции-члены не являются встроенными, может произойти небольшое увеличение размера кода и небольшое снижение производительности (но это важно, только если создание происходит в критическом фрагменте программы, привязанном к CPU).


Почему я получаю сообщение об ошибке после объявления объекта Foo через Foo x(Bar())?

Потому что это не создает объект Foo – этот код объявляет функцию, не являющуюся членом, которая возвращает объект Foo. Для описания этой ситуации Скоттом Майерсом был придуман термин «наиболее неоднозначный анализ» (Most Vexing Parse).

Это будет действительно больно; вам лучше присесть.

Во-первых, вот лучшее объяснение проблемы. Предположим, есть класс Bar, у которого есть конструктор по умолчанию. Это может быть даже класс библиотеки, такой как std::string, но пока мы будем называть его просто Bar:

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

Теперь предположим, что есть еще один класс с именем Foo, у которого есть конструктор, который принимает Bar. Как и раньше, это может определять кто-то другой, а не вы.

class Foo {
public:
  Foo(const Bar& b);  // или, возможно, Foo(Bar b)
  // ...
  void blah();
  // ...
};

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

void yourCode()
{
  Foo x(Bar());  // Вы думаете, что это создает объект Foo с именем x...
  x.blah();      // ...Но это не так, поскольку эта строка дает странное сообщение об ошибке
  // ...
}

Это долгая история, но одно из решений (надеюсь, вы уже сидите!) – добавить дополнительную пару скобок () вокруг Bar():

void yourCode()
{
  Foo x((Bar()));
        ↑     ↑ // Эти скобки спасут день

  x.blah();
  ↑↑↑↑↑↑↑↑ // Вух, теперь это работает: сообщений об ошибках больше нет

  // ...
}

Другое решение – использовать = в вашем объявлении (смотрите написанное мелким шрифтом ниже):

void yourCode()
{
  Foo x = Foo(Bar());  // Да, Вирджиния, этот синтаксис работает; смотрите ниже мелкий шрифт
  x.blah();            // Вух, теперь это работает: сообщений об ошибках больше нет
  // ...
}

Примечание. Приведенное выше решение требует, чтобы yourCode() имел доступ к конструктору копирования Foo. В большинстве ситуаций это означает, что конструктор копирования Foo должен быть общедоступным, хотя он не обязательно должен быть открытым в менее распространенном случае, когда yourCode() является другом класса Foo. Если вы не уверены, что это означает, попробуйте: если ваш код компилируется, вы прошли тест.

Вот еще одно решение (более мелким шрифтом ниже):

void yourCode()
{
  Foo x = Bar();  // обычно работает; смотрите мелкий шрифт ниже о том, что такое "обычно"
  x.blah();
  // ...
}

Примечание. Слово «обычно» в приведенном выше коде означает следующее: приведенный выше код даст ошибку, только если конструктор Foo::Foo(const Bar&)является явным (explicit), или когда конструктор копирования Foo недоступен (обычно, когда он является private или protected, а ваш код не является другом). Если вы не уверены, что это значит, потратьте минуту и скомпилируйте его. Во время компиляции вы гарантированно узнаете, работает ваш код или нет; если он компилируется правильно, он будет работать во время выполнения.

Однако лучшее решение, создание которого, по крайней мере, частично было мотивировано тем фактом, что существует этот ответ FAQ, – это использовать единообразную инициализацию, которая заменяет () вокруг вызова Bar() на {}.

void yourCode()
{
  Foo x{Bar()};  
  x.blah();      // Вух, теперь это работает: сообщений об ошибках больше нет
  // ...
}

Это конец описания решений; остальное – о том, почему это необходимо (это необязательно; вы можете пропустить этот раздел, если вы не слишком заботитесь о своей карьере, чтобы понять, что происходит; ха-ха). Когда компилятор видит Foo x(Bar( )), он думает, что часть Bar() объявляет функцию, не являющуюся членом, которая возвращает объект Bar, поэтому он думает, что вы объявляете существование функции с именем x, которая возвращает Foo и принимает единственный параметр типа «функция, не являющаяся членом, которая ничего не принимает и возвращает Bar».

А теперь самое печальное. На самом деле это жалко. Какой-нибудь бездумный бездельник пропустит этот последний абзац, а затем навяжет причудливый, неправильный, нерелевантный и просто глупый стандарт кодирования, который гласит что-то вроде: «Никогда не создавайте временные объекты с использованием конструктора по умолчанию» или «Всегда используйте = во всех инициализациях», или что-то еще столь же бессмысленное. Если это вы, пожалуйста, застрелитесь, прежде чем нанесете еще больший урон. Те, кто не понимает проблемы, не должны указывать другим, как ее решить.

(В основном это была шутка. Но в этом есть доля правды. Настоящая проблема в том, что люди склонны преклоняться перед последовательностью, и они склонны экстраполировать от неясного к общему. Это не мудро.)


Какова цель ключевого слова explicit?

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

Например, следующий код без ключевого слова explicit корректен:

class Foo {
public:
  Foo(int x);
  operator int();
};

class Bar {
public:
  Bar(double x);
  operator double();
};

void yourCode()
{
  Foo a = 42;         // Хорошо: вызывает Foo::Foo(int), передавая в качестве аргумента 42
  Foo b(42);          // Хорошо: вызывает Foo::Foo(int), передавая в качестве аргумента 42
  Foo c = Foo(42);    // Хорошо: вызывает Foo::Foo(int), передавая в качестве аргумента 42
  Foo d = (Foo)42;    // Хорошо: вызывает Foo::Foo(int), передавая в качестве аргумента 42
  int e = d;          // Хорошо: вызывает Foo::operator int()

  Bar x = 3.14;       // Хорошо: вызывает Bar::Bar(double), передавая в качестве аргумента 3.14 
  Bar y(3.14);        // Хорошо: вызывает Bar::Bar(double), передавая в качестве аргумента 3.14 
  Bar z = Bar(3.14);  // Хорошо: вызывает Bar::Bar(double), передавая в качестве аргумента 3.14 
  Bar w = (Bar)3.14;  // Хорошо: вызывает Bar::Bar(double), передавая в качестве аргумента 3.14 
  double v = w;       // Хорошо: вызывает Bar::operator double()
}

Но иногда вы хотите предотвратить такого рода неявное продвижение или неявное преобразование типа. Например, если Foo действительно представляет собой контейнер, подобный массиву, а 42 – это начальный размер, вы можете позволить своим пользователям говорить: Foo x(42); или, возможно, Foo x = Foo(42);, но только не Foo x = 42;. В таком случае вам следует использовать ключевое слово explicit:

class Foo {
public:
  explicit Foo(int x);
  explicit operator int();
};

class Bar {
public:
  explicit Bar(double x);
  explicit operator double();
};

void yourCode()
{
  Foo a = 42;           // Ошибка времени компиляции: не может преобразовать 42 в объект типа Foo
  Foo b(42);            // Хорошо: вызывает Foo::Foo(int), передавая 42 в качестве аргумента
  Foo c = Foo(42);      // Хорошо: вызывает Foo::Foo(int), передавая  42 в качестве аргумента
  Foo d = (Foo)42;      // Хорошо: вызывает Foo::Foo(int), передавая 42 в качестве аргумента
  int e = d;            // Ошибка времени компиляции: не может преобразовать d в integer
  int f = int(d);       // Хорошо: вызывает Foo::operator int()

  Bar x = 3.14;         // Ошибка времени компиляции: не может преобразовать 3.14 в объект типа Bar
  Bar y(3.14);          // Хорошо: вызывает Bar::Bar(double), передавая 3.14 в качестве аргумента
  Bar z = Bar(3.14);    // Хорошо: вызывает Bar::Bar(double), передавая 3.14 в качестве аргумента
  Bar w = (Bar)3.14;    // Хорошо: вызывает Bar::Bar(double), передавая 3.14 в качестве аргумента
  double v = w;         // Ошибка времени компиляции: не может преобразовать w в double
  double u = double(w); // Хорошо: вызывает Bar::operator double()
}

Вы можете смешивать в одном классе явные и неявные конструкторы и операторы преобразования. Например, у следующего класса есть явный конструктор, принимающий bool, и неявный конструктор, принимающий double, и его можно неявно преобразовать в double, но только явно преобразовать в bool:

#include <iostream>

class Foo {
public:
  Foo(double x)            { std::cout << "Foo(double)\n"; }
  explicit Foo(bool x)     { std::cout << "Foo(bool)\n"; }
  operator double()        { std::cout << "operator double()\n"; }
  explicit operator bool() { std::cout << "operator bool()\n"; }
};

void yourCode()
{
  Foo a = true;       // Хорошо: неявно продвигает true в (double)1.0, затем вызывает Foo::Foo(double)
  Foo b = Foo(true);  // Хорошо: явно вызывает Foo::Foo(bool)
  double c = b;       // Хорошо: неявно вызывает Foo::operator double()
  bool d = b;         // Хорошо: вызывает Foo::operator double() и неявно преобразует в bool
  if(b) {}            // Хорошо, явно вызывает Foo::operator bool()
}

Приведенный выше код напечатает следующее:

Foo(double)
Foo(bool)
operator double()
operator double()
operator bool()

Переменная a инициализируется с помощью конструктора Foo(double), потому что Foo(bool) не может использоваться в неявном приведении, но true может интерпретироваться как (double)true, то есть как 1.0, и неявно приводиться к Foo с помощью Foo::Foo(double). Это может быть, а может и не быть тем, что вы хотели, но так это происходит.


Почему мой конструктор работает неправильно?

Это вопрос, который возникает во многих формах. Например:

  • Почему компилятор копирует мои объекты, когда я этого не хочу?
  • Как отключить копирование?
  • Как остановить неявные преобразования?
  • Как мой int превратился в комплексное число?

По умолчанию классу предоставляется конструктор копирования и присваивание копирования, которые копируют все элементы, а также конструктор перемещения и присваивание перемещения, которые перемещают все элементы. Например:

struct Point {
  int x,y;
  Point(int xx = 0, int yy = 0) :x(xx), y(yy) { }
};

Point p1(1,2);
Point p2 = p1;  

Здесь мы получаем p2.x == p1.x и p2.y == p1.y. Часто это именно то, что вам нужно (и важно для совместимости с C), но рассмотрим другой пример:

class Handle {
private:
  string name;
  X* p;
public:
  Handle(string n)
      :name(n), p(0) { /* получает X с именем "name" и дает p указывать на него */ }
  ~Handle() { delete p; /* высвобождает X с именем "name" */ }
  // ...
};

void f(const string& hh)
{
  Handle h1(hh);
  Handle h2 = h1; // приведет к катастрофе!
  // ...
}

Здесь копирование по умолчанию дает нам h2.name == h1.name и h2.p == h1.p. Это приводит к катастрофе: когда мы выходим из f(), вызываются деструкторы для h1 и h2, а объект, на который указывают h1.p и h2.p, удаляется дважды.

Как этого избежать? Самое простое решение – пометить операции, которые копируются, как удаленные:

class Handle {
private:
  string name;
  X* p;

  Handle(const Handle&) = delete;   // предотвращает копирование
  Handle& operator=(const Handle&) = delete;
public:
  Handle(string n)
      :name(n), p(0) { /* получает X с именем "name" и дает p указывать на него */ }
  ~Handle() { delete p; /* высвобождает X с именем "name" */ }
  // ...
};

void f(const string& hh)
{
  Handle h1(hh);
  Handle h2 = h1; // ошибка (о ней сообщает компилятор)
  // ...
}

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

Теперь вернемся к Point. Для Point семантика копирования по умолчанию в порядке, проблема в конструкторе:

struct Point {
  int x,y;
  Point(int xx = 0, int yy = 0) :x(xx), y(yy) { }
};

void f(Point);

void g()
{
  Point orig;  // создать orig со значением по умолчанию (0,0)
  Point p1(2); // создать p1 со значением по умолчанию y-координаты 0
  f(2);        // вызвать Point(2,0);
}

Люди предоставляют аргументы по умолчанию, чтобы получить удобство, используемое для orig и p1. Затем некоторые удивляются преобразованию 2 в Point(2,0) при вызове f(). Этот конструктор определяет преобразование. По умолчанию это неявное преобразование. Чтобы такое преобразование было явным, объявите конструктор явным (explicit):

struct Point {
  int x,y;
  explicit Point(int xx = 0, int yy = 0) :x(xx), y(yy) { }
};

void f(Point);

void g()
{
  Point orig;          // создать orig со значением по умолчанию (0,0)
  Point p1(2);         // создатьp1 со значением y-координаты по умолчанию 0,
                       // что явно вызывает конструктор
  f(2);                // ошибка (попытка неявного преобразования)
  Point p2 = 2;        // ошибка (попытка неявного преобразования)
  Point p3 = Point(2); // ok (явное преобразование)
}

Теги

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

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.