Na początku rozważmy prostą klasę X, na podstawie której stworzono 3 obiekty:

#include <iostream>

class X
{
public:
   X(int i) : x(i)
   {
      std::cout << "X::X(int) : " << x << "\n";
   }

private:
   int x;
};

int main()
{
   X A(42);
   X B = X(42);
   X C = 42;
}

Uruchom w edytorze

Po uruchomieniu powyższego kodu otrzymujemy następujący output:

X::X(int) : 42                                                                                                                                                                               
X::X(int) : 42                                                                                                                                                                               
X::X(int) : 42

Kod obiektowy, który wygeneruje kompilator, będzie prawdopodobnie taki sam dla każdej inicjalizacji. Formalnie nie są to jednak tożsame konstrukcje (o czym przekonamy się później).

Pierwszy obiekt A powstał w wyniku inicjalizacji bezpośredniej (ang. direct initialization), czyli bezpośredniego wywołania konstruktora: X::X(int):

X A(42);

Natomiast pozostałe dwa obiekty (BC) powstały w wyniku inicjalizacji kopiującej (ang. copy initialization):

X B = X(42);
X C = 42;

Pojęcie inicjalizacji kopiującej sugeruje, iż w jej realizacji powinien brać udział konstruktor kopiujący. W powyższym przykładzie taki domyślny konstruktor kopiujący (ang. default copy constructor), może być wygenerowany przez kompilator, jeśli faktycznie będzie potrzebny. Zdefiniujmy zatem jawny publiczny konstruktor kopiujący, aby przekonać się czy istotnie bierze on udział w procesie inicjalizacji. Oczywiście będzie on wykonywać to samo co konstruktor kopiujący wygenerowany przez kompilator (kopiowanie składowej x), przy czym dodatkowo będzie wyświetlał komunikat informujący o jego wywołaniu:

#include <iostream>

class X
{
public:
   X(int i) : x(i)
   {
      std::cout << "X::X(int i) : " << x << "\n";
   }
   
   X(const X &other) : x(other.x)
   {
      std::cout << "X::X(const X&) : " << x << "\n";
   }

private:
   int x;
};

int main()
{
   X A(42);
   X B = X(42);
   X C = 42;
}

Uruchom w edytorze

Po uruchomieniu powyższego przykładu, otrzymujemy taki sam output jak w poprzednio (bez jawnie zdefiniowanego publicznego konstruktora kopiującego), czyli bez komunikatu o wywołaniu konstruktora kopiującego. Wstępnie sugeruje to (jak się za niedługo okaże błędnie), iż konstruktor kopiujący nie jest niezbędny:

X::X(int) : 42                                                                                                                                                                               
X::X(int) : 42                                                                                                                                                                               
X::X(int) : 42

Konstruktor Kopiujący Jednak Niezbędny

Kontynuując nasze rozważania zróbmy jeszcze jeden eksperyment przenosząc jawnie zdefiniowany konstruktor kopiujący z sekcji public do private (lub protected):

#include <iostream>

class X
{
public:
   X(int i) : x(i)
   {
      std::cout << "X::X(int i) : " << x << "\n";
   }

private:   
   X(const X &other) : x(other.x)
   {
      std::cout << "X::X(const X&) : " << x << "\n";
   }

   int x;
};

int main()
{
   X A(42);
   X B = X(42);
   X C = 42;
}

Tym razem otrzymamy błąd kompilacji podczas próby inicjalizacji kopiującej dla zmiennych BC (dodatkowo widać, iż podczas tworzenia obiektu C, nastąpiła nieudana próba konwersji zdefiniowanej przez użytkownika z typu int na X – temat ten został nieco przybliżony w poprzednim artykule: Konstruktor z Parametrem std::string vs Argument c-string):

main.cpp:23:18: error: 'X::X(const X&)' is private within this context
        X B = X(42);
                  ^
main.cpp:12:8: note: declared private here
        X(const X &other) : x(other.x)
        ^
main.cpp:24:14: error: 'X::X(const X&)' is private within this context
        X C = 42;
              ^~
main.cpp:12:8: note: declared private here
        X(const X &other) : x(other.x)
        ^
main.cpp:6:8: note:   after user-defined conversion: X::X(int)
        X(int i) : x(i)
        ^       ^

Aby zrozumieć dlaczego tak się stało, należy uświadomić sobie jaki kod generuje kompilator podczas inicjalizacji kopiującej. Podczas tworzenia obiektu B otrzymamy na początku kod równoważny z poniższym:

X temp(42); // Utworzenie obiektu tymczasowego (X::X(int))
X B(temp);  // Utworzenie obiektu B (X::X(const X&))
temp.~X();  // Wywołanie destruktora obiektu tymczasowego (X::~X())

Jak widać inicjalizacja kopiująca dla obiektu B, wymusza tworzenie obiektu tymczasowego zainicjalizowanego wartością 42. Następnie obiekt tymczasowy staje się argumentem przekazanym do konstruktora kopiującego w celu utworzenia obiektu B. Na zakończenie wywołany jest destruktor obiektu tymczasowego aby zwolnić zasoby. Tworzenie obiektu C przebiega podobnie, przy czym obiekt tymczasowy powstaje niejawnie (argumentem jest typ int o wartości 42). Okazuje się więc, że podczas początkowej fazy generowania kodu konstruktor kopiujący jest niezbędny. Dlatego też kompilacja przykładu z prywatnym konstruktorem kopiującym kończy się niepowodzeniem.

Optymalizacja

Kompilator może podjąć dalszą optymalizacje i wyeliminować operacje tworzenia obiektu tymczasowego oraz jego kopiowania (ang. copy elision) z procesu inicjalizacji kopiującej. Prowadzi to do prawidłowej kompilacji przykładu z publicznym i jawnie zdefiniowanym konstruktorem kopiującym, który w ostateczności nie jest wywoływany (ale jest niezbędny). Optymalizacja zredukuje powyższy kod do wywołania konstruktora X::X(int) (tak samo jak podczas inicjalizacji bezpośredniej):

X B(42);  // Utworzenie obiektu B (X::X(int))

Zatem pomimo ewentualnej optymalizacji (podjętej później), kod z prywatnym konstruktorem kopiującym jest niepoprawny. W tym miejscu warto zaznaczyć, iż standard języka C++ (aż do C++14 włącznie) pozwala, ale jednocześnie nie gwarantuje wykonania tego typu optymalizacji (wymaga więc bezwzględnej dostępności konstruktora kopiującego). W praktyce współczesne kompilatory standardowo realizują taką optymalizacje. Jeżeli jednak zależy nam na wyłączeniu tej optymalizacji, można zastosować odpowiednią flagę kompilacji. Przykładowo dla kompilatora g++ (GCC) jest to flaga -fno-elide-constors, a jej opis przedstawia się następująco:

-fno-elide-constructors
    The C++ standard allows an implementation to omit creating a temporary 
    which is only used to initialize another object of the same type. 
    Specifying this option disables that optimization, and forces g++ 
    to call the copy constructor in all cases.

Po jej zastosowaniu kod z publicznym i jawnie zdefiniowanym konstruktorem kopiującym da po uruchomieniu następujący output:

X::X(int i) : 42
X::X(int i) : 42
X::X(const X&) : 42
X::X(int i) : 42
X::X(const X&) : 42

Widać że pojawiły się 2 wywołania konstruktora kopiującego (X::X(const X&)) odpowiednio dla inicjalizacji obiektu BC.

Zalecana Inicjalizacja Bezpośrednia

Oczywiście ktoś mógłby stwierdzić, że przedstawiony powyżej problem można łatwo obejść stosując inicjalizację bezpośrednią zamiast inicjalizacji kopiującej. Można się z tym zgodzić dla typowego przypadku tworzenia obiektów. Obiekty BC można więc oczywiście utworzyć tak samo jak obiekt (eliminując opisany problem). W przypadku programowania opartego na szablonach (ang. templates) wręcz zaleca się aby zawsze stosować inicjalizację bezpośrednią. Inicjalizacja kopiująca może bowiem zakończyć się błędem kompilacji po konkretyzacji szablonu. Takie podejście zapewnia zatem prostotę i przenośność między różnymi typami danych.

Inicjalizacja Argumentów Wywoływanej Funkcji

Inicjalizacja kopiująca nadal pozostaje ważna, ponieważ jest ona obowiązkowa podczas inicjalizacji argumentu (przekazywanego przez wartość) wywoływanej funkcji. Takie zachowanie definiuje standard języka C++. Pozwala to wyeliminować przypadkowe, niejawne konwersje zdefiniowane przez użytkownika w sytuacji gdy konstruktor nie został oznaczony jako explicit. Ewentualne kompilatory stosujące w tym przypadku inicjalizację bezpośrednią nie są zgodne ze standardem i nie należy na nich polegać. Aby zobrazować ten fakt zmodyfikujmy przykład z prywatnym konstruktorem kopiującym – zamiast tworzyć obiekty BC dokonamy odpowiadających inicjalizacji argumentów wywołanej funkcji f:

#include <iostream>

class X
{
public:
   X(int i) : x(i)
   {
      std::cout << "X::X(int i) : " << x << "\n";
   }

private: 
   X(const X &other) : x(other.x)
   {
      std::cout << "X::X(const X&) : " << x << "\n";
   }

   int x;
};

void f(X x)
{
   std::cout << "void f(X)" << "\n";
}

int main()
{
   X A(42);
   
   f(A);
   f(X(42));
   f(42);
}

Kompilacja powyższego kodu zakończy się błędami analogicznymi do tych które pojawiły się dla inicjalizacji kopiującej obiektów BC:

main.cpp:29:7: error: ‘X::X(const X&)’ is private within this context
    f(A);
       ^
main.cpp:12:4: note: declared private here
    X(const X &other) : x(other.x)
    ^
main.cpp:20:6: note:   initializing argument 1 of ‘void f(X)’
 void f(X x)
      ^
main.cpp:30:11: error: ‘X::X(const X&)’ is private within this context
    f(X(42));
           ^
main.cpp:12:4: note: declared private here
    X(const X &other) : x(other.x)
    ^
main.cpp:20:6: note:   initializing argument 1 of ‘void f(X)’
 void f(X x)
      ^
main.cpp:31:8: error: ‘X::X(const X&)’ is private within this context
    f(42);
        ^
main.cpp:12:4: note: declared private here
    X(const X &other) : x(other.x)
    ^
main.cpp:6:4: note:   after user-defined conversion: X::X(int)
    X(int i) : x(i)
    ^
main.cpp:20:6: note:   initializing argument 1 of ‘void f(X)’
 void f(X x)
      ^

Kopiowanie Przez Wartość

Powyższy problem pojawia się jeśli argument przekażemy przez wartość. Załóżmy teraz, że funkcja wywołująca ma dostęp do prywatnego konstruktora kopiującego oraz nie podjęto optymalizacji. Podczas wywołania f(X(42)) lub f(42) powstaje obiekt tymczasowy (typu X z wartości 42 typu int), który po skopiowaniu go przez konstruktor kopiujący trafia do funkcji jako argument. Po zakończeniu funkcji następuje wywołanie destruktorów obu obiektów. Prowadzi to do bardzo nieefektywnego kodu, bowiem mamy tu aż 4 wywołania funkcji specjalnych (X::X(int), X::X(const X&) oraz 2 razy X::~X()):

X temp(42); // Utworzenie obiektu tymczasowego (X::X(int))
X x(temp);  // Utworzenie obiektu argumentu (X::X(const X&))
// Ciało funkcji f
x.~X();     // Wywołanie destruktora obiektu argumentu (X::~X())
temp.~X();  // Wywołanie destruktora obiektu tymczasowego (X::~X())

Na szczęście przedstawiona wcześniej optymalizacja zredukuje powyższy kod do 2 wywołań: konstruktora X::X(int) (tak samo jak podczas inicjalizacji bezpośredniej) oraz destruktora (X::~X()):

X x(42);  // Utworzenie obiektu argumentu (X::X(int))
// Ciało funkcji f
x.~X();   // Wywołanie destruktora obiektu argumentu (X::~X())

Oczywiście taka optymalizacja nie będzie mieć miejsca jeśli do funkcji przekażemy wcześniej utworzony obiekt. Przykładem niech będzie obiekt A przykazany do funkcji f w powyższym kodzie. W tym przypadku wywołany zostanie konstruktor kopiujący (w naszym przykładzie jest on prywatny, co wygeneruje błąd kompilacji), co niesie ze sobą koszt wynikający z kopiowania całego obiektu. Rozwiązaniem problemu nieefektywnego kopiowania argumentów funkcji jest przekazywanie ich przez referencje (do l-wartości lub r-wartości):

#include <iostream>

class X
{
public:
   X(int i) : x(i)
   {
      std::cout << "X::X(int i) : " << x << "\n";
   }

private: 
   X(const X &other) : x(other.x)
   {
      std::cout << "X::X(const X&) : " << x << "\n";
   }

   int x;
};

void f(X &x)
{
   std::cout << "void f(X&)" << "\n";
}

void f(const X &x)
{
   std::cout << "void f(const X&)" << "\n";
}

void f(X &&x)
{
   std::cout << "void f(X&&)" << "\n";
}

int main()
{
   X A(42);
   const X B(42);
   
   f(A);
   f(B);
   f(X(42));
   f(42);
}

Uruchom w edytorze

Wcześniej wspomniałem, iż problem błędu kompilacji wynika z przekazywania argumentu przez wartość. Wymusza to bowiem potrzebę użycia prywatnego (niedostępnego), konstruktora kopiującego. Użycie referencji rozwiązuje ten problem, ponieważ wiązanie obiektu z referencją nie skutkuje operacją kopiowania (a co najwyżej utworzeniem obiektu tymczasowego). Powyższy kod kompiluje się bezbłędnie i otrzymamy spodziewany output :

X::X(int i) : 42                                                                                                                                                                            
X::X(int i) : 42                                                                                                                                                                            
void f(X&)                                                                                                                                                                                  
void f(const X&)                                                                                                                                                                            
X::X(int i) : 42                                                                                                                                                                            
void f(X&&)                                                                                                                                                                                 
X::X(int i) : 42                                                                                                                                                                            
void f(X&&)

Standard C++17 Na Ratunek

Jak się okazuje, stosowania inicjalizacji kopiującej można w ostateczności uniknąć. Jednakże na zakończenie chciałbym odpowiedzieć na pytanie: czy w przypadku wystąpienia opisanych problemów, jedynym rozwiązaniem jest modyfikacja kodu źródłowego ? Posłużę się tu klasą noncopyable, która jawnie deklaruje konstruktor kopiujący jako usunięty (delete). Taka możliwość pojawiła się wraz ze standardem C++11 i jest ona bardziej przejrzysta niż ”sztuczna” deklaracja prywatnego konstruktora kopiującego. Oczywiście oznacza to, że obiekty tej klasy nie mogą być kopiowane. Warto także wspomnieć, iż konstruktor kopiujący może zostać usunięty niejawnie poprzez definicję przenoszącego konstruktora kopiującego lub przenoszącego operatora przypisania. W poniższym przykładzie użyjemy wyłącznie inicjalizacji kopiującej:

#include <iostream>

struct noncopyable
{
   noncopyable()
   {
      std::cout << "noncopyable::noncopyable()" << "\n";
   }
   
   noncopyable(const noncopyable &) = delete;
};

void f(noncopyable nc)
{
   std::cout << "f(noncopyable)" << "\n";
}

int main()
{
   noncopyable nc = noncopyable();
    
   f(noncopyable());
}

Powyższy kod nie zostanie skompilowany nawet przez kompilator zgodny ze standardem C++14:

main.cpp:20:33: error: use of deleted function ‘noncopyable::noncopyable(const noncopyable&)’
    noncopyable nc = noncopyable();
                                 ^
main.cpp:10:4: note: declared here
    noncopyable(const noncopyable &) = delete;
    ^~~~~~~~~~~
main.cpp:22:19: error: use of deleted function ‘noncopyable::noncopyable(const noncopyable&)’
    f(noncopyable());
                   ^
main.cpp:10:4: note: declared here
    noncopyable(const noncopyable &) = delete;
    ^~~~~~~~~~~
main.cpp:13:6: note:   initializing argument 1 of ‘void f(noncopyable)’
 void f(noncopyable nc)
      ^

Z pomocą przychodzi standard C++17, który w przeciwieństwie do poprzednich standardów,  gwarantuje wykonanie optymalizacji polegającej na eliminacji potrzeby stosowania konstruktora kopiującego. Tym razem powyższy kod (bez żadnych modyfikacji) skompiluje się, a po jego uruchomieniu (Uruchom w edytorze) przedstawi się nam spodziewany output:

noncopyable::noncopyable()                                                                                                                                                                  
noncopyable::noncopyable()                                                                                                                                                                  
f(noncopyable)

Podsumowanie

  • Konstruktor kopiujący jest niezbędny podczas inicjalizacji kopiującej (aż do standardu C++14 włącznie)
  • Standard C++ (aż do C++14 włącznie) nie gwarantuje eliminacji operacji kopiowania podczas inicjalizacji kopiującej
  • Tworząc obiekty zaleca się inicjalizację bezpośrednią zamiast inicjalizacji kopiującej (chyba że istnieje potrzeba stosowania semantyki konwersji zdefiniowanej przez użytkownika)
  • Używając szablonów zaleca się inicjalizację bezpośrednią
  • Podczas przekazywania argumentów do funkcji (przez wartość), stosowana jest zawsze inicjalizacja kopiująca (zachowanie to jest gwarantowane przez standard języka C++)
  • Jeżeli parametrem funkcji jest referencja (do l-wartości lub r-wartości) operacja kopiowania nie jest wykonywana (poprawa efektywności)
  • Standard C++17 gwarantuje eliminacje operacji kopiowania podczas inicjalizacji kopiującej