PVOID - Специальные члены

Специальные члены

Этот раздел является переводом туториала C++ Language


[ПРИМЕЧАНИЕ: этот раздел требует хорошего понимания динамически выделяемой памяти]

Специальные функции-члены - это такие функции члены, которые неявно определены как члены класса при некоторых условиях. всего их шесть:

Функция-член Типичная форма для класса C:
Конструктор по умолчанию C::C();
Деструктор C::~C();
Конструктор копирования C::C (const C&);
Присваивание копированием C& operator= (const C&);
Конструктор перемещения C::C (C&&);
Присваивание перемещением C& operator= (C&&);

 Рассмотрим каждый из них:

Конструктор по умолчанию


 Конструктор по умолчанию это конструктор, вызываемый, когда объекты класса объявлены, но не инициализированы какими-либо аргументами.

Если в определении класса нет конструкторов, компилятор предполагает, что класс имеет неявно определенный конструктор по умолчанию. Поэтому, после объявления класса, подобных этому:

class Example {
  public:
    int total;
    void accumulate (int x) { total += x; }
};

 Компилятор предполагает, что Example имеет конструктор по умолчанию. Таким образом, объекты этого класса могут быть созданы простым объявлением их без каких-либо аргументов:

Example ex;

 Но если класс имеет какой-либо конструктор, принимающий любое количество параметров и объявленный явным образом, то компилятор не предоставляет неявный конструктор по умолчанию и больше не позволяет объявлять объекты этого класса без аргументов. Например:

class Example2 {
  public:
    int total;
    Example2 (int initial_value) : total(initial_value) { };
    void accumulate (int x) { total += x; };
};

 Здесь мы объявили конструктор с параметром типа int. Поэтому следующее объявление объекта будет корректным:

Example2 ex (100);   // ok: вызывается конструктор 

 В отличие от этого:

Example2 ex;   // неверно: отсутствует конструктор по умолчанию 

 Это будет некорректно, так как класс объявлен с явным конструктором, принимающим один аргумент, который заменяет неявный конструктор по умолчанию, не принимающий аргументов.

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

// classes and default constructors
#include <iostream>
#include <string>
using namespace std;

class Example3 {
    string data;
  public:
    Example3 (const string& str) : data(str) {}
    Example3 () {}
    const string& content() const {return data;}
};

int main () {
  Example3 foo;
  Example3 bar ("Example");

  cout << "bar's content: " << bar.content() << '\n';
  return 0;
}
bar's content: Example

 Здесь Example3  имеет конструктор по умолчанию (т.е. конструктор без параметров), определенный как пустой блок:

Example3 () {}

 Это позволяет создавать объекты класса Example3 без аргументов (как foo в этом примере). Обычно конструктор по умолчанию как этот неявно создается для всех классов, которые не имеют других конструкторов, и поэтому не требуется их явное определение. Но в данном случае Example3 имеет другой конструктор:

Example3 (const string& str);

 И когда любой конструктор явно определен в классе, то конструктор по умолчанию не предоставляется автоматически.

Деструктор


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

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

// destructors
#include <iostream>
#include <string>
using namespace std;

class Example4 {
    string* ptr;
  public:
    // constructors:
    Example4() : ptr(new string) {}
    Example4 (const string& str) : ptr(new string(str)) {}
    // destructor:
    ~Example4 () {delete ptr;}
    // access content:
    const string& content() const {return *ptr;}
};

int main () {
  Example4 foo;
  Example4 bar ("Example");

  cout << "bar's content: " << bar.content() << '\n';
  return 0;
}
bar's content: Example

 При создании Example4 выделяет память для string. Эта память позже освобождается деструктором.

Деструктор для объекта вызывается в конце его жизни. В случае foo и bar это происходит в конце функции main().

Конструктор копирования


Когда объект принимает именованный объект своего типа как аргумент, вызывается его конструктор копирования для создания копии.

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

MyClass::MyClass (const MyClass&);

 Если класс не имеет других определенных конструкторов копирования или перемещения (или присваивания), неявно предоставляется конструктор копирования. Этот конструктор просто производит копию своих членов. Например, для такого класса, как этот:

class MyClass {
  public:
    int a, b; string c;
};

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

MyClass::MyClass(const MyClass& x) : a(x.a), b(x.b), c(x.c) {}

 Этот стандартный конструктор копирования может соответствовать нуждам многих классов. Но поверхностные копии это только копии самих членов класса, и это не то, что мы ожидаем для таких классов как Example4, определенный выше, потому что он содержит указатель, данные которого он обрабатывает. Для этого класса выполнение поверхностного копирования означает, что будет скопировано значение указателя, но не его содержание. Это означает, что оба объекта (копия и оригинал) будут разделять один объект string (они оба будут обращаться к одному и тому же объекту) и в какой-то момент (при уничтожении) оба объекта попытаются удалить один и тот же блок памяти, что, вероятно, приведет к краху программы во время выполнения. Это может быть решено путем определения следующего собственного конструктора копирования, который производит глубокое копирование:

// copy constructor: deep copy
#include <iostream>
#include <string>
using namespace std;

class Example5 {
    string* ptr;
  public:
    Example5 (const string& str) : ptr(new string(str)) {}
    ~Example5 () {delete ptr;}
    // copy constructor:
    Example5 (const Example5& x) : ptr(new string(x.content())) {}
    // access content:
    const string& content() const {return *ptr;}
};

int main () {
  Example5 foo ("Example");
  Example5 bar = foo;

  cout << "bar's content: " << bar.content() << '\n';
  return 0;
}
bar's content: Example

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

 Присваивание копированием


 Объекты копируются не только в конструкторе, в котором они инициализируются: они также могут быть скопированы операцией присваивания. Посмотрите на различия:

MyClass foo;
MyClass bar (foo); // инициализация объекта: вызывается конструктор копирования
MyClass baz = foo; // инициализация объекта: вызывается конструктор копирования
foo = bar; // объект уже инициализирован: вызывается присваивание копированием

 Отметим, что baz инициализируется в конструкторе с использованием знака присваивания, но это не операция присваивания! (хотя это может так выглядеть): это просто другой синтаксис для вызова конструктора с одним аргументом.

Присваивание foo это операция присваивания. Здесь не объявляется объектов, но производится операция над существующим объектом foo.

Оператор присваивания копированием это перегрузка оператора operator=, который принимает значение или ссылку на объект такого же класса как параметр. Возвращаемое значение это обычно ссылка на *this (хотя это и не требуется). Например, для класса MyClass присваивание копированием может иметь следующую сигнатуру:

MyClass& operator= (const MyClass&);

 Оператор присваивания копированием это также специальная функция и он также обычно определен неявно, если класс не имеет собственных определенных операторов присваивания копированием или перемещением (или конструктора перемещения).

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

Example5& operator= (const Example5& x) {
  delete ptr;                      // delete currently pointed string
  ptr = new string (x.content());  // allocate space for new string, and copy
  return *this;
}

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

Example5& operator= (const Example5& x) {
  *ptr = x.content();
  return *this;
}

 

Конструктор и присваивание перемещением


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

 Безымянные объекты - это объекты, которые являются временными по природе и поэтому даже не имеют имени. Типичный пример безымянного объекта это возвращаемые значения функцией или преобразованием типа.

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

 Конструктор перемещения вызывается, когда объект инициализируется при создании с использованием безымянного временного объекта. Присваивание перемещением вызывается, когда объекту присваивается значение безымянного объекта:

MyClass fn();            // функция, возвращающая объект MyClass
MyClass foo;             // конструктор по умолчанию
MyClass bar = foo;       // конструктор копирования
MyClass baz = fn();      // конструктор перемещения
foo = bar;               // присваивание копированием
baz = MyClass();         // присваивание перемещением

 Значение, возвращаемое fn() и значение, создаваемое MyClass(), это безымянные временные объекты. В таких случаях нет необходимости создавать копию, потому что безымянные объекты существуют очень недолго и могут быть присвоены другим объектам в момент, когда это наиболее эффективно.

 Конструктор перемещения и присваивание перемещением - это методы, которые принимают параметр типа ссылки rvalue на объект своего собственного класса:

MyClass (MyClass&&);             // конструктор перемещения
MyClass& operator= (MyClass&&);  // присваивание перемещением

 Ссылка rvalue определяется передачей типа с двумя амперсандами (&&). Как параметр, ссылка rvalue соответствует аргументам временных объектов этого типа.

 Концепция перемещения наиболее полезна для объектов, которые управляют используемой ими памятью, такие, как объекты, выделяющие память с помощью new и удаляющие ее с помощью delete. Для таких объектов копирование и перемещение это действительно разные операции:

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

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

Например:

// конструктор/присваивание перемещением
#include <iostream>
#include <string>
using namespace std;

class Example6 {
    string* ptr;
  public:
    Example6 (const string& str) : ptr(new string(str)) {}
    ~Example6 () {delete ptr;}
    // конструктор перемещения
    Example6 (Example6&& x) : ptr(x.ptr) {x.ptr=nullptr;}
    // присваивание перемещением
    Example6& operator= (Example6&& x) {
      delete ptr; 
      ptr = x.ptr;
      x.ptr=nullptr;
      return *this;
    }
    // доступ к содержимому:
    const string& content() const {return *ptr;}
    // сложение:
    Example6 operator+(const Example6& rhs) {
      return Example6(content()+rhs.content());
    }
};


int main () {
  Example6 foo ("Exam");
  Example6 bar = Example6("ple");   // вызов конструктора перемещения
  
  foo = foo + bar;                  // присваивание перемещением

  cout << "foo's content: " << foo.content() << '\n';
  return 0;
}
foo's content: Example

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

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

Члены класса, определенные неявно


Шесть специальных методов, описанных выше, это члены, неявно объявленные в классе при определенных обстоятельствах:

Метод Неявно определен: Определение по умолчанию:
Конструктор по умолчанию если нет конструкторов ничего не делает
Деструктор если нет деструктора  ничего не делает
Конструктор копирования если нет конструктора перемещения и присваивания перемещением копирует все члены
Присваивание копированием если нет конструктора перемещения и присваивания перемещением копирует все члены
Конструктор перемещения если нет деструктора, конструктора копирования и нет присваивания ни копированием ни перемещением перемещает все члены
Присваивание перемещением если нет деструктора, конструктора копирования и нет присваивания ни копированием ни перемещением перемещает все члены

 

 Обратите внимание, что не все специальные методы неявно определяются в одних и тех же случаях. Это обычно делается для обратной совместимости со структурами языка Си и более ранними версиями языка C++. Фактически, многие из этих случаев являются устаревшими. К счастью, каждый класс может явно указать, какие из этих членов существует с определением по умолчанию, а какие удаляются с использованием ключевых слов default и delete соответственно. Их синтаксис:

function_declaration = default;
function_declaration = delete;

 Например:

 

// неявные члены: default и delete
#include <iostream>
using namespace std;

class Rectangle {
    int width, height;
  public:
    Rectangle (int x, int y) : width(x), height(y) {}
    Rectangle() = default;
    Rectangle (const Rectangle& other) = delete;
    int area() {return width*height;}
};

int main () {
  Rectangle foo;
  Rectangle bar (10,20);

  cout << "bar's area: " << bar.area() << '\n';
  return 0;
}
bar's area: 200

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

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

Rectangle baz (foo);

 Он, однако, может быть создан явно путем определения конструктора копирования:

Rectangle::Rectangle (const Rectangle& other) = default;

 Что будет эквивалентно:

Rectangle::Rectangle (const Rectangle& other) 
  : width(other.width), height(other.height) {}

 Отметим, что ключевое слово default не определяет метод, эквивалентный конструктору по умолчанию (т.е. когда конструктор по умолчанию это конструктор без параметров), но эквивалентен конструктору, который будет неявно определен, если не удален.

В основном, в т.ч. и для будущей совместимости, является хорошим тоном для классов, которые явно определяют один конструктор копирования/перемещения или один оператор присваивания копированием/перемещением, но не оба, определить также delete или default для другого специального метода, который в нем не определен.