14.15 — Inicjalizacja klasy i skopiuj eliminację

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:

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 constructor

Ta 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 rvo zwraca Something Do main.
  • Raz, gdy wartość zwracana of rvo() jest używany do inicjalizacji s1.
  • Raz, gdy nrvo zwraca s Do main.
  • Raz, gdy wartość zwracana of nrvo() jest używany do inicjalizacji s2.

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.

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
142 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze