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; // inicjator po znaku równości (skopiuj inicjalizacja)
int c( 6 ); // inicjator w nawiasach (inicjalizacja bezpośrednia)
// List initialization methods (C++11)
int d { 7 }; // inicjator w nawiasach klamrowych (lista bezpośrednia inicjalizacja)
int e = { 8 }; // inicjator w nawiasach po znaku równości (inicjalizacja kopiowania listy)
int f {}; // inicjator w pustych nawiasach (inicjalizacja wartości)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';
}
// Kopiuj konstruktor
Foo(const Foo&)
{
std::cout << "Foo(const Foo&)\n";
}
};
int main()
{
// Calls Foo() default constructor
Foo f1; // default initialization
Foo f2{}; // inicjalizacja wartości (preferowana)
// Calls foo(int) normal constructor
Foo f3 = 3; // skopiuj inicjalizację (tylko konstruktory niejawne)
Foo f4(4); // direct initialization
Foo f5{ 5 }; // direct list initialization (preferred)
Foo f6 = { 6 }; // skopiuj inicjalizację listy (tylko konstruktory niejawne)
// Wywołuje konstruktor kopiujący foo(const Foo&)
Foo f7 = f3; // skopiuj inicjalizację
Foo f8(f3); // direct initialization
Foo f9{ f3 }; // direct list initialization (preferred)
Foo f10 = { f3 }; // skopiuj inicjalizację listy
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 } }; // skoncentruj się na tej linii
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 }; // wywołuje tylko coś(int), bez kopiowania konstruktorTa 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{}; // wywołuje Something() i konstruktor kopiujący
}
Something nrvo()
{
Something s{}; // calls Something()
return s; // wywołuje konstruktor kopiujący
}
int main()
{
std::cout << "Initializing s1\n";
Something s1 { rvo() }; // wywołuje konstruktor kopiujący
std::cout << "Initializing s2\n";
Something s2 { nrvo() }; // wywołuje konstruktor kopiujący
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.

