Dawno na lekcji 1.4 — Przypisywanie zmiennych i inicjalizacja omawiamy 6 podstawowych typów inicjalizacji obiektów o podstawach typy:
int a; // no initializer (default initialization)
int b = 5; // initializer after equals sign (copy initialization)
int c( 6 ); // initializer in parentheses (direct initialization)
// List initialization methods (C++11)
int d { 7 }; // initializer in braces (direct list initialization)
int e = { 8 }; // initializer in braces after equals sign (copy list initialization)
int f {}; // initializer is empty braces (value initialization)Wszystkie te typy inicjalizacji są prawidłowe dla obiektów z typami klas:
#include <iostream>
class Foo
{
public:
// Default constructor
Foo()
{
std::cout << "Foo()\n";
}
// Normal constructor
Foo(int x)
{
std::cout << "Foo(int) " << x << '\n';
}
// Copy constructor
Foo(const Foo&)
{
std::cout << "Foo(const Foo&)\n";
}
};
int main()
{
// Calls Foo() default constructor
Foo f1; // default initialization
Foo f2{}; // value initialization (preferred)
// Calls foo(int) normal constructor
Foo f3 = 3; // copy initialization (non-explicit constructors only)
Foo f4(4); // direct initialization
Foo f5{ 5 }; // direct list initialization (preferred)
Foo f6 = { 6 }; // copy list initialization (non-explicit constructors only)
// Calls foo(const Foo&) copy constructor
Foo f7 = f3; // copy initialization
Foo f8(f3); // direct initialization
Foo f9{ f3 }; // direct list initialization (preferred)
Foo f10 = { f3 }; // copy list initialization
return 0;
}We współczesnym C++ inicjalizacja kopiowania, inicjalizacja bezpośrednia i inicjalizacja listy zasadniczo robią to samo - inicjują obiekt.
W przypadku wszystkich typów inicjalizacji:
- Podczas inicjowania typu klasy sprawdzany jest zestaw konstruktorów dla tej klasy, a rozpoznawanie przeciążenia służy do określenia najlepszego dopasowania konstruktor. Może to obejmować niejawną konwersję argumentów.
- Podczas inicjowania typu innego niż klasowy, reguły konwersji niejawnej są używane do ustalenia, czy istnieje konwersja niejawna.
Kluczowa informacja
Istnieją trzy kluczowe różnice pomiędzy formami inicjalizacji:
- Inicjalizacja listy nie pozwala na zawężanie konwersji.
- Inicjalizacja kopiowania uwzględnia tylko niejawne konstruktory/funkcje konwersji. Omówimy to w lekcji 14.16 — Konwersja konstruktorów i jawne słowo kluczowe.
- Inicjalizacja listy nadaje priorytet pasującym konstruktorom list w stosunku do innych pasujących konstruktorów. Omówimy to na lekcji 16.2 — Wprowadzenie do std::vector i konstruktorów list.
Warto również zauważyć, że w niektórych okolicznościach pewne formy inicjalizacji są niedozwolone (np. na liście inicjatorów składowych konstruktora możemy używać tylko bezpośrednich form inicjalizacji, a nie inicjalizacji kopiowania).
Niepotrzebne kopie
Rozważ ten prosty program:
#include <iostream>
class Something
{
int m_x{};
public:
Something(int x)
: m_x{ x }
{
std::cout << "Normal constructor\n";
}
Something(const Something& s)
: m_x { s.m_x }
{
std::cout << "Copy constructor\n";
}
void print() const { std::cout << "Something(" << m_x << ")\n"; }
};
int main()
{
Something s { Something { 5 } }; // focus on this line
s.print();
return 0;
}Podczas inicjalizacji zmiennej s powyżej, najpierw konstruujemy tymczasową Something, inicjowaną wartością 5 (która używa konstruktora Something(int) ). Ten plik tymczasowy jest następnie używany do inicjalizacji s. Ponieważ tymczasowy i s mają ten sam typ (oba są obiektami Something ), zwykle zostanie tutaj wywołany konstruktor kopiujący Something(const Something&) , aby skopiować wartości z tymczasowego do s. Efekt końcowy jest taki, że s jest inicjalizowany wartością 5.
Bez jakichkolwiek optymalizacji powyższy program wyświetliłby:
Normal constructor Copy constructor Something(5)
Jednak ten program jest niepotrzebnie nieefektywny, ponieważ musieliśmy wykonać dwa wywołania konstruktora: jedno do Something(int) i jedno do Something(const Something&). Zauważ, że wynik końcowy powyższego jest taki sam, jak gdybyśmy zamiast tego napisali następujący:
Something s { 5 }; // only invokes Something(int), no copy constructorTa wersja daje ten sam wynik, ale jest bardziej wydajna, ponieważ wywołuje tylko Something(int) (nie jest potrzebny żaden konstruktor kopiujący).
Elizja kopiowania
Ponieważ kompilator może przepisać instrukcje w celu ich optymalizacji, można się zastanawiać, czy kompilator może zoptymalizować niepotrzebną kopię i potraktować Something s { Something{5} }; jako gdybyśmy napisali Something s { 5 } w pierwszej kolejności.
Odpowiedź brzmi tak, a proces ten nazywa się elision kopiowania. Elizja kopiowania jest techniką optymalizacji kompilatora, która pozwala kompilatorowi usunąć niepotrzebne kopiowanie obiektów. Innymi słowy, w przypadkach, gdy kompilator normalnie wywołałby konstruktor kopiujący, kompilator może dowolnie przepisać kod, aby całkowicie uniknąć wywołania konstruktora kopiującego. Kiedy kompilator optymalizuje wywołanie konstruktora kopiującego, mówimy, że konstruktor został pominięty.
W odróżnieniu od innych rodzajów optymalizacji, eliminacja kopii nie podlega zasadzie „jak gdyby”. Oznacza to, że elizja kopiowania może pominąć konstruktor kopiujący, nawet jeśli konstruktor kopiujący ma skutki uboczne (takie jak drukowanie tekstu na konsoli)! Właśnie dlatego konstruktory kopiujące nie powinny powodować skutków ubocznych innych niż kopiowanie — jeśli kompilator pominie wywołanie konstruktora kopiującego, efekty uboczne nie zostaną wykonane, a obserwowalne zachowanie programu ulegnie zmianie!
Powiązana treść
Omówiliśmy regułę as-if w lekcji 5.4 -- Reguła as-if i optymalizacja czasu kompilacji.
Możemy to zobaczyć w powyższym przykładzie. Jeśli uruchomisz program na kompilatorze C++17, zwróci on następujący wynik:
Normal constructor Something(5)
Kompilator usunął konstruktor kopiujący, aby uniknąć niepotrzebnej kopii, w wyniku czego instrukcja wyświetlająca „Konstruktor kopiujący” nie zostanie wykonana! Obserwowalne zachowanie naszego programu uległo zmianie z powodu elizji kopiowania!
Kopiuj elision w przekazywaniu przez wartość i zwracaj przez wartość
Konstruktor kopiujący jest zwykle wywoływany, gdy argument tego samego typu co parametr jest przekazywany przez wartość lub używany jest zwrot przez wartość. Jednakże w niektórych przypadkach kopie te mogą zostać pominięte. Poniższy program demonstruje niektóre z tych przypadków:
#include <iostream>
class Something
{
public:
Something() = default;
Something(const Something&)
{
std::cout << "Copy constructor called\n";
}
};
Something rvo()
{
return Something{}; // calls Something() and copy constructor
}
Something nrvo()
{
Something s{}; // calls Something()
return s; // calls copy constructor
}
int main()
{
std::cout << "Initializing s1\n";
Something s1 { rvo() }; // calls copy constructor
std::cout << "Initializing s2\n";
Something s2 { nrvo() }; // calls copy constructor
return 0;
}W języku C++14 lub starszym, z wyłączoną eliminacją kopiowania, powyższy program wywoła konstruktor kopiujący 4 razy:
- Raz, gdy
rvozwracaSomethingDomain. - Raz, gdy wartość zwracana of
rvo()jest używany do inicjalizacjis1. - Raz, gdy
nrvozwracasDomain. - Raz, gdy wartość zwracana of
nrvo()jest używany do inicjalizacjis2.
Jednak ze względu na eliminację kopiowania jest prawdopodobne, że Twój kompilator pominie większość lub wszystkie wywołania konstruktora kopiującego. Visual Studio 2022 pomija 3 przypadki (nie pomija to przypadku, gdy nrvo() zwraca wartość), a GCC pomija wszystkie 4.
Nie jest ważne zapamiętywanie, kiedy kompilator wykonuje/nie wykonuje elizji kopiowania. Po prostu wiedz, że jest to optymalizacja, którą wykona twój kompilator, jeśli będzie mógł. Jeśli spodziewasz się wywołania konstruktora kopiującego, a tak się nie dzieje, prawdopodobnie jest to spowodowane eliminacją kopii.
Obowiązkowe eliminacja kopii w C++17 C++17
Przed C++17 eliminacja kopii była wyłącznie opcjonalną optymalizacją, którą mogły przeprowadzić kompilatory. W C++ 17 w niektórych przypadkach eliminacja kopii stała się obowiązkowa. W takich przypadkach eliminacja kopiowania zostanie wykonana automatycznie (nawet jeśli poinformujesz kompilator, aby nie wykonywał eliminacji kopiowania).
Uruchamiając ten sam przykład co powyżej w C++ 17 lub nowszym, wywołania konstruktora kopiującego, które w przeciwnym razie wystąpiłyby, gdy rvo() powraca i gdy s1 jest inicjalizowane tą wartością, muszą zostać pominięte. Inicjalizacja s2 z nvro() nie jest obowiązkowym przypadkiem elizji, a zatem 2 wywołania konstruktora kopiującego, które tu występują, mogą zostać pominięte lub nie, w zależności od kompilatora i ustawień optymalizacji.
W opcjonalnych przypadkach elizji dostępny konstruktor kopiujący musi być dostępny (np. nie usunięty), nawet jeśli faktyczne wywołanie konstruktora kopiującego zostanie pominięte.
W przypadkach obowiązkowego elizji dostępna kopia konstruktor nie musi być dostępny (innymi słowy, obowiązkowe usunięcie może nastąpić nawet w przypadku usunięcia konstruktora kopiującego).
Dla zaawansowanych czytelników
W przypadkach, gdy nie zostanie wykonane opcjonalne wyeliminowanie kopiowania, semantyka przenoszenia może nadal pozwalać na przenoszenie obiektu zamiast kopiowania. Semantykę przenoszenia wprowadzamy w lekcji 16.5 — Zwrócenie std::vector i wprowadzenie do semantyki przenoszenia.

